hypomnema 1.4.1 → 1.5.0

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 (69) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.ko.md +1 -1
  4. package/README.md +1 -1
  5. package/commands/audit.md +1 -1
  6. package/commands/crystallize.md +11 -9
  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 +2 -2
  21. package/docs/CONTRIBUTING.md +20 -12
  22. package/hooks/hypo-auto-commit.mjs +2 -2
  23. package/hooks/hypo-auto-minimal-crystallize.mjs +30 -18
  24. package/hooks/hypo-compact-guard.mjs +2 -2
  25. package/hooks/hypo-cwd-change.mjs +27 -37
  26. package/hooks/hypo-personal-check.mjs +37 -22
  27. package/hooks/hypo-session-end.mjs +1 -1
  28. package/hooks/hypo-session-record.mjs +2 -2
  29. package/hooks/hypo-session-start.mjs +34 -40
  30. package/hooks/hypo-shared.mjs +364 -82
  31. package/hooks/version-check.mjs +1 -1
  32. package/package.json +5 -1
  33. package/scripts/check-tracker-ids.mjs +69 -31
  34. package/scripts/crystallize.mjs +322 -70
  35. package/scripts/doctor.mjs +7 -7
  36. package/scripts/feedback-sync.mjs +5 -5
  37. package/scripts/feedback.mjs +9 -10
  38. package/scripts/init.mjs +7 -7
  39. package/scripts/lib/check-tracker-ids.mjs +90 -32
  40. package/scripts/lib/design-history-stale.mjs +1 -1
  41. package/scripts/lib/extensions.mjs +7 -7
  42. package/scripts/lib/failure-type.mjs +1 -1
  43. package/scripts/lib/feedback-scope.mjs +1 -1
  44. package/scripts/lib/project-create.mjs +25 -7
  45. package/scripts/lib/schema-vocab.mjs +105 -1
  46. package/scripts/lib/template-schema-version.mjs +1 -1
  47. package/scripts/lib/wd-match.mjs +181 -0
  48. package/scripts/lib/wikilink.mjs +1 -1
  49. package/scripts/lint.mjs +14 -6
  50. package/scripts/rename.mjs +1 -1
  51. package/scripts/resume.mjs +20 -22
  52. package/scripts/session-audit.mjs +1 -1
  53. package/scripts/stats.mjs +3 -3
  54. package/scripts/uninstall.mjs +3 -3
  55. package/scripts/upgrade.mjs +17 -18
  56. package/skills/crystallize/SKILL.md +16 -8
  57. package/skills/graph/SKILL.md +2 -2
  58. package/skills/ingest/SKILL.md +4 -4
  59. package/skills/lint/SKILL.md +2 -2
  60. package/skills/query/SKILL.md +2 -2
  61. package/skills/verify/SKILL.md +2 -2
  62. package/templates/SCHEMA.md +12 -4
  63. package/templates/hypo-config.md +1 -1
  64. package/templates/hypo-guide.md +24 -15
  65. package/templates/projects/_template/hot.md +1 -1
  66. package/scripts/fix-status-verify.mjs +0 -256
  67. package/scripts/lib/adr-corpus.mjs +0 -79
  68. package/scripts/lib/fix-manifest.mjs +0 -109
  69. package/scripts/lib/fix-status-verify.mjs +0 -439
@@ -13,6 +13,11 @@
13
13
  * --hypo-dir=<path> Hypomnema root (default: resolved via HYPO_DIR / hypo-config.md / ~/hypomnema)
14
14
  * --min-group=<n> Min pages per tag group to report (default: 2)
15
15
  * --check-session-close Verify the strict session-close memory files — 5 mandatory + open-questions conditional
16
+ * --project=<slug> Override the recency-inferred project on --check / --mark (single segment
17
+ * [A-Za-z0-9._-]+, projects/<slug>/ must exist). On --check it NARROWS the
18
+ * gate to that one project — a project-scoped diagnostic, NOT a global
19
+ * compact-ready verdict. On --mark it is ATTRIBUTION only; the gate stays
20
+ * global (the marker == compact-ready invariant). Ignored on --apply.
16
21
  * --apply-session-close Apply a JSON payload that updates the 5 mandatory memory files
17
22
  * (+ optional open-questions). Idempotent — re-running with the same
18
23
  * payload is a no-op. Always finishes with the strict gate check.
@@ -31,13 +36,13 @@
31
36
  *
32
37
  * Payload schema:
33
38
  * {
34
- * "project": "<slug>", // optionaldefaults to resolveActiveProject()
39
+ * "project": "<slug>", // REQUIREDsingle segment [A-Za-z0-9._-]+ (≥1 alnum, not dot-only), projects/<slug>/ dir must exist (B-3: no recency fallback for apply)
35
40
  * "date": "YYYY-MM-DD", // optional — defaults to today (local)
36
41
  * "sessionState": { "content": "<full file>" }, // overwrite (idempotent: identical bytes → skip)
37
42
  * "projectHot": { "content": "<full file>" }, // overwrite
38
43
  * "rootHot": { "content": "<full file>" }, // overwrite
39
44
  * "sessionLog": { "entry": "## [date] ..." }, // append, skip if heading already present
40
- * "log": { "entry": "## [date] session | <project> ..." }, // append, skip if entry present
45
+ * "log": { "entry": "## [date] session | <project> ..." }, // OPTIONAL (B-1): omit it and apply derives the root log.md entry from this close's sessionLog heading; supply it only for a deliberately custom log line
41
46
  * "openQuestions":{ "content": "<full file>" } // optional overwrite
42
47
  * }
43
48
  *
@@ -60,7 +65,7 @@
60
65
  * hard-fails regardless of scope.
61
66
  */
62
67
 
63
- import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'fs';
68
+ import { existsSync, statSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'fs';
64
69
  import { join, dirname } from 'path';
65
70
  import { hostname } from 'os';
66
71
  import { spawnSync } from 'child_process';
@@ -68,6 +73,8 @@ import { fileURLToPath } from 'url';
68
73
  import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
69
74
  import { loadHypoIgnore } from './lib/hypo-ignore.mjs';
70
75
  import { collectPagesCrystallize, extractWikilinks } from './lib/wikilink.mjs';
76
+ import { isValidProjectName } from './lib/project-create.mjs';
77
+ import { appendPendingTags, checkForbidden } from './lib/schema-vocab.mjs';
71
78
  import {
72
79
  sessionCloseFileStatus,
73
80
  sessionCloseGlobalStatus,
@@ -76,15 +83,21 @@ import {
76
83
  sessionClosedMarkerPath,
77
84
  readSessionClosedMarker,
78
85
  partitionLintScope,
86
+ isUnderProjectDirs,
79
87
  sessionLogShardPath,
80
88
  sessionLogReadCandidates,
81
89
  sessionLogScopePath,
90
+ rootLogEntry,
82
91
  resolveTranscriptBySessionId,
83
92
  hasUserCloseSignal,
84
93
  commitWikiChanges,
85
94
  } from '../hooks/hypo-shared.mjs';
86
95
 
87
- const LINT_SCRIPT = join(dirname(fileURLToPath(import.meta.url)), 'lint.mjs');
96
+ // This script's own absolute path. Used to print copy-pasteable recovery
97
+ // commands as `node <SELF_SCRIPT> ...` rather than a bare `crystallize` bin,
98
+ // which is not on PATH in a Claude Code plugin install (only in an npm global).
99
+ const SELF_SCRIPT = fileURLToPath(import.meta.url);
100
+ const LINT_SCRIPT = join(dirname(SELF_SCRIPT), 'lint.mjs');
88
101
 
89
102
  // Spawn lint.mjs --json against `hypoDir` and return parsed result.
90
103
  // We shell out instead of refactoring lint.mjs into a library because lint.mjs
@@ -133,6 +146,7 @@ function parseArgs(argv) {
133
146
  payload: null,
134
147
  force: false,
135
148
  transcriptPath: null,
149
+ project: null,
136
150
  };
137
151
  for (const arg of argv.slice(2)) {
138
152
  if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
@@ -144,14 +158,27 @@ function parseArgs(argv) {
144
158
  else if (arg.startsWith('--session-id=')) args.sessionId = arg.slice(13);
145
159
  else if (arg.startsWith('--payload=')) args.payload = arg.slice(10);
146
160
  else if (arg.startsWith('--transcript-path=')) args.transcriptPath = expandHome(arg.slice(18));
161
+ else if (arg.startsWith('--project=')) args.project = arg.slice(10);
147
162
  else if (arg === '--force') args.force = true;
148
163
  else if (arg === '--json') args.json = true;
149
164
  }
150
165
  if (!args.hypoDir) args.hypoDir = resolveHypoRoot();
166
+ // --project=<slug> override (check/mark only). Validate the SYNTAX here so a
167
+ // traversal/charset attack (`--project=../x`) is rejected before any path is
168
+ // built from it — sessionCloseFileStatus(projectOverride) joins it directly.
169
+ // isValidProjectName is the SHARED validator (project-create.mjs), so the
170
+ // override accepts exactly the namespace createProject can scaffold. Existence
171
+ // (a real projects/<slug>/ directory) is checked in the run functions, where
172
+ // hypoDir is resolved and only the check/mark paths consume --project.
173
+ if (args.project != null && !isValidProjectName(args.project)) {
174
+ const msg = `--project "${args.project}" is not a valid project name (need a single segment with ≥1 alnum, charset A-Za-z0-9._-, not "."/"..")`;
175
+ console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
176
+ process.exit(1);
177
+ }
151
178
  return args;
152
179
  }
153
180
 
154
- // ── session-close hard gate (ADR 0055) ───────────────────────────────────────
181
+ // ── session-close hard gate ───────────────────────────────────────────────────
155
182
  // The marker attests "the USER closed this session". Its evidence transcript is
156
183
  // resolved STRICTLY from the session id (a globally-unique UUID) by globbing the
157
184
  // Claude project dirs — never from a CLI arg. A model owns the whole subprocess
@@ -163,12 +190,26 @@ function parseArgs(argv) {
163
190
  // closed.) `--transcript-path` survives ONLY for `--check-session-close`'s lint
164
191
  // scope, which writes no marker and so cannot cause an over-close.
165
192
 
193
+ // Validate that an explicit --project=<slug> override names a real project
194
+ // DIRECTORY. Syntax was already checked in parseArgs; this is the existence half,
195
+ // mirroring apply's payload.project check — a regular file or an absent dir at
196
+ // projects/<slug> is a hard error so the override never silently resolves to an
197
+ // all-missing status (which a reader would misread as "exists but incomplete").
198
+ function requireProjectDir(args, slug) {
199
+ const projectDir = join(args.hypoDir, 'projects', slug);
200
+ if (!existsSync(projectDir) || !statSync(projectDir).isDirectory()) {
201
+ const msg = `--project "${slug}" does not exist as a directory (no projects/${slug}/ directory)`;
202
+ console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
203
+ process.exit(1);
204
+ }
205
+ }
206
+
166
207
  // ── session-close check (spec §5.2.7 / §8.3) ────────────────────────
167
208
  // Mirrors the hard gate in hypo-personal-check.mjs so the /hypo:crystallize
168
209
  // flow can self-verify before /compact triggers PreCompact.
169
210
 
170
211
  function runSessionCloseCheck(args) {
171
- // ADR 0046: the check mirrors the FULL PreCompact gate via the shared
212
+ // The check mirrors the FULL PreCompact gate via the shared
172
213
  // precompactGateStatus (close files + lint + design-history + feedback
173
214
  // projection), not just the close files — so a green check means /compact
174
215
  // won't block on a human-fixable issue. Pass --transcript-path to widen the
@@ -179,15 +220,28 @@ function runSessionCloseCheck(args) {
179
220
  // (marker_present:true) while `ok` still reflected the stale active project —
180
221
  // the completion-signal trio (PreCompact / --check / marker) would diverge
181
222
  // (codex design Finding 2).
223
+ //
224
+ // --project=<slug> narrows BOTH the close status and the lint scope to that one
225
+ // project: a project-scoped DIAGNOSTIC, NOT the global compact-ready verdict.
226
+ // It is check-only: the marker writers stay global so
227
+ // the marker == compact-ready invariant holds. When narrowed, the
228
+ // transcript widening is suppressed: a transcript touch in some OTHER project
229
+ // would re-add that project's files to the lint scope and re-block the scoped
230
+ // check, defeating the point. The global (no --project) check keeps widening.
231
+ if (args.project) requireProjectDir(args, args.project);
182
232
  const status = precompactGateStatus(args.hypoDir, {
183
- ...(args.transcriptPath ? { transcriptPath: args.transcriptPath } : {}),
233
+ ...(args.project
234
+ ? { projectOverride: args.project }
235
+ : args.transcriptPath
236
+ ? { transcriptPath: args.transcriptPath }
237
+ : {}),
184
238
  ...(args.sessionId ? { sessionId: args.sessionId } : {}),
185
239
  });
186
240
  const close = status.close;
187
241
 
188
- // ADR 0047: when a --session-id is supplied, report whether THIS session's
242
+ // When a --session-id is supplied, report whether THIS session's
189
243
  // per-session marker (the Stop-chain completion signal) exists. This is a
190
- // separate field, NOT folded into `ok` `ok` stays the ADR 0046 compact-
244
+ // separate field, NOT folded into `ok`: `ok` stays the compact-
191
245
  // readiness verdict. A green gate with marker_present=false is exactly the
192
246
  // hand-edit close state: close is compact-ready but the Stop hook will
193
247
  // still block until the marker is written.
@@ -198,9 +252,18 @@ function runSessionCloseCheck(args) {
198
252
  // while /compact's Stop still blocks — the exact incoherence this ADR closes
199
253
  // (codex pre-commit CONCERN). readSessionClosedMarker unlinks an invalid
200
254
  // marker as it reads, matching the hook's behavior on the next Stop.
201
- const markerPresent = args.sessionId
202
- ? readSessionClosedMarker(args.hypoDir, args.sessionId) !== null
203
- : null;
255
+ const markerObj = args.sessionId ? readSessionClosedMarker(args.hypoDir, args.sessionId) : null;
256
+ const markerPresent = args.sessionId ? markerObj !== null : null;
257
+
258
+ // Scope of this check (codex design review finding 2 — the scope must be
259
+ // explicit in JSON + prose, not implied). `global` = the full PreCompact mirror
260
+ // (green ⇒ compact-ready). `project` = narrowed to --project=<slug> (green ⇒
261
+ // only THAT project is close-complete, NOT global compact-readiness). When a
262
+ // log-only marker governs the session, the gate runs in log-only mode and the
263
+ // --project override is IGNORED — surface that rather than implying X was
264
+ // checked (it was not).
265
+ const logOnlyWon = args.project != null && markerObj?.scope === 'log-only';
266
+ const scope = args.project ? (logOnlyWon ? 'log-only' : 'project') : 'global';
204
267
 
205
268
  if (args.json) {
206
269
  console.log(
@@ -215,6 +278,14 @@ function runSessionCloseCheck(args) {
215
278
  blockers: status.blockers,
216
279
  notices: status.notices,
217
280
  skipped: status.skipped,
281
+ // scope is additive; `global` keeps prior semantics for existing readers
282
+ scope,
283
+ ...(args.project
284
+ ? {
285
+ scoped_project: args.project,
286
+ ...(logOnlyWon ? { project_override_ignored: true } : {}),
287
+ }
288
+ : {}),
218
289
  ...(args.sessionId ? { session_id: args.sessionId, marker_present: markerPresent } : {}),
219
290
  },
220
291
  null,
@@ -224,8 +295,20 @@ function runSessionCloseCheck(args) {
224
295
  process.exit(status.ok ? 0 : 1);
225
296
  }
226
297
 
298
+ if (logOnlyWon) {
299
+ console.log(
300
+ `Note: a log-only session-closed marker governs session ${args.sessionId}, so the gate ran in log-only mode and --project=${args.project} was IGNORED (no project was checked).\n`,
301
+ );
302
+ } else if (scope === 'project') {
303
+ console.log(
304
+ `Note: --project=${args.project} — this is a PROJECT-SCOPED diagnostic, not the global /compact gate. A green result means only ${args.project} is close-complete; another project can still block /compact.\n`,
305
+ );
306
+ }
307
+
227
308
  const proj = close.project || '(unresolved)';
228
- console.log(`Compact-ready check (project: ${proj}, date: ${close.dates.join(' / ')}):\n`);
309
+ console.log(
310
+ `Compact-ready check (${scope === 'global' ? `project: ${proj}` : `scope: ${scope}, project: ${proj}`}, date: ${close.dates.join(' / ')}):\n`,
311
+ );
229
312
 
230
313
  const required = close.project
231
314
  ? [
@@ -246,7 +329,7 @@ function runSessionCloseCheck(args) {
246
329
  }
247
330
  // Beyond the close files: the rest of the PreCompact gate (lint, design-history,
248
331
  // feedback over-cap/conflict). These are what made a "close-complete" check
249
- // disagree with the real /compact gate before ADR 0046.
332
+ // disagree with the real /compact gate before this check was added.
250
333
  for (const b of status.blockers) {
251
334
  if (b.type !== 'close') console.log(` ✗ ${b.reason}`);
252
335
  }
@@ -254,22 +337,32 @@ function runSessionCloseCheck(args) {
254
337
  console.log('');
255
338
  for (const n of status.notices) console.log(` · ${n.reason}`);
256
339
  }
257
- // ADR 0047: surface the per-session marker state (separate from compact-
340
+ // Surface the per-session marker state (separate from compact-
258
341
  // readiness) so a green-but-unmarked close is visible at verify time.
259
342
  if (args.sessionId) {
260
343
  console.log('');
261
344
  console.log(
262
345
  markerPresent
263
346
  ? ` ✓ session-closed marker present (session_id: ${args.sessionId}).`
264
- : ` · session-closed marker absent (session_id: ${args.sessionId}) — the Stop hook will block until it is written. Run \`crystallize --mark-session-closed --session-id=${args.sessionId}${args.transcriptPath ? ` --transcript-path=${args.transcriptPath}` : ''}\`.`,
347
+ : ` · session-closed marker absent (session_id: ${args.sessionId}) — the Stop hook will block until it is written. Run \`node "${SELF_SCRIPT}" --mark-session-closed --session-id=${args.sessionId}${args.transcriptPath ? ` --transcript-path="${args.transcriptPath}"` : ''}\`.`,
265
348
  );
266
349
  }
267
350
  console.log('');
268
- console.log(
269
- status.ok
270
- ? '✓ Compact-ready no PreCompact gate blocker needs a human fix. (open-questions.md: conditional, not checked. The live /compact can still differ on a context-≥70% prompt, HYPO_SKIP_GATE, or a transcript-scoped lint error this check did not see — pass --transcript-path to include the latter.)'
271
- : '✗ Not compact-ready — resolve the ✗ items above, then retry. /compact would block on these.',
272
- );
351
+ if (scope === 'project') {
352
+ // Project-scoped diagnostic: green means ONLY this project is close-complete.
353
+ // Do NOT claim global compact-readiness (the whole point of the narrow).
354
+ console.log(
355
+ status.ok
356
+ ? `✓ ${args.project} is close-complete (project-scoped). This is NOT a global /compact guarantee — run \`--check-session-close\` without --project for that.`
357
+ : `✗ ${args.project} is not close-complete — resolve the ✗ items above.`,
358
+ );
359
+ } else {
360
+ console.log(
361
+ status.ok
362
+ ? '✓ Compact-ready — no PreCompact gate blocker needs a human fix. (open-questions.md: conditional, not checked. The live /compact can still differ on a context-≥70% prompt, HYPO_SKIP_GATE, or a transcript-scoped lint error this check did not see — pass --transcript-path to include the latter.)'
363
+ : '✗ Not compact-ready — resolve the ✗ items above, then retry. /compact would block on these.',
364
+ );
365
+ }
273
366
  process.exit(status.ok ? 0 : 1);
274
367
  }
275
368
 
@@ -358,18 +451,19 @@ function todayLocal() {
358
451
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
359
452
  }
360
453
 
361
- // Spec §5.2.7 / §8.3 + ADR 0029: 5 mandatory + 1 conditional. The payload
362
- // shape MUST mirror that contract — missing a mandatory field is a payload
363
- // bug, not a no-op. Caller is the LLM session-close flow, which composes the
364
- // payload deliberately; partial payloads must fail loudly so caller fixes them
365
- // rather than silently relying on yesterday's freshness state. (Codex review
366
- // of the apply path — Worker 1 finding 1.)
454
+ // Spec §5.2.7 / §8.3: 4 mandatory + 2 optional (`log`, `openQuestions`).
455
+ // The payload shape MUST mirror that contract — missing a mandatory field is a
456
+ // payload bug, not a no-op. Caller is the LLM session-close flow, which composes
457
+ // the payload deliberately; partial payloads must fail loudly so caller fixes
458
+ // them rather than silently relying on yesterday's freshness state. (Codex review
459
+ // of the apply path — Worker 1 finding 1.) `log` left the mandatory set in B-1:
460
+ // the root log.md entry is a DERIVABLE artifact (rootLogEntry over this close's
461
+ // sessionLog heading), so apply auto-fills it when the field is absent.
367
462
  const REQUIRED_PAYLOAD_FIELDS = [
368
463
  ['sessionState', 'content'],
369
464
  ['projectHot', 'content'],
370
465
  ['rootHot', 'content'],
371
466
  ['sessionLog', 'entry'],
372
- ['log', 'entry'],
373
467
  ];
374
468
 
375
469
  function validatePayloadShape(payload) {
@@ -397,20 +491,25 @@ function validatePayloadShape(payload) {
397
491
  errs.push('payload.openQuestions, when present, must be { content: string }');
398
492
  }
399
493
  }
494
+ if (payload.log !== undefined) {
495
+ if (!payload.log || typeof payload.log !== 'object' || typeof payload.log.entry !== 'string') {
496
+ errs.push('payload.log, when present, must be { entry: string }');
497
+ }
498
+ }
400
499
  if (payload.date !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(String(payload.date))) {
401
500
  errs.push('payload.date, when present, must be YYYY-MM-DD');
402
501
  }
403
502
  return errs;
404
503
  }
405
504
 
406
- // ── session-close marker (ADR 0022 amendment 2026-05-19) ──────
505
+ // ── session-close marker (amendment 2026-05-19) ───────────────
407
506
  // Standalone marker writer. Used when the LLM closes the session via direct
408
507
  // Write tool calls (not --apply-session-close). Hook `hypo-auto-minimal-
409
508
  // crystallize` is the only Reader; writer authority is intentionally split
410
509
  // between this CLI and the auto-write at the tail of applySessionClose.
411
510
  //
412
511
  // Contract: the marker is written only when the FULL /compact gate
413
- // (precompactGateStatus, ADR 0046) is green. A failed gate exits 1 with no
512
+ // (precompactGateStatus) is green. A failed gate exits 1 with no
414
513
  // marker — the next Stop hook re-blocks.
415
514
 
416
515
  function runMarkSessionClosed(args) {
@@ -419,7 +518,15 @@ function runMarkSessionClosed(args) {
419
518
  console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
420
519
  process.exit(1);
421
520
  }
422
- // ADR 0047: the per-session marker is the THIRD session-close completion
521
+ // --project=<slug> on --mark is ATTRIBUTION ONLY (the marker's `project` field).
522
+ // The gate stays GLOBAL — never narrowed — because a marker that narrowed its
523
+ // gate could attest compact-ready while PreCompact re-checks all of today's
524
+ // projects and stays red (the marker == compact-ready invariant,
525
+ // codex design finding 1/2). Validate the attribution slug exists as a
526
+ // directory, exactly as --check does, but only when it is actually used (a
527
+ // --log-only mark attributes to no project, so --project is moot there).
528
+ if (args.project && !args.logOnly) requireProjectDir(args, args.project);
529
+ // The per-session marker is the THIRD session-close completion
423
530
  // signal (after the PreCompact gate and `--check-session-close`). It must use
424
531
  // the SAME gate that governs /compact — precompactGateStatus — so the marker
425
532
  // can never attest "closed" while /compact would still block. This subsumes
@@ -464,7 +571,7 @@ function runMarkSessionClosed(args) {
464
571
  }
465
572
  process.exit(1);
466
573
  }
467
- // User-close hard gate (ADR 0055): the compact gate above only proves the wiki
574
+ // User-close hard gate: the compact gate above only proves the wiki
468
575
  // is compact-ready; it does NOT prove the USER asked to close. Refuse the marker
469
576
  // unless the transcript carries a genuine user close signal (NL close phrase,
470
577
  // /compact, or an AskUserQuestion close answer). This is the hard backstop for
@@ -484,8 +591,12 @@ function runMarkSessionClosed(args) {
484
591
  console.log(args.json ? JSON.stringify(result, null, 2) : `✗ ${reason}`);
485
592
  process.exit(1);
486
593
  }
594
+ // --project attributes the marker to that slug (gate stayed global); falls back
595
+ // to the gate's resolved primary. log-only marks attribute to no project. Used
596
+ // for the marker, the JSON result, and the success message so all three agree.
597
+ const markerProject = !args.logOnly && args.project ? args.project : status.project;
487
598
  writeSessionClosedMarker(args.hypoDir, args.sessionId, {
488
- project: status.project,
599
+ project: markerProject,
489
600
  ...(args.logOnly ? { scope: 'log-only' } : {}),
490
601
  });
491
602
  // Marker writer swallows IO errors (best-effort, see hypo-shared.mjs). Verify
@@ -504,11 +615,11 @@ function runMarkSessionClosed(args) {
504
615
  const result = {
505
616
  ok: true,
506
617
  session_id: args.sessionId,
507
- project: status.project,
618
+ project: markerProject,
508
619
  scope: args.logOnly ? 'log-only' : 'project',
509
620
  date: status.dates[0],
510
621
  notices: gate.notices,
511
- // ADR 0047: pure feedback-projection drift is a non-blocker the marker
622
+ // pure feedback-projection drift is a non-blocker: the marker
512
623
  // attests "compact-ready (no human-fixable blocker)", and the PreCompact
513
624
  // hook self-heals the projection (feedback-sync --write) at /compact. Surface
514
625
  // the deferral so the caller knows MEMORY/CLAUDE sync is pending, not lost.
@@ -520,7 +631,7 @@ function runMarkSessionClosed(args) {
520
631
  console.log(
521
632
  args.logOnly
522
633
  ? `✓ session-closed marker written (session_id: ${args.sessionId}, scope: log-only — no project attribution).`
523
- : `✓ session-closed marker written (session_id: ${args.sessionId}, project: ${status.project}).`,
634
+ : `✓ session-closed marker written (session_id: ${args.sessionId}, project: ${markerProject}).`,
524
635
  );
525
636
  if (gate.driftTargets.length > 0) {
526
637
  console.log(
@@ -542,7 +653,7 @@ function applySessionClose(args) {
542
653
  // for any actual apply work (readPayload below surfaces "payload is
543
654
  // required" the same way it always has).
544
655
  if (!args.force && !args.payload) {
545
- // ADR 0043: no-payload "already complete?" probe uses the
656
+ // No-payload "already complete?" probe uses the
546
657
  // global invariant, not a recency pick.
547
658
  const probe = sessionCloseGlobalStatus(args.hypoDir);
548
659
  if (probe.ok) {
@@ -585,20 +696,48 @@ function applySessionClose(args) {
585
696
  process.exit(1);
586
697
  }
587
698
 
588
- // Resolve project: explicit payload.project wins; else fall back to active project.
589
- // Done via sessionCloseFileStatus to keep one source of truth (and so a
590
- // missing pointer table surfaces the same error shape as --check-session-close).
699
+ // Resolve project: payload.project is REQUIRED (B-3, close-gate-hardening). The
700
+ // old recency fallback (payload.project || probe.project) could, on a same-date
701
+ // root-hot.md tie, resolve a DIFFERENT project than the one the payload's files
702
+ // belong to — apply would then write the close into the wrong project (silent
703
+ // data loss). Validate fail-fast, BEFORE the probe is consulted:
704
+ // - missing → no target to write; abort rather than infer.
705
+ // - invalid name → reject (non-string, wrong charset, or dot-only) BEFORE the
706
+ // existsSync(join(...)) path build, so a `../`-style value
707
+ // never reaches a path builder (traversal guard — order is
708
+ // the guard). isValidProjectName is SHARED with createProject
709
+ // so apply accepts exactly the namespace the repo can
710
+ // scaffold (A-Za-z0-9._-, single segment) — no narrower.
711
+ // - non-existent → projects/<slug>/ absent; abort rather than create.
712
+ // A payload.project that merely DIFFERS from the inferred active project is NOT an
713
+ // error — it is surfaced as a stderr note below and the close proceeds.
714
+ if (payload.project === undefined || payload.project === null) {
715
+ const msg = 'payload.project is required (apply must not infer the close target project)';
716
+ console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
717
+ process.exit(1);
718
+ }
719
+ if (!isValidProjectName(payload.project)) {
720
+ const msg = `payload.project ${JSON.stringify(payload.project)} is not a valid project name (single segment, charset A-Za-z0-9._-, ≥1 alnum, not "."/"..")`;
721
+ console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
722
+ process.exit(1);
723
+ }
724
+ // existsSync alone is not enough: a regular FILE at projects/<slug> would pass,
725
+ // then apply would build child paths under it and fail with an unstructured
726
+ // filesystem error (codex re-review). Require it to be a directory.
727
+ const projectDir = join(args.hypoDir, 'projects', payload.project);
728
+ if (!existsSync(projectDir) || !statSync(projectDir).isDirectory()) {
729
+ const msg = `payload.project "${payload.project}" does not exist as a directory (no projects/${payload.project}/ directory)`;
730
+ console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
731
+ process.exit(1);
732
+ }
733
+ const project = payload.project;
734
+ // probe (the recency-inferred active project) is now consulted ONLY to surface a
735
+ // divergence note — never to resolve the target. Computed AFTER validation so a
736
+ // malformed/missing payload.project fails fast without a pointer-table read.
591
737
  // Resolved BEFORE preflight because preflight needs overwrite-target paths
592
738
  // (which require the project slug) to filter out errors in files this apply
593
739
  // is about to replace — see the filter rationale below.
594
740
  const probe = sessionCloseFileStatus(args.hypoDir);
595
- const project = payload.project || probe.project;
596
- if (!project) {
597
- const msg =
598
- 'no project resolved (payload.project missing and root hot.md has no active-project row)';
599
- console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
600
- process.exit(1);
601
- }
602
741
  // The freshness verification below (and at the post-apply check) already honors
603
742
  // payload.project — `project` wins over the inferred active project, and the
604
743
  // post-apply sessionCloseFileStatus call passes it as projectOverride. But when the
@@ -606,7 +745,7 @@ function applySessionClose(args) {
606
745
  // (probe.project), that divergence used to be silent, so an operator couldn't tell
607
746
  // which project the close actually verified. Surface it on stderr (the stdout JSON
608
747
  // contract is untouched) so the verified/closed project is always explicit.
609
- if (payload.project && probe.project && probe.project !== payload.project) {
748
+ if (probe.project && probe.project !== payload.project) {
610
749
  process.stderr.write(
611
750
  `note: payload.project="${payload.project}" differs from the inferred active ` +
612
751
  `project "${probe.project}"; verifying and closing "${payload.project}".\n`,
@@ -614,6 +753,23 @@ function applySessionClose(args) {
614
753
  }
615
754
  const date = payload.date || todayLocal();
616
755
 
756
+ // B-1 derive precondition: when `log` is omitted, apply reconstructs the root
757
+ // log.md entry from THIS close's sessionLog heading. If sessionLog.entry has no
758
+ // `## [<date>] …` heading there is nothing to derive — and on a same-day SECOND
759
+ // close the date-level freshness verifier would still pass on the earlier
760
+ // close's entry, so the no-write would slip through as ok:true. Fail loud here,
761
+ // before any writes (codex pre-commit review).
762
+ if (
763
+ !payload.log &&
764
+ !new RegExp(`^#{1,6} \\[${date}\\]`, 'm').test(payload.sessionLog.entry || '')
765
+ ) {
766
+ const msg =
767
+ `payload.sessionLog.entry has no "## [${date}] …" heading to derive the log.md ` +
768
+ `entry from. Give it a dated heading, or supply payload.log explicitly.`;
769
+ console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
770
+ process.exit(1);
771
+ }
772
+
617
773
  // Preflight: lint the wiki BEFORE writing any payload bytes. If lint
618
774
  // has blockers (errors) in files this apply WON'T overwrite, the wiki is in
619
775
  // a degraded state and apply would mask the root cause — abort fail-fast.
@@ -638,7 +794,7 @@ function applySessionClose(args) {
638
794
  // payloadScope = every file this apply writes or appends. Both lint passes are
639
795
  // judged against it; errors elsewhere are surfaced as notices, never blocking.
640
796
  //
641
- // session-log needs TWO entries (ADR 0050): the daily WRITE target (what this
797
+ // session-log needs TWO entries: the daily WRITE target (what this
642
798
  // apply creates/appends, judged by post-apply lint) AND the freshness EVIDENCE
643
799
  // file. They coincide except in the hybrid cutover month, where a fallback-
644
800
  // aware no-op (the identical entry already lives in the legacy monthly file)
@@ -682,7 +838,7 @@ function applySessionClose(args) {
682
838
  ok: false,
683
839
  stage: 'preflight-lint',
684
840
  error: 'lint preflight failed — apply aborted (no payload bytes written)',
685
- lint: { ...preflightLint, blockingErrors },
841
+ lint: { ...summarizeLintForOutput(preflightLint), blockingErrors },
686
842
  };
687
843
  if (args.json) {
688
844
  console.log(JSON.stringify(out, null, 2));
@@ -720,7 +876,7 @@ function applySessionClose(args) {
720
876
  const rel = join('projects', project, 'session-log', `${date}.md`);
721
877
  const full = join(args.hypoDir, rel);
722
878
  const isPresent = entryAlreadyPresent(payload.sessionLog.entry);
723
- // Fallback-aware idempotency (ADR 0050 hybrid cutover): during the month the
879
+ // Fallback-aware idempotency (hybrid cutover): during the month the
724
880
  // shard takes over, today's entry may already live in the legacy monthly
725
881
  // file from an earlier (pre-cutover) close. Treat presence in EITHER the
726
882
  // daily shard or the legacy monthly file as "already written" so a same-day
@@ -750,7 +906,7 @@ function applySessionClose(args) {
750
906
  // otherwise mistake it for the evidence file. The dated `## [date] ...`
751
907
  // heading lives inside the entry, so freshness / derive / design-history
752
908
  // are unchanged.
753
- // PRAC-17 audit fields. The shard frontmatter is git-tracked and synced, so
909
+ // Audit fields (device, session_id). The shard frontmatter is git-tracked and synced, so
754
910
  // `device` is an INTENTIONAL synced multi-machine identifier (privacy note:
755
911
  // docs/ARCHITECTURE.md). It is a CREATOR-only stamp — only the session/
756
912
  // machine that first seeds the daily shard is recorded; later same-day
@@ -776,13 +932,43 @@ function applySessionClose(args) {
776
932
  }
777
933
  }
778
934
 
779
- {
935
+ // log.md: `payload.log` is OPTIONAL (B-1). When the caller supplies it, keep
936
+ // the explicit appendIfAbsent path (backward-compat: a custom log line, with
937
+ // the same idempotent dedup). When it is ABSENT, the root log.md entry is a
938
+ // DERIVABLE artifact: reconstruct the canonical `## [date] session | <project>`
939
+ // line directly from THIS close's session-log heading (`payload.sessionLog`),
940
+ // not by re-reading the session-log files. Deriving from the payload is what
941
+ // makes the per-close entry exact: a same-day second close lands its distinct
942
+ // heading, and a hybrid daily/monthly session-log split can't hide it (apply
943
+ // never reads those files for this). The global scan-based deriveRootLogEntries
944
+ // (the Stop hook) still backfills OTHER projects; calling it here would either
945
+ // miss the current entry (single-candidate read) or, with a loosened guard,
946
+ // append onto a deliberately custom payload.log (codex pre-commit review). The
947
+ // two payload paths are mutually exclusive: deriving on top of a present-but-
948
+ // malformed payload.log would mask it and weaken the verifier's fail-loud.
949
+ if (payload.log) {
780
950
  const wrote = appendIfAbsent(
781
951
  join(args.hypoDir, 'log.md'),
782
952
  payload.log.entry,
783
953
  entryAlreadyPresent(payload.log.entry),
784
954
  );
785
955
  (wrote ? applied : skipped).push('log (log.md)');
956
+ } else {
957
+ // matchAll (not exec) mirrors deriveRootLogEntries: a payload that carried
958
+ // more than one dated heading derives one canonical line each, symmetric with
959
+ // the global path. Exact-line dedup on the heading keeps a second apply (or a
960
+ // titleless vs titled same-day pair) from duplicating.
961
+ const logFull = join(args.hypoDir, 'log.md');
962
+ const headingRe = new RegExp(`^#{1,6} \\[${date}\\]\\s*(.*)$`, 'gm');
963
+ let wroteAny = false;
964
+ for (const m of (payload.sessionLog.entry || '').matchAll(headingRe)) {
965
+ const { heading, block } = rootLogEntry(project, date, m[1]);
966
+ const wrote = appendIfAbsent(logFull, block, (c) =>
967
+ (c || '').split(/\r?\n/).includes(heading),
968
+ );
969
+ wroteAny = wroteAny || wrote;
970
+ }
971
+ (wroteAny ? applied : skipped).push('log (log.md, derived)');
786
972
  }
787
973
 
788
974
  // Same-date-tie fix: verify against the SAME project this apply just wrote
@@ -792,6 +978,29 @@ function applySessionClose(args) {
792
978
  // a completed close (the 2026-06-09 security-ops-kb incident).
793
979
  const verification = sessionCloseFileStatus(args.hypoDir, { projectOverride: project });
794
980
 
981
+ // B-4 auto-register: lift unknown (non-forbidden) tags surfaced by the PREFLIGHT
982
+ // lint into SCHEMA.md's `### Pending` section so the post-apply lint sees them as
983
+ // known and the close never stalls on a vocabulary gap. The W10 id is hidden in
984
+ // non-strict --json output (lint.mjs toOut), so the unknown-tag warns are matched
985
+ // and the tag extracted from the message string itself — kept in lockstep with
986
+ // lint.mjs's W10 emit (a copy-edit there breaks this; the close-path round-trip
987
+ // test guards it). Forbidden patterns stay hard errors and are filtered out.
988
+ // SCOPE (eventual consistency, intended): this registers PRE-EXISTING wiki debt
989
+ // visible at preflight, NOT a novel tag this very close's payload introduces —
990
+ // that one would surface only at post-apply and lands on the NEXT close. The
991
+ // contract is "must not stall", which warns (not errors) already satisfy; the
992
+ // registration just keeps the vocabulary catching up.
993
+ // The capture is anchored on the FULL message suffix (not `[^"]+`) so a tag that
994
+ // itself contains a `"` — non-forbidden, so reachable — is captured whole rather
995
+ // than truncated at its first quote (codex stage-2 CONCERN).
996
+ const unknownTagRe = /^Unknown tag: "(.+)" \(not in SCHEMA\.md Tag Vocabulary\)/;
997
+ const pendingTags = [];
998
+ for (const w of preflightLint.warns || []) {
999
+ const m = unknownTagRe.exec(w.message || '');
1000
+ if (m && !checkForbidden(m[1])) pendingTags.push(m[1]);
1001
+ }
1002
+ if (pendingTags.length > 0) appendPendingTags(args.hypoDir, pendingTags);
1003
+
795
1004
  // Post-apply lint: payload may have introduced a malformed body or
796
1005
  // bad frontmatter. Surface as a distinct `stage` so caller can tell "lint
797
1006
  // broke" apart from "frontmatter stale". This runs even if the freshness gate
@@ -829,11 +1038,17 @@ function applySessionClose(args) {
829
1038
  const postLintOk = !postApplyCrashed && postBlocking.length === 0;
830
1039
  const ok = verification.ok && postLintOk;
831
1040
 
832
- // ADR 0022 amendment 2026-05-19: auto-write the per-session
1041
+ // Scope the non-blocking notice to the close-target project: debt under
1042
+ // projects/<project>/ stays listed; debt elsewhere folds to a count so the
1043
+ // same untouched-file debt does not re-list its filenames on every close.
1044
+ const closeScopeNotice = postNotice.filter((e) => isUnderProjectDirs(e.file, [project]));
1045
+ const otherDebtCount = postNotice.length - closeScopeNotice.length;
1046
+
1047
+ // Amendment 2026-05-19: auto-write the per-session
833
1048
  // closed marker on a verified close. Hook authority is read-only; this is
834
1049
  // one of the two writer paths (the other is --mark-session-closed standalone).
835
1050
  //
836
- // ADR 0047: the marker write is governed by the SAME gate as standalone
1051
+ // The marker write is governed by the SAME gate as standalone
837
1052
  // --mark-session-closed and /compact (precompactGateStatus), NOT just apply's
838
1053
  // `ok` + git-clean. Apply's payload preflight/post-apply lint and `ok` still
839
1054
  // govern apply SUCCESS (exit code below), but the marker must additionally
@@ -841,13 +1056,13 @@ function applySessionClose(args) {
841
1056
  // path could issue a marker the standalone path would refuse (the second
842
1057
  // divergence codex flagged).
843
1058
  //
844
- // ADR 0056: apply just wrote the payload, so the tree is dirty by its OWN
845
- // writes the gate's `uncommitted` git blocker would always trip and the
1059
+ // Apply just wrote the payload, so the tree is dirty by its OWN
1060
+ // writes: the gate's `uncommitted` git blocker would always trip and the
846
1061
  // marker would be skipped, deferring the close to a manual --mark-session-closed
847
- // (the ADR 0047 "done but still blocked" regression). Commit the payload HERE, via
1062
+ // ("done but still blocked" regression). Commit the payload HERE, via
848
1063
  // the SAME .hypoignore-aware helper the auto-commit Stop hook uses, so the gate sees
849
1064
  // a committed tree. Push stays deferred to the Stop hook; the resulting
850
- // committed-but-unpushed state is a gate notice, not a blocker (ADR 0056), so
1065
+ // committed-but-unpushed state is a gate notice, not a blocker, so
851
1066
  // this still marks. A commit failure (not a repo / pre-commit reject / git error)
852
1067
  // skips the marker WITH a surfaced reason — today's behavior was also "no marker",
853
1068
  // but silently.
@@ -869,14 +1084,14 @@ function applySessionClose(args) {
869
1084
  // (feedback/W8/hot/lint — git is now committed) is resolved.
870
1085
  markerSkipReason = 'compact-gate-not-ok';
871
1086
  } else if (!closeTranscript || !hasUserCloseSignal(closeTranscript)) {
872
- // User-close hard gate (ADR 0055): apply succeeded (payload files written)
1087
+ // User-close hard gate: apply succeeded (payload files written)
873
1088
  // but the user never signalled session close, so the marker — which attests
874
1089
  // "user closed" — is withheld. The wiki record stands; the session is simply
875
1090
  // not marked closed. Surfaced (not silent) so the caller knows.
876
1091
  markerSkipReason = closeTranscript ? 'no-user-close-signal' : 'transcript-unresolved';
877
1092
  } else {
878
1093
  writeSessionClosedMarker(args.hypoDir, args.sessionId, { project });
879
- // Codex CONCERN (ADR 0055/0056): the writer swallows IO errors (best-effort).
1094
+ // Codex CONCERN: the writer swallows IO errors (best-effort).
880
1095
  // Verify the file actually landed — mirroring the standalone path — instead of
881
1096
  // asserting markerWritten=true, so a .cache permission/disk problem surfaces
882
1097
  // rather than the caller reporting "closed" while the next Stop re-blocks.
@@ -903,13 +1118,22 @@ function applySessionClose(args) {
903
1118
  applied,
904
1119
  skipped,
905
1120
  verification,
906
- // ADR 0055: surface the marker outcome instead of skipping silently, so the
1121
+ // Surface the marker outcome instead of skipping silently, so the
907
1122
  // caller can tell "closed" from "applied but not marked".
908
1123
  ...(args.sessionId ? { markerWritten, markerSkipReason } : {}),
909
- lint: { preflight: preflightLint, postApply: postApplyLint },
910
- // Pre-existing lint debt in files this close did not author (Bug B): surfaced
911
- // for visibility, never gated. Empty on a clean vault.
912
- notices: [...new Set(postNotice.map((e) => e.file))],
1124
+ lint: {
1125
+ preflight: summarizeLintForOutput(preflightLint),
1126
+ postApply: summarizeLintForOutput(postApplyLint),
1127
+ },
1128
+ // Pre-existing lint debt in files this close did not author: surfaced for
1129
+ // visibility, never gated. Empty on a clean vault. Scoped to the close-target
1130
+ // project's own dir — debt under projects/<project>/ is this close's
1131
+ // neighborhood and stays listed; debt elsewhere (other projects, shared
1132
+ // pages, root files) folds into otherDebtCount so the same untouched-file
1133
+ // debt does not re-list its filenames on every close (run `node
1134
+ // scripts/lint.mjs` for the full list).
1135
+ notices: [...new Set(closeScopeNotice.map((e) => e.file))],
1136
+ otherDebtCount,
913
1137
  };
914
1138
 
915
1139
  if (args.json) {
@@ -963,13 +1187,18 @@ function applySessionClose(args) {
963
1187
  console.log(' Payload introduced a lint blocker — fix the payload content and retry.');
964
1188
  }
965
1189
  }
966
- if (postNotice.length > 0) {
1190
+ if (closeScopeNotice.length > 0) {
967
1191
  console.log(
968
- `\n· ${postNotice.length} pre-existing lint issue(s) in untouched files (not blocking): ${[
969
- ...new Set(postNotice.map((e) => e.file)),
1192
+ `\n· ${closeScopeNotice.length} pre-existing lint issue(s) in untouched files (not blocking): ${[
1193
+ ...new Set(closeScopeNotice.map((e) => e.file)),
970
1194
  ]
971
1195
  .slice(0, 5)
972
- .join(', ')}${postNotice.length > 5 ? ', …' : ''}`,
1196
+ .join(', ')}${closeScopeNotice.length > 5 ? ', …' : ''}`,
1197
+ );
1198
+ }
1199
+ if (otherDebtCount > 0) {
1200
+ console.log(
1201
+ `\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.`,
973
1202
  );
974
1203
  }
975
1204
  }
@@ -978,6 +1207,29 @@ function applySessionClose(args) {
978
1207
 
979
1208
  // ── helpers ──────────────────────────────────────────────────────────────────
980
1209
 
1210
+ // Cap the warn list in MODEL-FACING lint output. `result.lint` is serialized
1211
+ // into the --json apply result the documented close path reads, and lint runs
1212
+ // twice per close (preflight + post-apply), so an un-capped warn list (e.g.
1213
+ // hundreds of broken-wikilink warnings) lands in model context twice on every
1214
+ // close. Errors stay full (few, actionable); warns collapse to a count plus a
1215
+ // small sample for quick diagnosis. The INTERNAL preflightLint / postApplyLint
1216
+ // objects are untouched — the blocking filter and the pending-tag scan still
1217
+ // read the full warn list. lint.mjs --json itself also stays full: its
1218
+ // programmatic consumers (the pending-tag scan, the PreCompact W8 filter, tests)
1219
+ // need every warn.
1220
+ function summarizeLintForOutput(l) {
1221
+ const SAMPLE = 10;
1222
+ const warns = l.warns || [];
1223
+ const out = {
1224
+ ok: l.ok,
1225
+ errors: l.errors || [],
1226
+ warnCount: warns.length,
1227
+ warns: warns.slice(0, SAMPLE),
1228
+ };
1229
+ if (warns.length > SAMPLE) out.warnsTruncated = warns.length - SAMPLE;
1230
+ return out;
1231
+ }
1232
+
981
1233
  function parseFrontmatter(content) {
982
1234
  const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
983
1235
  if (!m) return null;