@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.
- package/CHANGELOG.md +66 -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/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/dispatch.d.mts.map +1 -1
- package/dist/cli/dispatch.mjs +2 -0
- package/dist/cli/dispatch.mjs.map +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/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/package.json +1 -1
- package/skills/apt-execute/SKILL.md +13 -0
- package/skills/apt-fan-out/SKILL.md +124 -0
- package/skills/apt-pr-review/SKILL.md +8 -1
- 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/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/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
|
@@ -21,9 +21,12 @@
|
|
|
21
21
|
* → dismiss.
|
|
22
22
|
*
|
|
23
23
|
* Mutations are written back in place — the findings markdown gets a
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
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
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
278
|
+
const suggestedFix = extractSection(cleanBody, 'Suggested Fix') ?? ''
|
|
255
279
|
|
|
256
280
|
// Line range: Evidence Context -> "Lines examined: a-b"
|
|
257
|
-
const contextSection = extractSection(
|
|
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(
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
549
|
-
|
|
550
|
-
//
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
-
|
|
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)),
|