peaks-cli 1.4.2 → 2.0.1

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 (180) hide show
  1. package/.claude-plugin/marketplace.json +51 -0
  2. package/CHANGELOG.md +279 -0
  3. package/README-en.md +226 -0
  4. package/README.md +152 -122
  5. package/dist/src/cli/commands/agent-commands.d.ts +20 -0
  6. package/dist/src/cli/commands/agent-commands.js +48 -0
  7. package/dist/src/cli/commands/audit-commands.d.ts +18 -0
  8. package/dist/src/cli/commands/audit-commands.js +138 -0
  9. package/dist/src/cli/commands/capability-commands.js +2 -1
  10. package/dist/src/cli/commands/classify-classify-commands.d.ts +19 -0
  11. package/dist/src/cli/commands/classify-classify-commands.js +151 -0
  12. package/dist/src/cli/commands/code-review-commands.d.ts +34 -0
  13. package/dist/src/cli/commands/code-review-commands.js +83 -0
  14. package/dist/src/cli/commands/config-commands.js +90 -0
  15. package/dist/src/cli/commands/context-commands.d.ts +21 -0
  16. package/dist/src/cli/commands/context-commands.js +167 -0
  17. package/dist/src/cli/commands/core-artifact-commands.js +60 -2
  18. package/dist/src/cli/commands/hook-handle.js +50 -0
  19. package/dist/src/cli/commands/loop-commands.d.ts +21 -0
  20. package/dist/src/cli/commands/loop-commands.js +128 -0
  21. package/dist/src/cli/commands/openspec-commands.js +37 -0
  22. package/dist/src/cli/commands/preferences-commands.d.ts +2 -0
  23. package/dist/src/cli/commands/preferences-commands.js +147 -0
  24. package/dist/src/cli/commands/skill-conformance-commands.d.ts +9 -0
  25. package/dist/src/cli/commands/skill-conformance-commands.js +39 -0
  26. package/dist/src/cli/commands/understand-commands.js +34 -0
  27. package/dist/src/cli/commands/upgrade-commands.d.ts +23 -0
  28. package/dist/src/cli/commands/upgrade-commands.js +57 -0
  29. package/dist/src/cli/commands/workflow-commands.js +70 -0
  30. package/dist/src/cli/commands/workspace-commands.js +117 -2
  31. package/dist/src/cli/program.js +30 -0
  32. package/dist/src/lib/render/message-renderer.d.ts +20 -0
  33. package/dist/src/lib/render/message-renderer.js +80 -0
  34. package/dist/src/services/agent/ecc-agent-service.d.ts +47 -0
  35. package/dist/src/services/agent/ecc-agent-service.js +143 -0
  36. package/dist/src/services/artifacts/request-artifact-service.js +14 -0
  37. package/dist/src/services/audit/backing-detector.d.ts +24 -0
  38. package/dist/src/services/audit/backing-detector.js +59 -0
  39. package/dist/src/services/audit/classifier.d.ts +38 -0
  40. package/dist/src/services/audit/classifier.js +127 -0
  41. package/dist/src/services/audit/enforcers/active-skill-resolver.d.ts +29 -0
  42. package/dist/src/services/audit/enforcers/active-skill-resolver.js +71 -0
  43. package/dist/src/services/audit/enforcers/design-draft-confirm.d.ts +25 -0
  44. package/dist/src/services/audit/enforcers/design-draft-confirm.js +54 -0
  45. package/dist/src/services/audit/enforcers/lint-audit-regression.d.ts +21 -0
  46. package/dist/src/services/audit/enforcers/lint-audit-regression.js +86 -0
  47. package/dist/src/services/audit/enforcers/lint-catalog-governance.d.ts +27 -0
  48. package/dist/src/services/audit/enforcers/lint-catalog-governance.js +38 -0
  49. package/dist/src/services/audit/enforcers/lint-cli-back.d.ts +16 -0
  50. package/dist/src/services/audit/enforcers/lint-cli-back.js +35 -0
  51. package/dist/src/services/audit/enforcers/lint-output-style.d.ts +11 -0
  52. package/dist/src/services/audit/enforcers/lint-output-style.js +94 -0
  53. package/dist/src/services/audit/enforcers/lint-reference-integrity.d.ts +6 -0
  54. package/dist/src/services/audit/enforcers/lint-reference-integrity.js +83 -0
  55. package/dist/src/services/audit/enforcers/lint-reference-shape.d.ts +30 -0
  56. package/dist/src/services/audit/enforcers/lint-reference-shape.js +272 -0
  57. package/dist/src/services/audit/enforcers/lint-style.d.ts +49 -0
  58. package/dist/src/services/audit/enforcers/lint-style.js +173 -0
  59. package/dist/src/services/audit/enforcers/lint-workflow-shape.d.ts +5 -0
  60. package/dist/src/services/audit/enforcers/lint-workflow-shape.js +141 -0
  61. package/dist/src/services/audit/enforcers/login-gate.d.ts +23 -0
  62. package/dist/src/services/audit/enforcers/login-gate.js +40 -0
  63. package/dist/src/services/audit/enforcers/mock-placement.d.ts +25 -0
  64. package/dist/src/services/audit/enforcers/mock-placement.js +48 -0
  65. package/dist/src/services/audit/enforcers/no-root-pollution.d.ts +21 -0
  66. package/dist/src/services/audit/enforcers/no-root-pollution.js +56 -0
  67. package/dist/src/services/audit/enforcers/pre-rd-scan.d.ts +22 -0
  68. package/dist/src/services/audit/enforcers/pre-rd-scan.js +23 -0
  69. package/dist/src/services/audit/enforcers/prototype-fidelity.d.ts +25 -0
  70. package/dist/src/services/audit/enforcers/prototype-fidelity.js +75 -0
  71. package/dist/src/services/audit/enforcers/resume-detection.d.ts +21 -0
  72. package/dist/src/services/audit/enforcers/resume-detection.js +52 -0
  73. package/dist/src/services/audit/enforcers/solo-code-ban.d.ts +23 -0
  74. package/dist/src/services/audit/enforcers/solo-code-ban.js +27 -0
  75. package/dist/src/services/audit/enforcers/sub-agent-sid.d.ts +25 -0
  76. package/dist/src/services/audit/enforcers/sub-agent-sid.js +63 -0
  77. package/dist/src/services/audit/enforcers/tech-doc-presence.d.ts +28 -0
  78. package/dist/src/services/audit/enforcers/tech-doc-presence.js +35 -0
  79. package/dist/src/services/audit/red-line-catalog-p2-a.d.ts +21 -0
  80. package/dist/src/services/audit/red-line-catalog-p2-a.js +233 -0
  81. package/dist/src/services/audit/red-line-catalog-p2-b.d.ts +19 -0
  82. package/dist/src/services/audit/red-line-catalog-p2-b.js +225 -0
  83. package/dist/src/services/audit/red-line-catalog.d.ts +51 -0
  84. package/dist/src/services/audit/red-line-catalog.js +210 -0
  85. package/dist/src/services/audit/red-lines-service.d.ts +23 -0
  86. package/dist/src/services/audit/red-lines-service.js +486 -0
  87. package/dist/src/services/audit/scanners/openspec-scanner.d.ts +15 -0
  88. package/dist/src/services/audit/scanners/openspec-scanner.js +55 -0
  89. package/dist/src/services/audit/scanners/rules-tree-scanner.d.ts +16 -0
  90. package/dist/src/services/audit/scanners/rules-tree-scanner.js +56 -0
  91. package/dist/src/services/audit/scanners/skills-tree-scanner.d.ts +17 -0
  92. package/dist/src/services/audit/scanners/skills-tree-scanner.js +46 -0
  93. package/dist/src/services/audit/static-service.d.ts +57 -0
  94. package/dist/src/services/audit/static-service.js +125 -0
  95. package/dist/src/services/audit/types.d.ts +69 -0
  96. package/dist/src/services/audit/types.js +13 -0
  97. package/dist/src/services/classify/classify-service.d.ts +42 -0
  98. package/dist/src/services/classify/classify-service.js +122 -0
  99. package/dist/src/services/classify/classify-types.d.ts +79 -0
  100. package/dist/src/services/classify/classify-types.js +90 -0
  101. package/dist/src/services/code-review/ocr-service.d.ts +129 -0
  102. package/dist/src/services/code-review/ocr-service.js +362 -0
  103. package/dist/src/services/config/config-migration.d.ts +32 -0
  104. package/dist/src/services/config/config-migration.js +111 -0
  105. package/dist/src/services/config/config-restore.d.ts +10 -0
  106. package/dist/src/services/config/config-restore.js +47 -0
  107. package/dist/src/services/config/config-rollback.d.ts +13 -0
  108. package/dist/src/services/config/config-rollback.js +26 -0
  109. package/dist/src/services/config/config-service.d.ts +36 -2
  110. package/dist/src/services/config/config-service.js +105 -0
  111. package/dist/src/services/config/config-types.d.ts +73 -0
  112. package/dist/src/services/config/config-types.js +28 -13
  113. package/dist/src/services/config/model-routing.js +5 -3
  114. package/dist/src/services/doctor/doctor-service.js +96 -0
  115. package/dist/src/services/ide/adapters/hermes-adapter.d.ts +21 -0
  116. package/dist/src/services/ide/adapters/hermes-adapter.js +51 -0
  117. package/dist/src/services/ide/adapters/openclaw-adapter.d.ts +14 -0
  118. package/dist/src/services/ide/adapters/openclaw-adapter.js +42 -0
  119. package/dist/src/services/ide/ide-registry.js +7 -0
  120. package/dist/src/services/ide/ide-types.d.ts +1 -1
  121. package/dist/src/services/openspec/openspec-propose-from-doctor-service.d.ts +31 -0
  122. package/dist/src/services/openspec/openspec-propose-from-doctor-service.js +95 -0
  123. package/dist/src/services/preferences/preferences-service.d.ts +6 -0
  124. package/dist/src/services/preferences/preferences-service.js +43 -0
  125. package/dist/src/services/preferences/preferences-types.d.ts +90 -0
  126. package/dist/src/services/preferences/preferences-types.js +38 -0
  127. package/dist/src/services/rd/rd-service.js +29 -1
  128. package/dist/src/services/skills/skill-conformance-service.d.ts +40 -0
  129. package/dist/src/services/skills/skill-conformance-service.js +136 -0
  130. package/dist/src/services/skills/skill-runbook-service.js +44 -10
  131. package/dist/src/services/skills/sync-service.d.ts +86 -0
  132. package/dist/src/services/skills/sync-service.js +271 -0
  133. package/dist/src/services/slice/slice-check-service.js +166 -13
  134. package/dist/src/services/slice/slice-check-types.d.ts +1 -1
  135. package/dist/src/services/standards/migrate-claude-rules-service.d.ts +19 -0
  136. package/dist/src/services/standards/migrate-claude-rules-service.js +193 -0
  137. package/dist/src/services/understand/understand-scan-service.js +15 -2
  138. package/dist/src/services/understand/understand-types.d.ts +26 -0
  139. package/dist/src/services/upgrade/1x-detector-service.d.ts +7 -0
  140. package/dist/src/services/upgrade/1x-detector-service.js +94 -0
  141. package/dist/src/services/upgrade/gitignore-migrate-service.d.ts +56 -0
  142. package/dist/src/services/upgrade/gitignore-migrate-service.js +170 -0
  143. package/dist/src/services/upgrade/upgrade-service.d.ts +47 -0
  144. package/dist/src/services/upgrade/upgrade-service.js +381 -0
  145. package/dist/src/services/workflow/workflow-router-service.js +15 -4
  146. package/dist/src/services/workspace/claude-settings-template.d.ts +53 -0
  147. package/dist/src/services/workspace/claude-settings-template.js +133 -0
  148. package/dist/src/services/workspace/sid-naming-guard.d.ts +14 -0
  149. package/dist/src/services/workspace/sid-naming-guard.js +31 -0
  150. package/dist/src/services/workspace/workspace-archive-service.d.ts +19 -0
  151. package/dist/src/services/workspace/workspace-archive-service.js +32 -0
  152. package/dist/src/services/workspace/workspace-clean-service.d.ts +41 -0
  153. package/dist/src/services/workspace/workspace-clean-service.js +86 -0
  154. package/dist/src/services/workspace/workspace-service.d.ts +24 -0
  155. package/dist/src/services/workspace/workspace-service.js +124 -2
  156. package/dist/src/services/workspace/workspace-state-service.d.ts +7 -0
  157. package/dist/src/services/workspace/workspace-state-service.js +43 -0
  158. package/dist/src/shared/change-id.js +4 -1
  159. package/dist/src/shared/version.d.ts +1 -1
  160. package/dist/src/shared/version.js +1 -1
  161. package/package.json +8 -2
  162. package/schemas/doctor-report.schema.json +1 -1
  163. package/scripts/install-skills.mjs +296 -12
  164. package/skills/peaks-doctor/SKILL.md +59 -0
  165. package/skills/peaks-doctor/references/doctor-check-catalog.md +31 -0
  166. package/skills/peaks-doctor/references/from-doctor-flow.md +64 -0
  167. package/skills/peaks-doctor/test_prompts.json +17 -0
  168. package/skills/peaks-ide/SKILL.md +2 -0
  169. package/skills/peaks-qa/SKILL.md +9 -7
  170. package/skills/peaks-qa/references/artifact-per-request.md +19 -5
  171. package/skills/peaks-qa/references/qa-perf-test-plan.md +6 -6
  172. package/skills/peaks-qa/references/qa-runbook.md +1 -1
  173. package/skills/peaks-rd/SKILL.md +25 -10
  174. package/skills/peaks-rd/references/ocr-integration.md +214 -0
  175. package/skills/peaks-rd/references/rd-fanout-contracts.md +70 -0
  176. package/skills/peaks-rd/references/rd-runbook.md +1 -1
  177. package/skills/peaks-solo/SKILL.md +16 -4
  178. package/skills/peaks-solo/references/anchoring-and-session-info.md +9 -0
  179. package/skills/peaks-solo/references/step-0-55-1x-detection.md +82 -0
  180. package/skills/peaks-solo/references/workflow-gates-and-types.md +9 -0
@@ -1,4 +1,5 @@
1
1
  import { dirname, join } from 'node:path';
2
+ import * as path from 'node:path';
2
3
  import { readText } from '../../shared/fs.js';
3
4
  import { loadSkillRegistry } from './skill-registry.js';
4
5
  const DESTRUCTIVE_APPLY_PATTERNS = [
@@ -12,8 +13,25 @@ const DESTRUCTIVE_APPLY_PATTERNS = [
12
13
  const AUTHORIZATION_KEYWORDS_PATTERN = /authoriz|explicit|--dry-run|approv|only after|only when/i;
13
14
  const PEAKS_COMMAND_LINE_PATTERN = /^\s*peaks\s+\w/;
14
15
  function extractRunbookSection(body) {
15
- const match = /## Default runbook\n+([\s\S]*?)(?=\n## |$)/.exec(body);
16
- return match === null ? null : match[1];
16
+ // Find a `## Default runbook` heading at the start of a line, then capture
17
+ // the section body up to the next `## ` heading or end of input.
18
+ //
19
+ // Implementation note: we avoid a regex with a multiline `(?=\n## |$)`
20
+ // lookahead because the `m` flag turns `$` into "end of any line" in
21
+ // JavaScript, which would let the lazy capture stop at the first `\n`.
22
+ // Instead, we (a) use the regex only to locate the heading, then (b)
23
+ // manually scan forward for the next `## ` heading or end of input.
24
+ const headingRe = /^## Default runbook[^\n]*(?:\n|$)/m;
25
+ const headingMatch = headingRe.exec(body);
26
+ if (headingMatch === null)
27
+ return null;
28
+ const startAfter = headingMatch.index + headingMatch[0].length;
29
+ const rest = body.slice(startAfter);
30
+ // Find the next `## ` heading at the start of a line.
31
+ const nextHeadingRe = /^## /m;
32
+ const nextMatch = nextHeadingRe.exec(rest);
33
+ const end = nextMatch === null ? rest.length : nextMatch.index;
34
+ return rest.slice(0, end);
17
35
  }
18
36
  /**
19
37
  * Load the runbook section, falling back to `references/runbook.md` if the
@@ -32,15 +50,31 @@ function extractRunbookSection(body) {
32
50
  */
33
51
  async function loadRunbookSection(skillPath, body) {
34
52
  const inline = extractRunbookSection(body);
35
- const refPath = join(dirname(skillPath), 'references', 'runbook.md');
36
- let refSection = null;
37
- try {
38
- const refBody = await readText(refPath);
39
- refSection = extractRunbookSection(refBody);
40
- }
41
- catch {
42
- // reference file does not exist or is not readable
53
+ const skillDir = dirname(skillPath);
54
+ // Try multiple reference filenames. Convention: each skill may name its
55
+ // runbook file `runbook.md` (the generic name, used by peaks-solo), or
56
+ // `<role>-runbook.md` (the role-suffixed name, used by peaks-rd → rd-runbook.md,
57
+ // peaks-qa → qa-runbook.md, etc.). Prefer the longest extracted section.
58
+ const refCandidates = [
59
+ join(skillDir, 'references', 'runbook.md'),
60
+ join(skillDir, 'references', `${path.basename(skillDir).replace(/^peaks-/, '')}-runbook.md`)
61
+ ];
62
+ let bestRef = null;
63
+ for (const refPath of refCandidates) {
64
+ try {
65
+ const refBody = await readText(refPath);
66
+ const refSection = extractRunbookSection(refBody);
67
+ if (refSection === null)
68
+ continue;
69
+ if (bestRef === null || refSection.length > bestRef.length) {
70
+ bestRef = refSection;
71
+ }
72
+ }
73
+ catch {
74
+ // candidate not present or unreadable; try the next one
75
+ }
43
76
  }
77
+ const refSection = bestRef;
44
78
  if (inline === null)
45
79
  return refSection;
46
80
  if (refSection === null)
@@ -0,0 +1,86 @@
1
+ import type { IdeId } from '../ide/ide-types.js';
2
+ /**
3
+ * The 8 platforms per Slice #12 final piece. Slice #0.7 + Slice
4
+ * #0.5.2 registered these in the IdeId union; this list is the
5
+ * single source of truth for the sync fan-out.
6
+ */
7
+ export declare const SYNC_PLATFORMS: readonly IdeId[];
8
+ export interface PlatformSyncResult {
9
+ /** The platform that was attempted. */
10
+ readonly platform: IdeId;
11
+ /** True if installBundledSkills returned without error. */
12
+ readonly ok: boolean;
13
+ /** Skills newly symlinked (idempotent re-runs return []). */
14
+ readonly installed: readonly string[];
15
+ /** Skills whose target was not a managed symlink (third-party owned). */
16
+ readonly skipped: readonly string[];
17
+ /** Error message; present when ok=false. */
18
+ readonly error?: string;
19
+ /** Wall-clock duration in ms. */
20
+ readonly durationMs: number;
21
+ }
22
+ export interface SyncServiceInput {
23
+ readonly projectRoot: string;
24
+ /** When omitted, the service iterates all 8 platforms. */
25
+ readonly platforms?: readonly IdeId[] | undefined;
26
+ /** When true, the installer is invoked in dry-run mode. */
27
+ readonly dryRun?: boolean | undefined;
28
+ }
29
+ export interface SyncServiceResult {
30
+ readonly applied: boolean;
31
+ readonly dryRun: boolean;
32
+ readonly projectRoot: string;
33
+ readonly perPlatform: readonly PlatformSyncResult[];
34
+ readonly syncedCount: number;
35
+ readonly failedCount: number;
36
+ readonly totalInstalled: number;
37
+ }
38
+ interface InstallBundledSkillsOptions {
39
+ readonly ideId: IdeId;
40
+ readonly projectRoot: string;
41
+ readonly dryRun?: boolean;
42
+ readonly targetRoot?: string;
43
+ }
44
+ interface InstallResult {
45
+ readonly installed: readonly string[];
46
+ readonly skipped: readonly string[];
47
+ }
48
+ type InstallerFn = (opts: InstallBundledSkillsOptions) => InstallResult;
49
+ /**
50
+ * Resolve the path of `install-skills.mjs` inside the peaks-cli
51
+ * install root, walking up from `import.meta.url` until a
52
+ * `package.json` with `"name": "peaks-cli"` is found. Returns
53
+ * `null` when peaks-cli is not on the import path or the script
54
+ * is absent (e.g. a partial install).
55
+ */
56
+ export declare function resolvePeaksCliInstallerPath(): string | null;
57
+ /**
58
+ * Test seam: attempt to import the installer at `scriptPath`.
59
+ * Returns the `installBundledSkills` function on success, or
60
+ * `null` when the file is missing / not importable. The
61
+ * production code calls this through `loadInstaller`; tests
62
+ * `vi.spyOn` it to drive the three-tier probe without touching
63
+ * the real filesystem.
64
+ */
65
+ export declare function loadInstallerForTest(scriptPath: string): Promise<InstallerFn | null>;
66
+ /**
67
+ * Validate a single platform id against the SYNC_PLATFORMS
68
+ * allowlist. Throws on a bogus value.
69
+ */
70
+ export declare function assertValidPlatform(platform: string): asserts platform is IdeId;
71
+ export declare function runSkillSync(input: SyncServiceInput): Promise<SyncServiceResult>;
72
+ /**
73
+ * Test-only export surface. Not part of the public API; subject
74
+ * to breaking changes without a major version bump.
75
+ *
76
+ * The seam exposes the `services` indirection table (so tests
77
+ * can `vi.spyOn` the resolver and loader) and a cache reset.
78
+ */
79
+ export declare const __testing: {
80
+ services: {
81
+ resolvePeaksCliInstallerPath(): string | null;
82
+ loadInstallerForTest(scriptPath: string): Promise<InstallerFn | null>;
83
+ };
84
+ resetInstallerCache(): void;
85
+ };
86
+ export {};
@@ -0,0 +1,271 @@
1
+ /**
2
+ * peaks skill sync — fan-out the peaks-* skill family to all 8
3
+ * supported LLM-CLI platforms. Per spec §9 line 1105 (Slice #12
4
+ * final piece): "peaks skills sync 8 平台分发".
5
+ *
6
+ * The 8 target platforms are enumerated by the `IdeId` union
7
+ * (src/services/ide/ide-types.ts:16-24). The per-IDE install
8
+ * profile is `IdeSkillInstall`; the actual symlink installer
9
+ * is `scripts/install-skills.mjs::installBundledSkills` (dynamically
10
+ * imported so this module does not require a build step).
11
+ *
12
+ * Slice 2.0.1-bug2-skill-sync-fallback: when peaks-cli is
13
+ * installed from npm into a consumer project, that consumer's
14
+ * CWD does not contain `scripts/install-skills.mjs`. The previous
15
+ * hard-coded `join(process.cwd(), 'scripts', 'install-skills.mjs')`
16
+ * therefore threw `ERR_MODULE_NOT_FOUND` in every consumer run.
17
+ * The fix is a three-tier probe:
18
+ * 1. peaks-cli's own install path (resolved from
19
+ * `import.meta.url` walking up to the package root, or
20
+ * from `process.argv[1]` for CJS-equivalent entrypoints),
21
+ * 2. the consumer CWD (`<cwd>/scripts/install-skills.mjs`),
22
+ * 3. graceful skip — warn once per process, return a no-op
23
+ * installer so the per-platform result is `ok: true` with
24
+ * `installed: []` and a `skipped` rationale.
25
+ */
26
+ import { existsSync } from 'node:fs';
27
+ import { dirname, join, resolve as resolvePath } from 'node:path';
28
+ import { fileURLToPath, pathToFileURL } from 'node:url';
29
+ /**
30
+ * The 8 platforms per Slice #12 final piece. Slice #0.7 + Slice
31
+ * #0.5.2 registered these in the IdeId union; this list is the
32
+ * single source of truth for the sync fan-out.
33
+ */
34
+ export const SYNC_PLATFORMS = [
35
+ 'claude-code',
36
+ 'trae',
37
+ 'codex',
38
+ 'cursor',
39
+ 'qoder',
40
+ 'tongyi-lingma',
41
+ 'hermes',
42
+ 'openclaw',
43
+ ];
44
+ /**
45
+ * Sentinel: the resolver ran, found no installer, and warned.
46
+ * Memoized so subsequent `loadInstaller()` calls short-circuit
47
+ * without re-walking the filesystem on every platform iteration.
48
+ */
49
+ const NO_INSTALLER_SENTINEL = Symbol('sync-service.no-installer');
50
+ /**
51
+ * Cache state: either an installer function, the "not found"
52
+ * sentinel, or `null` (cache cold, first probe still pending).
53
+ */
54
+ let cachedInstaller = null;
55
+ /**
56
+ * No-op installer used when neither candidate path resolves to
57
+ * an importable `install-skills.mjs`. Reports zero installs and
58
+ * a single skip reason so the per-platform result is `ok: true`
59
+ * with an explainable `skipped` line.
60
+ */
61
+ function noopInstaller(_opts) {
62
+ return {
63
+ installed: [],
64
+ skipped: [
65
+ 'install-skills.mjs not found in project; skill sync skipped — bundled skills are installed via peaks-cli postinstall',
66
+ ],
67
+ };
68
+ }
69
+ /**
70
+ * Internal indirection table for the test seam. The production
71
+ * `loadInstaller` reads `services.resolvePeaksCliInstallerPath()`
72
+ * and `services.loadInstallerForTest()` at call time, so a
73
+ * `vi.spyOn(services, 'resolvePeaksCliInstallerPath')` in tests
74
+ * takes effect (ES module top-level `const` captures the original
75
+ * reference and would bypass the spy).
76
+ */
77
+ const services = {
78
+ resolvePeaksCliInstallerPath,
79
+ loadInstallerForTest,
80
+ };
81
+ /**
82
+ * Resolve the path of `install-skills.mjs` inside the peaks-cli
83
+ * install root, walking up from `import.meta.url` until a
84
+ * `package.json` with `"name": "peaks-cli"` is found. Returns
85
+ * `null` when peaks-cli is not on the import path or the script
86
+ * is absent (e.g. a partial install).
87
+ */
88
+ export function resolvePeaksCliInstallerPath() {
89
+ const candidates = [];
90
+ // Tier 1a: walk up from this module's URL.
91
+ try {
92
+ const here = dirname(fileURLToPath(import.meta.url));
93
+ let cursor = here;
94
+ for (let depth = 0; depth < 8; depth += 1) {
95
+ const pkgJson = join(cursor, 'package.json');
96
+ if (existsSync(pkgJson)) {
97
+ candidates.push(join(cursor, 'scripts', 'install-skills.mjs'));
98
+ break;
99
+ }
100
+ const parent = dirname(cursor);
101
+ if (parent === cursor)
102
+ break;
103
+ cursor = parent;
104
+ }
105
+ }
106
+ catch {
107
+ // import.meta.url may be unavailable in some bundlers; fall through.
108
+ }
109
+ // Tier 1b: process.argv[1] (the entrypoint). Useful when this
110
+ // module is bundled or shimmed.
111
+ try {
112
+ const argvEntry = process.argv[1];
113
+ if (typeof argvEntry === 'string' && argvEntry.length > 0) {
114
+ let cursor = resolvePath(dirname(argvEntry));
115
+ for (let depth = 0; depth < 8; depth += 1) {
116
+ const pkgJson = join(cursor, 'package.json');
117
+ if (existsSync(pkgJson)) {
118
+ candidates.push(join(cursor, 'scripts', 'install-skills.mjs'));
119
+ break;
120
+ }
121
+ const parent = dirname(cursor);
122
+ if (parent === cursor)
123
+ break;
124
+ cursor = parent;
125
+ }
126
+ }
127
+ }
128
+ catch {
129
+ // process.argv may be unavailable in some runtimes; fall through.
130
+ }
131
+ for (const candidate of candidates) {
132
+ if (existsSync(candidate)) {
133
+ return candidate;
134
+ }
135
+ }
136
+ return null;
137
+ }
138
+ /**
139
+ * Test seam: attempt to import the installer at `scriptPath`.
140
+ * Returns the `installBundledSkills` function on success, or
141
+ * `null` when the file is missing / not importable. The
142
+ * production code calls this through `loadInstaller`; tests
143
+ * `vi.spyOn` it to drive the three-tier probe without touching
144
+ * the real filesystem.
145
+ */
146
+ export async function loadInstallerForTest(scriptPath) {
147
+ try {
148
+ const mod = (await import(pathToFileURL(scriptPath).href));
149
+ return mod.installBundledSkills;
150
+ }
151
+ catch {
152
+ return null;
153
+ }
154
+ }
155
+ /**
156
+ * Resolve and load the installer, memoizing the outcome.
157
+ * Three-tier probe (peaks-cli install path → CWD → no-op),
158
+ * with the "not found" outcome memoized as a sentinel so the
159
+ * warning is logged at most once per process.
160
+ *
161
+ * The probe delegates to the `services` indirection table so
162
+ * tests can `vi.spyOn(services, 'resolvePeaksCliInstallerPath')`
163
+ * and have the spied value take effect at runtime (direct
164
+ * module-level calls would bind the original function at load
165
+ * time and bypass the spy).
166
+ */
167
+ async function loadInstaller() {
168
+ if (cachedInstaller === NO_INSTALLER_SENTINEL) {
169
+ return noopInstaller;
170
+ }
171
+ if (cachedInstaller !== null) {
172
+ return cachedInstaller;
173
+ }
174
+ // Tier 1: peaks-cli install path.
175
+ const peaksCliScript = services.resolvePeaksCliInstallerPath();
176
+ if (peaksCliScript !== null) {
177
+ const installer = await services.loadInstallerForTest(peaksCliScript);
178
+ if (installer !== null) {
179
+ cachedInstaller = installer;
180
+ return installer;
181
+ }
182
+ }
183
+ // Tier 2: consumer CWD.
184
+ const cwdScript = join(process.cwd(), 'scripts', 'install-skills.mjs');
185
+ const cwdInstaller = await services.loadInstallerForTest(cwdScript);
186
+ if (cwdInstaller !== null) {
187
+ cachedInstaller = cwdInstaller;
188
+ return cwdInstaller;
189
+ }
190
+ // Tier 3: graceful skip. Warn once per process.
191
+ cachedInstaller = NO_INSTALLER_SENTINEL;
192
+ // eslint-disable-next-line no-console -- intentional user-visible signal
193
+ console.warn('peaks skill sync: install-skills.mjs not found in project; ' +
194
+ 'skipping (bundled skills come from peaks-cli postinstall).');
195
+ return noopInstaller;
196
+ }
197
+ /**
198
+ * Validate a single platform id against the SYNC_PLATFORMS
199
+ * allowlist. Throws on a bogus value.
200
+ */
201
+ export function assertValidPlatform(platform) {
202
+ if (!SYNC_PLATFORMS.includes(platform)) {
203
+ throw new Error(`peaks skill sync: unknown platform "${platform}". ` +
204
+ `Valid platforms: ${SYNC_PLATFORMS.join(', ')}`);
205
+ }
206
+ }
207
+ export async function runSkillSync(input) {
208
+ const platforms = input.platforms ?? SYNC_PLATFORMS;
209
+ for (const p of platforms) {
210
+ assertValidPlatform(p);
211
+ }
212
+ const dryRun = input.dryRun === true;
213
+ const installer = await loadInstaller();
214
+ const perPlatform = [];
215
+ let syncedCount = 0;
216
+ let failedCount = 0;
217
+ let totalInstalled = 0;
218
+ for (const platform of platforms) {
219
+ const start = Date.now();
220
+ try {
221
+ const result = installer({
222
+ ideId: platform,
223
+ projectRoot: input.projectRoot,
224
+ ...(dryRun ? { dryRun: true } : {}),
225
+ });
226
+ perPlatform.push({
227
+ platform,
228
+ ok: true,
229
+ installed: result.installed,
230
+ skipped: result.skipped,
231
+ durationMs: Date.now() - start,
232
+ });
233
+ syncedCount += 1;
234
+ totalInstalled += result.installed.length;
235
+ }
236
+ catch (err) {
237
+ const message = err instanceof Error ? err.message : String(err);
238
+ perPlatform.push({
239
+ platform,
240
+ ok: false,
241
+ installed: [],
242
+ skipped: [],
243
+ error: message,
244
+ durationMs: Date.now() - start,
245
+ });
246
+ failedCount += 1;
247
+ }
248
+ }
249
+ return {
250
+ applied: !dryRun,
251
+ dryRun,
252
+ projectRoot: input.projectRoot,
253
+ perPlatform,
254
+ syncedCount,
255
+ failedCount,
256
+ totalInstalled,
257
+ };
258
+ }
259
+ /**
260
+ * Test-only export surface. Not part of the public API; subject
261
+ * to breaking changes without a major version bump.
262
+ *
263
+ * The seam exposes the `services` indirection table (so tests
264
+ * can `vi.spyOn` the resolver and loader) and a cache reset.
265
+ */
266
+ export const __testing = {
267
+ services,
268
+ resetInstallerCache() {
269
+ cachedInstaller = null;
270
+ },
271
+ };
@@ -1,17 +1,55 @@
1
1
  import { execFileSync } from 'node:child_process';
2
- import { existsSync, statSync } from 'node:fs';
2
+ import { existsSync, readFileSync, statSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { isDirectory } from '../../shared/fs.js';
5
5
  import { getCurrentChangeId } from '../../shared/change-id.js';
6
6
  import { verifyPipeline } from '../workflow/pipeline-verify-service.js';
7
- function runCommand(command, args, cwd, timeoutMs) {
7
+ import { findMockViolations } from '../audit/enforcers/mock-placement.js';
8
+ import { runRedLinesAudit } from '../audit/red-lines-service.js';
9
+ /**
10
+ * Resolve a CLI binary to a project-local path, falling back to
11
+ * the system `npx`. pnpm (and npm/yarn) all create
12
+ * `node_modules/.bin/<name>`:
13
+ *
14
+ * - On Unix, this is a symlink to the package's executable.
15
+ * - On Windows, this is a `.cmd` shim; `execFileSync` only
16
+ * resolves `.cmd` through the shell (PATHEXT), so we pass
17
+ * `shell: true` when invoking one. Without this, the
18
+ * Windows `npx ENOENT` false-positive from
19
+ * observations 2317 + 2792 reproduces for every local
20
+ * binary.
21
+ *
22
+ * Returns the command + args + a `shell` flag that the
23
+ * `runCommand` helper threads into `execFileSync`.
24
+ */
25
+ function resolveLocalBinary(projectRoot, name) {
26
+ // pnpm creates `node_modules/.bin/<name>` (symlink on Unix,
27
+ // `.cmd` shim on Windows). We probe both shapes; the
28
+ // `process.platform === 'win32'` extension probe is the most
29
+ // portable approach.
30
+ const isWin = process.platform === 'win32';
31
+ const candidateNames = isWin ? [`${name}.cmd`, `${name}.ps1`, `${name}`] : [name];
32
+ for (const candidate of candidateNames) {
33
+ const cmdPath = join(projectRoot, 'node_modules', '.bin', candidate);
34
+ if (existsSync(cmdPath)) {
35
+ return { command: cmdPath, args: [], shell: isWin };
36
+ }
37
+ }
38
+ // Fallback: system npx. On Windows this still has the ENOENT
39
+ // issue, but the fallback is at least informative when it
40
+ // fires (the user can see "npx not found" instead of a
41
+ // silent exit 1).
42
+ return { command: 'npx', args: [name], shell: false };
43
+ }
44
+ function runCommand(command, args, cwd, timeoutMs, shell = false) {
8
45
  const start = Date.now();
9
46
  try {
10
47
  const stdout = execFileSync(command, args, {
11
48
  cwd,
12
49
  stdio: ['ignore', 'pipe', 'pipe'],
13
50
  timeout: timeoutMs,
14
- maxBuffer: 32 * 1024 * 1024
51
+ maxBuffer: 32 * 1024 * 1024,
52
+ shell
15
53
  }).toString('utf8');
16
54
  return {
17
55
  status: 'pass',
@@ -41,11 +79,17 @@ function tailLines(text, max) {
41
79
  }
42
80
  async function runTypecheck(projectRoot) {
43
81
  const start = Date.now();
44
- const result = runCommand('npx', ['tsc', '--noEmit'], projectRoot, 180_000);
82
+ // Per Windows npx ENOENT (observations 2317+2792 from
83
+ // 2026-06-09), prefer the project-local `node_modules/.bin/tsc`
84
+ // (symlink on Unix, .cmd on Windows). The local binary is
85
+ // installed by pnpm at workspace-install time and avoids the
86
+ // npx PATH-lookup issue.
87
+ const tsc = resolveLocalBinary(projectRoot, 'tsc');
88
+ const result = runCommand(tsc.command, [...tsc.args, '--noEmit'], projectRoot, 180_000, tsc.shell);
45
89
  const testFiles = result.stdout.match(/(tests?\/.*\.test\.ts)/g) ?? [];
46
90
  return {
47
91
  name: 'typecheck',
48
- description: 'npx tsc --noEmit (no JS emit, type-only check)',
92
+ description: `${tsc.command} --noEmit (no JS emit, type-only check)`,
49
93
  status: result.status,
50
94
  durationMs: result.durationMs,
51
95
  detail: result.status === 'pass'
@@ -76,13 +120,17 @@ async function runUnitTests(projectRoot, runTests) {
76
120
  // state. Opt-in to the full suite via `runTests: true` (CLI flag
77
121
  // `--run-tests`). See `references/runbook.md` for the rationale and
78
122
  // `tests/unit/slice-check-service.test.ts` for the regression net.
79
- const args = runTests
80
- ? ['vitest', 'run', '--reporter=default', '--coverage=false']
81
- : ['vitest', 'run', '--changed', '--reporter=default', '--coverage=false'];
123
+ // Per Windows npx ENOENT (observations 2317+2792), resolve
124
+ // the project-local vitest binary instead of shelling out
125
+ // through npx.
126
+ const vitest = resolveLocalBinary(projectRoot, 'vitest');
127
+ const vitestArgs = runTests
128
+ ? ['run', '--reporter=default', '--coverage=false']
129
+ : ['run', '--changed', '--reporter=default', '--coverage=false'];
82
130
  const description = runTests
83
- ? 'npx vitest run (full test suite, coverage off)'
84
- : 'npx vitest run --changed (tests for git-changed files only, coverage off)';
85
- const result = runCommand('npx', args, projectRoot, 600_000);
131
+ ? `${vitest.command} run (full test suite, coverage off)`
132
+ : `${vitest.command} run --changed (tests for git-changed files only, coverage off)`;
133
+ const result = runCommand(vitest.command, [...vitest.args, ...vitestArgs], projectRoot, 600_000, vitest.shell);
86
134
  const summary = parseVitestSummary(result.stdout, result.durationMs);
87
135
  // Vitest doesn't always print the per-bucket counts cleanly; infer "passed"
88
136
  // as total - failed - skipped when failed/skipped buckets are present.
@@ -225,7 +273,7 @@ export async function sliceCheck(options) {
225
273
  if (options.skipTests) {
226
274
  stages.push({
227
275
  name: 'unit-tests',
228
- description: 'npx vitest run (skipped per --skip-tests)',
276
+ description: 'vitest run (skipped per --skip-tests)',
229
277
  status: 'skipped',
230
278
  durationMs: 0,
231
279
  detail: 'Skipped: --skip-tests was set. Use the peaks-solo-test skill to run the full suite manually.'
@@ -244,7 +292,7 @@ export async function sliceCheck(options) {
244
292
  const failureCount = unitTests.data?.failed ?? 0;
245
293
  stages.push({
246
294
  name: 'unit-tests',
247
- description: `npx vitest run ${options.runTests === true ? '' : '--changed '} (overridden via --allow-pre-existing-failures)`.trim(),
295
+ description: `vitest run ${options.runTests === true ? '' : '--changed '} (overridden via --allow-pre-existing-failures)`.trim(),
248
296
  status: 'skipped',
249
297
  durationMs: unitTests.durationMs,
250
298
  detail: `pre-existing failures: ${failureCount} failing test(s) under coverage.exclude or unrelated to this slice; user opted in via --allow-pre-existing-failures. For the long-term fix, mark these tests .skip or move to coverage.exclude (see dogfood-2-f1-f4.md F17c).`,
@@ -261,6 +309,17 @@ export async function sliceCheck(options) {
261
309
  stages.push(await runReviewFanout(options.projectRoot, rid, options.refreshFanout));
262
310
  // Stage 4: gate verify-pipeline
263
311
  stages.push(await runGateVerifyPipeline(options.projectRoot, rid, rid));
312
+ // Stage 5: mock-placement (L2.1 P0 #5) — refuse inline mock data in src/ or skills/.
313
+ // Lifts changed files via `git diff --name-only HEAD`; falls back to a
314
+ // warning when the diff is empty (e.g. a fresh tree). Lighter than the
315
+ // full `peaks scan diff-vs-scope` and keeps the slice check self-contained.
316
+ stages.push(await runMockPlacement(options.projectRoot));
317
+ // Stage 6 (Slice #7 L2.4 P2-b): audit-regression — assert
318
+ // catalog integrity (no orphan enforcers, no orphan catalog
319
+ // entries), catalog size lower bound, and runtime budget.
320
+ // The stage runs `peaks audit red-lines` in-process (no
321
+ // subprocess) and is gating: failure exits non-zero.
322
+ stages.push(await runAuditRegression(options.projectRoot));
264
323
  const boundaryReady = stages.every((s) => s.status === 'pass' || s.status === 'skipped');
265
324
  const nextActions = [];
266
325
  if (!boundaryReady) {
@@ -283,3 +342,97 @@ export async function sliceCheck(options) {
283
342
  nextActions
284
343
  };
285
344
  }
345
+ async function runAuditRegression(projectRoot) {
346
+ const start = Date.now();
347
+ try {
348
+ const result = runRedLinesAudit({ projectRoot });
349
+ const durationMs = Date.now() - start;
350
+ // Slice #7 L2.4 P2-b acceptance A3 + A4:
351
+ // - totalRedLines >= 60 (catalog grew to 66; pins the lower bound)
352
+ // - enforcerFindings has no rl-audit-no-orphan-enforcer / rl-audit-no-orphan-catalog hits
353
+ const issues = [];
354
+ if (result.audit.totalRedLines < 60) {
355
+ issues.push(`totalRedLines ${result.audit.totalRedLines} < 60`);
356
+ }
357
+ const orphanFindings = result.audit.enforcerFindings.filter((f) => f.enforcerId === 'rl-audit-no-orphan-enforcer-001' ||
358
+ f.enforcerId === 'rl-audit-no-orphan-catalog-001');
359
+ if (orphanFindings.length > 0) {
360
+ issues.push(`${orphanFindings.length} orphan-enforcer / orphan-catalog finding(s)`);
361
+ }
362
+ if (issues.length > 0) {
363
+ return {
364
+ name: 'audit-regression',
365
+ description: 'audit-regression: catalog integrity + runtime budget (L2.4 P2-b stage 6)',
366
+ status: 'fail',
367
+ durationMs,
368
+ detail: issues.join('; '),
369
+ };
370
+ }
371
+ return {
372
+ name: 'audit-regression',
373
+ description: 'audit-regression: catalog integrity + runtime budget (L2.4 P2-b stage 6)',
374
+ status: 'pass',
375
+ durationMs,
376
+ detail: `catalog: ${result.audit.totalRedLines} entries (${result.audit.cliBacked} cli-backed, ${result.audit.proseOnly} prose-only); audit ran in ${durationMs}ms`,
377
+ };
378
+ }
379
+ catch (error) {
380
+ return {
381
+ name: 'audit-regression',
382
+ description: 'audit-regression: catalog integrity + runtime budget (L2.4 P2-b stage 6)',
383
+ status: 'fail',
384
+ durationMs: Date.now() - start,
385
+ detail: 'audit-regression failed: ' + (error?.message ?? String(error)),
386
+ };
387
+ }
388
+ }
389
+ async function runMockPlacement(projectRoot) {
390
+ const start = Date.now();
391
+ // List changed files via git. `--name-only` produces one path per line;
392
+ // we filter to text files in scope and read each.
393
+ const diffResult = runCommand('git', ['diff', '--name-only', '--diff-filter=ACMR', 'HEAD'], projectRoot, 30_000);
394
+ if (diffResult.status !== 'pass') {
395
+ return {
396
+ name: 'mock-placement',
397
+ description: 'mock-placement: no inline mock data in src/ or skills/ (L2.1 P0 #5)',
398
+ status: 'skipped',
399
+ durationMs: Date.now() - start,
400
+ detail: 'git diff failed or returned no changed files; mock-placement scan skipped.'
401
+ };
402
+ }
403
+ const changed = diffResult.stdout
404
+ .split('\n')
405
+ .map((l) => l.trim())
406
+ .filter(Boolean);
407
+ if (changed.length === 0) {
408
+ return {
409
+ name: 'mock-placement',
410
+ description: 'mock-placement: no inline mock data in src/ or skills/ (L2.1 P0 #5)',
411
+ status: 'skipped',
412
+ durationMs: Date.now() - start,
413
+ detail: 'no changed files in HEAD diff; mock-placement scan skipped.'
414
+ };
415
+ }
416
+ const files = changed
417
+ .filter((p) => p.startsWith('src/') || p.startsWith('skills/'))
418
+ .filter((p) => p.endsWith('.ts') || p.endsWith('.tsx') || p.endsWith('.js') || p.endsWith('.mjs'))
419
+ .map((filePath) => {
420
+ const abs = join(projectRoot, filePath);
421
+ if (!existsSync(abs))
422
+ return null;
423
+ const content = readFileSync(abs, 'utf-8');
424
+ return { filePath, content };
425
+ })
426
+ .filter((f) => f !== null);
427
+ const violations = findMockViolations(files);
428
+ return {
429
+ name: 'mock-placement',
430
+ description: 'mock-placement: no inline mock data in src/ or skills/ (L2.1 P0 #5)',
431
+ status: violations.length === 0 ? 'pass' : 'fail',
432
+ durationMs: Date.now() - start,
433
+ detail: violations.length === 0
434
+ ? `Scanned ${files.length} changed file(s); no inline mock data found.`
435
+ : `${violations.length} violation(s): ${violations.map((v) => `${v.filePath} (${v.snippet})`).join('; ')}`,
436
+ data: { scannedFiles: files.length, violations: violations.map((v) => ({ filePath: v.filePath, pattern: v.pattern, snippet: v.snippet })) }
437
+ };
438
+ }
@@ -28,7 +28,7 @@
28
28
  export type SliceCheckStageStatus = 'pass' | 'fail' | 'skipped';
29
29
  export type SliceCheckStage = {
30
30
  /** Stable id for the stage (matches the runbook's check list). */
31
- name: 'typecheck' | 'unit-tests' | 'review-fanout' | 'gate-verify-pipeline';
31
+ name: 'typecheck' | 'unit-tests' | 'review-fanout' | 'gate-verify-pipeline' | 'mock-placement' | 'audit-regression';
32
32
  /** Human-readable description. */
33
33
  description: string;
34
34
  status: SliceCheckStageStatus;