@ijfw/memory-server 1.4.4 → 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 +1016 -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/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 +38 -3
- 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 +84 -17
- package/src/orchestrator/subagent-telemetry.js +452 -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-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 +94 -17
- 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
|
@@ -7,11 +7,67 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Landed in W10-A0 (v1.4.4 prelude). checkpointWave is a stub;
|
|
9
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.
|
|
10
27
|
*/
|
|
11
28
|
|
|
12
29
|
import { mkdir, readFile, writeFile, rename, appendFile } from 'node:fs/promises';
|
|
13
30
|
import { join } from 'node:path';
|
|
14
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
|
+
}
|
|
15
71
|
|
|
16
72
|
// ---------------------------------------------------------------------------
|
|
17
73
|
// Internal YAML helpers — flat subset only (string/number/boolean/string[])
|
|
@@ -167,23 +223,70 @@ export async function readWaveState(waveId, projectRoot) {
|
|
|
167
223
|
}
|
|
168
224
|
|
|
169
225
|
/**
|
|
170
|
-
* Atomically write a wave's STATE.md
|
|
171
|
-
*
|
|
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).
|
|
172
239
|
*
|
|
173
240
|
* @param {string} waveId
|
|
174
|
-
* @param {{frontmatter: object, body
|
|
241
|
+
* @param {{frontmatter: object, body?: string}} state
|
|
175
242
|
* @param {string} projectRoot
|
|
176
243
|
* @returns {Promise<void>}
|
|
177
244
|
*/
|
|
178
245
|
export async function writeWaveState(waveId, state, projectRoot) {
|
|
179
|
-
const
|
|
180
|
-
|
|
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
|
+
);
|
|
181
261
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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
|
+
}
|
|
187
290
|
}
|
|
188
291
|
|
|
189
292
|
/**
|
|
@@ -226,9 +329,156 @@ export async function appendSummary(waveId, delta, projectRoot) {
|
|
|
226
329
|
});
|
|
227
330
|
}
|
|
228
331
|
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
// Rollup helpers — exported for direct testing (W11-B1 / S5)
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
|
|
229
336
|
/**
|
|
230
|
-
*
|
|
231
|
-
*
|
|
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).
|
|
232
482
|
*
|
|
233
483
|
* @param {string} waveId
|
|
234
484
|
* @param {string} projectRoot
|
|
@@ -238,18 +488,77 @@ export async function checkpointWave(waveId, projectRoot) {
|
|
|
238
488
|
const now = new Date().toISOString();
|
|
239
489
|
const existing = await readWaveState(waveId, projectRoot);
|
|
240
490
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
+
};
|
|
252
543
|
|
|
253
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
|
+
|
|
254
563
|
return next;
|
|
255
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
|
+
}
|
|
@@ -36,6 +36,92 @@ import os from 'node:os';
|
|
|
36
36
|
|
|
37
37
|
const SCHEMA_VERSION = '1.0';
|
|
38
38
|
|
|
39
|
+
// Maximum path length the registry will accept. Practical limits across the
|
|
40
|
+
// supported platforms are PATH_MAX=4096 (Linux), 1024 (macOS), 32767 (Windows
|
|
41
|
+
// long-path). 4096 is a comfortable cap that won't reject any real path but
|
|
42
|
+
// will reject a flood-of-bytes prompt-injection payload.
|
|
43
|
+
const MAX_PROJECT_ROOT_LEN = 4096;
|
|
44
|
+
|
|
45
|
+
// Unicode tag-block range (U+E0000–U+E007F) — invisible "ASCII Smuggler" code
|
|
46
|
+
// points historically used to hide prompt-injection payloads inside text the
|
|
47
|
+
// model sees but the human doesn't. Closed at the prelude in H1.4; we also
|
|
48
|
+
// reject at registry-write time to prevent contamination at the source.
|
|
49
|
+
// eslint-disable-next-line security/detect-unsafe-regex -- single-char Unicode range class; no quantifier; not backtrack-exploitable
|
|
50
|
+
const UNICODE_TAG_BLOCK_RE = /[\u{E0000}-\u{E007F}]/u;
|
|
51
|
+
|
|
52
|
+
// ASCII control characters (excluding nothing — \n \r \t \0 etc are ALL
|
|
53
|
+
// rejected when present in a path key).
|
|
54
|
+
// eslint-disable-next-line no-control-regex -- this regex EXISTS to reject control chars; lint hit is a false positive on the gate itself
|
|
55
|
+
const CONTROL_CHAR_RE = /[\x00-\x1F\x7F]/;
|
|
56
|
+
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
// Validation
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* F-SEC-4 (HIGH, update-install-trust audit v1.5.0):
|
|
63
|
+
* recordOverrideUse persisted any non-empty string as a project key and that
|
|
64
|
+
* value flows verbatim into the cross-project promote suggestion that lands
|
|
65
|
+
* in the prelude the model reads — a prompt-injection vector.
|
|
66
|
+
*
|
|
67
|
+
* Validate projectRoot is shaped like a real absolute filesystem path with
|
|
68
|
+
* no `..` segments, no control characters, no Unicode tag-block smuggling
|
|
69
|
+
* code points, and within a sane length cap. Returns `{ ok: true }` on pass
|
|
70
|
+
* or `{ ok: false, reason }` on fail. We do NOT throw — the caller in
|
|
71
|
+
* override-resolver.js wraps the registry write in a non-fatal try/catch
|
|
72
|
+
* and we want a structured signal instead of a thrown Error so future
|
|
73
|
+
* callers can surface a clean rejection without try/catch noise.
|
|
74
|
+
*
|
|
75
|
+
* @param {unknown} projectRoot
|
|
76
|
+
* @returns {{ ok: true } | { ok: false, reason: string }}
|
|
77
|
+
*/
|
|
78
|
+
function validateProjectRoot(projectRoot) {
|
|
79
|
+
if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
|
|
80
|
+
return { ok: false, reason: 'projectRoot must be a non-empty string' };
|
|
81
|
+
}
|
|
82
|
+
if (projectRoot.length > MAX_PROJECT_ROOT_LEN) {
|
|
83
|
+
return {
|
|
84
|
+
ok: false,
|
|
85
|
+
reason: `projectRoot exceeds max length ${MAX_PROJECT_ROOT_LEN}`,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
if (!path.isAbsolute(projectRoot)) {
|
|
89
|
+
return { ok: false, reason: 'projectRoot must be an absolute path' };
|
|
90
|
+
}
|
|
91
|
+
// Reject any `..` segment in the RAW path before normalization. path.normalize
|
|
92
|
+
// would collapse interior `..` (e.g. `/a/../b` -> `/b`) and silently rewrite
|
|
93
|
+
// the registry key, defeating the validation. A real filesystem path passed
|
|
94
|
+
// by override-resolver.js is already canonical, so a `..` is a tampering
|
|
95
|
+
// signal. Split on both POSIX and Windows separators so /a/../b and \a\..\b
|
|
96
|
+
// are both caught regardless of host platform.
|
|
97
|
+
const rawSegments = projectRoot.split(/[\\/]/);
|
|
98
|
+
if (rawSegments.includes('..')) {
|
|
99
|
+
return { ok: false, reason: 'projectRoot must not contain `..` segments' };
|
|
100
|
+
}
|
|
101
|
+
if (CONTROL_CHAR_RE.test(projectRoot)) {
|
|
102
|
+
return {
|
|
103
|
+
ok: false,
|
|
104
|
+
reason: 'projectRoot must not contain control characters',
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
if (UNICODE_TAG_BLOCK_RE.test(projectRoot)) {
|
|
108
|
+
return {
|
|
109
|
+
ok: false,
|
|
110
|
+
reason: 'projectRoot must not contain Unicode tag-block characters',
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
// Reject prompt-injection / markdown / HTML tokens that are not meaningful
|
|
114
|
+
// in a real path but ARE meaningful when rendered into the prelude. These
|
|
115
|
+
// are not legal in any reasonable filesystem path.
|
|
116
|
+
if (/[<>`]/.test(projectRoot)) {
|
|
117
|
+
return {
|
|
118
|
+
ok: false,
|
|
119
|
+
reason: 'projectRoot must not contain `<`, `>`, or backtick',
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
return { ok: true };
|
|
123
|
+
}
|
|
124
|
+
|
|
39
125
|
// ---------------------------------------------------------------------------
|
|
40
126
|
// Path helpers — read os.homedir()/HOME at call time so tests can swap HOME
|
|
41
127
|
// via process.env between calls.
|
|
@@ -111,21 +197,31 @@ async function readSettings() {
|
|
|
111
197
|
* Idempotent — re-recording the same (project, preset) updates applied_at
|
|
112
198
|
* rather than duplicating the entry.
|
|
113
199
|
*
|
|
114
|
-
*
|
|
200
|
+
* Returns `{ ok: true }` on success or `{ ok: false, reason }` on validation
|
|
201
|
+
* failure. Does NOT throw on bad input — the value flows into the prelude
|
|
202
|
+
* the model reads, so we want every untrusted projectRoot rejected loudly
|
|
203
|
+
* via the return contract instead of via an unhandled Error.
|
|
204
|
+
*
|
|
205
|
+
* @param {string} projectRoot MUST be an absolute path, no `..`, no
|
|
206
|
+
* control / Unicode tag-block / HTML chars.
|
|
115
207
|
* @param {string} preset
|
|
116
208
|
* @param {string} scope 'base' | 'user' | 'org' | 'project'
|
|
117
209
|
* @param {string} [project_type] auto-detected project type, defaults to
|
|
118
210
|
* whatever is already on file or 'unknown'.
|
|
211
|
+
* @returns {Promise<{ ok: true } | { ok: false, reason: string }>}
|
|
119
212
|
*/
|
|
120
213
|
export async function recordOverrideUse(projectRoot, preset, scope, project_type) {
|
|
121
|
-
|
|
122
|
-
|
|
214
|
+
// F-SEC-4 (HIGH): full path validation on projectRoot before it enters the
|
|
215
|
+
// registry. See validateProjectRoot above for the threat-model rationale.
|
|
216
|
+
const pathCheck = validateProjectRoot(projectRoot);
|
|
217
|
+
if (!pathCheck.ok) {
|
|
218
|
+
return pathCheck;
|
|
123
219
|
}
|
|
124
220
|
if (typeof preset !== 'string' || !preset) {
|
|
125
|
-
|
|
221
|
+
return { ok: false, reason: 'preset must be a non-empty string' };
|
|
126
222
|
}
|
|
127
223
|
if (typeof scope !== 'string' || !scope) {
|
|
128
|
-
|
|
224
|
+
return { ok: false, reason: 'scope must be a non-empty string' };
|
|
129
225
|
}
|
|
130
226
|
|
|
131
227
|
const state = await readRegistry();
|
|
@@ -153,6 +249,7 @@ export async function recordOverrideUse(projectRoot, preset, scope, project_type
|
|
|
153
249
|
}
|
|
154
250
|
state.projects[projectRoot] = proj;
|
|
155
251
|
await writeRegistry(state);
|
|
252
|
+
return { ok: true };
|
|
156
253
|
}
|
|
157
254
|
|
|
158
255
|
/**
|
|
@@ -186,6 +283,9 @@ export async function findProjectsWithOverride(preset) {
|
|
|
186
283
|
const out = [];
|
|
187
284
|
for (const [project, entry] of Object.entries(state.projects || {})) {
|
|
188
285
|
if (!entry || !Array.isArray(entry.active_overrides)) continue;
|
|
286
|
+
// F-SEC-4 (HIGH) defense-in-depth: legacy registries written before
|
|
287
|
+
// v1.5.1 validation could contain unsafe keys. Refuse to surface them.
|
|
288
|
+
if (!validateProjectRoot(project).ok) continue;
|
|
189
289
|
const hit = entry.active_overrides.find(
|
|
190
290
|
(o) => o && o.preset === preset
|
|
191
291
|
);
|
|
@@ -226,6 +326,10 @@ export async function findProjectsWithSimilarOverrideSet(
|
|
|
226
326
|
for (const [project, entry] of Object.entries(state.projects || {})) {
|
|
227
327
|
if (exclude && project === exclude) continue;
|
|
228
328
|
if (!entry || !Array.isArray(entry.active_overrides)) continue;
|
|
329
|
+
// F-SEC-4 (HIGH) defense-in-depth: legacy registries written before
|
|
330
|
+
// v1.5.1 validation could contain unsafe keys that would flow into the
|
|
331
|
+
// promote suggestion text and the prelude. Refuse to surface them.
|
|
332
|
+
if (!validateProjectRoot(project).ok) continue;
|
|
229
333
|
const theirs = new Set(
|
|
230
334
|
entry.active_overrides
|
|
231
335
|
.filter((o) => o && typeof o.preset === 'string')
|
|
@@ -304,4 +408,6 @@ export const __test = {
|
|
|
304
408
|
settingsPath,
|
|
305
409
|
readRegistry,
|
|
306
410
|
writeRegistry,
|
|
411
|
+
validateProjectRoot,
|
|
412
|
+
MAX_PROJECT_ROOT_LEN,
|
|
307
413
|
};
|