hypomnema 1.3.1 → 1.3.3
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 +2 -2
- package/.claude-plugin/plugin.json +2 -2
- package/README.ko.md +11 -10
- package/README.md +11 -10
- package/commands/audit.md +1 -1
- package/commands/crystallize.md +11 -11
- 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 +1 -1
- package/commands/lint.md +1 -1
- package/commands/query.md +1 -1
- package/commands/rename.md +79 -0
- package/commands/resume.md +2 -2
- package/commands/stats.md +1 -1
- package/commands/uninstall.md +1 -1
- package/commands/upgrade.md +1 -1
- package/commands/verify.md +1 -1
- package/docs/ARCHITECTURE.md +3 -3
- package/docs/CONTRIBUTING.md +2 -2
- package/hooks/hypo-auto-commit.mjs +8 -24
- package/hooks/hypo-auto-minimal-crystallize.mjs +57 -11
- package/hooks/hypo-compact-guard.mjs +6 -3
- package/hooks/hypo-cwd-change.mjs +1 -1
- package/hooks/hypo-first-prompt.mjs +2 -2
- package/hooks/hypo-hot-rebuild.mjs +14 -1
- package/hooks/hypo-personal-check.mjs +91 -179
- package/hooks/hypo-pre-commit.mjs +1 -1
- package/hooks/hypo-session-end.mjs +1 -1
- package/hooks/hypo-session-start.mjs +7 -7
- package/hooks/hypo-shared.mjs +1082 -89
- package/hooks/hypo-web-fetch-ingest.mjs +2 -2
- package/hooks/version-check-fetch.mjs +2 -8
- package/hooks/version-check.mjs +18 -0
- package/package.json +3 -2
- package/scripts/check-tracker-ids.mjs +329 -0
- package/scripts/crystallize.mjs +322 -110
- package/scripts/doctor.mjs +6 -9
- package/scripts/feedback-sync.mjs +1 -1
- package/scripts/init.mjs +1 -1
- package/scripts/install-git-hooks.mjs +75 -40
- package/scripts/lib/check-tracker-ids.mjs +140 -0
- package/scripts/lib/design-history-stale.mjs +59 -14
- package/scripts/lib/extensions.mjs +4 -4
- package/scripts/lib/fix-manifest.mjs +2 -2
- package/scripts/lib/fix-status-verify.mjs +5 -4
- package/scripts/lib/plugin-detect.mjs +15 -6
- package/scripts/lib/project-create.mjs +1 -1
- package/scripts/lint.mjs +63 -8
- package/scripts/rename.mjs +836 -0
- package/scripts/resume.mjs +75 -19
- package/scripts/uninstall.mjs +1 -1
- package/scripts/upgrade.mjs +26 -25
- package/skills/crystallize/SKILL.md +12 -9
- package/skills/graph/SKILL.md +1 -1
- package/skills/ingest/SKILL.md +1 -1
- package/skills/lint/SKILL.md +1 -1
- package/skills/query/SKILL.md +1 -1
- package/skills/verify/SKILL.md +1 -1
- package/templates/SCHEMA.md +2 -2
- package/templates/hypo-config.md +2 -2
- package/templates/hypo-guide.md +22 -1
- package/templates/hypo-help.md +1 -0
- package/templates/projects/_template/index.md +1 -1
package/scripts/doctor.mjs
CHANGED
|
@@ -222,7 +222,7 @@ function checkDirectories(hypoDir) {
|
|
|
222
222
|
'projects',
|
|
223
223
|
'sources',
|
|
224
224
|
// Extensions baseline (ADR 0024). Existence only — SHA / settings /
|
|
225
|
-
// manifest integrity is E5
|
|
225
|
+
// manifest integrity is E5.
|
|
226
226
|
'extensions/hooks',
|
|
227
227
|
'extensions/commands',
|
|
228
228
|
'extensions/skills',
|
|
@@ -310,7 +310,7 @@ function checkSettingsJson() {
|
|
|
310
310
|
// stale hypo-* entries (uninstall remnants).
|
|
311
311
|
// hypo-ext-* commands are user-extension entries (ADR 0024) — not core hooks,
|
|
312
312
|
// so they are intentionally absent from HOOK_MAP. Excluded here; their
|
|
313
|
-
// integrity (SHA + manifest + entry match) is checked separately in E5
|
|
313
|
+
// integrity (SHA + manifest + entry match) is checked separately in E5.
|
|
314
314
|
const isExtCommand = (cmd) => /(?:^|[/\s])hypo-ext-[^/\s]+\.mjs(?=$|["'\s])/.test(cmd);
|
|
315
315
|
const expectedCmds = new Set(
|
|
316
316
|
Object.entries(HOOK_MAP).flatMap(([, files]) =>
|
|
@@ -352,7 +352,7 @@ function checkSettingsJson() {
|
|
|
352
352
|
if (!g || typeof g !== 'object') continue;
|
|
353
353
|
for (const h of g.hooks || []) {
|
|
354
354
|
if (typeof h.command !== 'string' || !/hypo-[^/]+\.mjs/.test(h.command)) continue;
|
|
355
|
-
if (isExtCommand(h.command)) continue; // ext duplicates are E5's concern
|
|
355
|
+
if (isExtCommand(h.command)) continue; // ext duplicates are E5's concern
|
|
356
356
|
if (seen.has(h.command)) dupes.push(`${event}:${h.command}`);
|
|
357
357
|
else seen.add(h.command);
|
|
358
358
|
}
|
|
@@ -399,10 +399,7 @@ function checkGit(hypoDir) {
|
|
|
399
399
|
);
|
|
400
400
|
}
|
|
401
401
|
} else {
|
|
402
|
-
warn(
|
|
403
|
-
'.git/hooks/pre-commit',
|
|
404
|
-
'Not installed — run /hypo:init to install .hypoignore guard (fix #24)',
|
|
405
|
-
);
|
|
402
|
+
warn('.git/hooks/pre-commit', 'Not installed — run /hypo:init to install .hypoignore guard');
|
|
406
403
|
}
|
|
407
404
|
}
|
|
408
405
|
|
|
@@ -679,7 +676,7 @@ function checkCodexPaths() {
|
|
|
679
676
|
//
|
|
680
677
|
// E5 is doctor SURFACE for extensions integrity. The mixed-group surgical
|
|
681
678
|
// *write* (preserve sibling-plugin hooks, swap only ours) used to be deferred
|
|
682
|
-
// here;
|
|
679
|
+
// here; ADR 0024 amendment 2026-05-23 lifted that deferral —
|
|
683
680
|
// registerSettings (extensions.mjs:478 docstring) now does occurrence-first +
|
|
684
681
|
// 8-rank canonical write, and the (b) loop below mirrors that read-path via
|
|
685
682
|
// collectOurOccurrences so a valid mixed-group occurrence is no longer warned
|
|
@@ -773,7 +770,7 @@ function checkExtensions(hypoDir, claudeHome, target = 'claude') {
|
|
|
773
770
|
const expected = buildExpectedSettingsEntries(discovered.hooks, hooksDir);
|
|
774
771
|
|
|
775
772
|
// (b) for each registrable hook: locate every occurrence of our command
|
|
776
|
-
// (single-hook OR mixed group
|
|
773
|
+
// (single-hook OR mixed group) and pick the canonical via the
|
|
777
774
|
// SAME 8-rank logic registerSettings uses.
|
|
778
775
|
// Without this mirror, doctor picked the first traversal-order occurrence
|
|
779
776
|
// under the target event and warned "differs" even when a later
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* feedback-sync.mjs — project wiki feedback as SoT, external memory as projection (ADR 0031
|
|
3
|
+
* feedback-sync.mjs — project wiki feedback as SoT, external memory as projection (ADR 0031)
|
|
4
4
|
*
|
|
5
5
|
* Wiki `pages/feedback/<slug>.md` is the single source of truth for
|
|
6
6
|
* learning/correction knowledge. Two Claude Code memory surfaces are derived
|
package/scripts/init.mjs
CHANGED
|
@@ -928,7 +928,7 @@ if (args.hooks) {
|
|
|
928
928
|
}
|
|
929
929
|
for (const r of extResult.registered) log('merged', `extension ${r}`);
|
|
930
930
|
for (const w of extResult.warnings) log('skipped', `extension: ${w}`);
|
|
931
|
-
// E3
|
|
931
|
+
// E3: a hard conflict (unowned/symlinked target) blocks install — surface
|
|
932
932
|
// the recovery and force a non-zero exit. Drift is advisory (resolvable, no block).
|
|
933
933
|
if (extResult.conflicts.length > 0) {
|
|
934
934
|
log('errors', '[WIKI: existing file conflicts. Backup and retry, or use --force-extensions]');
|
|
@@ -79,10 +79,39 @@ function shellSingleQuote(s) {
|
|
|
79
79
|
return `'` + s.replace(/'/g, `'\\''`) + `'`;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
|
|
82
|
+
// Hook-specific gate body. Runs AFTER the shared identity guards, so everything
|
|
83
|
+
// here is already proven to be our trusted checkout. `set +e` is active, so the
|
|
84
|
+
// gate must explicitly `|| exit 1` to BLOCK a commit; a missing script is a
|
|
85
|
+
// fail-open skip (old checkout that predates the script).
|
|
86
|
+
function gateLines(kind) {
|
|
87
|
+
// CHECK_TRACKER_ROOT is a test-only seam in check-tracker-ids.mjs. Clear any
|
|
88
|
+
// inherited value so the real hook always gates THIS checkout's index, never a
|
|
89
|
+
// redirected one.
|
|
90
|
+
const unsetSeam = 'unset CHECK_TRACKER_ROOT';
|
|
91
|
+
if (kind === 'pre-commit') {
|
|
92
|
+
// 1) Auto-format staged files (its own exit 1 = a true git-add failure block).
|
|
93
|
+
// The sentinel tells pre-commit-format.mjs it runs under the trusted shim,
|
|
94
|
+
// so it preserves an inherited GIT_INDEX_FILE (index-whitelist defence).
|
|
95
|
+
// 2) Tracker-id gate on the staged blobs — blocks the commit on a leak.
|
|
96
|
+
return `${unsetSeam}
|
|
97
|
+
FMT="$HYPOMNEMA_ROOT/scripts/pre-commit-format.mjs"
|
|
98
|
+
[ -f "$FMT" ] && { HYPOMNEMA_HOOK_INVOCATION=1 node "$FMT" || exit 1; }
|
|
99
|
+
TRK="$HYPOMNEMA_ROOT/scripts/check-tracker-ids.mjs"
|
|
100
|
+
[ -f "$TRK" ] && { node "$TRK" --staged || exit 1; }
|
|
101
|
+
exit 0`;
|
|
102
|
+
}
|
|
103
|
+
// commit-msg: scan the message file (passed as $1) for wiki tracker ids.
|
|
104
|
+
return `${unsetSeam}
|
|
105
|
+
TRK="$HYPOMNEMA_ROOT/scripts/check-tracker-ids.mjs"
|
|
106
|
+
[ -f "$TRK" ] || exit 0
|
|
107
|
+
node "$TRK" --commit-msg "$1" || exit 1
|
|
108
|
+
exit 0`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function shimBody(kind, root, gitDir) {
|
|
83
112
|
return `#!/bin/sh
|
|
84
|
-
# hypomnema-
|
|
85
|
-
# Fail-open at every guard. Only the
|
|
113
|
+
# hypomnema-${kind}-marker v2
|
|
114
|
+
# Fail-open at every identity/setup guard. Only the gate below can exit nonzero.
|
|
86
115
|
set +e
|
|
87
116
|
HYPOMNEMA_ROOT=${shellSingleQuote(root)}
|
|
88
117
|
HYPOMNEMA_GIT_DIR=${shellSingleQuote(gitDir)}
|
|
@@ -91,28 +120,51 @@ TOPLEVEL="$(git rev-parse --show-toplevel 2>/dev/null)" || exit 0
|
|
|
91
120
|
[ "$TOPLEVEL" = "$HYPOMNEMA_ROOT" ] || exit 0
|
|
92
121
|
ABSGITDIR="$(git rev-parse --absolute-git-dir 2>/dev/null)" || exit 0
|
|
93
122
|
[ "$ABSGITDIR" = "$HYPOMNEMA_GIT_DIR" ] || exit 0
|
|
94
|
-
SCRIPT="$HYPOMNEMA_ROOT/scripts/pre-commit-format.mjs"
|
|
95
|
-
[ -f "$SCRIPT" ] || exit 0
|
|
96
123
|
command -v node >/dev/null 2>&1 || exit 0
|
|
97
|
-
|
|
98
|
-
# attacker invocation). The .mjs preserves inherited GIT_INDEX_FILE only when
|
|
99
|
-
# this sentinel is present — direct invocation drops it and falls back to the
|
|
100
|
-
# default \`.git/index\`. This closes prefix-matching attacks on the index
|
|
101
|
-
# whitelist (e.g. attacker-crafted .git/next-index-attack.lock).
|
|
102
|
-
HYPOMNEMA_HOOK_INVOCATION=1 exec node "$SCRIPT"
|
|
124
|
+
${gateLines(kind)}
|
|
103
125
|
`;
|
|
104
126
|
}
|
|
105
127
|
|
|
106
|
-
|
|
128
|
+
// Write a single hook shim. Returns true on success, false on write failure.
|
|
129
|
+
// Does NOT exit — the caller installs multiple hooks and reports once.
|
|
130
|
+
function writeShim(target, kind, root, gitDir) {
|
|
107
131
|
try {
|
|
108
|
-
fs.writeFileSync(target, shimBody(root, gitDir), { mode: 0o755 });
|
|
132
|
+
fs.writeFileSync(target, shimBody(kind, root, gitDir), { mode: 0o755 });
|
|
109
133
|
try {
|
|
110
134
|
fs.chmodSync(target, 0o755);
|
|
111
135
|
} catch {}
|
|
112
|
-
return
|
|
136
|
+
return true;
|
|
137
|
+
} catch {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Install/refresh one hook of the given kind under absHooksDir. Returns a short
|
|
143
|
+
// status string (never throws, never exits). Refreshes our own marker (any
|
|
144
|
+
// version) so a checkout move or a shim-body change re-propagates; never
|
|
145
|
+
// clobbers a user's own non-marker hook or a symlink.
|
|
146
|
+
function installHook(kind, absHooksDir, root, gitDir) {
|
|
147
|
+
const target = path.join(absHooksDir, kind);
|
|
148
|
+
let existing;
|
|
149
|
+
try {
|
|
150
|
+
existing = fs.lstatSync(target);
|
|
113
151
|
} catch (e) {
|
|
114
|
-
|
|
152
|
+
if (e.code === 'ENOENT') {
|
|
153
|
+
return writeShim(target, kind, root, gitDir) ? `installed ${kind}` : `write ${kind} failed`;
|
|
154
|
+
}
|
|
155
|
+
return `stat ${kind} failed: ${e.code}`;
|
|
156
|
+
}
|
|
157
|
+
if (existing.isSymbolicLink()) return `${kind} is symlink; not overwriting`;
|
|
158
|
+
let head;
|
|
159
|
+
try {
|
|
160
|
+
head = fs.readFileSync(target, 'utf-8').split('\n').slice(0, 3).join('\n');
|
|
161
|
+
} catch {
|
|
162
|
+
return `read ${kind} failed; skipping`;
|
|
115
163
|
}
|
|
164
|
+
if (head.includes(`hypomnema-${kind}-marker`)) {
|
|
165
|
+
return writeShim(target, kind, root, gitDir) ? `refreshed ${kind}` : `refresh ${kind} failed`;
|
|
166
|
+
}
|
|
167
|
+
return `existing non-marker ${kind}; not overwriting`;
|
|
116
168
|
}
|
|
117
169
|
|
|
118
170
|
async function main() {
|
|
@@ -225,31 +277,14 @@ async function main() {
|
|
|
225
277
|
return exitSilent('hooks dir outside .git/; skipping');
|
|
226
278
|
}
|
|
227
279
|
|
|
228
|
-
// (7)
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
}
|
|
237
|
-
return exitSilent(`stat target failed: ${e.code}; skipping`);
|
|
238
|
-
}
|
|
239
|
-
if (existing.isSymbolicLink()) {
|
|
240
|
-
return exitSilent('pre-commit is symlink; not overwriting');
|
|
241
|
-
}
|
|
242
|
-
let head;
|
|
243
|
-
try {
|
|
244
|
-
head = fs.readFileSync(target, 'utf-8').split('\n').slice(0, 3).join('\n');
|
|
245
|
-
} catch {
|
|
246
|
-
return exitSilent('read existing pre-commit failed; skipping');
|
|
247
|
-
}
|
|
248
|
-
if (head.includes('hypomnema-pre-commit-marker v1')) {
|
|
249
|
-
// Same marker — regenerate (refreshes embedded root if checkout moved).
|
|
250
|
-
return writeShim(target, expectedRoot, absGitDir);
|
|
251
|
-
}
|
|
252
|
-
return exitSilent('existing non-marker pre-commit; not overwriting');
|
|
280
|
+
// (7) Install both hooks: pre-commit (format + tracker-id gate on staged
|
|
281
|
+
// blobs) and commit-msg (tracker-id gate on the message). Each is
|
|
282
|
+
// independently marker-detected so a user's own hook is never clobbered.
|
|
283
|
+
const results = [
|
|
284
|
+
installHook('pre-commit', absHooksDir, expectedRoot, absGitDir),
|
|
285
|
+
installHook('commit-msg', absHooksDir, expectedRoot, absGitDir),
|
|
286
|
+
];
|
|
287
|
+
return exitSilent(results.join('; '));
|
|
253
288
|
} catch (e) {
|
|
254
289
|
return exitSilent(`unexpected: ${e.code || e.message}; skipping`);
|
|
255
290
|
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/check-tracker-ids.mjs — pure scanner for wiki-internal tracker IDs.
|
|
3
|
+
*
|
|
4
|
+
* The OSS repo must not ship references to the maintainer's PRIVATE wiki issue
|
|
5
|
+
* tracker (`ISSUE-N`) or fix tracker (`fix #N`) in public artifacts: an OSS user
|
|
6
|
+
* who opens a shipped file or reads the README/CHANGELOG just sees a dangling
|
|
7
|
+
* pointer into a tracker they cannot access. GitHub references (`PR #N`, `(#N)`,
|
|
8
|
+
* bare `#N`, issue URLs) are legitimate and must NOT be flagged.
|
|
9
|
+
*
|
|
10
|
+
* This module is PURE (no fs, no git, no process) so it is unit-testable; the
|
|
11
|
+
* CLI wrapper (scripts/check-tracker-ids.mjs) does the file/git/commit-msg I/O.
|
|
12
|
+
*
|
|
13
|
+
* Blocked (examples use an `N` placeholder, NOT a real digit, so this file
|
|
14
|
+
* itself scans clean and is NOT exempted from --all):
|
|
15
|
+
* - /\bISSUE-\d+\b/i → the wiki issue tracker, any case (e.g. ISSUE-N)
|
|
16
|
+
* - /\bfix[ \t ]+#\d+\b/i → the wiki fix tracker, any case (e.g. fix #N)
|
|
17
|
+
*
|
|
18
|
+
* User-facing docs ONLY (README.md, README.ko.md, docs/) additionally block
|
|
19
|
+
* `ADR NNNN` / `decisions/NNNN` (USER_FACING_PATTERNS) — dangling pointers into the
|
|
20
|
+
* maintainer's private wiki ADR set. The CLI scope-gates these so shipped code
|
|
21
|
+
* comments and CHANGELOG history keep their ADR anchors.
|
|
22
|
+
*
|
|
23
|
+
* Accepted edge cases (documented, not bugs):
|
|
24
|
+
* - FALSE POSITIVE: `FOO-ISSUE-N` matches (the `-` gives a word boundary
|
|
25
|
+
* before ISSUE). Such a string in this repo would still be a tracker ref.
|
|
26
|
+
* - The `fix #N` matcher requires the literal word `fix` immediately before the
|
|
27
|
+
* `#`, so `prefix #N`, `suffix #N`, `PR #N`, `(#N)`, and bare `#N` are all
|
|
28
|
+
* safe — none start the word `fix` right before the `#`.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
// Applied by the CLI to user-facing docs ONLY (README.md / README.ko.md / docs/),
|
|
32
|
+
// never to shipped code or CHANGELOG. A `ADR NNNN` / `decisions/NNNN` reference in
|
|
33
|
+
// prose a user reads points into the maintainer's private wiki ADR set, which the
|
|
34
|
+
// user does not have — a dangling pointer. The same anchor inside a code comment
|
|
35
|
+
// is maintainer rationale (kept), so this set is scope-gated rather than global.
|
|
36
|
+
// Examples below use `NNNN`, not real digits, so this file scans clean.
|
|
37
|
+
export const USER_FACING_PATTERNS = [
|
|
38
|
+
{
|
|
39
|
+
name: 'ADR NNNN',
|
|
40
|
+
label: 'wiki ADR pointer (user-facing docs)',
|
|
41
|
+
re: /\bADR[ \t]+\d{3,4}\b/gi,
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
name: 'decisions/NNNN',
|
|
45
|
+
label: 'wiki decisions path (user-facing docs)',
|
|
46
|
+
re: /\bdecisions\/\d{3,4}\b/gi,
|
|
47
|
+
},
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
// Each entry: a named, /g/i regex over a single line of text.
|
|
51
|
+
export const BLOCKED_PATTERNS = [
|
|
52
|
+
{
|
|
53
|
+
name: 'ISSUE-N',
|
|
54
|
+
label: 'wiki issue-tracker id',
|
|
55
|
+
re: /\bISSUE-\d+\b/gi,
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
name: 'fix #N',
|
|
59
|
+
label: 'wiki fix-tracker id',
|
|
60
|
+
re: /\bfix[ \t ]+#\d+\b/gi,
|
|
61
|
+
},
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Scan a blob of text against `patterns` (default BLOCKED_PATTERNS). Returns hits:
|
|
66
|
+
* { pattern, label, match, line, col, lineText }
|
|
67
|
+
* `line`/`col` are 1-based. Empty array => clean. The CLI passes the broader
|
|
68
|
+
* [...BLOCKED_PATTERNS, ...USER_FACING_PATTERNS] set only for user-facing docs.
|
|
69
|
+
*/
|
|
70
|
+
export function scanText(text, patterns = BLOCKED_PATTERNS) {
|
|
71
|
+
if (typeof text !== 'string' || text.length === 0) return [];
|
|
72
|
+
const hits = [];
|
|
73
|
+
const lines = text.split(/\r?\n/);
|
|
74
|
+
for (let i = 0; i < lines.length; i++) {
|
|
75
|
+
const lineText = lines[i];
|
|
76
|
+
for (const { name, label, re } of patterns) {
|
|
77
|
+
re.lastIndex = 0; // reset shared /g regex between lines
|
|
78
|
+
let m;
|
|
79
|
+
while ((m = re.exec(lineText)) !== null) {
|
|
80
|
+
hits.push({
|
|
81
|
+
pattern: name,
|
|
82
|
+
label,
|
|
83
|
+
match: m[0],
|
|
84
|
+
line: i + 1,
|
|
85
|
+
col: m.index + 1,
|
|
86
|
+
lineText,
|
|
87
|
+
});
|
|
88
|
+
if (m.index === re.lastIndex) re.lastIndex++; // never-zero-width guard
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return hits;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Strip a `--verbose` commit diff: everything from the scissors line onward is
|
|
97
|
+
* dropped by git before the commit is recorded, so it must not be scanned. The
|
|
98
|
+
* scissors art is comment-char-prefixed but the `>8` token is constant across
|
|
99
|
+
* comment chars, so match on that. Returns the text up to (excluding) the
|
|
100
|
+
* scissors line. If no scissors line is present, returns the input unchanged.
|
|
101
|
+
*/
|
|
102
|
+
// git's real scissors line is COMMENT-prefixed (`# ----- >8 -----`); a bare
|
|
103
|
+
// `--- >8 ---` line is NOT a scissors marker (git keeps it as body), so it must
|
|
104
|
+
// not trigger truncation. Require the leading comment char (default `#`, or `;`).
|
|
105
|
+
const SCISSORS_RE = /^[ \t]*[#;][ \t]*-{2,}[ \t]*>8[ \t]*-{2,}/;
|
|
106
|
+
|
|
107
|
+
export function stripScissors(text) {
|
|
108
|
+
if (typeof text !== 'string') return '';
|
|
109
|
+
const lines = text.split(/\r?\n/);
|
|
110
|
+
const idx = lines.findIndex((l) => SCISSORS_RE.test(l));
|
|
111
|
+
if (idx === -1) return text;
|
|
112
|
+
return lines.slice(0, idx).join('\n');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Does a commit-message file carry git's auto-generated template? git ONLY adds
|
|
117
|
+
* the instructional / status comment block (and the --verbose scissors) when the
|
|
118
|
+
* effective cleanup mode will STRIP comment lines (the editor default). In the
|
|
119
|
+
* comment-PRESERVING modes (`git commit -m` → whitespace, `--cleanup=verbatim`)
|
|
120
|
+
* git adds NO template, so a `#` line there is real committed content.
|
|
121
|
+
*
|
|
122
|
+
* The commit-msg hook therefore decides comment handling by template presence,
|
|
123
|
+
* not by config alone: template present ⇒ scan the strip-comments view (matches
|
|
124
|
+
* git, no false-positive on the template's branch/file names); template absent ⇒
|
|
125
|
+
* scan the raw view so a `#`-prefixed tracker id git WILL keep is still caught.
|
|
126
|
+
*
|
|
127
|
+
* Markers cover the default English git template + the scissors line. Other
|
|
128
|
+
* locales may miss the template and fall through to the raw (comment-keeping)
|
|
129
|
+
* scan — which is the SAFE direction for a gate (catches more, never less).
|
|
130
|
+
*/
|
|
131
|
+
export function messageHasGitTemplate(text) {
|
|
132
|
+
if (typeof text !== 'string') return false;
|
|
133
|
+
if (SCISSORS_RE_M.test(text)) return true; // --verbose scissors (comment-prefixed)
|
|
134
|
+
return /^[#;] *(Please enter the commit message|On branch |Changes to be committed:|Changes not staged for commit:|Untracked files:|Your branch (is|and))/m.test(
|
|
135
|
+
text,
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Multiline form of SCISSORS_RE for whole-message detection.
|
|
140
|
+
const SCISSORS_RE_M = /^[ \t]*[#;][ \t]*-{2,}[ \t]*>8[ \t]*-{2,}/m;
|
|
@@ -1,21 +1,65 @@
|
|
|
1
1
|
import { readFileSync, existsSync, readdirSync, statSync } from 'fs';
|
|
2
2
|
import { join } from 'path';
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
// session-log headings appear in two shapes in the wild: bracketed
|
|
5
|
+
// `## [YYYY-MM-DD]` (spec convention) and bare `## YYYY-MM-DD` (some entries,
|
|
6
|
+
// e.g. the 2026-06-08 SHIP entry). Two explicit branches instead of an
|
|
7
|
+
// `\[?...\]?` optional — the optional form silently accepts malformed partial
|
|
8
|
+
// brackets like `## [2026-06-08` and `## 2026-06-08]`, which we don't want to
|
|
9
|
+
// treat as valid dated headings. The bare branch carries a `(?!\])` guard so a
|
|
10
|
+
// trailing-only bracket (`## 2026-06-08]`) is rejected too — without it the
|
|
11
|
+
// bare branch would match the date and ignore the stray `]` (codex review).
|
|
12
|
+
const SESSION_LOG_HEADING_RE = /^## (?:\[(\d{4}-\d{2}-\d{2})\]|(\d{4}-\d{2}-\d{2})(?!\]))/gm;
|
|
5
13
|
const DESIGN_HISTORY_DATE_RE = /^## (\d{4}-\d{2}-\d{2})/gm;
|
|
6
14
|
|
|
15
|
+
// W8 false-positive fix (issue①): a session-log entry that explicitly declares
|
|
16
|
+
// "no design change" (the crystallize #41 `ADR 없음` marker) must not count
|
|
17
|
+
// toward design-history staleness — otherwise a no-design session pushes the
|
|
18
|
+
// session-log date past design-history forever (treadmill), or a real design
|
|
19
|
+
// session that forgot to append blocks correctly. We exclude an entry ONLY when
|
|
20
|
+
// it carries the `ADR 없음` marker AND no ADR reference in the same block. If
|
|
21
|
+
// both coexist (an ambiguous/contradictory entry), we include it — excluding it
|
|
22
|
+
// would re-introduce the exact false-negative W8 exists to catch (codex review).
|
|
23
|
+
const NO_ADR_MARKER_RE = /ADR\s*없음/;
|
|
24
|
+
const ADR_REF_RE = /ADR\s+\d{4}|decisions\/\d{4}/;
|
|
25
|
+
|
|
26
|
+
function isValidDate(literal) {
|
|
27
|
+
// The regex matches digit-shaped YYYY-MM-DD literals but cannot reject
|
|
28
|
+
// semantically invalid ones like 2026-13-01 or 2026-02-30. JavaScript's Date
|
|
29
|
+
// constructor returns an Invalid Date for those, which would later crash
|
|
30
|
+
// `toISOString()` with RangeError and poison `>` comparisons inside maxDate.
|
|
31
|
+
// Filter at the parse boundary so callers never see one.
|
|
32
|
+
return !Number.isNaN(new Date(literal).getTime());
|
|
33
|
+
}
|
|
34
|
+
|
|
7
35
|
function parseDates(text, pattern) {
|
|
8
36
|
const dates = [];
|
|
9
37
|
pattern.lastIndex = 0;
|
|
10
38
|
let m;
|
|
11
39
|
while ((m = pattern.exec(text)) !== null) {
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
40
|
+
if (isValidDate(m[1])) dates.push(new Date(m[1]));
|
|
41
|
+
}
|
|
42
|
+
return dates;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Parse session-log dates entry-by-entry, skipping no-design-change entries.
|
|
46
|
+
// Entries are sliced by heading start-index (not a single `$`-anchored block
|
|
47
|
+
// regex — multiline `$` terminates at line ends, not true EOF, so the last
|
|
48
|
+
// entry would be truncated). The last entry runs to EOF.
|
|
49
|
+
function parseSessionDates(text) {
|
|
50
|
+
const headings = [];
|
|
51
|
+
SESSION_LOG_HEADING_RE.lastIndex = 0;
|
|
52
|
+
let m;
|
|
53
|
+
while ((m = SESSION_LOG_HEADING_RE.exec(text)) !== null) {
|
|
54
|
+
headings.push({ literal: m[1] ?? m[2], start: m.index });
|
|
55
|
+
}
|
|
56
|
+
const dates = [];
|
|
57
|
+
for (let i = 0; i < headings.length; i++) {
|
|
58
|
+
const body = text.slice(headings[i].start, headings[i + 1]?.start ?? text.length);
|
|
59
|
+
// Exclude only an explicit no-design-change entry. An entry carrying both
|
|
60
|
+
// the marker and an ADR reference is treated as a design entry (included).
|
|
61
|
+
if (NO_ADR_MARKER_RE.test(body) && !ADR_REF_RE.test(body)) continue;
|
|
62
|
+
if (isValidDate(headings[i].literal)) dates.push(new Date(headings[i].literal));
|
|
19
63
|
}
|
|
20
64
|
return dates;
|
|
21
65
|
}
|
|
@@ -43,21 +87,22 @@ export function findDesignHistoryStale(hypoDir) {
|
|
|
43
87
|
const dhPath = join(projectDir, 'design-history.md');
|
|
44
88
|
if (!existsSync(dhPath)) continue;
|
|
45
89
|
|
|
46
|
-
// session-log can live as a flat `session-log.md` (legacy) or a directory
|
|
47
|
-
// `session-log/YYYY-MM.md` (
|
|
48
|
-
//
|
|
49
|
-
//
|
|
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` (ADR 0050 canonical; legacy
|
|
92
|
+
// monthly `YYYY-MM.md` files still appear pre-cutover). This globs every
|
|
93
|
+
// `.md` in the directory, so daily and monthly shapes are both aggregated —
|
|
94
|
+
// the staleness check needs to see all of them.
|
|
50
95
|
const sessionDates = [];
|
|
51
96
|
const flatSlPath = join(projectDir, 'session-log.md');
|
|
52
97
|
if (existsSync(flatSlPath)) {
|
|
53
|
-
sessionDates.push(...
|
|
98
|
+
sessionDates.push(...parseSessionDates(readFileSync(flatSlPath, 'utf-8')));
|
|
54
99
|
}
|
|
55
100
|
const dirSlPath = join(projectDir, 'session-log');
|
|
56
101
|
if (existsSync(dirSlPath) && statSync(dirSlPath).isDirectory()) {
|
|
57
102
|
for (const entry of readdirSync(dirSlPath)) {
|
|
58
103
|
if (!entry.endsWith('.md')) continue;
|
|
59
104
|
const text = readFileSync(join(dirSlPath, entry), 'utf-8');
|
|
60
|
-
sessionDates.push(...
|
|
105
|
+
sessionDates.push(...parseSessionDates(text));
|
|
61
106
|
}
|
|
62
107
|
}
|
|
63
108
|
if (sessionDates.length === 0) continue;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* scripts/lib/extensions.mjs — User extensions companion sync (ADR 0024
|
|
2
|
+
* scripts/lib/extensions.mjs — User extensions companion sync (ADR 0024).
|
|
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
|
|
@@ -80,7 +80,7 @@ function pkgRootDir(target) {
|
|
|
80
80
|
|
|
81
81
|
/**
|
|
82
82
|
* Discover sync-eligible extensions under `extDir`. Returns a per-type map plus a
|
|
83
|
-
* `warnings` array. Applies the `.hypoignore` filter
|
|
83
|
+
* `warnings` array. Applies the `.hypoignore` filter, the basename
|
|
84
84
|
* whitelist (plan §5 #9), and pairs each file with its optional `<name>.manifest.json`.
|
|
85
85
|
* No-ops gracefully when extDir is absent (e.g. --from-remote clones, plan §5 #8).
|
|
86
86
|
*/
|
|
@@ -364,7 +364,7 @@ export function syncExtensions({
|
|
|
364
364
|
const discovered = discoverExtensions(extDir, patterns, hypoDir);
|
|
365
365
|
result.warnings.push(...discovered.warnings);
|
|
366
366
|
|
|
367
|
-
// E4
|
|
367
|
+
// E4: Codex supports hooks + commands only. If the user authored
|
|
368
368
|
// skills/agents extensions, surface a one-time notice that they are skipped
|
|
369
369
|
// for this target rather than silently dropping them (plan §2 E4).
|
|
370
370
|
if (target === 'codex') {
|
|
@@ -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 (ADR 0024 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-
|
|
@@ -76,10 +76,10 @@ export const FIX_MANIFEST = [
|
|
|
76
76
|
// deliberate removal-marker comment + the negative-control test.
|
|
77
77
|
fixId: 26,
|
|
78
78
|
testNames: [
|
|
79
|
-
'replay-personal-check-bypass-order: wiki-context-critical.json does NOT bypass (
|
|
79
|
+
'replay-personal-check-bypass-order: wiki-context-critical.json does NOT bypass (negative control)',
|
|
80
80
|
],
|
|
81
81
|
adrPath: 'decisions/0022-session-close-ux-automation.md',
|
|
82
|
-
adrKeyLine: 'Capacity bypass (≥90%) REMOVED
|
|
82
|
+
adrKeyLine: 'Capacity bypass (≥90%) REMOVED',
|
|
83
83
|
},
|
|
84
84
|
{
|
|
85
85
|
fixId: 27,
|
|
@@ -27,8 +27,8 @@ const NEGATIVE_STATUS_TOKENS = ['STALE_MERGED', 'partial', 'retired'];
|
|
|
27
27
|
/**
|
|
28
28
|
* Parse anchor comments out of runner.mjs source text.
|
|
29
29
|
*
|
|
30
|
-
* // @fix #
|
|
31
|
-
* // @fix #
|
|
30
|
+
* // @fix #N: all type-conditional fields present → green
|
|
31
|
+
* // @fix #N: another test name
|
|
32
32
|
*
|
|
33
33
|
* The `@fix` prefix is mandatory — distinguishes anchors from prose comments
|
|
34
34
|
* that mention "fix #N" in passing. Each anchor line maps ONE fix # to ONE
|
|
@@ -79,8 +79,9 @@ export function parseStatus(specText) {
|
|
|
79
79
|
// For each line, find every fix # mention and check whether a positive
|
|
80
80
|
// status token sits within a small proximity window AFTER the mention. This
|
|
81
81
|
// avoids false positives when a line mentions multiple fix #s with status
|
|
82
|
-
// tokens that only apply to some of them (e.g.
|
|
83
|
-
// (
|
|
82
|
+
// tokens that only apply to some of them (e.g. a line where the first
|
|
83
|
+
// mention is "(resolved)" and a later one is "(advisory)" — only the first
|
|
84
|
+
// should be picked up).
|
|
84
85
|
const lines = specText.split('\n');
|
|
85
86
|
const PROXIMITY = 120; // chars after fix # to scan for status
|
|
86
87
|
for (const line of lines) {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Detect whether the Hypomnema Claude Code plugin is enabled in a settings.json.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// Dual-install guard: the manual/npm `upgrade.mjs` must know when the
|
|
4
4
|
// plugin is ALSO enabled, because the plugin loader already provides the core
|
|
5
5
|
// hooks/commands/settings — copying+registering them from a manual/npm `--apply`
|
|
6
6
|
// would double-register every hook.
|
|
@@ -9,14 +9,19 @@
|
|
|
9
9
|
// positive blocks/alters a legitimate npm-only user's upgrade, which is worse
|
|
10
10
|
// than the rare dual-install double-register it guards against. So it fails open
|
|
11
11
|
// (returns false) on every uncertainty and only fires on an exact, well-formed
|
|
12
|
-
// `enabledPlugins` entry whose plugin name is precisely `
|
|
12
|
+
// `enabledPlugins` entry whose plugin name is precisely `hypo` (the current
|
|
13
|
+
// plugin name) or `hypomnema` (the legacy name, pre-rename). Both are matched so
|
|
14
|
+
// the guard survives the rename's migration window: an existing user keeps the
|
|
15
|
+
// legacy `hypomnema@<marketplace>` key in `enabledPlugins` until they reinstall
|
|
16
|
+
// as `hypo@<marketplace>`, and the guard must hold across that gap.
|
|
13
17
|
|
|
14
18
|
import { readFileSync } from 'node:fs';
|
|
15
19
|
|
|
16
20
|
/**
|
|
17
21
|
* @param {string} settingsPath path to a Claude Code settings.json (e.g. ~/.claude/settings.json)
|
|
18
22
|
* @returns {boolean} true iff `enabledPlugins` contains a key shaped
|
|
19
|
-
* `hypomnema@<marketplace>` whose value
|
|
23
|
+
* `hypo@<marketplace>` (or the legacy `hypomnema@<marketplace>`) whose value
|
|
24
|
+
* is strictly `true`.
|
|
20
25
|
*/
|
|
21
26
|
export function isHypomnemaPluginEnabled(settingsPath) {
|
|
22
27
|
let raw;
|
|
@@ -41,11 +46,15 @@ export function isHypomnemaPluginEnabled(settingsPath) {
|
|
|
41
46
|
for (const [key, value] of Object.entries(enabled)) {
|
|
42
47
|
if (value !== true) continue; // strictly true only — no truthy coercion
|
|
43
48
|
// Require a real `name@marketplace` shape: an `@` that is neither the first
|
|
44
|
-
// nor the last char. A bare `"hypomnema": true` (no
|
|
45
|
-
// trigger — that is not a valid enabledPlugins
|
|
49
|
+
// nor the last char. A bare `"hypo": true` / `"hypomnema": true` (no
|
|
50
|
+
// marketplace) must NOT trigger — that is not a valid enabledPlugins
|
|
51
|
+
// identifier.
|
|
46
52
|
const at = key.indexOf('@');
|
|
47
53
|
if (at <= 0 || at === key.length - 1) continue;
|
|
48
|
-
|
|
54
|
+
const name = key.slice(0, at);
|
|
55
|
+
// Match the current plugin name and the legacy one (pre-rename) so the
|
|
56
|
+
// guard holds across the migration window. Exact, case-sensitive.
|
|
57
|
+
if (name === 'hypo' || name === 'hypomnema') return true;
|
|
49
58
|
}
|
|
50
59
|
return false;
|
|
51
60
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* project-create.mjs — atomic auto-project scaffold (
|
|
3
|
+
* project-create.mjs — atomic auto-project scaffold (ADR 0023)
|
|
4
4
|
*
|
|
5
5
|
* Invoked by the LLM (NOT a user-facing subcommand — ADR 0023 deprecated
|
|
6
6
|
* `hypomnema project new`) after the user answers "Y" to a SessionStart /
|