@aperant/framework 0.6.6 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/CHANGELOG.md +215 -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 +73 -5
  7. package/dist/cli/commands/ci-watch.mjs.map +1 -1
  8. package/dist/cli/commands/health-check.mjs +1 -1
  9. package/dist/cli/commands/health-check.mjs.map +1 -1
  10. package/dist/cli/commands/init.d.mts +17 -1
  11. package/dist/cli/commands/init.d.mts.map +1 -1
  12. package/dist/cli/commands/init.mjs +183 -11
  13. package/dist/cli/commands/init.mjs.map +1 -1
  14. package/dist/cli/commands/release-notes.d.mts +11 -0
  15. package/dist/cli/commands/release-notes.d.mts.map +1 -0
  16. package/dist/cli/commands/release-notes.mjs +173 -0
  17. package/dist/cli/commands/release-notes.mjs.map +1 -0
  18. package/dist/cli/commands/route.d.mts.map +1 -1
  19. package/dist/cli/commands/route.mjs +37 -2
  20. package/dist/cli/commands/route.mjs.map +1 -1
  21. package/dist/cli/commands/task.d.mts.map +1 -1
  22. package/dist/cli/commands/task.mjs +48 -0
  23. package/dist/cli/commands/task.mjs.map +1 -1
  24. package/dist/cli/config/load.d.mts +14 -0
  25. package/dist/cli/config/load.d.mts.map +1 -1
  26. package/dist/cli/config/load.mjs +40 -0
  27. package/dist/cli/config/load.mjs.map +1 -1
  28. package/dist/cli/config/upgrade-gitignore.d.mts.map +1 -1
  29. package/dist/cli/config/upgrade-gitignore.mjs +6 -0
  30. package/dist/cli/config/upgrade-gitignore.mjs.map +1 -1
  31. package/dist/cli/consistency/parse-review.d.mts.map +1 -1
  32. package/dist/cli/consistency/parse-review.mjs +4 -1
  33. package/dist/cli/consistency/parse-review.mjs.map +1 -1
  34. package/dist/cli/dispatch.d.mts.map +1 -1
  35. package/dist/cli/dispatch.mjs +2 -0
  36. package/dist/cli/dispatch.mjs.map +1 -1
  37. package/dist/cli/gate/gates/review-clean.d.mts.map +1 -1
  38. package/dist/cli/gate/gates/review-clean.mjs +4 -2
  39. package/dist/cli/gate/gates/review-clean.mjs.map +1 -1
  40. package/dist/cli/help.d.mts.map +1 -1
  41. package/dist/cli/help.mjs +1 -0
  42. package/dist/cli/help.mjs.map +1 -1
  43. package/dist/cli/host/detect.d.mts +122 -9
  44. package/dist/cli/host/detect.d.mts.map +1 -1
  45. package/dist/cli/host/detect.mjs +132 -0
  46. package/dist/cli/host/detect.mjs.map +1 -1
  47. package/dist/cli/install/legacy-paths.d.mts +38 -0
  48. package/dist/cli/install/legacy-paths.d.mts.map +1 -0
  49. package/dist/cli/install/legacy-paths.mjs +69 -0
  50. package/dist/cli/install/legacy-paths.mjs.map +1 -0
  51. package/dist/cli/install/runtime-migrate.d.mts +84 -0
  52. package/dist/cli/install/runtime-migrate.d.mts.map +1 -0
  53. package/dist/cli/install/runtime-migrate.mjs +244 -0
  54. package/dist/cli/install/runtime-migrate.mjs.map +1 -0
  55. package/dist/cli/install/update-chips.d.mts +23 -0
  56. package/dist/cli/install/update-chips.d.mts.map +1 -1
  57. package/dist/cli/install/update-chips.mjs +60 -0
  58. package/dist/cli/install/update-chips.mjs.map +1 -1
  59. package/dist/cli/release-notes/compile.d.mts +38 -0
  60. package/dist/cli/release-notes/compile.d.mts.map +1 -0
  61. package/dist/cli/release-notes/compile.mjs +244 -0
  62. package/dist/cli/release-notes/compile.mjs.map +1 -0
  63. package/dist/cli/release-notes/draft.d.mts +73 -0
  64. package/dist/cli/release-notes/draft.d.mts.map +1 -0
  65. package/dist/cli/release-notes/draft.mjs +120 -0
  66. package/dist/cli/release-notes/draft.mjs.map +1 -0
  67. package/dist/cli/release-notes/output-dir.d.mts +20 -0
  68. package/dist/cli/release-notes/output-dir.d.mts.map +1 -0
  69. package/dist/cli/release-notes/output-dir.mjs +42 -0
  70. package/dist/cli/release-notes/output-dir.mjs.map +1 -0
  71. package/dist/cli/release-notes/persona-filter.d.mts +51 -0
  72. package/dist/cli/release-notes/persona-filter.d.mts.map +1 -0
  73. package/dist/cli/release-notes/persona-filter.mjs +127 -0
  74. package/dist/cli/release-notes/persona-filter.mjs.map +1 -0
  75. package/dist/cli/release-notes/publish.d.mts +23 -0
  76. package/dist/cli/release-notes/publish.d.mts.map +1 -0
  77. package/dist/cli/release-notes/publish.mjs +125 -0
  78. package/dist/cli/release-notes/publish.mjs.map +1 -0
  79. package/dist/cli/release-notes/ship-autodraft.d.mts +37 -0
  80. package/dist/cli/release-notes/ship-autodraft.d.mts.map +1 -0
  81. package/dist/cli/release-notes/ship-autodraft.mjs +97 -0
  82. package/dist/cli/release-notes/ship-autodraft.mjs.map +1 -0
  83. package/dist/cli/route/drift-detect.d.mts +20 -0
  84. package/dist/cli/route/drift-detect.d.mts.map +1 -0
  85. package/dist/cli/route/drift-detect.mjs +107 -0
  86. package/dist/cli/route/drift-detect.mjs.map +1 -0
  87. package/dist/cli/util/aperant-section.d.mts +34 -0
  88. package/dist/cli/util/aperant-section.d.mts.map +1 -0
  89. package/dist/cli/util/aperant-section.mjs +127 -0
  90. package/dist/cli/util/aperant-section.mjs.map +1 -0
  91. package/dist/cli/util/copy.d.mts +28 -1
  92. package/dist/cli/util/copy.d.mts.map +1 -1
  93. package/dist/cli/util/copy.mjs +43 -55
  94. package/dist/cli/util/copy.mjs.map +1 -1
  95. package/dist/cli/util/semver.d.mts +17 -0
  96. package/dist/cli/util/semver.d.mts.map +1 -0
  97. package/dist/cli/util/semver.mjs +29 -0
  98. package/dist/cli/util/semver.mjs.map +1 -0
  99. package/dist/cli/util/skill-installs.d.mts +65 -9
  100. package/dist/cli/util/skill-installs.d.mts.map +1 -1
  101. package/dist/cli/util/skill-installs.mjs +130 -21
  102. package/dist/cli/util/skill-installs.mjs.map +1 -1
  103. package/dist/cli/util/version-preflight.d.mts +44 -0
  104. package/dist/cli/util/version-preflight.d.mts.map +1 -0
  105. package/dist/cli/util/version-preflight.mjs +66 -0
  106. package/dist/cli/util/version-preflight.mjs.map +1 -0
  107. package/dist/types/config.d.ts +11 -7
  108. package/dist/types/config.d.ts.map +1 -1
  109. package/package.json +133 -133
  110. package/skills/apt-close-task/SKILL.md +30 -0
  111. package/skills/apt-diagram/SKILL.md +45 -9
  112. package/skills/apt-release-notes/SKILL.md +193 -0
  113. package/skills/apt-release-notes/appendices/persona-voice.md +59 -0
  114. package/skills/apt-setup/SKILL.md +146 -3
  115. package/skills/apt-ship/SKILL.md +63 -4
  116. package/skills/apt-spar/SKILL.md +36 -11
  117. package/skills/apt-update/SKILL.md +77 -10
  118. package/skills/apt-watch-ci/SKILL.md +6 -3
  119. package/src/cli/commands/check-version.mjs +73 -1
  120. package/src/cli/commands/ci-watch.mjs +74 -5
  121. package/src/cli/commands/health-check.mjs +1 -1
  122. package/src/cli/commands/init.mjs +202 -10
  123. package/src/cli/commands/release-notes.mjs +187 -0
  124. package/src/cli/commands/route.mjs +38 -2
  125. package/src/cli/commands/task.mjs +49 -0
  126. package/src/cli/config/load.mjs +37 -0
  127. package/src/cli/config/upgrade-gitignore.mjs +6 -0
  128. package/src/cli/consistency/parse-review.mjs +3 -1
  129. package/src/cli/dispatch.mjs +2 -0
  130. package/src/cli/gate/gates/review-clean.mjs +4 -2
  131. package/src/cli/help.mjs +1 -0
  132. package/src/cli/host/detect.mjs +135 -0
  133. package/src/cli/install/legacy-paths.mjs +69 -0
  134. package/src/cli/install/runtime-migrate.mjs +252 -0
  135. package/src/cli/install/update-chips.mjs +57 -0
  136. package/src/cli/release-notes/compile.mjs +261 -0
  137. package/src/cli/release-notes/draft.mjs +133 -0
  138. package/src/cli/release-notes/output-dir.mjs +52 -0
  139. package/src/cli/release-notes/persona-filter.mjs +126 -0
  140. package/src/cli/release-notes/publish.mjs +128 -0
  141. package/src/cli/release-notes/ship-autodraft.mjs +106 -0
  142. package/src/cli/route/drift-detect.mjs +107 -0
  143. package/src/cli/util/aperant-section.mjs +136 -0
  144. package/src/cli/util/copy.mjs +43 -56
  145. package/src/cli/util/semver.mjs +28 -0
  146. package/src/cli/util/skill-installs.mjs +134 -21
  147. package/src/cli/util/version-preflight.mjs +65 -0
  148. package/templates/aperant-claude-md-appendix.md +37 -0
  149. package/templates/config.json +28 -3
  150. package/workflows/docs.md +12 -0
@@ -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
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * release-notes/publish.mjs — `gh release create/edit` shell.
3
+ *
4
+ * Reads `<output_dir>/<tag>.md` and shells `gh release create <tag>
5
+ * --notes-file <path>` (or `gh release edit` if a release already exists
6
+ * for that tag). When `gh` is unavailable on the host, returns a graceful
7
+ * `{status: 'skipped', reason: 'gh-not-found'}` envelope rather than
8
+ * crashing — AC4.
9
+ *
10
+ * Tests inject a custom shell via the GH_BIN env var (mocked to a no-op
11
+ * shell script) so the publish branch of TD-5 can run without making a
12
+ * real network call.
13
+ */
14
+
15
+ import { execFileSync, spawnSync } from 'node:child_process'
16
+ import { existsSync } from 'node:fs'
17
+ import { join } from 'node:path'
18
+
19
+ import { resolveOutputDir, validateTag } from './output-dir.mjs'
20
+
21
+ /**
22
+ * @typedef {Object} PublishInput
23
+ * @property {string} projectDir
24
+ * @property {string} tag
25
+ * @property {string | null} outputDir
26
+ * @property {boolean} dryRun
27
+ */
28
+
29
+ /**
30
+ * @typedef {{
31
+ * status: 'ok',
32
+ * mode: 'created' | 'edited' | 'dry-run',
33
+ * notesPath: string,
34
+ * } | {
35
+ * status: 'skipped',
36
+ * reason: 'gh-not-found' | 'notes-not-found' | 'gh-failed',
37
+ * notesPath?: string,
38
+ * exit_code?: number | null,
39
+ * stderr?: string | null,
40
+ * }} PublishResult
41
+ */
42
+
43
+ /**
44
+ * Resolve which `gh` binary to use. Defaults to `gh` on PATH; tests
45
+ * override via the GH_BIN env var (only honored in NODE_ENV=test).
46
+ *
47
+ * @returns {string}
48
+ */
49
+ function ghBin() {
50
+ if (process.env.NODE_ENV === 'test') {
51
+ const override = process.env.GH_BIN
52
+ if (override && override.length > 0) return override
53
+ }
54
+ return 'gh'
55
+ }
56
+
57
+ /**
58
+ * Detect whether `gh` is available on the host. Uses `which` semantics:
59
+ * `gh --version` exit-0 → available. Any error path → unavailable.
60
+ *
61
+ * @returns {boolean}
62
+ */
63
+ function ghAvailable() {
64
+ try {
65
+ const result = spawnSync(ghBin(), ['--version'], { stdio: ['ignore', 'ignore', 'ignore'] })
66
+ return result.status === 0
67
+ } catch {
68
+ return false
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Check whether a release already exists for the given tag.
74
+ *
75
+ * @param {string} projectDir
76
+ * @param {string} tag
77
+ * @returns {boolean}
78
+ */
79
+ function releaseExists(projectDir, tag) {
80
+ try {
81
+ const result = spawnSync(ghBin(), ['release', 'view', tag, '--json', 'tagName'], {
82
+ cwd: projectDir,
83
+ stdio: ['ignore', 'pipe', 'ignore'],
84
+ })
85
+ return result.status === 0
86
+ } catch {
87
+ return false
88
+ }
89
+ }
90
+
91
+ /**
92
+ * @param {PublishInput} input
93
+ * @returns {PublishResult}
94
+ */
95
+ export function publishTag(input) {
96
+ validateTag(input.tag)
97
+ const outputDir = resolveOutputDir(input.projectDir, input.outputDir)
98
+ const notesPath = join(outputDir, `${input.tag}.md`)
99
+ if (!existsSync(notesPath)) {
100
+ return { status: 'skipped', reason: 'notes-not-found', notesPath }
101
+ }
102
+ if (input.dryRun) {
103
+ return { status: 'ok', mode: 'dry-run', notesPath }
104
+ }
105
+ if (!ghAvailable()) {
106
+ return { status: 'skipped', reason: 'gh-not-found', notesPath }
107
+ }
108
+ const exists = releaseExists(input.projectDir, input.tag)
109
+ const verb = exists ? 'edit' : 'create'
110
+ const args = ['release', verb, input.tag, '--notes-file', notesPath]
111
+ // `gh release edit` only takes --notes-file; `gh release create` takes
112
+ // --notes-file too and a positional tag — same argv order works.
113
+ try {
114
+ execFileSync(ghBin(), args, {
115
+ cwd: input.projectDir,
116
+ stdio: ['ignore', 'pipe', 'pipe'],
117
+ })
118
+ } catch (err) {
119
+ return {
120
+ status: 'skipped',
121
+ reason: 'gh-failed',
122
+ notesPath,
123
+ exit_code: err.status ?? null,
124
+ stderr: err.stderr?.toString() ?? null,
125
+ }
126
+ }
127
+ return { status: 'ok', mode: exists ? 'edited' : 'created', notesPath }
128
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * release-notes/ship-autodraft.mjs — apt-ship Section 2.6 helper.
3
+ *
4
+ * Deterministic, unit-testable shape: given a feature-registry diff +
5
+ * personas envelope + persona_filter mode, decide whether the PR body
6
+ * should grow a `Release note: …` line OR ship should block.
7
+ *
8
+ * This module is intentionally pure — the apt-ship SKILL.md body shells
9
+ * `apt-tools features-audit . --diff-since <base> --json` and feeds the
10
+ * envelope here. Keeping the decision pure makes TD-4 trivial: fixture in,
11
+ * decision out, no I/O.
12
+ */
13
+
14
+ import { buildPersonaTierMap, classifyFragment } from './persona-filter.mjs'
15
+
16
+ /**
17
+ * @typedef {Object} FeatureDiffEntry
18
+ * @property {string} feature_id
19
+ * @property {string} [area]
20
+ * @property {string[]} [personas]
21
+ */
22
+
23
+ /**
24
+ * @typedef {Object} AutodraftInput
25
+ * @property {FeatureDiffEntry[]} featureDiff
26
+ * @property {{ personas?: Array<{ ui_json?: unknown }> } | null} personasEnvelope
27
+ * @property {'primary' | 'primary+secondary' | 'all'} personaFilter
28
+ * @property {boolean} requirePrField
29
+ */
30
+
31
+ /**
32
+ * @typedef {{
33
+ * status: 'draft',
34
+ * line: string,
35
+ * persona: string,
36
+ * matchedFeatures: string[],
37
+ * } | {
38
+ * status: 'block',
39
+ * reason: 'no-feature-match' | 'no-persona-signal',
40
+ * message: string,
41
+ * } | {
42
+ * status: 'warn',
43
+ * reason: 'no-feature-match' | 'no-persona-signal',
44
+ * message: string,
45
+ * }} AutodraftResult
46
+ */
47
+
48
+ const BLOCK_MESSAGE =
49
+ '[APT] No release-note signal found — author manual note in PR body or label PR no-notes'
50
+
51
+ /**
52
+ * Compute the autodraft decision.
53
+ *
54
+ * @param {AutodraftInput} input
55
+ * @returns {AutodraftResult}
56
+ */
57
+ export function decideAutodraft(input) {
58
+ const tierMap = buildPersonaTierMap(input.personasEnvelope)
59
+
60
+ if (!Array.isArray(input.featureDiff) || input.featureDiff.length === 0) {
61
+ return failClassify(input, 'no-feature-match')
62
+ }
63
+
64
+ // Walk the diff; the first feature whose persona resolves into the
65
+ // filter mode wins. (Multi-persona dispatch is intentionally out of
66
+ // scope here — host LLM-side narrative can refine per persona; the
67
+ // CLI just picks a clean signal so ship is decisive.)
68
+ for (const feature of input.featureDiff) {
69
+ if (!Array.isArray(feature.personas) || feature.personas.length === 0) continue
70
+ for (const personaTag of feature.personas) {
71
+ const decision = classifyFragment(personaTag, tierMap, input.personaFilter)
72
+ if (decision.bucket === 'body' && decision.resolvedTier) {
73
+ return {
74
+ status: 'draft',
75
+ line: `Release note: ${feature.feature_id} — ships for ${personaTag}.`,
76
+ persona: personaTag,
77
+ matchedFeatures: [feature.feature_id],
78
+ }
79
+ }
80
+ }
81
+ }
82
+
83
+ // No persona signal anywhere — features exist but none carry a tag
84
+ // that the roster + filter accept.
85
+ return failClassify(input, 'no-persona-signal')
86
+ }
87
+
88
+ /**
89
+ * Translate a failure reason into the right envelope shape based on
90
+ * `require_pr_field`. require=true → block; require=false → warn (skill
91
+ * proceeds without the line).
92
+ *
93
+ * @param {AutodraftInput} input
94
+ * @param {'no-feature-match' | 'no-persona-signal'} reason
95
+ * @returns {AutodraftResult}
96
+ */
97
+ function failClassify(input, reason) {
98
+ if (input.requirePrField) {
99
+ return { status: 'block', reason, message: BLOCK_MESSAGE }
100
+ }
101
+ return {
102
+ status: 'warn',
103
+ reason,
104
+ message: `[APT] auto-draft skipped (${reason}) — release_notes.require_pr_field is false; proceeding without a Release note line.`,
105
+ }
106
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * route/drift-detect.mjs — FRAMEWORK-BUG-017 (rung 1 of 3).
3
+ *
4
+ * In-band detection of the "installed kernel drifts behind source
5
+ * workspace" failure mode. When an Aperant contributor self-hosts the
6
+ * framework out of a monorepo checkout, the kernel at
7
+ * `.aperant/deps/node_modules/@aperant/framework/` lags behind the
8
+ * source at `packages/framework/` after every same-PR framework fix.
9
+ * The SessionStart worker only knows about npm — it cannot tell that
10
+ * the workspace is ahead of the install.
11
+ *
12
+ * This module walks up from `targetDir`, looking for a sibling
13
+ * `packages/framework/package.json`. When found AND its `version`
14
+ * differs from the installed kernel's `version`, it returns a synthetic
15
+ * `update_check`-shaped object so `route.mjs` can surface the chip
16
+ * immediately — without waiting for the SessionStart hook to fire.
17
+ *
18
+ * Safe for the route hot path: synchronous, file-existence checks
19
+ * only, bounded walk (6 segments max), no network, no spawn.
20
+ *
21
+ * Security (Codex review edit 2): the workspace `package.json:version`
22
+ * is validated against the same semver-shape regex `update-chips.mjs`
23
+ * uses on the cache. A poisoned workspace `package.json` cannot
24
+ * propagate a non-semver `latest_version` into the synthesized
25
+ * `update_check` block.
26
+ */
27
+
28
+ import { existsSync, readFileSync } from 'node:fs'
29
+ import { dirname, join } from 'node:path'
30
+ import { compareVersions, SEMVER_RE } from '../util/semver.mjs'
31
+
32
+ /** Max directory frames to walk before giving up. */
33
+ const MAX_WALK_DEPTH = 6
34
+
35
+ /**
36
+ * Walk up from `start`, returning every directory frame including
37
+ * `start` itself. Stops at the filesystem root or at a depth of
38
+ * `MAX_WALK_DEPTH` (whichever comes first). The hard cap keeps this
39
+ * cheap on the route hot path and contains accidental scans on deeply
40
+ * nested input paths.
41
+ *
42
+ * @param {string} start
43
+ * @returns {string[]}
44
+ */
45
+ function walkUp(start) {
46
+ const frames = []
47
+ let cur = start
48
+ for (let i = 0; i < MAX_WALK_DEPTH; i++) {
49
+ frames.push(cur)
50
+ const parent = dirname(cur)
51
+ if (parent === cur) break
52
+ cur = parent
53
+ }
54
+ return frames
55
+ }
56
+
57
+ /**
58
+ * Detect whether the workspace source for `@aperant/framework` is ahead
59
+ * of the installed kernel.
60
+ *
61
+ * @param {string} targetDir Absolute path the route was invoked against.
62
+ * @param {string|null} installedVersion The installed kernel's version (semver string), or null when unknown.
63
+ * @returns {null | {
64
+ * source_path: string,
65
+ * source_version: string,
66
+ * installed_version: string,
67
+ * drift: true,
68
+ * }}
69
+ */
70
+ export function detectSourceWorkspaceDrift(targetDir, installedVersion) {
71
+ if (!targetDir || typeof targetDir !== 'string') return null
72
+ if (!installedVersion || typeof installedVersion !== 'string') return null
73
+ if (!SEMVER_RE.test(installedVersion)) return null
74
+
75
+ for (const dir of walkUp(targetDir)) {
76
+ const candidate = join(dir, 'packages', 'framework', 'package.json')
77
+ if (!existsSync(candidate)) continue
78
+
79
+ let pkg
80
+ try {
81
+ pkg = JSON.parse(readFileSync(candidate, 'utf-8'))
82
+ } catch {
83
+ return null
84
+ }
85
+
86
+ // Validate the workspace package is actually @aperant/framework —
87
+ // guard against false matches on unrelated monorepos that happen
88
+ // to have a `packages/framework/` directory.
89
+ if (pkg?.name !== '@aperant/framework') continue
90
+
91
+ const sourceVersion = pkg?.version
92
+ if (typeof sourceVersion !== 'string' || !SEMVER_RE.test(sourceVersion)) return null
93
+ // Only report drift when workspace source is strictly ahead of the installed
94
+ // kernel. Equal (already up-to-date) and behind (installed is newer) both
95
+ // return null — only source-ahead is a drift worth surfacing.
96
+ if (compareVersions(sourceVersion, installedVersion) <= 0) return null
97
+
98
+ return {
99
+ source_path: candidate,
100
+ source_version: sourceVersion,
101
+ installed_version: installedVersion,
102
+ drift: true,
103
+ }
104
+ }
105
+
106
+ return null
107
+ }