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.
- package/CHANGELOG.md +55 -0
- package/README-en.md +26 -0
- package/README.md +26 -0
- package/dist/cli/commands/_register.js +3 -0
- package/dist/cli/commands/code-runtime-commands.js +15 -2
- package/dist/cli/commands/core/skill-command.js +2 -0
- package/dist/cli/commands/dispatch-commands.js +41 -2
- package/dist/cli/commands/e2e-verify.d.ts +47 -0
- package/dist/cli/commands/e2e-verify.js +211 -0
- package/dist/cli/commands/gate-commands.js +17 -4
- package/dist/cli/commands/statusline-commands.d.ts +6 -0
- package/dist/cli/commands/statusline-commands.js +48 -11
- package/dist/cli/commands/sub-agent-shutdown-commands.d.ts +31 -0
- package/dist/cli/commands/sub-agent-shutdown-commands.js +110 -0
- package/dist/cli/commands/worktree-auth-commands.js +27 -0
- package/dist/services/code/auto-compact-orchestrator.d.ts +39 -0
- package/dist/services/code/auto-compact-orchestrator.js +358 -41
- package/dist/services/code/post-compact-detector.js +12 -4
- package/dist/services/code/step-08-gate.js +6 -2
- package/dist/services/compact-statusline/compact-lifecycle-store.d.ts +68 -0
- package/dist/services/compact-statusline/compact-lifecycle-store.js +222 -0
- package/dist/services/compact-statusline/compact-statusline-service.d.ts +25 -5
- package/dist/services/compact-statusline/compact-statusline-service.js +244 -54
- package/dist/services/context/auto-compact-reader.d.ts +33 -4
- package/dist/services/context/auto-compact-reader.js +77 -22
- package/dist/services/context/auto-compact-types.d.ts +10 -2
- package/dist/services/context/build-dispatch-system-prompt.d.ts +26 -0
- package/dist/services/context/build-dispatch-system-prompt.js +33 -2
- package/dist/services/dispatch/conflict-replay.d.ts +32 -0
- package/dist/services/dispatch/conflict-replay.js +44 -0
- package/dist/services/dispatch/dispatch-record-writer.d.ts +31 -1
- package/dist/services/dispatch/dispatch-record-writer.js +27 -7
- package/dist/services/dispatch/e2e-fixtures.d.ts +18 -0
- package/dist/services/dispatch/e2e-fixtures.js +72 -0
- package/dist/services/dispatch/merge-back-runner.d.ts +18 -0
- package/dist/services/dispatch/merge-back-runner.js +83 -0
- package/dist/services/dispatch/post-merge.d.ts +21 -0
- package/dist/services/dispatch/post-merge.js +13 -0
- package/dist/services/dispatch/service-shutdown.d.ts +22 -0
- package/dist/services/dispatch/service-shutdown.js +51 -0
- package/dist/services/hooks/presence-marker-detector.js +12 -4
- package/dist/services/hooks/worktree-authorization-gate.d.ts +4 -0
- package/dist/services/hooks/worktree-authorization-gate.js +22 -0
- package/dist/services/skills/skill-statusline-renderer.d.ts +86 -3
- package/dist/services/skills/skill-statusline-renderer.js +374 -29
- package/dist/services/skills/skill-statusline-service.d.ts +2 -0
- package/dist/services/skills/skill-statusline-service.js +34 -5
- package/dist/services/skills/sync-service.d.ts +3 -0
- package/dist/services/skills/sync-service.js +1 -0
- package/dist/services/standards/project-context.d.ts +2 -1
- package/dist/services/standards/project-context.js +17 -2
- package/dist/services/standards/project-standards-service.d.ts +9 -0
- package/dist/services/standards/project-standards-service.js +26 -0
- package/dist/services/worktree/dispatch-provenance.d.ts +25 -0
- package/dist/services/worktree/dispatch-provenance.js +47 -0
- package/dist/services/worktree/git-worktree-parser.d.ts +8 -0
- package/dist/services/worktree/git-worktree-parser.js +45 -0
- package/dist/services/worktree/host-worktree-reconciler.d.ts +15 -0
- package/dist/services/worktree/host-worktree-reconciler.js +39 -0
- package/dist/services/worktree/long-path-cleanup.d.ts +10 -0
- package/dist/services/worktree/long-path-cleanup.js +38 -0
- package/dist/services/worktree/playwright-profile.d.ts +8 -0
- package/dist/services/worktree/playwright-profile.js +23 -0
- package/dist/services/worktree/worktree-gc-guard.d.ts +8 -0
- package/dist/services/worktree/worktree-gc-guard.js +38 -0
- package/package.json +4 -4
- 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
|
|
68
|
-
|
|
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
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
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
|
|
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
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
if (
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
96
|
-
|
|
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: '
|
|
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
|
-
* - `
|
|
158
|
-
*
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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;
|