@aperant/framework 0.6.7 → 0.7.3

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 (205) hide show
  1. package/CHANGELOG.md +240 -0
  2. package/agents/apt-planner.md +12 -0
  3. package/agents/apt-pr-review-fixer.md +13 -9
  4. package/bin/apt-tools.mjs +7 -0
  5. package/dist/cli/ci-watch/stop-matrix.d.mts.map +1 -1
  6. package/dist/cli/ci-watch/stop-matrix.mjs +16 -0
  7. package/dist/cli/ci-watch/stop-matrix.mjs.map +1 -1
  8. package/dist/cli/commands/ci-watch.d.mts +11 -0
  9. package/dist/cli/commands/ci-watch.d.mts.map +1 -1
  10. package/dist/cli/commands/ci-watch.mjs +147 -3
  11. package/dist/cli/commands/ci-watch.mjs.map +1 -1
  12. package/dist/cli/commands/features-audit.d.mts +24 -0
  13. package/dist/cli/commands/features-audit.d.mts.map +1 -1
  14. package/dist/cli/commands/features-audit.mjs +159 -5
  15. package/dist/cli/commands/features-audit.mjs.map +1 -1
  16. package/dist/cli/commands/health-check.d.mts +16 -0
  17. package/dist/cli/commands/health-check.d.mts.map +1 -1
  18. package/dist/cli/commands/health-check.mjs +119 -3
  19. package/dist/cli/commands/health-check.mjs.map +1 -1
  20. package/dist/cli/commands/init.d.mts +19 -1
  21. package/dist/cli/commands/init.d.mts.map +1 -1
  22. package/dist/cli/commands/init.mjs +143 -8
  23. package/dist/cli/commands/init.mjs.map +1 -1
  24. package/dist/cli/commands/modes.d.mts.map +1 -1
  25. package/dist/cli/commands/modes.mjs +11 -0
  26. package/dist/cli/commands/modes.mjs.map +1 -1
  27. package/dist/cli/commands/pr-review-audit-fixer.d.mts +13 -0
  28. package/dist/cli/commands/pr-review-audit-fixer.d.mts.map +1 -1
  29. package/dist/cli/commands/pr-review-audit-fixer.mjs +18 -5
  30. package/dist/cli/commands/pr-review-audit-fixer.mjs.map +1 -1
  31. package/dist/cli/commands/route.d.mts.map +1 -1
  32. package/dist/cli/commands/route.mjs +37 -2
  33. package/dist/cli/commands/route.mjs.map +1 -1
  34. package/dist/cli/commands/task.d.mts.map +1 -1
  35. package/dist/cli/commands/task.mjs +132 -5
  36. package/dist/cli/commands/task.mjs.map +1 -1
  37. package/dist/cli/commands/validate-evidence.d.mts +24 -2
  38. package/dist/cli/commands/validate-evidence.d.mts.map +1 -1
  39. package/dist/cli/commands/validate-evidence.mjs +154 -17
  40. package/dist/cli/commands/validate-evidence.mjs.map +1 -1
  41. package/dist/cli/commands/vitest-doctor.d.mts +2 -0
  42. package/dist/cli/commands/vitest-doctor.d.mts.map +1 -0
  43. package/dist/cli/commands/vitest-doctor.mjs +168 -0
  44. package/dist/cli/commands/vitest-doctor.mjs.map +1 -0
  45. package/dist/cli/config/gitignore-drift.d.mts +23 -1
  46. package/dist/cli/config/gitignore-drift.d.mts.map +1 -1
  47. package/dist/cli/config/gitignore-drift.mjs +81 -3
  48. package/dist/cli/config/gitignore-drift.mjs.map +1 -1
  49. package/dist/cli/config/load.d.mts +56 -2
  50. package/dist/cli/config/load.d.mts.map +1 -1
  51. package/dist/cli/config/load.mjs +192 -2
  52. package/dist/cli/config/load.mjs.map +1 -1
  53. package/dist/cli/consistency/parse-review.mjs +7 -0
  54. package/dist/cli/consistency/parse-review.mjs.map +1 -1
  55. package/dist/cli/dispatch.d.mts.map +1 -1
  56. package/dist/cli/dispatch.mjs +24 -2
  57. package/dist/cli/dispatch.mjs.map +1 -1
  58. package/dist/cli/gate/gates/gitignore-in-sync.d.mts +1 -1
  59. package/dist/cli/gate/gates/gitignore-in-sync.d.mts.map +1 -1
  60. package/dist/cli/gate/gates/gitignore-in-sync.mjs +5 -2
  61. package/dist/cli/gate/gates/gitignore-in-sync.mjs.map +1 -1
  62. package/dist/cli/gate/gates/review-clean.d.mts +5 -1
  63. package/dist/cli/gate/gates/review-clean.d.mts.map +1 -1
  64. package/dist/cli/gate/gates/review-clean.mjs +23 -18
  65. package/dist/cli/gate/gates/review-clean.mjs.map +1 -1
  66. package/dist/cli/gate/gates/verify-approved.d.mts +49 -1
  67. package/dist/cli/gate/gates/verify-approved.d.mts.map +1 -1
  68. package/dist/cli/gate/gates/verify-approved.mjs +93 -14
  69. package/dist/cli/gate/gates/verify-approved.mjs.map +1 -1
  70. package/dist/cli/help.d.mts.map +1 -1
  71. package/dist/cli/help.mjs +8 -2
  72. package/dist/cli/help.mjs.map +1 -1
  73. package/dist/cli/host/detect.d.mts +122 -9
  74. package/dist/cli/host/detect.d.mts.map +1 -1
  75. package/dist/cli/host/detect.mjs +132 -0
  76. package/dist/cli/host/detect.mjs.map +1 -1
  77. package/dist/cli/install/legacy-paths.d.mts +38 -0
  78. package/dist/cli/install/legacy-paths.d.mts.map +1 -0
  79. package/dist/cli/install/legacy-paths.mjs +69 -0
  80. package/dist/cli/install/legacy-paths.mjs.map +1 -0
  81. package/dist/cli/install/runtime-detect.d.mts +13 -0
  82. package/dist/cli/install/runtime-detect.d.mts.map +1 -1
  83. package/dist/cli/install/runtime-detect.mjs +9 -0
  84. package/dist/cli/install/runtime-detect.mjs.map +1 -1
  85. package/dist/cli/install/runtime-migrate.d.mts +84 -0
  86. package/dist/cli/install/runtime-migrate.d.mts.map +1 -0
  87. package/dist/cli/install/runtime-migrate.mjs +244 -0
  88. package/dist/cli/install/runtime-migrate.mjs.map +1 -0
  89. package/dist/cli/route/drift-detect.d.mts +20 -0
  90. package/dist/cli/route/drift-detect.d.mts.map +1 -0
  91. package/dist/cli/route/drift-detect.mjs +107 -0
  92. package/dist/cli/route/drift-detect.mjs.map +1 -0
  93. package/dist/cli/task/index-md.d.mts.map +1 -1
  94. package/dist/cli/task/index-md.mjs +14 -2
  95. package/dist/cli/task/index-md.mjs.map +1 -1
  96. package/dist/cli/util/aperant-section.d.mts +34 -0
  97. package/dist/cli/util/aperant-section.d.mts.map +1 -0
  98. package/dist/cli/util/aperant-section.mjs +127 -0
  99. package/dist/cli/util/aperant-section.mjs.map +1 -0
  100. package/dist/cli/util/copy.d.mts +28 -1
  101. package/dist/cli/util/copy.d.mts.map +1 -1
  102. package/dist/cli/util/copy.mjs +43 -55
  103. package/dist/cli/util/copy.mjs.map +1 -1
  104. package/dist/cli/util/semver.d.mts +17 -0
  105. package/dist/cli/util/semver.d.mts.map +1 -0
  106. package/dist/cli/util/semver.mjs +29 -0
  107. package/dist/cli/util/semver.mjs.map +1 -0
  108. package/dist/cli/util/skill-installs.d.mts +65 -9
  109. package/dist/cli/util/skill-installs.d.mts.map +1 -1
  110. package/dist/cli/util/skill-installs.mjs +130 -21
  111. package/dist/cli/util/skill-installs.mjs.map +1 -1
  112. package/dist/cli/util/version-preflight.d.mts +44 -0
  113. package/dist/cli/util/version-preflight.d.mts.map +1 -0
  114. package/dist/cli/util/version-preflight.mjs +66 -0
  115. package/dist/cli/util/version-preflight.mjs.map +1 -0
  116. package/dist/plugin/.claude-plugin/plugin.json +11 -2
  117. package/dist/plugin/agents/apt-improver.md +99 -0
  118. package/dist/plugin/agents/apt-planner.md +127 -10
  119. package/dist/plugin/agents/apt-pr-review-fixer.md +13 -9
  120. package/dist/plugin/skills/apt/SKILL.md +1 -0
  121. package/dist/plugin/skills/apt-close-task/SKILL.md +63 -1
  122. package/dist/plugin/skills/apt-debug/SKILL.md +39 -6
  123. package/dist/plugin/skills/apt-debug/appendices/diagnose-discipline.md +119 -0
  124. package/dist/plugin/skills/apt-diagram/SKILL.md +378 -0
  125. package/dist/plugin/skills/apt-diagram/appendices/design-discipline.md +97 -0
  126. package/dist/plugin/skills/apt-discuss/SKILL.md +72 -5
  127. package/dist/plugin/skills/apt-discuss/appendices/grill-discipline.md +104 -0
  128. package/dist/plugin/skills/apt-discuss/appendices/zoom-out-helper.md +79 -0
  129. package/dist/plugin/skills/apt-execute/SKILL.md +57 -5
  130. package/dist/plugin/skills/apt-execute/appendices/tdd-mode.md +107 -0
  131. package/dist/plugin/skills/apt-improve/DEEPENING.md +84 -0
  132. package/dist/plugin/skills/apt-improve/INTERFACE-DESIGN.md +97 -0
  133. package/dist/plugin/skills/apt-improve/LANGUAGE.md +104 -0
  134. package/dist/plugin/skills/apt-improve/SKILL.md +141 -0
  135. package/dist/plugin/skills/apt-plan/SKILL.md +171 -4
  136. package/dist/plugin/skills/apt-plan/adapters/conductor.md +98 -0
  137. package/dist/plugin/skills/apt-pr-review/SKILL.md +57 -18
  138. package/dist/plugin/skills/apt-prototype/LOGIC.md +109 -0
  139. package/dist/plugin/skills/apt-prototype/SKILL.md +143 -0
  140. package/dist/plugin/skills/apt-prototype/UI.md +90 -0
  141. package/dist/plugin/skills/apt-quick/SKILL.md +49 -8
  142. package/dist/plugin/skills/apt-release-notes/SKILL.md +193 -0
  143. package/dist/plugin/skills/apt-release-notes/appendices/persona-voice.md +59 -0
  144. package/dist/plugin/skills/apt-review/SKILL.md +2 -0
  145. package/dist/plugin/skills/apt-run/SKILL.md +32 -4
  146. package/dist/plugin/skills/apt-setup/SKILL.md +308 -6
  147. package/dist/plugin/skills/apt-ship/SKILL.md +122 -1
  148. package/dist/plugin/skills/apt-spar/SKILL.md +315 -0
  149. package/dist/plugin/skills/apt-triage/AGENT-BRIEF.md +84 -0
  150. package/dist/plugin/skills/apt-triage/OUT-OF-SCOPE.md +75 -0
  151. package/dist/plugin/skills/apt-triage/SKILL.md +169 -0
  152. package/dist/plugin/skills/apt-update/SKILL.md +77 -10
  153. package/dist/plugin/skills/apt-verify/SKILL.md +3 -0
  154. package/dist/plugin/skills/apt-verify-proof/SKILL.md +10 -5
  155. package/dist/plugin/skills/apt-watch-ci/SKILL.md +166 -0
  156. package/dist/plugin/skills/apt-zoom-out/SKILL.md +130 -0
  157. package/package.json +133 -133
  158. package/prompts/conductor-framework-context.md +63 -0
  159. package/prompts/conductor-system.md +11 -0
  160. package/skills/apt-close-task/SKILL.md +6 -0
  161. package/skills/apt-discuss/SKILL.md +47 -5
  162. package/skills/apt-execute/SKILL.md +9 -0
  163. package/skills/apt-plan/SKILL.md +12 -0
  164. package/skills/apt-pr-review/SKILL.md +11 -2
  165. package/skills/apt-quick/SKILL.md +19 -8
  166. package/skills/apt-researcher.md +1 -0
  167. package/skills/apt-setup/SKILL.md +33 -2
  168. package/skills/apt-ship/SKILL.md +16 -4
  169. package/skills/apt-spar/SKILL.md +36 -11
  170. package/skills/apt-update/SKILL.md +26 -1
  171. package/skills/apt-verify-proof/SKILL.md +7 -5
  172. package/skills/apt-watch-ci/SKILL.md +4 -1
  173. package/src/cli/ci-watch/stop-matrix.mjs +17 -0
  174. package/src/cli/commands/ci-watch.mjs +152 -3
  175. package/src/cli/commands/features-audit.mjs +164 -5
  176. package/src/cli/commands/health-check.mjs +116 -3
  177. package/src/cli/commands/init.mjs +154 -6
  178. package/src/cli/commands/modes.mjs +11 -0
  179. package/src/cli/commands/pr-review-audit-fixer.mjs +18 -5
  180. package/src/cli/commands/route.mjs +38 -2
  181. package/src/cli/commands/task.mjs +132 -5
  182. package/src/cli/commands/validate-evidence.mjs +158 -17
  183. package/src/cli/commands/vitest-doctor.mjs +173 -0
  184. package/src/cli/config/gitignore-drift.mjs +74 -3
  185. package/src/cli/config/load.mjs +188 -2
  186. package/src/cli/consistency/parse-review.mjs +6 -0
  187. package/src/cli/dispatch.mjs +23 -2
  188. package/src/cli/gate/gates/gitignore-in-sync.mjs +5 -2
  189. package/src/cli/gate/gates/review-clean.mjs +24 -19
  190. package/src/cli/gate/gates/verify-approved.mjs +97 -14
  191. package/src/cli/help.mjs +8 -2
  192. package/src/cli/host/detect.mjs +135 -0
  193. package/src/cli/install/legacy-paths.mjs +69 -0
  194. package/src/cli/install/runtime-detect.mjs +9 -0
  195. package/src/cli/install/runtime-migrate.mjs +252 -0
  196. package/src/cli/route/drift-detect.mjs +107 -0
  197. package/src/cli/task/index-md.mjs +15 -2
  198. package/src/cli/util/aperant-section.mjs +136 -0
  199. package/src/cli/util/copy.mjs +43 -56
  200. package/src/cli/util/semver.mjs +28 -0
  201. package/src/cli/util/skill-installs.mjs +134 -21
  202. package/src/cli/util/version-preflight.mjs +65 -0
  203. package/templates/aperant-claude-md-appendix.md +37 -0
  204. package/templates/config.json +2 -7
  205. package/workflows/verify-proof.md +8 -3
@@ -668,10 +668,14 @@ export function decideVerdict({ statusLine, fixesApplied, diffFiles }) {
668
668
  }
669
669
  }
670
670
 
671
- function readFixesAppliedForFixer(reviewDir, iteration, fixer) {
672
- // Try the group-specific naming first (fixes-applied-group{N}.md) for
673
- // fixer-{N}, then fall back to the unscoped fixes-applied.md which the
674
- // self-reviewer writes to self-review.md separately.
671
+ export function readFixesAppliedForFixer(reviewDir, iteration, fixer) {
672
+ // BUG-030: strict-ordered candidate list. Parallel fixers write to
673
+ // distinct fixer-named markdowns (fixes-applied-fixer-{N}.md AND/OR
674
+ // fixes-applied-{fixer}.md); the legacy group-named form remains for
675
+ // backward-compat with reviews started before this fix. fixes-applied.md
676
+ // (unscoped) stays last as the legacy single-fixer fallback. NO broad
677
+ // glob — false-positives on the wrong fixer's file are what caused the
678
+ // `hallucinated` flake (4 sightings in 3 days per spec-gaps).
675
679
  const iterDir = join(reviewDir, 'iterations', String(iteration))
676
680
  const candidates = []
677
681
  if (fixer === 'self-reviewer') {
@@ -681,7 +685,9 @@ function readFixesAppliedForFixer(reviewDir, iteration, fixer) {
681
685
  const match = /^fixer-(\d+)$/.exec(fixer)
682
686
  if (match) {
683
687
  candidates.push(join(iterDir, `fixes-applied-group${match[1]}.md`))
688
+ candidates.push(join(iterDir, `fixes-applied-fixer-${match[1]}.md`))
684
689
  }
690
+ candidates.push(join(iterDir, `fixes-applied-${fixer}.md`))
685
691
  candidates.push(join(iterDir, 'fixes-applied.md'))
686
692
  }
687
693
  for (const c of candidates) {
@@ -802,7 +808,14 @@ export function cmdPrReviewAuditFixer(projectDir, extraArgs) {
802
808
 
803
809
  const worktreeArg = flags.get('worktree')
804
810
  if (!worktreeArg) return err('Required: --worktree <path>')
805
- const worktree = resolve(worktreeArg)
811
+ // BUG-015: resolve relative to the passed projectDir (not process.cwd()).
812
+ // Callers invoke audit-fixer from a non-worktree cwd (the main repo) with
813
+ // a relative --worktree like `../worktrees/foo`; resolving against cwd
814
+ // returned a bogus path that didn't exist, causing the audit to see zero
815
+ // staged files and false-positive `hallucinated`. The existing `cwd:
816
+ // worktree` at the spawn site (line 437-473) already scopes git
817
+ // invocations correctly, so no `git -C` change is needed.
818
+ const worktree = resolve(projectDir, worktreeArg)
806
819
 
807
820
  const statusLineArg = flags.get('status-line')
808
821
  // Missing / empty status line is fail-closed to hallucinated, not a
@@ -26,16 +26,36 @@
26
26
 
27
27
  import { existsSync, readFileSync } from 'node:fs'
28
28
  import { homedir } from 'node:os'
29
- import { join, resolve } from 'node:path'
29
+ import { dirname, join, resolve } from 'node:path'
30
+ import { fileURLToPath } from 'node:url'
30
31
  import { getTaskIsolationConfig, getTrustedSkillSources } from '../config/load.mjs'
31
32
  import '../gate/gates/index.mjs'
32
33
  import * as gateRegistry from '../gate/registry.mjs'
33
34
  import { detectHost, SUPPORTED_HOSTS } from '../host/detect.mjs'
35
+ import { detectSourceWorkspaceDrift } from '../route/drift-detect.mjs'
34
36
  import { buildEnvelope } from '../route/envelope.mjs'
35
37
  import { discoverSkills } from '../route/skill-discover.mjs'
36
38
  import { executionModesList, isValidExecutionMode } from '../task/execution-mode.mjs'
37
39
  import { err, exitWith, ok } from '../util/result.mjs'
38
40
 
41
+ /**
42
+ * Read the installed kernel's own version from its package.json.
43
+ * Same pattern as commands/check-version.mjs:108-109 — `__dirname` of
44
+ * the running module resolves to the installed kernel for npm-installed
45
+ * users and to the source workspace for in-monorepo runs. Returns null
46
+ * on any I/O / parse failure (drift-detect treats null as "skip").
47
+ */
48
+ function readInstalledKernelVersion() {
49
+ try {
50
+ const here = dirname(fileURLToPath(import.meta.url))
51
+ const pkgPath = resolve(here, '..', '..', '..', 'package.json')
52
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))
53
+ return typeof pkg?.version === 'string' ? pkg.version : null
54
+ } catch {
55
+ return null
56
+ }
57
+ }
58
+
39
59
  /**
40
60
  * Read the shared update-check cache (written by the SessionStart hook
41
61
  * via apt-check-update-worker.js) and extract the fields /apt's SKILL.md
@@ -254,7 +274,23 @@ export function cmdRoute(projectDir, extraArgs) {
254
274
  const task_isolation = getTaskIsolationConfig(targetDir)
255
275
 
256
276
  const updateCheck = readUpdateCheckCache()
257
- const update_check = updateCheck ? { ...updateCheck } : null
277
+ let update_check = updateCheck ? { ...updateCheck } : null
278
+
279
+ // FRAMEWORK-BUG-017 rung 1: in-band detection of workspace-ahead-of-
280
+ // installed-kernel drift. Catches the self-host contributor case
281
+ // where the SessionStart worker can't see the local same-PR fix yet.
282
+ // Workspace wins when both signals fire (workspace is the source of
283
+ // truth for self-host).
284
+ const installedKernelVersion = readInstalledKernelVersion()
285
+ const drift = detectSourceWorkspaceDrift(targetDir, installedKernelVersion)
286
+ if (drift) {
287
+ update_check = {
288
+ update_available: true,
289
+ installed_version: drift.installed_version,
290
+ latest_version: drift.source_version,
291
+ reason: 'workspace source ahead of installed kernel — run /apt:update --from-source',
292
+ }
293
+ }
258
294
 
259
295
  // ---------------- Deterministic branches (zero-behavior-change) ----------------
260
296
  // These fire regardless of host detection status — missing host is not
@@ -14,7 +14,11 @@
14
14
  import { spawnSync } from 'node:child_process'
15
15
  import { existsSync, mkdirSync, readFileSync, rmSync } from 'node:fs'
16
16
  import { join, resolve } from 'node:path'
17
- import { getTaskIsolationConfig, loadProjectConfig } from '../config/load.mjs'
17
+ import {
18
+ getTaskIsolationConfig,
19
+ loadMergedProjectConfig,
20
+ loadProjectConfig,
21
+ } from '../config/load.mjs'
18
22
  import { findMostRecentEventForTask } from '../coordination/event-log.mjs'
19
23
  import { createCoordinationStore } from '../coordination/store.mjs'
20
24
  import { getGitIdentity } from '../git/identity.mjs'
@@ -127,9 +131,16 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
127
131
  // a later roadmap add-milestone/add-phase may populate it.
128
132
  }
129
133
 
130
- // Load config for date format preference
131
- const configPath = join(targetDir, '.aperant', 'config.json')
132
- const config = existsSync(configPath) ? JSON.parse(readFileSync(configPath, 'utf-8')) : {}
134
+ // AUDIT-001: read through the merged loader so local-only
135
+ // overrides for preferences.date_format / preferences.time_format
136
+ // win over the shared config.json fallback.
137
+ // Guard: malformed config.local.json must not crash task creation.
138
+ let config
139
+ try {
140
+ config = loadMergedProjectConfig(targetDir) ?? {}
141
+ } catch {
142
+ config = {}
143
+ }
133
144
  // User-supplied --id bypasses the collision guard (caller owns the
134
145
  // uniqueness contract). Generated ids run through a numeric-counter
135
146
  // disambiguator that appends -2, -3, … when the candidate already
@@ -982,7 +993,50 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
982
993
  state.last_activity = new Date().toISOString()
983
994
  atomicWriteJson(statePath, state)
984
995
  if (prUrlForCiWatch) {
985
- ciWatchResult = armCiWatchAfterPrUrl(targetDir, task, prUrlForCiWatch)
996
+ // BUG-027 (a): pre-arm gh check. If the PR is already
997
+ // MERGED/CLOSED, skip arming so we don't leak a new
998
+ // watcher state file we'll immediately have to GC. gh
999
+ // rate-limit / offline / ENOENT falls back to arm (mirror
1000
+ // the existing rate_limited/offline patterns at lines
1001
+ // 1322/1326) — non-fatal so a transient gh failure
1002
+ // doesn't prevent legitimate watcher arming.
1003
+ const ghFixture = process.env.APT_GH_FIXTURE_JSON
1004
+ let terminalState = null
1005
+ if (ghFixture) {
1006
+ try {
1007
+ const parsed = JSON.parse(ghFixture)
1008
+ if (parsed?.state === 'MERGED' || parsed?.state === 'CLOSED') {
1009
+ terminalState = parsed.state
1010
+ }
1011
+ } catch {
1012
+ /* malformed fixture → fall through to arm */
1013
+ }
1014
+ } else {
1015
+ try {
1016
+ const res = spawnSync('gh', ['pr', 'view', prUrlForCiWatch, '--json', 'state'], {
1017
+ cwd: targetDir,
1018
+ encoding: 'utf-8',
1019
+ })
1020
+ if (res.status === 0 && res.stdout) {
1021
+ const parsed = JSON.parse(res.stdout)
1022
+ if (parsed?.state === 'MERGED' || parsed?.state === 'CLOSED') {
1023
+ terminalState = parsed.state
1024
+ }
1025
+ }
1026
+ /* non-zero exit / ENOENT / parse error → fall through to arm */
1027
+ } catch {
1028
+ /* never block arming on gh transport error */
1029
+ }
1030
+ }
1031
+ if (terminalState) {
1032
+ ciWatchResult = {
1033
+ ci_watch_armed: false,
1034
+ ci_watch_skip_reason: 'pr-terminal-state',
1035
+ ci_watch: null,
1036
+ }
1037
+ } else {
1038
+ ciWatchResult = armCiWatchAfterPrUrl(targetDir, task, prUrlForCiWatch)
1039
+ }
986
1040
  }
987
1041
 
988
1042
  regenerateTaskIndex(targetDir)
@@ -1006,6 +1060,12 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
1006
1060
  updateOut.ci_watch_armed = ciWatchResult.ci_watch_armed
1007
1061
  updateOut.ci_watch_skip_reason = ciWatchResult.ci_watch_skip_reason
1008
1062
  if (ciWatchResult.ci_watch) updateOut.ci_watch = ciWatchResult.ci_watch
1063
+ // FRAMEWORK-BUG-018 Track 1: surface `scheduler_handle` at the top
1064
+ // level so `apt-ship/SKILL.md` §7.2 can read it via
1065
+ // `.scheduler_handle // empty` without dotting through `.ci_watch.*`.
1066
+ // `null` today (no production path populates it); Track 2 lands
1067
+ // non-null values without re-plumbing the envelope shape.
1068
+ updateOut.scheduler_handle = ciWatchResult.scheduler_handle ?? null
1009
1069
  }
1010
1070
  updateResult = ok(updateOut)
1011
1071
  },
@@ -1217,6 +1277,46 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
1217
1277
  // `gh pr view`, and close the ones GitHub says merged. Per spec §B1.
1218
1278
  const onlyTaskId = flags.get('task') || null
1219
1279
  const dryRun = flags.has('dry-run')
1280
+ const narrateOnly = flags.has('narrate-only')
1281
+
1282
+ // FRAMEWORK-BUG-020 — `--narrate-only` is read-only enumeration of
1283
+ // the pending_narration[] ledger. No `gh pr view`, no `task close`
1284
+ // spawn, no ledger writes. The skill body (apt-close-task §4) drains
1285
+ // per row via `task narration-drain` AFTER a successful narrator
1286
+ // spawn — at-least-once semantics is safe because the narrator is
1287
+ // anchor-replace idempotent (agents/apt-team-docs-narrator.md §6/§2).
1288
+ if (narrateOnly) {
1289
+ if (!existsSync(statePath)) {
1290
+ return ok({
1291
+ status: 'ok',
1292
+ command: 'task-close-merged',
1293
+ mode: 'narrate-only',
1294
+ closed: [],
1295
+ skipped: [],
1296
+ errors: [],
1297
+ })
1298
+ }
1299
+ const stateBlob = JSON.parse(readFileSync(statePath, 'utf-8'))
1300
+ const ledger = Array.isArray(stateBlob.pending_narration) ? stateBlob.pending_narration : []
1301
+ const filtered = onlyTaskId
1302
+ ? ledger.filter((row) => row && row.task_id === onlyTaskId)
1303
+ : ledger
1304
+ return ok({
1305
+ status: 'ok',
1306
+ command: 'task-close-merged',
1307
+ mode: 'narrate-only',
1308
+ closed: filtered.map((row) => ({
1309
+ task_id: row.task_id,
1310
+ scope: row.scope ?? null,
1311
+ phase_id: row.phase_id ?? null,
1312
+ closed_at: row.closed_at ?? null,
1313
+ pr_number: row.pr_number ?? null,
1314
+ narration_pending: true,
1315
+ })),
1316
+ skipped: [],
1317
+ errors: [],
1318
+ })
1319
+ }
1220
1320
 
1221
1321
  if (!existsSync(statePath)) {
1222
1322
  return ok({
@@ -1433,6 +1533,30 @@ export function cmdTask(subcommand, projectDir, extraArgs) {
1433
1533
  mergedAt: parsed.mergedAt ?? null,
1434
1534
  })
1435
1535
 
1536
+ // BUG-027 (b): tear down any armed ci-watch state for this PR.
1537
+ // `doStop` is idempotent (returns `not-found` for missing state
1538
+ // files), so calling unconditionally here is safe. Without this
1539
+ // the watcher state file leaks past PR-terminal-state and
1540
+ // downstream operations (ci-watch sweep, health-check) flag it
1541
+ // as orphaned. Failure is logged into errors[] as a warning;
1542
+ // the merge close itself has already succeeded.
1543
+ try {
1544
+ const prNumber = parsed.number ?? extractPrNumber(task.pr_url)
1545
+ if (prNumber !== null) {
1546
+ cmdCiWatch('stop', targetDir, [
1547
+ '--pr',
1548
+ String(prNumber),
1549
+ '--reason',
1550
+ 'task-closed-merged',
1551
+ ])
1552
+ }
1553
+ } catch (e) {
1554
+ errors.push({
1555
+ id,
1556
+ reason: `ci-watch-stop-failed: ${e?.message ?? String(e)}`,
1557
+ })
1558
+ }
1559
+
1436
1560
  // Append a pending_narration[] ledger entry on successful merged close
1437
1561
  // so /apt:close-task --narrate-only can drain it later. The ledger is
1438
1562
  // additive-only; failure to write must never undo the close above
@@ -1675,6 +1799,7 @@ function armCiWatchAfterPrUrl(targetDir, task, prUrl) {
1675
1799
  ci_watch_armed: true,
1676
1800
  ci_watch_skip_reason: null,
1677
1801
  ci_watch: envelope,
1802
+ scheduler_handle: envelope.scheduler_handle ?? null,
1678
1803
  }
1679
1804
  }
1680
1805
  if (envelope.status === 'busy' && envelope.reason === 'already-armed') {
@@ -1682,12 +1807,14 @@ function armCiWatchAfterPrUrl(targetDir, task, prUrl) {
1682
1807
  ci_watch_armed: true,
1683
1808
  ci_watch_skip_reason: 'already-armed',
1684
1809
  ci_watch: envelope,
1810
+ scheduler_handle: envelope.scheduler_handle ?? null,
1685
1811
  }
1686
1812
  }
1687
1813
  return {
1688
1814
  ci_watch_armed: false,
1689
1815
  ci_watch_skip_reason: envelope.reason || 'start-failed',
1690
1816
  ci_watch: envelope,
1817
+ scheduler_handle: envelope.scheduler_handle ?? null,
1691
1818
  }
1692
1819
  }
1693
1820
 
@@ -166,7 +166,12 @@ export function isAnnotationLine(line, sourceWindow) {
166
166
  */
167
167
  export function parseFindingsMarkdown(markdown) {
168
168
  const findings = []
169
- const headerRx = /^## Finding:\s*([A-Z]+-\d+)\s*--\s*(.+)$/gm
169
+ // BUG-032: widen to accept H2|H3, optional `Finding:` prefix, and any of
170
+ // ASCII `--`, em-dash `—`, or en-dash `–` as the id/title separator. The
171
+ // validator-agent's consolidated `validated.md` shape uses H3 + em-dash
172
+ // with no `Finding:` prefix; the specialist shape uses H2 + `Finding:` +
173
+ // ASCII `--`. Both must round-trip through the same parser.
174
+ const headerRx = /^#{2,3}\s+(?:Finding:\s*)?([A-Z]+-\d+)\s*(?:--|—|–)\s*(.+)$/gm
170
175
  const matches = [...markdown.matchAll(headerRx)]
171
176
  for (let i = 0; i < matches.length; i++) {
172
177
  const m = matches[i]
@@ -202,23 +207,45 @@ function parseFindingBlock(body, id, title, start, end) {
202
207
  // FRAMEWORK-BUG-001 surfaced by /apt:pr-review on PR #68 in 2026-04).
203
208
  // `(?:-\d+)?` consumes the trailing range so it doesn't leak into the
204
209
  // file capture.
210
+ // BUG-032: the validator-agent's `validated.md` carries the cited path
211
+ // in `- **Original**:` instead of `- **File**:`. Map either spelling
212
+ // onto `file`/`line`; specialist shape continues to write to `file`.
213
+ const fileField = fields.file ?? fields.original
205
214
  let file = null
206
215
  let line = null
207
- if (fields.file) {
208
- const fileMatch = /^`?([^`:]+?)(?::(\d+)(?:-\d+)?)?`?\s*$/.exec(fields.file)
216
+ let lineRangeFromField = null
217
+ if (fileField) {
218
+ const fileMatch = /^`?([^`:]+?)(?::(\d+)(?:-(\d+))?)?`?\s*$/.exec(fileField)
209
219
  if (fileMatch) {
210
220
  file = fileMatch[1].trim()
211
221
  line = fileMatch[2] ? Number(fileMatch[2]) : null
222
+ if (fileMatch[2] && fileMatch[3]) {
223
+ lineRangeFromField = [Number(fileMatch[2]), Number(fileMatch[3])]
224
+ }
212
225
  }
213
226
  }
214
227
 
215
228
  // Description block: everything between `### Description` and the next `###`.
216
229
  const description = extractSection(body, 'Description') ?? ''
217
230
 
218
- // Evidence code block: first triple-backticked block after `### Evidence`.
231
+ // Evidence code block: first triple-backticked block after `### Evidence`
232
+ // (specialist shape). BUG-032: validator-consolidated `validated.md`
233
+ // shape carries evidence as a `- **Evidence**:` field whose value is the
234
+ // fenced block immediately following the field line. Fall back to the
235
+ // first fenced block after that field marker when the section heading
236
+ // form yields nothing.
219
237
  const evidenceSection = extractSection(body, 'Evidence') ?? ''
238
+ let evidence
220
239
  const fenced = /```[^\n]*\n([\s\S]*?)```/.exec(evidenceSection)
221
- const evidence = fenced ? fenced[1] : evidenceSection
240
+ if (fenced) {
241
+ evidence = fenced[1]
242
+ } else if (evidenceSection) {
243
+ evidence = evidenceSection
244
+ } else {
245
+ const fieldEvidenceRx = /-\s*\*\*Evidence\*\*:\s*(?:\n+)```[^\n]*\n([\s\S]*?)```/i
246
+ const fieldFenced = fieldEvidenceRx.exec(body)
247
+ evidence = fieldFenced ? fieldFenced[1] : (fields.evidence ?? '')
248
+ }
222
249
 
223
250
  // Suggested Fix section (prose + optional fenced snippets). Unlike
224
251
  // Evidence we keep the whole section — snippets AND surrounding
@@ -231,9 +258,11 @@ function parseFindingBlock(body, id, title, start, end) {
231
258
  const rangeMatch = /Lines? examined[^\d]*(\d+)\s*-\s*(\d+)/i.exec(contextSection)
232
259
  const lineRange = rangeMatch
233
260
  ? [Number(rangeMatch[1]), Number(rangeMatch[2])]
234
- : line
235
- ? [line, line]
236
- : null
261
+ : lineRangeFromField
262
+ ? lineRangeFromField
263
+ : line
264
+ ? [line, line]
265
+ : null
237
266
 
238
267
  // Cross-file evidence (optional): `### Cross-File Evidence` block.
239
268
  // Each file gets a `- **File**: path` + fenced snippet.
@@ -294,11 +323,64 @@ export function detectReferencedFiles(finding) {
294
323
  return [...tokens].sort()
295
324
  }
296
325
 
326
+ /**
327
+ * BUG-014 fuzzy-evidence helpers — token-set Jaccard + line-anchor count.
328
+ *
329
+ * Tokenize on word characters so whitespace, punctuation, and operator
330
+ * spacing don't perturb similarity. Same tokenizer runs on both sides so
331
+ * the ratio is symmetric.
332
+ */
333
+ export function tokenizeForJaccard(text) {
334
+ if (!text) return []
335
+ return (
336
+ String(text)
337
+ .toLowerCase()
338
+ .match(/[a-z0-9_$]+/g) ?? []
339
+ )
340
+ }
341
+
342
+ export function jaccardSimilarity(a, b) {
343
+ const setA = new Set(tokenizeForJaccard(a))
344
+ const setB = new Set(tokenizeForJaccard(b))
345
+ if (setA.size === 0 && setB.size === 0) return 1
346
+ if (setA.size === 0 || setB.size === 0) return 0
347
+ let intersection = 0
348
+ for (const t of setA) if (setB.has(t)) intersection++
349
+ const union = setA.size + setB.size - intersection
350
+ return intersection / union
351
+ }
352
+
353
+ /**
354
+ * Count evidence lines that appear as literal substrings of the cited
355
+ * file range (after `normalizeForLiteralMatch`). Used as the
356
+ * anchor-count mitigation against token-stuffing under fuzzy mode.
357
+ */
358
+ export function countExactLineAnchors(evidenceLines, target) {
359
+ const normTarget = normalizeForLiteralMatch(target)
360
+ let count = 0
361
+ for (const line of evidenceLines) {
362
+ const normLine = normalizeForLiteralMatch(line)
363
+ if (!normLine) continue
364
+ if (normTarget.includes(normLine)) count++
365
+ }
366
+ return count
367
+ }
368
+
297
369
  /**
298
370
  * Check a single finding against the literal-substring rule. Returns
299
371
  * a decision object; caller applies it to the markdown.
372
+ *
373
+ * Optional `opts.allowApprox` enables BUG-014 opt-in fuzzy fallback —
374
+ * when the literal check fails, compute Jaccard ≥ `approxFloor` on the
375
+ * cited range AND require ≥2 exact line anchors in the cited range
376
+ * (token-stuffing mitigation). On success, emit downgraded status
377
+ * `dismissed_evidence_approx_confirmed` unless `opts.llmValidatorConfirmed`
378
+ * is also set, in which case status becomes `confirmed_valid_approx`.
300
379
  */
301
- export function checkFinding(finding, worktreeRoot) {
380
+ export function checkFinding(finding, worktreeRoot, opts = {}) {
381
+ const allowApprox = Boolean(opts.allowApprox)
382
+ const approxFloor = typeof opts.approxFloor === 'number' ? opts.approxFloor : 0.85
383
+ const llmValidatorConfirmed = Boolean(opts.llmValidatorConfirmed)
302
384
  // Rule 1: evidence must be a literal substring of the cited file range.
303
385
  if (!finding.file) {
304
386
  return {
@@ -361,6 +443,10 @@ export function checkFinding(finding, worktreeRoot) {
361
443
  const normTarget = normalizeForLiteralMatch(target)
362
444
  const normWholeEvidence = normalizeForLiteralMatch(keptLines.join('\n'))
363
445
 
446
+ // Capture the first literal-mismatch reason so the fuzzy fallback can
447
+ // reuse it as the rejection note when fuzzy ALSO fails.
448
+ let literalRejectNote = null
449
+
364
450
  if (normTarget.includes(normWholeEvidence)) {
365
451
  // Fast path: the kept evidence (joined back) is still a literal
366
452
  // contiguous substring of the cited range. This is the common
@@ -370,17 +456,43 @@ export function checkFinding(finding, worktreeRoot) {
370
456
  // (e.g. `code line A\n// ... 10 more\ncode line B`). Match each
371
457
  // kept line individually against the cited range. Every kept
372
458
  // line must appear; any miss is a paraphrase / fabrication and
373
- // the whole finding is rejected.
459
+ // the whole finding is rejected — unless the caller opted into
460
+ // fuzzy fallback (BUG-014).
374
461
  for (const line of keptLines) {
375
462
  const normLine = normalizeForLiteralMatch(line)
376
463
  if (!normLine) continue
377
464
  if (!normTarget.includes(normLine)) {
378
- return {
379
- status: 'dismissed_evidence_not_literal',
380
- note: `Evidence not found as literal substring of ${finding.file} lines ${safeStart}-${safeEnd}: line "${normLine.slice(0, 120)}" missing from cited range.`,
381
- }
465
+ literalRejectNote = `Evidence not found as literal substring of ${finding.file} lines ${safeStart}-${safeEnd}: line "${normLine.slice(0, 120)}" missing from cited range.`
466
+ break
467
+ }
468
+ }
469
+ }
470
+
471
+ // BUG-014: opt-in fuzzy fallback. Default behaviour (allowApprox=false)
472
+ // preserves the anti-fabrication guarantee — literal mismatch returns
473
+ // `dismissed_evidence_not_literal` unchanged. When the caller passes
474
+ // `--allow-approx-evidence`, attempt Jaccard ≥ floor AND ≥2 exact
475
+ // line anchors in the cited range (mitigates token-stuffing). On
476
+ // success, downgrade status to `dismissed_evidence_approx_confirmed`
477
+ // unless the caller also passed `--llm-validator-confirmed`, in
478
+ // which case promote to `confirmed_valid_approx`.
479
+ if (literalRejectNote) {
480
+ if (!allowApprox) {
481
+ return { status: 'dismissed_evidence_not_literal', note: literalRejectNote }
482
+ }
483
+ const similarity = jaccardSimilarity(keptLines.join('\n'), target)
484
+ const anchors = countExactLineAnchors(keptLines, target)
485
+ if (similarity < approxFloor || anchors < 2) {
486
+ return {
487
+ status: 'dismissed_evidence_not_literal',
488
+ note: `${literalRejectNote} Fuzzy fallback also rejected (jaccard=${similarity.toFixed(3)} floor=${approxFloor}, anchors=${anchors}/2).`,
382
489
  }
383
490
  }
491
+ const approxNote = `Approximate match accepted under --allow-approx-evidence (jaccard=${similarity.toFixed(3)} ≥ ${approxFloor}, anchors=${anchors} ≥ 2).`
492
+ if (llmValidatorConfirmed) {
493
+ return { status: 'confirmed_valid_approx', note: approxNote }
494
+ }
495
+ return { status: 'dismissed_evidence_approx_confirmed', note: approxNote }
384
496
  }
385
497
 
386
498
  // Rule 2: multi-file references require Cross-File Evidence.
@@ -490,6 +602,18 @@ export function cmdValidateEvidence(findingsDir, extraArgs) {
490
602
  const worktreeRoot = resolve(worktree)
491
603
  if (!existsSync(worktreeRoot)) return err(`Worktree not found: ${worktreeRoot}`)
492
604
 
605
+ // BUG-014: opt-in fuzzy-evidence flags. Defaults preserve the
606
+ // anti-fabrication guarantee — literal substring is still the only
607
+ // matcher when no flag is set.
608
+ const allowApprox = flags.get('allow-approx-evidence') === true
609
+ const approxFloorRaw = flags.get('approx-floor')
610
+ const approxFloor = typeof approxFloorRaw === 'string' ? Number(approxFloorRaw) : 0.85
611
+ if (allowApprox && (!Number.isFinite(approxFloor) || approxFloor <= 0 || approxFloor > 1)) {
612
+ return err(`Invalid --approx-floor value: ${approxFloorRaw} (expected 0 < n ≤ 1)`)
613
+ }
614
+ const llmValidatorConfirmed = flags.get('llm-validator-confirmed') === true
615
+ const checkOpts = { allowApprox, approxFloor, llmValidatorConfirmed }
616
+
493
617
  const files = readdirSync(dir).filter((f) => f.endsWith('.md'))
494
618
  const summary = {
495
619
  status: 'ok',
@@ -506,13 +630,21 @@ export function cmdValidateEvidence(findingsDir, extraArgs) {
506
630
  const full = join(dir, f)
507
631
  const md = readFileSync(full, 'utf-8')
508
632
  const findings = parseFindingsMarkdown(md)
633
+
634
+ // BUG-032: always record the file in `processed_files`. The
635
+ // previous `continue` on empty findings caused validated.md to
636
+ // silently no-op when the header regex didn't match, leaving
637
+ // downstream gates with `processed_files: []` and no audit trail
638
+ // that the file was even read.
639
+ summary.processed_files.push({ file: f, findings_count: findings.length })
640
+
509
641
  if (findings.length === 0) continue
510
642
 
511
643
  const decisions = []
512
644
  for (const finding of findings) {
513
- const decision = checkFinding(finding, worktreeRoot)
645
+ const decision = checkFinding(finding, worktreeRoot, checkOpts)
514
646
  decisions.push({ finding, decision })
515
- if (decision.status === 'confirmed_valid') {
647
+ if (decision.status === 'confirmed_valid' || decision.status === 'confirmed_valid_approx') {
516
648
  summary.validated++
517
649
  } else {
518
650
  summary.rejected++
@@ -530,7 +662,16 @@ export function cmdValidateEvidence(findingsDir, extraArgs) {
530
662
  if (rewritten !== md) {
531
663
  writeFileSync(full, rewritten, 'utf-8')
532
664
  }
533
- summary.processed_files.push(f)
665
+ }
666
+
667
+ // BUG-032: surface parser-shape drift early. If we read N markdown
668
+ // files but extracted zero findings, warn on stderr so future schema
669
+ // changes don't silently no-op the gate.
670
+ const totalFindings = summary.processed_files.reduce((acc, e) => acc + (e.findings_count ?? 0), 0)
671
+ if (files.length > 0 && totalFindings === 0) {
672
+ process.stderr.write(
673
+ `WARNING: validate-evidence read ${files.length} markdown file(s) but extracted 0 findings — parser shape mismatch?\n`,
674
+ )
534
675
  }
535
676
 
536
677
  return ok(summary)