hypomnema 1.3.4 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.ko.md +2 -2
- package/README.md +2 -2
- package/commands/feedback.md +4 -0
- package/docs/ARCHITECTURE.md +4 -2
- package/docs/CONTRIBUTING.md +56 -17
- package/hooks/hypo-auto-commit.mjs +6 -12
- package/hooks/hypo-session-record.mjs +5 -0
- package/hooks/hypo-session-start.mjs +7 -0
- package/hooks/hypo-shared.mjs +68 -1
- package/package.json +5 -2
- package/scripts/bump-version.mjs +20 -6
- package/scripts/check-readme-version.mjs +126 -0
- package/scripts/check-versions.mjs +171 -0
- package/scripts/crystallize.mjs +19 -32
- package/scripts/doctor.mjs +13 -4
- package/scripts/feedback.mjs +68 -1
- package/scripts/graph.mjs +5 -32
- package/scripts/init.mjs +2 -1
- package/scripts/lib/failure-type.mjs +33 -0
- package/scripts/lib/frontmatter.mjs +23 -2
- package/scripts/lib/schema-vocab.mjs +35 -0
- package/scripts/lib/template-schema-version.mjs +21 -0
- package/scripts/lib/wikilink.mjs +156 -0
- package/scripts/lint.mjs +86 -47
- package/scripts/rename.mjs +6 -30
- package/scripts/smoke-plugin.mjs +194 -0
- package/scripts/stats.mjs +22 -2
- package/scripts/upgrade.mjs +11 -3
- package/templates/SCHEMA.md +25 -1
- package/templates/hypo-config.md +1 -1
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// check-versions.mjs — assert every version-carrying file in the repo agrees, and
|
|
3
|
+
// (with --tag) that they all match the release tag. This makes the release pipeline
|
|
4
|
+
// OWN the plugin channel: a forgotten plugin.json / marketplace.json / hypo-config /
|
|
5
|
+
// lockfile bump hard-fails the release instead of publishing a split-version plugin.
|
|
6
|
+
//
|
|
7
|
+
// The set mirrors scripts/bump-version.mjs (package.json, .claude-plugin/plugin.json,
|
|
8
|
+
// .claude-plugin/marketplace.json, templates/hypo-config.md) PLUS package-lock.json,
|
|
9
|
+
// which npm — not bump-version — manages, so it can lag a bump and silently break
|
|
10
|
+
// `npm ci`. README/CHANGELOG carry prose version HISTORY (every past vX.Y.Z), not a
|
|
11
|
+
// single release authority, so they are intentionally excluded (covered by the
|
|
12
|
+
// bilingual + README-reconcile checklist instead).
|
|
13
|
+
//
|
|
14
|
+
// Usage:
|
|
15
|
+
// node scripts/check-versions.mjs # assert all files agree
|
|
16
|
+
// node scripts/check-versions.mjs --tag v1.4.0 # also assert they equal the tag
|
|
17
|
+
// node scripts/check-versions.mjs --root <dir> # point at a fixture (tests)
|
|
18
|
+
// node scripts/check-versions.mjs --json
|
|
19
|
+
//
|
|
20
|
+
// Exit 0 = consistent (and, with --tag, matches). Exit 1 = drift / unreadable / mismatch.
|
|
21
|
+
|
|
22
|
+
import { readFileSync } from 'fs';
|
|
23
|
+
import { join, dirname } from 'path';
|
|
24
|
+
import { fileURLToPath } from 'url';
|
|
25
|
+
|
|
26
|
+
function parseArgs(argv) {
|
|
27
|
+
const args = { root: null, tag: null, json: false };
|
|
28
|
+
for (let i = 0; i < argv.length; i++) {
|
|
29
|
+
const a = argv[i];
|
|
30
|
+
if (a.startsWith('--root=')) args.root = a.slice(7);
|
|
31
|
+
else if (a === '--root') args.root = argv[++i];
|
|
32
|
+
else if (a.startsWith('--tag=')) args.tag = a.slice(6);
|
|
33
|
+
else if (a === '--tag') args.tag = argv[++i];
|
|
34
|
+
else if (a === '--json') args.json = true;
|
|
35
|
+
}
|
|
36
|
+
return args;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
40
|
+
|
|
41
|
+
function readJson(abs) {
|
|
42
|
+
return JSON.parse(readFileSync(abs, 'utf-8'));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Collect {label, version} for every authoritative location. A location that
|
|
46
|
+
// cannot be read or whose field is missing becomes {label, version: null, error}.
|
|
47
|
+
function collectVersions(root) {
|
|
48
|
+
const sources = [];
|
|
49
|
+
const push = (label, fn) => {
|
|
50
|
+
try {
|
|
51
|
+
const v = fn();
|
|
52
|
+
if (typeof v !== 'string' || v.length === 0) {
|
|
53
|
+
sources.push({ label, version: null, error: 'version field missing or empty' });
|
|
54
|
+
} else {
|
|
55
|
+
sources.push({ label, version: v });
|
|
56
|
+
}
|
|
57
|
+
} catch (err) {
|
|
58
|
+
sources.push({ label, version: null, error: err?.message ?? String(err) });
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
push('package.json', () => readJson(join(root, 'package.json')).version);
|
|
63
|
+
|
|
64
|
+
// package-lock.json carries the version in TWO top-level spots (lockfileVersion 3):
|
|
65
|
+
// the root `.version` and `.packages[""].version`. Dependency versions deeper in
|
|
66
|
+
// the tree are NOT release authorities and must not be read.
|
|
67
|
+
push('package-lock.json (root)', () => readJson(join(root, 'package-lock.json')).version);
|
|
68
|
+
push('package-lock.json (packages[""])', () => {
|
|
69
|
+
const lock = readJson(join(root, 'package-lock.json'));
|
|
70
|
+
return lock.packages && lock.packages[''] ? lock.packages[''].version : undefined;
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const pluginName = (() => {
|
|
74
|
+
try {
|
|
75
|
+
return readJson(join(root, '.claude-plugin', 'plugin.json')).name;
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
})();
|
|
80
|
+
|
|
81
|
+
push(
|
|
82
|
+
'.claude-plugin/plugin.json',
|
|
83
|
+
() => readJson(join(root, '.claude-plugin', 'plugin.json')).version,
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
// Select the marketplace entry BY NAME (matching plugin.json), not by position:
|
|
87
|
+
// Claude Code's runtime resolves plugins by name (hooks/version-check.mjs), and a
|
|
88
|
+
// future second marketplace entry would make plugins[0] the wrong authority.
|
|
89
|
+
push('.claude-plugin/marketplace.json (entry: ' + (pluginName ?? '?') + ')', () => {
|
|
90
|
+
const mp = readJson(join(root, '.claude-plugin', 'marketplace.json'));
|
|
91
|
+
const plugins = Array.isArray(mp.plugins) ? mp.plugins : [];
|
|
92
|
+
if (!pluginName)
|
|
93
|
+
throw new Error('plugin.json name unreadable — cannot match marketplace entry');
|
|
94
|
+
const matches = plugins.filter((p) => p && p.name === pluginName);
|
|
95
|
+
if (matches.length !== 1) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`expected exactly one marketplace entry named "${pluginName}", found ${matches.length}`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
return matches[0].version;
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// hypo-config.md frontmatter: version: "X.Y.Z"
|
|
104
|
+
push('templates/hypo-config.md', () => {
|
|
105
|
+
const text = readFileSync(join(root, 'templates', 'hypo-config.md'), 'utf-8');
|
|
106
|
+
const m = text.match(/^version:\s*"?([^"\n]+)"?/m);
|
|
107
|
+
return m ? m[1].trim() : undefined;
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
return sources;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function main() {
|
|
114
|
+
const args = parseArgs(process.argv.slice(2));
|
|
115
|
+
const root = args.root || REPO_ROOT;
|
|
116
|
+
const sources = collectVersions(root);
|
|
117
|
+
|
|
118
|
+
const errored = sources.filter((s) => s.error);
|
|
119
|
+
const versions = [...new Set(sources.filter((s) => s.version).map((s) => s.version))];
|
|
120
|
+
|
|
121
|
+
// Normalize the tag by stripping exactly one leading `v` (release tags are vX.Y.Z,
|
|
122
|
+
// file versions are X.Y.Z). Track tag PRESENCE separately from the normalized
|
|
123
|
+
// value: a bare `v` (or any tag that normalizes to empty / non-semver) must HARD
|
|
124
|
+
// FAIL, not be mistaken for "no tag supplied" — otherwise a `git tag v` push would
|
|
125
|
+
// bypass the release gate. This preserves the old "Validate tag matches package
|
|
126
|
+
// version" guarantee while widening it to every channel.
|
|
127
|
+
const hasTag = args.tag != null;
|
|
128
|
+
const tagVersion = hasTag ? args.tag.replace(/^v/, '') : null;
|
|
129
|
+
const tagValid = hasTag && /^\d+\.\d+\.\d+(-[\w.]+)?$/.test(tagVersion);
|
|
130
|
+
|
|
131
|
+
const consistent = errored.length === 0 && versions.length === 1;
|
|
132
|
+
const tagOk = !hasTag || (tagValid && consistent && versions[0] === tagVersion);
|
|
133
|
+
const ok = consistent && tagOk;
|
|
134
|
+
|
|
135
|
+
if (args.json) {
|
|
136
|
+
console.log(
|
|
137
|
+
JSON.stringify(
|
|
138
|
+
{ ok, consistent, hasTag, tagVersion, tagValid, distinctVersions: versions, sources },
|
|
139
|
+
null,
|
|
140
|
+
2,
|
|
141
|
+
),
|
|
142
|
+
);
|
|
143
|
+
} else {
|
|
144
|
+
const width = Math.max(...sources.map((s) => s.label.length));
|
|
145
|
+
for (const s of sources) {
|
|
146
|
+
const val = s.error ? `ERROR: ${s.error}` : s.version;
|
|
147
|
+
console.log(` ${s.label.padEnd(width)} ${val}`);
|
|
148
|
+
}
|
|
149
|
+
if (errored.length) {
|
|
150
|
+
console.error(`\n✗ ${errored.length} version source(s) unreadable.`);
|
|
151
|
+
} else if (!consistent) {
|
|
152
|
+
console.error(
|
|
153
|
+
`\n✗ version drift — ${versions.length} distinct versions: ${versions.join(', ')}`,
|
|
154
|
+
);
|
|
155
|
+
} else if (hasTag && !tagValid) {
|
|
156
|
+
console.error(`\n✗ tag "${args.tag}" does not normalize to a valid semver version`);
|
|
157
|
+
} else if (!tagOk) {
|
|
158
|
+
console.error(
|
|
159
|
+
`\n✗ tag ${args.tag} (→ ${tagVersion}) does not match the file version ${versions[0]}`,
|
|
160
|
+
);
|
|
161
|
+
} else {
|
|
162
|
+
console.log(
|
|
163
|
+
`\n✓ all version-carrying files agree on ${versions[0]}${tagVersion ? ` (matches tag ${args.tag})` : ''}`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
process.exit(ok ? 0 : 1);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
main();
|
package/scripts/crystallize.mjs
CHANGED
|
@@ -60,20 +60,14 @@
|
|
|
60
60
|
* hard-fails regardless of scope.
|
|
61
61
|
*/
|
|
62
62
|
|
|
63
|
-
import {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
readdirSync,
|
|
67
|
-
statSync,
|
|
68
|
-
writeFileSync,
|
|
69
|
-
mkdirSync,
|
|
70
|
-
renameSync,
|
|
71
|
-
} from 'fs';
|
|
72
|
-
import { join, relative, extname, dirname } from 'path';
|
|
63
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'fs';
|
|
64
|
+
import { join, dirname } from 'path';
|
|
65
|
+
import { hostname } from 'os';
|
|
73
66
|
import { spawnSync } from 'child_process';
|
|
74
67
|
import { fileURLToPath } from 'url';
|
|
75
68
|
import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
|
|
76
|
-
import { loadHypoIgnore
|
|
69
|
+
import { loadHypoIgnore } from './lib/hypo-ignore.mjs';
|
|
70
|
+
import { collectPagesCrystallize, extractWikilinks } from './lib/wikilink.mjs';
|
|
77
71
|
import {
|
|
78
72
|
sessionCloseFileStatus,
|
|
79
73
|
sessionCloseGlobalStatus,
|
|
@@ -756,9 +750,21 @@ function applySessionClose(args) {
|
|
|
756
750
|
// otherwise mistake it for the evidence file. The dated `## [date] ...`
|
|
757
751
|
// heading lives inside the entry, so freshness / derive / design-history
|
|
758
752
|
// are unchanged.
|
|
753
|
+
// PRAC-17 audit fields. The shard frontmatter is git-tracked and synced, so
|
|
754
|
+
// `device` is an INTENTIONAL synced multi-machine identifier (privacy note:
|
|
755
|
+
// docs/ARCHITECTURE.md). It is a CREATOR-only stamp — only the session/
|
|
756
|
+
// machine that first seeds the daily shard is recorded; later same-day
|
|
757
|
+
// appends do not touch it. The per-session-accurate store is the LOCAL
|
|
758
|
+
// (.cache/, gitignored) index.jsonl written by hypo-session-record.mjs.
|
|
759
|
+
// `session_id` is honest naming: the value is the Claude session UUID, and
|
|
760
|
+
// it is present only on the Stop-chain close path that passes --session-id.
|
|
761
|
+
const device = String(hostname() || 'unknown').replace(/[\r\n]/g, '');
|
|
762
|
+
const auditFm =
|
|
763
|
+
(args.sessionId ? `session_id: ${String(args.sessionId).replace(/[\r\n]/g, '')}\n` : '') +
|
|
764
|
+
`device: ${device}\n`;
|
|
759
765
|
const header =
|
|
760
766
|
`---\ntitle: Session Log ${date} (${project})\n` +
|
|
761
|
-
`type: session-log\nupdated: ${date}\n---\n\n` +
|
|
767
|
+
`type: session-log\nupdated: ${date}\n${auditFm}---\n\n` +
|
|
762
768
|
`# Session Log ${date} (${project})\n`;
|
|
763
769
|
const entry = payload.sessionLog.entry;
|
|
764
770
|
const body = entry.endsWith('\n') ? entry : `${entry}\n`;
|
|
@@ -944,21 +950,6 @@ function applySessionClose(args) {
|
|
|
944
950
|
|
|
945
951
|
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
946
952
|
|
|
947
|
-
function collectPages(dir, root, acc = [], ignorePatterns = []) {
|
|
948
|
-
if (!existsSync(dir)) return acc;
|
|
949
|
-
for (const entry of readdirSync(dir)) {
|
|
950
|
-
if (entry.startsWith('.')) continue;
|
|
951
|
-
const full = join(dir, entry);
|
|
952
|
-
if (isScanIgnored(full, root, ignorePatterns)) continue;
|
|
953
|
-
const st = statSync(full);
|
|
954
|
-
if (st.isDirectory()) collectPages(full, root, acc, ignorePatterns);
|
|
955
|
-
else if (extname(entry) === '.md') {
|
|
956
|
-
acc.push({ path: full, rel: relative(root, full) });
|
|
957
|
-
}
|
|
958
|
-
}
|
|
959
|
-
return acc;
|
|
960
|
-
}
|
|
961
|
-
|
|
962
953
|
function parseFrontmatter(content) {
|
|
963
954
|
const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
964
955
|
if (!m) return null;
|
|
@@ -983,10 +974,6 @@ function parseTags(fm) {
|
|
|
983
974
|
.filter(Boolean);
|
|
984
975
|
}
|
|
985
976
|
|
|
986
|
-
function extractWikilinks(content) {
|
|
987
|
-
return [...content.matchAll(/\[\[([^\]|#]+?)(?:[|#][^\]]*?)?\]\]/g)].map((m) => m[1].trim());
|
|
988
|
-
}
|
|
989
|
-
|
|
990
977
|
// ── main ─────────────────────────────────────────────────────────────────────
|
|
991
978
|
|
|
992
979
|
const args = parseArgs(process.argv);
|
|
@@ -1005,7 +992,7 @@ if (args.checkSessionClose) {
|
|
|
1005
992
|
|
|
1006
993
|
const ignorePatterns = loadHypoIgnore(args.hypoDir);
|
|
1007
994
|
const pagesDir = join(args.hypoDir, 'pages');
|
|
1008
|
-
const pages =
|
|
995
|
+
const pages = collectPagesCrystallize(pagesDir, args.hypoDir, ignorePatterns);
|
|
1009
996
|
|
|
1010
997
|
const tagGroups = {}; // tag → [{ slug, title }]
|
|
1011
998
|
const unlinked = []; // pages with no outbound wikilinks
|
package/scripts/doctor.mjs
CHANGED
|
@@ -528,10 +528,19 @@ function checkSyncState(hypoDir) {
|
|
|
528
528
|
pass('Sync state', 'No unresolved sync failures');
|
|
529
529
|
} else {
|
|
530
530
|
const last = entries[entries.length - 1];
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
531
|
+
// A merge conflict needs a real manual merge, not a plain push/pull — give
|
|
532
|
+
// the same explicit guidance session-start does instead of the generic hint.
|
|
533
|
+
if (String(last.op || '').startsWith('conflict')) {
|
|
534
|
+
warn(
|
|
535
|
+
'Sync state',
|
|
536
|
+
`${entries.length} unresolved sync issue(s) — last: remote diverged (merge conflict). Your local work is committed; the other machine's version is on the remote. Resolve with \`git pull --no-rebase\`, fix conflicts, then push.`,
|
|
537
|
+
);
|
|
538
|
+
} else {
|
|
539
|
+
warn(
|
|
540
|
+
'Sync state',
|
|
541
|
+
`${entries.length} unresolved failure(s) — last: ${last.op || '?'} at ${last.timestamp || '?'}. Inspect .cache/sync-state.json or push/pull manually to clear.`,
|
|
542
|
+
);
|
|
543
|
+
}
|
|
535
544
|
}
|
|
536
545
|
}
|
|
537
546
|
|
package/scripts/feedback.mjs
CHANGED
|
@@ -48,6 +48,8 @@ import { spawnSync } from 'child_process';
|
|
|
48
48
|
import { fileURLToPath } from 'url';
|
|
49
49
|
import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
|
|
50
50
|
import { FEEDBACK_SCOPE_RE } from './lib/feedback-scope.mjs';
|
|
51
|
+
import { FAILURE_TYPE_ENUM } from './lib/failure-type.mjs';
|
|
52
|
+
import { parseFrontmatter } from './lib/frontmatter.mjs';
|
|
51
53
|
|
|
52
54
|
const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
|
|
53
55
|
|
|
@@ -69,6 +71,7 @@ function parseArgs(argv) {
|
|
|
69
71
|
promoteToGlobal: false,
|
|
70
72
|
reason: null,
|
|
71
73
|
source: null,
|
|
74
|
+
failureType: null,
|
|
72
75
|
behavior: null,
|
|
73
76
|
claudeHome: null,
|
|
74
77
|
projectId: null,
|
|
@@ -91,6 +94,7 @@ function parseArgs(argv) {
|
|
|
91
94
|
else if (arg === '--promote-to-global') args.promoteToGlobal = true;
|
|
92
95
|
else if (arg.startsWith('--reason=')) args.reason = arg.slice(9);
|
|
93
96
|
else if (arg.startsWith('--source=')) args.source = arg.slice(9);
|
|
97
|
+
else if (arg.startsWith('--failure-type=')) args.failureType = arg.slice(15);
|
|
94
98
|
else if (arg.startsWith('--behavior=')) args.behavior = arg.slice(11);
|
|
95
99
|
else if (arg.startsWith('--claude-home=')) args.claudeHome = expandHome(arg.slice(14));
|
|
96
100
|
else if (arg.startsWith('--project-id=')) args.projectId = arg.slice(13);
|
|
@@ -132,6 +136,16 @@ function parseTargets(raw) {
|
|
|
132
136
|
.filter(Boolean);
|
|
133
137
|
}
|
|
134
138
|
|
|
139
|
+
// FEAT-1: `failure_type` is OPTIONAL — an unset value is always fine. A set value
|
|
140
|
+
// must be one of the eight enum members (same vocabulary lint enforces). Returns
|
|
141
|
+
// an error string or null. Shared by create (validateClassification) and append.
|
|
142
|
+
function failureTypeError(value) {
|
|
143
|
+
if (!value) return null;
|
|
144
|
+
if (!FAILURE_TYPE_ENUM.includes(value))
|
|
145
|
+
return `--failure-type invalid: "${value}" (allowed: ${FAILURE_TYPE_ENUM.join(', ')})`;
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
135
149
|
// Validate the create-mode classification. Returns an array of error strings.
|
|
136
150
|
function validateClassification(args, targets) {
|
|
137
151
|
const errs = [];
|
|
@@ -151,6 +165,8 @@ function validateClassification(args, targets) {
|
|
|
151
165
|
errs.push(`--priority must be an integer 1-5 (got "${args.priority}")`);
|
|
152
166
|
if (!args.memorySummary) errs.push('--memory-summary is required');
|
|
153
167
|
if (!args.reason) errs.push('--reason is required');
|
|
168
|
+
const ftErr = failureTypeError(args.failureType);
|
|
169
|
+
if (ftErr) errs.push(ftErr);
|
|
154
170
|
|
|
155
171
|
// CLAUDE.md projection candidates must be global + L1 (ADR 0031 §6 filter), and
|
|
156
172
|
// carry the two conditional fields (lint #8). Enforce here so we never write a
|
|
@@ -201,6 +217,7 @@ function renderPage(args, targets, today) {
|
|
|
201
217
|
}
|
|
202
218
|
lines.push(`reason: ${oneLine(args.reason)}`);
|
|
203
219
|
lines.push(`source: ${oneLine(args.source || `session:${today}`)}`);
|
|
220
|
+
if (args.failureType) lines.push(`failure_type: ${args.failureType}`);
|
|
204
221
|
lines.push(`corrected_at: ${today}`);
|
|
205
222
|
lines.push(`updated: ${today}`);
|
|
206
223
|
lines.push(`created: ${today}`);
|
|
@@ -233,6 +250,24 @@ function bumpUpdated(content, today) {
|
|
|
233
250
|
return content.replace(m[0], `---\n${bumped}\n---`);
|
|
234
251
|
}
|
|
235
252
|
|
|
253
|
+
// Set `failure_type: <value>` in the leading frontmatter block. Scoped to the
|
|
254
|
+
// first `---` fence (like bumpUpdated) so a body line starting "failure_type:" is
|
|
255
|
+
// never touched; the `^` anchor keeps it top-level (an indented/nested key starts
|
|
256
|
+
// with whitespace and won't match). CRLF-aware (the shared parser accepts CRLF,
|
|
257
|
+
// so this must too — an LF-only match would silently skip a CRLF page) and it
|
|
258
|
+
// REPLACES an existing empty `failure_type:` line rather than leaving it blank.
|
|
259
|
+
// The caller only invokes this when the page has no real value (absent or empty),
|
|
260
|
+
// so a populated top-level key never reaches here.
|
|
261
|
+
function addFailureType(content, value) {
|
|
262
|
+
const m = content.match(/^---(\r?\n)([\s\S]*?)\r?\n---/);
|
|
263
|
+
if (!m) return content; // no frontmatter fence to host the field
|
|
264
|
+
const nl = m[1];
|
|
265
|
+
const fm = /^failure_type:\s*.*$/m.test(m[2])
|
|
266
|
+
? m[2].replace(/^failure_type:\s*.*$/m, `failure_type: ${value}`)
|
|
267
|
+
: `${m[2]}${nl}failure_type: ${value}`;
|
|
268
|
+
return content.replace(m[0], `---${nl}${fm}${nl}---`);
|
|
269
|
+
}
|
|
270
|
+
|
|
236
271
|
function writeFeedback(args, today) {
|
|
237
272
|
const feedbackDir = join(args.hypoDir, 'pages', 'feedback');
|
|
238
273
|
const filePath = join(feedbackDir, `${args.topic}.md`);
|
|
@@ -244,7 +279,39 @@ function writeFeedback(args, today) {
|
|
|
244
279
|
// Append a dated entry; preserve existing frontmatter classification.
|
|
245
280
|
mode = 'append';
|
|
246
281
|
const existing = readFileSync(filePath, 'utf-8');
|
|
247
|
-
|
|
282
|
+
// FEAT-1: failure_type is a per-page classification property. On append,
|
|
283
|
+
// set it if the page has none, error if it conflicts with an existing value,
|
|
284
|
+
// no-op if it matches. Without the flag, append is byte-for-byte unchanged.
|
|
285
|
+
const existingFt = (parseFrontmatter(existing) || {}).failure_type || null;
|
|
286
|
+
if (args.failureType) {
|
|
287
|
+
const ftErr = failureTypeError(args.failureType);
|
|
288
|
+
if (ftErr) {
|
|
289
|
+
console.error(`Error: ${ftErr}`);
|
|
290
|
+
process.exit(1);
|
|
291
|
+
}
|
|
292
|
+
if (existingFt && existingFt !== args.failureType) {
|
|
293
|
+
console.error(
|
|
294
|
+
`Error: failure_type mismatch on append: page has "${existingFt}", ` +
|
|
295
|
+
`--failure-type=${args.failureType}. A feedback page carries a single ` +
|
|
296
|
+
`failure_type; use a separate topic for a different failure type.`,
|
|
297
|
+
);
|
|
298
|
+
process.exit(1);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
let appended = existing.trimEnd() + `\n\n## ${today}\n\n${args.entry}\n`;
|
|
302
|
+
if (args.failureType && !existingFt) {
|
|
303
|
+
appended = addFailureType(appended, args.failureType);
|
|
304
|
+
// Fail loud rather than silently appending without the field: if the page's
|
|
305
|
+
// frontmatter is too malformed to host failure_type (no fence at all), the
|
|
306
|
+
// set-if-absent contract could not be honored.
|
|
307
|
+
if ((parseFrontmatter(appended) || {}).failure_type !== args.failureType) {
|
|
308
|
+
console.error(
|
|
309
|
+
`Error: could not set failure_type on "${args.topic}" — its frontmatter ` +
|
|
310
|
+
`is malformed (no parseable --- block). Fix the page, then retry.`,
|
|
311
|
+
);
|
|
312
|
+
process.exit(1);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
248
315
|
content = bumpUpdated(appended, today);
|
|
249
316
|
} else {
|
|
250
317
|
mode = 'create';
|
package/scripts/graph.mjs
CHANGED
|
@@ -14,10 +14,11 @@
|
|
|
14
14
|
* --min-edges=<n> Only include nodes with at least N edges (default: 0)
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import {
|
|
18
|
-
import { join
|
|
17
|
+
import { readFileSync } from 'fs';
|
|
18
|
+
import { join } from 'path';
|
|
19
19
|
import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
|
|
20
|
-
import { loadHypoIgnore
|
|
20
|
+
import { loadHypoIgnore } from './lib/hypo-ignore.mjs';
|
|
21
|
+
import { collectPagesGraph, extractWikilinks } from './lib/wikilink.mjs';
|
|
21
22
|
|
|
22
23
|
// ── arg parsing ───────────────────────────────────────────────────────────────
|
|
23
24
|
|
|
@@ -32,24 +33,6 @@ function parseArgs(argv) {
|
|
|
32
33
|
return args;
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
// ── page collector ────────────────────────────────────────────────────────────
|
|
36
|
-
|
|
37
|
-
function collectPages(dir, root, pages = [], ignorePatterns = []) {
|
|
38
|
-
if (!existsSync(dir)) return pages;
|
|
39
|
-
for (const entry of readdirSync(dir)) {
|
|
40
|
-
const full = join(dir, entry);
|
|
41
|
-
if (isScanIgnored(full, root, ignorePatterns)) continue;
|
|
42
|
-
const st = statSync(full);
|
|
43
|
-
if (st.isDirectory()) {
|
|
44
|
-
collectPages(full, root, pages, ignorePatterns);
|
|
45
|
-
} else if (extname(entry) === '.md' && !entry.startsWith('.')) {
|
|
46
|
-
const slug = relative(root, full).replace(/\.md$/, '').replace(/\\/g, '/');
|
|
47
|
-
pages.push({ path: full, slug, bare: basename(full, '.md') });
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
return pages;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
36
|
// ── slug resolver ─────────────────────────────────────────────────────────────
|
|
54
37
|
|
|
55
38
|
function buildSlugIndex(pages) {
|
|
@@ -61,16 +44,6 @@ function buildSlugIndex(pages) {
|
|
|
61
44
|
return index;
|
|
62
45
|
}
|
|
63
46
|
|
|
64
|
-
// ── wikilink extractor ────────────────────────────────────────────────────────
|
|
65
|
-
|
|
66
|
-
function extractWikilinks(content) {
|
|
67
|
-
const links = [];
|
|
68
|
-
for (const m of content.matchAll(/\[\[([^\]|#]+?)(?:[|#][^\]]*?)?\]\]/g)) {
|
|
69
|
-
links.push(m[1].trim());
|
|
70
|
-
}
|
|
71
|
-
return links;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
47
|
// ── graph builder ─────────────────────────────────────────────────────────────
|
|
75
48
|
|
|
76
49
|
function buildGraph(pages, slugIndex) {
|
|
@@ -180,7 +153,7 @@ const args = parseArgs(process.argv);
|
|
|
180
153
|
|
|
181
154
|
const ignorePatterns = loadHypoIgnore(args.hypoDir);
|
|
182
155
|
const scanDirs = ['pages', 'projects'].map((d) => join(args.hypoDir, d));
|
|
183
|
-
const pages = scanDirs.flatMap((d) =>
|
|
156
|
+
const pages = scanDirs.flatMap((d) => collectPagesGraph(d, args.hypoDir, ignorePatterns));
|
|
184
157
|
const slugIndex = buildSlugIndex(pages);
|
|
185
158
|
const graph = buildGraph(pages, slugIndex);
|
|
186
159
|
|
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 { templateSchemaVersion } from './lib/template-schema-version.mjs';
|
|
49
50
|
import { classifyInstall, downgradeGuardMessage } from '../hooks/version-check.mjs';
|
|
50
51
|
|
|
51
52
|
const HOME = homedir();
|
|
@@ -477,7 +478,7 @@ function writePkgJson(dryRun, extraFields = {}) {
|
|
|
477
478
|
...existing,
|
|
478
479
|
pkgRoot: PKG_ROOT,
|
|
479
480
|
pkgVersion: PKG_VERSION,
|
|
480
|
-
schemaVersion: '2.
|
|
481
|
+
schemaVersion: templateSchemaVersion(PKG_ROOT) ?? '2.1',
|
|
481
482
|
...extraFields,
|
|
482
483
|
};
|
|
483
484
|
if (!dryRun) {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Feedback page `failure_type:` field vocabulary — shared single source of truth.
|
|
2
|
+
//
|
|
3
|
+
// Consumed by:
|
|
4
|
+
// - scripts/lint.mjs (lint-time enum validation of feedback frontmatter)
|
|
5
|
+
// - scripts/feedback.mjs (create/append-time --failure-type validation)
|
|
6
|
+
// Keep this the ONLY definition; both consumers import it so the two validators
|
|
7
|
+
// never drift (mirrors feedback-scope.mjs / ADR 0034). stats.mjs aggregates by
|
|
8
|
+
// plain string and does not validate, so it is intentionally not a consumer.
|
|
9
|
+
//
|
|
10
|
+
// `failure_type` is an OPTIONAL field: it classifies feedback that came from a
|
|
11
|
+
// real failure incident. Pure preferences / new conventions ("always do X")
|
|
12
|
+
// omit it. Order below is the precedence used when classifying — most specific
|
|
13
|
+
// first (a failure that matches several is labeled by the earliest match):
|
|
14
|
+
// hallucination fabricated a fact / API / path
|
|
15
|
+
// false-completion declared "done" without running the required gate/test
|
|
16
|
+
// process-stall stopped instead of asking / continuing when it should
|
|
17
|
+
// over-caution re-asked / re-gated despite standing authority
|
|
18
|
+
// overreach acted beyond the requested scope
|
|
19
|
+
// incompleteness started correctly but omitted a required step / scope
|
|
20
|
+
// instruction-miss ignored an explicit this-session instruction
|
|
21
|
+
// convention-violation broke a standing documented convention (not restated)
|
|
22
|
+
// The runtime does NOT enforce the precedence tree — it only validates that a
|
|
23
|
+
// supplied value is one of these eight; classification is a human judgement.
|
|
24
|
+
export const FAILURE_TYPE_ENUM = [
|
|
25
|
+
'hallucination',
|
|
26
|
+
'false-completion',
|
|
27
|
+
'process-stall',
|
|
28
|
+
'over-caution',
|
|
29
|
+
'overreach',
|
|
30
|
+
'incompleteness',
|
|
31
|
+
'instruction-miss',
|
|
32
|
+
'convention-violation',
|
|
33
|
+
];
|
|
@@ -1,14 +1,35 @@
|
|
|
1
|
+
// A YAML block sequence entry: `-` followed by whitespace or end-of-line.
|
|
2
|
+
// Narrower than `startsWith('-')` so a (nonstandard) plain key like `-key:` is
|
|
3
|
+
// still read rather than mistaken for a list item.
|
|
4
|
+
export const SEQUENCE_ENTRY_RE = /^-(\s|$)/;
|
|
5
|
+
|
|
6
|
+
// Lenient, top-level-only frontmatter field extractor (NOT a YAML parser).
|
|
7
|
+
// Reads only unindented `key: value` lines, skipping indented lines and list
|
|
8
|
+
// items, so a nested mapping (e.g. a `type:` inside a `relations:` list) cannot
|
|
9
|
+
// clobber the page's real top-level field. Without this a `learning` page
|
|
10
|
+
// carrying a relations block was mis-read as `type: depends_on` and silently
|
|
11
|
+
// dropped by type-routed consumers (doctor's verify-freshness scan, lint's
|
|
12
|
+
// type check). First-wins on a repeated top-level key. Assumes the Hypomnema
|
|
13
|
+
// convention of unindented root fields (templates/SCHEMA.md §3). scripts/lint.mjs
|
|
14
|
+
// imports this and adds a separate W9 pass for invalid-YAML classes a real
|
|
15
|
+
// parser would reject.
|
|
1
16
|
export function parseFrontmatter(content) {
|
|
2
17
|
const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
3
18
|
if (!m) return null;
|
|
4
19
|
const fm = {};
|
|
5
20
|
for (const line of m[1].split(/\r?\n/)) {
|
|
21
|
+
if (/^\s/.test(line) || SEQUENCE_ENTRY_RE.test(line)) continue; // nested / list item
|
|
6
22
|
const idx = line.indexOf(':');
|
|
7
23
|
if (idx < 0) continue;
|
|
8
|
-
|
|
24
|
+
const key = line.slice(0, idx).trim();
|
|
25
|
+
if (!key || Object.hasOwn(fm, key)) continue; // first-wins
|
|
26
|
+
fm[key] = line
|
|
9
27
|
.slice(idx + 1)
|
|
10
28
|
.trim()
|
|
11
|
-
|
|
29
|
+
// strip a trailing YAML comment: `#` must follow whitespace to start one,
|
|
30
|
+
// so `concept#bad` stays literal (and still trips lint's unknown-type W2)
|
|
31
|
+
// while `concept # note` loses the comment.
|
|
32
|
+
.replace(/\s+#.*$/, '')
|
|
12
33
|
.replace(/^["']|["']$/g, '');
|
|
13
34
|
}
|
|
14
35
|
return fm;
|
|
@@ -94,3 +94,38 @@ export function parseSchemaPageDirs(hypoDir) {
|
|
|
94
94
|
}
|
|
95
95
|
return dirs;
|
|
96
96
|
}
|
|
97
|
+
|
|
98
|
+
// Type-token shape: lowercase identifier (a-z, digits, hyphen). Excludes
|
|
99
|
+
// memory-layer rows whose first cell is a filename (`hot.md`, `log.md`) — those
|
|
100
|
+
// carry a `.` and never match — so only real type names survive.
|
|
101
|
+
const TYPE_TOKEN_RE = /^[a-z][a-z0-9-]+$/;
|
|
102
|
+
|
|
103
|
+
// Derive the set of valid page `type` values from the SCHEMA "Page Type
|
|
104
|
+
// Taxonomy" table — the FIRST backticked cell of each table row (the type
|
|
105
|
+
// column). Single source of truth, mirroring parseSchemaPageDirs: adding a type
|
|
106
|
+
// row to a vault's SCHEMA automatically widens the accepted types, so a
|
|
107
|
+
// vault-local extension (e.g. working-doc / draft / qa-run) stops tripping the
|
|
108
|
+
// W2 unknown-type warning without editing lint. Returns an empty Set when
|
|
109
|
+
// SCHEMA.md or the table is absent — callers union this with a hardcoded core so
|
|
110
|
+
// the core types never depend on a present/complete SCHEMA.
|
|
111
|
+
export function parseSchemaTypes(hypoDir) {
|
|
112
|
+
const path = join(hypoDir, 'SCHEMA.md');
|
|
113
|
+
if (!existsSync(path)) return new Set();
|
|
114
|
+
const content = readFileSync(path, 'utf-8');
|
|
115
|
+
|
|
116
|
+
const headerMatch = TYPE_TAXONOMY_HEADER_RE.exec(content);
|
|
117
|
+
if (!headerMatch) return new Set();
|
|
118
|
+
|
|
119
|
+
const sectionStart = headerMatch.index + headerMatch[0].length;
|
|
120
|
+
const rest = content.slice(sectionStart);
|
|
121
|
+
const nextH2 = NEXT_H2_RE.exec(rest);
|
|
122
|
+
const section = nextH2 ? rest.slice(0, nextH2.index) : rest;
|
|
123
|
+
|
|
124
|
+
const types = new Set();
|
|
125
|
+
for (const rawLine of section.split('\n')) {
|
|
126
|
+
if (!rawLine.trimStart().startsWith('|')) continue;
|
|
127
|
+
const m = rawLine.match(/`([^`]+)`/); // first backtick token = type column
|
|
128
|
+
if (m && TYPE_TOKEN_RE.test(m[1].trim())) types.add(m[1].trim());
|
|
129
|
+
}
|
|
130
|
+
return types;
|
|
131
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { parseFrontmatter } from './frontmatter.mjs';
|
|
4
|
+
|
|
5
|
+
// The SCHEMA version this package ships, read from templates/SCHEMA.md
|
|
6
|
+
// frontmatter. init.mjs / upgrade.mjs stamp it into hypo-pkg.json metadata;
|
|
7
|
+
// deriving it here (rather than hardcoding a literal at each write site) keeps
|
|
8
|
+
// the stamped value from going stale on a schema bump — the failure mode that
|
|
9
|
+
// FEAT-1's 2.0 → 2.1 bump would otherwise have introduced. Returns null only if
|
|
10
|
+
// the template is missing/unreadable (a broken package), in which case callers
|
|
11
|
+
// keep their prior literal default.
|
|
12
|
+
export function templateSchemaVersion(pkgRoot) {
|
|
13
|
+
const p = join(pkgRoot, 'templates', 'SCHEMA.md');
|
|
14
|
+
if (!existsSync(p)) return null;
|
|
15
|
+
try {
|
|
16
|
+
const v = (parseFrontmatter(readFileSync(p, 'utf-8')) || {}).version;
|
|
17
|
+
return v ? String(v) : null;
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
}
|