hypomnema 1.0.1 → 1.2.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 (76) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.ko.md +12 -5
  4. package/README.md +12 -5
  5. package/commands/audit.md +46 -0
  6. package/commands/crystallize.md +113 -23
  7. package/commands/feedback.md +40 -26
  8. package/commands/ingest.md +31 -9
  9. package/commands/upgrade.md +2 -2
  10. package/docs/ARCHITECTURE.md +83 -9
  11. package/docs/CONTRIBUTING.md +2 -2
  12. package/hooks/hooks.json +39 -1
  13. package/hooks/hypo-auto-commit.mjs +23 -4
  14. package/hooks/hypo-auto-minimal-crystallize.mjs +145 -0
  15. package/hooks/hypo-auto-stage.mjs +9 -5
  16. package/hooks/hypo-compact-guard.mjs +33 -24
  17. package/hooks/hypo-cwd-change.mjs +107 -24
  18. package/hooks/hypo-file-watch.mjs +23 -10
  19. package/hooks/hypo-first-prompt.mjs +37 -23
  20. package/hooks/hypo-hot-rebuild.mjs +31 -8
  21. package/hooks/hypo-lookup.mjs +171 -65
  22. package/hooks/hypo-personal-check.mjs +207 -112
  23. package/hooks/hypo-pre-commit.mjs +46 -0
  24. package/hooks/hypo-session-end.mjs +58 -0
  25. package/hooks/hypo-session-record.mjs +60 -0
  26. package/hooks/hypo-session-start.mjs +312 -44
  27. package/hooks/hypo-shared.mjs +880 -28
  28. package/hooks/hypo-web-fetch-ingest.mjs +121 -0
  29. package/hooks/version-check-fetch.mjs +74 -0
  30. package/hooks/version-check.mjs +184 -0
  31. package/package.json +17 -3
  32. package/scripts/crystallize.mjs +623 -18
  33. package/scripts/doctor.mjs +739 -46
  34. package/scripts/feedback-sync.mjs +974 -0
  35. package/scripts/feedback.mjs +253 -44
  36. package/scripts/graph.mjs +35 -22
  37. package/scripts/ingest.mjs +89 -16
  38. package/scripts/init.mjs +442 -114
  39. package/scripts/lib/design-history-stale.mjs +83 -0
  40. package/scripts/lib/extensions.mjs +749 -0
  41. package/scripts/lib/frontmatter.mjs +5 -1
  42. package/scripts/lib/hypo-ignore.mjs +12 -10
  43. package/scripts/lib/pkg-json.mjs +23 -5
  44. package/scripts/lib/project-create.mjs +225 -0
  45. package/scripts/lib/schema-vocab.mjs +96 -0
  46. package/scripts/lint.mjs +238 -31
  47. package/scripts/query.mjs +26 -10
  48. package/scripts/resume.mjs +11 -5
  49. package/scripts/session-audit.mjs +277 -0
  50. package/scripts/smoke-pack.mjs +224 -0
  51. package/scripts/stats.mjs +24 -10
  52. package/scripts/uninstall.mjs +369 -48
  53. package/scripts/upgrade.mjs +766 -195
  54. package/scripts/verify.mjs +24 -14
  55. package/scripts/weekly-report.mjs +211 -0
  56. package/skills/crystallize/SKILL.md +24 -7
  57. package/skills/graph/SKILL.md +4 -0
  58. package/skills/ingest/SKILL.md +29 -5
  59. package/skills/lint/SKILL.md +4 -0
  60. package/skills/query/SKILL.md +4 -0
  61. package/skills/verify/SKILL.md +4 -0
  62. package/templates/.hypoignore +19 -2
  63. package/templates/Home.md +2 -0
  64. package/templates/SCHEMA.md +61 -6
  65. package/templates/extensions/agents/.gitkeep +0 -0
  66. package/templates/extensions/commands/.gitkeep +0 -0
  67. package/templates/extensions/hooks/.gitkeep +0 -0
  68. package/templates/extensions/skills/.gitkeep +0 -0
  69. package/templates/gitignore +5 -0
  70. package/templates/hot.md +2 -0
  71. package/templates/hypo-config.md +1 -1
  72. package/templates/hypo-guide.md +63 -1
  73. package/templates/hypo-help.md +1 -1
  74. package/templates/pages/observability/_index.md +77 -0
  75. package/templates/projects/_template/index.md +2 -2
  76. package/templates/projects/_template/prd.md +1 -1
@@ -0,0 +1,974 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * feedback-sync.mjs — project wiki feedback as SoT, external memory as projection (ADR 0031, fix #37)
4
+ *
5
+ * Wiki `pages/feedback/<slug>.md` is the single source of truth for
6
+ * learning/correction knowledge. Two Claude Code memory surfaces are derived
7
+ * (one-way) from it:
8
+ * - Project memory: <claude-home>/projects/<project-id>/memory/feedback_<slug>.md
9
+ * + MEMORY.md index (managed region)
10
+ * - Global learned: <claude-home>/CLAUDE.md <learned_behaviors> managed region
11
+ *
12
+ * --check / --write engine: per-slug managed blocks + sha256 idempotency +
13
+ * conflict (exit 3) + over-cap (exit 2) [Phase A]. --bootstrap / --import-target-
14
+ * change: reverse one-time helpers that scaffold pages/feedback/_drafts/ for human
15
+ * review — never write pages/feedback/<slug>.md directly [Phase D].
16
+ *
17
+ * Contract: projects/hypomnema/fix-37-contract.md (per-slug managed block model,
18
+ * sha256 over normalized inner content, sort key, exit matrix, project-id rule).
19
+ *
20
+ * Usage:
21
+ * node scripts/feedback-sync.mjs [--check|--write|--bootstrap|--import-target-change --from=<memory|claude>]
22
+ * --hypo-dir=<path> Hypomnema root (default: HYPO_DIR / hypo-config.md / ~/hypomnema)
23
+ * --claude-home=<path> Claude Code home (default: ~/.claude)
24
+ * --project-id=<id> Override derived project-id (§5; always wins, no prompt)
25
+ * --no-input Never prompt; treat unresolved project-id non-interactively
26
+ * --strict Promote warnings to failures (PreCompact gate)
27
+ * --json Machine-readable output
28
+ * --dry-run (bootstrap/import) report planned drafts, write nothing
29
+ *
30
+ * Project-id fallback (§5 step 4): when the derived project-id directory does not
31
+ * exist AND stdin is an interactive TTY AND --no-input is not set, prompt the user
32
+ * to confirm the derived id, enter a different one, or skip MEMORY projection.
33
+ * Non-interactive runs (hooks, CI, pipes — no TTY) NEVER prompt: they keep the
34
+ * existing behavior (warn + skip MEMORY, exit 0). feedback-sync runs inside the
35
+ * PreCompact hook non-interactively and must never block waiting on input.
36
+ *
37
+ * Exit codes:
38
+ * 0 clean / write succeeded
39
+ * 1 drift detected (--check) OR generic error (usage / wiki not found)
40
+ * 2 over-cap (CLAUDE 10 entries / MEMORY 200 index lines) — human decision required
41
+ * 3 conflict (managed block hash mismatch = manual edit) — no auto-merge
42
+ */
43
+
44
+ import {
45
+ existsSync,
46
+ readFileSync,
47
+ writeFileSync,
48
+ readdirSync,
49
+ mkdirSync,
50
+ rmSync,
51
+ realpathSync,
52
+ } from 'node:fs';
53
+ import { join, basename, resolve, sep } from 'node:path';
54
+ import { homedir } from 'node:os';
55
+ import { createHash } from 'node:crypto';
56
+ import { pathToFileURL } from 'node:url';
57
+ import { parseFrontmatter } from './lib/frontmatter.mjs';
58
+ import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
59
+
60
+ const HOME = homedir();
61
+
62
+ // ── arg parsing ────────────────────────────────────────────────────────────
63
+
64
+ function parseArgs(argv) {
65
+ const args = {
66
+ mode: 'check', // check | write | bootstrap | import
67
+ from: null,
68
+ hypoDir: null,
69
+ claudeHome: null,
70
+ projectId: null,
71
+ noInput: false,
72
+ skipMemory: false,
73
+ strict: false,
74
+ json: false,
75
+ dryRun: false,
76
+ cwd: process.cwd(),
77
+ };
78
+ for (const arg of argv.slice(2)) {
79
+ if (arg === '--check') args.mode = 'check';
80
+ else if (arg === '--write') args.mode = 'write';
81
+ else if (arg === '--bootstrap') args.mode = 'bootstrap';
82
+ else if (arg === '--import-target-change') args.mode = 'import';
83
+ else if (arg.startsWith('--from=')) args.from = arg.slice(7);
84
+ else if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
85
+ else if (arg.startsWith('--claude-home=')) args.claudeHome = expandHome(arg.slice(14));
86
+ else if (arg.startsWith('--project-id=')) args.projectId = arg.slice(13);
87
+ else if (arg.startsWith('--cwd='))
88
+ args.cwd = expandHome(arg.slice(6)); // test hook
89
+ else if (arg === '--no-input') args.noInput = true;
90
+ else if (arg === '--strict') args.strict = true;
91
+ else if (arg === '--json') args.json = true;
92
+ else if (arg === '--dry-run') args.dryRun = true;
93
+ }
94
+ if (!args.hypoDir) args.hypoDir = resolveHypoRoot();
95
+ if (!args.claudeHome) args.claudeHome = join(HOME, '.claude');
96
+ return args;
97
+ }
98
+
99
+ // ── frontmatter helpers ──────────────────────────────────────────────────────
100
+
101
+ // parseFrontmatter() flattens list values to their raw "[a, b]" string; split
102
+ // them back into arrays here (same shape lint.mjs uses for tags).
103
+ function parseListField(raw) {
104
+ if (!raw) return [];
105
+ const trimmed = String(raw)
106
+ .trim()
107
+ .replace(/^\[|\]$/g, '');
108
+ return trimmed
109
+ .split(',')
110
+ .map((t) => t.trim().replace(/^["']|["']$/g, ''))
111
+ .filter(Boolean);
112
+ }
113
+
114
+ function loadFeedbackPages(hypoDir) {
115
+ const dir = join(hypoDir, 'pages', 'feedback');
116
+ if (!existsSync(dir)) return [];
117
+ const pages = [];
118
+ for (const entry of readdirSync(dir)) {
119
+ if (!entry.endsWith('.md') || entry.startsWith('.') || entry.startsWith('_')) continue;
120
+ const path = join(dir, entry);
121
+ const content = readFileSync(path, 'utf-8');
122
+ const fm = parseFrontmatter(content) || {};
123
+ pages.push({
124
+ slug: basename(entry, '.md'),
125
+ fm,
126
+ targets: parseListField(fm.targets),
127
+ content,
128
+ path,
129
+ });
130
+ }
131
+ return pages;
132
+ }
133
+
134
+ // ── managed block primitives (contract §1/§2) ────────────────────────────────
135
+
136
+ const MARK_START = (slug, hash) =>
137
+ `<!-- HYPO:FEEDBACK-SYNC:START source=${slug} sha256=${hash} -->`;
138
+ const MARK_END = '<!-- HYPO:FEEDBACK-SYNC:END -->';
139
+ const MARK_ANCHOR = '<!-- HYPO:FEEDBACK-SYNC:ANCHOR -->';
140
+ // provenance header stamped on generated full-copy side-files so staleSideFiles
141
+ // only ever deletes files this tool wrote (never a user's hand-written memory)
142
+ const SIDE_MARKER = (slug) => `<!-- HYPO:FEEDBACK-SYNC source=${slug} -->`;
143
+ const SIDE_MARKER_PREFIX = '<!-- HYPO:FEEDBACK-SYNC source=';
144
+ const BLOCK_RE =
145
+ /<!-- HYPO:FEEDBACK-SYNC:START source=(\S+) sha256=([0-9a-f]{64}) -->\r?\n([\s\S]*?)\r?\n<!-- HYPO:FEEDBACK-SYNC:END -->/g;
146
+ // line-anchored so marker-looking text inside prose/code does not false-count
147
+ const START_RE = /^[ \t]*<!-- HYPO:FEEDBACK-SYNC:START\b/gm;
148
+ const END_RE = /^[ \t]*<!-- HYPO:FEEDBACK-SYNC:END -->[ \t]*$/gm;
149
+
150
+ // Count raw START/END markers; if they outnumber fully-matched blocks, a marker
151
+ // is malformed/unpaired (truncated, tampered hash, stray) → refuse (conflict).
152
+ function countMarkers(content) {
153
+ return {
154
+ starts: (content.match(START_RE) || []).length,
155
+ ends: (content.match(END_RE) || []).length,
156
+ };
157
+ }
158
+
159
+ // sha256 over the normalized inner content (contract §2): inner lines joined by
160
+ // \n, leading/trailing blank lines stripped, no trailing newline.
161
+ function normalizeInner(text) {
162
+ const lines = String(text).replace(/\r\n/g, '\n').split('\n');
163
+ while (lines.length && lines[0].trim() === '') lines.shift();
164
+ while (lines.length && lines[lines.length - 1].trim() === '') lines.pop();
165
+ return lines.join('\n');
166
+ }
167
+
168
+ function hashInner(text) {
169
+ return createHash('sha256').update(normalizeInner(text), 'utf-8').digest('hex');
170
+ }
171
+
172
+ function renderBlock(slug, inner) {
173
+ const norm = normalizeInner(inner);
174
+ return `${MARK_START(slug, hashInner(norm))}\n${norm}\n${MARK_END}`;
175
+ }
176
+
177
+ // Find existing managed blocks with their positions. Returns
178
+ // { blocks: [{slug, declaredHash, inner, actualHash, start, end}], firstStart, lastEnd }.
179
+ function findBlocks(content) {
180
+ const blocks = [];
181
+ let m;
182
+ BLOCK_RE.lastIndex = 0;
183
+ while ((m = BLOCK_RE.exec(content)) !== null) {
184
+ blocks.push({
185
+ slug: m[1],
186
+ declaredHash: m[2],
187
+ inner: m[3],
188
+ actualHash: hashInner(m[3]),
189
+ start: m.index,
190
+ end: m.index + m[0].length,
191
+ });
192
+ }
193
+ const firstStart = blocks.length ? blocks[0].start : -1;
194
+ const lastEnd = blocks.length ? blocks[blocks.length - 1].end : -1;
195
+ return { blocks, firstStart, lastEnd };
196
+ }
197
+
198
+ // Detect hand-added lines *between* managed blocks. Contract §1 requires manual
199
+ // entries to live outside the contiguous managed span; lines inside it would be
200
+ // silently dropped on rewrite, so we refuse (treated as conflict, exit 3).
201
+ function regionHasIntruders(content) {
202
+ const { blocks, firstStart, lastEnd } = findBlocks(content);
203
+ if (blocks.length < 1) return false;
204
+ const span = content.slice(firstStart, lastEnd).replace(BLOCK_RE, '');
205
+ return span.trim().length > 0;
206
+ }
207
+
208
+ // ── projection targets (descriptor abstraction — ADR 0032 reuse) ──────────────
209
+
210
+ const PUBLIC_SENSITIVITY = new Set(['public', 'sanitized']);
211
+
212
+ function memoryTarget(args, projectId) {
213
+ const root = join(args.claudeHome, 'projects', projectId, 'memory');
214
+ return {
215
+ name: 'memory',
216
+ file: join(root, 'MEMORY.md'),
217
+ dir: root,
218
+ cap: 200,
219
+ capKind: 'lines',
220
+ filter: (p) =>
221
+ p.fm.status === 'active' &&
222
+ (p.fm.scope === 'global' || p.fm.scope === `project:${projectId}`) &&
223
+ p.targets.includes('project-memory') &&
224
+ PUBLIC_SENSITIVITY.has(p.fm.sensitivity),
225
+ render: (p) =>
226
+ `- [${p.fm.title || p.slug}](feedback_${p.slug}.md) — ${p.fm.memory_summary || ''}`.trim(),
227
+ // full-copy individual files owned entirely by sync (contract §7); the
228
+ // provenance header marks them as tool-generated for safe staleness removal
229
+ sideFiles: (p) => [
230
+ {
231
+ path: join(root, `feedback_${p.slug}.md`),
232
+ content: `${SIDE_MARKER(p.slug)}\n${p.content}`,
233
+ },
234
+ ],
235
+ };
236
+ }
237
+
238
+ function claudeTarget(args) {
239
+ return {
240
+ name: 'claude',
241
+ file: join(args.claudeHome, 'CLAUDE.md'),
242
+ cap: 10,
243
+ capKind: 'entries',
244
+ container: 'learned_behaviors', // managed region must live inside <learned_behaviors>
245
+ filter: (p) =>
246
+ p.fm.status === 'active' &&
247
+ p.fm.scope === 'global' && // scope:project:* auto-rejected (contract §6)
248
+ p.fm.tier === 'L1' &&
249
+ p.targets.includes('claude-learned') &&
250
+ String(p.fm.promote_to_global) === 'true' &&
251
+ PUBLIC_SENSITIVITY.has(p.fm.sensitivity),
252
+ render: (p) => {
253
+ const date = (p.fm.updated || p.fm.date || '').slice(0, 10);
254
+ const datePart = date ? `[${date}] ` : '';
255
+ return `- ${datePart}${p.fm.global_summary || ''} — 근거: [[${p.slug}]]`;
256
+ },
257
+ sideFiles: () => [],
258
+ };
259
+ }
260
+
261
+ // ── sort key (contract §3): (priority desc, date desc, slug asc) ──────────────
262
+
263
+ function sortKey(a, b) {
264
+ const pa = Number(a.fm.priority) || 3;
265
+ const pb = Number(b.fm.priority) || 3;
266
+ if (pa !== pb) return pb - pa;
267
+ const da = (a.fm.updated || a.fm.date || '1970-01-01').slice(0, 10);
268
+ const db = (b.fm.updated || b.fm.date || '1970-01-01').slice(0, 10);
269
+ if (da !== db) return da < db ? 1 : -1;
270
+ return a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0;
271
+ }
272
+
273
+ // Build the desired ordered block list for a target.
274
+ function computeDesired(pages, target) {
275
+ return pages
276
+ .filter(target.filter)
277
+ .sort(sortKey)
278
+ .map((p) => {
279
+ const inner = target.render(p);
280
+ return { slug: p.slug, inner: normalizeInner(inner), hash: hashInner(inner), page: p };
281
+ });
282
+ }
283
+
284
+ // ── region insertion (contract §1) ───────────────────────────────────────────
285
+
286
+ function buildRegion(desired) {
287
+ return desired.map((d) => renderBlock(d.slug, d.inner)).join('\n');
288
+ }
289
+
290
+ // 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).
293
+ function buildNextContent(content, region, target) {
294
+ 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)
301
+ 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}` };
306
+ }
307
+ // an anchor is honored ONLY when it sits inside the container span
308
+ const anchorIdx = content.indexOf(MARK_ANCHOR);
309
+ if (anchorIdx > open && anchorIdx < close) {
310
+ return {
311
+ content:
312
+ content.slice(0, anchorIdx) + region + content.slice(anchorIdx + MARK_ANCHOR.length),
313
+ };
314
+ }
315
+ return { content: content.slice(0, close) + region + '\n' + content.slice(close) };
316
+ }
317
+ // memory index: anchor (anywhere) or append
318
+ const anchorIdx = content.indexOf(MARK_ANCHOR);
319
+ if (anchorIdx >= 0) {
320
+ return {
321
+ content: content.slice(0, anchorIdx) + region + content.slice(anchorIdx + MARK_ANCHOR.length),
322
+ };
323
+ }
324
+ const sep = content.endsWith('\n') ? '' : '\n';
325
+ return { content: content + sep + region + '\n' };
326
+ }
327
+
328
+ // sync-owned full-copy files (feedback_<slug>.md) no longer backed by an active
329
+ // candidate → must be removed so deletions/demotions propagate (MEDIUM-1).
330
+ function staleSideFiles(target, desired) {
331
+ if (!target.dir || !existsSync(target.dir)) return [];
332
+ const keep = new Set(desired.map((d) => `feedback_${d.slug}.md`));
333
+ return readdirSync(target.dir)
334
+ .filter((f) => /^feedback_.+\.md$/.test(f) && !keep.has(f))
335
+ .map((f) => join(target.dir, f))
336
+ .filter((p) => {
337
+ // only delete files THIS tool generated (provenance header on line 1) —
338
+ // never a user's hand-written feedback_*.md memory file
339
+ try {
340
+ return readFileSync(p, 'utf-8').startsWith(SIDE_MARKER_PREFIX);
341
+ } catch {
342
+ return false;
343
+ }
344
+ });
345
+ }
346
+
347
+ // ── per-target evaluation (preflight: validates + computes the write plan) ─────
348
+
349
+ function evaluateTarget(pages, target) {
350
+ const desired = computeDesired(pages, target);
351
+ const fileExists = existsSync(target.file);
352
+ const content = fileExists ? readFileSync(target.file, 'utf-8') : '';
353
+ const { blocks } = findBlocks(content);
354
+ const { starts, ends } = countMarkers(content);
355
+
356
+ // conflict: on-disk block whose inner content no longer matches its marker
357
+ const conflicts = blocks.filter((b) => b.actualHash !== b.declaredHash).map((b) => b.slug);
358
+ // unpaired: a raw START/END marker that BLOCK_RE could not pair (malformed/tampered)
359
+ const unpaired = starts !== blocks.length || ends !== blocks.length;
360
+ // intruder: hand-added lines inside the managed span (would be dropped on rewrite)
361
+ const intruder = regionHasIntruders(content);
362
+ // out-of-container: an existing managed block sitting outside <learned_behaviors>
363
+ // (drifted/hand-moved) — replacing it in place would leave it outside the
364
+ // required region, so refuse and require import.
365
+ 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);
370
+ }
371
+
372
+ const region = buildRegion(desired);
373
+ let overCap = false;
374
+ if (target.capKind === 'entries') overCap = desired.length > target.cap;
375
+ // count index content lines only — the START/END marker wrappers are sync
376
+ // bookkeeping, not part of the "200 index lines" budget.
377
+ else if (target.capKind === 'lines')
378
+ overCap = desired.reduce((n, d) => n + d.inner.split('\n').length, 0) > target.cap;
379
+
380
+ // build the next content (validation happens here, before any write)
381
+ let nextContent = null;
382
+ let buildError = null;
383
+ if (!fileExists && target.container) {
384
+ buildError = `target file missing: ${target.file}`;
385
+ } else {
386
+ const r = buildNextContent(fileExists ? content : '', region, target);
387
+ if (r.error) buildError = r.error;
388
+ else nextContent = r.content;
389
+ }
390
+
391
+ // side-files: writes (current candidates) + deletes (stale sync-owned copies)
392
+ const sideWrites = [];
393
+ for (const d of desired) {
394
+ for (const sf of target.sideFiles(d.page)) sideWrites.push(sf);
395
+ }
396
+ const sideDeletes = staleSideFiles(target, desired);
397
+
398
+ // dirty: main content would change OR any side-file would change/be removed
399
+ let dirty = nextContent !== null && nextContent !== (fileExists ? content : '');
400
+ if (sideDeletes.length) dirty = true;
401
+ for (const sf of sideWrites) {
402
+ const cur = existsSync(sf.path) ? readFileSync(sf.path, 'utf-8') : null;
403
+ if (cur !== sf.content) dirty = true;
404
+ }
405
+
406
+ return {
407
+ desired,
408
+ conflicts,
409
+ unpaired,
410
+ intruder,
411
+ outOfContainer,
412
+ overCap,
413
+ dirty,
414
+ content,
415
+ fileExists,
416
+ nextContent,
417
+ buildError,
418
+ sideWrites,
419
+ sideDeletes,
420
+ };
421
+ }
422
+
423
+ // Pure writer: applies a fully-validated plan. No validation here.
424
+ function applyTarget(target, res) {
425
+ if (target.dir) mkdirSync(target.dir, { recursive: true });
426
+ for (const sf of res.sideWrites) {
427
+ mkdirSync(join(sf.path, '..'), { recursive: true });
428
+ writeFileSync(sf.path, sf.content);
429
+ }
430
+ for (const p of res.sideDeletes) {
431
+ try {
432
+ rmSync(p);
433
+ } catch {
434
+ /* best-effort */
435
+ }
436
+ }
437
+ if (res.nextContent !== null && res.nextContent !== (res.fileExists ? res.content : '')) {
438
+ writeFileSync(target.file, res.nextContent);
439
+ }
440
+ }
441
+
442
+ // ── project-id derivation (contract §5, OQ-31.3) ──────────────────────────────
443
+
444
+ function deriveProjectId(args) {
445
+ if (args.projectId) return { id: args.projectId, derived: false, exists: true };
446
+ const id = args.cwd.replace(/[/.]/g, '-');
447
+ const dir = join(args.claudeHome, 'projects', id);
448
+ return { id, derived: true, exists: existsSync(dir) };
449
+ }
450
+
451
+ // Default interactive prompt (readline over stdin → stderr, so stdout stays clean
452
+ // for --json). Returns one of: { action: 'confirm' } | { action: 'id', id }
453
+ // | { action: 'skip' }. Isolated so resolveProjectId() can be unit-tested with a
454
+ // fake prompt and so the only thing that can touch stdin is gated behind isTTY.
455
+ async function defaultPrompt({ derivedId, claudeHome }) {
456
+ const rl = (await import('node:readline/promises')).createInterface({
457
+ input: process.stdin,
458
+ output: process.stderr,
459
+ });
460
+ try {
461
+ process.stderr.write(
462
+ `[feedback-sync] project-id "${derivedId}" has no directory under ${claudeHome}/projects.\n` +
463
+ ' [Enter] confirm and use it (memory dir will be created on --write)\n' +
464
+ ' type an id to use a different project-id\n' +
465
+ ' type "skip" to skip MEMORY projection\n',
466
+ );
467
+ const answer = (await rl.question('project-id> ')).trim();
468
+ if (!answer) return { action: 'confirm' };
469
+ if (answer.toLowerCase() === 'skip') return { action: 'skip' };
470
+ return { action: 'id', id: answer };
471
+ } finally {
472
+ rl.close();
473
+ }
474
+ }
475
+
476
+ // Interactive layer on top of the pure deriveProjectId(). Resolves to
477
+ // { id, derived, exists, skipMemory }. Only prompts when the derived dir is
478
+ // missing AND stdin is a TTY AND --no-input is unset. Non-TTY / --no-input /
479
+ // explicit --project-id paths NEVER call prompt → cannot hang (hook/CI safe).
480
+ // `prompt` is injectable for testing; defaults to readline.
481
+ async function resolveProjectId(args, { prompt = defaultPrompt, isTTY } = {}) {
482
+ const pid = deriveProjectId(args);
483
+ // explicit --project-id, or derived dir exists → use as-is (no prompt).
484
+ if (!pid.derived || pid.exists) return { ...pid, skipMemory: false };
485
+
486
+ const interactive = (isTTY ?? Boolean(process.stdin.isTTY)) && !args.noInput;
487
+ if (!interactive) {
488
+ // hook / CI / pipe / --no-input: keep current behavior — skip MEMORY, no hang.
489
+ return { ...pid, skipMemory: true };
490
+ }
491
+
492
+ const choice = await prompt({ derivedId: pid.id, claudeHome: args.claudeHome });
493
+ if (choice.action === 'skip') return { ...pid, skipMemory: true };
494
+ if (choice.action === 'id') {
495
+ const id = choice.id;
496
+ const exists = existsSync(join(args.claudeHome, 'projects', id));
497
+ // user-entered id is accepted even if missing (they may be creating it);
498
+ // the MEMORY dir is created on --write.
499
+ return { id, derived: false, exists, skipMemory: false };
500
+ }
501
+ // confirm: accept the derived id despite the missing dir.
502
+ return { ...pid, skipMemory: false };
503
+ }
504
+
505
+ // ── bootstrap + import (contract §11) ────────────────────────
506
+ //
507
+ // Both modes are *reverse* one-time helpers that scaffold wiki DRAFTS under
508
+ // pages/feedback/_drafts/ — they NEVER write pages/feedback/<slug>.md directly
509
+ // (the single-direction invariant, ADR 0031 §6). A human reviews each draft,
510
+ // fills the decision fields (scope/tier/targets/promote_to_global), and moves
511
+ // it into pages/feedback/. _drafts/ is excluded from sync candidates
512
+ // (loadFeedbackPages) and from lint (collectPages skips `_`-dirs), so an
513
+ // incomplete scaffold never trips required-field errors or projection.
514
+
515
+ // Provenance header so re-running bootstrap/import is recognisable and humans
516
+ // see at a glance the file is a generated scaffold awaiting review.
517
+ const DRAFT_MARKER = (origin) => `<!-- HYPO:FEEDBACK-SYNC:DRAFT origin=${origin} -->`;
518
+
519
+ // Slug from free text: keep unicode letters/digits (Korean rules stay readable),
520
+ // collapse every other run to a single dash, length-cap for filesystem sanity.
521
+ function kebabSlug(text, max = 48) {
522
+ const s = String(text)
523
+ .replace(/\*\*/g, '') // markdown bold
524
+ .replace(/`[^`]*`/g, ' ') // inline code spans
525
+ .normalize('NFC')
526
+ .toLowerCase()
527
+ .replace(/[^\p{L}\p{N}]+/gu, '-')
528
+ .replace(/^-+|-+$/g, '');
529
+ return s.slice(0, max).replace(/-+$/g, '') || 'entry';
530
+ }
531
+
532
+ // Reduce an externally-sourced slug (MEMORY index name, managed-block `source=`)
533
+ // to a single safe path segment. basename() collapses any `../` traversal to the
534
+ // final component, then we strip everything but unicode word chars / . _ - and
535
+ // leading dots. Returns null when nothing safe remains → caller skips it. Without
536
+ // this a crafted `source=../evil` / `feedback_../evil.md` would let --bootstrap /
537
+ // --import write outside _drafts (e.g. into pages/feedback/), breaking the
538
+ // one-way invariant.
539
+ function safeDraftSlug(raw) {
540
+ const seg = basename(String(raw).replace(/\\/g, '/'));
541
+ const cleaned = seg
542
+ .replace(/[^\p{L}\p{N}._-]+/gu, '-')
543
+ .replace(/^[.-]+/, '')
544
+ .replace(/-+$/g, '');
545
+ return cleaned && cleaned !== '.' && cleaned !== '..' ? cleaned : null;
546
+ }
547
+
548
+ // Defense-in-depth: refuse to write a draft whose resolved path escapes _drafts.
549
+ function assertUnderDrafts(draftsDir, target) {
550
+ const root = resolve(draftsDir) + sep;
551
+ if (!resolve(target).startsWith(root)) {
552
+ throw new Error(`refusing to write outside _drafts: ${target}`);
553
+ }
554
+ }
555
+
556
+ // One-line summary from a rule body: drop markdown noise, cap length.
557
+ function oneLineSummary(text, max = 100) {
558
+ const s = String(text).replace(/\*\*/g, '').replace(/\r?\n/g, ' ').replace(/\s+/g, ' ').trim();
559
+ return s.length > max ? s.slice(0, max - 1).trimEnd() + '…' : s;
560
+ }
561
+
562
+ // Parse <learned_behaviors> hand-written lines into {date, rule}. Lines INSIDE a
563
+ // HYPO:FEEDBACK-SYNC managed block are skipped — those already have a wiki SoT
564
+ // and are not legacy entries to migrate.
565
+ 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);
570
+ const scrubbed = inner.replace(BLOCK_RE, ''); // blank out already-projected blocks
571
+ const out = [];
572
+ for (const line of scrubbed.split('\n')) {
573
+ const m = line.match(/^- \[(\d{4}-\d{2}-\d{2})\]\s+(.*\S)\s*$/);
574
+ if (m) out.push({ date: m[1], rule: m[2].trim() });
575
+ }
576
+ return out;
577
+ }
578
+
579
+ // Parse MEMORY.md index for sync-shaped feedback entries:
580
+ // `- [Title](feedback_<name>.md) — summary`
581
+ // Non-`feedback_*` index lines are out of scope (not feedback projections).
582
+ function parseMemoryIndex(content) {
583
+ const out = [];
584
+ // scrub already-projected managed blocks first (parity with parseLearnedBehaviors):
585
+ // index lines inside a HYPO:FEEDBACK-SYNC block already have a wiki SoT and must
586
+ // not be re-drafted as legacy entries.
587
+ const scrubbed = content.replace(BLOCK_RE, '');
588
+ const re = /^- \[([^\]]*)\]\(feedback_([^)]+?)\.md\)\s*(?:—\s*(.*\S))?\s*$/gm;
589
+ let m;
590
+ while ((m = re.exec(scrubbed)) !== null) {
591
+ out.push({ title: m[1].trim(), name: m[2].trim(), summary: (m[3] || '').trim() });
592
+ }
593
+ return out;
594
+ }
595
+
596
+ // Frontmatter skeleton for a bootstrap draft. Decision fields the human must set
597
+ // are left as `TODO` (the file is excluded from lint, so this never errors).
598
+ function bootstrapDraftContent({ title, summary, body, date, origin }) {
599
+ const lines = [
600
+ DRAFT_MARKER(origin),
601
+ '---',
602
+ `title: ${title}`,
603
+ 'type: feedback',
604
+ 'status: draft',
605
+ 'scope: TODO # global | project:<slug>',
606
+ 'tier: TODO # L1 (CLAUDE.md <learned_behaviors> candidate) | L2',
607
+ 'targets: [project-memory] # + claude-learned for a global L1 rule',
608
+ 'sensitivity: public # public | sanitized (private is forbidden)',
609
+ 'priority: 3 # 1-5, higher wins over-cap',
610
+ `memory_summary: ${summary}`,
611
+ `global_summary: ${summary}`,
612
+ 'promote_to_global: false # set true to project into <learned_behaviors>',
613
+ 'reason: TODO',
614
+ `source: ${date ? `session:${date}` : 'TODO'}`,
615
+ ];
616
+ if (date) lines.push(`created: ${date}`, `updated: ${date}`);
617
+ lines.push(`bootstrap_origin: ${origin}`, '---', '', `# ${title}`, '', body, '');
618
+ return lines.join('\n');
619
+ }
620
+
621
+ // Frontmatter skeleton for an import draft: captures the on-disk (hand-edited)
622
+ // managed block content as the body so the human can reconcile it into the SoT.
623
+ function importDraftContent({ slug, inner, from }) {
624
+ return [
625
+ DRAFT_MARKER(`import-${from}`),
626
+ '---',
627
+ `title: imported ${slug}`,
628
+ 'type: feedback',
629
+ 'status: draft',
630
+ 'scope: TODO',
631
+ 'tier: TODO',
632
+ 'targets: [project-memory]',
633
+ 'sensitivity: public',
634
+ 'priority: 3',
635
+ `memory_summary: ${oneLineSummary(inner)}`,
636
+ `global_summary: ${oneLineSummary(inner)}`,
637
+ 'promote_to_global: false',
638
+ `reason: imported from ${from} <learned_behaviors>/MEMORY managed block (hand-edited)`,
639
+ 'source: TODO',
640
+ `imported_from: ${from}`,
641
+ '---',
642
+ '',
643
+ `# imported ${slug}`,
644
+ '',
645
+ '> The managed block below was edited outside the wiki. Reconcile it into',
646
+ `> pages/feedback/${slug}.md (the SoT), then re-run feedback-sync --write.`,
647
+ '',
648
+ inner,
649
+ '',
650
+ ].join('\n');
651
+ }
652
+
653
+ function existingPageSlugs(hypoDir) {
654
+ const dir = join(hypoDir, 'pages', 'feedback');
655
+ if (!existsSync(dir)) return new Set();
656
+ return new Set(
657
+ readdirSync(dir)
658
+ .filter((f) => f.endsWith('.md') && !f.startsWith('.') && !f.startsWith('_'))
659
+ .map((f) => basename(f, '.md')),
660
+ );
661
+ }
662
+
663
+ // --bootstrap: read the two legacy projection surfaces (CLAUDE.md
664
+ // <learned_behaviors> + MEMORY.md feedback_* index) and scaffold drafts.
665
+ function runBootstrap(args) {
666
+ const draftsDir = join(args.hypoDir, 'pages', 'feedback', '_drafts');
667
+ const existing = existingPageSlugs(args.hypoDir);
668
+ const report = { mode: 'bootstrap', dryRun: args.dryRun, created: [], skipped: [] };
669
+ const warnings = [];
670
+ const candidates = [];
671
+
672
+ const claudeFile = join(args.claudeHome, 'CLAUDE.md');
673
+ if (existsSync(claudeFile)) {
674
+ for (const lb of parseLearnedBehaviors(readFileSync(claudeFile, 'utf-8'))) {
675
+ const slug = `legacy-claude-${lb.date.replace(/-/g, '')}-${kebabSlug(lb.rule)}`;
676
+ candidates.push({
677
+ slug,
678
+ origin: 'claude-learned',
679
+ title: oneLineSummary(lb.rule, 60),
680
+ summary: oneLineSummary(lb.rule),
681
+ body: lb.rule,
682
+ date: lb.date,
683
+ });
684
+ }
685
+ } else {
686
+ warnings.push(`CLAUDE.md not found at ${claudeFile} — learned_behaviors source skipped`);
687
+ }
688
+
689
+ const pid = deriveProjectId(args);
690
+ const memFile = join(args.claudeHome, 'projects', pid.id, 'memory', 'MEMORY.md');
691
+ if (existsSync(memFile)) {
692
+ for (const e of parseMemoryIndex(readFileSync(memFile, 'utf-8'))) {
693
+ const slug = safeDraftSlug(e.name.replace(/_/g, '-'));
694
+ if (!slug) {
695
+ report.skipped.push({ slug: e.name, reason: 'unsafe-slug' });
696
+ continue;
697
+ }
698
+ candidates.push({
699
+ slug,
700
+ origin: 'memory-index',
701
+ title: e.title || slug,
702
+ summary: e.summary,
703
+ body: e.summary || e.title || slug,
704
+ date: '',
705
+ });
706
+ }
707
+ } else {
708
+ warnings.push(
709
+ `MEMORY.md not found for project-id "${pid.id}" at ${memFile} — memory source skipped`,
710
+ );
711
+ }
712
+
713
+ const seen = new Set();
714
+ for (const c of candidates) {
715
+ if (seen.has(c.slug)) {
716
+ report.skipped.push({ slug: c.slug, reason: 'duplicate-in-batch' });
717
+ continue;
718
+ }
719
+ seen.add(c.slug);
720
+ if (existing.has(c.slug)) {
721
+ report.skipped.push({ slug: c.slug, reason: 'page-exists' });
722
+ continue;
723
+ }
724
+ const draftPath = join(draftsDir, `${c.slug}.md`);
725
+ if (existsSync(draftPath)) {
726
+ report.skipped.push({ slug: c.slug, reason: 'draft-exists' });
727
+ continue;
728
+ }
729
+ assertUnderDrafts(draftsDir, draftPath);
730
+ report.created.push({ slug: c.slug, origin: c.origin, path: draftPath });
731
+ if (!args.dryRun) {
732
+ mkdirSync(draftsDir, { recursive: true });
733
+ writeFileSync(draftPath, bootstrapDraftContent(c));
734
+ }
735
+ }
736
+ return { code: 0, report, warnings };
737
+ }
738
+
739
+ // --import-target-change --from=<memory|claude>: capture hand-edited (conflict)
740
+ // managed blocks back into drafts so the human can reconcile them into the SoT.
741
+ function runImport(args) {
742
+ if (args.from !== 'memory' && args.from !== 'claude') {
743
+ return { code: 1, error: '--import-target-change requires --from=memory|claude' };
744
+ }
745
+ const file =
746
+ args.from === 'claude'
747
+ ? join(args.claudeHome, 'CLAUDE.md')
748
+ : join(args.claudeHome, 'projects', deriveProjectId(args).id, 'memory', 'MEMORY.md');
749
+ if (!existsSync(file)) return { code: 1, error: `target file not found: ${file}` };
750
+
751
+ const { blocks } = findBlocks(readFileSync(file, 'utf-8'));
752
+ const conflicts = blocks.filter((b) => b.actualHash !== b.declaredHash);
753
+ const report = { mode: 'import', from: args.from, dryRun: args.dryRun, imported: [] };
754
+ const warnings = [];
755
+ if (!conflicts.length) {
756
+ warnings.push(`no hand-edited (conflicting) managed blocks in ${file} — nothing to import`);
757
+ return { code: 0, report, warnings };
758
+ }
759
+ const draftsDir = join(args.hypoDir, 'pages', 'feedback', '_drafts');
760
+ const stamp = new Date().toISOString().slice(0, 10).replace(/-/g, '');
761
+ report.skipped = [];
762
+ for (const b of conflicts) {
763
+ // sanitize the marker-supplied slug (a tampered `source=../x` must not escape
764
+ // _drafts — codex BLOCKER), then pick a collision-free name so a re-run / a
765
+ // same-day import from both targets never clobbers a prior draft (or human
766
+ // edits to it — codex IMPORTANT). `from` is in the name to disambiguate
767
+ // memory vs claude imports of the same slug.
768
+ const slug = safeDraftSlug(b.slug);
769
+ if (!slug) {
770
+ report.skipped.push({ slug: b.slug, reason: 'unsafe-slug' });
771
+ continue;
772
+ }
773
+ let draftPath = join(draftsDir, `${slug}.import-${args.from}-${stamp}.md`);
774
+ for (let n = 2; existsSync(draftPath); n++) {
775
+ draftPath = join(draftsDir, `${slug}.import-${args.from}-${stamp}-${n}.md`);
776
+ }
777
+ assertUnderDrafts(draftsDir, draftPath);
778
+ report.imported.push({ slug, path: draftPath });
779
+ if (!args.dryRun) {
780
+ mkdirSync(draftsDir, { recursive: true });
781
+ writeFileSync(draftPath, importDraftContent({ slug, inner: b.inner, from: args.from }));
782
+ }
783
+ }
784
+ return { code: 0, report, warnings };
785
+ }
786
+
787
+ // ── modes ─────────────────────────────────────────────────────────────────────
788
+
789
+ function run(args, resolvedPid = null) {
790
+ if (!existsSync(args.hypoDir)) {
791
+ return { code: 1, error: `wiki not found: ${args.hypoDir}` };
792
+ }
793
+ if (args.mode === 'bootstrap') return runBootstrap(args);
794
+ if (args.mode === 'import') return runImport(args);
795
+
796
+ const pages = loadFeedbackPages(args.hypoDir);
797
+ // pid may be pre-resolved by the interactive layer in main(); fall back to the
798
+ // pure derivation for direct callers / tests. `skipMemory` carries the §5 step 4
799
+ // decision (non-interactive unresolved, or user chose "skip").
800
+ let pid;
801
+ if (resolvedPid) {
802
+ pid = resolvedPid;
803
+ } else {
804
+ // direct/sync caller (tests, machine path): no prompting possible here, so
805
+ // mirror the original non-interactive rule — skip MEMORY when the derived
806
+ // dir is missing (contract §5 step 4 non-interactive branch).
807
+ const d = deriveProjectId(args);
808
+ pid = { ...d, skipMemory: d.derived && !d.exists };
809
+ }
810
+ if (args.skipMemory) pid.skipMemory = true;
811
+
812
+ const targets = [];
813
+ // MEMORY target only when not skipped (contract §5 step 4: unknown project-id
814
+ // in non-interactive mode, or user-declined → skip, do not hard-fail).
815
+ if (!pid.skipMemory) {
816
+ targets.push(memoryTarget(args, pid.id));
817
+ }
818
+ targets.push(claudeTarget(args));
819
+
820
+ const report = { mode: args.mode, projectId: pid.id, projectIdResolved: pid.exists, targets: {} };
821
+ if (pid.skipMemory) report.skipMemory = true;
822
+ let code = 0;
823
+ // `warnings`: everything surfaced to the human (stderr / JSON report).
824
+ // `strictWarnings`: the subset `--strict` escalates to a non-zero exit.
825
+ // These are NOT the same set. The skip-MEMORY warning is an *environmental*
826
+ // state (a fresh / external user has no ~/.claude/projects/<id>/memory yet),
827
+ // not actionable drift — contract §5 step 4 promises this never hard-fails,
828
+ // so it must stay OUT of strictWarnings or the PreCompact gate (#3, which
829
+ // runs `--check --strict`) would block every first-run user. A private-
830
+ // sensitivity page IS a real SoT violation (ADR 0031 §7; lint #8 blocks it
831
+ // at the source) so it stays strict-escalatable as defense-in-depth.
832
+ const warnings = [];
833
+ const strictWarnings = [];
834
+ if (pid.skipMemory) {
835
+ warnings.push(
836
+ `project-id "${pid.id}" dir not found under ${args.claudeHome}/projects — MEMORY projection skipped (pass --project-id to override)`,
837
+ );
838
+ }
839
+ for (const p of pages) {
840
+ if (p.fm.sensitivity && !PUBLIC_SENSITIVITY.has(p.fm.sensitivity)) {
841
+ const w = `page "${p.slug}" excluded: sensitivity="${p.fm.sensitivity}" (only public|sanitized)`;
842
+ warnings.push(w);
843
+ strictWarnings.push(w);
844
+ }
845
+ }
846
+
847
+ // pass 1: preflight every target before touching disk — validates the write
848
+ // plan (container/anchor, markers) and computes next content. A conflict /
849
+ // over-cap / build error in ANY target blocks writes to ALL (atomicity — ADR
850
+ // 0031 Decision §6 "no auto-merge"; avoids a partial write where one target
851
+ // lands and another refuses).
852
+ const evals = targets.map((target) => ({ target, res: evaluateTarget(pages, target) }));
853
+ for (const { target, res } of evals) {
854
+ report.targets[target.name] = {
855
+ candidates: res.desired.length,
856
+ conflicts: res.conflicts,
857
+ unpaired: res.unpaired,
858
+ intruder: res.intruder,
859
+ outOfContainer: res.outOfContainer,
860
+ overCap: res.overCap,
861
+ dirty: res.dirty,
862
+ ...(res.buildError ? { buildError: res.buildError } : {}),
863
+ };
864
+ if (res.conflicts.length || res.intruder || res.unpaired || res.outOfContainer)
865
+ code = Math.max(code, 3);
866
+ else if (res.overCap) code = Math.max(code, 2);
867
+ else if (res.buildError) code = Math.max(code, 1);
868
+ else if (res.dirty && args.mode === 'check') code = Math.max(code, 1);
869
+ }
870
+
871
+ // strict promotes warnings to a failure; compute it BEFORE the write gate so a
872
+ // --write --strict refuses rather than writing then exiting non-zero.
873
+ const strictFail = args.strict && strictWarnings.length > 0;
874
+
875
+ // pass 2: write only when nothing blocks (code === 0 ⇒ no conflict, over-cap,
876
+ // build error, or check-mode drift) and no strict failure. Skip clean targets
877
+ // so writes stay byte-idempotent.
878
+ if (args.mode === 'write' && code === 0 && !strictFail) {
879
+ for (const { target, res } of evals) {
880
+ if (res.dirty) applyTarget(target, res);
881
+ }
882
+ }
883
+
884
+ if (strictFail) code = Math.max(code, 1);
885
+ return { code, report, warnings };
886
+ }
887
+
888
+ // ── main ───────────────────────────────────────────────────────────────────────
889
+
890
+ async function main() {
891
+ const args = parseArgs(process.argv);
892
+ // Resolve project-id before run() so the (possibly interactive) prompt happens
893
+ // exactly once. resolveProjectId only touches stdin when stdin.isTTY is truthy
894
+ // and --no-input is unset → hooks/CI/pipes never block here.
895
+ let resolvedPid = null;
896
+ if (existsSync(args.hypoDir) && args.mode !== 'bootstrap' && args.mode !== 'import') {
897
+ resolvedPid = await resolveProjectId(args);
898
+ }
899
+ const out = run(args, resolvedPid);
900
+
901
+ if (args.json) {
902
+ console.log(JSON.stringify(out.error ? { error: out.error } : out.report, null, 2));
903
+ } else if (out.error) {
904
+ console.error(`[feedback-sync] ${out.error}`);
905
+ } else if (out.report.mode === 'bootstrap') {
906
+ for (const w of out.warnings || []) console.error(`[feedback-sync] warn: ${w}`);
907
+ const verb = out.report.dryRun ? 'would create' : 'created';
908
+ for (const c of out.report.created)
909
+ console.error(
910
+ `[feedback-sync] ${verb} draft: pages/feedback/_drafts/${c.slug}.md (${c.origin})`,
911
+ );
912
+ for (const s of out.report.skipped)
913
+ console.error(`[feedback-sync] skipped ${s.slug}: ${s.reason}`);
914
+ console.error(
915
+ `[feedback-sync] bootstrap: ${out.report.created.length} ${verb}, ${out.report.skipped.length} skipped. ` +
916
+ `Fill scope/tier/targets/promote_to_global and move into pages/feedback/.`,
917
+ );
918
+ } else if (out.report.mode === 'import') {
919
+ for (const w of out.warnings || []) console.error(`[feedback-sync] warn: ${w}`);
920
+ const verb = out.report.dryRun ? 'would import' : 'imported';
921
+ for (const i of out.report.imported)
922
+ console.error(`[feedback-sync] ${verb} ${i.slug} → ${i.path}`);
923
+ if (out.report.imported.length)
924
+ console.error(
925
+ `[feedback-sync] import: ${out.report.imported.length} draft(s). Reconcile into the SoT page, then feedback-sync --write.`,
926
+ );
927
+ } else {
928
+ for (const w of out.warnings || []) console.error(`[feedback-sync] warn: ${w}`);
929
+ for (const [name, t] of Object.entries(out.report.targets)) {
930
+ if (t.conflicts.length || t.intruder || t.unpaired || t.outOfContainer) {
931
+ const why = t.conflicts.length
932
+ ? `block(s) manually edited: ${t.conflicts.join(', ')}`
933
+ : t.unpaired
934
+ ? 'malformed/unpaired HYPO:FEEDBACK-SYNC marker'
935
+ : t.outOfContainer
936
+ ? 'managed block sits outside <learned_behaviors>'
937
+ : 'managed region has unrecognized lines (move them outside the HYPO blocks)';
938
+ console.error(
939
+ `[feedback-sync] CONFLICT: ${name} ${why}\n` +
940
+ ` Run \`hypomnema feedback-sync --import-target-change --from=${name}\` to import.`,
941
+ );
942
+ } else if (t.buildError) console.error(`[feedback-sync] ERROR: ${name} ${t.buildError}`);
943
+ else if (t.overCap)
944
+ console.error(
945
+ `[feedback-sync] OVER-CAP: ${name} has ${t.candidates} candidates (cap ${name === 'claude' ? '10 entries' : '200 lines'}) — demote/archive required.`,
946
+ );
947
+ else if (t.dirty && args.mode === 'check')
948
+ console.error(`[feedback-sync] drift: ${name} projection is stale (run --write).`);
949
+ }
950
+ if (out.code === 0 && args.mode === 'write')
951
+ console.error('[feedback-sync] projections written.');
952
+ }
953
+
954
+ process.exit(out.code);
955
+ }
956
+
957
+ function isMain() {
958
+ try {
959
+ // normalize via realpathSync + pathToFileURL so paths with spaces /
960
+ // URL-significant chars / symlinks compare correctly. Node resolves
961
+ // import.meta.url through realpath, so argv[1] must be realpath'd too
962
+ // (e.g. macOS /tmp → /private/tmp); a raw `file://${argv[1]}` silently no-ops.
963
+ if (!process.argv[1]) return false;
964
+ return pathToFileURL(realpathSync(process.argv[1])).href === import.meta.url;
965
+ } catch {
966
+ return false;
967
+ }
968
+ }
969
+
970
+ if (isMain()) {
971
+ main();
972
+ }
973
+
974
+ export { parseArgs, deriveProjectId, resolveProjectId, run };