hypomnema 1.4.1 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/commands/audit.md +1 -1
- package/commands/crystallize.md +11 -9
- package/commands/doctor.md +1 -1
- package/commands/feedback.md +2 -2
- package/commands/graph.md +1 -1
- package/commands/ingest.md +1 -1
- package/commands/init.md +2 -2
- package/commands/lint.md +1 -1
- package/commands/query.md +1 -1
- package/commands/rename.md +1 -1
- package/commands/resume.md +1 -1
- package/commands/stats.md +1 -1
- package/commands/uninstall.md +4 -2
- package/commands/upgrade.md +1 -1
- package/commands/verify.md +1 -1
- package/docs/ARCHITECTURE.md +2 -2
- package/docs/CONTRIBUTING.md +20 -12
- package/hooks/hypo-auto-commit.mjs +2 -2
- package/hooks/hypo-auto-minimal-crystallize.mjs +30 -18
- package/hooks/hypo-compact-guard.mjs +2 -2
- package/hooks/hypo-cwd-change.mjs +27 -37
- package/hooks/hypo-personal-check.mjs +37 -22
- package/hooks/hypo-session-end.mjs +1 -1
- package/hooks/hypo-session-record.mjs +2 -2
- package/hooks/hypo-session-start.mjs +34 -40
- package/hooks/hypo-shared.mjs +364 -82
- package/hooks/version-check.mjs +1 -1
- package/package.json +5 -1
- package/scripts/check-tracker-ids.mjs +69 -31
- package/scripts/crystallize.mjs +322 -70
- package/scripts/doctor.mjs +7 -7
- package/scripts/feedback-sync.mjs +5 -5
- package/scripts/feedback.mjs +9 -10
- package/scripts/init.mjs +7 -7
- package/scripts/lib/check-tracker-ids.mjs +90 -32
- package/scripts/lib/design-history-stale.mjs +1 -1
- package/scripts/lib/extensions.mjs +7 -7
- package/scripts/lib/failure-type.mjs +1 -1
- package/scripts/lib/feedback-scope.mjs +1 -1
- package/scripts/lib/project-create.mjs +25 -7
- package/scripts/lib/schema-vocab.mjs +105 -1
- package/scripts/lib/template-schema-version.mjs +1 -1
- package/scripts/lib/wd-match.mjs +181 -0
- package/scripts/lib/wikilink.mjs +1 -1
- package/scripts/lint.mjs +14 -6
- package/scripts/rename.mjs +1 -1
- package/scripts/resume.mjs +20 -22
- package/scripts/session-audit.mjs +1 -1
- package/scripts/stats.mjs +3 -3
- package/scripts/uninstall.mjs +3 -3
- package/scripts/upgrade.mjs +17 -18
- package/skills/crystallize/SKILL.md +16 -8
- package/skills/graph/SKILL.md +2 -2
- package/skills/ingest/SKILL.md +4 -4
- package/skills/lint/SKILL.md +2 -2
- package/skills/query/SKILL.md +2 -2
- package/skills/verify/SKILL.md +2 -2
- package/templates/SCHEMA.md +12 -4
- package/templates/hypo-config.md +1 -1
- package/templates/hypo-guide.md +24 -15
- package/templates/projects/_template/hot.md +1 -1
- package/scripts/fix-status-verify.mjs +0 -256
- package/scripts/lib/adr-corpus.mjs +0 -79
- package/scripts/lib/fix-manifest.mjs +0 -109
- package/scripts/lib/fix-status-verify.mjs +0 -439
package/scripts/doctor.mjs
CHANGED
|
@@ -221,7 +221,7 @@ function checkDirectories(hypoDir) {
|
|
|
221
221
|
'pages',
|
|
222
222
|
'projects',
|
|
223
223
|
'sources',
|
|
224
|
-
// Extensions baseline
|
|
224
|
+
// Extensions baseline. Existence only — SHA / settings /
|
|
225
225
|
// manifest integrity is E5.
|
|
226
226
|
'extensions/hooks',
|
|
227
227
|
'extensions/commands',
|
|
@@ -308,7 +308,7 @@ function checkSettingsJson() {
|
|
|
308
308
|
}
|
|
309
309
|
|
|
310
310
|
// stale hypo-* entries (uninstall remnants).
|
|
311
|
-
// hypo-ext-* commands are user-extension entries
|
|
311
|
+
// hypo-ext-* commands are user-extension entries — not core hooks,
|
|
312
312
|
// so they are intentionally absent from HOOK_MAP. Excluded here; their
|
|
313
313
|
// integrity (SHA + manifest + entry match) is checked separately in E5.
|
|
314
314
|
const isExtCommand = (cmd) => /(?:^|[/\s])hypo-ext-[^/\s]+\.mjs(?=$|["'\s])/.test(cmd);
|
|
@@ -545,7 +545,7 @@ function checkSyncState(hypoDir) {
|
|
|
545
545
|
}
|
|
546
546
|
|
|
547
547
|
function checkProjectSuggestions(hypoDir) {
|
|
548
|
-
//
|
|
548
|
+
// Auto-project skip-persistence store. Absent file is
|
|
549
549
|
// healthy (no offers declined yet). Validate the RAW JSON shape here rather
|
|
550
550
|
// than via readProjectSuggestions(): that helper deliberately normalizes a
|
|
551
551
|
// non-array `skips` to [] for fail-open hook reads, which would mask a
|
|
@@ -665,7 +665,7 @@ function checkCodexPaths() {
|
|
|
665
665
|
}
|
|
666
666
|
}
|
|
667
667
|
|
|
668
|
-
// ── extensions integrity (
|
|
668
|
+
// ── extensions integrity (E5) ─────────────────────────────────────
|
|
669
669
|
|
|
670
670
|
// Detect drift between the user's `~/hypomnema/extensions/` source, the recorded
|
|
671
671
|
// per-target SHA map (`hypo-pkg.json`), and the installed copies + settings.json
|
|
@@ -685,7 +685,7 @@ function checkCodexPaths() {
|
|
|
685
685
|
//
|
|
686
686
|
// E5 is doctor SURFACE for extensions integrity. The mixed-group surgical
|
|
687
687
|
// *write* (preserve sibling-plugin hooks, swap only ours) used to be deferred
|
|
688
|
-
// here;
|
|
688
|
+
// here; amendment 2026-05-23 lifted that deferral:
|
|
689
689
|
// registerSettings (extensions.mjs:478 docstring) now does occurrence-first +
|
|
690
690
|
// 8-rank canonical write, and the (b) loop below mirrors that read-path via
|
|
691
691
|
// collectOurOccurrences so a valid mixed-group occurrence is no longer warned
|
|
@@ -926,7 +926,7 @@ function checkExtensions(hypoDir, claudeHome, target = 'claude') {
|
|
|
926
926
|
else warn(label, `${sample}${extra}`);
|
|
927
927
|
}
|
|
928
928
|
|
|
929
|
-
// ── feedback projection
|
|
929
|
+
// ── feedback projection ──────────────────────────────────────────
|
|
930
930
|
|
|
931
931
|
// Spawn feedback-sync.mjs --check --json and map its drift report onto doctor's
|
|
932
932
|
// pass/warn/fail. Integrity violations (exit-3 class: conflict / unpaired marker
|
|
@@ -1015,7 +1015,7 @@ function checkFeedbackProjection(hypoDir, claudeHome, projectId) {
|
|
|
1015
1015
|
}
|
|
1016
1016
|
}
|
|
1017
1017
|
|
|
1018
|
-
// ── stale sibling install (
|
|
1018
|
+
// ── stale sibling install (D) ────────────────────────────────────────────────
|
|
1019
1019
|
//
|
|
1020
1020
|
// Detect a SECOND, older Hypomnema that owns the `hypomnema` bin on PATH while a
|
|
1021
1021
|
// newer copy owns the active hooks. That sibling is a footgun: `hypomnema init` /
|
|
@@ -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
|
|
3
|
+
* feedback-sync.mjs — project wiki feedback as SoT, external memory as projection
|
|
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
|
|
@@ -506,7 +506,7 @@ async function resolveProjectId(args, { prompt = defaultPrompt, isTTY } = {}) {
|
|
|
506
506
|
//
|
|
507
507
|
// Both modes are *reverse* one-time helpers that scaffold wiki DRAFTS under
|
|
508
508
|
// pages/feedback/_drafts/ — they NEVER write pages/feedback/<slug>.md directly
|
|
509
|
-
// (the single-direction invariant
|
|
509
|
+
// (the single-direction invariant). A human reviews each draft,
|
|
510
510
|
// fills the decision fields (scope/tier/targets/promote_to_global), and moves
|
|
511
511
|
// it into pages/feedback/. _drafts/ is excluded from sync candidates
|
|
512
512
|
// (loadFeedbackPages) and from lint (collectPages skips `_`-dirs), so an
|
|
@@ -856,7 +856,7 @@ function run(args, resolvedPid = null) {
|
|
|
856
856
|
// not actionable drift — contract §5 step 4 promises this never hard-fails,
|
|
857
857
|
// so it must stay OUT of strictWarnings or the PreCompact gate (#3, which
|
|
858
858
|
// runs `--check --strict`) would block every first-run user. A private-
|
|
859
|
-
// sensitivity page IS a real SoT violation (
|
|
859
|
+
// sensitivity page IS a real SoT violation (lint #8 blocks it
|
|
860
860
|
// at the source) so it stays strict-escalatable as defense-in-depth.
|
|
861
861
|
const warnings = [];
|
|
862
862
|
const strictWarnings = [];
|
|
@@ -875,8 +875,8 @@ function run(args, resolvedPid = null) {
|
|
|
875
875
|
|
|
876
876
|
// pass 1: preflight every target before touching disk — validates the write
|
|
877
877
|
// plan (container/anchor, markers) and computes next content. A conflict /
|
|
878
|
-
// over-cap / build error in ANY target blocks writes to ALL (atomicity
|
|
879
|
-
//
|
|
878
|
+
// over-cap / build error in ANY target blocks writes to ALL (atomicity:
|
|
879
|
+
// "no auto-merge"; avoids a partial write where one target
|
|
880
880
|
// lands and another refuses).
|
|
881
881
|
const evals = targets.map((target) => ({ target, res: evaluateTarget(pages, target) }));
|
|
882
882
|
for (const { target, res } of evals) {
|
package/scripts/feedback.mjs
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
* Hypomnema feedback script
|
|
4
4
|
*
|
|
5
5
|
* Creates or appends to pages/feedback/<topic>.md with a behaviour-correction
|
|
6
|
-
* entry. The wiki feedback page is the single source of truth
|
|
7
|
-
*
|
|
6
|
+
* entry. The wiki feedback page is the single source of truth; MEMORY.md and
|
|
7
|
+
* CLAUDE.md <learned_behaviors> are one-way *projections*
|
|
8
8
|
* derived from it. Never hand-edit those targets — this script writes the page
|
|
9
9
|
* and then runs `feedback-sync --write` to refresh the projection automatically.
|
|
10
10
|
*
|
|
@@ -123,7 +123,7 @@ function listTopics(hypoDir) {
|
|
|
123
123
|
for (const f of files) console.log(` ${f.replace(/\.md$/, '')}`);
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
-
// ── classification validation (mirrors lint #8
|
|
126
|
+
// ── classification validation (mirrors lint #8) ─────────────────────────────────
|
|
127
127
|
|
|
128
128
|
const TIER_ENUM = ['L1', 'L2'];
|
|
129
129
|
const SENSITIVITY_ENUM = ['public', 'sanitized']; // private is forbidden (wiki is git-public)
|
|
@@ -136,7 +136,7 @@ function parseTargets(raw) {
|
|
|
136
136
|
.filter(Boolean);
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
-
//
|
|
139
|
+
// `failure_type` is OPTIONAL: an unset value is always fine. A set value
|
|
140
140
|
// must be one of the eight enum members (same vocabulary lint enforces). Returns
|
|
141
141
|
// an error string or null. Shared by create (validateClassification) and append.
|
|
142
142
|
function failureTypeError(value) {
|
|
@@ -168,7 +168,7 @@ function validateClassification(args, targets) {
|
|
|
168
168
|
const ftErr = failureTypeError(args.failureType);
|
|
169
169
|
if (ftErr) errs.push(ftErr);
|
|
170
170
|
|
|
171
|
-
// CLAUDE.md projection candidates must be global + L1 (
|
|
171
|
+
// CLAUDE.md projection candidates must be global + L1 (SoT filter), and
|
|
172
172
|
// carry the two conditional fields (lint #8). Enforce here so we never write a
|
|
173
173
|
// claude-learned page that lint rejects or that silently never projects.
|
|
174
174
|
if (targets.includes('claude-learned')) {
|
|
@@ -176,9 +176,8 @@ function validateClassification(args, targets) {
|
|
|
176
176
|
errs.push('--global-summary is required when --targets includes claude-learned');
|
|
177
177
|
if (!args.promoteToGlobal)
|
|
178
178
|
errs.push('--promote-to-global is required when --targets includes claude-learned');
|
|
179
|
-
if (args.scope !== 'global')
|
|
180
|
-
|
|
181
|
-
if (args.tier !== 'L1') errs.push('claude-learned projection requires --tier=L1 (ADR 0031 §6)');
|
|
179
|
+
if (args.scope !== 'global') errs.push('claude-learned projection requires --scope=global');
|
|
180
|
+
if (args.tier !== 'L1') errs.push('claude-learned projection requires --tier=L1');
|
|
182
181
|
}
|
|
183
182
|
return errs;
|
|
184
183
|
}
|
|
@@ -279,7 +278,7 @@ function writeFeedback(args, today) {
|
|
|
279
278
|
// Append a dated entry; preserve existing frontmatter classification.
|
|
280
279
|
mode = 'append';
|
|
281
280
|
const existing = readFileSync(filePath, 'utf-8');
|
|
282
|
-
//
|
|
281
|
+
// failure_type is a per-page classification property. On append,
|
|
283
282
|
// set it if the page has none, error if it conflicts with an existing value,
|
|
284
283
|
// no-op if it matches. Without the flag, append is byte-for-byte unchanged.
|
|
285
284
|
const existingFt = (parseFrontmatter(existing) || {}).failure_type || null;
|
|
@@ -348,7 +347,7 @@ function writeFeedback(args, today) {
|
|
|
348
347
|
return { wrote: true };
|
|
349
348
|
}
|
|
350
349
|
|
|
351
|
-
// ── projection post-step
|
|
350
|
+
// ── projection post-step ────────────────────────────────────────────────────────
|
|
352
351
|
|
|
353
352
|
// Refresh the MEMORY.md / CLAUDE.md projection from the just-written page. This
|
|
354
353
|
// is best-effort and NON-blocking: a projection failure (over-cap, conflict,
|
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
|
*
|
|
@@ -90,13 +90,31 @@ export function insertHotRow(content, name, today) {
|
|
|
90
90
|
* @param {{hypoDir: string, name: string, workingDir: string, started?: string, today?: string}} opts
|
|
91
91
|
* @returns {{created: string[], skipped: string[], warnings: string[], projectDir: string}}
|
|
92
92
|
*/
|
|
93
|
+
/**
|
|
94
|
+
* A project name must be a SINGLE path segment with at least one alnum. The
|
|
95
|
+
* charset alone is not enough: `.`, `..`, `...` all pass `[A-Za-z0-9._-]+` yet
|
|
96
|
+
* would resolve `projects/<name>` to the wiki root or `projects/` itself (codex
|
|
97
|
+
* review 2026-05-22, both workers) — so dot-only names are rejected and ≥1 alnum
|
|
98
|
+
* required. The leading `typeof` guard lets non-CLI callers (e.g. a JSON payload
|
|
99
|
+
* where the value could be a number) reuse this without a JS regex coercing a
|
|
100
|
+
* non-string into a "valid" name. Shared so the apply path (crystallize.mjs B-3)
|
|
101
|
+
* accepts exactly the namespace createProject can scaffold — no wider, no narrower.
|
|
102
|
+
*
|
|
103
|
+
* @param {unknown} name
|
|
104
|
+
* @returns {boolean}
|
|
105
|
+
*/
|
|
106
|
+
export function isValidProjectName(name) {
|
|
107
|
+
return (
|
|
108
|
+
typeof name === 'string' &&
|
|
109
|
+
/^[A-Za-z0-9._-]+$/.test(name) &&
|
|
110
|
+
!/^\.+$/.test(name) &&
|
|
111
|
+
/[A-Za-z0-9]/.test(name)
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
93
115
|
export function createProject(opts) {
|
|
94
116
|
const { name, workingDir } = opts;
|
|
95
|
-
|
|
96
|
-
// alone is not enough: `.`, `..`, `...` all pass `[A-Za-z0-9._-]+` yet would
|
|
97
|
-
// resolve `projects/<name>` to the wiki root or `projects/` itself (codex
|
|
98
|
-
// review 2026-05-22, both workers). Reject dot-only names and require alnum.
|
|
99
|
-
if (!name || !/^[A-Za-z0-9._-]+$/.test(name) || /^\.+$/.test(name) || !/[A-Za-z0-9]/.test(name)) {
|
|
117
|
+
if (!isValidProjectName(name)) {
|
|
100
118
|
throw new Error(
|
|
101
119
|
`invalid project name: ${JSON.stringify(name)} (need a single segment with ≥1 alnum, charset A-Za-z0-9._-, not "."/"..")`,
|
|
102
120
|
);
|