peaks-loop 4.1.0 → 4.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/README-en.md +1 -1
  3. package/README.md +1 -1
  4. package/dist/cli/commands/_register.js +2 -2
  5. package/dist/cli/commands/code-mode-gate-should-pause-command.js +1 -1
  6. package/dist/cli/commands/comments-commands.d.ts +14 -0
  7. package/dist/cli/commands/comments-commands.js +96 -0
  8. package/dist/cli/commands/core/memory-command.js +10 -0
  9. package/dist/cli/commands/core/skill-command.js +1 -1
  10. package/dist/cli/commands/core/standards-command.js +1 -1
  11. package/dist/cli/commands/ecc-commands.d.ts +17 -22
  12. package/dist/cli/commands/ecc-commands.js +38 -26
  13. package/dist/cli/commands/prd-commands.js +8 -2
  14. package/dist/cli/program.js +4 -9
  15. package/dist/services/code/mode-gate-types.d.ts +1 -1
  16. package/dist/services/code/mode-gate-types.js +0 -1
  17. package/dist/services/code/mode-gate.js +1 -9
  18. package/dist/services/code/user-touchpoint-classifier.js +0 -7
  19. package/dist/services/code-review/ecc-bridge.d.ts +6 -6
  20. package/dist/services/comments/citation-rules.d.ts +154 -0
  21. package/dist/services/comments/citation-rules.js +241 -0
  22. package/dist/services/comments/comment-audit.d.ts +57 -0
  23. package/dist/services/comments/comment-audit.js +100 -0
  24. package/dist/services/comments/comment-citations.d.ts +60 -0
  25. package/dist/services/comments/comment-citations.js +186 -0
  26. package/dist/services/comments/comment-hygiene.d.ts +79 -0
  27. package/dist/services/comments/comment-hygiene.js +133 -0
  28. package/dist/services/comments/comment-prune.d.ts +88 -0
  29. package/dist/services/comments/comment-prune.js +148 -0
  30. package/dist/services/comments/prune-apply.d.ts +60 -0
  31. package/dist/services/comments/prune-apply.js +150 -0
  32. package/dist/services/comments/repo-path-probe.d.ts +43 -0
  33. package/dist/services/comments/repo-path-probe.js +78 -0
  34. package/dist/services/log/retention.d.ts +0 -16
  35. package/dist/services/log/retention.js +0 -17
  36. package/dist/services/memory/project-memory-service/index.d.ts +1 -1
  37. package/dist/services/memory/project-memory-service/index.js +1 -1
  38. package/dist/services/memory/project-memory-service/store/atomic-write.d.ts +19 -7
  39. package/dist/services/memory/project-memory-service/store/atomic-write.js +120 -26
  40. package/dist/services/prd/handoff-frontmatter.js +61 -0
  41. package/dist/services/prd/handoff-gate-evidence.js +14 -10
  42. package/dist/services/prd/handoff-service.d.ts +11 -1
  43. package/dist/services/prd/handoff-service.js +11 -1
  44. package/dist/services/prd/handoff-types.d.ts +33 -1
  45. package/dist/services/recommendations/installed-capability-detector.d.ts +5 -5
  46. package/dist/services/recommendations/installed-capability-detector.js +11 -10
  47. package/dist/services/scan/archetype-detection.d.ts +37 -0
  48. package/dist/services/scan/archetype-detection.js +175 -2
  49. package/dist/services/scan/archetype-service.js +36 -22
  50. package/dist/services/scan/scan-types.d.ts +9 -0
  51. package/dist/services/workspace/generated-artifacts-stamp.d.ts +2 -2
  52. package/dist/services/workspace/generated-artifacts-stamp.js +2 -2
  53. package/dist/services/workspace/workspace-service.js +1 -1
  54. package/package.json +5 -5
  55. package/scripts/install-skills.mjs +0 -177
  56. package/skills/bee/peaks-qa/references/reading-handoff-frontmatter.md +3 -1
  57. package/skills/bee/peaks-rd/references/parallel-review-fanout.md +1 -1
  58. package/skills/bee/peaks-rd/references/rd-fanout-contracts.md +3 -2
  59. package/skills/bee/peaks-rd/references/writing-handoff-frontmatter.md +29 -7
  60. package/skills/peaks-code/references/frontend-only-mode.md +2 -2
  61. package/skills/peaks-code/references/startup-sequence.md +0 -4
  62. package/dist/cli/commands/upgrade-commands.d.ts +0 -25
  63. package/dist/cli/commands/upgrade-commands.js +0 -154
  64. package/dist/services/upgrade/1x-detector-service.d.ts +0 -7
  65. package/dist/services/upgrade/1x-detector-service.js +0 -96
  66. package/dist/services/upgrade/gitignore-migrate-service.d.ts +0 -56
  67. package/dist/services/upgrade/gitignore-migrate-service.js +0 -170
  68. package/dist/services/upgrade/upgrade-service.d.ts +0 -81
  69. package/dist/services/upgrade/upgrade-service.js +0 -428
  70. package/skills/peaks-code/references/step-0-55-1x-detection.md +0 -83
@@ -0,0 +1,60 @@
1
+ /**
2
+ * The line mechanics of a comment prune, and the proof that gates every write.
3
+ *
4
+ * Split from `comment-prune.ts` because the two change for different reasons: that
5
+ * module decides WHAT is debt (and follows the classifier), this one decides how a
6
+ * line is removed from a file without damaging the file (and follows the language).
7
+ * Keeping them apart is also what lets the proof be tested on a text array, with no
8
+ * filesystem and no scan in the room.
9
+ */
10
+ import type { PruneAction, PruneSkip } from './comment-prune.js';
11
+ /** What the write phase decided, per file. */
12
+ export type WriteOutcome = {
13
+ touched: string[];
14
+ notWritten: string[];
15
+ fileSkips: PruneSkip[];
16
+ };
17
+ /**
18
+ * Read a file, or report that it is not there — a prune never creates one.
19
+ *
20
+ * `existsSync` first, and NO catch: a `catch { return null }` answers "present but
21
+ * unreadable" (permissions, a locked handle, a directory named as a file) with the same
22
+ * `null` as "absent", and that conflation is the defect this repository already
23
+ * ratchets under `silentWarningCatchReturnNull` — this very helper was counted by it.
24
+ * An absent path is a state the caller handles; an unreadable one should be a crash an
25
+ * operator sees.
26
+ */
27
+ export declare function readFileSafe(root: string, rel: string): string | null;
28
+ /**
29
+ * Split a source into lines, remembering what separated them.
30
+ *
31
+ * `mixed` is the signal to leave a file alone rather than a detail to paper over: a
32
+ * file with both CRLF and LF endings cannot be re-joined from one delimiter without
33
+ * rewriting every line, and a rewrite of every line is not a comment edit — it would
34
+ * also make the proof below vacuous, because all the lines would differ.
35
+ */
36
+ export declare function splitLines(source: string): {
37
+ lines: string[];
38
+ eol: string;
39
+ mixed: boolean;
40
+ };
41
+ /** Group a plan by file, in first-seen order. */
42
+ export declare function actionsByFile(actions: readonly PruneAction[]): Map<string, PruneAction[]>;
43
+ /**
44
+ * The proof, as data: compare kept lines against the original.
45
+ *
46
+ * One string per violation; empty means every difference between the two texts is a
47
+ * line this plan named, and every line it named that was kept is byte-identical to
48
+ * what was there before. This is computed whether or not the caller intends to write,
49
+ * so a dry run reports the refusals a real run would hit.
50
+ */
51
+ export declare function proofViolations(before: readonly string[], after: readonly string[], actions: readonly PruneAction[]): string[];
52
+ /** Apply one file's actions to its lines, in the plan's own terms. */
53
+ export declare function pruneFileLines(lines: readonly string[], actions: readonly PruneAction[]): string[];
54
+ /**
55
+ * Run the proof on every planned file, and write only the files that passed it.
56
+ *
57
+ * A file that fails is reported and left alone — never written "mostly", because a
58
+ * partially-pruned file is exactly the state the proof exists to make unreachable.
59
+ */
60
+ export declare function executePlan(projectRoot: string, byFile: Map<string, PruneAction[]>, apply: boolean): WriteOutcome;
@@ -0,0 +1,150 @@
1
+ /**
2
+ * The line mechanics of a comment prune, and the proof that gates every write.
3
+ *
4
+ * Split from `comment-prune.ts` because the two change for different reasons: that
5
+ * module decides WHAT is debt (and follows the classifier), this one decides how a
6
+ * line is removed from a file without damaging the file (and follows the language).
7
+ * Keeping them apart is also what lets the proof be tested on a text array, with no
8
+ * filesystem and no scan in the room.
9
+ */
10
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
11
+ import { join } from 'node:path';
12
+ /**
13
+ * Read a file, or report that it is not there — a prune never creates one.
14
+ *
15
+ * `existsSync` first, and NO catch: a `catch { return null }` answers "present but
16
+ * unreadable" (permissions, a locked handle, a directory named as a file) with the same
17
+ * `null` as "absent", and that conflation is the defect this repository already
18
+ * ratchets under `silentWarningCatchReturnNull` — this very helper was counted by it.
19
+ * An absent path is a state the caller handles; an unreadable one should be a crash an
20
+ * operator sees.
21
+ */
22
+ export function readFileSafe(root, rel) {
23
+ const abs = join(root, rel);
24
+ return existsSync(abs) ? readFileSync(abs, 'utf8') : null;
25
+ }
26
+ /**
27
+ * Split a source into lines, remembering what separated them.
28
+ *
29
+ * `mixed` is the signal to leave a file alone rather than a detail to paper over: a
30
+ * file with both CRLF and LF endings cannot be re-joined from one delimiter without
31
+ * rewriting every line, and a rewrite of every line is not a comment edit — it would
32
+ * also make the proof below vacuous, because all the lines would differ.
33
+ */
34
+ export function splitLines(source) {
35
+ const crlf = source.includes('\r\n');
36
+ const lfOnly = /\n(?!\r)/.test(source.replace(/\r\n/g, '\n')) || /\n[^\r]/.test(source);
37
+ return {
38
+ lines: source.split(/\r?\n/),
39
+ eol: crlf ? '\r\n' : '\n',
40
+ mixed: crlf && lfOnly
41
+ };
42
+ }
43
+ /** Group a plan by file, in first-seen order. */
44
+ export function actionsByFile(actions) {
45
+ const byFile = new Map();
46
+ for (const action of actions) {
47
+ const list = byFile.get(action.file);
48
+ if (list === undefined)
49
+ byFile.set(action.file, [action]);
50
+ else
51
+ list.push(action);
52
+ }
53
+ return byFile;
54
+ }
55
+ /**
56
+ * The proof, as data: compare kept lines against the original.
57
+ *
58
+ * One string per violation; empty means every difference between the two texts is a
59
+ * line this plan named, and every line it named that was kept is byte-identical to
60
+ * what was there before. This is computed whether or not the caller intends to write,
61
+ * so a dry run reports the refusals a real run would hit.
62
+ */
63
+ export function proofViolations(before, after, actions) {
64
+ const dropped = new Set(actions.filter((a) => a.mode === 'drop-line').map((a) => a.line));
65
+ const stripped = new Set(actions.filter((a) => a.mode === 'strip-trailing').map((a) => a.line));
66
+ const violations = [];
67
+ let afterAt = 0;
68
+ before.forEach((original, index) => {
69
+ const line = index + 1;
70
+ if (dropped.has(line))
71
+ return; // deleted on purpose
72
+ if (stripped.has(line)) {
73
+ // The only thing a strip may do is reveal the code that was already there, so
74
+ // what survives must be a PREFIX of the original line — that is what makes it a
75
+ // comment edit, and anything else is a code edit wearing its clothes.
76
+ const kept = after[afterAt] ?? '';
77
+ afterAt += 1;
78
+ if (!original.startsWith(kept))
79
+ violations.push(`line ${line}: code text changed`);
80
+ return;
81
+ }
82
+ if (after[afterAt] !== original)
83
+ violations.push(`line ${line}: untouched line differs`);
84
+ afterAt += 1;
85
+ });
86
+ if (afterAt !== after.length)
87
+ violations.push(`produced ${after.length - afterAt} extra line(s)`);
88
+ return violations;
89
+ }
90
+ /** Apply one file's actions to its lines, in the plan's own terms. */
91
+ export function pruneFileLines(lines, actions) {
92
+ const drop = new Set(actions.filter((a) => a.mode === 'drop-line').map((a) => a.line));
93
+ const strip = new Map(actions.filter((a) => a.mode === 'strip-trailing').map((a) => [a.line, a.comment]));
94
+ const out = [];
95
+ lines.forEach((original, index) => {
96
+ const line = index + 1;
97
+ if (drop.has(line))
98
+ return;
99
+ if (strip.has(line)) {
100
+ // Cut where the scan's comment text starts, not at the first `//` in the line:
101
+ // `const url = "https://example.com"; // Slice 1 kept it` has its `//` inside a
102
+ // string literal, and slicing there would delete code.
103
+ const comment = strip.get(line) ?? '';
104
+ const at = original.indexOf(comment);
105
+ out.push(at > 0 ? original.slice(0, at).trimEnd() : comment.trimEnd());
106
+ return;
107
+ }
108
+ out.push(original);
109
+ });
110
+ return out;
111
+ }
112
+ /**
113
+ * Run the proof on every planned file, and write only the files that passed it.
114
+ *
115
+ * A file that fails is reported and left alone — never written "mostly", because a
116
+ * partially-pruned file is exactly the state the proof exists to make unreachable.
117
+ */
118
+ export function executePlan(projectRoot, byFile, apply) {
119
+ const touched = [];
120
+ const notWritten = [];
121
+ const fileSkips = [];
122
+ for (const [file, fileActions] of byFile) {
123
+ const source = readFileSafe(projectRoot, file);
124
+ if (source === null) {
125
+ notWritten.push(file);
126
+ continue;
127
+ }
128
+ const parts = splitLines(source);
129
+ if (parts.mixed) {
130
+ fileSkips.push({
131
+ file,
132
+ line: 0,
133
+ reason: 'mixed-line-endings',
134
+ text: `${parts.lines.length} line(s) left untouched`
135
+ });
136
+ notWritten.push(file);
137
+ continue;
138
+ }
139
+ const after = pruneFileLines(parts.lines, fileActions);
140
+ const violations = proofViolations(parts.lines, after, fileActions);
141
+ if (violations.length > 0) {
142
+ notWritten.push(`${file} (${violations[0]})`);
143
+ continue;
144
+ }
145
+ touched.push(file);
146
+ if (apply)
147
+ writeFileSync(join(projectRoot, file), after.join(parts.eol));
148
+ }
149
+ return { touched, notWritten, fileSkips };
150
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Is a path required to exist in a checkout at all?
3
+ *
4
+ * The dominant false positive on `dead-reference` was never a stale comment: it
5
+ * was a correct reference to a file git is told not to track. Ranked over this
6
+ * repository, the top twelve "dead" citations were `.claude/settings.local.json`,
7
+ * `.peaks/_runtime/session.json`, `.peaks/.session.json`,
8
+ * `.peaks/_runtime/active-skill.json`, `.codegraph/config.json`,
9
+ * `.peaks/preferences.json` and friends — all generated state, all named
10
+ * correctly by the code that writes them.
11
+ *
12
+ * So the probe answers "exists OR ignored", and the ignore reading is deliberately
13
+ * bounded: trailing-slash prefixes, whole-path entries, and bare-basename entries.
14
+ * That limit has a direction, and the direction is the safe one — a rule
15
+ * `.gitignore` expresses fancily (negation, `**`, character classes) is NOT read
16
+ * here, so such a path stays reported. A missed exemption is a finding a human
17
+ * reads and dismisses; a phantom exemption is a silent false green in a gate that
18
+ * exists to catch claims that outlived their referent.
19
+ */
20
+ type IgnoreRules = {
21
+ /** Directory rules: match the path and everything below it. */
22
+ readonly prefixes: readonly string[];
23
+ /** File rules written with a slash: match that exact repo-relative path. */
24
+ readonly exact: ReadonlySet<string>;
25
+ /** Basename rules: match that name in any directory. */
26
+ readonly basenames: ReadonlySet<string>;
27
+ };
28
+ /** True when `.gitignore` says this repo-relative path need not be on disk. */
29
+ export declare function isIgnoredPath(relPath: string, rules: IgnoreRules): boolean;
30
+ /**
31
+ * Build the existence probe a scan should use: a real filesystem lookup, widened
32
+ * by the repository's own ignore list read from its root.
33
+ *
34
+ * The second export below is the deliberate exception. "Is this an installed
35
+ * package name?" must NOT be widened by the ignore list, because `node_modules` is
36
+ * itself ignored — an ignore-aware probe answers yes for every first segment, and
37
+ * the whole `dead-reference` count collapses to zero while looking perfectly
38
+ * healthy.
39
+ */
40
+ export declare function createRepoProbe(repoRoot: string): (relPath: string) => boolean;
41
+ /** Filesystem-only existence, for questions where "git ignores it" is no answer. */
42
+ export declare function createFsProbe(repoRoot: string): (relPath: string) => boolean;
43
+ export {};
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Is a path required to exist in a checkout at all?
3
+ *
4
+ * The dominant false positive on `dead-reference` was never a stale comment: it
5
+ * was a correct reference to a file git is told not to track. Ranked over this
6
+ * repository, the top twelve "dead" citations were `.claude/settings.local.json`,
7
+ * `.peaks/_runtime/session.json`, `.peaks/.session.json`,
8
+ * `.peaks/_runtime/active-skill.json`, `.codegraph/config.json`,
9
+ * `.peaks/preferences.json` and friends — all generated state, all named
10
+ * correctly by the code that writes them.
11
+ *
12
+ * So the probe answers "exists OR ignored", and the ignore reading is deliberately
13
+ * bounded: trailing-slash prefixes, whole-path entries, and bare-basename entries.
14
+ * That limit has a direction, and the direction is the safe one — a rule
15
+ * `.gitignore` expresses fancily (negation, `**`, character classes) is NOT read
16
+ * here, so such a path stays reported. A missed exemption is a finding a human
17
+ * reads and dismisses; a phantom exemption is a silent false green in a gate that
18
+ * exists to catch claims that outlived their referent.
19
+ */
20
+ import { existsSync, readFileSync } from 'node:fs';
21
+ import { join } from 'node:path';
22
+ function parseGitignore(source) {
23
+ const prefixes = [];
24
+ const exact = new Set();
25
+ const basenames = new Set();
26
+ for (const raw of source.split(/\r?\n/)) {
27
+ const rule = raw.trim();
28
+ if (rule.length === 0 || rule.startsWith('#') || rule.startsWith('!'))
29
+ continue;
30
+ const cleaned = rule.replace(/^\//, '').replace(/\/$/, '');
31
+ if (cleaned.length === 0)
32
+ continue;
33
+ if (!cleaned.includes('/')) {
34
+ basenames.add(cleaned);
35
+ continue;
36
+ }
37
+ // A rule with a slash is registered both ways rather than guessed: `.peaks/_runtime/`
38
+ // is a directory, `.claude/settings.local.json` is one file, and a rule that
39
+ // matched only `path/` silently failed every single-file ignore in the
40
+ // repository. Registering both costs nothing — an over-broad directory prefix
41
+ // here can only ever name the path it was written for and things under it.
42
+ prefixes.push(`${cleaned}/`);
43
+ exact.add(cleaned);
44
+ }
45
+ return { prefixes, exact, basenames };
46
+ }
47
+ /** True when `.gitignore` says this repo-relative path need not be on disk. */
48
+ export function isIgnoredPath(relPath, rules) {
49
+ const posix = relPath.replace(/\\/g, '/');
50
+ if (rules.exact.has(posix))
51
+ return true;
52
+ if (rules.prefixes.some((prefix) => posix.startsWith(prefix)))
53
+ return true;
54
+ const segments = posix.split('/');
55
+ return segments.some((segment) => rules.basenames.has(segment));
56
+ }
57
+ /**
58
+ * Build the existence probe a scan should use: a real filesystem lookup, widened
59
+ * by the repository's own ignore list read from its root.
60
+ *
61
+ * The second export below is the deliberate exception. "Is this an installed
62
+ * package name?" must NOT be widened by the ignore list, because `node_modules` is
63
+ * itself ignored — an ignore-aware probe answers yes for every first segment, and
64
+ * the whole `dead-reference` count collapses to zero while looking perfectly
65
+ * healthy.
66
+ */
67
+ export function createRepoProbe(repoRoot) {
68
+ let rules = { prefixes: [], exact: new Set(), basenames: new Set() };
69
+ const ignorePath = join(repoRoot, '.gitignore');
70
+ if (existsSync(ignorePath)) {
71
+ rules = parseGitignore(readFileSync(ignorePath, 'utf8'));
72
+ }
73
+ return (relPath) => existsSync(join(repoRoot, relPath)) || isIgnoredPath(relPath, rules);
74
+ }
75
+ /** Filesystem-only existence, for questions where "git ignores it" is no answer. */
76
+ export function createFsProbe(repoRoot) {
77
+ return (relPath) => existsSync(join(repoRoot, relPath));
78
+ }
@@ -32,19 +32,3 @@ export type ApplyRetentionOptions = {
32
32
  * process between the `readdir` and the `unlink`).
33
33
  */
34
34
  export declare function applyRetention(opts?: ApplyRetentionOptions): string[];
35
- /**
36
- * Slice 3 (on-demand-ecc) — 7-day TTL sweep over `~/.peaks/cache/ecc-<sha>/`
37
- * directories. Delegates to `cleanupStaleCache` in the ECC cache
38
- * service so the retention policy lives next to the cache it
39
- * governs.
40
- *
41
- * Mirrors the `applyRetention` signature for symmetry: tests pass
42
- * `nowMs` + `dirOverride`; production callers leave both unset.
43
- */
44
- export declare function cleanupEccCache(options: {
45
- retentionDays: number;
46
- nowMs?: number;
47
- dirOverride?: string;
48
- }): {
49
- removed: string[];
50
- };
@@ -16,7 +16,6 @@
16
16
  import { readdirSync, statSync, unlinkSync, existsSync } from 'node:fs';
17
17
  import { join } from 'node:path';
18
18
  import { resolveLogDir } from './logger.js';
19
- import { cleanupStaleCache } from 'peaks-loop-mut';
20
19
  const LOG_FILE_NAME_PATTERN = /^peaks-loop-(\d{4}-\d{2}-\d{2})\.log$/;
21
20
  function dayDiffUtc(nowUtcMidnightMs, fileDateUtcMs) {
22
21
  const dayMs = 24 * 60 * 60 * 1000;
@@ -78,19 +77,3 @@ export function applyRetention(opts = {}) {
78
77
  }
79
78
  return removed;
80
79
  }
81
- /**
82
- * Slice 3 (on-demand-ecc) — 7-day TTL sweep over `~/.peaks/cache/ecc-<sha>/`
83
- * directories. Delegates to `cleanupStaleCache` in the ECC cache
84
- * service so the retention policy lives next to the cache it
85
- * governs.
86
- *
87
- * Mirrors the `applyRetention` signature for symmetry: tests pass
88
- * `nowMs` + `dirOverride`; production callers leave both unset.
89
- */
90
- export function cleanupEccCache(options) {
91
- return cleanupStaleCache({
92
- retentionDays: options.retentionDays,
93
- nowMs: options.nowMs ?? Date.now(),
94
- ...(options.dirOverride !== undefined ? { dirOverride: options.dirOverride } : {})
95
- });
96
- }
@@ -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, findSensitiveMemoryTitleTerm, hasSensitiveMemoryContent, SENSITIVE_MEMORY_CHECKS, UnsafeMemoryError, writeNewFile } from './store/atomic-write.js';
10
+ export { assertSafeMemory, assertSafeMemoryFileContent, findSensitiveMemoryContentRule, 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, findSensitiveMemoryTitleTerm, hasSensitiveMemoryContent, SENSITIVE_MEMORY_CHECKS, UnsafeMemoryError, writeNewFile } from './store/atomic-write.js';
22
+ export { assertSafeMemory, assertSafeMemoryFileContent, findSensitiveMemoryContentRule, 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';
@@ -24,6 +24,12 @@ export declare const SENSITIVE_MEMORY_CHECKS: {
24
24
  * a value — so echoing it into the error envelope cannot leak anything.
25
25
  */
26
26
  export declare function findSensitiveMemoryTitleTerm(title: string): string | null;
27
+ /**
28
+ * Which content rule refused this text — the id, never the match. A rule whose
29
+ * candidate fails `shaped` does not stop the scan: a later rule may still fire,
30
+ * exactly as a later alternative in the old `||` chain would have.
31
+ */
32
+ export declare function findSensitiveMemoryContentRule(content: string): string | null;
27
33
  export declare function hasSensitiveMemoryContent(content: string): boolean;
28
34
  /**
29
35
  * The refusal `assertSafeMemory` raises: the check that fired and the term it
@@ -42,19 +48,25 @@ export declare function hasSensitiveMemoryContent(content: string): boolean;
42
48
  * `check` for the same reason — a substring test against a message whose words
43
49
  * can be rewritten is not a routing rule, it is a guess.
44
50
  *
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
51
+ * NOTHING HERE WEAKENS THE REDACTOR. Both scans put a NAME in this field, never
52
+ * a match: the title term comes from `SENSITIVE_PROSE_TERMS` and the content
53
+ * value from the closed id list in `SENSITIVE_CONTENT_RULES`, so no credential
54
+ * can reach an envelope through it. The redactor's guarantee over `message` is
55
+ * intact and is pinned by the cases in
48
56
  * `tests/unit/services/memory/memory-title-sensitive-scan.test.ts`.
49
57
  */
50
58
  export declare class UnsafeMemoryError extends Error {
51
59
  /** One of `SENSITIVE_MEMORY_CHECKS` — which rule refused the write. */
52
60
  readonly check: string;
53
61
  /**
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.
62
+ * WHAT matched, as a name rather than a value: the credential term for the
63
+ * title scan, the rule id for the content scan, `null` only for the metadata
64
+ * key scan (whose predicate answers by a config-key vocabulary the memory
65
+ * metadata never contains — see the note on `assertSafeMemory`).
66
+ *
67
+ * It is never the match itself: for most content patterns the match IS the
68
+ * credential (`ghp_…`, the PEM header, the JWT), which is why their messages
69
+ * describe the pattern family and never echo the text.
58
70
  */
59
71
  readonly matchedTerm: string | null;
60
72
  constructor(check: string, detail: string, matchedTerm?: string | null);
@@ -4,7 +4,9 @@
4
4
  // - `hasSensitiveMemoryContent` — pattern check for api keys, tokens,
5
5
  // PEM private keys, JWTs, GitHub / GitLab tokens, AWS access keys. Used
6
6
  // both by the extract path (`assertSafeMemory`) and the backup path
7
- // (`assertSafeMemoryFileContent`).
7
+ // (`assertSafeMemoryFileContent`). It is a wrapper over
8
+ // `findSensitiveMemoryContentRule`, which answers the same question and
9
+ // names the rule that answered it.
8
10
  // - `findSensitiveMemoryTitleTerm` — the PROSE predicate for
9
11
  // `memory.title`. It is deliberately NOT the config-key predicate: see
10
12
  // the block comment above it (slice C0).
@@ -136,17 +138,89 @@ export function findSensitiveMemoryTitleTerm(title) {
136
138
  }
137
139
  return null;
138
140
  }
141
+ /**
142
+ * Is this string shaped like a credential VALUE rather than like prose?
143
+ *
144
+ * WHY THIS EXISTS. The value-carrying patterns used to fire on the SEPARATOR
145
+ * alone: `password:` was a match whether or not anything followed it, so a
146
+ * memory writing `password: the field label in the form` or `myApiKey: from
147
+ * env` was refused for describing an interface. Two documents were killed that
148
+ * way before anyone could say which of ten rules had fired — and the rules
149
+ * could not be tightened, because nothing in them described the VALUE.
150
+ *
151
+ * THE TEST, AND WHAT IT COSTS. A value is credential-shaped when it is at least
152
+ * 5 characters AND carries a digit or one of `_ + / =`. `hunter2`,
153
+ * `abcdef123456`, `supp3rSecret` and every base64-ish token pass;
154
+ * `Authorization`, `the`, `from`, `abc`, `well-designed` fail. Given up
155
+ * deliberately: an all-letter opaque run (`bearer abcdefghijklmnopq`) is no
156
+ * longer caught, and neither is a 4-character real password. The alternative was
157
+ * a rule that cannot tell an English word from a random string, and the
158
+ * complaint on record is about English words. Digits are the discriminator
159
+ * rather than `-` and `.` because hyphens and full stops belong to ordinary
160
+ * prose; provider tokens in the wild carry digits, and the prefix-shaped
161
+ * patterns (`ghp_`, `AKIA`, the PEM header, the JWT) keep their own length floors
162
+ * and are untouched by this predicate.
163
+ */
164
+ const MIN_VALUE_CHARS = 5;
165
+ function isCredentialShapedValue(value) {
166
+ return value.length >= MIN_VALUE_CHARS && /[0-9_+/=]/.test(value);
167
+ }
168
+ /** A placeholder names where a value will go; it is not itself a secret. */
169
+ const PLACEHOLDER_VALUE = /^[<[{(]|^(?:your|the|an|some|my|example|placeholder|todo|fake)/i;
170
+ function isPlaceholderValue(value) {
171
+ return (PLACEHOLDER_VALUE.test(value) ||
172
+ /(?:replace|insert|provide|redact|<[a-z_]+>)/i.test(value) ||
173
+ /^[A-Za-z]+(?:_[A-Z*]+)+$/.test(value) // API_KEY_HERE-style constant
174
+ );
175
+ }
176
+ const SENSITIVE_CONTENT_RULES = [
177
+ {
178
+ id: 'credential-assignment',
179
+ pattern: /\b(?:api[_-]?key|access[_-]?token|auth[_-]?token|refresh[_-]?token|secret|password|passwd|credential|token|bearer)\s*[:=]\s*([^\s,;"'`]+)/gi,
180
+ shaped: (value) => isCredentialShapedValue(value) && !isPlaceholderValue(value)
181
+ },
182
+ {
183
+ id: 'authorization-bearer-header',
184
+ pattern: /\bauthorization\s*:\s*bearer\s+([^\s,;"'`]+)/gi,
185
+ shaped: (value) => isCredentialShapedValue(value) && !isPlaceholderValue(value)
186
+ },
187
+ {
188
+ id: 'bearer-value',
189
+ pattern: /\bbearer\s+([A-Za-z0-9._~+/=-]{8,})/gi,
190
+ shaped: (value) => isCredentialShapedValue(value) && !isPlaceholderValue(value)
191
+ },
192
+ {
193
+ id: 'sk-prefixed-key',
194
+ // The SUFFIX is captured, not the whole `sk-…`: `sk-abcdef is the prefix`
195
+ // is a sentence about the scheme, and the shape test on the suffix is what
196
+ // tells it from `sk-abcdef1234567890`.
197
+ pattern: /\bsk-([A-Za-z0-9_-]{6,})\b/g,
198
+ shaped: (value) => isCredentialShapedValue(value) && !isPlaceholderValue(value)
199
+ },
200
+ { id: 'gh-prefixed-key', pattern: /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/g },
201
+ { id: 'github-pat', pattern: /\bgithub_pat_[A-Za-z0-9_]{20,}\b/g },
202
+ { id: 'glpat-prefixed-key', pattern: /\bglpat-[A-Za-z0-9_-]{20,}\b/g },
203
+ { id: 'akia-prefixed-key', pattern: /\bAKIA[0-9A-Z]{16}\b/g },
204
+ { id: 'pem-private-key', pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/g },
205
+ { id: 'jwt-shaped-value', pattern: /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/g }
206
+ ];
207
+ /**
208
+ * Which content rule refused this text — the id, never the match. A rule whose
209
+ * candidate fails `shaped` does not stop the scan: a later rule may still fire,
210
+ * exactly as a later alternative in the old `||` chain would have.
211
+ */
212
+ export function findSensitiveMemoryContentRule(content) {
213
+ for (const rule of SENSITIVE_CONTENT_RULES) {
214
+ for (const match of content.matchAll(rule.pattern)) {
215
+ const candidate = match[1] ?? match[0];
216
+ if (rule.shaped === undefined || rule.shaped(candidate))
217
+ return rule.id;
218
+ }
219
+ }
220
+ return null;
221
+ }
139
222
  export function hasSensitiveMemoryContent(content) {
140
- return (/(?:api[_-]?key|token|secret|password|credential|bearer)\s*[:=]/i.test(content) ||
141
- /\bauthorization\s*:\s*bearer\s+\S+/i.test(content) ||
142
- /\bbearer\s+[A-Za-z0-9._~+/=-]{12,}\b/i.test(content) ||
143
- /\bsk-[A-Za-z0-9_-]{6,}\b/.test(content) ||
144
- /\bgh[pousr]_[A-Za-z0-9_]{20,}\b/.test(content) ||
145
- /\bgithub_pat_[A-Za-z0-9_]{20,}\b/.test(content) ||
146
- /\bglpat-[A-Za-z0-9_-]{20,}\b/.test(content) ||
147
- /\bAKIA[0-9A-Z]{16}\b/.test(content) ||
148
- /-----BEGIN [A-Z ]*PRIVATE KEY-----/.test(content) ||
149
- /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/.test(content));
223
+ return findSensitiveMemoryContentRule(content) !== null;
150
224
  }
151
225
  /**
152
226
  * The refusal `assertSafeMemory` raises: the check that fired and the term it
@@ -165,19 +239,25 @@ export function hasSensitiveMemoryContent(content) {
165
239
  * `check` for the same reason — a substring test against a message whose words
166
240
  * can be rewritten is not a routing rule, it is a guess.
167
241
  *
168
- * NOTHING HERE WEAKENS THE REDACTOR. The content scan never puts its match in
169
- * this object (see `matchedTerm` below), so no value can reach an envelope
170
- * through it; the redactor's guarantee is intact and is pinned by the cases in
242
+ * NOTHING HERE WEAKENS THE REDACTOR. Both scans put a NAME in this field, never
243
+ * a match: the title term comes from `SENSITIVE_PROSE_TERMS` and the content
244
+ * value from the closed id list in `SENSITIVE_CONTENT_RULES`, so no credential
245
+ * can reach an envelope through it. The redactor's guarantee over `message` is
246
+ * intact and is pinned by the cases in
171
247
  * `tests/unit/services/memory/memory-title-sensitive-scan.test.ts`.
172
248
  */
173
249
  export class UnsafeMemoryError extends Error {
174
250
  /** One of `SENSITIVE_MEMORY_CHECKS` — which rule refused the write. */
175
251
  check;
176
252
  /**
177
- * The credential term the TITLE scan matched, or `null` for the other two
178
- * checks. `null` is not an omission: their match can BE the credential
179
- * (`ghp_…`, the PEM header, the JWT), which is why their messages describe
180
- * the pattern family and never echo the text.
253
+ * WHAT matched, as a name rather than a value: the credential term for the
254
+ * title scan, the rule id for the content scan, `null` only for the metadata
255
+ * key scan (whose predicate answers by a config-key vocabulary the memory
256
+ * metadata never contains — see the note on `assertSafeMemory`).
257
+ *
258
+ * It is never the match itself: for most content patterns the match IS the
259
+ * credential (`ghp_…`, the PEM header, the JWT), which is why their messages
260
+ * describe the pattern family and never echo the text.
181
261
  */
182
262
  matchedTerm;
183
263
  constructor(check, detail, matchedTerm = null) {
@@ -193,19 +273,28 @@ export function assertSafeMemory(memory) {
193
273
  if (containsSensitiveConfigValue(metadata)) {
194
274
  throw new UnsafeMemoryError(SENSITIVE_MEMORY_CHECKS.metadataKey, 'matched a credential key in the memory metadata');
195
275
  }
196
- if (hasSensitiveMemoryContent(content)) {
197
- // The match is deliberately NOT echoed, in the message or in the error's
198
- // fields: for most of these patterns the match IS the credential, so
276
+ const contentRule = findSensitiveMemoryContentRule(content);
277
+ if (contentRule !== null) {
278
+ // The matched TEXT is deliberately NOT echoed, in the message or in the
279
+ // error's fields: for most of these patterns the match IS the credential, so
199
280
  // copying it would write the value this scan exists to keep out.
200
281
  //
282
+ // The RULE ID is echoed, and it is echoed in `matchedTerm` rather than only
283
+ // in prose. This used to be the one refusal that could not say what fired:
284
+ // the predicate returned a boolean, so the caller had no match to report and
285
+ // `matchedTerm` was `null` by construction while the title scan named its
286
+ // term. A reader then had to guess across documents which of ten shapes had
287
+ // refused them. An id from a closed vocabulary leaks nothing and answers the
288
+ // question, so the two paths are diagnosable the same way.
289
+ //
201
290
  // The DETAIL is worded around the envelope redactor's vocabulary on
202
291
  // purpose. Its catch-all (`/(secret|token|password|api[-_ ]?key)/gi`)
203
292
  // rewrites those four words wherever they appear in a failure message, so
204
293
  // naming the pattern families in their own words arrives on the CLI as
205
294
  // "an [redacted] / [redacted] / [redacted] assignment" — a remedy sentence
206
- // redacted into uselessness by the very policy it agrees with. Measured on
207
- // the real CLI, both wordings; this one survives intact.
208
- throw new UnsafeMemoryError(SENSITIVE_MEMORY_CHECKS.content, 'matched a credential value in the memory content (a `key=value` credential assignment, a Bearer header, a PEM private key, a JWT, or a provider credential)');
295
+ // redacted into uselessness by the very policy it agrees with. The rule ids
296
+ // above are spelled to survive that pass.
297
+ throw new UnsafeMemoryError(SENSITIVE_MEMORY_CHECKS.content, `matched the credential pattern "${contentRule}" in the memory content`, contentRule);
209
298
  }
210
299
  const titleTerm = findSensitiveMemoryTitleTerm(memory.title);
211
300
  if (titleTerm !== null) {
@@ -213,8 +302,13 @@ export function assertSafeMemory(memory) {
213
302
  }
214
303
  }
215
304
  export function assertSafeMemoryFileContent(content) {
216
- if (hasSensitiveMemoryContent(content)) {
217
- throw new Error('Refusing to back up sensitive memory content');
305
+ const rule = findSensitiveMemoryContentRule(content);
306
+ if (rule !== null) {
307
+ // The id travels here too. This path has no `UnsafeMemoryError` (it is not
308
+ // the extract gate, and its callers do not route on `check`), but a backup
309
+ // refusal that cannot say which shape fired is the same dead end as the one
310
+ // the extract path used to be.
311
+ throw new Error(`Refusing to back up sensitive memory content (rule: ${rule})`);
218
312
  }
219
313
  }
220
314
  export function writeNewFile(path, content) {