hypomnema 1.4.2 → 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 +8 -8
- 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 +8 -7
- package/hooks/hypo-auto-commit.mjs +2 -2
- package/hooks/hypo-auto-minimal-crystallize.mjs +7 -7
- 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 +271 -71
- package/hooks/version-check.mjs +1 -1
- package/package.json +5 -1
- package/scripts/check-tracker-ids.mjs +69 -31
- package/scripts/crystallize.mjs +82 -38
- 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 +2 -2
- 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 +5 -5
- 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 +7 -7
- 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 +1 -1
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +20 -14
- 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
package/scripts/init.mjs
CHANGED
|
@@ -123,7 +123,7 @@ Commands:
|
|
|
123
123
|
doctor Health check: directories, files, hooks, settings.json, git
|
|
124
124
|
uninstall Remove hooks and registrations (dry-run by default; pass --apply)
|
|
125
125
|
feedback-sync Project feedback (SoT) → MEMORY.md / CLAUDE.md learned-behaviors
|
|
126
|
-
projection (--check default, --write to apply
|
|
126
|
+
projection (--check default, --write to apply)
|
|
127
127
|
|
|
128
128
|
Running \`hypomnema\` with no command is equivalent to \`hypomnema init\`.
|
|
129
129
|
|
|
@@ -200,7 +200,7 @@ const HYPO_DIRS = [
|
|
|
200
200
|
'journal/weekly',
|
|
201
201
|
'journal/monthly',
|
|
202
202
|
'pages/observability',
|
|
203
|
-
// User extensions companion
|
|
203
|
+
// User extensions companion. init creates the baseline
|
|
204
204
|
// dirs; #29 (E2) adds the hard-copy / manifest / settings sync into them.
|
|
205
205
|
'extensions/hooks',
|
|
206
206
|
'extensions/commands',
|
|
@@ -451,7 +451,7 @@ function pkgJsonPath() {
|
|
|
451
451
|
}
|
|
452
452
|
|
|
453
453
|
/**
|
|
454
|
-
*
|
|
454
|
+
* Abort when this package would DOWNGRADE a newer active install.
|
|
455
455
|
* Exit 2 (distinct from the generic exit-1 error class) on refusal so callers and
|
|
456
456
|
* tests can tell "refused downgrade" apart from "init failed". No-ops on a fresh
|
|
457
457
|
* install (no metadata), unparseable versions, the same package re-running itself,
|
|
@@ -829,7 +829,7 @@ const args = parseArgs(process.argv);
|
|
|
829
829
|
// Validate hooks.json before any file writes so a bad package leaves no partial state
|
|
830
830
|
const HOOK_MAP = args.hooks || args.codex ? loadHookMap() : null;
|
|
831
831
|
|
|
832
|
-
// Downgrade guard
|
|
832
|
+
// Downgrade guard: refuse to overwrite a NEWER active install with
|
|
833
833
|
// an older package. The footgun: a stale `npm i -g hypomnema` owns the `hypomnema`
|
|
834
834
|
// bin on PATH, so `hypomnema init` runs the OLD package and silently downgrades
|
|
835
835
|
// the newer install (dropping features like the update-notifier). Runs
|
|
@@ -854,7 +854,7 @@ if (args.fromRemote) {
|
|
|
854
854
|
for (const d of HYPO_DIRS) ensureDir(join(args.hypoDir, d), args.dryRun);
|
|
855
855
|
|
|
856
856
|
// 1b. extensions baseline: drop a .gitkeep into each so the empty dirs are
|
|
857
|
-
// git-trackable in the user's wiki repo
|
|
857
|
+
// git-trackable in the user's wiki repo.
|
|
858
858
|
for (const t of ['hooks', 'commands', 'skills', 'agents']) {
|
|
859
859
|
copyTemplate(
|
|
860
860
|
join('extensions', t, '.gitkeep'),
|
|
@@ -929,12 +929,12 @@ if (args.hooks) {
|
|
|
929
929
|
// Always record the active install identity (pkgRoot + pkgVersion), even for a
|
|
930
930
|
// --no-hooks --no-commands scaffold. That flow still installs the wiki pre-commit
|
|
931
931
|
// hook (and, with --codex, ~/.codex hooks); without a recorded pkgVersion baseline
|
|
932
|
-
// the
|
|
932
|
+
// the downgrade guard has nothing to compare against, so a later stale
|
|
933
933
|
// sibling could repoint those surfaces unguarded. writePkgJson merges (...existing),
|
|
934
934
|
// so this never clobbers a commands/extensions map written elsewhere.
|
|
935
935
|
writePkgJson(args.dryRun, commandSHAs ? { commands: commandSHAs } : {});
|
|
936
936
|
|
|
937
|
-
// 4b. user extensions companion sync
|
|
937
|
+
// 4b. user extensions companion sync. Runs after
|
|
938
938
|
// writePkgJson so the per-target SHA map is merged into the same hypo-pkg.json
|
|
939
939
|
// (preserving the commands map) rather than racing it.
|
|
940
940
|
if (args.hooks) {
|
|
@@ -10,15 +10,25 @@
|
|
|
10
10
|
* This module is PURE (no fs, no git, no process) so it is unit-testable; the
|
|
11
11
|
* CLI wrapper (scripts/check-tracker-ids.mjs) does the file/git/commit-msg I/O.
|
|
12
12
|
*
|
|
13
|
-
* Blocked (examples use an `N` placeholder, NOT a real
|
|
14
|
-
* itself scans clean and is NOT exempted from --all):
|
|
13
|
+
* Blocked everywhere in scope (examples use an `N` placeholder, NOT a real
|
|
14
|
+
* digit, so this file itself scans clean and is NOT exempted from --all):
|
|
15
15
|
* - /\bISSUE-\d+\b/i → the wiki issue tracker, any case (e.g. ISSUE-N)
|
|
16
16
|
* - /\bfix[ \t ]+#\d+\b/i → the wiki fix tracker, any case (e.g. fix #N)
|
|
17
|
+
* - /\bFEAT-\d+\b/i → the wiki feature tracker, any case (e.g. FEAT-N)
|
|
18
|
+
* - /\bIMPR-\d+\b/i → the wiki improvement tracker, any case (e.g. IMPR-N)
|
|
19
|
+
* - /\bPRAC-\d+\b/i → the wiki practice tracker, any case (e.g. PRAC-N)
|
|
17
20
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
21
|
+
* FEAT-/IMPR-/PRAC- were once exempt inside shipped code comments; that exemption
|
|
22
|
+
* shipped dangling pointers into the maintainer's private wiki to OSS users, so it
|
|
23
|
+
* was removed. They now block everywhere in scope; the maintainer keeps them only
|
|
24
|
+
* in tests/, qa-runs/, and local notes. The verifier subsystem that legitimately
|
|
25
|
+
* carries `decisions/NNNN` runtime data is excluded from the scan instead (see the
|
|
26
|
+
* CLI's EXCLUDED_FILES).
|
|
27
|
+
*
|
|
28
|
+
* `ADR NNNN` / `ADR-NNNN` / `decisions/NNNN` (DECISION_PATTERNS) are dangling
|
|
29
|
+
* pointers into the maintainer's private wiki ADR set. They block everywhere in
|
|
30
|
+
* scope EXCEPT CHANGELOG.md, whose version history legitimately cites the decision
|
|
31
|
+
* behind a release line. The CLI applies these per-file via patternsFor().
|
|
22
32
|
*
|
|
23
33
|
* Accepted edge cases (documented, not bugs):
|
|
24
34
|
* - FALSE POSITIVE: `FOO-ISSUE-N` matches (the `-` gives a word boundary
|
|
@@ -28,21 +38,22 @@
|
|
|
28
38
|
* safe — none start the word `fix` right before the `#`.
|
|
29
39
|
*/
|
|
30
40
|
|
|
31
|
-
//
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
//
|
|
35
|
-
//
|
|
36
|
-
// Examples below use `NNNN`, not real digits, so this file
|
|
37
|
-
|
|
41
|
+
// `ADR NNNN` / `ADR-NNNN` / `decisions/NNNN` point into the maintainer's private
|
|
42
|
+
// wiki ADR set, which an OSS user does not have. The CLI applies this set to every
|
|
43
|
+
// in-scope file EXCEPT CHANGELOG.md (version history may cite a decision); the
|
|
44
|
+
// verifier subsystem that carries decisions/ paths as runtime data is removed by
|
|
45
|
+
// EXCLUDED_FILES first. The ADR matcher tolerates a space, tab, or hyphen between
|
|
46
|
+
// `ADR` and the number. Examples below use `NNNN`, not real digits, so this file
|
|
47
|
+
// scans clean.
|
|
48
|
+
export const DECISION_PATTERNS = [
|
|
38
49
|
{
|
|
39
50
|
name: 'ADR NNNN',
|
|
40
|
-
label: 'wiki ADR pointer
|
|
41
|
-
re: /\bADR[ \t]+\d{3,4}\b/gi,
|
|
51
|
+
label: 'wiki ADR pointer',
|
|
52
|
+
re: /\bADR[ \t-]+\d{3,4}\b/gi,
|
|
42
53
|
},
|
|
43
54
|
{
|
|
44
55
|
name: 'decisions/NNNN',
|
|
45
|
-
label: 'wiki decisions path
|
|
56
|
+
label: 'wiki decisions path',
|
|
46
57
|
re: /\bdecisions\/\d{3,4}\b/gi,
|
|
47
58
|
},
|
|
48
59
|
];
|
|
@@ -59,32 +70,40 @@ export const BLOCKED_PATTERNS = [
|
|
|
59
70
|
label: 'wiki fix-tracker id',
|
|
60
71
|
re: /\bfix[ \t ]+#\d+\b/gi,
|
|
61
72
|
},
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
// maintainer context (e.g. `// FEAT-17 hardening`), exactly like ADR anchors —
|
|
67
|
-
// blocking them everywhere would flag ~20 real in-code references. They ARE
|
|
68
|
-
// blocked on the PUBLIC RELEASE SURFACE that carries no code: the annotated tag
|
|
69
|
-
// body (the source of the GitHub Release via --notes-from-tag). The CLI applies
|
|
70
|
-
// [...BLOCKED_PATTERNS, ...SURFACE_TRACKER_PATTERNS] only in --tag mode. The
|
|
71
|
-
// CHANGELOG's own surface-ID-0 is held by the section migration + a grep
|
|
72
|
-
// regression test, not by widening this file-scan set (changelog-pr-guide §5).
|
|
73
|
-
// Examples below use `N`, not a real digit, so this file scans clean.
|
|
74
|
-
export const SURFACE_TRACKER_PATTERNS = [
|
|
73
|
+
// FEAT-/IMPR-/PRAC- were a tag-body-only set until they were promoted here so
|
|
74
|
+
// they block in shipped code comments too (the old exemption leaked dead wiki
|
|
75
|
+
// pointers to OSS users). Examples below use `N`, not a real digit, so this
|
|
76
|
+
// file scans clean and is NOT exempted from --all.
|
|
75
77
|
{ name: 'FEAT-N', label: 'wiki feature-tracker id', re: /\bFEAT-\d+\b/gi },
|
|
76
78
|
{ name: 'IMPR-N', label: 'wiki improvement-tracker id', re: /\bIMPR-\d+\b/gi },
|
|
77
79
|
{ name: 'PRAC-N', label: 'wiki practice-tracker id', re: /\bPRAC-\d+\b/gi },
|
|
78
80
|
];
|
|
79
81
|
|
|
80
|
-
// The full tracker-ID set for a no-code public surface (the annotated tag body
|
|
81
|
-
|
|
82
|
+
// The full tracker-ID set for a no-code public surface (the annotated tag body,
|
|
83
|
+
// republished verbatim by `gh release create --notes-from-tag`). It equals
|
|
84
|
+
// BLOCKED_PATTERNS now that FEAT-/IMPR-/PRAC- live there; ADR/decisions anchors
|
|
85
|
+
// are intentionally allowed in the tag body (release notes cite decisions the way
|
|
86
|
+
// CHANGELOG history does). The CHANGELOG's own surface-ID-0 is held by the section
|
|
87
|
+
// migration + a grep regression test (changelog-pr-guide §5).
|
|
88
|
+
export const TAG_BODY_PATTERNS = [...BLOCKED_PATTERNS];
|
|
89
|
+
|
|
90
|
+
// Strip a leading comment-continuation marker (`*`, `//`, `#`) so a wrapped
|
|
91
|
+
// comment line can be re-joined to its predecessor as flowing text.
|
|
92
|
+
const COMMENT_CONT_RE = /^[ \t]*(?:\*|\/\/|#+)[ \t]?/;
|
|
82
93
|
|
|
83
94
|
/**
|
|
84
95
|
* Scan a blob of text against `patterns` (default BLOCKED_PATTERNS). Returns hits:
|
|
85
96
|
* { pattern, label, match, line, col, lineText }
|
|
86
97
|
* `line`/`col` are 1-based. Empty array => clean. The CLI passes the broader
|
|
87
|
-
* [...BLOCKED_PATTERNS, ...
|
|
98
|
+
* [...BLOCKED_PATTERNS, ...DECISION_PATTERNS] set for every in-scope file but the
|
|
99
|
+
* CHANGELOG.
|
|
100
|
+
*
|
|
101
|
+
* Tracker tokens also line-wrap inside comments (`... continuing (ADR\n * 0045)`).
|
|
102
|
+
* A pure per-line scan misses those, so each line is ALSO scanned joined to the
|
|
103
|
+
* next (with the next line's comment-continuation marker collapsed to a space);
|
|
104
|
+
* only matches that START on the current line and CROSS the join are kept, so a
|
|
105
|
+
* wrap is reported exactly once at its prefix line and non-wrapped tokens are
|
|
106
|
+
* never double-counted.
|
|
88
107
|
*/
|
|
89
108
|
export function scanText(text, patterns = BLOCKED_PATTERNS) {
|
|
90
109
|
if (typeof text !== 'string' || text.length === 0) return [];
|
|
@@ -107,6 +126,45 @@ export function scanText(text, patterns = BLOCKED_PATTERNS) {
|
|
|
107
126
|
if (m.index === re.lastIndex) re.lastIndex++; // never-zero-width guard
|
|
108
127
|
}
|
|
109
128
|
}
|
|
129
|
+
// Wrapped-token guard: a token can line-wrap at two points — between its
|
|
130
|
+
// prefix WORD and its number (`ADR\n0045`, where the break stands in for a
|
|
131
|
+
// space) OR right at its separator (`ISSUE-\n9`, `decisions/\n0031`,
|
|
132
|
+
// `fix #\n37`, where the digit must sit flush against `-`/`/`/`#`). So scan
|
|
133
|
+
// this line joined to the next BOTH with a space (space-separated forms) and
|
|
134
|
+
// with no gap (separator-flush forms), keeping only matches that begin on this
|
|
135
|
+
// line and cross the join. A token reconstructed by both joins (e.g. the ADR
|
|
136
|
+
// hyphen form) is de-duplicated by its whitespace-collapsed text.
|
|
137
|
+
if (i + 1 < lines.length) {
|
|
138
|
+
const lt = lineText.length;
|
|
139
|
+
const tail = lines[i + 1].replace(COMMENT_CONT_RE, '');
|
|
140
|
+
const seen = new Set();
|
|
141
|
+
for (const [joined, tailAt] of [
|
|
142
|
+
[lineText + ' ' + tail, lt + 1],
|
|
143
|
+
[lineText + tail, lt],
|
|
144
|
+
]) {
|
|
145
|
+
for (const { name, label, re } of patterns) {
|
|
146
|
+
re.lastIndex = 0;
|
|
147
|
+
let m;
|
|
148
|
+
while ((m = re.exec(joined)) !== null) {
|
|
149
|
+
if (m.index < lt && m.index + m[0].length > tailAt) {
|
|
150
|
+
const key = name + ':' + m[0].replace(/\s+/g, '');
|
|
151
|
+
if (!seen.has(key)) {
|
|
152
|
+
seen.add(key);
|
|
153
|
+
hits.push({
|
|
154
|
+
pattern: name,
|
|
155
|
+
label,
|
|
156
|
+
match: m[0],
|
|
157
|
+
line: i + 1,
|
|
158
|
+
col: m.index + 1,
|
|
159
|
+
lineText: joined,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (m.index === re.lastIndex) re.lastIndex++;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
110
168
|
}
|
|
111
169
|
return hits;
|
|
112
170
|
}
|
|
@@ -88,7 +88,7 @@ export function findDesignHistoryStale(hypoDir) {
|
|
|
88
88
|
if (!existsSync(dhPath)) continue;
|
|
89
89
|
|
|
90
90
|
// session-log can live as a flat `session-log.md` (legacy) or a directory of
|
|
91
|
-
// daily shards `session-log/YYYY-MM-DD.md` (
|
|
91
|
+
// daily shards `session-log/YYYY-MM-DD.md` (canonical; legacy
|
|
92
92
|
// monthly `YYYY-MM.md` files still appear pre-cutover). This globs every
|
|
93
93
|
// `.md` in the directory, so daily and monthly shapes are both aggregated —
|
|
94
94
|
// the staleness check needs to see all of them.
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* scripts/lib/extensions.mjs — User extensions companion sync
|
|
2
|
+
* scripts/lib/extensions.mjs — User extensions companion sync.
|
|
3
3
|
*
|
|
4
4
|
* `~/hypomnema/extensions/{hooks,commands,skills,agents}/` holds user-authored
|
|
5
5
|
* extensions, git-tracked alongside the wiki. init/upgrade hard-copy them into
|
|
@@ -38,12 +38,12 @@ const HOME = homedir();
|
|
|
38
38
|
// Extension types and their on-disk target subdirectory under ~/.claude or ~/.codex.
|
|
39
39
|
export const EXT_TYPES = ['hooks', 'commands', 'skills', 'agents'];
|
|
40
40
|
|
|
41
|
-
// Required filename prefix (
|
|
41
|
+
// Required filename prefix (plan §5 #9 enforces). Files
|
|
42
42
|
// without it are skipped so uninstall (which strips hypo-ext-*) can always reach
|
|
43
43
|
// every file we install — and so a stray name cannot collide with core hooks.
|
|
44
44
|
export const EXT_PREFIX = 'hypo-ext-';
|
|
45
45
|
|
|
46
|
-
// Codex supports hooks + commands only; skills/agents are Claude-only
|
|
46
|
+
// Codex supports hooks + commands only; skills/agents are Claude-only.
|
|
47
47
|
export const CODEX_TYPES = ['hooks', 'commands'];
|
|
48
48
|
|
|
49
49
|
// Per-type expected file extension. Hooks are executable .mjs; the rest are .md.
|
|
@@ -505,7 +505,7 @@ export function syncExtensions({
|
|
|
505
505
|
* 7. non-target event · mixed (extract + append new)
|
|
506
506
|
* 8. no occurrence (append new)
|
|
507
507
|
*
|
|
508
|
-
* Mixed-group invariant (
|
|
508
|
+
* Mixed-group invariant (amendment 2026-05-23): foreign hooks
|
|
509
509
|
* sharing the matcher group are NEVER read, modified, or reordered. The hosting
|
|
510
510
|
* group's matcher is also left exactly as-is once we extract — even when our
|
|
511
511
|
* extraction is the reason the group becomes single-foreign. Foreign handler-
|
|
@@ -516,7 +516,7 @@ export function syncExtensions({
|
|
|
516
516
|
* Our hook entry, however, is canonical-reset on any drift mutation (ranks 3
|
|
517
517
|
* and 4): the entire prior hook object — including any handler-level fields a
|
|
518
518
|
* user appended to our hypo-ext-* entry — is replaced by the manifest-derived
|
|
519
|
-
* `{ type, command, timeout? }` shape. This mirrors the
|
|
519
|
+
* `{ type, command, timeout? }` shape. This mirrors the hard-copy
|
|
520
520
|
* ownership semantic for hypo-ext-* file copies. Users who want extra handler
|
|
521
521
|
* fields on a Hypomnema-managed extension must extend the manifest, not edit
|
|
522
522
|
* settings.json directly (manifest-as-SoT). registerSettings is honest about
|
|
@@ -718,7 +718,7 @@ function registerSettings(settingsPath, expectedEntries, apply) {
|
|
|
718
718
|
// target single-hook drift → replace the canonical group object with
|
|
719
719
|
// the manifest-derived shape. Our hook entry is canonical-reset on this
|
|
720
720
|
// path (any user-added handler fields are discarded — that mirrors the
|
|
721
|
-
//
|
|
721
|
+
// hard-copy ownership semantic for hypo-ext-*).
|
|
722
722
|
const loc = locateGroup(settings.hooks, canonical.group);
|
|
723
723
|
if (loc) settings.hooks[loc.event][loc.groupIdx] = desiredGroup;
|
|
724
724
|
} else if (canonicalRank === 4) {
|
|
@@ -726,7 +726,7 @@ function registerSettings(settingsPath, expectedEntries, apply) {
|
|
|
726
726
|
// entry (looked up by identity) with the manifest-derived shape.
|
|
727
727
|
// Foreign hooks and the group-level matcher are untouched; our own
|
|
728
728
|
// hook is canonical-reset (handler-level user mods on the hypo-ext-*
|
|
729
|
-
// hook are not preserved — by the same
|
|
729
|
+
// hook are not preserved — by the same ownership semantic).
|
|
730
730
|
const hi = canonical.group.hooks.indexOf(canonical.hook);
|
|
731
731
|
if (hi !== -1) canonical.group.hooks[hi] = desiredHook;
|
|
732
732
|
} else {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// - scripts/lint.mjs (lint-time enum validation of feedback frontmatter)
|
|
5
5
|
// - scripts/feedback.mjs (create/append-time --failure-type validation)
|
|
6
6
|
// Keep this the ONLY definition; both consumers import it so the two validators
|
|
7
|
-
// never drift (mirrors feedback-scope.mjs
|
|
7
|
+
// never drift (mirrors feedback-scope.mjs). stats.mjs aggregates by
|
|
8
8
|
// plain string and does not validate, so it is intentionally not a consumer.
|
|
9
9
|
//
|
|
10
10
|
// `failure_type` is an OPTIONAL field: it classifies feedback that came from a
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// - scripts/lint.mjs (lint-time validation of feedback page frontmatter)
|
|
5
5
|
// - scripts/feedback.mjs (create-time --scope validation in /hypo:feedback)
|
|
6
6
|
// Keep this the ONLY definition; both consumers import it so the two validators
|
|
7
|
-
// never drift
|
|
7
|
+
// never drift. feedback-sync.mjs matches scope by plain string
|
|
8
8
|
// equality (not this regex), so it is intentionally not a consumer.
|
|
9
9
|
//
|
|
10
10
|
// Accepts `global` or `project:<id>`. The `<id>` charset matches what
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* project-create.mjs — atomic auto-project scaffold
|
|
3
|
+
* project-create.mjs — atomic auto-project scaffold
|
|
4
4
|
*
|
|
5
|
-
* Invoked by the LLM (NOT a user-facing subcommand
|
|
5
|
+
* Invoked by the LLM (NOT a user-facing subcommand; `hypomnema project new` is deprecated
|
|
6
6
|
* `hypomnema project new`) after the user answers "Y" to a SessionStart /
|
|
7
7
|
* CwdChanged auto-project offer. One call materializes a whole project:
|
|
8
8
|
*
|
|
@@ -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)) {
|
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
|