peaks-loop 4.0.3 → 4.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README-en.md +26 -0
  3. package/README.md +26 -0
  4. package/dist/cli/commands/_register.js +3 -0
  5. package/dist/cli/commands/code-runtime-commands.js +15 -2
  6. package/dist/cli/commands/core/skill-command.js +2 -0
  7. package/dist/cli/commands/dispatch-commands.js +41 -2
  8. package/dist/cli/commands/e2e-verify.d.ts +47 -0
  9. package/dist/cli/commands/e2e-verify.js +211 -0
  10. package/dist/cli/commands/gate-commands.js +17 -4
  11. package/dist/cli/commands/statusline-commands.d.ts +6 -0
  12. package/dist/cli/commands/statusline-commands.js +48 -11
  13. package/dist/cli/commands/sub-agent-shutdown-commands.d.ts +31 -0
  14. package/dist/cli/commands/sub-agent-shutdown-commands.js +110 -0
  15. package/dist/cli/commands/worktree-auth-commands.js +27 -0
  16. package/dist/services/code/auto-compact-orchestrator.d.ts +39 -0
  17. package/dist/services/code/auto-compact-orchestrator.js +358 -41
  18. package/dist/services/code/post-compact-detector.js +12 -4
  19. package/dist/services/code/step-08-gate.js +6 -2
  20. package/dist/services/compact-statusline/compact-lifecycle-store.d.ts +68 -0
  21. package/dist/services/compact-statusline/compact-lifecycle-store.js +222 -0
  22. package/dist/services/compact-statusline/compact-statusline-service.d.ts +25 -5
  23. package/dist/services/compact-statusline/compact-statusline-service.js +244 -54
  24. package/dist/services/context/auto-compact-reader.d.ts +33 -4
  25. package/dist/services/context/auto-compact-reader.js +77 -22
  26. package/dist/services/context/auto-compact-types.d.ts +10 -2
  27. package/dist/services/context/build-dispatch-system-prompt.d.ts +26 -0
  28. package/dist/services/context/build-dispatch-system-prompt.js +33 -2
  29. package/dist/services/dispatch/conflict-replay.d.ts +32 -0
  30. package/dist/services/dispatch/conflict-replay.js +44 -0
  31. package/dist/services/dispatch/dispatch-record-writer.d.ts +31 -1
  32. package/dist/services/dispatch/dispatch-record-writer.js +27 -7
  33. package/dist/services/dispatch/e2e-fixtures.d.ts +18 -0
  34. package/dist/services/dispatch/e2e-fixtures.js +72 -0
  35. package/dist/services/dispatch/merge-back-runner.d.ts +18 -0
  36. package/dist/services/dispatch/merge-back-runner.js +83 -0
  37. package/dist/services/dispatch/post-merge.d.ts +21 -0
  38. package/dist/services/dispatch/post-merge.js +13 -0
  39. package/dist/services/dispatch/service-shutdown.d.ts +22 -0
  40. package/dist/services/dispatch/service-shutdown.js +51 -0
  41. package/dist/services/hooks/presence-marker-detector.js +12 -4
  42. package/dist/services/hooks/worktree-authorization-gate.d.ts +4 -0
  43. package/dist/services/hooks/worktree-authorization-gate.js +22 -0
  44. package/dist/services/skills/skill-statusline-renderer.d.ts +86 -3
  45. package/dist/services/skills/skill-statusline-renderer.js +374 -29
  46. package/dist/services/skills/skill-statusline-service.d.ts +2 -0
  47. package/dist/services/skills/skill-statusline-service.js +34 -5
  48. package/dist/services/skills/sync-service.d.ts +3 -0
  49. package/dist/services/skills/sync-service.js +1 -0
  50. package/dist/services/standards/project-context.d.ts +2 -1
  51. package/dist/services/standards/project-context.js +17 -2
  52. package/dist/services/standards/project-standards-service.d.ts +9 -0
  53. package/dist/services/standards/project-standards-service.js +26 -0
  54. package/dist/services/worktree/dispatch-provenance.d.ts +25 -0
  55. package/dist/services/worktree/dispatch-provenance.js +47 -0
  56. package/dist/services/worktree/git-worktree-parser.d.ts +8 -0
  57. package/dist/services/worktree/git-worktree-parser.js +45 -0
  58. package/dist/services/worktree/host-worktree-reconciler.d.ts +15 -0
  59. package/dist/services/worktree/host-worktree-reconciler.js +39 -0
  60. package/dist/services/worktree/long-path-cleanup.d.ts +10 -0
  61. package/dist/services/worktree/long-path-cleanup.js +38 -0
  62. package/dist/services/worktree/playwright-profile.d.ts +8 -0
  63. package/dist/services/worktree/playwright-profile.js +23 -0
  64. package/dist/services/worktree/worktree-gc-guard.d.ts +8 -0
  65. package/dist/services/worktree/worktree-gc-guard.js +38 -0
  66. package/package.json +4 -4
  67. package/scripts/install-skills.mjs +11 -2
@@ -0,0 +1,25 @@
1
+ export declare const DISPATCH_PROVENANCE_ENV = "PEAKS_SUB_AGENT_DISPATCH_PROVENANCE";
2
+ export type DispatchProvenance = {
3
+ readonly schemaVersion: 1;
4
+ readonly token: string;
5
+ readonly sessionId: string;
6
+ readonly requestId: string;
7
+ readonly leaseId: string;
8
+ readonly isolation: 'worktree';
9
+ readonly issuedAt: string;
10
+ };
11
+ export declare function createDispatchProvenanceToken(input: {
12
+ readonly sessionId: string;
13
+ readonly requestId: string;
14
+ readonly leaseId: string;
15
+ }): string;
16
+ export declare function dispatchProvenanceFilePath(projectRoot: string, sessionId: string, token: string): string;
17
+ export declare function writeDispatchProvenance(input: {
18
+ readonly projectRoot: string;
19
+ readonly record: DispatchProvenance;
20
+ }): void;
21
+ export declare function readDispatchProvenance(input: {
22
+ readonly projectRoot: string;
23
+ readonly sessionId: string;
24
+ readonly token: string;
25
+ }): DispatchProvenance | null;
@@ -0,0 +1,47 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ export const DISPATCH_PROVENANCE_ENV = 'PEAKS_SUB_AGENT_DISPATCH_PROVENANCE';
5
+ const PROVENANCE_DIR = 'dispatch-provenance';
6
+ export function createDispatchProvenanceToken(input) {
7
+ return createHash('sha256')
8
+ .update(`${input.sessionId}|${input.requestId}|${input.leaseId}`)
9
+ .digest('hex')
10
+ .slice(0, 32);
11
+ }
12
+ export function dispatchProvenanceFilePath(projectRoot, sessionId, token) {
13
+ return join(projectRoot, '.peaks', '_runtime', sessionId, PROVENANCE_DIR, `${token}.json`);
14
+ }
15
+ export function writeDispatchProvenance(input) {
16
+ const path = dispatchProvenanceFilePath(input.projectRoot, input.record.sessionId, input.record.token);
17
+ mkdirSync(dirname(path), { recursive: true });
18
+ const tempPath = `${path}.tmp-${process.pid}-${Date.now()}`;
19
+ writeFileSync(tempPath, `${JSON.stringify(input.record, null, 2)}\n`, 'utf8');
20
+ renameSync(tempPath, path);
21
+ }
22
+ export function readDispatchProvenance(input) {
23
+ if (!/^[a-f0-9]{32}$/.test(input.token))
24
+ return null;
25
+ const path = dispatchProvenanceFilePath(input.projectRoot, input.sessionId, input.token);
26
+ if (!existsSync(path))
27
+ return null;
28
+ try {
29
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
30
+ if (parsed === null || typeof parsed !== 'object')
31
+ return null;
32
+ const value = parsed;
33
+ if (value.schemaVersion !== 1 ||
34
+ value.token !== input.token ||
35
+ value.sessionId !== input.sessionId ||
36
+ typeof value.requestId !== 'string' ||
37
+ typeof value.leaseId !== 'string' ||
38
+ value.isolation !== 'worktree' ||
39
+ typeof value.issuedAt !== 'string') {
40
+ return null;
41
+ }
42
+ return parsed;
43
+ }
44
+ catch {
45
+ return null;
46
+ }
47
+ }
@@ -0,0 +1,8 @@
1
+ export type GitWorktreeRecord = {
2
+ readonly path: string;
3
+ readonly head: string | null;
4
+ readonly branch: string | null;
5
+ readonly prunable: boolean;
6
+ };
7
+ export declare function parseGitWorktreePorcelain(raw: string): ReadonlyArray<GitWorktreeRecord>;
8
+ export declare function isPathInside(parent: string, candidate: string): boolean;
@@ -0,0 +1,45 @@
1
+ import { relative, resolve, sep } from 'node:path';
2
+ export function parseGitWorktreePorcelain(raw) {
3
+ const records = [];
4
+ let current = null;
5
+ const flush = () => {
6
+ if (current !== null) {
7
+ records.push({
8
+ path: current.path,
9
+ head: current.head,
10
+ branch: current.branch,
11
+ prunable: current.prunable,
12
+ });
13
+ }
14
+ current = null;
15
+ };
16
+ for (const line of raw.split(/\r?\n/)) {
17
+ if (line.length === 0) {
18
+ flush();
19
+ continue;
20
+ }
21
+ const space = line.indexOf(' ');
22
+ const key = space === -1 ? line : line.slice(0, space);
23
+ const value = space === -1 ? '' : line.slice(space + 1);
24
+ if (key === 'worktree') {
25
+ flush();
26
+ const next = { path: value, head: null, branch: null, prunable: false };
27
+ current = next;
28
+ }
29
+ else if (current !== null && key === 'HEAD') {
30
+ current = { path: current.path, head: value, branch: current.branch, prunable: current.prunable };
31
+ }
32
+ else if (current !== null && key === 'branch') {
33
+ current = { path: current.path, head: current.head, branch: value.replace(/^refs\/heads\//, ''), prunable: current.prunable };
34
+ }
35
+ else if (current !== null && key === 'prunable') {
36
+ current = { path: current.path, head: current.head, branch: current.branch, prunable: true };
37
+ }
38
+ }
39
+ flush();
40
+ return records;
41
+ }
42
+ export function isPathInside(parent, candidate) {
43
+ const rel = relative(resolve(parent), resolve(candidate));
44
+ return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !rel.startsWith(sep));
45
+ }
@@ -0,0 +1,15 @@
1
+ import { type GitWorktreeRecord } from './git-worktree-parser.js';
2
+ export type HostWorktreeFinding = GitWorktreeRecord & {
3
+ readonly managedLeaseId: string | null;
4
+ readonly state: 'managed' | 'unleased' | 'prunable';
5
+ };
6
+ export type HostWorktreeReconcileResult = {
7
+ readonly hostRoot: string;
8
+ readonly findings: ReadonlyArray<HostWorktreeFinding>;
9
+ readonly unmanaged: ReadonlyArray<HostWorktreeFinding>;
10
+ };
11
+ export declare function reconcileHostWorktrees(input: {
12
+ readonly projectRoot: string;
13
+ readonly sessionId: string;
14
+ readonly hostRoot?: string;
15
+ }): HostWorktreeReconcileResult;
@@ -0,0 +1,39 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
3
+ import { basename, join, resolve } from 'node:path';
4
+ import { leaseStoreDir, listLeasesSync } from './worktree-lease.js';
5
+ import { isPathInside, parseGitWorktreePorcelain } from './git-worktree-parser.js';
6
+ export function reconcileHostWorktrees(input) {
7
+ const hostRoot = resolve(input.hostRoot ?? join(input.projectRoot, '.claude', 'worktrees'));
8
+ let records = [];
9
+ try {
10
+ const raw = execFileSync('git', ['worktree', 'list', '--porcelain'], {
11
+ cwd: input.projectRoot,
12
+ encoding: 'utf8',
13
+ stdio: ['ignore', 'pipe', 'pipe'],
14
+ });
15
+ records = parseGitWorktreePorcelain(raw);
16
+ }
17
+ catch {
18
+ records = [];
19
+ }
20
+ const storeDir = leaseStoreDir(join(input.projectRoot, '.peaks', '_runtime', input.sessionId));
21
+ const leaseResult = listLeasesSync(storeDir, {
22
+ existsSync,
23
+ readdir: (path) => readdirSync(path),
24
+ readFile: (path) => readFileSync(path, 'utf8'),
25
+ });
26
+ const leases = leaseResult.kind === 'ok' ? leaseResult.leases : [];
27
+ const leaseByPath = new Map(leases.map((lease) => [resolve(lease.path), lease.leaseId]));
28
+ const findings = records
29
+ .filter((record) => isPathInside(hostRoot, record.path) && basename(record.path).startsWith('agent-'))
30
+ .map((record) => {
31
+ const managedLeaseId = leaseByPath.get(resolve(record.path)) ?? null;
32
+ return {
33
+ ...record,
34
+ managedLeaseId,
35
+ state: record.prunable ? 'prunable' : managedLeaseId === null ? 'unleased' : 'managed',
36
+ };
37
+ });
38
+ return { hostRoot, findings, unmanaged: findings.filter((finding) => finding.state !== 'managed') };
39
+ }
@@ -0,0 +1,10 @@
1
+ export type LongPathCleanupResult = {
2
+ readonly removed: boolean;
3
+ readonly strategy: 'git' | 'windows-long-path' | 'none';
4
+ readonly error?: string;
5
+ };
6
+ export declare function windowsLongPath(path: string): string;
7
+ export declare function removeRegisteredWorktree(input: {
8
+ readonly projectRoot: string;
9
+ readonly worktreePath: string;
10
+ }): LongPathCleanupResult;
@@ -0,0 +1,38 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, rmSync } from 'node:fs';
3
+ import { resolve } from 'node:path';
4
+ export function windowsLongPath(path) {
5
+ const absolute = resolve(path);
6
+ if (process.platform !== 'win32' || absolute.startsWith('\\\\?\\'))
7
+ return absolute;
8
+ if (absolute.startsWith('\\\\'))
9
+ return `\\\\?\\UNC\\${absolute.slice(2)}`;
10
+ return `\\\\?\\${absolute}`;
11
+ }
12
+ export function removeRegisteredWorktree(input) {
13
+ try {
14
+ execFileSync('git', ['worktree', 'remove', '--force', input.worktreePath], {
15
+ cwd: input.projectRoot,
16
+ stdio: ['ignore', 'pipe', 'pipe'],
17
+ });
18
+ execFileSync('git', ['worktree', 'prune'], { cwd: input.projectRoot, stdio: 'ignore' });
19
+ return { removed: !existsSync(input.worktreePath), strategy: 'git' };
20
+ }
21
+ catch (error) {
22
+ if (process.platform !== 'win32') {
23
+ return { removed: false, strategy: 'none', error: error instanceof Error ? error.message : String(error) };
24
+ }
25
+ try {
26
+ rmSync(windowsLongPath(input.worktreePath), { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
27
+ execFileSync('git', ['worktree', 'prune'], { cwd: input.projectRoot, stdio: 'ignore' });
28
+ return { removed: !existsSync(input.worktreePath), strategy: 'windows-long-path' };
29
+ }
30
+ catch (fallbackError) {
31
+ return {
32
+ removed: false,
33
+ strategy: 'none',
34
+ error: fallbackError instanceof Error ? fallbackError.message : String(fallbackError),
35
+ };
36
+ }
37
+ }
38
+ }
@@ -0,0 +1,8 @@
1
+ export declare function playwrightProfilePaths(input: {
2
+ readonly projectRoot: string;
3
+ readonly sessionId: string;
4
+ readonly dispatchId: string;
5
+ }): {
6
+ readonly userDataDir: string;
7
+ readonly profileName: string;
8
+ };
@@ -0,0 +1,23 @@
1
+ /**
2
+ * playwright-profile — deterministic Chromium user-data-dir +
3
+ * profile-name pair generator for a (session, dispatch) tuple.
4
+ *
5
+ * Slice 2026-08-01-subagent-merge-and-e2e (Task 2). Each sub-agent
6
+ * dispatch with `--isolation worktree` MUST land its Playwright MCP
7
+ * browser session in a unique Chromium profile so concurrent
8
+ * dispatches do not share cookies / localStorage / IndexedDB. The
9
+ * pair is:
10
+ *
11
+ * userDataDir = <projectRoot>/.peaks/_runtime/<sessionId>/pw-profiles/<dispatchId>
12
+ * profileName = "dispatch-<dispatchId>"
13
+ *
14
+ * The userDataDir is gitignored (it lives under `.peaks/_runtime/`)
15
+ * so cookies do not leak across slices. The profileName is the
16
+ * Chromium `--profile-directory` flag the Playwright MCP server
17
+ * reads when it spawns the browser.
18
+ */
19
+ import { join } from 'node:path';
20
+ export function playwrightProfilePaths(input) {
21
+ const userDataDir = join(input.projectRoot, '.peaks', '_runtime', input.sessionId, 'pw-profiles', input.dispatchId);
22
+ return { userDataDir, profileName: `dispatch-${input.dispatchId}` };
23
+ }
@@ -0,0 +1,8 @@
1
+ export type SkillJunctionReference = {
2
+ readonly junctionPath: string;
3
+ readonly target: string;
4
+ };
5
+ export declare function findManagedSkillJunctionReferences(input: {
6
+ readonly worktreePath: string;
7
+ readonly skillRoots: ReadonlyArray<string>;
8
+ }): ReadonlyArray<SkillJunctionReference>;
@@ -0,0 +1,38 @@
1
+ import { existsSync, lstatSync, readFileSync, readdirSync, readlinkSync } from 'node:fs';
2
+ import { dirname, resolve } from 'node:path';
3
+ export function findManagedSkillJunctionReferences(input) {
4
+ const targetRoot = resolve(input.worktreePath);
5
+ const refs = [];
6
+ for (const skillRoot of input.skillRoots) {
7
+ if (!existsSync(skillRoot))
8
+ continue;
9
+ for (const name of readdirSync(skillRoot)) {
10
+ if (!name.startsWith('peaks-') || name.endsWith('.peaks-managed'))
11
+ continue;
12
+ const junctionPath = resolve(skillRoot, name);
13
+ const markerPath = `${junctionPath}.peaks-managed`;
14
+ if (!existsSync(markerPath))
15
+ continue;
16
+ try {
17
+ if (!lstatSync(junctionPath).isSymbolicLink())
18
+ continue;
19
+ const link = readlinkSync(junctionPath);
20
+ const resolvedTarget = resolve(dirname(junctionPath), link);
21
+ if (isInside(targetRoot, resolvedTarget))
22
+ refs.push({ junctionPath, target: resolvedTarget });
23
+ }
24
+ catch {
25
+ const managedTarget = readFileSync(markerPath, 'utf8').trim();
26
+ if (managedTarget.length > 0 && isInside(targetRoot, managedTarget)) {
27
+ refs.push({ junctionPath, target: managedTarget });
28
+ }
29
+ }
30
+ }
31
+ }
32
+ return refs;
33
+ }
34
+ function isInside(parent, candidate) {
35
+ const normalizedParent = resolve(parent).toLowerCase();
36
+ const normalizedCandidate = resolve(candidate).toLowerCase();
37
+ return normalizedCandidate === normalizedParent || normalizedCandidate.startsWith(`${normalizedParent}\\`) || normalizedCandidate.startsWith(`${normalizedParent}/`);
38
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peaks-loop",
3
- "version": "4.0.3",
3
+ "version": "4.0.5",
4
4
  "description": "Loop Engineering CLI — workflow primitive / loop guards / evaluators / slice orchestration",
5
5
  "author": "SquabbyZ",
6
6
  "keywords": [
@@ -98,9 +98,9 @@
98
98
  "headroom-ai": "0.22.4",
99
99
  "yaml": "^2.9.0",
100
100
  "zod": "^3.25.76",
101
- "peaks-loop-mut": "0.1.6",
102
- "peaks-loop-shared-channel": "0.0.11",
103
- "peaks-loop-shared": "0.0.32"
101
+ "peaks-loop-mut": "0.1.9",
102
+ "peaks-loop-shared": "0.0.35",
103
+ "peaks-loop-shared-channel": "0.0.14"
104
104
  },
105
105
  "peerDependencies": {
106
106
  "@alibaba-group/open-code-review": "1.3.1"
@@ -641,11 +641,20 @@ export function installBundledSkills(options = {}) {
641
641
  if (current) {
642
642
  const managedTarget = getManagedTarget(targetPath);
643
643
  const linkTarget = current.isSymbolicLink() ? readlinkSync(targetPath) : null;
644
- if (linkTarget === sourcePath) {
644
+ const linkTargetExists = typeof linkTarget === 'string' && existsSync(linkTarget);
645
+ if (linkTarget === sourcePath && linkTargetExists) {
645
646
  installed.push(skillName);
646
647
  continue;
647
648
  }
648
- if ((current.isSymbolicLink() || isBrokenSymlink(current, targetPath)) && managedTarget === linkTarget) {
649
+ // Managed Junctions may point into an ephemeral host worktree. If that
650
+ // worktree was removed, Windows keeps the Junction entry but its target
651
+ // no longer resolves. Repair only when reconciliation was explicitly
652
+ // requested; normal postinstall keeps its existing ownership behavior.
653
+ if (
654
+ (current.isSymbolicLink() || isBrokenSymlink(current, targetPath)) &&
655
+ (managedTarget === linkTarget ||
656
+ (options.reconcileJunctions === true && managedTarget !== null && !linkTargetExists))
657
+ ) {
649
658
  validateSkillsRoot();
650
659
  unlinkSync(targetPath);
651
660
  validateSkillsRoot();