peaks-loop 4.0.4 → 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 (64) hide show
  1. package/CHANGELOG.md +34 -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/core/skill-command.js +2 -0
  6. package/dist/cli/commands/dispatch-commands.js +41 -2
  7. package/dist/cli/commands/e2e-verify.d.ts +47 -0
  8. package/dist/cli/commands/e2e-verify.js +211 -0
  9. package/dist/cli/commands/gate-commands.js +17 -4
  10. package/dist/cli/commands/statusline-commands.d.ts +6 -0
  11. package/dist/cli/commands/statusline-commands.js +48 -11
  12. package/dist/cli/commands/sub-agent-shutdown-commands.d.ts +31 -0
  13. package/dist/cli/commands/sub-agent-shutdown-commands.js +110 -0
  14. package/dist/cli/commands/worktree-auth-commands.js +27 -0
  15. package/dist/services/code/auto-compact-orchestrator.d.ts +39 -0
  16. package/dist/services/code/auto-compact-orchestrator.js +358 -41
  17. package/dist/services/code/post-compact-detector.js +12 -4
  18. package/dist/services/code/step-08-gate.js +6 -2
  19. package/dist/services/compact-statusline/compact-lifecycle-store.d.ts +68 -0
  20. package/dist/services/compact-statusline/compact-lifecycle-store.js +222 -0
  21. package/dist/services/compact-statusline/compact-statusline-service.d.ts +25 -5
  22. package/dist/services/compact-statusline/compact-statusline-service.js +244 -54
  23. package/dist/services/context/auto-compact-reader.js +12 -4
  24. package/dist/services/context/build-dispatch-system-prompt.d.ts +26 -0
  25. package/dist/services/context/build-dispatch-system-prompt.js +33 -2
  26. package/dist/services/dispatch/conflict-replay.d.ts +32 -0
  27. package/dist/services/dispatch/conflict-replay.js +44 -0
  28. package/dist/services/dispatch/dispatch-record-writer.d.ts +31 -1
  29. package/dist/services/dispatch/dispatch-record-writer.js +27 -7
  30. package/dist/services/dispatch/e2e-fixtures.d.ts +18 -0
  31. package/dist/services/dispatch/e2e-fixtures.js +72 -0
  32. package/dist/services/dispatch/merge-back-runner.d.ts +18 -0
  33. package/dist/services/dispatch/merge-back-runner.js +83 -0
  34. package/dist/services/dispatch/post-merge.d.ts +21 -0
  35. package/dist/services/dispatch/post-merge.js +13 -0
  36. package/dist/services/dispatch/service-shutdown.d.ts +22 -0
  37. package/dist/services/dispatch/service-shutdown.js +51 -0
  38. package/dist/services/hooks/presence-marker-detector.js +12 -4
  39. package/dist/services/hooks/worktree-authorization-gate.d.ts +4 -0
  40. package/dist/services/hooks/worktree-authorization-gate.js +22 -0
  41. package/dist/services/skills/skill-statusline-renderer.d.ts +86 -3
  42. package/dist/services/skills/skill-statusline-renderer.js +374 -29
  43. package/dist/services/skills/skill-statusline-service.d.ts +2 -0
  44. package/dist/services/skills/skill-statusline-service.js +34 -5
  45. package/dist/services/skills/sync-service.d.ts +3 -0
  46. package/dist/services/skills/sync-service.js +1 -0
  47. package/dist/services/standards/project-context.d.ts +2 -1
  48. package/dist/services/standards/project-context.js +17 -2
  49. package/dist/services/standards/project-standards-service.d.ts +9 -0
  50. package/dist/services/standards/project-standards-service.js +26 -0
  51. package/dist/services/worktree/dispatch-provenance.d.ts +25 -0
  52. package/dist/services/worktree/dispatch-provenance.js +47 -0
  53. package/dist/services/worktree/git-worktree-parser.d.ts +8 -0
  54. package/dist/services/worktree/git-worktree-parser.js +45 -0
  55. package/dist/services/worktree/host-worktree-reconciler.d.ts +15 -0
  56. package/dist/services/worktree/host-worktree-reconciler.js +39 -0
  57. package/dist/services/worktree/long-path-cleanup.d.ts +10 -0
  58. package/dist/services/worktree/long-path-cleanup.js +38 -0
  59. package/dist/services/worktree/playwright-profile.d.ts +8 -0
  60. package/dist/services/worktree/playwright-profile.js +23 -0
  61. package/dist/services/worktree/worktree-gc-guard.d.ts +8 -0
  62. package/dist/services/worktree/worktree-gc-guard.js +38 -0
  63. package/package.json +4 -4
  64. package/scripts/install-skills.mjs +11 -2
@@ -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;
@@ -0,0 +1,13 @@
1
+ export function planMergeBack(input) {
2
+ if (input.agentBranch.length === 0)
3
+ return { kind: 'missing', reason: 'agent-branch-empty' };
4
+ if (input.callerBranch === input.agentBranch)
5
+ return { kind: 'noop' };
6
+ if (input.conflictingFiles.length > 0)
7
+ return { kind: 'conflict', conflictingFiles: input.conflictingFiles };
8
+ const base = ['git', 'merge', '--no-ff'];
9
+ if (input.callerBranch === 'main' && input.commitsBehind === 0) {
10
+ return { kind: 'fast-forward', command: ['git', 'merge', '--ff-only', input.agentBranch] };
11
+ }
12
+ return { kind: 'no-ff', command: [...base, input.agentBranch] };
13
+ }
@@ -0,0 +1,22 @@
1
+ export type ServiceRegistration = {
2
+ readonly pid: number;
3
+ readonly name: string;
4
+ readonly url?: string;
5
+ };
6
+ export type ServiceKillOutcome = {
7
+ readonly pid: number;
8
+ readonly name: string;
9
+ readonly skipped: false;
10
+ readonly signal: 'SIGTERM' | 'SIGKILL' | 'taskkill';
11
+ };
12
+ export type ServiceKillSkipped = {
13
+ readonly pid: number;
14
+ readonly name: string;
15
+ readonly skipped: true;
16
+ readonly reason: 'not-running';
17
+ };
18
+ export type ServiceKillResult = ServiceKillOutcome | ServiceKillSkipped;
19
+ export declare function killRegisteredServices(input: {
20
+ readonly registrations: ReadonlyArray<ServiceRegistration>;
21
+ readonly platform?: NodeJS.Platform;
22
+ }): ReadonlyArray<ServiceKillResult>;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * service-shutdown — best-effort kill helper for sub-agent-registered
3
+ * local services.
4
+ *
5
+ * Slice 2026-08-01-subagent-merge-and-e2e (Task 3). The parent session
6
+ * reads `.peaks/_runtime/<sid>/dispatch/<dispatchId>/service-registrations.json`
7
+ * (written by `peaks sub-agent shutdown register --pid <pid> --name <label>`)
8
+ * and calls `killRegisteredServices` BEFORE the merge-back step so a
9
+ * long-lived `vite dev` / mock-API / docker-proxy process started by the
10
+ * sub-agent does not block the merge (file lock, port collision, dirty
11
+ * db, etc).
12
+ *
13
+ * The helper is intentionally minimal:
14
+ * - pid <= 0 or pid === process.pid → skip (`not-running`).
15
+ * - win32: `taskkill /T /F /PID <pid>` (force, with subtree).
16
+ * - POSIX: `kill -TERM <pid>`; on failure escalate to `kill -KILL`.
17
+ * - any error → `not-running` (best-effort; the parent will retry
18
+ * merge regardless).
19
+ *
20
+ * All native calls are best-effort and never throw; the orchestrator
21
+ * (merge-back-runner.ts) just records the array of outcomes on the
22
+ * dispatch record's `serviceKill` field (added in Task 7).
23
+ */
24
+ import { execFileSync } from 'node:child_process';
25
+ export function killRegisteredServices(input) {
26
+ const platform = input.platform ?? process.platform;
27
+ return input.registrations.map((reg) => {
28
+ if (reg.pid <= 0 || reg.pid === process.pid) {
29
+ return { pid: reg.pid, name: reg.name, skipped: true, reason: 'not-running' };
30
+ }
31
+ try {
32
+ if (platform === 'win32') {
33
+ execFileSync('taskkill', ['/T', '/F', '/PID', String(reg.pid)], { stdio: 'ignore' });
34
+ return { pid: reg.pid, name: reg.name, skipped: false, signal: 'taskkill' };
35
+ }
36
+ // POSIX: try SIGTERM via the `kill` CLI (universally available);
37
+ // escalate to SIGKILL on failure. The runner is best-effort.
38
+ try {
39
+ execFileSync('kill', ['-TERM', String(reg.pid)], { stdio: 'ignore' });
40
+ }
41
+ catch {
42
+ execFileSync('kill', ['-KILL', String(reg.pid)], { stdio: 'ignore' });
43
+ return { pid: reg.pid, name: reg.name, skipped: false, signal: 'SIGKILL' };
44
+ }
45
+ return { pid: reg.pid, name: reg.name, skipped: false, signal: 'SIGTERM' };
46
+ }
47
+ catch {
48
+ return { pid: reg.pid, name: reg.name, skipped: true, reason: 'not-running' };
49
+ }
50
+ });
51
+ }
@@ -46,15 +46,23 @@ function readPresenceFile(absolutePath) {
46
46
  try {
47
47
  raw = readFileSync(absolutePath, 'utf8');
48
48
  }
49
- catch { // TODO(g2): legacy silent catch — grace: 1 minor release (v2.14.0)
50
- return null;
49
+ catch (err) { // TODO(g2): legacy silent catch — now narrows to IO errors only (grace: 1 minor release, v2.14.0)
50
+ if (err instanceof ReferenceError)
51
+ throw err; // surface module-load bugs
52
+ if (err instanceof SyntaxError)
53
+ throw err; // surface parse bugs
54
+ return null; // only swallow IO errors
51
55
  }
52
56
  let parsed;
53
57
  try {
54
58
  parsed = JSON.parse(raw);
55
59
  }
56
- catch { // TODO(g2): legacy silent catch — grace: 1 minor release (v2.14.0)
57
- return null;
60
+ catch (err) { // TODO(g2): legacy silent catch — now narrows to IO errors only (grace: 1 minor release, v2.14.0)
61
+ if (err instanceof ReferenceError)
62
+ throw err; // surface module-load bugs
63
+ if (err instanceof SyntaxError)
64
+ throw err; // surface parse bugs
65
+ return null; // only swallow IO errors
58
66
  }
59
67
  if (parsed === null || typeof parsed !== 'object')
60
68
  return null;
@@ -104,6 +104,8 @@ export type WorktreeAuthDenyCode =
104
104
  /** Slice 2026-07-29-worktree-l2-extended Part 19: container
105
105
  * lease id is set but the lease file is unreadable / malformed. */
106
106
  | 'CONTAINER_LEASE_FILE_INVALID'
107
+ /** Host Agent/Task worktree isolation was not issued by Peaks dispatch. */
108
+ | 'HOST_AGENT_ISOLATION_UNMANAGED'
107
109
  /** Container lease exists but is not active (status != 'active' OR past expiresAt). */
108
110
  | 'CONTAINER_LEASE_NOT_ACTIVE'
109
111
  /** Container lease exists but its rid does not match the current peaks request. */
@@ -139,6 +141,8 @@ export type WorktreeAuthCheckInput = {
139
141
  * is active and its rid matches.
140
142
  */
141
143
  readonly containerLeaseId: string | null;
144
+ /** Peaks-issued provenance token required for host Agent/Task worktree isolation. */
145
+ readonly dispatchProvenanceToken?: string | null;
142
146
  };
143
147
  /** Stable identifier of the current "user authorization" — derived from session + tool + key args. */
144
148
  export declare function currentAuthFingerprint(input: WorktreeAuthCheckInput): string;
@@ -36,6 +36,7 @@ import { createHash } from 'node:crypto';
36
36
  import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
37
37
  import { dirname, join } from 'node:path';
38
38
  import { deserializeLease, isLeaseActive, leaseFilePath, } from '../worktree/worktree-lease.js';
39
+ import { readDispatchProvenance } from '../worktree/dispatch-provenance.js';
39
40
  import { containerLeaseFilePath, deserializeContainerLease, isContainerLeaseActive, } from '../container/container-lease.js';
40
41
  export const WORKTREE_AUTH_FILE = 'worktree-auth.json';
41
42
  /** Stable identifier of the current "user authorization" — derived from session + tool + key args. */
@@ -232,6 +233,27 @@ export function decideFromAuthorization(input, operation, file) {
232
233
  */
233
234
  export function evaluateWorktreeAuth(input) {
234
235
  const operation = classifyToolCall(input);
236
+ if (operation === 'agent-isolation-worktree') {
237
+ const token = input.dispatchProvenanceToken ?? '';
238
+ const provenance = token.length > 0
239
+ ? readDispatchProvenance({ projectRoot: input.projectRoot, sessionId: input.sessionId, token })
240
+ : null;
241
+ if (provenance === null ||
242
+ input.leaseId === null ||
243
+ provenance.leaseId !== input.leaseId ||
244
+ (input.requestId !== null && provenance.requestId !== input.requestId)) {
245
+ return {
246
+ allow: false,
247
+ code: 'HOST_AGENT_ISOLATION_UNMANAGED',
248
+ reason: 'Host Agent/Task worktree isolation lacks a matching Peaks dispatch provenance record and canonical lease.',
249
+ remediation: 'Route the work through Peaks sub-agent dispatch with worktree isolation. Existing leaked host worktrees must be reconciled or adopted before cleanup.'
250
+ };
251
+ }
252
+ const leaseDecision = decideFromLease({ ...input, requestId: provenance.requestId });
253
+ if (!leaseDecision.allow)
254
+ return leaseDecision;
255
+ return leaseDecision;
256
+ }
235
257
  if (operation === null) {
236
258
  // Pass-through (Read, Glob, etc., and any Bash that isn't worktree-mutating).
237
259
  return {