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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/apps/architecture-review/app.json +1 -1
- package/apps/architecture-review-fast/app.json +1 -1
- package/apps/end-to-end-golden-path/app.json +1 -1
- package/apps/pr-review-fix-ci/app.json +1 -1
- package/apps/release-cut/app.json +1 -1
- package/apps/research-synthesis/app.json +1 -1
- package/dist/cli/dispatch.js +18 -86
- package/dist/cli/parseargv.js +5 -2
- package/dist/core/capability-data.js +270 -0
- package/dist/core/capability-table.js +16 -3504
- package/dist/core/format/help.js +28 -5
- package/dist/core/multi-agent/collaboration.js +3 -2
- package/dist/core/multi-agent/coordinator.js +4 -3
- package/dist/core/pipeline/commit-gate.js +2 -1
- package/dist/core/state/state-explosion/digest.js +3 -2
- package/dist/core/state/state-explosion/graph.js +9 -8
- package/dist/core/state/state-explosion/helpers.js +2 -1
- package/dist/core/trust/ledger.js +2 -1
- package/dist/core/types/execution-backend.js +18 -0
- package/dist/core/types/observability.js +7 -0
- package/dist/core/util/collate.js +23 -0
- package/dist/core/version.js +1 -1
- package/dist/shell/audit-cli.js +46 -2
- package/dist/shell/commit-summary.js +2 -1
- package/dist/shell/drive.js +26 -10
- package/dist/shell/evidence-reasoning.js +4 -3
- package/dist/shell/execution-backend/registry.js +2 -1
- package/dist/shell/execution-backend/types.js +0 -9
- package/dist/shell/fs-atomic.js +14 -1
- package/dist/shell/multi-agent-operator-ux.js +4 -3
- package/dist/shell/observability.js +12 -11
- package/dist/shell/onramp.js +17 -1
- package/dist/shell/operator-ux-text.js +2 -1
- package/dist/shell/operator-ux.js +7 -6
- package/dist/shell/pipeline-cli.js +82 -72
- package/dist/shell/reclamation-io.js +2 -1
- package/dist/shell/report.js +2 -1
- package/dist/shell/run-store.js +17 -0
- package/dist/shell/state-explosion-cli.js +2 -1
- package/dist/shell/topology-io.js +2 -1
- package/dist/shell/trust-audit.js +41 -2
- package/dist/shell/worker-cli.js +9 -1
- package/dist/shell/worker-isolation.js +34 -9
- package/dist/shell/workflow-app-loader.js +3 -2
- package/dist/wiring/capability-table/basics.js +66 -0
- package/dist/wiring/capability-table/exec-backend.js +162 -0
- package/dist/wiring/capability-table/index.js +42 -0
- package/dist/wiring/capability-table/multi-agent.js +623 -0
- package/dist/wiring/capability-table/parity.js +466 -0
- package/dist/wiring/capability-table/pipeline.js +280 -0
- package/dist/wiring/capability-table/registry-core.js +189 -0
- package/dist/wiring/capability-table/reporting.js +394 -0
- package/dist/wiring/capability-table/scheduling-registry.js +558 -0
- package/dist/wiring/capability-table/state.js +173 -0
- package/dist/wiring/capability-table/trust-ledger.js +134 -0
- package/dist/wiring/capability-table/workflow-apps.js +366 -0
- package/docs/agent-delegation-drive.7.md +2 -0
- package/docs/cli-mcp-parity.7.md +7 -2
- package/docs/contract-migration-tooling.7.md +2 -0
- package/docs/control-plane-scheduling.7.md +2 -0
- package/docs/durable-state-and-locking.7.md +22 -0
- package/docs/evidence-adoption-reasoning-chain.7.md +2 -0
- package/docs/execution-backends.7.md +2 -0
- package/docs/index.md +1 -0
- package/docs/multi-agent-cli-mcp-surface.7.md +2 -0
- package/docs/multi-agent-eval-replay-harness.7.md +2 -0
- package/docs/multi-agent-operator-ux.7.md +2 -0
- package/docs/node-snapshot-diff-replay.7.md +2 -0
- package/docs/observability-cost-accounting.7.md +2 -0
- package/docs/project-index.md +25 -5
- package/docs/real-execution-backends.7.md +2 -0
- package/docs/release-and-migration.7.md +2 -0
- package/docs/release-tooling.7.md +2 -0
- package/docs/run-registry-control-plane.7.md +2 -0
- package/docs/run-retention-reclamation.7.md +2 -0
- package/docs/security-trust-hardening.7.md +30 -0
- package/docs/state-explosion-management.7.md +2 -0
- package/docs/team-collaboration.7.md +2 -0
- package/docs/trust-audit-anchor.7.md +69 -0
- package/docs/web-desktop-workbench.7.md +2 -0
- package/manifest/plugin.manifest.json +1 -1
- package/package.json +3 -1
- package/scripts/bump-version.js +24 -2
- package/scripts/canonical-apps.js +4 -4
- package/scripts/dogfood-release.js +1 -1
- package/scripts/golden-path.js +4 -4
- package/scripts/purity-baseline.json +68 -0
- package/scripts/purity-gate.js +239 -0
- package/scripts/release-check.js +8 -1
- package/scripts/version-sync-check.js +33 -12
- package/workflows/README.md +19 -0
|
@@ -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
|
|
@@ -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
|
|
@@ -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.
|
|
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.
|
|
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"
|
package/scripts/bump-version.js
CHANGED
|
@@ -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 (
|
|
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) &&
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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, "..", "..");
|
package/scripts/golden-path.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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\.
|
|
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();
|
package/scripts/release-check.js
CHANGED
|
@@ -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;
|