hypomnema 1.10.0 → 1.10.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.
@@ -68,9 +68,11 @@ import {
68
68
  import {
69
69
  isValidProjectName,
70
70
  TRACK_ID_RE,
71
+ buildBaselineEntry,
71
72
  closeIdFor,
72
73
  entryFileName,
73
74
  formatSessionEntry,
75
+ isBaselineId,
74
76
  parseSessionEntry,
75
77
  resolveSupersedesPrefix,
76
78
  splitLegacyFrontmatter,
@@ -1074,7 +1076,7 @@ function priorReceiptCommitRewritten(hypoDir, sessionId) {
1074
1076
  // between this CLI and the auto-write at the tail of applySessionClose.
1075
1077
  //
1076
1078
  // Contract: the marker is written only when precompactGateStatus, run in
1077
- // checkpointMode (the git axis narrowed), is green. A failed gate exits 1 with no
1079
+ // checkpointMode (which only makes an invalid session id a blocker), is green. A failed gate exits 1 with no
1078
1080
  // marker — the next Stop hook re-blocks.
1079
1081
 
1080
1082
  // `testHooks` is test-only, see settleCloseResolution.
@@ -1098,10 +1100,10 @@ export function runMarkSessionClosed(args, testHooks = null) {
1098
1100
  // The per-session marker is the THIRD session-close completion
1099
1101
  // signal (after the PreCompact gate and `--check-session-close`). It uses
1100
1102
  // precompactGateStatus, the gate the PreCompact notice reads, with one deliberate
1101
- // difference: `checkpointMode` (set below) replaces the git axis. The gate
1102
- // flags any uncommitted vault change, while the checkpoint blocks only
1103
- // on an uncommitted write THIS session still owns, so another session's
1104
- // dirty file can keep the gate red while this checkpoint still lands.
1103
+ // difference: `checkpointMode` (set below) only makes an invalid session id a
1104
+ // blocker of its own. The git axis is the same for every caller: it blocks
1105
+ // only on an uncommitted write THIS session still owns (or failed to record),
1106
+ // so another session's dirty file stays a notice for this checkpoint too.
1105
1107
  // Neither one stops /compact: PreCompact only shows a notice.
1106
1108
  // Every other axis is the same, so the marker still enforces feedback
1107
1109
  // projection (over-cap/conflict), W8 design-history staleness, and root
@@ -1121,11 +1123,10 @@ export function runMarkSessionClosed(args, testHooks = null) {
1121
1123
  // resolveGateProjectOverride (session-close-scope-boundary spec §2/§3): closeScope
1122
1124
  // above only widens THIS session's accountability set (evidence union for the
1123
1125
  // marker's attribution) -- it does not narrow closeAccountableScope's base, so a
1124
- // DIFFERENT project's dangling close files still land in the git-dirty check and
1125
- // block a `--project=<mine>` mark. Passed below as `attributionScope`: this is
1126
- // the value that feeds the mine/foreign partition in precompactGateStatus and
1127
- // demotes the foreign project's own close-file debt to a notice instead of
1128
- // block, and it also decides which projects the marker this call may go on to
1126
+ // DIFFERENT project's dangling close files still land in the lint scope. Passed
1127
+ // below as `attributionScope`: this is the value that feeds the mine/foreign
1128
+ // partition in precompactGateStatus and demotes the foreign project's own
1129
+ // close-file debt to a notice instead of block, and it also decides which projects the marker this call may go on to
1129
1130
  // write ends up attesting -- so passing `projectOverride` instead would narrow
1130
1131
  // sessionCloseGlobalStatus to args.project alone and turn the partition off,
1131
1132
  // which is the bug this key split fixes (a marker then claimed the demoted
@@ -1559,7 +1560,7 @@ export function runMarkSessionClosed(args, testHooks = null) {
1559
1560
  // computed once per run, so neither repeats on a later gate read.
1560
1561
  if (hostTagWarning) console.log(`\n⚠ ${hostTagWarning}`);
1561
1562
  if (receiptResult.leftUnverifiable) console.log(` · ${LEFT_UNVERIFIABLE_NOTE}`);
1562
- for (const n of proofNotices) console.log(` · ${n.reason}`);
1563
+ printGateNotices(result.notices);
1563
1564
  }
1564
1565
  process.exit(0);
1565
1566
  }
@@ -1909,12 +1910,38 @@ export function ensureProjectIndex(hypoDir, project, relPath, today, sessionId)
1909
1910
  const dest = join(hypoDir, relPath);
1910
1911
  const content = projectIndexSeed(project, today);
1911
1912
  if (content === null) return null; // template missing: nothing to scaffold from
1913
+ // The close gate has to know the file is this session's before it exists: a death between the
1914
+ // create and the write leaves a zero-byte index.md that nothing else would claim. So the record
1915
+ // comes first, with the seed's hash rather than the empty file's, so the Stop auto-commit does
1916
+ // not retire it (nor does it retire a claim whose file does not exist yet). A record that fails
1917
+ // stops here, before the create: its failure flag names the path, and the gate blocks it.
1918
+ //
1919
+ // Only when the file is absent now: recordTouchedPaths overwrites the hash of a claim that is
1920
+ // already there, and a file that exists may carry this session's own edit. Replacing that claim
1921
+ // with the seed hash lets the Stop commit retire it when HEAD holds the seed (another session
1922
+ // committed it), leaving the edit dirty and unclaimed. `onlyIfUnclaimed` closes the same hole for
1923
+ // an edit recorded between this check and the record below: the session lock decides, so an
1924
+ // existing claim keeps its hash. A file created between this check and the `wx` open below
1925
+ // leaves the seed claim behind, which the coarse rule accepts: this session had no claim on it.
1926
+ if (existsSync(dest)) return null;
1927
+ const seedHash = bytesSha256(content);
1928
+ if (
1929
+ sessionId &&
1930
+ !recordTouchedPaths(hypoDir, sessionId, [relPath], {
1931
+ expectedHashes: { [relPath]: seedHash },
1932
+ onlyIfUnclaimed: true,
1933
+ })
1934
+ )
1935
+ return null;
1912
1936
  mkdirSync(dirname(dest), { recursive: true });
1913
1937
  let fd;
1914
1938
  try {
1915
1939
  fd = openSync(dest, 'wx');
1916
1940
  } catch (e) {
1917
- if (e && e.code === 'EEXIST') return null; // another writer already created it
1941
+ // Another writer already created it. The claim just recorded stays: taking it back cannot tell
1942
+ // this call's claim from a same-session one that overlaps it, so the coarse rule keeps it. The
1943
+ // gate then blocks the file as this session's until it is committed, which retires the claim.
1944
+ if (e && e.code === 'EEXIST') return null;
1918
1945
  throw e;
1919
1946
  }
1920
1947
  try {
@@ -2371,6 +2398,81 @@ const ENTRY_CONFLICT_HELP = (relPath, reason) =>
2371
2398
  `Nothing was changed. Look at the file and the vault history, then move or remove the ` +
2372
2399
  `foreign bytes yourself and retry.`;
2373
2400
 
2401
+ /**
2402
+ * The id a baseline built from `text` (the flat session-state.md) gets, with `entry`'s stored done
2403
+ * heads and scope; `null` when the entry cannot be rebuilt.
2404
+ */
2405
+ function rebuiltBaselineId(entry, text, project) {
2406
+ try {
2407
+ return buildBaselineEntry({
2408
+ kind: 'state',
2409
+ text,
2410
+ date: entry.date,
2411
+ visibilityScope: entry.visibilityScope,
2412
+ project: entry.project ?? project,
2413
+ legacyDone: entry.tracks.filter((t) => t.done === true).map((t) => t.id),
2414
+ }).closeId;
2415
+ } catch {
2416
+ return null; // an input that cannot be rebuilt is not proof, so the baseline is not counted
2417
+ }
2418
+ }
2419
+
2420
+ /**
2421
+ * The baseline heads this session was shown when it read the old flat `session-state.md`:
2422
+ * `{trackId: [closeId]}`. A session that began before the vault moved to entries read that file,
2423
+ * and its base record holds the file's hash. The move wrote the same bytes into a baseline, so a
2424
+ * baseline whose source (the flat file in the parent of the commit that added it) hashes to what the
2425
+ * session read counts as heard, and a close can replace its heads. The source must also build the
2426
+ * baseline's own id (see rebuiltBaselineId), else the baseline holds other bytes than the session
2427
+ * read and is left alone. The LF source and its CRLF form
2428
+ * are both compared, since a checkout with autocrlf shows the session the CRLF bytes. `{}` when the
2429
+ * vault is not moved, the session recorded no hash of the file, or no baseline matches (a
2430
+ * baseline made from local bytes that differ from HEAD has no source to find, so it is not
2431
+ * counted, as before).
2432
+ */
2433
+ function flatReadBaselineHeads(hypoDir, sessionId, project, model) {
2434
+ if (!model.migrated) return {};
2435
+ const rel = `projects/${project}/session-state.md`;
2436
+ const base = readBaseEntry(hypoDir, sessionId, rel);
2437
+ const wanted = new Set(
2438
+ [base.state === 'hash' ? base.hash : null, readObservedHash(hypoDir, sessionId, rel)].filter(
2439
+ Boolean,
2440
+ ),
2441
+ );
2442
+ if (wanted.size === 0) return {};
2443
+ const git = (...a) =>
2444
+ spawnSync('git', ['-C', hypoDir, ...a], { encoding: 'utf-8', maxBuffer: 64 * 1024 * 1024 });
2445
+ const heads = {};
2446
+ for (const e of model.entries) {
2447
+ if (!isBaselineId(e.closeId) || e.tracks.length === 0) continue;
2448
+ const added = git(
2449
+ 'log',
2450
+ '--diff-filter=A',
2451
+ '--format=%H',
2452
+ '-n',
2453
+ '1',
2454
+ '--',
2455
+ `:(glob)projects/${project}/sessions/*-${e.closeId}.md`,
2456
+ );
2457
+ const sha = added.status === 0 ? added.stdout.trim() : '';
2458
+ if (!sha) continue;
2459
+ const source = git('show', revPathArg(`${sha}^`, rel));
2460
+ if (source.status !== 0) continue;
2461
+ const text = source.stdout;
2462
+ const crlf = text.replace(/\n/g, '\r\n');
2463
+ const match = wanted.has(hashContent(text)) || wanted.has(hashContent(crlf));
2464
+ if (!match) continue;
2465
+ // The hash proves the parent held the bytes the session read, not that those bytes made this
2466
+ // baseline: an archive resumed while the flat file was still tracked can publish a baseline of
2467
+ // other saved bytes in a commit whose parent holds the old file. Rebuild the id from the parent
2468
+ // text (done heads and scope as the baseline stores them) and count the baseline only when it
2469
+ // is the same one.
2470
+ if (rebuiltBaselineId(e, text, project) !== e.closeId) continue;
2471
+ for (const t of e.tracks) heads[t.id] = [...new Set([...(heads[t.id] ?? []), e.closeId])];
2472
+ }
2473
+ return heads;
2474
+ }
2475
+
2374
2476
  /**
2375
2477
  * Everything about this close's entry that can be decided before a byte is
2376
2478
  * written: track checks, default and explicit `supersedes`, the entry bytes, and
@@ -2405,6 +2507,11 @@ function planSessionEntry(args, payload, project, entryPath, closeId, legacyForm
2405
2507
  }
2406
2508
  }
2407
2509
  const observed = readObservedHeads(args.hypoDir, args.sessionId, project);
2510
+ // Both the default `supersedes` and the `legacy-*` retirement below read `observed.full`.
2511
+ const flatRead = flatReadBaselineHeads(args.hypoDir, args.sessionId, project, model);
2512
+ for (const [id, ids] of Object.entries(flatRead)) {
2513
+ observed.full[id] = [...new Set([...(observed.full[id] ?? []), ...ids])];
2514
+ }
2408
2515
  const entryTracks = [];
2409
2516
  const legacySplitRetired = [];
2410
2517
  const bodies = {};
@@ -4455,10 +4562,9 @@ function runMarkerPhase(args, project, appliedPaths, ok, hostTagWarning, receipt
4455
4562
  ...(closeTranscript ? { transcriptPath: closeTranscript } : {}),
4456
4563
  ...(autoMarkerOverride ? { attributionScope: autoMarkerOverride } : {}),
4457
4564
  // This is one of the two marker-writing paths that must share ONE
4458
- // `ok` invariant with `--mark-session-closed`. checkpointMode narrows the git axis to "is
4459
- // there an uncommitted write THIS session still owns" instead of
4460
- // refusing on any unattributed dirty root file; every other axis
4461
- // (close files, cwd, hot, lint, W8, feedback) is unchanged.
4565
+ // `ok` invariant with `--mark-session-closed`. The git axis asks "is
4566
+ // there an uncommitted write THIS session still owns" for every caller;
4567
+ // checkpointMode only makes an invalid session id a blocker of its own.
4462
4568
  checkpointMode: true,
4463
4569
  sessionId: args.sessionId,
4464
4570
  closeOpen,
@@ -4911,7 +5017,8 @@ function buildCloseResult({
4911
5017
  gateNotices: gateNotices || [],
4912
5018
  // The checkpoint gate's own blockers when it refused the marker
4913
5019
  // (markerSkipReason compact-gate-not-ok); empty otherwise. --check-session-close
4914
- // judges the whole vault on a wider git axis, so it is not a substitute.
5020
+ // runs the same git check but may lack this session's id, in which case it reads
5021
+ // no record and blocks the whole project folder, so it is not a substitute.
4915
5022
  gateBlockers: gateBlockers || [],
4916
5023
  // Always present (possibly empty), same visibility contract as `notices`/
4917
5024
  // `otherDebtCount` above — a caller should not have to guess whether the
@@ -4944,6 +5051,24 @@ function buildCloseResult({
4944
5051
  const DOCTOR_HINT =
4945
5052
  'doctor (`/hypo:doctor` in Claude Code, or `hypomnema doctor` with the npm CLI)';
4946
5053
 
5054
+ // The gate's demotions (what it declined to block on, such as a lint the deadline cut short) in
5055
+ // the text report. JSON carries them as `notices` / `gateNotices`; without this a terminal user
5056
+ // reads a bare success for a close the gate could not fully check.
5057
+ function printGateNotices(notices) {
5058
+ if (!notices || notices.length === 0) return;
5059
+ console.log(' Gate notices (not blockers):');
5060
+ for (const n of notices) console.log(` · ${n.reason}`);
5061
+ }
5062
+
5063
+ // The gate's blockers in the text report, one ✗ line each with its type, its file when it names
5064
+ // one, and its reason. JSON carries them as `gateBlockers`; without these lines the text report
5065
+ // named no blocker at all and sent the reader to --json to find out what to fix.
5066
+ function printGateBlockers(blockers) {
5067
+ if (!blockers || blockers.length === 0) return;
5068
+ console.log(' Gate blockers:');
5069
+ for (const b of blockers) console.log(` ✗ ${b.type}${b.file ? ` ${b.file}` : ''}: ${b.reason}`);
5070
+ }
5071
+
4947
5072
  // The human-readable (non --json) rendering of the same settled state.
4948
5073
  function printCloseReport({
4949
5074
  project,
@@ -4969,6 +5094,8 @@ function printCloseReport({
4969
5094
  restructureWaivers,
4970
5095
  obsoleteNotices,
4971
5096
  hostTagWarning,
5097
+ gateNotices,
5098
+ gateBlockers,
4972
5099
  }) {
4973
5100
  console.log(`Session-close apply (project: ${project}, date: ${date}):`);
4974
5101
  for (const a of applied) console.log(` ✓ wrote ${a}`);
@@ -4979,6 +5106,8 @@ function printCloseReport({
4979
5106
  for (const n of obsoleteNotices || []) console.log(` · ${n}`);
4980
5107
  // What the verification saw but did not gate on (this close has no log lines under its date).
4981
5108
  for (const n of verification?.notices || []) console.log(` · ${n.reason}`);
5109
+ printGateNotices(gateNotices);
5110
+ printGateBlockers(gateBlockers);
4982
5111
  // Surfaced unconditionally, success or failure. `restructure: true` no
4983
5112
  // longer waives the guard: the target below is still WITHHELD
4984
5113
  // and shows up in the conflicts loop right after this one. This line only
@@ -5129,11 +5258,14 @@ function printCloseReport({
5129
5258
  ` refused the per-session Stop-chain marker because a gate blocker\n` +
5130
5259
  ` remains. The session is NOT fully closed: the Stop hook will re-prompt\n` +
5131
5260
  ` until the marker is present. This is not a --session-id problem.\n` +
5132
- ` To fix: resolve the gate blocker (run git status in the vault: commit\n` +
5133
- ` or revert an uncommitted file in the project folder being closed, or\n` +
5134
- ` clear whichever other blocker the gate names), then re-run the same\n` +
5135
- ` close. No fresh close phrase is needed: a close signal is spent only\n` +
5136
- ` once the marker lands.\n`
5261
+ ` To fix: resolve each gate blocker (the ✗ lines above name each blocker,\n` +
5262
+ ` as gateBlockers[] does in --json): the uncommitted file this session\n` +
5263
+ ` recorded writing, or a flagged one: commit or revert it; a project folder\n` +
5264
+ ` blocked as a whole (type git: no record stamp, an incomplete record, or\n` +
5265
+ ` no session id), which its reason says how to clear, since a file in it\n` +
5266
+ ` may be another session's; or the cause of a skipped check. Then re-run\n` +
5267
+ ` the same close. No fresh close phrase is needed: a close signal is spent\n` +
5268
+ ` only once the marker lands.\n`
5137
5269
  : markerSkipReason === 'session-close-lock-timeout'
5138
5270
  ? ` The close files were applied and committed, but another close of this\n` +
5139
5271
  ` session held its close lock past the timeout, so no receipt or marker\n` +
@@ -5794,6 +5926,8 @@ export function applySessionClose(args, testHooks = null) {
5794
5926
  restructureWaivers,
5795
5927
  obsoleteNotices: [...obsoleteNotices, ...notices],
5796
5928
  hostTagWarning: hostTagNotice,
5929
+ gateNotices,
5930
+ gateBlockers,
5797
5931
  });
5798
5932
  }
5799
5933
  process.exit(ok ? 0 : 1);
@@ -41,7 +41,7 @@ export function runSessionCloseCheck(args) {
41
41
  // --project=<slug> narrows BOTH the close status and the lint scope to that one
42
42
  // project: a project-scoped DIAGNOSTIC, NOT the global close verdict.
43
43
  // It is check-only: the marker writers stay global (the marker gate is the
44
- // global gate with only the git axis narrowed by checkpointMode). When narrowed, the
44
+ // same global gate, and its git axis is the same one this check uses). When narrowed, the
45
45
  // transcript widening is suppressed: a transcript touch in some OTHER project
46
46
  // would re-add that project's files to the lint scope and re-block the scoped
47
47
  // check, defeating the point. The global (no --project) check keeps widening.
@@ -41,6 +41,7 @@ import {
41
41
  import { join, dirname, relative, resolve, posix, sep } from 'path';
42
42
  import { homedir } from 'os';
43
43
  import { sha256, isRegularFile, readFileIfRegular, writePkgJsonAtomic } from './pkg-json.mjs';
44
+ import { writeSettingsJson } from './settings-write.mjs';
44
45
  import { loadHypoIgnore, isIgnored } from './hypo-ignore.mjs';
45
46
 
46
47
  const HOME = homedir();
@@ -1914,8 +1915,7 @@ function registerSettings(settingsPath, expectedEntries, apply) {
1914
1915
  const next = JSON.stringify(settings, null, 2) + '\n';
1915
1916
  const changed = next !== original;
1916
1917
  if (changed && apply) {
1917
- mkdirSync(join(settingsPath, '..'), { recursive: true });
1918
- writeFileSync(settingsPath, next);
1918
+ writeSettingsJson(settingsPath, next);
1919
1919
  }
1920
1920
  return { registered, changed, invalidJson: false };
1921
1921
  }