@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,187 @@
1
+ /**
2
+ * commands/release-notes.mjs — `apt-tools release-notes <sub>` (subtask 2-3
3
+ * of persona-aware-changelog-and-release-note_16-05-26).
4
+ *
5
+ * Deterministic I/O surface for the apt:release-notes skill. NEVER calls
6
+ * an LLM. The SKILL.md body invokes the host LLM to author the note text
7
+ * and hands the structured payload to this CLI for persistence + compile.
8
+ *
9
+ * Sub-verb menu:
10
+ *
11
+ * apt-tools release-notes draft <project-dir> --pr <n> --note "<text>"
12
+ * [--persona <id>] [--output-dir <path>] [--commits <sha,sha,...>]
13
+ * [--date <YYYY-MM-DD>]
14
+ * → write `<output_dir>/_unreleased/PR-<n>.md`. Idempotent.
15
+ *
16
+ * apt-tools release-notes compile <project-dir> --tag <tag>
17
+ * [--output-dir <path>]
18
+ * → read every `_unreleased/PR-*.md`, filter by persona_filter, write
19
+ * `<output_dir>/<tag>.md`, clear `_unreleased/` only on success.
20
+ *
21
+ * apt-tools release-notes publish <project-dir> --tag <tag>
22
+ * [--output-dir <path>] [--dry-run]
23
+ * → read `<output_dir>/<tag>.md` + shell `gh release create/edit`.
24
+ *
25
+ * Return shape: always `{ status, command, ... }`. The wrap() helper in
26
+ * dispatch.mjs translates `{ status: 'error' }` envelopes into exit-1.
27
+ */
28
+
29
+ import { resolve } from 'node:path'
30
+
31
+ import { compileTag } from '../release-notes/compile.mjs'
32
+ import { writeDraft } from '../release-notes/draft.mjs'
33
+ import { publishTag } from '../release-notes/publish.mjs'
34
+ import { getFlagValue } from '../util/common-flags.mjs'
35
+ import { err, ok } from '../util/result.mjs'
36
+
37
+ /**
38
+ * Dispatch shape (mirrors `audit`): `release-notes: (args) =>
39
+ * wrap(cmdReleaseNotes)(args[1], args[2], args.slice(3))` — args[1] is the
40
+ * sub-verb, args[2] is the project dir.
41
+ *
42
+ * @param {string | undefined} subcommand
43
+ * @param {string | undefined} projectDirArg
44
+ * @param {string[] | undefined} extraArgs
45
+ */
46
+ export async function cmdReleaseNotes(subcommand, projectDirArg, extraArgs) {
47
+ const args = extraArgs ?? []
48
+ if (!subcommand) {
49
+ return err(
50
+ 'Usage: apt-tools release-notes <draft|compile|publish> <project-dir> [flags]\n' +
51
+ ' draft write docs/releases/_unreleased/PR-<n>.md\n' +
52
+ ' compile read fragments → docs/releases/<tag>.md, clear _unreleased/\n' +
53
+ ' publish shell gh release create/edit',
54
+ )
55
+ }
56
+ const projectDir = resolve(projectDirArg && projectDirArg.length > 0 ? projectDirArg : '.')
57
+
58
+ switch (subcommand) {
59
+ case 'draft':
60
+ return runDraft(projectDir, args)
61
+ case 'compile':
62
+ return runCompile(projectDir, args)
63
+ case 'publish':
64
+ return runPublish(projectDir, args)
65
+ default:
66
+ return err(
67
+ `apt-tools release-notes: unknown subcommand "${subcommand}". Known: draft, compile, publish.`,
68
+ )
69
+ }
70
+ }
71
+
72
+ /**
73
+ * draft — write a single PR fragment.
74
+ *
75
+ * @param {string} projectDir
76
+ * @param {string[]} args
77
+ */
78
+ function runDraft(projectDir, args) {
79
+ const prRaw = getFlagValue(args, '--pr')
80
+ const note = getFlagValue(args, '--note')
81
+ const persona = getFlagValue(args, '--persona')
82
+ const outputDirFlag = getFlagValue(args, '--output-dir')
83
+ const commitsFlag = getFlagValue(args, '--commits')
84
+ const dateFlag = getFlagValue(args, '--date')
85
+
86
+ if (!prRaw) {
87
+ return err('release-notes draft: --pr <number> is required')
88
+ }
89
+ const pr = Number.parseInt(prRaw, 10)
90
+ if (!Number.isFinite(pr) || pr <= 0) {
91
+ return err(`release-notes draft: --pr must be a positive integer (got "${prRaw}")`)
92
+ }
93
+ if (!note || note.trim().length === 0) {
94
+ return err('release-notes draft: --note "<text>" is required (and must be non-empty)')
95
+ }
96
+
97
+ const commits = commitsFlag
98
+ ? commitsFlag
99
+ .split(',')
100
+ .map((s) => s.trim())
101
+ .filter((s) => s.length > 0)
102
+ : null
103
+
104
+ const result = writeDraft({
105
+ projectDir,
106
+ pr,
107
+ note,
108
+ persona,
109
+ outputDir: outputDirFlag,
110
+ commits,
111
+ date: dateFlag,
112
+ })
113
+
114
+ return ok({
115
+ status: 'ok',
116
+ command: 'release-notes-draft',
117
+ projectDir,
118
+ pr,
119
+ written: result.written,
120
+ reason: result.reason,
121
+ path: result.path,
122
+ })
123
+ }
124
+
125
+ /**
126
+ * compile — produce the per-tag narrative file from accumulated fragments.
127
+ *
128
+ * @param {string} projectDir
129
+ * @param {string[]} args
130
+ */
131
+ function runCompile(projectDir, args) {
132
+ const tag = getFlagValue(args, '--tag')
133
+ if (!tag) {
134
+ return err('release-notes compile: --tag <tag> is required')
135
+ }
136
+ const outputDirFlag = getFlagValue(args, '--output-dir')
137
+ const result = compileTag({ projectDir, tag, outputDir: outputDirFlag })
138
+ return ok({
139
+ status: 'ok',
140
+ command: 'release-notes-compile',
141
+ projectDir,
142
+ tag,
143
+ written: result.written,
144
+ path: result.path,
145
+ fragments_total: result.fragmentsTotal,
146
+ fragments_in_body: result.fragmentsInBody,
147
+ fragments_in_triage: result.fragmentsInTriage,
148
+ fragments_filtered: result.fragmentsFiltered,
149
+ cleared_unreleased: result.clearedUnreleased,
150
+ })
151
+ }
152
+
153
+ /**
154
+ * publish — shell `gh release create/edit`. Returns a graceful
155
+ * `{status: 'skipped', reason: 'gh-not-found'}` when gh is unavailable.
156
+ *
157
+ * @param {string} projectDir
158
+ * @param {string[]} args
159
+ */
160
+ function runPublish(projectDir, args) {
161
+ const tag = getFlagValue(args, '--tag')
162
+ if (!tag) {
163
+ return err('release-notes publish: --tag <tag> is required')
164
+ }
165
+ const outputDirFlag = getFlagValue(args, '--output-dir')
166
+ const dryRun = args.includes('--dry-run')
167
+ const result = publishTag({ projectDir, tag, outputDir: outputDirFlag, dryRun })
168
+ if (result.status === 'skipped') {
169
+ return ok({
170
+ status: 'skipped',
171
+ command: 'release-notes-publish',
172
+ projectDir,
173
+ tag,
174
+ reason: result.reason,
175
+ notes_path: result.notesPath ?? null,
176
+ })
177
+ }
178
+ return ok({
179
+ status: 'ok',
180
+ command: 'release-notes-publish',
181
+ projectDir,
182
+ tag,
183
+ mode: result.mode,
184
+ dry_run: dryRun,
185
+ notes_path: result.notesPath,
186
+ })
187
+ }
@@ -26,16 +26,36 @@
26
26
 
27
27
  import { existsSync, readFileSync } from 'node:fs'
28
28
  import { homedir } from 'node:os'
29
- import { join, resolve } from 'node:path'
29
+ import { dirname, join, resolve } from 'node:path'
30
+ import { fileURLToPath } from 'node:url'
30
31
  import { getTaskIsolationConfig, getTrustedSkillSources } from '../config/load.mjs'
31
32
  import '../gate/gates/index.mjs'
32
33
  import * as gateRegistry from '../gate/registry.mjs'
33
34
  import { detectHost, SUPPORTED_HOSTS } from '../host/detect.mjs'
35
+ import { detectSourceWorkspaceDrift } from '../route/drift-detect.mjs'
34
36
  import { buildEnvelope } from '../route/envelope.mjs'
35
37
  import { discoverSkills } from '../route/skill-discover.mjs'
36
38
  import { executionModesList, isValidExecutionMode } from '../task/execution-mode.mjs'
37
39
  import { err, exitWith, ok } from '../util/result.mjs'
38
40
 
41
+ /**
42
+ * Read the installed kernel's own version from its package.json.
43
+ * Same pattern as commands/check-version.mjs:108-109 — `__dirname` of
44
+ * the running module resolves to the installed kernel for npm-installed
45
+ * users and to the source workspace for in-monorepo runs. Returns null
46
+ * on any I/O / parse failure (drift-detect treats null as "skip").
47
+ */
48
+ function readInstalledKernelVersion() {
49
+ try {
50
+ const here = dirname(fileURLToPath(import.meta.url))
51
+ const pkgPath = resolve(here, '..', '..', '..', 'package.json')
52
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
53
+ return typeof pkg?.version === 'string' ? pkg.version : null
54
+ } catch {
55
+ return null
56
+ }
57
+ }
58
+
39
59
  /**
40
60
  * Read the shared update-check cache (written by the SessionStart hook
41
61
  * via apt-check-update-worker.js) and extract the fields /apt's SKILL.md
@@ -254,7 +274,23 @@ export function cmdRoute(projectDir, extraArgs) {
254
274
  const task_isolation = getTaskIsolationConfig(targetDir)
255
275
 
256
276
  const updateCheck = readUpdateCheckCache()
257
- const update_check = updateCheck ? { ...updateCheck } : null
277
+ let update_check = updateCheck ? { ...updateCheck } : null
278
+
279
+ // FRAMEWORK-BUG-017 rung 1: in-band detection of workspace-ahead-of-
280
+ // installed-kernel drift. Catches the self-host contributor case
281
+ // where the SessionStart worker can't see the local same-PR fix yet.
282
+ // Workspace wins when both signals fire (workspace is the source of
283
+ // truth for self-host).
284
+ const installedKernelVersion = readInstalledKernelVersion()
285
+ const drift = detectSourceWorkspaceDrift(targetDir, installedKernelVersion)
286
+ if (drift) {
287
+ update_check = {
288
+ update_available: true,
289
+ installed_version: drift.installed_version,
290
+ latest_version: drift.source_version,
291
+ reason: 'workspace source ahead of installed kernel — run /apt:update --from-source',
292
+ }
293
+ }
258
294
 
259
295
  // ---------------- Deterministic branches (zero-behavior-change) ----------------
260
296
  // These fire regardless of host detection status — missing host is not
@@ -1006,6 +1006,12 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
1006
1006
  updateOut.ci_watch_armed = ciWatchResult.ci_watch_armed
1007
1007
  updateOut.ci_watch_skip_reason = ciWatchResult.ci_watch_skip_reason
1008
1008
  if (ciWatchResult.ci_watch) updateOut.ci_watch = ciWatchResult.ci_watch
1009
+ // FRAMEWORK-BUG-018 Track 1: surface `scheduler_handle` at the top
1010
+ // level so `apt-ship/SKILL.md` §7.2 can read it via
1011
+ // `.scheduler_handle // empty` without dotting through `.ci_watch.*`.
1012
+ // `null` today (no production path populates it); Track 2 lands
1013
+ // non-null values without re-plumbing the envelope shape.
1014
+ updateOut.scheduler_handle = ciWatchResult.scheduler_handle ?? null
1009
1015
  }
1010
1016
  updateResult = ok(updateOut)
1011
1017
  },
@@ -1217,6 +1223,46 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
1217
1223
  // `gh pr view`, and close the ones GitHub says merged. Per spec §B1.
1218
1224
  const onlyTaskId = flags.get('task') || null
1219
1225
  const dryRun = flags.has('dry-run')
1226
+ const narrateOnly = flags.has('narrate-only')
1227
+
1228
+ // FRAMEWORK-BUG-020 — `--narrate-only` is read-only enumeration of
1229
+ // the pending_narration[] ledger. No `gh pr view`, no `task close`
1230
+ // spawn, no ledger writes. The skill body (apt-close-task §4) drains
1231
+ // per row via `task narration-drain` AFTER a successful narrator
1232
+ // spawn — at-least-once semantics is safe because the narrator is
1233
+ // anchor-replace idempotent (agents/apt-team-docs-narrator.md §6/§2).
1234
+ if (narrateOnly) {
1235
+ if (!existsSync(statePath)) {
1236
+ return ok({
1237
+ status: 'ok',
1238
+ command: 'task-close-merged',
1239
+ mode: 'narrate-only',
1240
+ closed: [],
1241
+ skipped: [],
1242
+ errors: [],
1243
+ })
1244
+ }
1245
+ const stateBlob = JSON.parse(readFileSync(statePath, 'utf-8'))
1246
+ const ledger = Array.isArray(stateBlob.pending_narration) ? stateBlob.pending_narration : []
1247
+ const filtered = onlyTaskId
1248
+ ? ledger.filter((row) => row && row.task_id === onlyTaskId)
1249
+ : ledger
1250
+ return ok({
1251
+ status: 'ok',
1252
+ command: 'task-close-merged',
1253
+ mode: 'narrate-only',
1254
+ closed: filtered.map((row) => ({
1255
+ task_id: row.task_id,
1256
+ scope: row.scope ?? null,
1257
+ phase_id: row.phase_id ?? null,
1258
+ closed_at: row.closed_at ?? null,
1259
+ pr_number: row.pr_number ?? null,
1260
+ narration_pending: true,
1261
+ })),
1262
+ skipped: [],
1263
+ errors: [],
1264
+ })
1265
+ }
1220
1266
 
1221
1267
  if (!existsSync(statePath)) {
1222
1268
  return ok({
@@ -1675,6 +1721,7 @@ function armCiWatchAfterPrUrl(targetDir, task, prUrl) {
1675
1721
  ci_watch_armed: true,
1676
1722
  ci_watch_skip_reason: null,
1677
1723
  ci_watch: envelope,
1724
+ scheduler_handle: envelope.scheduler_handle ?? null,
1678
1725
  }
1679
1726
  }
1680
1727
  if (envelope.status === 'busy' && envelope.reason === 'already-armed') {
@@ -1682,12 +1729,14 @@ function armCiWatchAfterPrUrl(targetDir, task, prUrl) {
1682
1729
  ci_watch_armed: true,
1683
1730
  ci_watch_skip_reason: 'already-armed',
1684
1731
  ci_watch: envelope,
1732
+ scheduler_handle: envelope.scheduler_handle ?? null,
1685
1733
  }
1686
1734
  }
1687
1735
  return {
1688
1736
  ci_watch_armed: false,
1689
1737
  ci_watch_skip_reason: envelope.reason || 'start-failed',
1690
1738
  ci_watch: envelope,
1739
+ scheduler_handle: envelope.scheduler_handle ?? null,
1691
1740
  }
1692
1741
  }
1693
1742
 
@@ -20,6 +20,43 @@ export function loadProjectConfig(targetDir) {
20
20
  }
21
21
  }
22
22
 
23
+ /**
24
+ * Load `.aperant/config.json` deep-merged with `.aperant/config.local.json`.
25
+ * Local wins on key collision. Nested objects merge key-by-key; arrays and
26
+ * primitives replace wholesale.
27
+ *
28
+ * Returns null if config.json is missing/malformed (same as loadProjectConfig).
29
+ * Throws Error("config.local.json parse error: <msg>") if config.local.json
30
+ * exists but is malformed — this is a user-visible failure surface; silent
31
+ * fallback would mask the user's broken override. See FRAMEWORK-AUDIT-001.
32
+ *
33
+ * @param {string} targetDir
34
+ * @returns {object | null}
35
+ */
36
+ export function loadMergedProjectConfig(targetDir) {
37
+ const shared = loadProjectConfig(targetDir)
38
+ if (shared === null) return null
39
+ const localPath = join(targetDir, '.aperant', 'config.local.json')
40
+ if (!existsSync(localPath)) return shared
41
+ let local
42
+ try {
43
+ local = JSON.parse(readFileSync(localPath, 'utf-8'))
44
+ } catch (e) {
45
+ throw new Error(`config.local.json parse error: ${e.message}`)
46
+ }
47
+ return deepMerge(shared, local)
48
+ }
49
+
50
+ function deepMerge(a, b) {
51
+ if (a === null || typeof a !== 'object' || Array.isArray(a)) return b
52
+ if (b === null || typeof b !== 'object' || Array.isArray(b)) return b
53
+ const out = { ...a }
54
+ for (const k of Object.keys(b)) {
55
+ out[k] = k in a ? deepMerge(a[k], b[k]) : b[k]
56
+ }
57
+ return out
58
+ }
59
+
23
60
  /**
24
61
  * Return a normalized task_isolation config object with safe fallbacks.
25
62
  * Always returns a concrete object even when the project has no config at all,
@@ -183,6 +183,12 @@ export function buildAperantGitignoreTemplate(policy = DEFAULT_SHARE_POLICY) {
183
183
  lines.push('events/')
184
184
  lines.push('locks/')
185
185
  lines.push('team-status/')
186
+ // config.local.json — per-user override file (FRAMEWORK-AUDIT-001). The
187
+ // deny-by-default `*` rule above already ignores it because no allow line
188
+ // exists for it; this explicit deny is defense in depth so a future
189
+ // maintainer cannot accidentally un-ignore it with a broad `!config*.json`
190
+ // allow rule.
191
+ lines.push('config.local.json')
186
192
  lines.push('')
187
193
  lines.push('# ── Derived markdown indexes (regenerated from JSON) ──────────────')
188
194
  lines.push('tasks/INDEX.md')
@@ -10,7 +10,8 @@
10
10
  * `warning` -> `major`. `suggestion|nitpick|note|info` are dropped.
11
11
  *
12
12
  * Status normalizes to `fixed | deferred | open` (default `open`).
13
- * `resolved: true` is treated as `status: 'fixed'`.
13
+ * Both `fixed: true` and `resolved: true` are treated as `status: 'fixed'`
14
+ * (the reviewer skill writes `fixed: true`; `resolved: true` is legacy).
14
15
  *
15
16
  * Output: {findings: [{id, severity, status, title, file}]}
16
17
  */
@@ -89,6 +90,7 @@ function normalizeSeverity(sev) {
89
90
  }
90
91
 
91
92
  function normalizeStatus(f) {
93
+ if (f.fixed === true) return 'fixed'
92
94
  if (f.resolved === true) return 'fixed'
93
95
  if (typeof f.status === 'string') {
94
96
  const s = f.status.toLowerCase().trim()
@@ -71,6 +71,7 @@ import { cmdPrReview } from './commands/pr-review.mjs'
71
71
  import { cmdPrecompactHook } from './commands/precompact-hook.mjs'
72
72
  import { cmdProductContext } from './commands/product-context.mjs'
73
73
  import { cmdProfile } from './commands/profile.mjs'
74
+ import { cmdReleaseNotes } from './commands/release-notes.mjs'
74
75
  import { cmdRiskCheck } from './commands/risk-check.mjs'
75
76
  import { cmdRoadmap } from './commands/roadmap.mjs'
76
77
  import { cmdRoute } from './commands/route.mjs'
@@ -253,6 +254,7 @@ export const DISPATCH = {
253
254
  'variant-pick': (args) => wrap(cmdVariantPick)(args[1], args[2], args.slice(3)),
254
255
  worktree: (args) => wrap(cmdWorktree)(args[1], args[2], args.slice(3)),
255
256
  audit: (args) => wrap(cmdAudit)(args[1], args[2], args.slice(3)),
257
+ 'release-notes': (args) => wrap(cmdReleaseNotes)(args[1], args[2], args.slice(3)),
256
258
  team: (args) => wrap(cmdTeam)(args[1], args[2], args.slice(3)),
257
259
  active: (args) => wrap(cmdActive)(args[1], args[2], args.slice(3)),
258
260
  roadmap: (args) => wrap(cmdRoadmap)(args[1], args[2], args.slice(3)),
@@ -4,7 +4,9 @@
4
4
  *
5
5
  * Skipped when {task_dir}/review.json is absent (review is optional for some
6
6
  * tracks). When it is present, fails if any finding has
7
- * severity ∈ {"critical", "warning"} AND resolved !== true.
7
+ * severity ∈ {"critical", "warning"} AND neither `resolved === true` nor
8
+ * `fixed === true`. The reviewer skill writes resolution as `fixed: true`
9
+ * paired with `fix_commit`; `resolved: true` is a legacy alias.
8
10
  *
9
11
  * The findings array may be under `findings` or `issues` depending on the
10
12
  * reviewer format — we accept either.
@@ -47,7 +49,7 @@ const gate = {
47
49
  ? doc.issues
48
50
  : []
49
51
  const unresolved = findings.filter(
50
- (f) => BLOCKING_SEVERITIES.has(f?.severity) && f?.resolved !== true,
52
+ (f) => BLOCKING_SEVERITIES.has(f?.severity) && f?.resolved !== true && f?.fixed !== true,
51
53
  )
52
54
  if (unresolved.length > 0) {
53
55
  return {
package/src/cli/help.mjs CHANGED
@@ -31,6 +31,7 @@ Commands:
31
31
  task update <dir> --id <id> [--lifecycle-phase <p>] [--pr-url <url>] [--subtasks-total <N>] [--depends-on <refs>] Update non-linkage task state; --pr-url may arm ci-watch per ci_watch.after_ship
32
32
  task move <dir> --id <id> [--to-scope <s>] [--to-milestone <id>] [--to-phase <id>] [--to-roadmap-ref-url <url>] [--reason <text>] [--force] Reassign scope/milestone/phase with history
33
33
  task close <dir> --id <task-id> [--verdict <v>] Close task and move to completed
34
+ task close-merged <dir> [--task <id>] [--narrate-only] [--dry-run] Sweep shipped-pending-merge tasks for confirmed merges (--narrate-only returns pending_narration[] entries read-only without closing).
34
35
  task narration-drain <dir> --task <task-id> Remove a row from state.pending_narration[] under withFileLock (called by /apt:close-task --narrate-only after a successful narrator spawn)
35
36
  task complete-subtask <dir> --task-id <id> --subtask-id <sid|a+b+c> [--commit <sha>] [--notes <text>] Atomic plan ↔ build-progress sync
36
37
  features-audit <dir> [--diff-files <csv>|--since <ref>] [--apply-stubs] [--format json|md] Audit features registry for unregistered feature-qualifying files
@@ -12,6 +12,9 @@
12
12
  * for misses over silently routing into the wrong runtime.
13
13
  */
14
14
 
15
+ import { existsSync as fsExistsSync } from 'node:fs'
16
+ import { delimiter as PATH_DELIMITER, join as pathJoin } from 'node:path'
17
+
15
18
  /**
16
19
  * Canonical list of runtime identifiers accepted by detectHost's
17
20
  * `overrideCli` parameter AND by the route command's `--runtime <id>` flag.
@@ -211,5 +214,137 @@ export function detectHost(opts = {}) {
211
214
  capabilities,
212
215
  aperant_terminal: isAperantTerminal,
213
216
  aperant_socket: aperantSocket,
217
+ partner_transports: detectPartnerTransports({ env }),
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Partner-transport ladder used by `apt:spar` (FRAMEWORK-BUG-024).
223
+ *
224
+ * For each partner (codex / claude / gemini) we resolve a 3-rung ladder in
225
+ * preference order — MCP first (cleanest integration), then plugin shim
226
+ * (codex only), then raw CLI on $PATH. The first rung with `detected: true`
227
+ * wins; the spar skill's gate fails only when all three rungs report
228
+ * `detected: false`.
229
+ *
230
+ * Detection is best-effort and side-effect-free w/r/t the FS — only
231
+ * `existsSync` reads. No spawn, no network. The MCP rung uses
232
+ * `detected: "runtime-host-only"` when the framework process cannot probe
233
+ * the host's deferred-tool registry (the common case — MCP availability
234
+ * is host-internal), signalling the SKILL body to self-check at invocation
235
+ * time rather than fail the gate.
236
+ *
237
+ * Pure function w/r/t injected `env` + `existsSync` so tests can mock both
238
+ * without monkey-patching `process.env` or `node:fs`.
239
+ *
240
+ * @param {object} [opts]
241
+ * @param {NodeJS.ProcessEnv} [opts.env] — defaults to `process.env`
242
+ * @param {(p: string) => boolean} [opts.existsSync] — defaults to `node:fs` existsSync
243
+ */
244
+ export function detectPartnerTransports(opts = {}) {
245
+ const env = opts.env ?? process.env
246
+ const exists = opts.existsSync ?? fsExistsSync
247
+ const pathEntries = (env.PATH ?? '').split(PATH_DELIMITER).filter(Boolean)
248
+
249
+ return {
250
+ codex: [
251
+ detectMcpRung('codex', env),
252
+ detectPluginShim('codex', env, exists),
253
+ detectRawCli('codex', pathEntries, exists),
254
+ ],
255
+ claude: [
256
+ detectMcpRung('claude', env),
257
+ // No plugin shim exists for Claude today — kept as `detected: false`
258
+ // for ladder-shape symmetry so consumers can index by rung uniformly.
259
+ { rung: 'plugin', detected: false, reason: 'no plugin shim for claude' },
260
+ detectRawCli('claude', pathEntries, exists),
261
+ ],
262
+ gemini: [
263
+ detectMcpRung('gemini', env),
264
+ { rung: 'plugin', detected: false, reason: 'no plugin shim for gemini' },
265
+ detectRawCli('gemini', pathEntries, exists),
266
+ ],
267
+ }
268
+ }
269
+
270
+ /**
271
+ * MCP rung — detected if the host exposes its deferred-tool manifest via
272
+ * `CLAUDE_MCP_DEFERRED_TOOLS` (a JSON array of tool names) AND a matching
273
+ * `mcp__<partner>-mcp__*` entry is present. When the env var is absent the
274
+ * framework cannot observe the host's tool registry from outside, so we
275
+ * return `detected: "runtime-host-only"` — the SKILL body interprets this
276
+ * as "try it; if the host has the tool, use it; otherwise fall through".
277
+ */
278
+ function detectMcpRung(partner, env) {
279
+ const toolPrefix = mcpToolPrefix(partner)
280
+ const manifest = env.CLAUDE_MCP_DEFERRED_TOOLS
281
+ if (manifest) {
282
+ try {
283
+ const tools = JSON.parse(manifest)
284
+ if (
285
+ Array.isArray(tools) &&
286
+ tools.some((t) => typeof t === 'string' && t.startsWith(toolPrefix))
287
+ ) {
288
+ return { rung: 'mcp', detected: true, invocation: `${toolPrefix}*` }
289
+ }
290
+ return {
291
+ rung: 'mcp',
292
+ detected: false,
293
+ reason: `no ${toolPrefix}* tool in CLAUDE_MCP_DEFERRED_TOOLS`,
294
+ }
295
+ } catch {
296
+ // Malformed manifest — fall through to runtime-deferred
297
+ }
298
+ }
299
+ return { rung: 'mcp', detected: 'runtime-host-only', invocation: `${toolPrefix}*` }
300
+ }
301
+
302
+ function mcpToolPrefix(partner) {
303
+ if (partner === 'codex') return 'mcp__codex-mcp__'
304
+ if (partner === 'claude') return 'mcp__claude-code-mcp__'
305
+ if (partner === 'gemini') return 'mcp__gemini-mcp__'
306
+ return `mcp__${partner}-mcp__`
307
+ }
308
+
309
+ /**
310
+ * Plugin-shim rung — Claude Code plugins drop helper scripts under
311
+ * `${CLAUDE_PLUGIN_ROOT}/scripts/<partner>-companion.mjs`. Only codex
312
+ * ships a companion today; the caller passes `'codex'` to probe.
313
+ */
314
+ function detectPluginShim(partner, env, exists) {
315
+ const root = env.CLAUDE_PLUGIN_ROOT
316
+ if (!root) {
317
+ return { rung: 'plugin', detected: false, reason: 'CLAUDE_PLUGIN_ROOT unset' }
318
+ }
319
+ const script = pathJoin(root, 'scripts', `${partner}-companion.mjs`)
320
+ if (!exists(script)) {
321
+ return { rung: 'plugin', detected: false, reason: `${script} not found` }
214
322
  }
323
+ return { rung: 'plugin', detected: true, invocation: `node "${script}" task` }
324
+ }
325
+
326
+ /**
327
+ * Raw-CLI rung — probe pre-split $PATH entries for the partner binary using
328
+ * existsSync (no spawn). Accepts a pre-split array so detectPartnerTransports
329
+ * can parse $PATH once and share it across all partner probes.
330
+ *
331
+ * The binary name matches the partner id for all three currently-supported
332
+ * partners (codex / claude / gemini).
333
+ */
334
+ function detectRawCli(partner, pathEntries, exists) {
335
+ const bin = partner
336
+ for (const dir of pathEntries) {
337
+ const candidate = pathJoin(dir, bin)
338
+ if (exists(candidate)) {
339
+ return { rung: 'cli', detected: true, invocation: rawCliInvocation(partner) }
340
+ }
341
+ }
342
+ return { rung: 'cli', detected: false, reason: `${bin} not found on $PATH` }
343
+ }
344
+
345
+ function rawCliInvocation(partner) {
346
+ if (partner === 'codex') return 'codex exec'
347
+ if (partner === 'claude') return 'claude -p'
348
+ if (partner === 'gemini') return 'gemini -m'
349
+ return partner
215
350
  }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * install/legacy-paths.mjs — shared registry of legacy install topologies
3
+ * the framework once wrote to but no longer owns. Single source of truth
4
+ * for both:
5
+ *
6
+ * - BUG-021: `runLegacyCleanup` in `install/runtime-migrate.mjs` knows
7
+ * which files under a legacy root are framework-owned (safe to
8
+ * remove) vs user-added (preserve).
9
+ * - BUG-022: `readSkillInstallState` in `util/skill-installs.mjs` falls
10
+ * back to `version_file` here when no manifest is present, so a
11
+ * pre-manifest install still surfaces as "installed" rather than
12
+ * "missing-skills".
13
+ *
14
+ * Derivation: the codex `framework_files` list was extracted from
15
+ * `git show 1a8dd8a8 --stat` (the original "make Codex a first-class
16
+ * skill install surface" commit) — every `.agents/skills/…` path the
17
+ * installer once wrote, including the `.apt-version` stamp and the
18
+ * apt-pr-review / apt-discuss / apt-verify-proof sub-files. When the
19
+ * codex installer was retargeted to `.codex/`, that entire tree became
20
+ * orphaned on upgrading projects.
21
+ *
22
+ * Schema:
23
+ * { [runtimeId]: {
24
+ * install_root: relative path the runtime once wrote into,
25
+ * version_file: relative path of the legacy `.apt-version` stamp,
26
+ * framework_files: array of relative paths the installer once owned
27
+ * (under install_root).
28
+ * } }
29
+ *
30
+ * Frozen via Object.freeze so callers cannot mutate the shared registry.
31
+ */
32
+
33
+ /** @type {Record<string, { install_root: string, version_file: string, framework_files: ReadonlyArray<string> }>} */
34
+ export const LEGACY_INSTALL_PATHS = Object.freeze({
35
+ codex: Object.freeze({
36
+ install_root: '.agents/skills',
37
+ version_file: '.agents/skills/.apt-version',
38
+ framework_files: Object.freeze([
39
+ '.apt-version',
40
+ 'apt/SKILL.md',
41
+ 'apt-debug/SKILL.md',
42
+ 'apt-discuss/SKILL.md',
43
+ 'apt-discuss/examples/rationalization-example.md',
44
+ 'apt-docs/SKILL.md',
45
+ 'apt-execute/SKILL.md',
46
+ 'apt-pause/SKILL.md',
47
+ 'apt-plan/SKILL.md',
48
+ 'apt-pr-review/SCHEMA.md',
49
+ 'apt-pr-review/SKILL.md',
50
+ 'apt-pr-review/prompts/fp-gate-codex.md',
51
+ 'apt-pr-review/templates/external-comment.md',
52
+ 'apt-quick/SKILL.md',
53
+ 'apt-resume/SKILL.md',
54
+ 'apt-review/SKILL.md',
55
+ 'apt-roadmap/SKILL.md',
56
+ 'apt-roundtable/SKILL.md',
57
+ 'apt-run/SKILL.md',
58
+ 'apt-scan/SKILL.md',
59
+ 'apt-setup/SKILL.md',
60
+ 'apt-ship/SKILL.md',
61
+ 'apt-stress-test/SKILL.md',
62
+ 'apt-verify/SKILL.md',
63
+ 'apt-verify-proof/SKILL.md',
64
+ 'apt-verify-proof/references/multi-model-setup.md',
65
+ 'apt-verify-proof/references/multi-model-verification.md',
66
+ 'apt-verify-proof/references/proof-capture.md',
67
+ ]),
68
+ }),
69
+ })