@ijfw/memory-server 1.4.4 → 1.5.1
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/bin/ijfw-memorize +14 -7
- package/fixtures/team/book.json +6 -6
- package/fixtures/team/business.json +146 -20
- package/fixtures/team/content.json +6 -6
- package/fixtures/team/design.json +148 -20
- package/fixtures/team/mixed.json +206 -27
- package/fixtures/team/research.json +146 -20
- package/fixtures/team/software.json +148 -20
- package/fixtures/truncation-corpus/_generate-corpus.js +367 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-01/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-01/intent-journal.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-01/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-01/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-02/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-02/intent-journal.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-02/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-02/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-03/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-03/intent-journal.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-03/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-03/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-04/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-04/intent-journal.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-04/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-04/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-05/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-05/intent-journal.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-05/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-01-clean-exit-05/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/snapshots/v-midO-1-advance.json +11 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/snapshots/v-midO-2-advance.json +11 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/snapshots/v-midO-3-advance.json +11 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/snapshots/v-midO-4-advance.json +11 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/snapshots/v-midO-5-advance.json +11 -0
- package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-01/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-01/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-01/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-01/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-02/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-02/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-02/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-02/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-03/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-03/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-03/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-03/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-04/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-04/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-04/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-04/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-05/events.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-05/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-05/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-03-mid-append-05/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-01/events.jsonl +0 -0
- package/fixtures/truncation-corpus/fx-04-no-events-01/intent-journal.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-01/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-04-no-events-01/snapshots/v-noEv-1-set-phase.json +11 -0
- package/fixtures/truncation-corpus/fx-04-no-events-01/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-02/events.jsonl +0 -0
- package/fixtures/truncation-corpus/fx-04-no-events-02/intent-journal.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-02/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-04-no-events-02/snapshots/v-noEv-2-set-phase.json +11 -0
- package/fixtures/truncation-corpus/fx-04-no-events-02/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-03/events.jsonl +0 -0
- package/fixtures/truncation-corpus/fx-04-no-events-03/intent-journal.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-03/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-04-no-events-03/snapshots/v-noEv-3-set-phase.json +11 -0
- package/fixtures/truncation-corpus/fx-04-no-events-03/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-04/events.jsonl +0 -0
- package/fixtures/truncation-corpus/fx-04-no-events-04/intent-journal.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-04/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-04-no-events-04/snapshots/v-noEv-4-set-phase.json +11 -0
- package/fixtures/truncation-corpus/fx-04-no-events-04/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-05/events.jsonl +0 -0
- package/fixtures/truncation-corpus/fx-04-no-events-05/intent-journal.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-04-no-events-05/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-04-no-events-05/snapshots/v-noEv-5-set-phase.json +11 -0
- package/fixtures/truncation-corpus/fx-04-no-events-05/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-01/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-01/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-01/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-01/snapshots/v-errT-1-partial.json +11 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-01/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-02/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-02/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-02/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-02/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-03/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-03/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-03/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-03/snapshots/v-errT-3-partial.json +11 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-03/target/.ijfw/state/workflow.json +1 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-04/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-04/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-04/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-04/target/.ijfw/blackboard/decisions.jsonl +1 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-05/events.jsonl +2 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-05/intent-journal.jsonl +3 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-05/meta.json +18 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-05/snapshots/v-errT-5-partial.json +11 -0
- package/fixtures/truncation-corpus/fx-05-error-terminated-05/target/.ijfw/state/workflow.json +1 -0
- package/package.json +6 -3
- package/src/active-extension-writer.js +144 -64
- package/src/api-client.js +43 -5
- package/src/audit-roster.js +80 -5
- package/src/blackboard.js +298 -6
- package/src/cli-run.js +33 -5
- package/src/codex-agents.js +96 -5
- package/src/cost/aggregator.js +39 -9
- package/src/cost/pricing.js +57 -0
- package/src/cost/readers/gemini.js +1 -1
- package/src/cross-audit-chunker.js +189 -0
- package/src/cross-dispatcher.js +124 -21
- package/src/cross-orchestrator-cli.js +754 -159
- package/src/cross-orchestrator.js +1065 -17
- package/src/cross-project-search.js +195 -9
- package/src/dashboard-client-waves.html +304 -0
- package/src/dashboard-client.html +5 -1
- package/src/dashboard-server.js +73 -0
- package/src/deploy-alerts.js +150 -0
- package/src/design/iframe-bridge.js +242 -0
- package/src/design-companion.js +144 -0
- package/src/dispatch/checkpoint-cli.js +97 -0
- package/src/dispatch/colon-syntax.js +81 -1
- package/src/dispatch/extension.js +26 -2
- package/src/dispatch/registry-cli.js +4 -1
- package/src/dispatch/wave-cli.js +201 -6
- package/src/dispatch/worktree-cli.js +40 -0
- package/src/dispatch-planner.js +97 -2
- package/src/dream/runner.mjs +47 -11
- package/src/dream/stage-runner.js +40 -0
- package/src/dream/state-file.js +102 -0
- package/src/extension-installer.js +70 -24
- package/src/extension-quota-tracker.js +4 -2
- package/src/extension-registry.js +289 -35
- package/src/feedback-detector.js +26 -0
- package/src/fs-lock.js +259 -7
- package/src/gate-result.js +95 -1
- package/src/hardware-signer.js +4 -2
- package/src/hero-line.js +86 -5
- package/src/intent-router.js +35 -0
- package/src/lib/a11y-contract.js +117 -0
- package/src/lib/atomic-io.js +29 -8
- package/src/lib/cache-keepalive.js +150 -0
- package/src/lib/jsonl-rotation.js +104 -0
- package/src/lib/lighthouse-pillar.js +121 -0
- package/src/lib/llm-call.js +121 -0
- package/src/lib/playwright-baseline.js +205 -0
- package/src/lib/rekor-bridge.js +221 -0
- package/src/lib/repo-map.js +392 -0
- package/src/lib/shasum-verify.js +164 -0
- package/src/lib/sketches-gc.js +132 -0
- package/src/lib/tmp-suffix.js +62 -0
- package/src/lib/ui-review-runner.js +595 -0
- package/src/lib/uispec-drift.js +301 -0
- package/src/lib/uispec-intake.js +381 -0
- package/src/lib/worktree-guards.js +118 -0
- package/src/lib/worktree-recovery.js +100 -0
- package/src/memory/auto-linker.js +267 -0
- package/src/memory/benchmark.js +498 -0
- package/src/memory/dedup.js +126 -0
- package/src/memory/embedding-cache.js +136 -0
- package/src/memory/fact-extractor.js +168 -0
- package/src/memory/fts5.js +65 -1
- package/src/memory/migration-runner.js +6 -1
- package/src/memory/migrations/004-bitemporal.js +91 -0
- package/src/memory/migrations/005-vector-cache.js +61 -0
- package/src/memory/migrations/006-obsidian-graph.js +46 -0
- package/src/memory/migrations/007-skill-telemetry.js +24 -0
- package/src/memory/migrations/008-write-provenance.js +41 -0
- package/src/memory/migrations/009-obsidian-backfill.js +50 -0
- package/src/memory/obsidian-parser.js +152 -0
- package/src/memory/query-dataview.js +86 -0
- package/src/memory/search.js +46 -15
- package/src/memory/temporal.js +529 -0
- package/src/memory/tokenize.js +10 -0
- package/src/memory-facts-handler.js +37 -0
- package/src/memory-feedback.js +260 -2
- package/src/model-refresh.js +292 -0
- package/src/observability/cost-anomaly.js +166 -0
- package/src/observability/evaluator-checkpoint-contract.js +117 -0
- package/src/observability/trace-id.js +163 -0
- package/src/orchestrator/agents-md-blackboard.js +152 -0
- package/src/orchestrator/checkpoint-contract.md +140 -0
- package/src/orchestrator/debug-trident-trigger.js +374 -0
- package/src/orchestrator/debug-trident.js +570 -0
- package/src/orchestrator/merge-block-aware.js +350 -0
- package/src/orchestrator/plan-checker.js +475 -0
- package/src/orchestrator/post-done-runner.js +277 -0
- package/src/orchestrator/review.js +38 -3
- package/src/orchestrator/skill-telemetry-sink.js +29 -0
- package/src/orchestrator/skill-telemetry.js +37 -0
- package/src/orchestrator/state-events.js +459 -0
- package/src/orchestrator/state-sdk.js +1932 -0
- package/src/orchestrator/status-protocol.js +84 -17
- package/src/orchestrator/subagent-telemetry.js +471 -0
- package/src/orchestrator/termination.js +160 -0
- package/src/orchestrator/verification-gate.js +200 -16
- package/src/orchestrator/wave-state.js +332 -23
- package/src/orchestrator/worktree-provision.js +77 -0
- package/src/override-resolver.js +5 -3
- package/src/override-use-registry.js +111 -5
- package/src/receipts.js +36 -4
- package/src/recovery/checkpoint.js +56 -3
- package/src/recovery/code-fixer.js +961 -0
- package/src/recovery/truncation.js +317 -0
- package/src/redactor.js +75 -6
- package/src/runtime-mediator.js +15 -1
- package/src/sanitizer.js +10 -0
- package/src/search-hybrid.js +139 -0
- package/src/server.js +795 -112
- package/src/swarm/worktree.js +27 -4
- package/src/swarm-config.js +102 -17
- package/src/team/domain-templates/book.json +51 -0
- package/src/team/domain-templates/business.json +44 -0
- package/src/team/domain-templates/content.json +50 -0
- package/src/team/domain-templates/design.json +44 -0
- package/src/team/domain-templates/research.json +44 -0
- package/src/team/domain-templates/software.json +40 -0
- package/src/team/generator.js +440 -3
- package/src/team/modify.js +203 -0
- package/src/team/schemas.js +48 -0
- package/src/update-apply.js +19 -3
- package/src/dashboard-charts.js +0 -239
|
@@ -0,0 +1,1932 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* state-sdk.js — v1.5.0 T2: the state-SDK verb core + dispatcher.
|
|
3
|
+
*
|
|
4
|
+
* ONE `query(verb, payload, ctx)` dispatcher over a frozen 20-verb registry.
|
|
5
|
+
* The SDK is a **verb facade over the EXISTING physical state files** — it is
|
|
6
|
+
* the single mutation surface, not a new storage format. Physical files keep
|
|
7
|
+
* their existing locations and formats (see STATE-SDK-CONTRACT.md §1).
|
|
8
|
+
*
|
|
9
|
+
* Binds verbatim to `.planning/v150-gap-closure/STATE-SDK-CONTRACT.md` (T1,
|
|
10
|
+
* FROZEN). Every verb's Signature / Payload / Returns / Day-1 behavior comes
|
|
11
|
+
* straight off that contract.
|
|
12
|
+
*
|
|
13
|
+
* ───────────────────────────────────────────────────────────────────────────
|
|
14
|
+
* SCOPE BOUNDARY — T2 built the verb core; three later tasks wrapped it.
|
|
15
|
+
* All three (T3/T4/T5) are now REALIZED — this section is kept as the design
|
|
16
|
+
* record, but the seams below are live, not stubs:
|
|
17
|
+
*
|
|
18
|
+
* T3 (lock hierarchy) — `_withLocks()` is NOT a pass-through. It acquires
|
|
19
|
+
* real filesystem locks via `withFsLock`, ordering
|
|
20
|
+
* each verb's declared lock-target list through
|
|
21
|
+
* `canonicalLockOrder` (the §3 canonical acquire
|
|
22
|
+
* order) to prevent deadlock, then runs `fn` while
|
|
23
|
+
* the locks are held and releases on completion.
|
|
24
|
+
* T4 (intent/commit) — wraps `_journalBegin()` / `_journalCommit()`.
|
|
25
|
+
* Today they are no-ops; T4 makes them write the
|
|
26
|
+
* write-ahead `intent-journal.jsonl` records and
|
|
27
|
+
* keeps the pre-write snapshot for rollback.
|
|
28
|
+
* T5 (event emission) — wraps `_emitEvent()`. Today it is a no-op; T5
|
|
29
|
+
* makes it append to the rotated per-subagent event
|
|
30
|
+
* log AFTER lock release (fire-and-forget).
|
|
31
|
+
*
|
|
32
|
+
* Those three seams are the ONLY extension points later tasks touch. The verb
|
|
33
|
+
* handlers themselves are frozen by this task.
|
|
34
|
+
* ───────────────────────────────────────────────────────────────────────────
|
|
35
|
+
*
|
|
36
|
+
* ESM, Node ≥18, zero new production dependencies.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import {
|
|
40
|
+
readFileSync, existsSync, mkdirSync, appendFileSync, unlinkSync, readdirSync,
|
|
41
|
+
} from 'node:fs';
|
|
42
|
+
import { join, isAbsolute, dirname, basename } from 'node:path';
|
|
43
|
+
import { homedir } from 'node:os';
|
|
44
|
+
import { randomUUID, createHash } from 'node:crypto';
|
|
45
|
+
import { gunzipSync } from 'node:zlib';
|
|
46
|
+
import { execFileSync } from 'node:child_process';
|
|
47
|
+
|
|
48
|
+
import { writeAtomic, readSafe } from '../lib/atomic-io.js';
|
|
49
|
+
import { rotateJsonlIfNeeded } from '../lib/jsonl-rotation.js';
|
|
50
|
+
import { withFsLock, canonicalLockOrder, lockPathFor } from '../fs-lock.js';
|
|
51
|
+
import {
|
|
52
|
+
enforceVerificationGate as _realEnforceVerificationGate,
|
|
53
|
+
VerificationGateViolation,
|
|
54
|
+
} from './verification-gate.js';
|
|
55
|
+
import {
|
|
56
|
+
validatePlan as _realValidatePlan,
|
|
57
|
+
isHighFinding,
|
|
58
|
+
} from './plan-checker.js';
|
|
59
|
+
import { runSelfCheck as _realRunSelfCheck } from './post-done-runner.js';
|
|
60
|
+
import {
|
|
61
|
+
emitEvent as emitEventToLog,
|
|
62
|
+
appendUnderHeldLock as appendEventUnderHeldLock,
|
|
63
|
+
resolveEventLogPath,
|
|
64
|
+
} from './state-events.js';
|
|
65
|
+
// v1.5.1 cleanup C1: S08 incident-driven worktree safety guards. Previously
|
|
66
|
+
// orphan (only importer was the unwired runtime-loop.js). Wired here as
|
|
67
|
+
// preconditions of the LIVE `subagent.dispatch` verb — the genuinely-reachable
|
|
68
|
+
// worktree-isolated spawn path (ijfw_state MCP tool → query → subagent.dispatch).
|
|
69
|
+
// worktree-guards.js has no state-sdk dependency, so a static import is safe.
|
|
70
|
+
import {
|
|
71
|
+
captureSpawnToplevel,
|
|
72
|
+
assertPathWithinToplevel,
|
|
73
|
+
assertNoCwdDrift,
|
|
74
|
+
assertNotProtectedRef,
|
|
75
|
+
} from '../lib/worktree-guards.js';
|
|
76
|
+
|
|
77
|
+
// ---------------------------------------------------------------------------
|
|
78
|
+
// Gate-function indirection (T15 — Model 4 testability seam)
|
|
79
|
+
//
|
|
80
|
+
// ESM module bindings are read-only, so a test cannot replace
|
|
81
|
+
// `enforceVerificationGate` / `validatePlan` / `runSelfCheck` on the source
|
|
82
|
+
// module to drive the execution-fail branch deterministically. To exercise the
|
|
83
|
+
// "gate threw a non-Violation → degrade to advisory" path (contract §4 Model 4
|
|
84
|
+
// row 2) we route the three gate calls through a single mutable registry
|
|
85
|
+
// (`_gateFns`) and expose a test-only `_setGateFnsForTest({...})` /
|
|
86
|
+
// `_resetGateFnsForTest()` pair.
|
|
87
|
+
//
|
|
88
|
+
// PRODUCTION callers always go through the live registry — `_gateFns.foo(...)`
|
|
89
|
+
// reads the current binding on every call, so tests can swap it in/out around
|
|
90
|
+
// a single assertion without leaking state across tests. The default values
|
|
91
|
+
// are the real gate functions; tests restore the defaults in their `finally`.
|
|
92
|
+
//
|
|
93
|
+
// This is the same advisory-seam pattern used by `_resetRecordViolationDedup`
|
|
94
|
+
// in verification-gate.js — internal, documented, test-only.
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
const _gateFns = {
|
|
98
|
+
enforceVerificationGate: _realEnforceVerificationGate,
|
|
99
|
+
validatePlan: _realValidatePlan,
|
|
100
|
+
runSelfCheck: _realRunSelfCheck,
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Replace one or more gate functions. Test-only — production code MUST NOT
|
|
105
|
+
* call this. The previous values are NOT stacked; callers are responsible for
|
|
106
|
+
* restoring with `_resetGateFnsForTest()` in a `finally`.
|
|
107
|
+
*
|
|
108
|
+
* @param {{enforceVerificationGate?: Function, validatePlan?: Function, runSelfCheck?: Function}} overrides
|
|
109
|
+
* @internal
|
|
110
|
+
*/
|
|
111
|
+
export function _setGateFnsForTest(overrides) {
|
|
112
|
+
if (overrides && typeof overrides === 'object') {
|
|
113
|
+
if (typeof overrides.enforceVerificationGate === 'function') {
|
|
114
|
+
_gateFns.enforceVerificationGate = overrides.enforceVerificationGate;
|
|
115
|
+
}
|
|
116
|
+
if (typeof overrides.validatePlan === 'function') {
|
|
117
|
+
_gateFns.validatePlan = overrides.validatePlan;
|
|
118
|
+
}
|
|
119
|
+
if (typeof overrides.runSelfCheck === 'function') {
|
|
120
|
+
_gateFns.runSelfCheck = overrides.runSelfCheck;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Restore every gate function to its real implementation. Test-only.
|
|
127
|
+
* @internal
|
|
128
|
+
*/
|
|
129
|
+
export function _resetGateFnsForTest() {
|
|
130
|
+
_gateFns.enforceVerificationGate = _realEnforceVerificationGate;
|
|
131
|
+
_gateFns.validatePlan = _realValidatePlan;
|
|
132
|
+
_gateFns.runSelfCheck = _realRunSelfCheck;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// Constants
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
/** waveId / subagentId safe-token shape (contract §7 wave.get). */
|
|
140
|
+
const ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
|
|
141
|
+
|
|
142
|
+
/** Env escape hatch for the gate subsystem (Model 4 MCP-unavailable row). */
|
|
143
|
+
const GATE_BYPASS = process.env.IJFW_STATE_GATE_BYPASS === '1';
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Lock-acquisition tuning (T3). `staleMs` is the window after which a holder
|
|
147
|
+
* that has STOPPED refreshing (a crashed process) is reclaimed; `heartbeatMs`
|
|
148
|
+
* is well under it so a *live* long-running verb always renews its lock before
|
|
149
|
+
* a concurrent caller's stale check fires. `acquireTimeoutMs` is generous so a
|
|
150
|
+
* legitimate queue of concurrent verbs all get their turn rather than throwing
|
|
151
|
+
* `FsLockBusyError` under a burst.
|
|
152
|
+
*/
|
|
153
|
+
const LOCK_OPTS = {
|
|
154
|
+
staleMs: 10_000,
|
|
155
|
+
heartbeatMs: 2_000,
|
|
156
|
+
acquireTimeoutMs: 30_000,
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// Physical-path resolvers — every canonical state file from contract §1.
|
|
161
|
+
// The SDK introduces NO new file locations; these mirror the table verbatim.
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
const paths = {
|
|
165
|
+
workflow: (root) => join(root, '.ijfw', 'state', 'workflow.json'),
|
|
166
|
+
waves: (root) => join(root, '.ijfw', 'state', 'waves.json'),
|
|
167
|
+
intentJournal: (root) => join(root, '.ijfw', 'state', 'intent-journal.jsonl'),
|
|
168
|
+
waveDir: (root, waveId) => join(root, '.ijfw', `wave-${waveId}`),
|
|
169
|
+
waveState: (root, waveId) => join(root, '.ijfw', `wave-${waveId}`, 'STATE.md'),
|
|
170
|
+
checkpoint: (root, waveId, subId) =>
|
|
171
|
+
join(root, '.ijfw', `wave-${waveId}`, `subagent-${subId}.checkpoint.json`),
|
|
172
|
+
eventLog: (root, waveId, subId) =>
|
|
173
|
+
join(root, '.ijfw', `wave-${waveId}`, `events-${subId}.jsonl`),
|
|
174
|
+
decisions: (root) => join(root, '.ijfw', 'blackboard', 'decisions.jsonl'),
|
|
175
|
+
telemetry: (root) => join(root, '.ijfw', 'telemetry', 'convergence.json'),
|
|
176
|
+
teamWorkflow: (root) => join(root, '.ijfw', 'team', 'workflow.json'),
|
|
177
|
+
teamCharter: (root) => join(root, '.ijfw', 'team', 'charter.json'),
|
|
178
|
+
activeExtension: (home) => join(home, '.ijfw', 'state', 'active-extension.json'),
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
// T3 / T4 / T5 SEAMS — deliberately thin pass-throughs.
|
|
183
|
+
//
|
|
184
|
+
// These exist so the cross-cutting tasks have a single, well-named place to
|
|
185
|
+
// hook in. T2 must NOT implement locking / journaling / events itself.
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Lock-acquisition seam (T3) + journal-begin source of truth (T4 — issue 2).
|
|
190
|
+
*
|
|
191
|
+
* `lockTargets` is the canonical-sorted list of physical files a verb mutates.
|
|
192
|
+
* It is the verb's SINGLE declaration of its target set — `_withLocks` both
|
|
193
|
+
* acquires the locks from it AND (for a mutating verb) writes the write-ahead
|
|
194
|
+
* `begin` record + rollback snapshot from the SAME list. There is no second
|
|
195
|
+
* place that re-derives a verb's targets.
|
|
196
|
+
*
|
|
197
|
+
* `_withLocks` routes the list through `canonicalLockOrder` (defense in depth)
|
|
198
|
+
* so the acquire-order is always the STATE-SDK-CONTRACT §3 coarse-to-fine
|
|
199
|
+
* order regardless of caller input, then acquires every lock coarse-to-fine
|
|
200
|
+
* and releases in reverse by NESTING `withFsLock` calls — the innermost call
|
|
201
|
+
* runs `fn`, and the `finally` unwind of each `withFsLock` releases in exact
|
|
202
|
+
* reverse order. Because the acquire-order is total and deterministic, two
|
|
203
|
+
* verbs touching an overlapping file set can never form a lock-ordering cycle
|
|
204
|
+
* → the SDK is deadlock-free by construction.
|
|
205
|
+
*
|
|
206
|
+
* JOURNAL-BEGIN (T4): when `env` carries `{ isMutating:true }`, `_withLocks`
|
|
207
|
+
* runs `_journalBegin` AFTER acquiring the intent-journal lock but BEFORE `fn`
|
|
208
|
+
* — write-ahead by construction. The real journal targets are derived from
|
|
209
|
+
* `lockTargets` minus the intent-journal path itself (infrastructure, never a
|
|
210
|
+
* verb target). The resulting handle is stashed on `env.journalHandle` for the
|
|
211
|
+
* dispatcher's `_journalCommit`.
|
|
212
|
+
*
|
|
213
|
+
* Locks are heartbeat-refreshed (`LOCK_OPTS.heartbeatMs`) so a long-running
|
|
214
|
+
* verb is never wrongly reclaimed; a crashed holder still ages out at
|
|
215
|
+
* `LOCK_OPTS.staleMs`. No subprocess is spawned anywhere inside the lock
|
|
216
|
+
* (the verb core does no spawning — confirmed for T3).
|
|
217
|
+
*
|
|
218
|
+
* @param {string[]} lockTargets physical paths the verb mutates (any order)
|
|
219
|
+
* @param {() => Promise<T>} fn the verb's critical section
|
|
220
|
+
* @param {object} [env] per-invocation env — when mutating, carries verbId /
|
|
221
|
+
* verb / dedupKey / payloadDigest / isMutating /
|
|
222
|
+
* appendVerb; `_withLocks` writes `journalHandle` back.
|
|
223
|
+
* @returns {Promise<T>}
|
|
224
|
+
* @template T
|
|
225
|
+
*/
|
|
226
|
+
async function _withLocks(lockTargets, fn, env) {
|
|
227
|
+
const declared = Array.isArray(lockTargets) ? lockTargets : [];
|
|
228
|
+
const ordered = canonicalLockOrder(declared);
|
|
229
|
+
if (ordered.length === 0) {
|
|
230
|
+
// No file targets → no locks. A mutating verb with no file targets still
|
|
231
|
+
// needs a journal begin/commit pair so it is replay-classifiable; the
|
|
232
|
+
// dispatcher handles that case (env.journalHandle stays null here).
|
|
233
|
+
return fn();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Recursively nest withFsLock: acquire ordered[0] coarse-to-fine; the
|
|
237
|
+
// innermost frame runs `fn`. Each withFsLock's release fires on unwind, so
|
|
238
|
+
// locks release in exact reverse order automatically. For a mutating verb,
|
|
239
|
+
// immediately after the intent-journal lock (always ordered[0] — §3 #1) is
|
|
240
|
+
// held we run `_journalBegin`: write-ahead, and from the verb's OWN target
|
|
241
|
+
// list — never a re-derived one.
|
|
242
|
+
const journalAbs = ordered[0]; // §3 #1 — intent-journal is always first.
|
|
243
|
+
const realTargets = ordered.filter((t) => t !== journalAbs);
|
|
244
|
+
|
|
245
|
+
const acquireFrom = async (index) => {
|
|
246
|
+
if (index >= ordered.length) return fn();
|
|
247
|
+
return withFsLock(
|
|
248
|
+
lockPathFor(ordered[index]),
|
|
249
|
+
async () => {
|
|
250
|
+
// Just inside the intent-journal lock, before any other lock or `fn`:
|
|
251
|
+
// write the write-ahead begin record from this verb's real targets.
|
|
252
|
+
if (index === 0 && env && env.isMutating && !env.journalHandle) {
|
|
253
|
+
env.journalHandle = await _journalBegin({
|
|
254
|
+
root: env.root,
|
|
255
|
+
verb: env.verb,
|
|
256
|
+
verbId: env.verbId,
|
|
257
|
+
dedupKey: env.dedupKey,
|
|
258
|
+
payloadDigest: env.payloadDigest,
|
|
259
|
+
targets: realTargets,
|
|
260
|
+
// Append/dedupKey verbs are NOT snapshot-rolled-back (§4) — skip
|
|
261
|
+
// capturing a snapshot we would never restore.
|
|
262
|
+
snapshot: !env.appendVerb,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
return acquireFrom(index + 1);
|
|
266
|
+
},
|
|
267
|
+
LOCK_OPTS,
|
|
268
|
+
);
|
|
269
|
+
};
|
|
270
|
+
return acquireFrom(0);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Relative-path form for a journal `targets[]` entry. Project-scope files are
|
|
275
|
+
* rendered relative to `projectRoot` (the §4 example shape — e.g.
|
|
276
|
+
* `.ijfw/wave-W12-A/STATE.md`). The homedir active-extension file lives on a
|
|
277
|
+
* different filesystem root, so it cannot be made relative — it is recorded by
|
|
278
|
+
* its absolute path. Replay reconstructs `absPath` from the snapshot sidecar
|
|
279
|
+
* regardless, so the journal `targets[]` form is purely informational.
|
|
280
|
+
*/
|
|
281
|
+
function relForJournal(root, abs) {
|
|
282
|
+
const prefix = root.endsWith('/') ? root : `${root}/`;
|
|
283
|
+
return abs.startsWith(prefix) ? abs.slice(prefix.length) : abs;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
// T4 — Intent / commit journal (STATE-SDK-CONTRACT §4, CROSS-CUTTING MODEL 2).
|
|
288
|
+
//
|
|
289
|
+
// Every mutating verb writes a write-ahead `begin` record to
|
|
290
|
+
// `.ijfw/state/intent-journal.jsonl` BEFORE touching any target file and a
|
|
291
|
+
// `commit` record AFTER the atomic rename(s) succeed.
|
|
292
|
+
//
|
|
293
|
+
// SINGLE SOURCE OF TRUTH FOR A VERB'S TARGET SET (T4 spec-review issue 2):
|
|
294
|
+
// `_withLocks(targets, fn, env)` is the ONE place a verb's target list is
|
|
295
|
+
// known. The handler passes its real, canonical-sorted target list there to
|
|
296
|
+
// acquire locks; `_withLocks` *reuses that exact list* to write the `begin`
|
|
297
|
+
// record and capture the rollback snapshot. There is NO second switch that
|
|
298
|
+
// re-derives targets — a handler that changes its target set changes it in
|
|
299
|
+
// exactly one place (its own `_withLocks` call), and journaling follows for
|
|
300
|
+
// free. `_withLocks` runs strictly BEFORE `fn` (the mutation) and is
|
|
301
|
+
// write-ahead by construction; the dispatcher's `_journalCommit` runs after
|
|
302
|
+
// the handler returns, reading the handle `_withLocks` stashed on `env`.
|
|
303
|
+
//
|
|
304
|
+
// Rollback source: alongside the `begin` record, `_journalBegin` captures a
|
|
305
|
+
// pre-write snapshot of every target file into a per-verbId sidecar at
|
|
306
|
+
// `.ijfw/state/intent-snapshots/<verbId>.json`. `_journalCommit` deletes the
|
|
307
|
+
// sidecar (the write is durable — nothing to roll back). `state.replay` reads
|
|
308
|
+
// a partial's sidecar to restore its targets. Append/dedupKey verbs do NOT
|
|
309
|
+
// capture a snapshot (see ROLLBACK MODEL below).
|
|
310
|
+
//
|
|
311
|
+
// ROLLBACK MODEL — by verb kind (T4 spec-review issue 4):
|
|
312
|
+
// * Overwrite / read-modify-write verbs (no dedupKey — workflow.set-phase,
|
|
313
|
+
// wave.advance, phase.*, extension.set-active, …): a begin-without-commit
|
|
314
|
+
// partial is snapshot-rolled-back by `state.replay`. The whole target is
|
|
315
|
+
// restored to its pre-begin content.
|
|
316
|
+
// * Append / dedupKey verbs (wave.record-task, subagent.checkpoint,
|
|
317
|
+
// event.emit, telemetry.record, roster.record, decision.add, blocker.add,
|
|
318
|
+
// blocker.resolve): a partial append is LEFT IN PLACE. The append is
|
|
319
|
+
// durable and the `dedupKey` makes the caller's retry a no-op (§4) — so
|
|
320
|
+
// snapshot-rollback would only DESTROY a durably-committed record. Replay
|
|
321
|
+
// seals the partial with a commit marker; it never reverts the file.
|
|
322
|
+
// Append verbs therefore capture no snapshot at all.
|
|
323
|
+
//
|
|
324
|
+
// CRASH-SAFETY — honest scope (T4 spec-review issue 3): the LIVE double-call
|
|
325
|
+
// fast path is atomic — a verb's target-log dedup scan and its mutation run
|
|
326
|
+
// inside the verb's own §3 critical section. The `begin` record and the
|
|
327
|
+
// `commit` record, however, are written by TWO separate intent-journal lock
|
|
328
|
+
// acquisitions with the handler running between them; a crash can land in
|
|
329
|
+
// that window. That is precisely what `state.replay` reconciles — replay-level
|
|
330
|
+
// recovery is BEST-EFFORT across a crash, and the journal is the authority for
|
|
331
|
+
// it. No comment here claims a single-critical-section guarantee that does not
|
|
332
|
+
// exist; the design is a write-ahead log + replay, not a two-phase commit.
|
|
333
|
+
// ---------------------------------------------------------------------------
|
|
334
|
+
|
|
335
|
+
/** Snapshot-sidecar directory for in-flight (begin-but-not-commit) verbs. */
|
|
336
|
+
function snapshotDir(root) {
|
|
337
|
+
return join(root, '.ijfw', 'state', 'intent-snapshots');
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Snapshot-sidecar path for one verb invocation. */
|
|
341
|
+
function snapshotPath(root, verbId) {
|
|
342
|
+
return join(snapshotDir(root), `${verbId}.json`);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* LOCKING NOTE — `_journalBegin` runs INSIDE the §3 intent-journal lock that
|
|
347
|
+
* `_withLocks` already holds (it is the verb's outermost lock, §3 #1). It must
|
|
348
|
+
* NOT re-acquire that lock — `withFsLock` is a non-re-entrant `mkdir`-based
|
|
349
|
+
* mutex, so a nested acquire on the same path would deadlock against itself.
|
|
350
|
+
* `_journalBegin` is therefore lock-free by contract: its sole caller is
|
|
351
|
+
* `_withLocks`, immediately after the intent-journal lock is held.
|
|
352
|
+
*
|
|
353
|
+
* `_journalCommit` runs at the DISPATCHER level, strictly AFTER the handler
|
|
354
|
+
* has returned and released ALL §3 locks (including the intent-journal lock).
|
|
355
|
+
* It therefore acquires the intent-journal lock itself — no nesting, no
|
|
356
|
+
* re-entry. begin (inside the handler's journal lock) → handler → commit
|
|
357
|
+
* (its own fresh journal lock) is a sequential chain across TWO separate lock
|
|
358
|
+
* acquisitions; the window between them is reconciled by `state.replay`, not
|
|
359
|
+
* eliminated (see CRASH-SAFETY note above — this is a WAL + replay design).
|
|
360
|
+
*/
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Intent-journal `begin` writer (T4). LOCK-FREE — the caller (`_withLocks`)
|
|
364
|
+
* already holds the intent-journal lock. For overwrite verbs, captures a
|
|
365
|
+
* pre-write snapshot sidecar of every target; for append verbs
|
|
366
|
+
* (`record.snapshot === false`) it captures NOTHING (a partial append is
|
|
367
|
+
* never snapshot-rolled-back — §4). Then appends the `begin` record. Returns a
|
|
368
|
+
* handle the matching `_journalCommit` consumes.
|
|
369
|
+
*
|
|
370
|
+
* @param {{root:string, verb:string, verbId:string, dedupKey?:string, targets:string[], payloadDigest:string, snapshot:boolean}} record
|
|
371
|
+
* `targets` is the absolute-path list the verb mutates (the
|
|
372
|
+
* intent-journal file itself is excluded — it is infrastructure).
|
|
373
|
+
* `snapshot` — false for append/dedupKey verbs (no rollback snapshot).
|
|
374
|
+
* @returns {Promise<object>} journal handle — `{ begun, root, verbId, ... }`
|
|
375
|
+
*/
|
|
376
|
+
async function _journalBegin(record) {
|
|
377
|
+
const {
|
|
378
|
+
root, verb, verbId, dedupKey, targets, snapshot,
|
|
379
|
+
} = record;
|
|
380
|
+
const journal = paths.intentJournal(root);
|
|
381
|
+
const relTargets = [];
|
|
382
|
+
if (snapshot) {
|
|
383
|
+
const snapTargets = [];
|
|
384
|
+
for (const abs of targets) {
|
|
385
|
+
const rel = relForJournal(root, abs);
|
|
386
|
+
relTargets.push(rel);
|
|
387
|
+
// Pre-write snapshot: content + existence — rollback restores-or-deletes.
|
|
388
|
+
if (existsSync(abs)) {
|
|
389
|
+
snapTargets.push({
|
|
390
|
+
relPath: rel, absPath: abs, existed: true,
|
|
391
|
+
content: readFileSync(abs, 'utf8'),
|
|
392
|
+
});
|
|
393
|
+
} else {
|
|
394
|
+
snapTargets.push({ relPath: rel, absPath: abs, existed: false, content: null });
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
// Snapshot sidecar is written BEFORE the begin record: if we crash between
|
|
398
|
+
// the two, replay sees no begin record and treats the verb as never-started
|
|
399
|
+
// (the orphan sidecar is harmless — `state.validate` ignores it).
|
|
400
|
+
ensureDir(snapshotDir(root));
|
|
401
|
+
writeAtomic(snapshotPath(root, verbId), JSON.stringify({ verbId, targets: snapTargets }));
|
|
402
|
+
} else {
|
|
403
|
+
// Append/dedupKey verb — no snapshot. The begin record still lists the
|
|
404
|
+
// real targets so the journal stays a complete record of intent.
|
|
405
|
+
for (const abs of targets) relTargets.push(relForJournal(root, abs));
|
|
406
|
+
}
|
|
407
|
+
const begin = {
|
|
408
|
+
verb, verbId, phase: 'begin', ts: nowIso(), targets: relTargets,
|
|
409
|
+
payloadDigest: record.payloadDigest,
|
|
410
|
+
};
|
|
411
|
+
if (typeof dedupKey === 'string' && dedupKey) begin.dedupKey = dedupKey;
|
|
412
|
+
// `kind` lets `state.replay` decide rollback vs seal-only without re-deriving
|
|
413
|
+
// verb taxonomy — the begin record is self-describing.
|
|
414
|
+
begin.kind = snapshot ? 'overwrite' : 'append';
|
|
415
|
+
ensureDir(join(journal, '..'));
|
|
416
|
+
appendFileSync(journal, `${JSON.stringify(begin)}\n`, { mode: 0o600 });
|
|
417
|
+
return {
|
|
418
|
+
begun: true, root, verbId, verb, dedupKey, snapshot,
|
|
419
|
+
payloadDigest: record.payloadDigest,
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Intent-journal `commit` seam (T4 — IMPLEMENTED). Runs at the dispatcher
|
|
425
|
+
* level AFTER the handler released all §3 locks — so it acquires the
|
|
426
|
+
* intent-journal lock itself (no nesting, no re-entry). Appends the `commit`
|
|
427
|
+
* record (durable-applied marker) and deletes the now-redundant snapshot
|
|
428
|
+
* sidecar (overwrite verbs only — append verbs never wrote one).
|
|
429
|
+
*
|
|
430
|
+
* @param {object} handle the handle returned by `_journalBegin`
|
|
431
|
+
*/
|
|
432
|
+
async function _journalCommit(handle) {
|
|
433
|
+
if (!handle || !handle.begun) return;
|
|
434
|
+
const {
|
|
435
|
+
root, verbId, verb, dedupKey, snapshot,
|
|
436
|
+
} = handle;
|
|
437
|
+
const journal = paths.intentJournal(root);
|
|
438
|
+
await withFsLock(lockPathFor(journal), async () => {
|
|
439
|
+
const commit = {
|
|
440
|
+
verb, verbId, phase: 'commit', ts: nowIso(),
|
|
441
|
+
payloadDigest: handle.payloadDigest,
|
|
442
|
+
kind: snapshot ? 'overwrite' : 'append',
|
|
443
|
+
};
|
|
444
|
+
if (typeof dedupKey === 'string' && dedupKey) commit.dedupKey = dedupKey;
|
|
445
|
+
appendFileSync(journal, `${JSON.stringify(commit)}\n`, { mode: 0o600 });
|
|
446
|
+
// The write is durable — the snapshot is no longer needed for rollback.
|
|
447
|
+
// Append verbs never captured one; the unlink is a harmless no-op for them.
|
|
448
|
+
if (snapshot) {
|
|
449
|
+
try { const s = snapshotPath(root, verbId); if (existsSync(s)) unlinkSync(s); }
|
|
450
|
+
catch { /* best-effort; a stale sidecar of a committed verb is harmless */ }
|
|
451
|
+
}
|
|
452
|
+
}, LOCK_OPTS);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Event-emit seam (T5 — IMPLEMENTED). Fire-and-forget, AFTER lock release
|
|
457
|
+
* (Model 3). Distinct from the `event.emit` *verb* — the verb is a
|
|
458
|
+
* caller-facing journaled append that acquires its own §3 lock; this seam is
|
|
459
|
+
* the implicit per-query observability tap that fires for EVERY verb dispatch
|
|
460
|
+
* (read + mutating). The dispatcher invokes this AFTER the handler returns
|
|
461
|
+
* and AFTER all §3 locks are released — see the dispatcher's call sites.
|
|
462
|
+
*
|
|
463
|
+
* The tap takes NO §3 lock and is serialized per-log-path by an in-process
|
|
464
|
+
* Promise-chain mutex inside `state-events.emitEvent`. Errors are swallowed
|
|
465
|
+
* (logged to stderr in the impl); a tap failure NEVER propagates to the
|
|
466
|
+
* caller. This call returns immediately — the underlying append happens on
|
|
467
|
+
* the microtask queue but is not awaited by the dispatcher.
|
|
468
|
+
*
|
|
469
|
+
* @param {{verb:string, subagentId:string, ts:string, verbId:string,
|
|
470
|
+
* outcome:string, payloadDigest:string,
|
|
471
|
+
* projectRoot:string, waveId?:string}} event
|
|
472
|
+
*/
|
|
473
|
+
function _emitEvent(event) {
|
|
474
|
+
if (!event || !event.projectRoot) return;
|
|
475
|
+
// Fire-and-forget: do NOT await. `emitEvent` swallows its own errors so
|
|
476
|
+
// an unhandled rejection cannot escape here either.
|
|
477
|
+
emitEventToLog({
|
|
478
|
+
projectRoot: event.projectRoot,
|
|
479
|
+
waveId: event.waveId,
|
|
480
|
+
subagentId: event.subagentId,
|
|
481
|
+
verb: event.verb,
|
|
482
|
+
verbId: event.verbId,
|
|
483
|
+
outcome: event.outcome,
|
|
484
|
+
payloadDigest: event.payloadDigest,
|
|
485
|
+
ts: event.ts,
|
|
486
|
+
}).catch(() => { /* impossible — emitEvent swallows; belt-and-suspenders */ });
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// ---------------------------------------------------------------------------
|
|
490
|
+
// Internal I/O helpers — every verb routes file I/O through these so T3 has a
|
|
491
|
+
// single chokepoint to wrap and the atomic-write contract is enforced in one
|
|
492
|
+
// place (no handler ever calls fs.writeFile on a final path directly).
|
|
493
|
+
// ---------------------------------------------------------------------------
|
|
494
|
+
|
|
495
|
+
/** Read + JSON-parse a file; returns `fallback` when absent/unparseable. */
|
|
496
|
+
function readJson(path, fallback = null) {
|
|
497
|
+
const r = readSafe(path);
|
|
498
|
+
return r.ok ? r.data : fallback;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** Atomic JSON write (tmp-write + fsync + rename via atomic-io). */
|
|
502
|
+
function writeJson(path, obj) {
|
|
503
|
+
return writeAtomic(path, JSON.stringify(obj, null, 2));
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/** Ensure a directory exists (0o700 — matches atomic-io / fs-lock posture). */
|
|
507
|
+
function ensureDir(dir) {
|
|
508
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
/**
|
|
512
|
+
* Append one JSONL line. Rotates first when over the 4 MiB ceiling
|
|
513
|
+
* (jsonl-rotation `DEFAULT_ROTATE_SIZE`). Not idempotent on its own — callers
|
|
514
|
+
* supply a `dedupKey` and pre-check via `jsonlHasDedupKey` (Model 2).
|
|
515
|
+
*/
|
|
516
|
+
function appendJsonl(path, obj) {
|
|
517
|
+
ensureDir(join(path, '..'));
|
|
518
|
+
rotateJsonlIfNeeded(path);
|
|
519
|
+
appendFileSync(path, `${JSON.stringify(obj)}\n`, { mode: 0o600 });
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/** Read a JSONL file into an array of parsed objects (skips blank/bad lines). */
|
|
523
|
+
function readJsonl(path) {
|
|
524
|
+
if (!existsSync(path)) return [];
|
|
525
|
+
const out = [];
|
|
526
|
+
for (const line of readFileSync(path, 'utf8').split('\n')) {
|
|
527
|
+
const t = line.trim();
|
|
528
|
+
if (!t) continue;
|
|
529
|
+
try { out.push(JSON.parse(t)); } catch { /* skip a corrupt line */ }
|
|
530
|
+
}
|
|
531
|
+
return out;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** True when any record in the JSONL log already carries `dedupKey`. */
|
|
535
|
+
function jsonlHasDedupKey(path, dedupKey) {
|
|
536
|
+
return readJsonl(path).some((rec) => rec && rec.dedupKey === dedupKey);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Cross-rotation dedup helper for the `event.emit` verb. Scans the MOST
|
|
541
|
+
* RECENT `.jsonl.gz` archive sibling of `path` (produced by
|
|
542
|
+
* `jsonl-rotation.js` — date-stamped `<stem>.<date>[.<n>].jsonl.gz`) for a
|
|
543
|
+
* record carrying `dedupKey`. Returns the matched record (so the caller can
|
|
544
|
+
* surface its `seq`) or `null` when no archive exists / no match. Scope is
|
|
545
|
+
* intentionally bounded to the newest archive only — that matches normal-
|
|
546
|
+
* operation rotation windows and avoids unbounded gzip-decompression on every
|
|
547
|
+
* `event.emit` call. Errors (corrupt archive, gunzip failure, missing dir)
|
|
548
|
+
* are swallowed and treated as "no match" — the live-file scan is the
|
|
549
|
+
* authoritative path; the archive scan is best-effort cross-rotation safety.
|
|
550
|
+
*/
|
|
551
|
+
function findDedupKeyInNewestArchive(path, dedupKey) {
|
|
552
|
+
try {
|
|
553
|
+
const dir = dirname(path);
|
|
554
|
+
if (!existsSync(dir)) return null;
|
|
555
|
+
const base = basename(path);
|
|
556
|
+
const stem = base.endsWith('.jsonl') ? base.slice(0, -'.jsonl'.length) : base;
|
|
557
|
+
const archives = readdirSync(dir)
|
|
558
|
+
.filter((n) => n.startsWith(`${stem}.`) && n.endsWith('.jsonl.gz'))
|
|
559
|
+
.sort();
|
|
560
|
+
if (archives.length === 0) return null;
|
|
561
|
+
const newest = archives[archives.length - 1]; // lexicographic == chronological
|
|
562
|
+
const raw = gunzipSync(readFileSync(join(dir, newest))).toString('utf8');
|
|
563
|
+
for (const line of raw.split('\n')) {
|
|
564
|
+
const t = line.trim();
|
|
565
|
+
if (!t) continue;
|
|
566
|
+
let rec;
|
|
567
|
+
try { rec = JSON.parse(t); } catch { continue; }
|
|
568
|
+
if (rec && rec.dedupKey === dedupKey) return rec;
|
|
569
|
+
}
|
|
570
|
+
return null;
|
|
571
|
+
} catch {
|
|
572
|
+
return null;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Canonical JSON serialization — recursive, key-sorted. The digest must be
|
|
578
|
+
* STABLE across process restarts (replay safety: a verb is recognized as
|
|
579
|
+
* already-committed by digest). Plain `JSON.stringify` preserves key INSERTION
|
|
580
|
+
* order, so two payloads with the same content but different key order would
|
|
581
|
+
* hash differently and a replay would wrongly re-apply. This sorts every
|
|
582
|
+
* object's keys recursively so the canonical form is content-addressable.
|
|
583
|
+
*
|
|
584
|
+
* Arrays are NOT reordered — array element order is meaningful data.
|
|
585
|
+
* `undefined` collapses to `null` (matches JSON's own value space).
|
|
586
|
+
*/
|
|
587
|
+
function canonicalJson(value) {
|
|
588
|
+
if (value === undefined || value === null) return 'null';
|
|
589
|
+
if (typeof value === 'number') {
|
|
590
|
+
return Number.isFinite(value) ? JSON.stringify(value) : 'null';
|
|
591
|
+
}
|
|
592
|
+
if (typeof value === 'boolean' || typeof value === 'string') {
|
|
593
|
+
return JSON.stringify(value);
|
|
594
|
+
}
|
|
595
|
+
if (Array.isArray(value)) {
|
|
596
|
+
return `[${value.map((v) => canonicalJson(v === undefined ? null : v)).join(',')}]`;
|
|
597
|
+
}
|
|
598
|
+
if (typeof value === 'object') {
|
|
599
|
+
const keys = Object.keys(value).sort();
|
|
600
|
+
const parts = [];
|
|
601
|
+
for (const k of keys) {
|
|
602
|
+
if (value[k] === undefined) continue; // JSON.stringify drops these too
|
|
603
|
+
parts.push(`${JSON.stringify(k)}:${canonicalJson(value[k])}`);
|
|
604
|
+
}
|
|
605
|
+
return `{${parts.join(',')}}`;
|
|
606
|
+
}
|
|
607
|
+
// function / symbol / bigint — not valid JSON; collapse to null.
|
|
608
|
+
return 'null';
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* sha256-<hex> digest of the CANONICAL-JSON payload (intent/event records).
|
|
613
|
+
* Deterministic regardless of payload key insertion order — see `canonicalJson`.
|
|
614
|
+
* Exported so T4's idempotency suite can assert cross-run digest stability.
|
|
615
|
+
*/
|
|
616
|
+
export function payloadDigest(payload) {
|
|
617
|
+
return `sha256-${createHash('sha256').update(canonicalJson(payload)).digest('hex')}`;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// --- STATE.md (YAML frontmatter + md body) flat read/write ------------------
|
|
621
|
+
// A self-contained flat-YAML subset — string/number/boolean/string[]. The SDK
|
|
622
|
+
// is a facade: this matches wave-state.js's on-disk format exactly so a wave
|
|
623
|
+
// written by either surface round-trips through the other.
|
|
624
|
+
|
|
625
|
+
function parseFrontmatter(raw) {
|
|
626
|
+
if (!raw.startsWith('---')) {
|
|
627
|
+
throw new Error('state-sdk: STATE.md missing YAML frontmatter');
|
|
628
|
+
}
|
|
629
|
+
const end = raw.indexOf('\n---', 3);
|
|
630
|
+
if (end === -1) throw new Error('state-sdk: STATE.md has unclosed frontmatter');
|
|
631
|
+
const block = raw.slice(4, end);
|
|
632
|
+
const body = raw.slice(end + 4).replace(/^\n+/, '');
|
|
633
|
+
const fm = {};
|
|
634
|
+
const lines = block.split('\n');
|
|
635
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
636
|
+
const line = lines[i];
|
|
637
|
+
if (line.trim() === '' || line.trimStart().startsWith('#')) continue;
|
|
638
|
+
const c = line.indexOf(':');
|
|
639
|
+
if (c === -1) continue;
|
|
640
|
+
const key = line.slice(0, c).trim();
|
|
641
|
+
const rest = line.slice(c + 1).trim();
|
|
642
|
+
if (!key) continue;
|
|
643
|
+
if (rest === '') {
|
|
644
|
+
// block sequence (" - item" lines) or empty
|
|
645
|
+
const seq = [];
|
|
646
|
+
let j = i + 1;
|
|
647
|
+
while (j < lines.length && lines[j].trimStart().startsWith('- ')) {
|
|
648
|
+
seq.push(lines[j].replace(/^\s*-\s?/, ''));
|
|
649
|
+
j += 1;
|
|
650
|
+
}
|
|
651
|
+
if (seq.length) { fm[key] = seq; i = j - 1; } else { fm[key] = null; }
|
|
652
|
+
} else if (rest.startsWith('[')) {
|
|
653
|
+
const inner = rest.replace(/^\[/, '').replace(/\]$/, '');
|
|
654
|
+
fm[key] = inner ? inner.split(',').map((s) => s.trim().replace(/^['"]|['"]$/g, '')) : [];
|
|
655
|
+
} else if (rest === 'true') { fm[key] = true; }
|
|
656
|
+
else if (rest === 'false') { fm[key] = false; }
|
|
657
|
+
else if (rest === 'null' || rest === '~') { fm[key] = null; }
|
|
658
|
+
else if (rest !== '' && !Number.isNaN(Number(rest))) { fm[key] = Number(rest); }
|
|
659
|
+
else { fm[key] = rest.replace(/^['"]|['"]$/g, ''); }
|
|
660
|
+
}
|
|
661
|
+
return { frontmatter: fm, body };
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
function emitFrontmatter(obj) {
|
|
665
|
+
const lines = [];
|
|
666
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
667
|
+
if (v === null || v === undefined) lines.push(`${k}: null`);
|
|
668
|
+
else if (Array.isArray(v)) {
|
|
669
|
+
if (v.length === 0) lines.push(`${k}: []`);
|
|
670
|
+
else { lines.push(`${k}:`); for (const it of v) lines.push(` - ${it}`); }
|
|
671
|
+
} else if (typeof v === 'object') {
|
|
672
|
+
throw new Error(`state-sdk: nested YAML not supported (key "${k}")`);
|
|
673
|
+
} else lines.push(`${k}: ${v}`);
|
|
674
|
+
}
|
|
675
|
+
return lines.join('\n');
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/** Read a wave STATE.md → { frontmatter, body, raw } | null when absent. */
|
|
679
|
+
function readWaveStateFile(root, waveId) {
|
|
680
|
+
const p = paths.waveState(root, waveId);
|
|
681
|
+
if (!existsSync(p)) return null;
|
|
682
|
+
const raw = readFileSync(p, 'utf8');
|
|
683
|
+
const { frontmatter, body } = parseFrontmatter(raw);
|
|
684
|
+
return { frontmatter, body, raw };
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
/** Atomically write a wave STATE.md from { frontmatter, body }. */
|
|
688
|
+
function writeWaveStateFile(root, waveId, frontmatter, body) {
|
|
689
|
+
ensureDir(paths.waveDir(root, waveId));
|
|
690
|
+
const raw = `---\n${emitFrontmatter(frontmatter)}\n---\n\n${body || ''}`;
|
|
691
|
+
writeAtomic(paths.waveState(root, waveId), raw);
|
|
692
|
+
return { frontmatter, body: body || '', raw };
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// ---------------------------------------------------------------------------
|
|
696
|
+
// Context / payload validation
|
|
697
|
+
// ---------------------------------------------------------------------------
|
|
698
|
+
|
|
699
|
+
function requireRoot(ctx) {
|
|
700
|
+
if (!ctx || typeof ctx.projectRoot !== 'string' || ctx.projectRoot.length === 0) {
|
|
701
|
+
throw new Error('state-sdk: ctx.projectRoot is required');
|
|
702
|
+
}
|
|
703
|
+
return ctx.projectRoot;
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
function requireId(value, field) {
|
|
707
|
+
if (typeof value !== 'string' || !ID_RE.test(value)) {
|
|
708
|
+
throw new Error(`state-sdk: ${field} must match ${ID_RE} (got ${JSON.stringify(value)})`);
|
|
709
|
+
}
|
|
710
|
+
return value;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function requireStr(value, field) {
|
|
714
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
715
|
+
throw new Error(`state-sdk: ${field} is required (non-empty string)`);
|
|
716
|
+
}
|
|
717
|
+
return value;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
function nowIso() { return new Date().toISOString(); }
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* v1.5.1 cleanup C1 — S08 worktree-guard preconditions for `subagent.dispatch`.
|
|
724
|
+
*
|
|
725
|
+
* Runs the three incident-driven guards (worktree-guards.js) against the
|
|
726
|
+
* project root a worktree-isolated subagent is about to be dispatched into:
|
|
727
|
+
* #3099 — assertPathWithinToplevel (abs-path / symlink escape)
|
|
728
|
+
* #3097 — assertNoCwdDrift (cwd drifted off the toplevel)
|
|
729
|
+
* #2924 — assertNotProtectedRef (HEAD on main/master/develop/…)
|
|
730
|
+
*
|
|
731
|
+
* Semantics — `subagent.dispatch` is a brief-COMPOSITION verb (it produces a
|
|
732
|
+
* deterministic dispatch brief; the real spawn happens platform-side). The
|
|
733
|
+
* guards are therefore PRECONDITIONS surfaced on the result, not a hard abort
|
|
734
|
+
* of brief composition:
|
|
735
|
+
* - Project root IS a git repo → all 3 guards run. A failure is reported on
|
|
736
|
+
* `guard.violations[]` and `guard.ok=false`. With IJFW_WORKTREE_GUARD_STRICT=1
|
|
737
|
+
* a violation throws (aborts the dispatch before `_withLocks`).
|
|
738
|
+
* - Project root is NOT a git repo (or guards can't run) → `guard.ok` stays
|
|
739
|
+
* true with `guard.skipped` set; brief composition proceeds. This keeps the
|
|
740
|
+
* verb usable from non-git temp dirs (tests, scratch projects).
|
|
741
|
+
*
|
|
742
|
+
* Only `'worktree'` isolation is guarded — `'shared'` dispatch spawns no
|
|
743
|
+
* separate worktree so containment/drift/protected-ref don't apply.
|
|
744
|
+
*
|
|
745
|
+
* @param {string} projectRoot absolute path the subagent dispatches into
|
|
746
|
+
* @param {'shared'|'worktree'} isolation
|
|
747
|
+
* @returns {{ok:boolean, skipped?:string, violations:string[], branch?:string}}
|
|
748
|
+
* @throws {Error} only when IJFW_WORKTREE_GUARD_STRICT=1 and a guard fails
|
|
749
|
+
*/
|
|
750
|
+
function runWorktreeGuards(projectRoot, isolation) {
|
|
751
|
+
if (isolation !== 'worktree') {
|
|
752
|
+
return { ok: true, skipped: 'shared-isolation', violations: [] };
|
|
753
|
+
}
|
|
754
|
+
// Quiet git-repo pre-check — captureSpawnToplevel would otherwise let git's
|
|
755
|
+
// own "fatal: not a git repository" hit our stderr on non-git roots (common
|
|
756
|
+
// in tests / scratch projects). Suppress git stderr here, then run the real
|
|
757
|
+
// guards only when we know we're inside a work tree.
|
|
758
|
+
try {
|
|
759
|
+
const probe = execFileSync(
|
|
760
|
+
'git', ['rev-parse', '--is-inside-work-tree'],
|
|
761
|
+
{ cwd: projectRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
|
|
762
|
+
).trim();
|
|
763
|
+
if (probe !== 'true') {
|
|
764
|
+
return { ok: true, skipped: 'not-a-git-repo', violations: [] };
|
|
765
|
+
}
|
|
766
|
+
} catch {
|
|
767
|
+
// Not a git tree — nothing to contain. Brief composition still proceeds.
|
|
768
|
+
return { ok: true, skipped: 'not-a-git-repo', violations: [] };
|
|
769
|
+
}
|
|
770
|
+
let toplevel;
|
|
771
|
+
try {
|
|
772
|
+
toplevel = captureSpawnToplevel(projectRoot);
|
|
773
|
+
} catch {
|
|
774
|
+
return { ok: true, skipped: 'not-a-git-repo', violations: [] };
|
|
775
|
+
}
|
|
776
|
+
const violations = [];
|
|
777
|
+
let branch;
|
|
778
|
+
try {
|
|
779
|
+
assertPathWithinToplevel(projectRoot, toplevel);
|
|
780
|
+
} catch (e) {
|
|
781
|
+
violations.push(`path-escape: ${e.message}`);
|
|
782
|
+
}
|
|
783
|
+
try {
|
|
784
|
+
assertNoCwdDrift(toplevel, projectRoot);
|
|
785
|
+
} catch (e) {
|
|
786
|
+
violations.push(`cwd-drift: ${e.message}`);
|
|
787
|
+
}
|
|
788
|
+
try {
|
|
789
|
+
branch = assertNotProtectedRef(projectRoot);
|
|
790
|
+
} catch (e) {
|
|
791
|
+
violations.push(`protected-ref: ${e.message}`);
|
|
792
|
+
}
|
|
793
|
+
const ok = violations.length === 0;
|
|
794
|
+
if (!ok && process.env.IJFW_WORKTREE_GUARD_STRICT === '1') {
|
|
795
|
+
throw new Error(
|
|
796
|
+
`state-sdk: subagent.dispatch S08 worktree guard failed — ${violations.join('; ')}`,
|
|
797
|
+
);
|
|
798
|
+
}
|
|
799
|
+
return ok
|
|
800
|
+
? { ok: true, violations: [], branch }
|
|
801
|
+
: { ok: false, violations };
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
// ---------------------------------------------------------------------------
|
|
805
|
+
// VERB HANDLERS — one per contract §7 block. Signature: (payload, ctx, env).
|
|
806
|
+
// `env` carries the per-invocation { verbId } so handlers can stamp records.
|
|
807
|
+
// Read verbs return the documented shape; write verbs create-or-refuse Day-1.
|
|
808
|
+
// ---------------------------------------------------------------------------
|
|
809
|
+
|
|
810
|
+
const handlers = {
|
|
811
|
+
// --- workflow.get — read, Day-1 no-op ------------------------------------
|
|
812
|
+
async 'workflow.get'(_payload, ctx) {
|
|
813
|
+
const root = requireRoot(ctx);
|
|
814
|
+
const workflow = readJson(paths.workflow(root), null);
|
|
815
|
+
return { ok: true, workflow };
|
|
816
|
+
},
|
|
817
|
+
|
|
818
|
+
// --- workflow.set-phase — write, Day-1 create ----------------------------
|
|
819
|
+
async 'workflow.set-phase'(payload, ctx, env) {
|
|
820
|
+
const root = requireRoot(ctx);
|
|
821
|
+
const phase = requireStr(payload?.phase, 'phase');
|
|
822
|
+
const file = paths.workflow(root);
|
|
823
|
+
const targets = [paths.intentJournal(root), file];
|
|
824
|
+
return _withLocks(targets, async () => {
|
|
825
|
+
const current = readJson(file, {}) || {};
|
|
826
|
+
const next = { ...current, phase, updated_at: nowIso() };
|
|
827
|
+
next.status = payload.status ?? current.status ?? 'in_progress';
|
|
828
|
+
if (payload.milestone !== undefined) next.milestone = payload.milestone;
|
|
829
|
+
if (payload.version !== undefined) next.version = payload.version;
|
|
830
|
+
writeJson(file, next);
|
|
831
|
+
return { ok: true, workflow: next };
|
|
832
|
+
}, env);
|
|
833
|
+
},
|
|
834
|
+
|
|
835
|
+
// --- wave.get — read, Day-1 no-op ----------------------------------------
|
|
836
|
+
async 'wave.get'(payload, ctx) {
|
|
837
|
+
const root = requireRoot(ctx);
|
|
838
|
+
const waveId = requireId(payload?.waveId, 'waveId');
|
|
839
|
+
return { ok: true, wave: readWaveStateFile(root, waveId) };
|
|
840
|
+
},
|
|
841
|
+
|
|
842
|
+
// --- wave.advance — write, Day-1 create, gate=checkpoint-completeness ----
|
|
843
|
+
// v1.5.0 T18 (W3 mid-wave boundary): when the wave declares a hard gate
|
|
844
|
+
// — either via `payload.hardGate: true` on the call, or via the wave's
|
|
845
|
+
// persisted frontmatter (`hard_gate: true`) — we run a checkpoint-
|
|
846
|
+
// completeness pre-lock check: every subagent on the wave's roster MUST
|
|
847
|
+
// have a checkpoint file at `paths.checkpoint(root, waveId, subId)`.
|
|
848
|
+
// Missing checkpoints → verdict-fail → REFUSE (no state mutation, no
|
|
849
|
+
// journal commit). Advisory-by-default: when no hard gate is declared the
|
|
850
|
+
// current behavior is preserved verbatim. `GATE_BYPASS` downgrades a would-
|
|
851
|
+
// be refusal to a loud advisory — enforcement is a floor, never a single
|
|
852
|
+
// point of failure (contract §4 MCP-unavailable row).
|
|
853
|
+
async 'wave.advance'(payload, ctx, env) {
|
|
854
|
+
const root = requireRoot(ctx);
|
|
855
|
+
const waveId = requireId(payload?.waveId, 'waveId');
|
|
856
|
+
const status = requireStr(payload?.status, 'status');
|
|
857
|
+
|
|
858
|
+
// Pre-lock hard-gate decision. The wave's hard_gate declaration is
|
|
859
|
+
// persisted in its frontmatter so a downstream advance call (which may
|
|
860
|
+
// not re-pass `hardGate`) still honors it. An explicit `payload.hardGate`
|
|
861
|
+
// wins when supplied — that's how a caller arms the gate on first write.
|
|
862
|
+
const existingPreGate = readWaveStateFile(root, waveId);
|
|
863
|
+
const hardGate = payload?.hardGate === true
|
|
864
|
+
|| existingPreGate?.frontmatter?.hard_gate === true;
|
|
865
|
+
|
|
866
|
+
if (hardGate) {
|
|
867
|
+
// Compute the roster the gate measures. We honor an explicit
|
|
868
|
+
// `payload.requiredSubagents` (whitelist) so a caller can scope the
|
|
869
|
+
// check to the subset that should hold checkpoints at THIS advance;
|
|
870
|
+
// otherwise the wave's registered roster (from subagent.dispatch) is
|
|
871
|
+
// the source of truth.
|
|
872
|
+
const roster = Array.isArray(payload?.requiredSubagents)
|
|
873
|
+
? payload.requiredSubagents.filter((s) => typeof s === 'string' && s)
|
|
874
|
+
: (Array.isArray(existingPreGate?.frontmatter?.subagents)
|
|
875
|
+
? existingPreGate.frontmatter.subagents
|
|
876
|
+
: []);
|
|
877
|
+
const missing = [];
|
|
878
|
+
for (const subId of roster) {
|
|
879
|
+
if (!existsSync(paths.checkpoint(root, waveId, subId))) {
|
|
880
|
+
missing.push(subId);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
if (missing.length > 0) {
|
|
884
|
+
const reason = `wave.advance hard-gate: ${missing.length} subagent(s) `
|
|
885
|
+
+ `lack a checkpoint (${missing.join(', ')})`;
|
|
886
|
+
if (!GATE_BYPASS) {
|
|
887
|
+
// Verdict-fail → REFUSE. Pre-`_withLocks` early-return guarantees
|
|
888
|
+
// no state mutation, no journal begin/commit pair.
|
|
889
|
+
return {
|
|
890
|
+
ok: false, refused: true, gate: 'wave-advance-hard',
|
|
891
|
+
reason, missing,
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
// Bypass masks a would-be refusal — loud WARN + advisory result so
|
|
895
|
+
// the operator can see what enforcement skipped.
|
|
896
|
+
process.stderr.write(
|
|
897
|
+
'[state-sdk] WARN wave.advance gate bypassed via IJFW_STATE_GATE_BYPASS '
|
|
898
|
+
+ `(would-refuse: ${reason})\n`,
|
|
899
|
+
);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
const targets = [
|
|
904
|
+
paths.intentJournal(root), paths.waves(root), paths.waveState(root, waveId),
|
|
905
|
+
];
|
|
906
|
+
return _withLocks(targets, async () => {
|
|
907
|
+
const existing = readWaveStateFile(root, waveId);
|
|
908
|
+
const fm = {
|
|
909
|
+
...existing?.frontmatter,
|
|
910
|
+
wave_id: waveId,
|
|
911
|
+
status,
|
|
912
|
+
created_at: existing?.frontmatter?.created_at ?? nowIso(),
|
|
913
|
+
updated_at: nowIso(),
|
|
914
|
+
};
|
|
915
|
+
if (payload.frontmatter && typeof payload.frontmatter === 'object') {
|
|
916
|
+
for (const [k, v] of Object.entries(payload.frontmatter)) fm[k] = v;
|
|
917
|
+
}
|
|
918
|
+
// Persist the hard-gate declaration on the wave's frontmatter so
|
|
919
|
+
// subsequent advance calls honor it without re-passing `hardGate`.
|
|
920
|
+
if (payload?.hardGate === true) fm.hard_gate = true;
|
|
921
|
+
const wave = writeWaveStateFile(root, waveId, fm, existing?.body ?? '');
|
|
922
|
+
const result = { ok: true, wave };
|
|
923
|
+
if (hardGate && GATE_BYPASS) {
|
|
924
|
+
result.advisory = true;
|
|
925
|
+
result.gate = 'wave-advance-hard';
|
|
926
|
+
result.reason = 'IJFW_STATE_GATE_BYPASS=1';
|
|
927
|
+
}
|
|
928
|
+
return result;
|
|
929
|
+
}, env);
|
|
930
|
+
},
|
|
931
|
+
|
|
932
|
+
// --- wave.record-task — append, Day-1 create, dedupKey -------------------
|
|
933
|
+
async 'wave.record-task'(payload, ctx, env) {
|
|
934
|
+
const root = requireRoot(ctx);
|
|
935
|
+
const waveId = requireId(payload?.waveId, 'waveId');
|
|
936
|
+
const taskId = requireStr(payload?.taskId, 'taskId');
|
|
937
|
+
const status = requireStr(payload?.status, 'status');
|
|
938
|
+
const dedupKey = requireStr(payload?.dedupKey, 'dedupKey');
|
|
939
|
+
const targets = [paths.intentJournal(root), paths.waveState(root, waveId)];
|
|
940
|
+
return _withLocks(targets, async () => {
|
|
941
|
+
const existing = readWaveStateFile(root, waveId);
|
|
942
|
+
const tasks = Array.isArray(existing?.frontmatter?.tasks)
|
|
943
|
+
? [...existing.frontmatter.tasks] : [];
|
|
944
|
+
// Tasks are recorded as "taskId:status:dedupKey" — flat-YAML-safe.
|
|
945
|
+
if (tasks.some((t) => t.endsWith(`:${dedupKey}`))) {
|
|
946
|
+
const wave = existing ?? readWaveStateFile(root, waveId);
|
|
947
|
+
return { ok: true, wave, deduped: true };
|
|
948
|
+
}
|
|
949
|
+
tasks.push(`${taskId}:${status}:${dedupKey}`);
|
|
950
|
+
const fm = {
|
|
951
|
+
...existing?.frontmatter,
|
|
952
|
+
wave_id: waveId,
|
|
953
|
+
created_at: existing?.frontmatter?.created_at ?? nowIso(),
|
|
954
|
+
updated_at: nowIso(),
|
|
955
|
+
tasks,
|
|
956
|
+
};
|
|
957
|
+
if (existing?.frontmatter?.status === undefined) fm.status = 'in_progress';
|
|
958
|
+
const wave = writeWaveStateFile(root, waveId, fm, existing?.body ?? '');
|
|
959
|
+
return { ok: true, wave, deduped: false };
|
|
960
|
+
}, env);
|
|
961
|
+
},
|
|
962
|
+
|
|
963
|
+
// --- phase.plan-check — write, Day-1 refuse, gate=validatePlan -----------
|
|
964
|
+
async 'phase.plan-check'(payload, ctx, env) {
|
|
965
|
+
const root = requireRoot(ctx);
|
|
966
|
+
let planText = payload?.planText;
|
|
967
|
+
if (typeof planText !== 'string') {
|
|
968
|
+
const planPath = payload?.planPath;
|
|
969
|
+
if (typeof planPath !== 'string' || planPath.length === 0) {
|
|
970
|
+
throw new Error('state-sdk: phase.plan-check needs planPath or planText');
|
|
971
|
+
}
|
|
972
|
+
const abs = isAbsolute(planPath) ? planPath : join(root, planPath);
|
|
973
|
+
if (!existsSync(abs)) {
|
|
974
|
+
return { ok: false, refused: true, gate: 'plan-check', reason: 'plan-not-found' };
|
|
975
|
+
}
|
|
976
|
+
planText = readFileSync(abs, 'utf8');
|
|
977
|
+
}
|
|
978
|
+
// Model 4: gate-fail → refuse; gate threw → advisory (proceed).
|
|
979
|
+
// GATE_BYPASS (MCP-unavailable row of §4) short-circuits the gate to
|
|
980
|
+
// advisory and writes a loud WARN — enforcement is a floor, never a
|
|
981
|
+
// single point of failure.
|
|
982
|
+
if (GATE_BYPASS) {
|
|
983
|
+
process.stderr.write('[state-sdk] WARN phase.plan-check gate bypassed via IJFW_STATE_GATE_BYPASS\n');
|
|
984
|
+
const file = paths.workflow(root);
|
|
985
|
+
const targets = [paths.intentJournal(root), file];
|
|
986
|
+
return _withLocks(targets, async () => {
|
|
987
|
+
const current = readJson(file, {}) || {};
|
|
988
|
+
current.plan_check = {
|
|
989
|
+
verdict: 'bypass', phaseId: payload?.phaseId ?? null, checked_at: nowIso(),
|
|
990
|
+
};
|
|
991
|
+
writeJson(file, current);
|
|
992
|
+
return {
|
|
993
|
+
ok: true, advisory: true, gate: 'plan-check',
|
|
994
|
+
reason: 'IJFW_STATE_GATE_BYPASS=1', findings: [],
|
|
995
|
+
};
|
|
996
|
+
}, env);
|
|
997
|
+
}
|
|
998
|
+
let result;
|
|
999
|
+
try {
|
|
1000
|
+
result = _gateFns.validatePlan(planText, { strict: true });
|
|
1001
|
+
} catch (e) {
|
|
1002
|
+
process.stderr.write(`[state-sdk] WARN phase.plan-check gate execution-fail: ${e.message}\n`);
|
|
1003
|
+
return { ok: true, advisory: true, gate: 'plan-check', reason: e.message, findings: [] };
|
|
1004
|
+
}
|
|
1005
|
+
// v1.5.0 T17 (W1 plan-check hard-BLOCK): structurally REFUSE on any
|
|
1006
|
+
// HIGH-tier finding (severity in {BLOCK, HIGH} per `isHighFinding`).
|
|
1007
|
+
// This is the dispatch-blocking precondition — execute cannot proceed.
|
|
1008
|
+
// We don't rely on `result.ok` alone: even if a future `validatePlan`
|
|
1009
|
+
// regression set `ok:true` while a HIGH-tier finding slipped into the
|
|
1010
|
+
// list, the gate stays correct. Pre-`_withLocks` early-return guarantees
|
|
1011
|
+
// NO state mutation (no intent-journal append, no workflow.json write).
|
|
1012
|
+
const highFindings = Array.isArray(result.findings)
|
|
1013
|
+
? result.findings.filter(isHighFinding)
|
|
1014
|
+
: [];
|
|
1015
|
+
if (!result.ok || highFindings.length > 0) {
|
|
1016
|
+
return {
|
|
1017
|
+
ok: false, refused: true, gate: 'plan-check',
|
|
1018
|
+
findings: result.findings, reason: 'plan-check HIGH finding',
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
// Clean plan: record the verdict on the workflow object.
|
|
1022
|
+
const file = paths.workflow(root);
|
|
1023
|
+
const targets = [paths.intentJournal(root), file];
|
|
1024
|
+
return _withLocks(targets, async () => {
|
|
1025
|
+
const current = readJson(file, {}) || {};
|
|
1026
|
+
current.plan_check = {
|
|
1027
|
+
verdict: 'pass', phaseId: payload?.phaseId ?? null, checked_at: nowIso(),
|
|
1028
|
+
};
|
|
1029
|
+
writeJson(file, current);
|
|
1030
|
+
return { ok: true, findings: result.findings, verdict: 'pass' };
|
|
1031
|
+
}, env);
|
|
1032
|
+
},
|
|
1033
|
+
|
|
1034
|
+
// --- phase.complete — write, Day-1 create, gate=verification ------------
|
|
1035
|
+
async 'phase.complete'(payload, ctx, env) {
|
|
1036
|
+
const root = requireRoot(ctx);
|
|
1037
|
+
const phase = requireStr(payload?.phase, 'phase');
|
|
1038
|
+
const ev = payload?.evidence || {};
|
|
1039
|
+
// Model 4: verdict-fail → refuse; execution-fail / MCP-unavailable →
|
|
1040
|
+
// advisory (proceed). GATE_BYPASS short-circuits to advisory.
|
|
1041
|
+
let gateAdvisory = null;
|
|
1042
|
+
if (!GATE_BYPASS) {
|
|
1043
|
+
try {
|
|
1044
|
+
_gateFns.enforceVerificationGate(
|
|
1045
|
+
typeof ev.reportText === 'string' ? ev.reportText : '',
|
|
1046
|
+
Array.isArray(ev.toolCalls) ? ev.toolCalls : [],
|
|
1047
|
+
{ strict: true },
|
|
1048
|
+
);
|
|
1049
|
+
} catch (e) {
|
|
1050
|
+
if (e instanceof VerificationGateViolation) {
|
|
1051
|
+
return {
|
|
1052
|
+
ok: false, refused: true, gate: 'verification',
|
|
1053
|
+
reason: e.message,
|
|
1054
|
+
};
|
|
1055
|
+
}
|
|
1056
|
+
// Gate itself threw — execution-fail → degrade to advisory.
|
|
1057
|
+
process.stderr.write(`[state-sdk] WARN phase.complete gate execution-fail: ${e.message}\n`);
|
|
1058
|
+
gateAdvisory = e.message;
|
|
1059
|
+
}
|
|
1060
|
+
} else {
|
|
1061
|
+
gateAdvisory = 'IJFW_STATE_GATE_BYPASS=1';
|
|
1062
|
+
process.stderr.write('[state-sdk] WARN phase.complete gate bypassed via IJFW_STATE_GATE_BYPASS\n');
|
|
1063
|
+
}
|
|
1064
|
+
const file = paths.workflow(root);
|
|
1065
|
+
const targets = [paths.intentJournal(root), file];
|
|
1066
|
+
return _withLocks(targets, async () => {
|
|
1067
|
+
const current = readJson(file, {}) || {};
|
|
1068
|
+
const next = {
|
|
1069
|
+
...current, phase, status: 'complete', updated_at: nowIso(),
|
|
1070
|
+
};
|
|
1071
|
+
writeJson(file, next);
|
|
1072
|
+
if (gateAdvisory) {
|
|
1073
|
+
return { ok: true, advisory: true, gate: 'verification', reason: gateAdvisory, workflow: next };
|
|
1074
|
+
}
|
|
1075
|
+
return { ok: true, workflow: next };
|
|
1076
|
+
}, env);
|
|
1077
|
+
},
|
|
1078
|
+
|
|
1079
|
+
// --- subagent.dispatch — write, Day-1 create -----------------------------
|
|
1080
|
+
//
|
|
1081
|
+
// v1.5.0 T19 (G1 — subagent event stream + dispatch verb): produces a
|
|
1082
|
+
// DETERMINISTIC dispatch brief that bakes in the SDK contract — the
|
|
1083
|
+
// env-var passthrough (`IJFW_PROJECT_DIR`, `IJFW_SESSION_ID`,
|
|
1084
|
+
// `IJFW_PARENT_PROJECT_ROOT`, `IJFW_WAVE_ID`, `IJFW_SUBAGENT_ID`,
|
|
1085
|
+
// `IJFW_ISOLATION`) PLUS any caller-supplied env keys. The brief is
|
|
1086
|
+
// platform-agnostic markdown: on Claude the orchestrator hands it to the
|
|
1087
|
+
// native subagent primitive (deterministic execution); elsewhere it is
|
|
1088
|
+
// pasted into a prompt template (best-effort — recorded in the T16
|
|
1089
|
+
// enforcement matrix). Parent observes subagent progress via the event
|
|
1090
|
+
// log -- each verb the subagent dispatches fires `_emitEvent` (T5),
|
|
1091
|
+
// streamed by `pollEvents` from state-events.js.
|
|
1092
|
+
async 'subagent.dispatch'(payload, ctx, env) {
|
|
1093
|
+
const root = requireRoot(ctx);
|
|
1094
|
+
const subagentId = requireId(payload?.subagentId, 'subagentId');
|
|
1095
|
+
const waveId = requireId(payload?.waveId, 'waveId');
|
|
1096
|
+
const brief = requireStr(payload?.brief, 'brief');
|
|
1097
|
+
const isolation = payload?.isolation === 'shared' ? 'shared' : 'worktree';
|
|
1098
|
+
const role = typeof payload?.role === 'string' && payload.role.length > 0
|
|
1099
|
+
? payload.role : null;
|
|
1100
|
+
// v1.5.1 cleanup C1 — S08 worktree-guard preconditions. Runs the three
|
|
1101
|
+
// incident-driven guards BEFORE the wave-state mutation when isolation is
|
|
1102
|
+
// 'worktree'. This is the production caller worktree-guards.js was missing
|
|
1103
|
+
// (its only prior importer was the unwired runtime-loop.js). With
|
|
1104
|
+
// IJFW_WORKTREE_GUARD_STRICT=1 a guard violation throws here, aborting the
|
|
1105
|
+
// dispatch before `_withLocks`; otherwise it is surfaced on the result.
|
|
1106
|
+
const worktreeGuard = runWorktreeGuards(root, isolation);
|
|
1107
|
+
// Caller-supplied env passthrough (object: name → value). Coerce values to
|
|
1108
|
+
// strings (env vars are always strings) and drop nullish entries.
|
|
1109
|
+
const callerEnv = (payload?.env && typeof payload.env === 'object'
|
|
1110
|
+
&& !Array.isArray(payload.env)) ? payload.env : {};
|
|
1111
|
+
|
|
1112
|
+
// SDK env-var contract — the deterministic set every subagent inherits.
|
|
1113
|
+
// The parent's process env is the source of truth for IJFW_SESSION_ID /
|
|
1114
|
+
// IJFW_PARENT_PROJECT_ROOT (when the orchestrator runs inside a wrapper
|
|
1115
|
+
// that already set them); the verb derives the rest from its own payload.
|
|
1116
|
+
const sdkContractEnv = {
|
|
1117
|
+
IJFW_PROJECT_DIR: root,
|
|
1118
|
+
IJFW_PARENT_PROJECT_ROOT: process.env.IJFW_PARENT_PROJECT_ROOT || root,
|
|
1119
|
+
IJFW_WAVE_ID: waveId,
|
|
1120
|
+
IJFW_SUBAGENT_ID: subagentId,
|
|
1121
|
+
IJFW_ISOLATION: isolation,
|
|
1122
|
+
};
|
|
1123
|
+
if (typeof process.env.IJFW_SESSION_ID === 'string' && process.env.IJFW_SESSION_ID) {
|
|
1124
|
+
sdkContractEnv.IJFW_SESSION_ID = process.env.IJFW_SESSION_ID;
|
|
1125
|
+
}
|
|
1126
|
+
// Merge: caller env overrides SDK contract on duplicate keys (caller's
|
|
1127
|
+
// intent wins). Coerce all values to strings, drop null/undefined.
|
|
1128
|
+
const inheritedEnv = { ...sdkContractEnv };
|
|
1129
|
+
for (const [k, v] of Object.entries(callerEnv)) {
|
|
1130
|
+
if (v === null || v === undefined) continue;
|
|
1131
|
+
inheritedEnv[k] = String(v);
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
const targets = [paths.intentJournal(root), paths.waveState(root, waveId)];
|
|
1135
|
+
return _withLocks(targets, async () => {
|
|
1136
|
+
// Register the subagent on the wave STATE.md.
|
|
1137
|
+
const existing = readWaveStateFile(root, waveId);
|
|
1138
|
+
const roster = Array.isArray(existing?.frontmatter?.subagents)
|
|
1139
|
+
? [...existing.frontmatter.subagents] : [];
|
|
1140
|
+
if (!roster.includes(subagentId)) roster.push(subagentId);
|
|
1141
|
+
const fm = {
|
|
1142
|
+
...existing?.frontmatter,
|
|
1143
|
+
wave_id: waveId,
|
|
1144
|
+
status: existing?.frontmatter?.status ?? 'in_progress',
|
|
1145
|
+
created_at: existing?.frontmatter?.created_at ?? nowIso(),
|
|
1146
|
+
updated_at: nowIso(),
|
|
1147
|
+
subagents: roster,
|
|
1148
|
+
};
|
|
1149
|
+
writeWaveStateFile(root, waveId, fm, existing?.body ?? '');
|
|
1150
|
+
// `mode` is deterministic on Claude (real subagent primitive),
|
|
1151
|
+
// prompt-template elsewhere. T16 owns the per-platform matrix; the
|
|
1152
|
+
// verb core picks deterministic when a Claude subagent context is set.
|
|
1153
|
+
const mode = ctx?.platform === 'claude' || ctx?.subagentId
|
|
1154
|
+
? 'deterministic' : 'prompt-template';
|
|
1155
|
+
// Compose the deterministic dispatch brief — markdown, platform-
|
|
1156
|
+
// agnostic. The subagent reads this verbatim; SDK env vars are listed
|
|
1157
|
+
// explicitly so a best-effort prompt-template platform can paste them
|
|
1158
|
+
// into its shell-export preamble.
|
|
1159
|
+
const envLines = Object.keys(inheritedEnv).sort()
|
|
1160
|
+
.map((k) => ` ${k}=${inheritedEnv[k]}`);
|
|
1161
|
+
const eventLogPath = resolveEventLogPath(root, waveId, subagentId);
|
|
1162
|
+
const eventLogRel = eventLogPath.startsWith(root + '/')
|
|
1163
|
+
? eventLogPath.slice(root.length + 1) : eventLogPath;
|
|
1164
|
+
const dispatchBrief = [
|
|
1165
|
+
`# Subagent dispatch — ${subagentId} (wave ${waveId})`,
|
|
1166
|
+
role ? `Role: ${role}` : null,
|
|
1167
|
+
`Isolation: ${isolation}`,
|
|
1168
|
+
`Event log: ${eventLogRel}`,
|
|
1169
|
+
'',
|
|
1170
|
+
'## Inherited env (SDK contract + caller passthrough)',
|
|
1171
|
+
...envLines,
|
|
1172
|
+
'',
|
|
1173
|
+
'## Brief',
|
|
1174
|
+
brief,
|
|
1175
|
+
].filter((l) => l !== null).join('\n');
|
|
1176
|
+
return {
|
|
1177
|
+
ok: true,
|
|
1178
|
+
dispatchBrief,
|
|
1179
|
+
subagentId,
|
|
1180
|
+
waveId,
|
|
1181
|
+
mode,
|
|
1182
|
+
isolation,
|
|
1183
|
+
inheritedEnv,
|
|
1184
|
+
eventLogPath,
|
|
1185
|
+
// v1.5.1 cleanup C1 — S08 guard outcome. `{ok:true}` when guards passed
|
|
1186
|
+
// or were skipped (non-git root / shared isolation); `{ok:false,
|
|
1187
|
+
// violations[]}` when a containment/drift/protected-ref hazard was
|
|
1188
|
+
// detected (non-strict mode — the orchestrator should not spawn).
|
|
1189
|
+
worktreeGuard,
|
|
1190
|
+
};
|
|
1191
|
+
}, env);
|
|
1192
|
+
},
|
|
1193
|
+
|
|
1194
|
+
// --- subagent.checkpoint — append, Day-1 create, dedupKey ---------------
|
|
1195
|
+
async 'subagent.checkpoint'(payload, ctx, env) {
|
|
1196
|
+
const root = requireRoot(ctx);
|
|
1197
|
+
const waveId = requireId(payload?.waveId, 'waveId');
|
|
1198
|
+
const subagentId = requireId(payload?.subagentId, 'subagentId');
|
|
1199
|
+
const dedupKey = requireStr(payload?.dedupKey, 'dedupKey');
|
|
1200
|
+
if (!payload?.checkpoint || typeof payload.checkpoint !== 'object') {
|
|
1201
|
+
throw new Error('state-sdk: subagent.checkpoint needs a checkpoint object');
|
|
1202
|
+
}
|
|
1203
|
+
const file = paths.checkpoint(root, waveId, subagentId);
|
|
1204
|
+
const targets = [paths.intentJournal(root), file];
|
|
1205
|
+
return _withLocks(targets, async () => {
|
|
1206
|
+
const existing = readJson(file, null);
|
|
1207
|
+
if (existing && existing.dedupKey === dedupKey) {
|
|
1208
|
+
return { ok: true, path: file, deduped: true };
|
|
1209
|
+
}
|
|
1210
|
+
writeJson(file, {
|
|
1211
|
+
waveId, subagentId, dedupKey,
|
|
1212
|
+
checkpoint: payload.checkpoint, updated_at: nowIso(),
|
|
1213
|
+
});
|
|
1214
|
+
return { ok: true, path: file, deduped: false };
|
|
1215
|
+
}, env);
|
|
1216
|
+
},
|
|
1217
|
+
|
|
1218
|
+
// --- subagent.post-done — write, Day-1 create, gate=self-check ----------
|
|
1219
|
+
async 'subagent.post-done'(payload, ctx) {
|
|
1220
|
+
const root = requireRoot(ctx);
|
|
1221
|
+
// requireId is called for its side-effecting validation (throws on bad
|
|
1222
|
+
// input). The actual subagentId is consumed downstream by the gate; the
|
|
1223
|
+
// local binding stays prefixed with `_` to signal "intentionally unused".
|
|
1224
|
+
const _subagentId = requireId(payload?.subagentId, 'subagentId');
|
|
1225
|
+
const reportText = requireStr(payload?.reportText, 'reportText');
|
|
1226
|
+
const projectRoot = typeof payload?.projectRoot === 'string' && payload.projectRoot
|
|
1227
|
+
? payload.projectRoot : root;
|
|
1228
|
+
// Model 4: failed self-check is a verdict-fail → refuse. A thrown
|
|
1229
|
+
// self-check is an execution-fail → advisory (proceed). GATE_BYPASS
|
|
1230
|
+
// (MCP-unavailable row of §4) downgrades a would-be refusal to a loud
|
|
1231
|
+
// advisory — enforcement is a floor, never a single point of failure.
|
|
1232
|
+
let selfCheck;
|
|
1233
|
+
try {
|
|
1234
|
+
selfCheck = _gateFns.runSelfCheck(reportText, projectRoot);
|
|
1235
|
+
} catch (e) {
|
|
1236
|
+
process.stderr.write(`[state-sdk] WARN subagent.post-done gate execution-fail: ${e.message}\n`);
|
|
1237
|
+
return { ok: true, advisory: true, gate: 'post-done-self-check', reason: e.message };
|
|
1238
|
+
}
|
|
1239
|
+
// v1.5.1 cleanup C1 — T20 truncation classification. When the caller hands
|
|
1240
|
+
// us the subagent's `events` stream and/or intent `journal` on the payload,
|
|
1241
|
+
// run `detectTruncation` so a truncated post-DONE is classified on the LIVE
|
|
1242
|
+
// path (ijfw_state MCP tool → query → subagent.post-done). This is the
|
|
1243
|
+
// production caller recovery/truncation.js was missing — its only prior
|
|
1244
|
+
// importer was the unwired runtime-loop.js. Annotation-only: the classifier
|
|
1245
|
+
// never throws and never alters the self-check verdict.
|
|
1246
|
+
let truncation;
|
|
1247
|
+
if (Array.isArray(payload?.events) || Array.isArray(payload?.journal)) {
|
|
1248
|
+
try {
|
|
1249
|
+
const { detectTruncation } = await import('../recovery/truncation.js');
|
|
1250
|
+
const det = detectTruncation({
|
|
1251
|
+
events: payload.events,
|
|
1252
|
+
journal: payload.journal,
|
|
1253
|
+
expectedTerminalVerb: payload.expectedTerminalVerb,
|
|
1254
|
+
});
|
|
1255
|
+
truncation = {
|
|
1256
|
+
truncated: det.truncated,
|
|
1257
|
+
reason: det.reason,
|
|
1258
|
+
};
|
|
1259
|
+
} catch (e) {
|
|
1260
|
+
process.stderr.write(
|
|
1261
|
+
`[state-sdk] WARN subagent.post-done truncation classify failed: ${e.message}\n`,
|
|
1262
|
+
);
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
if (selfCheck.verdict !== 'PASSED') {
|
|
1266
|
+
const reason = `self-check FAILED — ${selfCheck.files_missing.length} missing file(s), `
|
|
1267
|
+
+ `${selfCheck.commits_missing.length} missing commit(s)`;
|
|
1268
|
+
// v1.5.1: LIVE wiring of debug-trident (T29) onto the production
|
|
1269
|
+
// gate-failure path. When the post-done self-check FAILS this is
|
|
1270
|
+
// exactly the stalled-investigation moment the Trident debug loop
|
|
1271
|
+
// exists for — dispatch codex+gemini to generate competing root-cause
|
|
1272
|
+
// hypotheses against the gate-failure evidence. FIRE-AND-FORGET:
|
|
1273
|
+
// `maybeFireDebugTrident` returns immediately, the campaign runs in a
|
|
1274
|
+
// detached promise — the verb's return value + timing are UNCHANGED so
|
|
1275
|
+
// STATE-SDK-CONTRACT §8 (subagent.post-done is a fast read verb) holds.
|
|
1276
|
+
// Env-gated (IJFW_DEBUG_TRIDENT) + silent no-op on missing deps; never
|
|
1277
|
+
// throws. Dynamic import avoids a static require cycle. Mirrors the
|
|
1278
|
+
// A-Mem auto-linker fire-and-forget pattern in memory/fts5.js.
|
|
1279
|
+
try {
|
|
1280
|
+
import('./debug-trident-trigger.js')
|
|
1281
|
+
.then(({ maybeFireDebugTrident }) => {
|
|
1282
|
+
maybeFireDebugTrident({
|
|
1283
|
+
projectRoot,
|
|
1284
|
+
subagentId: _subagentId,
|
|
1285
|
+
reason,
|
|
1286
|
+
reportText,
|
|
1287
|
+
selfCheck,
|
|
1288
|
+
});
|
|
1289
|
+
})
|
|
1290
|
+
.catch((e) => {
|
|
1291
|
+
try {
|
|
1292
|
+
process.stderr.write(
|
|
1293
|
+
`[state-sdk] WARN subagent.post-done debug-trident dispatch failed: ${e.message}\n`,
|
|
1294
|
+
);
|
|
1295
|
+
} catch { /* never throw */ }
|
|
1296
|
+
});
|
|
1297
|
+
} catch { /* fire-and-forget — never alters the verb verdict */ }
|
|
1298
|
+
if (!GATE_BYPASS) {
|
|
1299
|
+
return {
|
|
1300
|
+
ok: false, refused: true, gate: 'post-done-self-check', reason,
|
|
1301
|
+
...(truncation ? { truncation } : {}),
|
|
1302
|
+
};
|
|
1303
|
+
}
|
|
1304
|
+
// Bypass masks a would-be refusal — emit a loud WARN + advisory
|
|
1305
|
+
// result so the operator can see what enforcement skipped.
|
|
1306
|
+
process.stderr.write(
|
|
1307
|
+
'[state-sdk] WARN subagent.post-done gate bypassed via IJFW_STATE_GATE_BYPASS '
|
|
1308
|
+
+ `(would-refuse: ${reason})\n`,
|
|
1309
|
+
);
|
|
1310
|
+
return {
|
|
1311
|
+
ok: true, advisory: true, gate: 'post-done-self-check',
|
|
1312
|
+
reason: 'IJFW_STATE_GATE_BYPASS=1',
|
|
1313
|
+
selfCheck: {
|
|
1314
|
+
claimedPaths: selfCheck.files_claimed,
|
|
1315
|
+
claimedCommits: selfCheck.commits_claimed,
|
|
1316
|
+
verified: false,
|
|
1317
|
+
},
|
|
1318
|
+
...(truncation ? { truncation } : {}),
|
|
1319
|
+
};
|
|
1320
|
+
}
|
|
1321
|
+
return {
|
|
1322
|
+
ok: true,
|
|
1323
|
+
selfCheck: {
|
|
1324
|
+
claimedPaths: selfCheck.files_claimed,
|
|
1325
|
+
claimedCommits: selfCheck.commits_claimed,
|
|
1326
|
+
verified: selfCheck.verdict === 'PASSED',
|
|
1327
|
+
},
|
|
1328
|
+
...(truncation ? { truncation } : {}),
|
|
1329
|
+
};
|
|
1330
|
+
},
|
|
1331
|
+
|
|
1332
|
+
// --- event.emit — append, Day-1 create ----------------------------------
|
|
1333
|
+
// The `event.emit` *verb* is a caller-facing append (distinct from the
|
|
1334
|
+
// implicit per-query observability tap `_emitEvent`, which is the §3 #10
|
|
1335
|
+
// fire-and-forget one). §3 says the event-log entry "appears in the list
|
|
1336
|
+
// only so its relative position is defined if a future verb ever needs it
|
|
1337
|
+
// inline" — `event.emit` is that verb. It acquires the intent-journal lock
|
|
1338
|
+
// (for the §4 begin/commit pair) + the event-log lock so its
|
|
1339
|
+
// read-seq-then-append is atomic; both are released before the handler
|
|
1340
|
+
// returns. T5 fleshes out rotation + the post-lock observability envelope.
|
|
1341
|
+
async 'event.emit'(payload, ctx, env) {
|
|
1342
|
+
const root = requireRoot(ctx);
|
|
1343
|
+
const subagentId = requireId(payload?.subagentId, 'subagentId');
|
|
1344
|
+
const waveId = requireId(payload?.waveId, 'waveId');
|
|
1345
|
+
const eventType = requireStr(payload?.eventType, 'eventType');
|
|
1346
|
+
const dedupKey = requireStr(payload?.dedupKey, 'dedupKey');
|
|
1347
|
+
if (!payload?.data || typeof payload.data !== 'object') {
|
|
1348
|
+
throw new Error('state-sdk: event.emit needs a data object');
|
|
1349
|
+
}
|
|
1350
|
+
const log = paths.eventLog(root, waveId, subagentId);
|
|
1351
|
+
const targets = [paths.intentJournal(root), log];
|
|
1352
|
+
return _withLocks(targets, async () => {
|
|
1353
|
+
// Dedup against any prior record with the same dedupKey -- check the
|
|
1354
|
+
// live file (cheap hot path), then the most-recent archive
|
|
1355
|
+
// (cross-rotation dedup -- a normal-operation rotation must not silently
|
|
1356
|
+
// re-append a record with a previously-seen dedupKey). Scope is limited
|
|
1357
|
+
// to the most-recent archive (not the full archive history); that
|
|
1358
|
+
// matches the contract's append/dedup semantics — recent-enough to cover
|
|
1359
|
+
// a rotation window without scanning unbounded gzip blobs on every emit.
|
|
1360
|
+
const liveRecords = readJsonl(log);
|
|
1361
|
+
const liveDup = liveRecords.find((e) => e && e.dedupKey === dedupKey);
|
|
1362
|
+
if (liveDup) return { ok: true, seq: liveDup.seq, deduped: true };
|
|
1363
|
+
const archiveDup = findDedupKeyInNewestArchive(log, dedupKey);
|
|
1364
|
+
if (archiveDup) return { ok: true, seq: archiveDup.seq, deduped: true };
|
|
1365
|
+
|
|
1366
|
+
// T5: seq is assigned by the shared `state-events` helper so the verb's
|
|
1367
|
+
// seq stream + the dispatcher tap's seq stream are ONE stream, monotonic
|
|
1368
|
+
// across rotation. We are under the §3 event-log lock here, so we use
|
|
1369
|
+
// the under-lock path that bypasses the in-process tap mutex.
|
|
1370
|
+
//
|
|
1371
|
+
// Envelope shape is the §5 base shape (`{seq, verb, subagentId, ts,
|
|
1372
|
+
// verbId, outcome, payloadDigest}`) plus the verb-path-only extension
|
|
1373
|
+
// fields `eventType`, `data`, and `dedupKey`. §5 documents both shapes —
|
|
1374
|
+
// the dispatcher tap leaves `eventType` / `data` undefined; the verb
|
|
1375
|
+
// populates them. `verb` is the literal string `'event.emit'` (not
|
|
1376
|
+
// `eventType`) so consumers can branch on the canonical §5 field.
|
|
1377
|
+
const verbId = env?.verbId || `v-${randomUUID()}`;
|
|
1378
|
+
const record = appendEventUnderHeldLock({
|
|
1379
|
+
path: log,
|
|
1380
|
+
envelope: {
|
|
1381
|
+
verb: 'event.emit',
|
|
1382
|
+
subagentId,
|
|
1383
|
+
ts: nowIso(),
|
|
1384
|
+
verbId,
|
|
1385
|
+
outcome: 'ok',
|
|
1386
|
+
payloadDigest: payloadDigest(payload.data),
|
|
1387
|
+
// Verb-path extension fields (documented in §5 — optional, present
|
|
1388
|
+
// when the writer is the `event.emit` verb; undefined for taps).
|
|
1389
|
+
eventType,
|
|
1390
|
+
data: payload.data,
|
|
1391
|
+
dedupKey,
|
|
1392
|
+
},
|
|
1393
|
+
});
|
|
1394
|
+
return { ok: true, seq: record.seq, deduped: false };
|
|
1395
|
+
}, env);
|
|
1396
|
+
},
|
|
1397
|
+
|
|
1398
|
+
// --- telemetry.record — append, Day-1 create, dedupKey -----------------
|
|
1399
|
+
async 'telemetry.record'(payload, ctx, env) {
|
|
1400
|
+
const root = requireRoot(ctx);
|
|
1401
|
+
const kind = requireStr(payload?.kind, 'kind');
|
|
1402
|
+
const dedupKey = requireStr(payload?.dedupKey, 'dedupKey');
|
|
1403
|
+
if (!payload?.metrics || typeof payload.metrics !== 'object') {
|
|
1404
|
+
throw new Error('state-sdk: telemetry.record needs a metrics object');
|
|
1405
|
+
}
|
|
1406
|
+
const file = paths.telemetry(root);
|
|
1407
|
+
const targets = [paths.intentJournal(root), file];
|
|
1408
|
+
return _withLocks(targets, async () => {
|
|
1409
|
+
const current = readJson(file, null) || { records: [] };
|
|
1410
|
+
if (!Array.isArray(current.records)) current.records = [];
|
|
1411
|
+
if (current.records.some((r) => r && r.dedupKey === dedupKey)) {
|
|
1412
|
+
return { ok: true, telemetry: current, deduped: true };
|
|
1413
|
+
}
|
|
1414
|
+
current.records.push({
|
|
1415
|
+
kind, dedupKey, metrics: payload.metrics, recorded_at: nowIso(),
|
|
1416
|
+
});
|
|
1417
|
+
current.updated_at = nowIso();
|
|
1418
|
+
writeJson(file, current);
|
|
1419
|
+
|
|
1420
|
+
// v1.5.0 memory-moat M3 (INT.3): when kind === 'skill.execution',
|
|
1421
|
+
// additionally sink the structured row into memory.db's skill_telemetry
|
|
1422
|
+
// table so handlePrelude (INT.4) can surface recommended_skills.
|
|
1423
|
+
// Best-effort: failures here never affect the generic telemetry.record
|
|
1424
|
+
// verb result (which has already written its append-only record above).
|
|
1425
|
+
if (kind === 'skill.execution') {
|
|
1426
|
+
try {
|
|
1427
|
+
const { sinkSkillTelemetry } = await import('./skill-telemetry-sink.js');
|
|
1428
|
+
const Database = (await import('better-sqlite3')).default;
|
|
1429
|
+
const { join: joinP } = await import('node:path');
|
|
1430
|
+
const dbPath = joinP(root, '.ijfw', 'index', 'memory.db');
|
|
1431
|
+
const db = new Database(dbPath);
|
|
1432
|
+
try {
|
|
1433
|
+
sinkSkillTelemetry(db, payload);
|
|
1434
|
+
} finally {
|
|
1435
|
+
try { db.close(); } catch { /* best-effort */ }
|
|
1436
|
+
}
|
|
1437
|
+
} catch { /* best-effort sink — never block the generic record path */ }
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
return { ok: true, telemetry: current, deduped: false };
|
|
1441
|
+
}, env);
|
|
1442
|
+
},
|
|
1443
|
+
|
|
1444
|
+
// --- roster.synthesize — read, Day-1 no-op ------------------------------
|
|
1445
|
+
// Pure synthesis: computes a roster from the domain. roster.record persists.
|
|
1446
|
+
// The verb-core ships a built-in default roster per known domain; T25/T26
|
|
1447
|
+
// layer richer domain-template-driven synthesis on top.
|
|
1448
|
+
async 'roster.synthesize'(payload, ctx) {
|
|
1449
|
+
requireRoot(ctx);
|
|
1450
|
+
const domain = requireStr(payload?.domain, 'domain');
|
|
1451
|
+
const DEFAULT_ROSTERS = {
|
|
1452
|
+
software: [
|
|
1453
|
+
{ id: 'architect', role: 'system design', source: 'builtin' },
|
|
1454
|
+
{ id: 'builder', role: 'implementation', source: 'builtin' },
|
|
1455
|
+
{ id: 'reviewer', role: 'code review', source: 'builtin' },
|
|
1456
|
+
],
|
|
1457
|
+
book: [
|
|
1458
|
+
{ id: 'outliner', role: 'structure', source: 'builtin' },
|
|
1459
|
+
{ id: 'writer', role: 'drafting', source: 'builtin' },
|
|
1460
|
+
{ id: 'editor', role: 'revision', source: 'builtin' },
|
|
1461
|
+
],
|
|
1462
|
+
campaign: [
|
|
1463
|
+
{ id: 'strategist', role: 'positioning', source: 'builtin' },
|
|
1464
|
+
{ id: 'copywriter', role: 'messaging', source: 'builtin' },
|
|
1465
|
+
{ id: 'analyst', role: 'measurement', source: 'builtin' },
|
|
1466
|
+
],
|
|
1467
|
+
};
|
|
1468
|
+
const agents = DEFAULT_ROSTERS[domain];
|
|
1469
|
+
if (!agents) {
|
|
1470
|
+
return { ok: false, reason: 'domain-template-missing', domain };
|
|
1471
|
+
}
|
|
1472
|
+
return { ok: true, roster: { domain, agents } };
|
|
1473
|
+
},
|
|
1474
|
+
|
|
1475
|
+
// --- roster.record — append, Day-1 create, dedupKey --------------------
|
|
1476
|
+
async 'roster.record'(payload, ctx, env) {
|
|
1477
|
+
const root = requireRoot(ctx);
|
|
1478
|
+
const dedupKey = requireStr(payload?.dedupKey, 'dedupKey');
|
|
1479
|
+
const roster = payload?.roster;
|
|
1480
|
+
if (!roster || typeof roster !== 'object' || !Array.isArray(roster.agents)) {
|
|
1481
|
+
throw new Error('state-sdk: roster.record needs a roster { domain, agents }');
|
|
1482
|
+
}
|
|
1483
|
+
const file = paths.teamWorkflow(root);
|
|
1484
|
+
// The verb writes BOTH team/workflow.json AND team/charter.json — both are
|
|
1485
|
+
// declared targets so the journal `begin` records the full mutation set.
|
|
1486
|
+
const targets = [paths.intentJournal(root), file, paths.teamCharter(root)];
|
|
1487
|
+
return _withLocks(targets, async () => {
|
|
1488
|
+
const existing = readJson(file, null);
|
|
1489
|
+
if (existing && existing.dedupKey === dedupKey) {
|
|
1490
|
+
return { ok: true, path: file, deduped: true };
|
|
1491
|
+
}
|
|
1492
|
+
ensureDir(join(root, '.ijfw', 'team'));
|
|
1493
|
+
const record = { ...roster, dedupKey, recorded_at: nowIso() };
|
|
1494
|
+
writeJson(file, record);
|
|
1495
|
+
writeJson(paths.teamCharter(root), {
|
|
1496
|
+
domain: roster.domain,
|
|
1497
|
+
agent_count: roster.agents.length,
|
|
1498
|
+
recorded_at: record.recorded_at,
|
|
1499
|
+
});
|
|
1500
|
+
return { ok: true, path: file, deduped: false };
|
|
1501
|
+
}, env);
|
|
1502
|
+
},
|
|
1503
|
+
|
|
1504
|
+
// --- extension.set-active — write, Day-1 create, homedir file ----------
|
|
1505
|
+
// CRITICAL: writes the FLAT consumer-contract shape:
|
|
1506
|
+
// { name, scope, permissions:{ reads, writes }, activated_at,
|
|
1507
|
+
// activated_by_ide?, activated_by_pid?, quotas? }
|
|
1508
|
+
// Five consumers read these fields at the top level — runtime-mediator.js,
|
|
1509
|
+
// extension-permission-check.mjs, dashboard-server.js, dispatch/active-cli.js,
|
|
1510
|
+
// and active-extension-writer.detectCrossIdeDivergence. A wrapped
|
|
1511
|
+
// {manifest, scope, updated_at} shape would fail-closed at the security
|
|
1512
|
+
// boundary in runtime-mediator (returns MALFORMED) on every call. Contract:
|
|
1513
|
+
// .planning/v150-gap-closure/STATE-SDK-CONTRACT.md §7 extension.set-active.
|
|
1514
|
+
async 'extension.set-active'(payload, ctx, env) {
|
|
1515
|
+
requireRoot(ctx);
|
|
1516
|
+
const scope = payload?.scope;
|
|
1517
|
+
if (!['project', 'org', 'user'].includes(scope)) {
|
|
1518
|
+
throw new Error("state-sdk: extension.set-active scope must be 'project'|'org'|'user'");
|
|
1519
|
+
}
|
|
1520
|
+
const home = payload?.homeDir || ctx?.homeDir || homedir();
|
|
1521
|
+
const file = paths.activeExtension(home);
|
|
1522
|
+
const targets = [paths.intentJournal(requireRoot(ctx)), file];
|
|
1523
|
+
return _withLocks(targets, async () => {
|
|
1524
|
+
if (payload?.manifest === null) {
|
|
1525
|
+
// Clear the active extension.
|
|
1526
|
+
try { if (existsSync(file)) unlinkSync(file); } catch { /* best-effort */ }
|
|
1527
|
+
return { ok: true, path: file, cleared: true };
|
|
1528
|
+
}
|
|
1529
|
+
const manifest = payload?.manifest;
|
|
1530
|
+
if (!manifest || typeof manifest !== 'object' || typeof manifest.name !== 'string') {
|
|
1531
|
+
throw new Error('state-sdk: extension.set-active needs a manifest { name, permissions } or null');
|
|
1532
|
+
}
|
|
1533
|
+
// Build the FLAT consumer-contract shape.
|
|
1534
|
+
const perms = manifest.permissions && typeof manifest.permissions === 'object'
|
|
1535
|
+
? manifest.permissions : {};
|
|
1536
|
+
const reads = Array.isArray(perms.reads) ? perms.reads : [];
|
|
1537
|
+
const writes = Array.isArray(perms.writes) ? perms.writes : [];
|
|
1538
|
+
const out = {
|
|
1539
|
+
name: manifest.name,
|
|
1540
|
+
scope,
|
|
1541
|
+
permissions: { reads, writes },
|
|
1542
|
+
activated_at: nowIso(),
|
|
1543
|
+
};
|
|
1544
|
+
// Optional IDE/PID stamping — only when valid.
|
|
1545
|
+
const ideId = payload?.activated_by_ide;
|
|
1546
|
+
if (typeof ideId === 'string' && /^[a-z0-9-]+$/.test(ideId)) {
|
|
1547
|
+
out.activated_by_ide = ideId;
|
|
1548
|
+
const pid = payload?.activated_by_pid;
|
|
1549
|
+
if (typeof pid === 'number' && Number.isFinite(pid) && Number.isInteger(pid) && pid > 0) {
|
|
1550
|
+
out.activated_by_pid = pid;
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
// Optional quotas — copy only positive integer dimensions (matches
|
|
1554
|
+
// active-extension-writer.js semantics so the tier-2 hook can enforce).
|
|
1555
|
+
if (
|
|
1556
|
+
manifest.quotas !== undefined &&
|
|
1557
|
+
manifest.quotas !== null &&
|
|
1558
|
+
typeof manifest.quotas === 'object' &&
|
|
1559
|
+
!Array.isArray(manifest.quotas)
|
|
1560
|
+
) {
|
|
1561
|
+
const cleanQuotas = {};
|
|
1562
|
+
let copied = 0;
|
|
1563
|
+
for (const [k, v] of Object.entries(manifest.quotas)) {
|
|
1564
|
+
if (typeof v === 'number' && Number.isFinite(v) && Number.isInteger(v) && v > 0) {
|
|
1565
|
+
cleanQuotas[k] = v;
|
|
1566
|
+
copied++;
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
if (copied > 0) out.quotas = cleanQuotas;
|
|
1570
|
+
}
|
|
1571
|
+
writeJson(file, out);
|
|
1572
|
+
return { ok: true, path: file };
|
|
1573
|
+
}, env);
|
|
1574
|
+
},
|
|
1575
|
+
|
|
1576
|
+
// --- decision.add — append, Day-1 create, dedupKey --------------------
|
|
1577
|
+
async 'decision.add'(payload, ctx, env) {
|
|
1578
|
+
const root = requireRoot(ctx);
|
|
1579
|
+
const text = requireStr(payload?.text, 'text');
|
|
1580
|
+
const dedupKey = requireStr(payload?.dedupKey, 'dedupKey');
|
|
1581
|
+
const kind = typeof payload?.kind === 'string' && payload.kind ? payload.kind : 'decision';
|
|
1582
|
+
const log = paths.decisions(root);
|
|
1583
|
+
const targets = [paths.intentJournal(root), log];
|
|
1584
|
+
return _withLocks(targets, async () => {
|
|
1585
|
+
if (jsonlHasDedupKey(log, dedupKey)) return { ok: true, deduped: true };
|
|
1586
|
+
appendJsonl(log, { kind, text, dedupKey, ts: nowIso() });
|
|
1587
|
+
return { ok: true, deduped: false };
|
|
1588
|
+
}, env);
|
|
1589
|
+
},
|
|
1590
|
+
|
|
1591
|
+
// --- blocker.add — append, Day-1 create, dedupKey --------------------
|
|
1592
|
+
// Appends a kind:'blocker' record to decisions.jsonl — its ONLY mutation.
|
|
1593
|
+
// `waveId`, when given, is recorded INSIDE that blocker record; the verb does
|
|
1594
|
+
// NOT write any wave-<waveId>/STATE.md. The `blockers_open` wave-summary is
|
|
1595
|
+
// owned by `wave-state.js` (a separate co-writer of that key) — reconciling
|
|
1596
|
+
// it to a single writer is deferred to T7 (migrate wave-state.js to the SDK).
|
|
1597
|
+
// Lock targets therefore list exactly the one file the verb mutates.
|
|
1598
|
+
async 'blocker.add'(payload, ctx, env) {
|
|
1599
|
+
const root = requireRoot(ctx);
|
|
1600
|
+
const id = requireStr(payload?.id, 'id');
|
|
1601
|
+
const text = requireStr(payload?.text, 'text');
|
|
1602
|
+
const dedupKey = requireStr(payload?.dedupKey, 'dedupKey');
|
|
1603
|
+
const waveId = payload?.waveId === undefined
|
|
1604
|
+
? undefined : requireId(payload.waveId, 'waveId');
|
|
1605
|
+
const log = paths.decisions(root);
|
|
1606
|
+
const targets = [paths.intentJournal(root), log];
|
|
1607
|
+
return _withLocks(targets, async () => {
|
|
1608
|
+
if (jsonlHasDedupKey(log, dedupKey)) {
|
|
1609
|
+
return { ok: true, blockerId: id, deduped: true };
|
|
1610
|
+
}
|
|
1611
|
+
appendJsonl(log, {
|
|
1612
|
+
kind: 'blocker', blockerId: id, text, dedupKey,
|
|
1613
|
+
waveId: waveId ?? null, resolved: false, ts: nowIso(),
|
|
1614
|
+
});
|
|
1615
|
+
return { ok: true, blockerId: id, deduped: false };
|
|
1616
|
+
}, env);
|
|
1617
|
+
},
|
|
1618
|
+
|
|
1619
|
+
// --- blocker.resolve — append, Day-1 refuse, dedupKey ---------------
|
|
1620
|
+
// Appends a kind:'blocker-resolution' record to decisions.jsonl — its ONLY
|
|
1621
|
+
// mutation. `waveId`, when given, is recorded INSIDE that resolution record;
|
|
1622
|
+
// the verb does NOT write any wave-<waveId>/STATE.md. The `blockers_open`
|
|
1623
|
+
// wave-summary is owned by `wave-state.js`; its single-writer reconciliation
|
|
1624
|
+
// is deferred to T7 (migrate wave-state.js to the SDK). Lock targets list
|
|
1625
|
+
// exactly the one file the verb mutates.
|
|
1626
|
+
async 'blocker.resolve'(payload, ctx, env) {
|
|
1627
|
+
const root = requireRoot(ctx);
|
|
1628
|
+
const id = requireStr(payload?.id, 'id');
|
|
1629
|
+
const resolution = requireStr(payload?.resolution, 'resolution');
|
|
1630
|
+
const dedupKey = requireStr(payload?.dedupKey, 'dedupKey');
|
|
1631
|
+
const waveId = payload?.waveId === undefined
|
|
1632
|
+
? undefined : requireId(payload.waveId, 'waveId');
|
|
1633
|
+
const log = paths.decisions(root);
|
|
1634
|
+
if (!existsSync(log)) {
|
|
1635
|
+
return { ok: false, refused: true, reason: 'no-blocker-log' };
|
|
1636
|
+
}
|
|
1637
|
+
const targets = [paths.intentJournal(root), log];
|
|
1638
|
+
return _withLocks(targets, async () => {
|
|
1639
|
+
if (jsonlHasDedupKey(log, dedupKey)) {
|
|
1640
|
+
return { ok: true, blockerId: id, resolved: true, deduped: true };
|
|
1641
|
+
}
|
|
1642
|
+
// An open blocker exists iff there is a kind:'blocker' record with this
|
|
1643
|
+
// id and no later kind:'blocker-resolution' record for the same id.
|
|
1644
|
+
const records = readJsonl(log);
|
|
1645
|
+
const opened = records.some((r) => r && r.kind === 'blocker' && r.blockerId === id);
|
|
1646
|
+
const alreadyResolved = records.some(
|
|
1647
|
+
(r) => r && r.kind === 'blocker-resolution' && r.blockerId === id,
|
|
1648
|
+
);
|
|
1649
|
+
const resolvable = opened && !alreadyResolved;
|
|
1650
|
+
appendJsonl(log, {
|
|
1651
|
+
kind: 'blocker-resolution', blockerId: id, resolution, dedupKey,
|
|
1652
|
+
waveId: waveId ?? null, resolved: resolvable, ts: nowIso(),
|
|
1653
|
+
});
|
|
1654
|
+
return { ok: true, blockerId: id, resolved: resolvable, deduped: false };
|
|
1655
|
+
}, env);
|
|
1656
|
+
},
|
|
1657
|
+
|
|
1658
|
+
// --- state.replay — read (recovery), Day-1 no-op -------------------
|
|
1659
|
+
// T4 (this task): reads the intent journal, classifies each verbId, and
|
|
1660
|
+
// resolves partials BY VERB KIND (the begin record's `kind` field):
|
|
1661
|
+
// * begin + commit → already applied → skip (no-op).
|
|
1662
|
+
// * begin, no commit, kind:'overwrite' → snapshot-rollback: restore each
|
|
1663
|
+
// target from the pre-begin snapshot sidecar (restore-or-delete), then
|
|
1664
|
+
// seal with a synthetic commit.
|
|
1665
|
+
// * begin, no commit, kind:'append' → DO NOT roll back. A partial
|
|
1666
|
+
// append is durable and its dedupKey makes the caller's retry a no-op
|
|
1667
|
+
// (§4) — reverting the file would silently destroy a committed record.
|
|
1668
|
+
// Replay only seals it with a synthetic commit marker.
|
|
1669
|
+
// A second replay sees the synthetic commit and treats the partial as
|
|
1670
|
+
// resolved. T20 layers truncation-recovery orchestration on top.
|
|
1671
|
+
async 'state.replay'(payload, ctx) {
|
|
1672
|
+
const root = requireRoot(ctx);
|
|
1673
|
+
const journal = paths.intentJournal(root);
|
|
1674
|
+
if (!existsSync(journal)) {
|
|
1675
|
+
return { ok: true, replayed: [], skipped: [], rolledBack: [] };
|
|
1676
|
+
}
|
|
1677
|
+
// The replay walk + any rollback restores happen under the intent-journal
|
|
1678
|
+
// lock so a concurrent mutating verb cannot interleave with recovery.
|
|
1679
|
+
return withFsLock(lockPathFor(journal), async () => {
|
|
1680
|
+
const records = readJsonl(journal);
|
|
1681
|
+
const sinceVerbId = payload?.sinceVerbId;
|
|
1682
|
+
let scoped = records;
|
|
1683
|
+
if (typeof sinceVerbId === 'string' && sinceVerbId) {
|
|
1684
|
+
const idx = records.findIndex((r) => r && r.verbId === sinceVerbId);
|
|
1685
|
+
if (idx !== -1) scoped = records.slice(idx);
|
|
1686
|
+
}
|
|
1687
|
+
const begins = new Map();
|
|
1688
|
+
const commits = new Set();
|
|
1689
|
+
for (const r of scoped) {
|
|
1690
|
+
if (!r || typeof r.verbId !== 'string') continue;
|
|
1691
|
+
if (r.phase === 'begin') begins.set(r.verbId, r);
|
|
1692
|
+
else if (r.phase === 'commit') commits.add(r.verbId);
|
|
1693
|
+
}
|
|
1694
|
+
const skipped = [];
|
|
1695
|
+
const rolledBack = [];
|
|
1696
|
+
const sealed = [];
|
|
1697
|
+
for (const [verbId, beginRec] of begins) {
|
|
1698
|
+
if (commits.has(verbId)) {
|
|
1699
|
+
// begin + commit → durably applied. Re-issuing it would be a no-op,
|
|
1700
|
+
// so replay simply records it as skipped and mutates nothing.
|
|
1701
|
+
skipped.push(verbId);
|
|
1702
|
+
continue;
|
|
1703
|
+
}
|
|
1704
|
+
// Partial: begin without commit. Resolve it by verb kind.
|
|
1705
|
+
// `kind:'append'` → seal only; NEVER revert (a durable append's
|
|
1706
|
+
// record would be lost). The dedupKey makes the
|
|
1707
|
+
// caller's retry a no-op anyway (§4).
|
|
1708
|
+
// `kind:'overwrite'` (or a legacy begin with no `kind` but a
|
|
1709
|
+
// snapshot sidecar) → snapshot-rollback.
|
|
1710
|
+
// The snapshot sidecar's presence is the legacy-safe discriminator:
|
|
1711
|
+
// append verbs never write one.
|
|
1712
|
+
const snap = readJson(snapshotPath(root, verbId), null);
|
|
1713
|
+
const isAppend = beginRec.kind === 'append'
|
|
1714
|
+
|| (beginRec.kind === undefined && snap === null);
|
|
1715
|
+
if (!isAppend && snap && Array.isArray(snap.targets)) {
|
|
1716
|
+
// Overwrite verb: restore every target from the snapshot sidecar —
|
|
1717
|
+
// restore-or-delete per its pre-begin existence.
|
|
1718
|
+
for (const t of snap.targets) {
|
|
1719
|
+
try {
|
|
1720
|
+
if (t.existed) {
|
|
1721
|
+
writeAtomic(t.absPath, t.content ?? '');
|
|
1722
|
+
} else if (existsSync(t.absPath)) {
|
|
1723
|
+
unlinkSync(t.absPath); // the partial created it — undo by delete
|
|
1724
|
+
}
|
|
1725
|
+
} catch { /* a single target restore failing must not abort the walk */ }
|
|
1726
|
+
}
|
|
1727
|
+
}
|
|
1728
|
+
// Discard any snapshot sidecar (overwrite verbs only — append verbs
|
|
1729
|
+
// never wrote one) and seal the verbId with a synthetic `commit` so a
|
|
1730
|
+
// re-run of replay treats this partial as resolved.
|
|
1731
|
+
try {
|
|
1732
|
+
const s = snapshotPath(root, verbId);
|
|
1733
|
+
if (existsSync(s)) unlinkSync(s);
|
|
1734
|
+
} catch { /* best-effort */ }
|
|
1735
|
+
appendFileSync(journal, `${JSON.stringify({
|
|
1736
|
+
verb: beginRec.verb, verbId, phase: 'commit', ts: nowIso(),
|
|
1737
|
+
payloadDigest: beginRec.payloadDigest,
|
|
1738
|
+
kind: isAppend ? 'append' : 'overwrite',
|
|
1739
|
+
// `rolledBack:true` only for an overwrite verb whose targets were
|
|
1740
|
+
// reverted; an append partial is sealed in place, not rolled back.
|
|
1741
|
+
...(isAppend ? { sealed: true } : { rolledBack: true }),
|
|
1742
|
+
})}\n`, { mode: 0o600 });
|
|
1743
|
+
// `rolledBack[]` = overwrite partials whose targets were restored
|
|
1744
|
+
// (contract §7). `sealed[]` = append partials left durably in place
|
|
1745
|
+
// and only marked terminal — additive, does not redefine the three
|
|
1746
|
+
// documented arrays.
|
|
1747
|
+
if (isAppend) sealed.push(verbId);
|
|
1748
|
+
else rolledBack.push(verbId);
|
|
1749
|
+
}
|
|
1750
|
+
return {
|
|
1751
|
+
ok: true, replayed: [], skipped, rolledBack, sealed,
|
|
1752
|
+
};
|
|
1753
|
+
}, LOCK_OPTS);
|
|
1754
|
+
},
|
|
1755
|
+
|
|
1756
|
+
// --- state.validate — read, Day-1 no-op ----------------------------
|
|
1757
|
+
async 'state.validate'(_payload, ctx) {
|
|
1758
|
+
const root = requireRoot(ctx);
|
|
1759
|
+
const issues = [];
|
|
1760
|
+
// Parse-integrity scan of the canonical JSON state files.
|
|
1761
|
+
for (const [label, p] of [
|
|
1762
|
+
['workflow.json', paths.workflow(root)],
|
|
1763
|
+
['waves.json', paths.waves(root)],
|
|
1764
|
+
['telemetry/convergence.json', paths.telemetry(root)],
|
|
1765
|
+
['team/workflow.json', paths.teamWorkflow(root)],
|
|
1766
|
+
]) {
|
|
1767
|
+
if (!existsSync(p)) {
|
|
1768
|
+
issues.push({ file: label, problem: 'absent' });
|
|
1769
|
+
continue;
|
|
1770
|
+
}
|
|
1771
|
+
const r = readSafe(p);
|
|
1772
|
+
if (!r.ok) issues.push({ file: label, problem: `parse: ${r.error}` });
|
|
1773
|
+
}
|
|
1774
|
+
// Orphaned begin-without-commit records in the intent journal.
|
|
1775
|
+
const journal = paths.intentJournal(root);
|
|
1776
|
+
if (existsSync(journal)) {
|
|
1777
|
+
const records = readJsonl(journal);
|
|
1778
|
+
const commits = new Set(
|
|
1779
|
+
records.filter((r) => r && r.phase === 'commit').map((r) => r.verbId),
|
|
1780
|
+
);
|
|
1781
|
+
for (const r of records) {
|
|
1782
|
+
if (r && r.phase === 'begin' && !commits.has(r.verbId)) {
|
|
1783
|
+
issues.push({ file: 'intent-journal.jsonl', problem: `orphaned begin: ${r.verbId}` });
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
// `absent` is informational, not a failure — `valid` reflects only
|
|
1788
|
+
// genuine integrity problems among PRESENT files (contract §7).
|
|
1789
|
+
const valid = !issues.some((i) => i.problem !== 'absent');
|
|
1790
|
+
return { ok: true, valid, issues };
|
|
1791
|
+
},
|
|
1792
|
+
};
|
|
1793
|
+
|
|
1794
|
+
/** The frozen verb registry — verb name → handler. Exported for tests. */
|
|
1795
|
+
export const VERBS = handlers;
|
|
1796
|
+
|
|
1797
|
+
/**
|
|
1798
|
+
* Verbs that mutate state and therefore write an intent-journal begin/commit
|
|
1799
|
+
* pair (T4). Each one funnels through `_withLocks`, which is the single place
|
|
1800
|
+
* a verb's target set is declared — there is NO parallel `targetsFor` switch
|
|
1801
|
+
* (removed in the T4 spec-review fix; it was a second source of truth that
|
|
1802
|
+
* already drifted from the handlers).
|
|
1803
|
+
*
|
|
1804
|
+
* `subagent.post-done` is NOT here — contract §8 classes it as a `read` verb
|
|
1805
|
+
* (no-op Day-1, no file mutation) and §4 says read verbs write no journal
|
|
1806
|
+
* records. It runs only the post-done self-check gate.
|
|
1807
|
+
*/
|
|
1808
|
+
const MUTATING = new Set([
|
|
1809
|
+
'workflow.set-phase', 'wave.advance', 'wave.record-task', 'phase.plan-check',
|
|
1810
|
+
'phase.complete', 'subagent.dispatch', 'subagent.checkpoint',
|
|
1811
|
+
'event.emit', 'telemetry.record', 'roster.record',
|
|
1812
|
+
'extension.set-active', 'decision.add', 'blocker.add', 'blocker.resolve',
|
|
1813
|
+
]);
|
|
1814
|
+
|
|
1815
|
+
/**
|
|
1816
|
+
* Append/dedupKey verbs (contract §8). A partial append is replay-safe via its
|
|
1817
|
+
* `dedupKey` (§4), NOT via snapshot-rollback — so `_journalBegin` captures no
|
|
1818
|
+
* snapshot for these and `state.replay` never reverts their target file (it
|
|
1819
|
+
* would destroy a durably-committed record). All other mutating verbs are
|
|
1820
|
+
* overwrite / read-modify-write and DO snapshot-rollback.
|
|
1821
|
+
*/
|
|
1822
|
+
const APPEND_VERBS = new Set([
|
|
1823
|
+
'wave.record-task', 'subagent.checkpoint', 'event.emit', 'telemetry.record',
|
|
1824
|
+
'roster.record', 'decision.add', 'blocker.add', 'blocker.resolve',
|
|
1825
|
+
]);
|
|
1826
|
+
|
|
1827
|
+
// ---------------------------------------------------------------------------
|
|
1828
|
+
// THE DISPATCHER
|
|
1829
|
+
// ---------------------------------------------------------------------------
|
|
1830
|
+
|
|
1831
|
+
/**
|
|
1832
|
+
* query(verb, payload, ctx) — the single state-SDK mutation/read surface.
|
|
1833
|
+
*
|
|
1834
|
+
* Routes `verb` to its registered handler. An UNKNOWN verb throws — there is
|
|
1835
|
+
* NO silent fallback and NO default handler (contract §8, frozen for T2).
|
|
1836
|
+
*
|
|
1837
|
+
* @param {string} verb a verb name from the frozen 20-verb registry.
|
|
1838
|
+
* @param {object} [payload] verb-specific payload (see STATE-SDK-CONTRACT §7).
|
|
1839
|
+
* @param {{projectRoot:string, subagentId?:string, homeDir?:string, platform?:string}} [ctx]
|
|
1840
|
+
* @returns {Promise<object>} the verb's result shape; always carries `ok` + `verbId`.
|
|
1841
|
+
*/
|
|
1842
|
+
export async function query(verb, payload = {}, ctx = {}) {
|
|
1843
|
+
const handler = typeof verb === 'string' ? handlers[verb] : undefined;
|
|
1844
|
+
if (typeof handler !== 'function') {
|
|
1845
|
+
throw new Error(
|
|
1846
|
+
`state-sdk: unknown verb "${verb}" — no silent fallback. `
|
|
1847
|
+
+ `Known verbs: ${Object.keys(handlers).sort().join(', ')}`,
|
|
1848
|
+
);
|
|
1849
|
+
}
|
|
1850
|
+
|
|
1851
|
+
// Per-invocation id — `begin`/`commit` journal records (T4) and every event
|
|
1852
|
+
// record (T5) for this query share this verbId.
|
|
1853
|
+
const verbId = `v-${randomUUID()}-0000`;
|
|
1854
|
+
const digest = payloadDigest(payload);
|
|
1855
|
+
const isMutating = MUTATING.has(verb);
|
|
1856
|
+
|
|
1857
|
+
// The `env` object is the single channel between the dispatcher and the
|
|
1858
|
+
// verb's `_withLocks` call. For a mutating verb it carries everything
|
|
1859
|
+
// `_withLocks` needs to write the write-ahead `begin` record FROM THE VERB'S
|
|
1860
|
+
// OWN TARGET LIST (issue 2 — no re-derivation): `_withLocks` populates
|
|
1861
|
+
// `env.journalHandle` for `_journalCommit` to consume. The journal root is
|
|
1862
|
+
// required up-front so a mutating verb with a malformed ctx fails fast.
|
|
1863
|
+
const env = { verbId };
|
|
1864
|
+
if (isMutating) {
|
|
1865
|
+
env.isMutating = true;
|
|
1866
|
+
env.verb = verb;
|
|
1867
|
+
env.root = requireRoot(ctx);
|
|
1868
|
+
env.dedupKey = payload?.dedupKey;
|
|
1869
|
+
env.payloadDigest = digest;
|
|
1870
|
+
env.appendVerb = APPEND_VERBS.has(verb);
|
|
1871
|
+
env.journalHandle = null;
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
// The tap envelope's projectRoot is best-effort: `ctx.projectRoot` is
|
|
1875
|
+
// required for mutating verbs but may be unset for invalid calls — in that
|
|
1876
|
+
// case the tap silently no-ops (an erroring unknown verb / missing-root
|
|
1877
|
+
// call has nowhere to write its tap event). `waveId` is derived from the
|
|
1878
|
+
// payload when the verb names one — the tap routes to the wave-scoped log.
|
|
1879
|
+
const eventRoot = typeof ctx?.projectRoot === 'string' ? ctx.projectRoot : null;
|
|
1880
|
+
const eventWaveId = typeof payload?.waveId === 'string' ? payload.waveId : null;
|
|
1881
|
+
|
|
1882
|
+
let result;
|
|
1883
|
+
let outcome = 'ok';
|
|
1884
|
+
try {
|
|
1885
|
+
result = await handler(payload, ctx, env);
|
|
1886
|
+
if (result && result.refused) outcome = 'refused';
|
|
1887
|
+
else if (result && result.advisory) outcome = 'advisory';
|
|
1888
|
+
} catch (err) {
|
|
1889
|
+
outcome = 'error';
|
|
1890
|
+
// T4 — the handler threw: if a `begin` was written (`env.journalHandle`
|
|
1891
|
+
// set) the verb is a partial. Leave the begin record + snapshot in place
|
|
1892
|
+
// so `state.replay` rolls it back (overwrite verb) or seals it (append
|
|
1893
|
+
// verb). T5 — emit the failure event before re-throwing. Fire-and-forget,
|
|
1894
|
+
// no §3 lock taken, errors swallowed inside `_emitEvent`.
|
|
1895
|
+
_emitEvent({
|
|
1896
|
+
verb, subagentId: ctx?.subagentId ?? 'parent', ts: nowIso(),
|
|
1897
|
+
verbId, outcome, payloadDigest: digest,
|
|
1898
|
+
projectRoot: eventRoot, waveId: eventWaveId,
|
|
1899
|
+
});
|
|
1900
|
+
throw err;
|
|
1901
|
+
}
|
|
1902
|
+
|
|
1903
|
+
// T4 — `commit` marker after the handler returned. `env.journalHandle` is
|
|
1904
|
+
// set iff `_withLocks` ran a `begin` (every mutating verb that reaches its
|
|
1905
|
+
// critical section). A handler that returned early WITHOUT calling
|
|
1906
|
+
// `_withLocks` (e.g. `phase.plan-check` Day-1 refuse / gate refuse) wrote no
|
|
1907
|
+
// `begin` and needs no `commit` — it mutated nothing. When a `begin` exists,
|
|
1908
|
+
// commit regardless of refused/ok. This commit-on-refuse is sound ONLY
|
|
1909
|
+
// because of the §4 handler invariant: a handler MUST NOT return a refusal
|
|
1910
|
+
// after entering `_withLocks` / after any mutation — refusals are decided
|
|
1911
|
+
// before the critical section (verdict gates already run pre-`_withLocks`).
|
|
1912
|
+
// So a `begin`-then-`refused` result mutated nothing inside the lock, the
|
|
1913
|
+
// snapshot still equals disk, and committing only marks the verbId terminal
|
|
1914
|
+
// so replay never treats a clean pre-lock refusal as a recoverable partial.
|
|
1915
|
+
if (env.journalHandle) {
|
|
1916
|
+
await _journalCommit(env.journalHandle);
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
// T5 — fire-and-forget event AFTER the critical section. Per Model 3 the
|
|
1920
|
+
// tap is observability, not state — it runs post-lock-release and never
|
|
1921
|
+
// blocks the caller. `_emitEvent` returns synchronously after queueing.
|
|
1922
|
+
_emitEvent({
|
|
1923
|
+
verb, subagentId: ctx?.subagentId ?? 'parent', ts: nowIso(),
|
|
1924
|
+
verbId, outcome, payloadDigest: digest,
|
|
1925
|
+
projectRoot: eventRoot, waveId: eventWaveId,
|
|
1926
|
+
});
|
|
1927
|
+
|
|
1928
|
+
// Every query() result carries `verbId` + `ok` (contract §7).
|
|
1929
|
+
return { ok: result?.ok !== false, verbId, ...result };
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
export default { query, VERBS };
|