@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.
- package/CHANGELOG.md +65 -0
- package/README.md +12 -0
- package/agents/apt-executor.md +12 -0
- package/agents/apt-team-docs-narrator.md +11 -0
- package/dist/cli/ci-watch/stop-matrix.d.mts +17 -4
- package/dist/cli/ci-watch/stop-matrix.d.mts.map +1 -1
- package/dist/cli/ci-watch/stop-matrix.mjs +41 -4
- package/dist/cli/ci-watch/stop-matrix.mjs.map +1 -1
- package/dist/cli/commands/fan-out.d.mts +2 -0
- package/dist/cli/commands/fan-out.d.mts.map +1 -0
- package/dist/cli/commands/fan-out.mjs +123 -0
- package/dist/cli/commands/fan-out.mjs.map +1 -0
- package/dist/cli/commands/init.d.mts.map +1 -1
- package/dist/cli/commands/init.mjs +67 -27
- package/dist/cli/commands/init.mjs.map +1 -1
- package/dist/cli/commands/modes.d.mts +22 -0
- package/dist/cli/commands/modes.d.mts.map +1 -1
- package/dist/cli/commands/modes.mjs +4 -1
- package/dist/cli/commands/modes.mjs.map +1 -1
- package/dist/cli/commands/pr-review-audit-fixer.d.mts.map +1 -1
- package/dist/cli/commands/pr-review-audit-fixer.mjs +86 -6
- package/dist/cli/commands/pr-review-audit-fixer.mjs.map +1 -1
- package/dist/cli/commands/roadmap.d.mts +1 -1
- package/dist/cli/commands/roadmap.d.mts.map +1 -1
- package/dist/cli/commands/roadmap.mjs +47 -4
- package/dist/cli/commands/roadmap.mjs.map +1 -1
- package/dist/cli/commands/task.d.mts.map +1 -1
- package/dist/cli/commands/task.mjs +15 -0
- package/dist/cli/commands/task.mjs.map +1 -1
- package/dist/cli/commands/validate-evidence.d.mts +1 -1
- package/dist/cli/commands/validate-evidence.d.mts.map +1 -1
- package/dist/cli/commands/validate-evidence.mjs +72 -47
- package/dist/cli/commands/validate-evidence.mjs.map +1 -1
- package/dist/cli/commands/wave.d.mts.map +1 -1
- package/dist/cli/commands/wave.mjs +91 -61
- package/dist/cli/commands/wave.mjs.map +1 -1
- package/dist/cli/commands/worktree.mjs +62 -3
- package/dist/cli/commands/worktree.mjs.map +1 -1
- package/dist/cli/config/load.mjs +1 -1
- package/dist/cli/config/load.mjs.map +1 -1
- package/dist/cli/config/parallelization.d.mts +14 -0
- package/dist/cli/config/parallelization.d.mts.map +1 -0
- package/dist/cli/config/parallelization.mjs +19 -0
- package/dist/cli/config/parallelization.mjs.map +1 -0
- package/dist/cli/coordination/agent-identity.d.mts.map +1 -1
- package/dist/cli/coordination/agent-identity.mjs +7 -1
- package/dist/cli/coordination/agent-identity.mjs.map +1 -1
- package/dist/cli/design/frontmatter-schema.d.mts +4 -4
- package/dist/cli/dispatch.d.mts.map +1 -1
- package/dist/cli/dispatch.mjs +2 -0
- package/dist/cli/dispatch.mjs.map +1 -1
- package/dist/cli/install/cli-to-install-root.d.mts.map +1 -1
- package/dist/cli/install/cli-to-install-root.mjs +0 -2
- package/dist/cli/install/cli-to-install-root.mjs.map +1 -1
- package/dist/cli/install/runtime-detect.d.mts +2 -6
- package/dist/cli/install/runtime-detect.d.mts.map +1 -1
- package/dist/cli/install/runtime-detect.mjs +8 -3
- package/dist/cli/install/runtime-detect.mjs.map +1 -1
- package/dist/cli/install/transforms/pi.d.mts +6 -0
- package/dist/cli/install/transforms/pi.d.mts.map +1 -0
- package/dist/cli/install/transforms/pi.mjs +37 -0
- package/dist/cli/install/transforms/pi.mjs.map +1 -0
- package/dist/cli/personas/sidecar.d.mts +1 -1
- package/dist/cli/roadmap/convex-mirror.d.mts +21 -0
- package/dist/cli/roadmap/convex-mirror.d.mts.map +1 -0
- package/dist/cli/roadmap/convex-mirror.mjs +133 -0
- package/dist/cli/roadmap/convex-mirror.mjs.map +1 -0
- package/dist/cli/roadmap/lifecycle.d.mts +1 -1
- package/dist/cli/roadmap/lifecycle.d.mts.map +1 -1
- package/dist/cli/roadmap/lifecycle.mjs +54 -0
- package/dist/cli/roadmap/lifecycle.mjs.map +1 -1
- package/dist/cli/roadmap/rollup.d.mts +2 -2
- package/dist/cli/wfrun/unit-shape.d.mts +27 -0
- package/dist/cli/wfrun/unit-shape.d.mts.map +1 -0
- package/dist/cli/wfrun/unit-shape.mjs +38 -0
- package/dist/cli/wfrun/unit-shape.mjs.map +1 -0
- package/dist/plugin/.claude-plugin/plugin.json +2 -1
- package/dist/plugin/agents/apt-executor.md +12 -0
- package/dist/plugin/agents/apt-team-docs-narrator.md +11 -0
- package/dist/plugin/skills/apt/SKILL.md +3 -2
- package/dist/plugin/skills/apt-execute/SKILL.md +13 -0
- package/dist/plugin/skills/apt-fan-out/SKILL.md +124 -0
- package/dist/plugin/skills/apt-pr-review/SKILL.md +8 -1
- package/dist/plugin/skills/apt-review/SKILL.md +4 -0
- package/dist/plugin/skills/apt-run/SKILL.md +20 -5
- package/dist/plugin/skills/apt-spar/SKILL.md +12 -9
- package/dist/plugin/skills/apt-verify/SKILL.md +11 -0
- package/dist/plugin/skills/apt-watch-ci/SKILL.md +2 -2
- package/dist/schemas/quick-task.d.ts +17 -17
- package/package.json +133 -133
- package/skills/apt/SKILL.md +3 -2
- package/skills/apt-execute/SKILL.md +13 -0
- package/skills/apt-fan-out/SKILL.md +124 -0
- package/skills/apt-review/SKILL.md +4 -0
- package/skills/apt-run/SKILL.md +20 -5
- package/skills/apt-spar/SKILL.md +12 -9
- package/skills/apt-verify/SKILL.md +11 -0
- package/skills/apt-watch-ci/SKILL.md +2 -2
- package/src/cli/ci-watch/stop-matrix.mjs +44 -4
- package/src/cli/commands/fan-out.mjs +129 -0
- package/src/cli/commands/init.mjs +66 -27
- package/src/cli/commands/modes.mjs +4 -1
- package/src/cli/commands/pr-review-audit-fixer.mjs +82 -6
- package/src/cli/commands/roadmap.mjs +48 -4
- package/src/cli/commands/task.mjs +15 -0
- package/src/cli/commands/validate-evidence.mjs +75 -50
- package/src/cli/commands/wave.mjs +91 -58
- package/src/cli/commands/worktree.mjs +56 -3
- package/src/cli/config/load.mjs +1 -1
- package/src/cli/config/parallelization.mjs +18 -0
- package/src/cli/coordination/agent-identity.mjs +9 -1
- package/src/cli/dispatch.mjs +2 -0
- package/src/cli/install/cli-to-install-root.mjs +0 -2
- package/src/cli/install/runtime-detect.mjs +8 -3
- package/src/cli/install/transforms/pi.mjs +37 -0
- package/src/cli/roadmap/convex-mirror.mjs +131 -0
- package/src/cli/roadmap/lifecycle.mjs +48 -0
- package/src/cli/wfrun/unit-shape.mjs +39 -0
- 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
|
-
|
|
41
|
-
|
|
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
|
|
45
|
-
for (const
|
|
46
|
-
if (!
|
|
47
|
-
if (!Array.isArray(
|
|
48
|
-
return err(
|
|
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
|
|
53
|
-
for (const
|
|
54
|
-
|
|
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
|
|
58
|
-
for (const
|
|
59
|
-
for (const dep of
|
|
60
|
-
if (!
|
|
61
|
-
return err(
|
|
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
|
|
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
|
|
79
|
-
for (const dep of
|
|
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
|
|
95
|
-
if (color.get(
|
|
96
|
-
dfs(
|
|
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() //
|
|
114
|
+
const waveAssignment = new Map() // unit id -> wave number
|
|
107
115
|
const assigned = new Set()
|
|
108
116
|
|
|
109
|
-
// Wave 1:
|
|
110
|
-
for (const
|
|
111
|
-
if (
|
|
112
|
-
waveAssignment.set(
|
|
113
|
-
assigned.add(
|
|
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
|
|
125
|
+
// Iteratively assign remaining units
|
|
118
126
|
let currentWave = 1
|
|
119
127
|
let progress = true
|
|
120
|
-
while (assigned.size <
|
|
128
|
+
while (assigned.size < units.length && progress) {
|
|
121
129
|
progress = false
|
|
122
130
|
currentWave++
|
|
123
|
-
for (const
|
|
124
|
-
if (assigned.has(
|
|
131
|
+
for (const u of units) {
|
|
132
|
+
if (assigned.has(u.id)) continue
|
|
125
133
|
// Check if ALL dependencies are assigned
|
|
126
|
-
const allDepsAssigned =
|
|
134
|
+
const allDepsAssigned = u.dependencies.every((dep) => assigned.has(dep))
|
|
127
135
|
if (allDepsAssigned) {
|
|
128
|
-
const maxDepWave = Math.max(...
|
|
129
|
-
waveAssignment.set(
|
|
130
|
-
assigned.add(
|
|
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 <
|
|
137
|
-
const unassigned =
|
|
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
|
|
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
|
-
|
|
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
|
|
151
|
-
const w = waveAssignment.get(
|
|
152
|
-
wavesMap.get(w)
|
|
153
|
-
if (
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
123
|
+
// Resolve base branch: --from beats config beats default chain.
|
|
124
124
|
const fromFlag = flags.get('from')
|
|
125
|
-
const fromPref = fromFlag || isolation.from_branch || '
|
|
125
|
+
const fromPref = fromFlag || isolation.from_branch || 'main'
|
|
126
126
|
let baseBranch = fromPref
|
|
127
|
-
if (
|
|
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,
|
package/src/cli/config/load.mjs
CHANGED
|
@@ -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: '
|
|
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
|
|
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)
|
package/src/cli/dispatch.mjs
CHANGED
|
@@ -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
|
|
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,
|