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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/commands/crystallize.md +23 -6
- package/commands/feedback.md +1 -1
- package/docs/CONTRIBUTING.md +96 -11
- package/hooks/hypo-auto-commit.mjs +3 -3
- package/hooks/hypo-auto-minimal-crystallize.mjs +8 -3
- package/hooks/hypo-cwd-change.mjs +2 -2
- package/hooks/hypo-first-prompt.mjs +1 -1
- package/hooks/hypo-personal-check.mjs +57 -7
- package/hooks/hypo-session-start.mjs +51 -4
- package/hooks/hypo-shared.mjs +137 -12
- package/hooks/version-check.mjs +204 -6
- package/package.json +5 -2
- package/scripts/bump-version.mjs +9 -3
- package/scripts/check-bilingual.mjs +115 -0
- package/scripts/crystallize.mjs +124 -15
- package/scripts/doctor.mjs +45 -9
- package/scripts/feedback-sync.mjs +44 -15
- package/scripts/feedback.mjs +5 -5
- package/scripts/fix-status-verify.mjs +256 -0
- package/scripts/init.mjs +45 -4
- package/scripts/install-git-hooks.mjs +258 -0
- package/scripts/lib/adr-corpus.mjs +79 -0
- package/scripts/lib/check-bilingual.mjs +141 -0
- package/scripts/lib/extensions.mjs +3 -3
- package/scripts/lib/feedback-scope.mjs +21 -0
- package/scripts/lib/fix-manifest.mjs +109 -0
- package/scripts/lib/fix-status-verify.mjs +438 -0
- package/scripts/lib/pre-commit-format.mjs +251 -0
- package/scripts/lib/project-create.mjs +2 -2
- package/scripts/lint.mjs +48 -8
- package/scripts/pre-commit-format.mjs +198 -0
- package/scripts/smoke-pack.mjs +16 -0
- package/scripts/upgrade.mjs +55 -23
- package/skills/crystallize/SKILL.md +13 -2
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +4 -0
package/scripts/crystallize.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
|
542
|
-
//
|
|
543
|
-
//
|
|
544
|
-
//
|
|
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
|
-
|
|
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
|
-
//
|
|
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 && !
|
|
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 (!
|
|
709
|
+
if (!postLintOk) {
|
|
610
710
|
console.log('\n✗ post-apply lint failed:');
|
|
611
|
-
for (const e of
|
|
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
|
}
|
package/scripts/doctor.mjs
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
546
|
-
//
|
|
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
|
|
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
|
|
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:<
|
|
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
|
|
664
|
-
//
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
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
|
-
|
|
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
|
|
740
|
-
//
|
|
741
|
-
|
|
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 {
|
|
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 {
|
|
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) {
|
package/scripts/feedback.mjs
CHANGED
|
@@ -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:<
|
|
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:<
|
|
139
|
-
else if (!
|
|
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:"
|
|
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
|