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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.ko.md +39 -14
- package/README.md +39 -14
- package/commands/capture.md +8 -6
- package/commands/crystallize.md +39 -20
- package/docs/ARCHITECTURE.md +49 -14
- package/docs/CONTRIBUTING.md +31 -29
- package/hooks/base-store.mjs +265 -0
- package/hooks/hooks.json +7 -1
- package/hooks/hypo-auto-minimal-crystallize.mjs +63 -20
- package/hooks/hypo-auto-stage.mjs +30 -0
- package/hooks/hypo-cwd-change.mjs +31 -2
- package/hooks/hypo-file-watch.mjs +21 -2
- package/hooks/hypo-first-prompt.mjs +19 -3
- package/hooks/hypo-lookup.mjs +86 -29
- package/hooks/hypo-personal-check.mjs +24 -3
- package/hooks/hypo-session-record.mjs +2 -3
- package/hooks/hypo-session-start.mjs +89 -7
- package/hooks/hypo-shared.mjs +904 -128
- package/hooks/proposal-store.mjs +513 -0
- package/package.json +40 -14
- package/scripts/capture.mjs +556 -37
- package/scripts/crystallize.mjs +639 -108
- package/scripts/doctor.mjs +304 -9
- package/scripts/feedback-sync.mjs +515 -44
- package/scripts/graph.mjs +9 -2
- package/scripts/init.mjs +230 -34
- package/scripts/lib/extensions.mjs +656 -1
- package/scripts/lib/hypo-ignore.mjs +54 -6
- package/scripts/lib/hypo-root.mjs +56 -6
- package/scripts/lib/page-usage.mjs +15 -2
- package/scripts/lib/pkg-json.mjs +40 -0
- package/scripts/lib/plugin-detect.mjs +96 -6
- package/scripts/lib/wd-match.mjs +23 -5
- package/scripts/lib/wikilink.mjs +32 -6
- package/scripts/lint.mjs +20 -4
- package/scripts/proposal.mjs +1032 -0
- package/scripts/query.mjs +25 -4
- package/scripts/resume.mjs +34 -12
- package/scripts/stats.mjs +28 -8
- package/scripts/uninstall.mjs +141 -6
- package/scripts/upgrade.mjs +197 -15
- package/skills/crystallize/SKILL.md +44 -7
- package/skills/debate/SKILL.md +88 -0
- package/skills/debate/references/orchestration-patterns.md +83 -0
- package/templates/.hyposcanignore +10 -0
- package/templates/SCHEMA.md +12 -0
- package/templates/gitignore +5 -0
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +6 -0
- package/scripts/.gitkeep +0 -0
- package/scripts/check-bilingual.mjs +0 -153
- package/scripts/check-readme-version.mjs +0 -126
- package/scripts/check-tracker-ids.mjs +0 -426
- package/scripts/check-versions.mjs +0 -171
- package/scripts/install-git-hooks.mjs +0 -293
- package/scripts/lib/changelog-classify.mjs +0 -216
- package/scripts/lib/check-bilingual.mjs +0 -244
- package/scripts/lib/check-tracker-ids.mjs +0 -217
- package/scripts/lib/pre-commit-format.mjs +0 -251
- 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 (
|
|
292
|
-
// disk writes — so run() can preflight every target before
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
-
|
|
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)
|
|
388
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
428
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
const
|
|
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
|
-
|
|
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)
|
|
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 (
|
|
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';
|