hypomnema 1.4.1 → 1.5.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 +1 -1
- package/README.md +1 -1
- package/commands/audit.md +1 -1
- package/commands/crystallize.md +11 -9
- package/commands/doctor.md +1 -1
- package/commands/feedback.md +2 -2
- package/commands/graph.md +1 -1
- package/commands/ingest.md +1 -1
- package/commands/init.md +2 -2
- package/commands/lint.md +1 -1
- package/commands/query.md +1 -1
- package/commands/rename.md +1 -1
- package/commands/resume.md +1 -1
- package/commands/stats.md +1 -1
- package/commands/uninstall.md +4 -2
- package/commands/upgrade.md +1 -1
- package/commands/verify.md +1 -1
- package/docs/ARCHITECTURE.md +2 -2
- package/docs/CONTRIBUTING.md +20 -12
- package/hooks/hypo-auto-commit.mjs +2 -2
- package/hooks/hypo-auto-minimal-crystallize.mjs +30 -18
- package/hooks/hypo-compact-guard.mjs +2 -2
- package/hooks/hypo-cwd-change.mjs +27 -37
- package/hooks/hypo-personal-check.mjs +37 -22
- package/hooks/hypo-session-end.mjs +1 -1
- package/hooks/hypo-session-record.mjs +2 -2
- package/hooks/hypo-session-start.mjs +34 -40
- package/hooks/hypo-shared.mjs +364 -82
- package/hooks/version-check.mjs +1 -1
- package/package.json +5 -1
- package/scripts/check-tracker-ids.mjs +69 -31
- package/scripts/crystallize.mjs +322 -70
- package/scripts/doctor.mjs +7 -7
- package/scripts/feedback-sync.mjs +5 -5
- package/scripts/feedback.mjs +9 -10
- package/scripts/init.mjs +7 -7
- package/scripts/lib/check-tracker-ids.mjs +90 -32
- package/scripts/lib/design-history-stale.mjs +1 -1
- package/scripts/lib/extensions.mjs +7 -7
- package/scripts/lib/failure-type.mjs +1 -1
- package/scripts/lib/feedback-scope.mjs +1 -1
- package/scripts/lib/project-create.mjs +25 -7
- package/scripts/lib/schema-vocab.mjs +105 -1
- package/scripts/lib/template-schema-version.mjs +1 -1
- package/scripts/lib/wd-match.mjs +181 -0
- package/scripts/lib/wikilink.mjs +1 -1
- package/scripts/lint.mjs +14 -6
- package/scripts/rename.mjs +1 -1
- package/scripts/resume.mjs +20 -22
- package/scripts/session-audit.mjs +1 -1
- package/scripts/stats.mjs +3 -3
- package/scripts/uninstall.mjs +3 -3
- package/scripts/upgrade.mjs +17 -18
- package/skills/crystallize/SKILL.md +16 -8
- package/skills/graph/SKILL.md +2 -2
- package/skills/ingest/SKILL.md +4 -4
- package/skills/lint/SKILL.md +2 -2
- package/skills/query/SKILL.md +2 -2
- package/skills/verify/SKILL.md +2 -2
- package/templates/SCHEMA.md +12 -4
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +24 -15
- package/templates/projects/_template/hot.md +1 -1
- package/scripts/fix-status-verify.mjs +0 -256
- package/scripts/lib/adr-corpus.mjs +0 -79
- package/scripts/lib/fix-manifest.mjs +0 -109
- package/scripts/lib/fix-status-verify.mjs +0 -439
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from 'fs';
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from 'fs';
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
|
|
4
4
|
const VOCAB_HEADER_RE = /^##\s+(?:\d+\.\s+)?Tag\s+(?:Vocabulary|Taxonomy)\s*$/m;
|
|
@@ -66,6 +66,110 @@ export function parseSchemaVocab(hypoDir) {
|
|
|
66
66
|
return vocab;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
// Prose seeded into a freshly-created `### Pending` block. It carries NO
|
|
70
|
+
// backtick tokens, so parseSchemaVocab's vocabLineTokens never mistakes it for a
|
|
71
|
+
// vocabulary line — only the `**Pending**:` data line below it contributes tags.
|
|
72
|
+
const PENDING_HEADING = '### Pending (auto-registered)';
|
|
73
|
+
const PENDING_PROSE =
|
|
74
|
+
'Tags auto-registered by a crystallize close because they were not yet in the ' +
|
|
75
|
+
'vocabulary above. Review periodically: promote each into a category and delete ' +
|
|
76
|
+
'it here, or drop the tag from the page.';
|
|
77
|
+
const PENDING_HEADING_RE = /^###[ \t]+Pending\b.*$/im; // prefix/word match
|
|
78
|
+
const PENDING_DATA_RE = /^\*\*Pending[^*]*\*\*:.*$/im;
|
|
79
|
+
const NEXT_H3_RE = /^###[ \t]+/m;
|
|
80
|
+
const FORBIDDEN_HEADING_RE = /^###[ \t]+Forbidden\b/im;
|
|
81
|
+
|
|
82
|
+
function backtickList(tags) {
|
|
83
|
+
return tags.map((t) => `\`${t}\``).join(', ');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Auto-register unknown (non-forbidden) tags into the vault SCHEMA.md `### Pending`
|
|
87
|
+
// section so a close never stalls on a vocabulary gap and the next lint sees them
|
|
88
|
+
// as known (B-4). Pending lives INSIDE the "## Tag Vocabulary" H2 section because
|
|
89
|
+
// parseSchemaVocab is H2-bounded — placing it elsewhere would not widen the vocab.
|
|
90
|
+
// No-ops (returns []) when SCHEMA.md or the Tag Vocabulary header is absent, when
|
|
91
|
+
// every tag is already in the vocabulary, or when a tag is forbidden — registering
|
|
92
|
+
// a forbidden pattern is pointless (lint errors on it before the vocab check) and
|
|
93
|
+
// would only pollute Pending. Idempotent: re-running with the same tags writes
|
|
94
|
+
// nothing (they are already in the parsed vocabulary on the second call).
|
|
95
|
+
export function appendPendingTags(hypoDir, tags) {
|
|
96
|
+
const path = join(hypoDir, 'SCHEMA.md');
|
|
97
|
+
if (!existsSync(path)) return [];
|
|
98
|
+
const content = readFileSync(path, 'utf-8');
|
|
99
|
+
|
|
100
|
+
const headerMatch = VOCAB_HEADER_RE.exec(content);
|
|
101
|
+
if (!headerMatch) return [];
|
|
102
|
+
|
|
103
|
+
const current = parseSchemaVocab(hypoDir);
|
|
104
|
+
const seen = new Set();
|
|
105
|
+
const newTags = [];
|
|
106
|
+
for (const raw of tags || []) {
|
|
107
|
+
const t = typeof raw === 'string' ? raw.trim() : '';
|
|
108
|
+
// A literal backtick cannot be serialized inside the backtick-delimited
|
|
109
|
+
// `**Pending**:` line — it would close the token early and make
|
|
110
|
+
// parseSchemaVocab mis-tokenize (or blank) the whole line, taking valid
|
|
111
|
+
// sibling tags down with it (codex stage-2 CONCERN). Skip it: the tag stays a
|
|
112
|
+
// non-blocking unknown-tag warn the author can rename. (A `"` is safe — it
|
|
113
|
+
// round-trips between backticks — so only the backtick is rejected.)
|
|
114
|
+
if (!t || t.includes('`') || seen.has(t) || current.has(t) || checkForbidden(t)) continue;
|
|
115
|
+
seen.add(t);
|
|
116
|
+
newTags.push(t);
|
|
117
|
+
}
|
|
118
|
+
if (newTags.length === 0) return [];
|
|
119
|
+
|
|
120
|
+
const sectionStart = headerMatch.index + headerMatch[0].length;
|
|
121
|
+
const rest = content.slice(sectionStart);
|
|
122
|
+
const nextH2 = NEXT_H2_RE.exec(rest);
|
|
123
|
+
const sectionEnd = nextH2 ? sectionStart + nextH2.index : content.length;
|
|
124
|
+
const section = content.slice(sectionStart, sectionEnd);
|
|
125
|
+
|
|
126
|
+
let newSection;
|
|
127
|
+
const heading = PENDING_HEADING_RE.exec(section);
|
|
128
|
+
if (heading) {
|
|
129
|
+
// Pending subsection exists — bound it (heading → next H3 or section end) and
|
|
130
|
+
// either extend its `**Pending**:` data line or seed one if the block is empty
|
|
131
|
+
// (the template ships heading + prose with no tags yet).
|
|
132
|
+
const subStart = heading.index;
|
|
133
|
+
const after = section.slice(heading.index + heading[0].length);
|
|
134
|
+
const nextH3 = NEXT_H3_RE.exec(after);
|
|
135
|
+
const subEnd = nextH3 ? heading.index + heading[0].length + nextH3.index : section.length;
|
|
136
|
+
const sub = section.slice(subStart, subEnd);
|
|
137
|
+
|
|
138
|
+
const dataMatch = PENDING_DATA_RE.exec(sub);
|
|
139
|
+
let newSub;
|
|
140
|
+
if (dataMatch) {
|
|
141
|
+
const existing = [];
|
|
142
|
+
for (const m of dataMatch[0].matchAll(BACKTICK_TOKEN_RE)) {
|
|
143
|
+
const t = m[1].trim();
|
|
144
|
+
if (t && !t.includes(' ')) existing.push(t);
|
|
145
|
+
}
|
|
146
|
+
const merged = [...existing, ...newTags];
|
|
147
|
+
const dataLine = `**Pending**: ${backtickList(merged)}`;
|
|
148
|
+
newSub =
|
|
149
|
+
sub.slice(0, dataMatch.index) + dataLine + sub.slice(dataMatch.index + dataMatch[0].length);
|
|
150
|
+
} else {
|
|
151
|
+
const dataLine = `**Pending**: ${backtickList(newTags)}`;
|
|
152
|
+
newSub = `${sub.replace(/\s*$/, '')}\n\n${dataLine}\n`;
|
|
153
|
+
}
|
|
154
|
+
newSection = section.slice(0, subStart) + newSub + section.slice(subEnd);
|
|
155
|
+
} else {
|
|
156
|
+
// No Pending block yet — build one and place it before "### Forbidden patterns"
|
|
157
|
+
// if present (Pending is vocabulary; Forbidden is a separate concern), else at
|
|
158
|
+
// the end of the Tag Vocabulary section.
|
|
159
|
+
const block = `${PENDING_HEADING}\n\n${PENDING_PROSE}\n\n**Pending**: ${backtickList(newTags)}\n`;
|
|
160
|
+
const forbidden = FORBIDDEN_HEADING_RE.exec(section);
|
|
161
|
+
if (forbidden) {
|
|
162
|
+
newSection =
|
|
163
|
+
section.slice(0, forbidden.index) + `${block}\n` + section.slice(forbidden.index);
|
|
164
|
+
} else {
|
|
165
|
+
newSection = `${section.replace(/\s*$/, '')}\n\n${block}\n`;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
writeFileSync(path, content.slice(0, sectionStart) + newSection + content.slice(sectionEnd));
|
|
170
|
+
return newTags;
|
|
171
|
+
}
|
|
172
|
+
|
|
69
173
|
// Derive the set of valid immediate subdirectories under pages/ from the
|
|
70
174
|
// SCHEMA "Page Type Taxonomy" table (e.g. learnings, feedback, people). Single
|
|
71
175
|
// source of truth — adding a type+dir row to SCHEMA automatically widens the
|
|
@@ -6,7 +6,7 @@ import { parseFrontmatter } from './frontmatter.mjs';
|
|
|
6
6
|
// frontmatter. init.mjs / upgrade.mjs stamp it into hypo-pkg.json metadata;
|
|
7
7
|
// deriving it here (rather than hardcoding a literal at each write site) keeps
|
|
8
8
|
// the stamped value from going stale on a schema bump — the failure mode that
|
|
9
|
-
//
|
|
9
|
+
// the 2.0 → 2.1 schema bump would otherwise have introduced. Returns null only if
|
|
10
10
|
// the template is missing/unreadable (a broken package), in which case callers
|
|
11
11
|
// keep their prior literal default.
|
|
12
12
|
export function templateSchemaVersion(pkgRoot) {
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* scripts/lib/wd-match.mjs — shared cwd ↔ working_dir project matcher.
|
|
3
|
+
*
|
|
4
|
+
* The four readers (resume.mjs, hooks/hypo-shared.mjs, hooks/hypo-session-start.mjs,
|
|
5
|
+
* hooks/hypo-cwd-change.mjs) each used to inline the same prefix-match. This
|
|
6
|
+
* module is the single source of truth they delegate the path logic to, so the
|
|
7
|
+
* cross-machine basename fallback below is implemented (and tested) once.
|
|
8
|
+
*
|
|
9
|
+
* The cross-machine problem: a git-synced vault carries one machine's absolute
|
|
10
|
+
* working_dir to every machine, so on a second machine cwd never prefix-matches
|
|
11
|
+
* and cwd-first resume silently degrades to recency. The basename tier recovers
|
|
12
|
+
* the match WITHOUT a synced map or a writer: when the absolute prefix fails, a
|
|
13
|
+
* cwd ancestor whose directory name is a GLOBALLY unique project working_dir
|
|
14
|
+
* basename identifies the project. Unique-only, so a shared repo dirname fails
|
|
15
|
+
* closed back to recency (the original prefix-only behavior).
|
|
16
|
+
*
|
|
17
|
+
* The matcher (pickProjectByCwd) is PURE — no fs. collectProjectWorkingDirs is
|
|
18
|
+
* the disk companion that builds the project universe the matcher reasons over.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { homedir } from 'os';
|
|
22
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
|
23
|
+
import { join } from 'path';
|
|
24
|
+
import { parseFrontmatter } from './frontmatter.mjs';
|
|
25
|
+
|
|
26
|
+
// Expand a leading `~`/`~/`, strip trailing slashes. Mirrors the inline
|
|
27
|
+
// expansion the readers did, kept here so every caller normalizes identically.
|
|
28
|
+
// Returns null for empty/falsy input.
|
|
29
|
+
export function normalizeWorkingDir(p) {
|
|
30
|
+
if (!p) return null;
|
|
31
|
+
let s = String(p).trim();
|
|
32
|
+
if (s === '~') s = homedir();
|
|
33
|
+
else if (s.startsWith('~/')) s = `${homedir()}/${s.slice(2)}`;
|
|
34
|
+
s = s.replace(/\/+$/, '');
|
|
35
|
+
return s || null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// macOS (APFS/HFS+ default) and Windows match paths case-insensitively, so a
|
|
39
|
+
// case-only difference between cwd and a recorded working_dir is the SAME dir
|
|
40
|
+
// there and a genuinely different dir on Linux. Fold only on those platforms:
|
|
41
|
+
// on Linux folding stays off, preserving the exact original comparison.
|
|
42
|
+
export function isCaseInsensitiveFs(platform = process.platform) {
|
|
43
|
+
return platform === 'darwin' || platform === 'win32';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function casefold(s, ci) {
|
|
47
|
+
return ci ? s.toLowerCase() : s;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Trailing path segment (basename) without importing path — paths here are
|
|
51
|
+
// already normalized (no trailing slash). Returns '' for a bare root.
|
|
52
|
+
function lastSegment(p) {
|
|
53
|
+
const i = p.lastIndexOf('/');
|
|
54
|
+
return i < 0 ? p : p.slice(i + 1);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Resolve which project a cwd belongs to.
|
|
59
|
+
*
|
|
60
|
+
* @param {{slug:string, workingDir:string|null|undefined}[]} projects
|
|
61
|
+
* EVERY project with an index.md (the universe). The basename-uniqueness gate
|
|
62
|
+
* is computed over all of them, so an unrelated project sharing a dirname
|
|
63
|
+
* correctly disqualifies the basename tier (fail closed).
|
|
64
|
+
* @param {string} cwd process.cwd()
|
|
65
|
+
* @param {object} [opts]
|
|
66
|
+
* @param {string[]|null} [opts.eligible] restrict the ANSWER to these slugs
|
|
67
|
+
* (e.g. resume's hot.md rows); uniqueness is still judged over all `projects`.
|
|
68
|
+
* null = any project is an acceptable answer.
|
|
69
|
+
* @param {string|null} [opts.realpathCwd] realpathSync(cwd), if the caller
|
|
70
|
+
* resolved symlinks. Tried in addition to the raw cwd.
|
|
71
|
+
* @param {boolean} [opts.caseInsensitive] override FS case sensitivity (tests).
|
|
72
|
+
* @returns {string|null} matched slug, or null when nothing matches.
|
|
73
|
+
*/
|
|
74
|
+
export function pickProjectByCwd(projects, cwd, opts = {}) {
|
|
75
|
+
const { eligible = null, realpathCwd = null, caseInsensitive = isCaseInsensitiveFs() } = opts;
|
|
76
|
+
if (!cwd && !realpathCwd) return null;
|
|
77
|
+
|
|
78
|
+
const eligibleSet = eligible ? new Set(eligible) : null;
|
|
79
|
+
const isEligible = (slug) => !eligibleSet || eligibleSet.has(slug);
|
|
80
|
+
|
|
81
|
+
// Universe of normalized (slug, path) entries with a real working_dir.
|
|
82
|
+
const entries = [];
|
|
83
|
+
for (const p of projects) {
|
|
84
|
+
const path = normalizeWorkingDir(p.workingDir);
|
|
85
|
+
if (path) entries.push({ slug: p.slug, path });
|
|
86
|
+
}
|
|
87
|
+
if (entries.length === 0) return null;
|
|
88
|
+
|
|
89
|
+
// cwd variants in PRIORITY order: raw first, realpath (symlink-resolved) only
|
|
90
|
+
// as a fallback. A raw-cwd match must not be overridden by a realpath match
|
|
91
|
+
// (preserves the original single-cwd behavior; realpath only rescues misses).
|
|
92
|
+
const cwds = [];
|
|
93
|
+
for (const c of [cwd, realpathCwd]) {
|
|
94
|
+
const n = normalizeWorkingDir(c);
|
|
95
|
+
if (n && !cwds.includes(n)) cwds.push(n);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ── Tier 1: longest absolute prefix (the original behavior) ────────────────
|
|
99
|
+
// cwd === path or cwd under path/. Longest path wins so /repo/sub beats /repo.
|
|
100
|
+
// The FIRST cwd variant that yields any prefix match wins (raw before realpath).
|
|
101
|
+
for (const c of cwds) {
|
|
102
|
+
const cf = casefold(c, caseInsensitive);
|
|
103
|
+
let bestSlug = null;
|
|
104
|
+
let bestLen = -1;
|
|
105
|
+
for (const e of entries) {
|
|
106
|
+
if (!isEligible(e.slug)) continue;
|
|
107
|
+
const pf = casefold(e.path, caseInsensitive);
|
|
108
|
+
if ((cf === pf || cf.startsWith(`${pf}/`)) && e.path.length > bestLen) {
|
|
109
|
+
bestLen = e.path.length;
|
|
110
|
+
bestSlug = e.slug;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
if (bestSlug) return bestSlug;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ── Tier 2: globally-unique basename of a cwd ancestor (cross-machine) ─────
|
|
117
|
+
// Count basenames across the WHOLE universe. A basename is usable only when
|
|
118
|
+
// exactly one project carries it, so a shared dirname falls through to null.
|
|
119
|
+
const byBasename = new Map(); // folded basename -> { slug, count }
|
|
120
|
+
for (const e of entries) {
|
|
121
|
+
const b = casefold(lastSegment(e.path), caseInsensitive);
|
|
122
|
+
if (!b) continue;
|
|
123
|
+
const hit = byBasename.get(b);
|
|
124
|
+
if (hit) hit.count += 1;
|
|
125
|
+
else byBasename.set(b, { slug: e.slug, count: 1 });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// For each cwd variant (raw first), collect every ancestor whose basename is a
|
|
129
|
+
// globally-unique, eligible project basename. Use it ONLY when the whole chain
|
|
130
|
+
// points at exactly ONE project: if two different projects match along the
|
|
131
|
+
// path (e.g. cwd /x/monorepo/api with both `monorepo` and `api` registered),
|
|
132
|
+
// cwd alone can't disambiguate, so decline and let the caller fall back to
|
|
133
|
+
// recency (fail closed, no wrong-project guess).
|
|
134
|
+
for (const c of cwds) {
|
|
135
|
+
const matched = new Set();
|
|
136
|
+
let cur = c;
|
|
137
|
+
while (cur && cur.includes('/')) {
|
|
138
|
+
const b = casefold(lastSegment(cur), caseInsensitive);
|
|
139
|
+
const hit = b && byBasename.get(b);
|
|
140
|
+
if (hit && hit.count === 1 && isEligible(hit.slug)) matched.add(hit.slug);
|
|
141
|
+
cur = cur.slice(0, cur.lastIndexOf('/'));
|
|
142
|
+
}
|
|
143
|
+
if (matched.size === 1) return [...matched][0];
|
|
144
|
+
}
|
|
145
|
+
return null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Scan projects/<slug>/index.md and return [{slug, workingDir}] for every real
|
|
150
|
+
* project. Skips `_template` and dirs without an index.md. The full set is the
|
|
151
|
+
* uniqueness universe for tier 2, so callers should pass ALL projects here even
|
|
152
|
+
* when they later restrict the answer via `opts.eligible`.
|
|
153
|
+
*
|
|
154
|
+
* @param {string} hypoDir
|
|
155
|
+
* @returns {{slug:string, workingDir:string|null}[]}
|
|
156
|
+
*/
|
|
157
|
+
export function collectProjectWorkingDirs(hypoDir) {
|
|
158
|
+
const projectsDir = join(hypoDir, 'projects');
|
|
159
|
+
if (!existsSync(projectsDir)) return [];
|
|
160
|
+
const out = [];
|
|
161
|
+
for (const slug of readdirSync(projectsDir)) {
|
|
162
|
+
if (slug === '_template') continue;
|
|
163
|
+
const dir = join(projectsDir, slug);
|
|
164
|
+
try {
|
|
165
|
+
if (!statSync(dir).isDirectory()) continue;
|
|
166
|
+
} catch {
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
const indexPath = join(dir, 'index.md');
|
|
170
|
+
if (!existsSync(indexPath)) continue;
|
|
171
|
+
let workingDir = null;
|
|
172
|
+
try {
|
|
173
|
+
const fm = parseFrontmatter(readFileSync(indexPath, 'utf-8'));
|
|
174
|
+
workingDir = fm?.working_dir ?? null;
|
|
175
|
+
} catch {
|
|
176
|
+
workingDir = null;
|
|
177
|
+
}
|
|
178
|
+
out.push({ slug, workingDir });
|
|
179
|
+
}
|
|
180
|
+
return out;
|
|
181
|
+
}
|
package/scripts/lib/wikilink.mjs
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// - scripts/graph.mjs (slug index, page collection, link extraction)
|
|
8
8
|
// - scripts/crystallize.mjs (page collection, link extraction)
|
|
9
9
|
//
|
|
10
|
-
//
|
|
10
|
+
// Shared-resolver consolidation. Before this lib, each script carried its own copy of
|
|
11
11
|
// collectPages + a slug-form deriver, drifting in subtle ways. The four
|
|
12
12
|
// collectPages variants are NOT interchangeable — they differ deliberately in
|
|
13
13
|
// traversal/security/output-shape policy (codex design review, CONCERN):
|
package/scripts/lint.mjs
CHANGED
|
@@ -115,7 +115,7 @@ const TYPE_CONDITIONAL_FIELDS = {
|
|
|
115
115
|
'tool-eval': ['status'],
|
|
116
116
|
postmortem: ['outcome'],
|
|
117
117
|
learning: ['source'],
|
|
118
|
-
// feedback:
|
|
118
|
+
// feedback: projection SoT requires full classification
|
|
119
119
|
feedback: [
|
|
120
120
|
'status',
|
|
121
121
|
'scope',
|
|
@@ -136,13 +136,13 @@ const TYPE_ENUM_FIELDS = {
|
|
|
136
136
|
prd: { status: ['draft', 'active', 'completed', 'cancelled', 'archived'] },
|
|
137
137
|
adr: { status: ['proposed', 'accepted', 'deprecated', 'superseded'] },
|
|
138
138
|
'tool-eval': { status: ['adopted', 'evaluating', 'rejected'] },
|
|
139
|
-
// feedback:
|
|
139
|
+
// feedback: sensitivity:private is forbidden (wiki is a
|
|
140
140
|
// git-pushed public surface)
|
|
141
141
|
feedback: {
|
|
142
142
|
status: ['active', 'superseded', 'archived'],
|
|
143
143
|
tier: ['L1', 'L2'],
|
|
144
144
|
sensitivity: ['public', 'sanitized'],
|
|
145
|
-
//
|
|
145
|
+
// optional failure taxonomy. The enum loop guards on `if (fm[field])`,
|
|
146
146
|
// so an omitted failure_type is never validated (optional, migration-safe).
|
|
147
147
|
failure_type: FAILURE_TYPE_ENUM,
|
|
148
148
|
},
|
|
@@ -283,7 +283,7 @@ const issues = [];
|
|
|
283
283
|
// double-gate).
|
|
284
284
|
// NOTE: no gate currently passes --strict (npm run lint / CI / release.yml /
|
|
285
285
|
// crystallize / the close-gate all run plain lint), so promotion is
|
|
286
|
-
// forward-looking
|
|
286
|
+
// forward-looking: these surface as warnings today. The deliverable is
|
|
287
287
|
// "stop silently green-passing invalid YAML"; the W9 warning satisfies that.
|
|
288
288
|
const STRICT_PROMOTE_IDS = new Set(['W1', 'W2', 'W4', 'W9']);
|
|
289
289
|
|
|
@@ -392,7 +392,7 @@ function lintPage({ path, rel }, slugMap, tagVocab, pageDirs, validTypes) {
|
|
|
392
392
|
}
|
|
393
393
|
}
|
|
394
394
|
|
|
395
|
-
// feedback: scope vocabulary + conditional claude-learned fields
|
|
395
|
+
// feedback: scope vocabulary + conditional claude-learned fields
|
|
396
396
|
if (fm.type === 'feedback') {
|
|
397
397
|
const scope = fm.scope || '';
|
|
398
398
|
if (scope && !FEEDBACK_SCOPE_RE.test(scope)) {
|
|
@@ -426,7 +426,15 @@ function lintPage({ path, rel }, slugMap, tagVocab, pageDirs, validTypes) {
|
|
|
426
426
|
continue;
|
|
427
427
|
}
|
|
428
428
|
if (!tagVocab.has(tag)) {
|
|
429
|
-
|
|
429
|
+
// W10 (B-4): an unknown but non-forbidden tag is a WARNING, not a hard
|
|
430
|
+
// error — a session close must never stall on a vocabulary gap. The
|
|
431
|
+
// crystallize apply path parses these warns (by this exact message
|
|
432
|
+
// string — load-bearing, see scripts/crystallize.mjs auto-register block)
|
|
433
|
+
// and registers them into SCHEMA.md's `### Pending` section, so the next
|
|
434
|
+
// lint sees them as known. W10 is intentionally NOT in STRICT_PROMOTE_IDS:
|
|
435
|
+
// an unregistered tag is a vocabulary lag, not a content defect, so even
|
|
436
|
+
// `--strict` keeps it a warning (it only surfaces the id in --json output).
|
|
437
|
+
issue('warn', rel, `Unknown tag: "${tag}" (not in SCHEMA.md Tag Vocabulary)`, null, 'W10');
|
|
430
438
|
}
|
|
431
439
|
}
|
|
432
440
|
}
|
package/scripts/rename.mjs
CHANGED
|
@@ -234,7 +234,7 @@ function fail(args, msg) {
|
|
|
234
234
|
// • A directory move does NOT change basenames, only the path prefix. So bare
|
|
235
235
|
// `[[0052]]` links keep resolving to the relocated page automatically and need
|
|
236
236
|
// NO rewrite — only the FULL-slug and 1-seg DIR-RELATIVE forms encode the moved
|
|
237
|
-
// prefix and break. (Forms that drop ≥2 segments, e.g. `[[decisions/
|
|
237
|
+
// prefix and break. (Forms that drop ≥2 segments, e.g. `[[decisions/NNNN]]`, do
|
|
238
238
|
// not encode the renamed prefix either: lint cannot resolve them today and they
|
|
239
239
|
// survive a move unchanged — out of scope, matching lint's buildSlugMap.)
|
|
240
240
|
// • Every moved page is BOTH a target (it relocates) and a source (its links to
|
package/scripts/resume.mjs
CHANGED
|
@@ -26,10 +26,10 @@
|
|
|
26
26
|
* --json Output as JSON
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
|
-
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
|
29
|
+
import { existsSync, readFileSync, readdirSync, statSync, realpathSync } from 'fs';
|
|
30
30
|
import { join } from 'path';
|
|
31
|
-
import { homedir } from 'os';
|
|
32
31
|
import { resolveHypoRoot, expandHome } from './lib/hypo-root.mjs';
|
|
32
|
+
import { pickProjectByCwd, collectProjectWorkingDirs } from './lib/wd-match.mjs';
|
|
33
33
|
|
|
34
34
|
// ── arg parsing ──────────────────────────────────────────────────────────────
|
|
35
35
|
|
|
@@ -60,27 +60,25 @@ function parseFrontmatterField(content, key) {
|
|
|
60
60
|
.replace(/^['"]|['"]$/g, '');
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
63
|
+
// Return the project (among `slugs`) that owns cwd: a longest-prefix
|
|
64
|
+
// working_dir match, or — when no absolute path matches because the vault was
|
|
65
|
+
// synced from another machine — a cwd ancestor whose directory name is a
|
|
66
|
+
// globally-unique project basename. Uniqueness is judged over EVERY project on
|
|
67
|
+
// disk (not just `slugs`), so a shared dirname declines and falls back to
|
|
68
|
+
// recency. resume gives this authority over recency: a cwd↔project match wins
|
|
69
|
+
// regardless of which hot.md row is newest.
|
|
67
70
|
function pickByCwd(hypoDir, slugs, cwd) {
|
|
68
71
|
if (!cwd) return null;
|
|
69
|
-
let
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const wd = parseFrontmatterField(readFileSync(indexPath, 'utf-8'), 'working_dir');
|
|
75
|
-
if (!wd) continue;
|
|
76
|
-
let resolved = wd.startsWith('~/') ? join(homedir(), wd.slice(2)) : wd;
|
|
77
|
-
resolved = resolved.replace(/\/+$/, ''); // trailing-slash normalize
|
|
78
|
-
if ((cwd === resolved || cwd.startsWith(resolved + '/')) && resolved.length > bestLen) {
|
|
79
|
-
bestLen = resolved.length;
|
|
80
|
-
best = slug;
|
|
81
|
-
}
|
|
72
|
+
let realpathCwd = null;
|
|
73
|
+
try {
|
|
74
|
+
realpathCwd = realpathSync(cwd);
|
|
75
|
+
} catch {
|
|
76
|
+
realpathCwd = null;
|
|
82
77
|
}
|
|
83
|
-
return
|
|
78
|
+
return pickProjectByCwd(collectProjectWorkingDirs(hypoDir), cwd, {
|
|
79
|
+
eligible: slugs,
|
|
80
|
+
realpathCwd,
|
|
81
|
+
});
|
|
84
82
|
}
|
|
85
83
|
|
|
86
84
|
// When cwd is set but no project's working_dir matches it, resume falls back to
|
|
@@ -125,11 +123,11 @@ function resolveActiveProject(hypoDir, cwd = null) {
|
|
|
125
123
|
),
|
|
126
124
|
].map((m) => ({ name: m[1].trim(), date: m[2] || '', slug: m[3] }));
|
|
127
125
|
if (wikiRows.length > 0) {
|
|
128
|
-
// cwd-first
|
|
126
|
+
// cwd-first: a cwd↔working_dir match wins over recency, across
|
|
129
127
|
// ALL rows (not just a same-date tie). The user is physically in that
|
|
130
128
|
// project, so cwd is a stronger intent signal than "some other project was
|
|
131
129
|
// touched more recently". This reverses the earlier tie-breaker-only
|
|
132
|
-
// semantics now that resume=cwd-positive
|
|
130
|
+
// semantics now that resume=cwd-positive. Tradeoff: a stale cwd
|
|
133
131
|
// match can mask a genuinely newer project; `--project` overrides. close
|
|
134
132
|
// callers pass null → recency path below (resume=cwd-positive / close=no-pick).
|
|
135
133
|
if (cwd) {
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* (search count, ingest count, URLs mentioned, feedback count) so the
|
|
7
7
|
* weekly observability report (Lane E) can compute autonomy scores.
|
|
8
8
|
*
|
|
9
|
-
* Transcript dual-source
|
|
9
|
+
* Transcript dual-source:
|
|
10
10
|
* 1) Primary: <hypo-dir>/.cache/sessions/index.jsonl
|
|
11
11
|
* Written by hooks/hypo-session-record.mjs (Stop hook).
|
|
12
12
|
* 2) Fallback: ~/.claude/projects/<encoded>/*.jsonl
|
package/scripts/stats.mjs
CHANGED
|
@@ -48,8 +48,8 @@ function collectMdFiles(dir, acc = [], hypoDir = '', ignorePatterns = []) {
|
|
|
48
48
|
// Local top-level-only extractor, behavior-aligned with lib/frontmatter.mjs:
|
|
49
49
|
// skip indented / list lines, first-wins, strip a trailing `# comment`. Without
|
|
50
50
|
// the comment strip a `failure_type: overreach # note` would aggregate under the
|
|
51
|
-
// literal "overreach # note" key
|
|
52
|
-
// hold stats' dependency surface flat per the
|
|
51
|
+
// literal "overreach # note" key. Kept local (not the shared import) to
|
|
52
|
+
// hold stats' dependency surface flat per the scope split.
|
|
53
53
|
function parseFrontmatter(content) {
|
|
54
54
|
const m = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
55
55
|
if (!m) return null;
|
|
@@ -101,7 +101,7 @@ const sources = existsSync(join(args.hypoDir, 'sources'))
|
|
|
101
101
|
: [];
|
|
102
102
|
|
|
103
103
|
const typeCounts = {};
|
|
104
|
-
const failureTypeCounts = {}; //
|
|
104
|
+
const failureTypeCounts = {}; // feedback pages carrying a failure_type
|
|
105
105
|
let missingFrontmatter = 0;
|
|
106
106
|
|
|
107
107
|
for (const f of pageFiles) {
|
package/scripts/uninstall.mjs
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
* --force-extensions Remove user-modified extension files (hypo-ext-*) instead of preserving them
|
|
16
16
|
* --hooks-dir=<path> Override Claude hooks directory (default: ~/.claude/hooks)
|
|
17
17
|
*
|
|
18
|
-
* Extensions
|
|
18
|
+
* Extensions: hypo-ext-* hard-copies under
|
|
19
19
|
* ~/.claude/{hooks,commands,skills,agents}/ and ~/.codex/{hooks,commands}/ (with
|
|
20
20
|
* --codex) are removed when their on-disk SHA matches the recorded one in
|
|
21
21
|
* ~/.claude/hypo-pkg.json#extensions.<target>. User-modified copies are preserved
|
|
@@ -94,7 +94,7 @@ function removeCommands(apply, force) {
|
|
|
94
94
|
return { removed, skippedUserModified, skippedNonRegular };
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
// ── extensions removal
|
|
97
|
+
// ── extensions removal ───────────────────────────────────
|
|
98
98
|
|
|
99
99
|
// Strip per-target extension SHA records from ~/.claude/hypo-pkg.json. Surgical:
|
|
100
100
|
// we only touch the entries for keys we actually removed, so a `--force-extensions`
|
|
@@ -424,7 +424,7 @@ const hookResult = removeHookFiles(claudeHooksDir, hookFiles, args.apply);
|
|
|
424
424
|
const settingsResult = stripSettingsJson(claudeSettings, claudeHooksDir, hookMap, args.apply);
|
|
425
425
|
const commandResult = removeCommands(args.apply, args.forceCommands);
|
|
426
426
|
|
|
427
|
-
// Extensions
|
|
427
|
+
// Extensions. Order matters: remove files first, then strip
|
|
428
428
|
// settings, then surgically clear the per-target SHA map. The SHA strip uses
|
|
429
429
|
// removedKeys so a user-modified file we left in place keeps its recorded SHA
|
|
430
430
|
// (doctor still has a baseline next run). Settings are path-based and run even
|
package/scripts/upgrade.mjs
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* the user-extensions companion sync (E4).
|
|
21
21
|
* --json Output results as JSON
|
|
22
22
|
* --allow-downgrade Override the guard that refuses to overwrite a NEWER
|
|
23
|
-
* active install with an older package
|
|
23
|
+
* active install with an older package
|
|
24
24
|
* --allow-dual-install Override the dual-install guard: register the Claude core
|
|
25
25
|
* surface even though the Hypomnema plugin is also enabled
|
|
26
26
|
* (knowingly accept the double-registration risk)
|
|
@@ -235,7 +235,7 @@ function checkSchemaVersion(hypoDir) {
|
|
|
235
235
|
|
|
236
236
|
// Target-aware: the same core-hook integrity check runs against ~/.claude/hooks/
|
|
237
237
|
// for the default claude target, and against ~/.codex/hooks/ when --codex is set
|
|
238
|
-
//
|
|
238
|
+
// The function reads only (apply happens in applyHookFiles).
|
|
239
239
|
function checkHookFiles(hooksDir) {
|
|
240
240
|
const results = [];
|
|
241
241
|
|
|
@@ -473,15 +473,15 @@ function writeMigrationReport(hypoDir, fromVersion, toVersion, { pluginMode = fa
|
|
|
473
473
|
// Don't overwrite an existing report
|
|
474
474
|
if (existsSync(dest)) return dest;
|
|
475
475
|
|
|
476
|
-
// v1 → v2 specific guidance for the
|
|
477
|
-
//
|
|
478
|
-
//
|
|
479
|
-
//
|
|
480
|
-
//
|
|
481
|
-
// Fire for any 1.x → 2.x major crossing, not just 1.0 → 2.0 exactly: the
|
|
482
|
-
//
|
|
476
|
+
// v1 → v2 specific guidance for the feedback classification bump. Other major
|
|
477
|
+
// jumps fall back to the generic body. The schema deliberately keeps SCHEMA.md
|
|
478
|
+
// user-owned (Option C); auto-stub of the 9 new fields is rejected because
|
|
479
|
+
// scope/tier/targets/sensitivity/reason/source are semantic decisions whose
|
|
480
|
+
// wrong defaults would project wrong behavior.
|
|
481
|
+
// Fire for any 1.x → 2.x major crossing, not just 1.0 → 2.0 exactly: the
|
|
482
|
+
// feedback-field backfill that this body explains was introduced at 2.0
|
|
483
483
|
// and still applies to a user landing on any later 2.x (e.g. 2.1's additive
|
|
484
|
-
// failure_type
|
|
484
|
+
// failure_type). Keying on the exact target string would silently
|
|
485
485
|
// drop this guidance the moment the template minor moves.
|
|
486
486
|
const fromV = parseVersion(fromVersion);
|
|
487
487
|
const toV = parseVersion(toVersion);
|
|
@@ -489,8 +489,7 @@ function writeMigrationReport(hypoDir, fromVersion, toVersion, { pluginMode = fa
|
|
|
489
489
|
const specificBody = isV1ToV2
|
|
490
490
|
? `## What changed in SCHEMA 2.0
|
|
491
491
|
|
|
492
|
-
|
|
493
|
-
made \`feedback\` pages the single source of truth for behavior corrections and added
|
|
492
|
+
This release made \`feedback\` pages the single source of truth for behavior corrections and added
|
|
494
493
|
**9 hard-required frontmatter fields** for every \`feedback\` page:
|
|
495
494
|
|
|
496
495
|
- \`status\` (active | superseded | archived)
|
|
@@ -510,8 +509,8 @@ that does not satisfy all four is rejected by both \`hypomnema lint\` and
|
|
|
510
509
|
\`hypomnema feedback\` (see \`scripts/lint.mjs\` and \`scripts/feedback.mjs\`
|
|
511
510
|
enforcement).
|
|
512
511
|
|
|
513
|
-
|
|
514
|
-
|
|
512
|
+
This schema bump is semver-major because existing feedback pages without these
|
|
513
|
+
fields now fail \`hypomnema lint\`.
|
|
515
514
|
|
|
516
515
|
## Action items — existing wiki
|
|
517
516
|
|
|
@@ -799,7 +798,7 @@ function writePluginModeMetadata() {
|
|
|
799
798
|
const args = parseArgs(process.argv);
|
|
800
799
|
|
|
801
800
|
// Target paths. Claude is always checked; codex is checked only when --codex is set
|
|
802
|
-
//
|
|
801
|
+
// so users without codex installed see no false drift.
|
|
803
802
|
const claudeHooksDir = join(HOME, '.claude', 'hooks');
|
|
804
803
|
const claudeSettingsPath = join(HOME, '.claude', 'settings.json');
|
|
805
804
|
const codexHooksDir = join(HOME, '.codex', 'hooks');
|
|
@@ -858,7 +857,7 @@ const hooksCodex = args.codex ? checkHookFiles(codexHooksDir) : null;
|
|
|
858
857
|
const settingsCodex = args.codex ? checkSettingsJson(codexSettingsPath, codexHooksDir) : null;
|
|
859
858
|
const oldHookRefsCodex = args.codex ? checkOldHookNames(codexSettingsPath) : null;
|
|
860
859
|
|
|
861
|
-
// Extensions companion
|
|
860
|
+
// Extensions companion. Read-only check; the apply
|
|
862
861
|
// happens below, AFTER applyCommands, so the per-target SHA map merges into the
|
|
863
862
|
// hypo-pkg.json that applyCommands writes (rather than being clobbered by it).
|
|
864
863
|
const extSettingsPath = claudeSettingsPath;
|
|
@@ -935,7 +934,7 @@ let appliedExtensions = null;
|
|
|
935
934
|
let appliedExtensionsCodex = null;
|
|
936
935
|
|
|
937
936
|
if (args.apply) {
|
|
938
|
-
// Downgrade guard
|
|
937
|
+
// Downgrade guard: an `--apply` from an OLDER package than the
|
|
939
938
|
// active install would overwrite newer hooks (upgrade.mjs:287 copyFileSync) and
|
|
940
939
|
// rewrite hypo-pkg.json to the older version. Refuse before the first mutation.
|
|
941
940
|
// A dev workspace re-running its own --apply (incl. the post-commit sync hook)
|
|
@@ -1370,7 +1369,7 @@ if (hypoignore.status === 'no-file') {
|
|
|
1370
1369
|
for (const e of hypoignore.missing) lines.push(` + ${e.pattern}`);
|
|
1371
1370
|
}
|
|
1372
1371
|
|
|
1373
|
-
// Extensions companion (
|
|
1372
|
+
// Extensions companion (conflict/drift gating E3, #31). Shared by the
|
|
1374
1373
|
// claude target and, under --codex, the codex target (E4, #32) — the label keeps
|
|
1375
1374
|
// the two blocks distinguishable in the report.
|
|
1376
1375
|
function pushExtSummary(check, label) {
|