hypomnema 1.4.0 → 1.4.1

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.
@@ -919,8 +919,36 @@ function applySessionClose(args) {
919
919
  for (const a of applied) console.log(` ✓ wrote ${a}`);
920
920
  for (const s of skipped) console.log(` · skipped ${s} (already current)`);
921
921
  if (ok) {
922
- console.log('\n✓ session-close verified all 5 mandatory files fresh, lint clean.');
923
- } else {
922
+ // When the marker was withheld, qualify the success line so a reader scanning
923
+ // stdout alone cannot mistake "verified" for "fully closed". markerSkipReason
924
+ // is non-null exactly when args.sessionId is set and the marker did not land.
925
+ if (markerSkipReason) {
926
+ console.log(
927
+ '\n✓ session-close files verified (all 5 mandatory files fresh, lint clean).' +
928
+ '\n session NOT fully closed: the Stop-chain marker was not written (see warning below).',
929
+ );
930
+ } else {
931
+ console.log('\n✓ session-close verified — all 5 mandatory files fresh, lint clean.');
932
+ }
933
+ }
934
+ // When ok:true but the session-close marker was NOT written, the Stop-chain
935
+ // still sees an open session and will re-prompt at the next Stop. Surface this
936
+ // loudly so neither the human nor a skill-following model reads "ok:true" as
937
+ // "session fully closed". Gate on markerSkipReason (non-null exactly when
938
+ // args.sessionId is present and the marker was withheld).
939
+ if (markerSkipReason) {
940
+ process.stderr.write(
941
+ `\n⚠️ session-close marker NOT written (reason: ${markerSkipReason})\n` +
942
+ ` The 5 mandatory files were applied and verified (ok:true), but the\n` +
943
+ ` per-session Stop-chain marker was withheld. The session is NOT fully\n` +
944
+ ` closed: the Stop hook will re-prompt until the marker is present.\n` +
945
+ ` To fix: re-run with the correct main-conversation --session-id (NOT\n` +
946
+ ` a background task or Agent UUID from a /tmp/... path).\n` +
947
+ ` Example: crystallize.mjs --apply-session-close --payload=<path>\n` +
948
+ ` --session-id=<main-conversation-id> --hypo-dir=<path>\n`,
949
+ );
950
+ }
951
+ if (!ok) {
924
952
  if (!verification.ok) {
925
953
  const bad = [
926
954
  ...verification.missing.map((f) => `${f} (missing)`),
@@ -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 }
@@ -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');
@@ -76,7 +76,27 @@ reports and re-run until it prints **"Compact-ready"** — that is the signal th
76
76
  session is closed. A close-files-only pass is not enough; the real `/compact`
77
77
  also blocks on those other checks.
78
78
 
79
- Once it passes, report each item with ✓ and ask: "Session closed. Would you like to also run knowledge synthesis now, or stop here?"
79
+ **When using `--apply-session-close --session-id=<id>`** (the payload-driven
80
+ path), the `--session-id` must be the main conversation's session id. Do NOT
81
+ extract it from a background task or Agent output path (e.g., a UUID from
82
+ `/tmp/.../<uuid>/tasks/...`). Such a UUID is a background task id, not the
83
+ main conversation id. Passing the wrong id causes `markerWritten: false` with
84
+ `markerSkipReason: "transcript-unresolved"` or `"no-user-close-signal"`: the
85
+ 5 mandatory files are written (ok: true) but the Stop-chain marker is withheld,
86
+ so the session is not actually closed and the Stop hook re-prompts. The correct
87
+ id comes from the `[WIKI_AUTOCLOSE]` block reason or `$CLAUDE_SESSION_ID`.
88
+
89
+ Once it passes, report each item with ✓:
90
+ - ✓ session-state.md
91
+ - ✓ hot.md (project + root)
92
+ - ✓ session-log entry
93
+ - ✓ open-questions (or skipped if unchanged)
94
+ - ✓ log.md entry
95
+ - **marker written?** (required, if `--apply-session-close --session-id` was used): check `markerWritten` in the JSON output. If `true`, report "session-close marker written." If `false`, do NOT declare the session closed or complete. Instead report: "Files applied and verified (ok: true), but the session-close marker was not written (reason: `<markerSkipReason>`). The Stop-chain is still active. Re-run with the correct main-conversation `--session-id`."
96
+
97
+ If `markerWritten: true` (or `--session-id` was not passed), ask: "Session closed. Would you like to also run knowledge synthesis now, or stop here?"
98
+
99
+ If `markerWritten: false`, do NOT say "session closed." Surface the skip reason and instruct a re-run with the correct main-conversation `--session-id` (see the bullet above) before treating the session as closed.
80
100
 
81
101
  ---
82
102
 
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  title: Hypomnema Config
3
3
  type: config
4
- version: "1.4.0"
4
+ version: "1.4.1"
5
5
  created: YYYY-MM-DD
6
6
  ---
7
7
 
@@ -1,77 +0,0 @@
1
- #!/usr/bin/env node
2
- // Sync version across package.json, .claude-plugin/plugin.json, and templates/hypo-config.md.
3
- // Usage: node scripts/bump-version.mjs <new-version>
4
-
5
- import { readFileSync, writeFileSync } from 'node:fs';
6
- import { resolve, dirname } from 'node:path';
7
- import { fileURLToPath } from 'node:url';
8
-
9
- const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
10
- const next = process.argv[2];
11
-
12
- if (!next || !/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(next)) {
13
- console.error('Usage: node scripts/bump-version.mjs <semver>');
14
- console.error('Example: node scripts/bump-version.mjs 1.0.0');
15
- process.exit(1);
16
- }
17
-
18
- const targets = [
19
- {
20
- path: 'package.json',
21
- pattern: /("version"\s*:\s*")([^"]+)(")/,
22
- },
23
- {
24
- path: '.claude-plugin/plugin.json',
25
- pattern: /("version"\s*:\s*")([^"]+)(")/,
26
- },
27
- {
28
- path: '.claude-plugin/marketplace.json',
29
- pattern: /("version"\s*:\s*")([^"]+)(")/,
30
- },
31
- {
32
- path: 'templates/hypo-config.md',
33
- pattern: /(^version:\s*")([^"]+)(")/m,
34
- },
35
- ];
36
-
37
- for (const { path, pattern } of targets) {
38
- const abs = resolve(root, path);
39
- const before = readFileSync(abs, 'utf-8');
40
- const match = before.match(pattern);
41
- if (!match) {
42
- console.error(`✗ ${path}: version pattern not found`);
43
- process.exit(1);
44
- }
45
- const current = match[2];
46
- if (current === next) {
47
- console.log(`= ${path} already ${next}`);
48
- continue;
49
- }
50
- writeFileSync(abs, before.replace(pattern, `$1${next}$3`));
51
- console.log(`✓ ${path}: ${current} → ${next}`);
52
- }
53
-
54
- console.log(`\nThis bumps the 4 manifest files only. The full release checklist lives in`);
55
- console.log(`docs/CONTRIBUTING.md "Cutting a release" — follow it, or these steps:`);
56
- console.log(`\nNext steps:`);
57
- console.log(` 1. Sync package-lock (bump-version does NOT touch it; the lock carries the`);
58
- console.log(` version twice and a stale lock fails check:versions + breaks npm ci):`);
59
- console.log(` npm install --package-lock-only`);
60
- console.log(` 2. Edit CHANGELOG.md — ensure the "## [${next}]" section has a`);
61
- console.log(` "### 한글 요약" sub-section (release.yml check-bilingual gate).`);
62
- console.log(` 3. Reconcile BOTH READMEs — add a v${next} sentence to README.md AND`);
63
- console.log(` README.ko.md, and update the first-viewport "current release" pointer.`);
64
- console.log(` (This step was dropped 3x; check:readme is now the floor gate.)`);
65
- console.log(` 4. Local pre-checks — the full gate set CI + prepublishOnly run:`);
66
- console.log(` npm test && npm run lint && npm run check:versions \\`);
67
- console.log(` && npm run check:bilingual && npm run check:readme \\`);
68
- console.log(` && npm run smoke:plugin && npm run smoke-pack && npm run check:tracker-ids`);
69
- console.log(` 5. git add package.json package-lock.json .claude-plugin/ \\`);
70
- console.log(` templates/hypo-config.md CHANGELOG.md README.md README.ko.md`);
71
- console.log(` git commit -m "chore(release): v${next}"`);
72
- console.log(` 6. Create an ANNOTATED tag (lightweight tags are rejected by CI):`);
73
- console.log(` git tag -a v${next} -m "<English body>\\n\\n---\\n\\n<한글 요약>"`);
74
- console.log(` See docs/CONTRIBUTING.md "Cutting a release" for the full template.`);
75
- console.log(` 7. node scripts/check-bilingual.mjs --tag v${next} # local pre-check`);
76
- console.log(` 8. git push origin <branch> # then push the tag ALONE to trigger release:`);
77
- console.log(` git push origin v${next} # NOT --tags (avoid triggering stale tags)`);