hypomnema 1.4.0 → 1.4.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.
Files changed (39) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.ko.md +151 -153
  4. package/README.md +121 -123
  5. package/commands/audit.md +4 -4
  6. package/commands/crystallize.md +22 -7
  7. package/commands/doctor.md +3 -3
  8. package/commands/feedback.md +5 -3
  9. package/commands/graph.md +3 -3
  10. package/commands/ingest.md +6 -4
  11. package/commands/init.md +5 -3
  12. package/commands/lint.md +3 -3
  13. package/commands/query.md +3 -3
  14. package/commands/rename.md +4 -4
  15. package/commands/resume.md +4 -2
  16. package/commands/stats.md +3 -3
  17. package/commands/upgrade.md +4 -4
  18. package/commands/verify.md +3 -3
  19. package/docs/CONTRIBUTING.md +107 -16
  20. package/hooks/hypo-auto-minimal-crystallize.mjs +23 -11
  21. package/hooks/hypo-shared.mjs +96 -14
  22. package/package.json +6 -1
  23. package/scripts/check-bilingual.mjs +49 -11
  24. package/scripts/check-tracker-ids.mjs +60 -1
  25. package/scripts/crystallize.mjs +275 -39
  26. package/scripts/lib/changelog-classify.mjs +216 -0
  27. package/scripts/lib/check-bilingual.mjs +125 -22
  28. package/scripts/lib/check-tracker-ids.mjs +19 -0
  29. package/scripts/lib/project-create.mjs +23 -5
  30. package/scripts/lib/schema-vocab.mjs +105 -1
  31. package/scripts/lint.mjs +9 -1
  32. package/scripts/weekly-report.mjs +9 -3
  33. package/skills/crystallize/SKILL.md +30 -2
  34. package/templates/SCHEMA.md +11 -3
  35. package/templates/hypo-config.md +1 -1
  36. package/templates/hypo-guide.md +4 -1
  37. package/scripts/bump-version.mjs +0 -77
  38. package/scripts/smoke-pack.mjs +0 -261
  39. package/scripts/smoke-plugin.mjs +0 -194
@@ -0,0 +1,216 @@
1
+ /**
2
+ * lib/changelog-classify.mjs — pure classifier + surface sanitizer for the
3
+ * section-model CHANGELOG (changelog-pr-guide, format.md §3 / §5).
4
+ *
5
+ * Two responsibilities, both pure (no fs/git/process) so tests/runner.mjs can
6
+ * exercise them on synthetic strings:
7
+ *
8
+ * 1. classifyChange(commitTitle) -> { section, basis }
9
+ * Decide which of the three migrated sections a change belongs to.
10
+ * Precedence (format.md §3): tracker ID first, Conventional-Commit type
11
+ * second, the legacy heading hint third, a safe Chores fallback last.
12
+ * - tracker: FEAT-* -> New Features, ISSUE-* -> Bug Fixes,
13
+ * IMPR-* / PRAC-* -> Chores. Tracker wins over type, so a
14
+ * `feat(...)` commit tagged IMPR-N lands in Chores, and a
15
+ * `docs(...)` commit tagged ISSUE-N lands in Bug Fixes.
16
+ * (IDs in this comment use an `N` placeholder, not a real
17
+ * digit, so check-tracker-ids does not flag this file.)
18
+ * - type: feat -> New Features, fix -> Bug Fixes, and the
19
+ * non-user-visible types (chore/refactor/docs/ci/perf/...) ->
20
+ * Chores.
21
+ * - heading: an ID-less, type-less pre-convention item maps by the
22
+ * legacy heading it sits under (Added -> New Features,
23
+ * Fixed -> Bug Fixes, Changed/Internal/... -> Chores), passed
24
+ * via opts.legacyHeading.
25
+ * - fallback: nothing resolves -> Chores (basis tells the caller it was a
26
+ * guess, so a human can re-check).
27
+ *
28
+ * 2. sanitizeTrackerIds(text) -> text
29
+ * Strip every wiki tracker ID (FEAT-/IMPR-/ISSUE-/PRAC-N) from public
30
+ * surface, leaving the PR number (`#N`) as the only identifier
31
+ * (format.md §5, "표면 ID 0"). It also cleans the `(FEAT-N)` parens the ID
32
+ * left empty. NOTE: `fix #N` (the wiki fix-tracker) is NOT handled here —
33
+ * that pattern is the domain of check-tracker-ids; the classifier output
34
+ * never produces it. ADR anchors (`ADR NNNN`, `decisions/NNNN`) are left
35
+ * intact: CHANGELOG history keeps them (format.md §10).
36
+ *
37
+ * Section keys are stable machine strings; the human heading text
38
+ * (`New Features` etc.) is mapped by SECTION_TITLE for callers that render.
39
+ */
40
+
41
+ // Stable section keys. Order is the canonical render order (format.md §1).
42
+ export const SECTION = {
43
+ NEW_FEATURES: 'new-features',
44
+ BUG_FIXES: 'bug-fixes',
45
+ CHORES: 'chores',
46
+ };
47
+
48
+ export const SECTION_TITLE = {
49
+ 'new-features': 'New Features',
50
+ 'bug-fixes': 'Bug Fixes',
51
+ 'chores': 'Chores',
52
+ };
53
+
54
+ // Tracker-ID -> section, in precedence order. A change carries one tracker in
55
+ // practice; if two ever co-occur, the earlier rule wins (FEAT > ISSUE > IMPR >
56
+ // PRAC), so a feature-with-cleanup is surfaced as a feature.
57
+ export const TRACKER_RULES = [
58
+ { prefix: 'FEAT', re: /\bFEAT-\d+\b/i, section: SECTION.NEW_FEATURES },
59
+ { prefix: 'ISSUE', re: /\bISSUE-\d+\b/i, section: SECTION.BUG_FIXES },
60
+ { prefix: 'IMPR', re: /\bIMPR-\d+\b/i, section: SECTION.CHORES },
61
+ { prefix: 'PRAC', re: /\bPRAC-\d+\b/i, section: SECTION.CHORES },
62
+ ];
63
+
64
+ // Conventional-Commit type -> section (secondary signal). feat/fix are the two
65
+ // user-facing buckets; everything else is internal -> Chores (format.md §4:
66
+ // Chores is defined by KIND, not by visibility).
67
+ export const TYPE_SECTION = {
68
+ feat: SECTION.NEW_FEATURES,
69
+ fix: SECTION.BUG_FIXES,
70
+ chore: SECTION.CHORES,
71
+ refactor: SECTION.CHORES,
72
+ docs: SECTION.CHORES,
73
+ ci: SECTION.CHORES,
74
+ perf: SECTION.CHORES,
75
+ build: SECTION.CHORES,
76
+ test: SECTION.CHORES,
77
+ style: SECTION.CHORES,
78
+ };
79
+
80
+ // Legacy CHANGELOG heading -> section (format.md §6, the section-bound rows
81
+ // only). The hint used when neither a tracker ID nor a Conventional-Commit type
82
+ // resolves a change (a pre-convention prose item under an old `### Added` etc.).
83
+ // Headings that map to NON-section blocks (Highlights, Breaking, Upgrading,
84
+ // Known Issues, Notes) are deliberately absent: they are structural, not one of
85
+ // the three sections this function returns, so the caller routes them (§6/§8).
86
+ export const HEADING_SECTION = {
87
+ added: SECTION.NEW_FEATURES,
88
+ fixed: SECTION.BUG_FIXES,
89
+ changed: SECTION.CHORES,
90
+ internal: SECTION.CHORES,
91
+ maintenance: SECTION.CHORES,
92
+ documentation: SECTION.CHORES,
93
+ };
94
+
95
+ // Normalize a legacy heading to its HEADING_SECTION key: drop a leading `###`,
96
+ // the `⚠ ` warning glyph, and a trailing ` (한글)` variant marker, then
97
+ // lowercase. `### Fixed (한글)` and `⚠ Breaking` both reduce cleanly.
98
+ function normalizeHeading(heading) {
99
+ return String(heading == null ? '' : heading)
100
+ .replace(/^#+\s*/, '')
101
+ .replace(/^[⚠️\s]+/, '')
102
+ .replace(/\s*\(한글\)\s*$/, '')
103
+ .trim()
104
+ .toLowerCase();
105
+ }
106
+
107
+ // All four wiki tracker prefixes, as one alternation. Used by both the
108
+ // classifier (read) and the sanitizer (strip). Kept here as the single source
109
+ // so the two never drift.
110
+ export const TRACKER_ID_RE = /\b(?:FEAT|IMPR|ISSUE|PRAC)-\d+\b/gi;
111
+
112
+ // Internal labels that are NOT tracker IDs but must also leave the public
113
+ // surface (format.md §10): `Track A`, `Track A-sot`, `OQ-34`. These are NOT
114
+ // stripped by sanitizeTrackerIds — removing `Track A` cleanly needs a PR-number
115
+ // substitution, which is a human migration call (T5), not a regex. detect them
116
+ // so the migration can flag and replace them by hand rather than miss them.
117
+ export const INTERNAL_LABEL_RE = /\bTrack [A-Z](?:-[a-z]+)?\b|\bOQ-\d+\b/g;
118
+
119
+ // Parse the `type` out of a Conventional-Commit subject: `type(scope)!: rest`.
120
+ // Returns the lowercased type or null. The scope and the breaking-change `!`
121
+ // are optional.
122
+ function commitType(text) {
123
+ const m = /^([a-zA-Z]+)(?:\([^)]*\))?!?:/.exec(String(text).trim());
124
+ return m ? m[1].toLowerCase() : null;
125
+ }
126
+
127
+ /**
128
+ * Classify one change into a section, following format.md §3 precedence:
129
+ * tracker ID first, Conventional-Commit type second, the legacy heading hint
130
+ * third (for pre-convention prose items that carry neither), and a Chores
131
+ * fallback last. Returns { section, basis } where basis is
132
+ * 'tracker' | 'type' | 'heading' | 'fallback' — the basis is for the snapshot
133
+ * fixture / human audit, not public surface.
134
+ *
135
+ * @param {string} text commit subject / changelog line to classify
136
+ * @param {{legacyHeading?: string}} [opts] the `### Added` etc. the item sits
137
+ * under, used only when text alone is ambiguous (an ID-less, type-less line).
138
+ * @returns {{section: string, basis: 'tracker'|'type'|'heading'|'fallback'}}
139
+ */
140
+ export function classifyChange(text, opts = {}) {
141
+ const s = String(text == null ? '' : text);
142
+ // 1. tracker ID wins.
143
+ for (const rule of TRACKER_RULES) {
144
+ if (rule.re.test(s)) return { section: rule.section, basis: 'tracker' };
145
+ }
146
+ // 2. Conventional-Commit type.
147
+ const type = commitType(s);
148
+ if (type && TYPE_SECTION[type]) {
149
+ return { section: TYPE_SECTION[type], basis: 'type' };
150
+ }
151
+ // 3. legacy heading hint (format.md §3 step 3 / §6) — an old `### Added` item
152
+ // with no conventional prefix maps by its heading, not the Chores default.
153
+ if (opts && opts.legacyHeading != null) {
154
+ const key = normalizeHeading(opts.legacyHeading);
155
+ if (key in HEADING_SECTION) return { section: HEADING_SECTION[key], basis: 'heading' };
156
+ }
157
+ // 4. safe default — surfaced as a guess so a human can re-check.
158
+ return { section: SECTION.CHORES, basis: 'fallback' };
159
+ }
160
+
161
+ /**
162
+ * Remove every wiki tracker ID from `text`, leaving `#N` PR numbers untouched.
163
+ * Cleans up the parens / stray whitespace the removed ID leaves behind so
164
+ * `... (FEAT-N) (#N)` becomes `... (#N)`, not `... () (#N)`.
165
+ *
166
+ * @param {string} text
167
+ * @returns {string}
168
+ */
169
+ export function sanitizeTrackerIds(text) {
170
+ if (text == null) return '';
171
+ let s = String(text);
172
+ // 1. drop the tracker IDs themselves.
173
+ s = s.replace(TRACKER_ID_RE, '');
174
+ // 2. parens the ID emptied: `()` or `( )` -> gone.
175
+ s = s.replace(/\(\s*\)/g, '');
176
+ // 3. tidy whitespace the removal left: collapse runs, drop space hugging
177
+ // brackets/punctuation, strip per-line trailing space.
178
+ s = s.replace(/[ \t]{2,}/g, ' ');
179
+ s = s.replace(/([([])[ \t]+/g, '$1');
180
+ s = s.replace(/[ \t]+([)\].,;:])/g, '$1');
181
+ // a removed leading label leaves orphaned punctuation at the start of a line
182
+ // (e.g. `ISSUE-N: foo` -> `: foo`); drop it. NOTE: a tracker ID glued to other
183
+ // text by a non-paren separator (`ISSUE-N/foo`) is not a CHANGELOG form and is
184
+ // left minimally cleaned.
185
+ s = s.replace(/^[ \t]*[:;,][ \t]*/gm, '');
186
+ s = s.replace(/[ \t]+$/gm, '');
187
+ return s.trim();
188
+ }
189
+
190
+ /**
191
+ * Find internal labels (`Track A`, `OQ-NN`) that format.md §10 wants off the
192
+ * public surface but that sanitizeTrackerIds intentionally does NOT auto-strip
193
+ * (they need a PR-number substitution, a human call). Returns the matches so the
194
+ * migration (T5) can flag and replace them rather than silently ship them.
195
+ *
196
+ * @param {string} text
197
+ * @returns {string[]} matched labels (empty if none)
198
+ */
199
+ export function detectInternalLabels(text) {
200
+ INTERNAL_LABEL_RE.lastIndex = 0;
201
+ const out = String(text == null ? '' : text).match(INTERNAL_LABEL_RE);
202
+ return out ? out : [];
203
+ }
204
+
205
+ /**
206
+ * Does `text` still carry any wiki tracker ID? A cheap predicate for the
207
+ * regression gate ("surface ID 0") — distinct from check-tracker-ids, which
208
+ * only blocks ISSUE-/fix #. This sees all four prefixes.
209
+ *
210
+ * @param {string} text
211
+ * @returns {boolean}
212
+ */
213
+ export function hasTrackerId(text) {
214
+ TRACKER_ID_RE.lastIndex = 0;
215
+ return TRACKER_ID_RE.test(String(text == null ? '' : text));
216
+ }
@@ -34,20 +34,99 @@ export function escapeRegex(s) {
34
34
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
35
35
  }
36
36
 
37
+ // Section model (changelog-pr-guide format.md §1/§2). A version block carries
38
+ // gated sections, each split into "#### English" / "#### 한국어" sub-blocks at
39
+ // and after the Korean cutoff. Highlights is gated too (it has a curated Korean
40
+ // half); Changelog and the migration callout are NOT gated (language-neutral).
41
+ export const GATED_HEADINGS = ['Highlights', 'New Features', 'Bug Fixes', 'Chores'];
42
+
43
+ // Korean-summary cutoff: 1.2.0 is the first release that shipped a Korean half
44
+ // (empirically the first "### 한글 요약" in CHANGELOG.md). 1.0.0–1.1.0 are
45
+ // English-only; we never fabricate a Korean half for them (format.md §9).
46
+ export const KOREAN_CUTOFF = [1, 2, 0];
47
+
48
+ // Parse "major.minor.patch" (ignoring any prerelease/build suffix) into a number
49
+ // triple, or null if it does not look like semver.
50
+ export function parseSemver(v) {
51
+ const m = /^(\d+)\.(\d+)\.(\d+)/.exec(String(v == null ? '' : v).trim());
52
+ return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
53
+ }
54
+
55
+ // Is `version` at or past the Korean cutoff (>= 1.2.0)? A prerelease of a
56
+ // cutoff version (1.2.0-rc.1) counts as in-era. Unparseable → treated as not
57
+ // meeting the cutoff (the gate then only checks English presence).
58
+ export function meetsKoreanCutoff(version) {
59
+ const s = parseSemver(version);
60
+ if (!s) return false;
61
+ for (let i = 0; i < 3; i++) {
62
+ if (s[i] !== KOREAN_CUTOFF[i]) return s[i] > KOREAN_CUTOFF[i];
63
+ }
64
+ return true;
65
+ }
66
+
67
+ // Split `lines` into the sections introduced by a heading of exactly `hashes`
68
+ // '#'. A section ends at the next same-level heading OR any higher-level (fewer
69
+ // '#') heading; a deeper heading (more '#') stays inside the section body. So
70
+ // slicing a version block at level 3 keeps each "### Section" with its nested
71
+ // "#### English/한국어" lines, and re-slicing a section at level 4 yields those
72
+ // sub-blocks. Returns [{ title, body: string[] }].
73
+ function sliceSections(lines, hashes) {
74
+ const headRe = new RegExp(`^#{${hashes}} (.+?)\\s*$`);
75
+ const sameOrHigherRe = new RegExp(`^#{1,${hashes}} `);
76
+ const out = [];
77
+ let cur = null;
78
+ for (const line of lines) {
79
+ const m = headRe.exec(line);
80
+ if (m) {
81
+ if (cur) out.push(cur);
82
+ cur = { title: m[1].trim(), body: [] };
83
+ continue;
84
+ }
85
+ if (cur) {
86
+ // a same-or-higher level heading (that is not our own level) closes the
87
+ // open section without opening a tracked one.
88
+ if (sameOrHigherRe.test(line)) {
89
+ out.push(cur);
90
+ cur = null;
91
+ continue;
92
+ }
93
+ cur.body.push(line);
94
+ }
95
+ }
96
+ if (cur) out.push(cur);
97
+ return out;
98
+ }
99
+
100
+ // List the versions a CHANGELOG documents, in file order (skips "Unreleased").
101
+ export function listChangelogVersions(content) {
102
+ const lines = content.replace(/\r\n/g, '\n').split('\n');
103
+ const re = /^## \[([^\]]+)\](\s.*)?$/;
104
+ const out = [];
105
+ for (const l of lines) {
106
+ const m = re.exec(l);
107
+ if (m && m[1] !== 'Unreleased') out.push(m[1]);
108
+ }
109
+ return out;
110
+ }
111
+
37
112
  /**
38
- * Validate that CHANGELOG.md content has a "## [<version>]" section containing
39
- * a "### 한글 요약" sub-section with >= HANGUL_BODY_THRESHOLD Hangul chars.
113
+ * Validate one CHANGELOG version block against the section model.
114
+ *
115
+ * At/after the Korean cutoff (>= 1.2.0): the block must carry at least one gated
116
+ * section, and EVERY gated section present must hold both a "#### English" and a
117
+ * "#### 한국어" sub-block, the Korean one non-empty, with the version's total
118
+ * Korean >= HANGUL_BODY_THRESHOLD. Before the cutoff (1.0.0–1.1.0): English-only
119
+ * era — the block need only carry content; no Korean is required or fabricated.
40
120
  *
41
121
  * @param {string} content CHANGELOG.md raw content (CRLF tolerated).
42
122
  * @param {string} version Semver string to look up (e.g. "1.2.1").
43
- * @returns {{ok: true, hangulCount: number} | {ok: false, reason: string}}
123
+ * @returns {{ok: true, hangulCount: number, koreanExempt?: boolean} | {ok: false, reason: string}}
44
124
  */
45
125
  export function validateChangelog(content, version) {
46
126
  if (!version) return { ok: false, reason: 'no version supplied' };
47
127
  const lines = content.replace(/\r\n/g, '\n').split('\n');
48
128
  const versionEsc = escapeRegex(version);
49
129
  // Anchor closing bracket so 1.2.1 does NOT match the prefix of 1.2.10.
50
- // Allow trailing " - YYYY-MM-DD" or nothing.
51
130
  const sectionRe = new RegExp(`^## \\[${versionEsc}\\](\\s.*)?$`);
52
131
 
53
132
  const startIndices = [];
@@ -72,34 +151,58 @@ export function validateChangelog(content, version) {
72
151
  break;
73
152
  }
74
153
  }
75
- const sectionLines = lines.slice(start, sectionEnd);
154
+ const blockLines = lines.slice(start + 1, sectionEnd);
155
+ const sections = sliceSections(blockLines, 3);
156
+ const gated = sections.filter((s) => GATED_HEADINGS.includes(s.title));
76
157
 
77
- const koreanHeadIdx = sectionLines.findIndex((l) => l.trim() === '### 한글 요약');
78
- if (koreanHeadIdx === -1) {
79
- return { ok: false, reason: `section [${version}] missing "### 한글 요약" sub-section` };
158
+ if (!meetsKoreanCutoff(version)) {
159
+ // Pre-cutoff English-only era: require content (a non-heading body line),
160
+ // never a Korean half.
161
+ const hasContent = blockLines.some((l) => l.trim() && !/^#/.test(l));
162
+ if (!hasContent) {
163
+ return { ok: false, reason: `section [${version}] has no content` };
164
+ }
165
+ return { ok: true, hangulCount: 0, koreanExempt: true };
80
166
  }
81
167
 
82
- // Bound the Korean block: stop at the next H2 OR H3. CHANGELOG.md has
83
- // sibling H3s like "### Internal", "### Fixed" — Hangul in those sections
84
- // does not count toward the "### 한글 요약" requirement.
85
- let koreanEnd = sectionLines.length;
86
- for (let i = koreanHeadIdx + 1; i < sectionLines.length; i++) {
87
- if (/^(##|###) /.test(sectionLines[i])) {
88
- koreanEnd = i;
89
- break;
168
+ // Cutoff+: enforce the per-section bilingual structure.
169
+ if (gated.length === 0) {
170
+ return {
171
+ ok: false,
172
+ reason:
173
+ `section [${version}] has no gated section ` +
174
+ `(one of ${GATED_HEADINGS.join(' / ')} is required at >= 1.2.0)`,
175
+ };
176
+ }
177
+ let total = 0;
178
+ for (const sec of gated) {
179
+ const subs = sliceSections(sec.body, 4);
180
+ const hasEnglish = subs.some((s) => s.title === 'English');
181
+ const korean = subs.find((s) => s.title === '한국어');
182
+ if (!hasEnglish) {
183
+ return { ok: false, reason: `[${version}] "${sec.title}" missing "#### English" sub-block` };
184
+ }
185
+ if (!korean) {
186
+ return { ok: false, reason: `[${version}] "${sec.title}" missing "#### 한국어" sub-block` };
90
187
  }
188
+ const koCount = countHangul(korean.body.join('\n'));
189
+ if (koCount < 1) {
190
+ return {
191
+ ok: false,
192
+ reason: `[${version}] "${sec.title}" "#### 한국어" has no Korean text (heading alone does not count)`,
193
+ };
194
+ }
195
+ total += koCount;
91
196
  }
92
- const body = sectionLines.slice(koreanHeadIdx + 1, koreanEnd).join('\n');
93
- const count = countHangul(body);
94
- if (count < HANGUL_BODY_THRESHOLD) {
197
+ if (total < HANGUL_BODY_THRESHOLD) {
95
198
  return {
96
199
  ok: false,
97
200
  reason:
98
- `section [${version}] "### 한글 요약" body has ${count} Hangul chars ` +
99
- `(threshold: ${HANGUL_BODY_THRESHOLD}). Heading alone does not count write real Korean summary.`,
201
+ `section [${version}] total Korean is ${total} Hangul chars ` +
202
+ `(threshold: ${HANGUL_BODY_THRESHOLD}). Write real Korean summaries in the "#### 한국어" sub-blocks.`,
100
203
  };
101
204
  }
102
- return { ok: true, hangulCount: count };
205
+ return { ok: true, hangulCount: total };
103
206
  }
104
207
 
105
208
  /**
@@ -61,6 +61,25 @@ export const BLOCKED_PATTERNS = [
61
61
  },
62
62
  ];
63
63
 
64
+ // The remaining wiki tracker prefixes (FEAT-/IMPR-/PRAC-). These are NOT in
65
+ // BLOCKED_PATTERNS because shipped CODE COMMENTS legitimately cite them for
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 = [
75
+ { name: 'FEAT-N', label: 'wiki feature-tracker id', re: /\bFEAT-\d+\b/gi },
76
+ { name: 'IMPR-N', label: 'wiki improvement-tracker id', re: /\bIMPR-\d+\b/gi },
77
+ { name: 'PRAC-N', label: 'wiki practice-tracker id', re: /\bPRAC-\d+\b/gi },
78
+ ];
79
+
80
+ // The full tracker-ID set for a no-code public surface (the annotated tag body).
81
+ export const TAG_BODY_PATTERNS = [...BLOCKED_PATTERNS, ...SURFACE_TRACKER_PATTERNS];
82
+
64
83
  /**
65
84
  * Scan a blob of text against `patterns` (default BLOCKED_PATTERNS). Returns hits:
66
85
  * { pattern, label, match, line, col, lineText }
@@ -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
- // Name must be a single path segment with at least one alnum. The charset
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
  );
@@ -1,4 +1,4 @@
1
- import { existsSync, readFileSync } from 'fs';
1
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
2
2
  import { join } from 'path';
3
3
 
4
4
  const VOCAB_HEADER_RE = /^##\s+(?:\d+\.\s+)?Tag\s+(?:Vocabulary|Taxonomy)\s*$/m;
@@ -66,6 +66,110 @@ export function parseSchemaVocab(hypoDir) {
66
66
  return vocab;
67
67
  }
68
68
 
69
+ // Prose seeded into a freshly-created `### Pending` block. It carries NO
70
+ // backtick tokens, so parseSchemaVocab's vocabLineTokens never mistakes it for a
71
+ // vocabulary line — only the `**Pending**:` data line below it contributes tags.
72
+ const PENDING_HEADING = '### Pending (auto-registered)';
73
+ const PENDING_PROSE =
74
+ 'Tags auto-registered by a crystallize close because they were not yet in the ' +
75
+ 'vocabulary above. Review periodically: promote each into a category and delete ' +
76
+ 'it here, or drop the tag from the page.';
77
+ const PENDING_HEADING_RE = /^###[ \t]+Pending\b.*$/im; // prefix/word match
78
+ const PENDING_DATA_RE = /^\*\*Pending[^*]*\*\*:.*$/im;
79
+ const NEXT_H3_RE = /^###[ \t]+/m;
80
+ const FORBIDDEN_HEADING_RE = /^###[ \t]+Forbidden\b/im;
81
+
82
+ function backtickList(tags) {
83
+ return tags.map((t) => `\`${t}\``).join(', ');
84
+ }
85
+
86
+ // Auto-register unknown (non-forbidden) tags into the vault SCHEMA.md `### Pending`
87
+ // section so a close never stalls on a vocabulary gap and the next lint sees them
88
+ // as known (B-4). Pending lives INSIDE the "## Tag Vocabulary" H2 section because
89
+ // parseSchemaVocab is H2-bounded — placing it elsewhere would not widen the vocab.
90
+ // No-ops (returns []) when SCHEMA.md or the Tag Vocabulary header is absent, when
91
+ // every tag is already in the vocabulary, or when a tag is forbidden — registering
92
+ // a forbidden pattern is pointless (lint errors on it before the vocab check) and
93
+ // would only pollute Pending. Idempotent: re-running with the same tags writes
94
+ // nothing (they are already in the parsed vocabulary on the second call).
95
+ export function appendPendingTags(hypoDir, tags) {
96
+ const path = join(hypoDir, 'SCHEMA.md');
97
+ if (!existsSync(path)) return [];
98
+ const content = readFileSync(path, 'utf-8');
99
+
100
+ const headerMatch = VOCAB_HEADER_RE.exec(content);
101
+ if (!headerMatch) return [];
102
+
103
+ const current = parseSchemaVocab(hypoDir);
104
+ const seen = new Set();
105
+ const newTags = [];
106
+ for (const raw of tags || []) {
107
+ const t = typeof raw === 'string' ? raw.trim() : '';
108
+ // A literal backtick cannot be serialized inside the backtick-delimited
109
+ // `**Pending**:` line — it would close the token early and make
110
+ // parseSchemaVocab mis-tokenize (or blank) the whole line, taking valid
111
+ // sibling tags down with it (codex stage-2 CONCERN). Skip it: the tag stays a
112
+ // non-blocking unknown-tag warn the author can rename. (A `"` is safe — it
113
+ // round-trips between backticks — so only the backtick is rejected.)
114
+ if (!t || t.includes('`') || seen.has(t) || current.has(t) || checkForbidden(t)) continue;
115
+ seen.add(t);
116
+ newTags.push(t);
117
+ }
118
+ if (newTags.length === 0) return [];
119
+
120
+ const sectionStart = headerMatch.index + headerMatch[0].length;
121
+ const rest = content.slice(sectionStart);
122
+ const nextH2 = NEXT_H2_RE.exec(rest);
123
+ const sectionEnd = nextH2 ? sectionStart + nextH2.index : content.length;
124
+ const section = content.slice(sectionStart, sectionEnd);
125
+
126
+ let newSection;
127
+ const heading = PENDING_HEADING_RE.exec(section);
128
+ if (heading) {
129
+ // Pending subsection exists — bound it (heading → next H3 or section end) and
130
+ // either extend its `**Pending**:` data line or seed one if the block is empty
131
+ // (the template ships heading + prose with no tags yet).
132
+ const subStart = heading.index;
133
+ const after = section.slice(heading.index + heading[0].length);
134
+ const nextH3 = NEXT_H3_RE.exec(after);
135
+ const subEnd = nextH3 ? heading.index + heading[0].length + nextH3.index : section.length;
136
+ const sub = section.slice(subStart, subEnd);
137
+
138
+ const dataMatch = PENDING_DATA_RE.exec(sub);
139
+ let newSub;
140
+ if (dataMatch) {
141
+ const existing = [];
142
+ for (const m of dataMatch[0].matchAll(BACKTICK_TOKEN_RE)) {
143
+ const t = m[1].trim();
144
+ if (t && !t.includes(' ')) existing.push(t);
145
+ }
146
+ const merged = [...existing, ...newTags];
147
+ const dataLine = `**Pending**: ${backtickList(merged)}`;
148
+ newSub =
149
+ sub.slice(0, dataMatch.index) + dataLine + sub.slice(dataMatch.index + dataMatch[0].length);
150
+ } else {
151
+ const dataLine = `**Pending**: ${backtickList(newTags)}`;
152
+ newSub = `${sub.replace(/\s*$/, '')}\n\n${dataLine}\n`;
153
+ }
154
+ newSection = section.slice(0, subStart) + newSub + section.slice(subEnd);
155
+ } else {
156
+ // No Pending block yet — build one and place it before "### Forbidden patterns"
157
+ // if present (Pending is vocabulary; Forbidden is a separate concern), else at
158
+ // the end of the Tag Vocabulary section.
159
+ const block = `${PENDING_HEADING}\n\n${PENDING_PROSE}\n\n**Pending**: ${backtickList(newTags)}\n`;
160
+ const forbidden = FORBIDDEN_HEADING_RE.exec(section);
161
+ if (forbidden) {
162
+ newSection =
163
+ section.slice(0, forbidden.index) + `${block}\n` + section.slice(forbidden.index);
164
+ } else {
165
+ newSection = `${section.replace(/\s*$/, '')}\n\n${block}\n`;
166
+ }
167
+ }
168
+
169
+ writeFileSync(path, content.slice(0, sectionStart) + newSection + content.slice(sectionEnd));
170
+ return newTags;
171
+ }
172
+
69
173
  // Derive the set of valid immediate subdirectories under pages/ from the
70
174
  // SCHEMA "Page Type Taxonomy" table (e.g. learnings, feedback, people). Single
71
175
  // source of truth — adding a type+dir row to SCHEMA automatically widens the
package/scripts/lint.mjs CHANGED
@@ -426,7 +426,15 @@ function lintPage({ path, rel }, slugMap, tagVocab, pageDirs, validTypes) {
426
426
  continue;
427
427
  }
428
428
  if (!tagVocab.has(tag)) {
429
- issue('error', rel, `Unknown tag: "${tag}" (not in SCHEMA.md Tag Vocabulary)`);
429
+ // W10 (B-4): an unknown but non-forbidden tag is a WARNING, not a hard
430
+ // error — a session close must never stall on a vocabulary gap. The
431
+ // crystallize apply path parses these warns (by this exact message
432
+ // string — load-bearing, see scripts/crystallize.mjs auto-register block)
433
+ // and registers them into SCHEMA.md's `### Pending` section, so the next
434
+ // lint sees them as known. W10 is intentionally NOT in STRICT_PROMOTE_IDS:
435
+ // an unregistered tag is a vocabulary lag, not a content defect, so even
436
+ // `--strict` keeps it a warning (it only surfaces the id in --json output).
437
+ issue('warn', rel, `Unknown tag: "${tag}" (not in SCHEMA.md Tag Vocabulary)`, null, 'W10');
430
438
  }
431
439
  }
432
440
  }
@@ -123,7 +123,7 @@ export function buildReport(
123
123
  return { week: weekLabel, count: weekResults.length, counts, score, results: weekResults };
124
124
  }
125
125
 
126
- function renderMarkdown(report) {
126
+ function renderMarkdown(report, hypoDir) {
127
127
  const { week, count, counts, score, results } = report;
128
128
  const today = new Date().toISOString().slice(0, 10);
129
129
  const lines = [];
@@ -138,8 +138,14 @@ function renderMarkdown(report) {
138
138
  lines.push('');
139
139
  lines.push(`# Observability — Week ${week}`);
140
140
  lines.push('');
141
+ // Only emit the wikilink when its target page exists, so a fresh vault
142
+ // (or a deleted _index) does not stamp a broken link into every weekly.
143
+ const indexExists = !!hypoDir && existsSync(join(hypoDir, 'pages', 'observability', '_index.md'));
144
+ const heuristicRef = indexExists
145
+ ? '[[pages/observability/_index]]'
146
+ : '`pages/observability/_index.md` (not present in this vault)';
141
147
  lines.push(
142
- `> Generated by \`scripts/weekly-report.mjs\`. Heuristic definition: [[pages/observability/_index]].`,
148
+ `> Generated by \`scripts/weekly-report.mjs\`. Heuristic definition: ${heuristicRef}.`,
143
149
  );
144
150
  lines.push('');
145
151
  lines.push(`## Autonomy score (heuristic v0): **${score}%**`);
@@ -196,7 +202,7 @@ if (isMain()) {
196
202
  process.exit(0);
197
203
  }
198
204
 
199
- const md = renderMarkdown(report);
205
+ const md = renderMarkdown(report, args.hypoDir);
200
206
 
201
207
  if (args.write) {
202
208
  const dir = join(args.hypoDir, 'journal', 'weekly');