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.
- package/CHANGELOG.md +13 -0
- package/VERSION +1 -1
- package/framework/.claude/agents/prd-card-writer.md +25 -0
- package/framework/.claude/skills/new2/SKILL.md +42 -17
- package/framework/.claude/workflows/new2-resolve.js +187 -90
- package/framework/.claude/workflows/new2.js +416 -225
- package/framework/docs/WORKFLOWS.md +2 -2
- package/package.json +1 -1
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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 = []
|
|
48
|
-
const residualFollowups = []// { card, kind, followupCard, reason }
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
let
|
|
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
|
|
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'
|
|
118
|
+
ok: { type: 'boolean' },
|
|
72
119
|
worktreePath: { type: 'string' },
|
|
73
120
|
branch: { type: 'string' },
|
|
74
|
-
port: { type: ['number', 'string']
|
|
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 }
|
|
79
|
-
cards: { type: 'array', items: { type: 'string' }, description: 'card ids cleared to run
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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'
|
|
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
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
`
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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.json — NOT overlays/). Genuine user work → auto-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 unavailable → crossCard='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 —
|
|
156
|
-
//
|
|
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
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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'
|
|
173
|
-
|
|
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
|
|
190
|
-
//
|
|
191
|
-
//
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
345
|
+
// F-010/F-016 — unmet 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
|
-
// ---
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
if (
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
|
253
|
-
const kind =
|
|
254
|
-
const s = await resolve(kind, cardId, `${b.gate}: ${b.evidence
|
|
255
|
-
g(b.gate
|
|
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 (
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
`
|
|
272
|
-
|
|
273
|
-
|
|
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 —
|
|
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
|
|
298
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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 (
|
|
324
|
-
//
|
|
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
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
352
|
-
//
|
|
353
|
-
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
|
|
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 (
|
|
360
|
-
const s = await resolve('qa-fail', firstCard, `final gates failing: ${
|
|
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
|
|
539
|
+
ledger(firstCard, 'final-review', 'SKIPPED', batchFatal ? 'batch fatal' : degraded ? 'degraded (outage)' : 'no committed cards')
|
|
368
540
|
}
|
|
369
541
|
|
|
370
542
|
// ───────────────────────────────────────────────────────────────────────────
|
|
371
|
-
// Phase Merge —
|
|
372
|
-
//
|
|
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 (
|
|
379
|
-
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
`
|
|
384
|
-
|
|
385
|
-
|
|
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) —
|
|
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
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
|
|
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
|
|
422
|
-
const
|
|
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
|
-
|
|
429
|
-
|
|
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
|
-
|
|
432
|
-
|
|
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**
|
|
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}) ·
|
|
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
|
|
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 (
|
|
465
|
-
L.push(``, `## ⚠️
|
|
466
|
-
for (const f of
|
|
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
|
-
|
|
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
|
}
|