hypomnema 1.3.1 → 1.3.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 (46) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +2 -2
  3. package/README.ko.md +10 -10
  4. package/README.md +10 -10
  5. package/commands/crystallize.md +8 -8
  6. package/commands/feedback.md +1 -1
  7. package/commands/resume.md +1 -1
  8. package/docs/ARCHITECTURE.md +3 -3
  9. package/docs/CONTRIBUTING.md +2 -2
  10. package/hooks/hypo-auto-minimal-crystallize.mjs +57 -11
  11. package/hooks/hypo-compact-guard.mjs +1 -1
  12. package/hooks/hypo-cwd-change.mjs +1 -1
  13. package/hooks/hypo-first-prompt.mjs +2 -2
  14. package/hooks/hypo-hot-rebuild.mjs +14 -1
  15. package/hooks/hypo-personal-check.mjs +91 -179
  16. package/hooks/hypo-pre-commit.mjs +1 -1
  17. package/hooks/hypo-session-end.mjs +1 -1
  18. package/hooks/hypo-session-start.mjs +7 -7
  19. package/hooks/hypo-shared.mjs +788 -72
  20. package/hooks/hypo-web-fetch-ingest.mjs +2 -2
  21. package/hooks/version-check-fetch.mjs +2 -8
  22. package/hooks/version-check.mjs +18 -0
  23. package/package.json +3 -2
  24. package/scripts/check-tracker-ids.mjs +329 -0
  25. package/scripts/crystallize.mjs +244 -109
  26. package/scripts/doctor.mjs +6 -9
  27. package/scripts/feedback-sync.mjs +1 -1
  28. package/scripts/init.mjs +1 -1
  29. package/scripts/install-git-hooks.mjs +75 -40
  30. package/scripts/lib/check-tracker-ids.mjs +140 -0
  31. package/scripts/lib/design-history-stale.mjs +59 -14
  32. package/scripts/lib/extensions.mjs +4 -4
  33. package/scripts/lib/fix-manifest.mjs +2 -2
  34. package/scripts/lib/fix-status-verify.mjs +5 -4
  35. package/scripts/lib/plugin-detect.mjs +15 -6
  36. package/scripts/lib/project-create.mjs +1 -1
  37. package/scripts/lint.mjs +63 -8
  38. package/scripts/rename.mjs +373 -0
  39. package/scripts/resume.mjs +75 -19
  40. package/scripts/uninstall.mjs +1 -1
  41. package/scripts/upgrade.mjs +26 -25
  42. package/skills/crystallize/SKILL.md +10 -7
  43. package/templates/SCHEMA.md +2 -2
  44. package/templates/hypo-config.md +2 -2
  45. package/templates/hypo-guide.md +20 -1
  46. 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,15 @@ 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,
85
88
  } from '../hooks/hypo-shared.mjs';
86
89
 
87
90
  const LINT_SCRIPT = join(dirname(fileURLToPath(import.meta.url)), 'lint.mjs');
@@ -94,7 +97,7 @@ const LINT_SCRIPT = join(dirname(fileURLToPath(import.meta.url)), 'lint.mjs');
94
97
  // with valid JSON is a normal "errors present" signal, not a crash.
95
98
  // maxBuffer raised to 64 MiB: warn-only output on a large wiki can otherwise
96
99
  // 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).
100
+ // JSON.parse crash (codex P3 follow-up).
98
101
  function runLint(hypoDir) {
99
102
  const r = spawnSync(process.execPath, [LINT_SCRIPT, `--hypo-dir=${hypoDir}`, '--json'], {
100
103
  encoding: 'utf-8',
@@ -103,8 +106,17 @@ function runLint(hypoDir) {
103
106
  try {
104
107
  return JSON.parse(r.stdout);
105
108
  } catch {
109
+ // Report diagnostic metadata (sizes, exit/signal, spawn error code, a stderr
110
+ // tail) instead of dumping the whole — possibly huge, possibly truncated —
111
+ // stdout. lint.mjs now sets exitCode and exits naturally so its stdout is no
112
+ // longer cut at the 64 KiB pipe boundary; if this still fires it signals a
113
+ // genuine crash, and these fields say which kind.
114
+ const stderrTail = (r.stderr || '').slice(-2000);
106
115
  throw new Error(
107
- `lint helper produced unparseable output (exit=${r.status}):\n${r.stdout}\n${r.stderr}`,
116
+ `lint helper produced unparseable output ` +
117
+ `(exit=${r.status}, signal=${r.signal || 'none'}, ` +
118
+ `stdoutBytes=${(r.stdout || '').length}, spawnError=${r.error?.code || 'none'})` +
119
+ (stderrTail ? `\nstderr tail:\n${stderrTail}` : ''),
108
120
  );
109
121
  }
110
122
  }
@@ -119,6 +131,7 @@ function parseArgs(argv) {
119
131
  checkSessionClose: false,
120
132
  applySessionClose: false,
121
133
  markSessionClosed: false,
134
+ logOnly: false,
122
135
  sessionId: null,
123
136
  payload: null,
124
137
  force: false,
@@ -130,6 +143,7 @@ function parseArgs(argv) {
130
143
  else if (arg === '--check-session-close') args.checkSessionClose = true;
131
144
  else if (arg === '--apply-session-close') args.applySessionClose = true;
132
145
  else if (arg === '--mark-session-closed') args.markSessionClosed = true;
146
+ else if (arg === '--log-only') args.logOnly = true;
133
147
  else if (arg.startsWith('--session-id=')) args.sessionId = arg.slice(13);
134
148
  else if (arg.startsWith('--payload=')) args.payload = arg.slice(10);
135
149
  else if (arg.startsWith('--transcript-path=')) args.transcriptPath = expandHome(arg.slice(18));
@@ -145,17 +159,54 @@ function parseArgs(argv) {
145
159
  // flow can self-verify before /compact triggers PreCompact.
146
160
 
147
161
  function runSessionCloseCheck(args) {
148
- const status = sessionCloseFileStatus(args.hypoDir);
162
+ // ADR 0046: the check mirrors the FULL PreCompact gate via the shared
163
+ // precompactGateStatus (close files + lint + design-history + feedback
164
+ // projection), not just the close files — so a green check means /compact
165
+ // won't block on a human-fixable issue. Pass --transcript-path to widen the
166
+ // lint scope to the session's edited files exactly as the interactive hook
167
+ // does (without it, the scope is the mandatory close files only).
168
+ // Pass --session-id so a log-only marker activates log-only gate
169
+ // semantics here too. Without it the check would read the marker as present
170
+ // (marker_present:true) while `ok` still reflected the stale active project —
171
+ // the completion-signal trio (PreCompact / --check / marker) would diverge
172
+ // (codex design Finding 2).
173
+ const status = precompactGateStatus(args.hypoDir, {
174
+ ...(args.transcriptPath ? { transcriptPath: args.transcriptPath } : {}),
175
+ ...(args.sessionId ? { sessionId: args.sessionId } : {}),
176
+ });
177
+ const close = status.close;
178
+
179
+ // ADR 0047: when a --session-id is supplied, report whether THIS session's
180
+ // per-session marker (the Stop-chain completion signal) exists. This is a
181
+ // separate field, NOT folded into `ok` — `ok` stays the ADR 0046 compact-
182
+ // readiness verdict. A green gate with marker_present=false is exactly the
183
+ // hand-edit close state: close is compact-ready but the Stop hook will
184
+ // still block until the marker is written.
185
+ //
186
+ // Use the SAME reader the Stop hook gates on (readSessionClosedMarker), not
187
+ // raw file existence: a stale/corrupt marker file exists on disk but the hook
188
+ // rejects (and unlinks) it, so raw existsSync would report marker_present=true
189
+ // while /compact's Stop still blocks — the exact incoherence this ADR closes
190
+ // (codex pre-commit CONCERN). readSessionClosedMarker unlinks an invalid
191
+ // marker as it reads, matching the hook's behavior on the next Stop.
192
+ const markerPresent = args.sessionId
193
+ ? readSessionClosedMarker(args.hypoDir, args.sessionId) !== null
194
+ : null;
149
195
 
150
196
  if (args.json) {
151
197
  console.log(
152
198
  JSON.stringify(
153
199
  {
154
200
  ok: status.ok,
155
- project: status.project,
156
- dates: status.dates,
157
- stale: status.stale,
158
- missing: status.missing,
201
+ // flat close fields preserved for back-compat with prior readers
202
+ project: close.project,
203
+ dates: close.dates,
204
+ stale: close.stale,
205
+ missing: close.missing,
206
+ blockers: status.blockers,
207
+ notices: status.notices,
208
+ skipped: status.skipped,
209
+ ...(args.sessionId ? { session_id: args.sessionId, marker_present: markerPresent } : {}),
159
210
  },
160
211
  null,
161
212
  2,
@@ -164,31 +215,51 @@ function runSessionCloseCheck(args) {
164
215
  process.exit(status.ok ? 0 : 1);
165
216
  }
166
217
 
167
- const proj = status.project || '(unresolved)';
168
- console.log(`Session-close check (project: ${proj}, date: ${status.dates.join(' / ')}):\n`);
218
+ const proj = close.project || '(unresolved)';
219
+ console.log(`Compact-ready check (project: ${proj}, date: ${close.dates.join(' / ')}):\n`);
169
220
 
170
- const required = status.project
221
+ const required = close.project
171
222
  ? [
172
- `projects/${status.project}/session-state.md`,
173
- `projects/${status.project}/hot.md`,
223
+ `projects/${close.project}/session-state.md`,
224
+ `projects/${close.project}/hot.md`,
174
225
  'hot.md',
175
- `projects/${status.project}/session-log/${status.dates[0].slice(0, 7)}.md`,
226
+ sessionLogShardPath(close.project, close.dates[0]),
176
227
  'log.md',
177
228
  ]
178
229
  : [];
179
230
  for (const f of required) {
180
- const bad = status.missing.includes(f) ? 'missing' : status.stale.includes(f) ? 'stale' : '';
231
+ const bad = close.missing.includes(f) ? 'missing' : close.stale.includes(f) ? 'stale' : '';
181
232
  console.log(` ${bad ? '✗' : '✓'} ${f}${bad ? ` — ${bad}` : ''}`);
182
233
  }
183
234
  // Surface anything not covered by the canonical list (e.g. unresolved project).
184
- for (const f of [...status.missing, ...status.stale]) {
235
+ for (const f of [...close.missing, ...close.stale]) {
185
236
  if (!required.includes(f)) console.log(` ✗ ${f}`);
186
237
  }
238
+ // Beyond the close files: the rest of the PreCompact gate (lint, design-history,
239
+ // feedback over-cap/conflict). These are what made a "close-complete" check
240
+ // disagree with the real /compact gate before ADR 0046.
241
+ for (const b of status.blockers) {
242
+ if (b.type !== 'close') console.log(` ✗ ${b.reason}`);
243
+ }
244
+ if (status.notices.length > 0) {
245
+ console.log('');
246
+ for (const n of status.notices) console.log(` · ${n.reason}`);
247
+ }
248
+ // ADR 0047: surface the per-session marker state (separate from compact-
249
+ // readiness) so a green-but-unmarked close is visible at verify time.
250
+ if (args.sessionId) {
251
+ console.log('');
252
+ console.log(
253
+ markerPresent
254
+ ? ` ✓ session-closed marker present (session_id: ${args.sessionId}).`
255
+ : ` · 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}` : ''}\`.`,
256
+ );
257
+ }
187
258
  console.log('');
188
259
  console.log(
189
260
  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.',
261
+ ? '✓ 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.)'
262
+ : '✗ Not compact-readyresolve the items above, then retry. /compact would block on these.',
192
263
  );
193
264
  process.exit(status.ok ? 0 : 1);
194
265
  }
@@ -252,7 +323,7 @@ function writeIfChanged(path, content) {
252
323
  /**
253
324
  * Append `entry` to `path` only if `alreadyPresent(content)` is false.
254
325
  * 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
326
+ * mid-append cannot leave log.md or session-log/YYYY-MM-DD.md half-written, which
256
327
  * matters for these append-only history files.
257
328
  */
258
329
  function appendIfAbsent(path, entry, alreadyPresent) {
@@ -283,7 +354,7 @@ function todayLocal() {
283
354
  // bug, not a no-op. Caller is the LLM session-close flow, which composes the
284
355
  // payload deliberately; partial payloads must fail loudly so caller fixes them
285
356
  // rather than silently relying on yesterday's freshness state. (Codex review
286
- // of fix #38 — Worker 1 finding 1.)
357
+ // of the apply path — Worker 1 finding 1.)
287
358
  const REQUIRED_PAYLOAD_FIELDS = [
288
359
  ['sessionState', 'content'],
289
360
  ['projectHot', 'content'],
@@ -329,8 +400,9 @@ function validatePayloadShape(payload) {
329
400
  // crystallize` is the only Reader; writer authority is intentionally split
330
401
  // between this CLI and the auto-write at the tail of applySessionClose.
331
402
  //
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.
403
+ // Contract: the marker is written only when the FULL /compact gate
404
+ // (precompactGateStatus, ADR 0046) is green. A failed gate exits 1 with no
405
+ // marker — the next Stop hook re-blocks.
334
406
 
335
407
  function runMarkSessionClosed(args) {
336
408
  if (!args.sessionId) {
@@ -338,20 +410,35 @@ function runMarkSessionClosed(args) {
338
410
  console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
339
411
  process.exit(1);
340
412
  }
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) {
413
+ // ADR 0047: the per-session marker is the THIRD session-close completion
414
+ // signal (after the PreCompact gate and `--check-session-close`). It must use
415
+ // the SAME gate that governs /compact precompactGateStatus so the marker
416
+ // can never attest "closed" while /compact would still block. This subsumes
417
+ // the prior (sessionCloseGlobalStatus + hypoIsClean + scoped-lint) gate and
418
+ // additionally enforces feedback projection (over-cap/conflict), W8 design-
419
+ // history staleness, and root hot.md structure — the checks that the narrower
420
+ // marker gate skipped (the divergence behind this fix). git-clean is now a
421
+ // `git` blocker inside the gate. Pass --transcript-path to widen the lint
422
+ // scope to this session's edited files exactly as the interactive hook does;
423
+ // without it the scope is the mandatory close files only.
424
+ // --log-only marks a non-project (tooling / wiki-only) session as
425
+ // closed without attributing it to any project. The gate runs in log-only mode
426
+ // (project-close invariant → a today log.md entry; lint/W8 scoped to shared +
427
+ // touched files, never the active/phantom project), but git / hot / feedback
428
+ // still apply — log-only is NOT a global-gate bypass.
429
+ const gate = precompactGateStatus(args.hypoDir, {
430
+ ...(args.transcriptPath ? { transcriptPath: args.transcriptPath } : {}),
431
+ ...(args.logOnly ? { logOnly: true } : {}),
432
+ });
433
+ const status = gate.close;
434
+ if (!gate.ok) {
348
435
  const result = {
349
436
  ok: false,
350
437
  session_id: args.sessionId,
351
438
  project: status.project,
352
439
  missing: status.missing,
353
440
  stale: status.stale,
354
- git_reason: git.clean ? null : git.reason,
441
+ blockers: gate.blockers,
355
442
  error: 'session-close gate not satisfied — marker not written',
356
443
  };
357
444
  if (args.json) {
@@ -360,56 +447,14 @@ function runMarkSessionClosed(args) {
360
447
  console.log(
361
448
  `✗ session-close gate not satisfied — marker not written (project: ${status.project || '(unresolved)'}):`,
362
449
  );
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}`);
450
+ for (const b of gate.blockers) console.log(` ✗ ${b.reason}`);
366
451
  }
367
452
  process.exit(1);
368
453
  }
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
- }
411
- }
412
- writeSessionClosedMarker(args.hypoDir, args.sessionId, { project: status.project });
454
+ writeSessionClosedMarker(args.hypoDir, args.sessionId, {
455
+ project: status.project,
456
+ ...(args.logOnly ? { scope: 'log-only' } : {}),
457
+ });
413
458
  // Marker writer swallows IO errors (best-effort, see hypo-shared.mjs). Verify
414
459
  // the file actually landed before claiming success — otherwise CLI exits 0
415
460
  // while next Stop re-blocks, hiding a permission/disk problem.
@@ -427,22 +472,36 @@ function runMarkSessionClosed(args) {
427
472
  ok: true,
428
473
  session_id: args.sessionId,
429
474
  project: status.project,
475
+ scope: args.logOnly ? 'log-only' : 'project',
430
476
  date: status.dates[0],
477
+ notices: gate.notices,
478
+ // ADR 0047: pure feedback-projection drift is a non-blocker — the marker
479
+ // attests "compact-ready (no human-fixable blocker)", and the PreCompact
480
+ // hook self-heals the projection (feedback-sync --write) at /compact. Surface
481
+ // the deferral so the caller knows MEMORY/CLAUDE sync is pending, not lost.
482
+ drift_deferred: gate.driftTargets,
431
483
  };
432
484
  if (args.json) {
433
485
  console.log(JSON.stringify(result, null, 2));
434
486
  } else {
435
487
  console.log(
436
- `✓ session-closed marker written (session_id: ${args.sessionId}, project: ${status.project}).`,
488
+ args.logOnly
489
+ ? `✓ session-closed marker written (session_id: ${args.sessionId}, scope: log-only — no project attribution).`
490
+ : `✓ session-closed marker written (session_id: ${args.sessionId}, project: ${status.project}).`,
437
491
  );
492
+ if (gate.driftTargets.length > 0) {
493
+ console.log(
494
+ ` · feedback projection drift (${gate.driftTargets.join(', ')}) — will self-heal at /compact.`,
495
+ );
496
+ }
438
497
  }
439
498
  process.exit(0);
440
499
  }
441
500
 
442
501
  function applySessionClose(args) {
443
- // Fix #39 (option D): early-exit fires only when NO payload was supplied.
502
+ // Option D: early-exit fires only when NO payload was supplied.
444
503
  // Rationale: payload presence is explicit close intent and must always run
445
- // the full apply path — fix #38's per-entry idempotency (writeIfChanged +
504
+ // the full apply path — the per-entry idempotency (writeIfChanged +
446
505
  // exact-entry append dedup) keeps re-apply cheap without short-circuiting,
447
506
  // and avoids silent-success when a same-day second close brings new bytes.
448
507
  // Payload-less invocation is treated as a cheap "already complete?" probe.
@@ -450,7 +509,9 @@ function applySessionClose(args) {
450
509
  // for any actual apply work (readPayload below surfaces "payload is
451
510
  // required" the same way it always has).
452
511
  if (!args.force && !args.payload) {
453
- const probe = sessionCloseFileStatus(args.hypoDir);
512
+ // ADR 0043: no-payload "already complete?" probe uses the
513
+ // global invariant, not a recency pick.
514
+ const probe = sessionCloseGlobalStatus(args.hypoDir);
454
515
  if (probe.ok) {
455
516
  const result = {
456
517
  ok: true,
@@ -505,10 +566,22 @@ function applySessionClose(args) {
505
566
  console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
506
567
  process.exit(1);
507
568
  }
569
+ // The freshness verification below (and at the post-apply check) already honors
570
+ // payload.project — `project` wins over the inferred active project, and the
571
+ // post-apply sessionCloseFileStatus call passes it as projectOverride. But when the
572
+ // payload targets a DIFFERENT project than the one active-project resolution infers
573
+ // (probe.project), that divergence used to be silent, so an operator couldn't tell
574
+ // which project the close actually verified. Surface it on stderr (the stdout JSON
575
+ // contract is untouched) so the verified/closed project is always explicit.
576
+ if (payload.project && probe.project && probe.project !== payload.project) {
577
+ process.stderr.write(
578
+ `note: payload.project="${payload.project}" differs from the inferred active ` +
579
+ `project "${probe.project}"; verifying and closing "${payload.project}".\n`,
580
+ );
581
+ }
508
582
  const date = payload.date || todayLocal();
509
- const ym = date.slice(0, 7);
510
583
 
511
- // Fix #40 preflight: lint the wiki BEFORE writing any payload bytes. If lint
584
+ // Preflight: lint the wiki BEFORE writing any payload bytes. If lint
512
585
  // has blockers (errors) in files this apply WON'T overwrite, the wiki is in
513
586
  // a degraded state and apply would mask the root cause — abort fail-fast.
514
587
  //
@@ -531,11 +604,27 @@ function applySessionClose(args) {
531
604
  // the files it writes (other projects, shared pages this close did not author).
532
605
  // payloadScope = every file this apply writes or appends. Both lint passes are
533
606
  // judged against it; errors elsewhere are surfaced as notices, never blocking.
607
+ //
608
+ // session-log needs TWO entries (ADR 0050): the daily WRITE target (what this
609
+ // apply creates/appends, judged by post-apply lint) AND the freshness EVIDENCE
610
+ // file. They coincide except in the hybrid cutover month, where a fallback-
611
+ // aware no-op (the identical entry already lives in the legacy monthly file)
612
+ // writes no daily shard, leaving the monthly as the proof of freshness. Scope
613
+ // must then include that monthly file, or a CORRUPT monthly evidence file would
614
+ // pass the gate with its lint error demoted to a non-blocking notice.
615
+ // sessionLogScopePath returns the monthly ONLY when it carries today's heading
616
+ // (otherwise the daily write target), so unrelated monthly debt stays a notice.
617
+ // join() (platform-native), not the POSIX helper output: payloadScope membership
618
+ // is tested against lint's raw `e.file` (path.relative) WITHOUT posix
619
+ // normalization, so it must use the OS-native separator the sibling entries use.
620
+ const sessionLogWriteTarget = join('projects', project, 'session-log', `${date}.md`);
621
+ const sessionLogEvidence = join(...sessionLogScopePath(args.hypoDir, project, date).split('/'));
534
622
  const payloadScope = new Set([
535
623
  join('projects', project, 'session-state.md'),
536
624
  join('projects', project, 'hot.md'),
537
625
  'hot.md',
538
- join('projects', project, 'session-log', `${ym}.md`),
626
+ sessionLogWriteTarget,
627
+ sessionLogEvidence, // == write target, except a hybrid-month monthly fallback
539
628
  'log.md',
540
629
  ...(payload.openQuestions ? [join('pages', 'open-questions.md')] : []),
541
630
  ]);
@@ -590,18 +679,56 @@ function applySessionClose(args) {
590
679
  // dated today". The freshness gate (sessionCloseFileStatus) is what answers
591
680
  // "was this file touched today?"; that's a different concern and must not
592
681
  // 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).
682
+ // silently dropped (Codex review of the apply path — Worker 1 finding 2).
594
683
  const entryAlreadyPresent = (entry) => (content) =>
595
684
  content.includes(entry.endsWith('\n') ? entry.replace(/\n+$/, '') : entry);
596
685
 
597
686
  {
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})`);
687
+ const rel = join('projects', project, 'session-log', `${date}.md`);
688
+ const full = join(args.hypoDir, rel);
689
+ const isPresent = entryAlreadyPresent(payload.sessionLog.entry);
690
+ // Fallback-aware idempotency (ADR 0050 hybrid cutover): during the month the
691
+ // shard takes over, today's entry may already live in the legacy monthly
692
+ // file from an earlier (pre-cutover) close. Treat presence in EITHER the
693
+ // daily shard or the legacy monthly file as "already written" so a same-day
694
+ // second close does not duplicate an identical entry across both files —
695
+ // and so an idempotent re-apply stays a true no-op (no shard is created).
696
+ let alreadyThere = false;
697
+ for (const cand of sessionLogReadCandidates(project, date)) {
698
+ const cf = join(args.hypoDir, cand);
699
+ if (!existsSync(cf)) continue;
700
+ try {
701
+ if (isPresent(readFileSync(cf, 'utf-8'))) {
702
+ alreadyThere = true;
703
+ break;
704
+ }
705
+ } catch {
706
+ /* unreadable candidate — fall through to the write path */
707
+ }
708
+ }
709
+ if (alreadyThere) {
710
+ skipped.push(`sessionLog (${rel})`);
711
+ } else if (!existsSync(full)) {
712
+ // A daily shard is a new file most days. Seed minimal valid frontmatter
713
+ // (title + type, the two REQUIRED_FIELDS) so the shard is a first-class
714
+ // wiki page rather than a W1 "no frontmatter" warning, and write the header
715
+ // AND the first entry in ONE atomic write — never leave a header-only shard
716
+ // on disk, which freshness would skip (no dated heading) while derive could
717
+ // otherwise mistake it for the evidence file. The dated `## [date] ...`
718
+ // heading lives inside the entry, so freshness / derive / design-history
719
+ // are unchanged.
720
+ const header =
721
+ `---\ntitle: Session Log ${date} (${project})\n` +
722
+ `type: session-log\nupdated: ${date}\n---\n\n` +
723
+ `# Session Log ${date} (${project})\n`;
724
+ const entry = payload.sessionLog.entry;
725
+ const body = entry.endsWith('\n') ? entry : `${entry}\n`;
726
+ atomicWrite(full, `${header}\n${body}`);
727
+ applied.push(`sessionLog (${rel})`);
728
+ } else {
729
+ const wrote = appendIfAbsent(full, payload.sessionLog.entry, isPresent);
730
+ (wrote ? applied : skipped).push(`sessionLog (${rel})`);
731
+ }
605
732
  }
606
733
 
607
734
  {
@@ -613,14 +740,14 @@ function applySessionClose(args) {
613
740
  (wrote ? applied : skipped).push('log (log.md)');
614
741
  }
615
742
 
616
- // ISSUE-7 Part A: verify against the SAME project this apply just wrote
743
+ // Same-date-tie fix: verify against the SAME project this apply just wrote
617
744
  // (`project` = payload.project || probe.project, resolved at the top). Without
618
745
  // the override, sessionCloseFileStatus re-derives via resolveActiveProject and,
619
746
  // on a same-date root-hot.md tie, can pick a different project — false-failing
620
747
  // a completed close (the 2026-06-09 security-ops-kb incident).
621
748
  const verification = sessionCloseFileStatus(args.hypoDir, { projectOverride: project });
622
749
 
623
- // Fix #40 post-apply lint: payload may have introduced a malformed body or
750
+ // Post-apply lint: payload may have introduced a malformed body or
624
751
  // bad frontmatter. Surface as a distinct `stage` so caller can tell "lint
625
752
  // broke" apart from "frontmatter stale". This runs even if the freshness gate
626
753
  // also failed — both failure modes are useful to the caller.
@@ -660,18 +787,26 @@ function applySessionClose(args) {
660
787
  // ADR 0022 amendment 2026-05-19: auto-write the per-session
661
788
  // closed marker on a verified close. Hook authority is read-only; this is
662
789
  // 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.
790
+ //
791
+ // ADR 0047: the marker write is governed by the SAME gate as standalone
792
+ // --mark-session-closed and /compact (precompactGateStatus), NOT just apply's
793
+ // `ok` + git-clean. Apply's payload preflight/post-apply lint and `ok` still
794
+ // govern apply SUCCESS (exit code below), but the marker must additionally
795
+ // clear feedback projection / W8 design-history / hot.md structure, else this
796
+ // path could issue a marker the standalone path would refuse (the second
797
+ // divergence codex flagged). git-clean is subsumed by the gate's git blocker.
666
798
  if (ok && args.sessionId) {
667
- const git = hypoIsClean(args.hypoDir);
668
- if (git.clean) {
799
+ const markerGate = precompactGateStatus(
800
+ args.hypoDir,
801
+ args.transcriptPath ? { transcriptPath: args.transcriptPath } : {},
802
+ );
803
+ if (markerGate.ok) {
669
804
  writeSessionClosedMarker(args.hypoDir, args.sessionId, { project });
670
805
  }
671
- // git not clean → silent skip: caller's `result.ok` already reflects the
806
+ // gate not ok → silent skip: caller's `result.ok` already reflects the
672
807
  // 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).
808
+ // confuse the close-applied success path. Next Stop re-blocks until the
809
+ // remaining blocker (git/feedback/W8/hot/lint) is resolved.
675
810
  }
676
811
  const stage = ok
677
812
  ? null
@@ -222,7 +222,7 @@ function checkDirectories(hypoDir) {
222
222
  'projects',
223
223
  'sources',
224
224
  // Extensions baseline (ADR 0024). Existence only — SHA / settings /
225
- // manifest integrity is E5 (fix #33).
225
+ // manifest integrity is E5.
226
226
  'extensions/hooks',
227
227
  'extensions/commands',
228
228
  'extensions/skills',
@@ -310,7 +310,7 @@ function checkSettingsJson() {
310
310
  // stale hypo-* entries (uninstall remnants).
311
311
  // hypo-ext-* commands are user-extension entries (ADR 0024) — not core hooks,
312
312
  // so they are intentionally absent from HOOK_MAP. Excluded here; their
313
- // integrity (SHA + manifest + entry match) is checked separately in E5 (fix #33).
313
+ // integrity (SHA + manifest + entry match) is checked separately in E5.
314
314
  const isExtCommand = (cmd) => /(?:^|[/\s])hypo-ext-[^/\s]+\.mjs(?=$|["'\s])/.test(cmd);
315
315
  const expectedCmds = new Set(
316
316
  Object.entries(HOOK_MAP).flatMap(([, files]) =>
@@ -352,7 +352,7 @@ function checkSettingsJson() {
352
352
  if (!g || typeof g !== 'object') continue;
353
353
  for (const h of g.hooks || []) {
354
354
  if (typeof h.command !== 'string' || !/hypo-[^/]+\.mjs/.test(h.command)) continue;
355
- if (isExtCommand(h.command)) continue; // ext duplicates are E5's concern (fix #33)
355
+ if (isExtCommand(h.command)) continue; // ext duplicates are E5's concern
356
356
  if (seen.has(h.command)) dupes.push(`${event}:${h.command}`);
357
357
  else seen.add(h.command);
358
358
  }
@@ -399,10 +399,7 @@ function checkGit(hypoDir) {
399
399
  );
400
400
  }
401
401
  } else {
402
- warn(
403
- '.git/hooks/pre-commit',
404
- 'Not installed — run /hypo:init to install .hypoignore guard (fix #24)',
405
- );
402
+ warn('.git/hooks/pre-commit', 'Not installed — run /hypo:init to install .hypoignore guard');
406
403
  }
407
404
  }
408
405
 
@@ -679,7 +676,7 @@ function checkCodexPaths() {
679
676
  //
680
677
  // E5 is doctor SURFACE for extensions integrity. The mixed-group surgical
681
678
  // *write* (preserve sibling-plugin hooks, swap only ours) used to be deferred
682
- // here; fix #47 (ADR 0024 amendment 2026-05-23) lifted that deferral —
679
+ // here; ADR 0024 amendment 2026-05-23 lifted that deferral —
683
680
  // registerSettings (extensions.mjs:478 docstring) now does occurrence-first +
684
681
  // 8-rank canonical write, and the (b) loop below mirrors that read-path via
685
682
  // collectOurOccurrences so a valid mixed-group occurrence is no longer warned
@@ -773,7 +770,7 @@ function checkExtensions(hypoDir, claudeHome, target = 'claude') {
773
770
  const expected = buildExpectedSettingsEntries(discovered.hooks, hooksDir);
774
771
 
775
772
  // (b) for each registrable hook: locate every occurrence of our command
776
- // (single-hook OR mixed group, fix #47) and pick the canonical via the
773
+ // (single-hook OR mixed group) and pick the canonical via the
777
774
  // SAME 8-rank logic registerSettings uses.
778
775
  // Without this mirror, doctor picked the first traversal-order occurrence
779
776
  // under the target event and warned "differs" even when a later
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * feedback-sync.mjs — project wiki feedback as SoT, external memory as projection (ADR 0031, fix #37)
3
+ * feedback-sync.mjs — project wiki feedback as SoT, external memory as projection (ADR 0031)
4
4
  *
5
5
  * Wiki `pages/feedback/<slug>.md` is the single source of truth for
6
6
  * learning/correction knowledge. Two Claude Code memory surfaces are derived
package/scripts/init.mjs CHANGED
@@ -928,7 +928,7 @@ if (args.hooks) {
928
928
  }
929
929
  for (const r of extResult.registered) log('merged', `extension ${r}`);
930
930
  for (const w of extResult.warnings) log('skipped', `extension: ${w}`);
931
- // E3 (fix #31): a hard conflict (unowned/symlinked target) blocks install — surface
931
+ // E3: a hard conflict (unowned/symlinked target) blocks install — surface
932
932
  // the recovery and force a non-zero exit. Drift is advisory (resolvable, no block).
933
933
  if (extResult.conflicts.length > 0) {
934
934
  log('errors', '[WIKI: existing file conflicts. Backup and retry, or use --force-extensions]');