@aperant/framework 0.7.4 → 0.8.1

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 (119) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +12 -0
  3. package/agents/apt-executor.md +12 -0
  4. package/agents/apt-team-docs-narrator.md +11 -0
  5. package/dist/cli/ci-watch/stop-matrix.d.mts +17 -4
  6. package/dist/cli/ci-watch/stop-matrix.d.mts.map +1 -1
  7. package/dist/cli/ci-watch/stop-matrix.mjs +41 -4
  8. package/dist/cli/ci-watch/stop-matrix.mjs.map +1 -1
  9. package/dist/cli/commands/fan-out.d.mts +2 -0
  10. package/dist/cli/commands/fan-out.d.mts.map +1 -0
  11. package/dist/cli/commands/fan-out.mjs +123 -0
  12. package/dist/cli/commands/fan-out.mjs.map +1 -0
  13. package/dist/cli/commands/init.d.mts.map +1 -1
  14. package/dist/cli/commands/init.mjs +67 -27
  15. package/dist/cli/commands/init.mjs.map +1 -1
  16. package/dist/cli/commands/modes.d.mts +22 -0
  17. package/dist/cli/commands/modes.d.mts.map +1 -1
  18. package/dist/cli/commands/modes.mjs +4 -1
  19. package/dist/cli/commands/modes.mjs.map +1 -1
  20. package/dist/cli/commands/pr-review-audit-fixer.d.mts.map +1 -1
  21. package/dist/cli/commands/pr-review-audit-fixer.mjs +86 -6
  22. package/dist/cli/commands/pr-review-audit-fixer.mjs.map +1 -1
  23. package/dist/cli/commands/roadmap.d.mts +1 -1
  24. package/dist/cli/commands/roadmap.d.mts.map +1 -1
  25. package/dist/cli/commands/roadmap.mjs +47 -4
  26. package/dist/cli/commands/roadmap.mjs.map +1 -1
  27. package/dist/cli/commands/task.d.mts.map +1 -1
  28. package/dist/cli/commands/task.mjs +15 -0
  29. package/dist/cli/commands/task.mjs.map +1 -1
  30. package/dist/cli/commands/validate-evidence.d.mts +1 -1
  31. package/dist/cli/commands/validate-evidence.d.mts.map +1 -1
  32. package/dist/cli/commands/validate-evidence.mjs +72 -47
  33. package/dist/cli/commands/validate-evidence.mjs.map +1 -1
  34. package/dist/cli/commands/wave.d.mts.map +1 -1
  35. package/dist/cli/commands/wave.mjs +91 -61
  36. package/dist/cli/commands/wave.mjs.map +1 -1
  37. package/dist/cli/commands/worktree.mjs +62 -3
  38. package/dist/cli/commands/worktree.mjs.map +1 -1
  39. package/dist/cli/config/load.mjs +1 -1
  40. package/dist/cli/config/load.mjs.map +1 -1
  41. package/dist/cli/config/parallelization.d.mts +14 -0
  42. package/dist/cli/config/parallelization.d.mts.map +1 -0
  43. package/dist/cli/config/parallelization.mjs +19 -0
  44. package/dist/cli/config/parallelization.mjs.map +1 -0
  45. package/dist/cli/coordination/agent-identity.d.mts.map +1 -1
  46. package/dist/cli/coordination/agent-identity.mjs +7 -1
  47. package/dist/cli/coordination/agent-identity.mjs.map +1 -1
  48. package/dist/cli/design/frontmatter-schema.d.mts +4 -4
  49. package/dist/cli/dispatch.d.mts.map +1 -1
  50. package/dist/cli/dispatch.mjs +2 -0
  51. package/dist/cli/dispatch.mjs.map +1 -1
  52. package/dist/cli/install/cli-to-install-root.d.mts.map +1 -1
  53. package/dist/cli/install/cli-to-install-root.mjs +0 -2
  54. package/dist/cli/install/cli-to-install-root.mjs.map +1 -1
  55. package/dist/cli/install/runtime-detect.d.mts +2 -6
  56. package/dist/cli/install/runtime-detect.d.mts.map +1 -1
  57. package/dist/cli/install/runtime-detect.mjs +8 -3
  58. package/dist/cli/install/runtime-detect.mjs.map +1 -1
  59. package/dist/cli/install/transforms/pi.d.mts +6 -0
  60. package/dist/cli/install/transforms/pi.d.mts.map +1 -0
  61. package/dist/cli/install/transforms/pi.mjs +37 -0
  62. package/dist/cli/install/transforms/pi.mjs.map +1 -0
  63. package/dist/cli/personas/sidecar.d.mts +1 -1
  64. package/dist/cli/roadmap/convex-mirror.d.mts +21 -0
  65. package/dist/cli/roadmap/convex-mirror.d.mts.map +1 -0
  66. package/dist/cli/roadmap/convex-mirror.mjs +133 -0
  67. package/dist/cli/roadmap/convex-mirror.mjs.map +1 -0
  68. package/dist/cli/roadmap/lifecycle.d.mts +1 -1
  69. package/dist/cli/roadmap/lifecycle.d.mts.map +1 -1
  70. package/dist/cli/roadmap/lifecycle.mjs +54 -0
  71. package/dist/cli/roadmap/lifecycle.mjs.map +1 -1
  72. package/dist/cli/roadmap/rollup.d.mts +2 -2
  73. package/dist/cli/wfrun/unit-shape.d.mts +27 -0
  74. package/dist/cli/wfrun/unit-shape.d.mts.map +1 -0
  75. package/dist/cli/wfrun/unit-shape.mjs +38 -0
  76. package/dist/cli/wfrun/unit-shape.mjs.map +1 -0
  77. package/dist/plugin/.claude-plugin/plugin.json +2 -1
  78. package/dist/plugin/agents/apt-executor.md +12 -0
  79. package/dist/plugin/agents/apt-team-docs-narrator.md +11 -0
  80. package/dist/plugin/skills/apt/SKILL.md +3 -2
  81. package/dist/plugin/skills/apt-execute/SKILL.md +13 -0
  82. package/dist/plugin/skills/apt-fan-out/SKILL.md +124 -0
  83. package/dist/plugin/skills/apt-pr-review/SKILL.md +8 -1
  84. package/dist/plugin/skills/apt-review/SKILL.md +4 -0
  85. package/dist/plugin/skills/apt-run/SKILL.md +20 -5
  86. package/dist/plugin/skills/apt-spar/SKILL.md +12 -9
  87. package/dist/plugin/skills/apt-verify/SKILL.md +11 -0
  88. package/dist/plugin/skills/apt-watch-ci/SKILL.md +2 -2
  89. package/dist/schemas/quick-task.d.ts +17 -17
  90. package/package.json +133 -133
  91. package/skills/apt/SKILL.md +3 -2
  92. package/skills/apt-execute/SKILL.md +13 -0
  93. package/skills/apt-fan-out/SKILL.md +124 -0
  94. package/skills/apt-review/SKILL.md +4 -0
  95. package/skills/apt-run/SKILL.md +20 -5
  96. package/skills/apt-spar/SKILL.md +12 -9
  97. package/skills/apt-verify/SKILL.md +11 -0
  98. package/skills/apt-watch-ci/SKILL.md +2 -2
  99. package/src/cli/ci-watch/stop-matrix.mjs +44 -4
  100. package/src/cli/commands/fan-out.mjs +129 -0
  101. package/src/cli/commands/init.mjs +66 -27
  102. package/src/cli/commands/modes.mjs +4 -1
  103. package/src/cli/commands/pr-review-audit-fixer.mjs +82 -6
  104. package/src/cli/commands/roadmap.mjs +48 -4
  105. package/src/cli/commands/task.mjs +15 -0
  106. package/src/cli/commands/validate-evidence.mjs +75 -50
  107. package/src/cli/commands/wave.mjs +91 -58
  108. package/src/cli/commands/worktree.mjs +56 -3
  109. package/src/cli/config/load.mjs +1 -1
  110. package/src/cli/config/parallelization.mjs +18 -0
  111. package/src/cli/coordination/agent-identity.mjs +9 -1
  112. package/src/cli/dispatch.mjs +2 -0
  113. package/src/cli/install/cli-to-install-root.mjs +0 -2
  114. package/src/cli/install/runtime-detect.mjs +8 -3
  115. package/src/cli/install/transforms/pi.mjs +37 -0
  116. package/src/cli/roadmap/convex-mirror.mjs +131 -0
  117. package/src/cli/roadmap/lifecycle.mjs +48 -0
  118. package/src/cli/wfrun/unit-shape.mjs +39 -0
  119. package/templates/config.json +1 -1
@@ -1,10 +1,17 @@
1
1
  /**
2
2
  * commands/wave.mjs — wave analyzer (C29).
3
+ *
4
+ * FRAMEWORK-BUG-033 ID-03 — accepts two input forms at the I/O boundary:
5
+ * 1. Subtask-shaped plan (`{subtasks: [...]}`) — back-compat, byte-equivalent.
6
+ * 2. Task-shaped plan (`{unit_kind: "task", tasks: [...]}`) — new fan-out
7
+ * input. Internal algorithm is unit-agnostic; only the input
8
+ * normalization + output envelope keys differ.
3
9
  */
4
10
 
5
11
  import { existsSync, readFileSync } from 'node:fs'
6
12
  import { join, resolve } from 'node:path'
7
13
  import { err, exitWith, ok } from '../util/result.mjs'
14
+ import { normalizeToUnits } from '../wfrun/unit-shape.mjs'
8
15
 
9
16
  export function cmdWave(subcommand, projectDir, extraArgs) {
10
17
  if (!subcommand)
@@ -37,28 +44,29 @@ export function cmdWave(subcommand, projectDir, extraArgs) {
37
44
  return err(`Invalid JSON in plan file: ${e.message}`)
38
45
  }
39
46
 
40
- if (!plan.subtasks || !Array.isArray(plan.subtasks)) {
41
- return err('Plan must contain a "subtasks" array')
42
- }
47
+ // FRAMEWORK-BUG-033 ID-03 — normalize subtask/task input shapes.
48
+ const { units, unit_kind, error: shapeError } = normalizeToUnits(plan)
49
+ if (shapeError) return err(shapeError)
50
+ const isTaskUnit = unit_kind === 'task'
43
51
 
44
- // Validate each subtask has required fields
45
- for (const st of plan.subtasks) {
46
- if (!st.id) return err(`Subtask missing "id" field: ${JSON.stringify(st)}`)
47
- if (!Array.isArray(st.dependencies)) {
48
- return err(`Subtask "${st.id}" missing "dependencies" array`)
52
+ // Validate each unit has required fields
53
+ for (const u of units) {
54
+ if (!u.id) return err(`${unit_kind} missing "id" field: ${JSON.stringify(u)}`)
55
+ if (!Array.isArray(u.dependencies)) {
56
+ return err(`${unit_kind} "${u.id}" missing "dependencies" array`)
49
57
  }
50
58
  }
51
59
 
52
- const subtaskMap = new Map()
53
- for (const st of plan.subtasks) {
54
- subtaskMap.set(st.id, st)
60
+ const unitMap = new Map()
61
+ for (const u of units) {
62
+ unitMap.set(u.id, u)
55
63
  }
56
64
 
57
- // Validate all dependencies reference existing subtasks
58
- for (const st of plan.subtasks) {
59
- for (const dep of st.dependencies) {
60
- if (!subtaskMap.has(dep)) {
61
- return err(`Subtask "${st.id}" depends on unknown subtask "${dep}"`)
65
+ // Validate all dependencies reference existing units
66
+ for (const u of units) {
67
+ for (const dep of u.dependencies) {
68
+ if (!unitMap.has(dep)) {
69
+ return err(`${unit_kind} "${u.id}" depends on unknown ${unit_kind} "${dep}"`)
62
70
  }
63
71
  }
64
72
  }
@@ -68,15 +76,15 @@ export function cmdWave(subcommand, projectDir, extraArgs) {
68
76
  GRAY = 1,
69
77
  BLACK = 2
70
78
  const color = new Map()
71
- for (const st of plan.subtasks) color.set(st.id, WHITE)
79
+ for (const u of units) color.set(u.id, WHITE)
72
80
  let cycleFound = null
73
81
 
74
82
  function dfs(id, path) {
75
83
  if (cycleFound) return
76
84
  color.set(id, GRAY)
77
85
  path.push(id)
78
- const st = subtaskMap.get(id)
79
- for (const dep of st.dependencies) {
86
+ const u = unitMap.get(id)
87
+ for (const dep of u.dependencies) {
80
88
  if (color.get(dep) === GRAY) {
81
89
  // Found cycle: extract from dep to current position
82
90
  const cycleStart = path.indexOf(dep)
@@ -91,9 +99,9 @@ export function cmdWave(subcommand, projectDir, extraArgs) {
91
99
  color.set(id, BLACK)
92
100
  }
93
101
 
94
- for (const st of plan.subtasks) {
95
- if (color.get(st.id) === WHITE) {
96
- dfs(st.id, [])
102
+ for (const u of units) {
103
+ if (color.get(u.id) === WHITE) {
104
+ dfs(u.id, [])
97
105
  }
98
106
  if (cycleFound) break
99
107
  }
@@ -103,40 +111,40 @@ export function cmdWave(subcommand, projectDir, extraArgs) {
103
111
  }
104
112
 
105
113
  // Wave assignment algorithm (D-12, D-13)
106
- const waveAssignment = new Map() // subtask id -> wave number
114
+ const waveAssignment = new Map() // unit id -> wave number
107
115
  const assigned = new Set()
108
116
 
109
- // Wave 1: subtasks with no dependencies
110
- for (const st of plan.subtasks) {
111
- if (st.dependencies.length === 0) {
112
- waveAssignment.set(st.id, 1)
113
- assigned.add(st.id)
117
+ // Wave 1: units with no dependencies
118
+ for (const u of units) {
119
+ if (u.dependencies.length === 0) {
120
+ waveAssignment.set(u.id, 1)
121
+ assigned.add(u.id)
114
122
  }
115
123
  }
116
124
 
117
- // Iteratively assign remaining subtasks
125
+ // Iteratively assign remaining units
118
126
  let currentWave = 1
119
127
  let progress = true
120
- while (assigned.size < plan.subtasks.length && progress) {
128
+ while (assigned.size < units.length && progress) {
121
129
  progress = false
122
130
  currentWave++
123
- for (const st of plan.subtasks) {
124
- if (assigned.has(st.id)) continue
131
+ for (const u of units) {
132
+ if (assigned.has(u.id)) continue
125
133
  // Check if ALL dependencies are assigned
126
- const allDepsAssigned = st.dependencies.every((dep) => assigned.has(dep))
134
+ const allDepsAssigned = u.dependencies.every((dep) => assigned.has(dep))
127
135
  if (allDepsAssigned) {
128
- const maxDepWave = Math.max(...st.dependencies.map((dep) => waveAssignment.get(dep)))
129
- waveAssignment.set(st.id, maxDepWave + 1)
130
- assigned.add(st.id)
136
+ const maxDepWave = Math.max(...u.dependencies.map((dep) => waveAssignment.get(dep)))
137
+ waveAssignment.set(u.id, maxDepWave + 1)
138
+ assigned.add(u.id)
131
139
  progress = true
132
140
  }
133
141
  }
134
142
  }
135
143
 
136
- if (assigned.size < plan.subtasks.length) {
137
- const unassigned = plan.subtasks.filter((st) => !assigned.has(st.id)).map((st) => st.id)
144
+ if (assigned.size < units.length) {
145
+ const unassigned = units.filter((u) => !assigned.has(u.id)).map((u) => u.id)
138
146
  return err(
139
- `Could not assign all subtasks to waves. Missing dependency or orphan subtasks: ${unassigned.join(', ')}`,
147
+ `Could not assign all ${unit_kind}s to waves. Missing dependency or orphan ${unit_kind}s: ${unassigned.join(', ')}`,
140
148
  )
141
149
  }
142
150
 
@@ -144,45 +152,57 @@ export function cmdWave(subcommand, projectDir, extraArgs) {
144
152
  const totalWaves = Math.max(...waveAssignment.values())
145
153
  const wavesMap = new Map()
146
154
  for (let w = 1; w <= totalWaves; w++) {
147
- wavesMap.set(w, { wave: w, subtasks: [], files: [] })
155
+ // Output key per ID-03: subtask path keeps `subtasks`; task
156
+ // path uses `tasks`. Bit-equivalence for the subtask path
157
+ // is enforced by wave-engine-subtask-bit-equivalence.test.ts.
158
+ wavesMap.set(
159
+ w,
160
+ isTaskUnit ? { wave: w, tasks: [], files: [] } : { wave: w, subtasks: [], files: [] },
161
+ )
148
162
  }
149
163
 
150
- for (const st of plan.subtasks) {
151
- const w = waveAssignment.get(st.id)
152
- wavesMap.get(w).subtasks.push(st.id)
153
- if (Array.isArray(st.files)) {
154
- for (const f of st.files) {
155
- if (!wavesMap.get(w).files.includes(f)) {
156
- wavesMap.get(w).files.push(f)
164
+ for (const u of units) {
165
+ const w = waveAssignment.get(u.id)
166
+ const group = wavesMap.get(w)
167
+ if (isTaskUnit) group.tasks.push(u.id)
168
+ else group.subtasks.push(u.id)
169
+ if (Array.isArray(u.files)) {
170
+ for (const f of u.files) {
171
+ if (!group.files.includes(f)) {
172
+ group.files.push(f)
157
173
  }
158
174
  }
159
175
  }
160
176
  }
161
177
 
162
- // File conflict check: files appearing in multiple subtasks within same wave
178
+ // File conflict check: files appearing in multiple units within same wave
163
179
  const fileConflicts = []
164
180
  for (const [w, group] of wavesMap) {
165
181
  const fileOwners = new Map()
166
- for (const stId of group.subtasks) {
167
- const st = subtaskMap.get(stId)
168
- if (Array.isArray(st.files)) {
169
- for (const f of st.files) {
182
+ const groupIds = isTaskUnit ? group.tasks : group.subtasks
183
+ for (const uId of groupIds) {
184
+ const u = unitMap.get(uId)
185
+ if (Array.isArray(u.files)) {
186
+ for (const f of u.files) {
170
187
  if (!fileOwners.has(f)) fileOwners.set(f, [])
171
- fileOwners.get(f).push(stId)
188
+ fileOwners.get(f).push(uId)
172
189
  }
173
190
  }
174
191
  }
175
192
  for (const [file, owners] of fileOwners) {
176
193
  if (owners.length > 1) {
177
- fileConflicts.push({ wave: w, file, subtasks: owners })
194
+ fileConflicts.push(
195
+ isTaskUnit ? { wave: w, file, tasks: owners } : { wave: w, file, subtasks: owners },
196
+ )
178
197
  }
179
198
  }
180
199
  }
181
200
 
182
- // Build subtask_wave_map
183
- const subtaskWaveMap = {}
201
+ // Build wave map. Subtask path: subtask_wave_map (back-compat).
202
+ // Task path: unit_id_wave_map (new).
203
+ const unitIdWaveMap = {}
184
204
  for (const [id, w] of waveAssignment) {
185
- subtaskWaveMap[id] = w
205
+ unitIdWaveMap[id] = w
186
206
  }
187
207
 
188
208
  // Apply --wave N filter (D-16)
@@ -191,13 +211,26 @@ export function cmdWave(subcommand, projectDir, extraArgs) {
191
211
  waves = waves.filter((w) => w.wave === waveFilter)
192
212
  }
193
213
 
214
+ if (isTaskUnit) {
215
+ return ok({
216
+ status: 'ok',
217
+ command: 'wave-analyze-task',
218
+ unit_kind: 'task',
219
+ total_waves: totalWaves,
220
+ waves,
221
+ file_conflicts: fileConflicts,
222
+ unit_id_wave_map: unitIdWaveMap,
223
+ })
224
+ }
225
+
226
+ // Back-compat subtask envelope — byte-equivalent to pre-refactor output.
194
227
  return ok({
195
228
  status: 'ok',
196
229
  command: 'wave-analyze',
197
230
  total_waves: totalWaves,
198
231
  waves,
199
232
  file_conflicts: fileConflicts,
200
- subtask_wave_map: subtaskWaveMap,
233
+ subtask_wave_map: unitIdWaveMap,
201
234
  })
202
235
  }
203
236
  default:
@@ -120,11 +120,12 @@ function doCreate(_subcommand, targetDir, extraArgs, SAFE_ID_PATTERN, SAFE_BRANC
120
120
  )
121
121
  }
122
122
 
123
- // Resolve base branch: --from beats config beats current HEAD.
123
+ // Resolve base branch: --from beats config beats default chain.
124
124
  const fromFlag = flags.get('from')
125
- const fromPref = fromFlag || isolation.from_branch || 'current'
125
+ const fromPref = fromFlag || isolation.from_branch || 'main'
126
126
  let baseBranch = fromPref
127
- if (!fromPref || fromPref === 'current') {
127
+ if (fromPref === 'current' || fromPref === 'HEAD') {
128
+ // Explicit opt-in to chaining onto the user's current branch (AC8).
128
129
  try {
129
130
  baseBranch =
130
131
  execSync('git rev-parse --abbrev-ref HEAD', {
@@ -134,7 +135,59 @@ function doCreate(_subcommand, targetDir, extraArgs, SAFE_ID_PATTERN, SAFE_BRANC
134
135
  } catch {
135
136
  baseBranch = 'HEAD'
136
137
  }
138
+ } else if (fromPref === 'main') {
139
+ // Main-detection chain — local refs only, no network (per ID-02).
140
+ const candidates = ['origin/HEAD', 'origin/main', 'origin/master', 'main', 'master']
141
+ let resolved = null
142
+ for (const ref of candidates) {
143
+ try {
144
+ const out = execSync(`git rev-parse --verify ${ref}`, {
145
+ cwd: targetDir,
146
+ encoding: 'utf-8',
147
+ stdio: ['ignore', 'pipe', 'pipe'],
148
+ }).trim()
149
+ if (out) {
150
+ // origin/HEAD is a symref — resolve it to e.g. `origin/main`
151
+ // so the base branch we hand to `git worktree add` is usable.
152
+ if (ref === 'origin/HEAD') {
153
+ try {
154
+ const sym = execSync('git symbolic-ref refs/remotes/origin/HEAD', {
155
+ cwd: targetDir,
156
+ encoding: 'utf-8',
157
+ stdio: ['ignore', 'pipe', 'pipe'],
158
+ }).trim()
159
+ resolved = sym.replace(/^refs\/remotes\//, '') || ref
160
+ } catch {
161
+ resolved = ref
162
+ }
163
+ } else {
164
+ resolved = ref
165
+ }
166
+ break
167
+ }
168
+ } catch {
169
+ // Ref doesn't exist — try the next one.
170
+ }
171
+ }
172
+ if (resolved) {
173
+ baseBranch = resolved
174
+ } else {
175
+ // Degenerate repo (no main/master ref anywhere). Fall back to
176
+ // current HEAD so users in detached or greenfield repos still
177
+ // get a working worktree instead of a hard error.
178
+ try {
179
+ baseBranch =
180
+ execSync('git rev-parse --abbrev-ref HEAD', {
181
+ cwd: targetDir,
182
+ encoding: 'utf-8',
183
+ }).trim() || 'HEAD'
184
+ } catch {
185
+ baseBranch = 'HEAD'
186
+ }
187
+ }
137
188
  }
189
+ // Any other explicit fromPref value (e.g. `origin/main`, `feature/foo`,
190
+ // a SHA) is used as-is and falls through to SAFE_BRANCH_PATTERN below.
138
191
  if (!SAFE_BRANCH_PATTERN.test(baseBranch)) {
139
192
  throw new WorktreeUsageError(
140
193
  'Invalid base branch: must match [a-zA-Z0-9_-/.]. Got: ' + baseBranch,
@@ -252,7 +252,7 @@ export function getTaskIsolationConfig(projectDir) {
252
252
  const defaults = {
253
253
  worktree_per_task: false,
254
254
  branch_prefix: 'apt',
255
- from_branch: 'current',
255
+ from_branch: 'main',
256
256
  push_on_create: true,
257
257
  cleanup_on_close: 'ask',
258
258
  // C54 — language-agnostic worktree bootstrap. All off by default so
@@ -0,0 +1,18 @@
1
+ /**
2
+ * config/parallelization.mjs — FRAMEWORK-BUG-033 capability gap (AC10).
3
+ *
4
+ * Single helper exporting the `parallelization.max_tasks` cap consumed by
5
+ * the `/apt:fan-out` SKILL body. Default 4 when the key is absent. D-07:
6
+ * no paternalism cap — autonomy-3 power users can set arbitrarily high
7
+ * values via `.aperant/config.json:parallelization.max_tasks`.
8
+ */
9
+
10
+ /**
11
+ * @param {unknown} config — merged project config blob (or null/undefined).
12
+ * @returns {number} the configured max_tasks, or 4 when absent/invalid.
13
+ */
14
+ export function getMaxTasks(config) {
15
+ const raw = config?.parallelization?.max_tasks
16
+ if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0) return raw
17
+ return 4
18
+ }
@@ -83,7 +83,15 @@ export function getAgentIdentity(projectDir) {
83
83
 
84
84
  const pid = process.pid
85
85
  const host = osHostname()
86
- const agentId = process.env.APT_AGENT_ID || `${host}-${pid}`
86
+ const rawAgentId = process.env.APT_AGENT_ID || `${host}-${pid}`
87
+ // SEC-001: Disallow path traversal characters in agentId since it's used as a filesystem path component.
88
+ const SAFE_AGENT_ID = /^[A-Za-z0-9._-]+$/
89
+ if (!SAFE_AGENT_ID.test(rawAgentId)) {
90
+ throw new Error(
91
+ `Invalid APT_AGENT_ID: must match [A-Za-z0-9._-]+. Got: ${JSON.stringify(rawAgentId)}`,
92
+ )
93
+ }
94
+ const agentId = rawAgentId
87
95
  const incarnation = nextIncarnation(projectDir)
88
96
  const identity = { agentId, pid, hostname: host, incarnation }
89
97
  cache.set(sharedRoot, identity)
@@ -57,6 +57,7 @@ import { cmdDocsAudit } from './commands/docs-audit.mjs'
57
57
  import { cmdEditCheck } from './commands/edit-check.mjs'
58
58
  import { cmdEffects } from './commands/effects.mjs'
59
59
  import { cmdEvent } from './commands/event.mjs'
60
+ import { cmdFanOut } from './commands/fan-out.mjs'
60
61
  import { cmdFeaturesAudit } from './commands/features-audit.mjs'
61
62
  import { cmdGate } from './commands/gate.mjs'
62
63
  import { cmdHealthCheck } from './commands/health-check.mjs'
@@ -265,6 +266,7 @@ export const DISPATCH = {
265
266
  event: (args) => wrap(cmdEvent)(args[1], args[2], args.slice(3)),
266
267
  effects: (args) => wrap(cmdEffects)(args[1], args[2], args.slice(3)),
267
268
  wave: (args) => wrap(cmdWave)(args[1], args[2], args.slice(3)),
269
+ 'fan-out': (args) => wrap(cmdFanOut)(args[1], args[2], args.slice(3)),
268
270
  'coverage-check': (args) => wrap(cmdCoverageCheck)(args[1], args[2], args.slice(3)),
269
271
  'design-template': (args) => wrap(cmdDesignTemplate)(args[1], args[2], args.slice(3)),
270
272
  'variant-pick': (args) => wrap(cmdVariantPick)(args[1], args[2], args.slice(3)),
@@ -11,8 +11,6 @@
11
11
  * the lookups below work automatically.
12
12
  *
13
13
  * `null` is returned for:
14
- * - CLIs with no install target (e.g. `pi` — host detected, but no apt
15
- * install root exists for the Pi runtime).
16
14
  * - `'unknown'` — deterministic branches (init/status/help) don't need a
17
15
  * mapping; the router falls back to the framework-scoped dev default
18
16
  * defined in getTrustedSkillSources().
@@ -30,9 +30,7 @@ import { MANIFEST_FILENAME } from './manifest.mjs'
30
30
  * @property {string|null} cliId The `cli` string `detectHost()` returns
31
31
  * when this runtime's CLI is at the prompt.
32
32
  * `null` when the runtime has no install
33
- * target mapped to a host CLI (e.g. `pi`,
34
- * which has a detectable host but no apt
35
- * install root of its own).
33
+ * target mapped to a host CLI.
36
34
  * @property {string} [displayLabel] Optional user-facing label (e.g. "Kilo Code").
37
35
  * When unset, callers fall back to the
38
36
  * capitalized form of `id`. Use for runtimes
@@ -77,6 +75,13 @@ export const RUNTIMES = Object.freeze([
77
75
  markers: ['.opencode/config.json'],
78
76
  cliId: 'opencode',
79
77
  },
78
+ {
79
+ id: 'pi',
80
+ flag: '--pi',
81
+ installRoot: '.pi',
82
+ markers: ['.pi/settings.json', '.pi/skills', '.pi/prompts'],
83
+ cliId: 'pi',
84
+ },
80
85
  {
81
86
  id: 'kilo',
82
87
  flag: '--kilo',
@@ -0,0 +1,37 @@
1
+ /**
2
+ * install/transforms/pi.mjs — Pi coding agent transform.
3
+ *
4
+ * Pi uses prompt templates under `.pi/prompts/` for slash commands and
5
+ * root-level skills under `.pi/skills/`. Unlike Codex, Pi supports
6
+ * colon-form prompt names, so `/apt:foo` references are preserved.
7
+ */
8
+
9
+ import { basename, dirname } from 'node:path'
10
+ import { substituteVersion } from './_shared.mjs'
11
+
12
+ function promptNameForSkill(folder) {
13
+ if (folder === 'apt') return 'apt'
14
+ if (folder.startsWith('apt-')) return `apt:${folder.slice('apt-'.length)}`
15
+ return folder
16
+ }
17
+
18
+ /**
19
+ * @param {import('../pipeline.mjs').TransformInput} input
20
+ * @returns {import('../pipeline.mjs').TransformOutput[]}
21
+ */
22
+ export function transform({ canonicalContent, canonicalRelPath, version, fileType }) {
23
+ const content = substituteVersion(canonicalContent, version)
24
+ if (fileType === 'skill') {
25
+ const folder = basename(dirname(canonicalRelPath))
26
+ const promptName = promptNameForSkill(folder)
27
+ return [
28
+ { outputPath: `skills/${folder}/SKILL.md`, content },
29
+ { outputPath: `prompts/${promptName}.md`, content },
30
+ ]
31
+ }
32
+ if (fileType === 'agent') {
33
+ const file = basename(canonicalRelPath)
34
+ return [{ outputPath: `skills/${file}`, content }]
35
+ }
36
+ return []
37
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Slice 5 ST14 / AC10 / ID-06 — `apt-tools roadmap convex-mirror`.
3
+ *
4
+ * Helper that POSTs the rendered narrator prose to the Next.js
5
+ * /api/roadmap/narrator-mirror endpoint. Markdown is canonical
6
+ * (ADR-0006); this call is the Convex mirror.
7
+ *
8
+ * Fail-soft contract:
9
+ * - 200 {mirrored:true} → exit 0, status='ok'
10
+ * - 200 {mirrored:false,queued:true} → exit 0, status='queued'
11
+ * - 401 → exit 1, status='error' (auth misconfig)
12
+ * - 4xx (other than 401) → exit 1, status='error'
13
+ * - 5xx / network error → exit 0, status='queued', appends a
14
+ * pending JSONL row to
15
+ * .aperant/digests/narrator-mirror-pending.jsonl
16
+ * so a follow-up narrator invocation
17
+ * can replay locally if the cron
18
+ * drainer hasn't caught up.
19
+ *
20
+ * Token resolution order (per ID-07):
21
+ * 1. process.env.MCP_NARRATOR_TOKEN
22
+ * 2. .aperant/team.json :: narratorMirrorToken
23
+ */
24
+
25
+ import { appendFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs'
26
+ import { dirname, join } from 'node:path'
27
+
28
+ function resolveToken(projectDir) {
29
+ const envToken = process.env.MCP_NARRATOR_TOKEN
30
+ if (envToken && envToken.length > 0) return envToken
31
+ const teamJsonPath = join(projectDir, '.aperant', 'team.json')
32
+ if (!existsSync(teamJsonPath)) return null
33
+ try {
34
+ const parsed = JSON.parse(readFileSync(teamJsonPath, 'utf-8'))
35
+ if (typeof parsed.narratorMirrorToken === 'string' && parsed.narratorMirrorToken.length > 0) {
36
+ return parsed.narratorMirrorToken
37
+ }
38
+ } catch {
39
+ // Bad JSON — fall through to null
40
+ }
41
+ return null
42
+ }
43
+
44
+ function appendPendingJsonl(projectDir, row) {
45
+ const path = join(projectDir, '.aperant', 'digests', 'narrator-mirror-pending.jsonl')
46
+ mkdirSync(dirname(path), { recursive: true })
47
+ appendFileSync(path, `${JSON.stringify(row)}\n`)
48
+ }
49
+
50
+ /**
51
+ * Run the convex-mirror call.
52
+ *
53
+ * @param {string} projectDir
54
+ * @param {{scope: string, phaseId: string, anchorId: string, proseBody: string, mergedPr?: number, baseUrl?: string, fetchImpl?: typeof fetch}} args
55
+ * @returns {Promise<{status: 'ok'|'queued'|'error', code?: number, message?: string}>}
56
+ */
57
+ export async function runConvexMirror(projectDir, args) {
58
+ const { scope, phaseId, anchorId, proseBody, mergedPr, baseUrl, fetchImpl } = args
59
+ if (!scope || !phaseId || !anchorId || typeof proseBody !== 'string') {
60
+ return { status: 'error', message: 'missing required: scope, phaseId, anchorId, proseBody' }
61
+ }
62
+ const token = resolveToken(projectDir)
63
+ if (!token) {
64
+ return {
65
+ status: 'error',
66
+ message:
67
+ 'No narrator token (set MCP_NARRATOR_TOKEN or .aperant/team.json::narratorMirrorToken)',
68
+ }
69
+ }
70
+ const url = `${(baseUrl || process.env.APERANT_BASE_URL || 'https://app.aperant.com').replace(/\/$/, '')}/api/roadmap/narrator-mirror`
71
+ const body = {
72
+ scope,
73
+ phaseId,
74
+ narratorAnchorId: anchorId,
75
+ narratorProse: proseBody,
76
+ mergedPr,
77
+ }
78
+ const f = fetchImpl ?? fetch
79
+ let res
80
+ try {
81
+ res = await f(url, {
82
+ method: 'POST',
83
+ headers: {
84
+ Authorization: `Bearer ${token}`,
85
+ 'Content-Type': 'application/json',
86
+ },
87
+ body: JSON.stringify(body),
88
+ })
89
+ } catch (e) {
90
+ // Network failure → fail-soft queue.
91
+ const msg = e instanceof Error ? e.message : String(e)
92
+ appendPendingJsonl(projectDir, {
93
+ ...body,
94
+ lastError: `network: ${msg}`,
95
+ ts: new Date().toISOString(),
96
+ })
97
+ return { status: 'queued', message: `network error: ${msg}` }
98
+ }
99
+ if (res.status === 401) {
100
+ return { status: 'error', code: 401, message: 'auth: invalid or missing token' }
101
+ }
102
+ if (res.status >= 500) {
103
+ // 5xx → fail-soft queue.
104
+ appendPendingJsonl(projectDir, {
105
+ ...body,
106
+ lastError: `http ${res.status}`,
107
+ ts: new Date().toISOString(),
108
+ })
109
+ return { status: 'queued', code: res.status, message: 'server error — enqueued' }
110
+ }
111
+ if (res.status >= 400) {
112
+ let detail = ''
113
+ try {
114
+ detail = await res.text()
115
+ } catch {
116
+ // ignore
117
+ }
118
+ return { status: 'error', code: res.status, message: detail || `http ${res.status}` }
119
+ }
120
+ // 200/2xx
121
+ let parsed
122
+ try {
123
+ parsed = await res.json()
124
+ } catch {
125
+ parsed = { mirrored: true, queued: false }
126
+ }
127
+ if (parsed?.queued) {
128
+ return { status: 'queued', code: res.status, message: 'enqueued server-side' }
129
+ }
130
+ return { status: 'ok', code: res.status }
131
+ }
@@ -1,7 +1,17 @@
1
1
  /**
2
2
  * lifecycle.mjs — shared lifecycle-phase side-effect helper (C29).
3
+ *
4
+ * FRAMEWORK-BUG-033 defect 1/5: when `spec.to === 'completed'`, the helper
5
+ * enumerates sibling tasks in the same scope+milestone+phase and aborts
6
+ * the flip (returns null — idempotent no-op shape) when any sibling
7
+ * remains open (lifecycle_phase !== 'closed'). The guard lives inside the
8
+ * helper so every caller (`task update --lifecycle-phase`, `task close
9
+ * --verdict approved`, the drain wrapper, and `task close-merged`
10
+ * transitively via its `task close` child) inherits it.
3
11
  */
4
12
 
13
+ import { existsSync, readFileSync } from 'node:fs'
14
+ import { join } from 'node:path'
5
15
  import { loadRoadmapForScope, saveRoadmapForScope } from './io.mjs'
6
16
  import { applyRollup } from './rollup.mjs'
7
17
  import { syncRoadmapInProcess } from './sync.mjs'
@@ -53,6 +63,44 @@ export function applyLifecyclePhaseSideEffect(targetDir, task, spec, reasonPrefi
53
63
  // From-state guard: idempotent no-op when phase is not at `spec.from`.
54
64
  if (phase.status !== spec.from) return null
55
65
 
66
+ // FRAMEWORK-BUG-033 defect 1/5 — sibling-count guard. Only gates the
67
+ // `→ completed` direction (other transitions like `→ in-progress` are
68
+ // unaffected per ID-02 / AC7). Enumerates state.active_tasks filtered by
69
+ // scope+milestone+phase. Any open sibling (lifecycle_phase !== 'closed'
70
+ // and id !== task.id) blocks the flip with the same idempotent no-op
71
+ // shape as the from-state guard above.
72
+ //
73
+ // State source: caller may pass `opts.stateBlob` when already holding
74
+ // `withFileLock(state.json)` to avoid a re-read race; otherwise we read
75
+ // state.json from disk once. On the `task close` path the task has
76
+ // already been deleted from state.active_tasks before this helper runs,
77
+ // so the `t.id !== task.id` filter is a defensive no-op there.
78
+ if (spec.to === 'completed') {
79
+ let stateBlob = opts.stateBlob
80
+ if (!stateBlob) {
81
+ const statePath = join(targetDir, '.aperant', 'state.json')
82
+ if (existsSync(statePath)) {
83
+ try {
84
+ stateBlob = JSON.parse(readFileSync(statePath, 'utf-8'))
85
+ } catch {
86
+ // Fail-secure: cannot verify sibling state → assume siblings might exist → abort the flip.
87
+ return null
88
+ }
89
+ }
90
+ }
91
+ const activeTasks = stateBlob?.active_tasks || {}
92
+ for (const sibling of Object.values(activeTasks)) {
93
+ if (!sibling || typeof sibling !== 'object') continue
94
+ if (sibling.scope !== scope) continue
95
+ if (sibling.milestone_id !== task.milestone_id) continue
96
+ if (sibling.phase_id !== task.phase_id) continue
97
+ if (sibling.id === task.id) continue
98
+ if (sibling.lifecycle_phase === 'closed') continue
99
+ // Open sibling — abort the flip.
100
+ return null
101
+ }
102
+ }
103
+
56
104
  const transition = {
57
105
  op: reasonPrefix,
58
106
  scope,