@aperant/framework 0.6.7 → 0.7.3

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 (205) hide show
  1. package/CHANGELOG.md +240 -0
  2. package/agents/apt-planner.md +12 -0
  3. package/agents/apt-pr-review-fixer.md +13 -9
  4. package/bin/apt-tools.mjs +7 -0
  5. package/dist/cli/ci-watch/stop-matrix.d.mts.map +1 -1
  6. package/dist/cli/ci-watch/stop-matrix.mjs +16 -0
  7. package/dist/cli/ci-watch/stop-matrix.mjs.map +1 -1
  8. package/dist/cli/commands/ci-watch.d.mts +11 -0
  9. package/dist/cli/commands/ci-watch.d.mts.map +1 -1
  10. package/dist/cli/commands/ci-watch.mjs +147 -3
  11. package/dist/cli/commands/ci-watch.mjs.map +1 -1
  12. package/dist/cli/commands/features-audit.d.mts +24 -0
  13. package/dist/cli/commands/features-audit.d.mts.map +1 -1
  14. package/dist/cli/commands/features-audit.mjs +159 -5
  15. package/dist/cli/commands/features-audit.mjs.map +1 -1
  16. package/dist/cli/commands/health-check.d.mts +16 -0
  17. package/dist/cli/commands/health-check.d.mts.map +1 -1
  18. package/dist/cli/commands/health-check.mjs +119 -3
  19. package/dist/cli/commands/health-check.mjs.map +1 -1
  20. package/dist/cli/commands/init.d.mts +19 -1
  21. package/dist/cli/commands/init.d.mts.map +1 -1
  22. package/dist/cli/commands/init.mjs +143 -8
  23. package/dist/cli/commands/init.mjs.map +1 -1
  24. package/dist/cli/commands/modes.d.mts.map +1 -1
  25. package/dist/cli/commands/modes.mjs +11 -0
  26. package/dist/cli/commands/modes.mjs.map +1 -1
  27. package/dist/cli/commands/pr-review-audit-fixer.d.mts +13 -0
  28. package/dist/cli/commands/pr-review-audit-fixer.d.mts.map +1 -1
  29. package/dist/cli/commands/pr-review-audit-fixer.mjs +18 -5
  30. package/dist/cli/commands/pr-review-audit-fixer.mjs.map +1 -1
  31. package/dist/cli/commands/route.d.mts.map +1 -1
  32. package/dist/cli/commands/route.mjs +37 -2
  33. package/dist/cli/commands/route.mjs.map +1 -1
  34. package/dist/cli/commands/task.d.mts.map +1 -1
  35. package/dist/cli/commands/task.mjs +132 -5
  36. package/dist/cli/commands/task.mjs.map +1 -1
  37. package/dist/cli/commands/validate-evidence.d.mts +24 -2
  38. package/dist/cli/commands/validate-evidence.d.mts.map +1 -1
  39. package/dist/cli/commands/validate-evidence.mjs +154 -17
  40. package/dist/cli/commands/validate-evidence.mjs.map +1 -1
  41. package/dist/cli/commands/vitest-doctor.d.mts +2 -0
  42. package/dist/cli/commands/vitest-doctor.d.mts.map +1 -0
  43. package/dist/cli/commands/vitest-doctor.mjs +168 -0
  44. package/dist/cli/commands/vitest-doctor.mjs.map +1 -0
  45. package/dist/cli/config/gitignore-drift.d.mts +23 -1
  46. package/dist/cli/config/gitignore-drift.d.mts.map +1 -1
  47. package/dist/cli/config/gitignore-drift.mjs +81 -3
  48. package/dist/cli/config/gitignore-drift.mjs.map +1 -1
  49. package/dist/cli/config/load.d.mts +56 -2
  50. package/dist/cli/config/load.d.mts.map +1 -1
  51. package/dist/cli/config/load.mjs +192 -2
  52. package/dist/cli/config/load.mjs.map +1 -1
  53. package/dist/cli/consistency/parse-review.mjs +7 -0
  54. package/dist/cli/consistency/parse-review.mjs.map +1 -1
  55. package/dist/cli/dispatch.d.mts.map +1 -1
  56. package/dist/cli/dispatch.mjs +24 -2
  57. package/dist/cli/dispatch.mjs.map +1 -1
  58. package/dist/cli/gate/gates/gitignore-in-sync.d.mts +1 -1
  59. package/dist/cli/gate/gates/gitignore-in-sync.d.mts.map +1 -1
  60. package/dist/cli/gate/gates/gitignore-in-sync.mjs +5 -2
  61. package/dist/cli/gate/gates/gitignore-in-sync.mjs.map +1 -1
  62. package/dist/cli/gate/gates/review-clean.d.mts +5 -1
  63. package/dist/cli/gate/gates/review-clean.d.mts.map +1 -1
  64. package/dist/cli/gate/gates/review-clean.mjs +23 -18
  65. package/dist/cli/gate/gates/review-clean.mjs.map +1 -1
  66. package/dist/cli/gate/gates/verify-approved.d.mts +49 -1
  67. package/dist/cli/gate/gates/verify-approved.d.mts.map +1 -1
  68. package/dist/cli/gate/gates/verify-approved.mjs +93 -14
  69. package/dist/cli/gate/gates/verify-approved.mjs.map +1 -1
  70. package/dist/cli/help.d.mts.map +1 -1
  71. package/dist/cli/help.mjs +8 -2
  72. package/dist/cli/help.mjs.map +1 -1
  73. package/dist/cli/host/detect.d.mts +122 -9
  74. package/dist/cli/host/detect.d.mts.map +1 -1
  75. package/dist/cli/host/detect.mjs +132 -0
  76. package/dist/cli/host/detect.mjs.map +1 -1
  77. package/dist/cli/install/legacy-paths.d.mts +38 -0
  78. package/dist/cli/install/legacy-paths.d.mts.map +1 -0
  79. package/dist/cli/install/legacy-paths.mjs +69 -0
  80. package/dist/cli/install/legacy-paths.mjs.map +1 -0
  81. package/dist/cli/install/runtime-detect.d.mts +13 -0
  82. package/dist/cli/install/runtime-detect.d.mts.map +1 -1
  83. package/dist/cli/install/runtime-detect.mjs +9 -0
  84. package/dist/cli/install/runtime-detect.mjs.map +1 -1
  85. package/dist/cli/install/runtime-migrate.d.mts +84 -0
  86. package/dist/cli/install/runtime-migrate.d.mts.map +1 -0
  87. package/dist/cli/install/runtime-migrate.mjs +244 -0
  88. package/dist/cli/install/runtime-migrate.mjs.map +1 -0
  89. package/dist/cli/route/drift-detect.d.mts +20 -0
  90. package/dist/cli/route/drift-detect.d.mts.map +1 -0
  91. package/dist/cli/route/drift-detect.mjs +107 -0
  92. package/dist/cli/route/drift-detect.mjs.map +1 -0
  93. package/dist/cli/task/index-md.d.mts.map +1 -1
  94. package/dist/cli/task/index-md.mjs +14 -2
  95. package/dist/cli/task/index-md.mjs.map +1 -1
  96. package/dist/cli/util/aperant-section.d.mts +34 -0
  97. package/dist/cli/util/aperant-section.d.mts.map +1 -0
  98. package/dist/cli/util/aperant-section.mjs +127 -0
  99. package/dist/cli/util/aperant-section.mjs.map +1 -0
  100. package/dist/cli/util/copy.d.mts +28 -1
  101. package/dist/cli/util/copy.d.mts.map +1 -1
  102. package/dist/cli/util/copy.mjs +43 -55
  103. package/dist/cli/util/copy.mjs.map +1 -1
  104. package/dist/cli/util/semver.d.mts +17 -0
  105. package/dist/cli/util/semver.d.mts.map +1 -0
  106. package/dist/cli/util/semver.mjs +29 -0
  107. package/dist/cli/util/semver.mjs.map +1 -0
  108. package/dist/cli/util/skill-installs.d.mts +65 -9
  109. package/dist/cli/util/skill-installs.d.mts.map +1 -1
  110. package/dist/cli/util/skill-installs.mjs +130 -21
  111. package/dist/cli/util/skill-installs.mjs.map +1 -1
  112. package/dist/cli/util/version-preflight.d.mts +44 -0
  113. package/dist/cli/util/version-preflight.d.mts.map +1 -0
  114. package/dist/cli/util/version-preflight.mjs +66 -0
  115. package/dist/cli/util/version-preflight.mjs.map +1 -0
  116. package/dist/plugin/.claude-plugin/plugin.json +11 -2
  117. package/dist/plugin/agents/apt-improver.md +99 -0
  118. package/dist/plugin/agents/apt-planner.md +127 -10
  119. package/dist/plugin/agents/apt-pr-review-fixer.md +13 -9
  120. package/dist/plugin/skills/apt/SKILL.md +1 -0
  121. package/dist/plugin/skills/apt-close-task/SKILL.md +63 -1
  122. package/dist/plugin/skills/apt-debug/SKILL.md +39 -6
  123. package/dist/plugin/skills/apt-debug/appendices/diagnose-discipline.md +119 -0
  124. package/dist/plugin/skills/apt-diagram/SKILL.md +378 -0
  125. package/dist/plugin/skills/apt-diagram/appendices/design-discipline.md +97 -0
  126. package/dist/plugin/skills/apt-discuss/SKILL.md +72 -5
  127. package/dist/plugin/skills/apt-discuss/appendices/grill-discipline.md +104 -0
  128. package/dist/plugin/skills/apt-discuss/appendices/zoom-out-helper.md +79 -0
  129. package/dist/plugin/skills/apt-execute/SKILL.md +57 -5
  130. package/dist/plugin/skills/apt-execute/appendices/tdd-mode.md +107 -0
  131. package/dist/plugin/skills/apt-improve/DEEPENING.md +84 -0
  132. package/dist/plugin/skills/apt-improve/INTERFACE-DESIGN.md +97 -0
  133. package/dist/plugin/skills/apt-improve/LANGUAGE.md +104 -0
  134. package/dist/plugin/skills/apt-improve/SKILL.md +141 -0
  135. package/dist/plugin/skills/apt-plan/SKILL.md +171 -4
  136. package/dist/plugin/skills/apt-plan/adapters/conductor.md +98 -0
  137. package/dist/plugin/skills/apt-pr-review/SKILL.md +57 -18
  138. package/dist/plugin/skills/apt-prototype/LOGIC.md +109 -0
  139. package/dist/plugin/skills/apt-prototype/SKILL.md +143 -0
  140. package/dist/plugin/skills/apt-prototype/UI.md +90 -0
  141. package/dist/plugin/skills/apt-quick/SKILL.md +49 -8
  142. package/dist/plugin/skills/apt-release-notes/SKILL.md +193 -0
  143. package/dist/plugin/skills/apt-release-notes/appendices/persona-voice.md +59 -0
  144. package/dist/plugin/skills/apt-review/SKILL.md +2 -0
  145. package/dist/plugin/skills/apt-run/SKILL.md +32 -4
  146. package/dist/plugin/skills/apt-setup/SKILL.md +308 -6
  147. package/dist/plugin/skills/apt-ship/SKILL.md +122 -1
  148. package/dist/plugin/skills/apt-spar/SKILL.md +315 -0
  149. package/dist/plugin/skills/apt-triage/AGENT-BRIEF.md +84 -0
  150. package/dist/plugin/skills/apt-triage/OUT-OF-SCOPE.md +75 -0
  151. package/dist/plugin/skills/apt-triage/SKILL.md +169 -0
  152. package/dist/plugin/skills/apt-update/SKILL.md +77 -10
  153. package/dist/plugin/skills/apt-verify/SKILL.md +3 -0
  154. package/dist/plugin/skills/apt-verify-proof/SKILL.md +10 -5
  155. package/dist/plugin/skills/apt-watch-ci/SKILL.md +166 -0
  156. package/dist/plugin/skills/apt-zoom-out/SKILL.md +130 -0
  157. package/package.json +133 -133
  158. package/prompts/conductor-framework-context.md +63 -0
  159. package/prompts/conductor-system.md +11 -0
  160. package/skills/apt-close-task/SKILL.md +6 -0
  161. package/skills/apt-discuss/SKILL.md +47 -5
  162. package/skills/apt-execute/SKILL.md +9 -0
  163. package/skills/apt-plan/SKILL.md +12 -0
  164. package/skills/apt-pr-review/SKILL.md +11 -2
  165. package/skills/apt-quick/SKILL.md +19 -8
  166. package/skills/apt-researcher.md +1 -0
  167. package/skills/apt-setup/SKILL.md +33 -2
  168. package/skills/apt-ship/SKILL.md +16 -4
  169. package/skills/apt-spar/SKILL.md +36 -11
  170. package/skills/apt-update/SKILL.md +26 -1
  171. package/skills/apt-verify-proof/SKILL.md +7 -5
  172. package/skills/apt-watch-ci/SKILL.md +4 -1
  173. package/src/cli/ci-watch/stop-matrix.mjs +17 -0
  174. package/src/cli/commands/ci-watch.mjs +152 -3
  175. package/src/cli/commands/features-audit.mjs +164 -5
  176. package/src/cli/commands/health-check.mjs +116 -3
  177. package/src/cli/commands/init.mjs +154 -6
  178. package/src/cli/commands/modes.mjs +11 -0
  179. package/src/cli/commands/pr-review-audit-fixer.mjs +18 -5
  180. package/src/cli/commands/route.mjs +38 -2
  181. package/src/cli/commands/task.mjs +132 -5
  182. package/src/cli/commands/validate-evidence.mjs +158 -17
  183. package/src/cli/commands/vitest-doctor.mjs +173 -0
  184. package/src/cli/config/gitignore-drift.mjs +74 -3
  185. package/src/cli/config/load.mjs +188 -2
  186. package/src/cli/consistency/parse-review.mjs +6 -0
  187. package/src/cli/dispatch.mjs +23 -2
  188. package/src/cli/gate/gates/gitignore-in-sync.mjs +5 -2
  189. package/src/cli/gate/gates/review-clean.mjs +24 -19
  190. package/src/cli/gate/gates/verify-approved.mjs +97 -14
  191. package/src/cli/help.mjs +8 -2
  192. package/src/cli/host/detect.mjs +135 -0
  193. package/src/cli/install/legacy-paths.mjs +69 -0
  194. package/src/cli/install/runtime-detect.mjs +9 -0
  195. package/src/cli/install/runtime-migrate.mjs +252 -0
  196. package/src/cli/route/drift-detect.mjs +107 -0
  197. package/src/cli/task/index-md.mjs +15 -2
  198. package/src/cli/util/aperant-section.mjs +136 -0
  199. package/src/cli/util/copy.mjs +43 -56
  200. package/src/cli/util/semver.mjs +28 -0
  201. package/src/cli/util/skill-installs.mjs +134 -21
  202. package/src/cli/util/version-preflight.mjs +65 -0
  203. package/templates/aperant-claude-md-appendix.md +37 -0
  204. package/templates/config.json +2 -7
  205. package/workflows/verify-proof.md +8 -3
@@ -11,25 +11,174 @@
11
11
  * 1. `--diff-files <a,b,c>` explicit CSV (highest priority)
12
12
  * 2. `--since <ref>` → runs `git diff --name-only <ref>..HEAD`
13
13
  * 3. fallback: `git diff HEAD --name-only` (current WIP)
14
+ *
15
+ * BUG-025b worktree-redirect: features-audit historically wrote to the
16
+ * invoking cwd's `.aperant/features/`, leaking generated `*.json` into
17
+ * main when the executor invoked it from a worktree-isolated task. The
18
+ * fix applies a 4-step resolution order (see resolveWriteRoot below):
19
+ * (1) cwd IS a worktree root → write there (current behaviour)
20
+ * (2) --task-id maps to an active non-terminal worktree task →
21
+ * redirect writes to that worktree's `.aperant/features/`
22
+ * (3) multiple active worktree-isolated tasks + no --task-id → error
23
+ * (4) `--no-worktree-redirect` opt-out preserves layer (1) behaviour
14
24
  */
15
25
 
16
- import { execFileSync } from 'node:child_process'
17
- import { resolve } from 'node:path'
26
+ import { execFileSync, spawnSync } from 'node:child_process'
27
+ import { existsSync, readFileSync } from 'node:fs'
28
+ import { join, resolve, sep } from 'node:path'
18
29
  import { loadDocsPolicy, matchSurfaceToPolicy } from '../docs-audit/policy.mjs'
19
30
  import { applyStubPatches, auditFeatureRegistry } from '../features/registry-audit.mjs'
20
31
  import { parseFlags } from '../util/args.mjs'
21
32
  import { err, ok } from '../util/result.mjs'
22
33
 
34
+ const TERMINAL_PHASES = new Set(['completed', 'cancelled', 'closed', 'shipped', 'merged'])
35
+
36
+ /**
37
+ * BUG-025b — resolve where features-audit should write `*.json`.
38
+ * See module header for the 4-step order. Returns `{ writeRoot, redirect_notice }`
39
+ * on success or a synthetic error envelope (`{ error: { ... } }`) on the
40
+ * ambiguous-worktree case.
41
+ *
42
+ * @internal — exported for unit testing.
43
+ *
44
+ * @param {{projectDir: string, taskId?: string | null, noRedirect?: boolean}} args
45
+ * @returns {{writeRoot?: string, redirect_notice?: string | null, error?: {kind: string, message: string, active_worktree_tasks: string[]}}}
46
+ */
47
+ export function resolveWriteRoot({ projectDir, taskId, noRedirect }) {
48
+ const root = resolve(projectDir)
49
+ if (noRedirect === true) {
50
+ return { writeRoot: root, redirect_notice: null }
51
+ }
52
+ // Layer (1): cwd IS a worktree root. Worktrees have `git rev-parse
53
+ // --git-dir` != `git rev-parse --git-common-dir`. Falling through to
54
+ // the existing behaviour writes to this worktree's .aperant/features.
55
+ // Fast path: git sets GIT_DIR and GIT_COMMON_DIR in the environment
56
+ // when running inside a worktree hook context — if both are set and
57
+ // differ, we are in a worktree. This env-var check is ONLY valid at
58
+ // Layer (1) where we ask "is this process in a worktree?"; it must
59
+ // NOT be used to validate an untrusted external path (Layer 2 below).
60
+ const envDir = process.env.GIT_DIR
61
+ const envCommonDir = process.env.GIT_COMMON_DIR
62
+ if (envDir && envCommonDir) {
63
+ if (resolve(envDir) !== resolve(envCommonDir)) {
64
+ return { writeRoot: root, redirect_notice: null }
65
+ }
66
+ } else if (isWorktreeRoot(root)) {
67
+ return { writeRoot: root, redirect_notice: null }
68
+ }
69
+ // Layer (2): --task-id maps to an active non-terminal worktree-isolated
70
+ // task → redirect.
71
+ const stateActive = readActiveTasks(root)
72
+ if (taskId) {
73
+ const task = stateActive[taskId]
74
+ if (
75
+ task &&
76
+ !TERMINAL_PHASES.has(task.lifecycle_phase) &&
77
+ typeof task.worktree_path === 'string' &&
78
+ existsSync(task.worktree_path)
79
+ ) {
80
+ const resolvedWorktree = resolve(task.worktree_path)
81
+ const worktreesDir = resolve(root, '.aperant', 'worktrees') + sep
82
+ const isUnderProject = resolvedWorktree.startsWith(root + sep) || resolvedWorktree === root
83
+ const isUnderWorktrees = resolvedWorktree.startsWith(worktreesDir)
84
+ // Accept only paths under the project root, under .aperant/worktrees/,
85
+ // or confirmed as a real git worktree by git itself. Rejects arbitrary
86
+ // paths injected via a tampered state.json while preserving test
87
+ // fixtures that use ad-hoc worktree paths outside the project tree.
88
+ if (!isUnderProject && !isUnderWorktrees && !isWorktreeRoot(resolvedWorktree)) {
89
+ return { writeRoot: root, redirect_notice: null }
90
+ }
91
+ return {
92
+ writeRoot: resolvedWorktree,
93
+ redirect_notice: `features-audit redirected writes to worktree ${resolvedWorktree} (--task-id ${taskId})`,
94
+ }
95
+ }
96
+ }
97
+ // Layer (3): multiple active worktree-isolated tasks AND no --task-id
98
+ // → actionable error. Single-worktree case is OK to default to root
99
+ // (the executor will set up the worktree's registry on first scan).
100
+ const activeWorktreeTasks = Object.entries(stateActive).filter(([_id, t]) => {
101
+ return (
102
+ t &&
103
+ !TERMINAL_PHASES.has(t.lifecycle_phase) &&
104
+ typeof t.worktree_path === 'string' &&
105
+ existsSync(t.worktree_path)
106
+ )
107
+ })
108
+ if (!taskId && activeWorktreeTasks.length > 1) {
109
+ return {
110
+ error: {
111
+ kind: 'ambiguous-worktree-context',
112
+ message: 'ambiguous worktree context; pass --task-id <id> or --no-worktree-redirect',
113
+ active_worktree_tasks: activeWorktreeTasks.map(([id]) => id),
114
+ },
115
+ }
116
+ }
117
+ // Default: current behaviour (write into cwd).
118
+ return { writeRoot: root, redirect_notice: null }
119
+ }
120
+
121
+ function isWorktreeRoot(projectDir) {
122
+ try {
123
+ const dirRes = spawnSync('git', ['rev-parse', '--git-dir'], {
124
+ cwd: projectDir,
125
+ encoding: 'utf-8',
126
+ stdio: ['ignore', 'pipe', 'ignore'],
127
+ })
128
+ const commonRes = spawnSync('git', ['rev-parse', '--git-common-dir'], {
129
+ cwd: projectDir,
130
+ encoding: 'utf-8',
131
+ stdio: ['ignore', 'pipe', 'ignore'],
132
+ })
133
+ if (dirRes.status !== 0 || commonRes.status !== 0) return false
134
+ return (dirRes.stdout || '').trim() !== (commonRes.stdout || '').trim()
135
+ } catch {
136
+ return false
137
+ }
138
+ }
139
+
140
+ function readActiveTasks(projectDir) {
141
+ const statePath = join(projectDir, '.aperant', 'state.json')
142
+ if (!existsSync(statePath)) return {}
143
+ try {
144
+ const parsed = JSON.parse(readFileSync(statePath, 'utf-8'))
145
+ return parsed?.active_tasks ?? {}
146
+ } catch {
147
+ return {}
148
+ }
149
+ }
150
+
23
151
  export function cmdFeaturesAudit(projectDir, extraArgs) {
24
152
  if (!projectDir) return err('Usage: apt-tools features-audit <project-dir> [flags]')
25
153
  const root = resolve(projectDir)
26
154
  const flags = parseFlags(extraArgs)
27
155
 
156
+ // BUG-025b: redirect writes to the active task's worktree when the
157
+ // caller is in a non-worktree cwd. See resolveWriteRoot for the
158
+ // 4-step resolution order.
159
+ const taskIdFlag = flags.get('task-id')
160
+ const taskId = typeof taskIdFlag === 'string' && taskIdFlag.trim() ? taskIdFlag.trim() : null
161
+ const noRedirect = flags.get('no-worktree-redirect') === true
162
+ const writeRootResult = resolveWriteRoot({ projectDir: root, taskId, noRedirect })
163
+ if (writeRootResult.error) {
164
+ return err(writeRootResult.error.message, 1, {
165
+ kind: writeRootResult.error.kind,
166
+ active_worktree_tasks: writeRootResult.error.active_worktree_tasks,
167
+ })
168
+ }
169
+ const writeRoot = writeRootResult.writeRoot
170
+ const redirectNotice = writeRootResult.redirect_notice
171
+ if (redirectNotice) {
172
+ // Surface the redirect once on stderr so the executor's log carries
173
+ // the audit trail without polluting stdout JSON.
174
+ process.stderr.write(`[features-audit] ${redirectNotice}\n`)
175
+ }
176
+
28
177
  const diffFiles = resolveDiffFiles(root, flags)
29
178
  const applyStubs = flags.has('apply-stubs')
30
179
  const format = (flags.get('format') || 'json').toLowerCase()
31
180
 
32
- const result = auditFeatureRegistry({ projectDir: root, diffFiles })
181
+ const result = auditFeatureRegistry({ projectDir: writeRoot, diffFiles })
33
182
 
34
183
  // Honor `.aperant/config.json.docs_policy`: only apply stubs for targets
35
184
  // whose resolved policy is `auto-update`. This closes the skill-vs-code
@@ -52,7 +201,9 @@ export function cmdFeaturesAudit(projectDir, extraArgs) {
52
201
  }
53
202
  }
54
203
  if (eligible.length > 0) {
55
- applied_patches = applyStubPatches(root, eligible)
204
+ // BUG-025b: stubs are written to the resolved `writeRoot` (which
205
+ // may be the active task's worktree) rather than the invoking cwd.
206
+ applied_patches = applyStubPatches(writeRoot, eligible)
56
207
  }
57
208
  if (skipped.length > 0) {
58
209
  skipped_patches = skipped
@@ -65,6 +216,8 @@ export function cmdFeaturesAudit(projectDir, extraArgs) {
65
216
  command: 'features-audit',
66
217
  format: 'md',
67
218
  markdown: renderMarkdown(result, applied_patches, skipped_patches),
219
+ write_root: writeRoot,
220
+ ...(redirectNotice ? { redirect_notice: redirectNotice } : {}),
68
221
  ...result,
69
222
  ...(applied_patches ? { applied_patches } : {}),
70
223
  ...(skipped_patches ? { skipped_patches } : {}),
@@ -74,6 +227,8 @@ export function cmdFeaturesAudit(projectDir, extraArgs) {
74
227
  return ok({
75
228
  status: 'ok',
76
229
  command: 'features-audit',
230
+ write_root: writeRoot,
231
+ ...(redirectNotice ? { redirect_notice: redirectNotice } : {}),
77
232
  ...result,
78
233
  ...(applied_patches ? { applied_patches } : {}),
79
234
  ...(skipped_patches ? { skipped_patches } : {}),
@@ -88,7 +243,11 @@ function resolveDiffFiles(root, flags) {
88
243
  .map((s) => s.trim())
89
244
  .filter(Boolean)
90
245
  }
91
- const since = flags.get('since')
246
+ const sinceRaw = flags.get('since')
247
+ const since =
248
+ typeof sinceRaw === 'string' && /^[A-Za-z0-9/_.-]+$/.test(sinceRaw) && !sinceRaw.startsWith('-')
249
+ ? sinceRaw
250
+ : null
92
251
  try {
93
252
  const args = since ? ['diff', '--name-only', `${since}..HEAD`] : ['diff', 'HEAD', '--name-only']
94
253
  const out = execFileSync('git', args, {
@@ -2,7 +2,7 @@
2
2
  * commands/health-check.mjs — health-check command (C29).
3
3
  */
4
4
 
5
- import { execSync } from 'node:child_process'
5
+ import { execFileSync } from 'node:child_process'
6
6
  import { existsSync, readdirSync, readFileSync } from 'node:fs'
7
7
  import { dirname, join, resolve } from 'node:path'
8
8
  import { fileURLToPath } from 'node:url'
@@ -13,6 +13,33 @@ import { readSkillInstallState } from '../util/skill-installs.mjs'
13
13
  const __filename = fileURLToPath(import.meta.url)
14
14
  const __dirname = dirname(__filename)
15
15
 
16
+ /**
17
+ * SECURITY: validate a component path string from `.aperant/features/*.json`
18
+ * before passing it to git. The registry is on-disk JSON authored by
19
+ * features-audit; pre-fix the orphan walker spliced this string into a
20
+ * shell command (`execSync(\`git rev-parse HEAD:${comp} 2>/dev/null\`)`),
21
+ * making a malicious entry like `foo.ts; rm -rf $HOME` a command-injection
22
+ * vector against any user who ran `apt-tools health-check`. The fix
23
+ * combines `execFileSync` (argv-form, no shell) with a strict path
24
+ * whitelist: relative posix paths only, no NUL / control bytes / shell
25
+ * metacharacters / leading `/` / `..` segments.
26
+ *
27
+ * @internal — exported for unit testing.
28
+ * @param {unknown} value
29
+ * @returns {boolean}
30
+ */
31
+ export function isSafeComponentPath(value) {
32
+ if (typeof value !== 'string' || value.length === 0 || value.length > 512) return false
33
+ // Reject NUL, control bytes, and shell metacharacters that could escape
34
+ // argv interpretation if a downstream caller ever reverts to shell mode.
35
+ // biome-ignore lint/suspicious/noControlCharactersInRegex: rejecting control bytes IS the security guard — see isSafeComponentPath docstring + test.
36
+ if (/[\x00-\x1f;&|`$<>"\\]/.test(value)) return false
37
+ if (value.startsWith('/')) return false
38
+ if (value.startsWith('-')) return false // would be parsed as a flag
39
+ if (value.split('/').some((seg) => seg === '..' || seg === '.' || seg === '')) return false
40
+ return true
41
+ }
42
+
16
43
  export function cmdHealthCheck(projectDir) {
17
44
  if (!projectDir) return err('Usage: apt-tools health-check <project-dir>')
18
45
  const targetDir = resolve(projectDir)
@@ -22,9 +49,10 @@ export function cmdHealthCheck(projectDir) {
22
49
  // 1. Check for uncommitted changes in working tree
23
50
  let dirtyFiles = []
24
51
  try {
25
- const status = execSync('git status --porcelain 2>/dev/null', {
52
+ const status = execFileSync('git', ['status', '--porcelain'], {
26
53
  cwd: targetDir,
27
54
  encoding: 'utf-8',
55
+ stdio: ['ignore', 'pipe', 'ignore'],
28
56
  }).trim()
29
57
  if (status) {
30
58
  dirtyFiles = status.split('\n').filter(Boolean)
@@ -140,7 +168,7 @@ export function cmdHealthCheck(projectDir) {
140
168
  issues.push({
141
169
  type: 'missing-skills',
142
170
  severity: 'warning',
143
- detail: 'No installed skill version stamps found under .claude/skills or .agents/skills',
171
+ detail: 'No apt install manifests or version stamps found for any supported runtime',
144
172
  install_surfaces: installs,
145
173
  })
146
174
  }
@@ -156,6 +184,91 @@ export function cmdHealthCheck(projectDir) {
156
184
  }
157
185
  }
158
186
 
187
+ // 6. BUG-025b — orphaned-feature-component warning. Walk
188
+ // `.aperant/features/*.json`; for each cited component path, verify
189
+ // `git rev-parse HEAD:<path>` succeeds on the main repo's HEAD. A path
190
+ // that resolves on a worktree branch but is missing on main is a
191
+ // candidate orphan (the registry entry leaked from a worktree-isolated
192
+ // task and was never followed by the actual component landing on main).
193
+ // Severity `warn` — the registry is best-effort metadata, not a build
194
+ // blocker. Only fires on the main repo (skipped when health-check is
195
+ // invoked from a worktree).
196
+ try {
197
+ const featuresDir = join(aperantDir, 'features')
198
+ if (existsSync(featuresDir)) {
199
+ // SECURITY: argv-form (no shell) — see isSafeComponentPath comment.
200
+ const dirRes = execFileSync('git', ['rev-parse', '--git-dir'], {
201
+ cwd: targetDir,
202
+ encoding: 'utf-8',
203
+ stdio: ['ignore', 'pipe', 'ignore'],
204
+ }).trim()
205
+ const commonRes = execFileSync('git', ['rev-parse', '--git-common-dir'], {
206
+ cwd: targetDir,
207
+ encoding: 'utf-8',
208
+ stdio: ['ignore', 'pipe', 'ignore'],
209
+ }).trim()
210
+ if (dirRes === commonRes) {
211
+ const orphans = []
212
+ // PRF-001: one subprocess total instead of one per component.
213
+ // Build a Set of all tracked paths from a single ls-tree call.
214
+ const lsOut = execFileSync('git', ['ls-tree', '-r', 'HEAD', '--name-only'], {
215
+ cwd: targetDir,
216
+ encoding: 'utf-8',
217
+ stdio: ['ignore', 'pipe', 'ignore'],
218
+ }).trim()
219
+ const trackedPaths = new Set(
220
+ lsOut
221
+ .split('\n')
222
+ .map((s) => s.trim())
223
+ .filter(Boolean),
224
+ )
225
+ const files = readdirSync(featuresDir).filter(
226
+ (f) => f.endsWith('.json') && f !== 'REGISTRY.md',
227
+ )
228
+ // QUA-006: cap total component iterations to guard against
229
+ // adversarially large or malformed registry files.
230
+ const MAX_ORPHAN_ITERATIONS = 10_000
231
+ let iterCount = 0
232
+ let cappedAt = null
233
+ outer: for (const f of files) {
234
+ let area
235
+ try {
236
+ area = JSON.parse(readFileSync(join(featuresDir, f), 'utf-8'))
237
+ } catch {
238
+ continue
239
+ }
240
+ const components = Array.isArray(area?.components) ? area.components : []
241
+ for (const comp of components) {
242
+ if (iterCount >= MAX_ORPHAN_ITERATIONS) {
243
+ cappedAt = f
244
+ break outer
245
+ }
246
+ iterCount++
247
+ // SECURITY: reject obviously-unsafe component strings.
248
+ if (!isSafeComponentPath(comp)) continue
249
+ if (!trackedPaths.has(comp)) orphans.push({ area_file: f, component: comp })
250
+ }
251
+ }
252
+ if (cappedAt !== null) {
253
+ orphans.push({
254
+ area_file: cappedAt,
255
+ component: `(orphan scan capped at ${MAX_ORPHAN_ITERATIONS} entries — remaining components skipped)`,
256
+ })
257
+ }
258
+ if (orphans.length > 0) {
259
+ issues.push({
260
+ type: 'orphan-feature-components',
261
+ severity: 'warning',
262
+ detail: `${orphans.length} feature component(s) cited in .aperant/features/*.json are missing on HEAD — candidates leaked from worktree-isolated tasks`,
263
+ orphans: orphans.slice(0, 20),
264
+ })
265
+ }
266
+ }
267
+ }
268
+ } catch {
269
+ /* git not available or worktree — skip silently */
270
+ }
271
+
159
272
  return ok({
160
273
  status: 'ok',
161
274
  command: 'health-check',
@@ -29,6 +29,11 @@ import { fileURLToPath } from 'node:url'
29
29
  import { detectMode } from '../bootstrap/detect-mode.mjs'
30
30
  import { deriveSidecar } from '../bootstrap/product-context-sidecar.mjs'
31
31
  import { assertConfigWriteAllowed } from '../config/guard.mjs'
32
+ import {
33
+ AUDIT_001_LOCAL_FIELDS,
34
+ migrateDeferredFieldsToLocalConfig,
35
+ writeLocalConfigField,
36
+ } from '../config/load.mjs'
32
37
  import { migrateConfigSchema, setAt } from '../config/schema-migrate.mjs'
33
38
  import { resolveSharePolicy } from '../config/share-policy.mjs'
34
39
  import { buildAperantGitignoreTemplate } from '../config/upgrade-gitignore.mjs'
@@ -41,6 +46,7 @@ import {
41
46
  writeLastInstallSummary,
42
47
  } from '../install/pipeline.mjs'
43
48
  import { RUNTIMES, suggestRuntimes } from '../install/runtime-detect.mjs'
49
+ import { runLegacyCleanup } from '../install/runtime-migrate.mjs'
44
50
  import {
45
51
  CLASS_A_SETTINGS,
46
52
  CLASS_B_SETTINGS,
@@ -48,6 +54,7 @@ import {
48
54
  parseClassFlags,
49
55
  } from '../install/setting-class.mjs'
50
56
  import { detectAgentRuntime } from '../util/agent-runtime.mjs'
57
+ import { buildAptSection } from '../util/aperant-section.mjs'
51
58
  import { atomicWriteJson } from '../util/atomic-write.mjs'
52
59
  import { aperantBanner } from '../util/banner.mjs'
53
60
  import { copyDirRecursive, injectInstructionFile } from '../util/copy.mjs'
@@ -88,10 +95,22 @@ const TRANSFORM_LOADERS = new Map(
88
95
  * additive: callers without the flag still get the no-op short-circuit
89
96
  * (preserves `init-upgrade-drift.test.ts` case (a)).
90
97
  *
98
+ * 0.7.0 — `--force-overwrite-catalog` (FRAMEWORK-BUG-023) bypasses the
99
+ * foreign-row defensive check in `injectInstructionFile`. Power-user
100
+ * flag only — no interview prompt. Without it, an instruction file
101
+ * carrying rows the generator didn't produce causes init to exit 2 with
102
+ * `code: 'instruction-file.foreign-rows'` so unattended upgrade flows
103
+ * surface the would-be data loss instead of silently clobbering it.
104
+ *
91
105
  * @param {string[]} args
92
- * @returns {{ runtimes: string[], runtimes_explicit: boolean, target: string|null, interactive: boolean, skipBootstrap: boolean, visibility?: 'solo'|'team', autonomy?: 0|1|2|3, accept_new_defaults: boolean, yes: boolean, forceRuntimes: boolean }}
106
+ * @returns {{ runtimes: string[], runtimes_explicit: boolean, target: string|null, interactive: boolean, skipBootstrap: boolean, visibility?: 'solo'|'team', autonomy?: 0|1|2|3, accept_new_defaults: boolean, yes: boolean, forceRuntimes: boolean, forceOverwriteCatalog: boolean, keepLegacy: boolean }}
93
107
  */
94
108
  export function parseInstallFlags(args) {
109
+ // C58 (0.7.2): `--kilocode` is a deprecated alias of `--kilo`. Matches
110
+ // the upstream package + app branding so users typing what they see on
111
+ // kilocode.ai get the same install as the canonical `--kilo` flag.
112
+ // Internal runtime id stays `kilo` (no migration — per spec ID-01).
113
+ args = args.map((a) => (a === '--kilocode' ? '--kilo' : a))
95
114
  const flagSet = new Set(args.filter((a) => a.startsWith('--')))
96
115
  let target = null
97
116
  for (let i = 0; i < args.length; i++) {
@@ -105,6 +124,14 @@ export function parseInstallFlags(args) {
105
124
  // 0.6.7 (Bug 2 fix) — opt-in bypass of the upgrade_noop short-circuit
106
125
  // so /apt:update can re-stamp runtime files on a no-drift upgrade.
107
126
  const forceRuntimes = flagSet.has('--force-runtimes')
127
+ // 0.7.0 (FRAMEWORK-BUG-023) — opt-in bypass of the foreign-row check
128
+ // in injectInstructionFile. Power-user flag only.
129
+ const forceOverwriteCatalog = flagSet.has('--force-overwrite-catalog')
130
+ // 0.7.0 (FRAMEWORK-BUG-021) — opt out of the codex legacy-tree cleanup
131
+ // that fires when `codex` is in the runtime set. Defaults to false so
132
+ // the cleanup runs by default; pass `--keep-legacy` to preserve the
133
+ // orphaned `.agents/skills/` tree on upgrades.
134
+ const keepLegacy = flagSet.has('--keep-legacy')
108
135
 
109
136
  const classFlags = parseClassFlags(args)
110
137
 
@@ -112,6 +139,8 @@ export function parseInstallFlags(args) {
112
139
  target,
113
140
  skipBootstrap,
114
141
  forceRuntimes,
142
+ forceOverwriteCatalog,
143
+ keepLegacy,
115
144
  visibility: classFlags.visibility,
116
145
  autonomy: classFlags.autonomy,
117
146
  accept_new_defaults: classFlags.accept_new_defaults,
@@ -702,7 +731,11 @@ async function interactiveInterview(targetDir, version, canonicalRoot, archetype
702
731
  if (suggested.size === 0) suggested.add('claude')
703
732
  const sel = await clack.multiselect({
704
733
  message: 'Pick the runtimes to install Aperant into',
705
- options: RUNTIMES.map((r) => ({ value: r.id, label: r.id, hint: r.flag })),
734
+ options: RUNTIMES.map((r) => ({
735
+ value: r.id,
736
+ label: r.displayLabel ?? r.id,
737
+ hint: r.flag,
738
+ })),
706
739
  initialValues: [...suggested],
707
740
  required: true,
708
741
  })
@@ -928,7 +961,7 @@ async function interactiveInterview(targetDir, version, canonicalRoot, archetype
928
961
  * @param {Record<string, any>} config
929
962
  * @param {InstallAnswers} answers
930
963
  */
931
- function applyInterviewAnswers(config, answers) {
964
+ export function applyInterviewAnswers(config, answers, targetDir = null) {
932
965
  if (config.autonomy) {
933
966
  config.autonomy.default = answers.autonomyDefault
934
967
  }
@@ -943,8 +976,22 @@ function applyInterviewAnswers(config, answers) {
943
976
  }
944
977
  if (config.multi_model?.cross_verification) {
945
978
  const xverify = answers.crossVerificationTools
979
+ // `enabled` is project policy (is cross-verification on for this
980
+ // project?) — stays in shared `config.json`.
946
981
  config.multi_model.cross_verification.enabled = xverify.length > 0
947
- config.multi_model.cross_verification.tools = xverify
982
+ // AUDIT-001 (0.7.2 S1) — `tools` is per-device (which CLIs are installed
983
+ // HERE) and MUST land in `config.local.json`, NOT shared. Strip from
984
+ // the template to keep the shared file clean. When `targetDir` is null
985
+ // (legacy callers / tests that don't go through finishInit), we leave
986
+ // the tools array on the shared template — backward-compatible fallback
987
+ // — but the writer path always passes targetDir so production lands
988
+ // the value in local.
989
+ if (targetDir) {
990
+ delete config.multi_model.cross_verification.tools
991
+ writeLocalConfigField(targetDir, 'multi_model.cross_verification.tools', xverify)
992
+ } else {
993
+ config.multi_model.cross_verification.tools = xverify
994
+ }
948
995
  }
949
996
  config.project_type =
950
997
  answers.archetype.type === 'brownfield' ? 'unknown' : config.project_type || 'unknown'
@@ -1164,6 +1211,27 @@ export async function cmdInit(projectDir, extraArgs = []) {
1164
1211
  }
1165
1212
  }
1166
1213
 
1214
+ // FRAMEWORK-BUG-021 — sweep the legacy `.agents/skills/` codex tree
1215
+ // BEFORE the install pipeline runs so the install can re-stamp
1216
+ // `.codex/` against a clean slate. Only fires when codex is in the
1217
+ // requested runtime set and `--keep-legacy` was not passed; the
1218
+ // helper itself is conservative (no-op on managed installs, no-op
1219
+ // when no legacy framework files are present).
1220
+ let legacyCleanup = null
1221
+ if (effectiveAnswers.runtimes.includes('codex')) {
1222
+ if (parsedFlags.keepLegacy) {
1223
+ legacyCleanup = { runtime: 'codex', ran: false, reason: '--keep-legacy' }
1224
+ } else {
1225
+ const cleanup = runLegacyCleanup({ targetDir: installTarget })
1226
+ legacyCleanup = { runtime: 'codex', ...cleanup }
1227
+ if (cleanup.ran && cleanup.retained.length > 0) {
1228
+ process.stderr.write(
1229
+ `[apt-tools] legacy_cleanup retained ${cleanup.retained.length} user-added path(s) under .agents/skills/: ${cleanup.retained.join(', ')}\n`,
1230
+ )
1231
+ }
1232
+ }
1233
+ }
1234
+
1167
1235
  const results = await installRuntimes(
1168
1236
  effectiveAnswers.runtimes,
1169
1237
  canonicalRoot,
@@ -1186,7 +1254,14 @@ export async function cmdInit(projectDir, extraArgs = []) {
1186
1254
  effectiveAnswers,
1187
1255
  interactiveTty,
1188
1256
  kernelResult,
1257
+ parsedFlags.forceOverwriteCatalog === true,
1258
+ legacyCleanup,
1189
1259
  )
1260
+ // FRAMEWORK-BUG-023 — finishInit signals the foreign-row defensive
1261
+ // abort by returning an early-exit sentinel envelope.
1262
+ if (envelope?.__earlyExit) {
1263
+ return envelope.__earlyExit
1264
+ }
1190
1265
  if (interactiveTty) {
1191
1266
  await renderOutro(effectiveAnswers, results, version, kernelResult)
1192
1267
  // TTY mode: envelope was persisted to disk + suppressed from stdout
@@ -1365,6 +1440,19 @@ async function renderOutro(answers, results, version, kernelResult) {
1365
1440
  * deps/` — surfaced in the envelope so
1366
1441
  * downstream tooling can detect a
1367
1442
  * degraded install.
1443
+ * @param {boolean} [forceOverwriteCatalog=false]
1444
+ * FRAMEWORK-BUG-023 — bypass the
1445
+ * foreign-row check in
1446
+ * injectInstructionFile when the user
1447
+ * explicitly opted in via
1448
+ * --force-overwrite-catalog.
1449
+ * @param {object|null} [legacyCleanup=null]
1450
+ * FRAMEWORK-BUG-021 — outcome of the
1451
+ * codex `.agents/skills/` cleanup
1452
+ * step. `null` when codex was not in
1453
+ * the runtime set; otherwise the
1454
+ * shape `{ runtime, ran, … }` from
1455
+ * `runLegacyCleanup`.
1368
1456
  */
1369
1457
  function finishInit(
1370
1458
  targetDir,
@@ -1376,6 +1464,8 @@ function finishInit(
1376
1464
  answers,
1377
1465
  suppressStdout = false,
1378
1466
  kernelResult = null,
1467
+ forceOverwriteCatalog = false,
1468
+ legacyCleanup = null,
1379
1469
  ) {
1380
1470
  writeLastInstallSummary(targetDir, runtimeResults, version)
1381
1471
 
@@ -1420,7 +1510,11 @@ function finishInit(
1420
1510
  () => {
1421
1511
  if (!existsSync(configDest)) {
1422
1512
  const template = JSON.parse(readFileSync(configTemplate, 'utf-8'))
1423
- applyInterviewAnswers(template, answers)
1513
+ // AUDIT-001 (0.7.2 S1) — pass targetDir so the cross_verification.tools
1514
+ // per-device field lands in `config.local.json`, not the shared
1515
+ // `config.json`. Matches the upgrade-path migrator which already
1516
+ // moves the same field via migrateDeferredFieldsToLocalConfig.
1517
+ applyInterviewAnswers(template, answers, targetDir)
1424
1518
  atomicWriteJson(configDest, template)
1425
1519
  configCreated = true
1426
1520
  }
@@ -1448,6 +1542,22 @@ function finishInit(
1448
1542
  )
1449
1543
  }
1450
1544
 
1545
+ // AUDIT-001 — UPGRADE-only migrator. Moves the 5 deferred fields out
1546
+ // of `config.json` into `config.local.json` on real upgrade paths
1547
+ // (configMigrated === true), NOT on fresh seeds — the freshly-seeded
1548
+ // template config.json IS the post-migration state, so the migrator
1549
+ // would just round-trip with no observable benefit AND would trip
1550
+ // the next init invocation's no-drift detection. Idempotent on
1551
+ // re-run. Per-field independence per ID-08 — one failure does NOT
1552
+ // block the others.
1553
+ if (configMigrated && existsSync(configDest)) {
1554
+ try {
1555
+ migrateDeferredFieldsToLocalConfig(targetDir, AUDIT_001_LOCAL_FIELDS)
1556
+ } catch {
1557
+ /* never block init on telemetry-only migrator */
1558
+ }
1559
+ }
1560
+
1451
1561
  const stateTemplate = resolve(canonicalRoot, 'templates', 'state.json')
1452
1562
  const stateDest = join(aperantDir, 'state.json')
1453
1563
  let stateCreated = false
@@ -1506,7 +1616,40 @@ function finishInit(
1506
1616
  if (existsSync(claudeMdPath)) instructionFile = claudeMdPath
1507
1617
  else if (existsSync(agentsMdPath)) instructionFile = agentsMdPath
1508
1618
  else instructionFile = claudeMdPath
1509
- const injectionResult = injectInstructionFile(instructionFile)
1619
+ // FRAMEWORK-BUG-023 generate the section from discoverSkills before
1620
+ // handing it to the writer. The writer also runs a foreign-row defensive
1621
+ // check; if rows present in the file aren't in the generated set we
1622
+ // signal exit-2 via the __earlyExit sentinel so cmdInit can route the
1623
+ // envelope through exitWith() without writing a malformed install.
1624
+ const aptSection = buildAptSection({
1625
+ canonicalRoot,
1626
+ targetDir,
1627
+ cli: detectAgentRuntime(),
1628
+ })
1629
+ const injectionOutcome = injectInstructionFile(instructionFile, {
1630
+ aptSection,
1631
+ force: forceOverwriteCatalog,
1632
+ })
1633
+ if (injectionOutcome.status === 'aborted-foreign-rows') {
1634
+ return {
1635
+ __earlyExit: exitWith(
1636
+ {
1637
+ ok: false,
1638
+ status: 'error',
1639
+ command: 'init',
1640
+ code: 'instruction-file.foreign-rows',
1641
+ message:
1642
+ `${basename(instructionFile)} contains skill rows not present in the installed framework: ` +
1643
+ `${injectionOutcome.foreignRowIds.join(', ')}. ` +
1644
+ 'Re-run with --force-overwrite-catalog to regenerate anyway, or remove the foreign rows manually.',
1645
+ foreignRowIds: injectionOutcome.foreignRowIds,
1646
+ instructionFile: basename(instructionFile),
1647
+ },
1648
+ 2,
1649
+ ),
1650
+ }
1651
+ }
1652
+ const injectionResult = injectionOutcome.status
1510
1653
 
1511
1654
  const installedClaude = chosenIds.includes('claude') || existsSync(join(targetDir, '.claude'))
1512
1655
  const precompactResult = installedClaude
@@ -1617,6 +1760,11 @@ function finishInit(
1617
1760
  ...(kernelResult.reason ? { reason: kernelResult.reason } : {}),
1618
1761
  }
1619
1762
  : null,
1763
+ // FRAMEWORK-BUG-021 — always-present envelope field. `null` when
1764
+ // codex was not in the runtime set; otherwise the cleanup outcome
1765
+ // (uses `{ ran: false, reason }` for every skip case so parsers
1766
+ // see a uniform shape).
1767
+ legacy_cleanup: legacyCleanup,
1620
1768
  }
1621
1769
 
1622
1770
  if (suppressStdout) {
@@ -41,6 +41,17 @@ const FRONTMATTER_RE = /^---\s*([\s\S]*?)\s*---/
41
41
  * which config keys a skill cares about. Skills that don't touch
42
42
  * config are omitted (their config_keys is `[]`).
43
43
  *
44
+ * AUDIT-001 (0.7.2): all dotted paths below resolve against the MERGED
45
+ * config (`loadMergedProjectConfig()` — shared `config.json` + local
46
+ * `config.local.json` overlay). The five per-device fields enumerated by
47
+ * `AUDIT_001_LOCAL_FIELDS` in `cli/config/load.mjs` (currently
48
+ * `verification.open_report`, `multi_model.cross_verification.tools`,
49
+ * `preferences.{date_format,time_format,quick_task_post_verify}`)
50
+ * physically live in `config.local.json`; the wildcard `'*'` surface for
51
+ * `apt:setup` covers both files. Consumers MUST go through the merged
52
+ * loader — never raw `JSON.parse(readFileSync('.aperant/config.json'))` —
53
+ * to honor per-device overrides correctly.
54
+ *
44
55
  * @type {Record<string, string[]>}
45
56
  */
46
57
  const SKILL_CONFIG_KEYS = {