baldart 4.24.1 → 4.24.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +29 -0
- package/VERSION +1 -1
- package/framework/.claude/skills/new2/SKILL.md +25 -9
- package/framework/.claude/workflows/new2.js +146 -70
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,35 @@ All notable changes to BALDART will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [4.24.3] - 2026-06-10
|
|
9
|
+
|
|
10
|
+
**`new2`: two follow-ups from the v4.24.2 logic review — epic closure no longer misses deferred cards, and the owner-gated classification reaches the final review.** Both are "parallel location" gaps of earlier fixes (the v4.17.2 meta-lesson): v4.22.1's epic-closure and v4.24.1's owner-gated deferral each missed one site. **PATCH** (bug-fix to the EXPERIMENTAL `new2` surface only; no config key, no change to `/new`).
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **`new2/SKILL.md` (Step 5.2) — epic-closure re-check after deferred-DONE.** The merge agent's epic-closure (Phase 6b step 5e) runs BEFORE the skill marks deferred cards DONE post-run — so an epic whose last open child was a deferred card stayed TODO forever (nothing re-checked it). The skill now re-runs the all-children-DONE check for the parents of the cards it just marked DONE, closing the epic in the same reconciliation commit.
|
|
15
|
+
- **`new2.js` (Phase Final) — owner-gated deferrals no longer block the merge at final review.** The `merge-blocker`/`qa-fail` resolves checked only `.status`: a finding whose sole remedy is an external/infra step (e.g. the same pending remote `db:push` a reviewer re-raises batch-wide) set `mergeBlocked` and stranded a complete batch. The `deferralClass` check from the per-card loops (v4.24.1/v4.24.2) now applies here too: `owner-gated`/`not-a-code-defect` → follow-up tracked + `DEFERRED-OWNER-GATED` ledger row, merge proceeds; a genuine unresolved code defect still blocks.
|
|
16
|
+
|
|
17
|
+
## [4.24.2] - 2026-06-10
|
|
18
|
+
|
|
19
|
+
**`new2`: holistic logic review — deterministic owner_agent routing, a merge gate that no longer strands the batch, `deferralClass` end-to-end, and −N agent spawns per run.** A full logic review of `new2.js`/`new2-resolve.js`/`SKILL.md` against `/new`'s reference modules found four correctness defects and five sources of wasted spawns. The headline routing bug: the pre-flight's `ownerAgent` was passed RAW as `agentType` — the G25 "unknown→coder" rule lived only in the prompt, so any freeform value (`claude`, `backend`, a typo) was a PERMANENT spawn error → card `failed` → (combined with the merge-gate bug) the whole batch unmerged. **PATCH** (bug-fix/hardening of the EXPERIMENTAL `new2` surface only; **no `baldart.config.yml` key**, **no change to `/new`** — the schema-change propagation rule does not apply).
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- **`new2.js` (A1) — deterministic owner_agent clamp in JS, not prompt.** After the pre-flight, `cardGraph[].ownerAgent` is clamped through the exact `/new` router table (`implement.md` §6b): `coder`/`ui-expert` pass through; `plan`/`visual-designer`/`motion-expert`/unknown/missing degrade to `coder`, each with a `[ROUTER]` ledger row (audit trail). The RAW value is kept for the security-relevance heuristic. Fixes the "wrong agent for the card" failure class at the code level.
|
|
24
|
+
- **`new2.js` (A2) — the merge integrity gate matches its own comment.** `followup`/`blocked` cards (rolled back + tracked in the offline-safe residual ledger) no longer count as "incomplete": one failed card used to strand every committed card in an orphaned worktree with no resume path (the run wasn't `degraded`, so the skill never resumed). `failed` (crash) counts as complete ONLY when its cleanup verified the worktree clean.
|
|
25
|
+
- **`new2.js` + `new2/SKILL.md` (A3) — `deferralClass` end-to-end (v4.24.1 covered only the review-blocks loop).** The ac-unmet loop now records WHY each deferral happened; `residuals[]` carry `deferralClass` and committed cards carry `deferredClasses[]`. The skill marks a deferred card DONE post-run ONLY when every class is `owner-gated`/`not-a-code-defect`/`policy-deferred-ac`; an `unresolved` class (an AC the workflow tried and failed to implement) keeps the card IN_PROGRESS and is surfaced explicitly — a genuinely unmet DoD can no longer be auto-DONE'd with a follow-up as a fig leaf (F-029).
|
|
26
|
+
- **`new2.js` (A4) — crashed cards clean up after themselves.** `crashResult` now runs the rollback (whole-worktree scope — safe: all committed work lives in HEAD), so a crashed card's dirty files no longer poison the next card's E4 reconcile with a misleading `out-of-ownership` revert.
|
|
27
|
+
- **`new2.js` (A5) — `${paths.backlog_dir}` instead of a hardcoded `backlog/*.yml`** in the epic-closure merge prompt (consumer portability).
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
|
|
31
|
+
- **`new2.js` (B1) — per-card idempotency probe removed (−N Haiku spawns/run).** `cardGraph[].alreadyCommitted` is now probed once by the pre-flight (git-authoritative: commit in `trunk..HEAD` + validations green + no open follow-up); on a fresh worktree it costs nothing, and resume stays covered by the journal cache.
|
|
32
|
+
- **`new2.js` (B2) — `security-reviewer` fan-out only when the card's files intersect `high_risk_modules`** (the bare `highRisk.length` fired it on every card of every project that configures the list); the owner-agent and brief-token triggers are unchanged.
|
|
33
|
+
- **`new2.js` (B3) — review blocks and scope-expansion findings batched per kind+domain → ONE `resolve()` per group** via the existing `findings[]` contract (the same F-007 batching the final review already used). One-by-one routing was the dominant per-card cost driver (each resolve = fixer + judge + possible Tier-2 fan-out).
|
|
34
|
+
- **`new2.js` (B4) — `executionMode`/`groups` removed from the pre-flight contract**: the DAG scheduler is strictly sequential (single worktree) and nothing ever read them; telemetry now reports the honest constant. Real parallelism is a future release, after A/B data.
|
|
35
|
+
- **`new2.js` (B5/C) — dead code removed**: the never-populated `lessons` mechanism (every cardBrief promised batch lessons that were always `(none)`), the unreachable `resolve()` `'fatal'` branch (`new2-resolve` never returns it — v4.17.2 G1 rule), the always-empty per-card `telemetry` stub (per_card now reports `deferred`/`deferredClasses`/gate count), and the now-unused `batchFatal` flag.
|
|
36
|
+
|
|
8
37
|
## [4.24.1] - 2026-06-10
|
|
9
38
|
|
|
10
39
|
**`new2`: an owner-gated gate no longer destroys a completed card (silent work-loss → commit + defer).** A real `/new2` run on a schema-change card produced **zero output** despite 52 min of work: the card's only obstacle was an *owner-gated* step (`db:check-sync` needs an approved remote `db:push`). That step was correctly `policy-deferred` up front — but the **same** condition was *also* re-raised by a reviewer as a fresh `MIGRATION_NOT_DEPLOYED` blocker, which (via the review-block branch's `s !== 'resolved' → cardBlocked`) triggered `rollbackCard`'s `git clean -fd` and **erased the completed migration**. Compounding it: the `E4-file-diff` gate logged `AUTO-REVERTED` while reverting *nothing* (leaving the card's DoD-mandated ADR/ER-doc edits orphaned in the worktree — its MAY-EDIT map was narrower than the DoD), and the residual follow-up was written *inside the worktree* and marked `materialized:true` without disk proof, so it vanished when the batch didn't merge. Root cause confirmed via the gate ledger + on-disk state + two rounds of adversarial review (the obvious "E4 reverted it" diagnosis was **wrong** — E4 was a no-op; `rollbackCard` was the eraser). **PATCH** (bug-fix to the EXPERIMENTAL `new2` skill + its workflows; **no `baldart.config.yml` key** and **no change to shared `/new` prose** — the DONE-deferral is handled entirely inside `new2`'s own merge prompt + skill, so `/new` interactive is provably unaffected; the schema-change propagation rule does not apply).
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
4.24.
|
|
1
|
+
4.24.3
|
|
@@ -140,15 +140,31 @@ returns when the batch is done. It returns:
|
|
|
140
140
|
the offline path must match agent-path quality (F-039); it MUST pass the `/new` pre-flight field
|
|
141
141
|
check. If `prd-card-writer` is unavailable (total outage), fall back to a minimal valid stub. This
|
|
142
142
|
main-repo, **disk-verified** write is the SSOT — nothing is dropped even on a non-merged batch.
|
|
143
|
-
2. **Mark deferred cards DONE — only after their follow-up exists
|
|
144
|
-
were intentionally left **NON-DONE** because they carry
|
|
145
|
-
|
|
146
|
-
`cards_deferred_done_pending` in telemetry + the `F040-deferred`
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
143
|
+
2. **Mark deferred cards DONE — only after their follow-up exists AND every deferral class allows
|
|
144
|
+
it (F-040/H + A3).** Some committed cards were intentionally left **NON-DONE** because they carry
|
|
145
|
+
an open deferred AC: they are `perCardResults[]` entries with `deferred:true` plus a
|
|
146
|
+
`deferredClasses[]` array (also `cards_deferred_done_pending` in telemetry + the `F040-deferred`
|
|
147
|
+
ledger row). For each such card, **check the classes first**:
|
|
148
|
+
- **Every class ∈ {`owner-gated`, `not-a-code-defect`, `policy-deferred-ac`}** → the card's own
|
|
149
|
+
code is complete; the residual is an external/infra step. Now that step 1 guaranteed its
|
|
150
|
+
deferral's follow-up exists on disk in the main repo, set the card `status: DONE` +
|
|
151
|
+
`completed_date` + an implementation_note (`"DONE post-run (new2) — AC deferred to follow-up
|
|
152
|
+
<id>"`) in `${paths.backlog_dir}/<card>.yml`, and fold all of them into ONE reconciliation
|
|
153
|
+
commit in the MAIN repo.
|
|
154
|
+
- **ANY class ∈ {`unresolved`, `out-of-ownership`, `outage`} (or missing)** → the card's DoD is
|
|
155
|
+
genuinely NOT met (an AC the workflow tried and failed to implement). Leave it **IN_PROGRESS**
|
|
156
|
+
and surface it explicitly in the presentation: `"commit landed, DoD NON soddisfatta —
|
|
157
|
+
follow-up <id>"`. NEVER auto-DONE it — a follow-up tracks the gap, but DONE would lie (F-029).
|
|
158
|
+
**If a card's follow-up could NOT be created in step 1, leave it NON-DONE and surface it** —
|
|
159
|
+
fail-loud; NEVER mark a card DONE with a silently-dropped requirement (F-029).
|
|
160
|
+
**Then re-run the epic-closure check for the cards just marked DONE.** The merge agent's
|
|
161
|
+
epic-closure (Phase 6b step 5e) ran BEFORE this step, when the deferred cards were still
|
|
162
|
+
NON-DONE — so an epic whose last open child was a deferred card is still TODO and nothing
|
|
163
|
+
else will ever close it. For each distinct `group.parent` of the cards marked DONE here: if
|
|
164
|
+
`grep -l "parent: <EPIC-ID>" ${paths.backlog_dir}/*.yml | xargs grep -L "status: DONE"`
|
|
165
|
+
prints nothing, set the epic card `status: DONE` + `completed_date` + note
|
|
166
|
+
`"epic-closure gate — all children DONE (post-run, new2 skill)"`, folded into the SAME
|
|
167
|
+
reconciliation commit. If any child is still open, leave the epic untouched.
|
|
152
168
|
3. **Resume if degraded.** If `degraded` is true, re-invoke the workflow with
|
|
153
169
|
`Workflow({ scriptPath, resumeFromRunId })` (same `args` + the new `ts`). The
|
|
154
170
|
per-card **skip-completed** guard makes the resume idempotent — already-committed
|
|
@@ -27,6 +27,10 @@ export const meta = {
|
|
|
27
27
|
// { report, perCardResults, gateLedger, residualFollowups, telemetry, degraded, degradationReasons, residuals }
|
|
28
28
|
// `residuals` (materialized:false) is the OFFLINE-SAFE ledger of record — the skill writes
|
|
29
29
|
// any missing follow-up YAML and, if `degraded`, resumes via Workflow({scriptPath,resumeFromRunId}).
|
|
30
|
+
// A3 — residuals[] entries carry `deferralClass` and committed perCardResults[] carry
|
|
31
|
+
// `deferred` + `deferredClasses[]`: the skill marks a deferred card DONE post-run ONLY when
|
|
32
|
+
// every class is owner-gated / not-a-code-defect / policy-deferred-ac (an 'unresolved' class
|
|
33
|
+
// = DoD genuinely unmet → the card stays IN_PROGRESS and is surfaced).
|
|
30
34
|
// ───────────────────────────────────────────────────────────────────────────
|
|
31
35
|
|
|
32
36
|
// F-001/F-004 — tolerate args delivered as a JSON string (parse-or-default).
|
|
@@ -53,7 +57,6 @@ const gateLedger = [] // { card, gate, decision, detail }
|
|
|
53
57
|
const residualFollowups = [] // { card, kind, followupCard, reason }
|
|
54
58
|
const residuals = [] // F-020 OFFLINE-SAFE ledger: { card, kind, evidence, materialized }
|
|
55
59
|
const perCardResults = []
|
|
56
|
-
let batchFatal = false
|
|
57
60
|
let prodReadiness = null
|
|
58
61
|
let degraded = false
|
|
59
62
|
const degradationReasons = []
|
|
@@ -117,7 +120,7 @@ if (!cardIds.length) {
|
|
|
117
120
|
// ───────────────────────────────────────────────────────────────────────────
|
|
118
121
|
const PREFLIGHT_SCHEMA = {
|
|
119
122
|
type: 'object',
|
|
120
|
-
required: ['ok', 'worktreePath', 'branch', 'baseline', '
|
|
123
|
+
required: ['ok', 'worktreePath', 'branch', 'baseline', 'cards', 'cardGraph'],
|
|
121
124
|
additionalProperties: false,
|
|
122
125
|
properties: {
|
|
123
126
|
ok: { type: 'boolean' },
|
|
@@ -126,8 +129,9 @@ const PREFLIGHT_SCHEMA = {
|
|
|
126
129
|
port: { type: ['number', 'string'] },
|
|
127
130
|
baseline: { enum: ['pass', 'fail'] },
|
|
128
131
|
baselineLog: { type: 'string' },
|
|
129
|
-
executionMode:
|
|
130
|
-
|
|
132
|
+
// B4 — executionMode/groups removed: the DAG scheduler is strictly sequential (single
|
|
133
|
+
// worktree); computing team-mode groups was pre-flight work nobody read. Real parallelism
|
|
134
|
+
// is a future release, after A/B data.
|
|
131
135
|
cards: { type: 'array', items: { type: 'string' }, description: 'card ids cleared to run' },
|
|
132
136
|
// F-021/F-024/F-025/F-016 — the dependency graph + per-card routing facts (the
|
|
133
137
|
// script cannot read YAML; the pre-flight agent supplies these).
|
|
@@ -140,6 +144,10 @@ const PREFLIGHT_SCHEMA = {
|
|
|
140
144
|
dependsOn: { type: 'array', items: { type: 'string' }, description: 'IN-BATCH deps only' },
|
|
141
145
|
ownerAgent: { type: 'string', description: 'coder|ui-expert|visual-designer|motion-expert (G25: unknown→coder)' },
|
|
142
146
|
reviewProfile: { enum: ['skip', 'light', 'balanced', 'deep'] },
|
|
147
|
+
// B1/F-026 — git-authoritative idempotency, probed ONCE here instead of one Haiku
|
|
148
|
+
// spawn per card in runCard (always false on a fresh run).
|
|
149
|
+
alreadyCommitted: { type: 'boolean', description: 'commit referencing the card exists in trunk..HEAD of the worktree AND validation re-runs green AND no open follow-up' },
|
|
150
|
+
alreadyCommittedSha: { type: 'string' },
|
|
143
151
|
// F-016 — ACs whose only implementation file is outside the card MAY-EDIT,
|
|
144
152
|
// pre-classified deferred-by-policy (never routed to resolve).
|
|
145
153
|
policyDeferredACs: { type: 'array', items: { type: 'object', additionalProperties: true } },
|
|
@@ -212,9 +220,10 @@ try {
|
|
|
212
220
|
g3Bullet +
|
|
213
221
|
`• 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` +
|
|
214
222
|
`• 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` +
|
|
215
|
-
`• 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` +
|
|
223
|
+
`• 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, alreadyCommitted, alreadyCommittedSha }.\n` +
|
|
224
|
+
`• B1/F-026 idempotency (per card, AFTER the worktree exists): set alreadyCommitted:true (+ alreadyCommittedSha) IFF ALL hold: (a) a commit referencing the card id exists in ${TRUNK}..HEAD of the worktree; (b) the card's validation_commands re-run GREEN right now; (c) NO open follow-up card for it exists in ${paths.backlog_dir || 'backlog'}. On a FRESH worktree ${TRUNK}..HEAD is empty → all false, zero extra work.\n` +
|
|
216
225
|
`• 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` +
|
|
217
|
-
`•
|
|
226
|
+
`• Ownership (setup.md 3c): build the file-ownership map → /tmp; return ownershipMapPath. F-040: each card's MAY-EDIT = files_likely_touched ∪ every path NAMED EXPLICITLY in that card's acceptance_criteria/definition_of_done (an ADR the DoD says to update, the data-model / ER doc for a schema-change, etc.) — so editing a DoD-mandated doc is NOT a file-diff violation. Do NOT add another card's files this way.\n` +
|
|
218
227
|
`• Persist per-card architecture baselines to /tmp/arch-baseline-<CARD>.md; return archBaselinePaths.\n\n` +
|
|
219
228
|
`Return the structured PREFLIGHT object. ok:false ONLY if the workspace is unworkable.`,
|
|
220
229
|
{ label: 'preflight', phase: 'Pre-flight', agentType: 'general-purpose', schema: PREFLIGHT_SCHEMA }
|
|
@@ -240,6 +249,20 @@ const runnableCards = preflight.cards || []
|
|
|
240
249
|
const cardGraph = preflight.cardGraph || []
|
|
241
250
|
const graphById = {}
|
|
242
251
|
for (const n of cardGraph) graphById[n.id] = n
|
|
252
|
+
|
|
253
|
+
// A1/G25 — deterministic owner_agent clamp, in JS not prompt. An invalid agentType is a
|
|
254
|
+
// PERMANENT spawn error (→ crashResult → card 'failed'), so the /new router table
|
|
255
|
+
// (implement.md §6b) must be a code-level guarantee: plan/visual-designer/motion-expert
|
|
256
|
+
// degrade to coder (their briefing variant is not implemented — same as /new), anything
|
|
257
|
+
// unknown/missing → coder. The RAW value is kept for the security-relevance heuristic.
|
|
258
|
+
const OWNER_SPAWN = { coder: 'coder', 'ui-expert': 'ui-expert' }
|
|
259
|
+
for (const n of cardGraph) {
|
|
260
|
+
const raw = String(n.ownerAgent || '').trim()
|
|
261
|
+
const spawn = OWNER_SPAWN[raw.toLowerCase()] || 'coder'
|
|
262
|
+
n.ownerAgentRaw = raw
|
|
263
|
+
n.ownerAgent = spawn
|
|
264
|
+
ledger(n.id, 'router', spawn === raw ? 'OK' : 'CLAMPED', `owner_agent='${raw || '(missing)'}' → spawn=${spawn}`)
|
|
265
|
+
}
|
|
243
266
|
const sharedCtx = {
|
|
244
267
|
worktreePath: preflight.worktreePath,
|
|
245
268
|
branch: preflight.branch,
|
|
@@ -253,7 +276,7 @@ const sharedCtx = {
|
|
|
253
276
|
// F-016/F-010 — materialise ONE follow-up per policy-deferred AC up front; never resolve.
|
|
254
277
|
for (const n of cardGraph) {
|
|
255
278
|
for (const ac of (n.policyDeferredACs || [])) {
|
|
256
|
-
residuals.push({ card: n.id, kind: 'policy-deferred-ac', evidence: `AC-${ac.n}: ${ac.text} (${ac.reason || 'out-of-ownership / owner-gated'})`, materialized: false })
|
|
279
|
+
residuals.push({ card: n.id, kind: 'policy-deferred-ac', evidence: `AC-${ac.n}: ${ac.text} (${ac.reason || 'out-of-ownership / owner-gated'})`, materialized: false, deferralClass: 'policy-deferred-ac' })
|
|
257
280
|
acceptedDeferrals.add(sig(n.id, 'ac-unmet', `AC-${ac.n}: ${ac.text}`))
|
|
258
281
|
acceptedDeferrals.add(acSig(n.id, ac.n)) // F-040 (Fix G) — text-drift-proof AC key
|
|
259
282
|
|
|
@@ -309,14 +332,16 @@ async function resolve(kind, card, evidence, extra) {
|
|
|
309
332
|
if (e && (e.transientExhausted || isTransient(e))) noteDegraded('outage')
|
|
310
333
|
res = { status: 'followup', reason: 'resolve workflow error: ' + String(e && e.message), deferralClass: 'outage' }
|
|
311
334
|
}
|
|
335
|
+
// C — the 'fatal' branch was removed: new2-resolve never returns it (unreachable dead code,
|
|
336
|
+
// same rule as v4.17.2 G1).
|
|
312
337
|
const status = (res && res.status) || 'followup'
|
|
313
338
|
const deferralClass = (res && res.deferralClass) || null
|
|
314
|
-
if (status === 'fatal') { batchFatal = true; ledger(card, 'resolve:' + kind, 'FATAL', (res && res.reason) || ''); return { status, deferralClass } }
|
|
315
339
|
if (status === 'followup') {
|
|
316
340
|
acceptedDeferrals.add(s) // F-028 — a deferred residual must not be re-routed by a later gate.
|
|
317
341
|
const fc = (res && res.followupCard) || null
|
|
318
342
|
residualFollowups.push({ card, kind, followupCard: fc || '(pending)', reason: (res && res.reason) || '' })
|
|
319
|
-
|
|
343
|
+
// A3 — deferralClass rides the residual so the skill can gate DONE-reconciliation on it.
|
|
344
|
+
residuals.push({ card, kind, evidence, materialized: !!fc, deferralClass })
|
|
320
345
|
}
|
|
321
346
|
// F-022 — route out-of-scope findings the resolve surfaced.
|
|
322
347
|
for (const osf of (res && res.outOfScopeFindings) || []) {
|
|
@@ -334,19 +359,23 @@ async function resolve(kind, card, evidence, extra) {
|
|
|
334
359
|
async function rollbackCard(cardId, mayEdit) {
|
|
335
360
|
// F-018 — restore the card's files to HEAD so a failed card never poisons the next.
|
|
336
361
|
// Safe at file granularity because the DAG guarantees all deps are already committed
|
|
337
|
-
// (HEAD contains their work); this removes only THIS card's uncommitted changes.
|
|
362
|
+
// (HEAD contains their work); this removes only THIS card's uncommitted changes. With an
|
|
363
|
+
// empty mayEdit (A4 crash path: ownership unknown) the scope is the whole worktree — still
|
|
364
|
+
// safe for the same reason: everything good is in HEAD, only the crashed card is dirty.
|
|
365
|
+
// Returns true when the cleanup VERIFIED clean (A2 uses this to un-strand the merge gate).
|
|
366
|
+
const scope = (mayEdit || []).map((p) => `'${p}'`).join(' ') || '.'
|
|
338
367
|
try {
|
|
339
|
-
await agentSafe(
|
|
340
|
-
`In the worktree ${sharedCtx.worktreePath}, restore the working tree to a CLEAN state for a FAILED card: \`git restore --source=HEAD --worktree --staged -- ${
|
|
368
|
+
const r = await agentSafe(
|
|
369
|
+
`In the worktree ${sharedCtx.worktreePath}, restore the working tree to a CLEAN state for a FAILED card: \`git restore --source=HEAD --worktree --staged -- ${scope}\` then \`git clean -fd ${scope === '.' ? '' : '-- ' + scope}\` (with scope '.' this cleans the whole worktree — safe: all committed work lives in HEAD). Do NOT touch other cards' committed work. Confirm \`git status --porcelain\` is empty for the scope.`,
|
|
341
370
|
{ label: `rollback:${cardId}`, phase: 'Implement', agentType: 'general-purpose', model: 'haiku',
|
|
342
371
|
schema: { type: 'object', required: ['clean'], additionalProperties: true, properties: { clean: { type: 'boolean' }, note: { type: 'string' } } } }
|
|
343
372
|
)
|
|
344
|
-
|
|
373
|
+
return !!(r && r.clean)
|
|
374
|
+
} catch (_) { return false /* best-effort; OUTAGE path already flagged by caller */ }
|
|
345
375
|
}
|
|
346
376
|
|
|
347
|
-
async function runCard(cardId, cardPath
|
|
377
|
+
async function runCard(cardId, cardPath) {
|
|
348
378
|
const gates = []
|
|
349
|
-
const tele = {}
|
|
350
379
|
const node = graphById[cardId] || {}
|
|
351
380
|
const ownerAgent = node.ownerAgent || 'coder'
|
|
352
381
|
const reviewProfile = node.reviewProfile || 'balanced'
|
|
@@ -354,24 +383,23 @@ async function runCard(cardId, cardPath, lessons) {
|
|
|
354
383
|
// NON-DONE; the SKILL marks it DONE post-run only after the deferral's follow-up exists on disk
|
|
355
384
|
// in the main repo. Seeded from the pre-flight policy-deferred ACs; set by any owner-gated review
|
|
356
385
|
// deferral or unmet-AC follow-up below.
|
|
386
|
+
// A3 — deferredClasses records WHY each deferral happened. The skill marks the card DONE
|
|
387
|
+
// post-run ONLY if every class is owner-gated/not-a-code-defect/policy-deferred-ac; a class
|
|
388
|
+
// like 'unresolved' (a genuinely unimplemented AC) keeps the card IN_PROGRESS — never auto-DONE.
|
|
357
389
|
let deferredOpen = ((node.policyDeferredACs) || []).length > 0
|
|
390
|
+
const deferredClasses = new Set(deferredOpen ? ['policy-deferred-ac'] : [])
|
|
358
391
|
function g(name, decision, detail) { gates.push({ gate: name, decision, detail: detail || '' }); ledger(cardId, name, decision, detail) }
|
|
359
392
|
|
|
360
|
-
// F-026 — skip-completed
|
|
361
|
-
//
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
if (probe && probe.done) {
|
|
369
|
-
g('skip-completed', 'CACHED', probe.commit || 'already committed + green')
|
|
370
|
-
return { card: cardId, status: 'committed', commit: probe.commit || '-', filesChanged: [], scopeFiles: [], archBaselinePath: `/tmp/arch-baseline-${cardId}.md`, gates, telemetry: tele }
|
|
371
|
-
}
|
|
372
|
-
} catch (e) { if (e && e.transientExhausted) { noteDegraded('outage'); return { card: cardId, status: 'pending', gates, telemetry: tele } } }
|
|
393
|
+
// F-026/B1 — skip-completed from the PRE-FLIGHT's git-authoritative probe (cardGraph[].
|
|
394
|
+
// alreadyCommitted), not a per-card agent spawn: on a fresh run the old Haiku probe was N
|
|
395
|
+
// guaranteed-false spawns, and on resume the journal cache already covers it. Keyed on the
|
|
396
|
+
// receipt (commit in TRUNK..HEAD + green + no open follow-up), NOT the unreliable DONE flag.
|
|
397
|
+
if (node.alreadyCommitted) {
|
|
398
|
+
g('skip-completed', 'CACHED', node.alreadyCommittedSha || 'pre-flight: commit in trunk..HEAD + green + no open follow-up')
|
|
399
|
+
return { card: cardId, status: 'committed', commit: node.alreadyCommittedSha || '-', filesChanged: [], scopeFiles: [], archBaselinePath: `/tmp/arch-baseline-${cardId}.md`, gates }
|
|
400
|
+
}
|
|
373
401
|
|
|
374
|
-
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}\
|
|
402
|
+
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}\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.`
|
|
375
403
|
|
|
376
404
|
// --- Phase 1+2: dispatch the card's OWNER_AGENT (F-024), not general-purpose. ---
|
|
377
405
|
let impl
|
|
@@ -386,11 +414,11 @@ async function runCard(cardId, cardPath, lessons) {
|
|
|
386
414
|
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' } }, revertedOutOfOwnership: { type: 'array', items: { type: 'string' } }, fileDiffViolation: { type: 'boolean' }, note: { type: 'string' } } } }
|
|
387
415
|
)
|
|
388
416
|
} catch (e) {
|
|
389
|
-
if (e && e.transientExhausted) { noteDegraded('outage'); return { card: cardId, status: 'pending', gates
|
|
417
|
+
if (e && e.transientExhausted) { noteDegraded('outage'); return { card: cardId, status: 'pending', gates } }
|
|
390
418
|
throw e
|
|
391
419
|
}
|
|
392
420
|
|
|
393
|
-
if (impl && impl.epic) { g('router', 'EPIC-SKIPPED', 'epic card'); return { card: cardId, status: 'epic-skipped', gates, commit: '-'
|
|
421
|
+
if (impl && impl.epic) { g('router', 'EPIC-SKIPPED', 'epic card'); return { card: cardId, status: 'epic-skipped', gates, commit: '-' } }
|
|
394
422
|
|
|
395
423
|
const mayEdit = (impl && impl.mayEditPaths) || []
|
|
396
424
|
const scopeFiles = (impl && impl.scopeFiles) || []
|
|
@@ -407,22 +435,27 @@ async function runCard(cardId, cardPath, lessons) {
|
|
|
407
435
|
if (impl && impl.buildBlocked) {
|
|
408
436
|
const s = (await resolve('blocker', cardId, `Phase-2 gate failing: ${impl.blockedGate}`, { mayEditPaths: mayEdit, scopeFiles, domain: 'code' })).status
|
|
409
437
|
g('G26-build', s === 'resolved' ? 'RESOLVED' : 'FOLLOWUP', impl.blockedGate)
|
|
410
|
-
if (s !== 'resolved') { await rollbackCard(cardId, mayEdit); return { card: cardId, status: 'followup', gates, commit: '-', scopeFiles
|
|
438
|
+
if (s !== 'resolved') { await rollbackCard(cardId, mayEdit); return { card: cardId, status: 'followup', gates, commit: '-', scopeFiles } }
|
|
411
439
|
}
|
|
412
440
|
|
|
413
441
|
// F-010/F-016 — unmet ACs that are policy-deferred are skipped (already tracked).
|
|
414
442
|
for (const ac of (impl && impl.unmetACs) || []) {
|
|
415
|
-
if (acceptedDeferrals.has(acSig(cardId, ac.n)) || acceptedDeferrals.has(sig(cardId, 'ac-unmet', `AC-${ac.n}: ${ac.text}`))) { g('G7-ac-closure', 'DEFERRED-BY-POLICY', `AC-${ac.n}`); deferredOpen = true; continue }
|
|
416
|
-
const
|
|
417
|
-
g('G7-ac-closure',
|
|
418
|
-
|
|
443
|
+
if (acceptedDeferrals.has(acSig(cardId, ac.n)) || acceptedDeferrals.has(sig(cardId, 'ac-unmet', `AC-${ac.n}: ${ac.text}`))) { g('G7-ac-closure', 'DEFERRED-BY-POLICY', `AC-${ac.n}`); deferredOpen = true; deferredClasses.add('policy-deferred-ac'); continue }
|
|
444
|
+
const r = await resolve('ac-unmet', cardId, `AC-${ac.n}: ${ac.text}`, { mayEditPaths: mayEdit, scopeFiles, domain: 'code' })
|
|
445
|
+
g('G7-ac-closure', r.status === 'resolved' ? 'RESOLVED' : 'FOLLOWUP', `AC-${ac.n}`)
|
|
446
|
+
// A3 — record the deferral CLASS (v4.24.1 did this only for the blocks loop). An
|
|
447
|
+
// 'unresolved' AC still commits (never destroy completed work) but the class keeps the
|
|
448
|
+
// card from being auto-DONE by the skill — its DoD is genuinely not met.
|
|
449
|
+
if (r.status !== 'resolved') { deferredOpen = true; deferredClasses.add(r.deferralClass || 'unresolved') }
|
|
419
450
|
}
|
|
420
451
|
|
|
421
452
|
// --- Review fan-out (F-024/F-025): specialized agents, trimmed by review_profile. ---
|
|
422
453
|
// G5 — scopeFiles tokens alone miss a security card whose files don't carry them. Also
|
|
423
|
-
// trigger security-reviewer on the card's owner_agent and its brief (title/requirements).
|
|
424
|
-
|
|
425
|
-
|
|
454
|
+
// trigger security-reviewer on the card's RAW owner_agent and its brief (title/requirements).
|
|
455
|
+
// B2 — high_risk_modules triggers only when THIS card's files intersect them (`highRisk.length`
|
|
456
|
+
// alone fired security-reviewer on every card of every project that configures the list).
|
|
457
|
+
const securityRelevant = highRisk.some((m) => scopeFiles.some((f) => String(f).includes(String(m))))
|
|
458
|
+
|| node.ownerAgentRaw === 'security-reviewer'
|
|
426
459
|
|| /auth|security|secret|migration|rls/i.test(`${scopeFiles.join(' ')} ${cardBrief}`)
|
|
427
460
|
// v4.18.0 — at `light`, Codex is the SOLE finder (cost-shift off Claude); `code-reviewer` is the
|
|
428
461
|
// fallback when the companion is unavailable. The FP-gate equivalent is preserved downstream: any
|
|
@@ -463,9 +496,23 @@ async function runCard(cardId, cardPath, lessons) {
|
|
|
463
496
|
const blocks = reviewResults.flatMap((r) => (r.blocks || [])).filter((b) => b && b.gate && b.evidence)
|
|
464
497
|
const scopeExp = reviewResults.flatMap((r) => (r.scopeExpansion || []))
|
|
465
498
|
let cardBlocked = false
|
|
499
|
+
// B3 — group blocks by kind+domain → ONE resolve per group via findings[] (the same F-007
|
|
500
|
+
// batching the final review already does). One-by-one routing was the dominant per-card cost
|
|
501
|
+
// driver (each resolve = fixer + judge + possible Tier-2 fan-out). A group is homogeneous by
|
|
502
|
+
// kind+domain, so its single status/deferralClass is coherent for every block in it.
|
|
503
|
+
const blockGroups = {}
|
|
466
504
|
for (const b of blocks) {
|
|
467
505
|
const kind = /e2e/i.test(b.gate) ? 'e2e-blocked' : /qa/i.test(b.gate) ? 'qa-fail' : 'blocker'
|
|
468
|
-
const
|
|
506
|
+
const key = `${kind}::${b.domain || 'code'}`
|
|
507
|
+
;(blockGroups[key] = blockGroups[key] || []).push(Object.assign({ kindResolved: kind }, b))
|
|
508
|
+
}
|
|
509
|
+
for (const key of Object.keys(blockGroups)) {
|
|
510
|
+
const grp = blockGroups[key]
|
|
511
|
+
const kind = grp[0].kindResolved
|
|
512
|
+
const dom = grp[0].domain || 'code'
|
|
513
|
+
const r = await resolve(kind, cardId, grp.map((b) => `${b.gate}: ${b.evidence}`).join(' || '),
|
|
514
|
+
{ mayEditPaths: mayEdit, scopeFiles, domain: dom,
|
|
515
|
+
findings: grp.map((b) => ({ kind, evidence: `${b.gate}: ${b.evidence}`, domain: b.domain || 'code' })) })
|
|
469
516
|
// F-040 — THE primary fix. An owner-gated / not-a-code-defect deferral means the card's OWN
|
|
470
517
|
// code is complete and correct; the residual is an external/infra step (e.g. a remote db push)
|
|
471
518
|
// already tracked as a follow-up. Do NOT roll the card back — it proceeds to commit, NON-DONE
|
|
@@ -473,16 +520,22 @@ async function runCard(cardId, cardPath, lessons) {
|
|
|
473
520
|
// `s !== 'resolved' → cardBlocked` which destroyed a completed migration card's work over a db:push gate.
|
|
474
521
|
// A genuine unresolved CODE defect (or out-of-ownership/baseline/outage) still blocks + rolls back.
|
|
475
522
|
const ownerGated = r.status === 'followup' && (r.deferralClass === 'owner-gated' || r.deferralClass === 'not-a-code-defect')
|
|
476
|
-
g(b.gate, r.status === 'resolved' ? 'RESOLVED' : ownerGated ? 'DEFERRED-OWNER-GATED' : 'FOLLOWUP', b.evidence)
|
|
477
|
-
if (ownerGated) deferredOpen = true
|
|
523
|
+
for (const b of grp) g(b.gate, r.status === 'resolved' ? 'RESOLVED' : ownerGated ? 'DEFERRED-OWNER-GATED' : 'FOLLOWUP', b.evidence)
|
|
524
|
+
if (ownerGated) { deferredOpen = true; deferredClasses.add(r.deferralClass) }
|
|
478
525
|
else if (r.status !== 'resolved') cardBlocked = true
|
|
479
526
|
}
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
527
|
+
// B3 — scope-expansion findings batched per domain (same rationale).
|
|
528
|
+
const sxGroups = {}
|
|
529
|
+
for (const sx of scopeExp) { const d = sx.domain || 'code'; (sxGroups[d] = sxGroups[d] || []).push(sx) }
|
|
530
|
+
for (const dom of Object.keys(sxGroups)) {
|
|
531
|
+
const grp = sxGroups[dom]
|
|
532
|
+
const s = (await resolve('scope-expansion', cardId, grp.map((x) => x.evidence || '').join(' || '),
|
|
533
|
+
{ mayEditPaths: mayEdit, scopeFiles, domain: dom,
|
|
534
|
+
findings: grp.map((x) => ({ kind: 'scope-expansion', evidence: x.evidence || '', domain: dom })) })).status
|
|
535
|
+
for (const sx of grp) g('scope-expansion', s === 'resolved' ? 'INTEGRATED' : 'FOLLOWUP', sx.evidence || '')
|
|
483
536
|
}
|
|
484
537
|
|
|
485
|
-
if (cardBlocked) { await rollbackCard(cardId, mayEdit); return { card: cardId, status: 'followup', gates, commit: '-', scopeFiles, archBaselinePath: `/tmp/arch-baseline-${cardId}.md
|
|
538
|
+
if (cardBlocked) { await rollbackCard(cardId, mayEdit); return { card: cardId, status: 'followup', gates, commit: '-', scopeFiles, archBaselinePath: `/tmp/arch-baseline-${cardId}.md` } }
|
|
486
539
|
|
|
487
540
|
// --- Phase 4 — commit (F-023: Haiku + git-status reconcile, never git add -A). ---
|
|
488
541
|
// F-040/H — DONE policy. A card with an OPEN owner-gated/policy-deferred AC commits its code but
|
|
@@ -503,14 +556,14 @@ async function runCard(cardId, cardPath, lessons) {
|
|
|
503
556
|
schema: { type: 'object', required: ['committed'], additionalProperties: true, properties: { committed: { type: 'boolean' }, commit: { type: 'string' }, filesChanged: { type: 'array', items: { type: 'string' } }, reconcileNote: { type: 'string' } } } }
|
|
504
557
|
)
|
|
505
558
|
} catch (e) {
|
|
506
|
-
if (e && e.transientExhausted) { noteDegraded('outage'); await rollbackCard(cardId, mayEdit); return { card: cardId, status: 'pending', gates
|
|
559
|
+
if (e && e.transientExhausted) { noteDegraded('outage'); await rollbackCard(cardId, mayEdit); return { card: cardId, status: 'pending', gates } }
|
|
507
560
|
throw e
|
|
508
561
|
}
|
|
509
562
|
|
|
510
563
|
if (!commitRes || !commitRes.committed) {
|
|
511
564
|
const s = (await resolve('blocker', cardId, 'commit blocked after retries', { mayEditPaths: mayEdit, scopeFiles, domain: 'code' })).status
|
|
512
565
|
g('G16-commit', s === 'resolved' ? 'RESOLVED' : 'FOLLOWUP')
|
|
513
|
-
if (s !== 'resolved') { await rollbackCard(cardId, mayEdit); return { card: cardId, status: 'followup', gates, commit: '-', scopeFiles, archBaselinePath: `/tmp/arch-baseline-${cardId}.md
|
|
566
|
+
if (s !== 'resolved') { await rollbackCard(cardId, mayEdit); return { card: cardId, status: 'followup', gates, commit: '-', scopeFiles, archBaselinePath: `/tmp/arch-baseline-${cardId}.md` } }
|
|
514
567
|
}
|
|
515
568
|
if (commitRes && commitRes.reconcileNote) g('commit-reconcile', 'NOTE', commitRes.reconcileNote)
|
|
516
569
|
|
|
@@ -520,9 +573,12 @@ async function runCard(cardId, cardPath, lessons) {
|
|
|
520
573
|
commit: (commitRes && commitRes.commit) || '-',
|
|
521
574
|
filesChanged: (commitRes && commitRes.filesChanged) || [],
|
|
522
575
|
// F-040/H — true when this committed card is intentionally left NON-DONE (open deferral). The
|
|
523
|
-
// merge agent leaves it non-DONE and the SKILL marks it DONE after its follow-up materialises
|
|
576
|
+
// merge agent leaves it non-DONE and the SKILL marks it DONE after its follow-up materialises —
|
|
577
|
+
// but ONLY if every class in deferredClasses is owner-gated/not-a-code-defect/policy-deferred-ac
|
|
578
|
+
// (A3): an 'unresolved' class means the DoD is genuinely unmet → the card stays IN_PROGRESS.
|
|
524
579
|
deferred: deferredOpen,
|
|
525
|
-
|
|
580
|
+
deferredClasses: Array.from(deferredClasses),
|
|
581
|
+
scopeFiles, archBaselinePath: `/tmp/arch-baseline-${cardId}.md`, gates,
|
|
526
582
|
}
|
|
527
583
|
}
|
|
528
584
|
|
|
@@ -533,7 +589,7 @@ async function runCard(cardId, cardPath, lessons) {
|
|
|
533
589
|
// re-queue with a cap; sustained outage → stop cleanly + degraded return.
|
|
534
590
|
// ───────────────────────────────────────────────────────────────────────────
|
|
535
591
|
phase('Implement')
|
|
536
|
-
const
|
|
592
|
+
const failedCleaned = new Set() // A4 — failed cards whose crash cleanup VERIFIED clean (un-strands the merge gate)
|
|
537
593
|
const state = {} // cardId → 'pending'|'committed'|'followup'|'epic-skipped'|'blocked'|'failed'
|
|
538
594
|
const attempts = {} // cardId → retry count (transient)
|
|
539
595
|
const RETRY_CAP = 2
|
|
@@ -563,7 +619,7 @@ while (guard-- > 0) {
|
|
|
563
619
|
const next = runnableCards.find((id) => state[id] === 'pending' && depsSatisfied(id))
|
|
564
620
|
if (!next) break // nothing runnable (all done/blocked, or a cycle/stall)
|
|
565
621
|
|
|
566
|
-
const r = await runCard(next, pathById[next]
|
|
622
|
+
const r = await runCard(next, pathById[next]).catch((e) => crashResult(next, e))
|
|
567
623
|
if (r.status === 'pending') {
|
|
568
624
|
attempts[next]++
|
|
569
625
|
consecutiveOutage++
|
|
@@ -578,7 +634,6 @@ while (guard-- > 0) {
|
|
|
578
634
|
consecutiveOutage = 0
|
|
579
635
|
state[next] = r.status
|
|
580
636
|
perCardResults.push(r)
|
|
581
|
-
if (r.note) lessons.push(`${next}: ${r.note}`)
|
|
582
637
|
}
|
|
583
638
|
|
|
584
639
|
// Any still-pending card after the loop (outage) is recorded as a residual.
|
|
@@ -586,11 +641,17 @@ for (const id of runnableCards) {
|
|
|
586
641
|
if (state[id] === 'pending') residuals.push({ card: id, kind: 'not-reached', evidence: 'batch paused before this card ran', materialized: false })
|
|
587
642
|
}
|
|
588
643
|
|
|
589
|
-
function crashResult(id, e) {
|
|
590
|
-
if (e && (e.transientExhausted || isTransient(e))) { return { card: id, status: 'pending', gates: []
|
|
644
|
+
async function crashResult(id, e) {
|
|
645
|
+
if (e && (e.transientExhausted || isTransient(e))) { return { card: id, status: 'pending', gates: [] } }
|
|
591
646
|
residuals.push({ card: id, kind: 'agent-crash', evidence: String(e && e.message), materialized: false })
|
|
592
647
|
ledger(id, 'runCard', 'ERROR', String(e && e.message))
|
|
593
|
-
|
|
648
|
+
// A4 — a crashed card used to leave its dirty files in the worktree (the NEXT card's owner
|
|
649
|
+
// agent then reverted them via E4 with a misleading 'out-of-ownership' label). Clean up here;
|
|
650
|
+
// a VERIFIED-clean failure also stops stranding the merge gate (A2).
|
|
651
|
+
const clean = await rollbackCard(id, [])
|
|
652
|
+
if (clean) failedCleaned.add(id)
|
|
653
|
+
ledger(id, 'crash-cleanup', clean ? 'CLEAN' : 'DIRTY', clean ? 'worktree restored to HEAD' : 'cleanup unverified — merge stays blocked')
|
|
654
|
+
return { card: id, status: 'failed', gates: [{ gate: 'runCard', decision: 'ERROR', detail: String(e && e.message) }], commit: '-' }
|
|
594
655
|
}
|
|
595
656
|
|
|
596
657
|
const committed = perCardResults.filter((r) => r.status === 'committed')
|
|
@@ -601,8 +662,8 @@ const committed = perCardResults.filter((r) => r.status === 'committed')
|
|
|
601
662
|
// ───────────────────────────────────────────────────────────────────────────
|
|
602
663
|
phase('Final')
|
|
603
664
|
let finalSummary = null
|
|
604
|
-
let mergeBlocked =
|
|
605
|
-
if (committed.length && !
|
|
665
|
+
let mergeBlocked = degraded
|
|
666
|
+
if (committed.length && !degraded) {
|
|
606
667
|
const reviewScopeFiles = dedupe(committed.flatMap((r) => r.scopeFiles || []))
|
|
607
668
|
const archPaths = committed.map((r) => r.archBaselinePath).filter(Boolean)
|
|
608
669
|
const allArch = archPaths.length === committed.length ? archPaths : null
|
|
@@ -638,24 +699,32 @@ if (committed.length && !batchFatal && !degraded) {
|
|
|
638
699
|
const area = (Array.isArray(f.files) && f.files[0]) || (f.file) || (f.domain || 'misc')
|
|
639
700
|
;(byArea[area] = byArea[area] || []).push(f)
|
|
640
701
|
}
|
|
702
|
+
// F-040 (parallel location, v4.24.3) — the owner-gated classification applies HERE too,
|
|
703
|
+
// not just in the per-card loops: a final-review finding whose only remedy is an external/
|
|
704
|
+
// infra step (e.g. the same pending remote db:push a reviewer re-raises batch-wide) must
|
|
705
|
+
// NOT block the merge — the batch's code is complete and the residual is already tracked
|
|
706
|
+
// as a follow-up with its deferralClass. Only a genuine unresolved CODE defect blocks.
|
|
707
|
+
const ownerGatedFinal = (r) => r.status === 'followup' && (r.deferralClass === 'owner-gated' || r.deferralClass === 'not-a-code-defect')
|
|
641
708
|
for (const area of Object.keys(byArea)) {
|
|
642
709
|
const group = byArea[area]
|
|
643
|
-
const
|
|
710
|
+
const r = await resolve('merge-blocker', group[0].finding_id || firstCard,
|
|
644
711
|
group.map((f) => `${f.severity} ${f.title}: ${f.evidence}`).join(' || '),
|
|
645
712
|
{ mayEditPaths: reviewScopeFiles, scopeFiles: reviewScopeFiles, domain: group[0].domain || 'code',
|
|
646
|
-
findings: group.map((f) => ({ kind: 'merge-blocker', evidence: `${f.title}: ${f.evidence}`, domain: f.domain || 'code' })) })
|
|
647
|
-
if (
|
|
713
|
+
findings: group.map((f) => ({ kind: 'merge-blocker', evidence: `${f.title}: ${f.evidence}`, domain: f.domain || 'code' })) })
|
|
714
|
+
if (ownerGatedFinal(r)) ledger(group[0].finding_id || firstCard, 'final-merge-blocker', 'DEFERRED-OWNER-GATED', `${r.deferralClass} — follow-up tracked, merge NOT blocked`)
|
|
715
|
+
else if (r.status !== 'resolved') mergeBlocked = true
|
|
648
716
|
}
|
|
649
717
|
if (finalSummary && finalSummary.failingGates && finalSummary.failingGates.length) {
|
|
650
|
-
const
|
|
651
|
-
if (
|
|
718
|
+
const r = await resolve('qa-fail', firstCard, `final gates failing: ${finalSummary.failingGates.join(', ')}`, { mayEditPaths: reviewScopeFiles, scopeFiles: reviewScopeFiles, domain: 'code' })
|
|
719
|
+
if (ownerGatedFinal(r)) ledger(firstCard, 'final-qa', 'DEFERRED-OWNER-GATED', `${r.deferralClass} — follow-up tracked, merge NOT blocked`)
|
|
720
|
+
else if (r.status !== 'resolved') mergeBlocked = true
|
|
652
721
|
}
|
|
653
722
|
} else {
|
|
654
723
|
ledger(firstCard, 'final-review', 'SKIPPED', degraded ? 'degraded' : 'workflow returned null')
|
|
655
724
|
mergeBlocked = true
|
|
656
725
|
}
|
|
657
726
|
} else {
|
|
658
|
-
ledger(firstCard, 'final-review', 'SKIPPED',
|
|
727
|
+
ledger(firstCard, 'final-review', 'SKIPPED', degraded ? 'degraded (outage)' : 'no committed cards')
|
|
659
728
|
}
|
|
660
729
|
|
|
661
730
|
// ───────────────────────────────────────────────────────────────────────────
|
|
@@ -666,12 +735,19 @@ if (committed.length && !batchFatal && !degraded) {
|
|
|
666
735
|
// ───────────────────────────────────────────────────────────────────────────
|
|
667
736
|
phase('Merge')
|
|
668
737
|
let mergeResult = null
|
|
669
|
-
|
|
738
|
+
// A2 — 'followup'/'blocked' cards were rolled back and their residuals live in the offline-safe
|
|
739
|
+
// ledger: the worktree holds ONLY gate-passing committed work, so they must not strand the batch
|
|
740
|
+
// (the old filter contradicted the gate's own comment and orphaned the worktree with no resume
|
|
741
|
+
// path — not degraded, so the skill never resumed). 'failed' counts as complete ONLY when its
|
|
742
|
+
// crash cleanup VERIFIED the worktree clean (A4); an unverified crash still blocks the merge.
|
|
743
|
+
const incomplete = runnableCards.filter((id) =>
|
|
744
|
+
!(state[id] === 'committed' || state[id] === 'epic-skipped' || state[id] === 'followup'
|
|
745
|
+
|| state[id] === 'blocked' || (state[id] === 'failed' && failedCleaned.has(id))))
|
|
670
746
|
// F-040/H — committed cards intentionally left NON-DONE (open owner-gated/policy-deferred AC). They
|
|
671
747
|
// ARE merged (their code is complete), but Phase 6b must NOT force them to DONE; the SKILL does that
|
|
672
748
|
// post-run once the deferral's follow-up exists on disk. They count as complete for the merge gate.
|
|
673
749
|
const deferredCards = committed.filter((r) => r.deferred).map((r) => r.card)
|
|
674
|
-
const integrityOK = committed.length > 0 && !mergeBlocked && !
|
|
750
|
+
const integrityOK = committed.length > 0 && !mergeBlocked && !degraded && incomplete.length === 0
|
|
675
751
|
if (!committed.length) {
|
|
676
752
|
ledger(firstCard, 'merge', 'SKIPPED', 'no committed cards')
|
|
677
753
|
} else if (!integrityOK) {
|
|
@@ -686,7 +762,7 @@ if (!committed.length) {
|
|
|
686
762
|
`• 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` +
|
|
687
763
|
`• 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` +
|
|
688
764
|
`• F-040 DEFERRED CARDS — leave NON-DONE (do NOT force to DONE in Phase 6b): ${deferredCards.length ? deferredCards.join(' ') : '(none)'}. These committed their code but carry an OPEN owner-gated/policy-deferred AC (e.g. a pending remote db:push). Their YAML is INTENTIONALLY IN_PROGRESS; the new2 skill marks them DONE post-run after materialising the deferral's follow-up. They ARE part of the merge — just skip them in the DONE-reconciliation. Return deferredLeftOpen:[the ones you left non-DONE].\n` +
|
|
689
|
-
`• EPIC CLOSURE (Phase 6b step 5e): the epic/parent card (group.is_epic:true) is NOT in the batch and stays TODO unless closed here. For each distinct group.parent of the batch cards (and any epic card in the batch itself): if EVERY child of that epic — \`grep -l "parent: <EPIC-ID>" backlog/*.yml | xargs grep -L "status: DONE"\` prints nothing — set the epic card status:DONE + completed_date + note "epic-closure gate — all children DONE" and fold into the reconciliation commit. If any child is still open → leave the epic untouched. This is NOT a forcedDone violation (the epic is a tracker, gated on all-children-DONE, not on its own commit). Return epicsClosed:[<EPIC-IDs marked DONE>].\n` +
|
|
765
|
+
`• EPIC CLOSURE (Phase 6b step 5e): the epic/parent card (group.is_epic:true) is NOT in the batch and stays TODO unless closed here. For each distinct group.parent of the batch cards (and any epic card in the batch itself): if EVERY child of that epic — \`grep -l "parent: <EPIC-ID>" ${paths.backlog_dir || 'backlog'}/*.yml | xargs grep -L "status: DONE"\` prints nothing — set the epic card status:DONE + completed_date + note "epic-closure gate — all children DONE" and fold into the reconciliation commit. If any child is still open → leave the epic untouched. This is NOT a forcedDone violation (the epic is a tracker, gated on all-children-DONE, not on its own commit). Return epicsClosed:[<EPIC-IDs marked DONE>].\n` +
|
|
690
766
|
`• 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` +
|
|
691
767
|
`Return: { merged, mergeCommit, mergeTs, reconciliation, forcedDone:[], deferredLeftOpen:[], uncommittedLeft, note }`,
|
|
692
768
|
{ label: 'merge', phase: 'Merge', agentType: 'general-purpose', schema: MERGE_SCHEMA }
|
|
@@ -759,12 +835,12 @@ function buildTelemetry() {
|
|
|
759
835
|
merged: !!(mergeResult && mergeResult.merged),
|
|
760
836
|
degraded,
|
|
761
837
|
degradation_reasons: degradationReasons,
|
|
762
|
-
execution_mode:
|
|
838
|
+
execution_mode: 'sequential', // B4 — the scheduler is strictly sequential by design
|
|
763
839
|
codex_resolved: preflight ? !!preflight.codexResolved : null, // v4.18.0 — probed for EVERY batch (drives per-card Codex-light + multi-card cross-card)
|
|
764
840
|
// cost — total_tokens via budget.spent() delta; agent_count via counter; wall_clock_s stamped by the SKILL.
|
|
765
841
|
total_tokens: totalTokens,
|
|
766
842
|
agent_count: agentCount,
|
|
767
|
-
per_card: perCardResults.map((r) => ({ card: r.card, status: r.status,
|
|
843
|
+
per_card: perCardResults.map((r) => ({ card: r.card, status: r.status, deferred: !!r.deferred, deferredClasses: r.deferredClasses || [], gates: (r.gates || []).length })),
|
|
768
844
|
stats_requested: !!FLAGS.stats,
|
|
769
845
|
}
|
|
770
846
|
}
|
|
@@ -772,7 +848,7 @@ function buildTelemetry() {
|
|
|
772
848
|
function buildReport(o) {
|
|
773
849
|
const L = []
|
|
774
850
|
L.push(`# new2 batch — ${cardIds.join(' ')}`)
|
|
775
|
-
L.push(`Variant: **new2** · Mode:
|
|
851
|
+
L.push(`Variant: **new2** · Mode: sequential · Trunk: ${TRUNK}${degraded ? ' · ⚠️ DEGRADED (' + degradationReasons.join(',') + ')' : ''}`)
|
|
776
852
|
if (o.fatal) { L.push(``, `## ⛔ BATCH FATAL`, o.reason || 'workspace unworkable'); return L.join('\n') }
|
|
777
853
|
L.push(``, `## Esito card`)
|
|
778
854
|
L.push(`| Card | Status | Commit | File |`)
|