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
@@ -54,7 +54,7 @@ function majorOf(version) {
54
54
  const major = cleaned.split('.')[0];
55
55
  return major !== undefined && /^\d+$/.test(major) ? major : undefined;
56
56
  }
57
- function detectComponentLibrary(deps) {
57
+ export function detectComponentLibrary(projectRoot, deps) {
58
58
  if ('antd' in deps) {
59
59
  const major = majorOf(deps['antd']);
60
60
  const hasProSuite = '@ant-design/pro-components' in deps || Object.keys(deps).some((d) => d.startsWith('@ant-design/pro-'));
@@ -80,6 +80,19 @@ function detectComponentLibrary(deps) {
80
80
  return { name: 'chakra' };
81
81
  if ('vant' in deps)
82
82
  return { name: 'vant' };
83
+ // shadcn / ui is a Tailwind-based component primitive set shipped as
84
+ // copyable source files under `components/ui/`. It has no top-level
85
+ // package of its own, so the signal we use is: Tailwind is present
86
+ // AND at least one of its peer utilities is also present.
87
+ if (('class-variance-authority' in deps
88
+ || 'clsx' in deps
89
+ || 'tailwind-merge' in deps
90
+ || 'lucide-react' in deps)
91
+ && ('tailwindcss' in deps
92
+ || existsSync(join(projectRoot, 'components', 'ui'))
93
+ || existsSync(join(projectRoot, 'src', 'components', 'ui')))) {
94
+ return { name: 'shadcn' };
95
+ }
83
96
  return { name: 'none' };
84
97
  }
85
98
  function detectCssFrameworks(projectRoot, deps) {
@@ -235,7 +248,7 @@ function notableDepsList(deps) {
235
248
  export function detectProjectContext(projectRoot) {
236
249
  const { exists, deps } = readPackageJson(projectRoot);
237
250
  const { tool, path } = detectBuildTool(projectRoot);
238
- const componentLibrary = detectComponentLibrary(deps);
251
+ const componentLibrary = detectComponentLibrary(projectRoot, deps);
239
252
  const cssFrameworks = detectCssFrameworks(projectRoot, deps);
240
253
  return {
241
254
  hasPackageJson: exists,
@@ -291,6 +304,8 @@ export function componentLibraryLabel(lib) {
291
304
  return 'NextUI';
292
305
  case 'chakra':
293
306
  return 'Chakra UI';
307
+ case 'shadcn':
308
+ return 'shadcn / ui';
294
309
  case 'vant':
295
310
  return 'Vant (mobile)';
296
311
  case 'none':
@@ -1,3 +1,4 @@
1
+ import { type ProjectContext } from './project-context.js';
1
2
  /**
2
3
  * Typed error raised when a planned `peaks standards` write target would
3
4
  * land outside `projectRoot` (or collide with the user-level baseline
@@ -106,6 +107,14 @@ export type ProjectStandardsInitOptions = {
106
107
  * the file-probe logic.
107
108
  */
108
109
  export declare function detectLanguage(projectRoot: string): StandardsLanguage;
110
+ /**
111
+ * UI library priority rule (effective 2026-08-01). Public so unit tests
112
+ * can pin the contract without reaching through the plan builder.
113
+ *
114
+ * Returns the rule line, or `null` when the project has no detected
115
+ * component library (in which case the rule is intentionally silent).
116
+ */
117
+ export declare function renderUiLibraryPriorityRule(ctx: ProjectContext): string | null;
109
118
  export declare function createProjectStandardsInitPlan(options: ProjectStandardsInitOptions): ProjectStandardsInitPlan;
110
119
  export declare function createProjectStandardsUpdatePlan(options: ProjectStandardsInitOptions): ProjectStandardsUpdatePlan;
111
120
  export declare function executeProjectStandardsInit(options: ProjectStandardsInitOptions): ProjectStandardsInitResult;
@@ -245,6 +245,25 @@ function renderSecurity(ctx) {
245
245
  const rules = extra.length > 0 ? [...baseRules, '', '## Project-specific security focus', ...extra] : baseRules;
246
246
  return `${renderHeader('Security Review Standards')}${rules.join('\n')}\n`;
247
247
  }
248
+ /**
249
+ * UI library priority rule (effective 2026-08-01). Public so unit tests
250
+ * can pin the contract without reaching through the plan builder.
251
+ *
252
+ * Returns the rule line, or `null` when the project has no detected
253
+ * component library (in which case the rule is intentionally silent).
254
+ */
255
+ export function renderUiLibraryPriorityRule(ctx) {
256
+ const lib = ctx.componentLibrary.name;
257
+ const UI_LIB_NAMES = [
258
+ 'antd', 'antd-pro', 'mui', 'shadcn',
259
+ 'chakra', 'element-plus', 'element-ui',
260
+ 'arco', 'tdesign', 'semi', 'nextui'
261
+ ];
262
+ if (!UI_LIB_NAMES.includes(lib)) {
263
+ return null;
264
+ }
265
+ return `- UI library priority (peaks-loop, effective 2026-08-01): this project uses \`${lib}\`. Prefer the library's exported components over hand-rolled native DOM / HTML primitives. Native DOM is acceptable only for primitives the library does not ship; leave a one-line comment naming the library primitive that was unavailable. Library themes and tokens are the source of truth; do NOT introduce a parallel CSS framework or inline styles that fight the library.`;
266
+ }
248
267
  function renderLanguageCodingStyle(language, ctx) {
249
268
  const languageName = language === 'generic' ? 'Generic' : language[0].toUpperCase() + language.slice(1);
250
269
  const typeSafetyRule = language === 'typescript' || language === 'javascript'
@@ -267,6 +286,13 @@ function renderLanguageCodingStyle(language, ctx) {
267
286
  if ((language === 'typescript' || language === 'javascript') && ctx.buildTool === 'umi') {
268
287
  extra.push('- Use the project\'s existing service-layer pattern (`src/services/**`) for API calls; do not hand-roll `fetch` in components.');
269
288
  }
289
+ // UI library priority (effective 2026-08-01). Applies to any project whose
290
+ // scan identified one of the supported component libraries; the rule
291
+ // is generated by the standards update so it ships to every downstream
292
+ // project through `peaks standards init/update`.
293
+ const uiRule = renderUiLibraryPriorityRule(ctx);
294
+ if (uiRule !== null)
295
+ extra.push(uiRule);
270
296
  const rules = extra.length > 0 ? [...baseRules, '', '## Project-specific rules', ...extra] : baseRules;
271
297
  return `${renderHeader(`${languageName} Coding Standards`)}${rules.join('\n')}\n`;
272
298
  }
@@ -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.4",
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.7",
102
- "peaks-loop-shared": "0.0.33",
103
- "peaks-loop-shared-channel": "0.0.12"
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();