hypomnema 1.3.1 → 1.3.3

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 (65) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/README.ko.md +11 -10
  4. package/README.md +11 -10
  5. package/commands/audit.md +1 -1
  6. package/commands/crystallize.md +11 -11
  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 +1 -1
  12. package/commands/lint.md +1 -1
  13. package/commands/query.md +1 -1
  14. package/commands/rename.md +79 -0
  15. package/commands/resume.md +2 -2
  16. package/commands/stats.md +1 -1
  17. package/commands/uninstall.md +1 -1
  18. package/commands/upgrade.md +1 -1
  19. package/commands/verify.md +1 -1
  20. package/docs/ARCHITECTURE.md +3 -3
  21. package/docs/CONTRIBUTING.md +2 -2
  22. package/hooks/hypo-auto-commit.mjs +8 -24
  23. package/hooks/hypo-auto-minimal-crystallize.mjs +57 -11
  24. package/hooks/hypo-compact-guard.mjs +6 -3
  25. package/hooks/hypo-cwd-change.mjs +1 -1
  26. package/hooks/hypo-first-prompt.mjs +2 -2
  27. package/hooks/hypo-hot-rebuild.mjs +14 -1
  28. package/hooks/hypo-personal-check.mjs +91 -179
  29. package/hooks/hypo-pre-commit.mjs +1 -1
  30. package/hooks/hypo-session-end.mjs +1 -1
  31. package/hooks/hypo-session-start.mjs +7 -7
  32. package/hooks/hypo-shared.mjs +1082 -89
  33. package/hooks/hypo-web-fetch-ingest.mjs +2 -2
  34. package/hooks/version-check-fetch.mjs +2 -8
  35. package/hooks/version-check.mjs +18 -0
  36. package/package.json +3 -2
  37. package/scripts/check-tracker-ids.mjs +329 -0
  38. package/scripts/crystallize.mjs +322 -110
  39. package/scripts/doctor.mjs +6 -9
  40. package/scripts/feedback-sync.mjs +1 -1
  41. package/scripts/init.mjs +1 -1
  42. package/scripts/install-git-hooks.mjs +75 -40
  43. package/scripts/lib/check-tracker-ids.mjs +140 -0
  44. package/scripts/lib/design-history-stale.mjs +59 -14
  45. package/scripts/lib/extensions.mjs +4 -4
  46. package/scripts/lib/fix-manifest.mjs +2 -2
  47. package/scripts/lib/fix-status-verify.mjs +5 -4
  48. package/scripts/lib/plugin-detect.mjs +15 -6
  49. package/scripts/lib/project-create.mjs +1 -1
  50. package/scripts/lint.mjs +63 -8
  51. package/scripts/rename.mjs +836 -0
  52. package/scripts/resume.mjs +75 -19
  53. package/scripts/uninstall.mjs +1 -1
  54. package/scripts/upgrade.mjs +26 -25
  55. package/skills/crystallize/SKILL.md +12 -9
  56. package/skills/graph/SKILL.md +1 -1
  57. package/skills/ingest/SKILL.md +1 -1
  58. package/skills/lint/SKILL.md +1 -1
  59. package/skills/query/SKILL.md +1 -1
  60. package/skills/verify/SKILL.md +1 -1
  61. package/templates/SCHEMA.md +2 -2
  62. package/templates/hypo-config.md +2 -2
  63. package/templates/hypo-guide.md +22 -1
  64. package/templates/hypo-help.md +1 -0
  65. package/templates/projects/_template/index.md +1 -1
@@ -12,16 +12,16 @@
12
12
  * Options:
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
- * --check-session-close Verify the strict session-close memory files — 5 mandatory + open-questions conditional (fix #17)
15
+ * --check-session-close Verify the strict session-close memory files — 5 mandatory + open-questions conditional
16
16
  * --apply-session-close Apply a JSON payload that updates the 5 mandatory memory files
17
17
  * (+ optional open-questions). Idempotent — re-running with the same
18
18
  * payload is a no-op. Always finishes with the strict gate check.
19
19
  *
20
20
  * Without --payload, runs as a cheap "already complete?" probe:
21
21
  * if the strict gate is ok, exits 0 with alreadyComplete:true;
22
- * otherwise exits 1 with "payload is required". Fix #39 (option D):
22
+ * otherwise exits 1 with "payload is required". Option D:
23
23
  * payload presence = explicit close intent → always full apply
24
- * (fix #38's per-entry idempotency keeps re-apply cheap).
24
+ * (the per-entry idempotency keeps re-apply cheap).
25
25
  * --payload=<path|-> Path to JSON payload (file or `-` for stdin). Required for any
26
26
  * apply work; omit only for the probe path above.
27
27
  * --force Bypass the no-payload probe early-exit. Payload is still required
@@ -29,7 +29,7 @@
29
29
  * shortcut. Reserved for explicit diagnostics / scripted recovery.
30
30
  * --json Output as JSON
31
31
  *
32
- * Payload schema (fix #38):
32
+ * Payload schema:
33
33
  * {
34
34
  * "project": "<slug>", // optional — defaults to resolveActiveProject()
35
35
  * "date": "YYYY-MM-DD", // optional — defaults to today (local)
@@ -45,7 +45,7 @@
45
45
  * stale date, the final sessionCloseFileStatus check fails with a clear error so the
46
46
  * caller fixes the payload and retries. Silent rewrites would mask payload bugs.
47
47
  *
48
- * Lint gates (fix #40):
48
+ * Lint gates:
49
49
  * • Preflight — runs `lint.mjs --json` BEFORE any payload byte is written.
50
50
  * Errors in files this payload will OVERWRITE (sessionState/projectHot/
51
51
  * rootHot/openQuestions) are filtered out — they're about to be replaced,
@@ -76,12 +76,18 @@ import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
76
76
  import { loadHypoIgnore, isIgnored } from './lib/hypo-ignore.mjs';
77
77
  import {
78
78
  sessionCloseFileStatus,
79
+ sessionCloseGlobalStatus,
80
+ precompactGateStatus,
79
81
  writeSessionClosedMarker,
80
82
  sessionClosedMarkerPath,
81
- hypoIsClean,
82
- extractTouchedWikiFiles,
83
- closeFileTargets,
83
+ readSessionClosedMarker,
84
84
  partitionLintScope,
85
+ sessionLogShardPath,
86
+ sessionLogReadCandidates,
87
+ sessionLogScopePath,
88
+ resolveTranscriptBySessionId,
89
+ hasUserCloseSignal,
90
+ commitWikiChanges,
85
91
  } from '../hooks/hypo-shared.mjs';
86
92
 
87
93
  const LINT_SCRIPT = join(dirname(fileURLToPath(import.meta.url)), 'lint.mjs');
@@ -94,7 +100,7 @@ const LINT_SCRIPT = join(dirname(fileURLToPath(import.meta.url)), 'lint.mjs');
94
100
  // with valid JSON is a normal "errors present" signal, not a crash.
95
101
  // maxBuffer raised to 64 MiB: warn-only output on a large wiki can otherwise
96
102
  // trip Node's 1 MiB default, truncate stdout, and turn a clean wiki into a
97
- // JSON.parse crash (codex P3 — fix #40 follow-up).
103
+ // JSON.parse crash (codex P3 follow-up).
98
104
  function runLint(hypoDir) {
99
105
  const r = spawnSync(process.execPath, [LINT_SCRIPT, `--hypo-dir=${hypoDir}`, '--json'], {
100
106
  encoding: 'utf-8',
@@ -103,8 +109,17 @@ function runLint(hypoDir) {
103
109
  try {
104
110
  return JSON.parse(r.stdout);
105
111
  } catch {
112
+ // Report diagnostic metadata (sizes, exit/signal, spawn error code, a stderr
113
+ // tail) instead of dumping the whole — possibly huge, possibly truncated —
114
+ // stdout. lint.mjs now sets exitCode and exits naturally so its stdout is no
115
+ // longer cut at the 64 KiB pipe boundary; if this still fires it signals a
116
+ // genuine crash, and these fields say which kind.
117
+ const stderrTail = (r.stderr || '').slice(-2000);
106
118
  throw new Error(
107
- `lint helper produced unparseable output (exit=${r.status}):\n${r.stdout}\n${r.stderr}`,
119
+ `lint helper produced unparseable output ` +
120
+ `(exit=${r.status}, signal=${r.signal || 'none'}, ` +
121
+ `stdoutBytes=${(r.stdout || '').length}, spawnError=${r.error?.code || 'none'})` +
122
+ (stderrTail ? `\nstderr tail:\n${stderrTail}` : ''),
108
123
  );
109
124
  }
110
125
  }
@@ -119,6 +134,7 @@ function parseArgs(argv) {
119
134
  checkSessionClose: false,
120
135
  applySessionClose: false,
121
136
  markSessionClosed: false,
137
+ logOnly: false,
122
138
  sessionId: null,
123
139
  payload: null,
124
140
  force: false,
@@ -130,6 +146,7 @@ function parseArgs(argv) {
130
146
  else if (arg === '--check-session-close') args.checkSessionClose = true;
131
147
  else if (arg === '--apply-session-close') args.applySessionClose = true;
132
148
  else if (arg === '--mark-session-closed') args.markSessionClosed = true;
149
+ else if (arg === '--log-only') args.logOnly = true;
133
150
  else if (arg.startsWith('--session-id=')) args.sessionId = arg.slice(13);
134
151
  else if (arg.startsWith('--payload=')) args.payload = arg.slice(10);
135
152
  else if (arg.startsWith('--transcript-path=')) args.transcriptPath = expandHome(arg.slice(18));
@@ -140,22 +157,71 @@ function parseArgs(argv) {
140
157
  return args;
141
158
  }
142
159
 
160
+ // ── session-close hard gate (ADR 0055) ───────────────────────────────────────
161
+ // The marker attests "the USER closed this session". Its evidence transcript is
162
+ // resolved STRICTLY from the session id (a globally-unique UUID) by globbing the
163
+ // Claude project dirs — never from a CLI arg. A model owns the whole subprocess
164
+ // invocation, so trusting a `--transcript-path` it supplies would let it point at
165
+ // a forged `<session-id>.jsonl` it just wrote with a fake close phrase. Resolving
166
+ // from the id alone closes that: the only file the glob finds is the live
167
+ // transcript the harness itself maintains, which the model cannot author. (If the
168
+ // model drops a second `<id>.jsonl` elsewhere the glob returns >1 and fails
169
+ // closed.) `--transcript-path` survives ONLY for `--check-session-close`'s lint
170
+ // scope, which writes no marker and so cannot cause an over-close.
171
+
143
172
  // ── session-close check (spec §5.2.7 / §8.3) ────────────────────────
144
173
  // Mirrors the hard gate in hypo-personal-check.mjs so the /hypo:crystallize
145
174
  // flow can self-verify before /compact triggers PreCompact.
146
175
 
147
176
  function runSessionCloseCheck(args) {
148
- const status = sessionCloseFileStatus(args.hypoDir);
177
+ // ADR 0046: the check mirrors the FULL PreCompact gate via the shared
178
+ // precompactGateStatus (close files + lint + design-history + feedback
179
+ // projection), not just the close files — so a green check means /compact
180
+ // won't block on a human-fixable issue. Pass --transcript-path to widen the
181
+ // lint scope to the session's edited files exactly as the interactive hook
182
+ // does (without it, the scope is the mandatory close files only).
183
+ // Pass --session-id so a log-only marker activates log-only gate
184
+ // semantics here too. Without it the check would read the marker as present
185
+ // (marker_present:true) while `ok` still reflected the stale active project —
186
+ // the completion-signal trio (PreCompact / --check / marker) would diverge
187
+ // (codex design Finding 2).
188
+ const status = precompactGateStatus(args.hypoDir, {
189
+ ...(args.transcriptPath ? { transcriptPath: args.transcriptPath } : {}),
190
+ ...(args.sessionId ? { sessionId: args.sessionId } : {}),
191
+ });
192
+ const close = status.close;
193
+
194
+ // ADR 0047: when a --session-id is supplied, report whether THIS session's
195
+ // per-session marker (the Stop-chain completion signal) exists. This is a
196
+ // separate field, NOT folded into `ok` — `ok` stays the ADR 0046 compact-
197
+ // readiness verdict. A green gate with marker_present=false is exactly the
198
+ // hand-edit close state: close is compact-ready but the Stop hook will
199
+ // still block until the marker is written.
200
+ //
201
+ // Use the SAME reader the Stop hook gates on (readSessionClosedMarker), not
202
+ // raw file existence: a stale/corrupt marker file exists on disk but the hook
203
+ // rejects (and unlinks) it, so raw existsSync would report marker_present=true
204
+ // while /compact's Stop still blocks — the exact incoherence this ADR closes
205
+ // (codex pre-commit CONCERN). readSessionClosedMarker unlinks an invalid
206
+ // marker as it reads, matching the hook's behavior on the next Stop.
207
+ const markerPresent = args.sessionId
208
+ ? readSessionClosedMarker(args.hypoDir, args.sessionId) !== null
209
+ : null;
149
210
 
150
211
  if (args.json) {
151
212
  console.log(
152
213
  JSON.stringify(
153
214
  {
154
215
  ok: status.ok,
155
- project: status.project,
156
- dates: status.dates,
157
- stale: status.stale,
158
- missing: status.missing,
216
+ // flat close fields preserved for back-compat with prior readers
217
+ project: close.project,
218
+ dates: close.dates,
219
+ stale: close.stale,
220
+ missing: close.missing,
221
+ blockers: status.blockers,
222
+ notices: status.notices,
223
+ skipped: status.skipped,
224
+ ...(args.sessionId ? { session_id: args.sessionId, marker_present: markerPresent } : {}),
159
225
  },
160
226
  null,
161
227
  2,
@@ -164,31 +230,51 @@ function runSessionCloseCheck(args) {
164
230
  process.exit(status.ok ? 0 : 1);
165
231
  }
166
232
 
167
- const proj = status.project || '(unresolved)';
168
- console.log(`Session-close check (project: ${proj}, date: ${status.dates.join(' / ')}):\n`);
233
+ const proj = close.project || '(unresolved)';
234
+ console.log(`Compact-ready check (project: ${proj}, date: ${close.dates.join(' / ')}):\n`);
169
235
 
170
- const required = status.project
236
+ const required = close.project
171
237
  ? [
172
- `projects/${status.project}/session-state.md`,
173
- `projects/${status.project}/hot.md`,
238
+ `projects/${close.project}/session-state.md`,
239
+ `projects/${close.project}/hot.md`,
174
240
  'hot.md',
175
- `projects/${status.project}/session-log/${status.dates[0].slice(0, 7)}.md`,
241
+ sessionLogShardPath(close.project, close.dates[0]),
176
242
  'log.md',
177
243
  ]
178
244
  : [];
179
245
  for (const f of required) {
180
- const bad = status.missing.includes(f) ? 'missing' : status.stale.includes(f) ? 'stale' : '';
246
+ const bad = close.missing.includes(f) ? 'missing' : close.stale.includes(f) ? 'stale' : '';
181
247
  console.log(` ${bad ? '✗' : '✓'} ${f}${bad ? ` — ${bad}` : ''}`);
182
248
  }
183
249
  // Surface anything not covered by the canonical list (e.g. unresolved project).
184
- for (const f of [...status.missing, ...status.stale]) {
250
+ for (const f of [...close.missing, ...close.stale]) {
185
251
  if (!required.includes(f)) console.log(` ✗ ${f}`);
186
252
  }
253
+ // Beyond the close files: the rest of the PreCompact gate (lint, design-history,
254
+ // feedback over-cap/conflict). These are what made a "close-complete" check
255
+ // disagree with the real /compact gate before ADR 0046.
256
+ for (const b of status.blockers) {
257
+ if (b.type !== 'close') console.log(` ✗ ${b.reason}`);
258
+ }
259
+ if (status.notices.length > 0) {
260
+ console.log('');
261
+ for (const n of status.notices) console.log(` · ${n.reason}`);
262
+ }
263
+ // ADR 0047: surface the per-session marker state (separate from compact-
264
+ // readiness) so a green-but-unmarked close is visible at verify time.
265
+ if (args.sessionId) {
266
+ console.log('');
267
+ console.log(
268
+ markerPresent
269
+ ? ` ✓ session-closed marker present (session_id: ${args.sessionId}).`
270
+ : ` · 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}` : ''}\`.`,
271
+ );
272
+ }
187
273
  console.log('');
188
274
  console.log(
189
275
  status.ok
190
- ? '✓ All required memory files updated this session. (open-questions.md: conditional, not checked)'
191
- : '✗ Session close incomplete update the files marked above, then retry.',
276
+ ? '✓ 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.)'
277
+ : '✗ Not compact-readyresolve the items above, then retry. /compact would block on these.',
192
278
  );
193
279
  process.exit(status.ok ? 0 : 1);
194
280
  }
@@ -252,7 +338,7 @@ function writeIfChanged(path, content) {
252
338
  /**
253
339
  * Append `entry` to `path` only if `alreadyPresent(content)` is false.
254
340
  * Atomic: rebuilds the full file content and writes via atomicWrite — a crash
255
- * mid-append cannot leave log.md or session-log/YYYY-MM.md half-written, which
341
+ * mid-append cannot leave log.md or session-log/YYYY-MM-DD.md half-written, which
256
342
  * matters for these append-only history files.
257
343
  */
258
344
  function appendIfAbsent(path, entry, alreadyPresent) {
@@ -283,7 +369,7 @@ function todayLocal() {
283
369
  // bug, not a no-op. Caller is the LLM session-close flow, which composes the
284
370
  // payload deliberately; partial payloads must fail loudly so caller fixes them
285
371
  // rather than silently relying on yesterday's freshness state. (Codex review
286
- // of fix #38 — Worker 1 finding 1.)
372
+ // of the apply path — Worker 1 finding 1.)
287
373
  const REQUIRED_PAYLOAD_FIELDS = [
288
374
  ['sessionState', 'content'],
289
375
  ['projectHot', 'content'],
@@ -329,8 +415,9 @@ function validatePayloadShape(payload) {
329
415
  // crystallize` is the only Reader; writer authority is intentionally split
330
416
  // between this CLI and the auto-write at the tail of applySessionClose.
331
417
  //
332
- // Contract: marker is only written when sessionCloseFileStatus(hypoDir).ok.
333
- // A failed check exits 1 with no marker — the next Stop hook will re-block.
418
+ // Contract: the marker is written only when the FULL /compact gate
419
+ // (precompactGateStatus, ADR 0046) is green. A failed gate exits 1 with no
420
+ // marker — the next Stop hook re-blocks.
334
421
 
335
422
  function runMarkSessionClosed(args) {
336
423
  if (!args.sessionId) {
@@ -338,20 +425,39 @@ function runMarkSessionClosed(args) {
338
425
  console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
339
426
  process.exit(1);
340
427
  }
341
- // ADR 0022 amendment 2026-05-19 Q2: marker write authority requires BOTH
342
- // sessionCloseFileStatus.ok AND hypoIsClean.clean git dirty would let a
343
- // Stop hook pass while wiki changes are still uncommitted (auto-commit may
344
- // have failed in this run). Codex Worker-1 BLOCKER (pre-commit review).
345
- const status = sessionCloseFileStatus(args.hypoDir);
346
- const git = hypoIsClean(args.hypoDir);
347
- if (!status.ok || !git.clean) {
428
+ // ADR 0047: the per-session marker is the THIRD session-close completion
429
+ // signal (after the PreCompact gate and `--check-session-close`). It must use
430
+ // the SAME gate that governs /compact precompactGateStatus so the marker
431
+ // can never attest "closed" while /compact would still block. This subsumes
432
+ // the prior (sessionCloseGlobalStatus + hypoIsClean + scoped-lint) gate and
433
+ // additionally enforces feedback projection (over-cap/conflict), W8 design-
434
+ // history staleness, and root hot.md structure — the checks that the narrower
435
+ // marker gate skipped (the divergence behind this fix). git-clean is now a
436
+ // `git` blocker inside the gate. Pass --transcript-path to widen the lint
437
+ // scope to this session's edited files exactly as the interactive hook does;
438
+ // without it the scope is the mandatory close files only.
439
+ // --log-only marks a non-project (tooling / wiki-only) session as
440
+ // closed without attributing it to any project. The gate runs in log-only mode
441
+ // (project-close invariant → a today log.md entry; lint/W8 scoped to shared +
442
+ // touched files, never the active/phantom project), but git / hot / feedback
443
+ // still apply — log-only is NOT a global-gate bypass.
444
+ // Resolve the close transcript once from the session id (glob, never a CLI
445
+ // arg): it both widens the lint scope inside the gate AND is the evidence
446
+ // source for the user-close hard gate below.
447
+ const closeTranscript = resolveTranscriptBySessionId(args.sessionId);
448
+ const gate = precompactGateStatus(args.hypoDir, {
449
+ ...(closeTranscript ? { transcriptPath: closeTranscript } : {}),
450
+ ...(args.logOnly ? { logOnly: true } : {}),
451
+ });
452
+ const status = gate.close;
453
+ if (!gate.ok) {
348
454
  const result = {
349
455
  ok: false,
350
456
  session_id: args.sessionId,
351
457
  project: status.project,
352
458
  missing: status.missing,
353
459
  stale: status.stale,
354
- git_reason: git.clean ? null : git.reason,
460
+ blockers: gate.blockers,
355
461
  error: 'session-close gate not satisfied — marker not written',
356
462
  };
357
463
  if (args.json) {
@@ -360,56 +466,34 @@ function runMarkSessionClosed(args) {
360
466
  console.log(
361
467
  `✗ session-close gate not satisfied — marker not written (project: ${status.project || '(unresolved)'}):`,
362
468
  );
363
- for (const f of status.missing) console.log(` ✗ ${f} (missing)`);
364
- for (const f of status.stale) console.log(` ✗ ${f} (stale)`);
365
- if (!git.clean) console.log(` ✗ git: ${git.reason}`);
469
+ for (const b of gate.blockers) console.log(` ✗ ${b.reason}`);
366
470
  }
367
471
  process.exit(1);
368
472
  }
369
- // Bug A coherence: the marker suppresses the Stop hook, but PreCompact blocks
370
- // on lint. If a transcript is provided, refuse the marker when THIS session's
371
- // own files (edited mandatory close files) carry lint errors otherwise
372
- // Stop would pass only for /compact to immediately re-block. No transcript
373
- // legacy freshness+git recovery path (lint left to PreCompact).
374
- if (args.transcriptPath) {
375
- let scopedLint = null;
376
- try {
377
- scopedLint = runLint(args.hypoDir);
378
- } catch (err) {
379
- // Lint crash → fail-open (never strand the marker writer on tooling), same
380
- // posture as the PreCompact hook.
381
- process.stderr.write(
382
- `[crystallize] mark-session-closed lint skipped: ${err?.message ?? err}\n`,
383
- );
384
- }
385
- if (scopedLint) {
386
- const scope = new Set([
387
- ...extractTouchedWikiFiles(args.transcriptPath, args.hypoDir),
388
- ...closeFileTargets(args.hypoDir),
389
- ]);
390
- const { blocking } = partitionLintScope(scopedLint.errors || [], scope);
391
- if (blocking.length > 0) {
392
- const files = [...new Set(blocking.map((b) => b.file))];
393
- const result = {
394
- ok: false,
395
- session_id: args.sessionId,
396
- project: status.project,
397
- lint_blockers: files,
398
- error: "session-close gate not satisfied — lint errors in this session's files",
399
- };
400
- if (args.json) {
401
- console.log(JSON.stringify(result, null, 2));
402
- } else {
403
- console.log(
404
- `✗ lint errors in files this session touched — marker not written (fix then re-run):`,
405
- );
406
- for (const b of blocking) console.log(` ✗ ${b.file}: ${b.message}`);
407
- }
408
- process.exit(1);
409
- }
410
- }
473
+ // User-close hard gate (ADR 0055): the compact gate above only proves the wiki
474
+ // is compact-ready; it does NOT prove the USER asked to close. Refuse the marker
475
+ // unless the transcript carries a genuine user close signal (NL close phrase,
476
+ // /compact, or an AskUserQuestion close answer). This is the hard backstop for
477
+ // model over-close, where prose guidance lost to a conflicting global rule.
478
+ // Fail-closed when the transcript can't be resolved.
479
+ if (!closeTranscript || !hasUserCloseSignal(closeTranscript)) {
480
+ const reason = !closeTranscript
481
+ ? `cannot resolve a transcript for session ${args.sessionId} — the session-closed marker requires a verifiable user close signal`
482
+ : "no user close signal in this session's transcript — marker refused (the user did not signal session close)";
483
+ const result = {
484
+ ok: false,
485
+ session_id: args.sessionId,
486
+ project: status.project,
487
+ skipReason: 'no-user-close-signal',
488
+ error: reason,
489
+ };
490
+ console.log(args.json ? JSON.stringify(result, null, 2) : `✗ ${reason}`);
491
+ process.exit(1);
411
492
  }
412
- writeSessionClosedMarker(args.hypoDir, args.sessionId, { project: status.project });
493
+ writeSessionClosedMarker(args.hypoDir, args.sessionId, {
494
+ project: status.project,
495
+ ...(args.logOnly ? { scope: 'log-only' } : {}),
496
+ });
413
497
  // Marker writer swallows IO errors (best-effort, see hypo-shared.mjs). Verify
414
498
  // the file actually landed before claiming success — otherwise CLI exits 0
415
499
  // while next Stop re-blocks, hiding a permission/disk problem.
@@ -427,22 +511,36 @@ function runMarkSessionClosed(args) {
427
511
  ok: true,
428
512
  session_id: args.sessionId,
429
513
  project: status.project,
514
+ scope: args.logOnly ? 'log-only' : 'project',
430
515
  date: status.dates[0],
516
+ notices: gate.notices,
517
+ // ADR 0047: pure feedback-projection drift is a non-blocker — the marker
518
+ // attests "compact-ready (no human-fixable blocker)", and the PreCompact
519
+ // hook self-heals the projection (feedback-sync --write) at /compact. Surface
520
+ // the deferral so the caller knows MEMORY/CLAUDE sync is pending, not lost.
521
+ drift_deferred: gate.driftTargets,
431
522
  };
432
523
  if (args.json) {
433
524
  console.log(JSON.stringify(result, null, 2));
434
525
  } else {
435
526
  console.log(
436
- `✓ session-closed marker written (session_id: ${args.sessionId}, project: ${status.project}).`,
527
+ args.logOnly
528
+ ? `✓ session-closed marker written (session_id: ${args.sessionId}, scope: log-only — no project attribution).`
529
+ : `✓ session-closed marker written (session_id: ${args.sessionId}, project: ${status.project}).`,
437
530
  );
531
+ if (gate.driftTargets.length > 0) {
532
+ console.log(
533
+ ` · feedback projection drift (${gate.driftTargets.join(', ')}) — will self-heal at /compact.`,
534
+ );
535
+ }
438
536
  }
439
537
  process.exit(0);
440
538
  }
441
539
 
442
540
  function applySessionClose(args) {
443
- // Fix #39 (option D): early-exit fires only when NO payload was supplied.
541
+ // Option D: early-exit fires only when NO payload was supplied.
444
542
  // Rationale: payload presence is explicit close intent and must always run
445
- // the full apply path — fix #38's per-entry idempotency (writeIfChanged +
543
+ // the full apply path — the per-entry idempotency (writeIfChanged +
446
544
  // exact-entry append dedup) keeps re-apply cheap without short-circuiting,
447
545
  // and avoids silent-success when a same-day second close brings new bytes.
448
546
  // Payload-less invocation is treated as a cheap "already complete?" probe.
@@ -450,7 +548,9 @@ function applySessionClose(args) {
450
548
  // for any actual apply work (readPayload below surfaces "payload is
451
549
  // required" the same way it always has).
452
550
  if (!args.force && !args.payload) {
453
- const probe = sessionCloseFileStatus(args.hypoDir);
551
+ // ADR 0043: no-payload "already complete?" probe uses the
552
+ // global invariant, not a recency pick.
553
+ const probe = sessionCloseGlobalStatus(args.hypoDir);
454
554
  if (probe.ok) {
455
555
  const result = {
456
556
  ok: true,
@@ -505,10 +605,22 @@ function applySessionClose(args) {
505
605
  console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
506
606
  process.exit(1);
507
607
  }
608
+ // The freshness verification below (and at the post-apply check) already honors
609
+ // payload.project — `project` wins over the inferred active project, and the
610
+ // post-apply sessionCloseFileStatus call passes it as projectOverride. But when the
611
+ // payload targets a DIFFERENT project than the one active-project resolution infers
612
+ // (probe.project), that divergence used to be silent, so an operator couldn't tell
613
+ // which project the close actually verified. Surface it on stderr (the stdout JSON
614
+ // contract is untouched) so the verified/closed project is always explicit.
615
+ if (payload.project && probe.project && probe.project !== payload.project) {
616
+ process.stderr.write(
617
+ `note: payload.project="${payload.project}" differs from the inferred active ` +
618
+ `project "${probe.project}"; verifying and closing "${payload.project}".\n`,
619
+ );
620
+ }
508
621
  const date = payload.date || todayLocal();
509
- const ym = date.slice(0, 7);
510
622
 
511
- // Fix #40 preflight: lint the wiki BEFORE writing any payload bytes. If lint
623
+ // Preflight: lint the wiki BEFORE writing any payload bytes. If lint
512
624
  // has blockers (errors) in files this apply WON'T overwrite, the wiki is in
513
625
  // a degraded state and apply would mask the root cause — abort fail-fast.
514
626
  //
@@ -531,11 +643,27 @@ function applySessionClose(args) {
531
643
  // the files it writes (other projects, shared pages this close did not author).
532
644
  // payloadScope = every file this apply writes or appends. Both lint passes are
533
645
  // judged against it; errors elsewhere are surfaced as notices, never blocking.
646
+ //
647
+ // session-log needs TWO entries (ADR 0050): the daily WRITE target (what this
648
+ // apply creates/appends, judged by post-apply lint) AND the freshness EVIDENCE
649
+ // file. They coincide except in the hybrid cutover month, where a fallback-
650
+ // aware no-op (the identical entry already lives in the legacy monthly file)
651
+ // writes no daily shard, leaving the monthly as the proof of freshness. Scope
652
+ // must then include that monthly file, or a CORRUPT monthly evidence file would
653
+ // pass the gate with its lint error demoted to a non-blocking notice.
654
+ // sessionLogScopePath returns the monthly ONLY when it carries today's heading
655
+ // (otherwise the daily write target), so unrelated monthly debt stays a notice.
656
+ // join() (platform-native), not the POSIX helper output: payloadScope membership
657
+ // is tested against lint's raw `e.file` (path.relative) WITHOUT posix
658
+ // normalization, so it must use the OS-native separator the sibling entries use.
659
+ const sessionLogWriteTarget = join('projects', project, 'session-log', `${date}.md`);
660
+ const sessionLogEvidence = join(...sessionLogScopePath(args.hypoDir, project, date).split('/'));
534
661
  const payloadScope = new Set([
535
662
  join('projects', project, 'session-state.md'),
536
663
  join('projects', project, 'hot.md'),
537
664
  'hot.md',
538
- join('projects', project, 'session-log', `${ym}.md`),
665
+ sessionLogWriteTarget,
666
+ sessionLogEvidence, // == write target, except a hybrid-month monthly fallback
539
667
  'log.md',
540
668
  ...(payload.openQuestions ? [join('pages', 'open-questions.md')] : []),
541
669
  ]);
@@ -590,18 +718,56 @@ function applySessionClose(args) {
590
718
  // dated today". The freshness gate (sessionCloseFileStatus) is what answers
591
719
  // "was this file touched today?"; that's a different concern and must not
592
720
  // be reused for apply-time dedup, or a legitimate same-day second close gets
593
- // silently dropped (Codex review of fix #38 — Worker 1 finding 2).
721
+ // silently dropped (Codex review of the apply path — Worker 1 finding 2).
594
722
  const entryAlreadyPresent = (entry) => (content) =>
595
723
  content.includes(entry.endsWith('\n') ? entry.replace(/\n+$/, '') : entry);
596
724
 
597
725
  {
598
- const rel = join('projects', project, 'session-log', `${ym}.md`);
599
- const wrote = appendIfAbsent(
600
- join(args.hypoDir, rel),
601
- payload.sessionLog.entry,
602
- entryAlreadyPresent(payload.sessionLog.entry),
603
- );
604
- (wrote ? applied : skipped).push(`sessionLog (${rel})`);
726
+ const rel = join('projects', project, 'session-log', `${date}.md`);
727
+ const full = join(args.hypoDir, rel);
728
+ const isPresent = entryAlreadyPresent(payload.sessionLog.entry);
729
+ // Fallback-aware idempotency (ADR 0050 hybrid cutover): during the month the
730
+ // shard takes over, today's entry may already live in the legacy monthly
731
+ // file from an earlier (pre-cutover) close. Treat presence in EITHER the
732
+ // daily shard or the legacy monthly file as "already written" so a same-day
733
+ // second close does not duplicate an identical entry across both files —
734
+ // and so an idempotent re-apply stays a true no-op (no shard is created).
735
+ let alreadyThere = false;
736
+ for (const cand of sessionLogReadCandidates(project, date)) {
737
+ const cf = join(args.hypoDir, cand);
738
+ if (!existsSync(cf)) continue;
739
+ try {
740
+ if (isPresent(readFileSync(cf, 'utf-8'))) {
741
+ alreadyThere = true;
742
+ break;
743
+ }
744
+ } catch {
745
+ /* unreadable candidate — fall through to the write path */
746
+ }
747
+ }
748
+ if (alreadyThere) {
749
+ skipped.push(`sessionLog (${rel})`);
750
+ } else if (!existsSync(full)) {
751
+ // A daily shard is a new file most days. Seed minimal valid frontmatter
752
+ // (title + type, the two REQUIRED_FIELDS) so the shard is a first-class
753
+ // wiki page rather than a W1 "no frontmatter" warning, and write the header
754
+ // AND the first entry in ONE atomic write — never leave a header-only shard
755
+ // on disk, which freshness would skip (no dated heading) while derive could
756
+ // otherwise mistake it for the evidence file. The dated `## [date] ...`
757
+ // heading lives inside the entry, so freshness / derive / design-history
758
+ // are unchanged.
759
+ const header =
760
+ `---\ntitle: Session Log ${date} (${project})\n` +
761
+ `type: session-log\nupdated: ${date}\n---\n\n` +
762
+ `# Session Log ${date} (${project})\n`;
763
+ const entry = payload.sessionLog.entry;
764
+ const body = entry.endsWith('\n') ? entry : `${entry}\n`;
765
+ atomicWrite(full, `${header}\n${body}`);
766
+ applied.push(`sessionLog (${rel})`);
767
+ } else {
768
+ const wrote = appendIfAbsent(full, payload.sessionLog.entry, isPresent);
769
+ (wrote ? applied : skipped).push(`sessionLog (${rel})`);
770
+ }
605
771
  }
606
772
 
607
773
  {
@@ -613,14 +779,14 @@ function applySessionClose(args) {
613
779
  (wrote ? applied : skipped).push('log (log.md)');
614
780
  }
615
781
 
616
- // ISSUE-7 Part A: verify against the SAME project this apply just wrote
782
+ // Same-date-tie fix: verify against the SAME project this apply just wrote
617
783
  // (`project` = payload.project || probe.project, resolved at the top). Without
618
784
  // the override, sessionCloseFileStatus re-derives via resolveActiveProject and,
619
785
  // on a same-date root-hot.md tie, can pick a different project — false-failing
620
786
  // a completed close (the 2026-06-09 security-ops-kb incident).
621
787
  const verification = sessionCloseFileStatus(args.hypoDir, { projectOverride: project });
622
788
 
623
- // Fix #40 post-apply lint: payload may have introduced a malformed body or
789
+ // Post-apply lint: payload may have introduced a malformed body or
624
790
  // bad frontmatter. Surface as a distinct `stage` so caller can tell "lint
625
791
  // broke" apart from "frontmatter stale". This runs even if the freshness gate
626
792
  // also failed — both failure modes are useful to the caller.
@@ -660,18 +826,61 @@ function applySessionClose(args) {
660
826
  // ADR 0022 amendment 2026-05-19: auto-write the per-session
661
827
  // closed marker on a verified close. Hook authority is read-only; this is
662
828
  // one of the two writer paths (the other is --mark-session-closed standalone).
663
- // Marker requires BOTH file/lint gate (already in `ok`) AND clean git tree —
664
- // ADR Q2 explicit. Auto-commit may have failed silently in the Stop chain;
665
- // a dirty git would otherwise let the marker pass for an unrecorded close.
829
+ //
830
+ // ADR 0047: the marker write is governed by the SAME gate as standalone
831
+ // --mark-session-closed and /compact (precompactGateStatus), NOT just apply's
832
+ // `ok` + git-clean. Apply's payload preflight/post-apply lint and `ok` still
833
+ // govern apply SUCCESS (exit code below), but the marker must additionally
834
+ // clear feedback projection / W8 design-history / hot.md structure, else this
835
+ // path could issue a marker the standalone path would refuse (the second
836
+ // divergence codex flagged).
837
+ //
838
+ // ADR 0056: apply just wrote the payload, so the tree is dirty by its OWN
839
+ // writes — the gate's `uncommitted` git blocker would always trip and the
840
+ // marker would be skipped, deferring the close to a manual --mark-session-closed
841
+ // (the ADR 0047 "done but still blocked" regression). Commit the payload HERE, via
842
+ // the SAME .hypoignore-aware helper the auto-commit Stop hook uses, so the gate sees
843
+ // a committed tree. Push stays deferred to the Stop hook; the resulting
844
+ // committed-but-unpushed state is a gate notice, not a blocker (ADR 0056), so
845
+ // this still marks. A commit failure (not a repo / pre-commit reject / git error)
846
+ // skips the marker WITH a surfaced reason — today's behavior was also "no marker",
847
+ // but silently.
848
+ let markerWritten = false;
849
+ let markerSkipReason = null;
666
850
  if (ok && args.sessionId) {
667
- const git = hypoIsClean(args.hypoDir);
668
- if (git.clean) {
669
- writeSessionClosedMarker(args.hypoDir, args.sessionId, { project });
851
+ const commitOutcome = commitWikiChanges(args.hypoDir);
852
+ if (!commitOutcome.committed) {
853
+ markerSkipReason = `commit-failed: ${commitOutcome.reason}`;
854
+ } else {
855
+ const closeTranscript = resolveTranscriptBySessionId(args.sessionId);
856
+ const markerGate = precompactGateStatus(
857
+ args.hypoDir,
858
+ closeTranscript ? { transcriptPath: closeTranscript } : {},
859
+ );
860
+ if (!markerGate.ok) {
861
+ // compact gate not ok → skip. Caller's `result.ok` already reflects the
862
+ // file/lint state; next Stop re-blocks until the remaining blocker
863
+ // (feedback/W8/hot/lint — git is now committed) is resolved.
864
+ markerSkipReason = 'compact-gate-not-ok';
865
+ } else if (!closeTranscript || !hasUserCloseSignal(closeTranscript)) {
866
+ // User-close hard gate (ADR 0055): apply succeeded (payload files written)
867
+ // but the user never signalled session close, so the marker — which attests
868
+ // "user closed" — is withheld. The wiki record stands; the session is simply
869
+ // not marked closed. Surfaced (not silent) so the caller knows.
870
+ markerSkipReason = closeTranscript ? 'no-user-close-signal' : 'transcript-unresolved';
871
+ } else {
872
+ writeSessionClosedMarker(args.hypoDir, args.sessionId, { project });
873
+ // Codex CONCERN (ADR 0055/0056): the writer swallows IO errors (best-effort).
874
+ // Verify the file actually landed — mirroring the standalone path — instead of
875
+ // asserting markerWritten=true, so a .cache permission/disk problem surfaces
876
+ // rather than the caller reporting "closed" while the next Stop re-blocks.
877
+ if (existsSync(sessionClosedMarkerPath(args.hypoDir, args.sessionId))) {
878
+ markerWritten = true;
879
+ } else {
880
+ markerSkipReason = 'marker-did-not-land';
881
+ }
882
+ }
670
883
  }
671
- // git not clean → silent skip: caller's `result.ok` already reflects the
672
- // file/lint state; surfacing a "marker skipped" warning here would
673
- // confuse the close-applied success path. Next Stop re-blocks until
674
- // git is clean (auto-commit retries on subsequent runs).
675
884
  }
676
885
  const stage = ok
677
886
  ? null
@@ -688,6 +897,9 @@ function applySessionClose(args) {
688
897
  applied,
689
898
  skipped,
690
899
  verification,
900
+ // ADR 0055: surface the marker outcome instead of skipping silently, so the
901
+ // caller can tell "closed" from "applied but not marked".
902
+ ...(args.sessionId ? { markerWritten, markerSkipReason } : {}),
691
903
  lint: { preflight: preflightLint, postApply: postApplyLint },
692
904
  // Pre-existing lint debt in files this close did not author (Bug B): surfaced
693
905
  // for visibility, never gated. Empty on a clean vault.