peaks-loop 4.0.29 → 4.0.30

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.
Files changed (69) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/cli/commands/_register.js +4 -0
  3. package/dist/cli/commands/dispatch-commands.d.ts +5 -41
  4. package/dist/cli/commands/dispatch-commands.js +35 -185
  5. package/dist/cli/commands/evidence-commands.d.ts +12 -0
  6. package/dist/cli/commands/evidence-commands.js +43 -0
  7. package/dist/cli/commands/fresh-context-commands.d.ts +15 -0
  8. package/dist/cli/commands/fresh-context-commands.js +24 -0
  9. package/dist/cli/commands/request-commands.js +2 -96
  10. package/dist/cli/commands/request-format-helpers.d.ts +19 -0
  11. package/dist/cli/commands/request-format-helpers.js +102 -0
  12. package/dist/cli/commands/worktree-auth-commands.js +2 -472
  13. package/dist/cli/commands/worktree-lease-commands.d.ts +21 -0
  14. package/dist/cli/commands/worktree-lease-commands.js +500 -0
  15. package/dist/services/artifacts/request-artifact-service.js +13 -3
  16. package/dist/services/code/auto-compact-lifecycle.d.ts +102 -0
  17. package/dist/services/code/auto-compact-lifecycle.js +235 -0
  18. package/dist/services/code/auto-compact-orchestrator.d.ts +1 -1
  19. package/dist/services/code/auto-compact-orchestrator.js +1 -222
  20. package/dist/services/context/build-dispatch-system-prompt.d.ts +26 -0
  21. package/dist/services/context/build-dispatch-system-prompt.js +38 -3
  22. package/dist/services/dispatch/dispatch-record-types.d.ts +278 -0
  23. package/dist/services/dispatch/dispatch-record-types.js +15 -0
  24. package/dist/services/dispatch/dispatch-record-upgrade.d.ts +5 -0
  25. package/dist/services/dispatch/dispatch-record-upgrade.js +230 -0
  26. package/dist/services/dispatch/dispatch-record-writer.d.ts +3 -278
  27. package/dist/services/dispatch/dispatch-record-writer.js +3 -245
  28. package/dist/services/dispatch/dispatch-sub-agent.d.ts +43 -0
  29. package/dist/services/dispatch/dispatch-sub-agent.js +55 -0
  30. package/dist/services/dispatch/isolation-lease.d.ts +43 -0
  31. package/dist/services/dispatch/isolation-lease.js +129 -0
  32. package/dist/services/evidence/evidence-generator.d.ts +26 -0
  33. package/dist/services/evidence/evidence-generator.js +349 -0
  34. package/dist/services/fresh-context/config.d.ts +1 -0
  35. package/dist/services/fresh-context/config.js +13 -0
  36. package/dist/services/fresh-context/fresh-context-block.d.ts +12 -0
  37. package/dist/services/fresh-context/fresh-context-block.js +40 -0
  38. package/dist/services/fresh-context/trigger-scan.d.ts +30 -0
  39. package/dist/services/fresh-context/trigger-scan.js +29 -0
  40. package/dist/services/skills/hooks-codegate-superpowers.d.ts +65 -0
  41. package/dist/services/skills/hooks-codegate-superpowers.js +204 -0
  42. package/dist/services/skills/hooks-settings-service.d.ts +2 -64
  43. package/dist/services/skills/hooks-settings-service.js +2 -215
  44. package/dist/services/skills/skill-statusline-renderer.d.ts +2 -34
  45. package/dist/services/skills/skill-statusline-renderer.js +1 -184
  46. package/dist/services/skills/statusline-palette.d.ts +62 -0
  47. package/dist/services/skills/statusline-palette.js +190 -0
  48. package/dist/services/slice/slice-decompose-import-edges.d.ts +8 -0
  49. package/dist/services/slice/slice-decompose-import-edges.js +102 -0
  50. package/dist/services/slice/slice-decompose-service.js +3 -198
  51. package/dist/services/slice/slice-decompose-tarjan.d.ts +8 -0
  52. package/dist/services/slice/slice-decompose-tarjan.js +108 -0
  53. package/dist/services/standards/project-standards-service.d.ts +1 -9
  54. package/dist/services/standards/project-standards-service.js +3 -232
  55. package/dist/services/standards/standards-render.d.ts +23 -0
  56. package/dist/services/standards/standards-render.js +238 -0
  57. package/dist/services/standards/ui-library-dispatch-block.d.ts +27 -0
  58. package/dist/services/standards/ui-library-dispatch-block.js +48 -0
  59. package/dist/services/workspace/reconcile-migrate.d.ts +77 -0
  60. package/dist/services/workspace/reconcile-migrate.js +230 -0
  61. package/dist/services/workspace/reconcile-service.d.ts +0 -72
  62. package/dist/services/workspace/reconcile-service.js +3 -220
  63. package/dist/services/workspace/workspace-service.js +13 -2
  64. package/dist/shared/incrementing-number.d.ts +11 -0
  65. package/dist/shared/incrementing-number.js +16 -3
  66. package/package.json +5 -5
  67. package/skills/peaks-code/SKILL.md +6 -2
  68. package/skills/peaks-code/references/fresh-context-preflight.md +81 -0
  69. package/skills/peaks-code/references/sub-agent-dispatch.md +27 -8
@@ -1,279 +1,7 @@
1
- import type { SubAgentToolCall } from './sub-agent-dispatcher.js';
2
1
  import { type StageLabel } from './stage-enum.js';
3
- /** G6.3 Heartbeat entry — single update written by a running sub-agent. */
4
- export interface Heartbeat {
5
- readonly at: string;
6
- readonly status: HeartbeatStatus;
7
- readonly progress: number;
8
- readonly note: string | null;
9
- }
10
- export type HeartbeatStatus = 'queued' | 'running' | 'finalizing' | 'done' | 'failed' | 'stale' | 'cancelled' | 'no-execution' | 'never-started' | 'unreadable';
11
- export type DispatchRecordStatus = 'queued' | 'running' | 'finalizing' | 'done' | 'failed' | 'cancelled' | 'no-execution' | 'stale' | 'never-started' | 'unreadable';
12
- export type DispatchOutcome = 'success' | 'failed' | 'timeout' | 'cancelled' | 'no-execution';
13
- /** G2+G5+G6 dispatch record schema (AC-26 + AC-34). */
14
- export interface DispatchRecord {
15
- /**
16
- * Slice 2026-07-29-worktree-l2-extended Part 4.C: schema v3 makes
17
- * `leaseId` a structurally required field (was `leaseId?: string | null`
18
- * in v2). The v3 upgrade is a "fill in" migration: every dispatch
19
- * writer knows its lease id at construction time (Part 2.C's
20
- * --isolation worktree spawns it; non-isolation dispatches stamp
21
- * `null`). Readers tolerate both v2 and v3 on disk (see
22
- * `upgradeRecord`); the `?` was a Part 4.A ergonomic concession
23
- * to keep 4 unit-test literal sites from breaking the build. v3
24
- * moves the optional off the type and adds the field to the
25
- * 4 literal sites in one pass.
26
- *
27
- * Slice 2026-07-29-worktree-l2-extended Part 7: schema v3.1 adds
28
- * `isolationStartedAt: string | null` for the L4 isolation
29
- * bridge (Part 8 container POC). It's the ISO timestamp of
30
- * when the isolation mode was set up (e.g. when the worktree
31
- * was spawned). `null` means the dispatch did not request
32
- * isolation. The `version` field stays at 3 because the
33
- * v3 → v3.1 transition is additive; the new field is
34
- * defaulted to `null` on read so v3 records upgrade cleanly.
35
- */
36
- /**
37
- * Phase A Task 8: schema bumped to v4.1.0 (additive). The bump
38
- * is purely additive — new fields (`mode`, `vendor`,
39
- * `autoCompactEvents`, `tokenUsage`) all default safely on
40
- * read for legacy v4.0.0 / v3.2 / v3.1 / v3 / v2 / v1 records.
41
- * No existing field semantics changed.
42
- */
43
- readonly version: '4.1.0';
44
- readonly createdAt: string;
45
- readonly completedAt: string | null;
46
- readonly outcome: DispatchOutcome;
47
- readonly artifactPaths: readonly string[];
48
- readonly disposed: boolean;
49
- readonly disposedAt: string | null;
50
- readonly role: string;
51
- readonly requestId: string;
52
- readonly sessionId: string;
53
- readonly prompt: string;
54
- readonly toolCall: SubAgentToolCall;
55
- /** G5 batch id (AC-27) — uuid-like opaque token grouping one batch. */
56
- readonly batchId: string;
57
- /** G6 fields (AC-34) — backward compat: defaults on read. */
58
- readonly heartbeats: readonly Heartbeat[];
59
- readonly lastBeatAt: string | null;
60
- readonly status: DispatchRecordStatus;
61
- readonly stage: string | null;
62
- /**
63
- * Slice 2026-07-29-worktree-l2-extended Part 3.A + Part 4.C: the
64
- * worktree lease id stamped on this dispatch (via `peaks sub-agent
65
- * dispatch --isolation worktree`). The release hook (see
66
- * markCompleted + `peaks sub-agent heartbeat --status done`)
67
- * reads this field to auto-call `peaks worktree release` when
68
- * the sub-agent finalizes. `null` means the dispatch did not
69
- * request isolation and no release will fire. Persisted for
70
- * audit + idempotency so a re-read of an old record still
71
- * surfaces the lease id even if the on-disk lease file has since
72
- * been gc'd.
73
- *
74
- * v3 (Part 4.C) makes this structurally required. v2 records
75
- * missing the field upgrade to `null` on read (see
76
- * `upgradeRecord`).
77
- */
78
- readonly leaseId: string | null;
79
- /**
80
- * Slice 2026-07-29-worktree-l2-extended Part 7: ISO timestamp of
81
- * when the isolation mode was set up. For `--isolation worktree`
82
- * this is the moment `peaks worktree spawn` returned; for
83
- * `--isolation container` (Part 8) it's when the container
84
- * runtime reported the container as running. `null` when the
85
- * dispatch did not request isolation. Lets the dashboard
86
- * compute isolation duration (now - isolationStartedAt) without
87
- * cross-referencing the metrics stream.
88
- */
89
- readonly isolationStartedAt: string | null;
90
- /**
91
- * Slice 2026-08-01-subagent-merge-and-e2e (Task 7): v3.2 schema
92
- * bump. One entry per pid the parent best-effort-killed during
93
- * the service-shutdown phase of the merge-back pipeline (see
94
- * src/services/dispatch/service-shutdown.ts). Empty array when
95
- * the sub-agent did not register any services. The shape is the
96
- * union of ServiceKillOutcome (skipped=false) and
97
- * ServiceKillSkipped (skipped=true) — kept as a plain object
98
- * here so the on-disk schema does not lock onto the helper's
99
- * narrower union. The reader (merge-back-runner.ts) interprets
100
- * each entry based on the `skipped` field.
101
- */
102
- readonly serviceKill: ReadonlyArray<{
103
- readonly pid: number;
104
- readonly name: string;
105
- readonly signal: string;
106
- readonly exitCode: number | null;
107
- readonly skipped?: boolean;
108
- readonly reason?: string;
109
- }>;
110
- /**
111
- * Slice 2026-08-01-subagent-merge-and-e2e (Task 7): v3.2 schema
112
- * bump. Counts how many merge attempts the parent session has
113
- * made against this dispatch's branch. The conflict-replay
114
- * orchestrator bumps this on each retry (bounded to ONE re-dispatch
115
- * per merge attempt; multi-conflict cases escalate). Persisted so
116
- * the dashboard can render the retry count without replaying the
117
- * merge transcript.
118
- */
119
- readonly mergeBackAttempts: number;
120
- /**
121
- * Slice 4.0.8 RD §4 D4c (presence-lease-graph): the workflow id +
122
- * graph node id + graphRef this dispatch is bound to. Persisted
123
- * directly in the dispatch record (NOT in a sidecar) so the
124
- * envelope-writer `markCompleted` can auto-transition the bound
125
- * graph node to `envelope-received` with `ackStatus=pending` in
126
- * one protected update.
127
- *
128
- * The schema bump from `3.2 → 4.0.0` is BREAKING in the sense
129
- * that the literal type is narrowed. The optional `?` keeps back-
130
- * compat for old records that pre-date the binding (the
131
- * `upgradeRecord` reader defaults them to `null`).
132
- */
133
- readonly workflowId: string | null;
134
- readonly graphNodeId: string | null;
135
- readonly graphRef: string | null;
136
- /**
137
- * Phase A Task 8: dispatch execution mode. `in-process` is the
138
- * current behavior (LLM-side runner, no separate OS process).
139
- * `detached` is the new path: `peaks sub-agent dispatch --mode
140
- * detached` spawns a real child OS process running a different
141
- * LLM vendor (claude / codex / copilot) and reports back via
142
- * the dispatch record. v4.1.0 is the additive bump; legacy v4.0.0
143
- * records upgrade to `in-process` on read.
144
- */
145
- readonly mode: 'in-process' | 'detached';
146
- /**
147
- * Phase A Task 8: vendor id when `mode='detached'`. `null` when
148
- * the dispatch is in-process. Reserved for future use; current
149
- * detached sub-agents use `claude` but the schema also accepts
150
- * `codex` and `copilot` for the vendor-neutral adapter layer.
151
- */
152
- readonly vendor: 'claude' | 'codex' | 'copilot' | null;
153
- /**
154
- * Phase A Task 8: G8 autoCompact events accumulated by the child
155
- * LLM during a detached run. Each event records the threshold
156
- * that fired (0.85 = first warning, 0.95 = second warning) plus
157
- * token counts before/after. Empty for in-process dispatches
158
- * and for legacy records upgraded on read.
159
- */
160
- readonly autoCompactEvents: ReadonlyArray<{
161
- readonly at: number;
162
- readonly threshold: '0.85' | '0.95';
163
- readonly tokensBefore: number;
164
- readonly tokensAfter: number;
165
- readonly scratchFile?: string;
166
- }>;
167
- /**
168
- * Phase A Task 8: G8 token-usage accounting for detached sub-agents.
169
- * Detached runs have "unlimited spend but recorded" semantics —
170
- * the cost is recorded for audit but not enforced. `null` when
171
- * the dispatch is in-process (no detached accounting) or for
172
- * legacy records upgraded on read.
173
- */
174
- readonly tokenUsage: {
175
- readonly promptTokens: number;
176
- readonly completionTokens: number;
177
- readonly totalCostUsd?: number;
178
- } | null;
179
- }
180
- /** Input for the initial write. */
181
- export type WriteInitialDispatchInput = {
182
- projectRoot: string;
183
- sessionId: string;
184
- requestId: string;
185
- role: string;
186
- prompt: string;
187
- toolCall: SubAgentToolCall;
188
- batchId: string;
189
- /** Override the timestamp (testing). */
190
- now?: () => Date;
191
- /**
192
- * Slice 2026-07-29-worktree-l2-extended Part 3.A: the worktree
193
- * lease id this dispatch owns (set by `peaks sub-agent dispatch
194
- * --isolation worktree`). Persisted so the finalize-time release
195
- * hook in `markCompleted` can fire even after the dispatch
196
- * process exits. Optional; absent when the dispatch did not
197
- * request isolation.
198
- */
199
- leaseId?: string | null;
200
- /**
201
- * Slice 2026-07-29-worktree-l2-extended Part 7: ISO timestamp
202
- * when the isolation mode was set up. Optional on the input
203
- * (defaults to `null`); dispatch-commands.ts passes the spawn
204
- * time when `--isolation` is requested.
205
- */
206
- isolationStartedAt?: string | null;
207
- /**
208
- * Slice 4.0.8: workflow graph binding for the dispatch. Defaults
209
- * to `null` so a non-graph dispatch (legacy CLI flow, ad-hoc
210
- * dispatch) still writes a v4.0.0 record.
211
- */
212
- workflowId?: string | null;
213
- graphNodeId?: string | null;
214
- graphRef?: string | null;
215
- /**
216
- * Phase A Task 8: dispatch execution mode. Default `'in-process'`
217
- * preserves the current LLM-side runner behavior. `'detached'`
218
- * triggers the new real-OS-process path via
219
- * `peaks sub-agent dispatch --mode detached`.
220
- */
221
- mode?: 'in-process' | 'detached';
222
- /**
223
- * Phase A Task 8: vendor id when `mode='detached'`. Required by
224
- * the adapter layer to know which CLI / runtime to spawn. The
225
- * schema accepts the three vendors peaks-loop has adapters for
226
- * (claude / codex / copilot). Ignored when `mode='in-process'`.
227
- */
228
- vendor?: 'claude' | 'codex' | 'copilot';
229
- /**
230
- * Phase A Task 8: G8 autoCompact events accumulated by the child
231
- * LLM. Optional on input — most dispatches start with an empty
232
- * array and the detached runner appends events as they fire.
233
- */
234
- autoCompactEvents?: Array<{
235
- at: number;
236
- threshold: '0.85' | '0.95';
237
- tokensBefore: number;
238
- tokensAfter: number;
239
- scratchFile?: string;
240
- }>;
241
- /**
242
- * Phase A Task 8: G8 token-usage accounting. Detached runs
243
- * record spend for audit (unlimited, but persisted). Optional
244
- * on input; the detached runner fills this in as it streams
245
- * usage from the vendor API.
246
- */
247
- tokenUsage?: {
248
- promptTokens: number;
249
- completionTokens: number;
250
- totalCostUsd?: number;
251
- };
252
- };
253
- /** Heartbeat write input. */
254
- export type AppendHeartbeatInput = {
255
- recordPath: string;
256
- status: HeartbeatStatus;
257
- progress: number;
258
- note?: string;
259
- now?: () => Date;
260
- };
261
- /** Lifecycle transition input. */
262
- export type LifecycleInput = {
263
- recordPath: string;
264
- outcome: DispatchOutcome;
265
- status: DispatchRecordStatus;
266
- artifactPaths?: readonly string[];
267
- now?: () => Date;
268
- /**
269
- * Slice 2026-06-23-audit-4th #A4: trusted project root. Required
270
- * so the active-dispatches index can be updated without deriving
271
- * the root from the recordPath (the same anti-pattern that
272
- * audit-3rd #1 fixed for heartbeat). The CLI / LLM-side runner
273
- * passes this from `--project` or `process.cwd()`.
274
- */
275
- projectRoot?: string;
276
- };
2
+ import type { AppendHeartbeatInput, DispatchRecord, Heartbeat, LifecycleInput, WriteInitialDispatchInput } from './dispatch-record-types.js';
3
+ export type { AppendHeartbeatInput, DispatchOutcome, DispatchRecord, DispatchRecordStatus, Heartbeat, HeartbeatStatus, LifecycleInput, WriteInitialDispatchInput } from './dispatch-record-types.js';
4
+ export { isDispatchStatus, isOutcome } from './dispatch-record-upgrade.js';
277
5
  /** Write a new dispatch record (G2 + G5 + G6). Returns the absolute path. */
278
6
  export declare function writeInitialDispatchRecord(input: WriteInitialDispatchInput): {
279
7
  path: string;
@@ -386,6 +114,3 @@ export declare function setStage(input: {
386
114
  export declare function readRecord(recordPath: string): DispatchRecord;
387
115
  /** Read multiple records from a list of paths. Tolerates missing files. */
388
116
  export declare function readRecords(paths: readonly string[]): DispatchRecord[];
389
- declare function isDispatchStatus(v: unknown): v is DispatchRecordStatus;
390
- declare function isOutcome(v: unknown): v is DispatchOutcome;
391
- export { isDispatchStatus, isOutcome };
@@ -30,21 +30,9 @@ import { assertSafeDispatchRecordPath, dispatchRecordPath } from '../security/sa
30
30
  import { withFileLockSync } from 'peaks-loop-shared-channel';
31
31
  import { isStageLabel } from './stage-enum.js';
32
32
  import { emitLeaseEvent } from '../observability/observability-service.js';
33
- /**
34
- * PRD-002b slice 2 — extract dispatch-record size budgets (max-prompt
35
- * bytes, note truncation cap) + time-math primitives so the
36
- * no-magic-numbers rule stops flagging the writer pipeline.
37
- */
38
- const BYTES_PER_KB = 1024;
39
- const MAX_PROMPT_KB = 256;
40
- const MAX_PROMPT_BYTES = MAX_PROMPT_KB * BYTES_PER_KB;
41
- const NOTE_MAX_CHARS = 200;
42
- const MS_PER_SECOND = 1_000;
43
- const SECONDS_PER_MINUTE = 60;
44
- const MINUTES_PER_HOUR = 60;
45
- const HOURS_PER_DAY = 24;
46
- const MS_PER_DAY = HOURS_PER_DAY * MINUTES_PER_HOUR * SECONDS_PER_MINUTE * MS_PER_SECOND;
47
- const REDACTION_MAX_SCAN_DEPTH = 20;
33
+ import { upgradeRecord } from './dispatch-record-upgrade.js';
34
+ import { MAX_PROMPT_BYTES, MS_PER_DAY, NOTE_MAX_CHARS } from './dispatch-record-types.js';
35
+ export { isDispatchStatus, isOutcome } from './dispatch-record-upgrade.js';
48
36
  /** Write a new dispatch record (G2 + G5 + G6). Returns the absolute path. */
49
37
  export function writeInitialDispatchRecord(input) {
50
38
  const { projectRoot, sessionId, requestId, role, prompt, toolCall, batchId } = input;
@@ -674,236 +662,6 @@ export function readRecords(paths) {
674
662
  }
675
663
  return out;
676
664
  }
677
- function upgradeRecord(parsed) {
678
- if (!isObject(parsed)) {
679
- throw new Error('Dispatch record root must be an object');
680
- }
681
- const obj = parsed;
682
- // Slice 4.0.8: 3.2 → 4.0.0 schema bump. Phase A Task 8: 4.0.0 → 4.1.0
683
- // (additive). The literal type narrows to '4.1.0' but legacy v4.0.0 /
684
- // v3.2 / v3.1 / 3 / 2 / 1 records are accepted transparently and
685
- // upgraded on read.
686
- const rawVersion = obj.version;
687
- if (rawVersion !== '4.1.0' && rawVersion !== '4.0.0' && rawVersion !== '3.2' && rawVersion !== '3.1' && rawVersion !== 3 && rawVersion !== 2 && rawVersion !== 1) {
688
- throw new Error(`Dispatch record version mismatch: expected '4.1.0', '4.0.0', '3.2', '3.1', 3, 2, or 1, got ${JSON.stringify(rawVersion)}. ` +
689
- 'The v1 → v4.1.0 migration is in-file; records from much older or newer builds must be regenerated.');
690
- }
691
- const legacy = parseUpgradeRecordLegacyFields(obj);
692
- const migration = parseUpgradeRecordMigrationFields(obj);
693
- return {
694
- version: '4.1.0',
695
- createdAt: legacy.createdAt,
696
- completedAt: legacy.completedAt,
697
- outcome: legacy.outcome,
698
- artifactPaths: legacy.artifactPaths,
699
- disposed: legacy.disposed,
700
- disposedAt: legacy.disposedAt,
701
- role: legacy.role,
702
- requestId: legacy.requestId,
703
- sessionId: legacy.sessionId,
704
- prompt: legacy.prompt,
705
- toolCall: legacy.toolCall,
706
- batchId: legacy.batchId,
707
- heartbeats: legacy.heartbeats,
708
- lastBeatAt: legacy.lastBeatAt,
709
- status: legacy.status,
710
- stage: migration.stage,
711
- leaseId: migration.leaseId,
712
- isolationStartedAt: migration.isolationStartedAt,
713
- serviceKill: migration.serviceKill,
714
- mergeBackAttempts: migration.mergeBackAttempts,
715
- workflowId: migration.workflowId,
716
- graphNodeId: migration.graphNodeId,
717
- graphRef: migration.graphRef,
718
- // Phase A Task 8: detached sub-agent fields. Legacy records
719
- // (pre-4.1.0) default mode='in-process', vendor=null,
720
- // autoCompactEvents=[], tokenUsage=null. See
721
- // parseUpgradeRecordMigrationFields for the per-field
722
- // validation rules.
723
- mode: migration.mode,
724
- vendor: migration.vendor,
725
- autoCompactEvents: migration.autoCompactEvents,
726
- tokenUsage: migration.tokenUsage
727
- };
728
- }
729
- /**
730
- * Parse the v1..v3.2 core fields of a legacy dispatch record.
731
- * PRD-002b slice 6: extracted from `upgradeRecord` so the reader
732
- * stays under the `max-lines-per-function: 50` ESLint ceiling.
733
- * Behavior is byte-identical to the previous inline block.
734
- */
735
- function parseUpgradeRecordLegacyFields(obj) {
736
- const role = stringField(obj, 'role');
737
- const requestId = stringField(obj, 'requestId');
738
- const sessionId = stringField(obj, 'sessionId');
739
- const prompt = stringField(obj, 'prompt');
740
- // Slice 2026-06-23-audit-4th #C2: preserve toolCallVersion on read.
741
- // Pre-versioning records default to '2.0.0' (the pre-#C2 implicit
742
- // shape; matches the version stamped by every current dispatcher).
743
- const rawToolCall = obj.toolCall;
744
- if (!isObject(rawToolCall) || typeof rawToolCall.name !== 'string') {
745
- throw new Error('Dispatch record toolCall must be { name, args }');
746
- }
747
- const toolCall = {
748
- name: rawToolCall.name,
749
- args: (isObject(rawToolCall.args) ? rawToolCall.args : {}),
750
- ...(typeof rawToolCall.toolCallVersion === 'string' ? { toolCallVersion: rawToolCall.toolCallVersion } : { toolCallVersion: '2.0.0' })
751
- };
752
- const createdAt = stringField(obj, 'createdAt');
753
- const heartbeats = Array.isArray(obj.heartbeats)
754
- ? obj.heartbeats.filter(isValidHeartbeat)
755
- : [];
756
- const lastBeatAt = typeof obj.lastBeatAt === 'string' ? obj.lastBeatAt : null;
757
- // Slice 2026-07-29-dispatch-stall-governance / S1 (UQ-1) — `no-execution`
758
- // keeps its natural "dispatched, never executed" reading; an unparseable
759
- // status field now resolves to a *distinct* `unreadable` label so the
760
- // caller can tell "corrupt record" apart from "record written, no first
761
- // heartbeat" (which is the new `never-started` state).
762
- const status = isDispatchStatus(obj.status)
763
- ? obj.status
764
- : 'unreadable';
765
- const completedAt = typeof obj.completedAt === 'string' ? obj.completedAt : null;
766
- const outcome = isOutcome(obj.outcome) ? obj.outcome : 'no-execution';
767
- const artifactPaths = Array.isArray(obj.artifactPaths)
768
- ? obj.artifactPaths.filter((p) => typeof p === 'string')
769
- : [];
770
- const disposed = obj.disposed === true;
771
- const disposedAt = typeof obj.disposedAt === 'string' ? obj.disposedAt : null;
772
- const batchId = typeof obj.batchId === 'string' && obj.batchId.length > 0
773
- ? obj.batchId
774
- : 'legacy-batch';
775
- return {
776
- role,
777
- requestId,
778
- sessionId,
779
- prompt,
780
- toolCall,
781
- createdAt,
782
- heartbeats,
783
- lastBeatAt,
784
- status,
785
- completedAt,
786
- outcome,
787
- artifactPaths,
788
- disposed,
789
- disposedAt,
790
- batchId
791
- };
792
- }
793
- /**
794
- * Parse the post-v3 migration fields of a legacy dispatch record.
795
- * PRD-002b slice 6: extracted from `upgradeRecord` so the reader
796
- * stays under the `max-lines-per-function: 50` ESLint ceiling.
797
- * Behavior is byte-identical to the previous inline block.
798
- */
799
- function parseUpgradeRecordMigrationFields(obj) {
800
- return {
801
- // Slice 2026-07-29-dispatch-stall-governance / S5 (AC-5.1 / PB-2)
802
- // — legacy records (pre-slice) had no `stage` field. The reader
803
- // defaults to `null` so the watch surface can tell "no stage ever
804
- // emitted" apart from "stage: ''" (which is itself a *valid*
805
- // round-trip through the writer — an empty stage is rejected by
806
- // `setStage`, but a record that round-tripped through a non-strict
807
- // tool would land here).
808
- stage: typeof obj.stage === 'string' && obj.stage.length > 0 ? obj.stage : null,
809
- // Slice 2026-07-29-worktree-l2-extended Part 3.A: legacy records
810
- // have no `leaseId`; default to `null` so the auto-release hook
811
- // in `markCompleted` is a clean no-op for them.
812
- leaseId: typeof obj.leaseId === 'string' && /^[a-f0-9]{16}$/.test(obj.leaseId)
813
- ? obj.leaseId
814
- : null,
815
- // Slice 2026-07-29-worktree-l2-extended Part 7: v3 → v3.1
816
- // migration. Legacy records have no `isolationStartedAt`; default
817
- // to `null`. v3.1 readers can treat the field as opt-in.
818
- isolationStartedAt: typeof obj.isolationStartedAt === 'string' && obj.isolationStartedAt.length > 0
819
- ? obj.isolationStartedAt
820
- : null,
821
- // Slice 2026-08-01-subagent-merge-and-e2e (Task 7): v3.1 → v3.2
822
- // migration. Legacy v3.1 records have no `serviceKill` or
823
- // `mergeBackAttempts` fields. Default to [] and 0 so the
824
- // merge-back-runner (Task 9) can read either schema on disk.
825
- serviceKill: Array.isArray(obj.serviceKill)
826
- ? (obj.serviceKill.filter((e) => {
827
- if (typeof e !== 'object' || e === null)
828
- return false;
829
- const o = e;
830
- return typeof o.pid === 'number' && typeof o.name === 'string' && typeof o.signal === 'string' && (o.exitCode === null || typeof o.exitCode === 'number');
831
- }))
832
- : [],
833
- mergeBackAttempts: typeof obj.mergeBackAttempts === 'number' && Number.isFinite(obj.mergeBackAttempts) && obj.mergeBackAttempts >= 0
834
- ? Math.floor(obj.mergeBackAttempts)
835
- : 0,
836
- // Slice 4.0.8: 3.2 → 4.0.0 migration. v3.2 records on disk
837
- // pre-date the workflow-graph binding; default all three
838
- // fields to `null` so a legacy record upgrades transparently.
839
- workflowId: typeof obj.workflowId === 'string' && /^[a-zA-Z0-9._-]{1,200}$/.test(obj.workflowId) ? obj.workflowId : null,
840
- graphNodeId: typeof obj.graphNodeId === 'string' && /^[a-zA-Z0-9._-]{1,200}$/.test(obj.graphNodeId) ? obj.graphNodeId : null,
841
- graphRef: typeof obj.graphRef === 'string' ? obj.graphRef : null,
842
- // Phase A Task 8: 4.0.0 → 4.1.0 migration. Pre-4.1.0 records
843
- // have no mode / vendor / autoCompactEvents / tokenUsage
844
- // fields. Default to safe in-process / null / [] / null so
845
- // legacy records upgrade transparently without breaking
846
- // consumers (e.g. the dashboard, the merge-back-runner).
847
- mode: obj.mode === 'detached' ? 'detached' : 'in-process',
848
- vendor: obj.vendor === 'claude' || obj.vendor === 'codex' || obj.vendor === 'copilot' ? obj.vendor : null,
849
- autoCompactEvents: Array.isArray(obj.autoCompactEvents)
850
- ? obj.autoCompactEvents.filter((e) => typeof e?.at === 'number' &&
851
- (e?.threshold === '0.85' || e?.threshold === '0.95') &&
852
- typeof e?.tokensBefore === 'number' &&
853
- typeof e?.tokensAfter === 'number')
854
- : [],
855
- tokenUsage: typeof obj.tokenUsage === 'object' && obj.tokenUsage !== null && typeof obj.tokenUsage.promptTokens === 'number' && typeof obj.tokenUsage.completionTokens === 'number'
856
- ? {
857
- promptTokens: obj.tokenUsage.promptTokens,
858
- completionTokens: obj.tokenUsage.completionTokens,
859
- ...(typeof obj.tokenUsage.totalCostUsd === 'number'
860
- ? { totalCostUsd: obj.tokenUsage.totalCostUsd }
861
- : {}),
862
- }
863
- : null
864
- };
865
- }
866
- function isObject(v) {
867
- return typeof v === 'object' && v !== null && !Array.isArray(v);
868
- }
869
- function stringField(obj, key) {
870
- const v = obj[key];
871
- if (typeof v !== 'string') {
872
- throw new Error(`Dispatch record field '${key}' must be a string (got ${typeof v})`);
873
- }
874
- return v;
875
- }
876
- function isValidHeartbeat(v) {
877
- if (!isObject(v))
878
- return false;
879
- return (typeof v.at === 'string' &&
880
- isHeartbeatStatus(v.status) &&
881
- typeof v.progress === 'number' &&
882
- (v.note === null || typeof v.note === 'string'));
883
- }
884
- function isHeartbeatStatus(v) {
885
- return (v === 'queued' || v === 'running' || v === 'finalizing' ||
886
- v === 'done' || v === 'failed' || v === 'stale' ||
887
- // Slice 2026-07-29-dispatch-stall-governance / S2 — accept the
888
- // S1 terminal members so a sub-agent can report `cancelled`,
889
- // `no-execution`, `never-started`, or `unreadable` through the
890
- // heartbeat CLI.
891
- v === 'cancelled' || v === 'no-execution' ||
892
- v === 'never-started' || v === 'unreadable');
893
- }
894
- function isDispatchStatus(v) {
895
- return (v === 'queued' || v === 'running' || v === 'finalizing' ||
896
- v === 'done' || v === 'failed' || v === 'cancelled' ||
897
- v === 'no-execution' || v === 'stale' ||
898
- // Slice 2026-07-29-dispatch-stall-governance / S1 — accept the two
899
- // new terminal members from the startup-timeout service.
900
- v === 'never-started' || v === 'unreadable');
901
- }
902
- function isOutcome(v) {
903
- return (v === 'success' || v === 'failed' || v === 'timeout' ||
904
- v === 'cancelled' || v === 'no-execution');
905
- }
906
- export { isDispatchStatus, isOutcome };
907
665
  function writeAtomic(path, record) {
908
666
  const dir = dirname(path);
909
667
  // Slice 2026-06-23-audit-3rd #11: skip mkdirSync when the dir already
@@ -0,0 +1,43 @@
1
+ /**
2
+ * F5 follow-up (sediment 2026-08-11-rid-001-redo-fake-green-recovery-closure
3
+ * §Lesson 1): synchronous anti-fake-green file-existence gate. Runs
4
+ * `git ls-files <glob>` against `projectRoot` and returns the matching
5
+ * tracked file paths (relative to projectRoot). Empty array when no
6
+ * files match (e.g. untracked new file, wrong glob, not a git repo).
7
+ *
8
+ * Why `git ls-files` and not `fs.glob`: the anti-fake-green contract
9
+ * is "the file the sub-agent claims to have written must ACTUALLY be
10
+ * tracked by git" — `git ls-files` enforces that contract; `fs.glob`
11
+ * would happily return untracked-but-on-disk files (false-positive
12
+ * for the fake-green gate).
13
+ *
14
+ * Failure modes (best-effort, never throws):
15
+ * - git not on PATH → empty array (`ENOENT` swallowed)
16
+ * - not a git repo → empty array (git exits non-zero)
17
+ * - glob matches zero tracked files → empty array
18
+ *
19
+ * Exported for unit-test access (`tests/unit/sub-agent/must-ls-files-flag.test.ts`).
20
+ * The export is intentional — the helper has zero side effects and
21
+ * keeps the dispatch action handler small.
22
+ */
23
+ export declare function runGitLsFiles(projectRoot: string, glob: string): readonly string[];
24
+ /**
25
+ * `dispatchSubAgent` is the thin programmatic wrapper the integration
26
+ * test (`tests/integration/sub-agent-graph-binding.test.ts`) imports.
27
+ * It enforces --graph-node required BEFORE any record write so a
28
+ * caller can't bypass the CLI's requiredOption guard. Throws a typed
29
+ * error with `code = PEAKS_GRAPH_NODE_REQUIRED` when missing.
30
+ */
31
+ export declare function dispatchSubAgent(input: {
32
+ projectRoot: string;
33
+ role: string;
34
+ prompt: string;
35
+ sessionId?: string;
36
+ graphNode?: string;
37
+ workflowId?: string;
38
+ graphRef?: string;
39
+ }): Promise<{
40
+ role: string;
41
+ toolCall: unknown;
42
+ dispatchRecordPath: string | null;
43
+ }>;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * F5 follow-up (sediment 2026-08-11-rid-001-redo-fake-green-recovery-closure
3
+ * §Lesson 1): synchronous anti-fake-green file-existence gate. Runs
4
+ * `git ls-files <glob>` against `projectRoot` and returns the matching
5
+ * tracked file paths (relative to projectRoot). Empty array when no
6
+ * files match (e.g. untracked new file, wrong glob, not a git repo).
7
+ *
8
+ * Why `git ls-files` and not `fs.glob`: the anti-fake-green contract
9
+ * is "the file the sub-agent claims to have written must ACTUALLY be
10
+ * tracked by git" — `git ls-files` enforces that contract; `fs.glob`
11
+ * would happily return untracked-but-on-disk files (false-positive
12
+ * for the fake-green gate).
13
+ *
14
+ * Failure modes (best-effort, never throws):
15
+ * - git not on PATH → empty array (`ENOENT` swallowed)
16
+ * - not a git repo → empty array (git exits non-zero)
17
+ * - glob matches zero tracked files → empty array
18
+ *
19
+ * Exported for unit-test access (`tests/unit/sub-agent/must-ls-files-flag.test.ts`).
20
+ * The export is intentional — the helper has zero side effects and
21
+ * keeps the dispatch action handler small.
22
+ */
23
+ export function runGitLsFiles(projectRoot, glob) {
24
+ try {
25
+ const { execFileSync } = require('node:child_process');
26
+ const stdout = execFileSync('git', ['ls-files', '--', glob], { cwd: projectRoot, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true });
27
+ return stdout.split('\n').filter((line) => line.length > 0);
28
+ }
29
+ catch {
30
+ return [];
31
+ }
32
+ }
33
+ /* ---------- Slice 4.0.8 RD §4 D4c: programmatic dispatcher ---------- */
34
+ /**
35
+ * `dispatchSubAgent` is the thin programmatic wrapper the integration
36
+ * test (`tests/integration/sub-agent-graph-binding.test.ts`) imports.
37
+ * It enforces --graph-node required BEFORE any record write so a
38
+ * caller can't bypass the CLI's requiredOption guard. Throws a typed
39
+ * error with `code = PEAKS_GRAPH_NODE_REQUIRED` when missing.
40
+ */
41
+ export async function dispatchSubAgent(input) {
42
+ if (typeof input.graphNode !== 'string' || input.graphNode.length === 0) {
43
+ const err = new Error('PEAKS_GRAPH_NODE_REQUIRED: --graph-node is required (RD §4 D4c)');
44
+ err.code = 'PEAKS_GRAPH_NODE_REQUIRED';
45
+ throw err;
46
+ }
47
+ // The integration test only checks the rejection path; the success
48
+ // path is exercised by the existing CLI command. Return a minimal
49
+ // stub so any future programmatic caller has a stable surface.
50
+ return {
51
+ role: input.role,
52
+ toolCall: null,
53
+ dispatchRecordPath: null,
54
+ };
55
+ }