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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.ko.md +151 -153
- package/README.md +121 -123
- package/commands/audit.md +4 -4
- package/commands/crystallize.md +18 -5
- package/commands/doctor.md +3 -3
- package/commands/feedback.md +5 -3
- package/commands/graph.md +3 -3
- package/commands/ingest.md +6 -4
- package/commands/init.md +5 -3
- package/commands/lint.md +3 -3
- package/commands/query.md +3 -3
- package/commands/rename.md +4 -4
- package/commands/resume.md +4 -2
- package/commands/stats.md +3 -3
- package/commands/upgrade.md +4 -4
- package/commands/verify.md +3 -3
- package/docs/CONTRIBUTING.md +95 -11
- package/package.json +6 -1
- package/scripts/check-bilingual.mjs +49 -11
- package/scripts/check-tracker-ids.mjs +60 -1
- package/scripts/crystallize.mjs +30 -2
- package/scripts/lib/changelog-classify.mjs +216 -0
- package/scripts/lib/check-bilingual.mjs +125 -22
- package/scripts/lib/check-tracker-ids.mjs +19 -0
- package/scripts/weekly-report.mjs +9 -3
- package/skills/crystallize/SKILL.md +21 -1
- package/templates/hypo-config.md +1 -1
- package/scripts/bump-version.mjs +0 -77
- package/scripts/smoke-pack.mjs +0 -261
- package/scripts/smoke-plugin.mjs +0 -194
package/scripts/crystallize.mjs
CHANGED
|
@@ -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
|
-
|
|
923
|
-
|
|
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
|
|
39
|
-
*
|
|
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
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
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}]
|
|
99
|
-
`(threshold: ${HANGUL_BODY_THRESHOLD}).
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
|
package/templates/hypo-config.md
CHANGED
package/scripts/bump-version.mjs
DELETED
|
@@ -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)`);
|