peaks-loop 4.0.3 → 4.0.5

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 (67) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README-en.md +26 -0
  3. package/README.md +26 -0
  4. package/dist/cli/commands/_register.js +3 -0
  5. package/dist/cli/commands/code-runtime-commands.js +15 -2
  6. package/dist/cli/commands/core/skill-command.js +2 -0
  7. package/dist/cli/commands/dispatch-commands.js +41 -2
  8. package/dist/cli/commands/e2e-verify.d.ts +47 -0
  9. package/dist/cli/commands/e2e-verify.js +211 -0
  10. package/dist/cli/commands/gate-commands.js +17 -4
  11. package/dist/cli/commands/statusline-commands.d.ts +6 -0
  12. package/dist/cli/commands/statusline-commands.js +48 -11
  13. package/dist/cli/commands/sub-agent-shutdown-commands.d.ts +31 -0
  14. package/dist/cli/commands/sub-agent-shutdown-commands.js +110 -0
  15. package/dist/cli/commands/worktree-auth-commands.js +27 -0
  16. package/dist/services/code/auto-compact-orchestrator.d.ts +39 -0
  17. package/dist/services/code/auto-compact-orchestrator.js +358 -41
  18. package/dist/services/code/post-compact-detector.js +12 -4
  19. package/dist/services/code/step-08-gate.js +6 -2
  20. package/dist/services/compact-statusline/compact-lifecycle-store.d.ts +68 -0
  21. package/dist/services/compact-statusline/compact-lifecycle-store.js +222 -0
  22. package/dist/services/compact-statusline/compact-statusline-service.d.ts +25 -5
  23. package/dist/services/compact-statusline/compact-statusline-service.js +244 -54
  24. package/dist/services/context/auto-compact-reader.d.ts +33 -4
  25. package/dist/services/context/auto-compact-reader.js +77 -22
  26. package/dist/services/context/auto-compact-types.d.ts +10 -2
  27. package/dist/services/context/build-dispatch-system-prompt.d.ts +26 -0
  28. package/dist/services/context/build-dispatch-system-prompt.js +33 -2
  29. package/dist/services/dispatch/conflict-replay.d.ts +32 -0
  30. package/dist/services/dispatch/conflict-replay.js +44 -0
  31. package/dist/services/dispatch/dispatch-record-writer.d.ts +31 -1
  32. package/dist/services/dispatch/dispatch-record-writer.js +27 -7
  33. package/dist/services/dispatch/e2e-fixtures.d.ts +18 -0
  34. package/dist/services/dispatch/e2e-fixtures.js +72 -0
  35. package/dist/services/dispatch/merge-back-runner.d.ts +18 -0
  36. package/dist/services/dispatch/merge-back-runner.js +83 -0
  37. package/dist/services/dispatch/post-merge.d.ts +21 -0
  38. package/dist/services/dispatch/post-merge.js +13 -0
  39. package/dist/services/dispatch/service-shutdown.d.ts +22 -0
  40. package/dist/services/dispatch/service-shutdown.js +51 -0
  41. package/dist/services/hooks/presence-marker-detector.js +12 -4
  42. package/dist/services/hooks/worktree-authorization-gate.d.ts +4 -0
  43. package/dist/services/hooks/worktree-authorization-gate.js +22 -0
  44. package/dist/services/skills/skill-statusline-renderer.d.ts +86 -3
  45. package/dist/services/skills/skill-statusline-renderer.js +374 -29
  46. package/dist/services/skills/skill-statusline-service.d.ts +2 -0
  47. package/dist/services/skills/skill-statusline-service.js +34 -5
  48. package/dist/services/skills/sync-service.d.ts +3 -0
  49. package/dist/services/skills/sync-service.js +1 -0
  50. package/dist/services/standards/project-context.d.ts +2 -1
  51. package/dist/services/standards/project-context.js +17 -2
  52. package/dist/services/standards/project-standards-service.d.ts +9 -0
  53. package/dist/services/standards/project-standards-service.js +26 -0
  54. package/dist/services/worktree/dispatch-provenance.d.ts +25 -0
  55. package/dist/services/worktree/dispatch-provenance.js +47 -0
  56. package/dist/services/worktree/git-worktree-parser.d.ts +8 -0
  57. package/dist/services/worktree/git-worktree-parser.js +45 -0
  58. package/dist/services/worktree/host-worktree-reconciler.d.ts +15 -0
  59. package/dist/services/worktree/host-worktree-reconciler.js +39 -0
  60. package/dist/services/worktree/long-path-cleanup.d.ts +10 -0
  61. package/dist/services/worktree/long-path-cleanup.js +38 -0
  62. package/dist/services/worktree/playwright-profile.d.ts +8 -0
  63. package/dist/services/worktree/playwright-profile.js +23 -0
  64. package/dist/services/worktree/worktree-gc-guard.d.ts +8 -0
  65. package/dist/services/worktree/worktree-gc-guard.js +38 -0
  66. package/package.json +4 -4
  67. package/scripts/install-skills.mjs +11 -2
@@ -22,7 +22,7 @@
22
22
  * 3. `ratio: 0` with `source: 'conservative-fallback'` — the
23
23
  * orchestrator MUST NOT auto-fire compact on this signal.
24
24
  */
25
- import { existsSync, readFileSync, statSync } from 'node:fs';
25
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
26
26
  import { homedir } from 'node:os';
27
27
  import { join } from 'node:path';
28
28
  import { detectIdeFromEnv } from './ide-detect.js';
@@ -64,39 +64,76 @@ function readClaudeStatuslinePercent() {
64
64
  }
65
65
  }
66
66
  }
67
- catch { // TODO(g2): legacy silent catch — grace: 1 minor release (v2.14.0)
68
- return null;
67
+ catch (err) { // TODO(g2): legacy silent catch — now narrows to IO errors only (grace: 1 minor release, v2.14.0)
68
+ if (err instanceof ReferenceError)
69
+ throw err; // surface module-load bugs
70
+ if (err instanceof SyntaxError)
71
+ throw err; // surface parse bugs (e.g. broken statusline JSON)
72
+ return null; // only swallow IO errors
69
73
  }
70
74
  return null;
71
75
  }
72
76
  /**
73
- * Conservative transcript-size fallback. Walks
74
- * `~/.claude/projects/<hash>/<sid>.jsonl` and estimates
75
- * `ratio = bytesUsed / 256K`. Returns the bytes seen so the
76
- * orchestrator can show "estimated from 124KB of 256KB transcript"
77
- * in the envelope. Tagged `'conservative-fallback'` so callers
78
- * know NOT to treat this as a hard gate.
77
+ * Recursive search for `<sessionId>.jsonl` under `projectsDir`. Used by
78
+ * `readClaudeTranscriptFallback` and exported via `_internal` so unit
79
+ * tests can drive it without monkey-patching `os.homedir` (which is
80
+ * non-configurable in ESM module namespaces).
81
+ *
82
+ * The Mac layout encodes the cwd as a single hash directory; on Mac
83
+ * Claude Code nests the transcript under that hash with an extra level
84
+ * of subdirectory we cannot predict ahead of time. A flat readdir misses
85
+ * that branch and returns null — the silent-failure mode that this fix
86
+ * closes.
79
87
  */
80
- function readClaudeTranscriptFallback(sessionId) {
81
- const projectsDir = join(homedir(), '.claude', 'projects');
88
+ function findTranscriptJsonl(projectsDir, sessionId) {
82
89
  if (!existsSync(projectsDir))
83
90
  return null;
84
91
  try {
85
- const { readdirSync } = require('node:fs');
86
- for (const hashDir of readdirSync(projectsDir)) {
87
- const candidate = join(projectsDir, hashDir, `${sessionId}.jsonl`);
88
- if (existsSync(candidate)) {
89
- const bytes = statSync(candidate).size;
90
- const ratio = Math.min(1, bytes / (256 * 1024));
91
- return { ratio, bytes };
92
+ const stack = [projectsDir];
93
+ while (stack.length > 0) {
94
+ const dir = stack.pop();
95
+ if (dir === undefined)
96
+ break;
97
+ const entries = readdirSync(dir, { withFileTypes: true });
98
+ for (const entry of entries) {
99
+ const full = join(dir, entry.name);
100
+ if (entry.isDirectory()) {
101
+ stack.push(full);
102
+ }
103
+ else if (entry.isFile() && entry.name === `${sessionId}.jsonl`) {
104
+ const bytes = statSync(full).size;
105
+ return { path: full, bytes };
106
+ }
92
107
  }
93
108
  }
94
109
  }
95
- catch { // TODO(g2): legacy silent catch — grace: 1 minor release (v2.14.0)
96
- return null;
110
+ catch (err) { // TODO(g2): legacy silent catch — now narrows to IO errors only (grace: 1 minor release, v2.14.0)
111
+ if (err instanceof ReferenceError)
112
+ throw err; // surface module-load bugs
113
+ if (err instanceof SyntaxError)
114
+ throw err; // surface parse bugs
115
+ return null; // only swallow IO errors
97
116
  }
98
117
  return null;
99
118
  }
119
+ /**
120
+ * Conservative transcript-size fallback. Recursively searches
121
+ * `~/.claude/projects/<hash>/<sid-or-nested>.jsonl` (Mac may nest
122
+ * the jsonl under an
123
+ * extra directory we cannot predict ahead of time) and estimates
124
+ * `ratio = bytesUsed / 256K`. Returns the bytes seen so the
125
+ * orchestrator can show "estimated from 124KB of 256KB transcript"
126
+ * in the envelope. Tagged `'transcript-estimate'` (v2.14.0) so callers
127
+ * know it is a real signal, NOT a hard gate.
128
+ */
129
+ function readClaudeTranscriptFallback(sessionId) {
130
+ const projectsDir = join(homedir(), '.claude', 'projects');
131
+ const hit = findTranscriptJsonl(projectsDir, sessionId);
132
+ if (hit === null)
133
+ return null;
134
+ const ratio = Math.min(1, hit.bytes / (256 * 1024));
135
+ return { ratio, bytes: hit.bytes };
136
+ }
100
137
  /**
101
138
  * Probe the current AI CLI's context-fill ratio. Adapter-driven:
102
139
  * looks up the registered `IdeAdapter.compact` profile via
@@ -121,6 +158,24 @@ export function readContextPercent(input) {
121
158
  // through `unknown` to IdeId's wider 8-element set.
122
159
  const ideId = (detected === 'unknown' ? 'claude-code' : detected);
123
160
  const adapter = getAdapter(ideId);
161
+ // P0 user-overridden takes priority over env / statusline / transcript.
162
+ // Mac escape hatch: when the CLI/helper passes `--prompt-size <bytes>`,
163
+ // honor that number directly. Do NOT read env, statusline, or transcript
164
+ // — user intent always wins. Negative / non-finite values are ignored
165
+ // here (CLI layer validates >= 0); they fall through to the chain below.
166
+ if (input.promptSizeBytes !== undefined &&
167
+ Number.isFinite(input.promptSizeBytes) &&
168
+ input.promptSizeBytes >= 0) {
169
+ const ratio = Math.min(1, input.promptSizeBytes / capacityBytes);
170
+ return {
171
+ ratio,
172
+ source: 'user-overridden',
173
+ rawBytes: input.promptSizeBytes,
174
+ capacityBytes,
175
+ ide: ideId,
176
+ capturedAt
177
+ };
178
+ }
124
179
  // Primary: read the adapter-declared env-var (no hard-coded IDE names).
125
180
  if (adapter.compact) {
126
181
  const primary = readEnvPercent(env, adapter.compact.envVarForContextPercent);
@@ -147,7 +202,7 @@ export function readContextPercent(input) {
147
202
  if (fallback !== null) {
148
203
  return {
149
204
  ratio: fallback.ratio,
150
- source: 'conservative-fallback',
205
+ source: 'transcript-estimate',
151
206
  rawBytes: fallback.bytes,
152
207
  capacityBytes,
153
208
  ide: ideId,
@@ -161,4 +216,4 @@ export function readContextPercent(input) {
161
216
  return { ratio: 0, source: 'conservative-fallback', capacityBytes, ide: ideId, capturedAt };
162
217
  }
163
218
  /** Re-export the env-var probe for unit tests. */
164
- export const _internal = { readEnvPercent, readClaudeStatuslinePercent, readClaudeTranscriptFallback };
219
+ export const _internal = { readEnvPercent, readClaudeStatuslinePercent, readClaudeTranscriptFallback, findTranscriptJsonl };
@@ -152,10 +152,18 @@ export interface ContextPercentProbe {
152
152
  readonly ratio: number;
153
153
  /**
154
154
  * Adapter-driven source tag. Fixed values:
155
+ * - `user-overridden` (v2.14.0 rid-002: explicit `--prompt-size
156
+ * <bytes>` from the CLI / hook wrapper —
157
+ * takes priority P0 over EVERY other source.
158
+ * Mac escape hatch.)
155
159
  * - `${ideId}-env` (e.g. `claude-code-env`)
156
160
  * - `statusline-poll` (Claude Code MVP fallback)
157
- * - `conservative-fallback` (transcript size estimate OR
158
- * no signal available — caller
161
+ * - `transcript-estimate` (v2.14.0 Mac-aware: recursive
162
+ * readdir under ~/.claude/projects/
163
+ * finds <sid>.jsonl and reports
164
+ * bytes/256K; a real signal, NOT
165
+ * a hard gate)
166
+ * - `conservative-fallback` (no signal available — caller
159
167
  * MUST NOT treat as hard gate)
160
168
  * Future IDEs may add per-ide sources; the type accepts any
161
169
  * string to keep the schema forward-compatible.
@@ -52,6 +52,32 @@ export interface DispatchPromptInput {
52
52
  * --ttl <5m>` (already shipped). Update the prose once `spawn` lands.
53
53
  */
54
54
  export declare const L1_WORKTREE_GOVERNANCE_BLOCK = "## Superpowers chain refusal (L1 worktree governance \u2014 locked 2026-07-29)\n\nYou MUST NOT follow the superpowers chain for worktree decisions:\n\n- superpowers:brainstorming \u2192 superpowers:writing-plans \u2192\n superpowers:subagent-driven-development \u2192 superpowers:using-git-worktrees\n\nThis chain ends with `git worktree add` (superpowers:using-git-worktrees SKILL.md line 96). It bypasses peaks-loop's L2 hook gate and L3 IDE `permissions.deny`. Even when L3 denies the terminal Skill, the chain has already taught you to use raw `git worktree add`, so L3 is not sufficient.\n\nFor worktree operations, use ONLY:\n\n- `peaks worktree spawn --rid <rid> --ttl <duration> --purpose <text>` (after rid-L2-extended ships)\n- until then: `peaks worktree auth grant --rid <id> --reason <text> --ttl <5m>`\n\nThe superpowers skills remain available as REFERENCE material for brainstorming / planning techniques, but MUST NOT be used as a workflow. peaks-rd's own PRD/RD artifacts (`.peaks/_runtime/<sessionId>/rd/requests/<rid>.md`) are authoritative.\n\nIf the upstream superpowers chain suggests raw `git worktree add`:\n\n1. STOP\n2. Re-author the plan as a peaks-rd artifact\n3. Continue with `peaks worktree spawn` (or the auth-grant fallback)\n";
55
+ /**
56
+ * Slice 2026-08-01-subagent-merge-and-e2e (Task 8): the dispatch
57
+ * system prompt gains three lifecycle rules. The sub-agent must:
58
+ *
59
+ * 1. Register any long-lived local process it starts (vite dev,
60
+ * mock API, docker proxy, etc.) with
61
+ * `peaks sub-agent shutdown register --pid <pid> --name <label>`
62
+ * before it exits, so the parent session can best-effort-kill
63
+ * the process before merge-back (Task 3 + Task 6).
64
+ *
65
+ * 2. NOT run E2E. The parent session runs Playwright verification
66
+ * once after merge-back (Task 10). Sub-agent E2E runs are
67
+ * duplicates that the parent's single E2E pass will catch.
68
+ *
69
+ * 3. NOT call `git merge / pull / rebase` or
70
+ * `peaks worktree release`. The parent session owns the
71
+ * merge-back step (Task 9). Sub-agent merges double-write the
72
+ * index, race the worktree release, and can corrupt the
73
+ * caller's working branch.
74
+ *
75
+ * These rules are appended to the system prompt on top of the L1
76
+ * worktree governance block so the sub-agent sees them last (i.e.
77
+ * most-recently-read), which is the strongest prompt position in
78
+ * transformer attention.
79
+ */
80
+ export declare const LIFECYCLE_RULES = "## Sub-agent lifecycle rules (locked 2026-08-01)\n\n- If you start a long-lived local service (vite dev, mock API, docker container, etc.), register it with `peaks sub-agent shutdown register --pid <pid> --name <label>` before you exit. The parent session will best-effort-kill it before merge-back.\n- Do NOT run E2E. The parent session runs Playwright verification once after merge-back (Task 10). Your E2E work is duplicate effort.\n- Do NOT call `git merge`, `git pull`, `git rebase`, or `peaks worktree release`. The parent session owns the merge-back step.\n";
55
81
  /**
56
82
  * Compose the system-prompt body that the dispatch site prepends to
57
83
  * `formatTestToolDetection()\n\n`.
@@ -42,6 +42,37 @@ If the upstream superpowers chain suggests raw \`git worktree add\`:
42
42
  2. Re-author the plan as a peaks-rd artifact
43
43
  3. Continue with \`peaks worktree spawn\` (or the auth-grant fallback)
44
44
  `;
45
+ /**
46
+ * Slice 2026-08-01-subagent-merge-and-e2e (Task 8): the dispatch
47
+ * system prompt gains three lifecycle rules. The sub-agent must:
48
+ *
49
+ * 1. Register any long-lived local process it starts (vite dev,
50
+ * mock API, docker proxy, etc.) with
51
+ * `peaks sub-agent shutdown register --pid <pid> --name <label>`
52
+ * before it exits, so the parent session can best-effort-kill
53
+ * the process before merge-back (Task 3 + Task 6).
54
+ *
55
+ * 2. NOT run E2E. The parent session runs Playwright verification
56
+ * once after merge-back (Task 10). Sub-agent E2E runs are
57
+ * duplicates that the parent's single E2E pass will catch.
58
+ *
59
+ * 3. NOT call `git merge / pull / rebase` or
60
+ * `peaks worktree release`. The parent session owns the
61
+ * merge-back step (Task 9). Sub-agent merges double-write the
62
+ * index, race the worktree release, and can corrupt the
63
+ * caller's working branch.
64
+ *
65
+ * These rules are appended to the system prompt on top of the L1
66
+ * worktree governance block so the sub-agent sees them last (i.e.
67
+ * most-recently-read), which is the strongest prompt position in
68
+ * transformer attention.
69
+ */
70
+ export const LIFECYCLE_RULES = `## Sub-agent lifecycle rules (locked 2026-08-01)
71
+
72
+ - If you start a long-lived local service (vite dev, mock API, docker container, etc.), register it with \`peaks sub-agent shutdown register --pid <pid> --name <label>\` before you exit. The parent session will best-effort-kill it before merge-back.
73
+ - Do NOT run E2E. The parent session runs Playwright verification once after merge-back (Task 10). Your E2E work is duplicate effort.
74
+ - Do NOT call \`git merge\`, \`git pull\`, \`git rebase\`, or \`peaks worktree release\`. The parent session owns the merge-back step.
75
+ `;
45
76
  /**
46
77
  * Compose the system-prompt body that the dispatch site prepends to
47
78
  * `formatTestToolDetection()\n\n`.
@@ -66,9 +97,9 @@ export function buildDispatchSystemPrompt(input) {
66
97
  const { taskBody, memoryBlock, contextProbe } = input;
67
98
  const contextBlock = renderContextBlock(contextProbe ?? null);
68
99
  if (memoryBlock.available === true && typeof memoryBlock.block === 'string') {
69
- return `${L1_WORKTREE_GOVERNANCE_BLOCK}\n${contextBlock}${memoryBlock.block}\n## Task\n${taskBody}\n`;
100
+ return `${L1_WORKTREE_GOVERNANCE_BLOCK}\n${contextBlock}${memoryBlock.block}\n## Task\n${taskBody}\n${LIFECYCLE_RULES}`;
70
101
  }
71
- return `${L1_WORKTREE_GOVERNANCE_BLOCK}\n${contextBlock}${taskBody}`;
102
+ return `${L1_WORKTREE_GOVERNANCE_BLOCK}\n${contextBlock}${taskBody}\n${LIFECYCLE_RULES}`;
72
103
  }
73
104
  /**
74
105
  * Slice 2026-07-29-context-evaluation-accuracy: emit a
@@ -0,0 +1,32 @@
1
+ /**
2
+ * conflict-replay — pure envelope builder for the auto re-dispatch path.
3
+ *
4
+ * Slice 2026-08-01-subagent-merge-and-e2e (Task 5). When the parent
5
+ * session's merge-back attempt conflicts (see planMergeBack), the
6
+ * merge-back-runner (Task 9) calls `buildConflictReplay` to compose
7
+ * the envelope the parent passes back into the sub-agent dispatch
8
+ * site. The envelope has three sections:
9
+ *
10
+ * 1. callerBranch: <branch>
11
+ * 2. Original prompt (verbatim)
12
+ * 3. Prior merge transcript (one line per attempt)
13
+ * 4. Conflict diff (wrapped in a fenced ```diff block)
14
+ *
15
+ * Plus a stable instruction list the re-dispatched agent receives in
16
+ * addition to its task system prompt. The instruction set forbids
17
+ * new functionality and tells the agent to re-run the dispatch and
18
+ * report the new conflict state — the parent will retry the merge.
19
+ *
20
+ * The builder is pure: no fs, no network, no child_process.
21
+ */
22
+ export type ConflictReplayInput = {
23
+ readonly originalPrompt: string;
24
+ readonly mergeAttemptTranscript: ReadonlyArray<string>;
25
+ readonly conflictDiff: string;
26
+ readonly callerBranch: string;
27
+ };
28
+ export type ConflictReplayOutput = {
29
+ readonly prompt: string;
30
+ readonly instructions: ReadonlyArray<string>;
31
+ };
32
+ export declare function buildConflictReplay(input: ConflictReplayInput): ConflictReplayOutput;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * conflict-replay — pure envelope builder for the auto re-dispatch path.
3
+ *
4
+ * Slice 2026-08-01-subagent-merge-and-e2e (Task 5). When the parent
5
+ * session's merge-back attempt conflicts (see planMergeBack), the
6
+ * merge-back-runner (Task 9) calls `buildConflictReplay` to compose
7
+ * the envelope the parent passes back into the sub-agent dispatch
8
+ * site. The envelope has three sections:
9
+ *
10
+ * 1. callerBranch: <branch>
11
+ * 2. Original prompt (verbatim)
12
+ * 3. Prior merge transcript (one line per attempt)
13
+ * 4. Conflict diff (wrapped in a fenced ```diff block)
14
+ *
15
+ * Plus a stable instruction list the re-dispatched agent receives in
16
+ * addition to its task system prompt. The instruction set forbids
17
+ * new functionality and tells the agent to re-run the dispatch and
18
+ * report the new conflict state — the parent will retry the merge.
19
+ *
20
+ * The builder is pure: no fs, no network, no child_process.
21
+ */
22
+ const INSTRUCTIONS = [
23
+ 'A previous merge into the caller branch conflicted. Resolve the conflict preserving the intent of both your prior work and the caller branch.',
24
+ 'Do NOT introduce new functionality or refactor outside the conflict.',
25
+ 'Re-run the dispatch and report the new conflict state. The parent will retry the merge.',
26
+ ];
27
+ export function buildConflictReplay(input) {
28
+ const prompt = [
29
+ '## Conflict replay',
30
+ `callerBranch: ${input.callerBranch}`,
31
+ '',
32
+ '### Original prompt',
33
+ input.originalPrompt,
34
+ '',
35
+ '### Prior merge transcript',
36
+ ...input.mergeAttemptTranscript.map((l) => ` ${l}`),
37
+ '',
38
+ '### Conflict diff',
39
+ '```diff',
40
+ input.conflictDiff,
41
+ '```',
42
+ ].join('\n');
43
+ return { prompt, instructions: INSTRUCTIONS };
44
+ }
@@ -43,7 +43,7 @@ export interface DispatchRecord {
43
43
  * truth for "this record is v3.1-form"; readers check
44
44
  * `version === '3.1'` for forward-compatible dispatching.
45
45
  */
46
- readonly version: '3.1';
46
+ readonly version: '3.2';
47
47
  readonly createdAt: string;
48
48
  readonly completedAt: string | null;
49
49
  readonly outcome: DispatchOutcome;
@@ -90,6 +90,36 @@ export interface DispatchRecord {
90
90
  * cross-referencing the metrics stream.
91
91
  */
92
92
  readonly isolationStartedAt: string | null;
93
+ /**
94
+ * Slice 2026-08-01-subagent-merge-and-e2e (Task 7): v3.2 schema
95
+ * bump. One entry per pid the parent best-effort-killed during
96
+ * the service-shutdown phase of the merge-back pipeline (see
97
+ * src/services/dispatch/service-shutdown.ts). Empty array when
98
+ * the sub-agent did not register any services. The shape is the
99
+ * union of ServiceKillOutcome (skipped=false) and
100
+ * ServiceKillSkipped (skipped=true) — kept as a plain object
101
+ * here so the on-disk schema does not lock onto the helper's
102
+ * narrower union. The reader (merge-back-runner.ts) interprets
103
+ * each entry based on the `skipped` field.
104
+ */
105
+ readonly serviceKill: ReadonlyArray<{
106
+ readonly pid: number;
107
+ readonly name: string;
108
+ readonly signal: string;
109
+ readonly exitCode: number | null;
110
+ readonly skipped?: boolean;
111
+ readonly reason?: string;
112
+ }>;
113
+ /**
114
+ * Slice 2026-08-01-subagent-merge-and-e2e (Task 7): v3.2 schema
115
+ * bump. Counts how many merge attempts the parent session has
116
+ * made against this dispatch's branch. The conflict-replay
117
+ * orchestrator bumps this on each retry (bounded to ONE re-dispatch
118
+ * per merge attempt; multi-conflict cases escalate). Persisted so
119
+ * the dashboard can render the retry count without replaying the
120
+ * merge transcript.
121
+ */
122
+ readonly mergeBackAttempts: number;
93
123
  }
94
124
  /** Input for the initial write. */
95
125
  export type WriteInitialDispatchInput = {
@@ -44,7 +44,7 @@ export function writeInitialDispatchRecord(input) {
44
44
  const path = dispatchRecordPath(projectRoot, sessionId, requestId, now());
45
45
  const safePath = assertSafeDispatchRecordPath(path, projectRoot);
46
46
  const record = {
47
- version: '3.1',
47
+ version: '3.2',
48
48
  createdAt: now().toISOString(),
49
49
  completedAt: null,
50
50
  outcome: 'no-execution',
@@ -86,7 +86,12 @@ export function writeInitialDispatchRecord(input) {
86
86
  // handle is acceptable for the dashboard.
87
87
  isolationStartedAt: typeof input.isolationStartedAt === 'string' && input.isolationStartedAt.length > 0
88
88
  ? input.isolationStartedAt
89
- : null
89
+ : null,
90
+ // Slice 2026-08-01-subagent-merge-and-e2e (Task 7): v3.2 fields.
91
+ // New records start with empty serviceKill and zero attempts;
92
+ // the merge-back-runner (Task 9) populates them in place.
93
+ serviceKill: [],
94
+ mergeBackAttempts: 0
90
95
  };
91
96
  writeAtomic(safePath, record);
92
97
  // Slice 2026-06-23-audit-4th #A4: register the path in the
@@ -553,9 +558,9 @@ function upgradeRecord(parsed) {
553
558
  // with a schema we have not implemented). v1 records
554
559
  // (pre-audit-trail) must be regenerated.
555
560
  const rawVersion = obj.version;
556
- if (rawVersion !== '3.1' && rawVersion !== 3 && rawVersion !== 2 && rawVersion !== 1) {
557
- throw new Error(`Dispatch record version mismatch: expected '3.1', 3, 2, or 1, got ${JSON.stringify(rawVersion)}. ` +
558
- 'The v1 → v3.1 migration is in-file; records from much older or newer builds must be regenerated.');
561
+ if (rawVersion !== '3.2' && rawVersion !== '3.1' && rawVersion !== 3 && rawVersion !== 2 && rawVersion !== 1) {
562
+ throw new Error(`Dispatch record version mismatch: expected '3.2', '3.1', 3, 2, or 1, got ${JSON.stringify(rawVersion)}. ` +
563
+ 'The v1 → v3.2 migration is in-file; records from much older or newer builds must be regenerated.');
559
564
  }
560
565
  const role = stringField(obj, 'role');
561
566
  const requestId = stringField(obj, 'requestId');
@@ -597,7 +602,7 @@ function upgradeRecord(parsed) {
597
602
  ? obj.batchId
598
603
  : 'legacy-batch';
599
604
  return {
600
- version: '3.1',
605
+ version: '3.2',
601
606
  createdAt,
602
607
  completedAt,
603
608
  outcome,
@@ -632,7 +637,22 @@ function upgradeRecord(parsed) {
632
637
  // to `null`. v3.1 readers can treat the field as opt-in.
633
638
  isolationStartedAt: typeof obj.isolationStartedAt === 'string' && obj.isolationStartedAt.length > 0
634
639
  ? obj.isolationStartedAt
635
- : null
640
+ : null,
641
+ // Slice 2026-08-01-subagent-merge-and-e2e (Task 7): v3.1 → v3.2
642
+ // migration. Legacy v3.1 records have no `serviceKill` or
643
+ // `mergeBackAttempts` fields. Default to [] and 0 so the
644
+ // merge-back-runner (Task 9) can read either schema on disk.
645
+ serviceKill: Array.isArray(obj.serviceKill)
646
+ ? (obj.serviceKill.filter((e) => {
647
+ if (typeof e !== 'object' || e === null)
648
+ return false;
649
+ const o = e;
650
+ return typeof o.pid === 'number' && typeof o.name === 'string' && typeof o.signal === 'string' && (o.exitCode === null || typeof o.exitCode === 'number');
651
+ }))
652
+ : [],
653
+ mergeBackAttempts: typeof obj.mergeBackAttempts === 'number' && Number.isFinite(obj.mergeBackAttempts) && obj.mergeBackAttempts >= 0
654
+ ? Math.floor(obj.mergeBackAttempts)
655
+ : 0
636
656
  };
637
657
  }
638
658
  function isObject(v) {
@@ -0,0 +1,18 @@
1
+ export type E2EFixture = {
2
+ readonly name: string;
3
+ readonly file: string;
4
+ readonly url: string;
5
+ readonly matchers: ReadonlyArray<string>;
6
+ };
7
+ export type E2EPlan = {
8
+ readonly kind: 'disabled';
9
+ readonly reason: string;
10
+ } | {
11
+ readonly kind: 'empty';
12
+ } | {
13
+ readonly kind: 'fixtures';
14
+ readonly fixtures: ReadonlyArray<E2EFixture>;
15
+ };
16
+ export declare function readE2EPlan(input: {
17
+ readonly dir: string;
18
+ }): E2EPlan;
@@ -0,0 +1,72 @@
1
+ /**
2
+ * e2e-fixtures — pure reader for `qa/e2e/<slice>/<scenario>/*.md`.
3
+ *
4
+ * Slice 2026-08-01-subagent-merge-and-e2e (Task 4). The parent session
5
+ * calls `peaks e2e verify --slice <rid>` after the merge-back step. The
6
+ * CLI delegates to `runE2EVerify` (Task 10), which uses this reader to
7
+ * enumerate the fixtures for the slice. The reader returns one of
8
+ * three plans:
9
+ *
10
+ * - { kind: 'disabled', reason } when `qa/e2e/<slice>/disabled`
11
+ * exists (the user has opted out)
12
+ * - { kind: 'empty' } when the slice has no fixtures
13
+ * - { kind: 'fixtures', fixtures } when at least one *.md exists
14
+ *
15
+ * Each fixture is parsed for two fields:
16
+ * - `url: <absolute URL>` (mandatory line)
17
+ * - `matchers: [...]` (free-form lines below)
18
+ *
19
+ * The reader is intentionally simple: it does not validate the URL
20
+ * shape, the matcher grammar, or any browser-level concern. Those
21
+ * concerns belong to the Playwright runner (out of scope here).
22
+ */
23
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
24
+ import { join } from 'node:path';
25
+ function readMarkdownFixture(file) {
26
+ const raw = readFileSync(file, 'utf8');
27
+ let url = '';
28
+ const matchers = [];
29
+ for (const line of raw.split(/\r?\n/)) {
30
+ const urlMatch = /^url:\s*(\S.*)$/.exec(line);
31
+ if (urlMatch)
32
+ url = urlMatch[1] ?? '';
33
+ const matcherMatch = /^matchers:\s*(.*)$/.exec(line);
34
+ if (matcherMatch) {
35
+ const inline = (matcherMatch[1] ?? '').trim();
36
+ if (inline.length > 0)
37
+ matchers.push(inline);
38
+ }
39
+ const listMatch = /^\s*-\s*['"]?(.*?)['"]?\s*$/.exec(line);
40
+ if (listMatch && !/^matchers:\s*$/.test(line)) {
41
+ matchers.push((listMatch[1] ?? '').trim());
42
+ }
43
+ }
44
+ return { url, matchers };
45
+ }
46
+ export function readE2EPlan(input) {
47
+ if (!existsSync(input.dir))
48
+ return { kind: 'empty' };
49
+ if (existsSync(join(input.dir, 'disabled'))) {
50
+ return { kind: 'disabled', reason: 'disabled-file-present' };
51
+ }
52
+ const fixtures = [];
53
+ for (const scenario of readdirSync(input.dir)) {
54
+ const scenarioDir = join(input.dir, scenario);
55
+ if (!statSync(scenarioDir).isDirectory())
56
+ continue;
57
+ for (const file of readdirSync(scenarioDir)) {
58
+ if (!file.endsWith('.md'))
59
+ continue;
60
+ const parsed = readMarkdownFixture(join(scenarioDir, file));
61
+ if (parsed === null)
62
+ continue;
63
+ fixtures.push({
64
+ name: scenario,
65
+ file: join(scenarioDir, file),
66
+ url: parsed.url,
67
+ matchers: parsed.matchers,
68
+ });
69
+ }
70
+ }
71
+ return fixtures.length === 0 ? { kind: 'empty' } : { kind: 'fixtures', fixtures };
72
+ }
@@ -0,0 +1,18 @@
1
+ import { type ServiceKillResult } from './service-shutdown.js';
2
+ import { type ConflictReplayOutput } from './conflict-replay.js';
3
+ export type RunMergeBackInput = {
4
+ readonly projectRoot: string;
5
+ readonly sessionId: string;
6
+ readonly dispatchId: string;
7
+ readonly callerBranch: string;
8
+ readonly agentBranch: string;
9
+ readonly onConflict: (replay: ConflictReplayOutput) => Promise<{
10
+ readonly ok: boolean;
11
+ }>;
12
+ };
13
+ export type RunMergeBackResult = {
14
+ readonly kind: 'merged' | 'noop' | 'replay-exhausted' | 'replay-still-conflict';
15
+ readonly attempts: number;
16
+ readonly serviceKills: ReadonlyArray<ServiceKillResult>;
17
+ };
18
+ export declare function runMergeBack(input: RunMergeBackInput): Promise<RunMergeBackResult>;
@@ -0,0 +1,83 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { planMergeBack } from './post-merge.js';
5
+ import { killRegisteredServices } from './service-shutdown.js';
6
+ import { buildConflictReplay } from './conflict-replay.js';
7
+ const REGISTRATIONS_FILE = 'service-registrations.json';
8
+ function registrationsFile(input) {
9
+ return join(input.projectRoot, '.peaks', '_runtime', input.sessionId, 'dispatch', input.dispatchId, REGISTRATIONS_FILE);
10
+ }
11
+ function readRegistrations(input) {
12
+ const f = registrationsFile(input);
13
+ if (!existsSync(f))
14
+ return [];
15
+ try {
16
+ return JSON.parse(readFileSync(f, 'utf8'));
17
+ }
18
+ catch {
19
+ return [];
20
+ }
21
+ }
22
+ function captureConflictDiff(input) {
23
+ try {
24
+ return execFileSync('git', ['diff', '--merge', '--no-color'], { cwd: input.projectRoot, encoding: 'utf8' });
25
+ }
26
+ catch {
27
+ return '';
28
+ }
29
+ }
30
+ function captureTranscript(input) {
31
+ try {
32
+ return execFileSync('git', ['merge', '--no-edit', '--no-ff', input.agentBranch], { cwd: input.projectRoot, encoding: 'utf8' }).split('\n');
33
+ }
34
+ catch {
35
+ return ['git merge --no-edit --no-ff ' + input.agentBranch];
36
+ }
37
+ }
38
+ export async function runMergeBack(input) {
39
+ const kills = killRegisteredServices({ registrations: readRegistrations(input) });
40
+ const originalPrompt = process.env.PEAKS_DISPATCH_PROMPT ?? '';
41
+ let attempts = 0;
42
+ while (attempts < 2) {
43
+ attempts += 1;
44
+ const plan = planMergeBack({
45
+ callerBranch: input.callerBranch,
46
+ agentBranch: input.agentBranch,
47
+ commitsBehind: 0,
48
+ conflictingFiles: [],
49
+ });
50
+ if (plan.kind === 'noop')
51
+ return { kind: 'noop', attempts, serviceKills: kills };
52
+ if (plan.kind === 'missing')
53
+ return { kind: 'replay-exhausted', attempts, serviceKills: kills };
54
+ if (plan.kind === 'conflict') {
55
+ const conflictDiff = captureConflictDiff(input);
56
+ const replay = buildConflictReplay({ originalPrompt, mergeAttemptTranscript: [], conflictDiff, callerBranch: input.callerBranch });
57
+ const replayResult = await input.onConflict(replay);
58
+ if (!replayResult.ok) {
59
+ return { kind: attempts >= 2 ? 'replay-exhausted' : 'replay-still-conflict', attempts, serviceKills: kills };
60
+ }
61
+ continue;
62
+ }
63
+ try {
64
+ execFileSync('git', ['checkout', input.callerBranch], { cwd: input.projectRoot, stdio: 'ignore' });
65
+ execFileSync(plan.command[0], plan.command.slice(1), { cwd: input.projectRoot, stdio: 'ignore' });
66
+ return { kind: 'merged', attempts, serviceKills: kills };
67
+ }
68
+ catch (error) {
69
+ const transcript = captureTranscript(input);
70
+ const conflictDiff = captureConflictDiff(input);
71
+ const replay = buildConflictReplay({ originalPrompt, mergeAttemptTranscript: transcript, conflictDiff, callerBranch: input.callerBranch });
72
+ const replayResult = await input.onConflict(replay);
73
+ try {
74
+ execFileSync('git', ['merge', '--abort'], { cwd: input.projectRoot, stdio: 'ignore' });
75
+ }
76
+ catch { /* ignore */ }
77
+ if (!replayResult.ok) {
78
+ return { kind: attempts >= 2 ? 'replay-exhausted' : 'replay-still-conflict', attempts, serviceKills: kills };
79
+ }
80
+ }
81
+ }
82
+ return { kind: 'replay-exhausted', attempts, serviceKills: kills };
83
+ }
@@ -0,0 +1,21 @@
1
+ export type MergePlan = {
2
+ readonly kind: 'fast-forward';
3
+ readonly command: ReadonlyArray<string>;
4
+ } | {
5
+ readonly kind: 'no-ff';
6
+ readonly command: ReadonlyArray<string>;
7
+ } | {
8
+ readonly kind: 'conflict';
9
+ readonly conflictingFiles: ReadonlyArray<string>;
10
+ } | {
11
+ readonly kind: 'noop';
12
+ } | {
13
+ readonly kind: 'missing';
14
+ readonly reason: string;
15
+ };
16
+ export declare function planMergeBack(input: {
17
+ readonly callerBranch: string;
18
+ readonly agentBranch: string;
19
+ readonly commitsBehind: number;
20
+ readonly conflictingFiles: ReadonlyArray<string>;
21
+ }): MergePlan;