hypomnema 1.9.0 → 1.9.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.
@@ -584,7 +584,11 @@ export function gitDirtyFiles(dir = HYPO_DIR, opts = {}) {
584
584
  t1 === undefined ? { encoding: 'utf-8' } : { encoding: 'utf-8', timeout: t1 },
585
585
  );
586
586
  if (prefixRes.error || prefixRes.status !== 0) return []; // can't resolve the repo → cannot attribute
587
- const prefix = (prefixRes.stdout || '').trim();
587
+ // Only the line ending is git's framing (LF, or CRLF where git writes one). The
588
+ // prefix is a real path segment, so a leading or trailing space in a parent
589
+ // directory name belongs to it; trim() would make every path under that vault
590
+ // read as foreign.
591
+ const prefix = (prefixRes.stdout || '').replace(/\r?\n$/, '');
588
592
 
589
593
  const t2 = remainingSpawnTimeoutMs(deadline);
590
594
  if (t2 === 0) return [];
@@ -813,11 +817,11 @@ export function sessionLogScopePath(hypoDir, project, date) {
813
817
  * the project slug from anything that follows by whitespace, a colon, or
814
818
  * end-of-line, so the lookahead correctly rejects "session | foo-bar" when
815
819
  * looking for "foo". Both delimiters are canonical: the derive path
816
- * (rootLogEntry) emits `<project> — <title>` (space), while the dominant
817
- * hand-written convention is `<project>: <title>` (colon, since the tone rule
818
- * banned the em dash). The colon must be accepted: without it, a close whose
819
- * only log.md evidence used the colon form was misread as "stale" and blocked
820
- * non-deterministically. (Was a pre-existing boundary bug in
820
+ * (rootLogEntry) now emits `<project>: <title>` (colon, since the tone rule
821
+ * banned the em dash), and log.md files written by earlier versions carry
822
+ * `<project>` + space + em dash + `<title>`, which must keep matching. The colon must be
823
+ * accepted: without it, a close whose only log.md evidence used the colon form
824
+ * was misread as "stale" and blocked non-deterministically. (Was a pre-existing boundary bug in
821
825
  * sessionCloseFileStatus that the helper extraction inherited.)
822
826
  */
823
827
  export function hasLogEntry(content, date, project) {
@@ -1789,15 +1793,14 @@ function backUpOnce(hotPath, content, testHooks) {
1789
1793
  * one must never proceed once this step has been skipped by a thrown error.
1790
1794
  * @returns {boolean} true when the file was actually changed
1791
1795
  */
1792
- function ensureVaultGitignorePattern(hypoDir, pattern) {
1796
+ export function ensureVaultGitignorePattern(hypoDir, pattern) {
1793
1797
  const path = join(hypoDir, '.gitignore');
1794
1798
  const content = existsSync(path) ? readFileSync(path, 'utf-8') : '';
1795
1799
  if (content.split('\n').some((l) => l.trim() === pattern)) return false;
1796
- const next =
1797
- content.length > 0 && !content.endsWith('\n')
1798
- ? `${content}\n${pattern}\n`
1799
- : `${content}${pattern}\n`;
1800
- atomicWrite(path, next);
1800
+ // An append, not a rewrite: the file may be a symlink into a dotfiles repo, have a mode of
1801
+ // its own, or be saved by an editor at this moment; a replace would change the first two
1802
+ // and could drop the third.
1803
+ appendFileSync(path, `${content.length > 0 && !content.endsWith('\n') ? '\n' : ''}${pattern}\n`);
1801
1804
  return true;
1802
1805
  }
1803
1806
 
@@ -2908,10 +2911,21 @@ function deriveLogTitle(tail) {
2908
2911
  */
2909
2912
  export function rootLogEntry(slug, date, headingTail) {
2910
2913
  const title = deriveLogTitle(headingTail);
2911
- const heading = `## [${date}] session | ${slug}` + (title ? ` — ${title}` : '');
2914
+ const heading = `## [${date}] session | ${slug}` + (title ? `: ${title}` : '');
2912
2915
  return { heading, block: `${heading}\n→ [[projects/${slug}/hot]]` };
2913
2916
  }
2914
2917
 
2918
+ /**
2919
+ * The form of a log.md line that rootLogEntry's exact-line dedup compares. Earlier
2920
+ * versions wrote an em dash after the slug; this one writes `## [d] session | slug: title`. A
2921
+ * log.md that already carries the old heading must count as having the entry, or
2922
+ * the next derive appends the same session a second time. Only the separator right
2923
+ * after the slug is rewritten, so every other line is returned as it came.
2924
+ */
2925
+ export function rootLogHeadingKey(line) {
2926
+ return String(line).replace(/^(## \[[^\]]*\] session \| [^\s:]+) — /, '$1: ');
2927
+ }
2928
+
2915
2929
  /**
2916
2930
  * Append any missing root log.md `## [date] session | <slug>` entries derived from
2917
2931
  * each today-active project's session-log heading(s). Idempotent: dedups on the
@@ -3194,9 +3208,10 @@ export function deriveRootLogEntries(hypoDir) {
3194
3208
  }
3195
3209
 
3196
3210
  // Exact-LINE dedup: a titleless heading (`## [d] session | a`) is a substring/
3197
- // prefix of a titled one (`## [d] session | a — first`), so substring checks
3198
- // would wrongly drop a distinct same-day session. Track whole heading lines.
3199
- const seenHeadings = new Set((logContent || '').split(/\r?\n/));
3211
+ // prefix of a titled one (`## [d] session | a: first`), so substring checks
3212
+ // would wrongly drop a distinct same-day session. Track whole heading lines,
3213
+ // with an old em dash heading read as its colon form.
3214
+ const seenHeadings = new Set((logContent || '').split(/\r?\n/).map(rootLogHeadingKey));
3200
3215
  const additions = [];
3201
3216
  for (const slug of todayActive) {
3202
3217
  // Guard: only the log.md entry may be the gap; an otherwise-incomplete close
@@ -3256,7 +3271,7 @@ export function deriveRootLogEntries(hypoDir) {
3256
3271
  } catch {
3257
3272
  return 0;
3258
3273
  }
3259
- const seen = new Set((current || '').split(/\r?\n/));
3274
+ const seen = new Set((current || '').split(/\r?\n/).map(rootLogHeadingKey));
3260
3275
  const fresh = additions.filter(({ heading }) => {
3261
3276
  if (seen.has(heading)) return false;
3262
3277
  seen.add(heading);
@@ -4280,7 +4295,7 @@ export function commitWikiChanges(hypoDir, paths) {
4280
4295
  const prefixRes = git('rev-parse', '--show-prefix');
4281
4296
  if (prefixRes.status !== 0)
4282
4297
  return { committed: false, reason: `git rev-parse --show-prefix failed in ${hypoDir}` };
4283
- const prefix = (prefixRes.stdout || '').replace(/\n$/, '');
4298
+ const prefix = (prefixRes.stdout || '').replace(/\r?\n$/, '');
4284
4299
  const toVaultRelative = (f) => {
4285
4300
  if (!f || !prefix) return f || null;
4286
4301
  return f.startsWith(prefix) ? f.slice(prefix.length) : null;
@@ -6125,9 +6140,11 @@ export function precompactGateStatus(hypoDir, opts = {}) {
6125
6140
  );
6126
6141
  if (r.error || r.status === null) {
6127
6142
  skipped.feedback = true; // spawn failure → fail-open
6128
- } else if (r.status !== 0) {
6129
- // Only a non-zero exit carries an actionable issue. A clean check exits 0
6130
- // (the implicit else below) — that is NOT skipped, just nothing to do.
6143
+ } else {
6144
+ // A non-zero exit carries an actionable issue. A clean check exits 0 and is NOT
6145
+ // skipped, just nothing to do, except that its report can still carry
6146
+ // sideWarnings (a hand-written feedback_<slug>.md in the way): those exit 0, so
6147
+ // the report is read for them too.
6131
6148
  let report = null;
6132
6149
  try {
6133
6150
  report = JSON.parse(r.stdout || '');
@@ -6138,7 +6155,11 @@ export function precompactGateStatus(hypoDir, opts = {}) {
6138
6155
  const conflictedT = entries
6139
6156
  .filter(
6140
6157
  ([, t]) =>
6141
- t.intruder || t.unpaired || t.outOfContainer || (t.conflicts && t.conflicts.length),
6158
+ t.intruder ||
6159
+ t.unpaired ||
6160
+ t.outOfContainer ||
6161
+ (t.conflicts && t.conflicts.length) ||
6162
+ (t.sideEdited && t.sideEdited.length),
6142
6163
  )
6143
6164
  .map(([n]) => n);
6144
6165
  const overCapT = entries.filter(([, t]) => t.overCap).map(([n]) => n);
@@ -6165,14 +6186,15 @@ export function precompactGateStatus(hypoDir, opts = {}) {
6165
6186
  // gets bypassed.
6166
6187
  const sideWarnT = entries.filter(([, t]) => (t.sideWarnings || []).length);
6167
6188
  if (
6168
- !report ||
6169
- !(
6170
- conflictedT.length ||
6171
- overCapT.length ||
6172
- driftedT.length ||
6173
- buildErrT.length ||
6174
- sideWarnT.length
6175
- )
6189
+ r.status !== 0 &&
6190
+ (!report ||
6191
+ !(
6192
+ conflictedT.length ||
6193
+ overCapT.length ||
6194
+ driftedT.length ||
6195
+ buildErrT.length ||
6196
+ sideWarnT.length
6197
+ ))
6176
6198
  ) {
6177
6199
  skipped.feedback = true; // unparseable / non-actionable → fail-open
6178
6200
  } else if (buildErrT.length) {
@@ -6193,9 +6215,30 @@ export function precompactGateStatus(hypoDir, opts = {}) {
6193
6215
  reason: `feedback projection cannot be built — ${details} — no rules are loaded from it. ${remedies}`,
6194
6216
  });
6195
6217
  } else if (conflictedT.length) {
6218
+ // The remedy text comes from the report (feedback-sync names the real
6219
+ // slugs and leaves out --accept-wiki for an unpaired file). A report from an
6220
+ // older installed script has none, and this hook cannot tell from the report
6221
+ // which of its shapes is the right way out (the import command it used to
6222
+ // fall back to does nothing for an intruder-only target), so that target gets
6223
+ // a generic pointer and no state-specific command.
6224
+ const remedies = [
6225
+ ...new Set(
6226
+ entries
6227
+ .filter(([n]) => conflictedT.includes(n))
6228
+ .map(([, t]) =>
6229
+ typeof t.conflictRemedy === 'string' && t.conflictRemedy
6230
+ ? t.conflictRemedy
6231
+ : 'the installed feedback-sync sent no remedy for this target (its version differs from this hook): run `hypomnema feedback-sync --check` for the details, and update Hypomnema so the hook and the script match.',
6232
+ ),
6233
+ ),
6234
+ ].join(' ');
6196
6235
  blockers.push({
6197
6236
  type: 'feedback',
6198
- reason: `feedback projection conflict (manual edit of ${conflictedT.join(', ')}) — run \`hypomnema feedback-sync --import-target-change --from=<memory|claude>\``,
6237
+ // reasons are rendered with an appended period and joined with ', ', so
6238
+ // the full-sentence remedy loses its own final period here (CLI and doctor keep it)
6239
+ reason:
6240
+ `feedback projection conflict (manual edit of ${conflictedT.join(', ')}): ` +
6241
+ remedies.replace(/\.$/, ''),
6199
6242
  });
6200
6243
  } else if (overCapT.length) {
6201
6244
  blockers.push({
@@ -6209,14 +6252,26 @@ export function precompactGateStatus(hypoDir, opts = {}) {
6209
6252
  reason: `feedback projection drift (${driftedT.join(', ')}) — will self-heal at /compact`,
6210
6253
  });
6211
6254
  }
6212
- // Additive, and deliberately outside the chain above: a side-file I/O
6213
- // problem is orthogonal to the primary target's health, so it is a notice
6214
- // whatever else is (or is not) going on. It names the path and the
6215
- // permission fix, because that — not a command — is the way out.
6255
+ // Additive, and deliberately outside the chain above: a side-file problem
6256
+ // is orthogonal to the primary target's health, so it is a notice whatever
6257
+ // else is (or is not) going on. Two kinds reach here. "cannot read side
6258
+ // file" is an I/O error whose only way out is a permission bit on that
6259
+ // path (`--ensure-container` cannot touch it), so only that kind carries
6260
+ // the permissions advice. "not overwriting ..." is feedback-sync declining
6261
+ // to replace a hand-written file that lacks its provenance header; the
6262
+ // warning text already says to rename or delete it, and calling it
6263
+ // unreadable or pointing at permissions would be wrong. The reason has no
6264
+ // trailing period: the hook that renders it appends one.
6216
6265
  for (const [n, t] of sideWarnT) {
6266
+ const unreadable = t.sideWarnings.some((w) => w.startsWith('cannot read side file'));
6217
6267
  notices.push({
6218
6268
  type: 'feedback',
6219
- reason: `feedback projection side file unreadable (${n}): ${t.sideWarnings.join('; ')} — fix the permissions on that path; the primary projection still loads every rule (\`--ensure-container\` does not fix this)`,
6269
+ reason:
6270
+ `feedback projection side file warning (${n}): ${t.sideWarnings.join('; ')}. ` +
6271
+ 'The primary projection still loads every rule' +
6272
+ (unreadable
6273
+ ? '. Fix the permissions on that path; --ensure-container does not fix this'
6274
+ : ''),
6220
6275
  });
6221
6276
  }
6222
6277
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hypomnema",
3
- "version": "1.9.0",
3
+ "version": "1.9.1",
4
4
  "description": "LLM-native personal wiki system for Claude Code",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -27,6 +27,7 @@
27
27
  "scripts/upgrade.mjs",
28
28
  "scripts/verify.mjs",
29
29
  "scripts/weekly-report.mjs",
30
+ "scripts/lib/code-fence.mjs",
30
31
  "scripts/lib/core-hooks.mjs",
31
32
  "scripts/lib/crystallize-args.mjs",
32
33
  "scripts/lib/crystallize-close-apply.mjs",
@@ -15,9 +15,9 @@
15
15
  * --check-session-close Verify the strict session-close memory files: 4 mandatory + open-questions conditional
16
16
  * --project=<slug> Override the recency-inferred project on --check / --mark (single segment
17
17
  * [A-Za-z0-9._-]+, projects/<slug>/ must exist). On --check it NARROWS the
18
- * gate to that one project — a project-scoped diagnostic, NOT a global
19
- * compact-ready verdict. On --mark it is ATTRIBUTION only; the gate stays
20
- * global (the marker == compact-ready invariant). Ignored on --apply.
18
+ * gate to that one project: a project-scoped diagnostic, NOT a global
19
+ * close verdict. On --mark it is ATTRIBUTION only; the gate stays
20
+ * global (the marker gate is the global gate with the git axis narrowed). Ignored on --apply.
21
21
  * --apply-session-close Apply a JSON payload that updates the 4 mandatory memory files
22
22
  * (+ optional open-questions). Idempotent — re-running with the same
23
23
  * payload is a no-op. Always finishes with the strict gate check.
@@ -2170,7 +2170,12 @@ function checkFeedbackProjection(hypoDir, claudeHome, projectId) {
2170
2170
 
2171
2171
  // 1) integrity violations → FAIL
2172
2172
  const broken = targets.find(
2173
- ([, t]) => (t.conflicts && t.conflicts.length) || t.unpaired || t.intruder || t.outOfContainer,
2173
+ ([, t]) =>
2174
+ (t.conflicts && t.conflicts.length) ||
2175
+ t.unpaired ||
2176
+ t.intruder ||
2177
+ t.outOfContainer ||
2178
+ (t.sideEdited && t.sideEdited.length),
2174
2179
  );
2175
2180
  if (broken) {
2176
2181
  const [name, t] = broken;
@@ -2181,10 +2186,19 @@ function checkFeedbackProjection(hypoDir, claudeHome, projectId) {
2181
2186
  ? 'unpaired managed marker'
2182
2187
  : t.intruder
2183
2188
  ? 'hand-edited line inside managed region'
2184
- : 'managed block outside its container';
2189
+ : t.outOfContainer
2190
+ ? 'managed block outside its container'
2191
+ : `side file edited after it was generated (${t.sideEdited.join(', ')})`;
2185
2192
  fail(
2186
2193
  'Feedback projection integrity',
2187
- `${name}: ${reason} — run \`hypomnema feedback-sync --import-target-change\` to reconcile`,
2194
+ // feedback-sync decides the remedy by shape (import and accept for a
2195
+ // conflicting block, move the lines for an intruder, repair for unpaired
2196
+ // markers). An older installed script that does not send one gets a generic
2197
+ // pointer: the import command it used to get is only right when a block is in
2198
+ // conflict.
2199
+ `${name}: ${reason}. ` +
2200
+ (t.conflictRemedy ||
2201
+ 'The installed feedback-sync sent no remedy for this target (its version differs from this doctor): run `hypomnema feedback-sync --check` for the details, and update Hypomnema so the script matches.'),
2188
2202
  );
2189
2203
  } else {
2190
2204
  // 2) build error. Split by kind:
@@ -2245,10 +2259,14 @@ function checkFeedbackProjection(hypoDir, claudeHome, projectId) {
2245
2259
  // touch. feedback-sync already put the path in the message.
2246
2260
  for (const [name, t] of targets) {
2247
2261
  for (const w of t.sideWarnings || []) {
2262
+ // The advice follows the kind, as in the PreCompact gate: only an unreadable file
2263
+ // is a permission problem; "not overwriting ..." already says to rename or delete.
2248
2264
  warn(
2249
2265
  'Feedback projection side file',
2250
- `${name}: ${w} — fix the permissions on that path; the primary projection still loads ` +
2251
- `(\`--ensure-container\` does not fix this)`,
2266
+ `${name}: ${w}. ` +
2267
+ (w.startsWith('cannot read side file')
2268
+ ? 'fix the permissions on that path; the primary projection still loads (`--ensure-container` does not fix this)'
2269
+ : 'the primary projection still loads every rule'),
2252
2270
  );
2253
2271
  }
2254
2272
  }