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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.ko.md +2 -2
- package/README.md +2 -2
- package/commands/feedback.md +4 -0
- package/docs/ARCHITECTURE.md +4 -2
- package/docs/CONTRIBUTING.md +56 -17
- package/hooks/hypo-auto-commit.mjs +6 -12
- package/hooks/hypo-session-record.mjs +5 -0
- package/hooks/hypo-session-start.mjs +7 -0
- package/hooks/hypo-shared.mjs +68 -1
- package/package.json +5 -2
- package/scripts/bump-version.mjs +20 -6
- package/scripts/check-readme-version.mjs +126 -0
- package/scripts/check-versions.mjs +171 -0
- package/scripts/crystallize.mjs +19 -32
- package/scripts/doctor.mjs +15 -6
- package/scripts/feedback.mjs +68 -1
- package/scripts/graph.mjs +5 -32
- package/scripts/init.mjs +33 -3
- package/scripts/lib/failure-type.mjs +33 -0
- package/scripts/lib/frontmatter.mjs +23 -2
- package/scripts/lib/hypo-ignore.mjs +24 -0
- 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 +91 -51
- package/scripts/query.mjs +2 -2
- package/scripts/rename.mjs +6 -30
- package/scripts/smoke-plugin.mjs +194 -0
- package/scripts/stats.mjs +24 -4
- package/scripts/upgrade.mjs +11 -3
- package/scripts/verify.mjs +2 -2
- package/templates/SCHEMA.md +25 -1
- package/templates/hypo-config.md +1 -1
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// check-versions.mjs — assert every version-carrying file in the repo agrees, and
|
|
3
|
+
// (with --tag) that they all match the release tag. This makes the release pipeline
|
|
4
|
+
// OWN the plugin channel: a forgotten plugin.json / marketplace.json / hypo-config /
|
|
5
|
+
// lockfile bump hard-fails the release instead of publishing a split-version plugin.
|
|
6
|
+
//
|
|
7
|
+
// The set mirrors scripts/bump-version.mjs (package.json, .claude-plugin/plugin.json,
|
|
8
|
+
// .claude-plugin/marketplace.json, templates/hypo-config.md) PLUS package-lock.json,
|
|
9
|
+
// which npm — not bump-version — manages, so it can lag a bump and silently break
|
|
10
|
+
// `npm ci`. README/CHANGELOG carry prose version HISTORY (every past vX.Y.Z), not a
|
|
11
|
+
// single release authority, so they are intentionally excluded (covered by the
|
|
12
|
+
// bilingual + README-reconcile checklist instead).
|
|
13
|
+
//
|
|
14
|
+
// Usage:
|
|
15
|
+
// node scripts/check-versions.mjs # assert all files agree
|
|
16
|
+
// node scripts/check-versions.mjs --tag v1.4.0 # also assert they equal the tag
|
|
17
|
+
// node scripts/check-versions.mjs --root <dir> # point at a fixture (tests)
|
|
18
|
+
// node scripts/check-versions.mjs --json
|
|
19
|
+
//
|
|
20
|
+
// Exit 0 = consistent (and, with --tag, matches). Exit 1 = drift / unreadable / mismatch.
|
|
21
|
+
|
|
22
|
+
import { readFileSync } from 'fs';
|
|
23
|
+
import { join, dirname } from 'path';
|
|
24
|
+
import { fileURLToPath } from 'url';
|
|
25
|
+
|
|
26
|
+
function parseArgs(argv) {
|
|
27
|
+
const args = { root: null, tag: null, json: false };
|
|
28
|
+
for (let i = 0; i < argv.length; i++) {
|
|
29
|
+
const a = argv[i];
|
|
30
|
+
if (a.startsWith('--root=')) args.root = a.slice(7);
|
|
31
|
+
else if (a === '--root') args.root = argv[++i];
|
|
32
|
+
else if (a.startsWith('--tag=')) args.tag = a.slice(6);
|
|
33
|
+
else if (a === '--tag') args.tag = argv[++i];
|
|
34
|
+
else if (a === '--json') args.json = true;
|
|
35
|
+
}
|
|
36
|
+
return args;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
40
|
+
|
|
41
|
+
function readJson(abs) {
|
|
42
|
+
return JSON.parse(readFileSync(abs, 'utf-8'));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Collect {label, version} for every authoritative location. A location that
|
|
46
|
+
// cannot be read or whose field is missing becomes {label, version: null, error}.
|
|
47
|
+
function collectVersions(root) {
|
|
48
|
+
const sources = [];
|
|
49
|
+
const push = (label, fn) => {
|
|
50
|
+
try {
|
|
51
|
+
const v = fn();
|
|
52
|
+
if (typeof v !== 'string' || v.length === 0) {
|
|
53
|
+
sources.push({ label, version: null, error: 'version field missing or empty' });
|
|
54
|
+
} else {
|
|
55
|
+
sources.push({ label, version: v });
|
|
56
|
+
}
|
|
57
|
+
} catch (err) {
|
|
58
|
+
sources.push({ label, version: null, error: err?.message ?? String(err) });
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
push('package.json', () => readJson(join(root, 'package.json')).version);
|
|
63
|
+
|
|
64
|
+
// package-lock.json carries the version in TWO top-level spots (lockfileVersion 3):
|
|
65
|
+
// the root `.version` and `.packages[""].version`. Dependency versions deeper in
|
|
66
|
+
// the tree are NOT release authorities and must not be read.
|
|
67
|
+
push('package-lock.json (root)', () => readJson(join(root, 'package-lock.json')).version);
|
|
68
|
+
push('package-lock.json (packages[""])', () => {
|
|
69
|
+
const lock = readJson(join(root, 'package-lock.json'));
|
|
70
|
+
return lock.packages && lock.packages[''] ? lock.packages[''].version : undefined;
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const pluginName = (() => {
|
|
74
|
+
try {
|
|
75
|
+
return readJson(join(root, '.claude-plugin', 'plugin.json')).name;
|
|
76
|
+
} catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
})();
|
|
80
|
+
|
|
81
|
+
push(
|
|
82
|
+
'.claude-plugin/plugin.json',
|
|
83
|
+
() => readJson(join(root, '.claude-plugin', 'plugin.json')).version,
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
// Select the marketplace entry BY NAME (matching plugin.json), not by position:
|
|
87
|
+
// Claude Code's runtime resolves plugins by name (hooks/version-check.mjs), and a
|
|
88
|
+
// future second marketplace entry would make plugins[0] the wrong authority.
|
|
89
|
+
push('.claude-plugin/marketplace.json (entry: ' + (pluginName ?? '?') + ')', () => {
|
|
90
|
+
const mp = readJson(join(root, '.claude-plugin', 'marketplace.json'));
|
|
91
|
+
const plugins = Array.isArray(mp.plugins) ? mp.plugins : [];
|
|
92
|
+
if (!pluginName)
|
|
93
|
+
throw new Error('plugin.json name unreadable — cannot match marketplace entry');
|
|
94
|
+
const matches = plugins.filter((p) => p && p.name === pluginName);
|
|
95
|
+
if (matches.length !== 1) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`expected exactly one marketplace entry named "${pluginName}", found ${matches.length}`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
return matches[0].version;
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// hypo-config.md frontmatter: version: "X.Y.Z"
|
|
104
|
+
push('templates/hypo-config.md', () => {
|
|
105
|
+
const text = readFileSync(join(root, 'templates', 'hypo-config.md'), 'utf-8');
|
|
106
|
+
const m = text.match(/^version:\s*"?([^"\n]+)"?/m);
|
|
107
|
+
return m ? m[1].trim() : undefined;
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
return sources;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function main() {
|
|
114
|
+
const args = parseArgs(process.argv.slice(2));
|
|
115
|
+
const root = args.root || REPO_ROOT;
|
|
116
|
+
const sources = collectVersions(root);
|
|
117
|
+
|
|
118
|
+
const errored = sources.filter((s) => s.error);
|
|
119
|
+
const versions = [...new Set(sources.filter((s) => s.version).map((s) => s.version))];
|
|
120
|
+
|
|
121
|
+
// Normalize the tag by stripping exactly one leading `v` (release tags are vX.Y.Z,
|
|
122
|
+
// file versions are X.Y.Z). Track tag PRESENCE separately from the normalized
|
|
123
|
+
// value: a bare `v` (or any tag that normalizes to empty / non-semver) must HARD
|
|
124
|
+
// FAIL, not be mistaken for "no tag supplied" — otherwise a `git tag v` push would
|
|
125
|
+
// bypass the release gate. This preserves the old "Validate tag matches package
|
|
126
|
+
// version" guarantee while widening it to every channel.
|
|
127
|
+
const hasTag = args.tag != null;
|
|
128
|
+
const tagVersion = hasTag ? args.tag.replace(/^v/, '') : null;
|
|
129
|
+
const tagValid = hasTag && /^\d+\.\d+\.\d+(-[\w.]+)?$/.test(tagVersion);
|
|
130
|
+
|
|
131
|
+
const consistent = errored.length === 0 && versions.length === 1;
|
|
132
|
+
const tagOk = !hasTag || (tagValid && consistent && versions[0] === tagVersion);
|
|
133
|
+
const ok = consistent && tagOk;
|
|
134
|
+
|
|
135
|
+
if (args.json) {
|
|
136
|
+
console.log(
|
|
137
|
+
JSON.stringify(
|
|
138
|
+
{ ok, consistent, hasTag, tagVersion, tagValid, distinctVersions: versions, sources },
|
|
139
|
+
null,
|
|
140
|
+
2,
|
|
141
|
+
),
|
|
142
|
+
);
|
|
143
|
+
} else {
|
|
144
|
+
const width = Math.max(...sources.map((s) => s.label.length));
|
|
145
|
+
for (const s of sources) {
|
|
146
|
+
const val = s.error ? `ERROR: ${s.error}` : s.version;
|
|
147
|
+
console.log(` ${s.label.padEnd(width)} ${val}`);
|
|
148
|
+
}
|
|
149
|
+
if (errored.length) {
|
|
150
|
+
console.error(`\n✗ ${errored.length} version source(s) unreadable.`);
|
|
151
|
+
} else if (!consistent) {
|
|
152
|
+
console.error(
|
|
153
|
+
`\n✗ version drift — ${versions.length} distinct versions: ${versions.join(', ')}`,
|
|
154
|
+
);
|
|
155
|
+
} else if (hasTag && !tagValid) {
|
|
156
|
+
console.error(`\n✗ tag "${args.tag}" does not normalize to a valid semver version`);
|
|
157
|
+
} else if (!tagOk) {
|
|
158
|
+
console.error(
|
|
159
|
+
`\n✗ tag ${args.tag} (→ ${tagVersion}) does not match the file version ${versions[0]}`,
|
|
160
|
+
);
|
|
161
|
+
} else {
|
|
162
|
+
console.log(
|
|
163
|
+
`\n✓ all version-carrying files agree on ${versions[0]}${tagVersion ? ` (matches tag ${args.tag})` : ''}`,
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
process.exit(ok ? 0 : 1);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
main();
|
package/scripts/crystallize.mjs
CHANGED
|
@@ -60,20 +60,14 @@
|
|
|
60
60
|
* hard-fails regardless of scope.
|
|
61
61
|
*/
|
|
62
62
|
|
|
63
|
-
import {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
readdirSync,
|
|
67
|
-
statSync,
|
|
68
|
-
writeFileSync,
|
|
69
|
-
mkdirSync,
|
|
70
|
-
renameSync,
|
|
71
|
-
} from 'fs';
|
|
72
|
-
import { join, relative, extname, dirname } from 'path';
|
|
63
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync } from 'fs';
|
|
64
|
+
import { join, dirname } from 'path';
|
|
65
|
+
import { hostname } from 'os';
|
|
73
66
|
import { spawnSync } from 'child_process';
|
|
74
67
|
import { fileURLToPath } from 'url';
|
|
75
68
|
import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
|
|
76
|
-
import { loadHypoIgnore
|
|
69
|
+
import { loadHypoIgnore } from './lib/hypo-ignore.mjs';
|
|
70
|
+
import { collectPagesCrystallize, extractWikilinks } from './lib/wikilink.mjs';
|
|
77
71
|
import {
|
|
78
72
|
sessionCloseFileStatus,
|
|
79
73
|
sessionCloseGlobalStatus,
|
|
@@ -756,9 +750,21 @@ function applySessionClose(args) {
|
|
|
756
750
|
// otherwise mistake it for the evidence file. The dated `## [date] ...`
|
|
757
751
|
// heading lives inside the entry, so freshness / derive / design-history
|
|
758
752
|
// are unchanged.
|
|
753
|
+
// PRAC-17 audit fields. The shard frontmatter is git-tracked and synced, so
|
|
754
|
+
// `device` is an INTENTIONAL synced multi-machine identifier (privacy note:
|
|
755
|
+
// docs/ARCHITECTURE.md). It is a CREATOR-only stamp — only the session/
|
|
756
|
+
// machine that first seeds the daily shard is recorded; later same-day
|
|
757
|
+
// appends do not touch it. The per-session-accurate store is the LOCAL
|
|
758
|
+
// (.cache/, gitignored) index.jsonl written by hypo-session-record.mjs.
|
|
759
|
+
// `session_id` is honest naming: the value is the Claude session UUID, and
|
|
760
|
+
// it is present only on the Stop-chain close path that passes --session-id.
|
|
761
|
+
const device = String(hostname() || 'unknown').replace(/[\r\n]/g, '');
|
|
762
|
+
const auditFm =
|
|
763
|
+
(args.sessionId ? `session_id: ${String(args.sessionId).replace(/[\r\n]/g, '')}\n` : '') +
|
|
764
|
+
`device: ${device}\n`;
|
|
759
765
|
const header =
|
|
760
766
|
`---\ntitle: Session Log ${date} (${project})\n` +
|
|
761
|
-
`type: session-log\nupdated: ${date}\n---\n\n` +
|
|
767
|
+
`type: session-log\nupdated: ${date}\n${auditFm}---\n\n` +
|
|
762
768
|
`# Session Log ${date} (${project})\n`;
|
|
763
769
|
const entry = payload.sessionLog.entry;
|
|
764
770
|
const body = entry.endsWith('\n') ? entry : `${entry}\n`;
|
|
@@ -944,21 +950,6 @@ function applySessionClose(args) {
|
|
|
944
950
|
|
|
945
951
|
// ── helpers ──────────────────────────────────────────────────────────────────
|
|
946
952
|
|
|
947
|
-
function collectPages(dir, root, acc = [], ignorePatterns = []) {
|
|
948
|
-
if (!existsSync(dir)) return acc;
|
|
949
|
-
for (const entry of readdirSync(dir)) {
|
|
950
|
-
if (entry.startsWith('.')) continue;
|
|
951
|
-
const full = join(dir, entry);
|
|
952
|
-
if (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 =
|
|
995
|
+
const pages = collectPagesCrystallize(pagesDir, args.hypoDir, ignorePatterns);
|
|
1009
996
|
|
|
1010
997
|
const tagGroups = {}; // tag → [{ slug, title }]
|
|
1011
998
|
const unlinked = []; // pages with no outbound wikilinks
|
package/scripts/doctor.mjs
CHANGED
|
@@ -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,
|
|
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 &&
|
|
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
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
531
|
+
// A merge conflict needs a real manual merge, not a plain push/pull — give
|
|
532
|
+
// the same explicit guidance session-start does instead of the generic hint.
|
|
533
|
+
if (String(last.op || '').startsWith('conflict')) {
|
|
534
|
+
warn(
|
|
535
|
+
'Sync state',
|
|
536
|
+
`${entries.length} unresolved sync issue(s) — last: remote diverged (merge conflict). Your local work is committed; the other machine's version is on the remote. Resolve with \`git pull --no-rebase\`, fix conflicts, then push.`,
|
|
537
|
+
);
|
|
538
|
+
} else {
|
|
539
|
+
warn(
|
|
540
|
+
'Sync state',
|
|
541
|
+
`${entries.length} unresolved failure(s) — last: ${last.op || '?'} at ${last.timestamp || '?'}. Inspect .cache/sync-state.json or push/pull manually to clear.`,
|
|
542
|
+
);
|
|
543
|
+
}
|
|
535
544
|
}
|
|
536
545
|
}
|
|
537
546
|
|
package/scripts/feedback.mjs
CHANGED
|
@@ -48,6 +48,8 @@ import { spawnSync } from 'child_process';
|
|
|
48
48
|
import { fileURLToPath } from 'url';
|
|
49
49
|
import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
|
|
50
50
|
import { FEEDBACK_SCOPE_RE } from './lib/feedback-scope.mjs';
|
|
51
|
+
import { FAILURE_TYPE_ENUM } from './lib/failure-type.mjs';
|
|
52
|
+
import { parseFrontmatter } from './lib/frontmatter.mjs';
|
|
51
53
|
|
|
52
54
|
const SCRIPT_DIR = fileURLToPath(new URL('.', import.meta.url));
|
|
53
55
|
|
|
@@ -69,6 +71,7 @@ function parseArgs(argv) {
|
|
|
69
71
|
promoteToGlobal: false,
|
|
70
72
|
reason: null,
|
|
71
73
|
source: null,
|
|
74
|
+
failureType: null,
|
|
72
75
|
behavior: null,
|
|
73
76
|
claudeHome: null,
|
|
74
77
|
projectId: null,
|
|
@@ -91,6 +94,7 @@ function parseArgs(argv) {
|
|
|
91
94
|
else if (arg === '--promote-to-global') args.promoteToGlobal = true;
|
|
92
95
|
else if (arg.startsWith('--reason=')) args.reason = arg.slice(9);
|
|
93
96
|
else if (arg.startsWith('--source=')) args.source = arg.slice(9);
|
|
97
|
+
else if (arg.startsWith('--failure-type=')) args.failureType = arg.slice(15);
|
|
94
98
|
else if (arg.startsWith('--behavior=')) args.behavior = arg.slice(11);
|
|
95
99
|
else if (arg.startsWith('--claude-home=')) args.claudeHome = expandHome(arg.slice(14));
|
|
96
100
|
else if (arg.startsWith('--project-id=')) args.projectId = arg.slice(13);
|
|
@@ -132,6 +136,16 @@ function parseTargets(raw) {
|
|
|
132
136
|
.filter(Boolean);
|
|
133
137
|
}
|
|
134
138
|
|
|
139
|
+
// FEAT-1: `failure_type` is OPTIONAL — an unset value is always fine. A set value
|
|
140
|
+
// must be one of the eight enum members (same vocabulary lint enforces). Returns
|
|
141
|
+
// an error string or null. Shared by create (validateClassification) and append.
|
|
142
|
+
function failureTypeError(value) {
|
|
143
|
+
if (!value) return null;
|
|
144
|
+
if (!FAILURE_TYPE_ENUM.includes(value))
|
|
145
|
+
return `--failure-type invalid: "${value}" (allowed: ${FAILURE_TYPE_ENUM.join(', ')})`;
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
135
149
|
// Validate the create-mode classification. Returns an array of error strings.
|
|
136
150
|
function validateClassification(args, targets) {
|
|
137
151
|
const errs = [];
|
|
@@ -151,6 +165,8 @@ function validateClassification(args, targets) {
|
|
|
151
165
|
errs.push(`--priority must be an integer 1-5 (got "${args.priority}")`);
|
|
152
166
|
if (!args.memorySummary) errs.push('--memory-summary is required');
|
|
153
167
|
if (!args.reason) errs.push('--reason is required');
|
|
168
|
+
const ftErr = failureTypeError(args.failureType);
|
|
169
|
+
if (ftErr) errs.push(ftErr);
|
|
154
170
|
|
|
155
171
|
// CLAUDE.md projection candidates must be global + L1 (ADR 0031 §6 filter), and
|
|
156
172
|
// carry the two conditional fields (lint #8). Enforce here so we never write a
|
|
@@ -201,6 +217,7 @@ function renderPage(args, targets, today) {
|
|
|
201
217
|
}
|
|
202
218
|
lines.push(`reason: ${oneLine(args.reason)}`);
|
|
203
219
|
lines.push(`source: ${oneLine(args.source || `session:${today}`)}`);
|
|
220
|
+
if (args.failureType) lines.push(`failure_type: ${args.failureType}`);
|
|
204
221
|
lines.push(`corrected_at: ${today}`);
|
|
205
222
|
lines.push(`updated: ${today}`);
|
|
206
223
|
lines.push(`created: ${today}`);
|
|
@@ -233,6 +250,24 @@ function bumpUpdated(content, today) {
|
|
|
233
250
|
return content.replace(m[0], `---\n${bumped}\n---`);
|
|
234
251
|
}
|
|
235
252
|
|
|
253
|
+
// Set `failure_type: <value>` in the leading frontmatter block. Scoped to the
|
|
254
|
+
// first `---` fence (like bumpUpdated) so a body line starting "failure_type:" is
|
|
255
|
+
// never touched; the `^` anchor keeps it top-level (an indented/nested key starts
|
|
256
|
+
// with whitespace and won't match). CRLF-aware (the shared parser accepts CRLF,
|
|
257
|
+
// so this must too — an LF-only match would silently skip a CRLF page) and it
|
|
258
|
+
// REPLACES an existing empty `failure_type:` line rather than leaving it blank.
|
|
259
|
+
// The caller only invokes this when the page has no real value (absent or empty),
|
|
260
|
+
// so a populated top-level key never reaches here.
|
|
261
|
+
function addFailureType(content, value) {
|
|
262
|
+
const m = content.match(/^---(\r?\n)([\s\S]*?)\r?\n---/);
|
|
263
|
+
if (!m) return content; // no frontmatter fence to host the field
|
|
264
|
+
const nl = m[1];
|
|
265
|
+
const fm = /^failure_type:\s*.*$/m.test(m[2])
|
|
266
|
+
? m[2].replace(/^failure_type:\s*.*$/m, `failure_type: ${value}`)
|
|
267
|
+
: `${m[2]}${nl}failure_type: ${value}`;
|
|
268
|
+
return content.replace(m[0], `---${nl}${fm}${nl}---`);
|
|
269
|
+
}
|
|
270
|
+
|
|
236
271
|
function writeFeedback(args, today) {
|
|
237
272
|
const feedbackDir = join(args.hypoDir, 'pages', 'feedback');
|
|
238
273
|
const filePath = join(feedbackDir, `${args.topic}.md`);
|
|
@@ -244,7 +279,39 @@ function writeFeedback(args, today) {
|
|
|
244
279
|
// Append a dated entry; preserve existing frontmatter classification.
|
|
245
280
|
mode = 'append';
|
|
246
281
|
const existing = readFileSync(filePath, 'utf-8');
|
|
247
|
-
|
|
282
|
+
// FEAT-1: failure_type is a per-page classification property. On append,
|
|
283
|
+
// set it if the page has none, error if it conflicts with an existing value,
|
|
284
|
+
// no-op if it matches. Without the flag, append is byte-for-byte unchanged.
|
|
285
|
+
const existingFt = (parseFrontmatter(existing) || {}).failure_type || null;
|
|
286
|
+
if (args.failureType) {
|
|
287
|
+
const ftErr = failureTypeError(args.failureType);
|
|
288
|
+
if (ftErr) {
|
|
289
|
+
console.error(`Error: ${ftErr}`);
|
|
290
|
+
process.exit(1);
|
|
291
|
+
}
|
|
292
|
+
if (existingFt && existingFt !== args.failureType) {
|
|
293
|
+
console.error(
|
|
294
|
+
`Error: failure_type mismatch on append: page has "${existingFt}", ` +
|
|
295
|
+
`--failure-type=${args.failureType}. A feedback page carries a single ` +
|
|
296
|
+
`failure_type; use a separate topic for a different failure type.`,
|
|
297
|
+
);
|
|
298
|
+
process.exit(1);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
let appended = existing.trimEnd() + `\n\n## ${today}\n\n${args.entry}\n`;
|
|
302
|
+
if (args.failureType && !existingFt) {
|
|
303
|
+
appended = addFailureType(appended, args.failureType);
|
|
304
|
+
// Fail loud rather than silently appending without the field: if the page's
|
|
305
|
+
// frontmatter is too malformed to host failure_type (no fence at all), the
|
|
306
|
+
// set-if-absent contract could not be honored.
|
|
307
|
+
if ((parseFrontmatter(appended) || {}).failure_type !== args.failureType) {
|
|
308
|
+
console.error(
|
|
309
|
+
`Error: could not set failure_type on "${args.topic}" — its frontmatter ` +
|
|
310
|
+
`is malformed (no parseable --- block). Fix the page, then retry.`,
|
|
311
|
+
);
|
|
312
|
+
process.exit(1);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
248
315
|
content = bumpUpdated(appended, today);
|
|
249
316
|
} else {
|
|
250
317
|
mode = 'create';
|
package/scripts/graph.mjs
CHANGED
|
@@ -14,10 +14,11 @@
|
|
|
14
14
|
* --min-edges=<n> Only include nodes with at least N edges (default: 0)
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import {
|
|
18
|
-
import { join
|
|
17
|
+
import { readFileSync } from 'fs';
|
|
18
|
+
import { join } from 'path';
|
|
19
19
|
import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
|
|
20
|
-
import { loadHypoIgnore
|
|
20
|
+
import { loadHypoIgnore } from './lib/hypo-ignore.mjs';
|
|
21
|
+
import { collectPagesGraph, extractWikilinks } from './lib/wikilink.mjs';
|
|
21
22
|
|
|
22
23
|
// ── arg parsing ───────────────────────────────────────────────────────────────
|
|
23
24
|
|
|
@@ -32,24 +33,6 @@ function parseArgs(argv) {
|
|
|
32
33
|
return args;
|
|
33
34
|
}
|
|
34
35
|
|
|
35
|
-
// ── page collector ────────────────────────────────────────────────────────────
|
|
36
|
-
|
|
37
|
-
function collectPages(dir, root, pages = [], ignorePatterns = []) {
|
|
38
|
-
if (!existsSync(dir)) return pages;
|
|
39
|
-
for (const entry of readdirSync(dir)) {
|
|
40
|
-
const full = join(dir, entry);
|
|
41
|
-
if (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) =>
|
|
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.
|
|
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
|
-
|
|
24
|
+
const key = line.slice(0, idx).trim();
|
|
25
|
+
if (!key || Object.hasOwn(fm, key)) continue; // first-wins
|
|
26
|
+
fm[key] = line
|
|
9
27
|
.slice(idx + 1)
|
|
10
28
|
.trim()
|
|
11
|
-
|
|
29
|
+
// strip a trailing YAML comment: `#` must follow whitespace to start one,
|
|
30
|
+
// so `concept#bad` stays literal (and still trips lint's unknown-type W2)
|
|
31
|
+
// while `concept # note` loses the comment.
|
|
32
|
+
.replace(/\s+#.*$/, '')
|
|
12
33
|
.replace(/^["']|["']$/g, '');
|
|
13
34
|
}
|
|
14
35
|
return fm;
|