peaks-loop 4.0.52 → 4.0.54

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 (46) hide show
  1. package/CHANGELOG.md +89 -2
  2. package/README-en.md +1 -1
  3. package/README.md +1 -1
  4. package/dist/cli/commands/codegraph-commands.js +164 -5
  5. package/dist/cli/commands/core/memory-command.js +35 -2
  6. package/dist/cli/commands/dispatch-commands.js +4 -2
  7. package/dist/cli/commands/prd-commands.js +15 -0
  8. package/dist/cli/commands/sub-agent-shared.d.ts +23 -0
  9. package/dist/cli/commands/sub-agent-shared.js +39 -0
  10. package/dist/services/artifacts/artifact-prerequisites.js +34 -0
  11. package/dist/services/audit/enforcer-liveness.js +1 -1
  12. package/dist/services/codegraph/codegraph-config-repair-writer.d.ts +44 -0
  13. package/dist/services/codegraph/codegraph-config-repair-writer.js +112 -13
  14. package/dist/services/codegraph/codegraph-exclude-repair.d.ts +23 -1
  15. package/dist/services/codegraph/codegraph-exclude-repair.js +6 -0
  16. package/dist/services/dispatch/sub-agent-dispatcher.d.ts +0 -1
  17. package/dist/services/dispatch/sub-agent-dispatcher.js +0 -3
  18. package/dist/services/evidence/evidence-generator.js +11 -3
  19. package/dist/services/memory/project-memory-service/index.d.ts +1 -1
  20. package/dist/services/memory/project-memory-service/index.js +1 -1
  21. package/dist/services/memory/project-memory-service/store/atomic-write.d.ts +59 -0
  22. package/dist/services/memory/project-memory-service/store/atomic-write.js +167 -7
  23. package/dist/services/prd/gate-evidence-derivation.d.ts +160 -0
  24. package/dist/services/prd/gate-evidence-derivation.js +270 -0
  25. package/dist/services/prd/handoff-auto-regen.js +13 -1
  26. package/dist/services/prd/handoff-frontmatter.d.ts +1 -1
  27. package/dist/services/prd/handoff-frontmatter.js +62 -0
  28. package/dist/services/prd/handoff-gate-evidence.d.ts +95 -0
  29. package/dist/services/prd/handoff-gate-evidence.js +145 -0
  30. package/dist/services/prd/handoff-service.d.ts +21 -1
  31. package/dist/services/prd/handoff-service.js +45 -2
  32. package/dist/services/prd/handoff-types.d.ts +40 -0
  33. package/dist/services/prd/handoff-types.js +28 -1
  34. package/dist/services/prd/project-scan-reader.d.ts +7 -0
  35. package/dist/services/prd/project-scan-reader.js +7 -1
  36. package/dist/services/rd/reviewer-dispatch-policy.d.ts +36 -8
  37. package/dist/services/rd/reviewer-dispatch-policy.js +36 -8
  38. package/package.json +6 -6
  39. package/skills/bee/peaks-rd/SKILL.md +1 -1
  40. package/skills/bee/peaks-rd/references/rd-fanout-contracts.md +26 -10
  41. package/skills/bee/peaks-rd/references/rd-sub-agent-dispatch.md +1 -1
  42. package/skills/bee/peaks-rd/references/writing-handoff-frontmatter.md +6 -1
  43. package/skills/peaks-code/references/periodic-checkpoint.md +7 -5
  44. package/skills/peaks-code/references/startup-sequence.md +1 -1
  45. package/skills/peaks-final-review/SKILL.md +1 -1
  46. /package/{docs → contracts}/test-style-contract.md +0 -0
@@ -501,6 +501,40 @@ export async function checkPrerequisites(options) {
501
501
  }
502
502
  }
503
503
  }
504
+ // GATE C, second half (B2, rid `rid-b2-gate-evidence-wiring`): the capsule's
505
+ // own `gateEvidence` DECLARATION must be true — every path it names must
506
+ // exist. The table loop above owns "which artifacts this type must produce";
507
+ // this owns "the handoff may not claim evidence it does not have". They are
508
+ // complementary, and neither restates the other's paths: the declaration is
509
+ // derived from THE SAME table (`gate-evidence-derivation.ts`), so a row
510
+ // changed above changes the declared path below without a second edit.
511
+ //
512
+ // Guarded on `rd:qa-handoff` because that IS Gate C (the doc's gate table is
513
+ // keyed on it), and placed after the loop so it inherits the same
514
+ // `missing` / `warnings` / `ok` arithmetic the caller already throws on —
515
+ // `--allow-incomplete` and the `bypassedPrerequisites` report keep working
516
+ // with no second bypass path to keep in sync.
517
+ //
518
+ // It is skipped for types whose table has no `rd:qa-handoff` row (docs /
519
+ // chore) — the early return above means those types require no evidence at
520
+ // all, so there is no evidence contract to check. The `projectScan` they do
521
+ // carry is Gate A's, and Gate A is the check that owns it.
522
+ //
523
+ // The import is dynamic because `gate-evidence-derivation.ts` reaches
524
+ // `handoff-service.ts`, which imports `REQUEST_ID_PATTERN` from
525
+ // `request-artifact-service.ts`, which imports this module: a static edge
526
+ // here would close a runtime import cycle. Same idiom, same reason, as
527
+ // `request-commands.ts`'s lazy producer imports.
528
+ if (options.role === 'rd' && options.newState === 'qa-handoff') {
529
+ const { checkDeclaredGateEvidence } = await import('../prd/gate-evidence-derivation.js');
530
+ const declaredEvidence = await checkDeclaredGateEvidence({
531
+ projectRoot: options.projectRoot,
532
+ sessionId: options.sessionId,
533
+ requestId: options.requestId
534
+ });
535
+ missing.push(...declaredEvidence.missing);
536
+ warnings.push(...declaredEvidence.warnings);
537
+ }
504
538
  const result = { ok: missing.length === 0, missing, warnings };
505
539
  emitPrereqTransitionEvent({
506
540
  projectRoot: options.projectRoot,
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Enforcer liveness — A9 of `docs/diagnosis-2026-09-15-peaks-loop-state.md`.
2
+ * Enforcer liveness — A9 of `.peaks/docs/diagnosis-2026-09-15-peaks-loop-state.md`.
3
3
  *
4
4
  * `cli-backed` used to mean "the `enforcerRef` path exists on disk"
5
5
  * (`backing-detector.ts`). It did not mean "the enforcer runs". Ten catalog
@@ -86,3 +86,47 @@ export type CodegraphConfigRepairOutcome = {
86
86
  * anything is written, so a throw can never half-apply.
87
87
  */
88
88
  export declare function applyCodegraphConfigRepair(projectRoot: string, repair: CodegraphConfigRepairPlan): CodegraphConfigRepairOutcome;
89
+ export type CodegraphConfigRollbackResult = {
90
+ readonly rolledBack: true;
91
+ readonly configPath: string;
92
+ readonly backupPath: string;
93
+ } | {
94
+ readonly rolledBack: false;
95
+ readonly configPath: string;
96
+ readonly backupPath: string;
97
+ /** Why the rollback did not happen. Never `null` on this arm. */
98
+ readonly error: string;
99
+ };
100
+ /**
101
+ * Put `configPath` back to the bytes `writeConfigBackup` saved beside it, and
102
+ * back to the mode those bytes were saved with.
103
+ *
104
+ * This is the reverse of `applyCodegraphConfigRepair`'s write half and the only
105
+ * reason the `.bak` exists: until this function, the backup had no reader
106
+ * anywhere in the repository, so "byte-exact rollback" described a file nobody
107
+ * ever restored.
108
+ *
109
+ * The guard is `linkOrDirectoryAt` — the SAME predicate that refuses to WRITE
110
+ * through a link at the backup path refuses to READ through one here. The
111
+ * attack it closes is the mirror image of the writer's: `config.json.bak` is
112
+ * fixed and committable, so a repository that ships it as a link would
113
+ * otherwise have the link's target's bytes published into `.codegraph/config.json`
114
+ * as if they were the pre-repair config — attacker-chosen content, written
115
+ * under the operator's own config path, from a file the operator never
116
+ * reviewed. A refusal is a refusal and not a repair, at both ends.
117
+ *
118
+ * The restore runs through `writeConfigAtomic` (same-directory CSPRNG temp +
119
+ * `renameSync`) for the same reason the backup write does: a crash mid-write
120
+ * must leave the repaired config intact rather than a prefix of the restored
121
+ * one, and the mode is applied to the TEMP before the rename so the published
122
+ * file never exists at the process umask. The mode comes from `statSync` of the
123
+ * backup, symmetrical with the write end: the copy carries the original
124
+ * config's mode, so handing that mode back to the config restores what the
125
+ * project had granted rather than this process's umask.
126
+ *
127
+ * A refusal, or a backup that cannot be read, is RETURNED rather than thrown:
128
+ * the caller reports it as a field and must keep going — the forced rebuild it
129
+ * was asked for still has to run. `writeConfigAtomic`'s own fs throw is left to
130
+ * propagate, and the caller catches that too.
131
+ */
132
+ export declare function rollbackCodegraphConfig(configPath: string): Promise<CodegraphConfigRollbackResult>;
@@ -3,7 +3,10 @@
3
3
  // The pure repair plans (`repairCodegraphExclude` for the exclude axis,
4
4
  // `repairCodegraphInclude` for the include axis) and the writer that applies
5
5
  // them to `<projectRoot>/.codegraph/config.json` in ONE atomic rewrite, plus
6
- // the byte-exact `config.json.bak` copy it keeps for rollback.
6
+ // the byte-exact `config.json.bak` copy it keeps for rollback and
7
+ // `rollbackCodegraphConfig`, that copy's reader (slice A1 of
8
+ // `2026-09-17-session-607ead`: the reverse write, so the rollback the `.bak`
9
+ // has always promised is reachable).
7
10
  //
8
11
  // Extracted verbatim from `codegraph-exclude-repair.ts` (rid
9
12
  // 2026-09-17-oversize-followup, G1 — the 800-line file-size cap). Every moved
@@ -142,6 +145,40 @@ function writeConfigAtomic(filePath, content, mode) {
142
145
  throw error;
143
146
  }
144
147
  }
148
+ /**
149
+ * The shared shape of the two refusals this module makes at the FIXED,
150
+ * therefore guessable, `config.json.bak` path: a symbolic link (bytes land in
151
+ * whatever it points at), a directory (there is nowhere for them to land), or
152
+ * a hard link (`nlink > 1` — another name shares this inode). Returns `null`
153
+ * when the path is absent or is a plain regular file, the only two states both
154
+ * ends of the rollback contract may act on.
155
+ *
156
+ * ONE predicate, two callers, and that is the point: `writeConfigBackup`
157
+ * WRITES this copy and `rollbackCodegraphConfig` READS it back. A guard that
158
+ * drifted between the two would leave the rollback trusting a path the writer
159
+ * would have refused, so the refusal is the same at both ends because the
160
+ * trust premise is the same at both ends.
161
+ *
162
+ * `lstat`, never `stat`: `stat` follows a link and reports the victim's
163
+ * regular-file type, which is exactly the case both callers must refuse.
164
+ */
165
+ function linkOrDirectoryAt(path) {
166
+ const existing = lstatSync(path, { throwIfNoEntry: false });
167
+ if (existing === undefined ||
168
+ (!existing.isSymbolicLink() && !existing.isDirectory() && existing.nlink <= 1)) {
169
+ return null;
170
+ }
171
+ return {
172
+ // "through" for a link (the bytes land in whatever it points at), "at" for
173
+ // a directory (there is nowhere for them to land).
174
+ preposition: existing.isDirectory() ? 'at' : 'through',
175
+ kind: existing.isSymbolicLink()
176
+ ? 'a symbolic link'
177
+ : existing.isDirectory()
178
+ ? 'a directory'
179
+ : `a hard link (link count ${String(existing.nlink)})`
180
+ };
181
+ }
145
182
  /**
146
183
  * Copy the config's ORIGINAL bytes to `config.json.bak`, refusing to write
147
184
  * through a link that already occupies that path.
@@ -207,18 +244,9 @@ function writeConfigAtomic(filePath, content, mode) {
207
244
  */
208
245
  function writeConfigBackup(configPath, originalText) {
209
246
  const backupPath = `${configPath}${CODEGRAPH_CONFIG_BACKUP_SUFFIX}`;
210
- const existing = lstatSync(backupPath, { throwIfNoEntry: false });
211
- if (existing !== undefined &&
212
- (existing.isSymbolicLink() || existing.isDirectory() || existing.nlink > 1)) {
213
- const kind = existing.isSymbolicLink()
214
- ? 'a symbolic link'
215
- : existing.isDirectory()
216
- ? 'a directory'
217
- : `a hard link (link count ${String(existing.nlink)})`;
218
- // "through" for a link (the bytes land in whatever it points at), "at" for
219
- // a directory (there is nowhere for them to land).
220
- const preposition = existing.isDirectory() ? 'at' : 'through';
221
- throw new Error(`codegraph config backup ${backupPath}: refusing to write ${preposition} ${kind} occupying ` +
247
+ const occupied = linkOrDirectoryAt(backupPath);
248
+ if (occupied !== null) {
249
+ throw new Error(`codegraph config backup ${backupPath}: refusing to write ${occupied.preposition} ${occupied.kind} occupying ` +
222
250
  'this path. Another file or directory shares it, so a backup written here would overwrite ' +
223
251
  'that. Remove it (or point `peaks` at a project root whose `.codegraph/` it owns) and re-run.');
224
252
  }
@@ -320,3 +348,74 @@ export function applyCodegraphConfigRepair(projectRoot, repair) {
320
348
  includeCountAfter: includePlan.include.length
321
349
  };
322
350
  }
351
+ // ─────────────────────────────────────────────────────────────────────
352
+ // Rollback — the read side of the `.bak`
353
+ // ─────────────────────────────────────────────────────────────────────
354
+ /** Local, because this module must not import `codegraph-exclude-repair.ts`. */
355
+ function errorMessage(error) {
356
+ return error instanceof Error ? error.message : String(error);
357
+ }
358
+ /**
359
+ * Put `configPath` back to the bytes `writeConfigBackup` saved beside it, and
360
+ * back to the mode those bytes were saved with.
361
+ *
362
+ * This is the reverse of `applyCodegraphConfigRepair`'s write half and the only
363
+ * reason the `.bak` exists: until this function, the backup had no reader
364
+ * anywhere in the repository, so "byte-exact rollback" described a file nobody
365
+ * ever restored.
366
+ *
367
+ * The guard is `linkOrDirectoryAt` — the SAME predicate that refuses to WRITE
368
+ * through a link at the backup path refuses to READ through one here. The
369
+ * attack it closes is the mirror image of the writer's: `config.json.bak` is
370
+ * fixed and committable, so a repository that ships it as a link would
371
+ * otherwise have the link's target's bytes published into `.codegraph/config.json`
372
+ * as if they were the pre-repair config — attacker-chosen content, written
373
+ * under the operator's own config path, from a file the operator never
374
+ * reviewed. A refusal is a refusal and not a repair, at both ends.
375
+ *
376
+ * The restore runs through `writeConfigAtomic` (same-directory CSPRNG temp +
377
+ * `renameSync`) for the same reason the backup write does: a crash mid-write
378
+ * must leave the repaired config intact rather than a prefix of the restored
379
+ * one, and the mode is applied to the TEMP before the rename so the published
380
+ * file never exists at the process umask. The mode comes from `statSync` of the
381
+ * backup, symmetrical with the write end: the copy carries the original
382
+ * config's mode, so handing that mode back to the config restores what the
383
+ * project had granted rather than this process's umask.
384
+ *
385
+ * A refusal, or a backup that cannot be read, is RETURNED rather than thrown:
386
+ * the caller reports it as a field and must keep going — the forced rebuild it
387
+ * was asked for still has to run. `writeConfigAtomic`'s own fs throw is left to
388
+ * propagate, and the caller catches that too.
389
+ */
390
+ export async function rollbackCodegraphConfig(configPath) {
391
+ const backupPath = `${configPath}${CODEGRAPH_CONFIG_BACKUP_SUFFIX}`;
392
+ const occupied = linkOrDirectoryAt(backupPath);
393
+ if (occupied !== null) {
394
+ return {
395
+ rolledBack: false,
396
+ configPath,
397
+ backupPath,
398
+ error: `codegraph config rollback ${backupPath}: refusing to restore ${occupied.preposition} ${occupied.kind} occupying ` +
399
+ 'this path. A backup this writer did not create is not a rollback point, and restoring through it ' +
400
+ 'would publish bytes nobody reviewed. Restore it by hand and re-run.'
401
+ };
402
+ }
403
+ let originalText;
404
+ let originalMode;
405
+ try {
406
+ originalText = readFileSync(backupPath, 'utf8');
407
+ // `& 0o777` drops the file-type bits `statSync` packs above the permission
408
+ // bits — `chmod` takes permission bits only.
409
+ originalMode = statSync(backupPath).mode & 0o777;
410
+ }
411
+ catch (error) {
412
+ return {
413
+ rolledBack: false,
414
+ configPath,
415
+ backupPath,
416
+ error: `codegraph config rollback: cannot read ${backupPath}: ${errorMessage(error)}`
417
+ };
418
+ }
419
+ writeConfigAtomic(configPath, originalText, originalMode);
420
+ return { rolledBack: true, configPath, backupPath };
421
+ }
@@ -64,7 +64,15 @@ export type CodegraphExcludeRepairReport = {
64
64
  * AND the reads failed (there is no config to name).
65
65
  */
66
66
  readonly configPath: string;
67
- /** Byte-exact rollback copy (null when nothing was written). */
67
+ /**
68
+ * Byte-exact rollback copy (null when nothing was written).
69
+ *
70
+ * It is a rollback POINT, not a rollback: nothing in this seam reads it back.
71
+ * Putting the config back is the separate, explicit
72
+ * `peaks codegraph config-restore` verb, so this field is what that verb
73
+ * would read — and its presence is the one guarantee that a restore is
74
+ * possible at all.
75
+ */
68
76
  readonly backupPath: string | null;
69
77
  /** True when the post-repair `codegraph index` finished successfully. */
70
78
  readonly reindexed: boolean;
@@ -127,6 +135,20 @@ export type CodegraphExcludeRepairOptions = {
127
135
  * C) exists so that upgrading peaks-loop cannot change a downstream
128
136
  * project's cost profile, and purging dead rows is the caller's explicit
129
137
  * request instead.
138
+ *
139
+ * `'force'` changes ONLY the rebuild, never the config: it is the same
140
+ * two-axis repair as `'exclude'`, followed by `cg.clear()` + `indexAll()`
141
+ * instead of an incremental index. The config repair STAYS — that is what
142
+ * makes the force mode worth its cost, because `include` has to be widened
143
+ * before a rebuild can admit the files the config was dropping.
144
+ *
145
+ * Not a self-cancelling run, and that is a design decision rather than a
146
+ * detail: a mode that wrote the repair and then restored the pre-repair bytes
147
+ * would leave the config exactly as it found it, `peaks codegraph status`
148
+ * would still report the gap, and exit 75 would never clear. Putting the
149
+ * config BACK is the explicit `peaks codegraph config-restore` verb, which
150
+ * reads the `.bak` this run leaves — an operator decision, not a side effect
151
+ * of asking for a rebuild.
130
152
  */
131
153
  readonly reindex?: boolean | 'force';
132
154
  };
@@ -64,6 +64,12 @@
64
64
  // consumer project (`config.json.bak` matches neither the peaks-loop
65
65
  // snippet nor upstream's own `.codegraph/.gitignore`), so both files
66
66
  // are committable.
67
+ // - `rollbackCodegraphConfig` is that copy's READER, and it is reached by the
68
+ // EXPLICIT `peaks codegraph config-restore` verb — never from here. A
69
+ // repair must not undo itself: this seam's job is to close the gap, so a
70
+ // run that restored its own write would leave the config exactly as it
71
+ // found it and `peaks codegraph status` still reporting the gap (exit 75
72
+ // never clearing). Rolling back is an operator decision, not a step.
67
73
  // - The DIRECTORY both paths live in is contained: `assertCodegraphDirContained`
68
74
  // refuses when `<projectRoot>/.codegraph` resolves (junction or symlink)
69
75
  // outside the canonical project root, so neither the read nor either
@@ -207,7 +207,6 @@ export declare function awaitClaudeCodeBatch(input: SubAgentAwaitBatchInput): Pr
207
207
  * `awaitBatch` is a real implementation.
208
208
  */
209
209
  export interface PollDispatchRecordsOptions {
210
- readonly ide: 'trae' | 'codex' | 'cursor';
211
210
  readonly defaultTimeoutMs: number;
212
211
  readonly notePrefix: string;
213
212
  }
@@ -101,7 +101,6 @@ export const traeSubAgentDispatcher = {
101
101
  // Trae-default heartbeat (30s). The fallback `awaitByLlm` marker is
102
102
  // gone — Trae now joins like claude-code.
103
103
  awaitBatch: async (input) => pollDispatchRecords(input, {
104
- ide: 'trae',
105
104
  defaultTimeoutMs: 30_000,
106
105
  notePrefix: 'trae 1.3 real awaitBatch'
107
106
  })
@@ -129,7 +128,6 @@ export const codexSubAgentDispatcher = {
129
128
  toolCallVersion: '2.0.0',
130
129
  }),
131
130
  awaitBatch: async (input) => pollDispatchRecords(input, {
132
- ide: 'codex',
133
131
  defaultTimeoutMs: 45_000,
134
132
  notePrefix: 'codex 1.3 real awaitBatch'
135
133
  })
@@ -155,7 +153,6 @@ export const cursorSubAgentDispatcher = {
155
153
  toolCallVersion: '2.0.0',
156
154
  }),
157
155
  awaitBatch: async (input) => pollDispatchRecords(input, {
158
- ide: 'cursor',
159
156
  defaultTimeoutMs: 30_000,
160
157
  notePrefix: 'cursor 1.3 real awaitBatch'
161
158
  })
@@ -31,6 +31,7 @@
31
31
  import { mkdir, readdir, writeFile } from 'node:fs/promises';
32
32
  import { join } from 'node:path';
33
33
  import { serializeHandoffFrontmatter } from '../prd/handoff-frontmatter.js';
34
+ import { deriveGateEvidenceForRequest } from '../prd/gate-evidence-derivation.js';
34
35
  import { handoffRelativePath, sha256OfBody } from '../prd/handoff-service.js';
35
36
  import { getSessionDir } from '../session/getSessionDir.js';
36
37
  import { REQUEST_ID_PATTERN } from '../artifacts/request-artifact-service.js';
@@ -260,7 +261,7 @@ function buildQaRequest(rid, sid, files) {
260
261
  * producer/consumer divergence rid `2026-09-14-handoff-writer-gate-divergence`
261
262
  * exists to remove, surviving in a function the same slice edited.
262
263
  */
263
- function buildHandoff(rid, sid, title, files, lineCounts) {
264
+ function buildHandoff(rid, sid, title, files, lineCounts, gateEvidence) {
264
265
  const body = `# PRD Handoff — ${rid}
265
266
 
266
267
  ${title}. Mechanical verbatim module split; behavior-preserving.
@@ -281,7 +282,13 @@ ${lineCountsMd(lineCounts)}
281
282
  goals: [],
282
283
  acceptanceCriteria: [],
283
284
  preservedBehavior: [],
284
- handoffPath: handoffRelativePath(sid, rid)
285
+ handoffPath: handoffRelativePath(sid, rid),
286
+ // B2 / F1: the THIRD frontmatter producer. B1 left this one untouched and
287
+ // QA found it, so a `peaks evidence generate` capsule and a
288
+ // `peaks prd handoff init` capsule for the same slice disagreed about the
289
+ // same contract. Passed in rather than derived here so this function stays
290
+ // synchronous and disk-free; the caller derives.
291
+ ...(gateEvidence === undefined ? {} : { gateEvidence })
285
292
  };
286
293
  return { content: `${serializeHandoffFrontmatter(frontmatter)}${body}`, hash: handoffHash };
287
294
  }
@@ -334,7 +341,8 @@ export async function generateEvidence(options) {
334
341
  createdDirectories.push(dir);
335
342
  }
336
343
  const qaRequestPath = await resolveQaRequestPath(qaDir, rid);
337
- const handoff = buildHandoff(rid, sessionId, title, files, lineCounts);
344
+ const gateEvidence = await deriveGateEvidenceForRequest({ projectRoot, sessionId, requestId: rid });
345
+ const handoff = buildHandoff(rid, sessionId, title, files, lineCounts, gateEvidence);
338
346
  // Four of these twelve paths carry the rid because the `rd:qa-handoff` gate
339
347
  // requires `<rid>` in them: every slice in a session shares `rd/` and
340
348
  // `audit/`, so writing the ridless name silently destroyed the previous
@@ -7,7 +7,7 @@ export type { ExtractedMemoryBlocks } from './parsers/markdown-pure.js';
7
7
  export { parseBlock, parseBlockResult, parseMemoryFrontmatter, parseStoredMemoryFile, renderMemoryFile, resolveMemoryKind, resolveMemoryName, slugify } from './parsers/frontmatter.js';
8
8
  export type { MemoryKindResolution, MemoryKindSource, MemoryNameResolution, MemoryNameSource, ParsedMemoryFrontmatter } from './parsers/frontmatter.js';
9
9
  export { assertInsideProject, assertSafeProjectMemoryDir, assertSafeSessionDir, normalizeRealRoot, normalizeRoot, realPathOrThrow, resolveProjectPath, safeRealpath } from './store/paths.js';
10
- export { assertSafeMemory, assertSafeMemoryFileContent, hasSensitiveMemoryContent, writeNewFile } from './store/atomic-write.js';
10
+ export { assertSafeMemory, assertSafeMemoryFileContent, findSensitiveMemoryTitleTerm, hasSensitiveMemoryContent, SENSITIVE_MEMORY_CHECKS, UnsafeMemoryError, writeNewFile } from './store/atomic-write.js';
11
11
  export { ensureMemoryBootstrap, emptyByKind, emptyIndex, listMarkdownFiles, readProjectMemories, readProjectMemoryBody } from './index/search.js';
12
12
  export { buildMemoryIndex, generateMemoryIndexFile, readExistingIndex, readMemoryFileMtime, readMemoryIndex, readStoredMemoryNames } from './index/ranking.js';
13
13
  export { executeMemoryReindex, renderMemoryMarkdown, KIND_ORDER, MEMORY_MD_BANNER, MEMORY_MD_FILENAME } from './index/reindex.js';
@@ -19,7 +19,7 @@ export { describeMemoryBlockDrops, describeSessionScanFailures, summarizeBackupR
19
19
  export { parseBlock, parseBlockResult, parseMemoryFrontmatter, parseStoredMemoryFile, renderMemoryFile, resolveMemoryKind, resolveMemoryName, slugify } from './parsers/frontmatter.js';
20
20
  // Store: path safety + sensitive content
21
21
  export { assertInsideProject, assertSafeProjectMemoryDir, assertSafeSessionDir, normalizeRealRoot, normalizeRoot, realPathOrThrow, resolveProjectPath, safeRealpath } from './store/paths.js';
22
- export { assertSafeMemory, assertSafeMemoryFileContent, hasSensitiveMemoryContent, writeNewFile } from './store/atomic-write.js';
22
+ export { assertSafeMemory, assertSafeMemoryFileContent, findSensitiveMemoryTitleTerm, hasSensitiveMemoryContent, SENSITIVE_MEMORY_CHECKS, UnsafeMemoryError, writeNewFile } from './store/atomic-write.js';
23
23
  // Index: search / ranking / dispatch
24
24
  export { ensureMemoryBootstrap, emptyByKind, emptyIndex, listMarkdownFiles, readProjectMemories, readProjectMemoryBody } from './index/search.js';
25
25
  export { buildMemoryIndex, generateMemoryIndexFile, readExistingIndex, readMemoryFileMtime, readMemoryIndex, readStoredMemoryNames } from './index/ranking.js';
@@ -1,5 +1,64 @@
1
1
  import type { ExtractedProjectMemory } from '../types.js';
2
+ /**
3
+ * The names of the three checks `assertSafeMemory` can fail on, written once.
4
+ *
5
+ * The name of the check that fired rides the refusal message, so the reader is
6
+ * told WHICH rule stopped the write instead of only that something was
7
+ * refused. Callers route their remedy on these same constants (see
8
+ * `memoryExtractNextActions` in `src/cli/commands/core/memory-command.ts`) —
9
+ * two strings kept equal by hand is how a message and its advice drift apart.
10
+ */
11
+ export declare const SENSITIVE_MEMORY_CHECKS: {
12
+ readonly metadataKey: "metadata key scan";
13
+ readonly content: "content scan";
14
+ readonly title: "title scan";
15
+ };
16
+ /**
17
+ * The credential term this title contains (as a run of words), or `null`.
18
+ *
19
+ * Returns the MATCHED TERM rather than a boolean because the refusal message
20
+ * names it: `title scan matched the credential term "apikey"` is actionable,
21
+ * while "Refusing to store sensitive memory content" is what sent slice C0's
22
+ * user looking for a secret that was not there. The returned string is a
23
+ * dictionary word from the list above — never the title's own text, and never
24
+ * a value — so echoing it into the error envelope cannot leak anything.
25
+ */
26
+ export declare function findSensitiveMemoryTitleTerm(title: string): string | null;
2
27
  export declare function hasSensitiveMemoryContent(content: string): boolean;
28
+ /**
29
+ * The refusal `assertSafeMemory` raises: the check that fired and the term it
30
+ * matched travel as DATA, not only as prose.
31
+ *
32
+ * WHY A TYPE RATHER THAN A MESSAGE. The envelope redactor behind every
33
+ * `fail()` (`packages/peaks-loop-shared/src/result.ts`) strips the words
34
+ * secret / token / password / api-key out of every failure MESSAGE — a blanket
35
+ * rule, applied to messages this module does not own. So a message that names
36
+ * the matched term reaches the CLI reading `… the credential term
37
+ * "[redacted]" …`: the term is named, and then removed, on the way out.
38
+ *
39
+ * The term is a word from `SENSITIVE_PROSE_TERMS` — a fixed vocabulary, never a
40
+ * value — so there is nothing about it to redact, and it rides a field
41
+ * instead: `fail()` redacts `message` only. The CLI routes its remedy on
42
+ * `check` for the same reason — a substring test against a message whose words
43
+ * can be rewritten is not a routing rule, it is a guess.
44
+ *
45
+ * NOTHING HERE WEAKENS THE REDACTOR. The content scan never puts its match in
46
+ * this object (see `matchedTerm` below), so no value can reach an envelope
47
+ * through it; the redactor's guarantee is intact and is pinned by the cases in
48
+ * `tests/unit/services/memory/memory-title-sensitive-scan.test.ts`.
49
+ */
50
+ export declare class UnsafeMemoryError extends Error {
51
+ /** One of `SENSITIVE_MEMORY_CHECKS` — which rule refused the write. */
52
+ readonly check: string;
53
+ /**
54
+ * The credential term the TITLE scan matched, or `null` for the other two
55
+ * checks. `null` is not an omission: their match can BE the credential
56
+ * (`ghp_…`, the PEM header, the JWT), which is why their messages describe
57
+ * the pattern family and never echo the text.
58
+ */
59
+ readonly matchedTerm: string | null;
60
+ constructor(check: string, detail: string, matchedTerm?: string | null);
61
+ }
3
62
  export declare function assertSafeMemory(memory: ExtractedProjectMemory): void;
4
63
  export declare function assertSafeMemoryFileContent(content: string): void;
5
64
  export declare function writeNewFile(path: string, content: string): void;