@aperant/framework 0.7.4 → 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 +41 -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 +133 -133
- 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/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
|
@@ -5,11 +5,17 @@
|
|
|
5
5
|
* { action, counts_toward_budget, reason }
|
|
6
6
|
*
|
|
7
7
|
* No I/O, no fs, no gh CLI shell-outs — caller pre-fetches `checks` (the
|
|
8
|
-
* payload from `gh pr checks --json name,bucket,state,
|
|
8
|
+
* payload from `gh pr checks --json name,bucket,state,link,workflow,completedAt`)
|
|
9
9
|
* and `prMetadata` (`gh pr view --json state,reviewDecision`) and passes them
|
|
10
10
|
* in. The orchestrator in commands/ci-watch.mjs handles the side effects
|
|
11
11
|
* implied by the action.
|
|
12
12
|
*
|
|
13
|
+
* Primary per-check signal is `state` (uppercase per gh-CLI: SUCCESS,
|
|
14
|
+
* FAILURE, CANCELLED, TIMED_OUT, SKIPPED, IN_PROGRESS, QUEUED, PENDING,
|
|
15
|
+
* NEUTRAL). The legacy lowercase per-check field that gh dropped circa
|
|
16
|
+
* 2.65 is kept as a soft fallback for in-memory test fixtures only —
|
|
17
|
+
* production gh payloads no longer expose it (FRAMEWORK-BUG-016).
|
|
18
|
+
*
|
|
13
19
|
* Predicate order matters — short-circuits go first (merged/closed/green/cap
|
|
14
20
|
* fire before any per-check inspection). See spec.md §9 table for the full
|
|
15
21
|
* ordering.
|
|
@@ -31,8 +37,13 @@ const INFRA_FAILURE_CLASSES = new Set(['runner-oom', 'runner-cancelled', 'gh-5xx
|
|
|
31
37
|
/**
|
|
32
38
|
* @typedef {Object} CheckRow
|
|
33
39
|
* @property {string} [name]
|
|
34
|
-
* @property {string} [
|
|
35
|
-
*
|
|
40
|
+
* @property {string} [state] Primary per-check status (gh-CLI uppercase enum):
|
|
41
|
+
* 'SUCCESS' | 'FAILURE' | 'CANCELLED' | 'TIMED_OUT' | 'SKIPPED' |
|
|
42
|
+
* 'IN_PROGRESS' | 'QUEUED' | 'PENDING' | 'NEUTRAL'.
|
|
43
|
+
* @property {string} [conclusion] Legacy lowercase per-check field
|
|
44
|
+
* (deprecated — gh dropped it from `pr checks --json` schema circa 2.65).
|
|
45
|
+
* Retained only as a soft fallback for in-memory test fixtures
|
|
46
|
+
* (FRAMEWORK-BUG-016).
|
|
36
47
|
* @property {string} [bucket]
|
|
37
48
|
* @property {string} [failure_class] — optional pre-classified hint (test-only seam)
|
|
38
49
|
* @property {string} [log_fingerprint] — optional digest for identical-log detection
|
|
@@ -65,7 +76,16 @@ export function classify({ checks, state, prMetadata }) {
|
|
|
65
76
|
}
|
|
66
77
|
|
|
67
78
|
const failed = rows.filter((c) => isFailing(c))
|
|
68
|
-
|
|
79
|
+
// FRAMEWORK-BUG-016: `state` is gh-CLI's primary per-check field post-2.65.
|
|
80
|
+
// SKIPPED rows (workflow `if:` guards that didn't trip) are
|
|
81
|
+
// success-equivalent — they intentionally did not run and that is OK.
|
|
82
|
+
// Soft fallback to the legacy lowercase field keeps the in-memory test
|
|
83
|
+
// fixtures green (production gh payloads never hit it).
|
|
84
|
+
const isGreenRow = (c) =>
|
|
85
|
+
c.state === 'SUCCESS' ||
|
|
86
|
+
c.state === 'SKIPPED' ||
|
|
87
|
+
(c.state == null && c.conclusion === 'success')
|
|
88
|
+
const allGreen = rows.length > 0 && rows.every(isGreenRow)
|
|
69
89
|
|
|
70
90
|
// 3. All checks green.
|
|
71
91
|
if (allGreen) {
|
|
@@ -193,6 +213,26 @@ export function classify({ checks, state, prMetadata }) {
|
|
|
193
213
|
}
|
|
194
214
|
|
|
195
215
|
export function isFailing(check) {
|
|
216
|
+
// FRAMEWORK-BUG-016 / BUG-025: prefer the uppercase `state` field that gh
|
|
217
|
+
// 2.65+ exposes. SKIPPED / NEUTRAL / IN_PROGRESS / QUEUED / PENDING /
|
|
218
|
+
// SUCCESS are explicitly NOT red — SKIPPED in particular must never
|
|
219
|
+
// burn a fix attempt (workflow `if:` guards routinely skip jobs on PRs
|
|
220
|
+
// that don't trip them). The legacy lowercase field is a soft fallback
|
|
221
|
+
// for in-memory test fixtures only.
|
|
222
|
+
const s = check.state
|
|
223
|
+
if (
|
|
224
|
+
s === 'SUCCESS' ||
|
|
225
|
+
s === 'SKIPPED' ||
|
|
226
|
+
s === 'NEUTRAL' ||
|
|
227
|
+
s === 'IN_PROGRESS' ||
|
|
228
|
+
s === 'QUEUED' ||
|
|
229
|
+
s === 'PENDING'
|
|
230
|
+
) {
|
|
231
|
+
return false
|
|
232
|
+
}
|
|
233
|
+
if (s === 'FAILURE' || s === 'CANCELLED' || s === 'TIMED_OUT') {
|
|
234
|
+
return true
|
|
235
|
+
}
|
|
196
236
|
const c = check.conclusion
|
|
197
237
|
return c === 'failure' || c === 'cancelled' || c === 'timed_out' || c === 'action_required'
|
|
198
238
|
}
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* commands/fan-out.mjs — FRAMEWORK-BUG-033 capability gap (ID-04 / ID-05).
|
|
3
|
+
*
|
|
4
|
+
* Single resolver helper for the `/apt:fan-out` SKILL body. Three input
|
|
5
|
+
* forms accepted (`--tasks`, `--phases`, `--unblocked`); all resolve to
|
|
6
|
+
* the canonical `{task_ids, source, batch_id}` shape. The actual
|
|
7
|
+
* orchestration loop (worktree create, worker spawn, polling) lives in
|
|
8
|
+
* the SKILL body — this CLI just materializes the batch.
|
|
9
|
+
*
|
|
10
|
+
* Subcommands: `resolve`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
14
|
+
import { join, resolve } from 'node:path'
|
|
15
|
+
import { parseFlags } from '../util/args.mjs'
|
|
16
|
+
import { err, ok } from '../util/result.mjs'
|
|
17
|
+
|
|
18
|
+
const ID_PATTERN = /^[a-z0-9_-]+$/i
|
|
19
|
+
|
|
20
|
+
function generateBatchId() {
|
|
21
|
+
const d = new Date()
|
|
22
|
+
const yyyy = d.getUTCFullYear()
|
|
23
|
+
const mm = String(d.getUTCMonth() + 1).padStart(2, '0')
|
|
24
|
+
const dd = String(d.getUTCDate()).padStart(2, '0')
|
|
25
|
+
const hex = Math.random().toString(16).slice(2, 8)
|
|
26
|
+
return `fanout-${yyyy}${mm}${dd}-${hex}`
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function parseIdList(raw) {
|
|
30
|
+
if (typeof raw !== 'string') return []
|
|
31
|
+
return raw
|
|
32
|
+
.split(',')
|
|
33
|
+
.map((s) => s.trim())
|
|
34
|
+
.filter(Boolean)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function readState(targetDir) {
|
|
38
|
+
const statePath = join(targetDir, '.aperant', 'state.json')
|
|
39
|
+
if (!existsSync(statePath)) return null
|
|
40
|
+
try {
|
|
41
|
+
return JSON.parse(readFileSync(statePath, 'utf-8'))
|
|
42
|
+
} catch {
|
|
43
|
+
return null
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function resolveFromPhases(state, phases) {
|
|
48
|
+
const wanted = new Set(phases)
|
|
49
|
+
const ids = []
|
|
50
|
+
for (const [id, task] of Object.entries(state?.active_tasks || {})) {
|
|
51
|
+
if (!task) continue
|
|
52
|
+
const key = `${task.milestone_id ?? ''}/${task.phase_id ?? ''}`
|
|
53
|
+
if (wanted.has(key) && task.lifecycle_phase !== 'closed') {
|
|
54
|
+
ids.push(id)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return ids
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function resolveUnblocked(state) {
|
|
61
|
+
const ids = []
|
|
62
|
+
for (const [id, task] of Object.entries(state?.active_tasks || {})) {
|
|
63
|
+
if (task?.lifecycle_phase === 'ready-for-agent') ids.push(id)
|
|
64
|
+
}
|
|
65
|
+
return ids
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function cmdFanOut(subcommand, projectDir, extraArgs) {
|
|
69
|
+
if (!subcommand) {
|
|
70
|
+
return err(
|
|
71
|
+
'Usage: apt-tools fan-out <resolve> <project-dir> [--tasks <ids> | --phases <m/p,m/p> | --unblocked]',
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
switch (subcommand) {
|
|
75
|
+
case 'resolve': {
|
|
76
|
+
if (!projectDir) {
|
|
77
|
+
return err(
|
|
78
|
+
'Usage: apt-tools fan-out resolve <project-dir> [--tasks <ids> | --phases <m/p,m/p> | --unblocked]',
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
const targetDir = resolve(projectDir)
|
|
82
|
+
const flags = parseFlags(extraArgs)
|
|
83
|
+
|
|
84
|
+
const hasTasks = flags.has('tasks')
|
|
85
|
+
const hasPhases = flags.has('phases')
|
|
86
|
+
const hasUnblocked = flags.has('unblocked')
|
|
87
|
+
|
|
88
|
+
const inputForms = [hasTasks, hasPhases, hasUnblocked].filter(Boolean).length
|
|
89
|
+
if (inputForms === 0) {
|
|
90
|
+
return err('One of --tasks, --phases, or --unblocked is required')
|
|
91
|
+
}
|
|
92
|
+
if (inputForms > 1) {
|
|
93
|
+
return err('Only one of --tasks, --phases, or --unblocked may be passed at a time')
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let taskIds = []
|
|
97
|
+
let source = ''
|
|
98
|
+
if (hasTasks) {
|
|
99
|
+
taskIds = parseIdList(flags.get('tasks'))
|
|
100
|
+
source = 'tasks'
|
|
101
|
+
for (const id of taskIds) {
|
|
102
|
+
if (!ID_PATTERN.test(id)) {
|
|
103
|
+
return err(`Invalid task id in --tasks: "${id}" (must match /^[a-z0-9_-]+$/i)`)
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
} else if (hasPhases) {
|
|
107
|
+
const state = readState(targetDir)
|
|
108
|
+
if (!state) return err('No state.json found — cannot resolve --phases')
|
|
109
|
+
taskIds = resolveFromPhases(state, parseIdList(flags.get('phases')))
|
|
110
|
+
source = 'phases'
|
|
111
|
+
} else {
|
|
112
|
+
const state = readState(targetDir)
|
|
113
|
+
if (!state) return err('No state.json found — cannot resolve --unblocked')
|
|
114
|
+
taskIds = resolveUnblocked(state)
|
|
115
|
+
source = 'unblocked'
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return ok({
|
|
119
|
+
status: 'ok',
|
|
120
|
+
command: 'fan-out-resolve',
|
|
121
|
+
source,
|
|
122
|
+
task_ids: taskIds,
|
|
123
|
+
batch_id: generateBatchId(),
|
|
124
|
+
})
|
|
125
|
+
}
|
|
126
|
+
default:
|
|
127
|
+
return err(`Unknown fan-out subcommand: ${subcommand}. Available: resolve`)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -54,7 +54,7 @@ const FRONTMATTER_RE = /^---\s*([\s\S]*?)\s*---/
|
|
|
54
54
|
*
|
|
55
55
|
* @type {Record<string, string[]>}
|
|
56
56
|
*/
|
|
57
|
-
const SKILL_CONFIG_KEYS = {
|
|
57
|
+
export const SKILL_CONFIG_KEYS = {
|
|
58
58
|
'apt:execute': ['coding.tdd_default', 'tdd.iron_law'],
|
|
59
59
|
'apt:triage': [
|
|
60
60
|
'task_tracking.backend',
|
|
@@ -69,6 +69,9 @@ const SKILL_CONFIG_KEYS = {
|
|
|
69
69
|
'apt:ship': ['pipeline.auto_branch', 'pipeline.branch_prefix'],
|
|
70
70
|
'apt:setup': ['*'], // setup edits config — wildcard surface
|
|
71
71
|
'apt:run': ['parallelization.enabled', 'parallelization.max_agents'],
|
|
72
|
+
// FRAMEWORK-BUG-033 capability gap (AC10) — fan-out concurrency cap.
|
|
73
|
+
// Consumed by the /apt:fan-out SKILL body via cli/config/parallelization.mjs.
|
|
74
|
+
'apt:fan-out': ['parallelization.max_tasks', 'parallelization.max_agents'],
|
|
72
75
|
}
|
|
73
76
|
|
|
74
77
|
/**
|
|
@@ -147,13 +147,24 @@ export function parseFixesAppliedMarkdown(md) {
|
|
|
147
147
|
const fixesSection = extractTopLevelSection(md, 'Fixes Applied')
|
|
148
148
|
const selfReviewerSection = extractTopLevelSection(md, 'Fixes Applied by Self-Reviewer')
|
|
149
149
|
const skippedSection = extractTopLevelSection(md, 'Skipped')
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
150
|
+
const legacyFixerEntries = parseFixEntries(fixesSection)
|
|
151
|
+
const selfReviewerEntries = parseSelfReviewerFixEntries(selfReviewerSection)
|
|
152
|
+
// FRAMEWORK-BUG-035: additive `## Fix N` parser path. Scans the whole
|
|
153
|
+
// document — NOT a `## Fixes Applied` body — so it catches fixer reports
|
|
154
|
+
// shaped as numbered top-level blocks. Union, not substitution, per spec
|
|
155
|
+
// ID-03 so legacy `### {ID}` reports keep parsing unchanged.
|
|
156
|
+
const fixNEntries = parseFixNEntries(md)
|
|
157
|
+
const fixes = [...legacyFixerEntries, ...selfReviewerEntries, ...fixNEntries]
|
|
154
158
|
const skipped = parseSkippedEntries(skippedSection)
|
|
155
|
-
//
|
|
156
|
-
|
|
159
|
+
// Schema discriminator: when the ONLY entries came from the `## Fix N`
|
|
160
|
+
// parser path, label it `'fix-n'` so downstream code / debugging can
|
|
161
|
+
// tell which shape was on disk. Fall back to existing fixer / self-
|
|
162
|
+
// reviewer labels when those sections were present.
|
|
163
|
+
let schema
|
|
164
|
+
if (fixesSection) schema = 'fixer'
|
|
165
|
+
else if (selfReviewerSection) schema = 'self-reviewer'
|
|
166
|
+
else if (fixNEntries.length > 0) schema = 'fix-n'
|
|
167
|
+
else schema = 'fixer'
|
|
157
168
|
return { exists: true, fixes, skipped, schema }
|
|
158
169
|
}
|
|
159
170
|
|
|
@@ -237,6 +248,71 @@ function parseFixEntries(section) {
|
|
|
237
248
|
return entries
|
|
238
249
|
}
|
|
239
250
|
|
|
251
|
+
/**
|
|
252
|
+
* FRAMEWORK-BUG-035 — parse `## Fix N` top-level blocks into entries shaped
|
|
253
|
+
* like `parseFixEntries` output. The bug narrative documents `## Fix N`
|
|
254
|
+
* as the canonical shape going forward for fixes-applied-fixer-N.md, but
|
|
255
|
+
* the legacy `### {ID} — title` parser silently dropped every entry on
|
|
256
|
+
* those reports, surfacing `<none parsed>` and false-positive flipping
|
|
257
|
+
* the gate to `hallucinated`.
|
|
258
|
+
*
|
|
259
|
+
* This parser scans the WHOLE document (not a `## Fixes Applied` body)
|
|
260
|
+
* because the new shape uses `## Fix N` directly as top-level headers.
|
|
261
|
+
* Finding-ID extraction priority chain (per spec ID-04):
|
|
262
|
+
* 1. `- **Finding ID**:` field line
|
|
263
|
+
* 2. `- **Fix**:` field line
|
|
264
|
+
* 3. `- **ID**:` field line
|
|
265
|
+
* 4. synthetic `fix-N` fallback (matches `parseSelfReviewerFixEntries`'s
|
|
266
|
+
* `self-reviewer-fix-N` convention; the diff-intersect step in
|
|
267
|
+
* `decideVerdict` is the real check, not the id)
|
|
268
|
+
*
|
|
269
|
+
* Returns the same `{id, title, file, files, verification}` shape as
|
|
270
|
+
* `parseFixEntries` so downstream consumers see a uniform array.
|
|
271
|
+
*/
|
|
272
|
+
function parseFixNEntries(markdown) {
|
|
273
|
+
if (!markdown) return []
|
|
274
|
+
const entries = []
|
|
275
|
+
// `^## Fix N$` — top-level heading with the literal word "Fix" + a
|
|
276
|
+
// number. No trailing description in the header (the canonical shape
|
|
277
|
+
// per the BUG-034 narrative puts the description inside the body).
|
|
278
|
+
const headerRx = /^##\s+Fix\s+(\d+)\s*$/gm
|
|
279
|
+
const matches = [...markdown.matchAll(headerRx)]
|
|
280
|
+
for (let i = 0; i < matches.length; i++) {
|
|
281
|
+
const m = matches[i]
|
|
282
|
+
const start = m.index + m[0].length
|
|
283
|
+
// Terminate at the next top-level `## ` heading OR EOF. Mirrors
|
|
284
|
+
// `extractTopLevelSection`'s body slicing.
|
|
285
|
+
const rest = markdown.slice(start)
|
|
286
|
+
const nextTopRx = /\n##\s+/m
|
|
287
|
+
const nxt = nextTopRx.exec(rest)
|
|
288
|
+
const body = (nxt ? rest.slice(0, nxt.index) : rest).trim()
|
|
289
|
+
const findingId = extractFindingIdField(body) ?? `fix-${m[1]}`
|
|
290
|
+
const files = extractFileFields(body)
|
|
291
|
+
entries.push({
|
|
292
|
+
id: findingId,
|
|
293
|
+
title: null,
|
|
294
|
+
file: files[0] ?? null,
|
|
295
|
+
files,
|
|
296
|
+
verification: extractVerificationField(body),
|
|
297
|
+
})
|
|
298
|
+
}
|
|
299
|
+
return entries
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Extract the finding ID from a `## Fix N` block body via the priority
|
|
304
|
+
* chain documented in `parseFixNEntries`. Returns `null` if no field
|
|
305
|
+
* matched — caller falls back to the synthetic `fix-N` id.
|
|
306
|
+
*/
|
|
307
|
+
function extractFindingIdField(body) {
|
|
308
|
+
for (const key of ['Finding ID', 'Fix', 'ID']) {
|
|
309
|
+
const rx = new RegExp(`-\\s*\\*\\*${key}\\*\\*:\\s*([^\\n]+)`, 'i')
|
|
310
|
+
const m = rx.exec(body)
|
|
311
|
+
if (m) return m[1].trim()
|
|
312
|
+
}
|
|
313
|
+
return null
|
|
314
|
+
}
|
|
315
|
+
|
|
240
316
|
function parseSkippedEntries(section) {
|
|
241
317
|
if (!section) return []
|
|
242
318
|
const entries = []
|
|
@@ -11,6 +11,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSy
|
|
|
11
11
|
import { basename, join, resolve } from 'node:path'
|
|
12
12
|
import { selfStageCommittableWrites } from '../artifacts/self-stage.mjs'
|
|
13
13
|
import { getDefaultOwner, getGitIdentity } from '../git/identity.mjs'
|
|
14
|
+
import { runConvexMirror } from '../roadmap/convex-mirror.mjs'
|
|
14
15
|
import {
|
|
15
16
|
loadRoadmapForScope as ioLoadRoadmapForScope,
|
|
16
17
|
saveRoadmapForScope as ioSaveRoadmapForScope,
|
|
@@ -41,7 +42,7 @@ class RoadmapUsageError extends UsageError {
|
|
|
41
42
|
}
|
|
42
43
|
}
|
|
43
44
|
|
|
44
|
-
export function cmdRoadmap(subcommand, projectDir, extraArgs) {
|
|
45
|
+
export async function cmdRoadmap(subcommand, projectDir, extraArgs) {
|
|
45
46
|
if (!subcommand)
|
|
46
47
|
return err(
|
|
47
48
|
'Usage: apt-tools roadmap <init|render|add-milestone|add-phase|set-phase-status|rollup|delete-phase|delete-milestone|validate-refs|sync> <project-dir>',
|
|
@@ -49,14 +50,14 @@ export function cmdRoadmap(subcommand, projectDir, extraArgs) {
|
|
|
49
50
|
if (!projectDir) return err('Usage: apt-tools roadmap ' + subcommand + ' <project-dir>')
|
|
50
51
|
|
|
51
52
|
try {
|
|
52
|
-
return cmdRoadmapImpl(subcommand, projectDir, extraArgs)
|
|
53
|
+
return await cmdRoadmapImpl(subcommand, projectDir, extraArgs)
|
|
53
54
|
} catch (e) {
|
|
54
55
|
if (e instanceof RoadmapUsageError) return err(e.message)
|
|
55
56
|
throw e
|
|
56
57
|
}
|
|
57
58
|
}
|
|
58
59
|
|
|
59
|
-
function cmdRoadmapImpl(subcommand, projectDir, extraArgs) {
|
|
60
|
+
async function cmdRoadmapImpl(subcommand, projectDir, extraArgs) {
|
|
60
61
|
const targetDir = resolve(projectDir)
|
|
61
62
|
updateTeamMember(targetDir)
|
|
62
63
|
const roadmapRoot = join(targetDir, '.aperant', 'roadmap')
|
|
@@ -904,9 +905,52 @@ function cmdRoadmapImpl(subcommand, projectDir, extraArgs) {
|
|
|
904
905
|
})
|
|
905
906
|
}
|
|
906
907
|
|
|
908
|
+
case 'convex-mirror': {
|
|
909
|
+
// Slice 5 ST14 / AC10 / ID-06. POSTs the rendered narrator prose
|
|
910
|
+
// to the Next.js /api/roadmap/narrator-mirror endpoint. Markdown
|
|
911
|
+
// is canonical (ADR-0006); this call is the Convex mirror.
|
|
912
|
+
const scope = flags.get('scope')
|
|
913
|
+
const phaseId = flags.get('phase')
|
|
914
|
+
const anchorId = flags.get('anchor-id')
|
|
915
|
+
const prosePath = flags.get('prose-path')
|
|
916
|
+
const mergedPrRaw = flags.get('merged-pr')
|
|
917
|
+
if (!scope || !phaseId || !anchorId || !prosePath) {
|
|
918
|
+
return err(
|
|
919
|
+
'Usage: apt-tools roadmap convex-mirror <project-dir> --scope <s> --phase <p> --anchor-id <a> --prose-path <file> [--merged-pr <n>]',
|
|
920
|
+
)
|
|
921
|
+
}
|
|
922
|
+
let proseBody = ''
|
|
923
|
+
try {
|
|
924
|
+
proseBody = readFileSync(prosePath, 'utf-8')
|
|
925
|
+
} catch (e) {
|
|
926
|
+
return err(`prose-path read failed: ${e.message}`)
|
|
927
|
+
}
|
|
928
|
+
const mergedPr = mergedPrRaw ? Number(mergedPrRaw) : undefined
|
|
929
|
+
let result
|
|
930
|
+
try {
|
|
931
|
+
result = await runConvexMirror(targetDir, {
|
|
932
|
+
scope,
|
|
933
|
+
phaseId,
|
|
934
|
+
anchorId,
|
|
935
|
+
proseBody,
|
|
936
|
+
mergedPr,
|
|
937
|
+
})
|
|
938
|
+
} catch (e) {
|
|
939
|
+
return err(e.message)
|
|
940
|
+
}
|
|
941
|
+
if (result.status === 'error') {
|
|
942
|
+
return err(result.message ?? 'convex-mirror error')
|
|
943
|
+
}
|
|
944
|
+
return ok({
|
|
945
|
+
status: result.status,
|
|
946
|
+
command: 'roadmap-convex-mirror',
|
|
947
|
+
message: result.message,
|
|
948
|
+
})
|
|
949
|
+
}
|
|
950
|
+
|
|
907
951
|
default:
|
|
908
952
|
return err(
|
|
909
|
-
`Unknown roadmap subcommand: ${subcommand}. Expected: init, render, add-milestone, add-phase, set-phase-status, delete-phase, delete-milestone, validate-refs, sync`,
|
|
953
|
+
`Unknown roadmap subcommand: ${subcommand}. Expected: init, render, add-milestone, add-phase, set-phase-status, delete-phase, delete-milestone, validate-refs, sync, convex-mirror`,
|
|
910
954
|
)
|
|
911
955
|
}
|
|
912
956
|
}
|
|
@@ -264,6 +264,12 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
|
|
|
264
264
|
roadmap_ref_url: roadmapRefUrl,
|
|
265
265
|
depends_on: dependsOn,
|
|
266
266
|
outputs,
|
|
267
|
+
// FRAMEWORK-BUG-033 defect 2 — durable task→agent ownership.
|
|
268
|
+
// Defaults to null at create time; fan-out workers (or the
|
|
269
|
+
// `task update --assigned-to <agent-id>` path) populate it
|
|
270
|
+
// when they claim the task. Persists across short-lived CLI
|
|
271
|
+
// invocations unlike team-status/{agentId}.json (pid-keyed).
|
|
272
|
+
assigned_to: flags.has('assigned-to') ? flags.get('assigned-to') : null,
|
|
267
273
|
lifecycle_phase: 'planning',
|
|
268
274
|
history: [],
|
|
269
275
|
created_at: new Date().toISOString(),
|
|
@@ -704,6 +710,7 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
|
|
|
704
710
|
const result = applyLifecyclePhaseSideEffect(
|
|
705
711
|
targetDir,
|
|
706
712
|
{
|
|
713
|
+
id: task.id,
|
|
707
714
|
scope: task.scope || 'project',
|
|
708
715
|
milestone_id: task.milestone_id,
|
|
709
716
|
phase_id: task.phase_id,
|
|
@@ -933,6 +940,14 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
|
|
|
933
940
|
}
|
|
934
941
|
prUrlForCiWatch = url
|
|
935
942
|
}
|
|
943
|
+
// FRAMEWORK-BUG-033 defect 2 — durable task→agent ownership.
|
|
944
|
+
// Mutable owner field; fan-out workers write
|
|
945
|
+
// `fanout-${batch}-${task}` on claim, conductor reads to
|
|
946
|
+
// resolve crash recovery (which worker owned T1 when the
|
|
947
|
+
// conductor died?).
|
|
948
|
+
if (flags.has('assigned-to')) {
|
|
949
|
+
task.assigned_to = flags.get('assigned-to')
|
|
950
|
+
}
|
|
936
951
|
if (flags.has('subtasks-total'))
|
|
937
952
|
task.progress.subtasks_total = parseInt(flags.get('subtasks-total'), 10)
|
|
938
953
|
if (flags.has('subtasks-completed'))
|
|
@@ -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
|