hypomnema 1.3.3 → 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, isIgnored } 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 (isIgnored(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
@@ -18,7 +18,7 @@ import { homedir } from 'os';
18
18
  import { spawnSync } from 'child_process';
19
19
  import { fileURLToPath } from 'url';
20
20
  import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
21
- import { loadHypoIgnore, isIgnored } from './lib/hypo-ignore.mjs';
21
+ import { loadHypoIgnore, isScanIgnored } from './lib/hypo-ignore.mjs';
22
22
  import { parseFrontmatter } from './lib/frontmatter.mjs';
23
23
  import { readSyncState, projectSuggestionsPath } from '../hooks/hypo-shared.mjs';
24
24
  import {
@@ -443,7 +443,7 @@ function collectMdFiles(dir, acc = [], hypoDir = '', ignorePatterns = []) {
443
443
  for (const entry of readdirSync(dir)) {
444
444
  if (entry.startsWith('.')) continue;
445
445
  const full = join(dir, entry);
446
- if (hypoDir && isIgnored(full, hypoDir, ignorePatterns)) continue;
446
+ if (hypoDir && isScanIgnored(full, hypoDir, ignorePatterns)) continue;
447
447
  const st = statSync(full);
448
448
  if (st.isDirectory()) collectMdFiles(full, acc, hypoDir, ignorePatterns);
449
449
  else if (extname(entry) === '.md') acc.push(full);
@@ -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, isIgnored } 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 (isIgnored(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
@@ -32,7 +32,7 @@ import {
32
32
  renameSync,
33
33
  unlinkSync,
34
34
  } from 'fs';
35
- import { join, basename } from 'path';
35
+ import { join, basename, dirname } from 'path';
36
36
  import { homedir } from 'os';
37
37
  import { execSync, spawnSync } from 'child_process';
38
38
  import { fileURLToPath } from 'url';
@@ -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();
@@ -171,7 +172,19 @@ docstring at the top of scripts/<command>.mjs.`);
171
172
 
172
173
  // ── result tracking ──────────────────────────────────────────────────────────
173
174
 
174
- const results = { created: [], skipped: [], merged: [], errors: [] };
175
+ const results = { created: [], skipped: [], merged: [], deduped: [], errors: [] };
176
+
177
+ // Stock template basename → legacy hand-authored equivalents to honor instead of
178
+ // injecting a duplicate. The hypo-/wiki- namespace split (the rename that
179
+ // produced hypo-guide from wiki-guide, etc.) means an exact-filename check misses
180
+ // a user's existing page and drops a 0-reference duplicate orphan beside it.
181
+ // Mirrors the explicit HOOK_RENAMES idiom in upgrade.mjs. hypo-guide.md is
182
+ // intentionally absent: the runtime reads it by name, so a mid-migration vault
183
+ // must still receive it even when wiki-guide.md is present.
184
+ const PAGE_EQUIVALENTS = {
185
+ 'hypo-automation.md': ['wiki-automation.md'],
186
+ 'hypo-help.md': ['wiki-help.md'],
187
+ };
175
188
 
176
189
  function log(action, path) {
177
190
  results[action].push(path);
@@ -213,6 +226,19 @@ function copyTemplate(srcName, destPath, dryRun, transform) {
213
226
  log('skipped', destPath);
214
227
  return;
215
228
  }
229
+ // If a legacy hand-authored equivalent already exists, do NOT inject the stock
230
+ // page — that is exactly what produced a duplicate orphan. Skip LOUDLY (own
231
+ // bucket, ⚠ in the summary) so the user can merge manually. Fires in --dry-run
232
+ // too, so a dry run previews the suppression.
233
+ const equivalents = PAGE_EQUIVALENTS[basename(srcName)] || [];
234
+ const existingEquivalent = equivalents.find((name) => existsSync(join(dirname(destPath), name)));
235
+ if (existingEquivalent) {
236
+ log(
237
+ 'deduped',
238
+ `${destPath} — kept existing ${existingEquivalent}; stock ${basename(srcName)} not installed (merge manually if you want the template content)`,
239
+ );
240
+ return;
241
+ }
216
242
  if (!dryRun) {
217
243
  let content = readFileSync(src, 'utf-8');
218
244
  content = content.replace(/YYYY-MM-DD/g, new Date().toISOString().slice(0, 10));
@@ -452,7 +478,7 @@ function writePkgJson(dryRun, extraFields = {}) {
452
478
  ...existing,
453
479
  pkgRoot: PKG_ROOT,
454
480
  pkgVersion: PKG_VERSION,
455
- schemaVersion: '2.0',
481
+ schemaVersion: templateSchemaVersion(PKG_ROOT) ?? '2.1',
456
482
  ...extraFields,
457
483
  };
458
484
  if (!dryRun) {
@@ -1017,6 +1043,10 @@ if (results.skipped.length)
1017
1043
  );
1018
1044
  if (results.merged.length)
1019
1045
  lines.push(`↪ Merged into settings.json:\n${results.merged.map((p) => ` ${p}`).join('\n')}`);
1046
+ if (results.deduped.length)
1047
+ lines.push(
1048
+ `⚠ Skipped to avoid a duplicate — an equivalent page already exists (${results.deduped.length}):\n${results.deduped.map((p) => ` ${p}`).join('\n')}`,
1049
+ );
1020
1050
  if (results.errors.length)
1021
1051
  lines.push(`✗ Errors:\n${results.errors.map((p) => ` ${p}`).join('\n')}`);
1022
1052
 
@@ -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;