@aperant/framework 0.6.6 → 0.6.7

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 (88) hide show
  1. package/CHANGELOG.md +155 -0
  2. package/dist/cli/commands/check-version.d.mts.map +1 -1
  3. package/dist/cli/commands/check-version.mjs +76 -1
  4. package/dist/cli/commands/check-version.mjs.map +1 -1
  5. package/dist/cli/commands/ci-watch.d.mts.map +1 -1
  6. package/dist/cli/commands/ci-watch.mjs +34 -4
  7. package/dist/cli/commands/ci-watch.mjs.map +1 -1
  8. package/dist/cli/commands/init.d.mts +8 -1
  9. package/dist/cli/commands/init.d.mts.map +1 -1
  10. package/dist/cli/commands/init.mjs +91 -9
  11. package/dist/cli/commands/init.mjs.map +1 -1
  12. package/dist/cli/commands/release-notes.d.mts +11 -0
  13. package/dist/cli/commands/release-notes.d.mts.map +1 -0
  14. package/dist/cli/commands/release-notes.mjs +173 -0
  15. package/dist/cli/commands/release-notes.mjs.map +1 -0
  16. package/dist/cli/config/load.d.mts +14 -0
  17. package/dist/cli/config/load.d.mts.map +1 -1
  18. package/dist/cli/config/load.mjs +40 -0
  19. package/dist/cli/config/load.mjs.map +1 -1
  20. package/dist/cli/config/upgrade-gitignore.d.mts.map +1 -1
  21. package/dist/cli/config/upgrade-gitignore.mjs +6 -0
  22. package/dist/cli/config/upgrade-gitignore.mjs.map +1 -1
  23. package/dist/cli/consistency/parse-review.d.mts.map +1 -1
  24. package/dist/cli/consistency/parse-review.mjs +4 -1
  25. package/dist/cli/consistency/parse-review.mjs.map +1 -1
  26. package/dist/cli/dispatch.d.mts.map +1 -1
  27. package/dist/cli/dispatch.mjs +2 -0
  28. package/dist/cli/dispatch.mjs.map +1 -1
  29. package/dist/cli/gate/gates/review-clean.d.mts.map +1 -1
  30. package/dist/cli/gate/gates/review-clean.mjs +4 -2
  31. package/dist/cli/gate/gates/review-clean.mjs.map +1 -1
  32. package/dist/cli/install/update-chips.d.mts +23 -0
  33. package/dist/cli/install/update-chips.d.mts.map +1 -1
  34. package/dist/cli/install/update-chips.mjs +60 -0
  35. package/dist/cli/install/update-chips.mjs.map +1 -1
  36. package/dist/cli/release-notes/compile.d.mts +38 -0
  37. package/dist/cli/release-notes/compile.d.mts.map +1 -0
  38. package/dist/cli/release-notes/compile.mjs +244 -0
  39. package/dist/cli/release-notes/compile.mjs.map +1 -0
  40. package/dist/cli/release-notes/draft.d.mts +73 -0
  41. package/dist/cli/release-notes/draft.d.mts.map +1 -0
  42. package/dist/cli/release-notes/draft.mjs +120 -0
  43. package/dist/cli/release-notes/draft.mjs.map +1 -0
  44. package/dist/cli/release-notes/output-dir.d.mts +20 -0
  45. package/dist/cli/release-notes/output-dir.d.mts.map +1 -0
  46. package/dist/cli/release-notes/output-dir.mjs +42 -0
  47. package/dist/cli/release-notes/output-dir.mjs.map +1 -0
  48. package/dist/cli/release-notes/persona-filter.d.mts +51 -0
  49. package/dist/cli/release-notes/persona-filter.d.mts.map +1 -0
  50. package/dist/cli/release-notes/persona-filter.mjs +127 -0
  51. package/dist/cli/release-notes/persona-filter.mjs.map +1 -0
  52. package/dist/cli/release-notes/publish.d.mts +23 -0
  53. package/dist/cli/release-notes/publish.d.mts.map +1 -0
  54. package/dist/cli/release-notes/publish.mjs +125 -0
  55. package/dist/cli/release-notes/publish.mjs.map +1 -0
  56. package/dist/cli/release-notes/ship-autodraft.d.mts +37 -0
  57. package/dist/cli/release-notes/ship-autodraft.d.mts.map +1 -0
  58. package/dist/cli/release-notes/ship-autodraft.mjs +97 -0
  59. package/dist/cli/release-notes/ship-autodraft.mjs.map +1 -0
  60. package/dist/types/config.d.ts +11 -7
  61. package/dist/types/config.d.ts.map +1 -1
  62. package/package.json +133 -133
  63. package/skills/apt-close-task/SKILL.md +25 -0
  64. package/skills/apt-diagram/SKILL.md +45 -9
  65. package/skills/apt-release-notes/SKILL.md +193 -0
  66. package/skills/apt-release-notes/appendices/persona-voice.md +59 -0
  67. package/skills/apt-setup/SKILL.md +146 -3
  68. package/skills/apt-ship/SKILL.md +47 -0
  69. package/skills/apt-update/SKILL.md +51 -9
  70. package/skills/apt-watch-ci/SKILL.md +2 -2
  71. package/src/cli/commands/check-version.mjs +73 -1
  72. package/src/cli/commands/ci-watch.mjs +35 -4
  73. package/src/cli/commands/init.mjs +101 -9
  74. package/src/cli/commands/release-notes.mjs +187 -0
  75. package/src/cli/config/load.mjs +37 -0
  76. package/src/cli/config/upgrade-gitignore.mjs +6 -0
  77. package/src/cli/consistency/parse-review.mjs +3 -1
  78. package/src/cli/dispatch.mjs +2 -0
  79. package/src/cli/gate/gates/review-clean.mjs +4 -2
  80. package/src/cli/install/update-chips.mjs +57 -0
  81. package/src/cli/release-notes/compile.mjs +261 -0
  82. package/src/cli/release-notes/draft.mjs +133 -0
  83. package/src/cli/release-notes/output-dir.mjs +52 -0
  84. package/src/cli/release-notes/persona-filter.mjs +126 -0
  85. package/src/cli/release-notes/publish.mjs +128 -0
  86. package/src/cli/release-notes/ship-autodraft.mjs +106 -0
  87. package/templates/config.json +28 -3
  88. package/workflows/docs.md +12 -0
@@ -60,6 +60,63 @@ export function readUpdateCache() {
60
60
  }
61
61
  }
62
62
 
63
+ /** Semver-shape pattern: MAJOR.MINOR.PATCH with optional pre-release and build metadata. */
64
+ const SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/
65
+
66
+ /**
67
+ * Read the worker's cache file with TTL and schema validation. Returns
68
+ * `{ fresh: true, cache }` when the cache exists, parses, declares
69
+ * `schema_version === 1`, and was written within `ttlMs` milliseconds.
70
+ * Returns `{ fresh: false }` when missing, stale, or schema mismatch.
71
+ *
72
+ * Security: `latest_version` and `installed_version` inside
73
+ * `cache.framework` are validated as semver strings. Invalid values are
74
+ * dropped (set to null) so they never propagate into shell-interpolated
75
+ * contexts.
76
+ *
77
+ * ENOENT and JSON parse errors stay silent (expected when the cache is
78
+ * missing or being rewritten). Unexpected errors are logged to stderr.
79
+ *
80
+ * @param {number} ttlMs Maximum age in milliseconds before cache is treated as stale.
81
+ * @returns {{ fresh: true, cache: Record<string, unknown> } | { fresh: false }}
82
+ */
83
+ export function readFreshUpdateCache(ttlMs) {
84
+ const path = updateCachePath()
85
+ if (!existsSync(path)) return { fresh: false }
86
+ let data
87
+ try {
88
+ data = JSON.parse(readFileSync(path, 'utf-8'))
89
+ } catch (err) {
90
+ // SyntaxError = malformed JSON (expected); anything else is unexpected.
91
+ if (!(err instanceof SyntaxError)) {
92
+ console.error('[update-cache] read failed:', err.message)
93
+ }
94
+ return { fresh: false }
95
+ }
96
+ try {
97
+ if (!data || typeof data !== 'object') return { fresh: false }
98
+ if (data.schema_version !== 1) return { fresh: false }
99
+ if (typeof data.checked_at !== 'string') return { fresh: false }
100
+ const age = Date.now() - new Date(data.checked_at).getTime()
101
+ if (!Number.isFinite(age) || age < 0 || age >= ttlMs) return { fresh: false }
102
+ // Validate semver fields in framework block — reject non-semver strings.
103
+ const fw = data.framework && typeof data.framework === 'object' ? data.framework : {}
104
+ const sanitized = { ...data, framework: { ...fw } }
105
+ for (const field of ['latest_version', 'installed_version']) {
106
+ if (
107
+ typeof sanitized.framework[field] === 'string' &&
108
+ !SEMVER_RE.test(sanitized.framework[field])
109
+ ) {
110
+ sanitized.framework[field] = null
111
+ }
112
+ }
113
+ return { fresh: true, cache: sanitized }
114
+ } catch (err) {
115
+ console.error('[update-cache] read failed:', err.message)
116
+ return { fresh: false }
117
+ }
118
+ }
119
+
63
120
  /**
64
121
  * Build the chip string array for a given cache. Empty array when there
65
122
  * are no chips to show. Order: stale_files first (most urgent), then
@@ -0,0 +1,261 @@
1
+ /**
2
+ * release-notes/compile.mjs — tag-time narrative compiler.
3
+ *
4
+ * Reads every `<output_dir>/_unreleased/PR-*.md` fragment, classifies each
5
+ * against the project's persona roster + filter mode, and writes
6
+ * `<output_dir>/<tag>.md`. Atomic-rename pattern: the fragments dir is
7
+ * cleared only AFTER the compiled file lands on disk, so a crash mid-write
8
+ * cannot orphan fragments without their compiled output.
9
+ *
10
+ * Non-negotiable invariant (ID-6): unmapped fragments (no persona tag, or
11
+ * a tag the roster doesn't recognize) MUST appear in a `## Needs Triage`
12
+ * section. Never silent-drop. The triage section is the human curator's
13
+ * safety net — its presence is asserted by TD-3.
14
+ */
15
+
16
+ import { existsSync, readdirSync, readFileSync, rmSync } from 'node:fs'
17
+ import { join } from 'node:path'
18
+
19
+ import { loadProjectConfig } from '../config/load.mjs'
20
+ import { readPersonas } from '../personas/sidecar.mjs'
21
+ import { atomicWriteText } from '../util/atomic-write.mjs'
22
+ import { resolveOutputDir, validateTag } from './output-dir.mjs'
23
+ import { buildPersonaTierMap, classifyFragment, isNoneSentinel } from './persona-filter.mjs'
24
+
25
+ const VALID_MODES = new Set(['primary', 'primary+secondary', 'all'])
26
+
27
+ /** Pre-compiled at module level to avoid recompilation in the sort comparator (PRF-001). */
28
+ const PR_FILENAME_RE = /^PR-(\d+)\.md$/
29
+
30
+ /**
31
+ * Parse a single fragment file. Tolerant: missing keys default to empty
32
+ * strings; the body after `## Release note` is read verbatim.
33
+ *
34
+ * @param {string} text
35
+ * @returns {{ pr: string, date: string, persona: string, note: string, raw: string }}
36
+ */
37
+ function parseFragment(text) {
38
+ const lines = text.split('\n')
39
+ let pr = ''
40
+ let date = ''
41
+ let persona = ''
42
+ let note = ''
43
+ let noteStart = -1
44
+ for (let i = 0; i < lines.length; i++) {
45
+ const line = lines[i]
46
+ if (line.startsWith('pr: ')) pr = line.slice(4).trim()
47
+ else if (line.startsWith('date: ')) date = line.slice(6).trim()
48
+ else if (line.startsWith('persona: ')) persona = line.slice(9).trim()
49
+ else if (line.startsWith('## Release note')) {
50
+ noteStart = i
51
+ break
52
+ }
53
+ }
54
+ if (noteStart >= 0) {
55
+ note = lines
56
+ .slice(noteStart + 1)
57
+ .join('\n')
58
+ .trim()
59
+ }
60
+ return { pr, date, persona, note, raw: text }
61
+ }
62
+
63
+ /**
64
+ * Resolve the output dir and persona filter mode from config + flag override.
65
+ *
66
+ * @param {string} projectDir
67
+ * @param {string | null} flagOverride
68
+ * @returns {{ outputDir: string, personaFilter: 'primary' | 'primary+secondary' | 'all' }}
69
+ */
70
+ function resolveContext(projectDir, flagOverride) {
71
+ const config = loadProjectConfig(projectDir)
72
+ const outputDir = resolveOutputDir(projectDir, flagOverride)
73
+ const filterRaw = config?.changelog?.release_notes?.persona_filter
74
+ const personaFilter =
75
+ typeof filterRaw === 'string' && VALID_MODES.has(filterRaw)
76
+ ? /** @type {'primary' | 'primary+secondary' | 'all'} */ (filterRaw)
77
+ : 'primary+secondary'
78
+ return { outputDir, personaFilter }
79
+ }
80
+
81
+ /**
82
+ * @typedef {Object} CompileInput
83
+ * @property {string} projectDir
84
+ * @property {string} tag
85
+ * @property {string | null} outputDir
86
+ */
87
+
88
+ /**
89
+ * @typedef {Object} CompileResult
90
+ * @property {boolean} written
91
+ * @property {string} path
92
+ * @property {number} fragmentsTotal
93
+ * @property {number} fragmentsInBody
94
+ * @property {number} fragmentsInTriage
95
+ * @property {number} fragmentsFiltered
96
+ * @property {boolean} clearedUnreleased
97
+ */
98
+
99
+ /**
100
+ * Compile every `_unreleased/PR-*.md` fragment into `<output_dir>/<tag>.md`.
101
+ *
102
+ * @param {CompileInput} input
103
+ * @returns {CompileResult}
104
+ */
105
+ export function compileTag(input) {
106
+ validateTag(input.tag)
107
+ const { outputDir, personaFilter } = resolveContext(input.projectDir, input.outputDir)
108
+ const fragmentsDir = join(outputDir, '_unreleased')
109
+ const targetPath = join(outputDir, `${input.tag}.md`)
110
+
111
+ const rawFiles = existsSync(fragmentsDir)
112
+ ? readdirSync(fragmentsDir).filter((f) => PR_FILENAME_RE.test(f))
113
+ : []
114
+ // Pre-compute PR numbers once to avoid re-running the regex in the comparator (PRF-001).
115
+ const withNumbers = rawFiles.map((f) => ({
116
+ f,
117
+ n: Number.parseInt(PR_FILENAME_RE.exec(f)?.[1] ?? '0', 10),
118
+ }))
119
+ withNumbers.sort((a, b) => a.n - b.n)
120
+ const fragments = withNumbers.map(({ f }) => ({
121
+ filename: f,
122
+ ...parseFragment(readFileSync(join(fragmentsDir, f), 'utf-8')),
123
+ }))
124
+
125
+ const personasEnvelope = readPersonas(input.projectDir)
126
+ const tierMap = buildPersonaTierMap(personasEnvelope)
127
+
128
+ /** @type {Array<{ pr: string, date: string, persona: string, note: string, tier: 'primary' | 'secondary' | 'tertiary' }>} */
129
+ const bodyEntries = []
130
+ /** @type {Array<{ pr: string, date: string, persona: string, note: string, reason: string }>} */
131
+ const triageEntries = []
132
+ let filteredCount = 0
133
+
134
+ for (const frag of fragments) {
135
+ const { bucket, resolvedTier } = classifyFragment(frag.persona, tierMap, personaFilter)
136
+ if (bucket === 'body' && resolvedTier !== null && resolvedTier !== 'unknown') {
137
+ bodyEntries.push({
138
+ pr: frag.pr,
139
+ date: frag.date,
140
+ persona: frag.persona,
141
+ note: frag.note,
142
+ tier: resolvedTier,
143
+ })
144
+ } else if (bucket === 'triage' || resolvedTier === 'unknown') {
145
+ // Use the shared sentinel predicate so this label stays in lockstep
146
+ // with persona-filter.mjs's NONE_SENTINELS set. Without it, the
147
+ // classifier and labeller drift (e.g. literal "null" persona tags
148
+ // land in triage as 'unknown-persona' instead of 'no-persona-tag').
149
+ // 'unknown' tier (malformed tier value in personas.json) is also
150
+ // routed here — never silent-drop (ID-6 / LOG-001).
151
+ const reason = isNoneSentinel(frag.persona)
152
+ ? 'no-persona-tag'
153
+ : resolvedTier === 'unknown'
154
+ ? 'unknown-tier'
155
+ : 'unknown-persona'
156
+ triageEntries.push({
157
+ pr: frag.pr,
158
+ date: frag.date,
159
+ persona: frag.persona,
160
+ note: frag.note,
161
+ reason,
162
+ })
163
+ } else {
164
+ filteredCount++
165
+ }
166
+ }
167
+
168
+ const compiled = renderCompiled({
169
+ tag: input.tag,
170
+ body: bodyEntries,
171
+ triage: triageEntries,
172
+ filter: personaFilter,
173
+ })
174
+
175
+ atomicWriteText(targetPath, compiled)
176
+
177
+ // Clear _unreleased only AFTER the compiled file is durably written.
178
+ let clearedUnreleased = false
179
+ if (existsSync(fragmentsDir) && fragments.length > 0) {
180
+ for (const frag of fragments) {
181
+ rmSync(join(fragmentsDir, frag.filename), { force: true })
182
+ }
183
+ clearedUnreleased = true
184
+ }
185
+
186
+ return {
187
+ written: true,
188
+ path: targetPath,
189
+ fragmentsTotal: fragments.length,
190
+ fragmentsInBody: bodyEntries.length,
191
+ fragmentsInTriage: triageEntries.length,
192
+ fragmentsFiltered: filteredCount,
193
+ clearedUnreleased,
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Render the per-tag compiled body.
199
+ *
200
+ * Layout:
201
+ * # Release <tag>
202
+ *
203
+ * (per-tier sections — primary first, then secondary, then tertiary
204
+ * when filter==='all'. Each section's entries sorted by PR number.)
205
+ *
206
+ * ## Needs Triage (only when triage[] non-empty)
207
+ *
208
+ * @param {{
209
+ * tag: string,
210
+ * body: Array<{ pr: string, date: string, persona: string, note: string, tier: 'primary' | 'secondary' | 'tertiary' }>,
211
+ * triage: Array<{ pr: string, date: string, persona: string, note: string, reason: string }>,
212
+ * filter: 'primary' | 'primary+secondary' | 'all',
213
+ * }} input
214
+ * @returns {string}
215
+ */
216
+ function renderCompiled(input) {
217
+ const lines = []
218
+ lines.push(`# Release ${input.tag}`)
219
+ lines.push('')
220
+ lines.push(`<!-- persona_filter: ${input.filter} -->`)
221
+ lines.push('')
222
+
223
+ const tiersInOrder = /** @type {const} */ (['primary', 'secondary', 'tertiary'])
224
+ for (const tier of tiersInOrder) {
225
+ const rows = input.body.filter((e) => e.tier === tier)
226
+ if (rows.length === 0) continue
227
+ lines.push(`## ${capitalize(tier)} personas`)
228
+ lines.push('')
229
+ for (const row of rows) {
230
+ lines.push(`- **PR #${row.pr}** (${row.persona}): ${row.note}`)
231
+ }
232
+ lines.push('')
233
+ }
234
+
235
+ if (input.triage.length > 0) {
236
+ lines.push('## Needs Triage')
237
+ lines.push('')
238
+ lines.push('_These fragments lack a recognized persona tag. Curate before publishing._')
239
+ lines.push('')
240
+ for (const row of input.triage) {
241
+ const personaLabel = row.persona && row.persona !== '(none)' ? row.persona : '(no tag)'
242
+ lines.push(`- **PR #${row.pr}** (${personaLabel}, ${row.reason}): ${row.note}`)
243
+ }
244
+ lines.push('')
245
+ }
246
+
247
+ if (input.body.length === 0 && input.triage.length === 0) {
248
+ lines.push('_No fragments to compile._')
249
+ lines.push('')
250
+ }
251
+
252
+ return lines.join('\n')
253
+ }
254
+
255
+ /**
256
+ * @param {string} s
257
+ * @returns {string}
258
+ */
259
+ function capitalize(s) {
260
+ return s.length === 0 ? s : s[0].toUpperCase() + s.slice(1)
261
+ }
@@ -0,0 +1,133 @@
1
+ /**
2
+ * release-notes/draft.mjs — towncrier-style per-PR fragment writer.
3
+ *
4
+ * Writes `<output_dir>/_unreleased/PR-<n>.md` for a single PR. Idempotent:
5
+ * if the target file already exists with byte-identical content, the call
6
+ * is a no-op and the envelope reports `written: false`. The same input
7
+ * always produces the same bytes on disk — that is the load-bearing
8
+ * invariant the news-fragment pattern depends on (zero merge conflicts
9
+ * on the unreleased file, AC2).
10
+ *
11
+ * Deterministic I/O only. No LLM calls. No prompt templates. The host
12
+ * LLM authors the note text and hands it in via --note.
13
+ */
14
+
15
+ import { execFileSync } from 'node:child_process'
16
+ import { existsSync, readFileSync } from 'node:fs'
17
+ import { join } from 'node:path'
18
+
19
+ import { atomicWriteText } from '../util/atomic-write.mjs'
20
+ import { resolveOutputDir } from './output-dir.mjs'
21
+
22
+ /**
23
+ * Build the canonical fragment body. The shape is intentionally simple +
24
+ * stable so the compile step can parse it back without YAML / front-matter
25
+ * gymnastics. Date is the calling day in UTC, not a wall-clock timestamp —
26
+ * idempotency demands the same inputs produce the same bytes regardless of
27
+ * when the day they're called within.
28
+ *
29
+ * @param {{
30
+ * pr: number,
31
+ * note: string,
32
+ * persona: string | null,
33
+ * date: string,
34
+ * commits: string[],
35
+ * }} input
36
+ * @returns {string}
37
+ */
38
+ export function renderFragment(input) {
39
+ const personaLine = input.persona ? `persona: ${input.persona}` : 'persona: (none)'
40
+ const commitsLine =
41
+ input.commits.length === 0 ? 'commits: (none)' : `commits: ${input.commits.join(', ')}`
42
+ return [
43
+ `# PR-${input.pr}`,
44
+ '',
45
+ `date: ${input.date}`,
46
+ `pr: ${input.pr}`,
47
+ personaLine,
48
+ commitsLine,
49
+ '',
50
+ '## Release note',
51
+ '',
52
+ input.note.trim(),
53
+ '',
54
+ ].join('\n')
55
+ }
56
+
57
+ /**
58
+ * Resolve the commits referencing a PR by number. Best-effort — if git is
59
+ * unavailable or no matching commits are found, returns an empty array.
60
+ * Caller can pass commits explicitly via --commits to bypass git inspection.
61
+ *
62
+ * @param {string} projectDir
63
+ * @param {number} pr
64
+ * @returns {string[]}
65
+ */
66
+ function discoverCommitsForPr(projectDir, pr) {
67
+ try {
68
+ const out = execFileSync(
69
+ 'git',
70
+ ['-C', projectDir, 'log', '--no-merges', `--grep=#${pr}\\b`, '--format=%H'],
71
+ { encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'] },
72
+ )
73
+ return out
74
+ .split('\n')
75
+ .map((s) => s.trim())
76
+ .filter((s) => s.length > 0)
77
+ .map((sha) => sha.slice(0, 12))
78
+ } catch {
79
+ return []
80
+ }
81
+ }
82
+
83
+ /**
84
+ * @typedef {Object} DraftInput
85
+ * @property {string} projectDir
86
+ * @property {number} pr
87
+ * @property {string} note
88
+ * @property {string | null} persona
89
+ * @property {string | null} outputDir // CLI flag override (relative to projectDir or absolute).
90
+ * @property {string[] | null} commits // explicit commit list; null → autodetect via git log.
91
+ * @property {string | null} date // ISO yyyy-mm-dd; null → today (UTC).
92
+ */
93
+
94
+ /**
95
+ * @typedef {Object} DraftResult
96
+ * @property {boolean} written
97
+ * @property {string} path
98
+ * @property {string} reason // 'created' | 'no-change' | 'updated'
99
+ */
100
+
101
+ /**
102
+ * Write the PR fragment idempotently. Returns the result envelope.
103
+ *
104
+ * @param {DraftInput} input
105
+ * @returns {DraftResult}
106
+ */
107
+ export function writeDraft(input) {
108
+ const outputDir = resolveOutputDir(input.projectDir, input.outputDir)
109
+ const unreleasedDir = join(outputDir, '_unreleased')
110
+ const target = join(unreleasedDir, `PR-${input.pr}.md`)
111
+
112
+ const date = input.date ?? new Date().toISOString().slice(0, 10)
113
+ const commits = input.commits ?? discoverCommitsForPr(input.projectDir, input.pr)
114
+
115
+ const body = renderFragment({
116
+ pr: input.pr,
117
+ note: input.note,
118
+ persona: input.persona,
119
+ date,
120
+ commits,
121
+ })
122
+
123
+ if (existsSync(target)) {
124
+ const existing = readFileSync(target, 'utf-8')
125
+ if (existing === body) {
126
+ return { written: false, path: target, reason: 'no-change' }
127
+ }
128
+ atomicWriteText(target, body)
129
+ return { written: true, path: target, reason: 'updated' }
130
+ }
131
+ atomicWriteText(target, body)
132
+ return { written: true, path: target, reason: 'created' }
133
+ }
@@ -0,0 +1,52 @@
1
+ /**
2
+ * release-notes/output-dir.mjs — shared output directory resolver.
3
+ *
4
+ * Extracted from draft.mjs, compile.mjs, and publish.mjs to eliminate
5
+ * copy-paste duplication (QUA-003).
6
+ */
7
+
8
+ import { resolve } from 'node:path'
9
+
10
+ import { loadProjectConfig } from '../config/load.mjs'
11
+
12
+ const DEFAULT_OUTPUT_DIR = 'docs/releases'
13
+
14
+ /** Allowlist for release tag values — semver-compatible subset. */
15
+ const TAG_PATTERN = /^[a-zA-Z0-9._-]+$/
16
+
17
+ /**
18
+ * Validate a release tag. Throws if the tag contains path-traversal
19
+ * sequences (`..`, `/`) or characters outside the semver-compatible
20
+ * allowlist. Defends path.join calls in compile.mjs and publish.mjs
21
+ * against directory escape (SEC-001).
22
+ *
23
+ * @param {string} tag
24
+ * @returns {void}
25
+ */
26
+ export function validateTag(tag) {
27
+ if (tag.includes('..') || tag.includes('/')) {
28
+ throw new Error(`Invalid --tag: "${tag}" contains path-traversal sequences (.. or /)`)
29
+ }
30
+ if (!TAG_PATTERN.test(tag)) {
31
+ throw new Error(
32
+ `Invalid --tag: "${tag}" must match /^[a-zA-Z0-9._-]+$/ (no spaces or special characters)`,
33
+ )
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Resolve the canonical output directory from flag override → config → default.
39
+ * Flag override wins when present. Returns an absolute path.
40
+ *
41
+ * @param {string} projectDir
42
+ * @param {string | null} flagOverride
43
+ * @returns {string}
44
+ */
45
+ export function resolveOutputDir(projectDir, flagOverride) {
46
+ const config = loadProjectConfig(projectDir)
47
+ const configured = config?.changelog?.release_notes?.output_dir
48
+ return resolve(
49
+ projectDir,
50
+ flagOverride ?? (typeof configured === 'string' ? configured : DEFAULT_OUTPUT_DIR),
51
+ )
52
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * release-notes/persona-filter.mjs — shared persona-filter logic.
3
+ *
4
+ * The compile + autodraft surfaces both need the same predicate: given a
5
+ * persona id (or name) extracted from a fragment, the project's
6
+ * `.aperant/personas.json` roster, and a filter mode, decide whether the
7
+ * fragment belongs in the user-facing body, in the "Needs Triage" bucket,
8
+ * or filtered out entirely.
9
+ *
10
+ * Three filter modes (config.changelog.release_notes.persona_filter):
11
+ *
12
+ * - 'primary' — only personas whose `tier === 'primary'`.
13
+ * - 'primary+secondary' — primary OR secondary. DEFAULT.
14
+ * - 'all' — every tier passes (no filter).
15
+ *
16
+ * Triage rules (ID-6 in spec.md, never-silent-drop invariant):
17
+ *
18
+ * - Fragment with no persona tag → bucket = 'triage'.
19
+ * - Fragment with a tag that doesn't
20
+ * resolve to a known persona in the
21
+ * roster → bucket = 'triage'.
22
+ * - Fragment whose persona DOES resolve
23
+ * but is filtered out by the mode → bucket = 'filtered'.
24
+ * - Fragment whose persona resolves AND
25
+ * passes the mode → bucket = 'body', with the
26
+ * resolved tier exposed for
27
+ * grouping in compile output.
28
+ *
29
+ * The matcher is case-insensitive on persona names. Empty string and the
30
+ * literal sentinel `(none)` are both treated as "no tag" — the draft
31
+ * renderer writes `persona: (none)` when no --persona flag is passed.
32
+ */
33
+
34
+ /** @typedef {'primary' | 'secondary' | 'tertiary' | 'unknown'} PersonaTier */
35
+ /** @typedef {'body' | 'triage' | 'filtered'} FilterBucket */
36
+
37
+ const NONE_SENTINELS = new Set(['', '(none)', 'none', 'null', 'undefined'])
38
+
39
+ /**
40
+ * True when the raw persona-tag string from a fragment should be treated as
41
+ * "no tag" (and therefore routed to triage with reason `no-persona-tag`).
42
+ * Mirrors `NONE_SENTINELS` so callers — notably `compile.mjs`'s triage-reason
43
+ * labeller — stay in lockstep with the classifier. Case-insensitive.
44
+ *
45
+ * @param {string | null | undefined} raw
46
+ * @returns {boolean}
47
+ */
48
+ export function isNoneSentinel(raw) {
49
+ const normalized = (raw ?? '').trim().toLowerCase()
50
+ return NONE_SENTINELS.has(normalized)
51
+ }
52
+
53
+ /**
54
+ * Read tier + name out of a persona envelope row (the shape personas.mjs
55
+ * persists). Tier lives inside `ui_json` (a JSON string). Returns null
56
+ * when the row is unusable.
57
+ *
58
+ * @param {{ ui_json?: unknown }} row
59
+ * @returns {{ name: string, tier: PersonaTier } | null}
60
+ */
61
+ export function extractPersonaIdentity(row) {
62
+ if (!row || typeof row !== 'object') return null
63
+ if (typeof row.ui_json !== 'string') return null
64
+ let parsed
65
+ try {
66
+ parsed = JSON.parse(row.ui_json)
67
+ } catch {
68
+ return null
69
+ }
70
+ if (!parsed || typeof parsed !== 'object') return null
71
+ const name = typeof parsed.name === 'string' ? parsed.name.trim() : ''
72
+ const tierRaw = typeof parsed.tier === 'string' ? parsed.tier.trim().toLowerCase() : ''
73
+ if (name.length === 0) return null
74
+ /** @type {PersonaTier} */
75
+ const tier =
76
+ tierRaw === 'primary' || tierRaw === 'secondary' || tierRaw === 'tertiary' ? tierRaw : 'unknown'
77
+ return { name, tier }
78
+ }
79
+
80
+ /**
81
+ * Build a name → tier map from a personas envelope.
82
+ *
83
+ * @param {{ personas?: Array<{ ui_json?: unknown }> } | null | undefined} envelope
84
+ * @returns {Map<string, PersonaTier>}
85
+ */
86
+ export function buildPersonaTierMap(envelope) {
87
+ /** @type {Map<string, PersonaTier>} */
88
+ const map = new Map()
89
+ if (!envelope || !Array.isArray(envelope.personas)) return map
90
+ for (const row of envelope.personas) {
91
+ const identity = extractPersonaIdentity(row)
92
+ if (!identity) continue
93
+ map.set(identity.name.toLowerCase(), identity.tier)
94
+ }
95
+ return map
96
+ }
97
+
98
+ /**
99
+ * Classify a fragment's persona tag against the roster + filter mode.
100
+ *
101
+ * @param {string | null | undefined} personaTag The raw tag from the fragment.
102
+ * @param {Map<string, PersonaTier>} tierMap From `buildPersonaTierMap`.
103
+ * @param {'primary' | 'primary+secondary' | 'all'} mode
104
+ * @returns {{ bucket: FilterBucket, resolvedName: string | null, resolvedTier: PersonaTier | null }}
105
+ */
106
+ export function classifyFragment(personaTag, tierMap, mode) {
107
+ const normalized = (personaTag ?? '').trim().toLowerCase()
108
+ if (normalized.length === 0 || NONE_SENTINELS.has(normalized)) {
109
+ return { bucket: 'triage', resolvedName: null, resolvedTier: null }
110
+ }
111
+ const tier = tierMap.get(normalized)
112
+ if (tier === undefined) {
113
+ // Tag was set but the roster doesn't recognize it — never silent-drop.
114
+ return { bucket: 'triage', resolvedName: personaTag ?? null, resolvedTier: null }
115
+ }
116
+ if (mode === 'all') {
117
+ return { bucket: 'body', resolvedName: personaTag ?? null, resolvedTier: tier }
118
+ }
119
+ if (mode === 'primary' && tier !== 'primary') {
120
+ return { bucket: 'filtered', resolvedName: personaTag ?? null, resolvedTier: tier }
121
+ }
122
+ if (mode === 'primary+secondary' && tier !== 'primary' && tier !== 'secondary') {
123
+ return { bucket: 'filtered', resolvedName: personaTag ?? null, resolvedTier: tier }
124
+ }
125
+ return { bucket: 'body', resolvedName: personaTag ?? null, resolvedTier: tier }
126
+ }