cool-workflow 0.2.1 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/.codex-plugin/plugin.json +1 -1
  3. package/apps/architecture-review/app.json +1 -1
  4. package/apps/architecture-review-fast/app.json +1 -1
  5. package/apps/end-to-end-golden-path/app.json +1 -1
  6. package/apps/pr-review-fix-ci/app.json +1 -1
  7. package/apps/release-cut/app.json +1 -1
  8. package/apps/research-synthesis/app.json +1 -1
  9. package/dist/cli/dispatch.js +18 -86
  10. package/dist/cli/parseargv.js +5 -2
  11. package/dist/core/capability-data.js +270 -0
  12. package/dist/core/capability-table.js +16 -3504
  13. package/dist/core/format/help.js +28 -5
  14. package/dist/core/multi-agent/collaboration.js +3 -2
  15. package/dist/core/multi-agent/coordinator.js +4 -3
  16. package/dist/core/pipeline/commit-gate.js +2 -1
  17. package/dist/core/state/state-explosion/digest.js +3 -2
  18. package/dist/core/state/state-explosion/graph.js +9 -8
  19. package/dist/core/state/state-explosion/helpers.js +2 -1
  20. package/dist/core/trust/ledger.js +2 -1
  21. package/dist/core/types/execution-backend.js +18 -0
  22. package/dist/core/types/observability.js +7 -0
  23. package/dist/core/util/collate.js +23 -0
  24. package/dist/core/version.js +1 -1
  25. package/dist/shell/audit-cli.js +46 -2
  26. package/dist/shell/commit-summary.js +2 -1
  27. package/dist/shell/drive.js +26 -10
  28. package/dist/shell/evidence-reasoning.js +4 -3
  29. package/dist/shell/execution-backend/registry.js +2 -1
  30. package/dist/shell/execution-backend/types.js +0 -9
  31. package/dist/shell/fs-atomic.js +14 -1
  32. package/dist/shell/multi-agent-operator-ux.js +4 -3
  33. package/dist/shell/observability.js +12 -11
  34. package/dist/shell/onramp.js +17 -1
  35. package/dist/shell/operator-ux-text.js +2 -1
  36. package/dist/shell/operator-ux.js +7 -6
  37. package/dist/shell/pipeline-cli.js +82 -72
  38. package/dist/shell/reclamation-io.js +2 -1
  39. package/dist/shell/report.js +2 -1
  40. package/dist/shell/run-store.js +17 -0
  41. package/dist/shell/state-explosion-cli.js +2 -1
  42. package/dist/shell/topology-io.js +2 -1
  43. package/dist/shell/trust-audit.js +41 -2
  44. package/dist/shell/worker-cli.js +9 -1
  45. package/dist/shell/worker-isolation.js +34 -9
  46. package/dist/shell/workflow-app-loader.js +3 -2
  47. package/dist/wiring/capability-table/basics.js +66 -0
  48. package/dist/wiring/capability-table/exec-backend.js +162 -0
  49. package/dist/wiring/capability-table/index.js +42 -0
  50. package/dist/wiring/capability-table/multi-agent.js +623 -0
  51. package/dist/wiring/capability-table/parity.js +466 -0
  52. package/dist/wiring/capability-table/pipeline.js +280 -0
  53. package/dist/wiring/capability-table/registry-core.js +189 -0
  54. package/dist/wiring/capability-table/reporting.js +394 -0
  55. package/dist/wiring/capability-table/scheduling-registry.js +558 -0
  56. package/dist/wiring/capability-table/state.js +173 -0
  57. package/dist/wiring/capability-table/trust-ledger.js +134 -0
  58. package/dist/wiring/capability-table/workflow-apps.js +366 -0
  59. package/docs/agent-delegation-drive.7.md +2 -0
  60. package/docs/cli-mcp-parity.7.md +7 -2
  61. package/docs/contract-migration-tooling.7.md +2 -0
  62. package/docs/control-plane-scheduling.7.md +2 -0
  63. package/docs/durable-state-and-locking.7.md +22 -0
  64. package/docs/evidence-adoption-reasoning-chain.7.md +2 -0
  65. package/docs/execution-backends.7.md +2 -0
  66. package/docs/index.md +1 -0
  67. package/docs/multi-agent-cli-mcp-surface.7.md +2 -0
  68. package/docs/multi-agent-eval-replay-harness.7.md +2 -0
  69. package/docs/multi-agent-operator-ux.7.md +2 -0
  70. package/docs/node-snapshot-diff-replay.7.md +2 -0
  71. package/docs/observability-cost-accounting.7.md +2 -0
  72. package/docs/project-index.md +25 -5
  73. package/docs/real-execution-backends.7.md +2 -0
  74. package/docs/release-and-migration.7.md +2 -0
  75. package/docs/release-tooling.7.md +2 -0
  76. package/docs/run-registry-control-plane.7.md +2 -0
  77. package/docs/run-retention-reclamation.7.md +2 -0
  78. package/docs/security-trust-hardening.7.md +30 -0
  79. package/docs/state-explosion-management.7.md +2 -0
  80. package/docs/team-collaboration.7.md +2 -0
  81. package/docs/trust-audit-anchor.7.md +69 -0
  82. package/docs/web-desktop-workbench.7.md +2 -0
  83. package/manifest/plugin.manifest.json +1 -1
  84. package/package.json +3 -1
  85. package/scripts/bump-version.js +24 -2
  86. package/scripts/canonical-apps.js +4 -4
  87. package/scripts/dogfood-release.js +1 -1
  88. package/scripts/golden-path.js +4 -4
  89. package/scripts/purity-baseline.json +68 -0
  90. package/scripts/purity-gate.js +239 -0
  91. package/scripts/release-check.js +8 -1
  92. package/scripts/version-sync-check.js +33 -12
  93. package/workflows/README.md +19 -0
@@ -265,3 +265,5 @@ _No behavioral change in v0.1.89 (CLI-surface golden-path + help-output fixes on
265
265
  0.2.0
266
266
 
267
267
  0.2.1
268
+
269
+ 0.2.2
@@ -51,6 +51,34 @@ The agent host must still make certain of OS-level read isolation, write isolati
51
51
  limits on process execution, limits on network, and environment filtering.
52
52
  The audit layer lets you look at that boundary; it is not a kernel.
53
53
 
54
+ ## Requiring Attested Telemetry
55
+
56
+ `CW_REQUIRE_ATTESTED_TELEMETRY=1` (or `--require-attested-telemetry`, or a
57
+ durable `agent-config.json` field) is an opt-in, off-by-default gate: once
58
+ on, CW refuses to accept ANY worker result whose usage telemetry is not
59
+ cryptographically `attested` — a delegated hop whose signature does not
60
+ verify, and now also a MANUAL accept (`cw worker output`, `cw result`) that
61
+ carries no delegation telemetry at all. The manual shape used to slip past
62
+ the gate silently; it is now blocked the same way, with its own code
63
+ (`telemetry-missing-blocked`, distinct from a present-but-unverified hop's
64
+ `telemetry-unattested-blocked`).
65
+
66
+ An operator who genuinely needs to accept an unattested manual result under
67
+ the require flag passes `--allow-unattested` (MCP: `allowUnattested`). This
68
+ is never silent: it writes a `telemetry.gate-override` trust-audit event
69
+ (`decision: "allowed"`, `source: "operator"`) into the same hash-chained log
70
+ every other audit decision goes through, so the override itself is part of
71
+ the auditable record, not a hole in it.
72
+
73
+ A result-cache hit (the drive loop replaying a previously accepted result) is
74
+ never re-blocked by this gate — the underlying result was already gated (or
75
+ overridden) at its first acceptance. When the require flag is on, a cache
76
+ accept still records a `telemetry.cache-accept` event, so the audit trail
77
+ shows which accepts came from a fresh hop and which from the cache.
78
+
79
+ With the require flag off (the default), none of this changes: a plain
80
+ `cw worker output` / `cw result` behaves exactly as before.
81
+
54
82
  ## CLI
55
83
 
56
84
  ```bash
@@ -67,6 +95,8 @@ node scripts/cw.js audit decision <run-id> <worker-id> --path <path>
67
95
  node scripts/cw.js audit decision <run-id> <worker-id> --command "npm test"
68
96
  node scripts/cw.js audit decision <run-id> <worker-id> --network example.com
69
97
  node scripts/cw.js audit decision <run-id> <worker-id> --env SECRET_NAME
98
+ node scripts/cw.js worker output <run-id> <worker-id> <result-file> [--allow-unattested]
99
+ node scripts/cw.js result <run-id> <task-id> <result-file> [--allow-unattested]
70
100
  ```
71
101
 
72
102
  Denied audit decisions are put into audit files and joined to feedback/error
@@ -322,3 +322,5 @@ _No behavioral change in v0.1.89 (CLI-surface golden-path + help-output fixes on
322
322
  0.2.0
323
323
 
324
324
  0.2.1
325
+
326
+ 0.2.2
@@ -258,3 +258,5 @@ _No behavioral change in v0.1.89 (CLI-surface golden-path + help-output fixes on
258
258
  0.2.0
259
259
 
260
260
  0.2.1
261
+
262
+ 0.2.2
@@ -0,0 +1,69 @@
1
+ # Trust Audit Anchor
2
+
3
+ CW v0.2.1 adds the Trust Audit Anchor: a way to see when the END of a run's
4
+ trust-audit log was cut off. The hash chain in `audit/events.jsonl` lets
5
+ `cw audit verify` see an edited event, a removed middle event, a bad line, and
6
+ a mixed-era forgery. But one tamper shape gets past a pure chain walk: take
7
+ the last N lines off the file, and what is left is a shorter but fully
8
+ consistent chain — verify stays green. The anchor closes that hole, and it
9
+ does so without changing any old output byte.
10
+
11
+ ## Design Discipline
12
+
13
+ - Mechanism, not policy: the kernel gives you two small parts — a read of the
14
+ chain head, and a check against a head you saved earlier. WHEN you save a
15
+ head (after a run, before you publish, at export time) is your policy.
16
+ - Fail-closed: a saved head that is not on the chain, or an event count that
17
+ comes up short, makes verify exit non-zero with the distinct check code
18
+ `trust-audit-truncated`. A bad `--expect-head` / `--expect-count` value is
19
+ an error, never a check silently made weaker.
20
+ - POLA: with no anchor flags, `cw audit verify` output is byte-for-byte what
21
+ it was before this feature (no `anchor` key, same checks, same exit rules).
22
+ - Reuse: the anchor rides on the existing eventHash chain — no new file, no
23
+ new state, no new hash form. `cw audit head` is a read-only projection.
24
+ - Parity: `audit.head` is on both front doors (`cw audit head` and the MCP
25
+ tool `cw_audit_head`); the anchor args are on both `cw audit verify` and
26
+ `cw_audit_verify` (`expectHead` / `expectCount`).
27
+
28
+ ## CLI
29
+
30
+ ```text
31
+ node dist/cli.js audit head <run-id>
32
+ # -> { "schemaVersion": 1, "runId": "...", "eventCount": 87,
33
+ # "headHash": "sha256:..." }
34
+
35
+ node dist/cli.js audit verify <run-id> --expect-head <hash> --expect-count <n>
36
+ # green: verified true, "anchor": { ..., "satisfied": true }, exit 0
37
+ # cut tail: verified false, checks carry trust-audit-truncated, exit 1
38
+ ```
39
+
40
+ The head is the hash the NEXT appended event will link from: the last event's
41
+ `eventHash`, or the run's genesis hash when the log is empty. Save the pair
42
+ `{headHash, eventCount}` somewhere the log's writer cannot reach — a CI
43
+ variable, a note in your PR, the output of `cw run export` (the export
44
+ manifest hashes the event log bytes, so an export IS an anchor in file form).
45
+
46
+ ## How the check works
47
+
48
+ `verifyTrustAudit` walks the chain as before, and keeps the trail of head
49
+ hashes it saw (genesis, then the hash after each event). With an anchor:
50
+
51
+ - `expectCount`: the walked log must have at least that many events. Fewer =
52
+ `trust-audit-truncated` (check name `anchor-count`).
53
+ - `expectHead`: the saved head must be ON the trail. A log that was cut and
54
+ then padded back with new events reaches the old count, but the new events
55
+ link from an earlier point — the old head is no longer on the trail, so
56
+ this still fails (check name `anchor-head`).
57
+
58
+ ## Compatibility
59
+
60
+ Trust Audit Anchor is introduced in CW v0.2.1. Fields are additive and
61
+ optional; older run state loads unchanged. A plain `cw audit verify` keeps
62
+ its exact old output. The `anchor` key appears in the JSON only when the
63
+ caller passed an anchor flag.
64
+
65
+ ## See Also
66
+
67
+ security-trust-hardening(7), cli-mcp-parity(7), report-verifiable-bundle(7)
68
+
69
+ 0.2.2
@@ -276,3 +276,5 @@ _No behavioral change in v0.1.89 (CLI-surface golden-path + help-output fixes on
276
276
  0.2.0
277
277
 
278
278
  0.2.1
279
+
280
+ 0.2.2
@@ -2,7 +2,7 @@
2
2
  "_comment": "SINGLE SOURCE OF TRUTH for every vendor manifest. Edit THIS file, then run `npm run gen:manifests`. Do NOT hand-edit the generated vendor manifests (.claude-plugin/, .codex-plugin/, .agents/, .mcp.json) — `npm run gen:manifests -- --check` (run by release:check) will fail if they drift from this source.",
3
3
  "identity": {
4
4
  "name": "cool-workflow",
5
- "version": "0.2.1",
5
+ "version": "0.2.2",
6
6
  "license": "BSD-2-Clause",
7
7
  "homepage": "https://github.com/coo1white/cool-workflow",
8
8
  "author": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cool-workflow",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "bin": {
5
5
  "cool-workflow": "scripts/cw.js",
6
6
  "cw": "scripts/cw.js"
@@ -43,6 +43,7 @@
43
43
  "build": "tsc -p tsconfig.json",
44
44
  "check": "tsc -p tsconfig.json --noEmit",
45
45
  "dist:check": "node scripts/dist-drift-check.js",
46
+ "purity:check": "node scripts/purity-gate.js",
46
47
  "golden-path": "node scripts/golden-path.js",
47
48
  "dogfood:release": "node scripts/dogfood-release.js",
48
49
  "canonical-apps": "node scripts/canonical-apps.js",
@@ -68,6 +69,7 @@
68
69
  "test:fast": "npm run build --if-present && node dist/cli.js version > /dev/null && node test/run-all.js --concurrency auto",
69
70
  "test:ci": "npm run build && node dist/cli.js version > /dev/null && node test/run-all.js --sample 55",
70
71
  "test:coverage": "node dist/cli.js version > /dev/null && node scripts/coverage-gate.js --concurrency auto",
72
+ "test:unit": "node dist/cli.js version > /dev/null && node test/run-unit.js",
71
73
  "eval:replay": "tsc -p tsconfig.json && node test/multi-agent-eval-replay-harness-smoke.js",
72
74
  "ci": "npm run build && npm run check && npm run test && npm run release:check",
73
75
  "validate:schema": "node scripts/validate-run-state-schema.js"
@@ -46,6 +46,23 @@ function replaceFirstVersionField(absPath, next) {
46
46
  return true;
47
47
  }
48
48
 
49
+ function replaceLockfileVersions(absPath, next) {
50
+ // Move BOTH lockfile version fields with targeted string swaps, so the byte
51
+ // formatting npm wrote is kept as it is: the top-level "version" (the first
52
+ // one in the file) and the root-package entry `"": { "name": ..., "version": ... }`.
53
+ // Dependency entries are not touched — the second swap is keyed on the
54
+ // `"": {` root-package opening, which is present only once.
55
+ const text = fs.readFileSync(absPath, "utf8");
56
+ let updated = text.replace(/"version":\s*"[^"]*"/, `"version": "${next}"`);
57
+ updated = updated.replace(
58
+ /("":\s*\{\s*"name":\s*"[^"]*",\s*"version":\s*)"[^"]*"/,
59
+ `$1"${next}"`
60
+ );
61
+ if (updated === text) return false;
62
+ fs.writeFileSync(absPath, updated);
63
+ return true;
64
+ }
65
+
49
66
  function setNestedVersion(absPath, next) {
50
67
  // For files where the first `"version"` is NOT the right one, parse + set.
51
68
  const json = JSON.parse(fs.readFileSync(absPath, "utf8"));
@@ -75,9 +92,13 @@ function main() {
75
92
  // 1. package.json (the single source of truth version:sync now reads from)
76
93
  if (replaceFirstVersionField(path.join(pluginRoot, "package.json"), next)) note("package.json");
77
94
 
78
- // 2. package-lock.json (gitignored install artifact; only if present)
95
+ // 2. package-lock.json (tracked; only if present). The lockfile keeps the
96
+ // version in TWO places: the top-level "version" and the root-package
97
+ // entry packages[""].version. The old code moved only the first one, so
98
+ // the root-package entry kept an old version till the next `npm install`
99
+ // (the v0.1.97 drift seen after the v0.2.0/v0.2.1 cuts). Move both here.
79
100
  const lock = path.join(pluginRoot, "package-lock.json");
80
- if (fs.existsSync(lock) && replaceFirstVersionField(lock, next)) note("package-lock.json");
101
+ if (fs.existsSync(lock) && replaceLockfileVersions(lock, next)) note("package-lock.json");
81
102
 
82
103
  // 2b. Official MCP Registry server metadata (top-level server version + npm package version).
83
104
  const serverJson = path.join(pluginRoot, "server.json");
@@ -218,6 +239,7 @@ function contentSurfaceFiles(next) {
218
239
  { path: "plugins/cool-workflow/docs/run-retention-reclamation.7.md", needle: next, desc: "run retention doc" },
219
240
  { path: "plugins/cool-workflow/docs/durable-state-and-locking.7.md", needle: next, desc: "durable state doc" },
220
241
  { path: "plugins/cool-workflow/docs/release-and-migration.7.md", needle: next, desc: "release & migration doc" },
242
+ { path: "plugins/cool-workflow/docs/trust-audit-anchor.7.md", needle: next, desc: "trust audit anchor doc" },
221
243
  ];
222
244
  }
223
245
 
@@ -83,7 +83,7 @@ const canonicalApps = [
83
83
  "--source",
84
84
  "plugins/cool-workflow/docs/workflow-app-framework.7.md",
85
85
  "--scope",
86
- "Cool Workflow v0.2.1",
86
+ "Cool Workflow v0.2.2",
87
87
  "--freshness",
88
88
  "as of release preparation"
89
89
  ]
@@ -117,14 +117,14 @@ function main() {
117
117
  assert.ok(summary, `${app.id} must appear in app list`);
118
118
  assert.equal(summary.sourceKind, "app-directory");
119
119
  assert.equal(summary.legacy, false);
120
- assert.equal(summary.version, "0.2.1");
120
+ assert.equal(summary.version, "0.2.2");
121
121
 
122
122
  const validation = runJson(["app", "validate", manifestPath]);
123
123
  assert.equal(validation.valid, true, `${app.id} manifest must validate`);
124
124
 
125
125
  const shown = runJson(["app", "show", app.id]);
126
126
  assert.equal(shown.app.id, app.id);
127
- assert.equal(shown.app.version, "0.2.1");
127
+ assert.equal(shown.app.version, "0.2.2");
128
128
  assert.ok(shown.app.metadata.canonical, `${app.id} must be marked canonical`);
129
129
  assert.ok(shown.app.sandboxProfiles.length > 0, `${app.id} must declare sandbox profiles`);
130
130
  assertTaskIdsUnique(shown);
@@ -135,7 +135,7 @@ function main() {
135
135
  const plan = runJson(["plan", app.id, ...app.args(workspace)]);
136
136
  const state = JSON.parse(fs.readFileSync(plan.statePath, "utf8"));
137
137
  assert.equal(state.workflow.app.id, app.id);
138
- assert.equal(state.workflow.app.version, "0.2.1");
138
+ assert.equal(state.workflow.app.version, "0.2.2");
139
139
  assert.equal(state.workflow.app.metadata.canonical, true);
140
140
  assert.ok(state.tasks.some((task) => task.requiresEvidence), `${app.id} plan must include evidence gates`);
141
141
  assert.ok(state.tasks.every((task) => task.sandboxProfileId), `${app.id} plan must include sandbox hints`);
@@ -6,7 +6,7 @@ const fs = require("node:fs");
6
6
  const path = require("node:path");
7
7
  const { CoolWorkflowRunner } = require("../dist/shell/orchestrator.js");
8
8
 
9
- const TARGET_VERSION = "0.2.1";
9
+ const TARGET_VERSION = "0.2.2";
10
10
  const PREVIOUS_VERSION = "0.1.31";
11
11
  const pluginRoot = path.resolve(__dirname, "..");
12
12
  const repoRoot = path.resolve(pluginRoot, "..", "..");
@@ -33,7 +33,7 @@ function main() {
33
33
  const appValidation = runJson(["app", "validate", "end-to-end-golden-path"], pluginRoot);
34
34
  assert.equal(appValidation.valid, true);
35
35
  assert.equal(appValidation.summary.id, "end-to-end-golden-path");
36
- assert.equal(appValidation.summary.version, "0.2.1");
36
+ assert.equal(appValidation.summary.version, "0.2.2");
37
37
 
38
38
  const plan = runJson(
39
39
  [
@@ -42,7 +42,7 @@ function main() {
42
42
  "--repo",
43
43
  tmp,
44
44
  "--question",
45
- "Prove the deterministic v0.2.1 end-to-end golden path."
45
+ "Prove the deterministic v0.2.2 end-to-end golden path."
46
46
  ],
47
47
  pluginRoot
48
48
  );
@@ -52,7 +52,7 @@ function main() {
52
52
 
53
53
  let state = readJson(plan.statePath);
54
54
  assert.equal(state.workflow.app.id, "end-to-end-golden-path");
55
- assert.equal(state.workflow.app.version, "0.2.1");
55
+ assert.equal(state.workflow.app.version, "0.2.2");
56
56
  assert.equal(state.loopStage, "interpret");
57
57
 
58
58
  const dispatch = runJson(["dispatch", plan.runId, "--limit", "1", "--sandbox", "readonly"], tmp);
@@ -195,7 +195,7 @@ function main() {
195
195
  assert.equal(reportPath, plan.reportPath);
196
196
  assert.ok(fs.existsSync(reportPath));
197
197
  const report = fs.readFileSync(reportPath, "utf8");
198
- assert.match(report, /Workflow App: end-to-end-golden-path@0\.2\.1/);
198
+ assert.match(report, /Workflow App: end-to-end-golden-path@0\.2\.2/);
199
199
  assert.match(report, /## Candidates/);
200
200
  assert.match(report, /## Trust Audit/);
201
201
  assert.match(report, /## Acceptance Rationale/);
@@ -0,0 +1,68 @@
1
+ {
2
+ "builtinViolations": {
3
+ "core/state/run-paths.ts": [
4
+ "node:fs"
5
+ ],
6
+ "core/trust/telemetry-attestation.ts": [
7
+ "node:fs"
8
+ ]
9
+ },
10
+ "clockEnvCounts": {
11
+ "core/multi-agent/candidate-scoring.ts": {
12
+ "new Date()": 1
13
+ },
14
+ "core/pipeline/runner.ts": {
15
+ "new Date()": 1
16
+ },
17
+ "core/state/migrations.ts": {
18
+ "process.cwd()": 1
19
+ },
20
+ "core/state/state-explosion/digest.ts": {
21
+ "new Date()": 1
22
+ },
23
+ "core/state/state-explosion/graph.ts": {
24
+ "new Date()": 1
25
+ },
26
+ "core/state/state-explosion/report.ts": {
27
+ "new Date()": 1
28
+ },
29
+ "core/state/state-node.ts": {
30
+ "new Date()": 5
31
+ },
32
+ "core/trust/evidence-grounding.ts": {
33
+ "process.env": 3
34
+ }
35
+ },
36
+ "layerViolations": {
37
+ "core/capability-table.ts": [
38
+ "../wiring/capability-table"
39
+ ],
40
+ "wiring/capability-table/exec-backend.ts": [
41
+ "../../cli/io"
42
+ ],
43
+ "wiring/capability-table/multi-agent.ts": [
44
+ "../../cli/io"
45
+ ],
46
+ "wiring/capability-table/pipeline.ts": [
47
+ "../../cli/io"
48
+ ],
49
+ "wiring/capability-table/registry-core.ts": [
50
+ "../../cli/io"
51
+ ],
52
+ "wiring/capability-table/reporting.ts": [
53
+ "../../cli/io"
54
+ ],
55
+ "wiring/capability-table/scheduling-registry.ts": [
56
+ "../../cli/io"
57
+ ],
58
+ "wiring/capability-table/state.ts": [
59
+ "../../cli/io"
60
+ ],
61
+ "wiring/capability-table/trust-ledger.ts": [
62
+ "../../cli/io"
63
+ ],
64
+ "wiring/capability-table/workflow-apps.ts": [
65
+ "../../cli/io"
66
+ ]
67
+ }
68
+ }
@@ -0,0 +1,239 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ // purity-gate — fail closed if src/ crosses a layer boundary the rebuild
5
+ // docs claim is enforced, or if src/core/** reads an impure primitive
6
+ // (node:fs/child_process/net/http, process.env, process.cwd(), Date.now(),
7
+ // new Date(), Math.random()), beyond a committed, itemized baseline.
8
+ //
9
+ // Why this exists: docs/rebuild/PLAN.md and AGENTS.md both describe core/
10
+ // as pure (no IO) and shell/ as the only impure layer, and say this is
11
+ // "enforced by a lint rule". No such lint exists anywhere in scripts/ or
12
+ // package.json — only `tsc --noEmit`, which does not check import
13
+ // direction. The one place the rule is already broken is the hub itself:
14
+ // core/capability-table.ts imports ~30 things from ../shell.
15
+ //
16
+ // This is a RATCHET, not a clean-slate rule (matching dist-drift-check.js's
17
+ // and version-sync-check.js's style: node + git only, no new dependency).
18
+ // scripts/purity-baseline.json lists every violation that exists TODAY.
19
+ // The gate fails on:
20
+ // - a violation NOT in the baseline (a NEW break), and
21
+ // - a baseline entry that no longer matches reality (STALE — either the
22
+ // violation was fixed, in which case delete the entry, or the count
23
+ // changed, in which case update it consciously).
24
+ // Fail-closed both directions, same as dist-drift-check.js's added/changed/
25
+ // removed three-way diff.
26
+ //
27
+ // Layers (by top-level directory under src/):
28
+ // core/** -> may only import core/** (+ node:path, node:crypto)
29
+ // shell/** -> may import core/** or shell/**
30
+ // wiring/** -> may import core/**, shell/**, or wiring/** (not yet used;
31
+ // reserved for the capability-table split)
32
+ // cli/** -> may import core/**, shell/**, or cli/** (never mcp/**)
33
+ // mcp/** -> may import core/**, shell/**, or mcp/** (never cli/**)
34
+ //
35
+ // Approach: a plain-text specifier scan (import/export-from/require), not a
36
+ // real TS parser — this codebase uses only those three forms, verified
37
+ // against every current violation this baseline lists.
38
+
39
+ const fs = require("node:fs");
40
+ const path = require("node:path");
41
+
42
+ const packageDir = path.resolve(__dirname, "..");
43
+ const srcDir = path.join(packageDir, "src");
44
+ const baselinePath = path.join(__dirname, "purity-baseline.json");
45
+
46
+ const CORE_ALLOWED_BUILTINS = new Set(["node:path", "node:crypto"]);
47
+ const IMPURE_PATTERNS = ["process.env", "process.cwd()", "Date.now()", "new Date()", "Math.random()"];
48
+
49
+ const ALLOWED_TARGET_LAYERS = {
50
+ core: new Set(["core"]),
51
+ shell: new Set(["core", "shell"]),
52
+ wiring: new Set(["core", "shell", "wiring"]),
53
+ cli: new Set(["core", "shell", "cli"]),
54
+ mcp: new Set(["core", "shell", "mcp"]),
55
+ other: new Set(["core", "shell", "wiring", "cli", "mcp", "other"]),
56
+ };
57
+
58
+ function layerOf(relPath) {
59
+ if (relPath.startsWith("core/")) return "core";
60
+ if (relPath.startsWith("shell/")) return "shell";
61
+ if (relPath.startsWith("wiring/")) return "wiring";
62
+ if (relPath.startsWith("cli/") || relPath === "cli") return "cli";
63
+ if (relPath.startsWith("mcp/") || relPath === "mcp-server") return "mcp";
64
+ return "other";
65
+ }
66
+
67
+ function listTsFiles(dir) {
68
+ const out = [];
69
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
70
+ const full = path.join(dir, entry.name);
71
+ if (entry.isDirectory()) out.push(...listTsFiles(full));
72
+ else if (entry.name.endsWith(".ts")) out.push(full);
73
+ }
74
+ return out;
75
+ }
76
+
77
+ // This codebase's file-header comments routinely quote the EXACT patterns
78
+ // this gate looks for, in prose explaining why a file is pure (e.g. "never
79
+ // a top-level `require(\"node:fs\")`", "no fs, no process.env, no clock").
80
+ // A naive text scan reads those quotes as violations. Strip `//` and
81
+ // `/* */` comments before scanning (string/template literal contents are
82
+ // left untouched, so a real `from "..."` clause is never damaged); newlines
83
+ // are preserved so extractSpecifiers' `(?:^|\n)` anchor still lines up.
84
+ function stripComments(text) {
85
+ let out = "";
86
+ let mode = "code"; // "code" | "line" | "block" | quote character
87
+ for (let i = 0; i < text.length; i++) {
88
+ const c = text[i];
89
+ const c2 = text[i + 1];
90
+ if (mode === "line") {
91
+ if (c === "\n") { mode = "code"; out += c; }
92
+ continue;
93
+ }
94
+ if (mode === "block") {
95
+ if (c === "*" && c2 === "/") { mode = "code"; i++; continue; }
96
+ if (c === "\n") out += c;
97
+ continue;
98
+ }
99
+ if (mode === '"' || mode === "'" || mode === "`") {
100
+ out += c;
101
+ if (c === "\\") { out += c2 ?? ""; i++; continue; }
102
+ if (c === mode) mode = "code";
103
+ continue;
104
+ }
105
+ if (c === "/" && c2 === "/") { mode = "line"; i++; continue; }
106
+ if (c === "/" && c2 === "*") { mode = "block"; i++; continue; }
107
+ if (c === '"' || c === "'" || c === "`") { mode = c; out += c; continue; }
108
+ out += c;
109
+ }
110
+ return out;
111
+ }
112
+
113
+ // Every `from "spec"` (import or export-from, type or value — a type-only
114
+ // import still names a module, so it counts the same way a real dependency
115
+ // scan would) plus every `require("spec")`. Non-greedy across `from` finds
116
+ // each import's own clause without needing a real parser.
117
+ function extractSpecifiers(codeText) {
118
+ const specs = [];
119
+ const fromRe = /(?:^|\n)\s*(?:import|export)\s[\s\S]*?from\s+["']([^"']+)["']/g;
120
+ let m;
121
+ while ((m = fromRe.exec(codeText))) specs.push(m[1]);
122
+ const requireRe = /require\(\s*["']([^"']+)["']\s*\)/g;
123
+ while ((m = requireRe.exec(codeText))) specs.push(m[1]);
124
+ return specs;
125
+ }
126
+
127
+ function countOccurrences(text, needle) {
128
+ let count = 0;
129
+ let index = 0;
130
+ while ((index = text.indexOf(needle, index)) !== -1) {
131
+ count += 1;
132
+ index += needle.length;
133
+ }
134
+ return count;
135
+ }
136
+
137
+ function relSrcPath(absPath) {
138
+ return path.relative(srcDir, absPath).split(path.sep).join("/");
139
+ }
140
+
141
+ function scan() {
142
+ const layerViolations = {}; // relFile -> string[] (specs that cross a disallowed layer)
143
+ const builtinViolations = {}; // relFile -> string[] (banned node builtins imported from core)
144
+ const clockEnvCounts = {}; // relFile -> { pattern: count } (core only, count > 0)
145
+
146
+ for (const absFile of listTsFiles(srcDir)) {
147
+ const relFile = relSrcPath(absFile);
148
+ const fileLayer = layerOf(relFile.replace(/\.ts$/, ""));
149
+ const text = stripComments(fs.readFileSync(absFile, "utf8"));
150
+
151
+ for (const spec of extractSpecifiers(text)) {
152
+ if (spec.startsWith(".")) {
153
+ const targetAbs = path.resolve(path.dirname(absFile), spec);
154
+ const targetRel = relSrcPath(targetAbs);
155
+ const targetLayer = layerOf(targetRel);
156
+ if (!ALLOWED_TARGET_LAYERS[fileLayer].has(targetLayer)) {
157
+ (layerViolations[relFile] = layerViolations[relFile] || []).push(spec);
158
+ }
159
+ } else if (spec.startsWith("node:")) {
160
+ if (fileLayer === "core" && !CORE_ALLOWED_BUILTINS.has(spec)) {
161
+ (builtinViolations[relFile] = builtinViolations[relFile] || []).push(spec);
162
+ }
163
+ }
164
+ // Bare package specifiers (no "." or "node:" prefix): this package
165
+ // has zero runtime dependencies, so there are none to check.
166
+ }
167
+
168
+ if (fileLayer === "core") {
169
+ const counts = {};
170
+ for (const pattern of IMPURE_PATTERNS) {
171
+ const n = countOccurrences(text, pattern);
172
+ if (n > 0) counts[pattern] = n;
173
+ }
174
+ if (Object.keys(counts).length > 0) clockEnvCounts[relFile] = counts;
175
+ }
176
+ }
177
+
178
+ for (const bucket of [layerViolations, builtinViolations]) {
179
+ for (const file of Object.keys(bucket)) bucket[file] = [...new Set(bucket[file])].sort();
180
+ }
181
+ return { layerViolations, builtinViolations, clockEnvCounts };
182
+ }
183
+
184
+ function sortedKeys(obj) {
185
+ return Object.keys(obj).sort();
186
+ }
187
+
188
+ function diffListBuckets(actual, baseline, label, problems) {
189
+ const files = new Set([...sortedKeys(actual), ...sortedKeys(baseline)]);
190
+ for (const file of [...files].sort()) {
191
+ const actualList = actual[file] || [];
192
+ const baseList = baseline[file] || [];
193
+ const actualSet = new Set(actualList);
194
+ const baseSet = new Set(baseList);
195
+ const added = actualList.filter((s) => !baseSet.has(s));
196
+ const removed = baseList.filter((s) => !actualSet.has(s));
197
+ for (const spec of added) problems.push(`NEW ${label}: ${file} imports ${spec} (not in purity-baseline.json)`);
198
+ for (const spec of removed) problems.push(`STALE ${label} baseline entry: ${file} no longer imports ${spec} — delete it from purity-baseline.json`);
199
+ }
200
+ }
201
+
202
+ function diffCountBuckets(actual, baseline, problems) {
203
+ const files = new Set([...sortedKeys(actual), ...sortedKeys(baseline)]);
204
+ for (const file of [...files].sort()) {
205
+ const actualCounts = actual[file] || {};
206
+ const baseCounts = baseline[file] || {};
207
+ const patterns = new Set([...Object.keys(actualCounts), ...Object.keys(baseCounts)]);
208
+ for (const pattern of [...patterns].sort()) {
209
+ const a = actualCounts[pattern] || 0;
210
+ const b = baseCounts[pattern] || 0;
211
+ if (a !== b) {
212
+ problems.push(`clock/env count drift: ${file} has ${a} occurrence(s) of "${pattern}", purity-baseline.json expects ${b} — update the baseline consciously (this catches new AND removed occurrences)`);
213
+ }
214
+ }
215
+ }
216
+ }
217
+
218
+ function main() {
219
+ if (!fs.existsSync(baselinePath)) {
220
+ process.stderr.write(`purity gate: missing ${path.relative(packageDir, baselinePath)}\n`);
221
+ process.exit(1);
222
+ }
223
+ const baseline = JSON.parse(fs.readFileSync(baselinePath, "utf8"));
224
+ const actual = scan();
225
+
226
+ const problems = [];
227
+ diffListBuckets(actual.layerViolations, baseline.layerViolations || {}, "layer violation", problems);
228
+ diffListBuckets(actual.builtinViolations, baseline.builtinViolations || {}, "core builtin violation", problems);
229
+ diffCountBuckets(actual.clockEnvCounts, baseline.clockEnvCounts || {}, problems);
230
+
231
+ if (problems.length > 0) {
232
+ process.stderr.write(`purity gate: ${problems.length} problem(s)\n`);
233
+ for (const p of problems) process.stderr.write(` ${p}\n`);
234
+ process.exit(1);
235
+ }
236
+ process.stdout.write("purity gate: src/ matches the committed baseline (no new core/shell layer breaks, no clock/env drift).\n");
237
+ }
238
+
239
+ main();
@@ -39,6 +39,7 @@ const checks = [
39
39
  "docs/run-retention-reclamation.7.md",
40
40
  "docs/durable-state-and-locking.7.md",
41
41
  "docs/security-trust-hardening.7.md",
42
+ "docs/trust-audit-anchor.7.md",
42
43
  "../../CHANGELOG.md",
43
44
  "../../RELEASE.md"
44
45
  ]) {
@@ -58,6 +59,7 @@ const checks = [
58
59
  // `dist:check` builds from src/ AND fails closed if the committed dist/ drifted
59
60
  // from that fresh build — strictly stronger than a bare `npm run build`.
60
61
  { name: "dist freshness", command: ["npm", "run", "dist:check"] },
62
+ { name: "core/shell purity", command: ["npm", "run", "purity:check"] },
61
63
  { name: "type check", command: ["npm", "run", "check"] },
62
64
  { name: "onramp contract", command: ["npm", "run", "onramp:check"] },
63
65
  { name: "run-state schema consistency", command: ["node", "scripts/validate-run-state-schema.js"] },
@@ -66,6 +68,11 @@ const checks = [
66
68
  // (release-gate.sh) forces CW_TEST_CONCURRENCY=1 to stay sequential as the
67
69
  // deterministic backstop.
68
70
  { name: "tests", command: ["npm", "run", "test:ci"] },
71
+ // Pure core/ unit tests (test/*.test.js) — a separate suite from the smoke
72
+ // tests above (see test/run-unit.js's header). --skip-tests also skips
73
+ // this one, matching "tests": ci.yml runs test:unit directly, so this
74
+ // entry would just repeat it a second time in the release-check pass.
75
+ { name: "unit tests", command: ["npm", "run", "test:unit"] },
69
76
  { name: "canonical apps", command: ["npm", "run", "canonical-apps"] },
70
77
  { name: "golden path", command: ["npm", "run", "golden-path"] },
71
78
  { name: "CLI MCP parity", command: ["npm", "run", "parity:check"] },
@@ -88,7 +95,7 @@ function main() {
88
95
  process.stdout.write(`release:check ${check.name} ... `);
89
96
  const started = Date.now();
90
97
  try {
91
- if (skipTests && check.name === "tests") {
98
+ if (skipTests && (check.name === "tests" || check.name === "unit tests")) {
92
99
  results.push({ name: check.name, ok: true, skipped: true, elapsedMs: 0 });
93
100
  process.stdout.write("skipped\n");
94
101
  continue;