peaks-loop 4.0.49 → 4.0.50

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 (119) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README-en.md +1 -1
  3. package/README.md +1 -1
  4. package/dist/cli/commands/audit-commands.js +1 -0
  5. package/dist/cli/commands/baseline-commands.js +163 -25
  6. package/dist/cli/commands/core/skill-command.js +53 -4
  7. package/dist/cli/commands/core/standards-command.d.ts +24 -0
  8. package/dist/cli/commands/core/standards-command.js +74 -0
  9. package/dist/cli/commands/hooks-commands.js +55 -38
  10. package/dist/cli/commands/share-commands.js +37 -11
  11. package/dist/cli/commands/web-commands.js +8 -1
  12. package/dist/cli/commands/workflow-lifecycle-commands.d.ts +6 -0
  13. package/dist/cli/commands/workflow-lifecycle-commands.js +64 -3
  14. package/dist/services/adapter/adapter.d.ts +30 -0
  15. package/dist/services/adapter/auto-adapter.d.ts +13 -0
  16. package/dist/services/adapter/claude-adapter.js +12 -0
  17. package/dist/services/adapter/codex-adapter.d.ts +12 -0
  18. package/dist/services/adapter/codex-adapter.js +12 -0
  19. package/dist/services/adapter/copilot-adapter.d.ts +12 -0
  20. package/dist/services/adapter/copilot-adapter.js +12 -0
  21. package/dist/services/audit/backing-detector.d.ts +25 -7
  22. package/dist/services/audit/backing-detector.js +33 -17
  23. package/dist/services/audit/enforcer-liveness.d.ts +12 -0
  24. package/dist/services/audit/enforcer-liveness.js +100 -0
  25. package/dist/services/audit/enforcers/lint-catalog-governance.d.ts +23 -11
  26. package/dist/services/audit/enforcers/lint-catalog-governance.js +10 -14
  27. package/dist/services/audit/enforcers/lint-rd-handoff-coverage.d.ts +5 -15
  28. package/dist/services/audit/enforcers/lint-rd-handoff-coverage.js +94 -25
  29. package/dist/services/audit/enforcers/lint-style.d.ts +9 -1
  30. package/dist/services/audit/enforcers/lint-style.js +38 -2
  31. package/dist/services/audit/prose-ratio-calculator.d.ts +28 -17
  32. package/dist/services/audit/prose-ratio-calculator.js +25 -18
  33. package/dist/services/audit/red-line-catalog-p2-a.js +1 -1
  34. package/dist/services/audit/red-lines-service.js +51 -7
  35. package/dist/services/capability-audit-service/independent-checker.d.ts +15 -0
  36. package/dist/services/capability-audit-service/independent-checker.js +140 -0
  37. package/dist/services/capability-audit-service/index.d.ts +3 -1
  38. package/dist/services/capability-audit-service/index.js +1 -0
  39. package/dist/services/capability-audit-service/runner.d.ts +17 -13
  40. package/dist/services/capability-audit-service/runner.js +76 -15
  41. package/dist/services/capability-audit-service/types.d.ts +48 -0
  42. package/dist/services/capability-guard-runner/contracts/J01.js +21 -22
  43. package/dist/services/capability-guard-runner/contracts/J02.d.ts +1 -1
  44. package/dist/services/capability-guard-runner/contracts/J02.js +114 -28
  45. package/dist/services/capability-guard-runner/contracts/J03.d.ts +13 -0
  46. package/dist/services/capability-guard-runner/contracts/J03.js +72 -21
  47. package/dist/services/capability-guard-runner/contracts/J04.d.ts +6 -0
  48. package/dist/services/capability-guard-runner/contracts/J04.js +65 -32
  49. package/dist/services/capability-guard-runner/contracts/J05.js +118 -16
  50. package/dist/services/capability-guard-runner/contracts/J06.d.ts +14 -0
  51. package/dist/services/capability-guard-runner/contracts/J06.js +57 -39
  52. package/dist/services/capability-guard-runner/contracts/J07.d.ts +9 -0
  53. package/dist/services/capability-guard-runner/contracts/J07.js +76 -47
  54. package/dist/services/capability-guard-runner/contracts/J08.d.ts +11 -0
  55. package/dist/services/capability-guard-runner/contracts/J08.js +66 -39
  56. package/dist/services/capability-guard-runner/contracts/J09.d.ts +13 -0
  57. package/dist/services/capability-guard-runner/contracts/J09.js +95 -39
  58. package/dist/services/capability-guard-runner/contracts/J10.d.ts +12 -0
  59. package/dist/services/capability-guard-runner/contracts/J10.js +69 -35
  60. package/dist/services/capability-guard-runner/contracts/J11.d.ts +8 -0
  61. package/dist/services/capability-guard-runner/contracts/J11.js +73 -33
  62. package/dist/services/capability-guard-runner/contracts/J12.d.ts +12 -0
  63. package/dist/services/capability-guard-runner/contracts/J12.js +66 -30
  64. package/dist/services/capability-guard-runner/contracts/J13.d.ts +11 -0
  65. package/dist/services/capability-guard-runner/contracts/J13.js +62 -40
  66. package/dist/services/capability-guard-runner/contracts/J14.d.ts +11 -0
  67. package/dist/services/capability-guard-runner/contracts/J14.js +60 -31
  68. package/dist/services/capability-guard-runner/contracts/J15.d.ts +11 -0
  69. package/dist/services/capability-guard-runner/contracts/J15.js +70 -35
  70. package/dist/services/capability-guard-runner/contracts/_shared.d.ts +24 -0
  71. package/dist/services/capability-guard-runner/contracts/_shared.js +67 -0
  72. package/dist/services/capability-guard-runner/registry.d.ts +5 -0
  73. package/dist/services/capability-guard-runner/registry.js +140 -0
  74. package/dist/services/capability-guard-runner/runner.d.ts +26 -0
  75. package/dist/services/capability-guard-runner/runner.js +63 -6
  76. package/dist/services/code/auto-compact-modes.d.ts +13 -2
  77. package/dist/services/code/auto-compact-modes.js +20 -4
  78. package/dist/services/code/post-compact-detector.js +20 -11
  79. package/dist/services/code/step-08-gate.js +21 -6
  80. package/dist/services/config/config-safety.js +11 -9
  81. package/dist/services/final-review/pre-post-diff.js +10 -2
  82. package/dist/services/observability/observability-service.d.ts +1 -1
  83. package/dist/services/scan/api-diff-types.js +20 -2
  84. package/dist/services/security/safe-settings-path.js +19 -1
  85. package/dist/services/skill/skill-search-service.d.ts +3 -3
  86. package/dist/services/standards/loop-engineering-lint.d.ts +1 -1
  87. package/dist/services/standards/loop-engineering-lint.js +6 -0
  88. package/dist/services/web/daemon-registry.js +27 -2
  89. package/dist/services/workspace/claude-settings-template.d.ts +53 -37
  90. package/dist/services/workspace/claude-settings-template.js +105 -83
  91. package/dist/services/workspace/generated-artifacts-stamp.d.ts +119 -0
  92. package/dist/services/workspace/generated-artifacts-stamp.js +167 -0
  93. package/dist/services/workspace/workspace-claude-settings-materializer.d.ts +8 -0
  94. package/dist/services/workspace/workspace-claude-settings-materializer.js +38 -3
  95. package/dist/services/workspace/workspace-service.js +11 -1
  96. package/dist/shared/fs-utils.d.ts +26 -0
  97. package/dist/shared/fs-utils.js +35 -0
  98. package/package.json +9 -7
  99. package/scripts/copy-templates.mjs +0 -12
  100. package/scripts/install-skills.mjs +154 -53
  101. package/skills/bee/peaks-qa/SKILL.md +0 -1
  102. package/skills/bee/peaks-rd/SKILL.md +0 -1
  103. package/skills/peaks-code/SKILL.md +12 -10
  104. package/skills/peaks-code/references/periodic-checkpoint.md +2 -2
  105. package/skills/peaks-code/references/runbook.md +3 -0
  106. package/skills/peaks-code/references/session-overload-signal-index.md +4 -2
  107. package/skills/peaks-code/references/startup-sequence.md +2 -2
  108. package/skills/peaks-code/references/step-0-8-gate.md +1 -1
  109. package/skills/peaks-code/references/sub-agent-dispatch.md +19 -19
  110. package/dist/cli/commands/context-builder-commands.d.ts +0 -11
  111. package/dist/cli/commands/context-builder-commands.js +0 -85
  112. package/dist/services/hooks/write-gate.js +0 -111
  113. package/skills/bee/peaks-prd/references/command-migration.md +0 -3
  114. package/skills/bee/peaks-qa/references/command-migration.md +0 -3
  115. package/skills/bee/peaks-rd/references/command-migration.md +0 -3
  116. package/skills/bee/peaks-sc/references/command-migration.md +0 -3
  117. package/skills/bee/peaks-txt/references/command-migration.md +0 -3
  118. package/skills/bee/peaks-ui/references/command-migration.md +0 -3
  119. package/skills/peaks-code/references/command-migration.md +0 -3
@@ -173,15 +173,17 @@ export function resolveCanonicalProjectRootStrict(startPath) {
173
173
  catch {
174
174
  throw new InvalidProjectRootError('non-existent', startPath);
175
175
  }
176
- if (realStart !== start) {
177
- // User passed a path through a symlink; reject as non-canonical
178
- // for trust-boundary entry points.
179
- throw new InvalidProjectRootError('non-canonical', startPath);
180
- }
181
- // Delegate to the existing canonicalization (git root → heuristic)
182
- // AFTER the strict pre-checks pass.
183
- const canonical = resolveCanonicalProjectRoot(startPath);
184
- if (canonical === startPath || canonical === realStart) {
176
+ // Use the realpath form as the canonical input. macOS exposes
177
+ // `/var/folders/...` as a symlink to `/private/var/folders/...`, so any
178
+ // path returned by `os.tmpdir()` (and therefore any `mkdtempSync(...)`
179
+ // result) has `realStart !== start`. Rejecting that case as
180
+ // "non-canonical" would block the legitimate SessionStart hook path
181
+ // and the test fixtures under `tests/unit/hooks/`. The security
182
+ // property is preserved: `realpathSync` collapses any attacker-
183
+ // controlled symlink chain, so `realStart` IS the canonical form
184
+ // regardless of which prefix the caller used.
185
+ const canonical = resolveCanonicalProjectRoot(realStart);
186
+ if (canonical === realStart) {
185
187
  return canonical;
186
188
  }
187
189
  // Canonicalization moved the path (e.g. to a git root) — accept it
@@ -123,13 +123,21 @@ function isSourcePath(path) {
123
123
  * read as two deleted cases; a class that is half-counted is worse than a class
124
124
  * that is not counted at all.
125
125
  *
126
+ * Word-boundary simulation, not `\b`. The JS regex could spell this as `\b`
127
+ * directly, but the POSIX ERE side of the count has to run on macOS too —
128
+ * where `git grep -E` is BSD grep, and BSD grep's POSIX ERE has no `\b`
129
+ * (it is a literal `b`). The two expressions therefore both spell the boundary
130
+ * as `(^|[^A-Za-z0-9_])`: start of line OR a non-word character. The two
131
+ * sides MUST agree, and `git grep -c` counts matched lines (not occurrences)
132
+ * so the extra prefix group does not shift the count.
133
+ *
126
134
  * What is deliberately NOT modelled: an occurrence inside a comment or a string
127
135
  * literal still matches. That is symmetric — the same expression is evaluated on
128
136
  * both sides — so it cancels out of the DELTA, which is the only thing a removal
129
137
  * is ever derived from.
130
138
  */
131
- const TEST_CASE_LINE_RE = /\b(it|test)(\.[A-Za-z]+)*\(/;
132
- const TEST_CASE_ERE = '\\b(it|test)(\\.[A-Za-z]+)*\\(';
139
+ const TEST_CASE_LINE_RE = /(?:^|[^A-Za-z0-9_])(it|test)(\.[A-Za-z]+)*\(/;
140
+ const TEST_CASE_ERE = '(^|[^A-Za-z0-9_])(it|test)(\\.[A-Za-z]+)*\\(';
133
141
  /**
134
142
  * Column-0 `export` — an export STATEMENT, not a type-checked symbol.
135
143
  *
@@ -29,8 +29,8 @@ export declare const ObservabilityEventSchema: z.ZodObject<{
29
29
  ts: z.ZodString;
30
30
  sessionId: z.ZodString;
31
31
  category: z.ZodEnum<{
32
- dispatch: "dispatch";
33
32
  "slice-transition": "slice-transition";
33
+ dispatch: "dispatch";
34
34
  checkpoint: "checkpoint";
35
35
  "mode-gate": "mode-gate";
36
36
  "context-trigger": "context-trigger";
@@ -8,7 +8,25 @@
8
8
  * is not merely annotated. `confidence: 'exact'` is reserved for lines where
9
9
  * both sides were parsed AND the recorded interface was fully readable.
10
10
  */
11
+ import { realpathSync } from 'node:fs';
11
12
  import { resolve, sep } from 'node:path';
13
+ /**
14
+ * Canonicalize through symlinks so two paths that name the same directory through
15
+ * different prefixes (the macOS `/var` <-> `/private/var` quirk the brief calls
16
+ * out) line up as the same literal on both sides of the prefix test below.
17
+ * Falls back to `resolve()` when the path does not exist yet — `realpathSync`
18
+ * throws on missing paths, and several callers in the suite inspect not-yet-
19
+ * created paths (e.g. the "string prefix of a sibling" guard at
20
+ * api-diff-service.test.ts:462).
21
+ */
22
+ function safeRealpath(p) {
23
+ try {
24
+ return realpathSync(p);
25
+ }
26
+ catch {
27
+ return resolve(p);
28
+ }
29
+ }
12
30
  /** The honest boundary of the feature. Printed in the output, not just documented. */
13
31
  export const NOT_DETECTABLE = [
14
32
  'a field whose type is unchanged but whose meaning changed',
@@ -38,8 +56,8 @@ export function isRecord(value) {
38
56
  * the misleading `archive/docs/api.json`.
39
57
  */
40
58
  export function toDisplayPath(projectRoot, file) {
41
- const rootParts = resolve(projectRoot).split(sep);
42
- const fileParts = resolve(file).split(sep);
59
+ const rootParts = safeRealpath(projectRoot).split(sep);
60
+ const fileParts = safeRealpath(file).split(sep);
43
61
  const inside = fileParts.length > rootParts.length
44
62
  && rootParts.every((part, index) => part === fileParts[index]);
45
63
  return (inside ? fileParts.slice(rootParts.length) : fileParts).join('/');
@@ -71,12 +71,30 @@ export function assertSafeDispatchRecordPath(recordPath, projectRoot) {
71
71
  // to create it). Fall back to lexical comparison against the
72
72
  // canonical projectRoot — the write will then create the file,
73
73
  // and any symlink in the parent will be caught on the next read.
74
+ //
75
+ // macOS note: the project's own realpath may still resolve the
76
+ // `/var -> /private/var` symlink even when the sub-agent dir does
77
+ // not exist yet, so the caller can compare on a stable prefix
78
+ // instead of having one side of the comparison on `/var/...` and
79
+ // the other on `/private/var/...`. We still validate that the
80
+ // canonical record path lives under the canonical projectRoot.
74
81
  const fallback = resolve(projectRoot, '.peaks', SUB_AGENTS_DIR);
75
82
  const rel2 = relative(fallback, recordPath);
76
83
  if (rel2.startsWith('..') || isAbsolute(rel2)) {
77
84
  throw invalidPathError(recordPath, 'must be under .peaks/_sub_agents/');
78
85
  }
79
- return recordPath;
86
+ try {
87
+ realRoot = realpathSync(projectRoot);
88
+ const canonicalRecord = resolve(realRoot, '.peaks', SUB_AGENTS_DIR, recordPath.slice(fallback.length + 1));
89
+ const realRel = relative(realRoot, canonicalRecord);
90
+ if (realRel.startsWith('..' + sep) || realRel === '..' || isAbsolute(realRel)) {
91
+ throw invalidPathError(recordPath, 'escapes project root via symlink');
92
+ }
93
+ return canonicalRecord;
94
+ }
95
+ catch {
96
+ return recordPath;
97
+ }
80
98
  }
81
99
  const realRel = relative(realRoot, realRecord);
82
100
  if (realRel.startsWith('..' + sep) || realRel === '..' || isAbsolute(realRel)) {
@@ -32,14 +32,14 @@ export declare const SkillSearchInputSchema: z.ZodObject<{
32
32
  status: "status";
33
33
  resume: "resume";
34
34
  audit: "audit";
35
- ide: "ide";
35
+ "final-review": "final-review";
36
36
  content: "content";
37
+ ide: "ide";
37
38
  test: "test";
38
- research: "research";
39
39
  doctor: "doctor";
40
+ research: "research";
40
41
  triage: "triage";
41
42
  sop: "sop";
42
- "final-review": "final-review";
43
43
  "slice-decompose": "slice-decompose";
44
44
  "issue-fix-orchestrator": "issue-fix-orchestrator";
45
45
  "perf-audit": "perf-audit";
@@ -18,7 +18,7 @@
18
18
  * `peaks standards lint --category loop-engineering` (registered in M0's
19
19
  * plan) will read this file from disk and call this function.
20
20
  */
21
- export declare const EXPECTED_RED_LINE_IDS: readonly ["RL-0", "RL-1", "RL-2", "RL-3", "RL-4", "RL-5", "RL-6", "RL-7", "RL-8", "RL-9"];
21
+ export declare const EXPECTED_RED_LINE_IDS: readonly ["RL-0", "RL-1", "RL-2", "RL-3", "RL-4", "RL-5", "RL-6", "RL-7", "RL-8", "RL-9", "RL-10"];
22
22
  export type RedLineId = (typeof EXPECTED_RED_LINE_IDS)[number];
23
23
  export declare const REQUIRED_SECTIONS: readonly ["Failure modes", "Rewrite", "Self-check", "Out-of-scope"];
24
24
  export type RedLineSection = (typeof REQUIRED_SECTIONS)[number];
@@ -29,6 +29,12 @@ export const EXPECTED_RED_LINE_IDS = [
29
29
  'RL-7',
30
30
  'RL-8',
31
31
  'RL-9',
32
+ // RL-10 was written into the guideline file (its 4 sections included) but
33
+ // never added here, so the lint could not see it: the closed set stopped one
34
+ // short of the file it was supposed to police. The file's own footer said
35
+ // "Total red lines: 9 (RL-0..RL-9)" while RL-10 sat below it — the two
36
+ // statements contradicted each other and only the file was ever read.
37
+ 'RL-10',
32
38
  ];
33
39
  export const REQUIRED_SECTIONS = [
34
40
  'Failure modes',
@@ -5,10 +5,29 @@
5
5
  * WRITE goes through `assertUnder` first — the slice-wide guard against an
6
6
  * artifact escaping `<root>/.peaks/_runtime/<sid>/web/` (AC1).
7
7
  */
8
- import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs';
8
+ import { existsSync, mkdirSync, readFileSync, realpathSync, unlinkSync, writeFileSync } from 'node:fs';
9
9
  import { dirname } from 'node:path';
10
10
  import { assertUnder, webDaemonDir, webDaemonInfoPath, webSpawnLockPath } from './web-artifact-paths.js';
11
11
  import { parseDaemonInfo } from './web-protocol.js';
12
+ /**
13
+ * Canonicalize through symlinks so two paths that name the same directory through
14
+ * different prefixes compare equal — the macOS `/var` <-> `/private/var` quirk
15
+ * makes `mkdtempSync(...)` return `/var/folders/...` while `process.cwd()` after
16
+ * `chdir` returns `/private/var/folders/...`. They are the same directory on
17
+ * disk; without `realpathSync` the ownership check below silently rejects a
18
+ * legitimate record and the CLI cold-starts a real daemon instead of reusing
19
+ * the stub. Falls back to `resolve()` when the path is absent: a planted
20
+ * record naming a directory that does not exist still has to fail the
21
+ * containment test, and a `realpathSync` on it would throw.
22
+ */
23
+ function safeRealpath(p) {
24
+ try {
25
+ return realpathSync(p);
26
+ }
27
+ catch {
28
+ return p;
29
+ }
30
+ }
12
31
  /** A spawn lock older than this is reclaimed even if its owner pid is alive. */
13
32
  const SPAWN_LOCK_STALE_MS = 120_000;
14
33
  /**
@@ -46,7 +65,13 @@ export function readDaemonInfo(projectRoot, sessionId) {
46
65
  }
47
66
  try {
48
67
  const info = parseDaemonInfo(readFileSync(target, 'utf8'));
49
- if (info === null || info.projectRoot !== projectRoot || info.sessionId !== sessionId) {
68
+ if (info === null || info.sessionId !== sessionId) {
69
+ return null;
70
+ }
71
+ // Canonicalize both sides — a record written from one prefix (say
72
+ // `/var/folders/...`) and read from the canonical one (`/private/var/...`)
73
+ // describes the same project on macOS.
74
+ if (safeRealpath(info.projectRoot) !== safeRealpath(projectRoot)) {
50
75
  return null;
51
76
  }
52
77
  return info;
@@ -2,24 +2,31 @@
2
2
  * Slice 2.0.1-bug3-fact-forcing-bypass — pure-data template for the
3
3
  * consumer-project `.claude/settings.local.json` file.
4
4
  *
5
- * The template is a PreToolUse hook allow-list that bypasses the
6
- * Claude Code [Fact-Forcing Gate] for tool calls whose paths target
7
- * the peaks-managed `.peaks/` workspace. Without this bypass,
8
- * `peaks workspace init` (Step 0 of every peaks-code session) is
9
- * unrunnable in a consumer project because the gate blocks the very
10
- * first Write.
5
+ * The template exempts the peaks-managed `.peaks/` workspace from the
6
+ * Claude Code [Fact-Forcing Gate], so `peaks workspace init` (Step 0 of
7
+ * every peaks-code session) is runnable in a consumer project — without
8
+ * the exemption the gate blocks the very first Write.
9
+ *
10
+ * The exemption is declared in the `env` block
11
+ * (`EXTERNAL_GATE_EXEMPT_ENV`), which is what the gate actually reads.
12
+ * TEMPLATE_VERSION 1.7.0 moved it there; before that it was declared by a
13
+ * `Write|Edit|MultiEdit` handler that exited non-zero for non-`.peaks/`
14
+ * paths and was documented as "fall through to the gate". That concept does
15
+ * not exist in the Claude Code hook protocol (only exit 2 blocks; any other
16
+ * non-zero exit is a NON-BLOCKING ERROR reported once per edit), so the
17
+ * handler was corrected to abstain on every path — and an abstaining handler
18
+ * that is still INSTALLED is a no-op carrying a machine-specific absolute
19
+ * script path. TEMPLATE_VERSION 1.8.0 removed it rather than re-point it:
20
+ * it decided nothing, and the exemption it was written for lives in `env`.
11
21
  *
12
22
  * The template is a pure-data function (no filesystem, no clock) so
13
23
  * it can be unit-tested in isolation and so the on-disk file matches
14
24
  * the in-memory template byte-for-byte.
15
25
  *
16
- * One matcher is emitted:
17
- * 1. `Write|Edit|MultiEdit` — a `node <script>` handler that runs the
18
- * path gate shipped at `src/services/hooks/write-gate.js`. Exits 0
19
- * (allow) for the paths the gate skips, exit 1 (deny → fall through to
20
- * gate) for everything else. TEMPLATE_VERSION 1.6.0 moved the decision
21
- * out of an inlined `node -e "<js>"` one-liner, whose escaping was
22
- * bash-specific and therefore could not take a platform `shell` pin.
26
+ * Two `Bash` matchers are emitted (the Step 0.8 mechanical gate and the
27
+ * SOP gate-enforce handler). No `Write|Edit|MultiEdit` entry is emitted:
28
+ * that matcher's gate is `peaks code-gate --json`, installed into the
29
+ * committed `.claude/settings.json` by `peaks hooks install`.
23
30
  *
24
31
  * The previous `Bash` matcher (which whitelisted a fixed `peaks
25
32
  * <subcommand>` prefix) was removed in TEMPLATE_VERSION 1.2.0. The
@@ -80,8 +87,20 @@ export declare const CLAUDE_SETTINGS_LOCAL_FILENAME = ".claude/settings.local.js
80
87
  * (`EXTERNAL_GATE_EXEMPT_ENV`). The comparator now requires the
81
88
  * on-disk file to declare those exemptions too, so a project
82
89
  * installed by an earlier release refreshes once and converges.
90
+ * 1.8.0 — REMOVED the `Write|Edit|MultiEdit` handler and the shipped
91
+ * script it invoked (`src/services/hooks/write-gate.js`, also
92
+ * deleted). The handler abstained on every path by design (see
93
+ * that file's header for the rationale — it is the reason this
94
+ * is a deletion and not a repair), so the only things it still
95
+ * contributed were a `node "<abs path>"` command pinned to the
96
+ * Node version directory that happened to be on `$PATH` at
97
+ * install time, and a `shell: powershell` pin. The exemption it
98
+ * was written to provide is declared by the `env` block above.
99
+ * `mergeTemplateOwnedHooks` drops the retired entry from
100
+ * existing on-disk files, so an earlier install converges
101
+ * instead of keeping a no-op with a stale absolute path.
83
102
  */
84
- export declare const TEMPLATE_VERSION = "1.7.0";
103
+ export declare const TEMPLATE_VERSION = "1.8.0";
85
104
  /**
86
105
  * Compare two serialized template strings: does the on-disk file already
87
106
  * declare every entry the generated tree declares?
@@ -108,10 +127,11 @@ export declare const TEMPLATE_VERSION = "1.7.0";
108
127
  * detail).
109
128
  *
110
129
  * Returns `true` iff both strings parse to objects whose `hooks.PreToolUse`
111
- * arrays satisfy that containment AND the on-disk `env` already carries every
112
- * exemption the template declares (extra on-disk keys and extra globs are
113
- * allowed — a user may exempt other trees, and a requirement the file already
114
- * exceeds must not re-trigger a write).
130
+ * arrays satisfy that containment, the on-disk file carries NO entry this
131
+ * template has retired (`isRetiredTemplateEntry`), AND the on-disk `env`
132
+ * already carries every exemption the template declares (extra on-disk keys
133
+ * and extra globs are allowed — a user may exempt other trees, and a
134
+ * requirement the file already exceeds must not re-trigger a write).
115
135
  *
116
136
  * Returns `false` on any `JSON.parse` error, shape mismatch, or
117
137
  * missing `hooks.PreToolUse`. Whitespace and key order do NOT affect
@@ -147,23 +167,18 @@ export declare function templateContentMatches(generated: string, onDisk: string
147
167
  * Non-conforming entries (no string `matcher`, no `hooks` array) are preserved
148
168
  * rather than dropped: guessing at their shape is how a user's entry gets
149
169
  * deleted.
170
+ *
171
+ * ONE exception to "preserve what I do not declare": an entry this template
172
+ * used to declare and RETIRED (TEMPLATE_VERSION 1.8.0's `Write|Edit|MultiEdit`
173
+ * gate — see `isRetiredTemplateEntry`) is dropped rather than preserved.
174
+ * Declaring less cannot retire an entry on its own, because preserving
175
+ * undeclared entries is exactly what this function does; without the drop, a
176
+ * pre-1.8.0 install would keep the no-op handler and its version-pinned script
177
+ * path forever. The predicate is narrow enough that only the exact command
178
+ * this template emitted matches.
150
179
  */
151
180
  export declare function mergeTemplateOwnedHooks(onDisk: ReadonlyArray<unknown>, template: ReadonlyArray<unknown>): unknown[];
152
- /**
153
- * Absolute path of the shipped Write|Edit|MultiEdit gate script.
154
- *
155
- * `write-gate.js` is a plain `.js` (not a compiled `.ts`) precisely so this
156
- * single relative filename resolves in BOTH trees: `src/services/hooks/` for
157
- * `tsx` / vitest, `dist/services/hooks/` for an installed consumer (copied by
158
- * `scripts/copy-templates.mjs`; the `dist/**` `*.js` glob in
159
- * `package.json#files` already ships it).
160
- *
161
- * Separators are normalized to `/` so the emitted command contains no
162
- * backslash at all. That is what makes the handler shell-agnostic: bash
163
- * reduces `\X` inside `"..."` and PowerShell escapes with a backtick, so any
164
- * backslash in the string is one dialect's problem waiting to happen.
165
- */
166
- export declare function writeGateScriptPath(): string;
181
+ export declare function isRetiredTemplateEntry(entry: unknown): boolean;
167
182
  type ClaudeHookCommand = {
168
183
  type: 'command';
169
184
  command: string;
@@ -192,10 +207,11 @@ type ClaudeSettingsLocal = {
192
207
  * forcing gate is a core feature that PreToolUse hooks can short-
193
208
  * circuit but that the `permissions` block cannot.
194
209
  *
195
- * As of TEMPLATE_VERSION 1.2.0, only the `Write|Edit|MultiEdit`
196
- * matcher is emitted. Bash command enforcement is the responsibility
197
- * of `peaks gate enforce`, which `peaks hooks install` injects into
198
- * `.claude/settings.json` (not `.claude/settings.local.json`).
210
+ * As of TEMPLATE_VERSION 1.8.0 the template emits the two `Bash` matchers
211
+ * only. The `Write|Edit|MultiEdit` fact-forcing bypass is no longer a hook:
212
+ * it is the `env` exemption above, and that matcher's gate
213
+ * (`peaks code-gate --json`) is installed into the committed
214
+ * `.claude/settings.json` by `peaks hooks install`.
199
215
  */
200
216
  export declare function buildClaudeSettingsLocalJson(): ClaudeSettingsLocal;
201
217
  export {};
@@ -2,24 +2,31 @@
2
2
  * Slice 2.0.1-bug3-fact-forcing-bypass — pure-data template for the
3
3
  * consumer-project `.claude/settings.local.json` file.
4
4
  *
5
- * The template is a PreToolUse hook allow-list that bypasses the
6
- * Claude Code [Fact-Forcing Gate] for tool calls whose paths target
7
- * the peaks-managed `.peaks/` workspace. Without this bypass,
8
- * `peaks workspace init` (Step 0 of every peaks-code session) is
9
- * unrunnable in a consumer project because the gate blocks the very
10
- * first Write.
5
+ * The template exempts the peaks-managed `.peaks/` workspace from the
6
+ * Claude Code [Fact-Forcing Gate], so `peaks workspace init` (Step 0 of
7
+ * every peaks-code session) is runnable in a consumer project — without
8
+ * the exemption the gate blocks the very first Write.
9
+ *
10
+ * The exemption is declared in the `env` block
11
+ * (`EXTERNAL_GATE_EXEMPT_ENV`), which is what the gate actually reads.
12
+ * TEMPLATE_VERSION 1.7.0 moved it there; before that it was declared by a
13
+ * `Write|Edit|MultiEdit` handler that exited non-zero for non-`.peaks/`
14
+ * paths and was documented as "fall through to the gate". That concept does
15
+ * not exist in the Claude Code hook protocol (only exit 2 blocks; any other
16
+ * non-zero exit is a NON-BLOCKING ERROR reported once per edit), so the
17
+ * handler was corrected to abstain on every path — and an abstaining handler
18
+ * that is still INSTALLED is a no-op carrying a machine-specific absolute
19
+ * script path. TEMPLATE_VERSION 1.8.0 removed it rather than re-point it:
20
+ * it decided nothing, and the exemption it was written for lives in `env`.
11
21
  *
12
22
  * The template is a pure-data function (no filesystem, no clock) so
13
23
  * it can be unit-tested in isolation and so the on-disk file matches
14
24
  * the in-memory template byte-for-byte.
15
25
  *
16
- * One matcher is emitted:
17
- * 1. `Write|Edit|MultiEdit` — a `node <script>` handler that runs the
18
- * path gate shipped at `src/services/hooks/write-gate.js`. Exits 0
19
- * (allow) for the paths the gate skips, exit 1 (deny → fall through to
20
- * gate) for everything else. TEMPLATE_VERSION 1.6.0 moved the decision
21
- * out of an inlined `node -e "<js>"` one-liner, whose escaping was
22
- * bash-specific and therefore could not take a platform `shell` pin.
26
+ * Two `Bash` matchers are emitted (the Step 0.8 mechanical gate and the
27
+ * SOP gate-enforce handler). No `Write|Edit|MultiEdit` entry is emitted:
28
+ * that matcher's gate is `peaks code-gate --json`, installed into the
29
+ * committed `.claude/settings.json` by `peaks hooks install`.
23
30
  *
24
31
  * The previous `Bash` matcher (which whitelisted a fixed `peaks
25
32
  * <subcommand>` prefix) was removed in TEMPLATE_VERSION 1.2.0. The
@@ -33,8 +40,6 @@
33
40
  * consumer project's `.claude/settings.json` and which exits 0
34
41
  * silently for any command not guarded by a registered SOP gate.
35
42
  */
36
- import { dirname, resolve } from 'node:path';
37
- import { fileURLToPath } from 'node:url';
38
43
  import { EXTERNAL_GATE_EXEMPT_ENV, hasExternalGateExemptions, resolveHookShell, resolveHookSpec } from '../skills/hooks-codegate-superpowers.js';
39
44
  export const CLAUDE_SETTINGS_LOCAL_FILENAME = '.claude/settings.local.json';
40
45
  /**
@@ -83,8 +88,20 @@ export const CLAUDE_SETTINGS_LOCAL_FILENAME = '.claude/settings.local.json';
83
88
  * (`EXTERNAL_GATE_EXEMPT_ENV`). The comparator now requires the
84
89
  * on-disk file to declare those exemptions too, so a project
85
90
  * installed by an earlier release refreshes once and converges.
91
+ * 1.8.0 — REMOVED the `Write|Edit|MultiEdit` handler and the shipped
92
+ * script it invoked (`src/services/hooks/write-gate.js`, also
93
+ * deleted). The handler abstained on every path by design (see
94
+ * that file's header for the rationale — it is the reason this
95
+ * is a deletion and not a repair), so the only things it still
96
+ * contributed were a `node "<abs path>"` command pinned to the
97
+ * Node version directory that happened to be on `$PATH` at
98
+ * install time, and a `shell: powershell` pin. The exemption it
99
+ * was written to provide is declared by the `env` block above.
100
+ * `mergeTemplateOwnedHooks` drops the retired entry from
101
+ * existing on-disk files, so an earlier install converges
102
+ * instead of keeping a no-op with a stale absolute path.
86
103
  */
87
- export const TEMPLATE_VERSION = '1.7.0';
104
+ export const TEMPLATE_VERSION = '1.8.0';
88
105
  /**
89
106
  * Compare two serialized template strings: does the on-disk file already
90
107
  * declare every entry the generated tree declares?
@@ -111,10 +128,11 @@ export const TEMPLATE_VERSION = '1.7.0';
111
128
  * detail).
112
129
  *
113
130
  * Returns `true` iff both strings parse to objects whose `hooks.PreToolUse`
114
- * arrays satisfy that containment AND the on-disk `env` already carries every
115
- * exemption the template declares (extra on-disk keys and extra globs are
116
- * allowed — a user may exempt other trees, and a requirement the file already
117
- * exceeds must not re-trigger a write).
131
+ * arrays satisfy that containment, the on-disk file carries NO entry this
132
+ * template has retired (`isRetiredTemplateEntry`), AND the on-disk `env`
133
+ * already carries every exemption the template declares (extra on-disk keys
134
+ * and extra globs are allowed — a user may exempt other trees, and a
135
+ * requirement the file already exceeds must not re-trigger a write).
118
136
  *
119
137
  * Returns `false` on any `JSON.parse` error, shape mismatch, or
120
138
  * missing `hooks.PreToolUse`. Whitespace and key order do NOT affect
@@ -151,6 +169,23 @@ export function templateContentMatches(generated, onDisk) {
151
169
  }
152
170
  unmatched.splice(at, 1);
153
171
  }
172
+ // A RETIRED entry on disk is drift, and this clause is what makes the
173
+ // retirement in `mergeTemplateOwnedHooks` reach an installed file at all.
174
+ //
175
+ // Containment alone cannot express it: a file still carrying
176
+ // `Write|Edit|MultiEdit` declares every entry the template declares, so
177
+ // `templateContentMatches` answered "current", no rewrite ran, and the merge
178
+ // never got the chance to drop it. Measured on a throwaway project root
179
+ // before this clause existed — init against the rebuilt CLI reported
180
+ // `already-current` and the retired entry survived verbatim. Declaring less
181
+ // is not a retirement; the comparator has to say so.
182
+ //
183
+ // One extra rewrite per affected install, then the fixed point holds: the
184
+ // merge emits no retired entry, so the next comparison finds none and
185
+ // answers `current`.
186
+ if (parsedOnDisk.hooks.PreToolUse.some((entry) => isRetiredTemplateEntry(entry))) {
187
+ return false;
188
+ }
154
189
  // A project installed by a release that predates a template-declared
155
190
  // exemption still needs the refresh this comparator gates — otherwise the
156
191
  // entry would only ever appear on a machine that re-ran `peaks hooks
@@ -184,6 +219,15 @@ export function templateContentMatches(generated, onDisk) {
184
219
  * Non-conforming entries (no string `matcher`, no `hooks` array) are preserved
185
220
  * rather than dropped: guessing at their shape is how a user's entry gets
186
221
  * deleted.
222
+ *
223
+ * ONE exception to "preserve what I do not declare": an entry this template
224
+ * used to declare and RETIRED (TEMPLATE_VERSION 1.8.0's `Write|Edit|MultiEdit`
225
+ * gate — see `isRetiredTemplateEntry`) is dropped rather than preserved.
226
+ * Declaring less cannot retire an entry on its own, because preserving
227
+ * undeclared entries is exactly what this function does; without the drop, a
228
+ * pre-1.8.0 install would keep the no-op handler and its version-pinned script
229
+ * path forever. The predicate is narrow enough that only the exact command
230
+ * this template emitted matches.
187
231
  */
188
232
  export function mergeTemplateOwnedHooks(onDisk, template) {
189
233
  const slots = new Map();
@@ -195,6 +239,10 @@ export function mergeTemplateOwnedHooks(onDisk, template) {
195
239
  const taken = new Map();
196
240
  const preserved = [];
197
241
  for (const entry of onDisk) {
242
+ // Retired by this template — dropped, not carried across.
243
+ if (isRetiredTemplateEntry(entry)) {
244
+ continue;
245
+ }
198
246
  // Unowned by construction: not a shape the template could have declared.
199
247
  if (!isPreToolUseEntry(entry)) {
200
248
  preserved.push(entry);
@@ -249,44 +297,36 @@ function sameHooksArray(a, b) {
249
297
  return true;
250
298
  }
251
299
  /**
252
- * This module's own directory — `<root>/src/services/workspace` in the
253
- * source tree, `<root>/dist/services/workspace` in a build.
254
- *
255
- * Anchored on the running module rather than on `process.argv[1]`: the
256
- * same reason `daemon-supervisor.ts` documents — `argv[1]` is a different
257
- * file in each way the CLI is entered (`bin/peaks.js`, `src/cli/index.ts`
258
- * under tsx, `dist/cli/index.js` when invoked directly), whereas the
259
- * module's own location is the one fact that is always true.
260
- */
261
- const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
262
- /**
263
- * Absolute path of the shipped Write|Edit|MultiEdit gate script.
264
- *
265
- * `write-gate.js` is a plain `.js` (not a compiled `.ts`) precisely so this
266
- * single relative filename resolves in BOTH trees: `src/services/hooks/` for
267
- * `tsx` / vitest, `dist/services/hooks/` for an installed consumer (copied by
268
- * `scripts/copy-templates.mjs`; the `dist/**` `*.js` glob in
269
- * `package.json#files` already ships it).
300
+ * The retired `Write|Edit|MultiEdit` gate entry, as an on-disk file written by
301
+ * a pre-1.8.0 release holds it.
270
302
  *
271
- * Separators are normalized to `/` so the emitted command contains no
272
- * backslash at all. That is what makes the handler shell-agnostic: bash
273
- * reduces `\X` inside `"..."` and PowerShell escapes with a backtick, so any
274
- * backslash in the string is one dialect's problem waiting to happen.
275
- */
276
- export function writeGateScriptPath() {
277
- return resolve(MODULE_DIR, '..', 'hooks', 'write-gate.js').replaceAll('\\', '/');
278
- }
279
- /**
280
- * Build the Write|Edit|MultiEdit matcher command.
303
+ * TEMPLATE_VERSION 1.8.0 stopped emitting this entry. Declaring less is not
304
+ * enough on its own: `mergeTemplateOwnedHooks` preserves every on-disk entry
305
+ * the template does not declare — that is the whole point of the entry-level
306
+ * ownership rule (it is what keeps `installAutoCompactHook`'s `Bash|Task`
307
+ * entry alive) — so a project installed by an earlier release would keep the
308
+ * no-op handler, and its `node "C:/…/nvm/v24.14.0/…"` path, forever. This
309
+ * predicate is the retirement: `mergeTemplateOwnedHooks` drops a match.
281
310
  *
282
- * TEMPLATE_VERSION 1.6.0: `node "<script>"` with NO inline payload. The
283
- * decision lives in `src/services/hooks/write-gate.js` and was relocated
284
- * there verbatim. Because there is nothing left to escape, this handler is
285
- * shell-dialect-independent and can carry the same platform `shell` pin as
286
- * its Bash siblings (see `resolveHookShell`).
311
+ * Deliberately narrow. It matches the exact command shape this template used
312
+ * to emit — one handler, `node "<…>/services/hooks/write-gate.js"` — under the
313
+ * exact legacy matcher spelling, so a user's OWN `Write|Edit|MultiEdit` entry
314
+ * (a different command, or more than one handler) is preserved like any other
315
+ * entry the template does not declare. A looser "drop anything on this
316
+ * matcher" rule would delete a user's hook, which is the failure the ownership
317
+ * rule exists to prevent.
287
318
  */
288
- function buildWriteHookCommand() {
289
- return `node "${writeGateScriptPath()}"`;
319
+ const RETIRED_WRITE_GATE_MATCHER = 'Write|Edit|MultiEdit';
320
+ const RETIRED_WRITE_GATE_COMMAND = /^node "[^"]*\/services\/hooks\/write-gate\.js"$/;
321
+ export function isRetiredTemplateEntry(entry) {
322
+ if (!isPreToolUseEntry(entry))
323
+ return false;
324
+ if (entry.matcher !== RETIRED_WRITE_GATE_MATCHER)
325
+ return false;
326
+ if (entry.hooks.length !== 1)
327
+ return false;
328
+ const handler = entry.hooks[0];
329
+ return handler.type === 'command' && RETIRED_WRITE_GATE_COMMAND.test(handler.command);
290
330
  }
291
331
  /**
292
332
  * TEMPLATE_VERSION 1.4.0 — the SOP gate-enforce handler, read from the same
@@ -311,39 +351,22 @@ function buildGateEnforceHandler() {
311
351
  * forcing gate is a core feature that PreToolUse hooks can short-
312
352
  * circuit but that the `permissions` block cannot.
313
353
  *
314
- * As of TEMPLATE_VERSION 1.2.0, only the `Write|Edit|MultiEdit`
315
- * matcher is emitted. Bash command enforcement is the responsibility
316
- * of `peaks gate enforce`, which `peaks hooks install` injects into
317
- * `.claude/settings.json` (not `.claude/settings.local.json`).
354
+ * As of TEMPLATE_VERSION 1.8.0 the template emits the two `Bash` matchers
355
+ * only. The `Write|Edit|MultiEdit` fact-forcing bypass is no longer a hook:
356
+ * it is the `env` exemption above, and that matcher's gate
357
+ * (`peaks code-gate --json`) is installed into the committed
358
+ * `.claude/settings.json` by `peaks hooks install`.
318
359
  */
319
360
  export function buildClaudeSettingsLocalJson() {
320
- // TEMPLATE_VERSION 1.6.0 — the write handler can now be shell-pinned for the
321
- // same Windows reason as the two Bash handlers below: a shell-form command is
322
- // executed by Git Bash / MSYS2, which force-allocates a console window on
323
- // every matching tool call. It could NOT take the pin while its payload was
324
- // inlined JavaScript, because PowerShell does not perform bash's backslash
325
- // reduction and would have corrupted the payload. `undefined` on POSIX omits
326
- // the key entirely.
327
- const writeShell = resolveHookShell();
328
361
  return {
329
- // Slice emit-gateguard-exemption — the third-party gate exemption. Peaks
330
- // already bypasses its OWN fact-forcing gate for `.peaks/**` (the
331
- // Write|Edit|MultiEdit handler below); this is the same intent declared in
332
- // the currency an external PreToolUse gate reads. `peaks hooks install`
333
- // merges the same row into this file, so the two writers agree.
362
+ // Slice emit-gateguard-exemption — the third-party gate exemption. This is
363
+ // now the ONLY mechanism carrying the `.peaks/**` bypass (TEMPLATE_VERSION
364
+ // 1.8.0 removed the abstaining `Write|Edit|MultiEdit` handler that used to
365
+ // be described here). `peaks hooks install` merges the same row into this
366
+ // file, so the two writers agree.
334
367
  env: { ...EXTERNAL_GATE_EXEMPT_ENV },
335
368
  hooks: {
336
369
  PreToolUse: [
337
- {
338
- matcher: 'Write|Edit|MultiEdit',
339
- hooks: [
340
- {
341
- type: 'command',
342
- command: buildWriteHookCommand(),
343
- ...(writeShell !== undefined ? { shell: writeShell } : {})
344
- }
345
- ]
346
- },
347
370
  {
348
371
  // v3.1.2 Step 0.8 — Mechanical PreToolUse gate. Runs
349
372
  // `peaks code gate-step-08 --project .` before every Bash
@@ -351,7 +374,6 @@ export function buildClaudeSettingsLocalJson() {
351
374
  // describing the decision + optional `Next: slice #N+1 of
352
375
  // M (<currentSlice>)` line when progress.json exists). Exit
353
376
  // 2 = block (stderr contains the BLOCKED: ... reason).
354
- // The existing Write|Edit|MultiEdit matcher is preserved.
355
377
  matcher: 'Bash',
356
378
  hooks: [buildBashGateStep08Handler()]
357
379
  },