@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.
Files changed (83) hide show
  1. package/CHANGELOG.md +41 -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 +133 -133
  60. package/skills/apt-execute/SKILL.md +13 -0
  61. package/skills/apt-fan-out/SKILL.md +124 -0
  62. package/skills/apt-review/SKILL.md +4 -0
  63. package/skills/apt-run/SKILL.md +20 -5
  64. package/skills/apt-spar/SKILL.md +12 -9
  65. package/skills/apt-verify/SKILL.md +11 -0
  66. package/skills/apt-watch-ci/SKILL.md +2 -2
  67. package/src/cli/ci-watch/stop-matrix.mjs +44 -4
  68. package/src/cli/commands/fan-out.mjs +129 -0
  69. package/src/cli/commands/modes.mjs +4 -1
  70. package/src/cli/commands/pr-review-audit-fixer.mjs +82 -6
  71. package/src/cli/commands/roadmap.mjs +48 -4
  72. package/src/cli/commands/task.mjs +15 -0
  73. package/src/cli/commands/validate-evidence.mjs +75 -50
  74. package/src/cli/commands/wave.mjs +91 -58
  75. package/src/cli/commands/worktree.mjs +56 -3
  76. package/src/cli/config/load.mjs +1 -1
  77. package/src/cli/config/parallelization.mjs +18 -0
  78. package/src/cli/coordination/agent-identity.mjs +9 -1
  79. package/src/cli/dispatch.mjs +2 -0
  80. package/src/cli/roadmap/convex-mirror.mjs +131 -0
  81. package/src/cli/roadmap/lifecycle.mjs +48 -0
  82. package/src/cli/wfrun/unit-shape.mjs +39 -0
  83. 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,conclusion,detailsUrl`)
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} [conclusion] e.g. 'success' | 'failure' | 'cancelled' | 'timed_out' | 'neutral'
35
- * @property {string} [state]
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
- const allGreen = rows.length > 0 && rows.every((c) => c.conclusion === 'success')
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 fixes = [
151
- ...parseFixEntries(fixesSection),
152
- ...parseSelfReviewerFixEntries(selfReviewerSection),
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
- // Detect which heading was present; fixer takes precedence when both appear.
156
- const schema = fixesSection ? 'fixer' : 'self-reviewer'
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
- * 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