@ijfw/memory-server 1.4.3 → 1.5.0
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/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 +1 -1
- 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 +550 -14
- package/src/cross-orchestrator.js +1171 -10
- package/src/cross-project-search.js +195 -9
- package/src/dashboard-client-planning.html +273 -0
- package/src/dashboard-client-waves.html +304 -0
- package/src/dashboard-client.html +17 -2
- package/src/dashboard-server.js +152 -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 +27 -1
- package/src/dispatch/registry-cli.js +4 -1
- package/src/dispatch/wave-cli.js +323 -0
- 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/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 +554 -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 +152 -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/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/obsidian-parser.js +91 -0
- package/src/memory/query-dataview.js +86 -0
- package/src/memory/search.js +10 -0
- 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.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 +249 -0
- package/src/orchestrator/review.js +136 -0
- package/src/orchestrator/runtime-loop.js +430 -0
- 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 +1764 -0
- package/src/orchestrator/status-protocol.js +235 -0
- package/src/orchestrator/subagent-telemetry.js +452 -0
- package/src/orchestrator/termination.js +160 -0
- package/src/orchestrator/verification-gate.js +281 -0
- package/src/orchestrator/wave-state.js +564 -0
- package/src/orchestrator/worktree-provision.js +77 -0
- 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 +656 -0
- package/src/recovery/truncation.js +317 -0
- package/src/redactor.js +75 -6
- package/src/runtime-mediator.js +15 -0
- package/src/sanitizer.js +10 -0
- package/src/search-hybrid.js +139 -0
- package/src/server.js +603 -59
- package/src/swarm/worktree.js +27 -4
- package/src/swarm-config.js +113 -12
- package/src/team/domain-templates/book.json +51 -0
- package/src/team/domain-templates/business.json +41 -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 +41 -0
- package/src/team/domain-templates/software.json +40 -0
- package/src/team/generator.js +278 -3
- package/src/team/modify.js +203 -0
- package/src/team/schemas.js +48 -0
- package/src/update-apply.js +19 -3
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* wave-state.js — Atomic STATE.md read/write for orchestrator wave tracking.
|
|
3
|
+
*
|
|
4
|
+
* STATE.md lives at <projectRoot>/.ijfw/wave-<waveId>/STATE.md.
|
|
5
|
+
* Format: YAML frontmatter (---delimited) + markdown body.
|
|
6
|
+
* Writes are atomic: withFsLock + write-to-tmp + rename.
|
|
7
|
+
*
|
|
8
|
+
* Landed in W10-A0 (v1.4.4 prelude). checkpointWave is a stub;
|
|
9
|
+
* N4 (W10-A2) will flesh out the blackboard→STATE rollup logic.
|
|
10
|
+
*
|
|
11
|
+
* v1.5.0 T7 (this task): wave.* writes route through the state-SDK
|
|
12
|
+
* (`query('wave.advance', ...)`) — tmp+rename + locks + intent/commit
|
|
13
|
+
* journalling happen inside the SDK. STATE.md frontmatter is the single
|
|
14
|
+
* source of truth; the `blockers_open` key is now derived FROM
|
|
15
|
+
* `decisions.jsonl` at checkpoint time (the SDK's `blocker.add`/
|
|
16
|
+
* `blocker.resolve` verbs append there), giving a single writer and a single
|
|
17
|
+
* representation. `blockers_open` carries the blocker **id** array (machine-
|
|
18
|
+
* consumed); a separate `blockers_open_summary` carries human-readable text.
|
|
19
|
+
*
|
|
20
|
+
* KNOWN SDK GAP (T7-followup-1): the SDK's `wave.advance` verb does NOT
|
|
21
|
+
* accept a `body` field — its handler always preserves the existing body.
|
|
22
|
+
* Until a body-write SDK verb lands, `writeWaveState` does a follow-up raw
|
|
23
|
+
* atomic write to update the body. The body-write itself is still
|
|
24
|
+
* tmp+rename+lock-protected and the SDK frontmatter write already committed
|
|
25
|
+
* via the intent journal — so the worst-case partial state (frontmatter
|
|
26
|
+
* advanced, body stale) is bounded and self-healing on next checkpoint.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { mkdir, readFile, writeFile, rename, appendFile } from 'node:fs/promises';
|
|
30
|
+
import { join } from 'node:path';
|
|
31
|
+
import { withFsLock } from '../fs-lock.js';
|
|
32
|
+
import { readBlackboard } from '../blackboard.js';
|
|
33
|
+
import { query } from './state-sdk.js';
|
|
34
|
+
|
|
35
|
+
// Lazy S4 loader. Top-level `await import` would break `node:test` (unsettled
|
|
36
|
+
// top-level await). Resolves on first checkpointWave call instead. Missing
|
|
37
|
+
// module is non-fatal (silent fail — populateBlackboardBlock stays null).
|
|
38
|
+
//
|
|
39
|
+
// v1.5.0 audit-MED-work-M9: previously this used a `_s4LoadAttempted` boolean
|
|
40
|
+
// + a sync `_populateBlackboardBlock` mutation. That had a race window: two
|
|
41
|
+
// concurrent callers entering before the `await import` settled would BOTH
|
|
42
|
+
// fire `import()` (cheap on resolved-module cache, but the race-condition
|
|
43
|
+
// taxonomy still flagged it as a singleton smell). Replaced with a Promise
|
|
44
|
+
// singleton: the first caller stores the promise; subsequent callers await
|
|
45
|
+
// the same promise. No double-import, no race on the result variable.
|
|
46
|
+
let _populateBlackboardBlockPromise = null;
|
|
47
|
+
function loadPopulateBlackboardBlock() {
|
|
48
|
+
if (_populateBlackboardBlockPromise === null) {
|
|
49
|
+
_populateBlackboardBlockPromise = (async () => {
|
|
50
|
+
try {
|
|
51
|
+
const mod = await import('./agents-md-blackboard.js');
|
|
52
|
+
return mod.populateBlackboardBlock ?? null;
|
|
53
|
+
} catch {
|
|
54
|
+
// S4 not landed — advisory only
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
})();
|
|
58
|
+
}
|
|
59
|
+
return _populateBlackboardBlockPromise;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Test-only helper: reset the populateBlackboardBlock promise singleton so a
|
|
64
|
+
* test can simulate "first call after process start" semantics. Internal.
|
|
65
|
+
*
|
|
66
|
+
* @internal
|
|
67
|
+
*/
|
|
68
|
+
export function _resetPopulateBlackboardBlockSingleton() {
|
|
69
|
+
_populateBlackboardBlockPromise = null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Internal YAML helpers — flat subset only (string/number/boolean/string[])
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Parse a YAML frontmatter block (lines between the two `---` delimiters).
|
|
78
|
+
* Supports: scalar string/number/boolean values, arrays of strings (block style).
|
|
79
|
+
* Rejects nested maps with a clear error.
|
|
80
|
+
*
|
|
81
|
+
* @param {string} block Lines between the two `---` markers (no delimiters)
|
|
82
|
+
* @returns {object}
|
|
83
|
+
*/
|
|
84
|
+
function parseYaml(block) {
|
|
85
|
+
const result = {};
|
|
86
|
+
const lines = block.split('\n');
|
|
87
|
+
let i = 0;
|
|
88
|
+
while (i < lines.length) {
|
|
89
|
+
const line = lines[i];
|
|
90
|
+
if (line.trim() === '' || line.trimStart().startsWith('#')) { i++; continue; }
|
|
91
|
+
|
|
92
|
+
const colonIdx = line.indexOf(':');
|
|
93
|
+
if (colonIdx === -1) { i++; continue; }
|
|
94
|
+
|
|
95
|
+
const key = line.slice(0, colonIdx).trim();
|
|
96
|
+
const rest = line.slice(colonIdx + 1).trim();
|
|
97
|
+
|
|
98
|
+
if (!key) { i++; continue; }
|
|
99
|
+
|
|
100
|
+
// Detect nested map: next non-empty lines are indented key: value pairs
|
|
101
|
+
if (rest === '') {
|
|
102
|
+
// Could be array or nested map — peek ahead
|
|
103
|
+
const nextLines = [];
|
|
104
|
+
let j = i + 1;
|
|
105
|
+
while (j < lines.length && lines[j].trim() !== '' && !lines[j].match(/^\S.*:/)) {
|
|
106
|
+
nextLines.push(lines[j]);
|
|
107
|
+
j++;
|
|
108
|
+
}
|
|
109
|
+
if (nextLines.length > 0 && nextLines[0].trimStart().startsWith('- ')) {
|
|
110
|
+
// Block sequence
|
|
111
|
+
result[key] = nextLines.map((l) => l.replace(/^\s*-\s?/, ''));
|
|
112
|
+
i = j;
|
|
113
|
+
continue;
|
|
114
|
+
} else if (nextLines.length > 0) {
|
|
115
|
+
throw new Error(`wave-state: nested YAML maps are not supported (key: "${key}")`);
|
|
116
|
+
}
|
|
117
|
+
result[key] = null;
|
|
118
|
+
i++;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Inline array: [a, b, c]
|
|
123
|
+
if (rest.startsWith('[')) {
|
|
124
|
+
const inner = rest.replace(/^\[/, '').replace(/\]$/, '');
|
|
125
|
+
result[key] = inner ? inner.split(',').map((s) => s.trim().replace(/^['"]|['"]$/g, '')) : [];
|
|
126
|
+
i++;
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Scalar
|
|
131
|
+
if (rest === 'true') { result[key] = true; }
|
|
132
|
+
else if (rest === 'false') { result[key] = false; }
|
|
133
|
+
else if (rest === 'null' || rest === '~') { result[key] = null; }
|
|
134
|
+
else if (!Number.isNaN(Number(rest)) && rest !== '') { result[key] = Number(rest); }
|
|
135
|
+
else { result[key] = rest.replace(/^['"]|['"]$/g, ''); }
|
|
136
|
+
i++;
|
|
137
|
+
}
|
|
138
|
+
return result;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Emit a YAML frontmatter block for flat string/number/boolean/string[] values.
|
|
143
|
+
* @param {object} obj
|
|
144
|
+
* @returns {string} (no leading/trailing `---`)
|
|
145
|
+
*/
|
|
146
|
+
function emitYaml(obj) {
|
|
147
|
+
const lines = [];
|
|
148
|
+
for (const [key, val] of Object.entries(obj)) {
|
|
149
|
+
if (val === null || val === undefined) {
|
|
150
|
+
lines.push(`${key}: null`);
|
|
151
|
+
} else if (Array.isArray(val)) {
|
|
152
|
+
if (val.length === 0) {
|
|
153
|
+
lines.push(`${key}: []`);
|
|
154
|
+
} else {
|
|
155
|
+
lines.push(`${key}:`);
|
|
156
|
+
for (const item of val) lines.push(` - ${item}`);
|
|
157
|
+
}
|
|
158
|
+
} else if (typeof val === 'boolean') {
|
|
159
|
+
lines.push(`${key}: ${val}`);
|
|
160
|
+
} else if (typeof val === 'number') {
|
|
161
|
+
lines.push(`${key}: ${val}`);
|
|
162
|
+
} else if (typeof val === 'object') {
|
|
163
|
+
throw new Error(`wave-state: nested YAML objects are not supported (key: "${key}")`);
|
|
164
|
+
} else {
|
|
165
|
+
lines.push(`${key}: ${val}`);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return lines.join('\n');
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
// Path helpers
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
function wavePaths(waveId, projectRoot) {
|
|
176
|
+
const dir = join(projectRoot, '.ijfw', `wave-${waveId}`);
|
|
177
|
+
return {
|
|
178
|
+
dir,
|
|
179
|
+
state: join(dir, 'STATE.md'),
|
|
180
|
+
summary: join(dir, 'SUMMARY.md'),
|
|
181
|
+
lock: join(dir, '.STATE.md.lock'),
|
|
182
|
+
summaryLock: join(dir, '.SUMMARY.md.lock'),
|
|
183
|
+
tmp: join(dir, '.STATE.md.tmp'),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
// Public API
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Read a wave's STATE.md and return parsed { frontmatter, body, raw }.
|
|
193
|
+
* Returns null if the wave directory or file doesn't exist.
|
|
194
|
+
* Throws on malformed frontmatter.
|
|
195
|
+
*
|
|
196
|
+
* @param {string} waveId e.g. "W10-A0"
|
|
197
|
+
* @param {string} projectRoot absolute path to project root
|
|
198
|
+
* @returns {Promise<{frontmatter: object, body: string, raw: string} | null>}
|
|
199
|
+
*/
|
|
200
|
+
export async function readWaveState(waveId, projectRoot) {
|
|
201
|
+
const { state } = wavePaths(waveId, projectRoot);
|
|
202
|
+
let raw;
|
|
203
|
+
try {
|
|
204
|
+
raw = await readFile(state, 'utf8');
|
|
205
|
+
} catch (err) {
|
|
206
|
+
if (err.code === 'ENOENT') return null;
|
|
207
|
+
throw err;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Parse frontmatter
|
|
211
|
+
if (!raw.startsWith('---')) {
|
|
212
|
+
throw new Error(`wave-state: STATE.md for "${waveId}" is missing YAML frontmatter`);
|
|
213
|
+
}
|
|
214
|
+
const secondDelim = raw.indexOf('\n---', 3);
|
|
215
|
+
if (secondDelim === -1) {
|
|
216
|
+
throw new Error(`wave-state: STATE.md for "${waveId}" has unclosed YAML frontmatter`);
|
|
217
|
+
}
|
|
218
|
+
const fmBlock = raw.slice(4, secondDelim); // skip "---\n"
|
|
219
|
+
const body = raw.slice(secondDelim + 4).replace(/^\n+/, ''); // skip "\n---\n\n"
|
|
220
|
+
|
|
221
|
+
const frontmatter = parseYaml(fmBlock);
|
|
222
|
+
return { frontmatter, body, raw };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Atomically write a wave's STATE.md.
|
|
227
|
+
*
|
|
228
|
+
* v1.5.0 T7: frontmatter writes route through the state-SDK
|
|
229
|
+
* (`query('wave.advance', {waveId, status, frontmatter}, {projectRoot})`) so
|
|
230
|
+
* tmp+rename + locks + intent/commit journalling happen inside the SDK. The
|
|
231
|
+
* body — which the SDK contract does not yet expose a write verb for — is
|
|
232
|
+
* applied via a follow-up atomic write inside the same wave-STATE lock. The
|
|
233
|
+
* SDK's `wave.advance` handler preserves the existing body when it rewrites
|
|
234
|
+
* frontmatter, so the follow-up write only mutates body content and never
|
|
235
|
+
* loses an in-flight frontmatter update.
|
|
236
|
+
*
|
|
237
|
+
* Auto-creates `.ijfw/wave-<waveId>/` if missing (the SDK handler creates it
|
|
238
|
+
* on first call).
|
|
239
|
+
*
|
|
240
|
+
* @param {string} waveId
|
|
241
|
+
* @param {{frontmatter: object, body?: string}} state
|
|
242
|
+
* @param {string} projectRoot
|
|
243
|
+
* @returns {Promise<void>}
|
|
244
|
+
*/
|
|
245
|
+
export async function writeWaveState(waveId, state, projectRoot) {
|
|
246
|
+
const fm = state.frontmatter || {};
|
|
247
|
+
// SDK's wave.advance requires `status` — supply 'pending' as a safe default
|
|
248
|
+
// for callers that haven't materialised one yet (matches deriveStatus's
|
|
249
|
+
// default-on-empty-blackboard behaviour).
|
|
250
|
+
const status = (typeof fm.status === 'string' && fm.status.length > 0)
|
|
251
|
+
? fm.status : 'pending';
|
|
252
|
+
// wave.advance MERGES payload.frontmatter into the existing frontmatter;
|
|
253
|
+
// pass the full requested frontmatter so unrelated keys are overwritten
|
|
254
|
+
// intentionally (writeWaveState semantics: caller supplies the full
|
|
255
|
+
// frontmatter shape they want persisted).
|
|
256
|
+
await query(
|
|
257
|
+
'wave.advance',
|
|
258
|
+
{ waveId, status, frontmatter: { ...fm } },
|
|
259
|
+
{ projectRoot },
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
// Body follow-up: SDK-gap T7-followup-1 — wave.advance preserves existing
|
|
263
|
+
// body and there is no body-write SDK verb yet. Until one lands, do an
|
|
264
|
+
// atomic in-place body update. Held under the same wave-STATE lock used by
|
|
265
|
+
// every wave-state writer, so concurrent checkpoints serialise.
|
|
266
|
+
if (state.body !== undefined && state.body !== null) {
|
|
267
|
+
const { dir, state: statePath, lock, tmp } = wavePaths(waveId, projectRoot);
|
|
268
|
+
await withFsLock(lock, async () => {
|
|
269
|
+
await mkdir(dir, { recursive: true });
|
|
270
|
+
let frontmatterRaw;
|
|
271
|
+
try {
|
|
272
|
+
const raw = await readFile(statePath, 'utf8');
|
|
273
|
+
const secondDelim = raw.indexOf('\n---', 3);
|
|
274
|
+
// Defensive: if the SDK-written STATE.md is somehow malformed, fall
|
|
275
|
+
// back to re-emitting frontmatter from the in-memory shape rather
|
|
276
|
+
// than refusing the body write.
|
|
277
|
+
if (raw.startsWith('---') && secondDelim !== -1) {
|
|
278
|
+
frontmatterRaw = raw.slice(0, secondDelim + 4); // '---\n…\n---\n'
|
|
279
|
+
} else {
|
|
280
|
+
frontmatterRaw = `---\n${emitYaml(fm)}\n---\n`;
|
|
281
|
+
}
|
|
282
|
+
} catch {
|
|
283
|
+
frontmatterRaw = `---\n${emitYaml(fm)}\n---\n`;
|
|
284
|
+
}
|
|
285
|
+
const payload = `${frontmatterRaw}\n${state.body}`;
|
|
286
|
+
await writeFile(tmp, payload, 'utf8');
|
|
287
|
+
await rename(tmp, statePath);
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Append a delta entry to a wave's SUMMARY.md — markdown append-only log.
|
|
294
|
+
* r13-M-03 (post-Trident r13 fix): minimum-viable implementation closing the
|
|
295
|
+
* handoff §N4 promise. Full blackboard→STATE rollup remains future work for
|
|
296
|
+
* v1.5.0 (would mean reading blackboard.js claims/findings and summarising).
|
|
297
|
+
*
|
|
298
|
+
* Delta shape (caller chooses what to record):
|
|
299
|
+
* { agent_id?, task_id?, commits?: string[], tests_delta?: string,
|
|
300
|
+
* contracts_touched?: string[], surprises?: string }
|
|
301
|
+
*
|
|
302
|
+
* Atomic via withFsLock + appendFile. Each delta is rendered as a markdown
|
|
303
|
+
* H3 section dated by ISO timestamp; subsequent entries append below.
|
|
304
|
+
*
|
|
305
|
+
* @param {string} waveId
|
|
306
|
+
* @param {object} delta
|
|
307
|
+
* @param {string} projectRoot
|
|
308
|
+
* @returns {Promise<void>}
|
|
309
|
+
*/
|
|
310
|
+
export async function appendSummary(waveId, delta, projectRoot) {
|
|
311
|
+
const { dir, summary, summaryLock } = wavePaths(waveId, projectRoot);
|
|
312
|
+
const ts = new Date().toISOString();
|
|
313
|
+
const lines = [`### ${ts}`];
|
|
314
|
+
if (delta.agent_id) lines.push(`- **agent:** ${delta.agent_id}`);
|
|
315
|
+
if (delta.task_id) lines.push(`- **task:** ${delta.task_id}`);
|
|
316
|
+
if (Array.isArray(delta.commits) && delta.commits.length) {
|
|
317
|
+
lines.push(`- **commits:** ${delta.commits.join(', ')}`);
|
|
318
|
+
}
|
|
319
|
+
if (delta.tests_delta) lines.push(`- **tests:** ${delta.tests_delta}`);
|
|
320
|
+
if (Array.isArray(delta.contracts_touched) && delta.contracts_touched.length) {
|
|
321
|
+
lines.push(`- **contracts:** ${delta.contracts_touched.join(', ')}`);
|
|
322
|
+
}
|
|
323
|
+
if (delta.surprises) lines.push(`- **surprises:** ${delta.surprises}`);
|
|
324
|
+
const payload = lines.join('\n') + '\n\n';
|
|
325
|
+
|
|
326
|
+
await withFsLock(summaryLock, async () => {
|
|
327
|
+
await mkdir(dir, { recursive: true });
|
|
328
|
+
await appendFile(summary, payload, 'utf8');
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
// Rollup helpers — exported for direct testing (W11-B1 / S5)
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Derive the next STATE.md status from the wave-filtered blackboard slice and
|
|
338
|
+
* the previously-persisted state.
|
|
339
|
+
*
|
|
340
|
+
* Rules (R1 §S5):
|
|
341
|
+
* 1. any open blocker → 'blocked'
|
|
342
|
+
* 2. no claims at all → preserve existing status (default 'pending')
|
|
343
|
+
* 3. every claim 'released' → 'review'
|
|
344
|
+
* 4. otherwise → 'in_progress'
|
|
345
|
+
*
|
|
346
|
+
* @param {{claims: object[], findings: object[], blockers: object[]}} filtered
|
|
347
|
+
* @param {{frontmatter?: object} | null} existing
|
|
348
|
+
* @returns {'blocked'|'pending'|'review'|'in_progress'}
|
|
349
|
+
*/
|
|
350
|
+
export function deriveStatus(filtered, existing) {
|
|
351
|
+
if (filtered.blockers && filtered.blockers.length > 0) return 'blocked';
|
|
352
|
+
if (filtered.claims.length === 0) return existing?.frontmatter?.status ?? 'pending';
|
|
353
|
+
if (filtered.claims.every((c) => c.status === 'released')) return 'review';
|
|
354
|
+
return 'in_progress';
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
/**
|
|
358
|
+
* Tag a blackboard entry as belonging to a wave by checking, in order:
|
|
359
|
+
* - explicit `wave_id` field
|
|
360
|
+
* - artifact_id prefixed `<waveId>:`
|
|
361
|
+
* - message containing `[<waveId>]`
|
|
362
|
+
*
|
|
363
|
+
* @param {{claims?: {data?: {claims?: object[]}}, recent?: {findings?: object[], blockers?: object[]}}} blackboard
|
|
364
|
+
* @param {string} waveId
|
|
365
|
+
* @returns {{claims: object[], findings: object[], blockers: object[]}}
|
|
366
|
+
*/
|
|
367
|
+
export function filterByWave(blackboard, waveId) {
|
|
368
|
+
const tag = (entry) => {
|
|
369
|
+
if (!entry) return false;
|
|
370
|
+
if (entry.wave_id === waveId) return true;
|
|
371
|
+
if (typeof entry.artifact_id === 'string' && entry.artifact_id.startsWith(`${waveId}:`)) return true;
|
|
372
|
+
if (typeof entry.message === 'string' && entry.message.includes(`[${waveId}]`)) return true;
|
|
373
|
+
return false;
|
|
374
|
+
};
|
|
375
|
+
const claims = (blackboard.claims?.data?.claims ?? []).filter(tag);
|
|
376
|
+
const findings = (blackboard.recent?.findings ?? []).filter(tag);
|
|
377
|
+
const blockers = (blackboard.recent?.blockers ?? []).filter(tag);
|
|
378
|
+
return { claims, findings, blockers };
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Quote YAML strings that would otherwise confuse the flat-subset parser/emitter:
|
|
383
|
+
* presence of `:`, `#`, `[`, `]`, `{`, `}`, `"`, newline, or `<space>-`.
|
|
384
|
+
*
|
|
385
|
+
* Fold-in: Trident r13 F6 — emit safety for STATE.md frontmatter strings.
|
|
386
|
+
*
|
|
387
|
+
* @param {string} s
|
|
388
|
+
* @returns {string}
|
|
389
|
+
*/
|
|
390
|
+
export function quoteYamlStr(s) {
|
|
391
|
+
if (typeof s !== 'string') return String(s);
|
|
392
|
+
if (/[:#[\]{}"\n]|\s-/.test(s)) return `"${s.replace(/"/g, '\\"')}"`;
|
|
393
|
+
return s;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Render the markdown body for STATE.md from the wave-filtered blackboard slice.
|
|
398
|
+
* Findings are capped to the last 5 (matches frontmatter.findings_recent window).
|
|
399
|
+
*
|
|
400
|
+
* @param {{findings: object[], blockers: object[]}} filtered
|
|
401
|
+
* @param {{body?: string} | null} _existing (reserved for future merge logic)
|
|
402
|
+
* @returns {string}
|
|
403
|
+
*/
|
|
404
|
+
export function renderBody(filtered, _existing) {
|
|
405
|
+
const lines = [];
|
|
406
|
+
if (filtered.findings.length > 0) {
|
|
407
|
+
lines.push('## Recent findings');
|
|
408
|
+
for (const f of filtered.findings.slice(-5)) {
|
|
409
|
+
lines.push(`- ${f.message ?? '(unspecified)'}`);
|
|
410
|
+
}
|
|
411
|
+
lines.push('');
|
|
412
|
+
}
|
|
413
|
+
if (filtered.blockers.length > 0) {
|
|
414
|
+
lines.push('## Open blockers');
|
|
415
|
+
for (const b of filtered.blockers) {
|
|
416
|
+
lines.push(`- ${b.message ?? '(unspecified)'}`);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
return lines.join('\n');
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* v1.5.0 T7: derive the open-blocker set for a wave from `decisions.jsonl`.
|
|
424
|
+
*
|
|
425
|
+
* The SDK's `blocker.add` / `blocker.resolve` verbs append `kind:'blocker'` /
|
|
426
|
+
* `kind:'blocker-resolution'` records to `.ijfw/blackboard/decisions.jsonl`
|
|
427
|
+
* (T4 contract §7). A blocker is **open** when:
|
|
428
|
+
* - a `kind:'blocker'` record exists for the wave (matched by
|
|
429
|
+
* record.waveId === waveId), AND
|
|
430
|
+
* - no later `kind:'blocker-resolution'` record carries the same
|
|
431
|
+
* `blockerId`.
|
|
432
|
+
*
|
|
433
|
+
* Returns parallel arrays of stable ids (for `blockers_open`, machine-
|
|
434
|
+
* consumed) and human messages (for `blockers_open_summary`, optional UI).
|
|
435
|
+
*
|
|
436
|
+
* @param {{recent?: {decisions?: object[]}}} blackboard
|
|
437
|
+
* @param {string} waveId
|
|
438
|
+
* @returns {{ids: string[], summaries: string[]}}
|
|
439
|
+
*/
|
|
440
|
+
export function deriveOpenBlockers(blackboard, waveId) {
|
|
441
|
+
const decisions = Array.isArray(blackboard?.recent?.decisions)
|
|
442
|
+
? blackboard.recent.decisions : [];
|
|
443
|
+
const resolvedIds = new Set();
|
|
444
|
+
for (const r of decisions) {
|
|
445
|
+
if (r && r.kind === 'blocker-resolution' && typeof r.blockerId === 'string') {
|
|
446
|
+
resolvedIds.add(r.blockerId);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
const ids = [];
|
|
450
|
+
const summaries = [];
|
|
451
|
+
const seen = new Set();
|
|
452
|
+
for (const r of decisions) {
|
|
453
|
+
if (!r || r.kind !== 'blocker') continue;
|
|
454
|
+
if (typeof r.blockerId !== 'string' || !r.blockerId) continue;
|
|
455
|
+
if (r.waveId !== waveId) continue;
|
|
456
|
+
if (resolvedIds.has(r.blockerId)) continue;
|
|
457
|
+
if (seen.has(r.blockerId)) continue;
|
|
458
|
+
seen.add(r.blockerId);
|
|
459
|
+
ids.push(r.blockerId);
|
|
460
|
+
summaries.push(quoteYamlStr(typeof r.text === 'string' ? r.text : ''));
|
|
461
|
+
}
|
|
462
|
+
return { ids, summaries };
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Roll up the blackboard slice for `waveId` into STATE.md frontmatter+body.
|
|
467
|
+
*
|
|
468
|
+
* Steps:
|
|
469
|
+
* 1. Read existing STATE.md (preserve created_at if present).
|
|
470
|
+
* 2. Read blackboard.js — defensive: missing/uninitialized blackboard yields
|
|
471
|
+
* empty arrays so checkpointing never throws on a clean tree.
|
|
472
|
+
* 3. Filter blackboard entries by wave tag.
|
|
473
|
+
* 4. Derive `blockers_open` from `decisions.jsonl` (single source of truth —
|
|
474
|
+
* the SDK's blocker.add/blocker.resolve verbs append there). Legacy
|
|
475
|
+
* `blackboard.recent.blockers` (from `addBlackboardNote(kind:'blocker')`)
|
|
476
|
+
* still drives the `status='blocked'` rule for back-compat.
|
|
477
|
+
* 5. Derive status + frontmatter; render markdown body.
|
|
478
|
+
* 6. Persist atomically via writeWaveState (SDK-routed).
|
|
479
|
+
* 7. Append a SUMMARY.md delta when status transitions.
|
|
480
|
+
* 8. If S4's populateBlackboardBlock is loaded, refresh AGENTS.md (advisory —
|
|
481
|
+
* silent on failure).
|
|
482
|
+
*
|
|
483
|
+
* @param {string} waveId
|
|
484
|
+
* @param {string} projectRoot
|
|
485
|
+
* @returns {Promise<{frontmatter: object, body: string}>}
|
|
486
|
+
*/
|
|
487
|
+
export async function checkpointWave(waveId, projectRoot) {
|
|
488
|
+
const now = new Date().toISOString();
|
|
489
|
+
const existing = await readWaveState(waveId, projectRoot);
|
|
490
|
+
|
|
491
|
+
// readBlackboard returns synchronously per blackboard.js; uninitialized
|
|
492
|
+
// blackboard yields empty arrays so the rollup is safe on a clean tree.
|
|
493
|
+
let blackboard;
|
|
494
|
+
try {
|
|
495
|
+
blackboard = readBlackboard(projectRoot);
|
|
496
|
+
} catch {
|
|
497
|
+
blackboard = {
|
|
498
|
+
claims: { data: { claims: [] } },
|
|
499
|
+
recent: { findings: [], blockers: [], decisions: [] },
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const filtered = filterByWave(blackboard, waveId);
|
|
504
|
+
// T7: single-writer reconciliation. `blockers_open` is now derived from
|
|
505
|
+
// decisions.jsonl (the SDK's blocker.add/blocker.resolve target) — an array
|
|
506
|
+
// of stable blocker ids. The legacy blackboard `blockers.jsonl` slice is
|
|
507
|
+
// still used to drive `status='blocked'` so existing call sites that emit
|
|
508
|
+
// blockers via `addBlackboardNote(kind:'blocker')` keep working.
|
|
509
|
+
const openBlockers = deriveOpenBlockers(blackboard, waveId);
|
|
510
|
+
// For deriveStatus and renderBody, merge legacy filtered blockers with the
|
|
511
|
+
// SDK-derived ones so any source of an open blocker still flips status.
|
|
512
|
+
const sdkBlockerEntries = openBlockers.ids.map((id, i) => ({
|
|
513
|
+
blockerId: id, message: openBlockers.summaries[i], wave_id: waveId,
|
|
514
|
+
}));
|
|
515
|
+
// Deduplicate by message text — a legacy blocker and an SDK blocker with
|
|
516
|
+
// identical text shouldn't appear twice in the body.
|
|
517
|
+
const blockerMessages = new Set(filtered.blockers.map((b) => b.message ?? ''));
|
|
518
|
+
const mergedBlockers = [...filtered.blockers];
|
|
519
|
+
for (const b of sdkBlockerEntries) {
|
|
520
|
+
if (!blockerMessages.has(b.message)) mergedBlockers.push(b);
|
|
521
|
+
}
|
|
522
|
+
const mergedFiltered = { ...filtered, blockers: mergedBlockers };
|
|
523
|
+
const status = deriveStatus(mergedFiltered, existing);
|
|
524
|
+
|
|
525
|
+
const next = {
|
|
526
|
+
frontmatter: {
|
|
527
|
+
wave_id: waveId,
|
|
528
|
+
status,
|
|
529
|
+
created_at: existing?.frontmatter?.created_at ?? now,
|
|
530
|
+
checkpoint_at: now,
|
|
531
|
+
claims_active: filtered.claims.filter((c) => c.status === 'active').length,
|
|
532
|
+
findings_recent: filtered.findings.slice(-5).map((f) => quoteYamlStr(f.message ?? '')),
|
|
533
|
+
// T7: canonical machine-consumed shape — array of stable blocker ids
|
|
534
|
+
// sourced from decisions.jsonl. Empty when no SDK blockers are open.
|
|
535
|
+
blockers_open: openBlockers.ids,
|
|
536
|
+
// Human-readable summary (optional UI), populated from the same SDK
|
|
537
|
+
// decisions.jsonl records that fed `blockers_open`.
|
|
538
|
+
blockers_open_summary: openBlockers.summaries,
|
|
539
|
+
agents: [...new Set(filtered.claims.map((c) => c.agent ?? c.owner).filter(Boolean))],
|
|
540
|
+
},
|
|
541
|
+
body: renderBody(mergedFiltered, existing),
|
|
542
|
+
};
|
|
543
|
+
|
|
544
|
+
await writeWaveState(waveId, next, projectRoot);
|
|
545
|
+
|
|
546
|
+
// Append summary delta when status changes (audit log).
|
|
547
|
+
const prevStatus = existing?.frontmatter?.status ?? 'new';
|
|
548
|
+
if (prevStatus !== status) {
|
|
549
|
+
await appendSummary(
|
|
550
|
+
waveId,
|
|
551
|
+
{ agent_id: 'checkpointWave', surprises: `status: ${prevStatus} → ${status}` },
|
|
552
|
+
projectRoot,
|
|
553
|
+
);
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// S4 integration: refresh AGENTS.md BLACKBOARD block. Silent on failure —
|
|
557
|
+
// populating AGENTS.md is advisory and must not block checkpointing.
|
|
558
|
+
const populateBlackboardBlock = await loadPopulateBlackboardBlock();
|
|
559
|
+
if (populateBlackboardBlock) {
|
|
560
|
+
try { await populateBlackboardBlock(waveId, projectRoot); } catch { /* advisory */ }
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
return next;
|
|
564
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* worktree-provision.js — v1.5.0 S2: auto-detect + install deps in a fresh worktree.
|
|
3
|
+
*
|
|
4
|
+
* Closes the Step 0 briefing gap: subagents in isolation:worktree get a clean
|
|
5
|
+
* git checkout but no node_modules/. Without this, every Node-touching subagent
|
|
6
|
+
* burns its first ~30s npm install-ing.
|
|
7
|
+
*
|
|
8
|
+
* Security: uses node:child_process execFile (NO shell, no string concat).
|
|
9
|
+
* Detector commands + args are frozen literals; cwd is the only per-call
|
|
10
|
+
* variable and is validated via lstat before invocation. `--ignore-scripts`
|
|
11
|
+
* is non-negotiable on npm install. A subagent worktree with malicious
|
|
12
|
+
* package.json {scripts:{preinstall:"..."}} must not execute arbitrary code.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { execFile } from 'node:child_process';
|
|
16
|
+
import { promisify } from 'node:util';
|
|
17
|
+
import { lstat, readdir } from 'node:fs/promises';
|
|
18
|
+
import { existsSync } from 'node:fs';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
|
|
21
|
+
const execFileP = promisify(execFile);
|
|
22
|
+
|
|
23
|
+
export const DETECTORS = Object.freeze([
|
|
24
|
+
{ name: 'node', file: 'package.json', cmd: 'npm', args: ['install', '--no-audit', '--no-fund', '--ignore-scripts'] },
|
|
25
|
+
{ name: 'python', file: 'pyproject.toml', cmd: 'pip', args: ['install', '--quiet', '-e', '.'] },
|
|
26
|
+
{ name: 'rust', file: 'Cargo.toml', cmd: 'cargo', args: ['fetch'] },
|
|
27
|
+
{ name: 'go', file: 'go.mod', cmd: 'go', args: ['mod', 'download'] },
|
|
28
|
+
]);
|
|
29
|
+
|
|
30
|
+
export const DEFAULT_PER_INSTALL_MS = 2 * 60 * 1000;
|
|
31
|
+
export const DEFAULT_WALL_MS = 5 * 60 * 1000;
|
|
32
|
+
|
|
33
|
+
async function firstLevelDirs(root) {
|
|
34
|
+
try {
|
|
35
|
+
const entries = await readdir(root, { withFileTypes: true });
|
|
36
|
+
return entries.filter(e => e.isDirectory() && !e.name.startsWith('.')).map(e => join(root, e.name));
|
|
37
|
+
} catch { return []; }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function provisionWorktree(worktreePath, opts = {}) {
|
|
41
|
+
const detectors = opts.detectors ?? DETECTORS;
|
|
42
|
+
const perInstallMs = opts.perInstallMs ?? DEFAULT_PER_INSTALL_MS;
|
|
43
|
+
const wallMs = opts.wallMs ?? DEFAULT_WALL_MS;
|
|
44
|
+
const deadline = Date.now() + wallMs;
|
|
45
|
+
const result = { installed: [], skipped: [], failed: [] };
|
|
46
|
+
|
|
47
|
+
const candidateDirs = [worktreePath, ...(await firstLevelDirs(worktreePath))];
|
|
48
|
+
|
|
49
|
+
for (const det of detectors) {
|
|
50
|
+
if (Date.now() >= deadline) {
|
|
51
|
+
result.skipped.push({ name: det.name, reason: 'wall-deadline' });
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
for (const dir of candidateDirs) {
|
|
55
|
+
const manifest = join(dir, det.file);
|
|
56
|
+
if (!existsSync(manifest)) continue;
|
|
57
|
+
try {
|
|
58
|
+
const st = await lstat(manifest);
|
|
59
|
+
if (!st.isFile()) { result.skipped.push({ name: det.name, path: dir, reason: 'not-regular-file' }); continue; }
|
|
60
|
+
} catch { result.skipped.push({ name: det.name, path: dir, reason: 'lstat-failed' }); continue; }
|
|
61
|
+
|
|
62
|
+
const t0 = Date.now();
|
|
63
|
+
try {
|
|
64
|
+
await execFileP(det.cmd, det.args, { cwd: dir, timeout: perInstallMs, maxBuffer: 16 * 1024 * 1024 });
|
|
65
|
+
result.installed.push({ name: det.name, path: dir, ms: Date.now() - t0 });
|
|
66
|
+
} catch (err) {
|
|
67
|
+
// execFile timeout surfaces as either err.code === 'ETIMEDOUT' OR
|
|
68
|
+
// (err.killed && err.signal === 'SIGTERM'/'SIGKILL') depending on
|
|
69
|
+
// platform/Node version. Detect both to give callers a stable signal.
|
|
70
|
+
const timedOut = err.code === 'ETIMEDOUT'
|
|
71
|
+
|| (err.killed && (err.signal === 'SIGTERM' || err.signal === 'SIGKILL'));
|
|
72
|
+
result.failed.push({ name: det.name, path: dir, reason: timedOut ? 'timeout' : (err.code || err.message) });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return result;
|
|
77
|
+
}
|