baldart 4.16.2 → 4.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +27 -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/new-final-review.js +66 -17
- package/framework/.claude/workflows/new2-resolve.js +219 -92
- package/framework/.claude/workflows/new2.js +432 -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,394 @@ 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
|
// ───────────────────────────────────────────────────────────────────────────
|
|
237
|
+
// F-038 — doc-domain findings are fixed in the doc tree, NOT the card's code MAY-EDIT.
|
|
238
|
+
// Passing code-only ownership made the doc fixer edit docs/ "out of scope" and the JS
|
|
239
|
+
// fabrication cross-check reject a correct, judge-approved fix → a wasted Tier-2 fan-out
|
|
240
|
+
// (~150k tok) and a risk of a bogus follow-up for an already-resolved finding. Give doc
|
|
241
|
+
// findings their real owner territory.
|
|
242
|
+
function isDocDomain(d) { return /doc|wiki|ssot|readme/i.test(String(d || '')) }
|
|
243
|
+
function domainMayEdit(dom, codeScope) {
|
|
244
|
+
if (!isDocDomain(dom)) return codeScope
|
|
245
|
+
const docPaths = [paths.docs_dir, paths.references_dir, paths.wiki_dir, paths.prd_dir].filter(Boolean)
|
|
246
|
+
return docPaths.length ? docPaths : codeScope // doc-only ownership; fall back to code scope if no doc paths configured
|
|
247
|
+
}
|
|
248
|
+
|
|
158
249
|
async function resolve(kind, card, evidence, extra) {
|
|
159
|
-
const
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
250
|
+
const s = sig(card, kind, evidence)
|
|
251
|
+
if (resolvedSignatures.has(s) || acceptedDeferrals.has(s)) {
|
|
252
|
+
ledger(card, 'resolve:' + kind, 'DEDUP-SKIP', 'already resolved/deferred this run')
|
|
253
|
+
return 'resolved'
|
|
254
|
+
}
|
|
255
|
+
resolvedSignatures.add(s)
|
|
256
|
+
const dom = (extra && extra.domain) || 'code'
|
|
257
|
+
let res
|
|
258
|
+
try {
|
|
259
|
+
res = await workflow('new2-resolve', {
|
|
260
|
+
kind, cardId: card, evidence,
|
|
261
|
+
findings: (extra && extra.findings) || [{ kind, evidence, domain: dom }],
|
|
262
|
+
worktreePath: sharedCtx.worktreePath,
|
|
263
|
+
mayEditPaths: domainMayEdit(dom, (extra && extra.mayEditPaths) || []),
|
|
264
|
+
scopeFiles: (extra && extra.scopeFiles) || [],
|
|
265
|
+
domain: dom,
|
|
266
|
+
refModulesBase: REF, config: cfg, ts: TS,
|
|
267
|
+
})
|
|
268
|
+
} catch (e) {
|
|
269
|
+
if (e && (e.transientExhausted || isTransient(e))) noteDegraded('outage')
|
|
270
|
+
res = { status: 'followup', reason: 'resolve workflow error: ' + String(e && e.message) }
|
|
271
|
+
}
|
|
170
272
|
const status = (res && res.status) || 'followup'
|
|
171
273
|
if (status === 'fatal') { batchFatal = true; ledger(card, 'resolve:' + kind, 'FATAL', (res && res.reason) || ''); return status }
|
|
172
|
-
if (status === 'followup'
|
|
173
|
-
|
|
274
|
+
if (status === 'followup') {
|
|
275
|
+
acceptedDeferrals.add(s) // F-028 — a deferred residual must not be re-routed by a later gate.
|
|
276
|
+
const fc = (res && res.followupCard) || null
|
|
277
|
+
residualFollowups.push({ card, kind, followupCard: fc || '(pending)', reason: (res && res.reason) || '' })
|
|
278
|
+
residuals.push({ card, kind, evidence, materialized: !!fc })
|
|
279
|
+
}
|
|
280
|
+
// F-022 — route out-of-scope findings the resolve surfaced.
|
|
281
|
+
for (const osf of (res && res.outOfScopeFindings) || []) {
|
|
282
|
+
residuals.push({ card, kind: 'out-of-scope', evidence: `${osf.file || ''}:${osf.line || ''} ${osf.evidence || ''}`, materialized: false })
|
|
174
283
|
}
|
|
175
284
|
ledger(card, 'resolve:' + kind, status, (res && (res.followupCard || res.reason)) || '')
|
|
176
285
|
return status
|
|
177
286
|
}
|
|
178
287
|
|
|
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
288
|
// ───────────────────────────────────────────────────────────────────────────
|
|
189
|
-
// Per-card pipeline
|
|
190
|
-
//
|
|
191
|
-
//
|
|
289
|
+
// Per-card pipeline. Returns a status the scheduler keys on:
|
|
290
|
+
// 'committed' | 'followup' | 'epic-skipped' | 'pending'(transient, re-queue)
|
|
291
|
+
// On any non-committed exit the worktree is restored clean (F-018 saga compensation).
|
|
192
292
|
// ───────────────────────────────────────────────────────────────────────────
|
|
293
|
+
async function rollbackCard(cardId, mayEdit) {
|
|
294
|
+
// F-018 — restore the card's files to HEAD so a failed card never poisons the next.
|
|
295
|
+
// Safe at file granularity because the DAG guarantees all deps are already committed
|
|
296
|
+
// (HEAD contains their work); this removes only THIS card's uncommitted changes.
|
|
297
|
+
try {
|
|
298
|
+
await agentSafe(
|
|
299
|
+
`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.`,
|
|
300
|
+
{ label: `rollback:${cardId}`, phase: 'Implement', agentType: 'general-purpose', model: 'haiku',
|
|
301
|
+
schema: { type: 'object', required: ['clean'], additionalProperties: true, properties: { clean: { type: 'boolean' }, note: { type: 'string' } } } }
|
|
302
|
+
)
|
|
303
|
+
} catch (_) { /* best-effort; OUTAGE path already flagged by caller */ }
|
|
304
|
+
}
|
|
305
|
+
|
|
193
306
|
async function runCard(cardId, cardPath, lessons) {
|
|
194
307
|
const gates = []
|
|
195
308
|
const tele = {}
|
|
309
|
+
const node = graphById[cardId] || {}
|
|
310
|
+
const ownerAgent = node.ownerAgent || 'coder'
|
|
311
|
+
const reviewProfile = node.reviewProfile || 'balanced'
|
|
196
312
|
function g(name, decision, detail) { gates.push({ gate: name, decision, detail: detail || '' }); ledger(cardId, name, decision, detail) }
|
|
197
313
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
)
|
|
314
|
+
// F-026 — skip-completed: only if committed AND gates green for that sha AND no open follow-up.
|
|
315
|
+
// Keyed on the receipt, NOT the (unreliable) DONE flag.
|
|
316
|
+
try {
|
|
317
|
+
const probe = await agentSafe(
|
|
318
|
+
`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.`,
|
|
319
|
+
{ label: `probe:${cardId}`, phase: 'Implement', agentType: 'general-purpose', model: 'haiku',
|
|
320
|
+
schema: { type: 'object', required: ['done'], additionalProperties: true, properties: { done: { type: 'boolean' }, commit: { type: 'string' }, note: { type: 'string' } } } }
|
|
321
|
+
)
|
|
322
|
+
if (probe && probe.done) {
|
|
323
|
+
g('skip-completed', 'CACHED', probe.commit || 'already committed + green')
|
|
324
|
+
return { card: cardId, status: 'committed', commit: probe.commit || '-', filesChanged: [], scopeFiles: [], archBaselinePath: `/tmp/arch-baseline-${cardId}.md`, gates, telemetry: tele }
|
|
325
|
+
}
|
|
326
|
+
} catch (e) { if (e && e.transientExhausted) { noteDegraded('outage'); return { card: cardId, status: 'pending', gates, telemetry: tele } } }
|
|
327
|
+
|
|
328
|
+
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.`
|
|
329
|
+
|
|
330
|
+
// --- Phase 1+2: dispatch the card's OWNER_AGENT (F-024), not general-purpose. ---
|
|
331
|
+
let impl
|
|
332
|
+
try {
|
|
333
|
+
impl = await agentSafe(
|
|
334
|
+
`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` +
|
|
335
|
+
`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` +
|
|
336
|
+
`Return: { epic, buildBlocked, blockedGate, unmetACs:[{n,text}], scopeFiles, mayEditPaths, fileDiffViolation, note }`,
|
|
337
|
+
{ label: `implement:${cardId}`, phase: 'Implement', agentType: ownerAgent,
|
|
338
|
+
schema: { type: 'object', required: ['epic', 'buildBlocked', 'unmetACs', 'scopeFiles'], additionalProperties: true,
|
|
339
|
+
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' } } } }
|
|
340
|
+
)
|
|
341
|
+
} catch (e) {
|
|
342
|
+
if (e && e.transientExhausted) { noteDegraded('outage'); return { card: cardId, status: 'pending', gates, telemetry: tele } }
|
|
343
|
+
throw e
|
|
344
|
+
}
|
|
211
345
|
|
|
212
|
-
if (impl && impl.epic) { g('router', 'EPIC-SKIPPED', 'epic card
|
|
346
|
+
if (impl && impl.epic) { g('router', 'EPIC-SKIPPED', 'epic card'); return { card: cardId, status: 'epic-skipped', gates, commit: '-', telemetry: tele } }
|
|
213
347
|
|
|
214
348
|
const mayEdit = (impl && impl.mayEditPaths) || []
|
|
215
349
|
const scopeFiles = (impl && impl.scopeFiles) || []
|
|
216
|
-
if (impl && impl.fileDiffViolation) g('E4-file-diff', 'AUTO-REVERTED', 'coder touched files outside ownership
|
|
350
|
+
if (impl && impl.fileDiffViolation) g('E4-file-diff', 'AUTO-REVERTED', 'coder touched files outside ownership')
|
|
217
351
|
|
|
218
|
-
// G26 — build gates not converging → self-heal.
|
|
219
352
|
if (impl && impl.buildBlocked) {
|
|
220
353
|
const s = await resolve('blocker', cardId, `Phase-2 gate failing: ${impl.blockedGate}`, { mayEditPaths: mayEdit, scopeFiles, domain: 'code' })
|
|
221
354
|
g('G26-build', s === 'resolved' ? 'RESOLVED' : 'FOLLOWUP', impl.blockedGate)
|
|
222
|
-
if (s !== 'resolved') return { card: cardId, status: 'followup', gates, commit: '-', scopeFiles, telemetry: tele }
|
|
355
|
+
if (s !== 'resolved') { await rollbackCard(cardId, mayEdit); return { card: cardId, status: 'followup', gates, commit: '-', scopeFiles, telemetry: tele } }
|
|
223
356
|
}
|
|
224
357
|
|
|
225
|
-
//
|
|
358
|
+
// F-010/F-016 — unmet ACs that are policy-deferred are skipped (already tracked).
|
|
226
359
|
for (const ac of (impl && impl.unmetACs) || []) {
|
|
360
|
+
if (acceptedDeferrals.has(sig(cardId, 'ac-unmet', `AC-${ac.n}: ${ac.text}`))) { g('G7-ac-closure', 'DEFERRED-BY-POLICY', `AC-${ac.n}`); continue }
|
|
227
361
|
const s = await resolve('ac-unmet', cardId, `AC-${ac.n}: ${ac.text}`, { mayEditPaths: mayEdit, scopeFiles, domain: 'code' })
|
|
228
362
|
g('G7-ac-closure', s === 'resolved' ? 'RESOLVED' : 'FOLLOWUP', `AC-${ac.n}`)
|
|
229
363
|
}
|
|
230
364
|
|
|
231
|
-
// ---
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
if (
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
365
|
+
// --- Review fan-out (F-024/F-025): specialized agents, trimmed by review_profile. ---
|
|
366
|
+
const reviewers = reviewProfile === 'skip' ? []
|
|
367
|
+
: reviewProfile === 'light' ? ['code-reviewer']
|
|
368
|
+
: ['code-reviewer', 'doc-reviewer', 'qa-sentinel', 'api-perf-cost-auditor'].concat(
|
|
369
|
+
(highRisk.length || /auth|security|secret|migration|rls/i.test(scopeFiles.join(' '))) ? ['security-reviewer'] : [])
|
|
370
|
+
let reviewResults = []
|
|
371
|
+
try {
|
|
372
|
+
reviewResults = (await parallel(reviewers.map((ra) => () => agentSafe(
|
|
373
|
+
`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` +
|
|
374
|
+
`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` +
|
|
375
|
+
`Return: { blocks:[...], scopeExpansion:[...], note }`,
|
|
376
|
+
{ label: `review:${cardId}:${ra}`, phase: 'Implement', agentType: ra,
|
|
377
|
+
schema: { type: 'object', required: ['blocks', 'scopeExpansion'], additionalProperties: true,
|
|
378
|
+
properties: { blocks: { type: 'array', items: { type: 'object', additionalProperties: true } }, scopeExpansion: { type: 'array', items: { type: 'object', additionalProperties: true } }, note: { type: 'string' } } } }
|
|
379
|
+
).catch((e) => { if (e && e.transientExhausted) noteDegraded('outage'); return null }))) ).filter(Boolean)
|
|
380
|
+
} catch (_) { /* parallel never rejects; nulls filtered */ }
|
|
381
|
+
|
|
382
|
+
// F-014 — only route well-formed blocks (non-empty gate+evidence).
|
|
383
|
+
const blocks = reviewResults.flatMap((r) => (r.blocks || [])).filter((b) => b && b.gate && b.evidence)
|
|
384
|
+
const scopeExp = reviewResults.flatMap((r) => (r.scopeExpansion || []))
|
|
251
385
|
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
|
|
386
|
+
for (const b of blocks) {
|
|
387
|
+
const kind = /e2e/i.test(b.gate) ? 'e2e-blocked' : /qa/i.test(b.gate) ? 'qa-fail' : 'blocker'
|
|
388
|
+
const s = await resolve(kind, cardId, `${b.gate}: ${b.evidence}`, { mayEditPaths: mayEdit, scopeFiles, domain: b.domain || 'code' })
|
|
389
|
+
g(b.gate, s === 'resolved' ? 'RESOLVED' : 'FOLLOWUP', b.evidence)
|
|
256
390
|
if (s !== 'resolved') cardBlocked = true
|
|
257
391
|
}
|
|
258
|
-
|
|
259
|
-
// Scope-expansion findings (Asse 2) — deterministic boundary inside new2-resolve.
|
|
260
|
-
for (const sx of (review && review.scopeExpansion) || []) {
|
|
392
|
+
for (const sx of scopeExp) {
|
|
261
393
|
const s = await resolve('scope-expansion', cardId, sx.evidence || '', { mayEditPaths: mayEdit, scopeFiles, domain: sx.domain || 'code' })
|
|
262
394
|
g('scope-expansion', s === 'resolved' ? 'INTEGRATED' : 'FOLLOWUP', sx.evidence || '')
|
|
263
395
|
}
|
|
264
396
|
|
|
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
|
-
|
|
397
|
+
if (cardBlocked) { await rollbackCard(cardId, mayEdit); return { card: cardId, status: 'followup', gates, commit: '-', scopeFiles, archBaselinePath: `/tmp/arch-baseline-${cardId}.md`, telemetry: tele } }
|
|
398
|
+
|
|
399
|
+
// --- Phase 4 — commit (F-023: Haiku + git-status reconcile, never git add -A). ---
|
|
400
|
+
let commitRes
|
|
401
|
+
try {
|
|
402
|
+
commitRes = await agentSafe(
|
|
403
|
+
`Commit card ${cardId} in worktree ${sharedCtx.worktreePath}. MECHANICAL — do NOT re-read reference modules.\n` +
|
|
404
|
+
`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` +
|
|
405
|
+
`On COMMIT_LOCK: clear stale lock + retry once. Still locked → committed:false.\n\n` +
|
|
406
|
+
`Return: { committed, commit, filesChanged, reconcileNote }`,
|
|
407
|
+
{ label: `commit:${cardId}`, phase: 'Implement', agentType: 'general-purpose', model: 'haiku',
|
|
408
|
+
schema: { type: 'object', required: ['committed'], additionalProperties: true, properties: { committed: { type: 'boolean' }, commit: { type: 'string' }, filesChanged: { type: 'array', items: { type: 'string' } }, reconcileNote: { type: 'string' } } } }
|
|
409
|
+
)
|
|
410
|
+
} catch (e) {
|
|
411
|
+
if (e && e.transientExhausted) { noteDegraded('outage'); await rollbackCard(cardId, mayEdit); return { card: cardId, status: 'pending', gates, telemetry: tele } }
|
|
412
|
+
throw e
|
|
413
|
+
}
|
|
275
414
|
|
|
276
415
|
if (!commitRes || !commitRes.committed) {
|
|
277
416
|
const s = await resolve('blocker', cardId, 'commit blocked after retries', { mayEditPaths: mayEdit, scopeFiles, domain: 'code' })
|
|
278
417
|
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 }
|
|
418
|
+
if (s !== 'resolved') { await rollbackCard(cardId, mayEdit); return { card: cardId, status: 'followup', gates, commit: '-', scopeFiles, archBaselinePath: `/tmp/arch-baseline-${cardId}.md`, telemetry: tele } }
|
|
280
419
|
}
|
|
420
|
+
if (commitRes && commitRes.reconcileNote) g('commit-reconcile', 'NOTE', commitRes.reconcileNote)
|
|
281
421
|
|
|
282
422
|
g('commit', 'COMMITTED', (commitRes && commitRes.commit) || '')
|
|
283
423
|
return {
|
|
284
424
|
card: cardId, status: 'committed',
|
|
285
425
|
commit: (commitRes && commitRes.commit) || '-',
|
|
286
426
|
filesChanged: (commitRes && commitRes.filesChanged) || [],
|
|
287
|
-
scopeFiles, archBaselinePath: `/tmp/arch-baseline-${cardId}.md`,
|
|
288
|
-
gates, telemetry: tele,
|
|
427
|
+
scopeFiles, archBaselinePath: `/tmp/arch-baseline-${cardId}.md`, gates, telemetry: tele,
|
|
289
428
|
}
|
|
290
429
|
}
|
|
291
430
|
|
|
292
431
|
// ───────────────────────────────────────────────────────────────────────────
|
|
293
|
-
// Phase Implement —
|
|
432
|
+
// Phase Implement — DAG scheduler (F-021/F-017/F-018). Single-worktree semantics:
|
|
433
|
+
// a card runs only when ALL its in-batch deps are 'committed'; a failed/deferred
|
|
434
|
+
// card blocks its transitive dependents (no doomed resolve); transient failure →
|
|
435
|
+
// re-queue with a cap; sustained outage → stop cleanly + degraded return.
|
|
294
436
|
// ───────────────────────────────────────────────────────────────────────────
|
|
295
437
|
phase('Implement')
|
|
296
438
|
const lessons = []
|
|
297
|
-
const
|
|
298
|
-
|
|
439
|
+
const state = {} // cardId → 'pending'|'committed'|'followup'|'epic-skipped'|'blocked'|'failed'
|
|
440
|
+
const attempts = {} // cardId → retry count (transient)
|
|
441
|
+
const RETRY_CAP = 2
|
|
442
|
+
const MAX_CONSECUTIVE_OUTAGE = 3
|
|
443
|
+
for (const id of runnableCards) { state[id] = 'pending'; attempts[id] = 0 }
|
|
444
|
+
|
|
445
|
+
function depsSatisfied(id) {
|
|
446
|
+
const deps = ((graphById[id] || {}).dependsOn || []).filter((d) => runnableCards.includes(d))
|
|
447
|
+
return deps.every((d) => state[d] === 'committed' || state[d] === 'epic-skipped')
|
|
448
|
+
}
|
|
449
|
+
function depFailed(id) {
|
|
450
|
+
const deps = ((graphById[id] || {}).dependsOn || []).filter((d) => runnableCards.includes(d))
|
|
451
|
+
return deps.some((d) => state[d] === 'followup' || state[d] === 'blocked' || state[d] === 'failed')
|
|
452
|
+
}
|
|
299
453
|
|
|
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.
|
|
454
|
+
let consecutiveOutage = 0
|
|
455
|
+
let guard = runnableCards.length * (RETRY_CAP + 2) + 5
|
|
456
|
+
while (guard-- > 0) {
|
|
457
|
+
// Mark cards whose deps failed as blocked (F-017 — never route them to resolve).
|
|
313
458
|
for (const id of runnableCards) {
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
459
|
+
if (state[id] === 'pending' && depFailed(id)) {
|
|
460
|
+
state[id] = 'blocked'
|
|
461
|
+
residuals.push({ card: id, kind: 'blocked-by-dep', evidence: `blocked: a dependency failed/deferred`, materialized: false })
|
|
462
|
+
ledger(id, 'dep-barrier', 'BLOCKED', 'dependency failed/deferred')
|
|
463
|
+
}
|
|
317
464
|
}
|
|
465
|
+
const next = runnableCards.find((id) => state[id] === 'pending' && depsSatisfied(id))
|
|
466
|
+
if (!next) break // nothing runnable (all done/blocked, or a cycle/stall)
|
|
467
|
+
|
|
468
|
+
const r = await runCard(next, pathById[next], lessons).catch((e) => crashResult(next, e))
|
|
469
|
+
if (r.status === 'pending') {
|
|
470
|
+
attempts[next]++
|
|
471
|
+
consecutiveOutage++
|
|
472
|
+
if (attempts[next] > RETRY_CAP || consecutiveOutage >= MAX_CONSECUTIVE_OUTAGE) {
|
|
473
|
+
noteDegraded('outage')
|
|
474
|
+
ledger(next, 'outage', 'PAUSED', `transient failures (attempt ${attempts[next]}) — batch paused for durable resume`)
|
|
475
|
+
break // OUTAGE — stop cleanly; the skill resumes (skip-completed makes it idempotent).
|
|
476
|
+
}
|
|
477
|
+
ledger(next, 'retry', 'REQUEUE', `transient — attempt ${attempts[next]}/${RETRY_CAP}`)
|
|
478
|
+
continue // re-queue (state stays 'pending')
|
|
479
|
+
}
|
|
480
|
+
consecutiveOutage = 0
|
|
481
|
+
state[next] = r.status
|
|
482
|
+
perCardResults.push(r)
|
|
483
|
+
if (r.note) lessons.push(`${next}: ${r.note}`)
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Any still-pending card after the loop (outage) is recorded as a residual.
|
|
487
|
+
for (const id of runnableCards) {
|
|
488
|
+
if (state[id] === 'pending') residuals.push({ card: id, kind: 'not-reached', evidence: 'batch paused before this card ran', materialized: false })
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function crashResult(id, e) {
|
|
492
|
+
if (e && (e.transientExhausted || isTransient(e))) { return { card: id, status: 'pending', gates: [], telemetry: {} } }
|
|
493
|
+
residuals.push({ card: id, kind: 'agent-crash', evidence: String(e && e.message), materialized: false })
|
|
494
|
+
ledger(id, 'runCard', 'ERROR', String(e && e.message))
|
|
495
|
+
return { card: id, status: 'failed', gates: [{ gate: 'runCard', decision: 'ERROR', detail: String(e && e.message) }], commit: '-', telemetry: {} }
|
|
318
496
|
}
|
|
319
497
|
|
|
320
498
|
const committed = perCardResults.filter((r) => r.status === 'committed')
|
|
321
499
|
|
|
322
500
|
// ───────────────────────────────────────────────────────────────────────────
|
|
323
|
-
// Phase Final — cross-batch final review (
|
|
324
|
-
//
|
|
325
|
-
// drops only FALSE_POSITIVE); G18 unresolved BLOCKER/HIGH → new2-resolve, else
|
|
326
|
-
// block the merge.
|
|
501
|
+
// Phase Final — cross-batch final review (new-final-review). Findings batched per
|
|
502
|
+
// area (F-007/F-035/F-037); merge-artifact doc findings skipped (resolved-by-merge).
|
|
327
503
|
// ───────────────────────────────────────────────────────────────────────────
|
|
328
504
|
phase('Final')
|
|
329
505
|
let finalSummary = null
|
|
330
|
-
let mergeBlocked = batchFatal
|
|
331
|
-
if (committed.length && !batchFatal) {
|
|
506
|
+
let mergeBlocked = batchFatal || degraded
|
|
507
|
+
if (committed.length && !batchFatal && !degraded) {
|
|
332
508
|
const reviewScopeFiles = dedupe(committed.flatMap((r) => r.scopeFiles || []))
|
|
333
509
|
const archPaths = committed.map((r) => r.archBaselinePath).filter(Boolean)
|
|
334
510
|
const allArch = archPaths.length === committed.length ? archPaths : null
|
|
335
511
|
const hasApiDataFiles = reviewScopeFiles.some((f) => /api\/|data-model|\.sql$|migrations?\//i.test(f))
|
|
336
512
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
513
|
+
let final = null
|
|
514
|
+
try {
|
|
515
|
+
final = await workflow('new-final-review', {
|
|
516
|
+
firstCardId: firstCard, worktreePath: sharedCtx.worktreePath, baseBranch: TRUNK,
|
|
517
|
+
cardPaths: committed.map((r) => pathById[r.card]).filter(Boolean),
|
|
518
|
+
reviewScopeFiles, archBaselinePaths: allArch, hasApiDataFiles, config: cfg,
|
|
519
|
+
// F-041 — single-card batch: doc-reviewer + api-perf already ran per-card and there is
|
|
520
|
+
// NO cross-card conflict to find. Keep only the cross-model Codex pass + qa gates.
|
|
521
|
+
singleCard: committed.length === 1,
|
|
522
|
+
})
|
|
523
|
+
} catch (e) { if (e && isTransient(e)) noteDegraded('outage'); final = null }
|
|
347
524
|
|
|
348
525
|
if (final) {
|
|
349
526
|
finalSummary = final.summary || null
|
|
350
527
|
ledger(firstCard, 'final-review', 'DONE', `engine=${final.codexEngine}; verified=${final.summary ? final.summary.verified : '?'}`)
|
|
351
|
-
|
|
352
|
-
//
|
|
353
|
-
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
|
|
528
|
+
const all = (final.findings || []).filter((f) => (f.classification === 'VERIFIED' || f.classification === 'NEEDS_MANUAL_CONFIRMATION') && (f.severity === 'BLOCKER' || f.severity === 'HIGH'))
|
|
529
|
+
// F-035 — drop merge-artifact doc findings (content exists in worktree, the merge carries it).
|
|
530
|
+
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}`))
|
|
531
|
+
for (const f of all) if (!actionable.includes(f)) ledger(f.finding_id || firstCard, 'final-merge-artifact', 'SKIP', 'resolved-by-merge')
|
|
532
|
+
// F-007/F-037 — group actionable findings by fix-area (file) → one resolve per area.
|
|
533
|
+
const byArea = {}
|
|
534
|
+
for (const f of actionable) {
|
|
535
|
+
const area = (Array.isArray(f.files) && f.files[0]) || (f.file) || (f.domain || 'misc')
|
|
536
|
+
;(byArea[area] = byArea[area] || []).push(f)
|
|
537
|
+
}
|
|
538
|
+
for (const area of Object.keys(byArea)) {
|
|
539
|
+
const group = byArea[area]
|
|
540
|
+
const s = await resolve('merge-blocker', group[0].finding_id || firstCard,
|
|
541
|
+
group.map((f) => `${f.severity} ${f.title}: ${f.evidence}`).join(' || '),
|
|
542
|
+
{ mayEditPaths: reviewScopeFiles, scopeFiles: reviewScopeFiles, domain: group[0].domain || 'code',
|
|
543
|
+
findings: group.map((f) => ({ kind: 'merge-blocker', evidence: `${f.title}: ${f.evidence}`, domain: f.domain || 'code' })) })
|
|
357
544
|
if (s !== 'resolved') mergeBlocked = true
|
|
358
545
|
}
|
|
359
|
-
if (
|
|
360
|
-
const s = await resolve('qa-fail', firstCard, `final gates failing: ${
|
|
546
|
+
if (finalSummary && finalSummary.failingGates && finalSummary.failingGates.length) {
|
|
547
|
+
const s = await resolve('qa-fail', firstCard, `final gates failing: ${finalSummary.failingGates.join(', ')}`, { mayEditPaths: reviewScopeFiles, scopeFiles: reviewScopeFiles, domain: 'code' })
|
|
361
548
|
if (s !== 'resolved') mergeBlocked = true
|
|
362
549
|
}
|
|
363
550
|
} else {
|
|
364
|
-
ledger(firstCard, 'final-review', 'SKIPPED', 'workflow returned null')
|
|
551
|
+
ledger(firstCard, 'final-review', 'SKIPPED', degraded ? 'degraded' : 'workflow returned null')
|
|
552
|
+
mergeBlocked = true
|
|
365
553
|
}
|
|
366
554
|
} else {
|
|
367
|
-
ledger(firstCard, 'final-review', 'SKIPPED', batchFatal ? 'batch fatal
|
|
555
|
+
ledger(firstCard, 'final-review', 'SKIPPED', batchFatal ? 'batch fatal' : degraded ? 'degraded (outage)' : 'no committed cards')
|
|
368
556
|
}
|
|
369
557
|
|
|
370
558
|
// ───────────────────────────────────────────────────────────────────────────
|
|
371
|
-
// Phase Merge —
|
|
372
|
-
//
|
|
559
|
+
// Phase Merge — INTEGRITY-GATED (F-029/F-030/F-031). Merge ONLY if the batch is
|
|
560
|
+
// verifiably complete: every runnable card committed OR deferred with a real
|
|
561
|
+
// follow-up; no open BLOCKER; no degraded; no uncommitted code. NEVER force-DONE,
|
|
562
|
+
// NEVER git-add unreviewed code.
|
|
373
563
|
// ───────────────────────────────────────────────────────────────────────────
|
|
374
564
|
phase('Merge')
|
|
375
565
|
let mergeResult = null
|
|
566
|
+
const incomplete = runnableCards.filter((id) => state[id] !== 'committed' && state[id] !== 'epic-skipped')
|
|
567
|
+
const integrityOK = committed.length > 0 && !mergeBlocked && !batchFatal && !degraded && incomplete.length === 0
|
|
376
568
|
if (!committed.length) {
|
|
377
569
|
ledger(firstCard, 'merge', 'SKIPPED', 'no committed cards')
|
|
378
|
-
} else if (
|
|
379
|
-
|
|
570
|
+
} else if (!integrityOK) {
|
|
571
|
+
const why = degraded ? 'degraded (outage)' : mergeBlocked ? 'unresolved final BLOCKER/HIGH' : incomplete.length ? `incomplete cards: ${incomplete.join(' ')}` : 'integrity gate'
|
|
572
|
+
ledger(firstCard, 'merge', 'BLOCKED', `${why} — worktree left intact, NOT merged`)
|
|
380
573
|
} else {
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
`
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
574
|
+
try {
|
|
575
|
+
mergeResult = await agentSafe(
|
|
576
|
+
`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` +
|
|
577
|
+
`DETERMINISTIC POLICIES (NO prompts):\n` +
|
|
578
|
+
`• G24 → auto-merge via merge_strategy.\n` +
|
|
579
|
+
`• 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` +
|
|
580
|
+
`• 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` +
|
|
581
|
+
`• 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` +
|
|
582
|
+
`Return: { merged, mergeCommit, mergeTs, reconciliation, forcedDone:[], uncommittedLeft, note }`,
|
|
583
|
+
{ label: 'merge', phase: 'Merge', agentType: 'general-purpose', schema: MERGE_SCHEMA }
|
|
584
|
+
)
|
|
585
|
+
} catch (e) { if (e && e.transientExhausted) noteDegraded('outage'); mergeResult = null }
|
|
586
|
+
if (mergeResult && (mergeResult.forcedDone || []).length) { noteDegraded('false_done'); ledger(firstCard, 'F029-guard', 'VIOLATION', `forcedDone: ${mergeResult.forcedDone.join(' ')}`) }
|
|
587
|
+
if (mergeResult && mergeResult.uncommittedLeft) ledger(firstCard, 'F030-guard', 'LEFT-UNCOMMITTED', 'dirty code left (not swept) + reported')
|
|
387
588
|
ledger(firstCard, 'G24-merge', (mergeResult && mergeResult.merged) ? 'MERGED' : 'INCOMPLETE', (mergeResult && (mergeResult.mergeCommit || mergeResult.note)) || '')
|
|
388
589
|
if (mergeResult && mergeResult.reconciliation) ledger(firstCard, 'G19-23-reconcile', 'AUTO', mergeResult.reconciliation)
|
|
389
590
|
}
|
|
390
591
|
|
|
391
592
|
// ───────────────────────────────────────────────────────────────────────────
|
|
392
|
-
// Phase Production (Phase 7) —
|
|
393
|
-
// parity with /new). Auto-executes stack-matched deploys; reports the manual items.
|
|
593
|
+
// Phase Production (Phase 7) — non-blocking; report-not-execute (F-011 infra-deferred).
|
|
394
594
|
// ───────────────────────────────────────────────────────────────────────────
|
|
395
595
|
phase('Production')
|
|
396
596
|
if (mergeResult && mergeResult.merged) {
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
597
|
+
try {
|
|
598
|
+
prodReadiness = await agentSafe(
|
|
599
|
+
`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 }`,
|
|
600
|
+
{ label: 'production-readiness', phase: 'Production', agentType: 'general-purpose',
|
|
601
|
+
schema: { type: 'object', required: ['manualItems'], additionalProperties: true, properties: { autoExecuted: { type: 'array', items: { type: 'string' } }, manualItems: { type: 'array', items: { type: 'string' } }, note: { type: 'string' } } } }
|
|
602
|
+
)
|
|
603
|
+
ledger(firstCard, 'phase7-production', 'DONE', `auto=${((prodReadiness && prodReadiness.autoExecuted) || []).length} manual=${((prodReadiness && prodReadiness.manualItems) || []).length}`)
|
|
604
|
+
} catch (_) { ledger(firstCard, 'phase7-production', 'SKIPPED', 'agent failed (non-blocking)') }
|
|
403
605
|
} else {
|
|
404
606
|
ledger(firstCard, 'phase7-production', 'SKIPPED', 'not merged')
|
|
405
607
|
}
|
|
406
608
|
|
|
407
|
-
// ───────────────────────────────────────────────────────────────────────────
|
|
408
|
-
// Return — assemble the report + Phase-8 telemetry (variant new2).
|
|
409
|
-
// ───────────────────────────────────────────────────────────────────────────
|
|
410
609
|
return finalReturn({ fatal: false })
|
|
411
610
|
|
|
412
611
|
// ───────────────────────────────────────────────────────────────────────────
|
|
@@ -414,25 +613,36 @@ function finalReturn(opts) {
|
|
|
414
613
|
const fatal = opts && opts.fatal
|
|
415
614
|
const telemetry = buildTelemetry()
|
|
416
615
|
const report = buildReport({ fatal, reason: opts && opts.reason, finalSummary, mergeResult })
|
|
417
|
-
|
|
616
|
+
// residuals (materialized:false) is the offline-safe ledger; the skill writes the YAML.
|
|
617
|
+
return { report, perCardResults, gateLedger, residualFollowups, residuals, degraded, degradationReasons, telemetry }
|
|
418
618
|
}
|
|
419
619
|
|
|
420
620
|
function buildTelemetry() {
|
|
421
|
-
const
|
|
422
|
-
const
|
|
423
|
-
const fixCycles = gateLedger.filter((x) => /resolve:/.test(x.gate)).length
|
|
621
|
+
const tokensNow = (typeof budget !== 'undefined' && budget && typeof budget.spent === 'function') ? budget.spent() : null
|
|
622
|
+
const totalTokens = (tokensAtStart != null && tokensNow != null) ? (tokensNow - tokensAtStart) : null
|
|
424
623
|
return {
|
|
425
624
|
variant: 'new2',
|
|
426
625
|
batch: cardIds,
|
|
427
626
|
first_card: firstCard,
|
|
428
|
-
|
|
429
|
-
|
|
627
|
+
ts: TS || null,
|
|
628
|
+
cards_total: cardIds.length,
|
|
629
|
+
cards_real_done: perCardResults.filter((r) => r.status === 'committed').length,
|
|
630
|
+
cards_force_done: 0, // F-029 — force-DONE forbidden; always 0.
|
|
430
631
|
cards_followup: perCardResults.filter((r) => r.status === 'followup').length,
|
|
431
|
-
|
|
432
|
-
|
|
632
|
+
cards_blocked: runnableCards.filter((id) => state[id] === 'blocked').length,
|
|
633
|
+
cards_deferred: residuals.filter((x) => x.kind === 'policy-deferred-ac').length,
|
|
634
|
+
residuals_total: residuals.length,
|
|
635
|
+
// followups_on_disk is filled by the SKILL after it materialises pending residuals.
|
|
636
|
+
followups_materialized_in_workflow: residuals.filter((x) => x.materialized).length,
|
|
637
|
+
resolve_invocations: resolvedSignatures.size,
|
|
433
638
|
final_review: finalSummary || null,
|
|
434
639
|
merged: !!(mergeResult && mergeResult.merged),
|
|
640
|
+
degraded,
|
|
641
|
+
degradation_reasons: degradationReasons,
|
|
435
642
|
execution_mode: preflight ? preflight.executionMode : 'sequential',
|
|
643
|
+
// cost — total_tokens via budget.spent() delta; agent_count via counter; wall_clock_s stamped by the SKILL.
|
|
644
|
+
total_tokens: totalTokens,
|
|
645
|
+
agent_count: agentCount,
|
|
436
646
|
per_card: perCardResults.map((r) => ({ card: r.card, status: r.status, telemetry: r.telemetry || {} })),
|
|
437
647
|
stats_requested: !!FLAGS.stats,
|
|
438
648
|
}
|
|
@@ -441,40 +651,37 @@ function buildTelemetry() {
|
|
|
441
651
|
function buildReport(o) {
|
|
442
652
|
const L = []
|
|
443
653
|
L.push(`# new2 batch — ${cardIds.join(' ')}`)
|
|
444
|
-
L.push(`Variant: **new2**
|
|
654
|
+
L.push(`Variant: **new2** · Mode: ${preflight ? preflight.executionMode : '?'} · Trunk: ${TRUNK}${degraded ? ' · ⚠️ DEGRADED (' + degradationReasons.join(',') + ')' : ''}`)
|
|
445
655
|
if (o.fatal) { L.push(``, `## ⛔ BATCH FATAL`, o.reason || 'workspace unworkable'); return L.join('\n') }
|
|
446
656
|
L.push(``, `## Esito card`)
|
|
447
657
|
L.push(`| Card | Status | Commit | File |`)
|
|
448
658
|
L.push(`|------|--------|--------|------|`)
|
|
449
659
|
for (const r of perCardResults) L.push(`| ${r.card} | ${r.status} | ${r.commit || '-'} | ${(r.filesChanged || []).length} |`)
|
|
660
|
+
const blockedIds = runnableCards.filter((id) => state[id] === 'blocked' || state[id] === 'pending')
|
|
661
|
+
for (const id of blockedIds) L.push(`| ${id} | ${state[id]} | - | 0 |`)
|
|
450
662
|
if (finalSummary) {
|
|
451
663
|
L.push(``, `## Final review`)
|
|
452
|
-
L.push(`Verified: ${finalSummary.verified} (BLOCKER ${finalSummary.blockers} / HIGH ${finalSummary.highs}) ·
|
|
664
|
+
L.push(`Verified: ${finalSummary.verified} (BLOCKER ${finalSummary.blockers} / HIGH ${finalSummary.highs}) · failing gates: ${(finalSummary.failingGates || []).join(', ') || 'none'}`)
|
|
453
665
|
}
|
|
454
666
|
L.push(``, `## Merge`)
|
|
455
667
|
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).`)
|
|
668
|
+
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
669
|
if (prodReadiness) {
|
|
459
670
|
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}`)
|
|
671
|
+
L.push(``, `## Production readiness (Phase 7)`, `Auto: ${(prodReadiness.autoExecuted || []).length} · Manuali: ${man.length}`)
|
|
462
672
|
for (const m of man) L.push(`- [ ] ${m}`)
|
|
463
673
|
}
|
|
464
|
-
if (
|
|
465
|
-
L.push(``, `## ⚠️
|
|
466
|
-
for (const f of
|
|
674
|
+
if (residuals.length) {
|
|
675
|
+
L.push(``, `## ⚠️ Residui (il skill materializza le follow-up mancanti — nulla perso)`)
|
|
676
|
+
for (const f of residuals) L.push(`- ${f.card} (${f.kind})${f.materialized ? ' ✓' : ' — DA MATERIALIZZARE'}: ${f.evidence}`)
|
|
467
677
|
}
|
|
468
678
|
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._`)
|
|
679
|
+
if (excluded.length) { L.push(``, `## Card escluse in pre-flight`); for (const e of excluded) L.push(`- ${e.card}: ${e.detail}`) }
|
|
680
|
+
L.push(``, `_Gate ledger: ${gateLedger.length} decisioni deterministiche, 0 AskUserQuestion. Token: ${buildTelemetry().total_tokens ?? '?'} · agent: ${agentCount}._`)
|
|
474
681
|
return L.join('\n')
|
|
475
682
|
}
|
|
476
683
|
|
|
477
684
|
function dedupe(arr) { return Array.from(new Set(arr)) }
|
|
478
685
|
function emptyReturn(reason) {
|
|
479
|
-
return { report: `# new2 — no-op\n${reason}`, perCardResults: [], gateLedger: [], residualFollowups: [], telemetry: { variant: 'new2', cards_total: 0, reason } }
|
|
686
|
+
return { report: `# new2 — no-op\n${reason}`, perCardResults: [], gateLedger: [], residualFollowups: [], residuals: [], degraded: false, degradationReasons: [], telemetry: { variant: 'new2', cards_total: 0, reason } }
|
|
480
687
|
}
|