@aperant/framework 0.7.4 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/README.md +12 -0
  3. package/agents/apt-executor.md +12 -0
  4. package/agents/apt-team-docs-narrator.md +11 -0
  5. package/dist/cli/ci-watch/stop-matrix.d.mts +17 -4
  6. package/dist/cli/ci-watch/stop-matrix.d.mts.map +1 -1
  7. package/dist/cli/ci-watch/stop-matrix.mjs +41 -4
  8. package/dist/cli/ci-watch/stop-matrix.mjs.map +1 -1
  9. package/dist/cli/commands/fan-out.d.mts +2 -0
  10. package/dist/cli/commands/fan-out.d.mts.map +1 -0
  11. package/dist/cli/commands/fan-out.mjs +123 -0
  12. package/dist/cli/commands/fan-out.mjs.map +1 -0
  13. package/dist/cli/commands/init.d.mts.map +1 -1
  14. package/dist/cli/commands/init.mjs +67 -27
  15. package/dist/cli/commands/init.mjs.map +1 -1
  16. package/dist/cli/commands/modes.d.mts +22 -0
  17. package/dist/cli/commands/modes.d.mts.map +1 -1
  18. package/dist/cli/commands/modes.mjs +4 -1
  19. package/dist/cli/commands/modes.mjs.map +1 -1
  20. package/dist/cli/commands/pr-review-audit-fixer.d.mts.map +1 -1
  21. package/dist/cli/commands/pr-review-audit-fixer.mjs +86 -6
  22. package/dist/cli/commands/pr-review-audit-fixer.mjs.map +1 -1
  23. package/dist/cli/commands/roadmap.d.mts +1 -1
  24. package/dist/cli/commands/roadmap.d.mts.map +1 -1
  25. package/dist/cli/commands/roadmap.mjs +47 -4
  26. package/dist/cli/commands/roadmap.mjs.map +1 -1
  27. package/dist/cli/commands/task.d.mts.map +1 -1
  28. package/dist/cli/commands/task.mjs +15 -0
  29. package/dist/cli/commands/task.mjs.map +1 -1
  30. package/dist/cli/commands/validate-evidence.d.mts +1 -1
  31. package/dist/cli/commands/validate-evidence.d.mts.map +1 -1
  32. package/dist/cli/commands/validate-evidence.mjs +72 -47
  33. package/dist/cli/commands/validate-evidence.mjs.map +1 -1
  34. package/dist/cli/commands/wave.d.mts.map +1 -1
  35. package/dist/cli/commands/wave.mjs +91 -61
  36. package/dist/cli/commands/wave.mjs.map +1 -1
  37. package/dist/cli/commands/worktree.mjs +62 -3
  38. package/dist/cli/commands/worktree.mjs.map +1 -1
  39. package/dist/cli/config/load.mjs +1 -1
  40. package/dist/cli/config/load.mjs.map +1 -1
  41. package/dist/cli/config/parallelization.d.mts +14 -0
  42. package/dist/cli/config/parallelization.d.mts.map +1 -0
  43. package/dist/cli/config/parallelization.mjs +19 -0
  44. package/dist/cli/config/parallelization.mjs.map +1 -0
  45. package/dist/cli/coordination/agent-identity.d.mts.map +1 -1
  46. package/dist/cli/coordination/agent-identity.mjs +7 -1
  47. package/dist/cli/coordination/agent-identity.mjs.map +1 -1
  48. package/dist/cli/design/frontmatter-schema.d.mts +4 -4
  49. package/dist/cli/dispatch.d.mts.map +1 -1
  50. package/dist/cli/dispatch.mjs +2 -0
  51. package/dist/cli/dispatch.mjs.map +1 -1
  52. package/dist/cli/install/cli-to-install-root.d.mts.map +1 -1
  53. package/dist/cli/install/cli-to-install-root.mjs +0 -2
  54. package/dist/cli/install/cli-to-install-root.mjs.map +1 -1
  55. package/dist/cli/install/runtime-detect.d.mts +2 -6
  56. package/dist/cli/install/runtime-detect.d.mts.map +1 -1
  57. package/dist/cli/install/runtime-detect.mjs +8 -3
  58. package/dist/cli/install/runtime-detect.mjs.map +1 -1
  59. package/dist/cli/install/transforms/pi.d.mts +6 -0
  60. package/dist/cli/install/transforms/pi.d.mts.map +1 -0
  61. package/dist/cli/install/transforms/pi.mjs +37 -0
  62. package/dist/cli/install/transforms/pi.mjs.map +1 -0
  63. package/dist/cli/personas/sidecar.d.mts +1 -1
  64. package/dist/cli/roadmap/convex-mirror.d.mts +21 -0
  65. package/dist/cli/roadmap/convex-mirror.d.mts.map +1 -0
  66. package/dist/cli/roadmap/convex-mirror.mjs +133 -0
  67. package/dist/cli/roadmap/convex-mirror.mjs.map +1 -0
  68. package/dist/cli/roadmap/lifecycle.d.mts +1 -1
  69. package/dist/cli/roadmap/lifecycle.d.mts.map +1 -1
  70. package/dist/cli/roadmap/lifecycle.mjs +54 -0
  71. package/dist/cli/roadmap/lifecycle.mjs.map +1 -1
  72. package/dist/cli/roadmap/rollup.d.mts +2 -2
  73. package/dist/cli/wfrun/unit-shape.d.mts +27 -0
  74. package/dist/cli/wfrun/unit-shape.d.mts.map +1 -0
  75. package/dist/cli/wfrun/unit-shape.mjs +38 -0
  76. package/dist/cli/wfrun/unit-shape.mjs.map +1 -0
  77. package/dist/plugin/.claude-plugin/plugin.json +2 -1
  78. package/dist/plugin/agents/apt-executor.md +12 -0
  79. package/dist/plugin/agents/apt-team-docs-narrator.md +11 -0
  80. package/dist/plugin/skills/apt/SKILL.md +3 -2
  81. package/dist/plugin/skills/apt-execute/SKILL.md +13 -0
  82. package/dist/plugin/skills/apt-fan-out/SKILL.md +124 -0
  83. package/dist/plugin/skills/apt-pr-review/SKILL.md +8 -1
  84. package/dist/plugin/skills/apt-review/SKILL.md +4 -0
  85. package/dist/plugin/skills/apt-run/SKILL.md +20 -5
  86. package/dist/plugin/skills/apt-spar/SKILL.md +12 -9
  87. package/dist/plugin/skills/apt-verify/SKILL.md +11 -0
  88. package/dist/plugin/skills/apt-watch-ci/SKILL.md +2 -2
  89. package/dist/schemas/quick-task.d.ts +17 -17
  90. package/package.json +133 -133
  91. package/skills/apt/SKILL.md +3 -2
  92. package/skills/apt-execute/SKILL.md +13 -0
  93. package/skills/apt-fan-out/SKILL.md +124 -0
  94. package/skills/apt-review/SKILL.md +4 -0
  95. package/skills/apt-run/SKILL.md +20 -5
  96. package/skills/apt-spar/SKILL.md +12 -9
  97. package/skills/apt-verify/SKILL.md +11 -0
  98. package/skills/apt-watch-ci/SKILL.md +2 -2
  99. package/src/cli/ci-watch/stop-matrix.mjs +44 -4
  100. package/src/cli/commands/fan-out.mjs +129 -0
  101. package/src/cli/commands/init.mjs +66 -27
  102. package/src/cli/commands/modes.mjs +4 -1
  103. package/src/cli/commands/pr-review-audit-fixer.mjs +82 -6
  104. package/src/cli/commands/roadmap.mjs +48 -4
  105. package/src/cli/commands/task.mjs +15 -0
  106. package/src/cli/commands/validate-evidence.mjs +75 -50
  107. package/src/cli/commands/wave.mjs +91 -58
  108. package/src/cli/commands/worktree.mjs +56 -3
  109. package/src/cli/config/load.mjs +1 -1
  110. package/src/cli/config/parallelization.mjs +18 -0
  111. package/src/cli/coordination/agent-identity.mjs +9 -1
  112. package/src/cli/dispatch.mjs +2 -0
  113. package/src/cli/install/cli-to-install-root.mjs +0 -2
  114. package/src/cli/install/runtime-detect.mjs +8 -3
  115. package/src/cli/install/transforms/pi.mjs +37 -0
  116. package/src/cli/roadmap/convex-mirror.mjs +131 -0
  117. package/src/cli/roadmap/lifecycle.mjs +48 -0
  118. package/src/cli/wfrun/unit-shape.mjs +39 -0
  119. package/templates/config.json +1 -1
@@ -3,8 +3,9 @@
3
3
  * rewrite, 2026-04).
4
4
  *
5
5
  * Replaces the previous Claude-only `cp -r` installer with the per-runtime
6
- * transform pipeline (src/cli/install/pipeline.mjs). All 14 runtime targets
7
- * supported via `--<runtime>` flags or `--all`. Default with no flags in
6
+ * transform pipeline (src/cli/install/pipeline.mjs). Registered runtime
7
+ * targets are supported via `--<runtime>` flags or `--all`. Default with no
8
+ * flags in
8
9
  * non-TTY mode → `--claude` (backward compat — existing skills invoke
9
10
  * `apt-tools init <dir>` with no flags). Default in TTY → interactive prompt
10
11
  * with auto-detection markers.
@@ -40,12 +41,13 @@ import { buildAperantGitignoreTemplate } from '../config/upgrade-gitignore.mjs'
40
41
  import { detectPublicRemote } from '../git/remote.mjs'
41
42
  import { detectArchetype } from '../install/archetype.mjs'
42
43
  import { installKernel } from '../install/install-kernel.mjs'
44
+ import { MANIFEST_FILENAME } from '../install/manifest.mjs'
43
45
  import {
44
46
  discoverCanonicalFiles,
45
47
  runInstall,
46
48
  writeLastInstallSummary,
47
49
  } from '../install/pipeline.mjs'
48
- import { RUNTIMES, suggestRuntimes } from '../install/runtime-detect.mjs'
50
+ import { getRuntime, RUNTIMES, suggestRuntimes } from '../install/runtime-detect.mjs'
49
51
  import { runLegacyCleanup } from '../install/runtime-migrate.mjs'
50
52
  import {
51
53
  CLASS_A_SETTINGS,
@@ -392,6 +394,42 @@ function selectRuntimes(targetDir, parsedFlags) {
392
394
  return [...suggested]
393
395
  }
394
396
 
397
+ /**
398
+ * Return the runtime ids that should be installed during a no-schema-drift
399
+ * init. A current config is not enough to prove runtime files exist on this
400
+ * machine: team repos often carry `.aperant/config.json` but not local
401
+ * runtime roots/manifests.
402
+ *
403
+ * @param {string} targetDir
404
+ * @param {string[]} runtimeIds
405
+ * @returns {string[]}
406
+ */
407
+ function runtimesMissingManifest(targetDir, runtimeIds) {
408
+ const missing = []
409
+ for (const id of runtimeIds) {
410
+ const runtime = getRuntime(id)
411
+ if (!runtime) continue
412
+ const manifestPath = join(targetDir, runtime.installRoot, MANIFEST_FILENAME)
413
+ if (!existsSync(manifestPath)) missing.push(id)
414
+ }
415
+ return missing
416
+ }
417
+
418
+ /**
419
+ * Current-schema init should still run the runtime installer when the user
420
+ * explicitly requested runtimes, when --force-runtimes is present, or when
421
+ * local runtime roots are suggested but have no apt manifest.
422
+ *
423
+ * @param {string} targetDir
424
+ * @param {ReturnType<typeof parseInstallFlags>} parsedFlags
425
+ * @returns {string[]}
426
+ */
427
+ function selectNoDriftRuntimeInstallIds(targetDir, parsedFlags) {
428
+ if (parsedFlags.forceRuntimes) return selectRuntimes(targetDir, parsedFlags)
429
+ if (parsedFlags.runtimes_explicit) return parsedFlags.runtimes
430
+ return runtimesMissingManifest(targetDir, suggestRuntimes(targetDir))
431
+ }
432
+
395
433
  /**
396
434
  * Non-TTY / scripted path — derive answers from flags + sensible defaults,
397
435
  * gated by the Class A/B classifier (spec R2 + R3).
@@ -663,11 +701,16 @@ async function interactiveInterview(targetDir, version, canonicalRoot, archetype
663
701
  migration.applied_defaults.length === 0 &&
664
702
  migration.auto_added.length === 0
665
703
  if (noDrift) {
666
- // 0.6.7 (Bug 2 fix) — `--force-runtimes` bypasses the TTY
667
- // "Nothing to do." outro so the runtime walker re-stamps every
668
- // manifest; cmdInit's force-runtimes synthesis path takes over.
669
- if (parsedFlags.forceRuntimes) {
670
- return { upgrade_noop: true, skipBootstrap, migration }
704
+ const runtimeInstallIds = selectNoDriftRuntimeInstallIds(targetDir, parsedFlags)
705
+ // Runtime repair bypasses the TTY "Nothing to do." outro so the
706
+ // runtime walker can re-stamp missing manifests first.
707
+ if (runtimeInstallIds.length > 0) {
708
+ return {
709
+ upgrade_noop: true,
710
+ skipBootstrap,
711
+ migration,
712
+ runtime_reinstall_runtimes: runtimeInstallIds,
713
+ }
671
714
  }
672
715
  process.stdout.write(`\n${aperantBanner(version)}\n\n`)
673
716
  clack.intro('install')
@@ -1158,20 +1201,20 @@ export async function cmdInit(projectDir, extraArgs = []) {
1158
1201
  )
1159
1202
  }
1160
1203
 
1161
- // Upgrade no-drift — short-circuit before installRuntimes.
1162
- // /apt:update is the right surface for refreshing skills on a
1163
- // no-drift run; init's job here is to confirm the schema is current.
1204
+ const noDriftRuntimeInstallIds = answers?.upgrade_noop
1205
+ ? (answers.runtime_reinstall_runtimes ??
1206
+ selectNoDriftRuntimeInstallIds(installTarget, parsedFlags))
1207
+ : []
1208
+
1209
+ // Upgrade no-drift with no runtime work — short-circuit before
1210
+ // installRuntimes. /apt:update remains the right surface for refreshing
1211
+ // already-manifested skills; init only repairs selected or suggested
1212
+ // runtime roots that lack manifests.
1164
1213
  // The bootstrap auto-chain still runs (it's deterministic and
1165
1214
  // idempotent — preserves PROJECT.md, refreshes sidecar from it).
1166
1215
  // In TTY mode clack already rendered "Nothing to do." to stdout,
1167
1216
  // so suppress the JSON envelope (matches the cancelled path).
1168
- //
1169
- // 0.6.7 (Bug 2 fix) — `--force-runtimes` bypasses this short-circuit
1170
- // so `/apt:update` can re-stamp every runtime manifest even when
1171
- // `.aperant/config.json` is at the current schema. We synthesize a
1172
- // real InstallAnswers shape from the existing config so
1173
- // installRuntimes + finishInit run their normal upgrade path.
1174
- if (answers?.upgrade_noop && !parsedFlags.forceRuntimes) {
1217
+ if (answers?.upgrade_noop && noDriftRuntimeInstallIds.length === 0) {
1175
1218
  if (interactiveTty) return rawText('', 0)
1176
1219
  const bootstrap = answers.skipBootstrap
1177
1220
  ? { status: 'skipped', reason: '--skip-bootstrap flag' }
@@ -1187,18 +1230,14 @@ export async function cmdInit(projectDir, extraArgs = []) {
1187
1230
  })
1188
1231
  }
1189
1232
 
1190
- // 0.6.7 (Bug 2 fix) when forceRuntimes is set on a no-drift run,
1191
- // synthesize a real InstallAnswers shape from the existing config so
1192
- // the runtime walker re-stamps every manifest. Visibility/autonomy/
1193
- // cross-verification are read from answers.migration.migrated (already
1194
- // in memory from nonInteractiveAnswers — avoids a second config read);
1195
- // runtimes resolved via selectRuntimes helper shared with the upgrade path.
1233
+ // Current-schema runtime installsynthesize a real InstallAnswers
1234
+ // shape from the existing config so installRuntimes + finishInit run
1235
+ // their normal upgrade path.
1196
1236
  let effectiveAnswers = answers
1197
- if (answers?.upgrade_noop && parsedFlags.forceRuntimes) {
1237
+ if (answers?.upgrade_noop && noDriftRuntimeInstallIds.length > 0) {
1198
1238
  const existing = answers.migration?.migrated || {}
1199
- const runtimes = selectRuntimes(installTarget, parsedFlags)
1200
1239
  effectiveAnswers = {
1201
- runtimes,
1240
+ runtimes: noDriftRuntimeInstallIds,
1202
1241
  autonomyDefault: existing?.autonomy?.default ?? 1,
1203
1242
  visibility: existing?.share?.visibility === 'team' ? 'team' : 'solo',
1204
1243
  crossVerificationTools: existing?.multi_model?.cross_verification?.tools ?? [],
@@ -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