hypomnema 1.2.1 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.ko.md +4 -2
- package/README.md +4 -2
- package/commands/crystallize.md +23 -6
- package/commands/feedback.md +1 -1
- package/commands/upgrade.md +2 -0
- 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 +73 -19
- package/hooks/hypo-shared.mjs +206 -16
- 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 +130 -16
- 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/plugin-detect.mjs +51 -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/resume.mjs +61 -3
- package/scripts/smoke-pack.mjs +39 -2
- package/scripts/upgrade.mjs +308 -58
- package/skills/crystallize/SKILL.md +13 -2
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +4 -0
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
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* fix-status-verify (CLI) — verify fix→test linkage + ADR-line evidence
|
|
4
|
+
* against wiki spec claims.
|
|
5
|
+
*
|
|
6
|
+
* Phase 1: test-green half (anchors × spec status × runner results).
|
|
7
|
+
* Phase 2 (A-sot): manifest validation + manifest↔anchor drift + ADR core
|
|
8
|
+
* decision grep against the production corpus. See scripts/lib/fix-manifest.mjs
|
|
9
|
+
* and scripts/lib/fix-status-verify.mjs headers.
|
|
10
|
+
*
|
|
11
|
+
* Usage:
|
|
12
|
+
* node scripts/fix-status-verify.mjs [--hypo-dir <path>]
|
|
13
|
+
* [--spec <path>]
|
|
14
|
+
* [--runner <path>]
|
|
15
|
+
* [--test-command "<cmd>"]
|
|
16
|
+
* [--json]
|
|
17
|
+
*
|
|
18
|
+
* Exit 0 if no error-level findings, 1 otherwise.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
22
|
+
import { join, dirname, resolve } from 'node:path';
|
|
23
|
+
import { homedir } from 'node:os';
|
|
24
|
+
import { spawnSync } from 'node:child_process';
|
|
25
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
26
|
+
import {
|
|
27
|
+
parseAnchors,
|
|
28
|
+
parseStatus,
|
|
29
|
+
parseRunnerOutput,
|
|
30
|
+
verifyMatrix,
|
|
31
|
+
isReferenceStub,
|
|
32
|
+
validateManifest,
|
|
33
|
+
checkManifestCoverage,
|
|
34
|
+
checkAdrLines,
|
|
35
|
+
FIX_MANIFEST,
|
|
36
|
+
NO_ADR,
|
|
37
|
+
} from './lib/fix-status-verify.mjs';
|
|
38
|
+
import { buildCorpusSearch } from './lib/adr-corpus.mjs';
|
|
39
|
+
|
|
40
|
+
const REPO = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
41
|
+
|
|
42
|
+
// Production-code corpus for the ADR-line grep (spec §A amendment 2026-06-07:
|
|
43
|
+
// templates/ ships via npm `files`, so prompt-driven fixes are verifiable).
|
|
44
|
+
const CORPUS_DIRS = ['scripts', 'hooks', 'commands', 'skills', 'templates'];
|
|
45
|
+
// MUST exclude the manifest itself — it holds every adrKeyLine as a literal and
|
|
46
|
+
// would self-match, making ADR_LINE_MISSING impossible to ever fire.
|
|
47
|
+
const CORPUS_EXCLUDE = ['scripts/lib/fix-manifest.mjs'];
|
|
48
|
+
|
|
49
|
+
function parseArgs(argv) {
|
|
50
|
+
const out = {
|
|
51
|
+
hypoDir: process.env.HYPO_DIR || join(homedir(), 'hypomnema'),
|
|
52
|
+
spec: null,
|
|
53
|
+
runner: join(REPO, 'tests/runner.mjs'),
|
|
54
|
+
testCommand: 'npm test',
|
|
55
|
+
json: false,
|
|
56
|
+
manifest: null,
|
|
57
|
+
};
|
|
58
|
+
for (let i = 0; i < argv.length; i++) {
|
|
59
|
+
const a = argv[i];
|
|
60
|
+
if (a === '--hypo-dir') out.hypoDir = argv[++i];
|
|
61
|
+
else if (a === '--spec') out.spec = argv[++i];
|
|
62
|
+
else if (a === '--runner') out.runner = argv[++i];
|
|
63
|
+
else if (a === '--test-command') out.testCommand = argv[++i];
|
|
64
|
+
else if (a === '--manifest') out.manifest = argv[++i];
|
|
65
|
+
else if (a === '--json') out.json = true;
|
|
66
|
+
else if (a === '--help' || a === '-h') {
|
|
67
|
+
printHelp();
|
|
68
|
+
process.exit(0);
|
|
69
|
+
} else {
|
|
70
|
+
console.error(`unknown arg: ${a}`);
|
|
71
|
+
process.exit(2);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (!out.spec) {
|
|
75
|
+
out.spec = join(out.hypoDir, 'projects/hypomnema/spec-v1.2.md');
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function printHelp() {
|
|
81
|
+
console.log(
|
|
82
|
+
[
|
|
83
|
+
'fix-status-verify — Phase 1 (test-green half) of learned_behavior #6',
|
|
84
|
+
'',
|
|
85
|
+
'Options:',
|
|
86
|
+
' --hypo-dir <path> Wiki root (default: $HYPO_DIR or ~/hypomnema)',
|
|
87
|
+
' --spec <path> Override spec-v1.2.md path',
|
|
88
|
+
' --runner <path> Override tests/runner.mjs path',
|
|
89
|
+
' --test-command "<cmd>" Test invocation (default: "npm test")',
|
|
90
|
+
' --json Emit machine-readable JSON report',
|
|
91
|
+
'',
|
|
92
|
+
'Exit 0 if no error findings, 1 otherwise.',
|
|
93
|
+
'',
|
|
94
|
+
'NOTE: The default --spec is a `type: reference` redirect stub (the real',
|
|
95
|
+
'spec moved to archive/). Running without --spec fails with STUB_SPEC by',
|
|
96
|
+
'design — pass --spec <real spec> to verify against actual claims.',
|
|
97
|
+
'',
|
|
98
|
+
'Phase 2 (A-sot): also greps each manifest adrKeyLine against the',
|
|
99
|
+
'production corpus (scripts/ hooks/ commands/ skills/ templates/) and',
|
|
100
|
+
'checks manifest↔anchor drift. NO_ADR rows skip the grep (test-green only).',
|
|
101
|
+
].join('\n'),
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function runTests(testCommand) {
|
|
106
|
+
// Parse simple command (no shell metacharacters supported in args; this is
|
|
107
|
+
// a maintainer tool, not a security boundary).
|
|
108
|
+
const parts = testCommand.split(/\s+/).filter(Boolean);
|
|
109
|
+
const [cmd, ...args] = parts;
|
|
110
|
+
const result = spawnSync(cmd, args, {
|
|
111
|
+
cwd: REPO,
|
|
112
|
+
encoding: 'utf-8',
|
|
113
|
+
env: { ...process.env, FORCE_COLOR: '0' },
|
|
114
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
115
|
+
});
|
|
116
|
+
return {
|
|
117
|
+
stdout: result.stdout || '',
|
|
118
|
+
stderr: result.stderr || '',
|
|
119
|
+
exitCode: result.status,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function formatFinding(f) {
|
|
124
|
+
const icon = f.level === 'error' ? '✗' : '⚠';
|
|
125
|
+
// Some findings are not tied to a specific fix # (STUB_SPEC,
|
|
126
|
+
// TEST_RUN_NONZERO_EXIT). Only render the `fix #N` segment when present so
|
|
127
|
+
// they don't print `fix #undefined`.
|
|
128
|
+
const ref = f.fixNum != null ? ` fix #${f.fixNum}` : '';
|
|
129
|
+
return ` ${icon} [${f.class}]${ref}` + (f.testName ? ` (${f.testName})` : '') + `: ${f.detail}`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
async function main() {
|
|
133
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
134
|
+
|
|
135
|
+
// Manifest source: built-in code constant by default (ADR 0036). --manifest
|
|
136
|
+
// <path.mjs> overrides for tests, which inject a fixture manifest matching
|
|
137
|
+
// their synthetic fixes so the real manifest does not couple to fixtures.
|
|
138
|
+
let manifest = FIX_MANIFEST;
|
|
139
|
+
if (opts.manifest) {
|
|
140
|
+
if (!existsSync(opts.manifest)) {
|
|
141
|
+
console.error(`manifest not found: ${opts.manifest}`);
|
|
142
|
+
process.exit(2);
|
|
143
|
+
}
|
|
144
|
+
const mod = await import(pathToFileURL(resolve(opts.manifest)).href);
|
|
145
|
+
manifest = mod.FIX_MANIFEST;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (!existsSync(opts.spec)) {
|
|
149
|
+
console.error(`spec not found: ${opts.spec}`);
|
|
150
|
+
console.error('hint: pass --hypo-dir <path> or set $HYPO_DIR');
|
|
151
|
+
process.exit(2);
|
|
152
|
+
}
|
|
153
|
+
if (!existsSync(opts.runner)) {
|
|
154
|
+
console.error(`runner not found: ${opts.runner}`);
|
|
155
|
+
process.exit(2);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const specText = readFileSync(opts.spec, 'utf-8');
|
|
159
|
+
const runnerText = readFileSync(opts.runner, 'utf-8');
|
|
160
|
+
|
|
161
|
+
const anchors = parseAnchors(runnerText);
|
|
162
|
+
const status = parseStatus(specText);
|
|
163
|
+
const specIsStub = isReferenceStub(specText);
|
|
164
|
+
|
|
165
|
+
if (!opts.json) {
|
|
166
|
+
console.log(`fix-status-verify (Phase 1)`);
|
|
167
|
+
console.log(` spec: ${opts.spec}`);
|
|
168
|
+
console.log(` runner: ${opts.runner}`);
|
|
169
|
+
console.log(` ${status.size} positive status claim(s), ${anchors.size} anchor(s)`);
|
|
170
|
+
console.log(` running: ${opts.testCommand}`);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const testRun = runTests(opts.testCommand);
|
|
174
|
+
const testResults = parseRunnerOutput(testRun.stdout + '\n' + testRun.stderr);
|
|
175
|
+
|
|
176
|
+
if (!opts.json) {
|
|
177
|
+
const passes = [...testResults.values()].filter((v) => v === 'pass').length;
|
|
178
|
+
const fails = [...testResults.values()].filter((v) => v === 'fail').length;
|
|
179
|
+
console.log(` test run: ${passes} pass, ${fails} fail (exit ${testRun.exitCode})`);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const matrixResult = verifyMatrix({ anchors, status, testResults, specIsStub });
|
|
183
|
+
const findings = [...matrixResult.findings];
|
|
184
|
+
|
|
185
|
+
// Phase 2 (A-sot): manifest validation + ADR-line grep. validateManifest and
|
|
186
|
+
// checkAdrLines are spec-independent (manifest/code health) and run always;
|
|
187
|
+
// checkManifestCoverage keys off the spec status (a no-op under STUB_SPEC,
|
|
188
|
+
// where status is empty).
|
|
189
|
+
const needsCorpus = manifest.some((r) => r.adrKeyLine !== NO_ADR);
|
|
190
|
+
const adrSearch = needsCorpus
|
|
191
|
+
? buildCorpusSearch({ repoRoot: REPO, includeDirs: CORPUS_DIRS, excludePaths: CORPUS_EXCLUDE })
|
|
192
|
+
: () => false;
|
|
193
|
+
const adrExists = (adrPath) => existsSync(join(opts.hypoDir, 'projects/hypomnema', adrPath));
|
|
194
|
+
findings.push(...validateManifest(manifest));
|
|
195
|
+
findings.push(...checkManifestCoverage({ manifest, anchors, status }));
|
|
196
|
+
findings.push(...checkAdrLines({ manifest, searchFn: adrSearch, adrExistsFn: adrExists }));
|
|
197
|
+
|
|
198
|
+
// CLI-level error: if the test command itself exited nonzero, the test run
|
|
199
|
+
// is not green even if the anchored tests happen to all pass in the parsed
|
|
200
|
+
// output. Surface as a synthetic error finding so `ok` flips false.
|
|
201
|
+
if (testRun.exitCode !== 0) {
|
|
202
|
+
findings.push({
|
|
203
|
+
level: 'error',
|
|
204
|
+
class: 'TEST_RUN_NONZERO_EXIT',
|
|
205
|
+
detail: `test command "${opts.testCommand}" exited ${testRun.exitCode}`,
|
|
206
|
+
exitCode: testRun.exitCode,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
const ok = !findings.some((f) => f.level === 'error') && testRun.exitCode === 0;
|
|
210
|
+
|
|
211
|
+
const MANDATORY_NOTE =
|
|
212
|
+
'test-linkage + green + ADR-line grep (Phase 2): manifest evidence checked against production corpus';
|
|
213
|
+
|
|
214
|
+
if (opts.json) {
|
|
215
|
+
console.log(
|
|
216
|
+
JSON.stringify(
|
|
217
|
+
{
|
|
218
|
+
ok,
|
|
219
|
+
spec: opts.spec,
|
|
220
|
+
runner: opts.runner,
|
|
221
|
+
statusClaims: status.size,
|
|
222
|
+
anchorCount: anchors.size,
|
|
223
|
+
testsRan: testResults.size,
|
|
224
|
+
testExitCode: testRun.exitCode,
|
|
225
|
+
findings,
|
|
226
|
+
note: MANDATORY_NOTE,
|
|
227
|
+
},
|
|
228
|
+
null,
|
|
229
|
+
2,
|
|
230
|
+
),
|
|
231
|
+
);
|
|
232
|
+
} else {
|
|
233
|
+
const errors = findings.filter((f) => f.level === 'error');
|
|
234
|
+
const warns = findings.filter((f) => f.level === 'warn');
|
|
235
|
+
if (errors.length === 0 && warns.length === 0) {
|
|
236
|
+
console.log(` ✓ all ${status.size} claimed-merged fix(es) verified`);
|
|
237
|
+
} else {
|
|
238
|
+
if (errors.length) {
|
|
239
|
+
console.log(`\nerrors (${errors.length}):`);
|
|
240
|
+
for (const f of errors) console.log(formatFinding(f));
|
|
241
|
+
}
|
|
242
|
+
if (warns.length) {
|
|
243
|
+
console.log(`\nwarnings (${warns.length}):`);
|
|
244
|
+
for (const f of warns) console.log(formatFinding(f));
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
console.log(`\n(${MANDATORY_NOTE})`);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
process.exit(ok ? 0 : 1);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
main().catch((e) => {
|
|
254
|
+
console.error(e);
|
|
255
|
+
process.exit(2);
|
|
256
|
+
});
|
package/scripts/init.mjs
CHANGED
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
readFileIfRegular,
|
|
47
47
|
} from './lib/pkg-json.mjs';
|
|
48
48
|
import { syncExtensions } from './lib/extensions.mjs';
|
|
49
|
+
import { classifyInstall, downgradeGuardMessage } from '../hooks/version-check.mjs';
|
|
49
50
|
|
|
50
51
|
const HOME = homedir();
|
|
51
52
|
const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
|
|
@@ -108,6 +109,7 @@ function parseArgs(argv) {
|
|
|
108
109
|
fromRemote: null,
|
|
109
110
|
shellSetup: true,
|
|
110
111
|
shellConfig: null,
|
|
112
|
+
allowDowngrade: false,
|
|
111
113
|
};
|
|
112
114
|
for (const arg of argv.slice(2)) {
|
|
113
115
|
if (arg === '--help' || arg === '-h') {
|
|
@@ -136,6 +138,8 @@ Init options:
|
|
|
136
138
|
--from-remote=<url> Clone existing Hypomnema wiki from remote and install hooks
|
|
137
139
|
--no-shell Skip shell function setup (~/.zshrc / ~/.bashrc)
|
|
138
140
|
--shell-config=<path> Shell config file path (default: auto-detect)
|
|
141
|
+
--allow-downgrade Override the guard that refuses to overwrite NEWER active
|
|
142
|
+
hooks with an older package (stale-PATH-CLI footgun)
|
|
139
143
|
--dry-run Show what would be done without making changes
|
|
140
144
|
--help, -h Show this help message
|
|
141
145
|
|
|
@@ -160,6 +164,7 @@ docstring at the top of scripts/<command>.mjs.`);
|
|
|
160
164
|
} else if (arg === '--dry-run') args.dryRun = true;
|
|
161
165
|
else if (arg === '--no-shell') args.shellSetup = false;
|
|
162
166
|
else if (arg.startsWith('--shell-config=')) args.shellConfig = expandHome(arg.slice(15));
|
|
167
|
+
else if (arg === '--allow-downgrade') args.allowDowngrade = true;
|
|
163
168
|
}
|
|
164
169
|
return args;
|
|
165
170
|
}
|
|
@@ -419,6 +424,27 @@ function pkgJsonPath() {
|
|
|
419
424
|
return join(HOME, '.claude', 'hypo-pkg.json');
|
|
420
425
|
}
|
|
421
426
|
|
|
427
|
+
/**
|
|
428
|
+
* ADR 0038 (P): abort when this package would DOWNGRADE a newer active install.
|
|
429
|
+
* Exit 2 (distinct from the generic exit-1 error class) on refusal so callers and
|
|
430
|
+
* tests can tell "refused downgrade" apart from "init failed". No-ops on a fresh
|
|
431
|
+
* install (no metadata), unparseable versions, the same package re-running itself,
|
|
432
|
+
* --allow-downgrade, or --dry-run.
|
|
433
|
+
*/
|
|
434
|
+
function maybeRefuseDowngrade(op, allowDowngrade, dryRun) {
|
|
435
|
+
if (allowDowngrade || dryRun) return;
|
|
436
|
+
const active = readPkgJsonSafe(pkgJsonPath());
|
|
437
|
+
if (!active || !active.pkgVersion || !PKG_VERSION) return;
|
|
438
|
+
const verdict = classifyInstall(
|
|
439
|
+
{ pkgRoot: PKG_ROOT, version: PKG_VERSION },
|
|
440
|
+
{ pkgRoot: active.pkgRoot, version: active.pkgVersion },
|
|
441
|
+
);
|
|
442
|
+
if (verdict === 'downgrade') {
|
|
443
|
+
console.error(downgradeGuardMessage(PKG_VERSION, active.pkgVersion, op));
|
|
444
|
+
process.exit(2);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
422
448
|
function writePkgJson(dryRun, extraFields = {}) {
|
|
423
449
|
const dest = pkgJsonPath();
|
|
424
450
|
const existing = readPkgJsonSafe(dest);
|
|
@@ -777,6 +803,17 @@ const args = parseArgs(process.argv);
|
|
|
777
803
|
// Validate hooks.json before any file writes so a bad package leaves no partial state
|
|
778
804
|
const HOOK_MAP = args.hooks || args.codex ? loadHookMap() : null;
|
|
779
805
|
|
|
806
|
+
// Downgrade guard (ADR 0038, P): refuse to overwrite a NEWER active install with
|
|
807
|
+
// an older package. The footgun: a stale `npm i -g hypomnema` owns the `hypomnema`
|
|
808
|
+
// bin on PATH, so `hypomnema init` runs the OLD package and silently downgrades
|
|
809
|
+
// the newer install (dropping features like the update-notifier). Runs
|
|
810
|
+
// UNCONDITIONALLY before the first write — `--no-hooks --no-commands` is NOT safe:
|
|
811
|
+
// init still installs the wiki pre-commit hook (repointed to the stale pkgRoot)
|
|
812
|
+
// and, with `--codex`, writes ~/.codex hooks/settings. The guard no-ops on a fresh
|
|
813
|
+
// install, same realpath'd pkgRoot (dev re-run / npm-link / post-commit sync),
|
|
814
|
+
// --allow-downgrade, or --dry-run, so legitimate flows are unaffected.
|
|
815
|
+
maybeRefuseDowngrade('init', args.allowDowngrade, args.dryRun);
|
|
816
|
+
|
|
780
817
|
if (args.fromRemote) {
|
|
781
818
|
// ── from-remote path: clone → read config → install hooks ──────────────────
|
|
782
819
|
const cloned = cloneFromRemote(args.fromRemote, args.hypoDir, args.dryRun);
|
|
@@ -863,9 +900,13 @@ if (args.hooks) {
|
|
|
863
900
|
mergeSettingsJson(join(HOME, '.claude', 'settings.json'), claudeHooks, args.dryRun, HOOK_MAP);
|
|
864
901
|
}
|
|
865
902
|
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
903
|
+
// Always record the active install identity (pkgRoot + pkgVersion), even for a
|
|
904
|
+
// --no-hooks --no-commands scaffold. That flow still installs the wiki pre-commit
|
|
905
|
+
// hook (and, with --codex, ~/.codex hooks); without a recorded pkgVersion baseline
|
|
906
|
+
// the ADR 0038 downgrade guard has nothing to compare against, so a later stale
|
|
907
|
+
// sibling could repoint those surfaces unguarded. writePkgJson merges (...existing),
|
|
908
|
+
// so this never clobbers a commands/extensions map written elsewhere.
|
|
909
|
+
writePkgJson(args.dryRun, commandSHAs ? { commands: commandSHAs } : {});
|
|
869
910
|
|
|
870
911
|
// 4b. user extensions companion sync (ADR 0024). Runs after
|
|
871
912
|
// writePkgJson so the per-target SHA map is merged into the same hypo-pkg.json
|
|
@@ -887,7 +928,7 @@ if (args.hooks) {
|
|
|
887
928
|
}
|
|
888
929
|
for (const r of extResult.registered) log('merged', `extension ${r}`);
|
|
889
930
|
for (const w of extResult.warnings) log('skipped', `extension: ${w}`);
|
|
890
|
-
// E3 (#31): a hard conflict (unowned/symlinked target) blocks install — surface
|
|
931
|
+
// E3 (fix #31): a hard conflict (unowned/symlinked target) blocks install — surface
|
|
891
932
|
// the recovery and force a non-zero exit. Drift is advisory (resolvable, no block).
|
|
892
933
|
if (extResult.conflicts.length > 0) {
|
|
893
934
|
log('errors', '[WIKI: existing file conflicts. Backup and retry, or use --force-extensions]');
|