hypomnema 1.3.4 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.ko.md +151 -153
  4. package/README.md +121 -123
  5. package/commands/audit.md +4 -4
  6. package/commands/crystallize.md +18 -5
  7. package/commands/doctor.md +3 -3
  8. package/commands/feedback.md +9 -3
  9. package/commands/graph.md +3 -3
  10. package/commands/ingest.md +6 -4
  11. package/commands/init.md +5 -3
  12. package/commands/lint.md +3 -3
  13. package/commands/query.md +3 -3
  14. package/commands/rename.md +4 -4
  15. package/commands/resume.md +4 -2
  16. package/commands/stats.md +3 -3
  17. package/commands/upgrade.md +4 -4
  18. package/commands/verify.md +3 -3
  19. package/docs/ARCHITECTURE.md +4 -2
  20. package/docs/CONTRIBUTING.md +148 -25
  21. package/hooks/hypo-auto-commit.mjs +6 -12
  22. package/hooks/hypo-session-record.mjs +5 -0
  23. package/hooks/hypo-session-start.mjs +7 -0
  24. package/hooks/hypo-shared.mjs +68 -1
  25. package/package.json +10 -2
  26. package/scripts/check-bilingual.mjs +49 -11
  27. package/scripts/check-readme-version.mjs +126 -0
  28. package/scripts/check-tracker-ids.mjs +60 -1
  29. package/scripts/check-versions.mjs +171 -0
  30. package/scripts/crystallize.mjs +49 -34
  31. package/scripts/doctor.mjs +13 -4
  32. package/scripts/feedback.mjs +68 -1
  33. package/scripts/graph.mjs +5 -32
  34. package/scripts/init.mjs +2 -1
  35. package/scripts/lib/changelog-classify.mjs +216 -0
  36. package/scripts/lib/check-bilingual.mjs +125 -22
  37. package/scripts/lib/check-tracker-ids.mjs +19 -0
  38. package/scripts/lib/failure-type.mjs +33 -0
  39. package/scripts/lib/frontmatter.mjs +23 -2
  40. package/scripts/lib/schema-vocab.mjs +35 -0
  41. package/scripts/lib/template-schema-version.mjs +21 -0
  42. package/scripts/lib/wikilink.mjs +156 -0
  43. package/scripts/lint.mjs +86 -47
  44. package/scripts/rename.mjs +6 -30
  45. package/scripts/stats.mjs +22 -2
  46. package/scripts/upgrade.mjs +11 -3
  47. package/scripts/weekly-report.mjs +9 -3
  48. package/skills/crystallize/SKILL.md +21 -1
  49. package/templates/SCHEMA.md +25 -1
  50. package/templates/hypo-config.md +1 -1
  51. package/scripts/bump-version.mjs +0 -63
  52. package/scripts/smoke-pack.mjs +0 -261
package/scripts/lint.mjs CHANGED
@@ -17,13 +17,21 @@
17
17
  */
18
18
 
19
19
  import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from 'fs';
20
- import { join, relative, extname, basename } from 'path';
20
+ import { join, extname, basename } from 'path';
21
21
  import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
22
22
  import { SESSION_STATE_NEXT_HEADINGS } from '../hooks/hypo-shared.mjs';
23
23
  import { loadHypoIgnore, isScanIgnored } from './lib/hypo-ignore.mjs';
24
- import { parseSchemaVocab, checkForbidden, parseSchemaPageDirs } from './lib/schema-vocab.mjs';
24
+ import {
25
+ parseSchemaVocab,
26
+ checkForbidden,
27
+ parseSchemaPageDirs,
28
+ parseSchemaTypes,
29
+ } from './lib/schema-vocab.mjs';
25
30
  import { findDesignHistoryStale } from './lib/design-history-stale.mjs';
26
31
  import { FEEDBACK_SCOPE_RE } from './lib/feedback-scope.mjs';
32
+ import { FAILURE_TYPE_ENUM } from './lib/failure-type.mjs';
33
+ import { collectPagesLint, slugForms } from './lib/wikilink.mjs';
34
+ import { parseFrontmatter, SEQUENCE_ENTRY_RE } from './lib/frontmatter.mjs';
27
35
 
28
36
  // ── arg parsing ──────────────────────────────────────────────────────────────
29
37
 
@@ -41,19 +49,45 @@ function parseArgs(argv) {
41
49
 
42
50
  // ── frontmatter parser ────────────────────────────────────────────────────────
43
51
 
44
- function parseFrontmatter(content) {
45
- const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
46
- if (!m) return null;
47
- const fm = {};
48
- for (const line of m[1].split('\n')) {
52
+ // ── W9: narrow invalid-YAML detector ───────────────────────────────────────────
53
+ // NOT a full YAML parser (zero-dep policy). Catches only specific classes the
54
+ // lenient line-scanner (parseFrontmatter, imported from lib) silently passes but
55
+ // a real YAML parser (js-yaml, what Obsidian uses) rejects. Each class is
56
+ // conservative it may miss, but must NOT false-positive on valid YAML, so it
57
+ // inspects only top-level (unindented) lines: indented lines may be block-scalar
58
+ // content where ": "/tabs are legal, and reliably telling content from structure
59
+ // needs a real parser. (A leading-tab class was considered and dropped for this
60
+ // reason — it cannot distinguish `relations:\n\t- x` from a tabbed block-scalar
61
+ // body without tracking block context.) Returns an array of reasons.
62
+ function checkYamlInvalid(block) {
63
+ const reasons = [];
64
+ const seen = new Set();
65
+ for (const raw of block.split('\n')) {
66
+ const line = raw.replace(/\r$/, '');
67
+ if (!line.trim()) continue;
68
+ if (/^\s/.test(line) || SEQUENCE_ENTRY_RE.test(line)) continue; // top-level keys only
49
69
  const idx = line.indexOf(':');
50
70
  if (idx < 0) continue;
51
- fm[line.slice(0, idx).trim()] = line
52
- .slice(idx + 1)
53
- .trim()
54
- .replace(/^["']|["']$/g, '');
71
+ const key = line.slice(0, idx).trim();
72
+ if (!key) continue;
73
+ // duplicate top-level key (YAML rejects; line-scanner silently keeps one)
74
+ if (seen.has(key)) reasons.push(`duplicate key: "${key}"`);
75
+ else seen.add(key);
76
+ // colon-space inside an unquoted, non-flow plain scalar. A plain scalar may
77
+ // not contain ": " — js-yaml hard-errors or silently truncates. Skip quoted
78
+ // ("/') and flow ([/{) values where ": " is legal, and strip an inline
79
+ // " #" comment before scanning.
80
+ let val = line.slice(idx + 1).trim();
81
+ const c = val[0];
82
+ if (val && c !== '"' && c !== "'" && c !== '[' && c !== '{') {
83
+ const hash = val.indexOf(' #');
84
+ if (hash >= 0) val = val.slice(0, hash);
85
+ if (/:\s/.test(val)) {
86
+ reasons.push(`unquoted "${key}" value contains ": " (quote it): "${val.slice(0, 40)}"`);
87
+ }
88
+ }
55
89
  }
56
- return fm;
90
+ return reasons;
57
91
  }
58
92
 
59
93
  function parseTagsField(rawValue) {
@@ -108,31 +142,14 @@ const TYPE_ENUM_FIELDS = {
108
142
  status: ['active', 'superseded', 'archived'],
109
143
  tier: ['L1', 'L2'],
110
144
  sensitivity: ['public', 'sanitized'],
145
+ // FEAT-1: optional failure taxonomy. The enum loop guards on `if (fm[field])`,
146
+ // so an omitted failure_type is never validated (optional, migration-safe).
147
+ failure_type: FAILURE_TYPE_ENUM,
111
148
  },
112
149
  };
113
150
 
114
151
  // ── page collector ────────────────────────────────────────────────────────────
115
152
 
116
- function collectPages(dir, root, pages = [], ignorePatterns = []) {
117
- if (!existsSync(dir)) return pages;
118
- for (const entry of readdirSync(dir)) {
119
- const full = join(dir, entry);
120
- if (isScanIgnored(full, root, ignorePatterns)) continue;
121
- const st = statSync(full);
122
- if (st.isDirectory()) {
123
- // `_`-prefixed dirs (e.g. pages/feedback/_drafts) hold scaffolds / scratch
124
- // not yet promoted to content — skip so incomplete frontmatter never errors
125
- // (mirrors loadFeedbackPages in feedback-sync.mjs). `_`-prefixed *files*
126
- // (e.g. _index.md) are still linted.
127
- if (entry.startsWith('_')) continue;
128
- collectPages(full, root, pages, ignorePatterns);
129
- } else if (extname(entry) === '.md' && !entry.startsWith('.')) {
130
- pages.push({ path: full, rel: relative(root, full) });
131
- }
132
- }
133
- return pages;
134
- }
135
-
136
153
  // ── slug map ─────────────────────────────────────────────────────────────────
137
154
 
138
155
  // `extraTargets` are link-target-only slugs (root *.md, sources/*) that resolve
@@ -142,13 +159,14 @@ function buildSlugMap(pages, extraTargets = []) {
142
159
  const map = new Set();
143
160
  for (const { rel } of pages) {
144
161
  const noExt = rel.replace(/\.md$/, '').replace(/\\/g, '/');
145
- map.add(noExt);
146
- map.add(basename(rel, '.md'));
147
- // Dir-relative alias: drop the leading scan-dir segment so the vault's
148
- // convention link [[learnings/foo]] resolves to pages/learnings/foo.md.
149
- // (A page directly under a scan dir has no extra segment to drop.)
150
- const slash = noExt.indexOf('/');
151
- if (slash !== -1) map.add(noExt.slice(slash + 1));
162
+ // full slug + bare basename + dir-relative alias (drop the leading scan-dir
163
+ // segment so the convention link [[learnings/foo]] resolves to
164
+ // pages/learnings/foo.md). slugForms returns dirRel=null when the slug has no
165
+ // `/` (a page directly under a scan dir has no extra segment to drop).
166
+ const { full, bare, dirRel } = slugForms(noExt);
167
+ map.add(full);
168
+ map.add(bare);
169
+ if (dirRel) map.add(dirRel);
152
170
  }
153
171
  for (const t of extraTargets) map.add(t);
154
172
  return map;
@@ -165,7 +183,7 @@ function collectLinkTargets(hypoDir, ignorePatterns = []) {
165
183
  for (const entry of readdirSync(hypoDir)) {
166
184
  const full = join(hypoDir, entry);
167
185
  // root-level *.md FILES only (no recursion), honoring .hypoignore plus the
168
- // scan-only generated-artifact exclusions (isScanIgnored) like collectPages
186
+ // scan-only generated-artifact exclusions (isScanIgnored) like collectPagesLint
169
187
  // — otherwise an ignored root file (e.g. a secret) or a regenerable report
170
188
  // would resolve [[its-slug]] as valid, a false negative.
171
189
  if (
@@ -180,7 +198,7 @@ function collectLinkTargets(hypoDir, ignorePatterns = []) {
180
198
  }
181
199
  // sources/*: linkable as the full 'sources/<name>' slug only — deliberately no
182
200
  // bare basename, so a stale [[name]] can't silently resolve to a source file.
183
- for (const { rel } of collectPages(join(hypoDir, 'sources'), hypoDir, [], ignorePatterns)) {
201
+ for (const { rel } of collectPagesLint(join(hypoDir, 'sources'), hypoDir, ignorePatterns)) {
184
202
  targets.push(rel.replace(/\.md$/, '').replace(/\\/g, '/'));
185
203
  }
186
204
  return targets;
@@ -259,10 +277,15 @@ const issues = [];
259
277
  // STRICT_PROMOTE_IDS (OQ-E1, frozen as a code constant): confirmed content
260
278
  // defects only.
261
279
  // W1 no-frontmatter / W2 unknown-type / W4 broken-wikilink → promote.
280
+ // W9 invalid-YAML → promote (frontmatter a real YAML parser rejects).
262
281
  // W3 missing-updated → excluded (auto-repaired by --fix).
263
282
  // W8 design-history-stale → excluded (hypo-personal-check handles it; would
264
283
  // double-gate).
265
- const STRICT_PROMOTE_IDS = new Set(['W1', 'W2', 'W4']);
284
+ // NOTE: no gate currently passes --strict (npm run lint / CI / release.yml /
285
+ // crystallize / the close-gate all run plain lint), so promotion is
286
+ // forward-looking — these surface as warnings today. IMPR-3's deliverable is
287
+ // "stop silently green-passing invalid YAML"; the W9 warning satisfies that.
288
+ const STRICT_PROMOTE_IDS = new Set(['W1', 'W2', 'W4', 'W9']);
266
289
 
267
290
  function issue(severity, rel, msg, fullPath = null, id = null) {
268
291
  issues.push({ severity, file: rel, message: msg, path: fullPath, id });
@@ -285,7 +308,7 @@ function lintSessionStateHeadings(content, rel) {
285
308
  }
286
309
  }
287
310
 
288
- function lintPage({ path, rel }, slugMap, tagVocab, pageDirs) {
311
+ function lintPage({ path, rel }, slugMap, tagVocab, pageDirs, validTypes) {
289
312
  // Directory whitelist: a content page under pages/<subdir>/ must live in a
290
313
  // SCHEMA-defined directory. Catches typo'd dirs (e.g. pages/learning/ vs the
291
314
  // canonical pages/learnings/) regardless of frontmatter, since a directory
@@ -319,6 +342,16 @@ function lintPage({ path, rel }, slugMap, tagVocab, pageDirs) {
319
342
  return;
320
343
  }
321
344
 
345
+ // W9: invalid-YAML frontmatter the lenient line-scanner would otherwise pass
346
+ // green. Runs on the raw `---` block (the `content.match` above guarantees an
347
+ // opening fence; an unclosed fence falls through to the W3/parse path below).
348
+ const fmBlock = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
349
+ if (fmBlock) {
350
+ for (const reason of checkYamlInvalid(fmBlock[1])) {
351
+ issue('warn', rel, `Invalid YAML frontmatter: ${reason}`, null, 'W9');
352
+ }
353
+ }
354
+
322
355
  const fm = parseFrontmatter(content);
323
356
  if (!fm) {
324
357
  issue('error', rel, 'Malformed frontmatter (unclosed ---)');
@@ -329,7 +362,7 @@ function lintPage({ path, rel }, slugMap, tagVocab, pageDirs) {
329
362
  if (!fm[field]) issue('error', rel, `Missing required frontmatter field: ${field}`);
330
363
  }
331
364
 
332
- if (fm.type && !VALID_TYPES.includes(fm.type)) {
365
+ if (fm.type && !validTypes.has(fm.type)) {
333
366
  issue('warn', rel, `Unknown type: "${fm.type}"`, null, 'W2');
334
367
  }
335
368
 
@@ -413,13 +446,19 @@ const args = parseArgs(process.argv);
413
446
 
414
447
  const ignorePatterns = loadHypoIgnore(args.hypoDir);
415
448
  const scanDirs = ['pages', 'projects', 'journal'].map((d) => join(args.hypoDir, d));
416
- const pages = scanDirs.flatMap((d) => collectPages(d, args.hypoDir, [], ignorePatterns));
449
+ const pages = scanDirs.flatMap((d) => collectPagesLint(d, args.hypoDir, ignorePatterns));
417
450
  const linkTargets = collectLinkTargets(args.hypoDir, ignorePatterns);
418
451
  const slugMap = buildSlugMap(pages, linkTargets);
419
452
  const tagVocab = parseSchemaVocab(args.hypoDir);
420
453
  const pageDirs = parseSchemaPageDirs(args.hypoDir);
421
-
422
- for (const page of pages) lintPage(page, slugMap, tagVocab, pageDirs);
454
+ // Accepted page types = hardcoded core ∪ the vault SCHEMA's taxonomy. Union (not
455
+ // replace) so core types lint specially handles (session-state, source, …) are
456
+ // never lost when a vault's SCHEMA omits or predates them, while a vault-local
457
+ // extension (working-doc / draft / qa-run) is honored without editing lint —
458
+ // mirroring how tags and page dirs are SCHEMA-derived.
459
+ const validTypes = new Set([...VALID_TYPES, ...parseSchemaTypes(args.hypoDir)]);
460
+
461
+ for (const page of pages) lintPage(page, slugMap, tagVocab, pageDirs, validTypes);
423
462
 
424
463
  // W8: design-history.md stale relative to session-log.md. Emitted once per
425
464
  // project (not per page) — runs outside the page loop. POSIX-separated path
@@ -47,9 +47,10 @@ import {
47
47
  lstatSync,
48
48
  realpathSync,
49
49
  } from 'fs';
50
- import { join, relative, extname, basename, dirname, normalize, isAbsolute, sep } from 'path';
50
+ import { join, basename, dirname, normalize, isAbsolute, sep } from 'path';
51
51
  import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
52
- import { loadHypoIgnore, isScanIgnored } from './lib/hypo-ignore.mjs';
52
+ import { loadHypoIgnore } from './lib/hypo-ignore.mjs';
53
+ import { collectPagesRename, slugForms } from './lib/wikilink.mjs';
53
54
 
54
55
  // ── arg parsing ───────────────────────────────────────────────────────────────
55
56
 
@@ -66,28 +67,6 @@ function parseArgs(argv) {
66
67
  return args;
67
68
  }
68
69
 
69
- // ── page collection (mirrors graph.mjs / crystallize.mjs collectPages) ──────────
70
-
71
- function collectPages(dir, root, pages = [], ignorePatterns = []) {
72
- if (!existsSync(dir)) return pages;
73
- for (const entry of readdirSync(dir)) {
74
- const full = join(dir, entry);
75
- if (isScanIgnored(full, root, ignorePatterns)) continue;
76
- // lstat (never follow): a symlinked dir or file is skipped entirely so the
77
- // whole-vault scan can never traverse out of the vault via a symlink.
78
- const st = lstatSync(full);
79
- if (st.isSymbolicLink()) {
80
- continue;
81
- } else if (st.isDirectory()) {
82
- collectPages(full, root, pages, ignorePatterns);
83
- } else if (extname(entry) === '.md' && !entry.startsWith('.')) {
84
- const rel = relative(root, full).replace(/\\/g, '/');
85
- pages.push({ path: full, rel, slug: rel.replace(/\.md$/, ''), bare: basename(full, '.md') });
86
- }
87
- }
88
- return pages;
89
- }
90
-
91
70
  // ── preservation class of a link-source path ───────────────────────────────────
92
71
  // Two distinct reasons a file is normally skipped as a link SOURCE, kept separate
93
72
  // because directory mode treats them differently (codex design BLOCKER):
@@ -123,10 +102,7 @@ function isPreservedSource(rel) {
123
102
  // needs to KNOW when a form is shared, so it maps each form → the set of page
124
103
  // rels that expose it. precedence forms per page: full noExt slug, bare
125
104
  // basename, dir-relative (drop the leading scan-dir segment).
126
- function dirRelForm(slug) {
127
- const slash = slug.indexOf('/');
128
- return slash === -1 ? null : slug.slice(slash + 1);
129
- }
105
+ const dirRelForm = (slug) => slugForms(slug).dirRel;
130
106
 
131
107
  function buildFormIndex(pages) {
132
108
  const index = new Map(); // form → Set<rel>
@@ -473,7 +449,7 @@ function runDirectory(args, fromDirRel, ignorePatterns) {
473
449
  fail(args, `--from subtree contains a symlink — refusing (move could escape the vault)`);
474
450
  }
475
451
 
476
- const pages = collectPages(args.hypoDir, args.hypoDir, [], ignorePatterns);
452
+ const pages = collectPagesRename(args.hypoDir, args.hypoDir, ignorePatterns);
477
453
  const formIndex = buildFormIndex(pages);
478
454
 
479
455
  // The moving pages: every collected page whose rel is under the from-directory.
@@ -676,7 +652,7 @@ function run(args) {
676
652
  return runDirectory(args, fromNorm, ignorePatterns);
677
653
  }
678
654
 
679
- const pages = collectPages(args.hypoDir, args.hypoDir, [], ignorePatterns);
655
+ const pages = collectPagesRename(args.hypoDir, args.hypoDir, ignorePatterns);
680
656
  const formIndex = buildFormIndex(pages);
681
657
 
682
658
  const fromPage = resolveArgToPage(args.from, pages, formIndex);
package/scripts/stats.mjs CHANGED
@@ -45,16 +45,25 @@ function collectMdFiles(dir, acc = [], hypoDir = '', ignorePatterns = []) {
45
45
  return acc;
46
46
  }
47
47
 
48
+ // Local top-level-only extractor, behavior-aligned with lib/frontmatter.mjs:
49
+ // skip indented / list lines, first-wins, strip a trailing `# comment`. Without
50
+ // the comment strip a `failure_type: overreach # note` would aggregate under the
51
+ // literal "overreach # note" key (FEAT-1). Kept local (not the shared import) to
52
+ // hold stats' dependency surface flat per the FEAT-1 scope split.
48
53
  function parseFrontmatter(content) {
49
54
  const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
50
55
  if (!m) return null;
51
56
  const fm = {};
52
- for (const line of m[1].split('\n')) {
57
+ for (const line of m[1].split(/\r?\n/)) {
58
+ if (/^\s/.test(line) || /^-(\s|$)/.test(line)) continue; // nested / list item
53
59
  const idx = line.indexOf(':');
54
60
  if (idx < 0) continue;
55
- fm[line.slice(0, idx).trim()] = line
61
+ const key = line.slice(0, idx).trim();
62
+ if (!key || Object.hasOwn(fm, key)) continue; // first-wins
63
+ fm[key] = line
56
64
  .slice(idx + 1)
57
65
  .trim()
66
+ .replace(/\s+#.*$/, '')
58
67
  .replace(/^["']|["']$/g, '');
59
68
  }
60
69
  return fm;
@@ -92,6 +101,7 @@ const sources = existsSync(join(args.hypoDir, 'sources'))
92
101
  : [];
93
102
 
94
103
  const typeCounts = {};
104
+ const failureTypeCounts = {}; // FEAT-1: feedback pages carrying a failure_type
95
105
  let missingFrontmatter = 0;
96
106
 
97
107
  for (const f of pageFiles) {
@@ -108,6 +118,9 @@ for (const f of pageFiles) {
108
118
  }
109
119
  const t = fm.type || 'unknown';
110
120
  typeCounts[t] = (typeCounts[t] || 0) + 1;
121
+ if (t === 'feedback' && fm.failure_type) {
122
+ failureTypeCounts[fm.failure_type] = (failureTypeCounts[fm.failure_type] || 0) + 1;
123
+ }
111
124
  }
112
125
 
113
126
  let adrCount = 0;
@@ -122,6 +135,9 @@ const lastActivity = getLastActivity(args.hypoDir);
122
135
 
123
136
  const stats = {
124
137
  pages: { total: pageFiles.length, byType: typeCounts, missingFrontmatter },
138
+ // Omit the key entirely when no feedback page is classified (OQ-4: no empty
139
+ // section noise) so consumers can branch on presence.
140
+ ...(Object.keys(failureTypeCounts).length ? { failureTypes: failureTypeCounts } : {}),
125
141
  projects: projects.length,
126
142
  sources: sources.length,
127
143
  adrs: adrCount,
@@ -139,6 +155,10 @@ if (args.json) {
139
155
  if (missingFrontmatter) {
140
156
  console.log(` missing frontmatter: ${missingFrontmatter}`);
141
157
  }
158
+ const ftEntries = Object.entries(failureTypeCounts).sort((a, b) => b[1] - a[1]);
159
+ if (ftEntries.length) {
160
+ console.log(` failure types: ${ftEntries.map(([t, n]) => `${t} (${n})`).join(', ')}`);
161
+ }
142
162
  console.log(`Projects: ${projects.length}`);
143
163
  console.log(`Sources: ${sources.length}`);
144
164
  console.log(`ADRs: ${adrCount}`);
@@ -42,6 +42,7 @@ import { fileURLToPath } from 'url';
42
42
  import { createHash } from 'crypto';
43
43
  import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
44
44
  import { parseFrontmatter } from './lib/frontmatter.mjs';
45
+ import { templateSchemaVersion } from './lib/template-schema-version.mjs';
45
46
  import {
46
47
  readPkgJson as readPkgJsonSafe,
47
48
  writePkgJsonAtomic,
@@ -477,7 +478,14 @@ function writeMigrationReport(hypoDir, fromVersion, toVersion, { pluginMode = fa
477
478
  // right to keep SCHEMA.md as user-owned (Option C); auto-stub of the 9 new
478
479
  // fields is rejected because scope/tier/targets/sensitivity/reason/source
479
480
  // are semantic decisions whose wrong defaults would project wrong behavior.
480
- const isV1ToV2 = fromVersion === '1.0' && toVersion === '2.0';
481
+ // Fire for any 1.x → 2.x major crossing, not just 1.0 2.0 exactly: the ADR
482
+ // 0031 feedback-field backfill that this body explains was introduced at 2.0
483
+ // and still applies to a user landing on any later 2.x (e.g. 2.1's additive
484
+ // failure_type — FEAT-1). Keying on the exact target string would silently
485
+ // drop this guidance the moment the template minor moves.
486
+ const fromV = parseVersion(fromVersion);
487
+ const toV = parseVersion(toVersion);
488
+ const isV1ToV2 = fromV?.major === 1 && toV?.major === 2;
481
489
  const specificBody = isV1ToV2
482
490
  ? `## What changed in SCHEMA 2.0
483
491
 
@@ -755,7 +763,7 @@ function applyCommands(commandResults, force) {
755
763
  ...existing,
756
764
  pkgRoot: PKG_ROOT,
757
765
  pkgVersion,
758
- schemaVersion: '2.0',
766
+ schemaVersion: templateSchemaVersion(PKG_ROOT) ?? '2.1',
759
767
  commands: newSHAs,
760
768
  });
761
769
  return applied;
@@ -781,7 +789,7 @@ function writePluginModeMetadata() {
781
789
  ...existing,
782
790
  pkgRoot: PKG_ROOT,
783
791
  pkgVersion,
784
- schemaVersion: '2.0',
792
+ schemaVersion: templateSchemaVersion(PKG_ROOT) ?? '2.1',
785
793
  });
786
794
  return true;
787
795
  }
@@ -123,7 +123,7 @@ export function buildReport(
123
123
  return { week: weekLabel, count: weekResults.length, counts, score, results: weekResults };
124
124
  }
125
125
 
126
- function renderMarkdown(report) {
126
+ function renderMarkdown(report, hypoDir) {
127
127
  const { week, count, counts, score, results } = report;
128
128
  const today = new Date().toISOString().slice(0, 10);
129
129
  const lines = [];
@@ -138,8 +138,14 @@ function renderMarkdown(report) {
138
138
  lines.push('');
139
139
  lines.push(`# Observability — Week ${week}`);
140
140
  lines.push('');
141
+ // Only emit the wikilink when its target page exists, so a fresh vault
142
+ // (or a deleted _index) does not stamp a broken link into every weekly.
143
+ const indexExists = !!hypoDir && existsSync(join(hypoDir, 'pages', 'observability', '_index.md'));
144
+ const heuristicRef = indexExists
145
+ ? '[[pages/observability/_index]]'
146
+ : '`pages/observability/_index.md` (not present in this vault)';
141
147
  lines.push(
142
- `> Generated by \`scripts/weekly-report.mjs\`. Heuristic definition: [[pages/observability/_index]].`,
148
+ `> Generated by \`scripts/weekly-report.mjs\`. Heuristic definition: ${heuristicRef}.`,
143
149
  );
144
150
  lines.push('');
145
151
  lines.push(`## Autonomy score (heuristic v0): **${score}%**`);
@@ -196,7 +202,7 @@ if (isMain()) {
196
202
  process.exit(0);
197
203
  }
198
204
 
199
- const md = renderMarkdown(report);
205
+ const md = renderMarkdown(report, args.hypoDir);
200
206
 
201
207
  if (args.write) {
202
208
  const dir = join(args.hypoDir, 'journal', 'weekly');
@@ -76,7 +76,27 @@ reports and re-run until it prints **"Compact-ready"** — that is the signal th
76
76
  session is closed. A close-files-only pass is not enough; the real `/compact`
77
77
  also blocks on those other checks.
78
78
 
79
- Once it passes, report each item with ✓ and ask: "Session closed. Would you like to also run knowledge synthesis now, or stop here?"
79
+ **When using `--apply-session-close --session-id=<id>`** (the payload-driven
80
+ path), the `--session-id` must be the main conversation's session id. Do NOT
81
+ extract it from a background task or Agent output path (e.g., a UUID from
82
+ `/tmp/.../<uuid>/tasks/...`). Such a UUID is a background task id, not the
83
+ main conversation id. Passing the wrong id causes `markerWritten: false` with
84
+ `markerSkipReason: "transcript-unresolved"` or `"no-user-close-signal"`: the
85
+ 5 mandatory files are written (ok: true) but the Stop-chain marker is withheld,
86
+ so the session is not actually closed and the Stop hook re-prompts. The correct
87
+ id comes from the `[WIKI_AUTOCLOSE]` block reason or `$CLAUDE_SESSION_ID`.
88
+
89
+ Once it passes, report each item with ✓:
90
+ - ✓ session-state.md
91
+ - ✓ hot.md (project + root)
92
+ - ✓ session-log entry
93
+ - ✓ open-questions (or skipped if unchanged)
94
+ - ✓ log.md entry
95
+ - **marker written?** (required, if `--apply-session-close --session-id` was used): check `markerWritten` in the JSON output. If `true`, report "session-close marker written." If `false`, do NOT declare the session closed or complete. Instead report: "Files applied and verified (ok: true), but the session-close marker was not written (reason: `<markerSkipReason>`). The Stop-chain is still active. Re-run with the correct main-conversation `--session-id`."
96
+
97
+ If `markerWritten: true` (or `--session-id` was not passed), ask: "Session closed. Would you like to also run knowledge synthesis now, or stop here?"
98
+
99
+ If `markerWritten: false`, do NOT say "session closed." Surface the skip reason and instruct a re-run with the correct main-conversation `--session-id` (see the bullet above) before treating the session as closed.
80
100
 
81
101
  ---
82
102
 
@@ -2,7 +2,7 @@
2
2
  title: Wiki Schema
3
3
  type: schema
4
4
  updated: YYYY-MM-DD
5
- version: 2.0
5
+ version: 2.1
6
6
  ---
7
7
 
8
8
  # Wiki Schema
@@ -118,11 +118,35 @@ memory_summary: <one line for the MEMORY.md index>
118
118
  reason: <why this rule is needed>
119
119
  source: session:YYYY-MM-DD | commit:<hash> | pr:<n> | https://...
120
120
 
121
+ # optional (2.1) — failure taxonomy for incident-driven corrections:
122
+ failure_type: <enum> # one of the 8 values below; OMIT for pure
123
+ # preferences / new conventions ("always do X")
124
+
121
125
  # conditional — required when `targets` includes `claude-learned`:
122
126
  global_summary: <one line for the <learned_behaviors> entry>
123
127
  promote_to_global: true # explicit opt-in to global projection
124
128
  ```
125
129
 
130
+ `failure_type` (optional, added 2.1) classifies feedback that came from a real
131
+ failure incident so recurring mistake types become machine-aggregatable
132
+ (surfaced by `hypomnema stats`). Leave it off when the page records a preference
133
+ rather than a failure. The eight values, in classification-precedence order
134
+ (most specific first — a failure matching several takes the earliest):
135
+
136
+ | value | when |
137
+ |-------|------|
138
+ | `hallucination` | fabricated a fact / API / path |
139
+ | `false-completion` | declared "done" without running the required gate or test |
140
+ | `process-stall` | stopped instead of asking / continuing when it should have |
141
+ | `over-caution` | re-asked or re-gated despite standing authority |
142
+ | `overreach` | acted beyond the requested scope |
143
+ | `incompleteness` | started correctly but omitted a required step or scope |
144
+ | `instruction-miss` | ignored an explicit this-session instruction |
145
+ | `convention-violation` | broke a standing documented convention (not restated) |
146
+
147
+ `lint` rejects any value outside this set; an omitted `failure_type` is always
148
+ allowed.
149
+
126
150
  Edit the feedback page only — never hand-edit the generated
127
151
  `<!-- HYPO:FEEDBACK-SYNC:START … -->` managed blocks (sync detects tampering as a conflict).
128
152
 
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  title: Hypomnema Config
3
3
  type: config
4
- version: "1.3.4"
4
+ version: "1.4.1"
5
5
  created: YYYY-MM-DD
6
6
  ---
7
7
 
@@ -1,63 +0,0 @@
1
- #!/usr/bin/env node
2
- // Sync version across package.json, .claude-plugin/plugin.json, and templates/hypo-config.md.
3
- // Usage: node scripts/bump-version.mjs <new-version>
4
-
5
- import { readFileSync, writeFileSync } from 'node:fs';
6
- import { resolve, dirname } from 'node:path';
7
- import { fileURLToPath } from 'node:url';
8
-
9
- const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
10
- const next = process.argv[2];
11
-
12
- if (!next || !/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(next)) {
13
- console.error('Usage: node scripts/bump-version.mjs <semver>');
14
- console.error('Example: node scripts/bump-version.mjs 1.0.0');
15
- process.exit(1);
16
- }
17
-
18
- const targets = [
19
- {
20
- path: 'package.json',
21
- pattern: /("version"\s*:\s*")([^"]+)(")/,
22
- },
23
- {
24
- path: '.claude-plugin/plugin.json',
25
- pattern: /("version"\s*:\s*")([^"]+)(")/,
26
- },
27
- {
28
- path: '.claude-plugin/marketplace.json',
29
- pattern: /("version"\s*:\s*")([^"]+)(")/,
30
- },
31
- {
32
- path: 'templates/hypo-config.md',
33
- pattern: /(^version:\s*")([^"]+)(")/m,
34
- },
35
- ];
36
-
37
- for (const { path, pattern } of targets) {
38
- const abs = resolve(root, path);
39
- const before = readFileSync(abs, 'utf-8');
40
- const match = before.match(pattern);
41
- if (!match) {
42
- console.error(`✗ ${path}: version pattern not found`);
43
- process.exit(1);
44
- }
45
- const current = match[2];
46
- if (current === next) {
47
- console.log(`= ${path} already ${next}`);
48
- continue;
49
- }
50
- writeFileSync(abs, before.replace(pattern, `$1${next}$3`));
51
- console.log(`✓ ${path}: ${current} → ${next}`);
52
- }
53
-
54
- console.log(`\nNext steps:`);
55
- console.log(` 1. Edit CHANGELOG.md — ensure the "## [${next}]" section has a`);
56
- console.log(` "### 한글 요약" sub-section (release.yml check-bilingual gate).`);
57
- console.log(` 2. node scripts/check-bilingual.mjs --changelog # local pre-check`);
58
- console.log(` 3. git add -A && git commit -m "chore(release): v${next}"`);
59
- console.log(` 4. Create an ANNOTATED tag (lightweight tags are rejected by CI):`);
60
- console.log(` git tag -a v${next} -m "<English body>\\n\\n---\\n\\n<한글 요약>"`);
61
- console.log(` See docs/CONTRIBUTING.md "Cutting a release" for the full template.`);
62
- console.log(` 5. node scripts/check-bilingual.mjs --tag v${next} # local pre-check`);
63
- console.log(` 6. git push origin <branch> --tags`);