Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
42 changes: 42 additions & 0 deletions .githooks/fixture-ownership-census-guard.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { spawnSync } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";

const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const pattern = process.env.SIMFILE_CENSUS_HOOK_PATTERN ?? "fixture-ownership census permanently enforces";
const regenerationCommand = 'SIMFILE_UPDATE_FIXTURE_OWNERSHIP_CENSUS=1 node --import tsx --test --test-name-pattern "explicit maintainer command refreshes the derived ownership census" src/ownership/fixtureOwnershipRatchet.test.ts';

if (process.env.SIMFILE_SKIP_CENSUS_HOOK === "1") {
console.error("WARNING: SIMFILE_SKIP_CENSUS_HOOK=1 bypassed the fixture-ownership census guard.");
process.exit(0);
}

const env = { ...process.env };
// The child must never be able to repair the artifact this guard is checking.
delete env.SIMFILE_UPDATE_FIXTURE_OWNERSHIP_CENSUS;
// When the guard is itself invoked from inside a node:test run, an inherited
// NODE_TEST_CONTEXT makes the child refuse to run any file ("run() is being called
// recursively") and emit no TAP at all. Without this the guard would report a
// failure it never actually measured.
delete env.NODE_TEST_CONTEXT;
const result = spawnSync(process.execPath, [
"--import", "tsx", "--test", "--test-reporter=tap", "--test-name-pattern", pattern,
"src/ownership/fixtureOwnershipRatchet.test.ts",
], { cwd: root, env, encoding: "utf8" });
const stdout = result.stdout ?? "";
const stderr = result.stderr ?? "";
const hasExpectedTap = /^# pass 1$/m.test(stdout)
&& /^# fail 0$/m.test(stdout)
&& /^# tests 1$/m.test(stdout);
const matchedATest = !/^1\.\.0$/m.test(stdout);

if (result.status !== 0 || !hasExpectedTap || !matchedATest) {
console.error("Fixture-ownership census guard failed or matched no test.");
console.error(`Regenerate with: ${regenerationCommand}`);
console.error("Then git add fixtures/sims/tiny-football/ownership-census.json and retry.");
if (stderr.trim()) console.error(stderr.trim());
// node --test reports the failing assertion on stdout, so a guard that printed only
// stderr would refuse the commit without ever saying what was uncovered.
if (stdout.trim()) console.error(stdout.trim().split("\n").slice(-60).join("\n"));
process.exit(result.status && result.status > 0 ? result.status : 1);
}
29 changes: 29 additions & 0 deletions .githooks/install.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { spawnSync } from "node:child_process";
import path from "node:path";
import { fileURLToPath } from "node:url";

const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const probe = spawnSync("git", ["rev-parse", "--show-toplevel"], {
cwd: root,
encoding: "utf8",
});

if (probe.status !== 0) {
console.error("Simfile hooks: not a git repository; skipping hook installation.");
process.exit(0);
}

// `prepare` also runs for git/folder/link installs and for `npm pack`/publish. Only
// configure hooks when this package IS the checkout, never when it is a dependency
// nested inside someone else's repository.
const toplevel = path.resolve(probe.stdout.trim());
if (toplevel !== path.resolve(root)) {
console.error(`Simfile hooks: ${root} is not the git toplevel (${toplevel}); skipping hook installation.`);
process.exit(0);
}

const install = spawnSync("git", ["config", "core.hooksPath", ".githooks"], {
cwd: root,
stdio: "inherit",
});
process.exit(install.status ?? 1);
15 changes: 15 additions & 0 deletions .githooks/post-merge
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
#!/bin/sh
# A fast-forward merge creates no commit, so neither pre-commit nor pre-merge-commit
# runs. post-merge always runs, but git ignores its exit code — so this cannot refuse
# the merge, only make a stale census impossible to miss at the moment it lands.
if node "$(dirname "$0")/fixture-ownership-census-guard.mjs"; then
exit 0
fi
echo "" >&2
echo "########################################################################" >&2
echo "# THE MERGE YOU JUST LANDED LEFT THE FIXTURE OWNERSHIP CENSUS STALE. #" >&2
echo "# post-merge cannot refuse a fast-forward. Regenerate and amend NOW, #" >&2
echo "# or the next lane reads a failure it did not cause. #" >&2
echo "########################################################################" >&2
echo "" >&2
exit 0
2 changes: 2 additions & 0 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#!/bin/sh
exec node "$(dirname "$0")/fixture-ownership-census-guard.mjs"
2 changes: 2 additions & 0 deletions .githooks/pre-merge-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
#!/bin/sh
exec node "$(dirname "$0")/fixture-ownership-census-guard.mjs"
4 changes: 4 additions & 0 deletions .github/workflows/deploy-website.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ jobs:
run: npm ci
working-directory: website

- name: Audit runtime dependencies
run: npm audit --omit=dev --audit-level=high
working-directory: website

- name: Build
run: npm run build
working-directory: website
Expand Down
7 changes: 5 additions & 2 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ jobs:
- name: Install dependencies
run: npm ci

- name: Audit runtime dependencies
run: npm audit --omit=dev --audit-level=high

- name: Verify tag matches package version
shell: bash
run: |
Expand All @@ -52,8 +55,8 @@ jobs:
- name: Build
run: npm run build

- name: Verify package contents
run: npm pack --dry-run
- name: Verify isolated package install
run: npm run verify:package-closure

- name: Publish to npm
run: npm publish
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,6 @@ jobs:
node-version: 22
cache: npm
- run: npm ci
- run: npm audit --omit=dev --audit-level=high
- run: npm run typecheck
- run: npm test
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,6 @@ runs/*
here.txt
.env
.DS_Store
fixtures/**/runs/
fixtures/sims/tiny-football/.local/
web/public/cssoccer/
8 changes: 7 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ of the Simfile v0.1 world mechanics package.
├── package.json # npm package metadata and CLI scripts
├── tsconfig.json # Typecheck config
├── tsconfig.build.json # Build-only emit config
└── src/ # Schema, CLI, and runtime-neutral modules
└── src/ # Schema, dynamics, CLI, and runtime-neutral modules
```

## Rules
Expand All @@ -28,3 +28,9 @@ of the Simfile v0.1 world mechanics package.
- Keep CLI handlers thin; schema, planning, ledger, and runtime logic belong in modules.
- Do not import Spawnfile internals. Consume explicit machine-readable artifacts.
- Do not add Docker compilation, runtime auth, or deployment ownership here.
- `simfile run` may compose a linked Spawnfile lifecycle only through documented
CLI operations and versioned receipts. Lifecycle composition never selects,
wakes, invokes, polls, or waits for agent cognition.
- Keep `src/run/` as the timer-free local deterministic writer. Generic composed
lifecycle code belongs in its own implementation folder and must be reused by
any future `simfile dev` watch/debug wrapper.
27 changes: 23 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,11 +40,30 @@ Node.js 22+.

```bash
simfile validate ./Simfile.yaml # check a world
simfile run ./Simfile --view # run a linked world + organization, then watch it
simfile run ./Simfile --local --ticks 200 # bounded mechanics-only diagnostic
simfile view runs/<id> # replay a sealed run — scrub, descend, watch spread
simfile view --state .sim # watch a live world
simfile observe runs/<id> # reconcile causal chains + measure spread → report.json
```

For a linked project, `simfile run` performs lifecycle composition: it starts
the world paused on `simfile.world-sidecar-runtime.v1`, delegates organization
lifecycle to Spawnfile's public CLI, attests the topology and any separately
manifested capability extensions, and atomically activates both owners. Tick 1
and every later tick have no agent barrier. Organization-declared schedules
wake autonomous runtimes; Simfile never selects, wakes, invokes, polls, or
waits for cognition. Observation recommendations are optional pull-only sense
metadata, never deliveries or wake authority.

A future `simfile dev` wrapper may add watch/debug ergonomics, but must reuse
this lifecycle rather than own another one.

The live receipt also binds Spawnfile's pinned
`spawnfile.moltnet-release-identity.v1`: architecture, asset digest, release
version, source revision, and the exact `pi-bridge` capability. Unpinned
`latest` is not a live input.

## What you can see

`simfile view <run-dir>` serves a local web app that turns a run into an instrument, not a screensaver:
Expand All @@ -65,7 +84,7 @@ A `Simfile` declares world mechanics, kept deliberately genre-neutral:
| **clock** | ticks, phases, sim-time |
| **variables** | scoped state with ranges |
| **generators** | deterministic or stochastic drivers that move variables |
| **rules** | `when` conditions → effects (wake an agent, post a world message) |
| **rules** | `when` conditions → mechanical effects or observation metadata |
| **markers** | scan room traffic for tokens (a seeded secret, a name) |
| **probes** | scored assertions evaluated over a run |
| **run ledger** | the canonical, causally-ordered record everything else is measured against |
Expand Down Expand Up @@ -102,8 +121,9 @@ rules:
deadline_bites:
when: { variable: filing_pressure, above: 0.85 }
do:
- action: wake:recommend
- action: moltnet:message
to: room:office-floor:case-warroom
content: "Filing pressure crossed the deadline threshold."

markers:
tenant_name:
Expand All @@ -113,7 +133,7 @@ markers:

probes:
deadline_observed:
when: { event: wake.recommended, target: room:office-floor:case-warroom }
when: { event: world.message, target: room:office-floor:case-warroom }
expect: { at_least: 1 }
```

Expand All @@ -128,7 +148,6 @@ src/schema v0.1 world schema + validator
src/runtime deterministic world kernel (clock, generators, rules, markers, probes)
src/observe causal reconciliation + spread measurement
src/view + web the run-replay viewer (React), served by `simfile view`
src/sims composed-run drivers (shell Spawnfile, seed, observe)
docs/ design + research (DESIGN, VIEW_DESIGN, VIEW_STYLEGUIDE, …)
```

Expand Down
Loading
Loading