peaks-loop 4.0.51 → 4.0.53

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 (65) hide show
  1. package/CHANGELOG.md +59 -1
  2. package/README-en.md +1 -1
  3. package/README.md +1 -1
  4. package/dist/cli/commands/baseline-commands.js +11 -1
  5. package/dist/cli/commands/codegraph-command-runtime.d.ts +28 -0
  6. package/dist/cli/commands/codegraph-command-runtime.js +72 -0
  7. package/dist/cli/commands/codegraph-commands.d.ts +2 -11
  8. package/dist/cli/commands/codegraph-commands.js +173 -228
  9. package/dist/cli/commands/codegraph-status-command.d.ts +22 -0
  10. package/dist/cli/commands/codegraph-status-command.js +299 -0
  11. package/dist/cli/commands/core/memory-command.js +6 -2
  12. package/dist/cli/commands/job-commands.js +121 -30
  13. package/dist/cli/commands/project-commands.js +13 -3
  14. package/dist/cli/commands/request-commands.js +19 -8
  15. package/dist/cli/commands/slice-commands.js +2 -2
  16. package/dist/services/artifacts/artifact-prerequisites.js +23 -1
  17. package/dist/services/codegraph/codegraph-autorefresh.d.ts +16 -0
  18. package/dist/services/codegraph/codegraph-autorefresh.js +51 -5
  19. package/dist/services/codegraph/codegraph-config-repair-writer.d.ts +88 -0
  20. package/dist/services/codegraph/codegraph-config-repair-writer.js +322 -0
  21. package/dist/services/codegraph/codegraph-exclude-integrity.d.ts +20 -2
  22. package/dist/services/codegraph/codegraph-exclude-integrity.js +24 -3
  23. package/dist/services/codegraph/codegraph-exclude-reconciler.d.ts +23 -2
  24. package/dist/services/codegraph/codegraph-exclude-reconciler.js +123 -12
  25. package/dist/services/codegraph/codegraph-exclude-repair.d.ts +109 -55
  26. package/dist/services/codegraph/codegraph-exclude-repair.js +249 -195
  27. package/dist/services/codegraph/codegraph-include-reconciler.d.ts +10 -0
  28. package/dist/services/codegraph/codegraph-include-reconciler.js +160 -0
  29. package/dist/services/codegraph/codegraph-index-integrity.d.ts +268 -0
  30. package/dist/services/codegraph/codegraph-index-integrity.js +471 -0
  31. package/dist/services/codegraph/codegraph-service.d.ts +54 -0
  32. package/dist/services/codegraph/codegraph-service.js +84 -1
  33. package/dist/services/dispatch/sub-agent-dispatcher.d.ts +0 -1
  34. package/dist/services/dispatch/sub-agent-dispatcher.js +0 -3
  35. package/dist/services/doctor/doctor-service/checks/codegraph-exclude-integrity.js +19 -4
  36. package/dist/services/doctor/doctor-service/checks/codegraph-index-integrity.d.ts +54 -0
  37. package/dist/services/doctor/doctor-service/checks/codegraph-index-integrity.js +151 -0
  38. package/dist/services/doctor/doctor-service/checks/l3-orphan-sessions.js +10 -10
  39. package/dist/services/doctor/doctor-service/plugin-registry.js +2 -0
  40. package/dist/services/doctor/doctor-service/types.d.ts +25 -0
  41. package/dist/services/memory/project-memory-service/index/kind-dispatch.js +48 -13
  42. package/dist/services/memory/project-memory-service/index.d.ts +5 -3
  43. package/dist/services/memory/project-memory-service/index.js +2 -2
  44. package/dist/services/memory/project-memory-service/parsers/frontmatter.d.ts +15 -1
  45. package/dist/services/memory/project-memory-service/parsers/frontmatter.js +34 -6
  46. package/dist/services/memory/project-memory-service/parsers/markdown-pure.d.ts +27 -1
  47. package/dist/services/memory/project-memory-service/parsers/markdown-pure.js +92 -7
  48. package/dist/services/memory/project-memory-service/types.d.ts +86 -0
  49. package/dist/services/prd/handoff-gate-evidence.d.ts +1 -0
  50. package/dist/services/prd/handoff-gate-evidence.js +77 -0
  51. package/dist/services/slice/slice-check-types.d.ts +1 -1
  52. package/dist/services/workspace/runtime-layout.d.ts +91 -0
  53. package/dist/services/workspace/runtime-layout.js +148 -0
  54. package/dist/services/workspace/workspace-claude-settings-materializer.js +14 -0
  55. package/package.json +6 -6
  56. package/scripts/clean-dist.mjs +15 -3
  57. package/scripts/sync-version.mjs +26 -4
  58. package/skills/bee/peaks-prd/SKILL.md +1 -1
  59. package/skills/bee/peaks-qa/references/qa-skill-presence.md +1 -1
  60. package/skills/bee/peaks-rd/references/skill-presence-and-title.md +1 -1
  61. package/skills/bee/peaks-sc/SKILL.md +1 -1
  62. package/skills/bee/peaks-txt/SKILL.md +3 -3
  63. package/skills/peaks-code/SKILL.md +1 -1
  64. package/skills/peaks-code/references/project-memory-loading.md +19 -1
  65. package/skills/peaks-code/references/step-11-memory-sediment.md +1 -1
@@ -20,9 +20,32 @@
20
20
  // No filesystem imports. Pure functions only — easy to unit-test.
21
21
  // ---------------------------------------------------------------------------
22
22
  import { assertSafeMemory } from '../store/atomic-write.js';
23
- import { parseBlock, slugify } from './frontmatter.js';
23
+ import { parseBlockResult, slugify, VALID_MEMORY_KINDS } from './frontmatter.js';
24
24
  export const START_MARKER = '<!-- peaks-memory:start -->';
25
25
  export const END_MARKER = '<!-- peaks-memory:end -->';
26
+ /**
27
+ * A marker-shaped HTML comment: `<!--` then horizontal space then
28
+ * `peaks-memory:start` / `:end`, then anything up to the closing `-->`.
29
+ *
30
+ * This deliberately matches the exact markers TOO — the caller skips those by
31
+ * comparing against `START_MARKER` / `END_MARKER`, so the two literals stay the
32
+ * single source of truth instead of being re-spelled here.
33
+ *
34
+ * Anchoring on `peaks-memory:` immediately after the comment open is what keeps
35
+ * this from firing on prose: a comment that merely *mentions* the marker
36
+ * (`<!-- see the peaks-memory:start docs -->`) does not match, because the text
37
+ * after `<!--` is `see`, not `peaks-memory:`. Only an attempted marker matches.
38
+ */
39
+ const MARKER_SHAPED_COMMENT = /<!--[ \t]*peaks-memory:(start|end)\b[^>]*-->/g;
40
+ /**
41
+ * Why a marker-shaped comment was not usable, as a warning line. Names both the
42
+ * text that was found and the literal the locator wanted, because the whole
43
+ * failure is "these two differ in a way nothing told you about".
44
+ */
45
+ function describeUnrecognizedMarker(found) {
46
+ const wanted = found.includes(':end') ? END_MARKER : START_MARKER;
47
+ return `found ${JSON.stringify(found)}, which is not the exact marker ${JSON.stringify(wanted)} — the locator searches for that literal, so any block this opens is never found`;
48
+ }
26
49
  // Length bounds for index entry descriptions. The numbers were chosen when
27
50
  // summarizeMemoryBody was first introduced; locking them in as named
28
51
  // constants is a doc-as-code move so the truncation rule is no longer
@@ -47,8 +70,21 @@ export function summarizeMemoryBody(body) {
47
70
  }
48
71
  return first.slice(0, MAX_DESCRIPTION_LENGTH - ELLIPSIS_RESERVE) + '...';
49
72
  }
50
- export function extractStableProjectMemories(content, sourceArtifact) {
73
+ /**
74
+ * Same scan as `extractStableProjectMemories`, but also reports the blocks
75
+ * that were found between the markers and rejected by the parser.
76
+ *
77
+ * `extractStableProjectMemories` is the `.memories` projection of this, so the
78
+ * extracted set is identical by construction — the diagnostics cannot change
79
+ * which blocks are accepted.
80
+ */
81
+ export function extractStableProjectMemoriesWithDiagnostics(content, sourceArtifact) {
51
82
  const memories = [];
83
+ // Kept as `{index, drop}` pairs so located-block drops and near-miss-marker
84
+ // drops can be reported in DOCUMENT order through one channel. The index is
85
+ // ordering metadata only — it never reaches the caller.
86
+ const drops = [];
87
+ const locatedRanges = [];
52
88
  let searchStart = 0;
53
89
  while (searchStart < content.length) {
54
90
  const start = content.indexOf(START_MARKER, searchStart);
@@ -58,14 +94,63 @@ export function extractStableProjectMemories(content, sourceArtifact) {
58
94
  const end = content.indexOf(END_MARKER, bodyStart);
59
95
  if (end < 0)
60
96
  break;
61
- const memory = parseBlock(content.slice(bodyStart, end).trim(), sourceArtifact);
62
- if (memory) {
63
- assertSafeMemory(memory);
64
- memories.push(memory);
97
+ locatedRanges.push([start, end + END_MARKER.length]);
98
+ const parsed = parseBlockResult(content.slice(bodyStart, end).trim(), sourceArtifact);
99
+ if (parsed.ok) {
100
+ assertSafeMemory(parsed.memory);
101
+ memories.push(parsed.memory);
102
+ }
103
+ else {
104
+ drops.push({ index: start, drop: { sourceArtifact, reason: parsed.reason, detail: parsed.detail } });
65
105
  }
66
106
  searchStart = end + END_MARKER.length;
67
107
  }
68
- return memories.sort((left, right) => slugify(left.title).localeCompare(slugify(right.title)));
108
+ // Near-miss markers. These are invisible to the loop above by construction —
109
+ // it navigates by exact `indexOf` — so before this pass an artifact written
110
+ // with, say, an attribute inside the marker reported `extractedCount: 0` with
111
+ // `warnings: []`, indistinguishable from an artifact that had no blocks at
112
+ // all. Nothing is extracted here: this pass only names what was not found.
113
+ //
114
+ // A near-miss INSIDE a located block's span is the block's own body text, not
115
+ // an attempted marker, so it is skipped rather than reported.
116
+ for (const match of content.matchAll(MARKER_SHAPED_COMMENT)) {
117
+ const found = match[0];
118
+ if (found === START_MARKER || found === END_MARKER)
119
+ continue;
120
+ const index = match.index ?? 0;
121
+ if (locatedRanges.some(([from, to]) => index >= from && index < to))
122
+ continue;
123
+ drops.push({ index, drop: { sourceArtifact, reason: 'unrecognized-marker', detail: describeUnrecognizedMarker(found) } });
124
+ }
125
+ drops.sort((left, right) => left.index - right.index);
126
+ return {
127
+ memories: memories.sort((left, right) => slugify(left.title).localeCompare(slugify(right.title))),
128
+ dropped: drops.map((entry) => entry.drop)
129
+ };
130
+ }
131
+ export function extractStableProjectMemories(content, sourceArtifact) {
132
+ return extractStableProjectMemoriesWithDiagnostics(content, sourceArtifact).memories;
133
+ }
134
+ /**
135
+ * Render one warning line per rejected block, for the CLI envelope's
136
+ * `warnings` channel. `unknown-kind` additionally names the accepted
137
+ * vocabulary, because that is the failure whose remedy is a value change.
138
+ */
139
+ export function describeMemoryBlockDrops(dropped) {
140
+ return dropped.map((drop) => {
141
+ const hint = drop.reason === 'unknown-kind'
142
+ ? ` Accepted kinds: ${[...VALID_MEMORY_KINDS].join(', ')}.`
143
+ : '';
144
+ return `Skipped a memory block in ${drop.sourceArtifact}: ${drop.detail}.${hint}`;
145
+ });
146
+ }
147
+ /**
148
+ * Render one warning line per session artifact that could not be read. The
149
+ * sibling of `describeMemoryBlockDrops`, for the failure one level coarser: a
150
+ * whole file that never yielded blocks at all.
151
+ */
152
+ export function describeSessionScanFailures(failures) {
153
+ return failures.map((failure) => `Could not read the session artifact ${failure.file}: ${failure.detail}.`);
69
154
  }
70
155
  export function summarizeExtractResult(result) {
71
156
  return {
@@ -35,6 +35,50 @@ export type ExtractedProjectMemory = {
35
35
  body: string;
36
36
  sourceArtifact: string;
37
37
  };
38
+ /**
39
+ * Why a `<!-- peaks-memory:start -->` block that was FOUND was not extracted.
40
+ *
41
+ * One value per precondition in `parseBlockResult` (the extract path's block
42
+ * parser), in evaluation order. Before this existed, every one of these
43
+ * conditions collapsed into a bare `null` and the caller reported nothing, so
44
+ * `extractedCount: N` was indistinguishable from "the block was never there".
45
+ * Naming the cause is the whole point: a memory block that disappears must say
46
+ * why it disappeared.
47
+ *
48
+ * Diagnostics only — this type does not change WHICH blocks are accepted.
49
+ */
50
+ export type MemoryBlockDropReason = 'missing-separator' | 'missing-title' | 'missing-kind' | 'unknown-kind' | 'empty-body'
51
+ /**
52
+ * NOT a `parseBlockResult` precondition — this one comes from the scanner.
53
+ * A marker-shaped comment that is not the exact literal the locator searches
54
+ * for. The block it opens is therefore never found: it is not "rejected",
55
+ * it is invisible. Reporting it is the whole point, because nothing else in
56
+ * the pipeline can see it.
57
+ */
58
+ | 'unrecognized-marker';
59
+ /** A found-but-not-extracted memory block, with the precondition that failed. */
60
+ export type MemoryBlockDrop = {
61
+ /** Artifact path the block was found in (project-relative when extracted). */
62
+ sourceArtifact: string;
63
+ reason: MemoryBlockDropReason;
64
+ /** Human-readable explanation of the failed precondition. */
65
+ detail: string;
66
+ };
67
+ /**
68
+ * Result of parsing one memory block, as a discriminated union.
69
+ *
70
+ * `parseBlock` is the `null`-on-failure projection of this; both are produced
71
+ * by the same single implementation so they cannot disagree about which blocks
72
+ * are accepted.
73
+ */
74
+ export type MemoryBlockParse = {
75
+ ok: true;
76
+ memory: ExtractedProjectMemory;
77
+ } | {
78
+ ok: false;
79
+ reason: MemoryBlockDropReason;
80
+ detail: string;
81
+ };
38
82
  export type ProjectMemoryWrite = {
39
83
  memory: ExtractedProjectMemory;
40
84
  filePath: string;
@@ -47,6 +91,12 @@ export type ProjectMemoryExtractPlan = {
47
91
  backupPolicy: 'project-memory-primary-artifact-backup';
48
92
  extractedMemories: ExtractedProjectMemory[];
49
93
  plannedWrites: ProjectMemoryWrite[];
94
+ /**
95
+ * Blocks that were found between the markers but rejected by the parser.
96
+ * Reported to the user through the CLI envelope's `warnings` channel.
97
+ * Purely informational — this array does not influence extraction.
98
+ */
99
+ droppedBlocks: MemoryBlockDrop[];
50
100
  };
51
101
  export type ProjectMemoryExtractResult = ProjectMemoryExtractPlan & {
52
102
  writtenFiles: string[];
@@ -123,6 +173,22 @@ export type ExtractSessionMemoriesOptions = {
123
173
  sessionId: string;
124
174
  apply?: boolean;
125
175
  };
176
+ /**
177
+ * A session artifact that could not be read at all, so its blocks were never
178
+ * even candidates.
179
+ *
180
+ * Distinct from `MemoryBlockDrop`: that one describes a BLOCK that was found and
181
+ * rejected, and needs the artifact to have been readable in the first place.
182
+ * Folding a whole-file read failure into it would make `sourceArtifact` mean two
183
+ * different things, so this rides its own field and is rendered by its own
184
+ * `describeSessionScanFailures`.
185
+ */
186
+ export type SessionScanFailure = {
187
+ /** Project-relative path of the artifact that could not be read. */
188
+ file: string;
189
+ /** The underlying error message. Never invented; taken from the throw. */
190
+ detail: string;
191
+ };
126
192
  export type ExtractSessionMemoriesResult = {
127
193
  apply: boolean;
128
194
  projectRoot: string;
@@ -133,6 +199,26 @@ export type ExtractSessionMemoriesResult = {
133
199
  extractedCount: number;
134
200
  writtenFiles: string[];
135
201
  updatedIndex: boolean;
202
+ /**
203
+ * Blocks that were FOUND between the markers in this session's artifacts but
204
+ * rejected by the parser. Same diagnostic contract as
205
+ * `ProjectMemoryExtractPlan.droppedBlocks` — the sibling `peaks memory
206
+ * extract` path has carried this since M2, and this one silently dropped
207
+ * them, so `extractedCount: 1` out of three blocks was indistinguishable
208
+ * from "there was one block".
209
+ *
210
+ * Purely informational: this array does not influence which blocks are
211
+ * extracted, and it adds no field to the CLI's `data` payload — the reasons
212
+ * ride the existing envelope `warnings` channel.
213
+ */
214
+ droppedBlocks: MemoryBlockDrop[];
215
+ /**
216
+ * Session artifacts that could not be read, so their blocks were never
217
+ * candidates. Previously swallowed by a bare `catch {}`; reported now through
218
+ * the same CLI `warnings` channel. Diagnostic only — an unreadable artifact
219
+ * is still not an error, and the scan is otherwise unchanged.
220
+ */
221
+ scanFailures: SessionScanFailure[];
136
222
  };
137
223
  export type ProjectMemoryShowResult = {
138
224
  projectRoot: string;
@@ -0,0 +1 @@
1
+ export declare function readHandoffGateEvidence(filePath: string): Promise<readonly string[] | null>;
@@ -0,0 +1,77 @@
1
+ // src/services/prd/handoff-gate-evidence.ts
2
+ //
3
+ // AC-5 of slice 2026-09-17-4-0-51-cleanup: the handoff's frontmatter
4
+ // carries a `gateEvidence: string[]` field naming the gate names whose
5
+ // `gateEvidence` is asserted by this handoff. Pre-S2, the field was
6
+ // written into the frontmatter (by `initHandoff`) but NO consumer in
7
+ // `src/` ever READ it back — the field was prose, not data.
8
+ //
9
+ // This module is the SOLE reader for that field. It is a stand-alone
10
+ // reader (not a member of `handoff-service.ts`) so that the
11
+ // `HandoffFrontmatter` type stays a pure-data shape and this consumer
12
+ // can be added independently without disturbing every existing
13
+ // parse-and-validate path.
14
+ //
15
+ // Returns:
16
+ // - `string[]` — the gate names declared in the frontmatter
17
+ // - `null` — the file is missing, the frontmatter is malformed,
18
+ // the field is absent, or the field is not an array
19
+ // of strings. Callers MUST treat null as "no claim"
20
+ // and not as a failure.
21
+ //
22
+ // The reader is intentionally permissive (returns null instead of
23
+ // throwing) because a malformed frontmatter is the caller's signal to
24
+ // surface the failure to the operator, not the reader's signal to
25
+ // throw inside the gate pipeline.
26
+ import { existsSync } from 'node:fs';
27
+ import { readFile } from 'node:fs/promises';
28
+ import { parse as parseYaml } from 'yaml';
29
+ function parseGateEvidenceFrontmatter(raw) {
30
+ // Frontmatter is the `---`-fenced YAML block at the top of the file.
31
+ const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---/);
32
+ if (!match)
33
+ return { gateEvidence: null };
34
+ const yamlBody = match[1] ?? '';
35
+ let parsed;
36
+ try {
37
+ parsed = parseYaml(yamlBody);
38
+ }
39
+ catch (_error) {
40
+ // reader is permissive by design: malformed YAML → no claim (not throw)
41
+ void _error;
42
+ return { gateEvidence: null };
43
+ }
44
+ if (parsed === null || typeof parsed !== 'object')
45
+ return { gateEvidence: null };
46
+ const gateEvidence = parsed['gateEvidence'];
47
+ if (!Array.isArray(gateEvidence))
48
+ return { gateEvidence: null };
49
+ return { gateEvidence };
50
+ }
51
+ export async function readHandoffGateEvidence(filePath) {
52
+ if (!existsSync(filePath))
53
+ return null;
54
+ let raw;
55
+ try {
56
+ raw = await readFile(filePath, 'utf8');
57
+ }
58
+ catch (_error) {
59
+ // reader is permissive by design: missing file → no claim (not throw)
60
+ void _error;
61
+ return null;
62
+ }
63
+ try {
64
+ const { gateEvidence } = parseGateEvidenceFrontmatter(raw);
65
+ if (gateEvidence === null)
66
+ return null;
67
+ // Filter to strings only — a non-string element (number/boolean/
68
+ // null) is treated as "not a gate name" rather than as an error.
69
+ const strings = gateEvidence.filter((item) => typeof item === 'string');
70
+ return strings.length > 0 ? strings : null;
71
+ }
72
+ catch (_error) {
73
+ // reader is permissive by design: unexpected parse failure → no claim
74
+ void _error;
75
+ return null;
76
+ }
77
+ }
@@ -69,7 +69,7 @@ export type SliceCheckResult = {
69
69
  };
70
70
  export type SliceCheckOptions = {
71
71
  projectRoot: string;
72
- /** When omitted, slice check inspects `.peaks/_runtime/current-change` to find the active rid. */
72
+ /** REQUIRED. The `.peaks/_runtime/current-change` binding file is gone (slice 2026-06-29-change-id-root-removal); when omitted, slice check throws rather than guessing. */
73
73
  rid?: string;
74
74
  /**
75
75
  * When true, re-run the 3-way review fan-out (peaks-rd's code-review +
@@ -0,0 +1,91 @@
1
+ /**
2
+ * The canonical top-level layout of `.peaks/_runtime/`.
3
+ *
4
+ * WHY THIS MODULE EXISTS
5
+ *
6
+ * `.peaks/_runtime/` holds two populations whose names look identical:
7
+ *
8
+ * 1. SESSION DIRS — `<YYYY-MM-DD>-session-<hex>`, one per peaks session.
9
+ * Anything else that *looks* like a session id but is not one is a
10
+ * defect (a test that passed `--session-id x`, a typo, an old
11
+ * `unknown-sid` bucket).
12
+ * 2. SYSTEM ENTRIES — dirs and files the code itself writes there on
13
+ * purpose: `callers/`, `change/`, `benchmarks/`, `session.json`, …
14
+ *
15
+ * The doctor check `L3:l3-orphan-sessions` must flag (1) and tolerate (2),
16
+ * and the only thing that can tell them apart is a list of the system
17
+ * entries. That list used to live inside the check as a hand-maintained
18
+ * `new Set(['change'])` — and it had already drifted: `callers/` is a
19
+ * designed location (`caller-binding-service.ts` stores
20
+ * `.peaks/_runtime/callers/<callerId>.json`), was never added, and made
21
+ * `peaks doctor` exit non-zero on a clean workspace *permanently*
22
+ * (`4 orphan session(s) …: callers, cli, unknown-sid, x`). The check's own
23
+ * doc comment asked future maintainers to keep a *second* list — the prose
24
+ * `RUNTIME_SYSTEM_SUBDIRS_DOC` — in sync by hand. Prose cannot enforce
25
+ * itself, so the two lists diverged exactly as you would expect.
26
+ *
27
+ * WHY DERIVATION WAS REJECTED AND A REGISTRY IS THE SOURCE OF TRUTH
28
+ *
29
+ * A derivation would have to classify a bare name as "system" or "bogus"
30
+ * without a list. There is no signal to derive from: `callers` (system) and
31
+ * `x` (bogus) are the same shape — a lowercase word. The session-id regex
32
+ * separates them from `2026-09-16-session-5bcf09`, not from each other. So
33
+ * the alternatives were (a) keep a literal in the check, or (b) put the
34
+ * literal *and its consumers* in one place. This module is (b): the check
35
+ * imports the set instead of re-declaring it, and
36
+ * `tests/unit/workspace/runtime-layout-drift-guard.test.ts` parses `src/**`
37
+ * and fails when the code writes a `_runtime` child that is not registered
38
+ * here. The registry can no longer drift silently — it can only drift loudly,
39
+ * as a red test.
40
+ *
41
+ * The guard parses ASTs rather than grepping, because this repository
42
+ * documents the layout in prose constantly: `migrate-1-4-1-service.ts` has an
43
+ * array whose two adjacent string literals are `'_runtime', '_sub_agents'`
44
+ * (they are two *separate* SKIP entries), and `evidence-generator.ts`
45
+ * describes a past escape as `` `.peaks/_runtime/pwned.md` `` inside a
46
+ * comment. Both are false positives for a text scan and invisible to an AST
47
+ * walk. See `tests/unit/runtime/no-runtime-input-guard.test.ts` for the same
48
+ * decision made the same way.
49
+ */
50
+ /**
51
+ * `dir` entries are scanned by `L3:l3-orphan-sessions` and must not be
52
+ * flagged. `file` entries are never flagged by that check — it only reads
53
+ * directories — and are registered so the drift guard can classify them and
54
+ * so this module stays an honest census of the tree rather than half of one.
55
+ */
56
+ export type RuntimeEntryKind = 'dir' | 'file';
57
+ export interface RuntimeSystemEntry {
58
+ /** The exact top-level name under `.peaks/_runtime/`. */
59
+ readonly name: string;
60
+ readonly kind: RuntimeEntryKind;
61
+ /** What writes it and why it is legitimate. */
62
+ readonly purpose: string;
63
+ }
64
+ /**
65
+ * Reserved top-level dir for session-less command output.
66
+ *
67
+ * `peaks baseline audit` runs with no bound session (it is the only scorer
68
+ * that can run inside the secretless OIDC publish gate), so it has no
69
+ * `<sid>` to write under. It used to pass the literal `'cli'` as its
70
+ * sessionId, which made `.peaks/_runtime/cli/capability-audit/*.json` look
71
+ * exactly like a session dir that failed validation — `peaks doctor` has
72
+ * been red on this repository ever since. The name is underscore-prefixed to
73
+ * match the tree's existing convention for "machinery, not a session"
74
+ * (`_runtime`, `_sub_agents`) and to make it impossible to mistake for a
75
+ * session id.
76
+ */
77
+ export declare const RUNTIME_SESSIONLESS_SCOPE = "_audit";
78
+ /**
79
+ * Every legitimate top-level entry under `.peaks/_runtime/`.
80
+ *
81
+ * This is the list `L3:l3-orphan-sessions` tolerates. Adding an entry that
82
+ * nothing writes is a red test (the drift guard's reverse direction); writing
83
+ * an entry that is not here is also a red test. Both directions are pinned in
84
+ * `tests/unit/workspace/runtime-layout-drift-guard.test.ts`.
85
+ */
86
+ export declare const RUNTIME_SYSTEM_ENTRIES: readonly RuntimeSystemEntry[];
87
+ /**
88
+ * The set `L3:l3-orphan-sessions` filters with. Derived from the registry
89
+ * above so the check and the census cannot disagree about the same name.
90
+ */
91
+ export declare const RUNTIME_SYSTEM_SUBDIRS: ReadonlySet<string>;
@@ -0,0 +1,148 @@
1
+ /**
2
+ * The canonical top-level layout of `.peaks/_runtime/`.
3
+ *
4
+ * WHY THIS MODULE EXISTS
5
+ *
6
+ * `.peaks/_runtime/` holds two populations whose names look identical:
7
+ *
8
+ * 1. SESSION DIRS — `<YYYY-MM-DD>-session-<hex>`, one per peaks session.
9
+ * Anything else that *looks* like a session id but is not one is a
10
+ * defect (a test that passed `--session-id x`, a typo, an old
11
+ * `unknown-sid` bucket).
12
+ * 2. SYSTEM ENTRIES — dirs and files the code itself writes there on
13
+ * purpose: `callers/`, `change/`, `benchmarks/`, `session.json`, …
14
+ *
15
+ * The doctor check `L3:l3-orphan-sessions` must flag (1) and tolerate (2),
16
+ * and the only thing that can tell them apart is a list of the system
17
+ * entries. That list used to live inside the check as a hand-maintained
18
+ * `new Set(['change'])` — and it had already drifted: `callers/` is a
19
+ * designed location (`caller-binding-service.ts` stores
20
+ * `.peaks/_runtime/callers/<callerId>.json`), was never added, and made
21
+ * `peaks doctor` exit non-zero on a clean workspace *permanently*
22
+ * (`4 orphan session(s) …: callers, cli, unknown-sid, x`). The check's own
23
+ * doc comment asked future maintainers to keep a *second* list — the prose
24
+ * `RUNTIME_SYSTEM_SUBDIRS_DOC` — in sync by hand. Prose cannot enforce
25
+ * itself, so the two lists diverged exactly as you would expect.
26
+ *
27
+ * WHY DERIVATION WAS REJECTED AND A REGISTRY IS THE SOURCE OF TRUTH
28
+ *
29
+ * A derivation would have to classify a bare name as "system" or "bogus"
30
+ * without a list. There is no signal to derive from: `callers` (system) and
31
+ * `x` (bogus) are the same shape — a lowercase word. The session-id regex
32
+ * separates them from `2026-09-16-session-5bcf09`, not from each other. So
33
+ * the alternatives were (a) keep a literal in the check, or (b) put the
34
+ * literal *and its consumers* in one place. This module is (b): the check
35
+ * imports the set instead of re-declaring it, and
36
+ * `tests/unit/workspace/runtime-layout-drift-guard.test.ts` parses `src/**`
37
+ * and fails when the code writes a `_runtime` child that is not registered
38
+ * here. The registry can no longer drift silently — it can only drift loudly,
39
+ * as a red test.
40
+ *
41
+ * The guard parses ASTs rather than grepping, because this repository
42
+ * documents the layout in prose constantly: `migrate-1-4-1-service.ts` has an
43
+ * array whose two adjacent string literals are `'_runtime', '_sub_agents'`
44
+ * (they are two *separate* SKIP entries), and `evidence-generator.ts`
45
+ * describes a past escape as `` `.peaks/_runtime/pwned.md` `` inside a
46
+ * comment. Both are false positives for a text scan and invisible to an AST
47
+ * walk. See `tests/unit/runtime/no-runtime-input-guard.test.ts` for the same
48
+ * decision made the same way.
49
+ */
50
+ /**
51
+ * Reserved top-level dir for session-less command output.
52
+ *
53
+ * `peaks baseline audit` runs with no bound session (it is the only scorer
54
+ * that can run inside the secretless OIDC publish gate), so it has no
55
+ * `<sid>` to write under. It used to pass the literal `'cli'` as its
56
+ * sessionId, which made `.peaks/_runtime/cli/capability-audit/*.json` look
57
+ * exactly like a session dir that failed validation — `peaks doctor` has
58
+ * been red on this repository ever since. The name is underscore-prefixed to
59
+ * match the tree's existing convention for "machinery, not a session"
60
+ * (`_runtime`, `_sub_agents`) and to make it impossible to mistake for a
61
+ * session id.
62
+ */
63
+ export const RUNTIME_SESSIONLESS_SCOPE = '_audit';
64
+ /**
65
+ * Every legitimate top-level entry under `.peaks/_runtime/`.
66
+ *
67
+ * This is the list `L3:l3-orphan-sessions` tolerates. Adding an entry that
68
+ * nothing writes is a red test (the drift guard's reverse direction); writing
69
+ * an entry that is not here is also a red test. Both directions are pinned in
70
+ * `tests/unit/workspace/runtime-layout-drift-guard.test.ts`.
71
+ */
72
+ export const RUNTIME_SYSTEM_ENTRIES = [
73
+ {
74
+ name: 'change',
75
+ kind: 'dir',
76
+ purpose: 'change-id routing root for reviewable artifacts (workflow/artifact-paths.ts, prd/prd-blocks-checker.ts, workspace/reconcile-service.ts)'
77
+ },
78
+ {
79
+ name: 'callers',
80
+ kind: 'dir',
81
+ purpose: 'per-caller binding files `.peaks/_runtime/callers/<callerId>.json` (session/caller-binding-service.ts)'
82
+ },
83
+ {
84
+ name: 'benchmarks',
85
+ kind: 'dir',
86
+ purpose: '`peaks slice benchmark` artifacts `<rid>.benchmark.json` (cli/commands/slice-commands.ts)'
87
+ },
88
+ {
89
+ name: 'prd',
90
+ kind: 'dir',
91
+ purpose: 'read-only PRD artifact root `.peaks/_runtime/prd/requests/<rid>.md` (prd/prd-blocks-checker.ts); the writer lives outside this repo, the check tolerates it'
92
+ },
93
+ {
94
+ name: 'playwright-userdata',
95
+ kind: 'dir',
96
+ purpose: 'per-terminal browser profile dirs (cli/commands/playwright-commands.ts)'
97
+ },
98
+ {
99
+ name: 'playwright-sessions',
100
+ kind: 'dir',
101
+ purpose: 'playwright session records `<terminalId>.json` (cli/commands/playwright-commands.ts)'
102
+ },
103
+ {
104
+ name: 'test-cache',
105
+ kind: 'dir',
106
+ purpose: 'per-test fingerprint cache (cli/commands/test-commands.ts)'
107
+ },
108
+ {
109
+ name: 'sop-state',
110
+ kind: 'dir',
111
+ purpose: 'SOP state migrated from `.peaks/sop-state/` (cli/commands/workspace/reconcile-command.ts)'
112
+ },
113
+ {
114
+ name: RUNTIME_SESSIONLESS_SCOPE,
115
+ kind: 'dir',
116
+ purpose: 'capability-audit output for session-less runs `.peaks/_runtime/_audit/capability-audit/*.json` (cli/commands/baseline-commands.ts)'
117
+ },
118
+ {
119
+ name: 'session.json',
120
+ kind: 'file',
121
+ purpose: 'project-global session binding (session/session-binding-service.ts)'
122
+ },
123
+ {
124
+ name: 'active-skill.json',
125
+ kind: 'file',
126
+ purpose: 'active-skill presence marker (skills/skill-presence-service.ts)'
127
+ },
128
+ {
129
+ name: 'generated-artifacts.json',
130
+ kind: 'file',
131
+ purpose: 'stamp describing which generated artifacts exist on this machine, so a refresh can tell "stale" from "never generated" (services/workspace/generated-artifacts-stamp.ts)'
132
+ },
133
+ {
134
+ name: '.outer-session-cache.json',
135
+ kind: 'file',
136
+ purpose: 'outer (IDE) session cache for CLI sub-processes (cli/commands/outer-cache-commands.ts, session/session-binding-bridge.ts)'
137
+ },
138
+ {
139
+ name: '.rebuild-binding.lock',
140
+ kind: 'file',
141
+ purpose: 'binding-store rebuild lock (session/binding-store.ts)'
142
+ }
143
+ ];
144
+ /**
145
+ * The set `L3:l3-orphan-sessions` filters with. Derived from the registry
146
+ * above so the check and the census cannot disagree about the same name.
147
+ */
148
+ export const RUNTIME_SYSTEM_SUBDIRS = new Set(RUNTIME_SYSTEM_ENTRIES.filter((entry) => entry.kind === 'dir').map((entry) => entry.name));
@@ -163,6 +163,20 @@ const PEAKS_GITIGNORE_SNIPPET = [
163
163
  '# Both patterns below are PROJECT-ROOT-relative, so this snippet must land',
164
164
  '# in the root .gitignore — see `upsertPeaksGitignoreSnippet`.',
165
165
  '.peaks/.claude-settings-template.json',
166
+ '# Codegraph rollback copy (A3, 2026-09-17). Every `peaks codegraph init` /',
167
+ '# `repair-exclude` / `repair-index` / pre-dispatch preflight / post-slice',
168
+ '# auto-refresh copies `.codegraph/config.json` here before rewriting it, so',
169
+ '# this file is LOCAL CHURN: a committed one is silently replaced by the next',
170
+ '# repair. Upstream\'s own `.codegraph/.gitignore` does not cover it (it names',
171
+ '# *.db / *.db-wal / *.db-shm, cache/, *.log and .dirty only) and `.codegraph/`',
172
+ '# is NOT ignored wholesale downstream, so without this line a consumer project',
173
+ '# commits a file peaks-loop rewrites under it.',
174
+ '#',
175
+ '# ONLY the backup, never `config.json` itself: upstream deliberately keeps',
176
+ '# that config committable (it is a project\'s include/exclude policy, and',
177
+ '# committing it is how a team shares it), so ignoring it would hide a file',
178
+ '# people mean to commit. A rollback copy is the opposite kind of file.',
179
+ '.codegraph/config.json.bak',
166
180
  PEAKS_GITIGNORE_FOOTER,
167
181
  ''
168
182
  ].join('\n');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peaks-loop",
3
- "version": "4.0.51",
3
+ "version": "4.0.53",
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.36",
106
- "peaks-loop-mut": "0.1.49",
107
- "peaks-loop-shared": "0.0.85",
108
- "peaks-loop-shared-channel": "0.0.53"
105
+ "peaks-loop-shared": "0.0.87",
106
+ "peaks-loop-internal-runtime": "0.0.38",
107
+ "peaks-loop-mut": "0.1.51",
108
+ "peaks-loop-shared-channel": "0.0.55"
109
109
  },
110
110
  "devDependencies": {
111
111
  "@changesets/cli": "2.31.1",
@@ -128,7 +128,7 @@
128
128
  "vitest": "^4.1.10"
129
129
  },
130
130
  "scripts": {
131
- "build": "node ./scripts/sync-version.mjs && node ./scripts/clean-dist.mjs && pnpm -r --filter \"./packages/*\" run build && tsc -p tsconfig.build.json && node ./scripts/copy-templates.mjs && node ./scripts/check-build-integrity.mjs",
131
+ "build": "node ./scripts/sync-version.mjs && node ./scripts/clean-dist.mjs && pnpm -r --filter \"./packages/*\" run build && tsc -p tsconfig.build.json && node ./scripts/copy-templates.mjs && node ./scripts/check-build-integrity.mjs && node ./scripts/write-dist-stamp.mjs",
132
132
  "prepublish": "node ./scripts/sync-version.mjs",
133
133
  "postinstall": "node ./scripts/install-skills.mjs",
134
134
  "predev": "node ./scripts/sync-version.mjs",
@@ -10,9 +10,21 @@ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
10
10
  // packages/*/dist. Rationale: tsc's incremental-build cache
11
11
  // compares files by mtime + size, and a stale dist that
12
12
  // survived from a previous build can cause tsc to skip the
13
- // emission of newly-added src/*.ts (Bug-04 lineage — see
14
- // tests/unit/scripts/sync-version-invalidation.test.ts for
15
- // the regression pin on version.ts specifically).
13
+ // emission of newly-added src/*.ts (Bug-04 lineage).
14
+ //
15
+ // E2 (rid 2026-09-17-cli-output-and-stale-refs) — the citation that
16
+ // stood here named a regression pin on version.ts that was deleted
17
+ // in `f17aa377` ("delete 559 legacy unit tests"), and, like the one
18
+ // in `sync-version.mjs`, it was written without backticks so the
19
+ // citation-integrity guard could not see it. Corrected: the pin on
20
+ // the version.ts OUTPUT now lives in the lockstep tests (see
21
+ // `sync-version.mjs` for both names and for what they read).
22
+ //
23
+ // Recorded, not hidden: this wipe itself has no direct pin — no
24
+ // test executes this script, so nothing asserts that a stale
25
+ // packages/*/dist is gone before tsc runs. The `dist-freshness.mjs`
26
+ // module reasons FROM this wipe (it is why a dist mtime can be read
27
+ // as the build time) but does not test it.
16
28
  //
17
29
  // The wider wipe is safe because:
18
30
  // - watch.mjs only watches src/, schemas/, skills/ — it