hypomnema 1.6.2 → 1.7.0

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 (62) 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 +7 -1
  11. package/hooks/hypo-auto-minimal-crystallize.mjs +63 -20
  12. package/hooks/hypo-auto-stage.mjs +30 -0
  13. package/hooks/hypo-cwd-change.mjs +31 -2
  14. package/hooks/hypo-file-watch.mjs +21 -2
  15. package/hooks/hypo-first-prompt.mjs +19 -3
  16. package/hooks/hypo-lookup.mjs +86 -29
  17. package/hooks/hypo-personal-check.mjs +24 -3
  18. package/hooks/hypo-session-record.mjs +2 -3
  19. package/hooks/hypo-session-start.mjs +89 -7
  20. package/hooks/hypo-shared.mjs +904 -128
  21. package/hooks/proposal-store.mjs +513 -0
  22. package/package.json +40 -14
  23. package/scripts/capture.mjs +556 -37
  24. package/scripts/crystallize.mjs +639 -108
  25. package/scripts/doctor.mjs +304 -9
  26. package/scripts/feedback-sync.mjs +515 -44
  27. package/scripts/graph.mjs +9 -2
  28. package/scripts/init.mjs +230 -34
  29. package/scripts/lib/extensions.mjs +656 -1
  30. package/scripts/lib/hypo-ignore.mjs +54 -6
  31. package/scripts/lib/hypo-root.mjs +56 -6
  32. package/scripts/lib/page-usage.mjs +15 -2
  33. package/scripts/lib/pkg-json.mjs +40 -0
  34. package/scripts/lib/plugin-detect.mjs +96 -6
  35. package/scripts/lib/wd-match.mjs +23 -5
  36. package/scripts/lib/wikilink.mjs +32 -6
  37. package/scripts/lint.mjs +20 -4
  38. package/scripts/proposal.mjs +1032 -0
  39. package/scripts/query.mjs +25 -4
  40. package/scripts/resume.mjs +34 -12
  41. package/scripts/stats.mjs +28 -8
  42. package/scripts/uninstall.mjs +141 -6
  43. package/scripts/upgrade.mjs +197 -15
  44. package/skills/crystallize/SKILL.md +44 -7
  45. package/skills/debate/SKILL.md +88 -0
  46. package/skills/debate/references/orchestration-patterns.md +83 -0
  47. package/templates/.hyposcanignore +10 -0
  48. package/templates/SCHEMA.md +12 -0
  49. package/templates/gitignore +5 -0
  50. package/templates/hypo-config.md +1 -1
  51. package/templates/hypo-guide.md +6 -0
  52. package/scripts/.gitkeep +0 -0
  53. package/scripts/check-bilingual.mjs +0 -153
  54. package/scripts/check-readme-version.mjs +0 -126
  55. package/scripts/check-tracker-ids.mjs +0 -426
  56. package/scripts/check-versions.mjs +0 -171
  57. package/scripts/install-git-hooks.mjs +0 -293
  58. package/scripts/lib/changelog-classify.mjs +0 -216
  59. package/scripts/lib/check-bilingual.mjs +0 -244
  60. package/scripts/lib/check-tracker-ids.mjs +0 -217
  61. package/scripts/lib/pre-commit-format.mjs +0 -251
  62. package/scripts/pre-commit-format.mjs +0 -198
@@ -12,13 +12,17 @@
12
12
  * --check / --write engine: per-slug managed blocks + sha256 idempotency +
13
13
  * conflict (exit 3) + over-cap (exit 2) [Phase A]. --bootstrap / --import-target-
14
14
  * change: reverse one-time helpers that scaffold pages/feedback/_drafts/ for human
15
- * review — never write pages/feedback/<slug>.md directly [Phase D].
15
+ * review — never write pages/feedback/<slug>.md directly [Phase D]. --ensure-
16
+ * container: provisions the ONE thing --write can never create on its own — the
17
+ * `<learned_behaviors>` container itself is a precondition for --write, not
18
+ * something it can bootstrap into existence (that would mean guessing WHERE in
19
+ * an arbitrary, possibly hand-authored CLAUDE.md to insert it).
16
20
  *
17
21
  * Contract: projects/hypomnema/fix-37-contract.md (per-slug managed block model,
18
22
  * sha256 over normalized inner content, sort key, exit matrix, project-id rule).
19
23
  *
20
24
  * Usage:
21
- * node scripts/feedback-sync.mjs [--check|--write|--bootstrap|--import-target-change --from=<memory|claude>]
25
+ * node scripts/feedback-sync.mjs [--check|--write|--bootstrap|--import-target-change --from=<memory|claude>|--ensure-container]
22
26
  * --hypo-dir=<path> Hypomnema root (default: HYPO_DIR / hypo-config.md / ~/hypomnema)
23
27
  * --claude-home=<path> Claude Code home (default: ~/.claude)
24
28
  * --project-id=<id> Override derived project-id (§5; always wins, no prompt)
@@ -27,6 +31,23 @@
27
31
  * --json Machine-readable output
28
32
  * --dry-run (bootstrap/import) report planned drafts, write nothing
29
33
  *
34
+ * --ensure-container: the remedy for a 'build-failed' target (its
35
+ * `<learned_behaviors>` container is gone, so NOT ONE L1 rule loads and every
36
+ * --write is a silent no-op — see doctor's "Feedback projection" fail and the
37
+ * PreCompact gate's "feedback projection cannot be built" blocker). If
38
+ * `<claude-home>/CLAUDE.md` EXISTS and has no container, appends a short
39
+ * guidance comment plus an empty `<learned_behaviors></learned_behaviors>`
40
+ * pair to the end of the file — the existing bytes are re-written verbatim ahead
41
+ * of the addition, through an ATOMIC tmp+rename (see atomicWrite), so a crash or
42
+ * a full disk mid-write leaves the user's file exactly as it was. If the
43
+ * container is already there, it is a no-op (idempotent, safe to re-run). If
44
+ * the file does not exist at all, it is ALSO a no-op ('target-missing' is the
45
+ * ordinary first-run state, not something to provision here). If the file
46
+ * carries a CORRUPT container (inverted / duplicated / unpaired tags), it
47
+ * REFUSES and says what is wrong: appending a valid pair on top of a broken one
48
+ * produces a file this tool can never repair (the predicate keeps reading "no
49
+ * container", every re-run appends another pair, --write keeps failing).
50
+ *
30
51
  * Project-id fallback (§5 step 4): when the derived project-id directory does not
31
52
  * exist AND stdin is an interactive TTY AND --no-input is not set, prompt the user
32
53
  * to confirm the derived id, enter a different one, or skip MEMORY projection.
@@ -49,8 +70,12 @@ import {
49
70
  mkdirSync,
50
71
  rmSync,
51
72
  realpathSync,
73
+ lstatSync,
74
+ statSync,
75
+ chmodSync,
76
+ renameSync,
52
77
  } from 'node:fs';
53
- import { join, basename, resolve, sep } from 'node:path';
78
+ import { join, basename, dirname, resolve, sep } from 'node:path';
54
79
  import { homedir } from 'node:os';
55
80
  import { createHash } from 'node:crypto';
56
81
  import { pathToFileURL } from 'node:url';
@@ -63,7 +88,7 @@ const HOME = homedir();
63
88
 
64
89
  function parseArgs(argv) {
65
90
  const args = {
66
- mode: 'check', // check | write | bootstrap | import
91
+ mode: 'check', // check | write | bootstrap | import | ensure-container
67
92
  from: null,
68
93
  hypoDir: null,
69
94
  claudeHome: null,
@@ -80,6 +105,7 @@ function parseArgs(argv) {
80
105
  else if (arg === '--write') args.mode = 'write';
81
106
  else if (arg === '--bootstrap') args.mode = 'bootstrap';
82
107
  else if (arg === '--import-target-change') args.mode = 'import';
108
+ else if (arg === '--ensure-container') args.mode = 'ensure-container';
83
109
  else if (arg.startsWith('--from=')) args.from = arg.slice(7);
84
110
  else if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
85
111
  else if (arg.startsWith('--claude-home=')) args.claudeHome = expandHome(arg.slice(14));
@@ -96,6 +122,72 @@ function parseArgs(argv) {
96
122
  return args;
97
123
  }
98
124
 
125
+ // ── atomic write (tmp + rename) ──────────────────────────────────────────────
126
+ //
127
+ // `writeFileSync(file, content)` truncates first and writes second: a crash, a
128
+ // full disk, or a kill between the two leaves the file cut in half. Every file
129
+ // this tool touches is a file it does NOT own — `~/.claude/CLAUDE.md` is
130
+ // hand-authored global config, and --ensure-container advertises "existing
131
+ // content is never touched" while doing a whole-file overwrite. So writes go
132
+ // through tmp+rename: rename(2) is atomic within a filesystem, so the target
133
+ // holds either every old byte or every new byte, never a truncated mix. Same
134
+ // shape as hooks/base-store.mjs and hooks/proposal-store.mjs (kept local rather
135
+ // than imported: those live behind the hooks/ boundary and this file owns its
136
+ // own fs layer).
137
+ //
138
+ // A SYMLINKED target (a dotfile repo that links ~/.claude/CLAUDE.md into a git
139
+ // checkout is a common setup) is resolved with realpathSync FIRST, so the tmp
140
+ // file lands beside the REAL file — same directory, therefore same filesystem,
141
+ // therefore an atomic rename — and the rename replaces the real file instead of
142
+ // clobbering the link itself with a regular file.
143
+ function atomicWrite(file, content) {
144
+ let real = file;
145
+ try {
146
+ real = realpathSync(file);
147
+ } catch {
148
+ // not there yet (a first-write side-file): write it where it was requested
149
+ }
150
+ const dir = dirname(real);
151
+ mkdirSync(dir, { recursive: true });
152
+ // Carry the existing mode across: a bare rename would silently relax a 0600
153
+ // CLAUDE.md to whatever the process umask hands the tmp file.
154
+ let mode = null;
155
+ try {
156
+ mode = statSync(real).mode & 0o777;
157
+ } catch {
158
+ /* new file → default mode */
159
+ }
160
+ const tmp = join(
161
+ dir,
162
+ `.${basename(real)}.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`,
163
+ );
164
+ try {
165
+ writeFileSync(tmp, content);
166
+ if (mode !== null) chmodSync(tmp, mode);
167
+ renameSync(tmp, real);
168
+ } catch (err) {
169
+ try {
170
+ rmSync(tmp, { force: true });
171
+ } catch {
172
+ /* best-effort: an orphan tmp is inert, the original file is intact either way */
173
+ }
174
+ throw err;
175
+ }
176
+ }
177
+
178
+ // Is `file` a symlink whose target does not exist? existsSync FOLLOWS the link,
179
+ // so a dangling one reads as "no file at all" — the ordinary, benign first-run
180
+ // state — and a target that is configured but broken would be waved through with
181
+ // zero rules loaded. lstat sees the link itself.
182
+ function isDanglingSymlink(file) {
183
+ if (existsSync(file)) return false;
184
+ try {
185
+ return lstatSync(file).isSymbolicLink();
186
+ } catch {
187
+ return false; // genuinely absent
188
+ }
189
+ }
190
+
99
191
  // ── frontmatter helpers ──────────────────────────────────────────────────────
100
192
 
101
193
  // parseFrontmatter() flattens list values to their raw "[a, b]" string; split
@@ -287,33 +379,165 @@ function buildRegion(desired) {
287
379
  return desired.map((d) => renderBlock(d.slug, d.inner)).join('\n');
288
380
  }
289
381
 
382
+ // ── the <learned_behaviors> container: ONE judgment, one view ────────────────
383
+
384
+ const LB_OPEN = '<learned_behaviors>';
385
+ const LB_CLOSE = '</learned_behaviors>';
386
+
387
+ // Replace every character of `match` with a space, newlines excepted.
388
+ function blank(match) {
389
+ return match.replace(/[^\n]/g, ' ');
390
+ }
391
+
392
+ // Mask the spans a markdown reader does NOT read as live text — HTML comments,
393
+ // fenced code blocks, inline code spans — so a tag inside one of them cannot be
394
+ // mistaken for the container. Masked characters become spaces and newlines are
395
+ // kept, so the masked view has EXACTLY the same length and line structure as the
396
+ // input: an index found here is a valid index into the original content, which
397
+ // is what lets placement splice on it.
398
+ //
399
+ // Why this exists: a plain indexOf() said "container present" for
400
+ // `<!-- <learned_behaviors></learned_behaviors> -->` and for a pair inside a code
401
+ // fence. --ensure-container then no-op'd while placement wrote the managed region
402
+ // INSIDE the comment/fence, where it is inert — and the hook read that as ordinary
403
+ // drift and kept rewriting it. Inline code matters just as much: a CLAUDE.md that
404
+ // merely MENTIONS `<learned_behaviors>` in prose (this repo's own does) would
405
+ // otherwise read as an unpaired open tag.
406
+ function maskInertSpans(content) {
407
+ const lines = content.replace(/<!--[\s\S]*?-->/g, blank).split('\n');
408
+ let fence = null;
409
+ return lines
410
+ .map((line) => {
411
+ const delim = /^[ \t]*(`{3,}|~{3,})/.exec(line);
412
+ if (fence) {
413
+ if (delim && delim[1][0] === fence.char && delim[1].length >= fence.len) fence = null;
414
+ return blank(line); // every line inside the fence, close delimiter included
415
+ }
416
+ if (delim) {
417
+ fence = { char: delim[1][0], len: delim[1].length };
418
+ return blank(line);
419
+ }
420
+ return line.replace(/`[^`\n]*`/g, blank);
421
+ })
422
+ .join('\n');
423
+ }
424
+
425
+ function indicesOf(text, needle) {
426
+ const out = [];
427
+ for (let i = text.indexOf(needle); i !== -1; i = text.indexOf(needle, i + needle.length)) {
428
+ out.push(i);
429
+ }
430
+ return out;
431
+ }
432
+
433
+ /**
434
+ * The ONE definition of "the container is there", over the masked view above:
435
+ *
436
+ * { state: 'present', open, close } exactly one pair, open BEFORE close
437
+ * { state: 'absent', ... } no tag at all → --ensure-container provisions it
438
+ * { state: 'corrupt', reason, ... } inverted / duplicated / unpaired tags
439
+ *
440
+ * 'corrupt' is its own state because the alternative — folding it into "absent" —
441
+ * built a trap: with `</learned_behaviors>` sitting before `<learned_behaviors>`,
442
+ * a first-occurrence predicate reads false forever, so --ensure-container appends
443
+ * a fresh pair, the inverted tags stay, the predicate STILL reads false, and every
444
+ * --write keeps failing. The tool manufactured a state it could not repair. A
445
+ * corrupt container is damage a human fixes; the tool says so and stops.
446
+ *
447
+ * buildNextContent's placement, --ensure-container's idempotency check and
448
+ * evaluateTarget's out-of-container check all call this, so they cannot drift
449
+ * apart on what counts as a container — and they are wrong together or right
450
+ * together, never half of each.
451
+ */
452
+ function classifyContainer(content) {
453
+ const view = maskInertSpans(content);
454
+ const opens = indicesOf(view, LB_OPEN);
455
+ const closes = indicesOf(view, LB_CLOSE);
456
+ if (!opens.length && !closes.length) return { state: 'absent', open: -1, close: -1 };
457
+ if (opens.length === 1 && closes.length === 1 && opens[0] < closes[0]) {
458
+ return { state: 'present', open: opens[0], close: closes[0] };
459
+ }
460
+ let reason;
461
+ if (!opens.length) reason = `a closing ${LB_CLOSE} with no opening ${LB_OPEN}`;
462
+ else if (!closes.length) reason = `an opening ${LB_OPEN} with no closing ${LB_CLOSE}`;
463
+ else if (opens.length > 1 || closes.length > 1)
464
+ reason = `${opens.length} opening and ${closes.length} closing tags (exactly one pair is required)`;
465
+ else reason = `the closing ${LB_CLOSE} appears BEFORE the opening ${LB_OPEN}`;
466
+ return {
467
+ state: 'corrupt',
468
+ reason,
469
+ open: opens.length ? opens[0] : -1,
470
+ close: closes.length ? closes[0] : -1,
471
+ };
472
+ }
473
+
474
+ // ── remedies (one way out per CAUSE) ─────────────────────────────────────────
475
+ //
476
+ // A blocker that names a command which cannot possibly fix the condition it
477
+ // reports teaches the reader to ignore blockers. `--ensure-container` only ever
478
+ // touches CLAUDE.md's container: it cannot chmod an unreadable file, cannot
479
+ // repoint a dangling symlink, and must not append onto a corrupt container. The
480
+ // remedy is decided ONCE, here, next to the branch that detects the cause, and
481
+ // rides along in the JSON report (`buildErrorRemedy`) so doctor and the PreCompact
482
+ // gate print the same way out instead of each guessing one.
483
+ const REMEDY = {
484
+ containerMissing: (file) =>
485
+ 'Run `hypomnema feedback-sync --ensure-container` to add the missing ' +
486
+ `<learned_behaviors></learned_behaviors> pair to ${file} (existing content is untouched), ` +
487
+ 'then `hypomnema feedback-sync --write`.',
488
+ containerCorrupt: (file, reason) =>
489
+ `Repair ${file} BY HAND so it holds exactly one <learned_behaviors></learned_behaviors> pair ` +
490
+ `with the opening tag first — found ${reason}. \`--ensure-container\` refuses to append here: ` +
491
+ 'a second pair stacked on a broken one leaves the file unprojectable forever.',
492
+ io: (file) =>
493
+ `Fix the permissions on ${file} so this user can read and write it (e.g. \`chmod u+rw\`), then ` +
494
+ 're-run `hypomnema feedback-sync --write`. `--ensure-container` cannot fix a permission bit.',
495
+ danglingSymlink: (file) =>
496
+ `${file} is a symlink whose target does not exist (\`ls -l\` it). Repoint or remove the link, ` +
497
+ 'then re-run `hypomnema feedback-sync --write`.',
498
+ };
499
+
290
500
  // Compute the next file content for a target. Returns { content } on success or
291
- // { error } when the region cannot be placed (e.g. missing container). Pure — no
292
- // disk writes — so run() can preflight every target before any write (atomicity).
501
+ // { error, remedy } when the region cannot be placed (missing / corrupt
502
+ // container). Pure — no disk writes — so run() can preflight every target before
503
+ // any write (atomicity).
293
504
  function buildNextContent(content, region, target) {
294
505
  const { firstStart, lastEnd } = findBlocks(content);
295
- if (firstStart >= 0) {
296
- // replace the contiguous managed span (region may be '' to clear it)
297
- return { content: content.slice(0, firstStart) + region + content.slice(lastEnd) };
298
- }
299
- // no existing blocks
300
- if (region === '') return { content }; // nothing to place → no-op (idempotent)
506
+ // replace the contiguous managed span (region may be '' to clear it)
507
+ const replaceSpan = () => ({
508
+ content: content.slice(0, firstStart) + region + content.slice(lastEnd),
509
+ });
301
510
  if (target.container === 'learned_behaviors') {
302
- const open = content.indexOf('<learned_behaviors>');
303
- const close = content.indexOf('</learned_behaviors>');
304
- if (open < 0 || close < 0 || close < open) {
305
- return { error: `<learned_behaviors> container not found in ${target.file}` };
511
+ const c = classifyContainer(content);
512
+ // Checked BEFORE the existing-blocks fast path: rewriting a managed span
513
+ // inside a file whose container tags are broken just re-lands the region in a
514
+ // structure nothing reads.
515
+ if (c.state === 'corrupt') {
516
+ return {
517
+ error: `<learned_behaviors> container in ${target.file} is corrupt: ${c.reason}`,
518
+ remedy: REMEDY.containerCorrupt(target.file, c.reason),
519
+ };
520
+ }
521
+ if (firstStart >= 0) return replaceSpan();
522
+ if (region === '') return { content }; // nothing to place → no-op (idempotent)
523
+ if (c.state === 'absent') {
524
+ return {
525
+ error: `<learned_behaviors></learned_behaviors> container not found in ${target.file}`,
526
+ remedy: REMEDY.containerMissing(target.file),
527
+ };
306
528
  }
307
529
  // an anchor is honored ONLY when it sits inside the container span
308
530
  const anchorIdx = content.indexOf(MARK_ANCHOR);
309
- if (anchorIdx > open && anchorIdx < close) {
531
+ if (anchorIdx > c.open && anchorIdx < c.close) {
310
532
  return {
311
533
  content:
312
534
  content.slice(0, anchorIdx) + region + content.slice(anchorIdx + MARK_ANCHOR.length),
313
535
  };
314
536
  }
315
- return { content: content.slice(0, close) + region + '\n' + content.slice(close) };
537
+ return { content: content.slice(0, c.close) + region + '\n' + content.slice(c.close) };
316
538
  }
539
+ if (firstStart >= 0) return replaceSpan();
540
+ if (region === '') return { content };
317
541
  // memory index: anchor (anywhere) or append
318
542
  const anchorIdx = content.indexOf(MARK_ANCHOR);
319
543
  if (anchorIdx >= 0) {
@@ -327,6 +551,10 @@ function buildNextContent(content, region, target) {
327
551
 
328
552
  // sync-owned full-copy files (feedback_<slug>.md) no longer backed by an active
329
553
  // candidate → must be removed so deletions/demotions propagate (MEDIUM-1).
554
+ // readdirSync MAY THROW (e.g. EACCES on target.dir) — deliberately NOT caught
555
+ // here. The caller (evaluateTarget) catches it and classifies it exactly like a
556
+ // primary-target read failure ('build-failed'), so a directory permission
557
+ // problem cannot silently masquerade as "no stale side-files to clean up".
330
558
  function staleSideFiles(target, desired) {
331
559
  if (!target.dir || !existsSync(target.dir)) return [];
332
560
  const keep = new Set(desired.map((d) => `feedback_${d.slug}.md`));
@@ -335,7 +563,11 @@ function staleSideFiles(target, desired) {
335
563
  .map((f) => join(target.dir, f))
336
564
  .filter((p) => {
337
565
  // only delete files THIS tool generated (provenance header on line 1) —
338
- // never a user's hand-written feedback_*.md memory file
566
+ // never a user's hand-written feedback_*.md memory file. Unreadable here
567
+ // just means "don't delete it" (a safe, non-destructive default) — this
568
+ // per-file check was already caught before this fix and stays that way;
569
+ // it is the DIRECTORY listing and the PRIMARY target read that used to
570
+ // throw uncaught, not this narrow provenance probe.
339
571
  try {
340
572
  return readFileSync(p, 'utf-8').startsWith(SIDE_MARKER_PREFIX);
341
573
  } catch {
@@ -349,7 +581,24 @@ function staleSideFiles(target, desired) {
349
581
  function evaluateTarget(pages, target) {
350
582
  const desired = computeDesired(pages, target);
351
583
  const fileExists = existsSync(target.file);
352
- const content = fileExists ? readFileSync(target.file, 'utf-8') : '';
584
+ const dangling = isDanglingSymlink(target.file);
585
+ // The FILE existing does not mean it is READABLE. A permission error here
586
+ // (mode 000, an ACL change mid-session, ...) used to throw uncaught: the
587
+ // whole --check/--write run crashed, no JSON report was produced, and every
588
+ // downstream consumer (doctor's "no JSON" branch, the PreCompact gate's
589
+ // unparseable-stdout branch) reads "no report" as "nothing to project" and
590
+ // fails OPEN — reproducing, via an ordinary filesystem error, the exact
591
+ // failure mode ("rules not loaded, gate stays green") this whole projection
592
+ // system exists to prevent. Caught and classified as 'build-failed' instead.
593
+ let content = '';
594
+ let ioError = null;
595
+ if (fileExists) {
596
+ try {
597
+ content = readFileSync(target.file, 'utf-8');
598
+ } catch (err) {
599
+ ioError = `cannot read target file ${target.file}: ${err.message}`;
600
+ }
601
+ }
353
602
  const { blocks } = findBlocks(content);
354
603
  const { starts, ends } = countMarkers(content);
355
604
 
@@ -361,12 +610,16 @@ function evaluateTarget(pages, target) {
361
610
  const intruder = regionHasIntruders(content);
362
611
  // out-of-container: an existing managed block sitting outside <learned_behaviors>
363
612
  // (drifted/hand-moved) — replacing it in place would leave it outside the
364
- // required region, so refuse and require import.
613
+ // required region, so refuse and require import. A CORRUPT container is
614
+ // deliberately NOT reported here: it is a build failure with its own remedy
615
+ // (repair by hand), and routing it through the conflict path would print
616
+ // "run --import-target-change", which reconciles nothing.
365
617
  let outOfContainer = false;
366
- if (target.container === 'learned_behaviors' && blocks.length) {
367
- const open = content.indexOf('<learned_behaviors>');
368
- const close = content.indexOf('</learned_behaviors>');
369
- outOfContainer = open < 0 || close < 0 || blocks.some((b) => b.start < open || b.end > close);
618
+ const container = target.container === 'learned_behaviors' ? classifyContainer(content) : null;
619
+ if (container && container.state !== 'corrupt' && blocks.length) {
620
+ outOfContainer =
621
+ container.state !== 'present' ||
622
+ blocks.some((b) => b.start < container.open || b.end > container.close);
370
623
  }
371
624
 
372
625
  const region = buildRegion(desired);
@@ -378,28 +631,97 @@ function evaluateTarget(pages, target) {
378
631
  overCap = desired.reduce((n, d) => n + d.inner.split('\n').length, 0) > target.cap;
379
632
 
380
633
  // build the next content (validation happens here, before any write)
634
+ //
635
+ // buildErrorKind splits the two very different reasons a build fails, because
636
+ // consumers must treat them differently and the string alone does not say which
637
+ // is which:
638
+ //
639
+ // 'target-missing' The target FILE is not there at all (no ~/.claude/
640
+ // CLAUDE.md yet). That is the ordinary first-run state, so
641
+ // the PreCompact gate and doctor must NOT block on it — a
642
+ // block here would hit every new user (same reasoning that
643
+ // keeps this out of strictWarnings below).
644
+ // 'build-failed' The file EXISTS but cannot be projected into — e.g. its
645
+ // <learned_behaviors> container is gone. Nothing is broken
646
+ // about the user's setup EXCEPT that not one L1 rule loads
647
+ // on this machine, and every sync is a silent no-op. That
648
+ // is a hard failure and must be surfaced as one.
381
649
  let nextContent = null;
382
650
  let buildError = null;
383
- if (!fileExists && target.container) {
651
+ let buildErrorKind = null;
652
+ let buildErrorRemedy = null;
653
+ if (ioError) {
654
+ // The target file exists but could not be read (permissions, a mid-session
655
+ // ACL change, ...). Structurally identical to a missing container: nothing
656
+ // is projected, and the difference from a fresh install matters, so this
657
+ // gets the SAME 'build-failed' kind as a missing container, not
658
+ // 'target-missing' (the file is right there, just unreadable).
659
+ buildError = ioError;
660
+ buildErrorKind = 'build-failed';
661
+ buildErrorRemedy = REMEDY.io(target.file);
662
+ } else if (dangling) {
663
+ // A dangling symlink is NOT 'target-missing'. existsSync follows the link and
664
+ // sees nothing, so this used to be classified as the benign first-run state:
665
+ // the gate stayed green while the configured target was broken and zero rules
666
+ // loaded — and a --write would have replaced the link with a regular file,
667
+ // silently detaching it from whatever it was meant to point at.
668
+ buildError = `target file ${target.file} is a dangling symlink (its link target does not exist)`;
669
+ buildErrorKind = 'build-failed';
670
+ buildErrorRemedy = REMEDY.danglingSymlink(target.file);
671
+ } else if (!fileExists && target.container) {
384
672
  buildError = `target file missing: ${target.file}`;
673
+ buildErrorKind = 'target-missing';
385
674
  } else {
386
675
  const r = buildNextContent(fileExists ? content : '', region, target);
387
- if (r.error) buildError = r.error;
388
- else nextContent = r.content;
676
+ if (r.error) {
677
+ buildError = r.error;
678
+ buildErrorKind = 'build-failed';
679
+ buildErrorRemedy = r.remedy;
680
+ } else nextContent = r.content;
389
681
  }
390
682
 
391
- // side-files: writes (current candidates) + deletes (stale sync-owned copies)
683
+ // side-files: writes (current candidates) + deletes (stale sync-owned copies).
684
+ // An I/O error on a SIDE file (the directory listing, or one feedback_<slug>.md
685
+ // copy) is a WARNING, not a build failure — the split matters:
686
+ //
687
+ // PRIMARY target (CLAUDE.md's container, MEMORY.md's index) unreadable ⇒ the
688
+ // projection cannot be built at all, no rule loads, hard-fail ('build-failed').
689
+ // SIDE file unreadable ⇒ the index line still projects and every rule still
690
+ // loads; one full-copy convenience file is stale. Blocking /compact on that
691
+ // — with a message naming `--ensure-container`, which only ever touches
692
+ // CLAUDE.md and could not chmod a file if it tried — is a gate whose own
693
+ // named remedy cannot open it. Warn, name the path, name the permission fix.
694
+ //
695
+ // They are NOT swallowed (that was the original bug: a silent "nothing to clean
696
+ // up" default) — they ride out in the report as `sideWarnings` and reach the
697
+ // human through doctor and the PreCompact gate's notices.
698
+ const sideWarnings = [];
392
699
  const sideWrites = [];
393
700
  for (const d of desired) {
394
701
  for (const sf of target.sideFiles(d.page)) sideWrites.push(sf);
395
702
  }
396
- const sideDeletes = staleSideFiles(target, desired);
703
+ let sideDeletes = [];
704
+ if (!buildError) {
705
+ try {
706
+ sideDeletes = staleSideFiles(target, desired);
707
+ } catch (err) {
708
+ sideWarnings.push(`cannot read side-file directory ${target.dir}: ${err.message}`);
709
+ }
710
+ }
397
711
 
398
712
  // dirty: main content would change OR any side-file would change/be removed
399
713
  let dirty = nextContent !== null && nextContent !== (fileExists ? content : '');
400
714
  if (sideDeletes.length) dirty = true;
401
715
  for (const sf of sideWrites) {
402
- const cur = existsSync(sf.path) ? readFileSync(sf.path, 'utf-8') : null;
716
+ let cur = null;
717
+ if (existsSync(sf.path)) {
718
+ try {
719
+ cur = readFileSync(sf.path, 'utf-8');
720
+ } catch (err) {
721
+ sideWarnings.push(`cannot read side file ${sf.path}: ${err.message}`);
722
+ cur = null; // unreadable → treat as needing a (re)write, the safe default
723
+ }
724
+ }
403
725
  if (cur !== sf.content) dirty = true;
404
726
  }
405
727
 
@@ -415,28 +737,40 @@ function evaluateTarget(pages, target) {
415
737
  fileExists,
416
738
  nextContent,
417
739
  buildError,
740
+ buildErrorKind,
741
+ buildErrorRemedy,
418
742
  sideWrites,
419
743
  sideDeletes,
744
+ sideWarnings,
420
745
  };
421
746
  }
422
747
 
423
- // Pure writer: applies a fully-validated plan. No validation here.
748
+ // Writer: applies a fully-validated plan. No validation here. Every write is
749
+ // atomic (tmp+rename), so an interrupted run leaves each file at its previous
750
+ // content rather than truncated. Returns the (non-fatal) side-file warnings it
751
+ // collected; a PRIMARY-target write failure is NOT swallowed — it throws, and
752
+ // run() turns it into a reported error with the permission remedy.
424
753
  function applyTarget(target, res) {
754
+ const warnings = [];
425
755
  if (target.dir) mkdirSync(target.dir, { recursive: true });
426
756
  for (const sf of res.sideWrites) {
427
- mkdirSync(join(sf.path, '..'), { recursive: true });
428
- writeFileSync(sf.path, sf.content);
757
+ try {
758
+ atomicWrite(sf.path, sf.content);
759
+ } catch (err) {
760
+ warnings.push(`cannot write side file ${sf.path}: ${err.message}`);
761
+ }
429
762
  }
430
763
  for (const p of res.sideDeletes) {
431
764
  try {
432
765
  rmSync(p);
433
- } catch {
434
- /* best-effort */
766
+ } catch (err) {
767
+ warnings.push(`cannot remove stale side file ${p}: ${err.message}`);
435
768
  }
436
769
  }
437
770
  if (res.nextContent !== null && res.nextContent !== (res.fileExists ? res.content : '')) {
438
- writeFileSync(target.file, res.nextContent);
771
+ atomicWrite(target.file, res.nextContent);
439
772
  }
773
+ return warnings;
440
774
  }
441
775
 
442
776
  // ── project-id derivation ─────────────────────────────────────────────────────
@@ -563,10 +897,12 @@ function oneLineSummary(text, max = 100) {
563
897
  // HYPO:FEEDBACK-SYNC managed block are skipped — those already have a wiki SoT
564
898
  // and are not legacy entries to migrate.
565
899
  function parseLearnedBehaviors(content) {
566
- const open = content.indexOf('<learned_behaviors>');
567
- const close = content.indexOf('</learned_behaviors>');
568
- if (open < 0 || close < 0 || close < open) return [];
569
- const inner = content.slice(open + '<learned_behaviors>'.length, close);
900
+ // Same container judgment placement uses (classifyContainer): a pair quoted in
901
+ // an HTML comment / a code fence / inline code is not a container to read
902
+ // legacy rules out of, and a corrupt one has no unambiguous inner span at all.
903
+ const c = classifyContainer(content);
904
+ if (c.state !== 'present') return [];
905
+ const inner = content.slice(c.open + LB_OPEN.length, c.close);
570
906
  const scrubbed = inner.replace(BLOCK_RE, ''); // blank out already-projected blocks
571
907
  const out = [];
572
908
  for (const line of scrubbed.split('\n')) {
@@ -813,12 +1149,89 @@ function runImport(args) {
813
1149
  return { code: 0, report, warnings };
814
1150
  }
815
1151
 
1152
+ // The provisioning step --write can never do on its own: --write only ever
1153
+ // PLACES a region INSIDE an existing `<learned_behaviors>` container (it has
1154
+ // no way to guess where in an arbitrary, possibly hand-authored CLAUDE.md a
1155
+ // container belongs — that would risk corrupting content it does not own).
1156
+ // When the container is simply gone (deleted by hand, a bad merge, ...),
1157
+ // --write's only recourse is the 'build-failed' error, and a gate that reports
1158
+ // a failure with no way through gets bypassed rather than obeyed — this is the
1159
+ // way through.
1160
+ //
1161
+ // file MISSING entirely → no-op ('target-missing' is the ordinary first-run
1162
+ // state; --ensure-container repairs an EXISTING file, it does not create
1163
+ // one out of nothing).
1164
+ // file is a DANGLING symlink → fail. It is not missing, it is broken, and the
1165
+ // fix is on the link, not here.
1166
+ // file exists, container ALREADY present → no-op (idempotent: safe to run
1167
+ // from a blocker message without checking state first).
1168
+ // file exists, container CORRUPT → fail, and say what is wrong. A blind append
1169
+ // onto inverted/duplicated tags is how this command would manufacture a state
1170
+ // it can never repair: the pair it adds does not fix the broken one, the
1171
+ // predicate still reads "no container", the next run appends ANOTHER pair, and
1172
+ // --write fails forever. Refusing is the only honest move.
1173
+ // file exists, no container → append a short guidance comment plus an empty
1174
+ // `<learned_behaviors></learned_behaviors>` pair to the END of the file. The
1175
+ // existing bytes are re-written verbatim ahead of the addition through
1176
+ // atomicWrite (tmp+rename), so a crash or a full disk mid-write cannot leave
1177
+ // the user's global config truncated — the file is either wholly old or
1178
+ // wholly new. (The previous writeFileSync(file, content + addition) truncated
1179
+ // first and wrote second: the one command that promises "existing content is
1180
+ // never touched" was the one that could shred it.)
1181
+ function runEnsureContainer(args) {
1182
+ const target = claudeTarget(args);
1183
+ const file = target.file;
1184
+ if (!existsSync(file)) {
1185
+ if (isDanglingSymlink(file)) {
1186
+ return {
1187
+ code: 1,
1188
+ error:
1189
+ `${file} is a dangling symlink (its link target does not exist) — nothing to provision. ` +
1190
+ REMEDY.danglingSymlink(file),
1191
+ };
1192
+ }
1193
+ return { code: 0, report: { mode: 'ensure-container', file, action: 'target-missing' } };
1194
+ }
1195
+ let content;
1196
+ try {
1197
+ content = readFileSync(file, 'utf-8');
1198
+ } catch (err) {
1199
+ return { code: 1, error: `cannot read ${file}: ${err.message} — ${REMEDY.io(file)}` };
1200
+ }
1201
+ const c = classifyContainer(content);
1202
+ if (c.state === 'present') {
1203
+ return { code: 0, report: { mode: 'ensure-container', file, action: 'noop-already-present' } };
1204
+ }
1205
+ if (c.state === 'corrupt') {
1206
+ return {
1207
+ code: 1,
1208
+ error:
1209
+ `refusing to append to ${file}: it already carries a corrupt <learned_behaviors> ` +
1210
+ `container — ${c.reason}. ${REMEDY.containerCorrupt(file, c.reason)}`,
1211
+ };
1212
+ }
1213
+ const sep = content.endsWith('\n') ? '' : '\n';
1214
+ const addition =
1215
+ `${sep}\n` +
1216
+ '<!-- Added by `hypomnema feedback-sync --ensure-container`: this pair is the managed\n' +
1217
+ 'region feedback-sync projects wiki-sourced learned behaviors into. Do not hand-edit its\n' +
1218
+ 'contents — a hand-edit becomes a sync conflict. -->\n' +
1219
+ '<learned_behaviors>\n</learned_behaviors>\n';
1220
+ try {
1221
+ atomicWrite(file, content + addition);
1222
+ } catch (err) {
1223
+ return { code: 1, error: `cannot write ${file}: ${err.message} — ${REMEDY.io(file)}` };
1224
+ }
1225
+ return { code: 0, report: { mode: 'ensure-container', file, action: 'created' } };
1226
+ }
1227
+
816
1228
  // ── modes ─────────────────────────────────────────────────────────────────────
817
1229
 
818
1230
  function run(args, resolvedPid = null) {
819
1231
  if (!existsSync(args.hypoDir)) {
820
1232
  return { code: 1, error: `wiki not found: ${args.hypoDir}` };
821
1233
  }
1234
+ if (args.mode === 'ensure-container') return runEnsureContainer(args);
822
1235
  if (args.mode === 'bootstrap') return runBootstrap(args);
823
1236
  if (args.mode === 'import') return runImport(args);
824
1237
 
@@ -888,8 +1301,25 @@ function run(args, resolvedPid = null) {
888
1301
  outOfContainer: res.outOfContainer,
889
1302
  overCap: res.overCap,
890
1303
  dirty: res.dirty,
891
- ...(res.buildError ? { buildError: res.buildError } : {}),
1304
+ // buildErrorKind rides along with buildError so a consumer (doctor, the
1305
+ // PreCompact gate) can tell a first-run missing file from a structurally
1306
+ // broken one WITHOUT re-deriving that judgment by string-matching the
1307
+ // message. The judgment is made once, here, where the branch is taken —
1308
+ // and buildErrorRemedy carries the way OUT for that exact cause, so no
1309
+ // consumer has to guess one (a permission error advised to run
1310
+ // `--ensure-container` is a blocker its own remedy cannot open).
1311
+ ...(res.buildError
1312
+ ? {
1313
+ buildError: res.buildError,
1314
+ buildErrorKind: res.buildErrorKind,
1315
+ ...(res.buildErrorRemedy ? { buildErrorRemedy: res.buildErrorRemedy } : {}),
1316
+ }
1317
+ : {}),
1318
+ // Non-fatal: a side-file I/O problem degrades the projection, it does not
1319
+ // break it. Reported (never swallowed), but it must not block /compact.
1320
+ ...(res.sideWarnings.length ? { sideWarnings: res.sideWarnings } : {}),
892
1321
  };
1322
+ for (const w of res.sideWarnings) warnings.push(`${target.name}: ${w}`);
893
1323
  if (res.conflicts.length || res.intruder || res.unpaired || res.outOfContainer)
894
1324
  code = Math.max(code, 3);
895
1325
  else if (res.overCap) code = Math.max(code, 2);
@@ -906,7 +1336,26 @@ function run(args, resolvedPid = null) {
906
1336
  // so writes stay byte-idempotent.
907
1337
  if (args.mode === 'write' && code === 0 && !strictFail) {
908
1338
  for (const { target, res } of evals) {
909
- if (res.dirty) applyTarget(target, res);
1339
+ if (!res.dirty) continue;
1340
+ try {
1341
+ warnings.push(...applyTarget(target, res).map((w) => `${target.name}: ${w}`));
1342
+ } catch (err) {
1343
+ // A PRIMARY-target write failure (ENOSPC, EACCES, a read-only mount).
1344
+ // Reported, not thrown: an uncaught throw prints no JSON, and every
1345
+ // consumer reads "no report" as "nothing to project" and fails OPEN —
1346
+ // the exact silent-green failure this projection system exists to stop.
1347
+ // The preflight already ruled out every failure it CAN see; an fs error
1348
+ // at write time is the one thing it cannot, so the cross-target atomicity
1349
+ // contract holds up to that point and no further (documented, not fixed
1350
+ // here: a two-phase commit across two independent files is a different
1351
+ // change).
1352
+ return {
1353
+ code: 1,
1354
+ error: `cannot write ${target.file}: ${err.message} — ${REMEDY.io(target.file)}`,
1355
+ report,
1356
+ warnings,
1357
+ };
1358
+ }
910
1359
  }
911
1360
  }
912
1361
 
@@ -922,7 +1371,12 @@ async function main() {
922
1371
  // exactly once. resolveProjectId only touches stdin when stdin.isTTY is truthy
923
1372
  // and --no-input is unset → hooks/CI/pipes never block here.
924
1373
  let resolvedPid = null;
925
- if (existsSync(args.hypoDir) && args.mode !== 'bootstrap' && args.mode !== 'import') {
1374
+ if (
1375
+ existsSync(args.hypoDir) &&
1376
+ args.mode !== 'bootstrap' &&
1377
+ args.mode !== 'import' &&
1378
+ args.mode !== 'ensure-container' // no MEMORY/project-id involvement at all
1379
+ ) {
926
1380
  resolvedPid = await resolveProjectId(args);
927
1381
  }
928
1382
  const out = run(args, resolvedPid);
@@ -931,6 +1385,23 @@ async function main() {
931
1385
  console.log(JSON.stringify(out.error ? { error: out.error } : out.report, null, 2));
932
1386
  } else if (out.error) {
933
1387
  console.error(`[feedback-sync] ${out.error}`);
1388
+ } else if (out.report.mode === 'ensure-container') {
1389
+ const { action, file } = out.report;
1390
+ if (action === 'target-missing') {
1391
+ console.error(
1392
+ `[feedback-sync] ensure-container: ${file} does not exist yet — nothing to provision ` +
1393
+ `(this is the ordinary first-run state, not something --ensure-container creates).`,
1394
+ );
1395
+ } else if (action === 'noop-already-present') {
1396
+ console.error(
1397
+ `[feedback-sync] ensure-container: ${file} already has a <learned_behaviors> container — no changes made.`,
1398
+ );
1399
+ } else {
1400
+ console.error(
1401
+ `[feedback-sync] ensure-container: appended an empty <learned_behaviors></learned_behaviors> ` +
1402
+ `container to ${file} (existing content untouched). Run \`hypomnema feedback-sync --write\` next.`,
1403
+ );
1404
+ }
934
1405
  } else if (out.report.mode === 'bootstrap') {
935
1406
  for (const w of out.warnings || []) console.error(`[feedback-sync] warn: ${w}`);
936
1407
  const verb = out.report.dryRun ? 'would create' : 'created';