hypomnema 1.4.0 → 1.4.2

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 (39) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.ko.md +151 -153
  4. package/README.md +121 -123
  5. package/commands/audit.md +4 -4
  6. package/commands/crystallize.md +22 -7
  7. package/commands/doctor.md +3 -3
  8. package/commands/feedback.md +5 -3
  9. package/commands/graph.md +3 -3
  10. package/commands/ingest.md +6 -4
  11. package/commands/init.md +5 -3
  12. package/commands/lint.md +3 -3
  13. package/commands/query.md +3 -3
  14. package/commands/rename.md +4 -4
  15. package/commands/resume.md +4 -2
  16. package/commands/stats.md +3 -3
  17. package/commands/upgrade.md +4 -4
  18. package/commands/verify.md +3 -3
  19. package/docs/CONTRIBUTING.md +107 -16
  20. package/hooks/hypo-auto-minimal-crystallize.mjs +23 -11
  21. package/hooks/hypo-shared.mjs +96 -14
  22. package/package.json +6 -1
  23. package/scripts/check-bilingual.mjs +49 -11
  24. package/scripts/check-tracker-ids.mjs +60 -1
  25. package/scripts/crystallize.mjs +275 -39
  26. package/scripts/lib/changelog-classify.mjs +216 -0
  27. package/scripts/lib/check-bilingual.mjs +125 -22
  28. package/scripts/lib/check-tracker-ids.mjs +19 -0
  29. package/scripts/lib/project-create.mjs +23 -5
  30. package/scripts/lib/schema-vocab.mjs +105 -1
  31. package/scripts/lint.mjs +9 -1
  32. package/scripts/weekly-report.mjs +9 -3
  33. package/skills/crystallize/SKILL.md +30 -2
  34. package/templates/SCHEMA.md +11 -3
  35. package/templates/hypo-config.md +1 -1
  36. package/templates/hypo-guide.md +4 -1
  37. package/scripts/bump-version.mjs +0 -77
  38. package/scripts/smoke-pack.mjs +0 -261
  39. package/scripts/smoke-plugin.mjs +0 -194
@@ -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,
@@ -79,12 +86,17 @@ import {
79
86
  sessionLogShardPath,
80
87
  sessionLogReadCandidates,
81
88
  sessionLogScopePath,
89
+ rootLogEntry,
82
90
  resolveTranscriptBySessionId,
83
91
  hasUserCloseSignal,
84
92
  commitWikiChanges,
85
93
  } from '../hooks/hypo-shared.mjs';
86
94
 
87
- const LINT_SCRIPT = join(dirname(fileURLToPath(import.meta.url)), 'lint.mjs');
95
+ // This script's own absolute path. Used to print copy-pasteable recovery
96
+ // commands as `node <SELF_SCRIPT> ...` rather than a bare `crystallize` bin,
97
+ // which is not on PATH in a Claude Code plugin install (only in an npm global).
98
+ const SELF_SCRIPT = fileURLToPath(import.meta.url);
99
+ const LINT_SCRIPT = join(dirname(SELF_SCRIPT), 'lint.mjs');
88
100
 
89
101
  // Spawn lint.mjs --json against `hypoDir` and return parsed result.
90
102
  // We shell out instead of refactoring lint.mjs into a library because lint.mjs
@@ -133,6 +145,7 @@ function parseArgs(argv) {
133
145
  payload: null,
134
146
  force: false,
135
147
  transcriptPath: null,
148
+ project: null,
136
149
  };
137
150
  for (const arg of argv.slice(2)) {
138
151
  if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
@@ -144,10 +157,23 @@ function parseArgs(argv) {
144
157
  else if (arg.startsWith('--session-id=')) args.sessionId = arg.slice(13);
145
158
  else if (arg.startsWith('--payload=')) args.payload = arg.slice(10);
146
159
  else if (arg.startsWith('--transcript-path=')) args.transcriptPath = expandHome(arg.slice(18));
160
+ else if (arg.startsWith('--project=')) args.project = arg.slice(10);
147
161
  else if (arg === '--force') args.force = true;
148
162
  else if (arg === '--json') args.json = true;
149
163
  }
150
164
  if (!args.hypoDir) args.hypoDir = resolveHypoRoot();
165
+ // --project=<slug> override (check/mark only). Validate the SYNTAX here so a
166
+ // traversal/charset attack (`--project=../x`) is rejected before any path is
167
+ // built from it — sessionCloseFileStatus(projectOverride) joins it directly.
168
+ // isValidProjectName is the SHARED validator (project-create.mjs), so the
169
+ // override accepts exactly the namespace createProject can scaffold. Existence
170
+ // (a real projects/<slug>/ directory) is checked in the run functions, where
171
+ // hypoDir is resolved and only the check/mark paths consume --project.
172
+ if (args.project != null && !isValidProjectName(args.project)) {
173
+ const msg = `--project "${args.project}" is not a valid project name (need a single segment with ≥1 alnum, charset A-Za-z0-9._-, not "."/"..")`;
174
+ console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
175
+ process.exit(1);
176
+ }
151
177
  return args;
152
178
  }
153
179
 
@@ -163,6 +189,20 @@ function parseArgs(argv) {
163
189
  // closed.) `--transcript-path` survives ONLY for `--check-session-close`'s lint
164
190
  // scope, which writes no marker and so cannot cause an over-close.
165
191
 
192
+ // Validate that an explicit --project=<slug> override names a real project
193
+ // DIRECTORY. Syntax was already checked in parseArgs; this is the existence half,
194
+ // mirroring apply's payload.project check — a regular file or an absent dir at
195
+ // projects/<slug> is a hard error so the override never silently resolves to an
196
+ // all-missing status (which a reader would misread as "exists but incomplete").
197
+ function requireProjectDir(args, slug) {
198
+ const projectDir = join(args.hypoDir, 'projects', slug);
199
+ if (!existsSync(projectDir) || !statSync(projectDir).isDirectory()) {
200
+ const msg = `--project "${slug}" does not exist as a directory (no projects/${slug}/ directory)`;
201
+ console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
202
+ process.exit(1);
203
+ }
204
+ }
205
+
166
206
  // ── session-close check (spec §5.2.7 / §8.3) ────────────────────────
167
207
  // Mirrors the hard gate in hypo-personal-check.mjs so the /hypo:crystallize
168
208
  // flow can self-verify before /compact triggers PreCompact.
@@ -179,8 +219,21 @@ function runSessionCloseCheck(args) {
179
219
  // (marker_present:true) while `ok` still reflected the stale active project —
180
220
  // the completion-signal trio (PreCompact / --check / marker) would diverge
181
221
  // (codex design Finding 2).
222
+ //
223
+ // --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
227
+ // transcript widening is suppressed: a transcript touch in some OTHER project
228
+ // would re-add that project's files to the lint scope and re-block the scoped
229
+ // check, defeating the point. The global (no --project) check keeps widening.
230
+ if (args.project) requireProjectDir(args, args.project);
182
231
  const status = precompactGateStatus(args.hypoDir, {
183
- ...(args.transcriptPath ? { transcriptPath: args.transcriptPath } : {}),
232
+ ...(args.project
233
+ ? { projectOverride: args.project }
234
+ : args.transcriptPath
235
+ ? { transcriptPath: args.transcriptPath }
236
+ : {}),
184
237
  ...(args.sessionId ? { sessionId: args.sessionId } : {}),
185
238
  });
186
239
  const close = status.close;
@@ -198,9 +251,18 @@ function runSessionCloseCheck(args) {
198
251
  // while /compact's Stop still blocks — the exact incoherence this ADR closes
199
252
  // (codex pre-commit CONCERN). readSessionClosedMarker unlinks an invalid
200
253
  // 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;
254
+ const markerObj = args.sessionId ? readSessionClosedMarker(args.hypoDir, args.sessionId) : null;
255
+ const markerPresent = args.sessionId ? markerObj !== null : null;
256
+
257
+ // Scope of this check (codex design review finding 2 — the scope must be
258
+ // explicit in JSON + prose, not implied). `global` = the full PreCompact mirror
259
+ // (green ⇒ compact-ready). `project` = narrowed to --project=<slug> (green ⇒
260
+ // only THAT project is close-complete, NOT global compact-readiness). When a
261
+ // log-only marker governs the session, the gate runs in log-only mode and the
262
+ // --project override is IGNORED — surface that rather than implying X was
263
+ // checked (it was not).
264
+ const logOnlyWon = args.project != null && markerObj?.scope === 'log-only';
265
+ const scope = args.project ? (logOnlyWon ? 'log-only' : 'project') : 'global';
204
266
 
205
267
  if (args.json) {
206
268
  console.log(
@@ -215,6 +277,14 @@ function runSessionCloseCheck(args) {
215
277
  blockers: status.blockers,
216
278
  notices: status.notices,
217
279
  skipped: status.skipped,
280
+ // scope is additive; `global` keeps prior semantics for existing readers
281
+ scope,
282
+ ...(args.project
283
+ ? {
284
+ scoped_project: args.project,
285
+ ...(logOnlyWon ? { project_override_ignored: true } : {}),
286
+ }
287
+ : {}),
218
288
  ...(args.sessionId ? { session_id: args.sessionId, marker_present: markerPresent } : {}),
219
289
  },
220
290
  null,
@@ -224,8 +294,20 @@ function runSessionCloseCheck(args) {
224
294
  process.exit(status.ok ? 0 : 1);
225
295
  }
226
296
 
297
+ if (logOnlyWon) {
298
+ console.log(
299
+ `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`,
300
+ );
301
+ } else if (scope === 'project') {
302
+ console.log(
303
+ `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`,
304
+ );
305
+ }
306
+
227
307
  const proj = close.project || '(unresolved)';
228
- console.log(`Compact-ready check (project: ${proj}, date: ${close.dates.join(' / ')}):\n`);
308
+ console.log(
309
+ `Compact-ready check (${scope === 'global' ? `project: ${proj}` : `scope: ${scope}, project: ${proj}`}, date: ${close.dates.join(' / ')}):\n`,
310
+ );
229
311
 
230
312
  const required = close.project
231
313
  ? [
@@ -261,15 +343,25 @@ function runSessionCloseCheck(args) {
261
343
  console.log(
262
344
  markerPresent
263
345
  ? ` ✓ 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}` : ''}\`.`,
346
+ : ` · 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
347
  );
266
348
  }
267
349
  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
- );
350
+ if (scope === 'project') {
351
+ // Project-scoped diagnostic: green means ONLY this project is close-complete.
352
+ // Do NOT claim global compact-readiness (the whole point of the narrow).
353
+ console.log(
354
+ status.ok
355
+ ? `✓ ${args.project} is close-complete (project-scoped). This is NOT a global /compact guarantee — run \`--check-session-close\` without --project for that.`
356
+ : `✗ ${args.project} is not close-complete — resolve the ✗ items above.`,
357
+ );
358
+ } else {
359
+ console.log(
360
+ status.ok
361
+ ? '✓ 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.)'
362
+ : '✗ Not compact-ready — resolve the ✗ items above, then retry. /compact would block on these.',
363
+ );
364
+ }
273
365
  process.exit(status.ok ? 0 : 1);
274
366
  }
275
367
 
@@ -358,18 +450,19 @@ function todayLocal() {
358
450
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
359
451
  }
360
452
 
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.)
453
+ // Spec §5.2.7 / §8.3 + ADR 0029: 4 mandatory + 2 optional (`log`, `openQuestions`).
454
+ // The payload shape MUST mirror that contract — missing a mandatory field is a
455
+ // payload bug, not a no-op. Caller is the LLM session-close flow, which composes
456
+ // the payload deliberately; partial payloads must fail loudly so caller fixes
457
+ // them rather than silently relying on yesterday's freshness state. (Codex review
458
+ // of the apply path — Worker 1 finding 1.) `log` left the mandatory set in B-1:
459
+ // the root log.md entry is a DERIVABLE artifact (rootLogEntry over this close's
460
+ // sessionLog heading), so apply auto-fills it when the field is absent.
367
461
  const REQUIRED_PAYLOAD_FIELDS = [
368
462
  ['sessionState', 'content'],
369
463
  ['projectHot', 'content'],
370
464
  ['rootHot', 'content'],
371
465
  ['sessionLog', 'entry'],
372
- ['log', 'entry'],
373
466
  ];
374
467
 
375
468
  function validatePayloadShape(payload) {
@@ -397,6 +490,11 @@ function validatePayloadShape(payload) {
397
490
  errs.push('payload.openQuestions, when present, must be { content: string }');
398
491
  }
399
492
  }
493
+ if (payload.log !== undefined) {
494
+ if (!payload.log || typeof payload.log !== 'object' || typeof payload.log.entry !== 'string') {
495
+ errs.push('payload.log, when present, must be { entry: string }');
496
+ }
497
+ }
400
498
  if (payload.date !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(String(payload.date))) {
401
499
  errs.push('payload.date, when present, must be YYYY-MM-DD');
402
500
  }
@@ -419,6 +517,14 @@ function runMarkSessionClosed(args) {
419
517
  console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
420
518
  process.exit(1);
421
519
  }
520
+ // --project=<slug> on --mark is ATTRIBUTION ONLY (the marker's `project` field).
521
+ // The gate stays GLOBAL — never narrowed — because a marker that narrowed its
522
+ // 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 /
524
+ // codex design finding 1/2). Validate the attribution slug exists as a
525
+ // directory, exactly as --check does, but only when it is actually used (a
526
+ // --log-only mark attributes to no project, so --project is moot there).
527
+ if (args.project && !args.logOnly) requireProjectDir(args, args.project);
422
528
  // ADR 0047: the per-session marker is the THIRD session-close completion
423
529
  // signal (after the PreCompact gate and `--check-session-close`). It must use
424
530
  // the SAME gate that governs /compact — precompactGateStatus — so the marker
@@ -484,8 +590,12 @@ function runMarkSessionClosed(args) {
484
590
  console.log(args.json ? JSON.stringify(result, null, 2) : `✗ ${reason}`);
485
591
  process.exit(1);
486
592
  }
593
+ // --project attributes the marker to that slug (gate stayed global); falls back
594
+ // to the gate's resolved primary. log-only marks attribute to no project. Used
595
+ // for the marker, the JSON result, and the success message so all three agree.
596
+ const markerProject = !args.logOnly && args.project ? args.project : status.project;
487
597
  writeSessionClosedMarker(args.hypoDir, args.sessionId, {
488
- project: status.project,
598
+ project: markerProject,
489
599
  ...(args.logOnly ? { scope: 'log-only' } : {}),
490
600
  });
491
601
  // Marker writer swallows IO errors (best-effort, see hypo-shared.mjs). Verify
@@ -504,7 +614,7 @@ function runMarkSessionClosed(args) {
504
614
  const result = {
505
615
  ok: true,
506
616
  session_id: args.sessionId,
507
- project: status.project,
617
+ project: markerProject,
508
618
  scope: args.logOnly ? 'log-only' : 'project',
509
619
  date: status.dates[0],
510
620
  notices: gate.notices,
@@ -520,7 +630,7 @@ function runMarkSessionClosed(args) {
520
630
  console.log(
521
631
  args.logOnly
522
632
  ? `✓ 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}).`,
633
+ : `✓ session-closed marker written (session_id: ${args.sessionId}, project: ${markerProject}).`,
524
634
  );
525
635
  if (gate.driftTargets.length > 0) {
526
636
  console.log(
@@ -585,20 +695,48 @@ function applySessionClose(args) {
585
695
  process.exit(1);
586
696
  }
587
697
 
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).
698
+ // Resolve project: payload.project is REQUIRED (B-3, close-gate-hardening). The
699
+ // old recency fallback (payload.project || probe.project) could, on a same-date
700
+ // root-hot.md tie, resolve a DIFFERENT project than the one the payload's files
701
+ // belong to — apply would then write the close into the wrong project (silent
702
+ // data loss). Validate fail-fast, BEFORE the probe is consulted:
703
+ // - missing → no target to write; abort rather than infer.
704
+ // - invalid name → reject (non-string, wrong charset, or dot-only) BEFORE the
705
+ // existsSync(join(...)) path build, so a `../`-style value
706
+ // never reaches a path builder (traversal guard — order is
707
+ // the guard). isValidProjectName is SHARED with createProject
708
+ // so apply accepts exactly the namespace the repo can
709
+ // scaffold (A-Za-z0-9._-, single segment) — no narrower.
710
+ // - non-existent → projects/<slug>/ absent; abort rather than create.
711
+ // A payload.project that merely DIFFERS from the inferred active project is NOT an
712
+ // error — it is surfaced as a stderr note below and the close proceeds.
713
+ if (payload.project === undefined || payload.project === null) {
714
+ const msg = 'payload.project is required (apply must not infer the close target project)';
715
+ console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
716
+ process.exit(1);
717
+ }
718
+ if (!isValidProjectName(payload.project)) {
719
+ const msg = `payload.project ${JSON.stringify(payload.project)} is not a valid project name (single segment, charset A-Za-z0-9._-, ≥1 alnum, not "."/"..")`;
720
+ console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
721
+ process.exit(1);
722
+ }
723
+ // existsSync alone is not enough: a regular FILE at projects/<slug> would pass,
724
+ // then apply would build child paths under it and fail with an unstructured
725
+ // filesystem error (codex re-review). Require it to be a directory.
726
+ const projectDir = join(args.hypoDir, 'projects', payload.project);
727
+ if (!existsSync(projectDir) || !statSync(projectDir).isDirectory()) {
728
+ const msg = `payload.project "${payload.project}" does not exist as a directory (no projects/${payload.project}/ directory)`;
729
+ console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
730
+ process.exit(1);
731
+ }
732
+ const project = payload.project;
733
+ // probe (the recency-inferred active project) is now consulted ONLY to surface a
734
+ // divergence note — never to resolve the target. Computed AFTER validation so a
735
+ // malformed/missing payload.project fails fast without a pointer-table read.
591
736
  // Resolved BEFORE preflight because preflight needs overwrite-target paths
592
737
  // (which require the project slug) to filter out errors in files this apply
593
738
  // is about to replace — see the filter rationale below.
594
739
  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
740
  // The freshness verification below (and at the post-apply check) already honors
603
741
  // payload.project — `project` wins over the inferred active project, and the
604
742
  // post-apply sessionCloseFileStatus call passes it as projectOverride. But when the
@@ -606,7 +744,7 @@ function applySessionClose(args) {
606
744
  // (probe.project), that divergence used to be silent, so an operator couldn't tell
607
745
  // which project the close actually verified. Surface it on stderr (the stdout JSON
608
746
  // contract is untouched) so the verified/closed project is always explicit.
609
- if (payload.project && probe.project && probe.project !== payload.project) {
747
+ if (probe.project && probe.project !== payload.project) {
610
748
  process.stderr.write(
611
749
  `note: payload.project="${payload.project}" differs from the inferred active ` +
612
750
  `project "${probe.project}"; verifying and closing "${payload.project}".\n`,
@@ -614,6 +752,23 @@ function applySessionClose(args) {
614
752
  }
615
753
  const date = payload.date || todayLocal();
616
754
 
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}`);
769
+ process.exit(1);
770
+ }
771
+
617
772
  // Preflight: lint the wiki BEFORE writing any payload bytes. If lint
618
773
  // has blockers (errors) in files this apply WON'T overwrite, the wiki is in
619
774
  // a degraded state and apply would mask the root cause — abort fail-fast.
@@ -776,13 +931,43 @@ function applySessionClose(args) {
776
931
  }
777
932
  }
778
933
 
779
- {
934
+ // log.md: `payload.log` is OPTIONAL (B-1). When the caller supplies it, keep
935
+ // the explicit appendIfAbsent path (backward-compat: a custom log line, with
936
+ // the same idempotent dedup). When it is ABSENT, the root log.md entry is a
937
+ // DERIVABLE artifact: reconstruct the canonical `## [date] session | <project>`
938
+ // line directly from THIS close's session-log heading (`payload.sessionLog`),
939
+ // not by re-reading the session-log files. Deriving from the payload is what
940
+ // makes the per-close entry exact: a same-day second close lands its distinct
941
+ // heading, and a hybrid daily/monthly session-log split can't hide it (apply
942
+ // never reads those files for this). The global scan-based deriveRootLogEntries
943
+ // (the Stop hook) still backfills OTHER projects; calling it here would either
944
+ // miss the current entry (single-candidate read) or, with a loosened guard,
945
+ // append onto a deliberately custom payload.log (codex pre-commit review). The
946
+ // two payload paths are mutually exclusive: deriving on top of a present-but-
947
+ // malformed payload.log would mask it and weaken the verifier's fail-loud.
948
+ if (payload.log) {
780
949
  const wrote = appendIfAbsent(
781
950
  join(args.hypoDir, 'log.md'),
782
951
  payload.log.entry,
783
952
  entryAlreadyPresent(payload.log.entry),
784
953
  );
785
954
  (wrote ? applied : skipped).push('log (log.md)');
955
+ } else {
956
+ // matchAll (not exec) mirrors deriveRootLogEntries: a payload that carried
957
+ // more than one dated heading derives one canonical line each, symmetric with
958
+ // the global path. Exact-line dedup on the heading keeps a second apply (or a
959
+ // titleless vs titled same-day pair) from duplicating.
960
+ const logFull = join(args.hypoDir, 'log.md');
961
+ const headingRe = new RegExp(`^#{1,6} \\[${date}\\]\\s*(.*)$`, 'gm');
962
+ let wroteAny = false;
963
+ for (const m of (payload.sessionLog.entry || '').matchAll(headingRe)) {
964
+ const { heading, block } = rootLogEntry(project, date, m[1]);
965
+ const wrote = appendIfAbsent(logFull, block, (c) =>
966
+ (c || '').split(/\r?\n/).includes(heading),
967
+ );
968
+ wroteAny = wroteAny || wrote;
969
+ }
970
+ (wroteAny ? applied : skipped).push('log (log.md, derived)');
786
971
  }
787
972
 
788
973
  // Same-date-tie fix: verify against the SAME project this apply just wrote
@@ -792,6 +977,29 @@ function applySessionClose(args) {
792
977
  // a completed close (the 2026-06-09 security-ops-kb incident).
793
978
  const verification = sessionCloseFileStatus(args.hypoDir, { projectOverride: project });
794
979
 
980
+ // B-4 auto-register: lift unknown (non-forbidden) tags surfaced by the PREFLIGHT
981
+ // lint into SCHEMA.md's `### Pending` section so the post-apply lint sees them as
982
+ // known and the close never stalls on a vocabulary gap. The W10 id is hidden in
983
+ // non-strict --json output (lint.mjs toOut), so the unknown-tag warns are matched
984
+ // and the tag extracted from the message string itself — kept in lockstep with
985
+ // lint.mjs's W10 emit (a copy-edit there breaks this; the close-path round-trip
986
+ // test guards it). Forbidden patterns stay hard errors and are filtered out.
987
+ // SCOPE (eventual consistency, intended): this registers PRE-EXISTING wiki debt
988
+ // visible at preflight, NOT a novel tag this very close's payload introduces —
989
+ // that one would surface only at post-apply and lands on the NEXT close. The
990
+ // contract is "must not stall", which warns (not errors) already satisfy; the
991
+ // registration just keeps the vocabulary catching up.
992
+ // The capture is anchored on the FULL message suffix (not `[^"]+`) so a tag that
993
+ // itself contains a `"` — non-forbidden, so reachable — is captured whole rather
994
+ // than truncated at its first quote (codex stage-2 CONCERN).
995
+ const unknownTagRe = /^Unknown tag: "(.+)" \(not in SCHEMA\.md Tag Vocabulary\)/;
996
+ const pendingTags = [];
997
+ for (const w of preflightLint.warns || []) {
998
+ const m = unknownTagRe.exec(w.message || '');
999
+ if (m && !checkForbidden(m[1])) pendingTags.push(m[1]);
1000
+ }
1001
+ if (pendingTags.length > 0) appendPendingTags(args.hypoDir, pendingTags);
1002
+
795
1003
  // Post-apply lint: payload may have introduced a malformed body or
796
1004
  // bad frontmatter. Surface as a distinct `stage` so caller can tell "lint
797
1005
  // broke" apart from "frontmatter stale". This runs even if the freshness gate
@@ -919,8 +1127,36 @@ function applySessionClose(args) {
919
1127
  for (const a of applied) console.log(` ✓ wrote ${a}`);
920
1128
  for (const s of skipped) console.log(` · skipped ${s} (already current)`);
921
1129
  if (ok) {
922
- console.log('\n✓ session-close verified all 5 mandatory files fresh, lint clean.');
923
- } else {
1130
+ // When the marker was withheld, qualify the success line so a reader scanning
1131
+ // stdout alone cannot mistake "verified" for "fully closed". markerSkipReason
1132
+ // is non-null exactly when args.sessionId is set and the marker did not land.
1133
+ if (markerSkipReason) {
1134
+ console.log(
1135
+ '\n✓ session-close files verified (all 5 mandatory files fresh, lint clean).' +
1136
+ '\n session NOT fully closed: the Stop-chain marker was not written (see warning below).',
1137
+ );
1138
+ } else {
1139
+ console.log('\n✓ session-close verified — all 5 mandatory files fresh, lint clean.');
1140
+ }
1141
+ }
1142
+ // When ok:true but the session-close marker was NOT written, the Stop-chain
1143
+ // still sees an open session and will re-prompt at the next Stop. Surface this
1144
+ // loudly so neither the human nor a skill-following model reads "ok:true" as
1145
+ // "session fully closed". Gate on markerSkipReason (non-null exactly when
1146
+ // args.sessionId is present and the marker was withheld).
1147
+ if (markerSkipReason) {
1148
+ process.stderr.write(
1149
+ `\n⚠️ session-close marker NOT written (reason: ${markerSkipReason})\n` +
1150
+ ` The 5 mandatory files were applied and verified (ok:true), but the\n` +
1151
+ ` per-session Stop-chain marker was withheld. The session is NOT fully\n` +
1152
+ ` closed: the Stop hook will re-prompt until the marker is present.\n` +
1153
+ ` To fix: re-run with the correct main-conversation --session-id (NOT\n` +
1154
+ ` a background task or Agent UUID from a /tmp/... path).\n` +
1155
+ ` Example: crystallize.mjs --apply-session-close --payload=<path>\n` +
1156
+ ` --session-id=<main-conversation-id> --hypo-dir=<path>\n`,
1157
+ );
1158
+ }
1159
+ if (!ok) {
924
1160
  if (!verification.ok) {
925
1161
  const bad = [
926
1162
  ...verification.missing.map((f) => `${f} (missing)`),