@aperant/framework 0.7.0 → 0.7.4

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 (151) hide show
  1. package/CHANGELOG.md +205 -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 +108 -2
  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 +118 -2
  19. package/dist/cli/commands/health-check.mjs.map +1 -1
  20. package/dist/cli/commands/init.d.mts +9 -0
  21. package/dist/cli/commands/init.d.mts.map +1 -1
  22. package/dist/cli/commands/init.mjs +49 -4
  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/task.d.mts.map +1 -1
  32. package/dist/cli/commands/task.mjs +84 -5
  33. package/dist/cli/commands/task.mjs.map +1 -1
  34. package/dist/cli/commands/validate-evidence.d.mts +24 -2
  35. package/dist/cli/commands/validate-evidence.d.mts.map +1 -1
  36. package/dist/cli/commands/validate-evidence.mjs +154 -17
  37. package/dist/cli/commands/validate-evidence.mjs.map +1 -1
  38. package/dist/cli/commands/vitest-doctor.d.mts +2 -0
  39. package/dist/cli/commands/vitest-doctor.d.mts.map +1 -0
  40. package/dist/cli/commands/vitest-doctor.mjs +168 -0
  41. package/dist/cli/commands/vitest-doctor.mjs.map +1 -0
  42. package/dist/cli/config/gitignore-drift.d.mts +23 -1
  43. package/dist/cli/config/gitignore-drift.d.mts.map +1 -1
  44. package/dist/cli/config/gitignore-drift.mjs +81 -3
  45. package/dist/cli/config/gitignore-drift.mjs.map +1 -1
  46. package/dist/cli/config/load.d.mts +56 -2
  47. package/dist/cli/config/load.d.mts.map +1 -1
  48. package/dist/cli/config/load.mjs +192 -2
  49. package/dist/cli/config/load.mjs.map +1 -1
  50. package/dist/cli/consistency/parse-review.mjs +7 -0
  51. package/dist/cli/consistency/parse-review.mjs.map +1 -1
  52. package/dist/cli/dispatch.d.mts.map +1 -1
  53. package/dist/cli/dispatch.mjs +24 -2
  54. package/dist/cli/dispatch.mjs.map +1 -1
  55. package/dist/cli/gate/gates/gitignore-in-sync.d.mts +1 -1
  56. package/dist/cli/gate/gates/gitignore-in-sync.d.mts.map +1 -1
  57. package/dist/cli/gate/gates/gitignore-in-sync.mjs +5 -2
  58. package/dist/cli/gate/gates/gitignore-in-sync.mjs.map +1 -1
  59. package/dist/cli/gate/gates/review-clean.d.mts +5 -1
  60. package/dist/cli/gate/gates/review-clean.d.mts.map +1 -1
  61. package/dist/cli/gate/gates/review-clean.mjs +23 -18
  62. package/dist/cli/gate/gates/review-clean.mjs.map +1 -1
  63. package/dist/cli/gate/gates/verify-approved.d.mts +49 -1
  64. package/dist/cli/gate/gates/verify-approved.d.mts.map +1 -1
  65. package/dist/cli/gate/gates/verify-approved.mjs +93 -14
  66. package/dist/cli/gate/gates/verify-approved.mjs.map +1 -1
  67. package/dist/cli/help.d.mts.map +1 -1
  68. package/dist/cli/help.mjs +7 -2
  69. package/dist/cli/help.mjs.map +1 -1
  70. package/dist/cli/install/runtime-detect.d.mts +13 -0
  71. package/dist/cli/install/runtime-detect.d.mts.map +1 -1
  72. package/dist/cli/install/runtime-detect.mjs +9 -0
  73. package/dist/cli/install/runtime-detect.mjs.map +1 -1
  74. package/dist/cli/task/index-md.d.mts.map +1 -1
  75. package/dist/cli/task/index-md.mjs +14 -2
  76. package/dist/cli/task/index-md.mjs.map +1 -1
  77. package/dist/plugin/.claude-plugin/plugin.json +11 -2
  78. package/dist/plugin/agents/apt-improver.md +99 -0
  79. package/dist/plugin/agents/apt-planner.md +127 -10
  80. package/dist/plugin/agents/apt-pr-review-fixer.md +13 -9
  81. package/dist/plugin/skills/apt/SKILL.md +1 -0
  82. package/dist/plugin/skills/apt-close-task/SKILL.md +63 -1
  83. package/dist/plugin/skills/apt-debug/SKILL.md +39 -6
  84. package/dist/plugin/skills/apt-debug/appendices/diagnose-discipline.md +119 -0
  85. package/dist/plugin/skills/apt-diagram/SKILL.md +378 -0
  86. package/dist/plugin/skills/apt-diagram/appendices/design-discipline.md +97 -0
  87. package/dist/plugin/skills/apt-discuss/SKILL.md +72 -5
  88. package/dist/plugin/skills/apt-discuss/appendices/grill-discipline.md +104 -0
  89. package/dist/plugin/skills/apt-discuss/appendices/zoom-out-helper.md +79 -0
  90. package/dist/plugin/skills/apt-execute/SKILL.md +57 -5
  91. package/dist/plugin/skills/apt-execute/appendices/tdd-mode.md +107 -0
  92. package/dist/plugin/skills/apt-improve/DEEPENING.md +84 -0
  93. package/dist/plugin/skills/apt-improve/INTERFACE-DESIGN.md +97 -0
  94. package/dist/plugin/skills/apt-improve/LANGUAGE.md +104 -0
  95. package/dist/plugin/skills/apt-improve/SKILL.md +141 -0
  96. package/dist/plugin/skills/apt-plan/SKILL.md +171 -4
  97. package/dist/plugin/skills/apt-plan/adapters/conductor.md +98 -0
  98. package/dist/plugin/skills/apt-pr-review/SKILL.md +57 -18
  99. package/dist/plugin/skills/apt-prototype/LOGIC.md +109 -0
  100. package/dist/plugin/skills/apt-prototype/SKILL.md +143 -0
  101. package/dist/plugin/skills/apt-prototype/UI.md +90 -0
  102. package/dist/plugin/skills/apt-quick/SKILL.md +49 -8
  103. package/dist/plugin/skills/apt-release-notes/SKILL.md +193 -0
  104. package/dist/plugin/skills/apt-release-notes/appendices/persona-voice.md +59 -0
  105. package/dist/plugin/skills/apt-review/SKILL.md +2 -0
  106. package/dist/plugin/skills/apt-run/SKILL.md +32 -4
  107. package/dist/plugin/skills/apt-setup/SKILL.md +308 -6
  108. package/dist/plugin/skills/apt-ship/SKILL.md +122 -1
  109. package/dist/plugin/skills/apt-spar/SKILL.md +315 -0
  110. package/dist/plugin/skills/apt-triage/AGENT-BRIEF.md +84 -0
  111. package/dist/plugin/skills/apt-triage/OUT-OF-SCOPE.md +75 -0
  112. package/dist/plugin/skills/apt-triage/SKILL.md +169 -0
  113. package/dist/plugin/skills/apt-update/SKILL.md +77 -10
  114. package/dist/plugin/skills/apt-verify/SKILL.md +3 -0
  115. package/dist/plugin/skills/apt-verify-proof/SKILL.md +10 -5
  116. package/dist/plugin/skills/apt-watch-ci/SKILL.md +166 -0
  117. package/dist/plugin/skills/apt-zoom-out/SKILL.md +130 -0
  118. package/package.json +1 -1
  119. package/prompts/conductor-framework-context.md +63 -0
  120. package/prompts/conductor-system.md +11 -0
  121. package/skills/apt-close-task/SKILL.md +1 -0
  122. package/skills/apt-discuss/SKILL.md +47 -5
  123. package/skills/apt-execute/SKILL.md +9 -0
  124. package/skills/apt-plan/SKILL.md +12 -0
  125. package/skills/apt-pr-review/SKILL.md +19 -3
  126. package/skills/apt-quick/SKILL.md +19 -8
  127. package/skills/apt-researcher.md +1 -0
  128. package/skills/apt-setup/SKILL.md +33 -2
  129. package/skills/apt-verify-proof/SKILL.md +7 -5
  130. package/src/cli/ci-watch/stop-matrix.mjs +17 -0
  131. package/src/cli/commands/ci-watch.mjs +113 -2
  132. package/src/cli/commands/features-audit.mjs +164 -5
  133. package/src/cli/commands/health-check.mjs +115 -2
  134. package/src/cli/commands/init.mjs +52 -4
  135. package/src/cli/commands/modes.mjs +11 -0
  136. package/src/cli/commands/pr-review-audit-fixer.mjs +18 -5
  137. package/src/cli/commands/task.mjs +83 -5
  138. package/src/cli/commands/validate-evidence.mjs +158 -17
  139. package/src/cli/commands/vitest-doctor.mjs +173 -0
  140. package/src/cli/config/gitignore-drift.mjs +74 -3
  141. package/src/cli/config/load.mjs +188 -2
  142. package/src/cli/consistency/parse-review.mjs +6 -0
  143. package/src/cli/dispatch.mjs +23 -2
  144. package/src/cli/gate/gates/gitignore-in-sync.mjs +5 -2
  145. package/src/cli/gate/gates/review-clean.mjs +24 -19
  146. package/src/cli/gate/gates/verify-approved.mjs +97 -14
  147. package/src/cli/help.mjs +7 -2
  148. package/src/cli/install/runtime-detect.mjs +9 -0
  149. package/src/cli/task/index-md.mjs +15 -2
  150. package/templates/config.json +2 -7
  151. 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)
@@ -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'
@@ -101,6 +106,11 @@ const TRANSFORM_LOADERS = new Map(
101
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 }}
102
107
  */
103
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))
104
114
  const flagSet = new Set(args.filter((a) => a.startsWith('--')))
105
115
  let target = null
106
116
  for (let i = 0; i < args.length; i++) {
@@ -721,7 +731,11 @@ async function interactiveInterview(targetDir, version, canonicalRoot, archetype
721
731
  if (suggested.size === 0) suggested.add('claude')
722
732
  const sel = await clack.multiselect({
723
733
  message: 'Pick the runtimes to install Aperant into',
724
- 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
+ })),
725
739
  initialValues: [...suggested],
726
740
  required: true,
727
741
  })
@@ -947,7 +961,7 @@ async function interactiveInterview(targetDir, version, canonicalRoot, archetype
947
961
  * @param {Record<string, any>} config
948
962
  * @param {InstallAnswers} answers
949
963
  */
950
- function applyInterviewAnswers(config, answers) {
964
+ export function applyInterviewAnswers(config, answers, targetDir = null) {
951
965
  if (config.autonomy) {
952
966
  config.autonomy.default = answers.autonomyDefault
953
967
  }
@@ -962,8 +976,22 @@ function applyInterviewAnswers(config, answers) {
962
976
  }
963
977
  if (config.multi_model?.cross_verification) {
964
978
  const xverify = answers.crossVerificationTools
979
+ // `enabled` is project policy (is cross-verification on for this
980
+ // project?) — stays in shared `config.json`.
965
981
  config.multi_model.cross_verification.enabled = xverify.length > 0
966
- 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
+ }
967
995
  }
968
996
  config.project_type =
969
997
  answers.archetype.type === 'brownfield' ? 'unknown' : config.project_type || 'unknown'
@@ -1482,7 +1510,11 @@ function finishInit(
1482
1510
  () => {
1483
1511
  if (!existsSync(configDest)) {
1484
1512
  const template = JSON.parse(readFileSync(configTemplate, 'utf-8'))
1485
- 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)
1486
1518
  atomicWriteJson(configDest, template)
1487
1519
  configCreated = true
1488
1520
  }
@@ -1510,6 +1542,22 @@ function finishInit(
1510
1542
  )
1511
1543
  }
1512
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
+
1513
1561
  const stateTemplate = resolve(canonicalRoot, 'templates', 'state.json')
1514
1562
  const stateDest = join(aperantDir, 'state.json')
1515
1563
  let stateCreated = false
@@ -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 = {
@@ -668,10 +668,14 @@ export function decideVerdict({ statusLine, fixesApplied, diffFiles }) {
668
668
  }
669
669
  }
670
670
 
671
- function readFixesAppliedForFixer(reviewDir, iteration, fixer) {
672
- // Try the group-specific naming first (fixes-applied-group{N}.md) for
673
- // fixer-{N}, then fall back to the unscoped fixes-applied.md which the
674
- // self-reviewer writes to self-review.md separately.
671
+ export function readFixesAppliedForFixer(reviewDir, iteration, fixer) {
672
+ // BUG-030: strict-ordered candidate list. Parallel fixers write to
673
+ // distinct fixer-named markdowns (fixes-applied-fixer-{N}.md AND/OR
674
+ // fixes-applied-{fixer}.md); the legacy group-named form remains for
675
+ // backward-compat with reviews started before this fix. fixes-applied.md
676
+ // (unscoped) stays last as the legacy single-fixer fallback. NO broad
677
+ // glob — false-positives on the wrong fixer's file are what caused the
678
+ // `hallucinated` flake (4 sightings in 3 days per spec-gaps).
675
679
  const iterDir = join(reviewDir, 'iterations', String(iteration))
676
680
  const candidates = []
677
681
  if (fixer === 'self-reviewer') {
@@ -681,7 +685,9 @@ function readFixesAppliedForFixer(reviewDir, iteration, fixer) {
681
685
  const match = /^fixer-(\d+)$/.exec(fixer)
682
686
  if (match) {
683
687
  candidates.push(join(iterDir, `fixes-applied-group${match[1]}.md`))
688
+ candidates.push(join(iterDir, `fixes-applied-fixer-${match[1]}.md`))
684
689
  }
690
+ candidates.push(join(iterDir, `fixes-applied-${fixer}.md`))
685
691
  candidates.push(join(iterDir, 'fixes-applied.md'))
686
692
  }
687
693
  for (const c of candidates) {
@@ -802,7 +808,14 @@ export function cmdPrReviewAuditFixer(projectDir, extraArgs) {
802
808
 
803
809
  const worktreeArg = flags.get('worktree')
804
810
  if (!worktreeArg) return err('Required: --worktree <path>')
805
- const worktree = resolve(worktreeArg)
811
+ // BUG-015: resolve relative to the passed projectDir (not process.cwd()).
812
+ // Callers invoke audit-fixer from a non-worktree cwd (the main repo) with
813
+ // a relative --worktree like `../worktrees/foo`; resolving against cwd
814
+ // returned a bogus path that didn't exist, causing the audit to see zero
815
+ // staged files and false-positive `hallucinated`. The existing `cwd:
816
+ // worktree` at the spawn site (line 437-473) already scopes git
817
+ // invocations correctly, so no `git -C` change is needed.
818
+ const worktree = resolve(projectDir, worktreeArg)
806
819
 
807
820
  const statusLineArg = flags.get('status-line')
808
821
  // Missing / empty status line is fail-closed to hallucinated, not a
@@ -14,7 +14,11 @@
14
14
  import { spawnSync } from 'node:child_process'
15
15
  import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
16
16
  import { join, resolve } from 'node:path'
17
- import { getTaskIsolationConfig, loadProjectConfig } from '../config/load.mjs'
17
+ import {
18
+ getTaskIsolationConfig,
19
+ loadMergedProjectConfig,
20
+ loadProjectConfig,
21
+ } from '../config/load.mjs'
18
22
  import { findMostRecentEventForTask } from '../coordination/event-log.mjs'
19
23
  import { createCoordinationStore } from '../coordination/store.mjs'
20
24
  import { getGitIdentity } from '../git/identity.mjs'
@@ -127,9 +131,16 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
127
131
  // a later roadmap add-milestone/add-phase may populate it.
128
132
  }
129
133
 
130
- // Load config for date format preference
131
- const configPath = join(targetDir, '.aperant', 'config.json')
132
- const config = existsSync(configPath) ? JSON.parse(readFileSync(configPath, 'utf-8')) : {}
134
+ // AUDIT-001: read through the merged loader so local-only
135
+ // overrides for preferences.date_format / preferences.time_format
136
+ // win over the shared config.json fallback.
137
+ // Guard: malformed config.local.json must not crash task creation.
138
+ let config
139
+ try {
140
+ config = loadMergedProjectConfig(targetDir) ?? {}
141
+ } catch {
142
+ config = {}
143
+ }
133
144
  // User-supplied --id bypasses the collision guard (caller owns the
134
145
  // uniqueness contract). Generated ids run through a numeric-counter
135
146
  // disambiguator that appends -2, -3, … when the candidate already
@@ -982,7 +993,50 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
982
993
  state.last_activity = new Date().toISOString()
983
994
  atomicWriteJson(statePath, state)
984
995
  if (prUrlForCiWatch) {
985
- ciWatchResult = armCiWatchAfterPrUrl(targetDir, task, prUrlForCiWatch)
996
+ // BUG-027 (a): pre-arm gh check. If the PR is already
997
+ // MERGED/CLOSED, skip arming so we don't leak a new
998
+ // watcher state file we'll immediately have to GC. gh
999
+ // rate-limit / offline / ENOENT falls back to arm (mirror
1000
+ // the existing rate_limited/offline patterns at lines
1001
+ // 1322/1326) — non-fatal so a transient gh failure
1002
+ // doesn't prevent legitimate watcher arming.
1003
+ const ghFixture = process.env.APT_GH_FIXTURE_JSON
1004
+ let terminalState = null
1005
+ if (ghFixture) {
1006
+ try {
1007
+ const parsed = JSON.parse(ghFixture)
1008
+ if (parsed?.state === 'MERGED' || parsed?.state === 'CLOSED') {
1009
+ terminalState = parsed.state
1010
+ }
1011
+ } catch {
1012
+ /* malformed fixture → fall through to arm */
1013
+ }
1014
+ } else {
1015
+ try {
1016
+ const res = spawnSync('gh', ['pr', 'view', prUrlForCiWatch, '--json', 'state'], {
1017
+ cwd: targetDir,
1018
+ encoding: 'utf-8',
1019
+ })
1020
+ if (res.status === 0 && res.stdout) {
1021
+ const parsed = JSON.parse(res.stdout)
1022
+ if (parsed?.state === 'MERGED' || parsed?.state === 'CLOSED') {
1023
+ terminalState = parsed.state
1024
+ }
1025
+ }
1026
+ /* non-zero exit / ENOENT / parse error → fall through to arm */
1027
+ } catch {
1028
+ /* never block arming on gh transport error */
1029
+ }
1030
+ }
1031
+ if (terminalState) {
1032
+ ciWatchResult = {
1033
+ ci_watch_armed: false,
1034
+ ci_watch_skip_reason: 'pr-terminal-state',
1035
+ ci_watch: null,
1036
+ }
1037
+ } else {
1038
+ ciWatchResult = armCiWatchAfterPrUrl(targetDir, task, prUrlForCiWatch)
1039
+ }
986
1040
  }
987
1041
 
988
1042
  regenerateTaskIndex(targetDir)
@@ -1479,6 +1533,30 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
1479
1533
  mergedAt: parsed.mergedAt ?? null,
1480
1534
  })
1481
1535
 
1536
+ // BUG-027 (b): tear down any armed ci-watch state for this PR.
1537
+ // `doStop` is idempotent (returns `not-found` for missing state
1538
+ // files), so calling unconditionally here is safe. Without this
1539
+ // the watcher state file leaks past PR-terminal-state and
1540
+ // downstream operations (ci-watch sweep, health-check) flag it
1541
+ // as orphaned. Failure is logged into errors[] as a warning;
1542
+ // the merge close itself has already succeeded.
1543
+ try {
1544
+ const prNumber = parsed.number ?? extractPrNumber(task.pr_url)
1545
+ if (prNumber !== null) {
1546
+ cmdCiWatch('stop', targetDir, [
1547
+ '--pr',
1548
+ String(prNumber),
1549
+ '--reason',
1550
+ 'task-closed-merged',
1551
+ ])
1552
+ }
1553
+ } catch (e) {
1554
+ errors.push({
1555
+ id,
1556
+ reason: `ci-watch-stop-failed: ${e?.message ?? String(e)}`,
1557
+ })
1558
+ }
1559
+
1482
1560
  // Append a pending_narration[] ledger entry on successful merged close
1483
1561
  // so /apt:close-task --narrate-only can drain it later. The ledger is
1484
1562
  // additive-only; failure to write must never undo the close above