hypomnema 1.6.2 → 1.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.ko.md +39 -14
  4. package/README.md +39 -14
  5. package/commands/capture.md +8 -6
  6. package/commands/crystallize.md +39 -20
  7. package/docs/ARCHITECTURE.md +49 -14
  8. package/docs/CONTRIBUTING.md +31 -29
  9. package/hooks/base-store.mjs +265 -0
  10. package/hooks/hooks.json +18 -1
  11. package/hooks/hypo-auto-commit.mjs +92 -15
  12. package/hooks/hypo-auto-minimal-crystallize.mjs +63 -20
  13. package/hooks/hypo-auto-stage.mjs +41 -1
  14. package/hooks/hypo-close-guard.mjs +246 -0
  15. package/hooks/hypo-cwd-change.mjs +31 -2
  16. package/hooks/hypo-file-watch.mjs +21 -2
  17. package/hooks/hypo-first-prompt.mjs +19 -3
  18. package/hooks/hypo-hot-rebuild.mjs +43 -5
  19. package/hooks/hypo-lookup.mjs +86 -29
  20. package/hooks/hypo-personal-check.mjs +24 -3
  21. package/hooks/hypo-session-record.mjs +2 -3
  22. package/hooks/hypo-session-start.mjs +199 -20
  23. package/hooks/hypo-shared.mjs +2080 -176
  24. package/hooks/proposal-store.mjs +513 -0
  25. package/hooks/version-check.mjs +45 -0
  26. package/package.json +42 -14
  27. package/scripts/capture.mjs +556 -37
  28. package/scripts/crystallize.mjs +751 -110
  29. package/scripts/doctor.mjs +787 -26
  30. package/scripts/feedback-sync.mjs +515 -44
  31. package/scripts/graph.mjs +35 -13
  32. package/scripts/init.mjs +287 -41
  33. package/scripts/lib/extensions.mjs +656 -1
  34. package/scripts/lib/git-hooks-dir.mjs +229 -0
  35. package/scripts/lib/hypo-ignore.mjs +54 -6
  36. package/scripts/lib/hypo-root.mjs +56 -6
  37. package/scripts/lib/page-usage.mjs +15 -2
  38. package/scripts/lib/pkg-json.mjs +40 -0
  39. package/scripts/lib/plugin-detect.mjs +96 -6
  40. package/scripts/lib/project-create.mjs +5 -1
  41. package/scripts/lib/rename-marker.mjs +39 -0
  42. package/scripts/lib/wd-match.mjs +23 -5
  43. package/scripts/lib/wikilink.mjs +32 -6
  44. package/scripts/lint.mjs +103 -5
  45. package/scripts/proposal.mjs +1032 -0
  46. package/scripts/query.mjs +25 -4
  47. package/scripts/rename.mjs +223 -18
  48. package/scripts/resume.mjs +34 -12
  49. package/scripts/stats.mjs +41 -9
  50. package/scripts/uninstall.mjs +141 -6
  51. package/scripts/upgrade.mjs +197 -15
  52. package/skills/crystallize/SKILL.md +44 -7
  53. package/skills/debate/SKILL.md +88 -0
  54. package/skills/debate/references/orchestration-patterns.md +83 -0
  55. package/templates/.hyposcanignore +10 -0
  56. package/templates/SCHEMA.md +12 -0
  57. package/templates/gitignore +9 -0
  58. package/templates/hypo-config.md +1 -1
  59. package/templates/hypo-guide.md +6 -0
  60. package/scripts/.gitkeep +0 -0
  61. package/scripts/check-bilingual.mjs +0 -153
  62. package/scripts/check-readme-version.mjs +0 -126
  63. package/scripts/check-tracker-ids.mjs +0 -426
  64. package/scripts/check-versions.mjs +0 -171
  65. package/scripts/install-git-hooks.mjs +0 -293
  66. package/scripts/lib/changelog-classify.mjs +0 -216
  67. package/scripts/lib/check-bilingual.mjs +0 -244
  68. package/scripts/lib/check-tracker-ids.mjs +0 -217
  69. package/scripts/lib/pre-commit-format.mjs +0 -251
  70. package/scripts/pre-commit-format.mjs +0 -198
@@ -73,16 +73,18 @@ import {
73
73
  mkdirSync,
74
74
  renameSync,
75
75
  realpathSync,
76
+ openSync,
77
+ writeSync,
78
+ closeSync,
76
79
  } from 'fs';
77
80
  import { join, dirname } from 'path';
78
- import { hostname } from 'os';
79
81
  import { spawnSync } from 'child_process';
80
82
  import { fileURLToPath, pathToFileURL } from 'url';
81
83
  import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
82
84
  import { loadHypoIgnore } from './lib/hypo-ignore.mjs';
83
85
  import { collectPagesCrystallize, extractWikilinks } from './lib/wikilink.mjs';
84
86
  import { aggregateColdCandidates } from './lib/page-usage.mjs';
85
- import { isValidProjectName } from './lib/project-create.mjs';
87
+ import { isValidProjectName, substituteTokens, TEMPLATE_DIR } from './lib/project-create.mjs';
86
88
  import { appendPendingTags, checkForbidden } from './lib/schema-vocab.mjs';
87
89
  import {
88
90
  sessionCloseFileStatus,
@@ -102,7 +104,14 @@ import {
102
104
  resolveTranscriptBySessionId,
103
105
  hasUserCloseSignal,
104
106
  commitWikiChanges,
107
+ vaultCommitLockTarget,
108
+ currentDevice,
109
+ scopeVisible,
110
+ readVisibilityScope,
111
+ withFileLock,
105
112
  } from '../hooks/hypo-shared.mjs';
113
+ import { hashContent, readBaseEntry, advanceBase } from '../hooks/base-store.mjs';
114
+ import { writeProposal } from '../hooks/proposal-store.mjs';
106
115
 
107
116
  // This script's own absolute path. Used to print copy-pasteable recovery
108
117
  // commands as `node <SELF_SCRIPT> ...` rather than a bare `crystallize` bin,
@@ -169,6 +178,7 @@ function parseArgs(argv) {
169
178
  else if (arg.startsWith('--session-id=')) args.sessionId = arg.slice(13);
170
179
  else if (arg.startsWith('--payload=')) args.payload = arg.slice(10);
171
180
  else if (arg.startsWith('--transcript-path=')) args.transcriptPath = expandHome(arg.slice(18));
181
+ else if (arg.startsWith('--session-cwd=')) args.sessionCwd = expandHome(arg.slice(14));
172
182
  else if (arg.startsWith('--project=')) args.project = arg.slice(10);
173
183
  else if (arg === '--force') args.force = true;
174
184
  else if (arg === '--json') args.json = true;
@@ -240,13 +250,31 @@ function runSessionCloseCheck(args) {
240
250
  // would re-add that project's files to the lint scope and re-block the scoped
241
251
  // check, defeating the point. The global (no --project) check keeps widening.
242
252
  if (args.project) requireProjectDir(args, args.project);
253
+ // Resolve the transcript from --session-id when --transcript-path was not given,
254
+ // exactly as --mark and the apply auto-marker already do. The transcript is what
255
+ // attributes the close as well as widening the lint scope, and PreCompact
256
+ // always has one from its hook payload. A check without it would compute an EMPTY
257
+ // close scope, fall back to the global block, and report RED for debt that /compact
258
+ // demotes. Checklist step 14 tells the model to trust this command, so an over-red
259
+ // check is as harmful as an over-green one.
260
+ const checkTranscript =
261
+ args.transcriptPath ||
262
+ (args.sessionId ? resolveTranscriptBySessionId(args.sessionId) : null) ||
263
+ null;
243
264
  const status = precompactGateStatus(args.hypoDir, {
244
265
  ...(args.project
245
266
  ? { projectOverride: args.project }
246
- : args.transcriptPath
247
- ? { transcriptPath: args.transcriptPath }
267
+ : checkTranscript
268
+ ? { transcriptPath: checkTranscript }
248
269
  : {}),
249
270
  ...(args.sessionId ? { sessionId: args.sessionId } : {}),
271
+ // The P2 cwd close check (session-close attribution) applies to the GLOBAL gate only. Under
272
+ // --project the check is a project-scoped diagnostic, so a cwd blocker for a
273
+ // DIFFERENT project would muddy that scoped answer — pass sessionCwd only for
274
+ // the global form. process.cwd() is deliberately NOT a fallback: a check run
275
+ // after `cd ~/hypomnema` would map to the vault, not the session (authoritative
276
+ // enforcement lives in the PreCompact/Stop hooks, which carry payload.cwd).
277
+ ...(args.sessionCwd && !args.project ? { sessionCwd: args.sessionCwd } : {}),
250
278
  });
251
279
  const close = status.close;
252
280
 
@@ -281,11 +309,17 @@ function runSessionCloseCheck(args) {
281
309
  JSON.stringify(
282
310
  {
283
311
  ok: status.ok,
284
- // flat close fields preserved for back-compat with prior readers
312
+ // flat close fields preserved for back-compat with prior readers. They now
313
+ // describe what BLOCKS: a foreign project's incomplete close is demoted out
314
+ // of stale/missing into close_debt, so a reader that treats a
315
+ // non-empty `missing` as failure still agrees with `ok` instead of
316
+ // contradicting it.
285
317
  project: close.project,
286
318
  dates: close.dates,
287
319
  stale: close.stale,
288
320
  missing: close.missing,
321
+ ...(close.debt?.length ? { close_debt: close.debt } : {}),
322
+ ...(close.scope ? { close_scope: close.scope } : {}),
289
323
  blockers: status.blockers,
290
324
  notices: status.notices,
291
325
  skipped: status.skipped,
@@ -411,6 +445,14 @@ function readPayload(source) {
411
445
  }
412
446
  }
413
447
 
448
+ // How long an append waits for its per-target lock before withholding to the
449
+ // proposal-pending gate (see withFileLock). A withheld append blocks the close but
450
+ // is NOT parked as a proposal artifact — the next close re-appends. Default 5s is
451
+ // generous for a real close;
452
+ // the env override exists ONLY so tests can force a fast timeout instead of
453
+ // spinning the full 5s. Not a documented production knob.
454
+ const APPEND_LOCK_TIMEOUT_MS = Number(process.env.HYPO_APPEND_LOCK_TIMEOUT_MS) || 5000;
455
+
414
456
  /** Atomic write via tmp+rename. `<path>.<pid>.<rand>.tmp` so concurrent helpers
415
457
  * don't fight over the same shared `<path>.tmp` slot. */
416
458
  function atomicWrite(path, content) {
@@ -420,17 +462,51 @@ function atomicWrite(path, content) {
420
462
  renameSync(tmp, path);
421
463
  }
422
464
 
423
- /** Atomic write that skips when on-disk bytes already match `content`. */
424
- function writeIfChanged(path, content) {
425
- if (existsSync(path)) {
426
- try {
427
- if (readFileSync(path, 'utf-8') === content) return false; // idempotent skip
428
- } catch {
429
- /* fall through to overwrite */
430
- }
465
+ /**
466
+ * Read a target's current bytes, distinguishing "absent" from "unreadable" the
467
+ * same way base-store's hashFile does. The overwrite guard needs all three
468
+ * answers: content to compare, `null` to know creating is safe, `undefined` to
469
+ * refuse to guess.
470
+ * @returns {string|null|undefined} bytes, `null` if absent, `undefined` if unreadable
471
+ */
472
+ function readTarget(path) {
473
+ if (!existsSync(path)) return null;
474
+ try {
475
+ return readFileSync(path, 'utf-8');
476
+ } catch {
477
+ return undefined;
478
+ }
479
+ }
480
+
481
+ /**
482
+ * Has the target drifted away from what this session observed at start?
483
+ *
484
+ * Branches on base-store's `state` discriminator, never on the truthiness of
485
+ * `hash`: 'absent' and 'unknown' both carry `hash: null`, and collapsing them
486
+ * would read never-observed as safe-to-write, defeating the guard entirely.
487
+ *
488
+ * @param {{state: 'hash'|'absent'|'unknown', hash: string|null}} entry
489
+ * @param {string|null|undefined} disk current bytes / absent / unreadable
490
+ * @returns {string|null} a conflict reason, or null when this session may write
491
+ */
492
+ function overwriteConflictReason(entry, disk) {
493
+ // Cannot read what we are about to replace: fail safe, never assume unchanged.
494
+ if (disk === undefined) return 'target-unreadable';
495
+ switch (entry.state) {
496
+ case 'unknown':
497
+ // No snapshot for this (session, target). Someone else's edits could be
498
+ // sitting on disk and we would have no way to tell.
499
+ return 'base-unknown';
500
+ case 'absent':
501
+ // We observed no file. Creating it is safe; finding one now means another
502
+ // writer got there first.
503
+ return disk === null ? null : 'base-absent-target-exists';
504
+ case 'hash':
505
+ if (disk === null) return 'base-hash-target-missing';
506
+ return hashContent(disk) === entry.hash ? null : 'base-mismatch';
507
+ default:
508
+ return 'base-unknown';
431
509
  }
432
- atomicWrite(path, content);
433
- return true;
434
510
  }
435
511
 
436
512
  /**
@@ -444,8 +520,16 @@ function appendIfAbsent(path, entry, alreadyPresent) {
444
520
  if (existsSync(path)) {
445
521
  try {
446
522
  content = readFileSync(path, 'utf-8');
447
- } catch {
448
- content = '';
523
+ } catch (err) {
524
+ // ENOENT here is a narrow existsSync-then-readFileSync race (the file
525
+ // vanished between the two calls) — content='' is safe, we would create
526
+ // it fresh anyway. Anything else (EACCES/EISDIR/...) is a PERSISTENT
527
+ // read failure: retrying (the caller's lock-timeout conflict path) would
528
+ // never fix it, and swallowing it here would fall through to the
529
+ // atomicWrite below and silently replace the existing file's bytes with
530
+ // just this one entry — a data-loss overwrite. So rethrow and let the
531
+ // withFileLock/call-site catch hard-fail the close instead.
532
+ if (err?.code !== 'ENOENT') throw err;
449
533
  }
450
534
  }
451
535
  if (alreadyPresent(content)) return false;
@@ -510,6 +594,13 @@ function validatePayloadShape(payload) {
510
594
  if (payload.date !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(String(payload.date))) {
511
595
  errs.push('payload.date, when present, must be YYYY-MM-DD');
512
596
  }
597
+ if (
598
+ payload.sessionId !== undefined &&
599
+ payload.sessionId !== null &&
600
+ typeof payload.sessionId !== 'string'
601
+ ) {
602
+ errs.push('payload.sessionId, when present, must be a string');
603
+ }
513
604
  return errs;
514
605
  }
515
606
 
@@ -529,11 +620,14 @@ function runMarkSessionClosed(args) {
529
620
  console.log(args.json ? JSON.stringify({ ok: false, error: msg }, null, 2) : `✗ ${msg}`);
530
621
  process.exit(1);
531
622
  }
532
- // --project=<slug> on --mark is ATTRIBUTION ONLY (the marker's `project` field).
533
- // The gate stays GLOBAL — never narrowed — because a marker that narrowed its
534
- // gate could attest compact-ready while PreCompact re-checks all of today's
535
- // projects and stays red (the marker == compact-ready invariant,
536
- // codex design finding 1/2). Validate the attribution slug exists as a
623
+ // --project=<slug> on --mark names the project THIS session closed. It sets the
624
+ // marker's `project` field AND enters the close scope, so an incomplete close in a
625
+ // project this session did not touch is demoted to a notice instead of refusing the
626
+ // marker. It is NOT a gate narrow in the old sense: the marker records the same slug
627
+ // it scoped by, and PreCompact re-derives its own scope FROM that marker — so the
628
+ // two can never disagree, which is what the earlier "attribution only, gate stays
629
+ // global" rule was protecting (it assumed PreCompact stayed global; it no longer
630
+ // does). Everything else the gate checks stays global. Validate the slug exists as a
537
631
  // directory, exactly as --check does, but only when it is actually used (a
538
632
  // --log-only mark attributes to no project, so --project is moot there).
539
633
  if (args.project && !args.logOnly) requireProjectDir(args, args.project);
@@ -558,8 +652,12 @@ function runMarkSessionClosed(args) {
558
652
  // source for the user-close hard gate below.
559
653
  const closeTranscript = resolveTranscriptBySessionId(args.sessionId);
560
654
  const gate = precompactGateStatus(args.hypoDir, {
655
+ ...(args.project && !args.logOnly ? { closeScope: [args.project] } : {}),
561
656
  ...(closeTranscript ? { transcriptPath: closeTranscript } : {}),
562
657
  ...(args.logOnly ? { logOnly: true } : {}),
658
+ // P2 (session-close attribution): the marker gate must refuse to attest compact-ready while the
659
+ // session's cwd project has an unstarted close. logOnly exempts it in-gate.
660
+ ...(args.sessionCwd ? { sessionCwd: args.sessionCwd } : {}),
563
661
  });
564
662
  const status = gate.close;
565
663
  if (!gate.ok) {
@@ -602,12 +700,43 @@ function runMarkSessionClosed(args) {
602
700
  console.log(args.json ? JSON.stringify(result, null, 2) : `✗ ${reason}`);
603
701
  process.exit(1);
604
702
  }
605
- // --project attributes the marker to that slug (gate stayed global); falls back
606
- // to the gate's resolved primary. log-only marks attribute to no project. Used
607
- // for the marker, the JSON result, and the success message so all three agree.
608
- const markerProject = !args.logOnly && args.project ? args.project : status.project;
703
+ // Marker attribution comes from EVIDENCE, never from the gate's global
704
+ // `primary` (which is recency-derived, hypo-shared.mjs). The marker is what
705
+ // PreCompact later re-derives its own scope from, so attributing it to a project
706
+ // this session did not close hands PreCompact a scope the marker never cleared.
707
+ // The evidence set is the close scope — explicit --project ∪ the transcript's
708
+ // touched close files ∪ any prior marker for this session — all of which
709
+ // resolveCloseScope already unioned into status.scope. The recency primary is
710
+ // deliberately excluded: an empty scope means this session has no proof it closed
711
+ // any project (evidence-based close attribution), so we FAIL CLOSED rather than misattribute to recency.
712
+ const closeScope = status.scope || [];
713
+ const markerProjects = [
714
+ ...new Set([...(!args.logOnly && args.project ? [args.project] : []), ...closeScope]),
715
+ ];
716
+ if (!args.logOnly && markerProjects.length === 0) {
717
+ const err =
718
+ 'cannot attribute this close to a project — no evidence (no --project, no transcript close-file edits, no prior marker). ' +
719
+ 'Pass --project=<slug> for the project this session closed, or --log-only for a non-project (tooling/wiki-only) session.';
720
+ console.log(
721
+ args.json
722
+ ? JSON.stringify(
723
+ {
724
+ ok: false,
725
+ session_id: args.sessionId,
726
+ skipReason: 'no-attribution-evidence',
727
+ error: err,
728
+ },
729
+ null,
730
+ 2,
731
+ )
732
+ : `✗ ${err}`,
733
+ );
734
+ process.exit(1);
735
+ }
736
+ const markerProject = !args.logOnly && args.project ? args.project : markerProjects[0];
609
737
  writeSessionClosedMarker(args.hypoDir, args.sessionId, {
610
738
  project: markerProject,
739
+ projects: args.logOnly ? [] : markerProjects,
611
740
  ...(args.logOnly ? { scope: 'log-only' } : {}),
612
741
  });
613
742
  // Marker writer swallows IO errors (best-effort, see hypo-shared.mjs). Verify
@@ -714,10 +843,140 @@ export function closeResultContradiction({ ok, markerWritten, markerSkipReason }
714
843
  return null;
715
844
  }
716
845
 
846
+ // What the model should do when the close is refused. Deliberately does NOT name
847
+ // a flag: the way out of this gate cannot be an argument the model can add, or
848
+ // the gate is decorative. The way out is the user, which is the one input the
849
+ // model does not author.
850
+ const CLOSE_REFUSAL_HELP = [
851
+ 'Nothing was written and nothing was committed.',
852
+ '',
853
+ 'Do NOT add a bypass flag, and do NOT write the close files directly with an editor',
854
+ 'or a shell — that is the same close without the check, and it is the thing this gate',
855
+ 'exists to stop.',
856
+ '',
857
+ 'If the user has not asked to close: do not close. Session-close is not a reward for',
858
+ 'finishing a task, and a long session is not a close signal. Keep working, or ask ONCE',
859
+ 'whether to wrap up, and take no for an answer.',
860
+ '',
861
+ 'If the user HAS asked: pass the current main-conversation --session-id (not a',
862
+ 'background-task or agent uuid from a /tmp path) and re-run this exact payload. The',
863
+ 'writes are idempotent.',
864
+ ].join('\n');
865
+
866
+ /**
867
+ * May this session apply a close at all?
868
+ *
869
+ * Authority comes from the transcript, because the transcript is the one input the
870
+ * model cannot author: the user's own words are in it, and nothing the model says
871
+ * counts (extractUserMessages drops injected, tool, and hook-feedback text).
872
+ *
873
+ * { ok: true }
874
+ * { ok: false, reason, error } reason: session-id-required | transcript-unresolved
875
+ * | no-user-close-signal
876
+ */
877
+ function verifyCloseAuthority(sessionId) {
878
+ if (!sessionId) {
879
+ return {
880
+ ok: false,
881
+ reason: 'session-id-required',
882
+ error:
883
+ 'session-close apply refused before any wiki write or commit: --session-id is required, ' +
884
+ "because the close signal is verified against that session's transcript. Omitting it does " +
885
+ 'not skip the check, it fails it.',
886
+ };
887
+ }
888
+ const transcript = resolveTranscriptBySessionId(sessionId);
889
+ if (!transcript) {
890
+ return {
891
+ ok: false,
892
+ reason: 'transcript-unresolved',
893
+ error:
894
+ `session-close apply refused before any wiki write or commit: no transcript resolves for ` +
895
+ `session ${sessionId}. Pass the MAIN conversation's session id — a uuid taken from a ` +
896
+ `background-task output path or an agent thread is not it, and --transcript-path is not ` +
897
+ `authority here.`,
898
+ };
899
+ }
900
+ if (!hasUserCloseSignal(transcript)) {
901
+ return {
902
+ ok: false,
903
+ reason: 'no-user-close-signal',
904
+ error:
905
+ "session-close apply refused before any wiki write or commit: this session's transcript " +
906
+ 'carries no user close signal. The user did not ask to close.',
907
+ };
908
+ }
909
+ return { ok: true };
910
+ }
911
+
912
+ // A-1 (project index lifecycle): seed projects/<project>/index.md from the
913
+ // template the first time this project closes without one. SCHEMA.md declares
914
+ // project-index at projects/*/index.md and templates/projects/_template/ ships
915
+ // one, but createProject (the auto-project-offer path) is the only writer of
916
+ // it today — a project directory created any other way (a manual mkdir, an
917
+ // older vault, direct Write tool calls) never gets one. Idempotent: an
918
+ // existing index.md is left untouched; this only fills the gap, and only the
919
+ // three tokens the template defines are substituted — the rest (one-line
920
+ // description, Progress checklist) stays human-authored prose exactly as
921
+ // createProject already leaves it.
922
+ //
923
+ // `working_dir` has no authoritative source in this flow: apply never
924
+ // receives the session's cwd (see hooks/hypo-shared.mjs's precompactGateStatus
925
+ // doc — process.cwd() is explicitly non-authoritative here, since it reflects
926
+ // this script's own launch directory, not the session's). It is left EMPTY,
927
+ // not filled with a placeholder string: hooks/hypo-shared.mjs's collector
928
+ // (collectProjectWorkingDirs) and findBackfillCandidate both treat any truthy
929
+ // `working_dir` as "already anchored" and stop offering to backfill the real
930
+ // cwd — a fake placeholder is truthy, so it would silently and permanently
931
+ // swallow the exact anchor-recovery path a human would otherwise get. Empty
932
+ // stays falsy there, so the project surfaces as a genuine backfill candidate
933
+ // until a person (or the auto-project-offer flow) fills in a real path. The
934
+ // key itself stays present in the frontmatter (substituted to an empty value,
935
+ // not omitted) so the shape matches every other index.md and a human sees
936
+ // exactly where to type the answer.
937
+ // "Never overwrite an existing index" is this feature's explicit contract, so
938
+ // creation uses an EXCLUSIVE create (`wx`), not the existsSync-then-atomicWrite
939
+ // shape every other target in this file uses. atomicWrite's tmp+rename
940
+ // replaces whatever sits at `dest` the instant rename fires — existing or not —
941
+ // so a plain `if (existsSync(dest)) return null` beforehand only narrows the
942
+ // race, it does not close it: another writer (a human, or a concurrent close)
943
+ // can land real bytes at `dest` between that check and this function's rename.
944
+ // `wx` makes the OS do the check-and-create atomically, mirroring
945
+ // hooks/base-store.mjs's snapshotBase (`openSync(path, 'wx')` + EEXIST ==
946
+ // "someone already got there, leave it"). No tmp+rename is needed here: unlike
947
+ // atomicWrite's use case (replacing bytes a reader might already be mid-read
948
+ // of), a `wx` create can never observably tear — the file either doesn't
949
+ // exist yet (nothing to tear) or the open fails outright.
950
+ export function ensureProjectIndex(hypoDir, project, relPath, today) {
951
+ const dest = join(hypoDir, relPath);
952
+ const src = join(TEMPLATE_DIR, 'index.md');
953
+ if (!existsSync(src)) return null; // template missing — nothing to scaffold from
954
+ const content = substituteTokens(readFileSync(src, 'utf-8'), {
955
+ name: project,
956
+ started: today,
957
+ workingDir: '',
958
+ today,
959
+ });
960
+ mkdirSync(dirname(dest), { recursive: true });
961
+ let fd;
962
+ try {
963
+ fd = openSync(dest, 'wx');
964
+ } catch (e) {
965
+ if (e && e.code === 'EEXIST') return null; // another writer already created it
966
+ throw e;
967
+ }
968
+ try {
969
+ writeSync(fd, content);
970
+ } finally {
971
+ closeSync(fd);
972
+ }
973
+ return relPath;
974
+ }
975
+
717
976
  function applySessionClose(args) {
718
977
  // Option D: early-exit fires only when NO payload was supplied.
719
978
  // Rationale: payload presence is explicit close intent and must always run
720
- // the full apply path — the per-entry idempotency (writeIfChanged +
979
+ // the full apply path — the per-entry idempotency (overwrite's step-1 skip +
721
980
  // exact-entry append dedup) keeps re-apply cheap without short-circuiting,
722
981
  // and avoids silent-success when a same-day second close brings new bytes.
723
982
  // Payload-less invocation is treated as a cheap "already complete?" probe.
@@ -748,6 +1007,51 @@ function applySessionClose(args) {
748
1007
  // "payload is required" with the same error shape as before.
749
1008
  }
750
1009
 
1010
+ // ── close authority: BEFORE any write ──────────────────────────────────────
1011
+ // A payload is a claim that the user asked to close. Verify it here, ahead of
1012
+ // readPayload and every byte that follows, because everything after this point
1013
+ // is authoritative: session-state.md and the hot files are overwritten, the
1014
+ // session-log and log.md are appended, and hypo-auto-commit lands the lot in git
1015
+ // on the next Stop whether or not this command commits.
1016
+ //
1017
+ // The gate used to stand at the END, next to the marker (planMarkerDecision).
1018
+ // It refused the MARKER on a close the user never asked for, and let the writes
1019
+ // and the commit through. Worse, it was opt-in BY THE CALLER: with no
1020
+ // --session-id there was no transcript to check, so the check simply did not
1021
+ // run, and the apply reported ok:true with no skip reason at all. The model
1022
+ // chose whether the model got checked. A model that has already decided to close
1023
+ // does not reach for the flag that would stop it.
1024
+ //
1025
+ // So: no verifiable close signal, no close. Zero bytes, no commit, exit 1.
1026
+ //
1027
+ // What this does and does not promise. It promises that THIS entrypoint refuses
1028
+ // a close with no recognized transcript evidence behind it. It does not promise
1029
+ // the model cannot write these files at all: Write and Bash remain outside this
1030
+ // gate, and the evidence predicate itself is coarse (any close signal anywhere in
1031
+ // the transcript counts, so a stale one from earlier in a long session still
1032
+ // passes, and it is the model that authors the AskUserQuestion option labels the
1033
+ // user picks from). Narrowing that (current-turn binding, revocation, consent
1034
+ // that the model did not word) is the follow-up this ADR names; it is not a
1035
+ // reason to leave the default open in the meantime.
1036
+ // Only a payload-bearing call can write. A payload-less one falls through to the
1037
+ // "payload is required" error below without touching a byte, so gating it here
1038
+ // would just replace one refusal with a less accurate one.
1039
+ const closeAuth = args.payload ? verifyCloseAuthority(args.sessionId) : { ok: true };
1040
+ if (!closeAuth.ok) {
1041
+ const out = {
1042
+ ok: false,
1043
+ stage: 'no-user-close-signal',
1044
+ reason: closeAuth.reason,
1045
+ applied: [],
1046
+ committed: false,
1047
+ error: closeAuth.error,
1048
+ };
1049
+ console.log(
1050
+ args.json ? JSON.stringify(out, null, 2) : `✗ ${closeAuth.error}\n\n${CLOSE_REFUSAL_HELP}`,
1051
+ );
1052
+ process.exit(1);
1053
+ }
1054
+
751
1055
  let payload;
752
1056
  try {
753
1057
  payload = readPayload(args.payload);
@@ -768,6 +1072,43 @@ function applySessionClose(args) {
768
1072
  process.exit(1);
769
1073
  }
770
1074
 
1075
+ // Payload↔session binding (cross-session payload collision). The payload temp
1076
+ // file is now written to a session-scoped path (see commands/crystallize.md), so
1077
+ // two same-day sessions no longer share a file. This is the belt to that
1078
+ // suspenders: if the payload names
1079
+ // the session it was authored for, it must be THIS one — the --session-id whose
1080
+ // transcript already cleared close authority above. A mismatch means the file on
1081
+ // disk is not this session's close (a stray or hand-reused path handed us another
1082
+ // session's payload), and applying it would stamp that content with this session's
1083
+ // marker while the original session's record vanishes — the exact loss this
1084
+ // guard exists to prevent. Refuse before a byte is written.
1085
+ //
1086
+ // Absent field → fail open: older payloads predate this field, and Part 1's unique
1087
+ // path already prevents the collision. So the check only ever tightens; it never
1088
+ // rejects a close it would otherwise have allowed on a matching (or absent) id. It
1089
+ // is deliberately identity-based, not cwd-based: closing project B from a session
1090
+ // whose cwd is project A stays supported (payload.project is authoritative), so a
1091
+ // legitimately cross-project close is untouched.
1092
+ if (
1093
+ payload.sessionId !== undefined &&
1094
+ payload.sessionId !== null &&
1095
+ payload.sessionId !== args.sessionId
1096
+ ) {
1097
+ const msg =
1098
+ `payload.sessionId ${JSON.stringify(payload.sessionId)} does not match --session-id ` +
1099
+ `${JSON.stringify(args.sessionId)}: this payload was authored for a different session, so ` +
1100
+ `it is not this session's close. Refusing before any write (cross-session guard).`;
1101
+ const out = {
1102
+ ok: false,
1103
+ stage: 'session-id-mismatch',
1104
+ error: msg,
1105
+ applied: [],
1106
+ committed: false,
1107
+ };
1108
+ console.log(args.json ? JSON.stringify(out, null, 2) : `✗ ${msg}`);
1109
+ process.exit(1);
1110
+ }
1111
+
771
1112
  // Resolve project: payload.project is REQUIRED (B-3, close-gate-hardening). The
772
1113
  // old recency fallback (payload.project || probe.project) could, on a same-date
773
1114
  // root-hot.md tie, resolve a DIFFERENT project than the one the payload's files
@@ -883,6 +1224,10 @@ function applySessionClose(args) {
883
1224
  // Append targets (session-log, log.md) are NOT filtered: appending can't
884
1225
  // repair existing corruption, so a corrupt session-log must still block.
885
1226
  // Warns are informational (not gated) in either pass.
1227
+ //
1228
+ // The filter says "about to be replaced", and the observed-base guard can later
1229
+ // decline to replace one of these. Preflight runs before the guard, so it cannot
1230
+ // know. Harmless: post-apply lint re-scopes the same file and blocks on it there.
886
1231
  const overwriteTargets = new Set();
887
1232
  if (payload.sessionState) overwriteTargets.add(join('projects', project, 'session-state.md'));
888
1233
  if (payload.projectHot) overwriteTargets.add(join('projects', project, 'hot.md'));
@@ -908,6 +1253,11 @@ function applySessionClose(args) {
908
1253
  // normalization, so it must use the OS-native separator the sibling entries use.
909
1254
  const sessionLogWriteTarget = join('projects', project, 'session-log', `${date}.md`);
910
1255
  const sessionLogEvidence = join(...sessionLogScopePath(args.hypoDir, project, date).split('/'));
1256
+ // A-1: known here (read-only check, no write yet) so a freshly-scaffolded
1257
+ // index.md is scoped to THIS close's own payloadScope below, rather than
1258
+ // showing up as an unrelated pre-existing-content notice.
1259
+ const indexRelPath = join('projects', project, 'index.md');
1260
+ const indexMissing = !existsSync(join(args.hypoDir, indexRelPath));
911
1261
  const payloadScope = new Set([
912
1262
  join('projects', project, 'session-state.md'),
913
1263
  join('projects', project, 'hot.md'),
@@ -916,6 +1266,7 @@ function applySessionClose(args) {
916
1266
  sessionLogEvidence, // == write target, except a hybrid-month monthly fallback
917
1267
  'log.md',
918
1268
  ...(payload.openQuestions ? [join('pages', 'open-questions.md')] : []),
1269
+ ...(indexMissing ? [indexRelPath] : []),
919
1270
  ]);
920
1271
 
921
1272
  let preflightLint;
@@ -952,11 +1303,70 @@ function applySessionClose(args) {
952
1303
 
953
1304
  const applied = [];
954
1305
  const skipped = [];
955
-
1306
+ // The ACTUAL vault-relative paths this apply wrote, kept separate
1307
+ // from `applied` (whose entries are display strings like `key (relPath)`,
1308
+ // not bare paths). This is the scope handed to commitWikiChanges below;
1309
+ // never the broader `payloadScope` above, which also includes lint/evidence
1310
+ // candidates this apply may not have written a byte to.
1311
+ const appliedPaths = [];
1312
+ // Overwrite targets this apply refused to write because the page moved under
1313
+ // it. T6 turns these into `.cache/proposals/` artifacts; here they are already
1314
+ // enough to withhold the bytes and fail the close.
1315
+ const conflicts = [];
1316
+
1317
+ /**
1318
+ * Replace a whole page, guarded by the base this session observed at start.
1319
+ *
1320
+ * The step order is load-bearing, not stylistic:
1321
+ *
1322
+ * 1. idempotent skip (disk already equals the payload)
1323
+ * 2. conflict (base unknown, or disk drifted away from base)
1324
+ * 3. direct write, then advance the base
1325
+ *
1326
+ * Step 1 must come first for two reasons. It keeps every existing
1327
+ * `--apply-session-close --session-id` test green (they read the payload
1328
+ * straight off disk, so they land here before any base lookup). And it breaks
1329
+ * the apply-then-reclose loop: once a human applies proposal P, disk == proposed
1330
+ * == payload.content, so the next close skips before it can re-raise a conflict.
1331
+ *
1332
+ * There is no caller here without a `--session-id`. verifyCloseAuthority refuses
1333
+ * that at the door, before a byte is written, so a session id is always present
1334
+ * by the time this runs and the base lookup always has something to look up.
1335
+ */
956
1336
  const overwrite = (key, relPath, field) => {
957
1337
  if (!field || typeof field.content !== 'string') return; // optional / absent
958
- const wrote = writeIfChanged(join(args.hypoDir, relPath), field.content);
959
- (wrote ? applied : skipped).push(`${key} (${relPath})`);
1338
+ const full = join(args.hypoDir, relPath);
1339
+ const disk = readTarget(full);
1340
+
1341
+ // (1) idempotent skip — preserves writeIfChanged's contract
1342
+ if (disk === field.content) {
1343
+ skipped.push(`${key} (${relPath})`);
1344
+ return;
1345
+ }
1346
+
1347
+ // (2) conflict, only where a session context makes a base observable
1348
+ if (args.sessionId) {
1349
+ const entry = readBaseEntry(args.hypoDir, args.sessionId, relPath);
1350
+ const reason = overwriteConflictReason(entry, disk);
1351
+ if (reason) {
1352
+ conflicts.push({
1353
+ key,
1354
+ target: relPath,
1355
+ reason,
1356
+ baseHash: entry.hash,
1357
+ currentHash: typeof disk === 'string' ? hashContent(disk) : null,
1358
+ proposedContent: field.content,
1359
+ });
1360
+ return; // target bytes untouched
1361
+ }
1362
+ }
1363
+
1364
+ // (3) write, then the content we just wrote IS this session's new base
1365
+ atomicWrite(full, field.content);
1366
+ if (args.sessionId)
1367
+ advanceBase(args.hypoDir, args.sessionId, relPath, hashContent(field.content));
1368
+ applied.push(`${key} (${relPath})`);
1369
+ appliedPaths.push(relPath);
960
1370
  };
961
1371
 
962
1372
  overwrite('sessionState', join('projects', project, 'session-state.md'), payload.sessionState);
@@ -964,6 +1374,17 @@ function applySessionClose(args) {
964
1374
  overwrite('rootHot', 'hot.md', payload.rootHot);
965
1375
  overwrite('openQuestions', join('pages', 'open-questions.md'), payload.openQuestions);
966
1376
 
1377
+ // A-1: fill a missing project index as part of this close's writes (after
1378
+ // preflight passed, so an aborted close never leaves a half-applied side
1379
+ // effect on disk).
1380
+ if (indexMissing) {
1381
+ const createdIndex = ensureProjectIndex(args.hypoDir, project, indexRelPath, date);
1382
+ if (createdIndex) {
1383
+ applied.push(`projectIndex (${createdIndex})`);
1384
+ appliedPaths.push(createdIndex);
1385
+ }
1386
+ }
1387
+
967
1388
  // Append idempotency: dedup by exact-entry presence, not by "any heading
968
1389
  // dated today". The freshness gate (sessionCloseFileStatus) is what answers
969
1390
  // "was this file touched today?"; that's a different concern and must not
@@ -976,59 +1397,91 @@ function applySessionClose(args) {
976
1397
  const rel = join('projects', project, 'session-log', `${date}.md`);
977
1398
  const full = join(args.hypoDir, rel);
978
1399
  const isPresent = entryAlreadyPresent(payload.sessionLog.entry);
979
- // Fallback-aware idempotency (hybrid cutover): during the month the
980
- // shard takes over, today's entry may already live in the legacy monthly
981
- // file from an earlier (pre-cutover) close. Treat presence in EITHER the
982
- // daily shard or the legacy monthly file as "already written" so a same-day
983
- // second close does not duplicate an identical entry across both files —
984
- // and so an idempotent re-apply stays a true no-op (no shard is created).
985
- let alreadyThere = false;
986
- for (const cand of sessionLogReadCandidates(project, date)) {
987
- const cf = join(args.hypoDir, cand);
988
- if (!existsSync(cf)) continue;
989
- try {
990
- if (isPresent(readFileSync(cf, 'utf-8'))) {
991
- alreadyThere = true;
992
- break;
993
- }
994
- } catch {
995
- /* unreadable candidate — fall through to the write path */
996
- }
997
- }
998
- if (alreadyThere) {
999
- skipped.push(`sessionLog (${rel})`);
1000
- } else if (!existsSync(full)) {
1001
- // A daily shard is a new file most days. Seed minimal valid frontmatter
1002
- // (title + type, the two REQUIRED_FIELDS) so the shard is a first-class
1003
- // wiki page rather than a W1 "no frontmatter" warning, and write the header
1004
- // AND the first entry in ONE atomic write — never leave a header-only shard
1005
- // on disk, which freshness would skip (no dated heading) while derive could
1006
- // otherwise mistake it for the evidence file. The dated `## [date] ...`
1007
- // heading lives inside the entry, so freshness / derive / design-history
1008
- // are unchanged.
1009
- // Audit fields (device, session_id). The shard frontmatter is git-tracked and synced, so
1010
- // `device` is an INTENTIONAL synced multi-machine identifier (privacy note:
1011
- // docs/ARCHITECTURE.md). It is a CREATOR-only stamp — only the session/
1012
- // machine that first seeds the daily shard is recorded; later same-day
1013
- // appends do not touch it. The per-session-accurate store is the LOCAL
1014
- // (.cache/, gitignored) index.jsonl written by hypo-session-record.mjs.
1015
- // `session_id` is honest naming: the value is the Claude session UUID, and
1016
- // it is present only on the Stop-chain close path that passes --session-id.
1017
- const device = String(hostname() || 'unknown').replace(/[\r\n]/g, '');
1018
- const auditFm =
1019
- (args.sessionId ? `session_id: ${String(args.sessionId).replace(/[\r\n]/g, '')}\n` : '') +
1020
- `device: ${device}\n`;
1021
- const header =
1022
- `---\ntitle: Session Log ${date} (${project})\n` +
1023
- `type: session-log\nupdated: ${date}\n${auditFm}---\n\n` +
1024
- `# Session Log ${date} (${project})\n`;
1025
- const entry = payload.sessionLog.entry;
1026
- const body = entry.endsWith('\n') ? entry : `${entry}\n`;
1027
- atomicWrite(full, `${header}\n${body}`);
1028
- applied.push(`sessionLog (${rel})`);
1029
- } else {
1030
- const wrote = appendIfAbsent(full, payload.sessionLog.entry, isPresent);
1031
- (wrote ? applied : skipped).push(`sessionLog (${rel})`);
1400
+ // Serialize dedup + create/append on the daily shard so two concurrent
1401
+ // closes never lose an entry: the second closer takes the lock only after
1402
+ // the first committed, re-reads the shard under the lock, and appends onto
1403
+ // the committed bytes (temp+rename write-isolation is preserved — a partial
1404
+ // write never tears the target). Create and append share ONE lock, so the
1405
+ // "seed a new shard" and "append to an existing shard" branches can't race
1406
+ // each other — only one closer is ever in the create path (closes the
1407
+ // wx-window a bare exclusive-create would leave open).
1408
+ try {
1409
+ const outcome = withFileLock(
1410
+ full,
1411
+ () => {
1412
+ // Fallback-aware idempotency (hybrid cutover): during the month the
1413
+ // shard takes over, today's entry may already live in the legacy monthly
1414
+ // file from an earlier (pre-cutover) close. Treat presence in EITHER the
1415
+ // daily shard or the legacy monthly file as "already written" so a same-day
1416
+ // second close does not duplicate an identical entry across both files —
1417
+ // and so an idempotent re-apply stays a true no-op (no shard is created).
1418
+ for (const cand of sessionLogReadCandidates(project, date)) {
1419
+ const cf = join(args.hypoDir, cand);
1420
+ if (!existsSync(cf)) continue;
1421
+ try {
1422
+ if (isPresent(readFileSync(cf, 'utf-8'))) return 'skipped';
1423
+ } catch {
1424
+ /* unreadable candidate — fall through to the write path */
1425
+ }
1426
+ }
1427
+ if (!existsSync(full)) {
1428
+ // A daily shard is a new file most days. Seed minimal valid frontmatter
1429
+ // (title + type, the two REQUIRED_FIELDS) so the shard is a first-class
1430
+ // wiki page rather than a W1 "no frontmatter" warning, and write the header
1431
+ // AND the first entry in ONE atomic write — never leave a header-only shard
1432
+ // on disk, which freshness would skip (no dated heading) while derive could
1433
+ // otherwise mistake it for the evidence file. The dated `## [date] ...`
1434
+ // heading lives inside the entry, so freshness / derive / design-history
1435
+ // are unchanged.
1436
+ // Audit fields (device, session_id). The shard frontmatter is git-tracked and synced, so
1437
+ // `device` is an INTENTIONAL synced multi-machine identifier (privacy note:
1438
+ // docs/ARCHITECTURE.md). It is a CREATOR-only stamp — only the session/
1439
+ // machine that first seeds the daily shard is recorded; later same-day
1440
+ // appends do not touch it. The per-session-accurate store is the LOCAL
1441
+ // (.cache/, gitignored) index.jsonl written by hypo-session-record.mjs.
1442
+ // `session_id` is honest naming: the value is the Claude session UUID, and
1443
+ // it is present only on the Stop-chain close path that passes --session-id.
1444
+ const device = currentDevice();
1445
+ const auditFm =
1446
+ (args.sessionId
1447
+ ? `session_id: ${String(args.sessionId).replace(/[\r\n]/g, '')}\n`
1448
+ : '') + `device: ${device}\n`;
1449
+ const header =
1450
+ `---\ntitle: Session Log ${date} (${project})\n` +
1451
+ `type: session-log\nupdated: ${date}\n${auditFm}---\n\n` +
1452
+ `# Session Log ${date} (${project})\n`;
1453
+ const entry = payload.sessionLog.entry;
1454
+ const body = entry.endsWith('\n') ? entry : `${entry}\n`;
1455
+ atomicWrite(full, `${header}\n${body}`);
1456
+ return 'created';
1457
+ }
1458
+ return appendIfAbsent(full, payload.sessionLog.entry, isPresent) ? 'appended' : 'skipped';
1459
+ },
1460
+ { timeoutMs: APPEND_LOCK_TIMEOUT_MS },
1461
+ );
1462
+ (outcome === 'skipped' ? skipped : applied).push(`sessionLog (${rel})`);
1463
+ if (outcome !== 'skipped') appliedPaths.push(rel);
1464
+ } catch (err) {
1465
+ // Only a lock-TIMEOUT is withheld as a conflict. A real fn() write error
1466
+ // (disk-full, EACCES, mkdir failure) must NOT be masked as a proposal-
1467
+ // pending timeout — rethrow so it hard-fails like the overwrite path does.
1468
+ if (err?.code !== 'ELOCKTIMEOUT') throw err;
1469
+ // Lock-timeout: withhold rather than lose the entry. Recorded as a conflict
1470
+ // so the close goes proposal-pending (ok:false, no marker) and the next
1471
+ // close re-applies. `kind: 'append'` is what T6 branches on to SKIP parking
1472
+ // this: an append conflict never becomes a `.cache/proposals/` artifact —
1473
+ // the lock-timeout is transient and the next close self-heals by
1474
+ // re-appending, whereas a whole-file re-apply would drop this shard's other
1475
+ // entries. It still blocks the close; it just gets no artifact.
1476
+ conflicts.push({
1477
+ key: 'sessionLog',
1478
+ target: rel,
1479
+ reason: 'append-lock-timeout',
1480
+ kind: 'append',
1481
+ baseHash: null,
1482
+ currentHash: null,
1483
+ proposedContent: payload.sessionLog.entry,
1484
+ });
1032
1485
  }
1033
1486
  }
1034
1487
 
@@ -1046,31 +1499,128 @@ function applySessionClose(args) {
1046
1499
  // append onto a deliberately custom payload.log (codex pre-commit review). The
1047
1500
  // two payload paths are mutually exclusive: deriving on top of a present-but-
1048
1501
  // malformed payload.log would mask it and weaken the verifier's fail-loud.
1502
+ // log.md is shared across projects and also written by deriveRootLogEntries
1503
+ // (the Stop-hook backfill in hypo-shared.mjs). Both take the SAME lock on
1504
+ // log.md, so a concurrent close's append and this close's append serialize
1505
+ // instead of overwriting each other.
1506
+ const logFull = join(args.hypoDir, 'log.md');
1049
1507
  if (payload.log) {
1050
- const wrote = appendIfAbsent(
1051
- join(args.hypoDir, 'log.md'),
1052
- payload.log.entry,
1053
- entryAlreadyPresent(payload.log.entry),
1054
- );
1055
- (wrote ? applied : skipped).push('log (log.md)');
1508
+ try {
1509
+ const wrote = withFileLock(
1510
+ logFull,
1511
+ () => appendIfAbsent(logFull, payload.log.entry, entryAlreadyPresent(payload.log.entry)),
1512
+ { timeoutMs: APPEND_LOCK_TIMEOUT_MS },
1513
+ );
1514
+ (wrote ? applied : skipped).push('log (log.md)');
1515
+ if (wrote) appliedPaths.push('log.md');
1516
+ } catch (err) {
1517
+ if (err?.code !== 'ELOCKTIMEOUT') throw err;
1518
+ // proposedContent is append-ready root-log bytes (the custom log line).
1519
+ conflicts.push({
1520
+ key: 'log',
1521
+ target: 'log.md',
1522
+ reason: 'append-lock-timeout',
1523
+ kind: 'append',
1524
+ baseHash: null,
1525
+ currentHash: null,
1526
+ proposedContent: payload.log.entry,
1527
+ });
1528
+ }
1056
1529
  } else {
1057
1530
  // matchAll (not exec) mirrors deriveRootLogEntries: a payload that carried
1058
1531
  // more than one dated heading derives one canonical line each, symmetric with
1059
1532
  // the global path. Exact-line dedup on the heading keeps a second apply (or a
1060
1533
  // titleless vs titled same-day pair) from duplicating.
1061
- const logFull = join(args.hypoDir, 'log.md');
1062
1534
  const headingRe = new RegExp(`^#{1,6} \\[${date}\\]\\s*(.*)$`, 'gm');
1063
- let wroteAny = false;
1064
- for (const m of (payload.sessionLog.entry || '').matchAll(headingRe)) {
1065
- const { heading, block } = rootLogEntry(project, date, m[1]);
1066
- const wrote = appendIfAbsent(logFull, block, (c) =>
1067
- (c || '').split(/\r?\n/).includes(heading),
1535
+ try {
1536
+ const wroteAny = withFileLock(
1537
+ logFull,
1538
+ () => {
1539
+ let w = false;
1540
+ for (const m of (payload.sessionLog.entry || '').matchAll(headingRe)) {
1541
+ const { heading, block } = rootLogEntry(project, date, m[1]);
1542
+ const wrote = appendIfAbsent(logFull, block, (c) =>
1543
+ (c || '').split(/\r?\n/).includes(heading),
1544
+ );
1545
+ w = w || wrote;
1546
+ }
1547
+ return w;
1548
+ },
1549
+ { timeoutMs: APPEND_LOCK_TIMEOUT_MS },
1068
1550
  );
1069
- wroteAny = wroteAny || wrote;
1551
+ (wroteAny ? applied : skipped).push('log (log.md, derived)');
1552
+ if (wroteAny) appliedPaths.push('log.md');
1553
+ } catch (err) {
1554
+ if (err?.code !== 'ELOCKTIMEOUT') throw err;
1555
+ // `derived: true` discriminates this from the payload.log conflict above:
1556
+ // here proposedContent is the session-log entry to RE-DERIVE root-log lines
1557
+ // from (via rootLogEntry over its dated headings), NOT append-ready bytes.
1558
+ // Like every `kind: 'append'` conflict, this is NOT parked as an artifact —
1559
+ // it blocks the close and the next close re-derives + re-appends. The derived
1560
+ // shape only matters to the retry, never to T6's overwrite proposal store.
1561
+ conflicts.push({
1562
+ key: 'log',
1563
+ target: 'log.md',
1564
+ reason: 'append-lock-timeout',
1565
+ kind: 'append',
1566
+ derived: true,
1567
+ baseHash: null,
1568
+ currentHash: null,
1569
+ proposedContent: payload.sessionLog.entry,
1570
+ });
1070
1571
  }
1071
- (wroteAny ? applied : skipped).push('log (log.md, derived)');
1072
1572
  }
1073
1573
 
1574
+ // T6: park drifted OVERWRITE targets as `.cache/proposals/` artifacts.
1575
+ //
1576
+ // Runs regardless of --json AND regardless of args.sessionId: writing the
1577
+ // artifact is a SIDE EFFECT, not output, so it is conditioned on neither the
1578
+ // report format nor a session context. (In practice an overwrite conflict only
1579
+ // arises with a session id, so a session-less apply just finds no overwrite
1580
+ // conflicts to park — but the loop is unconditional to match that contract.)
1581
+ // Only overwrite conflicts (`kind !== 'append'`) become artifacts. An append
1582
+ // conflict is a transient lock-timeout the NEXT close self-heals by
1583
+ // re-appending; parking it as a whole-file artifact and later re-applying it
1584
+ // (T7 replaces the whole target) would drop every OTHER entry in that
1585
+ // append-only history file. Append conflicts still sit in `conflicts`, so the
1586
+ // close still goes proposal-pending — they just get no artifact and no
1587
+ // human-apply step.
1588
+ const proposals = [];
1589
+ const proposalStoreFailures = [];
1590
+ const device = currentDevice();
1591
+ for (const c of conflicts) {
1592
+ if (c.kind === 'append') continue; // append conflicts are never parked (see above)
1593
+ try {
1594
+ const saved = writeProposal(args.hypoDir, {
1595
+ target: c.target,
1596
+ baseHash: c.baseHash,
1597
+ currentAtProposalHash: c.currentHash,
1598
+ proposedContent: c.proposedContent, // internal (pre-drop) full page bytes
1599
+ sessionId: args.sessionId, // may be null; writeProposal coerces it
1600
+ device,
1601
+ });
1602
+ proposals.push({ id: saved.id, target: saved.target, path: saved.path });
1603
+ // Supersede-delete failure is NON-fatal: the new artifact IS parked, only
1604
+ // a stale sibling lingers (superseded next close). Surface it, don't fail.
1605
+ for (const w of saved.supersedeWarnings) {
1606
+ process.stderr.write(`\n⚠️ ${w} (stale artifact left behind, not fatal)\n`);
1607
+ }
1608
+ } catch (err) {
1609
+ // fail-loud: the target was withheld AND its bytes are now on neither disk
1610
+ // NOR a proposal artifact — a genuine data-loss risk. Never swallow this.
1611
+ const error = (err && err.message) || String(err);
1612
+ proposalStoreFailures.push({ target: c.target, key: c.key, error });
1613
+ process.stderr.write(
1614
+ `\n🛑 PROPOSAL STORE FAILED for ${c.key} (${c.target}): ${error}\n` +
1615
+ ` This close WITHHELD the target (it drifted from your observed base) but\n` +
1616
+ ` could NOT write the .cache/proposals/ artifact either. The payload bytes\n` +
1617
+ ` are on NEITHER disk NOR a proposal — re-run the close once the .cache/\n` +
1618
+ ` directory is writable so the withheld content is not lost.\n`,
1619
+ );
1620
+ }
1621
+ }
1622
+ const proposalStoreFailed = proposalStoreFailures.length > 0;
1623
+
1074
1624
  // Same-date-tie fix: verify against the SAME project this apply just wrote
1075
1625
  // (`project` = payload.project || probe.project, resolved at the top). Without
1076
1626
  // the override, sessionCloseFileStatus re-derives via resolveActiveProject and,
@@ -1099,7 +1649,16 @@ function applySessionClose(args) {
1099
1649
  const m = unknownTagRe.exec(w.message || '');
1100
1650
  if (m && !checkForbidden(m[1])) pendingTags.push(m[1]);
1101
1651
  }
1102
- if (pendingTags.length > 0) appendPendingTags(args.hypoDir, pendingTags);
1652
+ // Withheld a target? Then register nothing. This close is going to be re-run
1653
+ // once a human resolves the conflict, and its `ok:false` skips the commit below
1654
+ // (`if (ok && args.sessionId)`) — so registering here would leave SCHEMA.md
1655
+ // mutated, uncommitted, and absent from `applied`, i.e. a silent side effect of
1656
+ // a close whose whole point was to write nothing. Registration is
1657
+ // eventually-consistent by design, so deferring it to the next close costs
1658
+ // nothing (codex W2 CONCERN).
1659
+ if (pendingTags.length > 0 && conflicts.length === 0) {
1660
+ appendPendingTags(args.hypoDir, pendingTags);
1661
+ }
1103
1662
 
1104
1663
  // Post-apply lint: payload may have introduced a malformed body or
1105
1664
  // bad frontmatter. Surface as a distinct `stage` so caller can tell "lint
@@ -1138,7 +1697,12 @@ function applySessionClose(args) {
1138
1697
  const postLintOk = !postApplyCrashed && postBlocking.length === 0;
1139
1698
  // `let` (not const): the close-result invariant self-check below may flip this
1140
1699
  // to false when the settled close result is internally contradictory.
1141
- let ok = verification.ok && postLintOk;
1700
+ //
1701
+ // A withheld conflict target must fail the close on its own, not merely via the
1702
+ // freshness gate. If the other session already touched that page TODAY, freshness
1703
+ // sees a fresh file and passes — and the close would report ok:true, write the
1704
+ // marker, and drop this session's payload silently. `conflicts` closes that hole.
1705
+ let ok = verification.ok && postLintOk && conflicts.length === 0;
1142
1706
 
1143
1707
  // Scope the non-blocking notice to the close-target project: debt under
1144
1708
  // projects/<project>/ stays listed; debt elsewhere folds to a count so the
@@ -1177,15 +1741,35 @@ function applySessionClose(args) {
1177
1741
  // user-close signal ONLY once the gate passes. planMarkerDecision owns the
1178
1742
  // branch priority + reason strings; the booleans below are computed in that
1179
1743
  // same short-circuiting order so no read runs earlier than it does today.
1180
- const commitOutcome = commitWikiChanges(args.hypoDir);
1744
+ // Scope this commit to the paths THIS apply actually wrote
1745
+ // (appliedPaths), never the broader payloadScope, which also
1746
+ // names lint/evidence candidates apply may not have touched a byte of.
1747
+ // Locked against the SAME target the auto-commit Stop hook holds, so a
1748
+ // concurrent Stop-chain commit on this vault can't interleave with this
1749
+ // apply's stage+commit. A lock-timeout is treated exactly like any other
1750
+ // commit failure below (skip the marker, surface the reason) rather than
1751
+ // crashing the apply.
1752
+ let commitOutcome;
1753
+ try {
1754
+ commitOutcome = withFileLock(vaultCommitLockTarget(args.hypoDir), () =>
1755
+ commitWikiChanges(args.hypoDir, appliedPaths),
1756
+ );
1757
+ } catch (err) {
1758
+ commitOutcome = { committed: false, reason: `vault-commit-lock: ${err?.message || err}` };
1759
+ }
1181
1760
  let closeTranscript = null;
1182
1761
  let gateOk = false;
1183
1762
  if (commitOutcome.committed) {
1184
1763
  closeTranscript = resolveTranscriptBySessionId(args.sessionId);
1185
- gateOk = precompactGateStatus(
1186
- args.hypoDir,
1187
- closeTranscript ? { transcriptPath: closeTranscript } : {},
1188
- ).ok;
1764
+ // closeScope: apply KNOWS which project it just closed, and it wrote
1765
+ // that project's files from inside this process — they never appear in the
1766
+ // transcript as Edit/Write, so `payload.project` is the only signal that puts
1767
+ // this close in scope. Without it, apply's own incomplete close could be demoted
1768
+ // to a foreign-debt notice and marked green.
1769
+ gateOk = precompactGateStatus(args.hypoDir, {
1770
+ closeScope: [project],
1771
+ ...(closeTranscript ? { transcriptPath: closeTranscript } : {}),
1772
+ }).ok;
1189
1773
  }
1190
1774
  const decision = planMarkerDecision({
1191
1775
  ok,
@@ -1200,7 +1784,9 @@ function applySessionClose(args) {
1200
1784
  });
1201
1785
  markerSkipReason = decision.skipReason;
1202
1786
  if (decision.write) {
1203
- writeSessionClosedMarker(args.hypoDir, args.sessionId, { project });
1787
+ // apply KNOWS its authoritative payload.project — stamp it as the v4
1788
+ // evidence set so PreCompact trusts this marker's scope directly (session-close attribution).
1789
+ writeSessionClosedMarker(args.hypoDir, args.sessionId, { project, projects: [project] });
1204
1790
  // Codex CONCERN: the writer swallows IO errors (best-effort).
1205
1791
  // Verify the file actually landed — mirroring the standalone path — instead of
1206
1792
  // asserting markerWritten=true, so a .cache permission/disk problem surfaces
@@ -1212,13 +1798,21 @@ function applySessionClose(args) {
1212
1798
  }
1213
1799
  }
1214
1800
  }
1801
+ // A conflict outranks the downstream gates: verification and lint both describe
1802
+ // a tree this apply declined to finish writing, so naming them would point the
1803
+ // reader at the wrong repair. A proposal-STORE failure outranks even that: the
1804
+ // withheld bytes never reached an artifact, so it is the most urgent repair.
1215
1805
  let stage = ok
1216
1806
  ? null
1217
- : !verification.ok && !postLintOk
1218
- ? 'post-apply-verification+lint'
1219
- : !verification.ok
1220
- ? 'post-apply-verification'
1221
- : 'post-apply-lint';
1807
+ : proposalStoreFailed
1808
+ ? 'proposal-store-failed'
1809
+ : conflicts.length > 0
1810
+ ? 'proposal-pending'
1811
+ : !verification.ok && !postLintOk
1812
+ ? 'post-apply-verification+lint'
1813
+ : !verification.ok
1814
+ ? 'post-apply-verification'
1815
+ : 'post-apply-lint';
1222
1816
  // Runtime close-result invariant self-check. When a
1223
1817
  // marker-write path (args.sessionId present) settles into an internally
1224
1818
  // contradictory shape — ok:true with the marker silently withheld and no
@@ -1249,6 +1843,29 @@ function applySessionClose(args) {
1249
1843
  date,
1250
1844
  applied,
1251
1845
  skipped,
1846
+ // Targets withheld: an overwrite drifted from this session's observed base, or
1847
+ // an append could not take the file lock in time (`kind: 'append'`). Two
1848
+ // channels resolve these, and `proposals` vs `conflicts[].kind` are the sole
1849
+ // discriminators: an OVERWRITE conflict is parked as a `.cache/proposals/`
1850
+ // artifact (below) for a human to review and re-apply; an APPEND conflict gets
1851
+ // NO artifact and is re-tried automatically by the next close. `proposedContent`
1852
+ // is dropped from the reported shape either way (the artifact / the next close
1853
+ // holds the bytes; a whole page or an append entry does not belong in the JSON).
1854
+ conflicts: conflicts.map(({ proposedContent: _drop, ...rest }) => rest),
1855
+ // Parked overwrite proposals (id/target/path), one per drifted overwrite
1856
+ // target. Empty when only append conflicts (or none) occurred. The T7 CLI
1857
+ // lists and applies these; append conflicts never appear here.
1858
+ proposals,
1859
+ ...(proposalStoreFailed ? { proposalStoreFailures } : {}),
1860
+ // Partial close: some overwrite direct-writes (and/or appends) already landed
1861
+ // on disk while at least one conflict withheld the rest. Because the close is
1862
+ // ok:false, commitWikiChanges + the marker are skipped — so those written
1863
+ // files sit on disk UNCOMMITTED until the conflict is resolved and the close
1864
+ // re-runs. Named honestly: `appliedUncommitted` covers every write that landed
1865
+ // (overwrite or append), not overwrites alone.
1866
+ ...(applied.length > 0 && conflicts.length > 0
1867
+ ? { partialConflict: true, appliedUncommitted: [...applied] }
1868
+ : {}),
1252
1869
  verification,
1253
1870
  // Surface the marker outcome instead of skipping silently, so the
1254
1871
  // caller can tell "closed" from "applied but not marked".
@@ -1274,6 +1891,29 @@ function applySessionClose(args) {
1274
1891
  console.log(`Session-close apply (project: ${project}, date: ${date}):`);
1275
1892
  for (const a of applied) console.log(` ✓ wrote ${a}`);
1276
1893
  for (const s of skipped) console.log(` · skipped ${s} (already current)`);
1894
+ // Never let a withheld target read as a skip: `skipped` means "already current",
1895
+ // this means "your bytes are NOT on disk". Overwrite conflicts drifted from base;
1896
+ // an append conflict is a lock-timeout (someone else held the file's lock), which
1897
+ // is transient — the next close re-applies.
1898
+ for (const c of conflicts) {
1899
+ const why =
1900
+ c.kind === 'append'
1901
+ ? 'could not acquire the append lock in time; the next close re-applies'
1902
+ : 'the page changed since this session read it';
1903
+ console.log(` ⚠ WITHHELD ${c.key} (${c.target}) — ${c.reason}; ${why}`);
1904
+ }
1905
+ for (const p of proposals) {
1906
+ console.log(
1907
+ ` · parked proposal ${p.id} for ${p.target} (review with \`hypomnema proposal\`)`,
1908
+ );
1909
+ }
1910
+ if (applied.length > 0 && conflicts.length > 0) {
1911
+ console.log(
1912
+ '\n· partial close: the writes above ARE on disk but NOT committed — this close is\n' +
1913
+ ' ok:false, so no commit and no session-close marker run until the withheld\n' +
1914
+ ' target(s) are resolved and the close re-runs.',
1915
+ );
1916
+ }
1277
1917
  if (ok) {
1278
1918
  // When the marker was withheld, qualify the success line so a reader scanning
1279
1919
  // stdout alone cannot mistake "verified" for "fully closed". markerSkipReason
@@ -1430,6 +2070,7 @@ function main() {
1430
2070
  } catch {
1431
2071
  continue;
1432
2072
  }
2073
+ if (!scopeVisible(readVisibilityScope(content), currentDevice())) continue;
1433
2074
  const fm = parseFrontmatter(content);
1434
2075
  if (!fm) continue;
1435
2076