@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
@@ -3,22 +3,54 @@
3
3
  * G4 verify-approved — blocking gate for `apt:ship`.
4
4
  *
5
5
  * Requires {task_dir}/qa_signoff.json to exist, parse, and carry
6
- * verdict === "approved". The ship skill's existing prose only asked the
7
- * agent to "confirm" approval; this gate enforces it deterministically.
6
+ * verdict === "approved" (BUG-013: normalized via parseQaSignoff case
7
+ * and underscore/hyphen variants collapse to canonical). BUG-012: also
8
+ * accepts `approved-with-notes` when config.qa.approved_with_notes_threshold
9
+ * is set AND every dimension score meets the threshold (best-effort when
10
+ * dimensions are missing or in an unknown shape).
8
11
  *
9
- * NOTE: the file may use either `verdict` (newer) or `status` (older) field
10
- * name for the QA result. We accept both for forwards/backwards compatibility,
11
- * but only the string "approved" passes — "approved-with-notes" fails
12
- * because ship-time deserves the strongest signal.
12
+ * Accepts both `verdict` (newer) and `status` (older) field names — that
13
+ * normalization is delegated to parseQaSignoff.
13
14
  */
14
15
 
15
16
  import { existsSync, readFileSync } from 'node:fs'
16
17
  import { join } from 'node:path'
18
+ import { parseQaSignoff } from '../../consistency/parse-qa.mjs'
17
19
  import { registerGate } from '../registry.mjs'
18
20
 
21
+ /**
22
+ * Extract dimension scores from the qa_signoff.json shape. We've seen two
23
+ * shapes in the wild:
24
+ * - Array: `dimensions: [{name: 'security', score: 0.9}, ...]`
25
+ * - Object: `dimensions: {security: 0.9, performance: 0.85}` OR
26
+ * `dimensions: {security: {score: 0.9}, ...}`
27
+ * Returns an array of numeric scores, or null when no recognizable shape
28
+ * is present (best-effort accept per brief).
29
+ */
30
+ function extractDimensionScores(doc) {
31
+ const dims = doc.dimensions ?? doc.qa?.dimensions ?? null
32
+ if (!dims) return null
33
+ if (Array.isArray(dims)) {
34
+ const scores = dims
35
+ .map((d) => (typeof d?.score === 'number' ? d.score : null))
36
+ .filter((s) => s !== null)
37
+ return scores.length > 0 ? scores : null
38
+ }
39
+ if (typeof dims === 'object') {
40
+ const scores = []
41
+ for (const v of Object.values(dims)) {
42
+ if (typeof v === 'number') scores.push(v)
43
+ else if (typeof v?.score === 'number') scores.push(v.score)
44
+ }
45
+ return scores.length > 0 ? scores : null
46
+ }
47
+ return null
48
+ }
49
+
19
50
  const gate = {
20
51
  id: 'verify-approved',
21
- description: 'qa_signoff.json exists with verdict === "approved"',
52
+ description:
53
+ 'qa_signoff.json exists with verdict === "approved" (or "approved-with-notes" when configured)',
22
54
  severity: 'blocking',
23
55
  skills: ['ship'],
24
56
  async check(ctx) {
@@ -41,18 +73,69 @@ const gate = {
41
73
  inputs,
42
74
  }
43
75
  }
44
- const verdict = doc.verdict ?? doc.status ?? null
45
- if (verdict !== 'approved') {
76
+
77
+ // BUG-013: route through parseQaSignoff so the verdict is
78
+ // case-insensitive AND tolerates `_↔-` (so 'APPROVED', 'Approved',
79
+ // ' approved ', 'APPROVED_WITH_NOTES' all behave canonically). The
80
+ // pre-fix gate compared the raw string against `'approved'` which
81
+ // false-rejected every legitimate variant.
82
+ const { verdict } = parseQaSignoff(doc)
83
+
84
+ if (verdict === 'approved') {
46
85
  return {
47
- status: 'fail',
48
- reason: `qa_signoff.json verdict is "${verdict}" (expected "approved")`,
49
- evidence: { verdict },
86
+ status: 'pass',
87
+ reason: 'qa_signoff.json verdict normalized to "approved"',
50
88
  inputs,
51
89
  }
52
90
  }
91
+
92
+ // BUG-012: accept `approved-with-notes` when the project has opted
93
+ // in via config.qa.approved_with_notes_threshold. The threshold is
94
+ // the minimum per-dimension score required. Best-effort: when
95
+ // dimensions are missing / in an unknown shape we still accept and
96
+ // log, rather than hard-failing, so projects that haven't started
97
+ // scoring dimensions yet aren't blocked.
98
+ if (verdict === 'approved-with-notes') {
99
+ const threshold = ctx.config?.qa?.approved_with_notes_threshold
100
+ if (typeof threshold !== 'number') {
101
+ return {
102
+ status: 'fail',
103
+ reason:
104
+ 'qa_signoff.json verdict is "approved-with-notes" but config.qa.approved_with_notes_threshold is not set',
105
+ evidence: { verdict, threshold: threshold ?? null },
106
+ inputs,
107
+ }
108
+ }
109
+ const scores = extractDimensionScores(doc)
110
+ if (scores === null) {
111
+ return {
112
+ status: 'pass',
113
+ reason: `qa_signoff.json verdict is "approved-with-notes" and threshold ${threshold} is enabled; dimensions missing or unknown-shape — accepting best-effort`,
114
+ evidence: { verdict, threshold, dimensions_present: false },
115
+ inputs,
116
+ }
117
+ }
118
+ const failing = scores.filter((s) => s < threshold)
119
+ if (failing.length > 0) {
120
+ return {
121
+ status: 'fail',
122
+ reason: `qa_signoff.json verdict is "approved-with-notes" but ${failing.length}/${scores.length} dimension score(s) below threshold ${threshold}`,
123
+ evidence: { verdict, threshold, scores, failing },
124
+ inputs,
125
+ }
126
+ }
127
+ return {
128
+ status: 'pass',
129
+ reason: `qa_signoff.json verdict is "approved-with-notes" and all ${scores.length} dimension score(s) meet threshold ${threshold}`,
130
+ evidence: { verdict, threshold, scores },
131
+ inputs,
132
+ }
133
+ }
134
+
53
135
  return {
54
- status: 'pass',
55
- reason: 'qa_signoff.json.verdict === "approved"',
136
+ status: 'fail',
137
+ reason: `qa_signoff.json verdict is "${verdict}" (expected "approved" or "approved-with-notes" with threshold)`,
138
+ evidence: { verdict },
56
139
  inputs,
57
140
  }
58
141
  },
package/src/cli/help.mjs CHANGED
@@ -31,9 +31,10 @@ Commands:
31
31
  task update <dir> --id <id> [--lifecycle-phase <p>] [--pr-url <url>] [--subtasks-total <N>] [--depends-on <refs>] Update non-linkage task state; --pr-url may arm ci-watch per ci_watch.after_ship
32
32
  task move <dir> --id <id> [--to-scope <s>] [--to-milestone <id>] [--to-phase <id>] [--to-roadmap-ref-url <url>] [--reason <text>] [--force] Reassign scope/milestone/phase with history
33
33
  task close <dir> --id <task-id> [--verdict <v>] Close task and move to completed
34
+ task close-merged <dir> [--task <id>] [--narrate-only] [--dry-run] Sweep shipped-pending-merge tasks for confirmed merges (--narrate-only returns pending_narration[] entries read-only without closing).
34
35
  task narration-drain <dir> --task <task-id> Remove a row from state.pending_narration[] under withFileLock (called by /apt:close-task --narrate-only after a successful narrator spawn)
35
36
  task complete-subtask <dir> --task-id <id> --subtask-id <sid|a+b+c> [--commit <sha>] [--notes <text>] Atomic plan ↔ build-progress sync
36
- features-audit <dir> [--diff-files <csv>|--since <ref>] [--apply-stubs] [--format json|md] Audit features registry for unregistered feature-qualifying files
37
+ features-audit <dir> [--task-id <id>] [--no-worktree-redirect] [--diff-files <csv>|--since <ref>] [--apply-stubs] [--format json|md] Audit features registry for unregistered feature-qualifying files (BUG-025b: --task-id redirects writes to active task's worktree)
37
38
  docs-audit <dir> [--diff-files <csv>|--since <ref>|--dry-run] [--policy <path>] [--format json|md] Audit documented surfaces for docs-drift
38
39
 
39
40
  lock claim <dir> --subtask <id> Claim exclusive lock on a subtask
@@ -109,7 +110,9 @@ Commands:
109
110
 
110
111
  migrate-pr-reviews <project-dir> Migrate legacy .aperant/reviews/ → .aperant/pr-reviews/pr-{N}/rounds/{R}/ (idempotent)
111
112
 
112
- validate-evidence <findings-dir> --worktree <path> Enforce the Evidence Rule (literal substring + multi-file check); mutates findings markdown in place
113
+ validate-evidence <findings-dir> --worktree <path> [--allow-approx-evidence] [--approx-floor <n>] [--llm-validator-confirmed] Enforce the Evidence Rule (literal substring + multi-file check); mutates findings markdown in place. BUG-014: --allow-approx-evidence opts into Jaccard + line-anchor fallback (downgrade to dismissed_evidence_approx_confirmed unless --llm-validator-confirmed promotes to confirmed_valid_approx).
114
+
115
+ vitest doctor <project-dir> [--format json|md] BUG-026: walk vitest.config.{ts,js,mjs} files, report NODE_ENV/jsxAutomaticShim matrix, warn when JSX-rendering tests lack jsxAutomaticShim coverage (exits 1 on warnings)
113
116
 
114
117
  wfrun validate <workflow.yaml> C17: parse + schema-check a declarative YAML workflow (exits 0 on success)
115
118
  wfrun plan <workflow.yaml> [--input k=v …] C17: resolve DAG waves + inputs, emit run-plan JSON (no side effects)
@@ -125,6 +128,9 @@ Commands:
125
128
  pr-review record-comment <dir> --pr <N> --round <R> --url <url> --body-hash <sha256> [--review-id <id>] [--posted-at <iso>] Append entry to comment_timeline (Phase 6b)
126
129
  pr-review audit-fixer <dir> --review-dir <path> --iteration <N> --fixer <name> --worktree <path> --status-line <str> Audit fixer output against git state; exits 0 with JSON envelope (verdict: verified|hallucinated|skipped)
127
130
 
131
+ ci-watch start|stop|status|tick <dir> [--pr <N>] [...] /apt:watch-ci primitives — see SKILL.md for full flag matrix
132
+ ci-watch sweep <dir> --orphaned [--dry-run] BUG-027: enumerate ci-watches state files, query gh per PR, remove terminal-state (MERGED|CLOSED) entries (manual GC; rate-limited)
133
+
128
134
  Flags:
129
135
  --version Print version and exit
130
136
  --help Print usage and exit
@@ -12,6 +12,9 @@
12
12
  * for misses over silently routing into the wrong runtime.
13
13
  */
14
14
 
15
+ import { existsSync as fsExistsSync } from 'node:fs'
16
+ import { delimiter as PATH_DELIMITER, join as pathJoin } from 'node:path'
17
+
15
18
  /**
16
19
  * Canonical list of runtime identifiers accepted by detectHost's
17
20
  * `overrideCli` parameter AND by the route command's `--runtime <id>` flag.
@@ -211,5 +214,137 @@ export function detectHost(opts = {}) {
211
214
  capabilities,
212
215
  aperant_terminal: isAperantTerminal,
213
216
  aperant_socket: aperantSocket,
217
+ partner_transports: detectPartnerTransports({ env }),
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Partner-transport ladder used by `apt:spar` (FRAMEWORK-BUG-024).
223
+ *
224
+ * For each partner (codex / claude / gemini) we resolve a 3-rung ladder in
225
+ * preference order — MCP first (cleanest integration), then plugin shim
226
+ * (codex only), then raw CLI on $PATH. The first rung with `detected: true`
227
+ * wins; the spar skill's gate fails only when all three rungs report
228
+ * `detected: false`.
229
+ *
230
+ * Detection is best-effort and side-effect-free w/r/t the FS — only
231
+ * `existsSync` reads. No spawn, no network. The MCP rung uses
232
+ * `detected: "runtime-host-only"` when the framework process cannot probe
233
+ * the host's deferred-tool registry (the common case — MCP availability
234
+ * is host-internal), signalling the SKILL body to self-check at invocation
235
+ * time rather than fail the gate.
236
+ *
237
+ * Pure function w/r/t injected `env` + `existsSync` so tests can mock both
238
+ * without monkey-patching `process.env` or `node:fs`.
239
+ *
240
+ * @param {object} [opts]
241
+ * @param {NodeJS.ProcessEnv} [opts.env] — defaults to `process.env`
242
+ * @param {(p: string) => boolean} [opts.existsSync] — defaults to `node:fs` existsSync
243
+ */
244
+ export function detectPartnerTransports(opts = {}) {
245
+ const env = opts.env ?? process.env
246
+ const exists = opts.existsSync ?? fsExistsSync
247
+ const pathEntries = (env.PATH ?? '').split(PATH_DELIMITER).filter(Boolean)
248
+
249
+ return {
250
+ codex: [
251
+ detectMcpRung('codex', env),
252
+ detectPluginShim('codex', env, exists),
253
+ detectRawCli('codex', pathEntries, exists),
254
+ ],
255
+ claude: [
256
+ detectMcpRung('claude', env),
257
+ // No plugin shim exists for Claude today — kept as `detected: false`
258
+ // for ladder-shape symmetry so consumers can index by rung uniformly.
259
+ { rung: 'plugin', detected: false, reason: 'no plugin shim for claude' },
260
+ detectRawCli('claude', pathEntries, exists),
261
+ ],
262
+ gemini: [
263
+ detectMcpRung('gemini', env),
264
+ { rung: 'plugin', detected: false, reason: 'no plugin shim for gemini' },
265
+ detectRawCli('gemini', pathEntries, exists),
266
+ ],
267
+ }
268
+ }
269
+
270
+ /**
271
+ * MCP rung — detected if the host exposes its deferred-tool manifest via
272
+ * `CLAUDE_MCP_DEFERRED_TOOLS` (a JSON array of tool names) AND a matching
273
+ * `mcp__<partner>-mcp__*` entry is present. When the env var is absent the
274
+ * framework cannot observe the host's tool registry from outside, so we
275
+ * return `detected: "runtime-host-only"` — the SKILL body interprets this
276
+ * as "try it; if the host has the tool, use it; otherwise fall through".
277
+ */
278
+ function detectMcpRung(partner, env) {
279
+ const toolPrefix = mcpToolPrefix(partner)
280
+ const manifest = env.CLAUDE_MCP_DEFERRED_TOOLS
281
+ if (manifest) {
282
+ try {
283
+ const tools = JSON.parse(manifest)
284
+ if (
285
+ Array.isArray(tools) &&
286
+ tools.some((t) => typeof t === 'string' && t.startsWith(toolPrefix))
287
+ ) {
288
+ return { rung: 'mcp', detected: true, invocation: `${toolPrefix}*` }
289
+ }
290
+ return {
291
+ rung: 'mcp',
292
+ detected: false,
293
+ reason: `no ${toolPrefix}* tool in CLAUDE_MCP_DEFERRED_TOOLS`,
294
+ }
295
+ } catch {
296
+ // Malformed manifest — fall through to runtime-deferred
297
+ }
298
+ }
299
+ return { rung: 'mcp', detected: 'runtime-host-only', invocation: `${toolPrefix}*` }
300
+ }
301
+
302
+ function mcpToolPrefix(partner) {
303
+ if (partner === 'codex') return 'mcp__codex-mcp__'
304
+ if (partner === 'claude') return 'mcp__claude-code-mcp__'
305
+ if (partner === 'gemini') return 'mcp__gemini-mcp__'
306
+ return `mcp__${partner}-mcp__`
307
+ }
308
+
309
+ /**
310
+ * Plugin-shim rung — Claude Code plugins drop helper scripts under
311
+ * `${CLAUDE_PLUGIN_ROOT}/scripts/<partner>-companion.mjs`. Only codex
312
+ * ships a companion today; the caller passes `'codex'` to probe.
313
+ */
314
+ function detectPluginShim(partner, env, exists) {
315
+ const root = env.CLAUDE_PLUGIN_ROOT
316
+ if (!root) {
317
+ return { rung: 'plugin', detected: false, reason: 'CLAUDE_PLUGIN_ROOT unset' }
318
+ }
319
+ const script = pathJoin(root, 'scripts', `${partner}-companion.mjs`)
320
+ if (!exists(script)) {
321
+ return { rung: 'plugin', detected: false, reason: `${script} not found` }
214
322
  }
323
+ return { rung: 'plugin', detected: true, invocation: `node "${script}" task` }
324
+ }
325
+
326
+ /**
327
+ * Raw-CLI rung — probe pre-split $PATH entries for the partner binary using
328
+ * existsSync (no spawn). Accepts a pre-split array so detectPartnerTransports
329
+ * can parse $PATH once and share it across all partner probes.
330
+ *
331
+ * The binary name matches the partner id for all three currently-supported
332
+ * partners (codex / claude / gemini).
333
+ */
334
+ function detectRawCli(partner, pathEntries, exists) {
335
+ const bin = partner
336
+ for (const dir of pathEntries) {
337
+ const candidate = pathJoin(dir, bin)
338
+ if (exists(candidate)) {
339
+ return { rung: 'cli', detected: true, invocation: rawCliInvocation(partner) }
340
+ }
341
+ }
342
+ return { rung: 'cli', detected: false, reason: `${bin} not found on $PATH` }
343
+ }
344
+
345
+ function rawCliInvocation(partner) {
346
+ if (partner === 'codex') return 'codex exec'
347
+ if (partner === 'claude') return 'claude -p'
348
+ if (partner === 'gemini') return 'gemini -m'
349
+ return partner
215
350
  }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * install/legacy-paths.mjs — shared registry of legacy install topologies
3
+ * the framework once wrote to but no longer owns. Single source of truth
4
+ * for both:
5
+ *
6
+ * - BUG-021: `runLegacyCleanup` in `install/runtime-migrate.mjs` knows
7
+ * which files under a legacy root are framework-owned (safe to
8
+ * remove) vs user-added (preserve).
9
+ * - BUG-022: `readSkillInstallState` in `util/skill-installs.mjs` falls
10
+ * back to `version_file` here when no manifest is present, so a
11
+ * pre-manifest install still surfaces as "installed" rather than
12
+ * "missing-skills".
13
+ *
14
+ * Derivation: the codex `framework_files` list was extracted from
15
+ * `git show 1a8dd8a8 --stat` (the original "make Codex a first-class
16
+ * skill install surface" commit) — every `.agents/skills/…` path the
17
+ * installer once wrote, including the `.apt-version` stamp and the
18
+ * apt-pr-review / apt-discuss / apt-verify-proof sub-files. When the
19
+ * codex installer was retargeted to `.codex/`, that entire tree became
20
+ * orphaned on upgrading projects.
21
+ *
22
+ * Schema:
23
+ * { [runtimeId]: {
24
+ * install_root: relative path the runtime once wrote into,
25
+ * version_file: relative path of the legacy `.apt-version` stamp,
26
+ * framework_files: array of relative paths the installer once owned
27
+ * (under install_root).
28
+ * } }
29
+ *
30
+ * Frozen via Object.freeze so callers cannot mutate the shared registry.
31
+ */
32
+
33
+ /** @type {Record<string, { install_root: string, version_file: string, framework_files: ReadonlyArray<string> }>} */
34
+ export const LEGACY_INSTALL_PATHS = Object.freeze({
35
+ codex: Object.freeze({
36
+ install_root: '.agents/skills',
37
+ version_file: '.agents/skills/.apt-version',
38
+ framework_files: Object.freeze([
39
+ '.apt-version',
40
+ 'apt/SKILL.md',
41
+ 'apt-debug/SKILL.md',
42
+ 'apt-discuss/SKILL.md',
43
+ 'apt-discuss/examples/rationalization-example.md',
44
+ 'apt-docs/SKILL.md',
45
+ 'apt-execute/SKILL.md',
46
+ 'apt-pause/SKILL.md',
47
+ 'apt-plan/SKILL.md',
48
+ 'apt-pr-review/SCHEMA.md',
49
+ 'apt-pr-review/SKILL.md',
50
+ 'apt-pr-review/prompts/fp-gate-codex.md',
51
+ 'apt-pr-review/templates/external-comment.md',
52
+ 'apt-quick/SKILL.md',
53
+ 'apt-resume/SKILL.md',
54
+ 'apt-review/SKILL.md',
55
+ 'apt-roadmap/SKILL.md',
56
+ 'apt-roundtable/SKILL.md',
57
+ 'apt-run/SKILL.md',
58
+ 'apt-scan/SKILL.md',
59
+ 'apt-setup/SKILL.md',
60
+ 'apt-ship/SKILL.md',
61
+ 'apt-stress-test/SKILL.md',
62
+ 'apt-verify/SKILL.md',
63
+ 'apt-verify-proof/SKILL.md',
64
+ 'apt-verify-proof/references/multi-model-setup.md',
65
+ 'apt-verify-proof/references/multi-model-verification.md',
66
+ 'apt-verify-proof/references/proof-capture.md',
67
+ ]),
68
+ }),
69
+ })
@@ -33,6 +33,11 @@ import { MANIFEST_FILENAME } from './manifest.mjs'
33
33
  * target mapped to a host CLI (e.g. `pi`,
34
34
  * which has a detectable host but no apt
35
35
  * install root of its own).
36
+ * @property {string} [displayLabel] Optional user-facing label (e.g. "Kilo Code").
37
+ * When unset, callers fall back to the
38
+ * capitalized form of `id`. Use for runtimes
39
+ * whose product branding differs from the
40
+ * internal id (C58: `kilo` → "Kilo Code").
36
41
  */
37
42
 
38
43
  /** @type {RuntimeDescriptor[]} */
@@ -78,6 +83,10 @@ export const RUNTIMES = Object.freeze([
78
83
  installRoot: '.kilo',
79
84
  markers: ['.kilo/config.json'],
80
85
  cliId: 'kilo',
86
+ // C58 (0.7.2): user-visible label. Internal id stays `kilo` (zero
87
+ // migration per spec ID-01). `--kilocode` is also accepted as a
88
+ // deprecated alias of `--kilo` at the CLI flag parser layer.
89
+ displayLabel: 'Kilo Code',
81
90
  },
82
91
  {
83
92
  id: 'copilot',
@@ -0,0 +1,252 @@
1
+ /**
2
+ * install/runtime-migrate.mjs — at-install cleanup of legacy install
3
+ * trees the framework no longer owns (FRAMEWORK-BUG-021).
4
+ *
5
+ * Codex was originally installed into `.agents/skills/` (commit 1a8dd8a8)
6
+ * and later moved to `.codex/`. Projects that upgraded across that move
7
+ * end up with a stale `.agents/skills/` tree that:
8
+ *
9
+ * - keeps `.apt-version` on disk → the legacy fallback in
10
+ * `readSkillInstallState` reports a stale codex install.
11
+ * - shows up in `health-check` as `stale-skills`.
12
+ * - leaves dozens of orphaned SKILL.md files the runtime no longer
13
+ * reads.
14
+ *
15
+ * `runLegacyCleanup` runs once per `apt-tools init` when codex is in the
16
+ * requested runtime set. It is conservative by default:
17
+ *
18
+ * - Only files listed in `LEGACY_INSTALL_PATHS.codex.framework_files`
19
+ * are removed. User-added skill directories (`my-skill/`, …) are
20
+ * preserved. If anything beyond the known framework set remains,
21
+ * `retained[]` lists those paths.
22
+ * - A present-and-current `apt-file-manifest.json` is treated as the
23
+ * authoritative "this is a managed install, leave it alone" signal:
24
+ * the function no-ops with `reason: 'managed'`.
25
+ * - The caller can suppress cleanup entirely via `--keep-legacy` at the
26
+ * init.mjs site; the function itself is dumb about flags.
27
+ */
28
+
29
+ import { existsSync, readdirSync, rmdirSync, unlinkSync } from 'node:fs'
30
+ import { dirname, join } from 'node:path'
31
+ import { LEGACY_INSTALL_PATHS } from './legacy-paths.mjs'
32
+ import { MANIFEST_FILENAME } from './manifest.mjs'
33
+
34
+ /**
35
+ * @typedef {Object} LegacyCodexDetection
36
+ * @property {boolean} present True when `.agents/skills/` exists.
37
+ * @property {string[]} frameworkFiles Subset of `framework_files` that
38
+ * actually exist on disk (relative
39
+ * to install_root).
40
+ * @property {string[]} userFiles Everything else under
41
+ * install_root (relative to
42
+ * install_root). May include both
43
+ * files and directories. Drives the
44
+ * `retained[]` warning.
45
+ * @property {boolean} hasAptVersion Whether the legacy `.apt-version`
46
+ * stamp is present.
47
+ * @property {boolean} hasManifest Whether an
48
+ * `apt-file-manifest.json` is
49
+ * present at install_root (signals
50
+ * a managed install — cleanup
51
+ * should no-op).
52
+ */
53
+
54
+ const LEGACY = LEGACY_INSTALL_PATHS.codex
55
+
56
+ /**
57
+ * Recursively enumerate every file path under `dir`, returning paths
58
+ * relative to `dir`. Used to compute the "user files" set (anything not
59
+ * in the framework_files list).
60
+ *
61
+ * Returns [] when the dir doesn't exist. Swallows per-entry errors so a
62
+ * single unreadable symlink doesn't abort detection.
63
+ *
64
+ * @param {string} dir
65
+ * @returns {string[]}
66
+ */
67
+ function walkRelative(dir) {
68
+ /** @type {string[]} */
69
+ const out = []
70
+ if (!existsSync(dir)) return out
71
+ /** @param {string} current @param {string} rel */
72
+ const visit = (current, rel) => {
73
+ let entries
74
+ try {
75
+ entries = readdirSync(current, { withFileTypes: true })
76
+ } catch {
77
+ return
78
+ }
79
+ for (const entry of entries) {
80
+ const childPath = join(current, entry.name)
81
+ const childRel = rel ? `${rel}/${entry.name}` : entry.name
82
+ if (entry.isDirectory()) {
83
+ visit(childPath, childRel)
84
+ } else if (entry.isFile()) {
85
+ out.push(childRel)
86
+ }
87
+ // Symlinks / other entry types are intentionally ignored — the
88
+ // framework only ever writes regular files.
89
+ }
90
+ }
91
+ visit(dir, '')
92
+ return out
93
+ }
94
+
95
+ /**
96
+ * Probe the codex legacy install tree at `<targetDir>/.agents/skills/`.
97
+ *
98
+ * @param {string} targetDir
99
+ * @returns {LegacyCodexDetection}
100
+ */
101
+ export function detectLegacyCodexTree(targetDir) {
102
+ const installRoot = join(targetDir, LEGACY.install_root)
103
+ if (!existsSync(installRoot)) {
104
+ return {
105
+ present: false,
106
+ frameworkFiles: [],
107
+ userFiles: [],
108
+ hasAptVersion: false,
109
+ hasManifest: false,
110
+ }
111
+ }
112
+
113
+ const onDisk = walkRelative(installRoot)
114
+ const frameworkSet = new Set(LEGACY.framework_files)
115
+ const frameworkFiles = onDisk.filter((p) => frameworkSet.has(p))
116
+ const userFiles = onDisk.filter((p) => !frameworkSet.has(p) && p !== MANIFEST_FILENAME)
117
+
118
+ return {
119
+ present: true,
120
+ frameworkFiles,
121
+ userFiles,
122
+ hasAptVersion: onDisk.includes('.apt-version'),
123
+ hasManifest: onDisk.includes(MANIFEST_FILENAME),
124
+ }
125
+ }
126
+
127
+ /**
128
+ * Try to remove a directory if it is empty. Swallows ENOTEMPTY / ENOENT
129
+ * so the caller can blindly sweep candidate dirs in any order.
130
+ *
131
+ * @param {string} dir
132
+ * @returns {boolean} true iff the dir was actually removed.
133
+ */
134
+ function rmEmptyDir(dir) {
135
+ if (!existsSync(dir)) return false
136
+ try {
137
+ rmdirSync(dir)
138
+ return true
139
+ } catch {
140
+ return false
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Clean up the legacy `.agents/skills/` tree if it looks orphaned.
146
+ *
147
+ * Branches:
148
+ * 1. `present: false` OR `frameworkFiles.length === 0` → no-op,
149
+ * `{ ran: false, reason: 'no-legacy' }`.
150
+ * 2. `hasManifest: true` → managed install, no-op,
151
+ * `{ ran: false, reason: 'managed' }`.
152
+ * 3. Otherwise: per-file `unlinkSync` each framework-owned file;
153
+ * `rmdirSync` any now-empty `apt-*` subdirectory; sweep
154
+ * `.agents/skills/` only if it is empty; sweep `.agents/` only if
155
+ * its `skills/` got removed and `.agents/` is empty.
156
+ *
157
+ * Returns `{ ran: true, removed, retained, rmdirs }` on the cleanup
158
+ * branch:
159
+ * - `removed` — relative paths under `install_root` that were unlinked.
160
+ * - `retained` — relative paths under `install_root` that were NOT
161
+ * removed (user-added skills / files not in the legacy set).
162
+ * - `rmdirs` — absolute paths of directories that were removed
163
+ * (apt-* subdirs, the install_root itself, .agents/), in removal order.
164
+ *
165
+ * Atomicity: per-file unlink is non-atomic. A partial-failure scenario
166
+ * (process killed mid-loop) is tolerable — the next `init` run will
167
+ * re-detect the remaining framework files and finish the sweep.
168
+ *
169
+ * @param {{ targetDir: string, dryRun?: boolean }} args
170
+ * @returns {
171
+ * | { ran: false, reason: 'no-legacy' | 'managed' }
172
+ * | { ran: true, removed: string[], retained: string[], rmdirs: string[] }
173
+ * }
174
+ */
175
+ export function runLegacyCleanup({ targetDir, dryRun = false }) {
176
+ const detection = detectLegacyCodexTree(targetDir)
177
+
178
+ if (!detection.present || detection.frameworkFiles.length === 0) {
179
+ return { ran: false, reason: 'no-legacy' }
180
+ }
181
+ if (detection.hasManifest) {
182
+ return { ran: false, reason: 'managed' }
183
+ }
184
+
185
+ const installRoot = join(targetDir, LEGACY.install_root)
186
+ const removed = []
187
+ const rmdirs = []
188
+
189
+ if (dryRun) {
190
+ // Dry-run: report what WOULD be removed without touching the filesystem.
191
+ // `retained` is the user-file set from the pre-walk (no sweep occurred).
192
+ // `rmdirs` is the set of apt-* subdirs that would become empty after removal.
193
+ const wouldRemove = detection.frameworkFiles
194
+ const retained = detection.userFiles
195
+ const candidateDirs = new Set()
196
+ for (const rel of wouldRemove) {
197
+ let parent = dirname(rel)
198
+ while (parent !== '.' && parent !== '') {
199
+ candidateDirs.add(parent)
200
+ parent = dirname(parent)
201
+ }
202
+ }
203
+ const wouldRmdirs = [...candidateDirs]
204
+ .sort((a, b) => b.length - a.length)
205
+ .map((rel) => join(installRoot, rel))
206
+ return { ran: true, dryRun: true, removed: wouldRemove, retained, rmdirs: wouldRmdirs }
207
+ }
208
+
209
+ for (const rel of detection.frameworkFiles) {
210
+ const abs = join(installRoot, rel)
211
+ try {
212
+ unlinkSync(abs)
213
+ removed.push(rel)
214
+ } catch {
215
+ /* best-effort: leave a partial sweep; next run finishes it */
216
+ }
217
+ }
218
+
219
+ // Sweep any now-empty `apt-*/` subdirs the unlinks left behind.
220
+ // Walk parent dirs of every removed file in reverse-depth order so
221
+ // nested `apt-pr-review/prompts/` empties before `apt-pr-review/`.
222
+ const candidateDirs = new Set()
223
+ for (const rel of removed) {
224
+ let parent = dirname(rel)
225
+ while (parent !== '.' && parent !== '') {
226
+ candidateDirs.add(parent)
227
+ parent = dirname(parent)
228
+ }
229
+ }
230
+ const sortedDirs = [...candidateDirs].sort((a, b) => b.length - a.length)
231
+ for (const rel of sortedDirs) {
232
+ const abs = join(installRoot, rel)
233
+ if (rmEmptyDir(abs)) rmdirs.push(abs)
234
+ }
235
+
236
+ // Sweep `.agents/skills/` only if fully empty after the per-file
237
+ // pass — preserves any user-added skill dirs.
238
+ if (rmEmptyDir(installRoot)) {
239
+ rmdirs.push(installRoot)
240
+ // Sweep `.agents/` only if it became empty too (i.e. no other
241
+ // `.agents/<sibling>` content the user keeps there).
242
+ const agentsRoot = dirname(installRoot)
243
+ if (rmEmptyDir(agentsRoot)) rmdirs.push(agentsRoot)
244
+ }
245
+
246
+ // Use the pre-walk's userFiles as `retained` — the post-walk only existed
247
+ // to defensively re-scan, but pre-walk already captured user files accurately.
248
+ // (PRF-002: avoid redundant detectLegacyCodexTree call.)
249
+ const retained = detection.userFiles
250
+
251
+ return { ran: true, removed, retained, rmdirs }
252
+ }