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.
- package/CHANGELOG.md +34 -0
- package/README-en.md +26 -0
- package/README.md +26 -0
- package/dist/cli/commands/_register.js +3 -0
- 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.js +12 -4
- 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
|
@@ -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;
|
|
@@ -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
|
|
50
|
-
|
|
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
|
|
57
|
-
|
|
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 {
|