hypomnema 1.2.1 → 1.3.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 (38) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/commands/crystallize.md +23 -6
  4. package/commands/feedback.md +1 -1
  5. package/docs/CONTRIBUTING.md +96 -11
  6. package/hooks/hypo-auto-commit.mjs +3 -3
  7. package/hooks/hypo-auto-minimal-crystallize.mjs +8 -3
  8. package/hooks/hypo-cwd-change.mjs +2 -2
  9. package/hooks/hypo-first-prompt.mjs +1 -1
  10. package/hooks/hypo-personal-check.mjs +57 -7
  11. package/hooks/hypo-session-start.mjs +51 -4
  12. package/hooks/hypo-shared.mjs +137 -12
  13. package/hooks/version-check.mjs +204 -6
  14. package/package.json +5 -2
  15. package/scripts/bump-version.mjs +9 -3
  16. package/scripts/check-bilingual.mjs +115 -0
  17. package/scripts/crystallize.mjs +124 -15
  18. package/scripts/doctor.mjs +45 -9
  19. package/scripts/feedback-sync.mjs +44 -15
  20. package/scripts/feedback.mjs +5 -5
  21. package/scripts/fix-status-verify.mjs +256 -0
  22. package/scripts/init.mjs +45 -4
  23. package/scripts/install-git-hooks.mjs +258 -0
  24. package/scripts/lib/adr-corpus.mjs +79 -0
  25. package/scripts/lib/check-bilingual.mjs +141 -0
  26. package/scripts/lib/extensions.mjs +3 -3
  27. package/scripts/lib/feedback-scope.mjs +21 -0
  28. package/scripts/lib/fix-manifest.mjs +109 -0
  29. package/scripts/lib/fix-status-verify.mjs +438 -0
  30. package/scripts/lib/pre-commit-format.mjs +251 -0
  31. package/scripts/lib/project-create.mjs +2 -2
  32. package/scripts/lint.mjs +48 -8
  33. package/scripts/pre-commit-format.mjs +198 -0
  34. package/scripts/smoke-pack.mjs +16 -0
  35. package/scripts/upgrade.mjs +55 -23
  36. package/skills/crystallize/SKILL.md +13 -2
  37. package/templates/hypo-config.md +1 -1
  38. package/templates/hypo-guide.md +4 -0
@@ -55,7 +55,9 @@
55
55
  * hypo-personal-check is still the final enforcement.
56
56
  * • Post-apply — runs after the writes. Surfaces as stage='post-apply-lint'
57
57
  * (or 'post-apply-verification+lint' if freshness also fails). Catches
58
- * payloads that introduce a broken wikilink / malformed body.
58
+ * payloads that introduce a malformed body / bad frontmatter (error-level);
59
+ * broken wikilinks are lint W4 warnings and are not gated. A lint crash
60
+ * hard-fails regardless of scope.
59
61
  */
60
62
 
61
63
  import {
@@ -77,6 +79,9 @@ import {
77
79
  writeSessionClosedMarker,
78
80
  sessionClosedMarkerPath,
79
81
  hypoIsClean,
82
+ extractTouchedWikiFiles,
83
+ closeFileTargets,
84
+ partitionLintScope,
80
85
  } from '../hooks/hypo-shared.mjs';
81
86
 
82
87
  const LINT_SCRIPT = join(dirname(fileURLToPath(import.meta.url)), 'lint.mjs');
@@ -117,6 +122,7 @@ function parseArgs(argv) {
117
122
  sessionId: null,
118
123
  payload: null,
119
124
  force: false,
125
+ transcriptPath: null,
120
126
  };
121
127
  for (const arg of argv.slice(2)) {
122
128
  if (arg.startsWith('--hypo-dir=')) args.hypoDir = expandHome(arg.slice(11));
@@ -126,6 +132,7 @@ function parseArgs(argv) {
126
132
  else if (arg === '--mark-session-closed') args.markSessionClosed = true;
127
133
  else if (arg.startsWith('--session-id=')) args.sessionId = arg.slice(13);
128
134
  else if (arg.startsWith('--payload=')) args.payload = arg.slice(10);
135
+ else if (arg.startsWith('--transcript-path=')) args.transcriptPath = expandHome(arg.slice(18));
129
136
  else if (arg === '--force') args.force = true;
130
137
  else if (arg === '--json') args.json = true;
131
138
  }
@@ -246,7 +253,7 @@ function writeIfChanged(path, content) {
246
253
  * Append `entry` to `path` only if `alreadyPresent(content)` is false.
247
254
  * Atomic: rebuilds the full file content and writes via atomicWrite — a crash
248
255
  * mid-append cannot leave log.md or session-log/YYYY-MM.md half-written, which
249
- * matters for these append-only history files (codex review of fix #38).
256
+ * matters for these append-only history files.
250
257
  */
251
258
  function appendIfAbsent(path, entry, alreadyPresent) {
252
259
  let content = '';
@@ -350,13 +357,58 @@ function runMarkSessionClosed(args) {
350
357
  if (args.json) {
351
358
  console.log(JSON.stringify(result, null, 2));
352
359
  } else {
353
- console.log(`✗ session-close gate not satisfied — marker not written (project: ${status.project || '(unresolved)'}):`);
360
+ console.log(
361
+ `✗ session-close gate not satisfied — marker not written (project: ${status.project || '(unresolved)'}):`,
362
+ );
354
363
  for (const f of status.missing) console.log(` ✗ ${f} (missing)`);
355
364
  for (const f of status.stale) console.log(` ✗ ${f} (stale)`);
356
365
  if (!git.clean) console.log(` ✗ git: ${git.reason}`);
357
366
  }
358
367
  process.exit(1);
359
368
  }
369
+ // Bug A coherence: the marker suppresses the Stop hook, but PreCompact blocks
370
+ // on lint. If a transcript is provided, refuse the marker when THIS session's
371
+ // own files (edited ∪ mandatory close files) carry lint errors — otherwise
372
+ // Stop would pass only for /compact to immediately re-block. No transcript →
373
+ // legacy freshness+git recovery path (lint left to PreCompact).
374
+ if (args.transcriptPath) {
375
+ let scopedLint = null;
376
+ try {
377
+ scopedLint = runLint(args.hypoDir);
378
+ } catch (err) {
379
+ // Lint crash → fail-open (never strand the marker writer on tooling), same
380
+ // posture as the PreCompact hook.
381
+ process.stderr.write(
382
+ `[crystallize] mark-session-closed lint skipped: ${err?.message ?? err}\n`,
383
+ );
384
+ }
385
+ if (scopedLint) {
386
+ const scope = new Set([
387
+ ...extractTouchedWikiFiles(args.transcriptPath, args.hypoDir),
388
+ ...closeFileTargets(args.hypoDir),
389
+ ]);
390
+ const { blocking } = partitionLintScope(scopedLint.errors || [], scope);
391
+ if (blocking.length > 0) {
392
+ const files = [...new Set(blocking.map((b) => b.file))];
393
+ const result = {
394
+ ok: false,
395
+ session_id: args.sessionId,
396
+ project: status.project,
397
+ lint_blockers: files,
398
+ error: "session-close gate not satisfied — lint errors in this session's files",
399
+ };
400
+ if (args.json) {
401
+ console.log(JSON.stringify(result, null, 2));
402
+ } else {
403
+ console.log(
404
+ `✗ lint errors in files this session touched — marker not written (fix then re-run):`,
405
+ );
406
+ for (const b of blocking) console.log(` ✗ ${b.file}: ${b.message}`);
407
+ }
408
+ process.exit(1);
409
+ }
410
+ }
411
+ }
360
412
  writeSessionClosedMarker(args.hypoDir, args.sessionId, { project: status.project });
361
413
  // Marker writer swallows IO errors (best-effort, see hypo-shared.mjs). Verify
362
414
  // the file actually landed before claiming success — otherwise CLI exits 0
@@ -364,7 +416,11 @@ function runMarkSessionClosed(args) {
364
416
  // Codex Worker-2 CONCERN (pre-commit review).
365
417
  if (!existsSync(sessionClosedMarkerPath(args.hypoDir, args.sessionId))) {
366
418
  const err = 'marker file did not land after write (likely .cache permission/disk issue)';
367
- console.log(args.json ? JSON.stringify({ ok: false, session_id: args.sessionId, error: err }, null, 2) : `✗ ${err}`);
419
+ console.log(
420
+ args.json
421
+ ? JSON.stringify({ ok: false, session_id: args.sessionId, error: err }, null, 2)
422
+ : `✗ ${err}`,
423
+ );
368
424
  process.exit(1);
369
425
  }
370
426
  const result = {
@@ -376,7 +432,9 @@ function runMarkSessionClosed(args) {
376
432
  if (args.json) {
377
433
  console.log(JSON.stringify(result, null, 2));
378
434
  } else {
379
- console.log(`✓ session-closed marker written (session_id: ${args.sessionId}, project: ${status.project}).`);
435
+ console.log(
436
+ `✓ session-closed marker written (session_id: ${args.sessionId}, project: ${status.project}).`,
437
+ );
380
438
  }
381
439
  process.exit(0);
382
440
  }
@@ -469,6 +527,19 @@ function applySessionClose(args) {
469
527
  if (payload.rootHot) overwriteTargets.add('hot.md');
470
528
  if (payload.openQuestions) overwriteTargets.add(join('pages', 'open-questions.md'));
471
529
 
530
+ // Bug B: the documented close path must not be blocked by lint debt OUTSIDE
531
+ // the files it writes (other projects, shared pages this close did not author).
532
+ // payloadScope = every file this apply writes or appends. Both lint passes are
533
+ // judged against it; errors elsewhere are surfaced as notices, never blocking.
534
+ const payloadScope = new Set([
535
+ join('projects', project, 'session-state.md'),
536
+ join('projects', project, 'hot.md'),
537
+ 'hot.md',
538
+ join('projects', project, 'session-log', `${ym}.md`),
539
+ 'log.md',
540
+ ...(payload.openQuestions ? [join('pages', 'open-questions.md')] : []),
541
+ ]);
542
+
472
543
  let preflightLint;
473
544
  try {
474
545
  preflightLint = runLint(args.hypoDir);
@@ -477,7 +548,13 @@ function applySessionClose(args) {
477
548
  console.log(args.json ? JSON.stringify(out, null, 2) : `✗ ${e.message}`);
478
549
  process.exit(1);
479
550
  }
480
- const blockingErrors = preflightLint.errors.filter((e) => !overwriteTargets.has(e.file));
551
+ // Block only on errors in payload files we are NOT about to overwrite (append
552
+ // targets — session-log, log.md — can't be repaired by appending, so existing
553
+ // corruption there must block). Overwrite targets are about to be replaced;
554
+ // out-of-scope debt is not this close's concern (Bug B).
555
+ const blockingErrors = preflightLint.errors.filter(
556
+ (e) => payloadScope.has(e.file) && !overwriteTargets.has(e.file),
557
+ );
481
558
  if (blockingErrors.length > 0) {
482
559
  const out = {
483
560
  ok: false,
@@ -538,14 +615,19 @@ function applySessionClose(args) {
538
615
 
539
616
  const verification = sessionCloseFileStatus(args.hypoDir);
540
617
 
541
- // Fix #40 post-apply lint: payload may have introduced a broken wikilink or
542
- // a malformed session-state body. Surface as a distinct `stage` so caller can
543
- // tell "lint broke" apart from "frontmatter stale". This runs even if the
544
- // freshness gate also failed — both failure modes are useful to the caller.
618
+ // Fix #40 post-apply lint: payload may have introduced a malformed body or
619
+ // bad frontmatter. Surface as a distinct `stage` so caller can tell "lint
620
+ // broke" apart from "frontmatter stale". This runs even if the freshness gate
621
+ // also failed — both failure modes are useful to the caller.
545
622
  let postApplyLint;
623
+ let postApplyCrashed = false;
546
624
  try {
547
625
  postApplyLint = runLint(args.hypoDir);
548
626
  } catch (e) {
627
+ // A lint crash (unparseable output) after writes is NOT scopeable — there is
628
+ // no reliable `file` to classify — and must stay a HARD failure, exactly as
629
+ // before scoping was introduced.
630
+ postApplyCrashed = true;
549
631
  postApplyLint = {
550
632
  ok: false,
551
633
  errors: [{ file: '(lint crash)', message: e.message }],
@@ -553,9 +635,24 @@ function applySessionClose(args) {
553
635
  };
554
636
  }
555
637
 
556
- const ok = verification.ok && postApplyLint.ok;
638
+ // Scope post-apply lint to payload files (Bug B): a payload-introduced error
639
+ // lands in a file this apply wrote, so it blocks; pre-existing debt elsewhere
640
+ // is a non-blocking notice. A lint crash bypasses scoping and blocks outright.
641
+ let postBlocking;
642
+ let postNotice;
643
+ if (postApplyCrashed) {
644
+ postBlocking = postApplyLint.errors;
645
+ postNotice = [];
646
+ } else {
647
+ ({ blocking: postBlocking, notice: postNotice } = partitionLintScope(
648
+ postApplyLint.errors || [],
649
+ payloadScope,
650
+ ));
651
+ }
652
+ const postLintOk = !postApplyCrashed && postBlocking.length === 0;
653
+ const ok = verification.ok && postLintOk;
557
654
 
558
- // fix #27 PR-C (ADR 0022 amendment 2026-05-19): auto-write the per-session
655
+ // ADR 0022 amendment 2026-05-19: auto-write the per-session
559
656
  // closed marker on a verified close. Hook authority is read-only; this is
560
657
  // one of the two writer paths (the other is --mark-session-closed standalone).
561
658
  // Marker requires BOTH file/lint gate (already in `ok`) AND clean git tree —
@@ -573,7 +670,7 @@ function applySessionClose(args) {
573
670
  }
574
671
  const stage = ok
575
672
  ? null
576
- : !verification.ok && !postApplyLint.ok
673
+ : !verification.ok && !postLintOk
577
674
  ? 'post-apply-verification+lint'
578
675
  : !verification.ok
579
676
  ? 'post-apply-verification'
@@ -587,6 +684,9 @@ function applySessionClose(args) {
587
684
  skipped,
588
685
  verification,
589
686
  lint: { preflight: preflightLint, postApply: postApplyLint },
687
+ // Pre-existing lint debt in files this close did not author (Bug B): surfaced
688
+ // for visibility, never gated. Empty on a clean vault.
689
+ notices: [...new Set(postNotice.map((e) => e.file))],
590
690
  };
591
691
 
592
692
  if (args.json) {
@@ -606,12 +706,21 @@ function applySessionClose(args) {
606
706
  console.log(`\n✗ session-close still incomplete after apply: ${bad}`);
607
707
  console.log(' Fix the payload (likely an `updated:` field) and retry.');
608
708
  }
609
- if (!postApplyLint.ok) {
709
+ if (!postLintOk) {
610
710
  console.log('\n✗ post-apply lint failed:');
611
- for (const e of postApplyLint.errors) console.log(` ✗ ${e.file}: ${e.message}`);
711
+ for (const e of postBlocking) console.log(` ✗ ${e.file}: ${e.message}`);
612
712
  console.log(' Payload introduced a lint blocker — fix the payload content and retry.');
613
713
  }
614
714
  }
715
+ if (postNotice.length > 0) {
716
+ console.log(
717
+ `\n· ${postNotice.length} pre-existing lint issue(s) in untouched files (not blocking): ${[
718
+ ...new Set(postNotice.map((e) => e.file)),
719
+ ]
720
+ .slice(0, 5)
721
+ .join(', ')}${postNotice.length > 5 ? ', …' : ''}`,
722
+ );
723
+ }
615
724
  }
616
725
  process.exit(ok ? 0 : 1);
617
726
  }
@@ -31,7 +31,8 @@ import {
31
31
  EXT_TYPES,
32
32
  CODEX_TYPES,
33
33
  } from './lib/extensions.mjs';
34
- import { sha256, readFileIfRegular } from './lib/pkg-json.mjs';
34
+ import { sha256, readFileIfRegular, readPkgJson } from './lib/pkg-json.mjs';
35
+ import { resolveCliOnPath, classifyInstall } from '../hooks/version-check.mjs';
35
36
 
36
37
  const HOME = homedir();
37
38
  const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
@@ -221,7 +222,7 @@ function checkDirectories(hypoDir) {
221
222
  'projects',
222
223
  'sources',
223
224
  // Extensions baseline (ADR 0024). Existence only — SHA / settings /
224
- // manifest integrity is E5 (#33).
225
+ // manifest integrity is E5 (fix #33).
225
226
  'extensions/hooks',
226
227
  'extensions/commands',
227
228
  'extensions/skills',
@@ -306,10 +307,10 @@ function checkSettingsJson() {
306
307
  fail('settings.json hook registrations', `0/${total} registered — run /hypo:init`);
307
308
  }
308
309
 
309
- // fix #7: stale hypo-* entries (uninstall remnants).
310
+ // stale hypo-* entries (uninstall remnants).
310
311
  // hypo-ext-* commands are user-extension entries (ADR 0024) — not core hooks,
311
312
  // so they are intentionally absent from HOOK_MAP. Excluded here; their
312
- // integrity (SHA + manifest + entry match) is checked separately in E5 (#33).
313
+ // integrity (SHA + manifest + entry match) is checked separately in E5 (fix #33).
313
314
  const isExtCommand = (cmd) => /(?:^|[/\s])hypo-ext-[^/\s]+\.mjs(?=$|["'\s])/.test(cmd);
314
315
  const expectedCmds = new Set(
315
316
  Object.entries(HOOK_MAP).flatMap(([, files]) =>
@@ -342,7 +343,7 @@ function checkSettingsJson() {
342
343
  pass('settings.json stale hypo-* entries', 'None');
343
344
  }
344
345
 
345
- // fix #7: duplicate hypo-* entries per event
346
+ // duplicate hypo-* entries per event
346
347
  const dupes = [];
347
348
  for (const [event, groups] of Object.entries(settings.hooks || {})) {
348
349
  if (!Array.isArray(groups)) continue;
@@ -351,7 +352,7 @@ function checkSettingsJson() {
351
352
  if (!g || typeof g !== 'object') continue;
352
353
  for (const h of g.hooks || []) {
353
354
  if (typeof h.command !== 'string' || !/hypo-[^/]+\.mjs/.test(h.command)) continue;
354
- if (isExtCommand(h.command)) continue; // ext duplicates are E5's concern (#33)
355
+ if (isExtCommand(h.command)) continue; // ext duplicates are E5's concern (fix #33)
355
356
  if (seen.has(h.command)) dupes.push(`${event}:${h.command}`);
356
357
  else seen.add(h.command);
357
358
  }
@@ -538,12 +539,12 @@ function checkSyncState(hypoDir) {
538
539
  }
539
540
 
540
541
  function checkProjectSuggestions(hypoDir) {
541
- // fix #23 / ADR 0023: the auto-project skip-persistence store. Absent file is
542
+ // ADR 0023: the auto-project skip-persistence store. Absent file is
542
543
  // healthy (no offers declined yet). Validate the RAW JSON shape here rather
543
544
  // than via readProjectSuggestions(): that helper deliberately normalizes a
544
545
  // non-array `skips` to [] for fail-open hook reads, which would mask a
545
- // malformed file and silently break permanent "N" suppression (codex review
546
- // 2026-05-22). Doctor must catch the malformation the helper hides.
546
+ // malformed file and silently break permanent "N" suppression. Doctor must
547
+ // catch the malformation the helper hides.
547
548
  const path = projectSuggestionsPath(hypoDir);
548
549
  if (!existsSync(path)) {
549
550
  pass('Auto-project suggestions', 'No skip-persistence file (clean)');
@@ -1008,6 +1009,40 @@ function checkFeedbackProjection(hypoDir, claudeHome, projectId) {
1008
1009
  }
1009
1010
  }
1010
1011
 
1012
+ // ── stale sibling install (ADR 0038, D) ──────────────────────────────────────
1013
+ //
1014
+ // Detect a SECOND, older Hypomnema that owns the `hypomnema` bin on PATH while a
1015
+ // newer copy owns the active hooks. That sibling is a footgun: `hypomnema init` /
1016
+ // `upgrade --apply` routed through it downgrades the active hooks. This is a
1017
+ // detective backstop to the preventive init/upgrade guard — but it must NOT be
1018
+ // the only surface, since `hypomnema doctor` invoked via the stale CLI would run
1019
+ // the OLD doctor (the active-hook notifier covers that live case). fs-only.
1020
+ function checkStaleSibling() {
1021
+ const active = readPkgJson(join(HOME, '.claude', 'hypo-pkg.json'));
1022
+ if (!active || !active.pkgVersion) {
1023
+ pass('PATH CLI vs active install', 'no active metadata (skipped)');
1024
+ return;
1025
+ }
1026
+ const cli = resolveCliOnPath('hypomnema');
1027
+ if (!cli) {
1028
+ pass('PATH CLI vs active install', `no \`hypomnema\` on PATH (active v${active.pkgVersion})`);
1029
+ return;
1030
+ }
1031
+ const verdict = classifyInstall(
1032
+ { pkgRoot: cli.pkgRoot, version: cli.version },
1033
+ { pkgRoot: active.pkgRoot, version: active.pkgVersion },
1034
+ );
1035
+ if (verdict === 'downgrade') {
1036
+ warn(
1037
+ 'PATH CLI vs active install',
1038
+ `stale sibling: \`${cli.binPath}\` is v${cli.version}, active is v${active.pkgVersion} — ` +
1039
+ `running it would DOWNGRADE hooks. Remove with \`npm uninstall -g hypomnema\``,
1040
+ );
1041
+ } else {
1042
+ pass('PATH CLI vs active install', `v${cli.version} (active v${active.pkgVersion})`);
1043
+ }
1044
+ }
1045
+
1011
1046
  // ── main ─────────────────────────────────────────────────────────────────────
1012
1047
 
1013
1048
  const args = parseArgs(process.argv);
@@ -1023,6 +1058,7 @@ if (rootOk) {
1023
1058
  }
1024
1059
  checkHooks();
1025
1060
  checkSettingsJson();
1061
+ checkStaleSibling();
1026
1062
  if (args.codex) checkCodexPaths();
1027
1063
  if (rootOk) checkExtensions(args.hypoDir, args.claudeHome, 'claude');
1028
1064
  if (rootOk && args.codex) checkExtensions(args.hypoDir, args.claudeHome, 'codex');
@@ -205,7 +205,7 @@ function regionHasIntruders(content) {
205
205
  return span.trim().length > 0;
206
206
  }
207
207
 
208
- // ── projection targets (descriptor abstraction — ADR 0032 reuse) ──────────────
208
+ // ── projection targets (descriptor abstraction) ──────────────────────────────
209
209
 
210
210
  const PUBLIC_SENSITIVITY = new Set(['public', 'sanitized']);
211
211
 
@@ -439,7 +439,7 @@ function applyTarget(target, res) {
439
439
  }
440
440
  }
441
441
 
442
- // ── project-id derivation (contract §5, OQ-31.3) ──────────────────────────────
442
+ // ── project-id derivation ─────────────────────────────────────────────────────
443
443
 
444
444
  function deriveProjectId(args) {
445
445
  if (args.projectId) return { id: args.projectId, derived: false, exists: true };
@@ -602,7 +602,7 @@ function bootstrapDraftContent({ title, summary, body, date, origin }) {
602
602
  `title: ${title}`,
603
603
  'type: feedback',
604
604
  'status: draft',
605
- 'scope: TODO # global | project:<slug>',
605
+ 'scope: TODO # global | project:<project-id>',
606
606
  'tier: TODO # L1 (CLAUDE.md <learned_behaviors> candidate) | L2',
607
607
  'targets: [project-memory] # + claude-learned for a global L1 rule',
608
608
  'sensitivity: public # public | sanitized (private is forbidden)',
@@ -660,13 +660,18 @@ function existingPageSlugs(hypoDir) {
660
660
  );
661
661
  }
662
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: [] };
663
+ // Source loader for --bootstrap (input side, symmetric with the output-side
664
+ // target descriptors): read the two legacy projection surfaces — CLAUDE.md
665
+ // <learned_behaviors> + the project MEMORY.md feedback_* index — and shape them
666
+ // into ordered draft candidates. CLAUDE candidates come first (file order from
667
+ // parseLearnedBehaviors), then MEMORY (parseMemoryIndex order); this order drives
668
+ // duplicate handling in runBootstrap, so it must be preserved. Returns
669
+ // { candidates, warnings, skipped } where `skipped` carries the unsafe MEMORY
670
+ // slugs the loader could not sanitize (seeded into report.skipped before the
671
+ // dedup loop appends its own skips).
672
+ function loadBootstrapSources(args) {
669
673
  const warnings = [];
674
+ const skipped = [];
670
675
  const candidates = [];
671
676
 
672
677
  const claudeFile = join(args.claudeHome, 'CLAUDE.md');
@@ -692,7 +697,7 @@ function runBootstrap(args) {
692
697
  for (const e of parseMemoryIndex(readFileSync(memFile, 'utf-8'))) {
693
698
  const slug = safeDraftSlug(e.name.replace(/_/g, '-'));
694
699
  if (!slug) {
695
- report.skipped.push({ slug: e.name, reason: 'unsafe-slug' });
700
+ skipped.push({ slug: e.name, reason: 'unsafe-slug' });
696
701
  continue;
697
702
  }
698
703
  candidates.push({
@@ -710,6 +715,17 @@ function runBootstrap(args) {
710
715
  );
711
716
  }
712
717
 
718
+ return { candidates, warnings, skipped };
719
+ }
720
+
721
+ // --bootstrap: load the two legacy projection surfaces (loadBootstrapSources)
722
+ // and scaffold one draft per deduped candidate.
723
+ function runBootstrap(args) {
724
+ const draftsDir = join(args.hypoDir, 'pages', 'feedback', '_drafts');
725
+ const existing = existingPageSlugs(args.hypoDir);
726
+ const { candidates, warnings, skipped } = loadBootstrapSources(args);
727
+ const report = { mode: 'bootstrap', dryRun: args.dryRun, created: [], skipped: [...skipped] };
728
+
713
729
  const seen = new Set();
714
730
  for (const c of candidates) {
715
731
  if (seen.has(c.slug)) {
@@ -736,20 +752,33 @@ function runBootstrap(args) {
736
752
  return { code: 0, report, warnings };
737
753
  }
738
754
 
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) {
755
+ // Source loader for --import-target-change (input side): select the target
756
+ // projection file (CLAUDE.md or the project MEMORY.md), read it, and return the
757
+ // managed blocks whose inner content no longer matches their marker hash
758
+ // (hand-edited = conflict). This IS the import contract — findBlocks + hash-
759
+ // mismatch filter, NOT projection evaluation. Returns { file, conflicts } or
760
+ // { error } for an invalid --from / missing target file.
761
+ function loadImportConflicts(args) {
742
762
  if (args.from !== 'memory' && args.from !== 'claude') {
743
- return { code: 1, error: '--import-target-change requires --from=memory|claude' };
763
+ return { error: '--import-target-change requires --from=memory|claude' };
744
764
  }
745
765
  const file =
746
766
  args.from === 'claude'
747
767
  ? join(args.claudeHome, 'CLAUDE.md')
748
768
  : join(args.claudeHome, 'projects', deriveProjectId(args).id, 'memory', 'MEMORY.md');
749
- if (!existsSync(file)) return { code: 1, error: `target file not found: ${file}` };
769
+ if (!existsSync(file)) return { error: `target file not found: ${file}` };
750
770
 
751
771
  const { blocks } = findBlocks(readFileSync(file, 'utf-8'));
752
772
  const conflicts = blocks.filter((b) => b.actualHash !== b.declaredHash);
773
+ return { file, conflicts };
774
+ }
775
+
776
+ // --import-target-change --from=<memory|claude>: capture hand-edited (conflict)
777
+ // managed blocks back into drafts so the human can reconcile them into the SoT.
778
+ function runImport(args) {
779
+ const src = loadImportConflicts(args);
780
+ if (src.error) return { code: 1, error: src.error };
781
+ const { file, conflicts } = src;
753
782
  const report = { mode: 'import', from: args.from, dryRun: args.dryRun, imported: [] };
754
783
  const warnings = [];
755
784
  if (!conflicts.length) {
@@ -19,7 +19,7 @@
19
19
  * --title=<text> Frontmatter title (default: topic)
20
20
  *
21
21
  * Classification (lint #8 schema — required on create):
22
- * --scope=<v> global | project:<slug> (required)
22
+ * --scope=<v> global | project:<project-id> (required)
23
23
  * --tier=<v> L1 | L2 (required)
24
24
  * --targets=<list> comma list of project-memory,claude-learned (default: project-memory)
25
25
  * --sensitivity=<v> public | sanitized (default: public)
@@ -47,6 +47,7 @@ import { join } from 'path';
47
47
  import { spawnSync } from 'child_process';
48
48
  import { fileURLToPath } from 'url';
49
49
  import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
50
+ import { FEEDBACK_SCOPE_RE } from './lib/feedback-scope.mjs';
50
51
 
51
52
  const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
52
53
 
@@ -120,7 +121,6 @@ function listTopics(hypoDir) {
120
121
 
121
122
  // ── classification validation (mirrors lint #8 / ADR 0031 §6) ──────────────────
122
123
 
123
- const SCOPE_RE = /^(global|project:[a-z0-9][a-z0-9-]*)$/;
124
124
  const TIER_ENUM = ['L1', 'L2'];
125
125
  const SENSITIVITY_ENUM = ['public', 'sanitized']; // private is forbidden (wiki is git-public)
126
126
  const TARGET_ENUM = ['project-memory', 'claude-learned'];
@@ -135,8 +135,8 @@ function parseTargets(raw) {
135
135
  // Validate the create-mode classification. Returns an array of error strings.
136
136
  function validateClassification(args, targets) {
137
137
  const errs = [];
138
- if (!args.scope) errs.push('--scope is required (global | project:<slug>)');
139
- else if (!SCOPE_RE.test(args.scope)) errs.push(`--scope invalid: "${args.scope}"`);
138
+ if (!args.scope) errs.push('--scope is required (global | project:<project-id>)');
139
+ else if (!FEEDBACK_SCOPE_RE.test(args.scope)) errs.push(`--scope invalid: "${args.scope}"`);
140
140
  if (!args.tier) errs.push('--tier is required (L1 | L2)');
141
141
  else if (!TIER_ENUM.includes(args.tier)) errs.push(`--tier invalid: "${args.tier}"`);
142
142
  if (!SENSITIVITY_ENUM.includes(args.sensitivity))
@@ -222,7 +222,7 @@ function renderPage(args, targets, today) {
222
222
  // it the freshest correction, so it should sort first. Rewrite the `updated:`
223
223
  // line ONLY inside the leading frontmatter block (between the first pair of
224
224
  // `---` fences). A naive multiline replace would also rewrite any body line that
225
- // happens to start with "updated:" (codex review) — so we scope to the fence.
225
+ // happens to start with "updated:" — so we scope to the fence.
226
226
  function bumpUpdated(content, today) {
227
227
  const m = content.match(/^---\n([\s\S]*?)\n---/);
228
228
  if (!m) return content; // no frontmatter → nothing to bump