hypomnema 1.4.2 → 1.5.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/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.ko.md +35 -18
  4. package/README.md +25 -8
  5. package/commands/audit.md +2 -2
  6. package/commands/crystallize.md +22 -14
  7. package/commands/doctor.md +1 -1
  8. package/commands/feedback.md +2 -2
  9. package/commands/graph.md +1 -1
  10. package/commands/ingest.md +1 -1
  11. package/commands/init.md +2 -2
  12. package/commands/lint.md +1 -1
  13. package/commands/query.md +1 -1
  14. package/commands/rename.md +1 -1
  15. package/commands/resume.md +1 -1
  16. package/commands/stats.md +1 -1
  17. package/commands/uninstall.md +4 -2
  18. package/commands/upgrade.md +1 -1
  19. package/commands/verify.md +1 -1
  20. package/docs/ARCHITECTURE.md +4 -4
  21. package/docs/CONTRIBUTING.md +8 -7
  22. package/hooks/hypo-auto-commit.mjs +2 -2
  23. package/hooks/hypo-auto-minimal-crystallize.mjs +7 -7
  24. package/hooks/hypo-compact-guard.mjs +2 -2
  25. package/hooks/hypo-cwd-change.mjs +27 -37
  26. package/hooks/hypo-lookup.mjs +37 -2
  27. package/hooks/hypo-personal-check.mjs +37 -22
  28. package/hooks/hypo-session-end.mjs +1 -1
  29. package/hooks/hypo-session-record.mjs +2 -2
  30. package/hooks/hypo-session-start.mjs +62 -42
  31. package/hooks/hypo-shared.mjs +430 -85
  32. package/hooks/version-check.mjs +1 -1
  33. package/package.json +5 -1
  34. package/scripts/check-tracker-ids.mjs +69 -31
  35. package/scripts/crystallize.mjs +151 -53
  36. package/scripts/doctor.mjs +7 -7
  37. package/scripts/feedback-sync.mjs +5 -5
  38. package/scripts/feedback.mjs +9 -10
  39. package/scripts/init.mjs +7 -7
  40. package/scripts/lib/check-tracker-ids.mjs +90 -32
  41. package/scripts/lib/design-history-stale.mjs +1 -1
  42. package/scripts/lib/extensions.mjs +7 -7
  43. package/scripts/lib/failure-type.mjs +1 -1
  44. package/scripts/lib/feedback-scope.mjs +1 -1
  45. package/scripts/lib/page-usage.mjs +141 -0
  46. package/scripts/lib/project-create.mjs +2 -2
  47. package/scripts/lib/template-schema-version.mjs +1 -1
  48. package/scripts/lib/wd-match.mjs +181 -0
  49. package/scripts/lib/wikilink.mjs +1 -1
  50. package/scripts/lint.mjs +5 -5
  51. package/scripts/rename.mjs +1 -1
  52. package/scripts/resume.mjs +20 -22
  53. package/scripts/session-audit.mjs +1 -1
  54. package/scripts/stats.mjs +3 -3
  55. package/scripts/uninstall.mjs +3 -3
  56. package/scripts/upgrade.mjs +85 -18
  57. package/skills/crystallize/SKILL.md +17 -11
  58. package/skills/graph/SKILL.md +3 -3
  59. package/skills/ingest/SKILL.md +5 -5
  60. package/skills/lint/SKILL.md +3 -3
  61. package/skills/query/SKILL.md +3 -3
  62. package/skills/verify/SKILL.md +3 -3
  63. package/templates/SCHEMA.md +1 -1
  64. package/templates/hypo-config.md +1 -1
  65. package/templates/hypo-guide.md +21 -15
  66. package/templates/projects/_template/hot.md +1 -1
  67. package/scripts/fix-status-verify.mjs +0 -256
  68. package/scripts/lib/adr-corpus.mjs +0 -79
  69. package/scripts/lib/fix-manifest.mjs +0 -109
  70. package/scripts/lib/fix-status-verify.mjs +0 -439
@@ -249,7 +249,7 @@ export function isOptedOut(env = process.env) {
249
249
  return Boolean(env.HYPO_NO_UPDATE_CHECK || env.NO_UPDATE_NOTIFIER || env.CI);
250
250
  }
251
251
 
252
- // ── stale-sibling detection (ADR 0038) ───────────────────────────────────────
252
+ // ── stale-sibling detection ───────────────────────────────────────────────────
253
253
  //
254
254
  // A second, OLDER Hypomnema can sit on $PATH (e.g. a stale `npm i -g hypomnema`)
255
255
  // while a newer copy owns the active hooks. The CLI bin (`hypomnema`) then routes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hypomnema",
3
- "version": "1.4.2",
3
+ "version": "1.5.1",
4
4
  "description": "LLM-native personal wiki system for Claude Code",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -14,6 +14,10 @@
14
14
  "!scripts/bump-version.mjs",
15
15
  "!scripts/smoke-pack.mjs",
16
16
  "!scripts/smoke-plugin.mjs",
17
+ "!scripts/fix-status-verify.mjs",
18
+ "!scripts/lib/fix-status-verify.mjs",
19
+ "!scripts/lib/fix-manifest.mjs",
20
+ "!scripts/lib/adr-corpus.mjs",
17
21
  "commands/",
18
22
  "hooks/",
19
23
  "skills/",
@@ -1,13 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * check-tracker-ids.mjs — CLI gate: no wiki-internal tracker IDs (ISSUE-N,
4
- * fix #N) in OSS-public artifacts.
4
+ * fix #N, FEAT-N, IMPR-N, PRAC-N) in OSS-public artifacts.
5
5
  *
6
6
  * Rule source: CLAUDE.md learned_behaviors (no-internal-tracker-ids-in-oss-
7
7
  * artifacts, 2026-06-09). The repo ships through npm + a Claude Code plugin;
8
- * a `fix #N` / `ISSUE-N` in any shipped file or in README/CHANGELOG is a
9
- * dangling pointer into the maintainer's PRIVATE wiki tracker. GitHub refs
10
- * (`PR #N`, `(#N)`, `#N`, issue URLs) are legitimate and never flagged.
8
+ * an `ISSUE-N` / `fix #N` / `FEAT-N` / `IMPR-N` / `PRAC-N` in any shipped file or
9
+ * in README/CHANGELOG is a dangling pointer into the maintainer's PRIVATE wiki
10
+ * tracker. GitHub refs (`PR #N`, `(#N)`, `#N`, issue URLs) are legitimate and
11
+ * never flagged.
11
12
  *
12
13
  * Modes:
13
14
  * --all (default) Walk the public-artifact scope and scan every text
@@ -31,14 +32,16 @@
31
32
  * auto-ships it) + shipped trees commands/ hooks/ scripts/ skills/ templates/
32
33
  * docs/ .github/ .claude-plugin/. Excluded: tests/ and qa-runs/ (internal
33
34
  * maintainer artifacts — a tracker id in a test description aids traceability
34
- * and never reaches an installed user), node_modules/, .git/. The checker's OWN
35
- * sources are scanned (NOT exempt); their examples use `N` placeholders so they
36
- * stay clean.
35
+ * and never reaches an installed user), node_modules/, .git/, and the
36
+ * fix-status-verify subsystem (EXCLUDED_FILES maintainer-only, carries wiki
37
+ * `decisions/` paths as runtime data, un-shipped from the npm package). The
38
+ * checker's OWN sources are scanned (NOT exempt); their examples use `N`
39
+ * placeholders so they stay clean.
37
40
  *
38
- * ADR scope (narrower): README.md, README.ko.md, and docs/ additionally block
39
- * `ADR NNNN` / `decisions/NNNN` wiki-ADR pointers (USER_FACING_PATTERNS). Shipped
40
- * code comments and CHANGELOG history keep their ADR anchors, so this set is
41
- * applied per-file via patternsFor() only inside that doc surface.
41
+ * ADR scope: `ADR NNNN` / `decisions/NNNN` wiki-ADR pointers (DECISION_PATTERNS)
42
+ * are blocked everywhere in scope EXCEPT CHANGELOG.md, whose version history
43
+ * legitimately cites the decision behind a release line. patternsFor() applies the
44
+ * broader set to every in-scope file but the CHANGELOG.
42
45
  */
43
46
 
44
47
  import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
@@ -50,7 +53,7 @@ import {
50
53
  stripScissors,
51
54
  messageHasGitTemplate,
52
55
  BLOCKED_PATTERNS,
53
- USER_FACING_PATTERNS,
56
+ DECISION_PATTERNS,
54
57
  TAG_BODY_PATTERNS,
55
58
  } from './lib/check-tracker-ids.mjs';
56
59
  import {
@@ -70,7 +73,8 @@ const REPO_ROOT = process.env.CHECK_TRACKER_ROOT || join(__dirname, '..');
70
73
 
71
74
  const RULE_REF =
72
75
  'Rule: CLAUDE.md learned_behaviors (no-internal-tracker-ids-in-oss-artifacts). ' +
73
- 'OSS-public artifacts must not reference the private wiki tracker (ISSUE-N / fix #N). ' +
76
+ 'OSS-public artifacts must not reference the private wiki tracker ' +
77
+ '(ISSUE-N / fix #N / FEAT-N / IMPR-N / PRAC-N). ' +
74
78
  'Use a GitHub ref (#N / PR #N) or drop the reference.';
75
79
 
76
80
  // Top-level public-artifact entry points. Files are scanned directly; dirs are
@@ -94,15 +98,49 @@ const SCOPE_DIRS = [
94
98
  // and never reaches an installed user). The checker's OWN sources are NOT
95
99
  // excluded — they use `N` placeholders in their examples so they scan clean.
96
100
  const EXCLUDED_DIRS = new Set(['node_modules', '.git', 'tests', 'qa-runs']);
97
- const EXCLUDED_FILES = new Set();
101
+ // The fix-status-verify subsystem (run only via `npm run fix:verify`, never by a
102
+ // shipped command/hook/skill) is a maintainer evidence-verification tool. Its
103
+ // manifest carries `decisions/NNNN` wiki paths as RUNTIME DATA (resolved against
104
+ // the maintainer's local wiki), so the anchors cannot be stripped the way a
105
+ // comment can. It is removed from the npm package (package.json `files`) and
106
+ // excluded here so the gate does not flag its load-bearing data.
107
+ // Stored POSIX-style ('/'); paths are normalized before lookup (see toPosix).
108
+ const EXCLUDED_FILES = new Set([
109
+ 'scripts/fix-status-verify.mjs',
110
+ 'scripts/lib/fix-status-verify.mjs',
111
+ 'scripts/lib/fix-manifest.mjs',
112
+ 'scripts/lib/adr-corpus.mjs',
113
+ ]);
98
114
 
99
115
  // Only text artifacts. Binary / lockfiles add noise and never carry prose refs.
100
- const TEXT_EXT = new Set(['.md', '.mjs', '.js', '.cjs', '.json', '.yml', '.yaml', '.sh', '.txt']);
116
+ // .svg is included because the shipped docs/assets/ logos are hand-authored XML
117
+ // text whose <title>/<desc>/comment fields could carry a dangling wiki pointer.
118
+ const TEXT_EXT = new Set([
119
+ '.md',
120
+ '.mjs',
121
+ '.js',
122
+ '.cjs',
123
+ '.json',
124
+ '.yml',
125
+ '.yaml',
126
+ '.sh',
127
+ '.txt',
128
+ '.svg',
129
+ ]);
130
+
131
+ // Normalize OS separators to '/' so the two scan modes classify a path the same
132
+ // way on every host: --all builds relPath via relative() (platform sep, so '\' on
133
+ // Windows) while --staged takes git's output (always '/'). Without this, a Windows
134
+ // --staged run would neither exclude the verifier files nor recognize scope dirs.
135
+ // No-op on POSIX (sep is already '/').
136
+ function toPosix(relPath) {
137
+ return sep === '/' ? relPath : relPath.split(sep).join('/');
138
+ }
101
139
 
102
140
  function isExcludedRel(relPath) {
103
- const parts = relPath.split(sep);
104
- if (parts.some((p) => EXCLUDED_DIRS.has(p))) return true;
105
- if (EXCLUDED_FILES.has(relPath)) return true;
141
+ const p = toPosix(relPath);
142
+ if (p.split('/').some((seg) => EXCLUDED_DIRS.has(seg))) return true;
143
+ if (EXCLUDED_FILES.has(p)) return true;
106
144
  return false;
107
145
  }
108
146
 
@@ -116,22 +154,22 @@ function isTextFile(p) {
116
154
  function isInScope(relPath) {
117
155
  if (isExcludedRel(relPath)) return false;
118
156
  if (!isTextFile(relPath)) return false;
119
- if (SCOPE_FILES.includes(relPath)) return true;
120
- return SCOPE_DIRS.includes(relPath.split(sep)[0]);
157
+ const p = toPosix(relPath);
158
+ if (SCOPE_FILES.includes(p)) return true;
159
+ return SCOPE_DIRS.includes(p.split('/')[0]);
121
160
  }
122
161
 
123
- // User-facing prose surface: README.md, README.ko.md, and the docs/ tree. These
124
- // additionally block `ADR NNNN` / `decisions/NNNN` (dangling wiki-ADR pointers).
125
- // Everything else in scope shipped code, CHANGELOG, package.json — keeps its ADR
126
- // anchors, so the broader pattern set is applied here ONLY.
127
- const USER_FACING_DOCS = new Set(['README.md', 'README.ko.md']);
128
- function isUserFacingDoc(relPath) {
129
- return USER_FACING_DOCS.has(relPath) || relPath.split(sep)[0] === 'docs';
130
- }
162
+ // `ADR NNNN` / `decisions/NNNN` are dangling pointers into the maintainer's
163
+ // private wiki ADR set, so they are blocked everywhere in scope EXCEPT the
164
+ // CHANGELOG, whose version history legitimately cites the decision behind a
165
+ // release line (and never reaches an installed user as a live link). The verifier
166
+ // subsystem that carries `decisions/` paths as runtime data is already removed by
167
+ // EXCLUDED_FILES, so it is never reached here.
168
+ const ADR_EXEMPT_FILES = new Set(['CHANGELOG.md']);
131
169
  function patternsFor(relPath) {
132
- return isUserFacingDoc(relPath)
133
- ? [...BLOCKED_PATTERNS, ...USER_FACING_PATTERNS]
134
- : BLOCKED_PATTERNS;
170
+ return ADR_EXEMPT_FILES.has(toPosix(relPath))
171
+ ? BLOCKED_PATTERNS
172
+ : [...BLOCKED_PATTERNS, ...DECISION_PATTERNS];
135
173
  }
136
174
 
137
175
  // Recursively collect in-scope text files under an absolute dir.
@@ -73,6 +73,7 @@ import { fileURLToPath } from 'url';
73
73
  import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
74
74
  import { loadHypoIgnore } from './lib/hypo-ignore.mjs';
75
75
  import { collectPagesCrystallize, extractWikilinks } from './lib/wikilink.mjs';
76
+ import { aggregateColdCandidates } from './lib/page-usage.mjs';
76
77
  import { isValidProjectName } from './lib/project-create.mjs';
77
78
  import { appendPendingTags, checkForbidden } from './lib/schema-vocab.mjs';
78
79
  import {
@@ -83,10 +84,13 @@ import {
83
84
  sessionClosedMarkerPath,
84
85
  readSessionClosedMarker,
85
86
  partitionLintScope,
87
+ isUnderProjectDirs,
86
88
  sessionLogShardPath,
87
89
  sessionLogReadCandidates,
88
90
  sessionLogScopePath,
89
91
  rootLogEntry,
92
+ hasSessionLogHeading,
93
+ hasLogEntry,
90
94
  resolveTranscriptBySessionId,
91
95
  hasUserCloseSignal,
92
96
  commitWikiChanges,
@@ -177,7 +181,7 @@ function parseArgs(argv) {
177
181
  return args;
178
182
  }
179
183
 
180
- // ── session-close hard gate (ADR 0055) ───────────────────────────────────────
184
+ // ── session-close hard gate ───────────────────────────────────────────────────
181
185
  // The marker attests "the USER closed this session". Its evidence transcript is
182
186
  // resolved STRICTLY from the session id (a globally-unique UUID) by globbing the
183
187
  // Claude project dirs — never from a CLI arg. A model owns the whole subprocess
@@ -208,7 +212,7 @@ function requireProjectDir(args, slug) {
208
212
  // flow can self-verify before /compact triggers PreCompact.
209
213
 
210
214
  function runSessionCloseCheck(args) {
211
- // ADR 0046: the check mirrors the FULL PreCompact gate via the shared
215
+ // The check mirrors the FULL PreCompact gate via the shared
212
216
  // precompactGateStatus (close files + lint + design-history + feedback
213
217
  // projection), not just the close files — so a green check means /compact
214
218
  // won't block on a human-fixable issue. Pass --transcript-path to widen the
@@ -221,9 +225,9 @@ function runSessionCloseCheck(args) {
221
225
  // (codex design Finding 2).
222
226
  //
223
227
  // --project=<slug> narrows BOTH the close status and the lint scope to that one
224
- // project: a project-scoped DIAGNOSTIC, NOT the global compact-ready verdict
225
- // (ADR 0046 caveat below). It is check-only the marker writers stay global so
226
- // the marker == compact-ready invariant holds (ADR 0047). When narrowed, the
228
+ // project: a project-scoped DIAGNOSTIC, NOT the global compact-ready verdict.
229
+ // It is check-only: the marker writers stay global so
230
+ // the marker == compact-ready invariant holds. When narrowed, the
227
231
  // transcript widening is suppressed: a transcript touch in some OTHER project
228
232
  // would re-add that project's files to the lint scope and re-block the scoped
229
233
  // check, defeating the point. The global (no --project) check keeps widening.
@@ -238,9 +242,9 @@ function runSessionCloseCheck(args) {
238
242
  });
239
243
  const close = status.close;
240
244
 
241
- // ADR 0047: when a --session-id is supplied, report whether THIS session's
245
+ // When a --session-id is supplied, report whether THIS session's
242
246
  // per-session marker (the Stop-chain completion signal) exists. This is a
243
- // separate field, NOT folded into `ok` `ok` stays the ADR 0046 compact-
247
+ // separate field, NOT folded into `ok`: `ok` stays the compact-
244
248
  // readiness verdict. A green gate with marker_present=false is exactly the
245
249
  // hand-edit close state: close is compact-ready but the Stop hook will
246
250
  // still block until the marker is written.
@@ -328,7 +332,7 @@ function runSessionCloseCheck(args) {
328
332
  }
329
333
  // Beyond the close files: the rest of the PreCompact gate (lint, design-history,
330
334
  // feedback over-cap/conflict). These are what made a "close-complete" check
331
- // disagree with the real /compact gate before ADR 0046.
335
+ // disagree with the real /compact gate before this check was added.
332
336
  for (const b of status.blockers) {
333
337
  if (b.type !== 'close') console.log(` ✗ ${b.reason}`);
334
338
  }
@@ -336,7 +340,7 @@ function runSessionCloseCheck(args) {
336
340
  console.log('');
337
341
  for (const n of status.notices) console.log(` · ${n.reason}`);
338
342
  }
339
- // ADR 0047: surface the per-session marker state (separate from compact-
343
+ // Surface the per-session marker state (separate from compact-
340
344
  // readiness) so a green-but-unmarked close is visible at verify time.
341
345
  if (args.sessionId) {
342
346
  console.log('');
@@ -450,7 +454,7 @@ function todayLocal() {
450
454
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
451
455
  }
452
456
 
453
- // Spec §5.2.7 / §8.3 + ADR 0029: 4 mandatory + 2 optional (`log`, `openQuestions`).
457
+ // Spec §5.2.7 / §8.3: 4 mandatory + 2 optional (`log`, `openQuestions`).
454
458
  // The payload shape MUST mirror that contract — missing a mandatory field is a
455
459
  // payload bug, not a no-op. Caller is the LLM session-close flow, which composes
456
460
  // the payload deliberately; partial payloads must fail loudly so caller fixes
@@ -501,14 +505,14 @@ function validatePayloadShape(payload) {
501
505
  return errs;
502
506
  }
503
507
 
504
- // ── session-close marker (ADR 0022 amendment 2026-05-19) ──────
508
+ // ── session-close marker (amendment 2026-05-19) ───────────────
505
509
  // Standalone marker writer. Used when the LLM closes the session via direct
506
510
  // Write tool calls (not --apply-session-close). Hook `hypo-auto-minimal-
507
511
  // crystallize` is the only Reader; writer authority is intentionally split
508
512
  // between this CLI and the auto-write at the tail of applySessionClose.
509
513
  //
510
514
  // Contract: the marker is written only when the FULL /compact gate
511
- // (precompactGateStatus, ADR 0046) is green. A failed gate exits 1 with no
515
+ // (precompactGateStatus) is green. A failed gate exits 1 with no
512
516
  // marker — the next Stop hook re-blocks.
513
517
 
514
518
  function runMarkSessionClosed(args) {
@@ -520,12 +524,12 @@ function runMarkSessionClosed(args) {
520
524
  // --project=<slug> on --mark is ATTRIBUTION ONLY (the marker's `project` field).
521
525
  // The gate stays GLOBAL — never narrowed — because a marker that narrowed its
522
526
  // gate could attest compact-ready while PreCompact re-checks all of today's
523
- // projects and stays red (the marker == compact-ready invariant, ADR 0047 /
527
+ // projects and stays red (the marker == compact-ready invariant,
524
528
  // codex design finding 1/2). Validate the attribution slug exists as a
525
529
  // directory, exactly as --check does, but only when it is actually used (a
526
530
  // --log-only mark attributes to no project, so --project is moot there).
527
531
  if (args.project && !args.logOnly) requireProjectDir(args, args.project);
528
- // ADR 0047: the per-session marker is the THIRD session-close completion
532
+ // The per-session marker is the THIRD session-close completion
529
533
  // signal (after the PreCompact gate and `--check-session-close`). It must use
530
534
  // the SAME gate that governs /compact — precompactGateStatus — so the marker
531
535
  // can never attest "closed" while /compact would still block. This subsumes
@@ -570,7 +574,7 @@ function runMarkSessionClosed(args) {
570
574
  }
571
575
  process.exit(1);
572
576
  }
573
- // User-close hard gate (ADR 0055): the compact gate above only proves the wiki
577
+ // User-close hard gate: the compact gate above only proves the wiki
574
578
  // is compact-ready; it does NOT prove the USER asked to close. Refuse the marker
575
579
  // unless the transcript carries a genuine user close signal (NL close phrase,
576
580
  // /compact, or an AskUserQuestion close answer). This is the hard backstop for
@@ -618,7 +622,7 @@ function runMarkSessionClosed(args) {
618
622
  scope: args.logOnly ? 'log-only' : 'project',
619
623
  date: status.dates[0],
620
624
  notices: gate.notices,
621
- // ADR 0047: pure feedback-projection drift is a non-blocker the marker
625
+ // pure feedback-projection drift is a non-blocker: the marker
622
626
  // attests "compact-ready (no human-fixable blocker)", and the PreCompact
623
627
  // hook self-heals the projection (feedback-sync --write) at /compact. Surface
624
628
  // the deferral so the caller knows MEMORY/CLAUDE sync is pending, not lost.
@@ -652,7 +656,7 @@ function applySessionClose(args) {
652
656
  // for any actual apply work (readPayload below surfaces "payload is
653
657
  // required" the same way it always has).
654
658
  if (!args.force && !args.payload) {
655
- // ADR 0043: no-payload "already complete?" probe uses the
659
+ // No-payload "already complete?" probe uses the
656
660
  // global invariant, not a recency pick.
657
661
  const probe = sessionCloseGlobalStatus(args.hypoDir);
658
662
  if (probe.ok) {
@@ -752,21 +756,49 @@ function applySessionClose(args) {
752
756
  }
753
757
  const date = payload.date || todayLocal();
754
758
 
755
- // B-1 derive precondition: when `log` is omitted, apply reconstructs the root
756
- // log.md entry from THIS close's sessionLog heading. If sessionLog.entry has no
757
- // `## [<date>] …` heading there is nothing to derive — and on a same-day SECOND
758
- // close the date-level freshness verifier would still pass on the earlier
759
- // close's entry, so the no-write would slip through as ok:true. Fail loud here,
760
- // before any writes (codex pre-commit review).
761
- if (
762
- !payload.log &&
763
- !new RegExp(`^#{1,6} \\[${date}\\]`, 'm').test(payload.sessionLog.entry || '')
764
- ) {
765
- const msg =
766
- `payload.sessionLog.entry has no "## [${date}] …" heading to derive the log.md ` +
767
- `entry from. Give it a dated heading, or supply payload.log explicitly.`;
768
- console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
759
+ // Pre-apply freshness-contract gate: the post-apply verification holds
760
+ // sessionCloseFileStatus's hasSessionLogHeading / hasLogEntry as the
761
+ // definition of "closed today". Enforce that SAME contract on the payload
762
+ // BEFORE writing a byte, so a heading the gate won't recognize is rejected
763
+ // here as a format mismatch not written and then misdiagnosed downstream as
764
+ // "stale" (the "not updated" vs "format mismatch" conflation). All checks
765
+ // exit 1 with stage='pre-apply-verification' and leave the tree untouched.
766
+ const failPreApply = (msg) => {
767
+ console.log(
768
+ args.json
769
+ ? JSON.stringify({ ok: false, stage: 'pre-apply-verification', error: msg }, null, 2)
770
+ : `✗ ${msg}`,
771
+ );
769
772
  process.exit(1);
773
+ };
774
+ // (a) The session-log entry must carry a dated `## [<date>] …` ATX heading. The
775
+ // post-apply gate checks the session-log file for exactly this heading, so a
776
+ // headingless entry would write then false-fail as "stale". This also doubles
777
+ // as the B-1 derive precondition: when `log` is omitted the root log.md entry
778
+ // is reconstructed from THIS heading, and on a same-day SECOND close the
779
+ // date-level verifier would still pass on the earlier entry, so a no-derive
780
+ // would slip through as ok:true. The `!payload.log` branch keeps the original
781
+ // derive-specific wording (a test asserts it).
782
+ if (!hasSessionLogHeading(payload.sessionLog.entry || '', date)) {
783
+ failPreApply(
784
+ !payload.log
785
+ ? `payload.sessionLog.entry has no "## [${date}] …" heading to derive the log.md ` +
786
+ `entry from. Give it a dated heading, or supply payload.log explicitly.`
787
+ : `payload.sessionLog.entry has no "## [${date}] …" heading. The close gate ` +
788
+ `identifies a session-log by its dated ATX heading; give the entry a ` +
789
+ `"## [${date}] <title>" heading (the brackets are required).`,
790
+ );
791
+ }
792
+ // (b) An explicit payload.log entry must match the canonical
793
+ // `## [<date>] session | <project>` line the gate looks for (colon or space
794
+ // delimiter after the slug). Otherwise the write lands but post-apply
795
+ // verification reports log.md as stale. When `log` is omitted the line is
796
+ // derived canonically (rootLogEntry) so this cannot mismatch.
797
+ if (payload.log && !hasLogEntry(payload.log.entry || '', date, project)) {
798
+ failPreApply(
799
+ `payload.log.entry has no "## [${date}] session | ${project}" heading that the ` +
800
+ `close gate recognizes. Fix the entry heading, or omit payload.log to derive it.`,
801
+ );
770
802
  }
771
803
 
772
804
  // Preflight: lint the wiki BEFORE writing any payload bytes. If lint
@@ -793,7 +825,7 @@ function applySessionClose(args) {
793
825
  // payloadScope = every file this apply writes or appends. Both lint passes are
794
826
  // judged against it; errors elsewhere are surfaced as notices, never blocking.
795
827
  //
796
- // session-log needs TWO entries (ADR 0050): the daily WRITE target (what this
828
+ // session-log needs TWO entries: the daily WRITE target (what this
797
829
  // apply creates/appends, judged by post-apply lint) AND the freshness EVIDENCE
798
830
  // file. They coincide except in the hybrid cutover month, where a fallback-
799
831
  // aware no-op (the identical entry already lives in the legacy monthly file)
@@ -837,7 +869,7 @@ function applySessionClose(args) {
837
869
  ok: false,
838
870
  stage: 'preflight-lint',
839
871
  error: 'lint preflight failed — apply aborted (no payload bytes written)',
840
- lint: { ...preflightLint, blockingErrors },
872
+ lint: { ...summarizeLintForOutput(preflightLint), blockingErrors },
841
873
  };
842
874
  if (args.json) {
843
875
  console.log(JSON.stringify(out, null, 2));
@@ -875,7 +907,7 @@ function applySessionClose(args) {
875
907
  const rel = join('projects', project, 'session-log', `${date}.md`);
876
908
  const full = join(args.hypoDir, rel);
877
909
  const isPresent = entryAlreadyPresent(payload.sessionLog.entry);
878
- // Fallback-aware idempotency (ADR 0050 hybrid cutover): during the month the
910
+ // Fallback-aware idempotency (hybrid cutover): during the month the
879
911
  // shard takes over, today's entry may already live in the legacy monthly
880
912
  // file from an earlier (pre-cutover) close. Treat presence in EITHER the
881
913
  // daily shard or the legacy monthly file as "already written" so a same-day
@@ -905,7 +937,7 @@ function applySessionClose(args) {
905
937
  // otherwise mistake it for the evidence file. The dated `## [date] ...`
906
938
  // heading lives inside the entry, so freshness / derive / design-history
907
939
  // are unchanged.
908
- // PRAC-17 audit fields. The shard frontmatter is git-tracked and synced, so
940
+ // Audit fields (device, session_id). The shard frontmatter is git-tracked and synced, so
909
941
  // `device` is an INTENTIONAL synced multi-machine identifier (privacy note:
910
942
  // docs/ARCHITECTURE.md). It is a CREATOR-only stamp — only the session/
911
943
  // machine that first seeds the daily shard is recorded; later same-day
@@ -1037,11 +1069,17 @@ function applySessionClose(args) {
1037
1069
  const postLintOk = !postApplyCrashed && postBlocking.length === 0;
1038
1070
  const ok = verification.ok && postLintOk;
1039
1071
 
1040
- // ADR 0022 amendment 2026-05-19: auto-write the per-session
1072
+ // Scope the non-blocking notice to the close-target project: debt under
1073
+ // projects/<project>/ stays listed; debt elsewhere folds to a count so the
1074
+ // same untouched-file debt does not re-list its filenames on every close.
1075
+ const closeScopeNotice = postNotice.filter((e) => isUnderProjectDirs(e.file, [project]));
1076
+ const otherDebtCount = postNotice.length - closeScopeNotice.length;
1077
+
1078
+ // Amendment 2026-05-19: auto-write the per-session
1041
1079
  // closed marker on a verified close. Hook authority is read-only; this is
1042
1080
  // one of the two writer paths (the other is --mark-session-closed standalone).
1043
1081
  //
1044
- // ADR 0047: the marker write is governed by the SAME gate as standalone
1082
+ // The marker write is governed by the SAME gate as standalone
1045
1083
  // --mark-session-closed and /compact (precompactGateStatus), NOT just apply's
1046
1084
  // `ok` + git-clean. Apply's payload preflight/post-apply lint and `ok` still
1047
1085
  // govern apply SUCCESS (exit code below), but the marker must additionally
@@ -1049,13 +1087,13 @@ function applySessionClose(args) {
1049
1087
  // path could issue a marker the standalone path would refuse (the second
1050
1088
  // divergence codex flagged).
1051
1089
  //
1052
- // ADR 0056: apply just wrote the payload, so the tree is dirty by its OWN
1053
- // writes the gate's `uncommitted` git blocker would always trip and the
1090
+ // Apply just wrote the payload, so the tree is dirty by its OWN
1091
+ // writes: the gate's `uncommitted` git blocker would always trip and the
1054
1092
  // marker would be skipped, deferring the close to a manual --mark-session-closed
1055
- // (the ADR 0047 "done but still blocked" regression). Commit the payload HERE, via
1093
+ // ("done but still blocked" regression). Commit the payload HERE, via
1056
1094
  // the SAME .hypoignore-aware helper the auto-commit Stop hook uses, so the gate sees
1057
1095
  // a committed tree. Push stays deferred to the Stop hook; the resulting
1058
- // committed-but-unpushed state is a gate notice, not a blocker (ADR 0056), so
1096
+ // committed-but-unpushed state is a gate notice, not a blocker, so
1059
1097
  // this still marks. A commit failure (not a repo / pre-commit reject / git error)
1060
1098
  // skips the marker WITH a surfaced reason — today's behavior was also "no marker",
1061
1099
  // but silently.
@@ -1077,14 +1115,14 @@ function applySessionClose(args) {
1077
1115
  // (feedback/W8/hot/lint — git is now committed) is resolved.
1078
1116
  markerSkipReason = 'compact-gate-not-ok';
1079
1117
  } else if (!closeTranscript || !hasUserCloseSignal(closeTranscript)) {
1080
- // User-close hard gate (ADR 0055): apply succeeded (payload files written)
1118
+ // User-close hard gate: apply succeeded (payload files written)
1081
1119
  // but the user never signalled session close, so the marker — which attests
1082
1120
  // "user closed" — is withheld. The wiki record stands; the session is simply
1083
1121
  // not marked closed. Surfaced (not silent) so the caller knows.
1084
1122
  markerSkipReason = closeTranscript ? 'no-user-close-signal' : 'transcript-unresolved';
1085
1123
  } else {
1086
1124
  writeSessionClosedMarker(args.hypoDir, args.sessionId, { project });
1087
- // Codex CONCERN (ADR 0055/0056): the writer swallows IO errors (best-effort).
1125
+ // Codex CONCERN: the writer swallows IO errors (best-effort).
1088
1126
  // Verify the file actually landed — mirroring the standalone path — instead of
1089
1127
  // asserting markerWritten=true, so a .cache permission/disk problem surfaces
1090
1128
  // rather than the caller reporting "closed" while the next Stop re-blocks.
@@ -1111,13 +1149,22 @@ function applySessionClose(args) {
1111
1149
  applied,
1112
1150
  skipped,
1113
1151
  verification,
1114
- // ADR 0055: surface the marker outcome instead of skipping silently, so the
1152
+ // Surface the marker outcome instead of skipping silently, so the
1115
1153
  // caller can tell "closed" from "applied but not marked".
1116
1154
  ...(args.sessionId ? { markerWritten, markerSkipReason } : {}),
1117
- lint: { preflight: preflightLint, postApply: postApplyLint },
1118
- // Pre-existing lint debt in files this close did not author (Bug B): surfaced
1119
- // for visibility, never gated. Empty on a clean vault.
1120
- notices: [...new Set(postNotice.map((e) => e.file))],
1155
+ lint: {
1156
+ preflight: summarizeLintForOutput(preflightLint),
1157
+ postApply: summarizeLintForOutput(postApplyLint),
1158
+ },
1159
+ // Pre-existing lint debt in files this close did not author: surfaced for
1160
+ // visibility, never gated. Empty on a clean vault. Scoped to the close-target
1161
+ // project's own dir — debt under projects/<project>/ is this close's
1162
+ // neighborhood and stays listed; debt elsewhere (other projects, shared
1163
+ // pages, root files) folds into otherDebtCount so the same untouched-file
1164
+ // debt does not re-list its filenames on every close (run `node
1165
+ // scripts/lint.mjs` for the full list).
1166
+ notices: [...new Set(closeScopeNotice.map((e) => e.file))],
1167
+ otherDebtCount,
1121
1168
  };
1122
1169
 
1123
1170
  if (args.json) {
@@ -1171,13 +1218,18 @@ function applySessionClose(args) {
1171
1218
  console.log(' Payload introduced a lint blocker — fix the payload content and retry.');
1172
1219
  }
1173
1220
  }
1174
- if (postNotice.length > 0) {
1221
+ if (closeScopeNotice.length > 0) {
1175
1222
  console.log(
1176
- `\n· ${postNotice.length} pre-existing lint issue(s) in untouched files (not blocking): ${[
1177
- ...new Set(postNotice.map((e) => e.file)),
1223
+ `\n· ${closeScopeNotice.length} pre-existing lint issue(s) in untouched files (not blocking): ${[
1224
+ ...new Set(closeScopeNotice.map((e) => e.file)),
1178
1225
  ]
1179
1226
  .slice(0, 5)
1180
- .join(', ')}${postNotice.length > 5 ? ', …' : ''}`,
1227
+ .join(', ')}${closeScopeNotice.length > 5 ? ', …' : ''}`,
1228
+ );
1229
+ }
1230
+ if (otherDebtCount > 0) {
1231
+ console.log(
1232
+ `\n· +${otherDebtCount} pre-existing lint issue(s) elsewhere in the vault (other projects / shared pages, not blocking) — run \`node scripts/lint.mjs\` for the full list.`,
1181
1233
  );
1182
1234
  }
1183
1235
  }
@@ -1186,6 +1238,29 @@ function applySessionClose(args) {
1186
1238
 
1187
1239
  // ── helpers ──────────────────────────────────────────────────────────────────
1188
1240
 
1241
+ // Cap the warn list in MODEL-FACING lint output. `result.lint` is serialized
1242
+ // into the --json apply result the documented close path reads, and lint runs
1243
+ // twice per close (preflight + post-apply), so an un-capped warn list (e.g.
1244
+ // hundreds of broken-wikilink warnings) lands in model context twice on every
1245
+ // close. Errors stay full (few, actionable); warns collapse to a count plus a
1246
+ // small sample for quick diagnosis. The INTERNAL preflightLint / postApplyLint
1247
+ // objects are untouched — the blocking filter and the pending-tag scan still
1248
+ // read the full warn list. lint.mjs --json itself also stays full: its
1249
+ // programmatic consumers (the pending-tag scan, the PreCompact W8 filter, tests)
1250
+ // need every warn.
1251
+ function summarizeLintForOutput(l) {
1252
+ const SAMPLE = 10;
1253
+ const warns = l.warns || [];
1254
+ const out = {
1255
+ ok: l.ok,
1256
+ errors: l.errors || [],
1257
+ warnCount: warns.length,
1258
+ warns: warns.slice(0, SAMPLE),
1259
+ };
1260
+ if (warns.length > SAMPLE) out.warnsTruncated = warns.length - SAMPLE;
1261
+ return out;
1262
+ }
1263
+
1189
1264
  function parseFrontmatter(content) {
1190
1265
  const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
1191
1266
  if (!m) return null;
@@ -1271,8 +1346,12 @@ const synthesisGroups = Object.entries(tagGroups)
1271
1346
  .sort((a, b) => b[1].length - a[1].length)
1272
1347
  .map(([tag, pages]) => ({ tag, pages }));
1273
1348
 
1349
+ // Lookup-cold candidates (B): pages with inbound wikilinks that lookup has not
1350
+ // injected within the recency window. Advisory only, never gates, never mutates.
1351
+ const coldCandidates = aggregateColdCandidates(args.hypoDir, { ignorePatterns });
1352
+
1274
1353
  if (args.json) {
1275
- console.log(JSON.stringify({ synthesisGroups, unlinked, drafts }, null, 2));
1354
+ console.log(JSON.stringify({ synthesisGroups, unlinked, drafts, coldCandidates }, null, 2));
1276
1355
  process.exit(0);
1277
1356
  }
1278
1357
 
@@ -1302,6 +1381,25 @@ if (drafts.length > 0) {
1302
1381
  console.log('');
1303
1382
  }
1304
1383
 
1384
+ // Advisory (non-gating): pages the graph treats as live but lookup has not
1385
+ // injected recently. Held until enough page-usage history accrues.
1386
+ if (coldCandidates.status === 'ok' && coldCandidates.candidates.length > 0) {
1387
+ found = true;
1388
+ console.log(
1389
+ `Lookup-cold pages (${coldCandidates.candidates.length}), inbound links but not injected recently:`,
1390
+ );
1391
+ for (const p of coldCandidates.candidates) console.log(` [[${p.slug}]] (${p.title})`);
1392
+ console.log('');
1393
+ } else if (
1394
+ coldCandidates.status === 'insufficient-data' &&
1395
+ coldCandidates.reason === 'span-too-short'
1396
+ ) {
1397
+ // Only surface the "held" notice once a log is actually accruing (span under
1398
+ // the cold-start window). A vault with no log at all stays silent so this
1399
+ // advisory never becomes permanent noise on every crystallize run.
1400
+ console.log('Lookup-cold scan held: not enough page-usage history yet (advisory).\n');
1401
+ }
1402
+
1305
1403
  if (!found) {
1306
1404
  console.log('✓ No crystallization candidates found — Hypomnema looks well-connected.');
1307
1405
  }