@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,235 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* status-protocol.js — 4-value agent status protocol + commit-before-report verification.
|
|
3
|
+
*
|
|
4
|
+
* Every implementer agent must end its report with:
|
|
5
|
+
* Status: <VALUE>
|
|
6
|
+
* Branch: <branch>
|
|
7
|
+
* Commit: <sha>
|
|
8
|
+
* Tests: <summary>
|
|
9
|
+
*
|
|
10
|
+
* Landed in W10-A1 (v1.4.4 N2).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { execFileSync } from 'node:child_process';
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Constants
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
|
|
19
|
+
export const STATUS_VALUES = Object.freeze([
|
|
20
|
+
'DONE',
|
|
21
|
+
'DONE_WITH_CONCERNS',
|
|
22
|
+
'NEEDS_CONTEXT',
|
|
23
|
+
'BLOCKED',
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// ProtocolViolation
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
|
|
30
|
+
export class ProtocolViolation extends Error {
|
|
31
|
+
/**
|
|
32
|
+
* @param {string} reason Human-readable explanation
|
|
33
|
+
* @param {string} raw The original report text
|
|
34
|
+
*/
|
|
35
|
+
constructor(reason, raw) {
|
|
36
|
+
super(reason);
|
|
37
|
+
this.name = 'ProtocolViolation';
|
|
38
|
+
this.reason = reason;
|
|
39
|
+
this.raw = raw;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// parseAgentReport
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Parse a structured agent report into its constituent fields.
|
|
49
|
+
*
|
|
50
|
+
* Required field: `Status: <VALUE>` — throws ProtocolViolation if missing or invalid.
|
|
51
|
+
* All other fields are extracted best-effort (undefined if absent).
|
|
52
|
+
*
|
|
53
|
+
* @param {string} reportText
|
|
54
|
+
* @returns {{ status: string, commit_sha?: string, branch?: string, tests?: string,
|
|
55
|
+
* concerns?: string, reason?: string, missing?: string, tried?: string,
|
|
56
|
+
* attempts: number, raw: string }}
|
|
57
|
+
* @throws {ProtocolViolation}
|
|
58
|
+
*/
|
|
59
|
+
export function parseAgentReport(reportText) {
|
|
60
|
+
const raw = reportText;
|
|
61
|
+
|
|
62
|
+
// v1.5.1 H1.2 (audit workflow.md HIGH-F1): take the LAST `^Status:` match,
|
|
63
|
+
// not the first. The protocol places `Status:` at the end of the report, so
|
|
64
|
+
// when an agent quotes a prior wave's `Status: BLOCKED` mid-body, the FIRST
|
|
65
|
+
// match was hijacking the agent's own status at the end. Same fix applied
|
|
66
|
+
// to `extract()` below for Attempts / Branch / Commit / Tests / etc.
|
|
67
|
+
const statusMatches = [...raw.matchAll(/^Status:\s*(\S+)\s*$/gm)];
|
|
68
|
+
const statusMatch = statusMatches[statusMatches.length - 1];
|
|
69
|
+
if (!statusMatch) {
|
|
70
|
+
throw new ProtocolViolation('missing Status: line in agent report', raw);
|
|
71
|
+
}
|
|
72
|
+
const status = statusMatch[1];
|
|
73
|
+
if (!STATUS_VALUES.includes(status)) {
|
|
74
|
+
throw new ProtocolViolation(
|
|
75
|
+
`invalid status "${status}"; expected one of ${STATUS_VALUES.join(', ')}`,
|
|
76
|
+
raw,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
status,
|
|
82
|
+
commit_sha: extract(raw, 'Commit'),
|
|
83
|
+
branch: extract(raw, 'Branch'),
|
|
84
|
+
tests: extract(raw, 'Tests'),
|
|
85
|
+
concerns: extract(raw, 'Concerns'),
|
|
86
|
+
reason: extract(raw, 'Reason'),
|
|
87
|
+
missing: extract(raw, 'Missing'),
|
|
88
|
+
tried: extract(raw, 'Tried'),
|
|
89
|
+
// v1.5.0-major S07 (W12-A): 3-attempt cap signal. Opt-in; defaults to 0
|
|
90
|
+
// when an implementer omits the line — preserving prior behavior for
|
|
91
|
+
// every existing report. Implementers using ijfw-executor.md set this to
|
|
92
|
+
// the max auto-fix attempts on any single issue (Rules 1-3).
|
|
93
|
+
attempts: parseInt(extract(raw, 'Attempts') || '0', 10),
|
|
94
|
+
raw,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Extract the LAST single-line field value, or undefined if absent.
|
|
100
|
+
*
|
|
101
|
+
* v1.5.1 H1.2 (audit workflow.md HIGH-F1 / MED-C2): take the last match, not
|
|
102
|
+
* the first, so a quoted prior `Attempts: 5` (or any other field) does not
|
|
103
|
+
* override the agent's own value at the end of the report.
|
|
104
|
+
*
|
|
105
|
+
* Callers pass static field names (Commit, Branch, Tests, Attempts, etc.), so
|
|
106
|
+
* regex injection through `field` is not a concern — but we keep this comment
|
|
107
|
+
* as a tripwire: if you ever pass a user-controlled string here, escape it.
|
|
108
|
+
*/
|
|
109
|
+
// v1.5.0 audit-LOW-work-L1: defensive escapeRegExp around the field name.
|
|
110
|
+
// Today callers only pass static field names (Commit, Branch, Tests, etc.) so
|
|
111
|
+
// regex injection is impossible -- but the previous comment was a tripwire,
|
|
112
|
+
// not a guard. This makes the function safe even if a future caller forgets.
|
|
113
|
+
function escapeRegExp(s) {
|
|
114
|
+
return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function extract(text, field) {
|
|
118
|
+
const matches = [...text.matchAll(new RegExp(`^${escapeRegExp(field)}:\\s*(.+?)\\s*$`, 'gm'))];
|
|
119
|
+
const last = matches[matches.length - 1];
|
|
120
|
+
return last ? last[1] : undefined;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
// verifyFreshCommit (internal)
|
|
125
|
+
// ---------------------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Returns true if the commit at `sha` was authored at or after
|
|
129
|
+
* (dispatchTimestamp - 1s tolerance) AND is reachable from the dispatched branch.
|
|
130
|
+
*
|
|
131
|
+
* v1.5.0 S3 (W11-A3): branch-tuple check closes the r13-M-N2 bypass where a
|
|
132
|
+
* stale commit on main could pass as "fresh" because the time window happened
|
|
133
|
+
* to match. Empty/undefined branch falls back to time-only (detached HEAD or
|
|
134
|
+
* implicit-main case — orchestrator's choice whether to enforce).
|
|
135
|
+
*
|
|
136
|
+
* @param {string|undefined} sha
|
|
137
|
+
* @param {string|undefined} branch Dispatched branch name (empty = skip membership check)
|
|
138
|
+
* @param {number} dispatchTimestamp Unix seconds
|
|
139
|
+
* @param {{ projectRoot: string }} ctx
|
|
140
|
+
* @returns {boolean}
|
|
141
|
+
*/
|
|
142
|
+
function verifyFreshCommit(sha, branch, dispatchTimestamp, ctx) {
|
|
143
|
+
if (!sha) return false;
|
|
144
|
+
try {
|
|
145
|
+
// 1. Freshness check.
|
|
146
|
+
// r13-M-02: 1s tolerance for clock skew.
|
|
147
|
+
// v1.5.0 audit-LOW-work-L4: bumped to 2s to close the Windows-share
|
|
148
|
+
// mtime-granularity edge case (FAT/SMB filesystems report 2s
|
|
149
|
+
// resolution; a 1s window can false-reject a genuinely-fresh commit
|
|
150
|
+
// when the orchestrator clock and the worktree filesystem disagree
|
|
151
|
+
// by ~1s). 2s is still tight enough that the original "stale commit
|
|
152
|
+
// that happens to match the time window" attack stays out of reach.
|
|
153
|
+
const tsOut = execFileSync(
|
|
154
|
+
'git',
|
|
155
|
+
['log', '-1', '--format=%ct', sha],
|
|
156
|
+
{ cwd: ctx.projectRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
|
|
157
|
+
).trim();
|
|
158
|
+
const commitTs = parseInt(tsOut, 10);
|
|
159
|
+
if (!Number.isFinite(commitTs) || commitTs < dispatchTimestamp - 2) return false;
|
|
160
|
+
|
|
161
|
+
// 2. v1.5.0 S3: branch-tuple check. Closes the "stale commit from main passes
|
|
162
|
+
// as fresh because the time window happens to match" bypass that r13-M-N2
|
|
163
|
+
// deferred to the structural fix.
|
|
164
|
+
// Empty branch = detached HEAD or implicit-main — skip membership check
|
|
165
|
+
// (orchestrator's choice whether to enforce).
|
|
166
|
+
if (branch && branch.length > 0) {
|
|
167
|
+
const branchOut = execFileSync(
|
|
168
|
+
'git',
|
|
169
|
+
['branch', '--contains', sha, '--list', branch],
|
|
170
|
+
{ cwd: ctx.projectRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] },
|
|
171
|
+
);
|
|
172
|
+
if (branchOut.trim().length === 0) return false;
|
|
173
|
+
}
|
|
174
|
+
return true;
|
|
175
|
+
} catch {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// handleStatus
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Decide the orchestrator action based on a parsed agent report.
|
|
186
|
+
*
|
|
187
|
+
* @param {{ status: string, commit_sha?: string, branch?: string, concerns?: string,
|
|
188
|
+
* missing?: string, reason?: string, tried?: string, attempts?: number }} parsed
|
|
189
|
+
* @param {number} dispatchTimestamp Unix seconds (Date.now()/1000 at dispatch)
|
|
190
|
+
* @param {{ projectRoot: string }} ctx
|
|
191
|
+
* @returns {{ action: string, [key: string]: unknown }}
|
|
192
|
+
*/
|
|
193
|
+
export function handleStatus(parsed, dispatchTimestamp, ctx) {
|
|
194
|
+
// v1.5.0-major S07 (W12-A): 3-attempt cap is a hard escalation signal
|
|
195
|
+
// regardless of reported status. If an implementer (ijfw-executor.md) ran
|
|
196
|
+
// out the per-issue auto-fix budget, the orchestrator MUST surface to the
|
|
197
|
+
// user — even if the agent claims DONE — because by definition the
|
|
198
|
+
// remaining issue is documented but unfixed. R2's #1 pattern: convert
|
|
199
|
+
// truncation from a behavior problem to a budget problem.
|
|
200
|
+
if (typeof parsed.attempts === 'number' && parsed.attempts >= 3) {
|
|
201
|
+
return {
|
|
202
|
+
action: 'escalate_to_user',
|
|
203
|
+
reason: '3-attempt-cap-hit',
|
|
204
|
+
original_status: parsed.status,
|
|
205
|
+
original_action: undefined,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
switch (parsed.status) {
|
|
209
|
+
case 'DONE': {
|
|
210
|
+
const fresh = verifyFreshCommit(
|
|
211
|
+
parsed.commit_sha,
|
|
212
|
+
parsed.branch,
|
|
213
|
+
dispatchTimestamp,
|
|
214
|
+
ctx,
|
|
215
|
+
);
|
|
216
|
+
if (!fresh) {
|
|
217
|
+
return { action: 'redispatch_needs_context', missing: 'commit-before-report' };
|
|
218
|
+
}
|
|
219
|
+
return { action: 'proceed_to_review', commit_sha: parsed.commit_sha };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
case 'DONE_WITH_CONCERNS':
|
|
223
|
+
return { action: 'proceed_with_flag', concerns: parsed.concerns };
|
|
224
|
+
|
|
225
|
+
case 'NEEDS_CONTEXT':
|
|
226
|
+
return { action: 'redispatch_with_context', missing: parsed.missing };
|
|
227
|
+
|
|
228
|
+
case 'BLOCKED':
|
|
229
|
+
return { action: 'escalate_to_user', reason: parsed.reason, tried: parsed.tried };
|
|
230
|
+
|
|
231
|
+
default:
|
|
232
|
+
// STATUS_VALUES is exhaustive; parseAgentReport guards this.
|
|
233
|
+
throw new ProtocolViolation(`unhandled status "${parsed.status}"`, parsed.raw ?? '');
|
|
234
|
+
}
|
|
235
|
+
}
|
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* subagent-telemetry.js — v1.5.0 S1: subagent checkpoint/resume telemetry.
|
|
3
|
+
*
|
|
4
|
+
* Closes 8/13 truncation pattern observed across v1.4.4 Wave 10 + v1.5.0
|
|
5
|
+
* research dispatch. Implementer agents call `recordCheckpoint` via the
|
|
6
|
+
* (forthcoming) `ijfw checkpoint` CLI to persist progress before the
|
|
7
|
+
* Claude Code subagent harness's ~20-tool / 60s wall-clock cap fires.
|
|
8
|
+
* The orchestrator calls `listOrphanedSubagents` post-wave to detect
|
|
9
|
+
* truncations, and `readLastCheckpoint` to resume mid-execution.
|
|
10
|
+
*
|
|
11
|
+
* Storage layout:
|
|
12
|
+
* <projectRoot>/.ijfw/wave-<waveId>/subagent-<subId>.checkpoint.json
|
|
13
|
+
* Lock:
|
|
14
|
+
* <projectRoot>/.ijfw/wave-<waveId>/.subagent-<subId>.lock
|
|
15
|
+
*
|
|
16
|
+
* v1.5.0 T9: checkpoint/summary/violation writes route through the state-SDK
|
|
17
|
+
* verbs (`subagent.checkpoint`, `event.emit`) instead of raw fs writes.
|
|
18
|
+
* Append ops carry stable dedupKeys derived from their inputs.
|
|
19
|
+
*
|
|
20
|
+
* Frozen surface for Wave 11-A (S1 rest, S2, S3) — do not change signatures.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { mkdir, readFile, readdir, copyFile, stat } from 'node:fs/promises';
|
|
24
|
+
import { join } from 'node:path';
|
|
25
|
+
import { createHash } from 'node:crypto';
|
|
26
|
+
import { query } from './state-sdk.js';
|
|
27
|
+
import { pollEvents } from './state-events.js';
|
|
28
|
+
// v1.5.0 N4.obs M1+M2: trace-id + hierarchical observation path.
|
|
29
|
+
import { getTraceId, composePath } from '../observability/trace-id.js';
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Frozen constants — Wave 11-A imports these directly
|
|
33
|
+
// ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
export const MAX_CHECKPOINT_SIZE = 4 * 1024;
|
|
36
|
+
export const SUB_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
|
37
|
+
export const WAVE_ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Internal helpers
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
function validateWaveId(waveId) {
|
|
44
|
+
if (typeof waveId !== 'string' || !WAVE_ID_PATTERN.test(waveId)) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`subagent-telemetry: invalid waveId "${waveId}" — must match ${WAVE_ID_PATTERN}`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function validateSubId(subId) {
|
|
52
|
+
if (typeof subId !== 'string' || !SUB_ID_PATTERN.test(subId)) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`subagent-telemetry: invalid subId "${subId}" — must match ${SUB_ID_PATTERN}`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function checkpointPaths(waveId, subId, projectRoot) {
|
|
60
|
+
const dir = join(projectRoot, '.ijfw', `wave-${waveId}`);
|
|
61
|
+
return {
|
|
62
|
+
dir,
|
|
63
|
+
file: join(dir, `subagent-${subId}.checkpoint.json`),
|
|
64
|
+
lock: join(dir, `.subagent-${subId}.lock`),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Derive a short hash string for use in dedupKeys. Stable across calls with
|
|
70
|
+
* the same input — sha256 of JSON-serialised payload, first 12 hex chars.
|
|
71
|
+
*/
|
|
72
|
+
function shortHash(obj) {
|
|
73
|
+
return createHash('sha256').update(JSON.stringify(obj)).digest('hex').slice(0, 12);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
// Public API — FROZEN for Wave 11-A
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Atomically record a subagent checkpoint. Caller-supplied `checkpoint` is
|
|
82
|
+
* merged into a payload envelope with schema_version/wave_id/sub_id/ts.
|
|
83
|
+
*
|
|
84
|
+
* @param {string} waveId e.g. "W11-A0"
|
|
85
|
+
* @param {string} subId e.g. "W11-A1"
|
|
86
|
+
* @param {object} checkpoint arbitrary JSON (e.g. tool_use_count, last_action)
|
|
87
|
+
* @param {string} projectRoot absolute path to project root
|
|
88
|
+
* @returns {Promise<void>}
|
|
89
|
+
*/
|
|
90
|
+
export async function recordCheckpoint(waveId, subId, checkpoint, projectRoot) {
|
|
91
|
+
validateWaveId(waveId);
|
|
92
|
+
validateSubId(subId);
|
|
93
|
+
|
|
94
|
+
// v1.5.0-major S01: when running in a worktree subagent, the parent orchestrator
|
|
95
|
+
// passes IJFW_PARENT_PROJECT_ROOT in env so checkpoints land in the parent's
|
|
96
|
+
// .ijfw/ directory (visible after worktree cleanup). Backward-compatible:
|
|
97
|
+
// missing env var → use projectRoot as before.
|
|
98
|
+
const effectiveRoot = process.env.IJFW_PARENT_PROJECT_ROOT ?? projectRoot;
|
|
99
|
+
|
|
100
|
+
// v1.5.0 N4.obs M1: tag every checkpoint with the orchestrator's trace_id when
|
|
101
|
+
// one is available (env-var inheritance or already-cached). Never fabricate
|
|
102
|
+
// here -- if the env isn't set + the orchestrator never called ensureTraceId,
|
|
103
|
+
// we skip the field so old consumers don't see a half-populated id.
|
|
104
|
+
const traceId = getTraceId();
|
|
105
|
+
// v1.5.0 N4.obs M2: hierarchical observation path. The orchestrator can pass
|
|
106
|
+
// a richer path via `checkpoint.path`; otherwise we synthesise a default that
|
|
107
|
+
// a UI tree-view can group on: /wave-<waveId>/sub-<subId>.
|
|
108
|
+
const observPath = (typeof checkpoint.path === 'string' && checkpoint.path.length > 0)
|
|
109
|
+
? checkpoint.path
|
|
110
|
+
: composePath({ waveId, subId });
|
|
111
|
+
|
|
112
|
+
// Build the checkpoint envelope (preserving existing field contract for
|
|
113
|
+
// readLastCheckpoint consumers). The envelope is passed as the `checkpoint`
|
|
114
|
+
// object to the SDK verb and stored nested under that key.
|
|
115
|
+
const ts = new Date().toISOString();
|
|
116
|
+
const envelope = {
|
|
117
|
+
schema_version: 1,
|
|
118
|
+
wave_id: waveId,
|
|
119
|
+
sub_id: subId,
|
|
120
|
+
ts,
|
|
121
|
+
...(traceId ? { trace_id: traceId } : {}),
|
|
122
|
+
path: observPath,
|
|
123
|
+
...checkpoint,
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const serialised = JSON.stringify(envelope);
|
|
127
|
+
if (serialised.length > MAX_CHECKPOINT_SIZE) {
|
|
128
|
+
throw new Error(
|
|
129
|
+
`subagent-telemetry: checkpoint size ${serialised.length} exceeds MAX_CHECKPOINT_SIZE ${MAX_CHECKPOINT_SIZE}`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// v1.5.0 T9: route through state-SDK verb — no raw fs write.
|
|
134
|
+
// dedupKey is stable for this (subId, ts) pair: callers that retry within
|
|
135
|
+
// the same millisecond get a no-op rather than a double-write.
|
|
136
|
+
const dedupKey = `checkpoint:${subId}:${ts}`;
|
|
137
|
+
await query(
|
|
138
|
+
'subagent.checkpoint',
|
|
139
|
+
{ waveId, subagentId: subId, checkpoint: envelope, dedupKey },
|
|
140
|
+
{ projectRoot: effectiveRoot },
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Read the most recent checkpoint for a (waveId, subId) pair.
|
|
146
|
+
* Returns parsed JSON object, or `null` if the file doesn't exist yet.
|
|
147
|
+
*
|
|
148
|
+
* @param {string} waveId
|
|
149
|
+
* @param {string} subId
|
|
150
|
+
* @param {string} projectRoot
|
|
151
|
+
* @returns {Promise<object|null>}
|
|
152
|
+
*/
|
|
153
|
+
export async function readLastCheckpoint(waveId, subId, projectRoot) {
|
|
154
|
+
validateWaveId(waveId);
|
|
155
|
+
validateSubId(subId);
|
|
156
|
+
|
|
157
|
+
// v1.5.0-major S01: honor IJFW_PARENT_PROJECT_ROOT for worktree subagents.
|
|
158
|
+
const effectiveRoot = process.env.IJFW_PARENT_PROJECT_ROOT ?? projectRoot;
|
|
159
|
+
|
|
160
|
+
const { file } = checkpointPaths(waveId, subId, effectiveRoot);
|
|
161
|
+
let raw;
|
|
162
|
+
try {
|
|
163
|
+
raw = await readFile(file, 'utf8');
|
|
164
|
+
} catch (err) {
|
|
165
|
+
if (err && err.code === 'ENOENT') return null;
|
|
166
|
+
throw err;
|
|
167
|
+
}
|
|
168
|
+
const parsed = JSON.parse(raw);
|
|
169
|
+
// v1.5.0 T9: the SDK verb writes `{ waveId, subagentId, dedupKey, checkpoint,
|
|
170
|
+
// updated_at }` — the caller-supplied envelope lives nested under `checkpoint`.
|
|
171
|
+
// Unwrap to restore the flat shape that readLastCheckpoint consumers expect.
|
|
172
|
+
// Old-format files (no `checkpoint` key, have `schema_version`) are returned
|
|
173
|
+
// as-is for backward compatibility during migration.
|
|
174
|
+
if (parsed && typeof parsed.checkpoint === 'object' && parsed.checkpoint !== null
|
|
175
|
+
&& typeof parsed.waveId === 'string') {
|
|
176
|
+
return parsed.checkpoint;
|
|
177
|
+
}
|
|
178
|
+
return parsed;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* v1.5.0 T9: Append a summary event for a subagent via the `event.emit` verb.
|
|
183
|
+
* Routes through the state-SDK — no raw fs write.
|
|
184
|
+
*
|
|
185
|
+
* @param {string} waveId
|
|
186
|
+
* @param {string} subId
|
|
187
|
+
* @param {object} data arbitrary JSON (summary body ≤ 4 KiB)
|
|
188
|
+
* @param {string} projectRoot
|
|
189
|
+
* @returns {Promise<{ok: boolean, seq?: number, deduped?: boolean}>}
|
|
190
|
+
*/
|
|
191
|
+
export async function appendSummary(waveId, subId, data, projectRoot) {
|
|
192
|
+
validateWaveId(waveId);
|
|
193
|
+
validateSubId(subId);
|
|
194
|
+
if (!data || typeof data !== 'object') {
|
|
195
|
+
throw new Error('subagent-telemetry: appendSummary requires a data object');
|
|
196
|
+
}
|
|
197
|
+
// v1.5.0-major S01: honor IJFW_PARENT_PROJECT_ROOT for worktree subagents.
|
|
198
|
+
const effectiveRoot = process.env.IJFW_PARENT_PROJECT_ROOT ?? projectRoot;
|
|
199
|
+
// dedupKey: stable hash of (waveId, subId, data) — identical retries are no-ops.
|
|
200
|
+
const dedupKey = `summary:${subId}:${shortHash({ waveId, subId, data })}`;
|
|
201
|
+
return query(
|
|
202
|
+
'event.emit',
|
|
203
|
+
{ subagentId: subId, waveId, eventType: 'summary', data, dedupKey },
|
|
204
|
+
{ projectRoot: effectiveRoot },
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* v1.5.0 T9: Record a violation event for a subagent via the `event.emit` verb.
|
|
210
|
+
* Routes through the state-SDK — no raw fs write.
|
|
211
|
+
*
|
|
212
|
+
* @param {string} waveId
|
|
213
|
+
* @param {string} subId
|
|
214
|
+
* @param {object} data violation payload (type, message, etc.) ≤ 4 KiB
|
|
215
|
+
* @param {string} projectRoot
|
|
216
|
+
* @returns {Promise<{ok: boolean, seq?: number, deduped?: boolean}>}
|
|
217
|
+
*/
|
|
218
|
+
export async function recordViolation(waveId, subId, data, projectRoot) {
|
|
219
|
+
validateWaveId(waveId);
|
|
220
|
+
validateSubId(subId);
|
|
221
|
+
if (!data || typeof data !== 'object') {
|
|
222
|
+
throw new Error('subagent-telemetry: recordViolation requires a data object');
|
|
223
|
+
}
|
|
224
|
+
// v1.5.0-major S01: honor IJFW_PARENT_PROJECT_ROOT for worktree subagents.
|
|
225
|
+
const effectiveRoot = process.env.IJFW_PARENT_PROJECT_ROOT ?? projectRoot;
|
|
226
|
+
// dedupKey: stable hash of (waveId, subId, data) — identical retries are no-ops.
|
|
227
|
+
const dedupKey = `violation:${subId}:${shortHash({ waveId, subId, data })}`;
|
|
228
|
+
return query(
|
|
229
|
+
'event.emit',
|
|
230
|
+
{ subagentId: subId, waveId, eventType: 'violation', data, dedupKey },
|
|
231
|
+
{ projectRoot: effectiveRoot },
|
|
232
|
+
);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* List subagent IDs that have a checkpoint file but are NOT in the wave's
|
|
237
|
+
* completed-subs set. Used by the orchestrator post-wave to detect truncated
|
|
238
|
+
* subagents.
|
|
239
|
+
*
|
|
240
|
+
* v1 (W11-A0): the completed-subs set is empty (we don't yet consume
|
|
241
|
+
* STATE.md frontmatter.completed_subs). This returns ALL subIds with a
|
|
242
|
+
* checkpoint file. W11-A1 will refine by reading the wave STATE.md.
|
|
243
|
+
*
|
|
244
|
+
* v1.5.0-major S01: accepts optional `additionalRoots: string[]` — additional
|
|
245
|
+
* project roots to scan (e.g. active git worktree paths). This lets the
|
|
246
|
+
* orchestrator see checkpoints written by worktree subagents BEFORE drain runs,
|
|
247
|
+
* by inspecting both the parent root and each worktree root. Honors
|
|
248
|
+
* IJFW_PARENT_PROJECT_ROOT for the primary `projectRoot` argument like
|
|
249
|
+
* record/read do (worktree subagents calling this still see the parent dir).
|
|
250
|
+
*
|
|
251
|
+
* Returns [] if no directory exists. Returned IDs are deduplicated.
|
|
252
|
+
*
|
|
253
|
+
* @param {string} waveId
|
|
254
|
+
* @param {string} projectRoot
|
|
255
|
+
* @param {string[]} [additionalRoots] optional extra roots to scan
|
|
256
|
+
* @returns {Promise<string[]>}
|
|
257
|
+
*/
|
|
258
|
+
export async function listOrphanedSubagents(waveId, projectRoot, additionalRoots = []) {
|
|
259
|
+
validateWaveId(waveId);
|
|
260
|
+
|
|
261
|
+
// v1.5.0-major S01: honor IJFW_PARENT_PROJECT_ROOT for the primary root.
|
|
262
|
+
const effectiveRoot = process.env.IJFW_PARENT_PROJECT_ROOT ?? projectRoot;
|
|
263
|
+
|
|
264
|
+
const rootsToScan = [effectiveRoot, ...(Array.isArray(additionalRoots) ? additionalRoots : [])];
|
|
265
|
+
|
|
266
|
+
const seen = new Set();
|
|
267
|
+
for (const root of rootsToScan) {
|
|
268
|
+
if (typeof root !== 'string' || root.length === 0) continue;
|
|
269
|
+
const dir = join(root, '.ijfw', `wave-${waveId}`);
|
|
270
|
+
let entries;
|
|
271
|
+
try {
|
|
272
|
+
entries = await readdir(dir);
|
|
273
|
+
} catch (err) {
|
|
274
|
+
if (err && err.code === 'ENOENT') continue;
|
|
275
|
+
throw err;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
for (const name of entries) {
|
|
279
|
+
// Match subagent-<subId>.checkpoint.json
|
|
280
|
+
const m = name.match(/^subagent-(.+)\.checkpoint\.json$/);
|
|
281
|
+
if (!m) continue;
|
|
282
|
+
const subId = m[1];
|
|
283
|
+
// Defence in depth: skip entries that wouldn't pass the pattern (e.g.
|
|
284
|
+
// an attacker-crafted filename someone dropped in by hand).
|
|
285
|
+
if (!SUB_ID_PATTERN.test(subId)) continue;
|
|
286
|
+
seen.add(subId);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// Completed-subs set is empty in v1 — every subId with a checkpoint is
|
|
291
|
+
// "orphaned" from the orchestrator's POV until W11-A1 wires in STATE.md.
|
|
292
|
+
return [...seen];
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* v1.5.0-major S01: drain checkpoint files from a worktree's .ijfw/wave-<id>/
|
|
297
|
+
* directory into the parent project's .ijfw/wave-<id>/ directory.
|
|
298
|
+
*
|
|
299
|
+
* Belt-and-suspenders for the IJFW_PARENT_PROJECT_ROOT env-var passthrough:
|
|
300
|
+
* if a subagent runs without the env var set (older callers, manual `claude`
|
|
301
|
+
* invocation in a worktree, etc.) the checkpoint will land in the worktree's
|
|
302
|
+
* own .ijfw/ — this drain copies them into the parent before
|
|
303
|
+
* `git worktree remove` deletes them forever.
|
|
304
|
+
*
|
|
305
|
+
* Idempotent: re-running with the same source overwrites destination (same
|
|
306
|
+
* checkpoint content). Filenames are preserved.
|
|
307
|
+
*
|
|
308
|
+
* @param {string} waveId
|
|
309
|
+
* @param {string} worktreePath absolute path to the worktree root
|
|
310
|
+
* @param {string} projectRoot absolute path to the parent project root
|
|
311
|
+
* @returns {Promise<{ok: true, drained: number} | {ok: false, reason: string}>}
|
|
312
|
+
*/
|
|
313
|
+
export async function drainCheckpoints(waveId, worktreePath, projectRoot) {
|
|
314
|
+
validateWaveId(waveId);
|
|
315
|
+
if (typeof worktreePath !== 'string' || worktreePath.length === 0) {
|
|
316
|
+
return { ok: false, reason: 'invalid worktreePath' };
|
|
317
|
+
}
|
|
318
|
+
if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
|
|
319
|
+
return { ok: false, reason: 'invalid projectRoot' };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const sourceDir = join(worktreePath, '.ijfw', `wave-${waveId}`);
|
|
323
|
+
const destDir = join(projectRoot, '.ijfw', `wave-${waveId}`);
|
|
324
|
+
|
|
325
|
+
let entries;
|
|
326
|
+
try {
|
|
327
|
+
entries = await readdir(sourceDir);
|
|
328
|
+
} catch (err) {
|
|
329
|
+
if (err && err.code === 'ENOENT') return { ok: true, drained: 0 };
|
|
330
|
+
return { ok: false, reason: `readdir ${sourceDir}: ${err.message}` };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
await mkdir(destDir, { recursive: true });
|
|
334
|
+
|
|
335
|
+
// v1.5.0 audit-LOW-work-L3: parallelise the per-file copy. The previous
|
|
336
|
+
// sequential await pinned us to O(N * disk-latency); on a wave with 20+
|
|
337
|
+
// subagents this is the difference between ~1s and "feels instant".
|
|
338
|
+
// Each entry has independent src/dst paths so there's no ordering
|
|
339
|
+
// requirement and no shared state to coordinate.
|
|
340
|
+
const tasks = entries.map(async (name) => {
|
|
341
|
+
const m = name.match(/^subagent-(.+)\.checkpoint\.json$/);
|
|
342
|
+
if (!m) return 0;
|
|
343
|
+
const subId = m[1];
|
|
344
|
+
if (!SUB_ID_PATTERN.test(subId)) return 0;
|
|
345
|
+
|
|
346
|
+
const src = join(sourceDir, name);
|
|
347
|
+
const dst = join(destDir, name);
|
|
348
|
+
try {
|
|
349
|
+
const srcStat = await stat(src);
|
|
350
|
+
if (!srcStat.isFile()) return 0;
|
|
351
|
+
} catch {
|
|
352
|
+
return 0;
|
|
353
|
+
}
|
|
354
|
+
try {
|
|
355
|
+
await copyFile(src, dst);
|
|
356
|
+
return 1;
|
|
357
|
+
} catch {
|
|
358
|
+
return 0;
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
const counts = await Promise.all(tasks);
|
|
362
|
+
const drained = counts.reduce((a, b) => a + b, 0);
|
|
363
|
+
|
|
364
|
+
return { ok: true, drained };
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ---------------------------------------------------------------------------
|
|
368
|
+
// v1.5.0 T19 (G1) — subagent dispatch + event-stream reader
|
|
369
|
+
//
|
|
370
|
+
// Thin SDK-facing helpers the orchestrator uses to (1) dispatch a subagent
|
|
371
|
+
// via the deterministic `subagent.dispatch` verb and (2) poll its per-
|
|
372
|
+
// subagent event log to see verbs arrive live (every verb the subagent
|
|
373
|
+
// dispatches fires `_emitEvent` per T5 / state-events.js).
|
|
374
|
+
// ---------------------------------------------------------------------------
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Dispatch a subagent via the `subagent.dispatch` state-SDK verb. Wraps the
|
|
378
|
+
* raw `query()` call with the documented orchestrator-side signature
|
|
379
|
+
* (waveId, subId, role, brief, projectRoot, opts) and returns the verb's
|
|
380
|
+
* `{ ok, dispatchBrief, subagentId, waveId, mode, isolation, inheritedEnv,
|
|
381
|
+
* eventLogPath }` shape verbatim.
|
|
382
|
+
*
|
|
383
|
+
* The dispatch brief is DETERMINISTIC on Claude (handed to the native
|
|
384
|
+
* subagent primitive) and a best-effort PROMPT TEMPLATE elsewhere — the
|
|
385
|
+
* `mode` field discriminates. The SDK env-var contract is baked into the
|
|
386
|
+
* brief and returned via `inheritedEnv` for the caller to seed the
|
|
387
|
+
* subagent process when the platform supports env passthrough.
|
|
388
|
+
*
|
|
389
|
+
* @param {string} waveId wave id (matches /^[A-Za-z0-9_-]{1,64}$/)
|
|
390
|
+
* @param {string} subId subagent id (matches /^[A-Za-z0-9_-]{1,64}$/)
|
|
391
|
+
* @param {string} role role label (e.g. 'implementer', 'reviewer')
|
|
392
|
+
* @param {string} brief the task brief markdown
|
|
393
|
+
* @param {string} projectRoot absolute path to the project root
|
|
394
|
+
* @param {{isolation?: 'shared'|'worktree', env?: object, platform?: string,
|
|
395
|
+
* subagentId?: string}} [opts]
|
|
396
|
+
* @returns {Promise<object>} the verb result (see contract §7
|
|
397
|
+
* `subagent.dispatch`).
|
|
398
|
+
*/
|
|
399
|
+
export async function dispatchSubagent(waveId, subId, role, brief, projectRoot, opts = {}) {
|
|
400
|
+
validateWaveId(waveId);
|
|
401
|
+
validateSubId(subId);
|
|
402
|
+
if (typeof brief !== 'string' || brief.length === 0) {
|
|
403
|
+
throw new Error('subagent-telemetry: dispatchSubagent requires a non-empty brief');
|
|
404
|
+
}
|
|
405
|
+
if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
|
|
406
|
+
throw new Error('subagent-telemetry: dispatchSubagent requires projectRoot');
|
|
407
|
+
}
|
|
408
|
+
const payload = {
|
|
409
|
+
subagentId: subId,
|
|
410
|
+
waveId,
|
|
411
|
+
brief,
|
|
412
|
+
isolation: opts.isolation === 'shared' ? 'shared' : 'worktree',
|
|
413
|
+
};
|
|
414
|
+
if (typeof role === 'string' && role.length > 0) payload.role = role;
|
|
415
|
+
if (opts.env && typeof opts.env === 'object' && !Array.isArray(opts.env)) {
|
|
416
|
+
payload.env = opts.env;
|
|
417
|
+
}
|
|
418
|
+
const ctx = { projectRoot };
|
|
419
|
+
if (typeof opts.platform === 'string') ctx.platform = opts.platform;
|
|
420
|
+
if (typeof opts.subagentId === 'string') ctx.subagentId = opts.subagentId;
|
|
421
|
+
return query('subagent.dispatch', payload, ctx);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Stream the per-subagent event log. Returns the events with `seq > since`
|
|
426
|
+
* (the cursor), plus the highest `seq` seen so the caller can feed it back
|
|
427
|
+
* on the next poll. NEVER uses `fs.watch` — explicit-interval polling per
|
|
428
|
+
* contract §5 (works across all 13 platforms).
|
|
429
|
+
*
|
|
430
|
+
* Every verb the subagent calls fires `_emitEvent` after lock release; the
|
|
431
|
+
* tap envelope carries `{ seq, verb, subagentId, ts, verbId, outcome,
|
|
432
|
+
* payloadDigest }` — exactly the rows this poller surfaces.
|
|
433
|
+
*
|
|
434
|
+
* @param {string} waveId
|
|
435
|
+
* @param {string} subId
|
|
436
|
+
* @param {string} projectRoot
|
|
437
|
+
* @param {number} [since] highest seq already processed (default 0)
|
|
438
|
+
* @returns {{events: object[], cursor: number}}
|
|
439
|
+
*/
|
|
440
|
+
export function streamSubagentEvents(waveId, subId, projectRoot, since = 0) {
|
|
441
|
+
validateWaveId(waveId);
|
|
442
|
+
validateSubId(subId);
|
|
443
|
+
if (typeof projectRoot !== 'string' || projectRoot.length === 0) {
|
|
444
|
+
throw new Error('subagent-telemetry: streamSubagentEvents requires projectRoot');
|
|
445
|
+
}
|
|
446
|
+
return pollEvents({
|
|
447
|
+
projectRoot,
|
|
448
|
+
waveId,
|
|
449
|
+
subagentId: subId,
|
|
450
|
+
since: Number.isFinite(since) ? since : 0,
|
|
451
|
+
});
|
|
452
|
+
}
|