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.
@@ -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();
@@ -60,20 +60,14 @@
60
60
  * hard-fails regardless of scope.
61
61
  */
62
62
 
63
- import {
64
- existsSync,
65
- readFileSync,
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, isScanIgnored } from './lib/hypo-ignore.mjs';
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 = collectPages(pagesDir, args.hypoDir, [], ignorePatterns);
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
@@ -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
- warn(
532
- 'Sync state',
533
- `${entries.length} unresolved failure(s) — last: ${last.op || '?'} at ${last.timestamp || '?'}. Inspect .cache/sync-state.json or push/pull manually to clear.`,
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
 
@@ -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
- const appended = existing.trimEnd() + `\n\n## ${today}\n\n${args.entry}\n`;
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 { existsSync, readFileSync, readdirSync, statSync } from 'fs';
18
- import { join, relative, extname, basename } from 'path';
17
+ import { readFileSync } from 'fs';
18
+ import { join } from 'path';
19
19
  import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
20
- import { loadHypoIgnore, isScanIgnored } from './lib/hypo-ignore.mjs';
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) => collectPages(d, args.hypoDir, [], ignorePatterns));
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.0',
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
- fm[line.slice(0, idx).trim()] = line
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
- .replace(/\s*#.*$/, '')
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
+ }