hypomnema 1.3.0 → 1.3.2
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 +12 -10
- package/README.md +12 -10
- package/commands/crystallize.md +8 -8
- package/commands/feedback.md +1 -1
- package/commands/resume.md +1 -1
- package/commands/upgrade.md +2 -0
- package/docs/ARCHITECTURE.md +3 -3
- package/docs/CONTRIBUTING.md +2 -2
- package/hooks/hypo-auto-minimal-crystallize.mjs +57 -11
- package/hooks/hypo-compact-guard.mjs +1 -1
- 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 +26 -19
- package/hooks/hypo-shared.mjs +839 -58
- 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 +249 -109
- 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 +60 -0
- package/scripts/lib/project-create.mjs +1 -1
- package/scripts/lint.mjs +63 -8
- package/scripts/rename.mjs +373 -0
- package/scripts/resume.mjs +127 -13
- package/scripts/smoke-pack.mjs +23 -2
- package/scripts/uninstall.mjs +1 -1
- package/scripts/upgrade.mjs +266 -47
- package/skills/crystallize/SKILL.md +10 -7
- package/templates/SCHEMA.md +2 -2
- package/templates/hypo-config.md +2 -2
- package/templates/hypo-guide.md +20 -1
- package/templates/projects/_template/index.md +1 -1
|
@@ -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) {
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Detect whether the Hypomnema Claude Code plugin is enabled in a settings.json.
|
|
2
|
+
//
|
|
3
|
+
// Dual-install guard: the manual/npm `upgrade.mjs` must know when the
|
|
4
|
+
// plugin is ALSO enabled, because the plugin loader already provides the core
|
|
5
|
+
// hooks/commands/settings — copying+registering them from a manual/npm `--apply`
|
|
6
|
+
// would double-register every hook.
|
|
7
|
+
//
|
|
8
|
+
// This parser is INTENTIONALLY conservative. The asymmetric cost is: a false
|
|
9
|
+
// positive blocks/alters a legitimate npm-only user's upgrade, which is worse
|
|
10
|
+
// than the rare dual-install double-register it guards against. So it fails open
|
|
11
|
+
// (returns false) on every uncertainty and only fires on an exact, well-formed
|
|
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.
|
|
17
|
+
|
|
18
|
+
import { readFileSync } from 'node:fs';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {string} settingsPath path to a Claude Code settings.json (e.g. ~/.claude/settings.json)
|
|
22
|
+
* @returns {boolean} true iff `enabledPlugins` contains a key shaped
|
|
23
|
+
* `hypo@<marketplace>` (or the legacy `hypomnema@<marketplace>`) whose value
|
|
24
|
+
* is strictly `true`.
|
|
25
|
+
*/
|
|
26
|
+
export function isHypomnemaPluginEnabled(settingsPath) {
|
|
27
|
+
let raw;
|
|
28
|
+
try {
|
|
29
|
+
raw = readFileSync(settingsPath, 'utf-8');
|
|
30
|
+
} catch {
|
|
31
|
+
return false; // missing / unreadable → cannot prove enabled → fail open
|
|
32
|
+
}
|
|
33
|
+
let parsed;
|
|
34
|
+
try {
|
|
35
|
+
parsed = JSON.parse(raw);
|
|
36
|
+
} catch {
|
|
37
|
+
return false; // corrupt JSON → fail open
|
|
38
|
+
}
|
|
39
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return false;
|
|
40
|
+
|
|
41
|
+
const enabled = parsed.enabledPlugins;
|
|
42
|
+
// enabledPlugins is an object map `{ "<name>@<marketplace>": true|false }`.
|
|
43
|
+
// Anything else (absent, array, scalar) → not enabled.
|
|
44
|
+
if (!enabled || typeof enabled !== 'object' || Array.isArray(enabled)) return false;
|
|
45
|
+
|
|
46
|
+
for (const [key, value] of Object.entries(enabled)) {
|
|
47
|
+
if (value !== true) continue; // strictly true only — no truthy coercion
|
|
48
|
+
// Require a real `name@marketplace` shape: an `@` that is neither the first
|
|
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.
|
|
52
|
+
const at = key.indexOf('@');
|
|
53
|
+
if (at <= 0 || at === key.length - 1) continue;
|
|
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;
|
|
58
|
+
}
|
|
59
|
+
return false;
|
|
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 /
|
package/scripts/lint.mjs
CHANGED
|
@@ -81,7 +81,7 @@ const TYPE_CONDITIONAL_FIELDS = {
|
|
|
81
81
|
'tool-eval': ['status'],
|
|
82
82
|
postmortem: ['outcome'],
|
|
83
83
|
learning: ['source'],
|
|
84
|
-
// feedback: ADR 0031
|
|
84
|
+
// feedback: ADR 0031 — projection SoT requires full classification
|
|
85
85
|
feedback: [
|
|
86
86
|
'status',
|
|
87
87
|
'scope',
|
|
@@ -102,7 +102,7 @@ const TYPE_ENUM_FIELDS = {
|
|
|
102
102
|
prd: { status: ['draft', 'active', 'completed', 'cancelled', 'archived'] },
|
|
103
103
|
adr: { status: ['proposed', 'accepted', 'deprecated', 'superseded'] },
|
|
104
104
|
'tool-eval': { status: ['adopted', 'evaluating', 'rejected'] },
|
|
105
|
-
// feedback: ADR 0031
|
|
105
|
+
// feedback: ADR 0031 — sensitivity:private is forbidden (wiki is a
|
|
106
106
|
// git-pushed public surface)
|
|
107
107
|
feedback: {
|
|
108
108
|
status: ['active', 'superseded', 'archived'],
|
|
@@ -135,15 +135,56 @@ function collectPages(dir, root, pages = [], ignorePatterns = []) {
|
|
|
135
135
|
|
|
136
136
|
// ── slug map ─────────────────────────────────────────────────────────────────
|
|
137
137
|
|
|
138
|
-
|
|
138
|
+
// `extraTargets` are link-target-only slugs (root *.md, sources/*) that resolve
|
|
139
|
+
// wikilinks but are not themselves linted — added verbatim, with NO derived
|
|
140
|
+
// basename/dir-relative aliases, so they can't mask an unrelated broken link.
|
|
141
|
+
function buildSlugMap(pages, extraTargets = []) {
|
|
139
142
|
const map = new Set();
|
|
140
143
|
for (const { rel } of pages) {
|
|
141
|
-
|
|
144
|
+
const noExt = rel.replace(/\.md$/, '').replace(/\\/g, '/');
|
|
145
|
+
map.add(noExt);
|
|
142
146
|
map.add(basename(rel, '.md'));
|
|
147
|
+
// Dir-relative alias: drop the leading scan-dir segment so the vault's
|
|
148
|
+
// convention link [[learnings/foo]] resolves to pages/learnings/foo.md.
|
|
149
|
+
// (A page directly under a scan dir has no extra segment to drop.)
|
|
150
|
+
const slash = noExt.indexOf('/');
|
|
151
|
+
if (slash !== -1) map.add(noExt.slice(slash + 1));
|
|
143
152
|
}
|
|
153
|
+
for (const t of extraTargets) map.add(t);
|
|
144
154
|
return map;
|
|
145
155
|
}
|
|
146
156
|
|
|
157
|
+
// Link-target-only slugs: files that are valid wikilink destinations but are
|
|
158
|
+
// NOT linted themselves. Root-level *.md (hot.md / log.md / hypo-guide.md /
|
|
159
|
+
// SCHEMA.md — special operational files whose types sit outside VALID_TYPES)
|
|
160
|
+
// and sources/* (immutable captured sources). Returned as verbatim slugs so
|
|
161
|
+
// buildSlugMap adds no derived aliases for them.
|
|
162
|
+
function collectLinkTargets(hypoDir, ignorePatterns = []) {
|
|
163
|
+
const targets = [];
|
|
164
|
+
if (existsSync(hypoDir)) {
|
|
165
|
+
for (const entry of readdirSync(hypoDir)) {
|
|
166
|
+
const full = join(hypoDir, entry);
|
|
167
|
+
// root-level *.md FILES only (no recursion), honoring .hypoignore exactly
|
|
168
|
+
// like collectPages — otherwise an ignored root file (e.g. a secret) would
|
|
169
|
+
// resolve [[its-slug]] as valid, a false negative.
|
|
170
|
+
if (
|
|
171
|
+
extname(entry) === '.md' &&
|
|
172
|
+
!entry.startsWith('.') &&
|
|
173
|
+
!isIgnored(full, hypoDir, ignorePatterns) &&
|
|
174
|
+
statSync(full).isFile()
|
|
175
|
+
) {
|
|
176
|
+
targets.push(entry.replace(/\.md$/, ''));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// sources/*: linkable as the full 'sources/<name>' slug only — deliberately no
|
|
181
|
+
// bare basename, so a stale [[name]] can't silently resolve to a source file.
|
|
182
|
+
for (const { rel } of collectPages(join(hypoDir, 'sources'), hypoDir, [], ignorePatterns)) {
|
|
183
|
+
targets.push(rel.replace(/\.md$/, '').replace(/\\/g, '/'));
|
|
184
|
+
}
|
|
185
|
+
return targets;
|
|
186
|
+
}
|
|
187
|
+
|
|
147
188
|
// ── wikilink extractor ────────────────────────────────────────────────────────
|
|
148
189
|
|
|
149
190
|
// Strip regions where wikilinks are not real references:
|
|
@@ -168,7 +209,12 @@ function stripNonWikilinkRegions(content) {
|
|
|
168
209
|
function extractWikilinks(content) {
|
|
169
210
|
const stripped = stripNonWikilinkRegions(content);
|
|
170
211
|
const links = [];
|
|
171
|
-
|
|
212
|
+
// Target capture excludes `\` and stops before an optional `\` preceding the
|
|
213
|
+
// alias/anchor delimiter, so a markdown-table-escaped alias
|
|
214
|
+
// `[[a/b\|label]]` (the `\|` is a literal pipe inside a table cell) yields the
|
|
215
|
+
// clean target `a/b`, not `a/b\`. A bare `[[a\]]` (no delimiter) simply fails
|
|
216
|
+
// to match rather than being mis-read as `[[a]]`.
|
|
217
|
+
for (const m of stripped.matchAll(/\[\[([^\]|#\\]+?)(?:\\?[|#][^\]]*?)?\]\]/g)) {
|
|
172
218
|
links.push(m[1].trim());
|
|
173
219
|
}
|
|
174
220
|
return links;
|
|
@@ -367,7 +413,8 @@ const args = parseArgs(process.argv);
|
|
|
367
413
|
const ignorePatterns = loadHypoIgnore(args.hypoDir);
|
|
368
414
|
const scanDirs = ['pages', 'projects', 'journal'].map((d) => join(args.hypoDir, d));
|
|
369
415
|
const pages = scanDirs.flatMap((d) => collectPages(d, args.hypoDir, [], ignorePatterns));
|
|
370
|
-
const
|
|
416
|
+
const linkTargets = collectLinkTargets(args.hypoDir, ignorePatterns);
|
|
417
|
+
const slugMap = buildSlugMap(pages, linkTargets);
|
|
371
418
|
const tagVocab = parseSchemaVocab(args.hypoDir);
|
|
372
419
|
const pageDirs = parseSchemaPageDirs(args.hypoDir);
|
|
373
420
|
|
|
@@ -382,7 +429,7 @@ for (const s of findDesignHistoryStale(args.hypoDir)) {
|
|
|
382
429
|
issue(
|
|
383
430
|
'warn',
|
|
384
431
|
`projects/${s.project}/design-history.md`,
|
|
385
|
-
`design-history stale: session-log 최신=${s.lastSession} > design-history 최신=${s.lastDesignHistory}${gap} — projects/${s.project}/design-history.md에 설계 변경 사항을 append
|
|
432
|
+
`design-history stale: session-log 설계-관련 최신=${s.lastSession} > design-history 최신=${s.lastDesignHistory}${gap} — projects/${s.project}/design-history.md에 설계 변경 사항을 append 하거나, 무-설계 세션이면 session-log 엔트리에 "ADR 없음" 마커를 명시하세요`,
|
|
386
433
|
null,
|
|
387
434
|
'W8',
|
|
388
435
|
);
|
|
@@ -474,4 +521,12 @@ if (args.json) {
|
|
|
474
521
|
}
|
|
475
522
|
}
|
|
476
523
|
|
|
477
|
-
|
|
524
|
+
// Set exitCode and let Node exit naturally rather than calling process.exit():
|
|
525
|
+
// when stdout is a pipe, the JSON write above is async, and a synchronous
|
|
526
|
+
// process.exit() tears the process down before the OS pipe buffer (64 KiB) is
|
|
527
|
+
// drained — truncating large `--json` output mid-string. Consumers that spawn
|
|
528
|
+
// this script and JSON.parse the stdout (crystallize's runLint, the PreCompact
|
|
529
|
+
// gate) then crash on the partial output. There are no pending async handles
|
|
530
|
+
// here (pure synchronous fs), so the event loop empties at end-of-script and
|
|
531
|
+
// Node flushes stdout fully before exiting with this code.
|
|
532
|
+
process.exitCode = errors.length > 0 ? 1 : 0;
|