peaks-loop 4.0.48 → 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 (156) hide show
  1. package/CHANGELOG.md +44 -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/compact-command.js +1 -3
  7. package/dist/cli/commands/core/skill-command.js +53 -4
  8. package/dist/cli/commands/core/standards-command.d.ts +24 -0
  9. package/dist/cli/commands/core/standards-command.js +74 -0
  10. package/dist/cli/commands/feedback-commands.d.ts +11 -7
  11. package/dist/cli/commands/feedback-commands.js +49 -17
  12. package/dist/cli/commands/final-review-commands.js +12 -0
  13. package/dist/cli/commands/hooks-commands.js +55 -38
  14. package/dist/cli/commands/loop-eval-commands.js +22 -6
  15. package/dist/cli/commands/share-commands.js +37 -11
  16. package/dist/cli/commands/slice-integrate-commands.js +17 -0
  17. package/dist/cli/commands/web-commands.js +8 -1
  18. package/dist/cli/commands/workflow-lifecycle-commands.d.ts +6 -0
  19. package/dist/cli/commands/workflow-lifecycle-commands.js +64 -3
  20. package/dist/services/adapter/adapter.d.ts +30 -0
  21. package/dist/services/adapter/auto-adapter.d.ts +13 -0
  22. package/dist/services/adapter/claude-adapter.js +12 -0
  23. package/dist/services/adapter/codex-adapter.d.ts +12 -0
  24. package/dist/services/adapter/codex-adapter.js +12 -0
  25. package/dist/services/adapter/copilot-adapter.d.ts +12 -0
  26. package/dist/services/adapter/copilot-adapter.js +12 -0
  27. package/dist/services/artifacts/artifact-prerequisites.js +10 -0
  28. package/dist/services/artifacts/request-artifact-service.js +59 -38
  29. package/dist/services/audit/backing-detector.d.ts +25 -7
  30. package/dist/services/audit/backing-detector.js +33 -17
  31. package/dist/services/audit/enforcer-liveness.d.ts +12 -0
  32. package/dist/services/audit/enforcer-liveness.js +100 -0
  33. package/dist/services/audit/enforcers/active-skill-resolver.js +14 -1
  34. package/dist/services/audit/enforcers/lint-catalog-governance.d.ts +23 -11
  35. package/dist/services/audit/enforcers/lint-catalog-governance.js +10 -14
  36. package/dist/services/audit/enforcers/lint-rd-handoff-coverage.d.ts +5 -15
  37. package/dist/services/audit/enforcers/lint-rd-handoff-coverage.js +94 -25
  38. package/dist/services/audit/enforcers/lint-style.d.ts +9 -1
  39. package/dist/services/audit/enforcers/lint-style.js +38 -2
  40. package/dist/services/audit/prose-ratio-calculator.d.ts +28 -17
  41. package/dist/services/audit/prose-ratio-calculator.js +25 -18
  42. package/dist/services/audit/red-line-catalog-p2-a.js +1 -1
  43. package/dist/services/audit/red-lines-service.js +51 -7
  44. package/dist/services/capability-audit-service/independent-checker.d.ts +15 -0
  45. package/dist/services/capability-audit-service/independent-checker.js +140 -0
  46. package/dist/services/capability-audit-service/index.d.ts +3 -1
  47. package/dist/services/capability-audit-service/index.js +1 -0
  48. package/dist/services/capability-audit-service/runner.d.ts +17 -13
  49. package/dist/services/capability-audit-service/runner.js +76 -15
  50. package/dist/services/capability-audit-service/types.d.ts +48 -0
  51. package/dist/services/capability-guard-runner/contracts/J01.js +21 -22
  52. package/dist/services/capability-guard-runner/contracts/J02.d.ts +1 -1
  53. package/dist/services/capability-guard-runner/contracts/J02.js +114 -28
  54. package/dist/services/capability-guard-runner/contracts/J03.d.ts +13 -0
  55. package/dist/services/capability-guard-runner/contracts/J03.js +72 -21
  56. package/dist/services/capability-guard-runner/contracts/J04.d.ts +6 -0
  57. package/dist/services/capability-guard-runner/contracts/J04.js +65 -32
  58. package/dist/services/capability-guard-runner/contracts/J05.js +118 -16
  59. package/dist/services/capability-guard-runner/contracts/J06.d.ts +14 -0
  60. package/dist/services/capability-guard-runner/contracts/J06.js +57 -39
  61. package/dist/services/capability-guard-runner/contracts/J07.d.ts +9 -0
  62. package/dist/services/capability-guard-runner/contracts/J07.js +76 -47
  63. package/dist/services/capability-guard-runner/contracts/J08.d.ts +11 -0
  64. package/dist/services/capability-guard-runner/contracts/J08.js +66 -39
  65. package/dist/services/capability-guard-runner/contracts/J09.d.ts +13 -0
  66. package/dist/services/capability-guard-runner/contracts/J09.js +95 -39
  67. package/dist/services/capability-guard-runner/contracts/J10.d.ts +12 -0
  68. package/dist/services/capability-guard-runner/contracts/J10.js +69 -35
  69. package/dist/services/capability-guard-runner/contracts/J11.d.ts +8 -0
  70. package/dist/services/capability-guard-runner/contracts/J11.js +73 -33
  71. package/dist/services/capability-guard-runner/contracts/J12.d.ts +12 -0
  72. package/dist/services/capability-guard-runner/contracts/J12.js +66 -30
  73. package/dist/services/capability-guard-runner/contracts/J13.d.ts +11 -0
  74. package/dist/services/capability-guard-runner/contracts/J13.js +62 -40
  75. package/dist/services/capability-guard-runner/contracts/J14.d.ts +11 -0
  76. package/dist/services/capability-guard-runner/contracts/J14.js +60 -31
  77. package/dist/services/capability-guard-runner/contracts/J15.d.ts +11 -0
  78. package/dist/services/capability-guard-runner/contracts/J15.js +70 -35
  79. package/dist/services/capability-guard-runner/contracts/_shared.d.ts +24 -0
  80. package/dist/services/capability-guard-runner/contracts/_shared.js +67 -0
  81. package/dist/services/capability-guard-runner/registry.d.ts +5 -0
  82. package/dist/services/capability-guard-runner/registry.js +140 -0
  83. package/dist/services/capability-guard-runner/runner.d.ts +26 -0
  84. package/dist/services/capability-guard-runner/runner.js +63 -6
  85. package/dist/services/code/auto-compact-lifecycle.d.ts +75 -0
  86. package/dist/services/code/auto-compact-lifecycle.js +65 -16
  87. package/dist/services/code/auto-compact-modes.d.ts +13 -2
  88. package/dist/services/code/auto-compact-modes.js +20 -4
  89. package/dist/services/code/auto-compact-orchestrator.js +119 -19
  90. package/dist/services/code/compact-event-settle.d.ts +20 -8
  91. package/dist/services/code/compact-event-settle.js +21 -0
  92. package/dist/services/code/post-compact-detector.js +20 -11
  93. package/dist/services/code/step-08-gate.js +21 -6
  94. package/dist/services/compact-statusline/compact-statusline-service.js +56 -22
  95. package/dist/services/config/config-safety.js +11 -9
  96. package/dist/services/context/auto-compact-types.d.ts +20 -2
  97. package/dist/services/feedback/feedback-promotion-service.d.ts +137 -14
  98. package/dist/services/feedback/feedback-promotion-service.js +341 -20
  99. package/dist/services/feedback/promotion-artifact-evidence.d.ts +69 -0
  100. package/dist/services/feedback/promotion-artifact-evidence.js +332 -0
  101. package/dist/services/final-review/pre-post-diff.js +10 -2
  102. package/dist/services/job/job-progress-store.js +18 -3
  103. package/dist/services/observability/jsonl-store.d.ts +19 -0
  104. package/dist/services/observability/jsonl-store.js +27 -2
  105. package/dist/services/observability/observability-service.d.ts +11 -4
  106. package/dist/services/observability/observability-service.js +16 -3
  107. package/dist/services/prd/handoff-service.js +43 -0
  108. package/dist/services/qa/qa-business-review-state.js +19 -5
  109. package/dist/services/sc/sc-service.d.ts +8 -0
  110. package/dist/services/sc/sc-service.js +8 -1
  111. package/dist/services/scan/api-diff-types.js +20 -2
  112. package/dist/services/security/safe-settings-path.js +19 -1
  113. package/dist/services/session/getSessionDir.d.ts +33 -0
  114. package/dist/services/session/getSessionDir.js +60 -0
  115. package/dist/services/skill/skill-search-service.d.ts +3 -3
  116. package/dist/services/slice/slice-review-state.js +19 -4
  117. package/dist/services/standards/loop-engineering-lint.d.ts +1 -1
  118. package/dist/services/standards/loop-engineering-lint.js +6 -0
  119. package/dist/services/web/daemon-registry.js +27 -2
  120. package/dist/services/workflow/pipeline-verify-gate-support.js +10 -11
  121. package/dist/services/workflow/pipeline-verify-service.d.ts +1 -1
  122. package/dist/services/workflow/pipeline-verify-service.js +23 -10
  123. package/dist/services/workflow/pipeline-verify-types.d.ts +5 -3
  124. package/dist/services/workspace/claude-settings-template.d.ts +53 -37
  125. package/dist/services/workspace/claude-settings-template.js +105 -83
  126. package/dist/services/workspace/generated-artifacts-stamp.d.ts +119 -0
  127. package/dist/services/workspace/generated-artifacts-stamp.js +167 -0
  128. package/dist/services/workspace/workspace-claude-settings-materializer.d.ts +8 -0
  129. package/dist/services/workspace/workspace-claude-settings-materializer.js +38 -3
  130. package/dist/services/workspace/workspace-service.js +11 -1
  131. package/dist/shared/fs-utils.d.ts +26 -0
  132. package/dist/shared/fs-utils.js +35 -0
  133. package/dist/shared/runtime-root.d.ts +73 -0
  134. package/dist/shared/runtime-root.js +77 -0
  135. package/package.json +9 -7
  136. package/scripts/copy-templates.mjs +0 -12
  137. package/scripts/install-skills.mjs +154 -53
  138. package/skills/bee/peaks-qa/SKILL.md +0 -1
  139. package/skills/bee/peaks-rd/SKILL.md +0 -1
  140. package/skills/peaks-code/SKILL.md +12 -10
  141. package/skills/peaks-code/references/periodic-checkpoint.md +2 -2
  142. package/skills/peaks-code/references/runbook.md +3 -0
  143. package/skills/peaks-code/references/session-overload-signal-index.md +4 -2
  144. package/skills/peaks-code/references/startup-sequence.md +2 -2
  145. package/skills/peaks-code/references/step-0-8-gate.md +1 -1
  146. package/skills/peaks-code/references/sub-agent-dispatch.md +19 -19
  147. package/dist/cli/commands/context-builder-commands.d.ts +0 -11
  148. package/dist/cli/commands/context-builder-commands.js +0 -85
  149. package/dist/services/hooks/write-gate.js +0 -111
  150. package/skills/bee/peaks-prd/references/command-migration.md +0 -3
  151. package/skills/bee/peaks-qa/references/command-migration.md +0 -3
  152. package/skills/bee/peaks-rd/references/command-migration.md +0 -3
  153. package/skills/bee/peaks-sc/references/command-migration.md +0 -3
  154. package/skills/bee/peaks-txt/references/command-migration.md +0 -3
  155. package/skills/bee/peaks-ui/references/command-migration.md +0 -3
  156. package/skills/peaks-code/references/command-migration.md +0 -3
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Version stamp for the artifacts `peaks workspace init` generates into a
3
+ * consumer project (G4, 2026-09-15).
4
+ *
5
+ * WHY THIS EXISTS. `initWorkspace` is the only writer of a consumer project's
6
+ * generated config: `.claude/settings.local.json`, the offline
7
+ * `.peaks/.claude-settings-template.json` copy, and the managed `.gitignore`
8
+ * snippet. `ensureSession` early-returns as soon as a session is bound —
9
+ * deliberately, see its own comment — so after the FIRST init nothing ever
10
+ * re-runs the generator. `npm i -g peaks-loop@<newer>` therefore upgrades the
11
+ * CLI and leaves the project's generated config exactly as the OLD release
12
+ * wrote it.
13
+ *
14
+ * `.claude/settings.local.json` IS drift-checked against the current template
15
+ * — but only when something calls `initWorkspace`, which is precisely the
16
+ * event that stopped happening. The escape hatch (`peaks upgrade
17
+ * --apply-init`) is real and idempotent. What was missing is anything that
18
+ * tells the user — or the LLM driving them — that they need it. That is the
19
+ * gap this module closes: a stamp the generator writes, and a detector that
20
+ * can answer "the file on disk was produced by 4.0.40 while the installed
21
+ * peaks-loop is 4.0.49".
22
+ *
23
+ * WHY A SEPARATE FILE AND NOT A KEY INSIDE THE ARTIFACTS.
24
+ * `.claude/settings.local.json` is read by Claude Code, which owns its schema;
25
+ * a version key peaks invented has no contract there and could be rejected or
26
+ * silently ignored — a stamp nobody reads is worse than none. `.peaks/_runtime/`
27
+ * is peaks' own gitignored tree and already holds exactly this kind of
28
+ * machine-local bookkeeping (`session.json`, `.outer-session-cache.json`), so
29
+ * the stamp lives beside them and needs no new `.gitignore` line.
30
+ *
31
+ * WHAT IS COMPARED. Two versions, because they move independently and a
32
+ * mismatch of either means the same thing to the user (regenerate):
33
+ * - `packageVersion` — the installed peaks-loop release. Moves on `npm i -g`.
34
+ * - `templateVersion` — `TEMPLATE_VERSION`, the shape of the hooks tree this
35
+ * release emits. Can move without a release the user would notice.
36
+ *
37
+ * The stamp records what the generator LAST WROTE. It is not a promise that
38
+ * the on-disk artifacts still match it — a user may have hand-edited
39
+ * `.claude/settings.local.json` since. That question is the existing
40
+ * `templateContentMatches` comparator's, and it is asked on the next init,
41
+ * which is what this detector tells the user to trigger.
42
+ */
43
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
44
+ import { dirname, join } from 'node:path';
45
+ import { CLI_VERSION } from 'peaks-loop-shared/version';
46
+ import { isExpectedFsMiss } from '../../shared/fs-utils.js';
47
+ import { TEMPLATE_VERSION } from './claude-settings-template.js';
48
+ /**
49
+ * Where the stamp lives, relative to the project root.
50
+ *
51
+ * `.peaks/_runtime/` is gitignored by every peaks-loop project already (see
52
+ * the root `.gitignore` snippet), so this file never shows up in
53
+ * `git status` — which matters, because the whole point is that its content
54
+ * changes on the user's machine and not in their repository.
55
+ */
56
+ export const GENERATED_ARTIFACTS_STAMP_RELATIVE_PATH = join('.peaks', '_runtime', 'generated-artifacts.json');
57
+ /**
58
+ * The artifacts whose generation the stamp describes. Used to answer "is
59
+ * there anything on disk that could be stale?" — a project that has never run
60
+ * `peaks workspace init` has nothing to refresh and must not be nagged.
61
+ */
62
+ export const GENERATED_ARTIFACT_RELATIVE_PATHS = [
63
+ join('.claude', 'settings.local.json'),
64
+ join('.peaks', '.claude-settings-template.json')
65
+ ];
66
+ export function generatedArtifactsStampPath(projectRoot) {
67
+ return join(projectRoot, GENERATED_ARTIFACTS_STAMP_RELATIVE_PATH);
68
+ }
69
+ /** Does this project have any generated artifact on disk at all? */
70
+ function hasGeneratedArtifact(projectRoot) {
71
+ return GENERATED_ARTIFACT_RELATIVE_PATHS.some((rel) => existsSync(join(projectRoot, rel)));
72
+ }
73
+ /**
74
+ * Read the stamp, or `null` when there is none / it is not the shape this
75
+ * module writes. Tolerant on purpose: a malformed stamp must degrade to
76
+ * "unstamped", never to a crash on the per-turn read path.
77
+ *
78
+ * The catch still NAMES what it tolerates rather than swallowing everything —
79
+ * the same correction the two P1 sites in this slice got (see
80
+ * `isExpectedFsMiss`). A missing file (raced away between `existsSync` and the
81
+ * read) and a stamp we cannot parse both mean "no usable stamp"; a
82
+ * `TypeError` from a broken dependency does not.
83
+ */
84
+ export function readGeneratedArtifactsStamp(projectRoot) {
85
+ const stampPath = generatedArtifactsStampPath(projectRoot);
86
+ if (!existsSync(stampPath))
87
+ return null;
88
+ let parsed;
89
+ try {
90
+ parsed = JSON.parse(readFileSync(stampPath, 'utf8'));
91
+ }
92
+ catch (err) {
93
+ if (!isExpectedFsMiss(err) && !(err instanceof SyntaxError))
94
+ throw err;
95
+ return null;
96
+ }
97
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
98
+ return null;
99
+ const candidate = parsed;
100
+ if (typeof candidate['packageVersion'] !== 'string' ||
101
+ typeof candidate['templateVersion'] !== 'string' ||
102
+ typeof candidate['writtenAt'] !== 'string') {
103
+ return null;
104
+ }
105
+ return {
106
+ stampVersion: 1,
107
+ packageVersion: candidate['packageVersion'],
108
+ templateVersion: candidate['templateVersion'],
109
+ writtenAt: candidate['writtenAt']
110
+ };
111
+ }
112
+ /**
113
+ * Record what the generator just wrote. Called by `initWorkspace` on every
114
+ * successful materialization — including the one that changes nothing —
115
+ * because the stamp's job is "when did a release last regenerate this
116
+ * project", not "when did the bytes change".
117
+ *
118
+ * `packageVersion` / `now` are injectable so a test can write a deliberately
119
+ * old stamp without touching the clock or the package.
120
+ */
121
+ export function writeGeneratedArtifactsStamp(projectRoot, options = {}) {
122
+ const stamp = {
123
+ stampVersion: 1,
124
+ packageVersion: options.packageVersion ?? CLI_VERSION,
125
+ templateVersion: TEMPLATE_VERSION,
126
+ writtenAt: (options.now ?? new Date()).toISOString()
127
+ };
128
+ const stampPath = generatedArtifactsStampPath(projectRoot);
129
+ const dir = dirname(stampPath);
130
+ if (!existsSync(dir)) {
131
+ mkdirSync(dir, { recursive: true });
132
+ }
133
+ writeFileSync(stampPath, `${JSON.stringify(stamp, null, 2)}\n`, 'utf8');
134
+ return stamp;
135
+ }
136
+ /**
137
+ * Is this project's generated config behind the installed peaks-loop?
138
+ *
139
+ * Returns `stale: false` — with no reasons — for the two cases that are NOT
140
+ * staleness:
141
+ * - the project has no generated artifact at all (never initialized), and
142
+ * - the stamp matches both expected versions.
143
+ *
144
+ * `unstamped` is reported only when an artifact EXISTS and no stamp does:
145
+ * those are projects initialized by a release that predates this module, and
146
+ * they are exactly the population the defect was reported against.
147
+ */
148
+ export function detectStaleGeneratedArtifacts(projectRoot) {
149
+ const expected = { packageVersion: CLI_VERSION, templateVersion: TEMPLATE_VERSION };
150
+ const onDisk = readGeneratedArtifactsStamp(projectRoot);
151
+ const artifactsPresent = hasGeneratedArtifact(projectRoot);
152
+ if (!artifactsPresent) {
153
+ return { stale: false, reasons: [], onDisk, expected };
154
+ }
155
+ if (existsSync(generatedArtifactsStampPath(projectRoot)) && onDisk === null) {
156
+ return { stale: true, reasons: ['stamp-unreadable'], onDisk, expected };
157
+ }
158
+ if (onDisk === null) {
159
+ return { stale: true, reasons: ['unstamped'], onDisk, expected };
160
+ }
161
+ const reasons = [];
162
+ if (onDisk.packageVersion !== expected.packageVersion)
163
+ reasons.push('package-upgraded');
164
+ if (onDisk.templateVersion !== expected.templateVersion)
165
+ reasons.push('template-changed');
166
+ return { stale: reasons.length > 0, reasons, onDisk, expected };
167
+ }
@@ -9,6 +9,14 @@
9
9
  * the parent module and calls into this sibling. Function signatures
10
10
  * and behaviour are unchanged (verbatim move).
11
11
  */
12
+ /**
13
+ * The peaks-managed snippet appended to the consumer project's
14
+ * `.peaks/.gitignore` so the local-only settings file never lands
15
+ * in a commit. Marked with a managed-by header so we can detect (and
16
+ * not double-append) on subsequent inits.
17
+ */
18
+ export declare const PEAKS_GITIGNORE_HEADER = "# >>> peaks-loop managed snippet (slice 2.0.1-bug3) \u2014 do not edit by hand";
19
+ export declare const PEAKS_GITIGNORE_FOOTER = "# <<< peaks-loop managed snippet";
12
20
  /**
13
21
  * Materialize the consumer-project `.claude/settings.local.json` and
14
22
  * ensure the consumer's `.peaks/.gitignore` covers it. Returns a
@@ -81,6 +81,9 @@ function readEnvObject(serialized) {
81
81
  * → installAutoCompactHook (installed, 4: … | Bash|Task)
82
82
  * → init again (REFRESHED, 3: … ) ← the Bash|Task entry is gone
83
83
  *
84
+ * (The 3 is a 2 since TEMPLATE_VERSION 1.8.0 retired the
85
+ * `Write|Edit|MultiEdit` entry. The measurement above is left as taken.)
86
+ *
84
87
  * and nothing re-installs it: the hook's whole job was to fire on the next
85
88
  * Bash/Task call, so once it is deleted the auto-compact contract stops
86
89
  * silently.
@@ -139,8 +142,13 @@ function isPlainRecord(value) {
139
142
  * in a commit. Marked with a managed-by header so we can detect (and
140
143
  * not double-append) on subsequent inits.
141
144
  */
142
- const PEAKS_GITIGNORE_HEADER = '# >>> peaks-loop managed snippet (slice 2.0.1-bug3) — do not edit by hand';
143
- const PEAKS_GITIGNORE_FOOTER = '# <<< peaks-loop managed snippet';
145
+ // Exported (S6, 2026-09-15) so the convergence tests can build a block with
146
+ // the REAL delimiters. A test that retypes them gets a prefix and silently
147
+ // exercises the append path instead of the converge path — which is exactly
148
+ // what happened on the first run of
149
+ // `tests/unit/services/workspace/gitignore-snippet-convergence.test.ts`.
150
+ export const PEAKS_GITIGNORE_HEADER = '# >>> peaks-loop managed snippet (slice 2.0.1-bug3) — do not edit by hand';
151
+ export const PEAKS_GITIGNORE_FOOTER = '# <<< peaks-loop managed snippet';
144
152
  const PEAKS_GITIGNORE_SNIPPET = [
145
153
  PEAKS_GITIGNORE_HEADER,
146
154
  '# Consumer-project .claude/settings.local.json: written by `peaks workspace init`',
@@ -386,7 +394,34 @@ async function upsertPeaksGitignoreSnippet(projectRoot) {
386
394
  existing = '';
387
395
  }
388
396
  }
389
- if (existing.includes(PEAKS_GITIGNORE_HEADER)) {
397
+ // D2 fix (2026-09-15): the block is now converged, not merely appended.
398
+ //
399
+ // Before this, a project that already had the header returned here forever:
400
+ // the snippet was written once by whichever release first initialized the
401
+ // project and NEVER updated, so a pattern added to the snippet by a later
402
+ // release (`slice 2.0.1-bug3` moved the block from `.peaks/.gitignore` to
403
+ // the root for exactly this class of reason) reached only projects that had
404
+ // not been initialized yet — i.e. new users got the fix and existing users
405
+ // did not, which is backwards.
406
+ //
407
+ // Scope of the rewrite is the managed block, and only the block. Every line
408
+ // outside `HEADER..FOOTER` is spliced through byte-for-byte, which is the
409
+ // "never overwrite the user's edits" semantic this function always had. The
410
+ // header itself says "do not edit by hand"; a user who did is the case
411
+ // `stripLegacyPeaksGitignoreSnippet` already refuses to guess at, and this
412
+ // does the same — a header with no footer is left alone rather than opened
413
+ // up and repaired at the risk of eating the rest of the file.
414
+ const start = existing.indexOf(PEAKS_GITIGNORE_HEADER);
415
+ if (start !== -1) {
416
+ const footerAt = existing.indexOf(PEAKS_GITIGNORE_FOOTER, start + PEAKS_GITIGNORE_HEADER.length);
417
+ if (footerAt === -1)
418
+ return;
419
+ const end = footerAt + PEAKS_GITIGNORE_FOOTER.length;
420
+ const currentBlock = existing.slice(start, end);
421
+ const nextBlock = PEAKS_GITIGNORE_SNIPPET.trimEnd();
422
+ if (currentBlock === nextBlock)
423
+ return;
424
+ await writeFile(gitignorePath, existing.slice(0, start) + nextBlock + existing.slice(end), 'utf8');
390
425
  return;
391
426
  }
392
427
  const separator = existing.length > 0 && !existing.endsWith('\n') ? '\n' : '';
@@ -363,6 +363,15 @@ export async function initWorkspace(options) {
363
363
  .map((write) => write.relativePath)
364
364
  };
365
365
  }
366
+ const claudeSettings = await materializeClaudeSettingsLocal(options.projectRoot, options.noClaudeHooks === true);
367
+ // G4 (2026-09-15): record which release generated this project's config.
368
+ // Without it there is nothing on disk for a LATER release to compare
369
+ // against, so "your generated config is behind the installed peaks-loop"
370
+ // is not a decidable question — which is the whole of D1. Written on every
371
+ // init (including the no-op ones): the stamp answers "when did a release
372
+ // last regenerate this project", not "when did the bytes change". See
373
+ // `generated-artifacts-stamp.ts`.
374
+ writeGeneratedArtifactsStamp(options.projectRoot);
366
375
  return {
367
376
  sessionId: options.sessionId,
368
377
  sessionRoot,
@@ -370,7 +379,7 @@ export async function initWorkspace(options) {
370
379
  alreadyExisted,
371
380
  bound,
372
381
  previousSessionId,
373
- claudeSettings: await materializeClaudeSettingsLocal(options.projectRoot, options.noClaudeHooks === true),
382
+ claudeSettings,
374
383
  standardsMissing,
375
384
  ...(standardsApplied !== undefined ? { standardsApplied } : {})
376
385
  };
@@ -385,6 +394,7 @@ export async function initWorkspace(options) {
385
394
  // are unchanged (verbatim move).
386
395
  import { materializeClaudeSettingsLocal } from './workspace-claude-settings-materializer.js';
387
396
  export { materializeClaudeSettingsLocal } from './workspace-claude-settings-materializer.js';
397
+ import { writeGeneratedArtifactsStamp } from './generated-artifacts-stamp.js';
388
398
  /**
389
399
  * Slice C10 (2026-06-24-legacy-change-id-sibling): whole-dir shape check
390
400
  * for the legacy sibling `.peaks/_runtime/<sessionId>/`. Returns `true` ONLY when
@@ -1,4 +1,30 @@
1
1
  import { type Platform } from './platform.js';
2
+ /**
3
+ * Does this error mean "the path is legitimately absent or unreadable by us"?
4
+ *
5
+ * A `catch` is allowed to turn THAT class of failure into a fallback value.
6
+ * Every other error is a bug and must propagate. Without the distinction,
7
+ * "this catch only swallows IO errors" is a claim the code cannot keep:
8
+ *
9
+ * catch (err) {
10
+ * if (err instanceof ReferenceError) throw err;
11
+ * if (err instanceof SyntaxError) throw err;
12
+ * return null; // ← swallows every TypeError, every ERR_INVALID_ARG_TYPE,
13
+ * // every error a dependency throws, and calls it "IO"
14
+ * }
15
+ *
16
+ * That exact shape sat on two priority sites (`post-compact-detector`,
17
+ * `step-08-gate`) with that exact comment. Rethrowing the two JS error
18
+ * classes it happened to name is not the same rule as swallowing one error
19
+ * class and rethrowing the rest — and it is the *inverted* one: the more
20
+ * unexpected the failure, the more certainly it was swallowed.
21
+ *
22
+ * The set is deliberately the "miss" codes only. `EISDIR` is included because
23
+ * reading a directory where a file was expected is indistinguishable from
24
+ * absent to every caller here, and excluding it would turn a pre-existing
25
+ * silent fallback into a new loud failure for no gain.
26
+ */
27
+ export declare function isExpectedFsMiss(err: unknown): boolean;
2
28
  export declare function getDirectoryLinkType(targetPlatform?: Platform): 'junction' | 'dir';
3
29
  export declare function createDirectoryLinkSync(target: string, linkPath: string): void;
4
30
  export declare function readDirectoryLinkTarget(linkPath: string): string | null;
@@ -1,5 +1,40 @@
1
1
  import { symlinkSync as nodeSymlinkSync, readlinkSync } from 'node:fs';
2
2
  import { platform } from './platform.js';
3
+ /**
4
+ * Does this error mean "the path is legitimately absent or unreadable by us"?
5
+ *
6
+ * A `catch` is allowed to turn THAT class of failure into a fallback value.
7
+ * Every other error is a bug and must propagate. Without the distinction,
8
+ * "this catch only swallows IO errors" is a claim the code cannot keep:
9
+ *
10
+ * catch (err) {
11
+ * if (err instanceof ReferenceError) throw err;
12
+ * if (err instanceof SyntaxError) throw err;
13
+ * return null; // ← swallows every TypeError, every ERR_INVALID_ARG_TYPE,
14
+ * // every error a dependency throws, and calls it "IO"
15
+ * }
16
+ *
17
+ * That exact shape sat on two priority sites (`post-compact-detector`,
18
+ * `step-08-gate`) with that exact comment. Rethrowing the two JS error
19
+ * classes it happened to name is not the same rule as swallowing one error
20
+ * class and rethrowing the rest — and it is the *inverted* one: the more
21
+ * unexpected the failure, the more certainly it was swallowed.
22
+ *
23
+ * The set is deliberately the "miss" codes only. `EISDIR` is included because
24
+ * reading a directory where a file was expected is indistinguishable from
25
+ * absent to every caller here, and excluding it would turn a pre-existing
26
+ * silent fallback into a new loud failure for no gain.
27
+ */
28
+ export function isExpectedFsMiss(err) {
29
+ const code = err?.code;
30
+ return (code === 'ENOENT' ||
31
+ code === 'ENOTDIR' ||
32
+ code === 'EISDIR' ||
33
+ code === 'EACCES' ||
34
+ code === 'EPERM' ||
35
+ code === 'ELOOP' ||
36
+ code === 'ENAMETOOLONG');
37
+ }
3
38
  export function getDirectoryLinkType(targetPlatform = platform) {
4
39
  return targetPlatform === 'win32' ? 'junction' : 'dir';
5
40
  }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The one seam through which a `.peaks/_runtime` path is built.
3
+ *
4
+ * Slice 2026-09-15 (runtime-path-unrepresentable). Three attempts to *detect*
5
+ * a caller-supplied id reaching a runtime join all failed the same way: the
6
+ * shipped text rule caught 4 of 12 fixture shapes where the name-based
7
+ * predicate it replaced caught 8, and the version that reached 10 of 12 gave
8
+ * back the change that reached 12 because it cost seven findings on live code —
9
+ * two of them structural (a guard helper, and readers that take the guarded
10
+ * value as a parameter). Its measured verdict: reassignment between guard and
11
+ * join, and guard-after-join, are **domination failures inside a single
12
+ * function, invisible to any text key**.
13
+ *
14
+ * So the instrument is retired in favour of a property. The id cannot reach a
15
+ * runtime join unguarded because there is no longer a join that accepts an
16
+ * unguarded string: `RuntimeRoot.join` takes `GuardedSegment`, and a
17
+ * `GuardedSegment` can only be produced by `guardRuntimeSegment`, which throws
18
+ * on the shapes the escapes used. A newly written unguarded join is a type
19
+ * error at authoring time — not a removed guard that some later scan notices.
20
+ *
21
+ * The raw root is not obtainable as a string except through `dir()`, which is
22
+ * named so that a join written from it (`join(root.dir(), id)`) reads at review
23
+ * time as the bypass it is. `dir()` exists because callers legitimately
24
+ * enumerate the root itself; it is not a join.
25
+ */
26
+ declare const RUNTIME_SEGMENT: unique symbol;
27
+ /**
28
+ * A path segment that has been checked as a single safe segment.
29
+ *
30
+ * Unforgeable by construction: the brand is a `unique symbol` that is never
31
+ * exported, so `value as GuardedSegment` outside this module is a type error
32
+ * and a plain `string` is not assignable. `guardRuntimeSegment` is the only
33
+ * producer.
34
+ */
35
+ export type GuardedSegment = string & {
36
+ readonly [RUNTIME_SEGMENT]: true;
37
+ };
38
+ /**
39
+ * Check a caller-supplied string and brand it for use as a runtime path
40
+ * segment. `label` names the id in the error the way the caller knows it
41
+ * ("session id", "project id"), because the throw site is one function away
42
+ * from the caller that supplied it.
43
+ *
44
+ * Rejects exactly the shapes `isUnsafePathInput` rejects: separators, `..`,
45
+ * absolute and drive-prefixed paths, UNC and URL shapes, and empty segments.
46
+ */
47
+ export declare function guardRuntimeSegment(value: string, label: string): GuardedSegment;
48
+ /**
49
+ * The `.peaks/_runtime` root of one project, as a capability rather than a
50
+ * string. `#path` is a private field, so the raw root cannot be read off the
51
+ * object and joined by an unguarded `join()`.
52
+ */
53
+ export declare class RuntimeRoot {
54
+ #private;
55
+ private constructor();
56
+ /** The runtime root of `projectRoot`. */
57
+ static at(projectRoot: string): RuntimeRoot;
58
+ /**
59
+ * Join guarded segments onto the root. At least one segment is required: a
60
+ * zero-argument `join()` would hand back the bare root as a `string`, which
61
+ * is the capability this class exists to withhold.
62
+ */
63
+ join(first: GuardedSegment, ...rest: readonly GuardedSegment[]): string;
64
+ /**
65
+ * The root itself, for READ-only enumeration (`readdir`, `existsSync`) — not
66
+ * for joining. Deliberately a method rather than a property so a bypass is
67
+ * legible at the call site.
68
+ */
69
+ dir(): string;
70
+ }
71
+ /** The runtime root of `projectRoot`. */
72
+ export declare function runtimeRoot(projectRoot: string): RuntimeRoot;
73
+ export {};
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The one seam through which a `.peaks/_runtime` path is built.
3
+ *
4
+ * Slice 2026-09-15 (runtime-path-unrepresentable). Three attempts to *detect*
5
+ * a caller-supplied id reaching a runtime join all failed the same way: the
6
+ * shipped text rule caught 4 of 12 fixture shapes where the name-based
7
+ * predicate it replaced caught 8, and the version that reached 10 of 12 gave
8
+ * back the change that reached 12 because it cost seven findings on live code —
9
+ * two of them structural (a guard helper, and readers that take the guarded
10
+ * value as a parameter). Its measured verdict: reassignment between guard and
11
+ * join, and guard-after-join, are **domination failures inside a single
12
+ * function, invisible to any text key**.
13
+ *
14
+ * So the instrument is retired in favour of a property. The id cannot reach a
15
+ * runtime join unguarded because there is no longer a join that accepts an
16
+ * unguarded string: `RuntimeRoot.join` takes `GuardedSegment`, and a
17
+ * `GuardedSegment` can only be produced by `guardRuntimeSegment`, which throws
18
+ * on the shapes the escapes used. A newly written unguarded join is a type
19
+ * error at authoring time — not a removed guard that some later scan notices.
20
+ *
21
+ * The raw root is not obtainable as a string except through `dir()`, which is
22
+ * named so that a join written from it (`join(root.dir(), id)`) reads at review
23
+ * time as the bypass it is. `dir()` exists because callers legitimately
24
+ * enumerate the root itself; it is not a join.
25
+ */
26
+ import { join } from 'node:path';
27
+ import { isUnsafePathInput } from './path-safety.js';
28
+ /**
29
+ * Check a caller-supplied string and brand it for use as a runtime path
30
+ * segment. `label` names the id in the error the way the caller knows it
31
+ * ("session id", "project id"), because the throw site is one function away
32
+ * from the caller that supplied it.
33
+ *
34
+ * Rejects exactly the shapes `isUnsafePathInput` rejects: separators, `..`,
35
+ * absolute and drive-prefixed paths, UNC and URL shapes, and empty segments.
36
+ */
37
+ export function guardRuntimeSegment(value, label) {
38
+ if (isUnsafePathInput(value)) {
39
+ throw new Error(`Invalid ${label}: ${value} (must be a single path segment)`);
40
+ }
41
+ return value;
42
+ }
43
+ /**
44
+ * The `.peaks/_runtime` root of one project, as a capability rather than a
45
+ * string. `#path` is a private field, so the raw root cannot be read off the
46
+ * object and joined by an unguarded `join()`.
47
+ */
48
+ export class RuntimeRoot {
49
+ #path;
50
+ constructor(path) {
51
+ this.#path = path;
52
+ }
53
+ /** The runtime root of `projectRoot`. */
54
+ static at(projectRoot) {
55
+ return new RuntimeRoot(join(projectRoot, '.peaks', '_runtime'));
56
+ }
57
+ /**
58
+ * Join guarded segments onto the root. At least one segment is required: a
59
+ * zero-argument `join()` would hand back the bare root as a `string`, which
60
+ * is the capability this class exists to withhold.
61
+ */
62
+ join(first, ...rest) {
63
+ return join(this.#path, first, ...rest);
64
+ }
65
+ /**
66
+ * The root itself, for READ-only enumeration (`readdir`, `existsSync`) — not
67
+ * for joining. Deliberately a method rather than a property so a bypass is
68
+ * legible at the call site.
69
+ */
70
+ dir() {
71
+ return this.#path;
72
+ }
73
+ }
74
+ /** The runtime root of `projectRoot`. */
75
+ export function runtimeRoot(projectRoot) {
76
+ return RuntimeRoot.at(projectRoot);
77
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peaks-loop",
3
- "version": "4.0.48",
3
+ "version": "4.0.50",
4
4
  "description": "Loop Engineering CLI — workflow primitive / loop guards / evaluators / slice orchestration",
5
5
  "author": "SquabbyZ",
6
6
  "keywords": [
@@ -102,10 +102,10 @@
102
102
  "picomatch": "4.0.4",
103
103
  "yaml": "^2.9.0",
104
104
  "zod": "^4.4.3",
105
- "peaks-loop-internal-runtime": "0.0.33",
106
- "peaks-loop-shared-channel": "0.0.50",
107
- "peaks-loop-shared": "0.0.82",
108
- "peaks-loop-mut": "0.1.46"
105
+ "peaks-loop-mut": "0.1.48",
106
+ "peaks-loop-internal-runtime": "0.0.35",
107
+ "peaks-loop-shared-channel": "0.0.52",
108
+ "peaks-loop-shared": "0.0.84"
109
109
  },
110
110
  "devDependencies": {
111
111
  "@changesets/cli": "2.31.1",
@@ -141,6 +141,8 @@
141
141
  "test:dev": "vitest run tests/unit",
142
142
  "test:dev:cli": "vitest run tests/unit",
143
143
  "test:unit": "vitest run tests/unit",
144
+ "test:e2e": "vitest run --config vitest.config.e2e.ts",
145
+ "test:lint": "vitest run --config vitest.config.lint.ts",
144
146
  "test:integration": "vitest run --config vitest.config.integration.ts tests/integration",
145
147
  "test:capability-guard": "vitest run --config vitest.config.integration.ts tests/integration/capability-guard",
146
148
  "test:cli": "vitest run tests/unit",
@@ -149,8 +151,8 @@
149
151
  "test:ci": "node ./scripts/sync-version.mjs && node ./scripts/lint/silent-warning-detector.mjs && vitest run --coverage",
150
152
  "test:changed": "node scripts/test-changed.mjs",
151
153
  "lint:silent-warning": "node ./scripts/lint/silent-warning-detector.mjs",
152
- "test:race": "vitest run --no-file-parallelism packages/peaks-loop-shared-channel/tests/shared-channel.test.ts tests/unit/dispatch-record-writer.test.ts tests/unit/services/retrospective/heartbeat.test.ts tests/unit/cli/commands/share-commands.test.ts",
153
- "test:replay": "node scripts/fixture-capture-setup.mjs && vitest run tests/unit/replay tests/unit/fixture",
154
+ "test:race": "vitest run --no-file-parallelism packages/peaks-loop-shared-channel/tests/shared-channel.test.ts tests/unit/services/dispatch/dispatch-record-writer.test.ts",
155
+ "test:replay": "node scripts/fixture-capture-setup.mjs && vitest run tests/unit/services/dispatch/conflict-replay.test.ts tests/unit/services/dispatch/e2e-fixtures.test.ts",
154
156
  "fixture:capture-setup": "node scripts/fixture-capture-setup.mjs",
155
157
  "test:coverage": "node ./scripts/coverage-c8.mjs",
156
158
  "test:coverage:c8": "node ./scripts/coverage-c8.mjs",
@@ -60,18 +60,6 @@ const targets = [
60
60
  src: join(packageRoot, 'src/services/skillhub/migrations'),
61
61
  dest: join(packageRoot, 'dist/services/skillhub/migrations'),
62
62
  extensions: ['.sql']
63
- },
64
- {
65
- // Slice c5-write-hook-exec-form: the Write|Edit|MultiEdit PreToolUse gate
66
- // emitted by `peaks workspace init` is invoked as `node <path>`, and the
67
- // path is resolved relative to this module — `dist/services/hooks/` in an
68
- // installed consumer, `src/services/hooks/` under tsx. A plain `.js` asset
69
- // (not a `.ts` compiled by tsc) is what lets ONE relative filename be valid
70
- // in both trees. Without this copy the hook would be a broken path in every
71
- // installed consumer while every test in this repo still passed.
72
- src: join(packageRoot, 'src/services/hooks'),
73
- dest: join(packageRoot, 'dist/services/hooks'),
74
- extensions: ['.js']
75
63
  }
76
64
  ];
77
65