baldart 4.16.2 → 4.17.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.
@@ -1,12 +1,12 @@
1
1
  export const meta = {
2
2
  name: 'new2',
3
3
  description:
4
- "EXPERIMENTAL workflow host for /new (A/B testing). Runs an ENTIRE backlog-card batch autonomously in the background runtime — pre-flight, per-card implement+review pipeline (sequential or team-mode parallel coders), cross-batch final review, and auto-merge — so subagent output never enters the main orchestrator context. Zero AskUserQuestion: each /new gate is a deterministic policy; blocking gates and scope-expanding findings are routed to the new2-resolve self-healing workflow, whose terminal is an auto-materialised follow-up card (nothing silently dropped). Agents Read /new's reference modules for review semantics (args.refModulesBase) so this script encodes only orchestration shape + gate policy. Claude-only.",
4
+ "EXPERIMENTAL workflow host for /new (A/B testing). Runs an ENTIRE backlog-card batch autonomously in the background runtime — pre-flight, a dependency-gated (DAG) per-card implement+review pipeline with specialized agents, cross-batch final review, and an integrity-gated auto-merge — so subagent output never enters the main orchestrator context. Zero AskUserQuestion: each /new gate is a deterministic policy; blocking gates and scope-expanding findings are routed to the new2-resolve self-healing workflow. Resilient by design: transient API errors are retried, a sustained outage degrades cleanly (durable resume), follow-ups for residuals are reconciled by the skill (offline-safe), and the merge is blocked unless the batch is verifiably complete (no false-DONE, no unreviewed code). Agents Read /new's reference modules for semantics (args.refModulesBase) so this script encodes only orchestration shape + gate policy. Claude-only.",
5
5
  phases: [
6
- { title: 'Pre-flight', detail: 'deterministic workspace hygiene + worktree + cross-card grounding (Phase 0)' },
7
- { title: 'Implement', detail: 'per-card implement+review pipeline; team-mode runs coders in parallel per wave' },
6
+ { title: 'Pre-flight', detail: 'deterministic workspace hygiene + worktree + dep-graph + cross-card grounding (Phase 0)' },
7
+ { title: 'Implement', detail: 'dependency-gated per-card implement+review pipeline with owner_agent + specialized review fan-out' },
8
8
  { title: 'Final', detail: 'cross-batch final review (delegates to new-final-review)' },
9
- { title: 'Merge', detail: 'auto-merge to trunk via git.merge_strategy + cleanup' },
9
+ { title: 'Merge', detail: 'integrity-gated auto-merge to trunk via git.merge_strategy + cleanup' },
10
10
  { title: 'Production', detail: 'post-merge production-readiness checklist (Phase 7, non-blocking)' },
11
11
  ],
12
12
  }
@@ -21,12 +21,17 @@ export const meta = {
21
21
  // refModulesBase string dir holding /new reference modules (semantic SSOT)
22
22
  // config object parsed baldart.config.yml (paths.*/stack.*/features.*/git.*)
23
23
  // flags { stats, effort, full }
24
+ // ts string ISO timestamp injected by the skill (Date.now() is unavailable here)
24
25
  //
25
26
  // return (consumed by the skill at Step 5):
26
- // { report, perCardResults, gateLedger, residualFollowups, telemetry }
27
+ // { report, perCardResults, gateLedger, residualFollowups, telemetry, degraded, degradationReasons, residuals }
28
+ // `residuals` (materialized:false) is the OFFLINE-SAFE ledger of record — the skill writes
29
+ // any missing follow-up YAML and, if `degraded`, resumes via Workflow({scriptPath,resumeFromRunId}).
27
30
  // ───────────────────────────────────────────────────────────────────────────
28
31
 
29
- const a = args || {}
32
+ // F-001/F-004 tolerate args delivered as a JSON string (parse-or-default).
33
+ let a = args || {}
34
+ if (typeof a === 'string') { try { a = JSON.parse(a) } catch (_) { a = {} } }
30
35
  const cfg = a.config || {}
31
36
  const cardIds = Array.isArray(a.cardIds) ? a.cardIds : []
32
37
  const cardPaths = Array.isArray(a.cardPaths) ? a.cardPaths : []
@@ -35,29 +40,71 @@ const TRUNK = a.trunk || 'main'
35
40
  const METRICS = a.metricsDir || 'docs/metrics'
36
41
  const REF = (a.refModulesBase || '.claude/skills/new/references').replace(/\/$/, '')
37
42
  const FLAGS = a.flags || {}
43
+ const TS = a.ts || ''
38
44
  const features = cfg.features || {}
39
45
  const paths = cfg.paths || {}
40
46
  const gitCfg = cfg.git || {}
41
47
  const highRisk = paths.high_risk_modules || []
42
48
  const mergeStrategy = gitCfg.merge_strategy || 'pr'
43
49
 
44
- // Mutable batch state — the workflow's variables ARE the tracker (kept out of the
45
- // main loop). Agents also write the on-disk /tmp/batch-tracker-<id>.md for recovery.
50
+ // Mutable batch state — the workflow's variables ARE the tracker (kept out of the main loop).
46
51
  const firstCard = cardIds[0] || 'BATCH'
47
- const gateLedger = [] // { card, gate, decision, detail }
48
- const residualFollowups = []// { card, kind, followupCard, reason }
49
- const perCardResults = [] // structured per-card outcome
50
- let batchFatal = false // set when a resolve() returns 'fatal' (unrecoverable)
51
- let prodReadiness = null // Phase 7 result (post-merge, non-blocking)
52
+ const gateLedger = [] // { card, gate, decision, detail }
53
+ const residualFollowups = [] // { card, kind, followupCard, reason }
54
+ const residuals = [] // F-020 OFFLINE-SAFE ledger: { card, kind, evidence, materialized }
55
+ const perCardResults = []
56
+ let batchFatal = false
57
+ let prodReadiness = null
58
+ let degraded = false
59
+ const degradationReasons = []
60
+
61
+ // F-019/F-032 — agent spawn accounting + cost capture (budget.spent() is the only
62
+ // usage introspection available to the script; Date.now() is not).
63
+ let agentCount = 0
64
+ const tokensAtStart = (typeof budget !== 'undefined' && budget && typeof budget.spent === 'function') ? budget.spent() : null
65
+
66
+ // F-009/F-028 — run-level dedup + accepted-deferral ledger (deterministic keys; no Date.now/Math.random).
67
+ const resolvedSignatures = new Set()
68
+ const acceptedDeferrals = new Set()
69
+ function sig(card, gate, evidence) {
70
+ const e = String(evidence || '').toLowerCase().replace(/\s+/g, ' ').replace(/[0-9a-f]{7,40}/g, '#').trim().slice(0, 160)
71
+ return `${card}::${String(gate || '').toLowerCase()}::${e}`
72
+ }
52
73
 
53
74
  function ledger(card, gate, decision, detail) {
54
75
  gateLedger.push({ card, gate, decision, detail: detail || '' })
55
76
  log(`gate[${card}] ${gate} → ${decision}${detail ? ' — ' + detail : ''}`)
56
77
  }
78
+ function noteDegraded(reason) { degraded = true; if (!degradationReasons.includes(reason)) degradationReasons.push(reason) }
79
+
80
+ // F-019 — transient-error classifier + bounded immediate retry. NOTE: there is no
81
+ // reliable sleep/jitter in the workflow runtime, so this is NOT timed backoff — it
82
+ // absorbs only brief blips. Sustained outages are handled by the OUTAGE state +
83
+ // durable resume (F-012/F-020), not by retrying forever.
84
+ const TRANSIENT = /overload|529|429|rate.?limit|5\d\d|econn|etimedout|timeout|temporar|unavailable/i
85
+ function isTransient(e) { return TRANSIENT.test(String((e && e.message) || e || '')) }
86
+ async function agentSafe(prompt, opts, maxAttempts) {
87
+ const cap = maxAttempts || 3
88
+ let lastErr = null
89
+ for (let i = 0; i < cap; i++) {
90
+ agentCount++
91
+ try {
92
+ // vary the label per attempt so retried spawns are individually visible
93
+ const o = i === 0 ? opts : Object.assign({}, opts, { label: `${(opts && opts.label) || 'agent'}~retry${i}` })
94
+ return await agent(prompt, o)
95
+ } catch (e) {
96
+ lastErr = e
97
+ if (!isTransient(e)) throw e // permanent error → surface immediately
98
+ }
99
+ }
100
+ const err = new Error('transient-exhausted: ' + String((lastErr && lastErr.message) || lastErr))
101
+ err.transientExhausted = true
102
+ throw err
103
+ }
57
104
 
58
105
  if (!cardIds.length) {
59
106
  log('new2: no card IDs supplied — nothing to do.')
60
- return emptyReturn('no cards supplied')
107
+ return emptyReturn('no-cards-supplied')
61
108
  }
62
109
 
63
110
  // ───────────────────────────────────────────────────────────────────────────
@@ -65,23 +112,40 @@ if (!cardIds.length) {
65
112
  // ───────────────────────────────────────────────────────────────────────────
66
113
  const PREFLIGHT_SCHEMA = {
67
114
  type: 'object',
68
- required: ['ok', 'worktreePath', 'branch', 'baseline', 'executionMode', 'cards'],
115
+ required: ['ok', 'worktreePath', 'branch', 'baseline', 'executionMode', 'cards', 'cardGraph'],
69
116
  additionalProperties: false,
70
117
  properties: {
71
- ok: { type: 'boolean', description: 'false only when the workspace cannot be made workable (e.g. baseline irrecoverable)' },
118
+ ok: { type: 'boolean' },
72
119
  worktreePath: { type: 'string' },
73
120
  branch: { type: 'string' },
74
- port: { type: ['number', 'string'], description: 'dev port or "-"' },
121
+ port: { type: ['number', 'string'] },
75
122
  baseline: { enum: ['pass', 'fail'] },
76
123
  baselineLog: { type: 'string' },
77
124
  executionMode: { enum: ['sequential', 'team'] },
78
- groups: { type: 'array', items: { type: 'object', additionalProperties: true }, description: 'team-mode waves: [{ level, cards:[ids] }]' },
79
- cards: { type: 'array', items: { type: 'string' }, description: 'card ids cleared to run (invalid/blocked-dep excluded)' },
80
- excluded: { type: 'array', items: { type: 'object', additionalProperties: true }, description: '[{ card, reason }] for G4/G5 exclusions' },
81
- ownershipMapPath: { type: 'string', description: '/tmp path to the file-ownership map' },
82
- crossCard: { type: 'string', description: 'distilled cross-card findings or PASS/SKIPPED/UNAVAILABLE' },
83
- workspaceNote: { type: 'string', description: 'G1/G2 distilled (stash label, ff-pull, divergence left + reported)' },
84
- archBaselinePaths: { type: 'array', items: { type: 'string' }, description: 'per-card /tmp/arch-baseline-<id>.md (for final-review dedup)' },
125
+ groups: { type: 'array', items: { type: 'object', additionalProperties: true } },
126
+ cards: { type: 'array', items: { type: 'string' }, description: 'card ids cleared to run' },
127
+ // F-021/F-024/F-025/F-016 the dependency graph + per-card routing facts (the
128
+ // script cannot read YAML; the pre-flight agent supplies these).
129
+ cardGraph: {
130
+ type: 'array',
131
+ items: {
132
+ type: 'object', additionalProperties: true,
133
+ properties: {
134
+ id: { type: 'string' },
135
+ dependsOn: { type: 'array', items: { type: 'string' }, description: 'IN-BATCH deps only' },
136
+ ownerAgent: { type: 'string', description: 'coder|ui-expert|visual-designer|motion-expert (G25: unknown→coder)' },
137
+ reviewProfile: { enum: ['skip', 'light', 'balanced', 'deep'] },
138
+ // F-016 — ACs whose only implementation file is outside the card MAY-EDIT,
139
+ // pre-classified deferred-by-policy (never routed to resolve).
140
+ policyDeferredACs: { type: 'array', items: { type: 'object', additionalProperties: true } },
141
+ },
142
+ },
143
+ },
144
+ excluded: { type: 'array', items: { type: 'object', additionalProperties: true } },
145
+ ownershipMapPath: { type: 'string' },
146
+ crossCard: { type: 'string' },
147
+ workspaceNote: { type: 'string' },
148
+ archBaselinePaths: { type: 'array', items: { type: 'string' } },
85
149
  },
86
150
  }
87
151
 
@@ -91,12 +155,13 @@ const MERGE_SCHEMA = {
91
155
  merged: { type: 'boolean' },
92
156
  mergeCommit: { type: 'string' },
93
157
  mergeTs: { type: 'string' },
94
- reconciliation: { type: 'string', description: 'Phase 6b/6c distilled (divergence left+reported, stash restore, sync markers)' },
158
+ reconciliation: { type: 'string' },
159
+ forcedDone: { type: 'array', items: { type: 'string' }, description: 'MUST be empty — false-DONE is forbidden (F-029)' },
160
+ uncommittedLeft: { type: 'boolean', description: 'true if dirty code was left (NOT committed) + reported (F-030)' },
95
161
  note: { type: 'string' },
96
162
  },
97
163
  }
98
164
 
99
- // Shared context fragment every per-card agent gets.
100
165
  const projectBrief = [
101
166
  `Main repo: ${MAIN}`,
102
167
  `Trunk: ${TRUNK}`,
@@ -106,35 +171,34 @@ const projectBrief = [
106
171
  ].filter(Boolean).join('\n')
107
172
 
108
173
  // ───────────────────────────────────────────────────────────────────────────
109
- // Phase Pre-flight (Phase 0 + Pre-flight) — ONE ops agent runs all the git-heavy,
110
- // deterministic workspace work (the script itself has no shell). Gate policies:
111
- // G1 dirty-tree → auto-stash (restore at merge); restore-conflict → leave+report
112
- // G2 divergence → behind: ff-pull; ahead/diverged: proceed on isolated worktree + report
113
- // G3 cross-card cap unavailable → proceed + log CAPABILITY_UNAVAILABLE
114
- // G4 card-field invalid → exclude that card + continue
115
- // G5 depends-on (dep outside batch, not DONE) → exclude card + its dependents
116
- // E2 baseline build fail → retry once inside the agent; surface baseline:fail
174
+ // Phase Pre-flight — ONE ops agent runs all git-heavy work + returns the dep graph.
117
175
  // ───────────────────────────────────────────────────────────────────────────
118
176
  phase('Pre-flight')
119
- const preflight = await agent(
120
- `You are the deterministic PRE-FLIGHT for an autonomous /new batch (variant new2). Follow ${REF}/setup.md (Phase 0 + Pre-flight) and ${REF}/implement.md (Phase 1 depends-on gate) for the SEMANTICS, but replace EVERY AskUserQuestion with the deterministic policy below. You run all git/bash yourself (the workflow cannot).\n\n` +
121
- `${projectBrief}\n\nCards in batch (Read each YAML):\n${cardPaths.join('\n')}\nCard IDs: ${cardIds.join(' ')}\n\n` +
122
- `Create/maintain the recovery tracker at /tmp/batch-tracker-${firstCard}.md (per setup.md § Context Tracking).\n\n` +
123
- `DETERMINISTIC GATE POLICIES (NO user prompts):\n` +
124
- `• G1 dirty-tree (main repo ${MAIN}): partition framework-managed noise exactly as setup.md step 3 ($METRICS=${METRICS}, .baldart/generated|state.json|skill-conflicts.json — NOT overlays/). If genuine user work remains → auto-stash 'baldart-new2-${firstCard}-<ts>' (main checkout, safe) and record the MESSAGE label for restore at merge. Never commit/abort/prompt.\n` +
125
- `• G2 divergence vs origin/${TRUNK}: behind-only AND HEAD==${TRUNK} → ff-pull; ahead-genuine or diverged → DO NOT push/rebase/reset; proceed (the worktree is isolated) and put a one-line note in workspaceNote. Framework-management commits are auto-ignored (setup.md step 4 reclassification).\n` +
126
- `• G3 cross-card grounding (setup.md 3d): run the Codex cross-card check with the provenance skip; on CODEX_NOT_FOUND fall back to code-reviewer; if BOTH unavailable → proceed and set crossCard='CAPABILITY_UNAVAILABLE'. Read the audit file ONLY via the [codex]-stripping filter; return DISTILLED findings. CRITICAL if conflicts are FOUND you MUST APPLY them, not just report: FILE_CONFLICT / ORDER_RISK force the conflicting cards SEQUENTIAL (encode this in executionMode/groups AND the ownership map); STATE_MUTATION fold a warning into the affected cards via the ownership map. The returned executionMode/groups MUST already reflect these constraints.\n` +
127
- `• G4 card-field validation (setup.md 1b/1c): a card missing requirements/acceptance_criteria/files_likely_touched EXCLUDE it (add to excluded[] with reason); continue with valid cards. Never HALT the batch for one bad card.\n` +
128
- `• G5 depends-on (implement.md Phase 1 step 2): if a card's depends_on names a card that is NOT DONE and is NOT in this batch exclude that card AND, TRANSITIVELY, every in-batch card that depends on it (excluded[], reason 'depends_on <dep> not DONE'). GUARANTEE: the returned cards[] must contain NO card that transitively depends on an excluded card. If the dep IS in this batch, keep it and order it first.\n` +
129
- `• Worktree (setup.md step 4): create ONE code worktree off ${TRUNK} (slug from git_strategy.branch or the parent/lead card title), install deps, assign a port, run the baseline (tsc+lint+build). Use the git-authoritative idempotency pre-check (step 4a2). E2: if baseline FAILS, fix-attempt once (the trunk itself may not build); if still failing return baseline:'fail' with baselineLog (the caller treats it as batch-fatal).\n` +
130
- `• Complexity (setup.md 3c): decide executionMode sequential|team and, for team, the groups [{level,cards}]. Build the file-ownership map; write it to /tmp and return ownershipMapPath.\n` +
131
- `• Persist per-card architecture baselines you gather to /tmp/arch-baseline-<CARD>.md and return archBaselinePaths (final review reuses them).\n\n` +
132
- `Return the structured PREFLIGHT object. ok:false ONLY if the workspace is unworkable (baseline irrecoverable / worktree uncreatable).`,
133
- { label: 'preflight', phase: 'Pre-flight', agentType: 'general-purpose', schema: PREFLIGHT_SCHEMA }
134
- )
177
+ let preflight = null
178
+ try {
179
+ preflight = await agentSafe(
180
+ `You are the deterministic PRE-FLIGHT for an autonomous /new batch (variant new2). Follow ${REF}/setup.md (Phase 0 + Pre-flight) and ${REF}/implement.md (Phase 1 depends-on gate) for the SEMANTICS, but replace EVERY AskUserQuestion with the deterministic policy below. You run all git/bash yourself (the workflow cannot).\n\n` +
181
+ `${projectBrief}\n\nCards in batch (Read each YAML):\n${cardPaths.join('\n')}\nCard IDs: ${cardIds.join(' ')}\n\n` +
182
+ `Create/maintain the recovery tracker at /tmp/batch-tracker-${firstCard}.md (per setup.md § Context Tracking).\n\n` +
183
+ `DETERMINISTIC GATE POLICIES (NO user prompts):\n` +
184
+ `• G1 dirty-tree (main repo ${MAIN}): partition framework-managed noise exactly as setup.md step 3 ($METRICS=${METRICS}, .baldart/generated|state.json|skill-conflicts.jsonNOT overlays/). Genuine user workauto-stash 'baldart-new2-${firstCard}' (main checkout) and record the label. Never commit/abort/prompt.\n` +
185
+ `• Worktree (setup.md step 4): create ONE code worktree off ${TRUNK}; install deps; assign a port; run the baseline (tsc+lint+build). Copy ONLY the artifacts needed (env/.env.local/.env.example/supabase/.temp) do NOT bulk-copy untracked files from the main repo (avoids stray backlog cards in the worktree). Use the git-authoritative idempotency pre-check. E2: baseline FAILS fix once; still failing → baseline:'fail' + baselineLog (batch-fatal).\n` +
186
+ `• G3 cross-card grounding (setup.md 3d): run the Codex cross-card check (provenance skip; CODEX_NOT_FOUND code-reviewer; both unavailablecrossCard='CAPABILITY_UNAVAILABLE'). Read the audit ONLY via the [codex]-stripping filter; return DISTILLED findings. If conflicts FOUND you MUST APPLY them: FILE_CONFLICT/ORDER_RISK force the cards SEQUENTIAL and encode it in dependsOn (the conflicting later card dependsOn the earlier).\n` +
187
+ `• G4 card-field validation (setup.md 1b/1c): card missing requirements/acceptance_criteria/files_likely_touched EXCLUDE (excluded[] + reason). Never HALT for one bad card.\n` +
188
+ `• G5 depends-on: a card whose depends_on names a non-DONE card NOT in this batch EXCLUDE it AND every in-batch card that transitively depends on it.\n` +
189
+ `• cardGraph (REQUIRED, F-021): for every runnable card return { id, dependsOn:[IN-BATCH deps only], ownerAgent (the card's owner_agent; G25 unknown→'coder'), reviewProfile (the card's review_profile; default 'balanced'), policyDeferredACs }.\n` +
190
+ `• F-016 AC↔ownership consistency: for each acceptance_criterion, derive the file(s) it requires editing. If those files are NOT a subset of the card's MAY-EDIT/files_likely_touched → add the AC to policyDeferredACs:[{n,text,owningCard|owningFile,reason}] (it will become ONE follow-up, never a resolve). Do the same for any AC whose remedy is an owner-gated infra action (remote db push / deploy / secret / DNS).\n` +
191
+ `• Complexity (setup.md 3c): decide executionMode sequential|team (+ groups for team). Build the file-ownership map /tmp; return ownershipMapPath.\n` +
192
+ `• Persist per-card architecture baselines to /tmp/arch-baseline-<CARD>.md; return archBaselinePaths.\n\n` +
193
+ `Return the structured PREFLIGHT object. ok:false ONLY if the workspace is unworkable.`,
194
+ { label: 'preflight', phase: 'Pre-flight', agentType: 'general-purpose', schema: PREFLIGHT_SCHEMA }
195
+ )
196
+ } catch (e) {
197
+ if (e && e.transientExhausted) { noteDegraded('outage'); ledger(firstCard, 'preflight', 'OUTAGE', 'transient API failure during pre-flight') }
198
+ return finalReturn({ fatal: true, reason: 'pre-flight failed: ' + String(e && e.message) })
199
+ }
135
200
 
136
201
  if (!preflight || preflight.ok === false || preflight.baseline === 'fail') {
137
- // E2 terminal — the only batch-fatal case: no workable tree.
138
202
  ledger(firstCard, 'E2-baseline', 'BATCH-FATAL', (preflight && preflight.baselineLog) || 'workspace unworkable')
139
203
  return finalReturn({ fatal: true, reason: 'baseline build irrecoverable — see baselineLog' })
140
204
  }
@@ -144,6 +208,9 @@ if (preflight.workspaceNote) ledger(firstCard, 'G1/G2-workspace', 'AUTO', prefli
144
208
  if (preflight.crossCard) ledger(firstCard, 'G3-cross-card', 'INFO', preflight.crossCard)
145
209
 
146
210
  const runnableCards = preflight.cards || []
211
+ const cardGraph = preflight.cardGraph || []
212
+ const graphById = {}
213
+ for (const n of cardGraph) graphById[n.id] = n
147
214
  const sharedCtx = {
148
215
  worktreePath: preflight.worktreePath,
149
216
  branch: preflight.branch,
@@ -151,262 +218,378 @@ const sharedCtx = {
151
218
  archBaselinePaths: preflight.archBaselinePaths || [],
152
219
  }
153
220
 
221
+ // F-016/F-010 — materialise ONE follow-up per policy-deferred AC up front; never resolve.
222
+ for (const n of cardGraph) {
223
+ for (const ac of (n.policyDeferredACs || [])) {
224
+ residuals.push({ card: n.id, kind: 'policy-deferred-ac', evidence: `AC-${ac.n}: ${ac.text} (${ac.reason || 'out-of-ownership / owner-gated'})`, materialized: false })
225
+ acceptedDeferrals.add(sig(n.id, 'ac-unmet', `AC-${ac.n}: ${ac.text}`))
226
+ ledger(n.id, 'F016-policy-defer', 'DEFERRED-BY-POLICY', `AC-${ac.n} → follow-up (owner: ${ac.owningCard || ac.owningFile || '?'})`)
227
+ }
228
+ }
229
+
230
+ const pathById = {}
231
+ cardIds.forEach((id, i) => { pathById[id] = cardPaths[i] || `${paths.backlog_dir || ''}/${id}.yml` })
232
+
154
233
  // ───────────────────────────────────────────────────────────────────────────
155
- // new2-resolve bridge — every blocking gate and every scope-expanding finding
156
- // goes here (Asse 1/2/3). Returns 'resolved' | 'followup' | 'fatal'.
234
+ // new2-resolve bridge — dedup (F-009) + accepted-deferral skip (F-028) + group batching (F-007).
235
+ // `findings` is a list; a single residual is a list of 1 (retro-compat). Returns 'resolved'|'followup'|'fatal'.
157
236
  // ───────────────────────────────────────────────────────────────────────────
158
237
  async function resolve(kind, card, evidence, extra) {
159
- const res = await workflow('new2-resolve', {
160
- kind,
161
- cardId: card,
162
- evidence,
163
- worktreePath: sharedCtx.worktreePath,
164
- mayEditPaths: (extra && extra.mayEditPaths) || [],
165
- scopeFiles: (extra && extra.scopeFiles) || [],
166
- domain: (extra && extra.domain) || 'code',
167
- refModulesBase: REF,
168
- config: cfg,
169
- }).catch((e) => ({ status: 'followup', reason: 'resolve workflow error: ' + (e && e.message) }))
238
+ const s = sig(card, kind, evidence)
239
+ if (resolvedSignatures.has(s) || acceptedDeferrals.has(s)) {
240
+ ledger(card, 'resolve:' + kind, 'DEDUP-SKIP', 'already resolved/deferred this run')
241
+ return 'resolved'
242
+ }
243
+ resolvedSignatures.add(s)
244
+ let res
245
+ try {
246
+ res = await workflow('new2-resolve', {
247
+ kind, cardId: card, evidence,
248
+ findings: (extra && extra.findings) || [{ kind, evidence, domain: (extra && extra.domain) || 'code' }],
249
+ worktreePath: sharedCtx.worktreePath,
250
+ mayEditPaths: (extra && extra.mayEditPaths) || [],
251
+ scopeFiles: (extra && extra.scopeFiles) || [],
252
+ domain: (extra && extra.domain) || 'code',
253
+ refModulesBase: REF, config: cfg, ts: TS,
254
+ })
255
+ } catch (e) {
256
+ if (e && (e.transientExhausted || isTransient(e))) noteDegraded('outage')
257
+ res = { status: 'followup', reason: 'resolve workflow error: ' + String(e && e.message) }
258
+ }
170
259
  const status = (res && res.status) || 'followup'
171
260
  if (status === 'fatal') { batchFatal = true; ledger(card, 'resolve:' + kind, 'FATAL', (res && res.reason) || ''); return status }
172
- if (status === 'followup' && res && res.followupCard) {
173
- residualFollowups.push({ card, kind, followupCard: res.followupCard, reason: res.reason || '' })
261
+ if (status === 'followup') {
262
+ acceptedDeferrals.add(s) // F-028 a deferred residual must not be re-routed by a later gate.
263
+ const fc = (res && res.followupCard) || null
264
+ residualFollowups.push({ card, kind, followupCard: fc || '(pending)', reason: (res && res.reason) || '' })
265
+ residuals.push({ card, kind, evidence, materialized: !!fc })
266
+ }
267
+ // F-022 — route out-of-scope findings the resolve surfaced.
268
+ for (const osf of (res && res.outOfScopeFindings) || []) {
269
+ residuals.push({ card, kind: 'out-of-scope', evidence: `${osf.file || ''}:${osf.line || ''} ${osf.evidence || ''}`, materialized: false })
174
270
  }
175
271
  ledger(card, 'resolve:' + kind, status, (res && (res.followupCard || res.reason)) || '')
176
272
  return status
177
273
  }
178
274
 
179
- // A crashed runCard (E1) routes through new2-resolve(agent-crash) for a specialist
180
- // retry, then ALWAYS lands as a tracked followup — never a silent drop.
181
- async function crashFollowup(id, e) {
182
- const msg = String(e && e.message)
183
- const s = await resolve('agent-crash', id, 'runCard crashed: ' + msg, { mayEditPaths: [], scopeFiles: [] })
184
- if (s !== 'followup') residualFollowups.push({ card: id, kind: 'agent-crash', followupCard: '(specialist retry attempted — manual check)', reason: msg })
185
- return { card: id, status: 'followup', gates: [{ gate: 'runCard', decision: 'ERROR', detail: msg }], commit: '-', telemetry: {} }
186
- }
187
-
188
275
  // ───────────────────────────────────────────────────────────────────────────
189
- // Per-card pipeline sequences /new Phases 1→4 via agents that Read the modules.
190
- // Each phase agent returns a structured verdict; this function applies the
191
- // deterministic gate policy and routes blocks to new2-resolve.
276
+ // Per-card pipeline. Returns a status the scheduler keys on:
277
+ // 'committed' | 'followup' | 'epic-skipped' | 'pending'(transient, re-queue)
278
+ // On any non-committed exit the worktree is restored clean (F-018 saga compensation).
192
279
  // ───────────────────────────────────────────────────────────────────────────
280
+ async function rollbackCard(cardId, mayEdit) {
281
+ // F-018 — restore the card's files to HEAD so a failed card never poisons the next.
282
+ // Safe at file granularity because the DAG guarantees all deps are already committed
283
+ // (HEAD contains their work); this removes only THIS card's uncommitted changes.
284
+ try {
285
+ await agentSafe(
286
+ `In the worktree ${sharedCtx.worktreePath}, restore the working tree to a CLEAN state for a FAILED card: \`git restore --source=HEAD --worktree --staged -- ${(mayEdit || []).map((p) => `'${p}'`).join(' ') || '.'}\` then \`git clean -fd\` ONLY within the card MAY-EDIT paths. Do NOT touch other cards' committed work. Confirm \`git status --porcelain\` is empty for those paths.`,
287
+ { label: `rollback:${cardId}`, phase: 'Implement', agentType: 'general-purpose', model: 'haiku',
288
+ schema: { type: 'object', required: ['clean'], additionalProperties: true, properties: { clean: { type: 'boolean' }, note: { type: 'string' } } } }
289
+ )
290
+ } catch (_) { /* best-effort; OUTAGE path already flagged by caller */ }
291
+ }
292
+
193
293
  async function runCard(cardId, cardPath, lessons) {
194
294
  const gates = []
195
295
  const tele = {}
296
+ const node = graphById[cardId] || {}
297
+ const ownerAgent = node.ownerAgent || 'coder'
298
+ const reviewProfile = node.reviewProfile || 'balanced'
196
299
  function g(name, decision, detail) { gates.push({ gate: name, decision, detail: detail || '' }); ledger(cardId, name, decision, detail) }
197
300
 
198
- const cardBrief = `${projectBrief}\n\nCard: ${cardId}\nCard YAML: ${cardPath}\nWorktree: ${sharedCtx.worktreePath} (cd into it)\nFile-ownership map: ${sharedCtx.ownershipMapPath}\nBatch lessons so far: ${lessons.length ? lessons.join(' | ') : '(none)'}\nArch baseline (write to /tmp/arch-baseline-${cardId}.md): reuse if present.`
199
-
200
- // --- Phase 1+2: claim, ground, implement (coder), completeness; returns the
201
- // completion verdict + the AC ledger + the file diff. Self-heals build gates
202
- // (G26) up to the module's retry cap before reporting unresolved. ---
203
- const impl = await agent(
204
- `Implement card ${cardId} per ${REF}/implement.md (Phase 1 claim+architect+plan-auditor, Phase 2 coder dispatch via owner_agent — G25: unknown owner_agent → default to coder, never HALT) and ${REF}/completeness.md (Phase 2.5 completeness + 2.5b AC-closure ledger). Run all gates/bash yourself.\n\n${cardBrief}\n\n` +
205
- `POLICIES: G25 unknown owner_agent → coder + log. G26 Phase-2 lint/tsc/test/build failing after the module's retry cap (or stuck-loop) → return buildBlocked:true with the failing gate. Do NOT prompt. Build the AC Closure Ledger (one row per acceptance_criteria: implemented | unmet) — DO NOT silently defer; just report unmet rows. Persist the architecture baseline to /tmp/arch-baseline-${cardId}.md and the diff to /tmp/diff-${cardId}.txt.\n\n` +
206
- `Return: { epic:boolean, buildBlocked:boolean, blockedGate:string, unmetACs:[{n,text}], scopeFiles:[...], mayEditPaths:[...], fileDiffViolation:boolean (already auto-reverted per 11b), note }`,
207
- { label: `implement:${cardId}`, phase: 'Implement', agentType: 'general-purpose',
208
- schema: { type: 'object', required: ['epic', 'buildBlocked', 'unmetACs', 'scopeFiles'], additionalProperties: true,
209
- properties: { epic: { type: 'boolean' }, buildBlocked: { type: 'boolean' }, blockedGate: { type: 'string' }, unmetACs: { type: 'array', items: { type: 'object', additionalProperties: true } }, scopeFiles: { type: 'array', items: { type: 'string' } }, mayEditPaths: { type: 'array', items: { type: 'string' } }, fileDiffViolation: { type: 'boolean' }, note: { type: 'string' } } } }
210
- )
301
+ // F-026 skip-completed: only if committed AND gates green for that sha AND no open follow-up.
302
+ // Keyed on the receipt, NOT the (unreliable) DONE flag.
303
+ try {
304
+ const probe = await agentSafe(
305
+ `Idempotency probe for card ${cardId} in worktree ${sharedCtx.worktreePath}. Return done:true ONLY if ALL hold: (a) a commit referencing ${cardId} exists in ${TRUNK}..HEAD; (b) the card's validation_commands re-run GREEN right now (tsc/lint/card greps); (c) NO open follow-up card for ${cardId} exists in ${paths.backlog_dir || 'backlog'}. Otherwise done:false. Do not edit anything.`,
306
+ { label: `probe:${cardId}`, phase: 'Implement', agentType: 'general-purpose', model: 'haiku',
307
+ schema: { type: 'object', required: ['done'], additionalProperties: true, properties: { done: { type: 'boolean' }, commit: { type: 'string' }, note: { type: 'string' } } } }
308
+ )
309
+ if (probe && probe.done) {
310
+ g('skip-completed', 'CACHED', probe.commit || 'already committed + green')
311
+ return { card: cardId, status: 'committed', commit: probe.commit || '-', filesChanged: [], scopeFiles: [], archBaselinePath: `/tmp/arch-baseline-${cardId}.md`, gates, telemetry: tele }
312
+ }
313
+ } catch (e) { if (e && e.transientExhausted) { noteDegraded('outage'); return { card: cardId, status: 'pending', gates, telemetry: tele } } }
314
+
315
+ const cardBrief = `${projectBrief}\n\nCard: ${cardId}\nCard YAML: ${cardPath}\nOwner agent: ${ownerAgent} · Review profile: ${reviewProfile}\nWorktree: ${sharedCtx.worktreePath} (cd into it)\nFile-ownership map: ${sharedCtx.ownershipMapPath}\nBatch lessons so far: ${lessons.length ? lessons.join(' | ') : '(none)'}\nArch baseline (write to /tmp/arch-baseline-${cardId}.md): reuse if present.\nNOTE: ACs already pre-classified as policy-deferred MUST NOT be implemented or routed — they are tracked as follow-ups.`
316
+
317
+ // --- Phase 1+2: dispatch the card's OWNER_AGENT (F-024), not general-purpose. ---
318
+ let impl
319
+ try {
320
+ impl = await agentSafe(
321
+ `Implement card ${cardId} per ${REF}/implement.md (Phase 1 claim+architect+plan-auditor, Phase 2 you ARE the owner_agent '${ownerAgent}') and ${REF}/completeness.md (Phase 2.5 + 2.5b AC-closure ledger). Run all gates/bash yourself.\n\n${cardBrief}\n\n` +
322
+ `POLICIES: G26 Phase-2 lint/tsc/test/build failing after the module's retry cap → buildBlocked:true + blockedGate. Build the AC Closure Ledger (one row per AC: implemented|unmet|policy-deferred). DO NOT silently defer; report unmet rows (excluding policy-deferred). Persist arch baseline to /tmp/arch-baseline-${cardId}.md and the diff to /tmp/diff-${cardId}.txt.\n\n` +
323
+ `Return: { epic, buildBlocked, blockedGate, unmetACs:[{n,text}], scopeFiles, mayEditPaths, fileDiffViolation, note }`,
324
+ { label: `implement:${cardId}`, phase: 'Implement', agentType: ownerAgent,
325
+ schema: { type: 'object', required: ['epic', 'buildBlocked', 'unmetACs', 'scopeFiles'], additionalProperties: true,
326
+ properties: { epic: { type: 'boolean' }, buildBlocked: { type: 'boolean' }, blockedGate: { type: 'string' }, unmetACs: { type: 'array', items: { type: 'object', additionalProperties: true } }, scopeFiles: { type: 'array', items: { type: 'string' } }, mayEditPaths: { type: 'array', items: { type: 'string' } }, fileDiffViolation: { type: 'boolean' }, note: { type: 'string' } } } }
327
+ )
328
+ } catch (e) {
329
+ if (e && e.transientExhausted) { noteDegraded('outage'); return { card: cardId, status: 'pending', gates, telemetry: tele } }
330
+ throw e
331
+ }
211
332
 
212
- if (impl && impl.epic) { g('router', 'EPIC-SKIPPED', 'epic card — not implemented'); return { card: cardId, status: 'epic-skipped', gates, commit: '-', telemetry: tele } }
333
+ if (impl && impl.epic) { g('router', 'EPIC-SKIPPED', 'epic card'); return { card: cardId, status: 'epic-skipped', gates, commit: '-', telemetry: tele } }
213
334
 
214
335
  const mayEdit = (impl && impl.mayEditPaths) || []
215
336
  const scopeFiles = (impl && impl.scopeFiles) || []
216
- if (impl && impl.fileDiffViolation) g('E4-file-diff', 'AUTO-REVERTED', 'coder touched files outside ownership — reverted per implement.md 11b')
337
+ if (impl && impl.fileDiffViolation) g('E4-file-diff', 'AUTO-REVERTED', 'coder touched files outside ownership')
217
338
 
218
- // G26 — build gates not converging → self-heal.
219
339
  if (impl && impl.buildBlocked) {
220
340
  const s = await resolve('blocker', cardId, `Phase-2 gate failing: ${impl.blockedGate}`, { mayEditPaths: mayEdit, scopeFiles, domain: 'code' })
221
341
  g('G26-build', s === 'resolved' ? 'RESOLVED' : 'FOLLOWUP', impl.blockedGate)
222
- if (s !== 'resolved') return { card: cardId, status: 'followup', gates, commit: '-', scopeFiles, telemetry: tele }
342
+ if (s !== 'resolved') { await rollbackCard(cardId, mayEdit); return { card: cardId, status: 'followup', gates, commit: '-', scopeFiles, telemetry: tele } }
223
343
  }
224
344
 
225
- // G6+G7AC-Closure: every unmet AC must be implemented (never silently deferred).
345
+ // F-010/F-016unmet ACs that are policy-deferred are skipped (already tracked).
226
346
  for (const ac of (impl && impl.unmetACs) || []) {
347
+ if (acceptedDeferrals.has(sig(cardId, 'ac-unmet', `AC-${ac.n}: ${ac.text}`))) { g('G7-ac-closure', 'DEFERRED-BY-POLICY', `AC-${ac.n}`); continue }
227
348
  const s = await resolve('ac-unmet', cardId, `AC-${ac.n}: ${ac.text}`, { mayEditPaths: mayEdit, scopeFiles, domain: 'code' })
228
349
  g('G7-ac-closure', s === 'resolved' ? 'RESOLVED' : 'FOLLOWUP', `AC-${ac.n}`)
229
350
  }
230
351
 
231
- // --- Phases 2.55 / 2.6 / 3 / 3.5 / 3.7 — review fan-out. One agent runs the
232
- // review pipeline per the modules and returns each gate's verdict + any
233
- // scope-expanding findings. Auto-revert gates (G9/G12) are handled inside. ---
234
- const review = await agent(
235
- `Run the per-card review pipeline for ${cardId} per ${REF}/review-cycle.md (Phase 2.55 Simplify, 2.6 E2E, 3 doc, 3.5 QA) and ${REF}/codex-gate.md (Phase 3.7 Codex). Run all agents/gates yourself.\n\n${cardBrief}\nDiff: /tmp/diff-${cardId}.txt\n\n` +
236
- `POLICIES (NO prompts): G9 simplify-regression after retries → AUTO-REVERT the simplify fixes, proceed. G12 doc-phase regression after retries → AUTO-REVERT the doc edits that broke the build. G11 E2E 'error' (tooling) → proceed with e2eStatus:'ERROR' (flag only). For BLOCKING failures that survive the module's retry cap, report them so the caller routes them to new2-resolve: G10 E2E 'blocked', G13 QA fail, G14 Codex BLOCKER/HIGH, G8 alias-mutation un-guarded, G15 Codex crash (security domain: never inline-bypass). Also report scopeExpansion findings (legitimate findings BEYOND this card's AC: from Simplify/Codex/doc-drift→bug).\n\n` +
237
- `Return: { simplify:'clean|reverted|applied', e2eStatus:'passed|skipped|blocked|error|overridden', qa:'pass|fail|skip', codex:'pass|blocker|crash', blocks:[{gate,domain,evidence}], scopeExpansion:[{evidence,domain,withinOwnership:boolean,newAC:boolean}], docGaps:{found,fixed}, qaFirstAttempt:'pass|fail|n/a' }`,
238
- { label: `review:${cardId}`, phase: 'Implement', agentType: 'general-purpose',
239
- schema: { type: 'object', required: ['blocks', 'scopeExpansion'], additionalProperties: true,
240
- properties: { simplify: { type: 'string' }, e2eStatus: { type: 'string' }, qa: { type: 'string' }, codex: { type: 'string' }, blocks: { type: 'array', items: { type: 'object', additionalProperties: true } }, scopeExpansion: { type: 'array', items: { type: 'object', additionalProperties: true } }, docGaps: { type: 'object', additionalProperties: true }, qaFirstAttempt: { type: 'string' } } } }
241
- )
242
-
243
- if (review) {
244
- if (review.simplify) g('G9-simplify', review.simplify === 'reverted' ? 'AUTO-REVERT' : 'OK', review.simplify)
245
- if (review.e2eStatus) g('G10/G11-e2e', review.e2eStatus === 'error' ? 'FLAG' : review.e2eStatus.toUpperCase())
246
- if (review.qaFirstAttempt) tele.qa_first_attempt = review.qaFirstAttempt
247
- if (review.docGaps) tele.doc_gaps = review.docGaps
248
- }
249
-
250
- // Route blocking review failures (G8/G10/G13/G14/G15) to new2-resolve.
352
+ // --- Review fan-out (F-024/F-025): specialized agents, trimmed by review_profile. ---
353
+ const reviewers = reviewProfile === 'skip' ? []
354
+ : reviewProfile === 'light' ? ['code-reviewer']
355
+ : ['code-reviewer', 'doc-reviewer', 'qa-sentinel', 'api-perf-cost-auditor'].concat(
356
+ (highRisk.length || /auth|security|secret|migration|rls/i.test(scopeFiles.join(' '))) ? ['security-reviewer'] : [])
357
+ let reviewResults = []
358
+ try {
359
+ reviewResults = (await parallel(reviewers.map((ra) => () => agentSafe(
360
+ `You are ${ra}. Review card ${cardId} per ${REF}/review-cycle.md + ${REF}/codex-gate.md (your domain only). Run your gates on the COMMITTED-or-working state.\n\n${cardBrief}\nDiff: /tmp/diff-${cardId}.txt\n\n` +
361
+ `Report ONLY blocking failures that survive your retry cap as blocks:[{gate,domain,evidence}] (each MUST have non-empty gate AND evidence F-014). Report legitimate findings BEYOND this card's AC as scopeExpansion:[{evidence,domain,withinOwnership,newAC}].\n\n` +
362
+ `Return: { blocks:[...], scopeExpansion:[...], note }`,
363
+ { label: `review:${cardId}:${ra}`, phase: 'Implement', agentType: ra,
364
+ schema: { type: 'object', required: ['blocks', 'scopeExpansion'], additionalProperties: true,
365
+ properties: { blocks: { type: 'array', items: { type: 'object', additionalProperties: true } }, scopeExpansion: { type: 'array', items: { type: 'object', additionalProperties: true } }, note: { type: 'string' } } } }
366
+ ).catch((e) => { if (e && e.transientExhausted) noteDegraded('outage'); return null }))) ).filter(Boolean)
367
+ } catch (_) { /* parallel never rejects; nulls filtered */ }
368
+
369
+ // F-014 — only route well-formed blocks (non-empty gate+evidence).
370
+ const blocks = reviewResults.flatMap((r) => (r.blocks || [])).filter((b) => b && b.gate && b.evidence)
371
+ const scopeExp = reviewResults.flatMap((r) => (r.scopeExpansion || []))
251
372
  let cardBlocked = false
252
- for (const b of (review && review.blocks) || []) {
253
- const kind = b.gate && /e2e/i.test(b.gate) ? 'e2e-blocked' : (b.gate && /qa/i.test(b.gate) ? 'qa-fail' : 'blocker')
254
- const s = await resolve(kind, cardId, `${b.gate}: ${b.evidence || ''}`, { mayEditPaths: mayEdit, scopeFiles, domain: b.domain || 'code' })
255
- g(b.gate || 'review-block', s === 'resolved' ? 'RESOLVED' : 'FOLLOWUP', b.evidence || '')
373
+ for (const b of blocks) {
374
+ const kind = /e2e/i.test(b.gate) ? 'e2e-blocked' : /qa/i.test(b.gate) ? 'qa-fail' : 'blocker'
375
+ const s = await resolve(kind, cardId, `${b.gate}: ${b.evidence}`, { mayEditPaths: mayEdit, scopeFiles, domain: b.domain || 'code' })
376
+ g(b.gate, s === 'resolved' ? 'RESOLVED' : 'FOLLOWUP', b.evidence)
256
377
  if (s !== 'resolved') cardBlocked = true
257
378
  }
258
-
259
- // Scope-expansion findings (Asse 2) — deterministic boundary inside new2-resolve.
260
- for (const sx of (review && review.scopeExpansion) || []) {
379
+ for (const sx of scopeExp) {
261
380
  const s = await resolve('scope-expansion', cardId, sx.evidence || '', { mayEditPaths: mayEdit, scopeFiles, domain: sx.domain || 'code' })
262
381
  g('scope-expansion', s === 'resolved' ? 'INTEGRATED' : 'FOLLOWUP', sx.evidence || '')
263
382
  }
264
383
 
265
- if (cardBlocked) return { card: cardId, status: 'followup', gates, commit: '-', scopeFiles, archBaselinePath: `/tmp/arch-baseline-${cardId}.md`, telemetry: tele }
266
-
267
- // --- Phase 4 — commit (G16: auto-clear COMMIT_LOCK + retry; persistent → resolve). ---
268
- const commitRes = await agent(
269
- `Commit card ${cardId} in the worktree per ${REF}/commit.md (Phase 4 explicit-staging — NEVER git add -A, NEVER git stash in a worktree — + Phase 5 context clean; mark the card DONE + ssot-registry). Run git yourself.\n\n${cardBrief}\n\n` +
270
- `POLICY G16: on commit failure auto-clear stale COMMIT_LOCK and retry (module cap). 'nothing to commit' = already committed (record HEAD). If still locked after the cap → return committed:false (caller routes to new2-resolve).\n\n` +
271
- `Return: { committed:boolean, commit:string, filesChanged:[...] }`,
272
- { label: `commit:${cardId}`, phase: 'Implement', agentType: 'general-purpose',
273
- schema: { type: 'object', required: ['committed'], additionalProperties: true, properties: { committed: { type: 'boolean' }, commit: { type: 'string' }, filesChanged: { type: 'array', items: { type: 'string' } } } } }
274
- )
384
+ if (cardBlocked) { await rollbackCard(cardId, mayEdit); return { card: cardId, status: 'followup', gates, commit: '-', scopeFiles, archBaselinePath: `/tmp/arch-baseline-${cardId}.md`, telemetry: tele } }
385
+
386
+ // --- Phase 4 — commit (F-023: Haiku + git-status reconcile, never git add -A). ---
387
+ let commitRes
388
+ try {
389
+ commitRes = await agentSafe(
390
+ `Commit card ${cardId} in worktree ${sharedCtx.worktreePath}. MECHANICAL — do NOT re-read reference modules.\n` +
391
+ `Steps: (1) \`git status --porcelain\`; (2) stage = MAY-EDIT (${JSON.stringify(mayEdit)}) dirty — NEVER \`git add -A\`, NEVER \`git stash\`; if dirty has files OUTSIDE MAY-EDIT, do NOT stage them and set reconcileNote; (3) commit message \`[${cardId}] <concise>\`; (4) mark the card DONE in its YAML + add the ssot-registry row; (5) 'nothing to commit' = already committed (record HEAD).\n` +
392
+ `On COMMIT_LOCK: clear stale lock + retry once. Still locked committed:false.\n\n` +
393
+ `Return: { committed, commit, filesChanged, reconcileNote }`,
394
+ { label: `commit:${cardId}`, phase: 'Implement', agentType: 'general-purpose', model: 'haiku',
395
+ schema: { type: 'object', required: ['committed'], additionalProperties: true, properties: { committed: { type: 'boolean' }, commit: { type: 'string' }, filesChanged: { type: 'array', items: { type: 'string' } }, reconcileNote: { type: 'string' } } } }
396
+ )
397
+ } catch (e) {
398
+ if (e && e.transientExhausted) { noteDegraded('outage'); await rollbackCard(cardId, mayEdit); return { card: cardId, status: 'pending', gates, telemetry: tele } }
399
+ throw e
400
+ }
275
401
 
276
402
  if (!commitRes || !commitRes.committed) {
277
403
  const s = await resolve('blocker', cardId, 'commit blocked after retries', { mayEditPaths: mayEdit, scopeFiles, domain: 'code' })
278
404
  g('G16-commit', s === 'resolved' ? 'RESOLVED' : 'FOLLOWUP')
279
- if (s !== 'resolved') return { card: cardId, status: 'followup', gates, commit: '-', scopeFiles, archBaselinePath: `/tmp/arch-baseline-${cardId}.md`, telemetry: tele }
405
+ if (s !== 'resolved') { await rollbackCard(cardId, mayEdit); return { card: cardId, status: 'followup', gates, commit: '-', scopeFiles, archBaselinePath: `/tmp/arch-baseline-${cardId}.md`, telemetry: tele } }
280
406
  }
407
+ if (commitRes && commitRes.reconcileNote) g('commit-reconcile', 'NOTE', commitRes.reconcileNote)
281
408
 
282
409
  g('commit', 'COMMITTED', (commitRes && commitRes.commit) || '')
283
410
  return {
284
411
  card: cardId, status: 'committed',
285
412
  commit: (commitRes && commitRes.commit) || '-',
286
413
  filesChanged: (commitRes && commitRes.filesChanged) || [],
287
- scopeFiles, archBaselinePath: `/tmp/arch-baseline-${cardId}.md`,
288
- gates, telemetry: tele,
414
+ scopeFiles, archBaselinePath: `/tmp/arch-baseline-${cardId}.md`, gates, telemetry: tele,
289
415
  }
290
416
  }
291
417
 
292
418
  // ───────────────────────────────────────────────────────────────────────────
293
- // Phase Implement — driver: sequential or team-mode (parallel coders per wave).
419
+ // Phase Implement — DAG scheduler (F-021/F-017/F-018). Single-worktree semantics:
420
+ // a card runs only when ALL its in-batch deps are 'committed'; a failed/deferred
421
+ // card blocks its transitive dependents (no doomed resolve); transient failure →
422
+ // re-queue with a cap; sustained outage → stop cleanly + degraded return.
294
423
  // ───────────────────────────────────────────────────────────────────────────
295
424
  phase('Implement')
296
425
  const lessons = []
297
- const pathById = {}
298
- cardIds.forEach((id, i) => { pathById[id] = cardPaths[i] || `${paths.backlog_dir || ''}/${id}.yml` })
426
+ const state = {} // cardId → 'pending'|'committed'|'followup'|'epic-skipped'|'blocked'|'failed'
427
+ const attempts = {} // cardId retry count (transient)
428
+ const RETRY_CAP = 2
429
+ const MAX_CONSECUTIVE_OUTAGE = 3
430
+ for (const id of runnableCards) { state[id] = 'pending'; attempts[id] = 0 }
431
+
432
+ function depsSatisfied(id) {
433
+ const deps = ((graphById[id] || {}).dependsOn || []).filter((d) => runnableCards.includes(d))
434
+ return deps.every((d) => state[d] === 'committed' || state[d] === 'epic-skipped')
435
+ }
436
+ function depFailed(id) {
437
+ const deps = ((graphById[id] || {}).dependsOn || []).filter((d) => runnableCards.includes(d))
438
+ return deps.some((d) => state[d] === 'followup' || state[d] === 'blocked' || state[d] === 'failed')
439
+ }
299
440
 
300
- if (preflight.executionMode === 'team' && Array.isArray(preflight.groups) && preflight.groups.length) {
301
- // Waves run in order (a higher level waits for lower levels); cards within a wave
302
- // run concurrently — parallel() keeps every coder's bloat inside the runtime.
303
- const sortedGroups = preflight.groups.slice().sort((x, y) => (x.level || 0) - (y.level || 0))
304
- for (const grp of sortedGroups) {
305
- const ids = (grp.cards || []).filter((id) => runnableCards.includes(id))
306
- if (!ids.length) continue
307
- log(`Team wave level=${grp.level}: ${ids.length} card(s) in parallel.`)
308
- const waveResults = await parallel(ids.map((id) => () => runCard(id, pathById[id], lessons).catch((e) => crashFollowup(id, e))))
309
- for (const r of waveResults.filter(Boolean)) { perCardResults.push(r); if (r.note) lessons.push(`${r.card}: ${r.note}`) }
310
- }
311
- } else {
312
- // Sequential — cards share one worktree and run one at a time.
441
+ let consecutiveOutage = 0
442
+ let guard = runnableCards.length * (RETRY_CAP + 2) + 5
443
+ while (guard-- > 0) {
444
+ // Mark cards whose deps failed as blocked (F-017 never route them to resolve).
313
445
  for (const id of runnableCards) {
314
- const r = await runCard(id, pathById[id], lessons).catch((e) => crashFollowup(id, e))
315
- perCardResults.push(r)
316
- if (r && r.note) lessons.push(`${r.card}: ${r.note}`)
446
+ if (state[id] === 'pending' && depFailed(id)) {
447
+ state[id] = 'blocked'
448
+ residuals.push({ card: id, kind: 'blocked-by-dep', evidence: `blocked: a dependency failed/deferred`, materialized: false })
449
+ ledger(id, 'dep-barrier', 'BLOCKED', 'dependency failed/deferred')
450
+ }
317
451
  }
452
+ const next = runnableCards.find((id) => state[id] === 'pending' && depsSatisfied(id))
453
+ if (!next) break // nothing runnable (all done/blocked, or a cycle/stall)
454
+
455
+ const r = await runCard(next, pathById[next], lessons).catch((e) => crashResult(next, e))
456
+ if (r.status === 'pending') {
457
+ attempts[next]++
458
+ consecutiveOutage++
459
+ if (attempts[next] > RETRY_CAP || consecutiveOutage >= MAX_CONSECUTIVE_OUTAGE) {
460
+ noteDegraded('outage')
461
+ ledger(next, 'outage', 'PAUSED', `transient failures (attempt ${attempts[next]}) — batch paused for durable resume`)
462
+ break // OUTAGE — stop cleanly; the skill resumes (skip-completed makes it idempotent).
463
+ }
464
+ ledger(next, 'retry', 'REQUEUE', `transient — attempt ${attempts[next]}/${RETRY_CAP}`)
465
+ continue // re-queue (state stays 'pending')
466
+ }
467
+ consecutiveOutage = 0
468
+ state[next] = r.status
469
+ perCardResults.push(r)
470
+ if (r.note) lessons.push(`${next}: ${r.note}`)
471
+ }
472
+
473
+ // Any still-pending card after the loop (outage) is recorded as a residual.
474
+ for (const id of runnableCards) {
475
+ if (state[id] === 'pending') residuals.push({ card: id, kind: 'not-reached', evidence: 'batch paused before this card ran', materialized: false })
476
+ }
477
+
478
+ function crashResult(id, e) {
479
+ if (e && (e.transientExhausted || isTransient(e))) { return { card: id, status: 'pending', gates: [], telemetry: {} } }
480
+ residuals.push({ card: id, kind: 'agent-crash', evidence: String(e && e.message), materialized: false })
481
+ ledger(id, 'runCard', 'ERROR', String(e && e.message))
482
+ return { card: id, status: 'failed', gates: [{ gate: 'runCard', decision: 'ERROR', detail: String(e && e.message) }], commit: '-', telemetry: {} }
318
483
  }
319
484
 
320
485
  const committed = perCardResults.filter((r) => r.status === 'committed')
321
486
 
322
487
  // ───────────────────────────────────────────────────────────────────────────
323
- // Phase Final — cross-batch final review (reuse new-final-review). F.5 user gates
324
- // become deterministic: G17 needs-manual treat as VERIFIED (the workflow already
325
- // drops only FALSE_POSITIVE); G18 unresolved BLOCKER/HIGH → new2-resolve, else
326
- // block the merge.
488
+ // Phase Final — cross-batch final review (new-final-review). Findings batched per
489
+ // area (F-007/F-035/F-037); merge-artifact doc findings skipped (resolved-by-merge).
327
490
  // ───────────────────────────────────────────────────────────────────────────
328
491
  phase('Final')
329
492
  let finalSummary = null
330
- let mergeBlocked = batchFatal
331
- if (committed.length && !batchFatal) {
493
+ let mergeBlocked = batchFatal || degraded
494
+ if (committed.length && !batchFatal && !degraded) {
332
495
  const reviewScopeFiles = dedupe(committed.flatMap((r) => r.scopeFiles || []))
333
496
  const archPaths = committed.map((r) => r.archBaselinePath).filter(Boolean)
334
497
  const allArch = archPaths.length === committed.length ? archPaths : null
335
498
  const hasApiDataFiles = reviewScopeFiles.some((f) => /api\/|data-model|\.sql$|migrations?\//i.test(f))
336
499
 
337
- const final = await workflow('new-final-review', {
338
- firstCardId: firstCard,
339
- worktreePath: sharedCtx.worktreePath,
340
- baseBranch: TRUNK,
341
- cardPaths: committed.map((r) => pathById[r.card]).filter(Boolean),
342
- reviewScopeFiles,
343
- archBaselinePaths: allArch,
344
- hasApiDataFiles,
345
- config: cfg,
346
- }).catch(() => null)
500
+ let final = null
501
+ try {
502
+ final = await workflow('new-final-review', {
503
+ firstCardId: firstCard, worktreePath: sharedCtx.worktreePath, baseBranch: TRUNK,
504
+ cardPaths: committed.map((r) => pathById[r.card]).filter(Boolean),
505
+ reviewScopeFiles, archBaselinePaths: allArch, hasApiDataFiles, config: cfg,
506
+ })
507
+ } catch (e) { if (e && isTransient(e)) noteDegraded('outage'); final = null }
347
508
 
348
509
  if (final) {
349
510
  finalSummary = final.summary || null
350
511
  ledger(firstCard, 'final-review', 'DONE', `engine=${final.codexEngine}; verified=${final.summary ? final.summary.verified : '?'}`)
351
- // G18 apply BLOCKER/HIGH fixes; unresolved block merge. G17: a workflow
352
- // cannot ask the user, so NEEDS_MANUAL_CONFIRMATION is treated as VERIFIED
353
- // (attempt the fix) never silently ignored (the count-only path was the bug).
354
- const blockers = (final.findings || []).filter((f) => (f.classification === 'VERIFIED' || f.classification === 'NEEDS_MANUAL_CONFIRMATION') && (f.severity === 'BLOCKER' || f.severity === 'HIGH'))
355
- for (const f of blockers) {
356
- const s = await resolve('merge-blocker', f.finding_id || firstCard, `${f.classification} ${f.severity} ${f.title}: ${f.evidence}`, { mayEditPaths: reviewScopeFiles, scopeFiles: reviewScopeFiles, domain: f.domain || 'code' })
512
+ const all = (final.findings || []).filter((f) => (f.classification === 'VERIFIED' || f.classification === 'NEEDS_MANUAL_CONFIRMATION') && (f.severity === 'BLOCKER' || f.severity === 'HIGH'))
513
+ // F-035 drop merge-artifact doc findings (content exists in worktree, the merge carries it).
514
+ const actionable = all.filter((f) => !/in (the )?main repo|not (in|on) (the )?main|merge[- ]?time|worktree.*(not|missing).*main/i.test(`${f.title} ${f.evidence}`))
515
+ for (const f of all) if (!actionable.includes(f)) ledger(f.finding_id || firstCard, 'final-merge-artifact', 'SKIP', 'resolved-by-merge')
516
+ // F-007/F-037 group actionable findings by fix-area (file) → one resolve per area.
517
+ const byArea = {}
518
+ for (const f of actionable) {
519
+ const area = (Array.isArray(f.files) && f.files[0]) || (f.file) || (f.domain || 'misc')
520
+ ;(byArea[area] = byArea[area] || []).push(f)
521
+ }
522
+ for (const area of Object.keys(byArea)) {
523
+ const group = byArea[area]
524
+ const s = await resolve('merge-blocker', group[0].finding_id || firstCard,
525
+ group.map((f) => `${f.severity} ${f.title}: ${f.evidence}`).join(' || '),
526
+ { mayEditPaths: reviewScopeFiles, scopeFiles: reviewScopeFiles, domain: group[0].domain || 'code',
527
+ findings: group.map((f) => ({ kind: 'merge-blocker', evidence: `${f.title}: ${f.evidence}`, domain: f.domain || 'code' })) })
357
528
  if (s !== 'resolved') mergeBlocked = true
358
529
  }
359
- if ((final.summary && final.summary.failingGates && final.summary.failingGates.length)) {
360
- const s = await resolve('qa-fail', firstCard, `final gates failing: ${final.summary.failingGates.join(', ')}`, { mayEditPaths: reviewScopeFiles, scopeFiles: reviewScopeFiles, domain: 'code' })
530
+ if (finalSummary && finalSummary.failingGates && finalSummary.failingGates.length) {
531
+ const s = await resolve('qa-fail', firstCard, `final gates failing: ${finalSummary.failingGates.join(', ')}`, { mayEditPaths: reviewScopeFiles, scopeFiles: reviewScopeFiles, domain: 'code' })
361
532
  if (s !== 'resolved') mergeBlocked = true
362
533
  }
363
534
  } else {
364
- ledger(firstCard, 'final-review', 'SKIPPED', 'workflow returned null')
535
+ ledger(firstCard, 'final-review', 'SKIPPED', degraded ? 'degraded' : 'workflow returned null')
536
+ mergeBlocked = true
365
537
  }
366
538
  } else {
367
- ledger(firstCard, 'final-review', 'SKIPPED', batchFatal ? 'batch fatal review/merge halted' : 'no committed cards')
539
+ ledger(firstCard, 'final-review', 'SKIPPED', batchFatal ? 'batch fatal' : degraded ? 'degraded (outage)' : 'no committed cards')
368
540
  }
369
541
 
370
542
  // ───────────────────────────────────────────────────────────────────────────
371
- // Phase Merge — auto-merge to trunk (G24) + Phase 6b/6c reconciliation (conservative
372
- // git policies: G19–G23). Skipped when a final BLOCKER/HIGH is unresolved.
543
+ // Phase Merge — INTEGRITY-GATED (F-029/F-030/F-031). Merge ONLY if the batch is
544
+ // verifiably complete: every runnable card committed OR deferred with a real
545
+ // follow-up; no open BLOCKER; no degraded; no uncommitted code. NEVER force-DONE,
546
+ // NEVER git-add unreviewed code.
373
547
  // ───────────────────────────────────────────────────────────────────────────
374
548
  phase('Merge')
375
549
  let mergeResult = null
550
+ const incomplete = runnableCards.filter((id) => state[id] !== 'committed' && state[id] !== 'epic-skipped')
551
+ const integrityOK = committed.length > 0 && !mergeBlocked && !batchFatal && !degraded && incomplete.length === 0
376
552
  if (!committed.length) {
377
553
  ledger(firstCard, 'merge', 'SKIPPED', 'no committed cards')
378
- } else if (mergeBlocked) {
379
- ledger(firstCard, 'merge', 'BLOCKED', 'unresolved final BLOCKER/HIGH worktree left intact for review')
554
+ } else if (!integrityOK) {
555
+ const why = degraded ? 'degraded (outage)' : mergeBlocked ? 'unresolved final BLOCKER/HIGH' : incomplete.length ? `incomplete cards: ${incomplete.join(' ')}` : 'integrity gate'
556
+ ledger(firstCard, 'merge', 'BLOCKED', `${why} — worktree left intact, NOT merged`)
380
557
  } else {
381
- mergeResult = await agent(
382
- `Auto-merge the batch worktree to ${TRUNK} per ${REF}/merge-cleanup.md (Phase 6 via /mw programmatic with checksAlreadyPassed:true, Phase 6b status reconciliation, Phase 6c workspace hygiene). Run git yourself.\n\n${projectBrief}\nWorktree: ${sharedCtx.worktreePath}\nBranch: ${sharedCtx.branch}\nmerge_strategy: ${mergeStrategy}\nCommitted cards: ${committed.map((r) => r.card).join(' ')}\nPhase-0 stash to restore (if any): see /tmp/batch-tracker-${firstCard}.md ## Workspace Snapshot.\n\n` +
383
- `DETERMINISTIC POLICIES (NO prompts): G24 → auto-merge via merge_strategy. G19 sync-deferred if HEAD==${TRUNK} ff-pull, else leave+report. G20 sync-needs-decision leave+report (never touch foreign files). G21 post-batch dirty (no stash) partition-ignore framework artifacts; leave the rest + report. G22 divergence → behind: ff-pull; ahead/both: leave+report; NEVER reset --hard / force-push. G23 stash restore conflict → leave the stash intact + report (never drop). Phase 6b: force any non-DONE batch card to DONE.\n\n` +
384
- `Return: { merged:boolean, mergeCommit, mergeTs, reconciliation, note }`,
385
- { label: 'merge', phase: 'Merge', agentType: 'general-purpose', schema: MERGE_SCHEMA }
386
- )
558
+ try {
559
+ mergeResult = await agentSafe(
560
+ `Auto-merge the batch worktree to ${TRUNK} per ${REF}/merge-cleanup.md (Phase 6 via /mw programmatic checksAlreadyPassed:true, Phase 6b status reconciliation, Phase 6c hygiene). Run git yourself.\n\n${projectBrief}\nWorktree: ${sharedCtx.worktreePath}\nBranch: ${sharedCtx.branch}\nmerge_strategy: ${mergeStrategy}\nCommitted cards: ${committed.map((r) => r.card).join(' ')}\nPhase-0 stash to restore (if any): see /tmp/batch-tracker-${firstCard}.md.\n\n` +
561
+ `DETERMINISTIC POLICIES (NO prompts):\n` +
562
+ `• G24 auto-merge via merge_strategy.\n` +
563
+ `• F-030 HARD RULE: NEVER \`git add\`/commit code that did not pass the per-card gates. If the worktree is dirty with uncommitted code → DO NOT commit it; leave it, set uncommittedLeft:true, and report. NO "safety commit". Security/migration code is NEVER swept in.\n` +
564
+ `• F-029 HARD RULE: Phase 6b reconciliation marks a card DONE ONLY if it has a real commit in ${TRUNK}..HEAD AND its gates are green. NEVER force a non-implemented card to DONE. Return forcedDone:[] (must be empty).\n` +
565
+ `• G19 sync-deferred → HEAD==${TRUNK} ff-pull, else leave+report. G20 → leave+report. G21 post-batch dirty → partition-ignore framework artifacts; leave the rest + report (do NOT commit). G22 divergence → behind: ff-pull; ahead/both: leave+report; NEVER reset --hard/force-push. G23 stash restore conflict → leave intact + report.\n\n` +
566
+ `Return: { merged, mergeCommit, mergeTs, reconciliation, forcedDone:[], uncommittedLeft, note }`,
567
+ { label: 'merge', phase: 'Merge', agentType: 'general-purpose', schema: MERGE_SCHEMA }
568
+ )
569
+ } catch (e) { if (e && e.transientExhausted) noteDegraded('outage'); mergeResult = null }
570
+ if (mergeResult && (mergeResult.forcedDone || []).length) { noteDegraded('false_done'); ledger(firstCard, 'F029-guard', 'VIOLATION', `forcedDone: ${mergeResult.forcedDone.join(' ')}`) }
571
+ if (mergeResult && mergeResult.uncommittedLeft) ledger(firstCard, 'F030-guard', 'LEFT-UNCOMMITTED', 'dirty code left (not swept) + reported')
387
572
  ledger(firstCard, 'G24-merge', (mergeResult && mergeResult.merged) ? 'MERGED' : 'INCOMPLETE', (mergeResult && (mergeResult.mergeCommit || mergeResult.note)) || '')
388
573
  if (mergeResult && mergeResult.reconciliation) ledger(firstCard, 'G19-23-reconcile', 'AUTO', mergeResult.reconciliation)
389
574
  }
390
575
 
391
576
  // ───────────────────────────────────────────────────────────────────────────
392
- // Phase Production (Phase 7) — post-merge readiness checklist (non-blocking, full
393
- // parity with /new). Auto-executes stack-matched deploys; reports the manual items.
577
+ // Phase Production (Phase 7) — non-blocking; report-not-execute (F-011 infra-deferred).
394
578
  // ───────────────────────────────────────────────────────────────────────────
395
579
  phase('Production')
396
580
  if (mergeResult && mergeResult.merged) {
397
- prodReadiness = await agent(
398
- `Run the post-merge Production Readiness checklist per ${REF}/production-readiness.md (Phase 7) over the batch's changed files. Auto-EXECUTE only stack-matched index / access-rule / cron deploys; REPORT (do not execute) env vars, feature flags, DB migrations, third-party secrets, DNS changes. NON-BLOCKING — never fail the batch.\n\n${projectBrief}\nChanged files: ${dedupe(committed.flatMap((r) => r.filesChanged || [])).join(', ') || '(derive from git)'}\n\nReturn: { autoExecuted:[...], manualItems:[...], note }`,
399
- { label: 'production-readiness', phase: 'Production', agentType: 'general-purpose',
400
- schema: { type: 'object', required: ['manualItems'], additionalProperties: true, properties: { autoExecuted: { type: 'array', items: { type: 'string' } }, manualItems: { type: 'array', items: { type: 'string' } }, note: { type: 'string' } } } }
401
- )
402
- ledger(firstCard, 'phase7-production', 'DONE', `auto=${((prodReadiness && prodReadiness.autoExecuted) || []).length} manual=${((prodReadiness && prodReadiness.manualItems) || []).length}`)
581
+ try {
582
+ prodReadiness = await agentSafe(
583
+ `Run the post-merge Production Readiness checklist per ${REF}/production-readiness.md (Phase 7) over the batch's changed files. Auto-EXECUTE only stack-matched index/access-rule/cron deploys; REPORT (do not execute) env vars, feature flags, DB migrations, secrets, DNS. NON-BLOCKING.\n\n${projectBrief}\nChanged files: ${dedupe(committed.flatMap((r) => r.filesChanged || [])).join(', ') || '(derive from git)'}\n\nReturn: { autoExecuted:[...], manualItems:[...], note }`,
584
+ { label: 'production-readiness', phase: 'Production', agentType: 'general-purpose',
585
+ schema: { type: 'object', required: ['manualItems'], additionalProperties: true, properties: { autoExecuted: { type: 'array', items: { type: 'string' } }, manualItems: { type: 'array', items: { type: 'string' } }, note: { type: 'string' } } } }
586
+ )
587
+ ledger(firstCard, 'phase7-production', 'DONE', `auto=${((prodReadiness && prodReadiness.autoExecuted) || []).length} manual=${((prodReadiness && prodReadiness.manualItems) || []).length}`)
588
+ } catch (_) { ledger(firstCard, 'phase7-production', 'SKIPPED', 'agent failed (non-blocking)') }
403
589
  } else {
404
590
  ledger(firstCard, 'phase7-production', 'SKIPPED', 'not merged')
405
591
  }
406
592
 
407
- // ───────────────────────────────────────────────────────────────────────────
408
- // Return — assemble the report + Phase-8 telemetry (variant new2).
409
- // ───────────────────────────────────────────────────────────────────────────
410
593
  return finalReturn({ fatal: false })
411
594
 
412
595
  // ───────────────────────────────────────────────────────────────────────────
@@ -414,25 +597,36 @@ function finalReturn(opts) {
414
597
  const fatal = opts && opts.fatal
415
598
  const telemetry = buildTelemetry()
416
599
  const report = buildReport({ fatal, reason: opts && opts.reason, finalSummary, mergeResult })
417
- return { report, perCardResults, gateLedger, residualFollowups, telemetry }
600
+ // residuals (materialized:false) is the offline-safe ledger; the skill writes the YAML.
601
+ return { report, perCardResults, gateLedger, residualFollowups, residuals, degraded, degradationReasons, telemetry }
418
602
  }
419
603
 
420
604
  function buildTelemetry() {
421
- const total = cardIds.length
422
- const done = perCardResults.filter((r) => r.status === 'committed').length
423
- const fixCycles = gateLedger.filter((x) => /resolve:/.test(x.gate)).length
605
+ const tokensNow = (typeof budget !== 'undefined' && budget && typeof budget.spent === 'function') ? budget.spent() : null
606
+ const totalTokens = (tokensAtStart != null && tokensNow != null) ? (tokensNow - tokensAtStart) : null
424
607
  return {
425
608
  variant: 'new2',
426
609
  batch: cardIds,
427
610
  first_card: firstCard,
428
- cards_total: total,
429
- cards_committed: done,
611
+ ts: TS || null,
612
+ cards_total: cardIds.length,
613
+ cards_real_done: perCardResults.filter((r) => r.status === 'committed').length,
614
+ cards_force_done: 0, // F-029 — force-DONE forbidden; always 0.
430
615
  cards_followup: perCardResults.filter((r) => r.status === 'followup').length,
431
- followups_created: residualFollowups.length,
432
- resolve_invocations: fixCycles,
616
+ cards_blocked: runnableCards.filter((id) => state[id] === 'blocked').length,
617
+ cards_deferred: residuals.filter((x) => x.kind === 'policy-deferred-ac').length,
618
+ residuals_total: residuals.length,
619
+ // followups_on_disk is filled by the SKILL after it materialises pending residuals.
620
+ followups_materialized_in_workflow: residuals.filter((x) => x.materialized).length,
621
+ resolve_invocations: resolvedSignatures.size,
433
622
  final_review: finalSummary || null,
434
623
  merged: !!(mergeResult && mergeResult.merged),
624
+ degraded,
625
+ degradation_reasons: degradationReasons,
435
626
  execution_mode: preflight ? preflight.executionMode : 'sequential',
627
+ // cost — total_tokens via budget.spent() delta; agent_count via counter; wall_clock_s stamped by the SKILL.
628
+ total_tokens: totalTokens,
629
+ agent_count: agentCount,
436
630
  per_card: perCardResults.map((r) => ({ card: r.card, status: r.status, telemetry: r.telemetry || {} })),
437
631
  stats_requested: !!FLAGS.stats,
438
632
  }
@@ -441,40 +635,37 @@ function buildTelemetry() {
441
635
  function buildReport(o) {
442
636
  const L = []
443
637
  L.push(`# new2 batch — ${cardIds.join(' ')}`)
444
- L.push(`Variant: **new2** (workflow-hosted) · Mode: ${preflight ? preflight.executionMode : '?'} · Trunk: ${TRUNK}`)
638
+ L.push(`Variant: **new2** · Mode: ${preflight ? preflight.executionMode : '?'} · Trunk: ${TRUNK}${degraded ? ' · ⚠️ DEGRADED (' + degradationReasons.join(',') + ')' : ''}`)
445
639
  if (o.fatal) { L.push(``, `## ⛔ BATCH FATAL`, o.reason || 'workspace unworkable'); return L.join('\n') }
446
640
  L.push(``, `## Esito card`)
447
641
  L.push(`| Card | Status | Commit | File |`)
448
642
  L.push(`|------|--------|--------|------|`)
449
643
  for (const r of perCardResults) L.push(`| ${r.card} | ${r.status} | ${r.commit || '-'} | ${(r.filesChanged || []).length} |`)
644
+ const blockedIds = runnableCards.filter((id) => state[id] === 'blocked' || state[id] === 'pending')
645
+ for (const id of blockedIds) L.push(`| ${id} | ${state[id]} | - | 0 |`)
450
646
  if (finalSummary) {
451
647
  L.push(``, `## Final review`)
452
- L.push(`Verified: ${finalSummary.verified} (BLOCKER ${finalSummary.blockers} / HIGH ${finalSummary.highs}) · needs-manual ${finalSummary.needsManual} · failing gates: ${(finalSummary.failingGates || []).join(', ') || 'none'}`)
648
+ L.push(`Verified: ${finalSummary.verified} (BLOCKER ${finalSummary.blockers} / HIGH ${finalSummary.highs}) · failing gates: ${(finalSummary.failingGates || []).join(', ') || 'none'}`)
453
649
  }
454
650
  L.push(``, `## Merge`)
455
651
  if (mergeResult && mergeResult.merged) L.push(`Merged → ${TRUNK} @ ${mergeResult.mergeCommit || '?'}${mergeResult.reconciliation ? ' · ' + mergeResult.reconciliation : ''}`)
456
- else if (mergeBlocked) L.push(`Merge BLOCKED — ${batchFatal ? 'batch fatal (new2-resolve)' : 'unresolved final BLOCKER/HIGH'}. Worktree left intact at ${sharedCtx.worktreePath}.`)
457
- else L.push(`Not merged (no committed cards or incomplete).`)
652
+ else L.push(`**NOT merged** — ${degraded ? 'batch degraded (outage); resume to finish' : mergeBlocked ? 'unresolved final BLOCKER/HIGH or incomplete cards' : 'no committed cards'}. Worktree left intact at ${sharedCtx ? sharedCtx.worktreePath : '?'}.`)
458
653
  if (prodReadiness) {
459
654
  const man = prodReadiness.manualItems || []
460
- L.push(``, `## Production readiness (Phase 7)`)
461
- L.push(`Auto-eseguiti: ${(prodReadiness.autoExecuted || []).length} · Da fare a mano: ${man.length}`)
655
+ L.push(``, `## Production readiness (Phase 7)`, `Auto: ${(prodReadiness.autoExecuted || []).length} · Manuali: ${man.length}`)
462
656
  for (const m of man) L.push(`- [ ] ${m}`)
463
657
  }
464
- if (residualFollowups.length) {
465
- L.push(``, `## ⚠️ Follow-up auto-creati (residui non risolti autonomamente — nulla perso)`)
466
- for (const f of residualFollowups) L.push(`- **${f.followupCard}** ← ${f.card} (${f.kind})${f.reason ? ': ' + f.reason : ''}`)
658
+ if (residuals.length) {
659
+ L.push(``, `## ⚠️ Residui (il skill materializza le follow-up mancanti — nulla perso)`)
660
+ for (const f of residuals) L.push(`- ${f.card} (${f.kind})${f.materialized ? ' ✓' : ' DA MATERIALIZZARE'}: ${f.evidence}`)
467
661
  }
468
662
  const excluded = gateLedger.filter((x) => x.decision === 'EXCLUDED')
469
- if (excluded.length) {
470
- L.push(``, `## Card escluse in pre-flight`)
471
- for (const e of excluded) L.push(`- ${e.card}: ${e.detail}`)
472
- }
473
- L.push(``, `_Gate ledger: ${gateLedger.length} decisioni deterministiche, 0 AskUserQuestion._`)
663
+ if (excluded.length) { L.push(``, `## Card escluse in pre-flight`); for (const e of excluded) L.push(`- ${e.card}: ${e.detail}`) }
664
+ L.push(``, `_Gate ledger: ${gateLedger.length} decisioni deterministiche, 0 AskUserQuestion. Token: ${buildTelemetry().total_tokens ?? '?'} · agent: ${agentCount}._`)
474
665
  return L.join('\n')
475
666
  }
476
667
 
477
668
  function dedupe(arr) { return Array.from(new Set(arr)) }
478
669
  function emptyReturn(reason) {
479
- return { report: `# new2 — no-op\n${reason}`, perCardResults: [], gateLedger: [], residualFollowups: [], telemetry: { variant: 'new2', cards_total: 0, reason } }
670
+ return { report: `# new2 — no-op\n${reason}`, perCardResults: [], gateLedger: [], residualFollowups: [], residuals: [], degraded: false, degradationReasons: [], telemetry: { variant: 'new2', cards_total: 0, reason } }
480
671
  }