@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
@@ -0,0 +1,173 @@
1
+ /**
2
+ * commands/vitest-doctor.mjs — FRAMEWORK-BUG-026 diagnostic subcommand.
3
+ *
4
+ * Globs every `vitest.config.{ts,js,mjs}` under the project root (skipping
5
+ * node_modules), parses each for the two shim signals the framework
6
+ * centralizes in `packages/framework/test-utils/vitest.shared.mjs`:
7
+ *
8
+ * - `NODE_ENV='test'` (process.env.NODE_ENV set OR defineVitestShim active)
9
+ * - `oxc.jsx.runtime='automatic'` (jsxAutomaticShim active)
10
+ *
11
+ * Reports a matrix and warns when a workspace has JSX-rendering tests
12
+ * (any `*.test.tsx` under `src/`) but no jsxAutomaticShim coverage —
13
+ * that's the BUG-026 first-test paper cut.
14
+ *
15
+ * Exit codes:
16
+ * 0 — no warnings (or only informational matrix output)
17
+ * 1 — at least one workspace has JSX-rendering tests without
18
+ * jsxAutomaticShim coverage
19
+ */
20
+
21
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
22
+ import { join, relative, resolve } from 'node:path'
23
+ import { parseFlags } from '../util/args.mjs'
24
+ import { err, exitWith, ok } from '../util/result.mjs'
25
+
26
+ const VITEST_CONFIG_NAMES = new Set(['vitest.config.ts', 'vitest.config.js', 'vitest.config.mjs'])
27
+
28
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'out', '.next', '.aperant'])
29
+
30
+ export function cmdVitestDoctor(projectDir, extraArgs) {
31
+ if (!projectDir) return err('Usage: apt-tools vitest doctor <project-dir> [--format json|md]')
32
+ const root = resolve(projectDir)
33
+ if (!existsSync(root)) return err(`project-dir not found: ${root}`)
34
+ const flags = parseFlags(extraArgs ?? [])
35
+ const format = (flags.get('format') || 'json').toLowerCase()
36
+
37
+ const configs = findVitestConfigs(root)
38
+ const matrix = configs.map((configPath) => inspectConfig(root, configPath))
39
+ const warnings = matrix
40
+ .filter((row) => row.has_jsx_rendering_tests && !row.jsx_automatic_shim)
41
+ .map((row) => ({
42
+ workspace: row.workspace,
43
+ reason:
44
+ 'has JSX-rendering tests under src/ but vitest.config does not enable jsxAutomaticShim',
45
+ }))
46
+
47
+ const envelope = {
48
+ status: 'ok',
49
+ command: 'vitest-doctor',
50
+ project_dir: root,
51
+ configs_found: matrix.length,
52
+ matrix,
53
+ warnings,
54
+ }
55
+
56
+ if (format === 'md') {
57
+ envelope.markdown = renderMarkdown(matrix, warnings)
58
+ }
59
+
60
+ if (warnings.length > 0) {
61
+ // Exit code 1 — surface drift so the user knows to wire the shim.
62
+ return exitWith(envelope, 1)
63
+ }
64
+ return ok(envelope)
65
+ }
66
+
67
+ function findVitestConfigs(root) {
68
+ const out = []
69
+ walk(root, root, out)
70
+ return out
71
+ }
72
+
73
+ function walk(rootAbs, dirAbs, out) {
74
+ let names
75
+ try {
76
+ names = readdirSync(dirAbs)
77
+ } catch {
78
+ return
79
+ }
80
+ for (const name of names) {
81
+ if (SKIP_DIRS.has(name)) continue
82
+ const abs = join(dirAbs, name)
83
+ let st
84
+ try {
85
+ st = statSync(abs)
86
+ } catch {
87
+ continue
88
+ }
89
+ if (st.isDirectory()) {
90
+ walk(rootAbs, abs, out)
91
+ } else if (st.isFile() && VITEST_CONFIG_NAMES.has(name)) {
92
+ out.push(abs)
93
+ }
94
+ }
95
+ }
96
+
97
+ function inspectConfig(rootAbs, configPath) {
98
+ const workspace = relative(rootAbs, configPath)
99
+ const dir = join(configPath, '..')
100
+ let body = ''
101
+ try {
102
+ body = readFileSync(configPath, 'utf-8')
103
+ } catch {
104
+ return {
105
+ workspace,
106
+ node_env_shim: false,
107
+ jsx_automatic_shim: false,
108
+ has_jsx_rendering_tests: false,
109
+ error: 'read-failed',
110
+ }
111
+ }
112
+ // NODE_ENV shim: either the literal `process.env.NODE_ENV = 'test'`
113
+ // pattern OR `defineVitestShim(` (any non-explicit-false arg).
114
+ const node_env_shim =
115
+ /process\.env\.NODE_ENV\s*=\s*['"`]test['"`]/.test(body) ||
116
+ /defineVitestShim\([^)]*\)/.test(body)
117
+ // jsxAutomaticShim: literal `oxc.jsx.runtime` = 'automatic' OR
118
+ // `jsxAutomaticShim: true` inside a defineVitestShim() arg.
119
+ const jsx_automatic_shim =
120
+ /oxc\s*:\s*\{[^}]*jsx[^}]*runtime\s*:\s*['"`]automatic['"`]/s.test(body) ||
121
+ /jsxAutomaticShim\s*:\s*true/.test(body)
122
+ const has_jsx_rendering_tests = workspaceHasJsxRenderingTests(dir)
123
+ return { workspace, node_env_shim, jsx_automatic_shim, has_jsx_rendering_tests }
124
+ }
125
+
126
+ function workspaceHasJsxRenderingTests(workspaceDir) {
127
+ const srcDir = join(workspaceDir, 'src')
128
+ if (!existsSync(srcDir)) return false
129
+ return scanForTsxTest(srcDir)
130
+ }
131
+
132
+ function scanForTsxTest(dirAbs) {
133
+ let names
134
+ try {
135
+ names = readdirSync(dirAbs)
136
+ } catch {
137
+ return false
138
+ }
139
+ for (const name of names) {
140
+ if (SKIP_DIRS.has(name)) continue
141
+ const abs = join(dirAbs, name)
142
+ let st
143
+ try {
144
+ st = statSync(abs)
145
+ } catch {
146
+ continue
147
+ }
148
+ if (st.isDirectory()) {
149
+ if (scanForTsxTest(abs)) return true
150
+ } else if (st.isFile() && /\.test\.tsx$/.test(name)) {
151
+ return true
152
+ }
153
+ }
154
+ return false
155
+ }
156
+
157
+ function renderMarkdown(matrix, warnings) {
158
+ const lines = ['## vitest-doctor matrix', '']
159
+ lines.push('| workspace | NODE_ENV shim | jsxAutomaticShim | has JSX tests |')
160
+ lines.push('|-----------|---------------|------------------|---------------|')
161
+ for (const r of matrix) {
162
+ lines.push(
163
+ `| \`${r.workspace}\` | ${r.node_env_shim ? '✓' : '✗'} | ${r.jsx_automatic_shim ? '✓' : '✗'} | ${r.has_jsx_rendering_tests ? '✓' : '—'} |`,
164
+ )
165
+ }
166
+ if (warnings.length > 0) {
167
+ lines.push('', '## Warnings', '')
168
+ for (const w of warnings) {
169
+ lines.push(`- \`${w.workspace}\` — ${w.reason}`)
170
+ }
171
+ }
172
+ return lines.join('\n')
173
+ }
@@ -14,6 +14,7 @@
14
14
 
15
15
  import { existsSync, readFileSync } from 'node:fs'
16
16
  import { join } from 'node:path'
17
+ import { detectWorktree } from '../git/worktree-detect.mjs'
17
18
  import { resolveSharePolicy } from './share-policy.mjs'
18
19
  import { buildAperantGitignoreTemplate } from './upgrade-gitignore.mjs'
19
20
 
@@ -35,11 +36,66 @@ const SKIP_COMMANDS = new Set([
35
36
  '-h',
36
37
  ])
37
38
 
39
+ /**
40
+ * BUG-028 — resolve the shared (main-repo) root for the dispatch hook.
41
+ *
42
+ * Mirrors `coordination/agent-identity.mjs::resolveSharedRoot` but does NOT
43
+ * depend on that module. The CoordinationStore seam is `@internal` (R8) —
44
+ * `bin/apt-tools.mjs` and its dispatch hooks must reach git through
45
+ * `git/worktree-detect.mjs` directly, not through `coordination/*`.
46
+ *
47
+ * @internal
48
+ */
49
+ function resolveSharedRootForDrift(projectDir) {
50
+ try {
51
+ const wt = detectWorktree(projectDir)
52
+ return wt.is_worktree && wt.main_repo_root ? wt.main_repo_root : projectDir
53
+ } catch {
54
+ return projectDir
55
+ }
56
+ }
57
+
58
+ /**
59
+ * BUG-028 — pretty-print the first few missing / extra lines as a
60
+ * unified-diff snippet for the gate's `reason` field. Capped at
61
+ * `maxLines` per side so the reason stays short enough for terminal
62
+ * output. Returns the empty string when both sides match.
63
+ */
64
+ export function buildGitignoreDiffSummary(expected, actual, maxLines = 3) {
65
+ const expSet = new Set(String(expected).split('\n'))
66
+ const actSet = new Set(String(actual).split('\n'))
67
+ const missing = []
68
+ for (const line of expSet) {
69
+ if (line === '') continue
70
+ if (!actSet.has(line)) {
71
+ missing.push(line)
72
+ if (missing.length >= maxLines) break
73
+ }
74
+ }
75
+ const extra = []
76
+ for (const line of actSet) {
77
+ if (line === '') continue
78
+ if (!expSet.has(line)) {
79
+ extra.push(line)
80
+ if (extra.length >= maxLines) break
81
+ }
82
+ }
83
+ if (missing.length === 0 && extra.length === 0) return ''
84
+ const parts = []
85
+ for (const line of missing) parts.push(`+ ${line}`)
86
+ for (const line of extra) parts.push(`- ${line}`)
87
+ return parts.join('\n')
88
+ }
89
+
38
90
  /**
39
91
  * Pure comparison primitive — returns whether the on-disk `.aperant/.gitignore`
40
92
  * differs from the policy-expected template. Returns `{ drifted: false }` when
41
93
  * the `.aperant/` dir or file is absent (nothing to compare).
42
94
  *
95
+ * BUG-028: when drifted, embeds a unified-diff summary (≤3 missing/extra
96
+ * lines per side) in `reason` so the user sees exactly what drifted
97
+ * without re-running `apt-tools config upgrade-gitignore --dry-run`.
98
+ *
43
99
  * @internal
44
100
  */
45
101
  export function checkGitignoreDrift(projectRoot) {
@@ -65,7 +121,16 @@ export function checkGitignoreDrift(projectRoot) {
65
121
  } catch {
66
122
  return { drifted: false, reason: 'read-failed' }
67
123
  }
68
- return { drifted: actual !== expected, reason: null }
124
+ const drifted = actual !== expected
125
+ if (!drifted) return { drifted: false, reason: null }
126
+ const diff = buildGitignoreDiffSummary(expected, actual)
127
+ return {
128
+ drifted: true,
129
+ reason: diff
130
+ ? `.aperant/.gitignore drift detected:\n${diff}`
131
+ : '.aperant/.gitignore drift detected (whitespace-only)',
132
+ diff,
133
+ }
69
134
  }
70
135
 
71
136
  /**
@@ -74,15 +139,21 @@ export function checkGitignoreDrift(projectRoot) {
74
139
  * - `command` is not in the hot-path allowlist
75
140
  * - `.aperant/.gitignore` exists AND does not match the expected template
76
141
  *
142
+ * BUG-028: resolves the shared (main-repo) root internally so the same drift
143
+ * case produces the same verdict regardless of cwd context. Pre-fix this
144
+ * inspected `cwd` directly, producing a warning from one cwd context and a
145
+ * clean envelope from another for the same on-disk drift.
146
+ *
77
147
  * Never throws.
78
148
  *
79
149
  * @internal
80
150
  */
81
- export function emitDriftWarningIfAny(projectRoot, command) {
151
+ export function emitDriftWarningIfAny(cwd, command) {
82
152
  try {
83
153
  if (process.env.APT_SUPPRESS_GITIGNORE_DRIFT === '1') return
84
154
  if (!command || SKIP_COMMANDS.has(command)) return
85
- const { drifted } = checkGitignoreDrift(projectRoot)
155
+ const sharedRoot = resolveSharedRootForDrift(cwd)
156
+ const { drifted } = checkGitignoreDrift(sharedRoot)
86
157
  if (drifted) {
87
158
  process.stderr.write(
88
159
  '[apt-tools] warning: .aperant/.gitignore is out of sync with share policy — run `apt-tools config upgrade-gitignore . --dry-run` to see the diff.\n',
@@ -2,7 +2,7 @@
2
2
  * config/load.mjs — project config loader + task-isolation normalizer (C29).
3
3
  */
4
4
 
5
- import { existsSync, readFileSync } from 'node:fs'
5
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
6
6
  import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
7
7
  import { fileURLToPath } from 'node:url'
8
8
  import { cliToInstallRoot } from '../install/cli-to-install-root.mjs'
@@ -31,7 +31,7 @@ export function loadProjectConfig(targetDir) {
31
31
  * fallback would mask the user's broken override. See FRAMEWORK-AUDIT-001.
32
32
  *
33
33
  * @param {string} targetDir
34
- * @returns {object | null}
34
+ * @returns {Record<string, any> | null}
35
35
  */
36
36
  export function loadMergedProjectConfig(targetDir) {
37
37
  const shared = loadProjectConfig(targetDir)
@@ -57,6 +57,192 @@ function deepMerge(a, b) {
57
57
  return out
58
58
  }
59
59
 
60
+ /**
61
+ * AUDIT-001 — read+write helpers for `.aperant/config.local.json`.
62
+ * Used by the 5 deferred fields migrated out of `config.json`:
63
+ * - verification.open_report
64
+ * - multi_model.cross_verification.tools
65
+ * - preferences.date_format
66
+ * - preferences.time_format
67
+ * - preferences.quick_task_post_verify
68
+ *
69
+ * Each field has its own read/write site. The shared helpers below give
70
+ * those sites a single primitive instead of hand-rolled JSON I/O.
71
+ */
72
+ function setDottedPath(obj, dotPath, value) {
73
+ const parts = dotPath.split('.')
74
+ let cursor = obj
75
+ for (let i = 0; i < parts.length - 1; i++) {
76
+ const key = parts[i]
77
+ if (!cursor[key] || typeof cursor[key] !== 'object' || Array.isArray(cursor[key])) {
78
+ cursor[key] = {}
79
+ }
80
+ cursor = cursor[key]
81
+ }
82
+ cursor[parts[parts.length - 1]] = value
83
+ }
84
+
85
+ function getDottedPath(obj, dotPath) {
86
+ const parts = dotPath.split('.')
87
+ let cursor = obj
88
+ for (const part of parts) {
89
+ if (cursor === null || cursor === undefined || typeof cursor !== 'object') return undefined
90
+ cursor = cursor[part]
91
+ }
92
+ return cursor
93
+ }
94
+
95
+ function deleteDottedPath(obj, dotPath) {
96
+ const parts = dotPath.split('.')
97
+ let cursor = obj
98
+ for (let i = 0; i < parts.length - 1; i++) {
99
+ const key = parts[i]
100
+ if (!cursor[key] || typeof cursor[key] !== 'object') return false
101
+ cursor = cursor[key]
102
+ }
103
+ const leaf = parts[parts.length - 1]
104
+ if (leaf in cursor) {
105
+ delete cursor[leaf]
106
+ return true
107
+ }
108
+ return false
109
+ }
110
+
111
+ /**
112
+ * Read a single dotted-path field from the merged config (local overrides
113
+ * shared). Returns `undefined` when neither file carries the path.
114
+ *
115
+ * @internal
116
+ */
117
+ export function readLocalOrSharedField(targetDir, dotPath) {
118
+ const merged = loadMergedProjectConfig(targetDir)
119
+ if (!merged) return undefined
120
+ return getDottedPath(merged, dotPath)
121
+ }
122
+
123
+ /**
124
+ * Write a single dotted-path field into `.aperant/config.local.json`,
125
+ * creating the file (and intermediate path) when it doesn't exist.
126
+ *
127
+ * The shared `config.json` is NOT touched by this helper — that's the
128
+ * load-bearing AUDIT-001 contract. Callers that need to remove a stale
129
+ * entry from `config.json` (e.g. the init.mjs migrator) must do so
130
+ * explicitly.
131
+ *
132
+ * @internal
133
+ */
134
+ export function writeLocalConfigField(targetDir, dotPath, value) {
135
+ const aperantDir = join(targetDir, '.aperant')
136
+ mkdirSync(aperantDir, { recursive: true })
137
+ const localPath = join(aperantDir, 'config.local.json')
138
+ let local = {}
139
+ if (existsSync(localPath)) {
140
+ try {
141
+ local = JSON.parse(readFileSync(localPath, 'utf-8'))
142
+ if (local === null || typeof local !== 'object' || Array.isArray(local)) local = {}
143
+ } catch (e) {
144
+ throw new Error(`config.local.json parse error: ${e.message}`)
145
+ }
146
+ }
147
+ setDottedPath(local, dotPath, value)
148
+ writeFileSync(localPath, `${JSON.stringify(local, null, 2)}\n`)
149
+ }
150
+
151
+ /**
152
+ * AUDIT-001 — first-run migrator. For each dotted-path entry present in
153
+ * `.aperant/config.json`, copy the value into `.aperant/config.local.json`
154
+ * (idempotent — won't overwrite an existing local value) and delete the
155
+ * entry from `config.json`. Each field's migration is independent —
156
+ * one failure does NOT block the others (a per-field try/catch wraps
157
+ * each move).
158
+ *
159
+ * Returns an array `[{ field, migrated_value, status }]` for caller
160
+ * telemetry; `status` is `'migrated' | 'already-local' | 'absent' | 'error'`.
161
+ *
162
+ * @internal
163
+ */
164
+ export function migrateDeferredFieldsToLocalConfig(targetDir, dotPaths) {
165
+ const results = []
166
+ const sharedPath = join(targetDir, '.aperant', 'config.json')
167
+ if (!existsSync(sharedPath)) {
168
+ for (const dotPath of dotPaths) results.push({ field: dotPath, status: 'absent' })
169
+ return results
170
+ }
171
+ let shared
172
+ try {
173
+ shared = JSON.parse(readFileSync(sharedPath, 'utf-8'))
174
+ } catch {
175
+ for (const dotPath of dotPaths) results.push({ field: dotPath, status: 'error' })
176
+ return results
177
+ }
178
+ // Pre-load local config ONCE so the already-local check reads from
179
+ // local-only state (NOT from the merged loader — that would conflate
180
+ // the shared entry we're about to delete with a real local override).
181
+ const localPath = join(targetDir, '.aperant', 'config.local.json')
182
+ let localAtStart = {}
183
+ if (existsSync(localPath)) {
184
+ try {
185
+ localAtStart = JSON.parse(readFileSync(localPath, 'utf-8')) ?? {}
186
+ } catch {
187
+ localAtStart = {}
188
+ }
189
+ }
190
+ let sharedMutated = false
191
+ for (const dotPath of dotPaths) {
192
+ try {
193
+ const sharedValue = getDottedPath(shared, dotPath)
194
+ if (sharedValue === undefined) {
195
+ results.push({ field: dotPath, status: 'absent' })
196
+ continue
197
+ }
198
+ const existingLocal = getDottedPath(localAtStart, dotPath)
199
+ // Already-local: the local override already exists. Strip the
200
+ // stale shared entry but keep the local value as-is (don't
201
+ // overwrite a user-tuned local with the stale shared default).
202
+ if (existingLocal !== undefined) {
203
+ deleteDottedPath(shared, dotPath)
204
+ sharedMutated = true
205
+ results.push({ field: dotPath, status: 'already-local', migrated_value: sharedValue })
206
+ continue
207
+ }
208
+ writeLocalConfigField(targetDir, dotPath, sharedValue)
209
+ deleteDottedPath(shared, dotPath)
210
+ sharedMutated = true
211
+ results.push({ field: dotPath, status: 'migrated', migrated_value: sharedValue })
212
+ } catch (e) {
213
+ results.push({ field: dotPath, status: 'error', error: e?.message ?? String(e) })
214
+ }
215
+ }
216
+ if (sharedMutated) {
217
+ try {
218
+ writeFileSync(sharedPath, `${JSON.stringify(shared, null, 2)}\n`)
219
+ } catch (e) {
220
+ // Disk write failed after per-field local writes succeeded.
221
+ // Log a structured warning so callers can surface the partial state
222
+ // to the user (config.local.json updated, config.json NOT cleaned up).
223
+ results.push({
224
+ field: '__write_shared__',
225
+ status: 'error',
226
+ error: `migration_partial_rollback: config.local.json was updated but config.json could not be written (${e?.message ?? String(e)}). Re-run init to retry.`,
227
+ })
228
+ }
229
+ }
230
+ return results
231
+ }
232
+
233
+ /**
234
+ * AUDIT-001 — the 5 deferred fields that move to config.local.json.
235
+ * Single source of truth used by both the init migrator and the
236
+ * per-field regression tests.
237
+ */
238
+ export const AUDIT_001_LOCAL_FIELDS = Object.freeze([
239
+ 'verification.open_report',
240
+ 'multi_model.cross_verification.tools',
241
+ 'preferences.date_format',
242
+ 'preferences.time_format',
243
+ 'preferences.quick_task_post_verify',
244
+ ])
245
+
60
246
  /**
61
247
  * Return a normalized task_isolation config object with safe fallbacks.
62
248
  * Always returns a concrete object even when the project has no config at all,
@@ -95,6 +95,12 @@ function normalizeStatus(f) {
95
95
  if (typeof f.status === 'string') {
96
96
  const s = f.status.toLowerCase().trim()
97
97
  if (STATUS_MAP[s]) return STATUS_MAP[s]
98
+ // BUG-009: reviewer-skill writes `deferred-per-spec`, `deferred-followup`,
99
+ // etc. for findings that are intentionally not being fixed in the
100
+ // current scope. Collapse the variant prefix back to canonical
101
+ // `deferred` so downstream gates (review-clean, ship) treat them
102
+ // the same as a bare `status: "deferred"`.
103
+ if (s.startsWith('deferred-')) return 'deferred'
98
104
  }
99
105
  return 'open'
100
106
  }
@@ -41,7 +41,7 @@ import { cmdAuthorSkill } from './commands/author-skill.mjs'
41
41
  import { cmdBootstrap } from './commands/bootstrap.mjs'
42
42
  import { cmdCheck } from './commands/check.mjs'
43
43
  import { cmdCheckVersion } from './commands/check-version.mjs'
44
- import { cmdCiWatch } from './commands/ci-watch.mjs'
44
+ import { cmdCiWatch, cmdCiWatchSweep } from './commands/ci-watch.mjs'
45
45
  import { cmdCommit } from './commands/commit.mjs'
46
46
  import { cmdConsistency } from './commands/consistency.mjs'
47
47
  import { cmdConstitution } from './commands/constitution.mjs'
@@ -93,6 +93,7 @@ import { cmdValidateEvidence } from './commands/validate-evidence.mjs'
93
93
  import { cmdVariantGallery } from './commands/variant-gallery.mjs'
94
94
  import { cmdVariantPick } from './commands/variant-pick.mjs'
95
95
  import { cmdVariantPool } from './commands/variant-pool.mjs'
96
+ import { cmdVitestDoctor } from './commands/vitest-doctor.mjs'
96
97
  import { cmdWave } from './commands/wave.mjs'
97
98
  import { cmdWfrun } from './commands/wfrun.mjs'
98
99
  import { cmdWorkflow } from './commands/workflow.mjs'
@@ -241,6 +242,21 @@ export const DISPATCH = {
241
242
  'precompact-hook': (args) => wrap(cmdPrecompactHook)(args[1]),
242
243
  'migrate-pr-reviews': (args) => wrap(cmdMigratePrReviews)(args[1]),
243
244
  'validate-evidence': (args) => wrap(cmdValidateEvidence)(args[1], args.slice(2)),
245
+ vitest: (args) => {
246
+ // `vitest doctor <project-dir>` — currently the only `vitest`
247
+ // subcommand. Reserved namespace for future vitest diagnostics.
248
+ const subcommand = args[1]
249
+ if (subcommand === 'doctor') {
250
+ return wrap(cmdVitestDoctor)(args[2], args.slice(3))
251
+ }
252
+ return wrap(() => ({
253
+ envelope: {
254
+ status: 'error',
255
+ error: `Usage: apt-tools vitest doctor <project-dir>. Unknown subcommand: ${subcommand ?? '(none)'}`,
256
+ },
257
+ exitCode: 1,
258
+ }))()
259
+ },
244
260
 
245
261
  // ---- (positional dir, args[2] subcommand, slice(3)) shape ----
246
262
  tokens: (args) => wrap(cmdTokens)(args[1], args[2], args.slice(3)),
@@ -261,7 +277,12 @@ export const DISPATCH = {
261
277
  seed: (args) => wrap(cmdSeed)(args[1], args[2], args.slice(3)),
262
278
  workflow: (args) => wrap(cmdWorkflow)(args[1], args[2], args.slice(3)),
263
279
  'pr-review': (args) => wrap(cmdPrReview)(args[1], args[2], args.slice(3)),
264
- 'ci-watch': (args) => wrap(cmdCiWatch)(args[1], args[2], args.slice(3)),
280
+ 'ci-watch': (args) => {
281
+ // `sweep` is async; route it to the async sibling so the sync dispatcher
282
+ // path doesn't return a CommandResult | Promise<CommandResult> union.
283
+ if (args[1] === 'sweep') return wrap(cmdCiWatchSweep)(args[2], args.slice(3))
284
+ return wrap(cmdCiWatch)(args[1], args[2], args.slice(3))
285
+ },
265
286
  'plan-approval': (args) => wrap(cmdPlanApproval)(args[1], args[2], args.slice(3)),
266
287
 
267
288
  // ---- Pocock adoption (ST-5, ST-6, ST-10, ST-13) artifact writers + discoverability ----
@@ -22,10 +22,13 @@ const gate = {
22
22
  ]
23
23
  const result = checkGitignoreDrift(ctx.sharedRoot)
24
24
  if (result.drifted) {
25
+ // BUG-028: surface the unified-diff summary in the reason so
26
+ // users see exactly which template lines drifted without
27
+ // re-running `apt-tools config upgrade-gitignore --dry-run`.
25
28
  return {
26
29
  status: 'fail',
27
- reason: '.aperant/.gitignore has drifted from policy-expected template',
28
- evidence: { reason: result.reason ?? null },
30
+ reason: result.reason ?? '.aperant/.gitignore has drifted from policy-expected template',
31
+ evidence: { diff: result.diff ?? null },
29
32
  inputs,
30
33
  }
31
34
  }
@@ -3,24 +3,27 @@
3
3
  * G5 review-clean — blocking gate for `apt:ship`.
4
4
  *
5
5
  * Skipped when {task_dir}/review.json is absent (review is optional for some
6
- * tracks). When it is present, fails if any finding has
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.
10
- *
11
- * The findings array may be under `findings` or `issues` depending on the
12
- * reviewer format — we accept either.
6
+ * tracks). When it is present, delegates to `parseReview` (DRY with the
7
+ * consistency rule) which normalizes severities + statuses across all the
8
+ * shapes the reviewer skill writes: `fixed: true`, `resolved: true`,
9
+ * `status: "fixed"` (string), and `status: "deferred-*"` variants
10
+ * (BUG-009). Fails if any post-normalization finding is severity
11
+ * critical|major AND status is neither `fixed` nor `deferred`.
13
12
  */
14
13
 
15
14
  import { existsSync, readFileSync } from 'node:fs'
16
15
  import { join } from 'node:path'
16
+ import { parseReview } from '../../consistency/parse-review.mjs'
17
17
  import { registerGate } from '../registry.mjs'
18
18
 
19
- const BLOCKING_SEVERITIES = new Set(['critical', 'warning'])
19
+ // Post-normalization severities. `parseReview` maps `warning → major`,
20
+ // so the gate keys off `major` here even though the input may carry
21
+ // `warning`.
22
+ const POST_NORM_BLOCKING = new Set(['critical', 'major'])
20
23
 
21
24
  const gate = {
22
25
  id: 'review-clean',
23
- description: 'review.json has no unresolved critical/warning findings (skipped if absent)',
26
+ description: 'review.json has no unresolved critical/major findings (skipped if absent)',
24
27
  severity: 'blocking',
25
28
  skills: ['ship'],
26
29
  async check(ctx) {
@@ -43,23 +46,25 @@ const gate = {
43
46
  inputs,
44
47
  }
45
48
  }
46
- const findings = Array.isArray(doc.findings)
47
- ? doc.findings
48
- : Array.isArray(doc.issues)
49
- ? doc.issues
50
- : []
51
- const unresolved = findings.filter(
52
- (f) => BLOCKING_SEVERITIES.has(f?.severity) && f?.resolved !== true && f?.fixed !== true,
49
+ // BUG-009: delegate to parseReview so this gate sees the SAME
50
+ // normalized findings the consistency rule does. The pre-fix
51
+ // inline filter only inspected boolean `fixed`/`resolved`, missing
52
+ // `status: "fixed"` strings + `summary.findings_remaining: 0`
53
+ // shapes the reviewer skill writes; this delegation closes that
54
+ // hole and picks up `deferred-*` variants for free.
55
+ const { findings: normalized } = parseReview(doc)
56
+ const unresolved = normalized.filter(
57
+ (f) => POST_NORM_BLOCKING.has(f.severity) && f.status !== 'fixed' && f.status !== 'deferred',
53
58
  )
54
59
  if (unresolved.length > 0) {
55
60
  return {
56
61
  status: 'fail',
57
- reason: `${unresolved.length} unresolved critical/warning finding(s) in review.json`,
62
+ reason: `${unresolved.length} unresolved critical/major finding(s) in review.json`,
58
63
  evidence: {
59
64
  unresolved: unresolved.map((f) => ({
60
65
  severity: f.severity,
61
66
  id: f.id ?? null,
62
- title: f.title ?? f.message ?? null,
67
+ title: f.title ?? null,
63
68
  })),
64
69
  },
65
70
  inputs,
@@ -67,7 +72,7 @@ const gate = {
67
72
  }
68
73
  return {
69
74
  status: 'pass',
70
- reason: `review.json has no unresolved critical/warning findings (${findings.length} total)`,
75
+ reason: `review.json has no unresolved critical/major findings (${normalized.length} total)`,
71
76
  inputs,
72
77
  }
73
78
  },