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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.ko.md +151 -153
- package/README.md +121 -123
- package/commands/audit.md +4 -4
- package/commands/crystallize.md +18 -5
- package/commands/doctor.md +3 -3
- package/commands/feedback.md +9 -3
- package/commands/graph.md +3 -3
- package/commands/ingest.md +6 -4
- package/commands/init.md +5 -3
- package/commands/lint.md +3 -3
- package/commands/query.md +3 -3
- package/commands/rename.md +4 -4
- package/commands/resume.md +4 -2
- package/commands/stats.md +3 -3
- package/commands/upgrade.md +4 -4
- package/commands/verify.md +3 -3
- package/docs/ARCHITECTURE.md +4 -2
- package/docs/CONTRIBUTING.md +148 -25
- package/hooks/hypo-auto-commit.mjs +6 -12
- package/hooks/hypo-session-record.mjs +5 -0
- package/hooks/hypo-session-start.mjs +7 -0
- package/hooks/hypo-shared.mjs +68 -1
- package/package.json +10 -2
- package/scripts/check-bilingual.mjs +49 -11
- package/scripts/check-readme-version.mjs +126 -0
- package/scripts/check-tracker-ids.mjs +60 -1
- package/scripts/check-versions.mjs +171 -0
- package/scripts/crystallize.mjs +49 -34
- package/scripts/doctor.mjs +13 -4
- package/scripts/feedback.mjs +68 -1
- package/scripts/graph.mjs +5 -32
- package/scripts/init.mjs +2 -1
- package/scripts/lib/changelog-classify.mjs +216 -0
- package/scripts/lib/check-bilingual.mjs +125 -22
- package/scripts/lib/check-tracker-ids.mjs +19 -0
- package/scripts/lib/failure-type.mjs +33 -0
- package/scripts/lib/frontmatter.mjs +23 -2
- package/scripts/lib/schema-vocab.mjs +35 -0
- package/scripts/lib/template-schema-version.mjs +21 -0
- package/scripts/lib/wikilink.mjs +156 -0
- package/scripts/lint.mjs +86 -47
- package/scripts/rename.mjs +6 -30
- package/scripts/stats.mjs +22 -2
- package/scripts/upgrade.mjs +11 -3
- package/scripts/weekly-report.mjs +9 -3
- package/skills/crystallize/SKILL.md +21 -1
- package/templates/SCHEMA.md +25 -1
- package/templates/hypo-config.md +1 -1
- package/scripts/bump-version.mjs +0 -63
- 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,
|
|
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 {
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
//
|
|
148
|
-
//
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 && !
|
|
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) =>
|
|
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
|
-
|
|
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
|
package/scripts/rename.mjs
CHANGED
|
@@ -47,9 +47,10 @@ import {
|
|
|
47
47
|
lstatSync,
|
|
48
48
|
realpathSync,
|
|
49
49
|
} from 'fs';
|
|
50
|
-
import { join,
|
|
50
|
+
import { join, basename, dirname, normalize, isAbsolute, sep } from 'path';
|
|
51
51
|
import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
|
|
52
|
-
import { loadHypoIgnore
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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}`);
|
package/scripts/upgrade.mjs
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
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:
|
|
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
|
-
|
|
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
|
|
package/templates/SCHEMA.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
title: Wiki Schema
|
|
3
3
|
type: schema
|
|
4
4
|
updated: YYYY-MM-DD
|
|
5
|
-
version: 2.
|
|
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
|
|
package/templates/hypo-config.md
CHANGED
package/scripts/bump-version.mjs
DELETED
|
@@ -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`);
|