@aperant/framework 0.7.3 → 0.8.0

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 (84) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/agents/apt-executor.md +12 -0
  3. package/agents/apt-team-docs-narrator.md +11 -0
  4. package/dist/cli/ci-watch/stop-matrix.d.mts +17 -4
  5. package/dist/cli/ci-watch/stop-matrix.d.mts.map +1 -1
  6. package/dist/cli/ci-watch/stop-matrix.mjs +41 -4
  7. package/dist/cli/ci-watch/stop-matrix.mjs.map +1 -1
  8. package/dist/cli/commands/fan-out.d.mts +2 -0
  9. package/dist/cli/commands/fan-out.d.mts.map +1 -0
  10. package/dist/cli/commands/fan-out.mjs +123 -0
  11. package/dist/cli/commands/fan-out.mjs.map +1 -0
  12. package/dist/cli/commands/modes.d.mts +22 -0
  13. package/dist/cli/commands/modes.d.mts.map +1 -1
  14. package/dist/cli/commands/modes.mjs +4 -1
  15. package/dist/cli/commands/modes.mjs.map +1 -1
  16. package/dist/cli/commands/pr-review-audit-fixer.d.mts.map +1 -1
  17. package/dist/cli/commands/pr-review-audit-fixer.mjs +86 -6
  18. package/dist/cli/commands/pr-review-audit-fixer.mjs.map +1 -1
  19. package/dist/cli/commands/roadmap.d.mts +1 -1
  20. package/dist/cli/commands/roadmap.d.mts.map +1 -1
  21. package/dist/cli/commands/roadmap.mjs +47 -4
  22. package/dist/cli/commands/roadmap.mjs.map +1 -1
  23. package/dist/cli/commands/task.d.mts.map +1 -1
  24. package/dist/cli/commands/task.mjs +15 -0
  25. package/dist/cli/commands/task.mjs.map +1 -1
  26. package/dist/cli/commands/validate-evidence.d.mts +1 -1
  27. package/dist/cli/commands/validate-evidence.d.mts.map +1 -1
  28. package/dist/cli/commands/validate-evidence.mjs +72 -47
  29. package/dist/cli/commands/validate-evidence.mjs.map +1 -1
  30. package/dist/cli/commands/wave.d.mts.map +1 -1
  31. package/dist/cli/commands/wave.mjs +91 -61
  32. package/dist/cli/commands/wave.mjs.map +1 -1
  33. package/dist/cli/commands/worktree.mjs +62 -3
  34. package/dist/cli/commands/worktree.mjs.map +1 -1
  35. package/dist/cli/config/load.mjs +1 -1
  36. package/dist/cli/config/load.mjs.map +1 -1
  37. package/dist/cli/config/parallelization.d.mts +14 -0
  38. package/dist/cli/config/parallelization.d.mts.map +1 -0
  39. package/dist/cli/config/parallelization.mjs +19 -0
  40. package/dist/cli/config/parallelization.mjs.map +1 -0
  41. package/dist/cli/coordination/agent-identity.d.mts.map +1 -1
  42. package/dist/cli/coordination/agent-identity.mjs +7 -1
  43. package/dist/cli/coordination/agent-identity.mjs.map +1 -1
  44. package/dist/cli/dispatch.d.mts.map +1 -1
  45. package/dist/cli/dispatch.mjs +2 -0
  46. package/dist/cli/dispatch.mjs.map +1 -1
  47. package/dist/cli/roadmap/convex-mirror.d.mts +21 -0
  48. package/dist/cli/roadmap/convex-mirror.d.mts.map +1 -0
  49. package/dist/cli/roadmap/convex-mirror.mjs +133 -0
  50. package/dist/cli/roadmap/convex-mirror.mjs.map +1 -0
  51. package/dist/cli/roadmap/lifecycle.d.mts +1 -1
  52. package/dist/cli/roadmap/lifecycle.d.mts.map +1 -1
  53. package/dist/cli/roadmap/lifecycle.mjs +54 -0
  54. package/dist/cli/roadmap/lifecycle.mjs.map +1 -1
  55. package/dist/cli/wfrun/unit-shape.d.mts +27 -0
  56. package/dist/cli/wfrun/unit-shape.d.mts.map +1 -0
  57. package/dist/cli/wfrun/unit-shape.mjs +38 -0
  58. package/dist/cli/wfrun/unit-shape.mjs.map +1 -0
  59. package/package.json +1 -1
  60. package/skills/apt-execute/SKILL.md +13 -0
  61. package/skills/apt-fan-out/SKILL.md +124 -0
  62. package/skills/apt-pr-review/SKILL.md +8 -1
  63. package/skills/apt-review/SKILL.md +4 -0
  64. package/skills/apt-run/SKILL.md +20 -5
  65. package/skills/apt-spar/SKILL.md +12 -9
  66. package/skills/apt-verify/SKILL.md +11 -0
  67. package/skills/apt-watch-ci/SKILL.md +2 -2
  68. package/src/cli/ci-watch/stop-matrix.mjs +44 -4
  69. package/src/cli/commands/fan-out.mjs +129 -0
  70. package/src/cli/commands/modes.mjs +4 -1
  71. package/src/cli/commands/pr-review-audit-fixer.mjs +82 -6
  72. package/src/cli/commands/roadmap.mjs +48 -4
  73. package/src/cli/commands/task.mjs +15 -0
  74. package/src/cli/commands/validate-evidence.mjs +75 -50
  75. package/src/cli/commands/wave.mjs +91 -58
  76. package/src/cli/commands/worktree.mjs +56 -3
  77. package/src/cli/config/load.mjs +1 -1
  78. package/src/cli/config/parallelization.mjs +18 -0
  79. package/src/cli/coordination/agent-identity.mjs +9 -1
  80. package/src/cli/dispatch.mjs +2 -0
  81. package/src/cli/roadmap/convex-mirror.mjs +131 -0
  82. package/src/cli/roadmap/lifecycle.mjs +48 -0
  83. package/src/cli/wfrun/unit-shape.mjs +39 -0
  84. package/templates/config.json +1 -1
@@ -21,9 +21,12 @@
21
21
  * → dismiss.
22
22
  *
23
23
  * Mutations are written back in place — the findings markdown gets a
24
- * new `- **Validation Status**: ${status}` line plus a
25
- * `### Validation Note` block. The command always exits 0; the caller
26
- * checks the JSON summary on stdout.
24
+ * trailing `### Validation Metadata` section per finding carrying
25
+ * `- **Status**: …` and (optionally) `- **Note**: …` fields (the legacy
26
+ * field-form `- **Validation Status**:` + `### Validation Note` shape is
27
+ * still READ for back-compat but never re-emitted — see
28
+ * FRAMEWORK-BUG-034 in `docs/frameworks/spec-gaps.md`). The command
29
+ * always exits 0; the caller checks the JSON summary on stdout.
27
30
  *
28
31
  * Usage:
29
32
  * apt-tools validate-evidence <findings-dir> --worktree <path>
@@ -189,10 +192,25 @@ export function parseFindingsMarkdown(markdown) {
189
192
  }
190
193
 
191
194
  function parseFindingBlock(body, id, title, start, end) {
195
+ // FRAMEWORK-BUG-034: strip any stamper-emitted Validation Status /
196
+ // Validation Note / Validation Metadata content from the body BEFORE
197
+ // extracting fields and evidence. The legacy field-form
198
+ // `- **Validation Status**: ...` line was bleeding into `fields.evidence`
199
+ // because the `fieldRx` `\s*` after the empty-value `- **Evidence**:`
200
+ // marker consumed the newline and grabbed the next line as the field's
201
+ // value. Stripping pre-parse keeps the parser inputs clean regardless of
202
+ // whether the file was stamped under the old or new shape. The original
203
+ // body is preserved for `raw` and for the dual-shape `hasValidationStatus`
204
+ // detector below.
205
+ const cleanBody = body
206
+ .replace(/^-\s*\*\*Validation Status\*\*:[^\n]*\n?/gim, '')
207
+ .replace(/\n?###\s*Validation Note\s*\n[\s\S]*?(?=\n###|$)/i, '')
208
+ .replace(/\n?###\s*Validation Metadata\s*\n[\s\S]*?(?=\n###|$)/i, '')
209
+
192
210
  const fields = {}
193
211
  // `- **File**: \`path:line\`` etc.
194
212
  const fieldRx = /^-\s*\*\*([^*]+)\*\*:\s*(.+)$/gm
195
- for (const m of body.matchAll(fieldRx)) {
213
+ for (const m of cleanBody.matchAll(fieldRx)) {
196
214
  const key = m[1].trim().toLowerCase()
197
215
  fields[key] = m[2].trim()
198
216
  }
@@ -226,7 +244,7 @@ function parseFindingBlock(body, id, title, start, end) {
226
244
  }
227
245
 
228
246
  // Description block: everything between `### Description` and the next `###`.
229
- const description = extractSection(body, 'Description') ?? ''
247
+ const description = extractSection(cleanBody, 'Description') ?? ''
230
248
 
231
249
  // Evidence code block: first triple-backticked block after `### Evidence`
232
250
  // (specialist shape). BUG-032: validator-consolidated `validated.md`
@@ -234,7 +252,13 @@ function parseFindingBlock(body, id, title, start, end) {
234
252
  // fenced block immediately following the field line. Fall back to the
235
253
  // first fenced block after that field marker when the section heading
236
254
  // form yields nothing.
237
- const evidenceSection = extractSection(body, 'Evidence') ?? ''
255
+ //
256
+ // FRAMEWORK-BUG-034: the `fieldEvidenceRx` fallback below is safe as-is
257
+ // because the writer no longer emits `- **Validation Status**` field-form
258
+ // lines (see stampFindings comment) AND `parseFindingBlock` strips any
259
+ // legacy Status / Validation Note text from `cleanBody` above, so the
260
+ // regex can no longer trip on the stamper's own output.
261
+ const evidenceSection = extractSection(cleanBody, 'Evidence') ?? ''
238
262
  let evidence
239
263
  const fenced = /```[^\n]*\n([\s\S]*?)```/.exec(evidenceSection)
240
264
  if (fenced) {
@@ -243,7 +267,7 @@ function parseFindingBlock(body, id, title, start, end) {
243
267
  evidence = evidenceSection
244
268
  } else {
245
269
  const fieldEvidenceRx = /-\s*\*\*Evidence\*\*:\s*(?:\n+)```[^\n]*\n([\s\S]*?)```/i
246
- const fieldFenced = fieldEvidenceRx.exec(body)
270
+ const fieldFenced = fieldEvidenceRx.exec(cleanBody)
247
271
  evidence = fieldFenced ? fieldFenced[1] : (fields.evidence ?? '')
248
272
  }
249
273
 
@@ -251,10 +275,10 @@ function parseFindingBlock(body, id, title, start, end) {
251
275
  // Evidence we keep the whole section — snippets AND surrounding
252
276
  // sentences both live here — because file tokens in either form are
253
277
  // legitimate evidence that the fix crosses files.
254
- const suggestedFix = extractSection(body, 'Suggested Fix') ?? ''
278
+ const suggestedFix = extractSection(cleanBody, 'Suggested Fix') ?? ''
255
279
 
256
280
  // Line range: Evidence Context -> "Lines examined: a-b"
257
- const contextSection = extractSection(body, 'Evidence Context') ?? ''
281
+ const contextSection = extractSection(cleanBody, 'Evidence Context') ?? ''
258
282
  const rangeMatch = /Lines? examined[^\d]*(\d+)\s*-\s*(\d+)/i.exec(contextSection)
259
283
  const lineRange = rangeMatch
260
284
  ? [Number(rangeMatch[1]), Number(rangeMatch[2])]
@@ -266,7 +290,7 @@ function parseFindingBlock(body, id, title, start, end) {
266
290
 
267
291
  // Cross-file evidence (optional): `### Cross-File Evidence` block.
268
292
  // Each file gets a `- **File**: path` + fenced snippet.
269
- const crossFileSection = extractSection(body, 'Cross-File Evidence') ?? ''
293
+ const crossFileSection = extractSection(cleanBody, 'Cross-File Evidence') ?? ''
270
294
  const crossFileEvidence = []
271
295
  const crossFileRx = /-\s*\*\*File\*\*:\s*`?([^`\n]+?)`?\s*\n+```[^\n]*\n([\s\S]*?)```/g
272
296
  for (const m of crossFileSection.matchAll(crossFileRx)) {
@@ -287,7 +311,14 @@ function parseFindingBlock(body, id, title, start, end) {
287
311
  description,
288
312
  suggestedFix,
289
313
  crossFileEvidence,
290
- hasValidationStatus: /\*\*Validation Status\*\*/i.test(body),
314
+ // FRAMEWORK-BUG-034: detection broadened to recognize BOTH the new
315
+ // `### Validation Metadata` section (the post-fix writer shape) AND
316
+ // the legacy `- **Validation Status**:` field-form (back-compat for
317
+ // any validated.md on disk written before the fix). The flag name
318
+ // stays `hasValidationStatus` for callers; see spec.md ID-01 for the
319
+ // "renamed conceptually" rationale.
320
+ hasValidationStatus:
321
+ /###\s*Validation Metadata/i.test(body) || /\*\*Validation Status\*\*/i.test(body),
291
322
  }
292
323
  }
293
324
 
@@ -537,51 +568,45 @@ export function checkFinding(finding, worktreeRoot, opts = {}) {
537
568
  }
538
569
 
539
570
  /**
540
- * Rewrite a findings markdown in place, stamping each finding with
541
- * `- **Validation Status**: …` + a `### Validation Note` block.
571
+ * Rewrite a findings markdown in place, stamping each finding with a
572
+ * trailing `### Validation Metadata` section carrying Status + Note.
573
+ *
574
+ * FRAMEWORK-BUG-034 (2026-05-19, PR #208): the previous writer emitted
575
+ * `- **Validation Status**:` as a field-form line. The fieldRx in
576
+ * `parseFindingBlock` (`/^-\s*\*\*([^*]+)\*\*:\s*(.+)$/gm`) lets `\s*`
577
+ * consume the newline after the empty-value `- **Evidence**:` field,
578
+ * so on a re-read the inserted Status line is captured as
579
+ * `fields.evidence` and the literal-substring step dismisses 28/32
580
+ * findings citing the writer's own stamp as the missing source line.
581
+ *
582
+ * Fix shape (per spec.md ID-01): WRITER always emits ONLY the new
583
+ * `### Validation Metadata` heading shape — the `###` shape can't fall
584
+ * into the field-cluster regex, so the self-reference loop can't form.
585
+ * READER side (`parseFindingBlock`) supports BOTH the new section AND
586
+ * the legacy field-form so any older validated.md on disk still
587
+ * round-trips. See spec.md ID-01 + ID-02 for the dual-shape rationale.
542
588
  */
543
589
  function stampFindings(markdown, decisions) {
544
590
  // Work from the end so earlier edits don't shift later offsets.
545
591
  let out = markdown
546
592
  for (let i = decisions.length - 1; i >= 0; i--) {
547
593
  const { finding, decision } = decisions[i]
548
- // If the finding already has a Validation Status line, replace
549
- // the value; otherwise inject before the first `### Description`
550
- // (or at end of body if no such heading).
551
- const block = out.slice(finding.start, finding.end)
552
- let newBlock
553
- if (finding.hasValidationStatus) {
554
- newBlock = block.replace(
555
- /-\s*\*\*Validation Status\*\*:\s*[^\n]*/i,
556
- `- **Validation Status**: ${decision.status}`,
557
- )
558
- // Replace the note block too, if any.
559
- if (decision.note) {
560
- if (/###\s*Validation Note/i.test(newBlock)) {
561
- newBlock = newBlock.replace(
562
- /###\s*Validation Note\s*\n[\s\S]*?(?=\n###|$)/i,
563
- `### Validation Note\n${decision.note}\n`,
564
- )
565
- } else {
566
- newBlock = `${newBlock.replace(/\s*$/, '')}\n\n### Validation Note\n${decision.note}\n`
567
- }
568
- }
569
- } else {
570
- // Insert "- **Validation Status**: ..." right after the first
571
- // `- **...**:` field block.
572
- const lastFieldRx = /((?:^-\s*\*\*[^*]+\*\*:.*(?:\n|$))+)/m
573
- const match = lastFieldRx.exec(block)
574
- const insertion = `- **Validation Status**: ${decision.status}\n`
575
- if (match) {
576
- const insertAt = match.index + match[0].length
577
- newBlock = block.slice(0, insertAt) + insertion + block.slice(insertAt)
578
- } else {
579
- newBlock = `${block.replace(/\s*$/, '')}\n${insertion}`
580
- }
581
- if (decision.note) {
582
- newBlock = `${newBlock.replace(/\s*$/, '')}\n\n### Validation Note\n${decision.note}\n`
583
- }
584
- }
594
+ let block = out.slice(finding.start, finding.end)
595
+
596
+ // Strip any legacy field-form Validation Status line. Multiple lines
597
+ // possible if a previous buggy re-run left several stamps; replaceAll-
598
+ // style global match keeps this idempotent.
599
+ block = block.replace(/^-\s*\*\*Validation Status\*\*:[^\n]*\n?/gim, '')
600
+ // Strip any legacy `### Validation Note` block.
601
+ block = block.replace(/\n?###\s*Validation Note\s*\n[\s\S]*?(?=\n###|$)/i, '')
602
+ // Strip any prior `### Validation Metadata` section so we can
603
+ // re-emit fresh values (idempotency across re-runs, spec ID-02).
604
+ block = block.replace(/\n?###\s*Validation Metadata\s*\n[\s\S]*?(?=\n###|$)/i, '')
605
+
606
+ const lines = [`### Validation Metadata`, `- **Status**: ${decision.status}`]
607
+ if (decision.note) lines.push(`- **Note**: ${decision.note}`)
608
+ const section = `${lines.join('\n')}\n`
609
+ const newBlock = `${block.replace(/\s*$/, '')}\n\n${section}`
585
610
  out = out.slice(0, finding.start) + newBlock + out.slice(finding.end)
586
611
  }
587
612
  return out
@@ -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)),