baldart 4.24.0 → 4.24.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
CHANGED
|
@@ -5,6 +5,20 @@ 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.1] - 2026-06-10
|
|
9
|
+
|
|
10
|
+
**`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).
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
|
|
14
|
+
- **`framework/.claude/workflows/new2.js` + `new2-resolve.js` — classification-based card-block (the primary fix, F-040).** `resolve()` now propagates a structured `deferralClass` from `new2-resolve`'s terminal-judge. A review-block that resolves to an **owner-gated / not-a-code-defect** deferral no longer sets `cardBlocked` → the card's *complete* code is committed (it is **not** rolled back); only a genuine unresolved **code** defect (or `out-of-ownership`/`baseline`/`outage`) still blocks + rolls back. A deliberately-broken migration (`db:reset` failing) is still classified code-defect → blocks, so this is not an over-match escape hatch.
|
|
15
|
+
- **`new2.js` — `E4-file-diff` is honest.** The old `AUTO-REVERTED` log reverted nothing. The owner agent now reconciles out-of-ownership edits itself (per `implement.md §11b`) and reports `revertedOutOfOwnership`; the gate logs `REVERTED` / `FLAGGED` to match reality, and an unresolved violation becomes a tracked residual (never silent, never orphaned).
|
|
16
|
+
- **`new2.js` pre-flight — ownership map ⊇ DoD (root cause of the E4 false positive).** A card's MAY-EDIT now = `files_likely_touched` ∪ paths **named explicitly** in its `acceptance_criteria`/`definition_of_done` (the ADR/data-model/ER doc a schema-change must touch), so editing a DoD-mandated doc is no longer a file-diff violation.
|
|
17
|
+
- **`new2.js` + `new2/SKILL.md` — DONE deferred to the skill, gated on the follow-up existing on disk (closes the F-029 false-DONE the review surfaced).** A card carrying an open owner-gated/policy-deferred AC commits its code but stays **NON-DONE**; the merge agent is told to leave those cards non-DONE; the **skill** marks them DONE post-run **only after** verifying the deferral's follow-up exists on disk in the main repo (fail-loud otherwise — never DONE with a dropped requirement).
|
|
18
|
+
- **`new2-resolve.js` + `new2/SKILL.md` — follow-ups are reliable.** Follow-up materialisation is best-effort inside the workflow (it rides the merge if the batch merges); the **skill is the SSOT**, verifying every residual against the **main-repo** disk and creating any missing follow-up there — so a non-merged batch never loses one. `materialized` is now advisory only.
|
|
19
|
+
- **`new2.js` (Fix G) — AC-deferral dedup is text-drift-proof.** The policy-deferred-AC key is now scoped to the AC *number* (`acSig`), so a deferred AC is no longer re-routed to `resolve()` a second time when the pre-flight and implement agents word the AC text slightly differently.
|
|
20
|
+
- **`new2/SKILL.md` — telemetry reconciled against disk.** Before recording, the skill verifies each `committed` card actually has a commit on trunk and never presents progress the disk does not show; adds `cards_deferred_done_pending` so the A/B record distinguishes "code landed" from "DONE".
|
|
21
|
+
|
|
8
22
|
## [4.24.0] - 2026-06-10
|
|
9
23
|
|
|
10
24
|
**Atomic backlog-ID allocator — no FEAT/BUG collisions across parallel worktrees.** When several `/prd` (or `/new`/`new2` follow-up) sessions run in parallel on sibling worktrees, each branched from the same trunk, the old `max(^id: FEAT-) + 1` scan made them all land on the **same next integer**: the other session's card was in flight on an unmerged sibling branch, invisible to both the local backlog and the trunk merge-base — so two epics both became `FEAT-0024` and conflicted at rebase/merge. The `git fetch` + merge-base scan only ever covered *already-merged* IDs, never in-flight ones. A new allocator anchors a lock + per-prefix high-water mark in `$MAIN/.worktrees/` (the shared coordination point every worktree already reaches via `git rev-parse --git-common-dir`, gitignored like `registry.json`), so a reservation is atomic across every worktree **on the same machine**. The high-water mark bumped under the lock is the correctness anchor; `max()` against the real backlog + sibling-worktree backlogs + reservations log + trunk merge-base makes it **self-healing**. **MINOR** (additive capability on the `worktree-manager` skill; opt-in — callers fall back to the inline merge-base scan + `[ID-RACE-RISK]` note when the script is absent, so older installs and cross-machine cloud agents are unaffected. **No `baldart.config.yml` key** — the allocator reuses `paths.backlog_dir` + the gitignored `.worktrees/` convention, so the schema-change propagation rule does not apply).
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
4.24.
|
|
1
|
+
4.24.1
|
|
@@ -117,42 +117,56 @@ returns when the batch is done. It returns:
|
|
|
117
117
|
|
|
118
118
|
- `report` — ready-to-show markdown batch summary.
|
|
119
119
|
- `residuals` — the **OFFLINE-SAFE ledger of record**: every residual the workflow
|
|
120
|
-
could not finish, each `{ card, kind, evidence, materialized }`.
|
|
121
|
-
|
|
122
|
-
|
|
120
|
+
could not finish, each `{ card, kind, evidence, materialized }`. The `materialized`
|
|
121
|
+
flag is **advisory only** — `true` means the workflow *attempted* a write (possibly
|
|
122
|
+
into a worktree that never merged), not that a card exists on disk in the main repo.
|
|
123
|
+
**You (the skill) must reconcile EVERY residual against the main-repo disk** (Step 5.1).
|
|
123
124
|
- `degraded` / `degradationReasons` — the batch stopped early under a sustained
|
|
124
125
|
outage (or another degradation). The batch is NOT complete; it must be resumed.
|
|
125
126
|
- `telemetry` — the Phase-8 record (`variant:"new2"`).
|
|
126
127
|
|
|
127
128
|
### Step 5 — Reconcile, resume, present, record
|
|
128
129
|
|
|
129
|
-
1. **Materialise
|
|
130
|
-
workflow
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
`
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
130
|
+
1. **Materialise follow-ups in the MAIN repo — verify on disk, do NOT trust `materialized`
|
|
131
|
+
(F-040).** The workflow's agents run cd'd into the *worktree*, so any follow-up they wrote
|
|
132
|
+
may live in a worktree that was NOT merged (and is now gone) — `materialized:true` only means
|
|
133
|
+
"the workflow attempted a write", never proof on disk. So for **every** `residuals[]` entry
|
|
134
|
+
(regardless of the `materialized` flag), check whether a matching follow-up card actually exists
|
|
135
|
+
on disk under `${paths.backlog_dir}` in the **MAIN repo** (`<card>-followup-*.yml`). If it is
|
|
136
|
+
absent, create it by **delegating to the `prd-card-writer` agent** — the same owner the workflow
|
|
137
|
+
uses (card-template, Rule C `review_profile`, `owner_agent` routed to the residual's domain,
|
|
138
|
+
traceability) — derived from the residual (≥1 requirement; `acceptance_criteria` = the verbatim
|
|
139
|
+
residual; `files_likely_touched` from the card's ownership). Do NOT hand-write a minimal stub —
|
|
140
|
+
the offline path must match agent-path quality (F-039); it MUST pass the `/new` pre-flight field
|
|
141
|
+
check. If `prd-card-writer` is unavailable (total outage), fall back to a minimal valid stub. This
|
|
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 (F-040/H).** Some committed cards
|
|
144
|
+
were intentionally left **NON-DONE** because they carry an open owner-gated/policy-deferred AC
|
|
145
|
+
(e.g. a pending remote `db:push`): they are `perCardResults[]` entries with `deferred:true` (also
|
|
146
|
+
`cards_deferred_done_pending` in telemetry + the `F040-deferred` ledger row). For each such card,
|
|
147
|
+
now that step 1 guaranteed its deferral's follow-up exists on disk in the main repo, set the card
|
|
148
|
+
`status: DONE` + `completed_date` + an implementation_note (`"DONE post-run (new2) — AC deferred to
|
|
149
|
+
follow-up <id>"`) in `${paths.backlog_dir}/<card>.yml`, and fold all of them into ONE reconciliation
|
|
150
|
+
commit in the MAIN repo. **If a card's follow-up could NOT be created in step 1, leave it NON-DONE
|
|
151
|
+
and surface it** — fail-loud; NEVER mark a card DONE with a silently-dropped requirement (F-029).
|
|
152
|
+
3. **Resume if degraded.** If `degraded` is true, re-invoke the workflow with
|
|
142
153
|
`Workflow({ scriptPath, resumeFromRunId })` (same `args` + the new `ts`). The
|
|
143
154
|
per-card **skip-completed** guard makes the resume idempotent — already-committed
|
|
144
155
|
cards are skipped, only the incomplete/blocked ones run. Repeat until `degraded`
|
|
145
156
|
is false (or the same cards stall twice → surface to the user).
|
|
146
|
-
|
|
157
|
+
4. **Present.** Print `report` verbatim. Surface `residuals` prominently
|
|
147
158
|
("questi residui sono tracciati come follow-up: …") — the post-run review that
|
|
148
159
|
replaced the ~25 mid-run questions. If `degraded`, say so plainly (the run was
|
|
149
160
|
incomplete and resumed).
|
|
150
|
-
|
|
151
|
-
`${metricsDir}/skill-runs.jsonl`, fill the fields the workflow could not compute
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
`
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
the
|
|
158
|
-
|
|
161
|
+
5. **Record truthful telemetry — reconciled against disk (F-040).** Before appending `telemetry`
|
|
162
|
+
to `${metricsDir}/skill-runs.jsonl`, fill the fields the workflow could not compute and
|
|
163
|
+
**reconcile the report against the real disk state** (agent `reason` strings can over-claim — a
|
|
164
|
+
residual may say "AC PASS / migration created" about a change a rollback later erased). Verify:
|
|
165
|
+
every `perCardResults` entry marked `committed` actually has a commit in `${trunk}`
|
|
166
|
+
(`git -C $MAIN log --oneline ${trunk} | grep <card>`); annotate any divergence and never present
|
|
167
|
+
progress the disk does not show. Then fill `wall_clock_s` (now − kickoff `ts`) and
|
|
168
|
+
`followups_on_disk` (count the actual follow-up files on disk in the main repo, NOT
|
|
169
|
+
`residualFollowups.length` — which double-counts). `total_tokens`/`agent_count` come from the
|
|
170
|
+
workflow; if `total_tokens` is null, run the `/new` Phase-8 `-stats` script to backfill real
|
|
171
|
+
`usage`. Keep `degraded`/`degradation_reasons` + `cards_deferred_done_pending` in the record so
|
|
172
|
+
the A/B comparison stays honest. Do NOT re-summarise the cards — the workflow already did.
|
|
@@ -153,12 +153,12 @@ if (kind === 'scope-expansion') {
|
|
|
153
153
|
`If INTEGRATE: apply (you are ${fixerAgent}), re-run lint+tsc, return applied:true verified:true. If FOLLOW-UP: applied:false verified:false note:'needs-followup: <why>'.`,
|
|
154
154
|
{ label: `resolve:scope:${card}`, phase: 'Repair', agentType: fixerAgent, schema: FIX_SCHEMA }
|
|
155
155
|
)
|
|
156
|
-
} catch (e) { if (e && e.transientExhausted) return { status: 'followup', reason: 'outage during scope-expansion', outOfScopeFindings: [] }; throw e }
|
|
156
|
+
} catch (e) { if (e && e.transientExhausted) return { status: 'followup', reason: 'outage during scope-expansion', deferralClass: 'outage', outOfScopeFindings: [] }; throw e }
|
|
157
157
|
if (decide && decide.verified) {
|
|
158
158
|
const ok = await judgeVerify([{ i: 1, r: decide }])
|
|
159
159
|
if (ok.ok) { log('scope-expansion integrated within ownership.'); return { status: 'resolved', outOfScopeFindings: collectOOS(decide) } }
|
|
160
160
|
}
|
|
161
|
-
return await materialiseFollowup('scope-expansion', (decide && decide.note) || 'outside ownership / new AC / protected', collectOOS(decide))
|
|
161
|
+
return await materialiseFollowup('scope-expansion', (decide && decide.note) || 'outside ownership / new AC / protected', collectOOS(decide), 'scope-expansion')
|
|
162
162
|
}
|
|
163
163
|
|
|
164
164
|
// ───────────────────────────────────────────────────────────────────────────
|
|
@@ -179,7 +179,7 @@ try {
|
|
|
179
179
|
`Tier-1 targeted repair for card ${card} (${kind}).\n\n${brief}\n\n${gateHint}\n\nApply the minimal correct fix within MAY-EDIT only. Re-run the originating gate and report verified honestly (never claim verified without re-running it).`,
|
|
180
180
|
{ label: `resolve:${kind}:${card}`, phase: 'Repair', agentType: fixerAgent, schema: FIX_SCHEMA }
|
|
181
181
|
)
|
|
182
|
-
} catch (e) { if (e && e.transientExhausted) return { status: 'followup', reason: 'outage during tier-1', outOfScopeFindings: [] }; throw e }
|
|
182
|
+
} catch (e) { if (e && e.transientExhausted) return { status: 'followup', reason: 'outage during tier-1', deferralClass: 'outage', outOfScopeFindings: [] }; throw e }
|
|
183
183
|
|
|
184
184
|
// F-008 — terminal short-circuit, verified not trusted.
|
|
185
185
|
if (attempt && attempt.terminal) {
|
|
@@ -197,7 +197,7 @@ if (attempt && attempt.terminal) {
|
|
|
197
197
|
confirmed = !!(tj && tj.confirmed)
|
|
198
198
|
} catch (_) { confirmed = false }
|
|
199
199
|
}
|
|
200
|
-
if (confirmed) { log(`${kind} terminal (${tr}) — short-circuit to follow-up.`); return await materialiseFollowup(kind, `terminal: ${tr} — ${attempt.note || ''}`, collectOOS(attempt)) }
|
|
200
|
+
if (confirmed) { log(`${kind} terminal (${tr}) — short-circuit to follow-up.`); return await materialiseFollowup(kind, `terminal: ${tr} — ${attempt.note || ''}`, collectOOS(attempt), tr || 'unresolved') }
|
|
201
201
|
log(`terminal verdict (${tr}) rejected — proceeding to multi-attempt.`)
|
|
202
202
|
}
|
|
203
203
|
|
|
@@ -242,7 +242,7 @@ if (canFanOut && !protectedDomain) {
|
|
|
242
242
|
log('budget near target — skipping tier-2 fan-out.')
|
|
243
243
|
}
|
|
244
244
|
|
|
245
|
-
return await materialiseFollowup(kind, (attempt && attempt.note) || 'unresolved after repair tiers', collectOOS(attempt).concat(tier2OOS))
|
|
245
|
+
return await materialiseFollowup(kind, (attempt && attempt.note) || 'unresolved after repair tiers', collectOOS(attempt).concat(tier2OOS), 'unresolved')
|
|
246
246
|
|
|
247
247
|
// ───────────────────────────────────────────────────────────────────────────
|
|
248
248
|
// F-015/F-033 — mandatory adversarial judge + deterministic JS cross-check.
|
|
@@ -266,11 +266,20 @@ async function judgeVerify(verifiedAttempts) {
|
|
|
266
266
|
return { ok: true, best: judge.best }
|
|
267
267
|
}
|
|
268
268
|
|
|
269
|
-
|
|
269
|
+
// F-040 — `deferralClass` (4th arg) classifies WHY this became a follow-up so new2.js can
|
|
270
|
+
// decide whether the CARD should still commit. owner-gated / not-a-code-defect → the card's
|
|
271
|
+
// own code is complete; the residual is an external step (do NOT roll the card back). Anything
|
|
272
|
+
// else (unresolved code defect, out-of-ownership, baseline) → genuine block → rollback as before.
|
|
273
|
+
// The classifier flows back through resolve() in new2.js; never write it into the worktree.
|
|
274
|
+
async function materialiseFollowup(k, reason, oos, deferralClass) {
|
|
275
|
+
const cls = deferralClass || 'unresolved'
|
|
270
276
|
let r = null
|
|
271
277
|
try {
|
|
272
278
|
// F-039 — backlog cards are owned by prd-card-writer (card-template + Rule C
|
|
273
279
|
// review_profile + owner_agent + traceability), NOT a hand-written Haiku stub.
|
|
280
|
+
// F-040 — the workflow agent runs cd'd into the worktree, so this write is BEST-EFFORT
|
|
281
|
+
// (it rides the merge if the batch merges). The SKILL is the SSOT: it verifies/creates the
|
|
282
|
+
// card in the MAIN repo post-run, so a non-merged batch never loses the follow-up.
|
|
274
283
|
r = await agentSafe(
|
|
275
284
|
`Create ONE follow-up backlog card so this residual is TRACKED, not dropped (per ${REF}/completeness.md Phase 2.5b option 3). You are prd-card-writer: apply your card-template, Rule C (review_profile), owner_agent routing, and traceability rules — do NOT emit a minimal stub.\n\n${brief}\nKind: ${k}\nResidual domain: ${domain}\nReason unresolved: ${reason}\n\n` +
|
|
276
285
|
`Write ${backlogDir}/${card}-followup-<gate>.yml with status: TODO, derived from the residual: requirements + acceptance_criteria (the verbatim residual as ≥1 AC), owner_agent routed to the residual domain (${domain}), review_profile per Rule C, files_likely_touched ≥1 from the card ownership / remedy files. It MUST pass the /new pre-flight field check. Return the created card id.`,
|
|
@@ -280,9 +289,9 @@ async function materialiseFollowup(k, reason, oos) {
|
|
|
280
289
|
// F-020 — could not materialise (e.g. outage): return WITHOUT a followupCard so the
|
|
281
290
|
// SKILL writes it from the offline-safe residual ledger. Never claim it was created.
|
|
282
291
|
log(`follow-up materialisation failed (${String(e && e.message)}) — skill will reconcile.`)
|
|
283
|
-
return { status: 'followup', followupCard: null, reason, outOfScopeFindings: oos || [] }
|
|
292
|
+
return { status: 'followup', followupCard: null, reason, deferralClass: cls, outOfScopeFindings: oos || [] }
|
|
284
293
|
}
|
|
285
294
|
const followupCard = (r && r.created && r.followupCard) ? r.followupCard : null
|
|
286
295
|
log(`${k} → follow-up ${followupCard || '(deferred to skill)'} (nothing dropped).`)
|
|
287
|
-
return { status: 'followup', followupCard, reason, outOfScopeFindings: oos || [] }
|
|
296
|
+
return { status: 'followup', followupCard, reason, deferralClass: cls, outOfScopeFindings: oos || [] }
|
|
288
297
|
}
|
|
@@ -70,6 +70,11 @@ function sig(card, gate, evidence) {
|
|
|
70
70
|
const e = String(evidence || '').toLowerCase().replace(/\s+/g, ' ').replace(/[0-9a-f]{7,40}/g, '#').trim().slice(0, 160)
|
|
71
71
|
return `${card}::${String(gate || '').toLowerCase()}::${e}`
|
|
72
72
|
}
|
|
73
|
+
// F-040 (Fix G) — AC-deferral key on AC NUMBER ONLY. The full-text sig() drifted between the
|
|
74
|
+
// pre-flight policyDeferredACs[].text and the implement agent's unmetACs[].text, so a policy-
|
|
75
|
+
// deferred AC got re-routed to resolve() a second time (the migration-card double-routing). This
|
|
76
|
+
// coarse key is scoped to ac-defer so it never collides with a freeform 'blocker' finding.
|
|
77
|
+
function acSig(card, n) { return `${card}::ac-defer::ac-${String(n)}` }
|
|
73
78
|
|
|
74
79
|
function ledger(card, gate, decision, detail) {
|
|
75
80
|
gateLedger.push({ card, gate, decision, detail: detail || '' })
|
|
@@ -163,6 +168,7 @@ const MERGE_SCHEMA = {
|
|
|
163
168
|
mergeTs: { type: 'string' },
|
|
164
169
|
reconciliation: { type: 'string' },
|
|
165
170
|
forcedDone: { type: 'array', items: { type: 'string' }, description: 'MUST be empty — false-DONE is forbidden (F-029)' },
|
|
171
|
+
deferredLeftOpen: { type: 'array', items: { type: 'string' }, description: 'F-040 — committed cards left NON-DONE (open owner-gated AC); the skill marks them DONE post-run' },
|
|
166
172
|
epicsClosed: { type: 'array', items: { type: 'string' }, description: 'Epic/parent cards marked DONE by Phase 6b step 5e (all children DONE) — NOT a forcedDone violation' },
|
|
167
173
|
uncommittedLeft: { type: 'boolean', description: 'true if dirty code was left (NOT committed) + reported (F-030)' },
|
|
168
174
|
note: { type: 'string' },
|
|
@@ -208,7 +214,7 @@ try {
|
|
|
208
214
|
`• 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` +
|
|
209
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` +
|
|
210
216
|
`• 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` +
|
|
211
|
-
`• Complexity (setup.md 3c): decide executionMode sequential|team (+ groups for team). Build the file-ownership map → /tmp; return ownershipMapPath.\n` +
|
|
217
|
+
`• Complexity (setup.md 3c): decide executionMode sequential|team (+ groups for team). 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` +
|
|
212
218
|
`• Persist per-card architecture baselines to /tmp/arch-baseline-<CARD>.md; return archBaselinePaths.\n\n` +
|
|
213
219
|
`Return the structured PREFLIGHT object. ok:false ONLY if the workspace is unworkable.`,
|
|
214
220
|
{ label: 'preflight', phase: 'Pre-flight', agentType: 'general-purpose', schema: PREFLIGHT_SCHEMA }
|
|
@@ -249,6 +255,8 @@ for (const n of cardGraph) {
|
|
|
249
255
|
for (const ac of (n.policyDeferredACs || [])) {
|
|
250
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 })
|
|
251
257
|
acceptedDeferrals.add(sig(n.id, 'ac-unmet', `AC-${ac.n}: ${ac.text}`))
|
|
258
|
+
acceptedDeferrals.add(acSig(n.id, ac.n)) // F-040 (Fix G) — text-drift-proof AC key
|
|
259
|
+
|
|
252
260
|
ledger(n.id, 'F016-policy-defer', 'DEFERRED-BY-POLICY', `AC-${ac.n} → follow-up (owner: ${ac.owningCard || ac.owningFile || '?'})`)
|
|
253
261
|
}
|
|
254
262
|
}
|
|
@@ -274,11 +282,15 @@ function domainMayEdit(dom, codeScope) {
|
|
|
274
282
|
return docPaths.length ? docPaths : codeScope // doc-only ownership; fall back to code scope if no doc paths configured
|
|
275
283
|
}
|
|
276
284
|
|
|
285
|
+
// F-040 — returns { status:'resolved'|'followup'|'fatal', deferralClass }. deferralClass tells
|
|
286
|
+
// the caller WHY a followup happened: 'owner-gated'/'not-a-code-defect' → the card's own code is
|
|
287
|
+
// complete (external/infra step remains) → caller must NOT roll the card back; anything else
|
|
288
|
+
// ('unresolved'/'out-of-ownership'/'baseline-not-reached'/'outage') → genuine block → rollback.
|
|
277
289
|
async function resolve(kind, card, evidence, extra) {
|
|
278
290
|
const s = sig(card, kind, evidence)
|
|
279
291
|
if (resolvedSignatures.has(s) || acceptedDeferrals.has(s)) {
|
|
280
292
|
ledger(card, 'resolve:' + kind, 'DEDUP-SKIP', 'already resolved/deferred this run')
|
|
281
|
-
return 'resolved'
|
|
293
|
+
return { status: 'resolved', deferralClass: null }
|
|
282
294
|
}
|
|
283
295
|
resolvedSignatures.add(s)
|
|
284
296
|
const dom = (extra && extra.domain) || 'code'
|
|
@@ -295,10 +307,11 @@ async function resolve(kind, card, evidence, extra) {
|
|
|
295
307
|
})
|
|
296
308
|
} catch (e) {
|
|
297
309
|
if (e && (e.transientExhausted || isTransient(e))) noteDegraded('outage')
|
|
298
|
-
res = { status: 'followup', reason: 'resolve workflow error: ' + String(e && e.message) }
|
|
310
|
+
res = { status: 'followup', reason: 'resolve workflow error: ' + String(e && e.message), deferralClass: 'outage' }
|
|
299
311
|
}
|
|
300
312
|
const status = (res && res.status) || 'followup'
|
|
301
|
-
|
|
313
|
+
const deferralClass = (res && res.deferralClass) || null
|
|
314
|
+
if (status === 'fatal') { batchFatal = true; ledger(card, 'resolve:' + kind, 'FATAL', (res && res.reason) || ''); return { status, deferralClass } }
|
|
302
315
|
if (status === 'followup') {
|
|
303
316
|
acceptedDeferrals.add(s) // F-028 — a deferred residual must not be re-routed by a later gate.
|
|
304
317
|
const fc = (res && res.followupCard) || null
|
|
@@ -310,7 +323,7 @@ async function resolve(kind, card, evidence, extra) {
|
|
|
310
323
|
residuals.push({ card, kind: 'out-of-scope', evidence: `${osf.file || ''}:${osf.line || ''} ${osf.evidence || ''}`, materialized: false })
|
|
311
324
|
}
|
|
312
325
|
ledger(card, 'resolve:' + kind, status, (res && (res.followupCard || res.reason)) || '')
|
|
313
|
-
return status
|
|
326
|
+
return { status, deferralClass }
|
|
314
327
|
}
|
|
315
328
|
|
|
316
329
|
// ───────────────────────────────────────────────────────────────────────────
|
|
@@ -337,6 +350,11 @@ async function runCard(cardId, cardPath, lessons) {
|
|
|
337
350
|
const node = graphById[cardId] || {}
|
|
338
351
|
const ownerAgent = node.ownerAgent || 'coder'
|
|
339
352
|
const reviewProfile = node.reviewProfile || 'balanced'
|
|
353
|
+
// F-040/H — a card carrying an open owner-gated/policy-deferred AC commits its code but stays
|
|
354
|
+
// NON-DONE; the SKILL marks it DONE post-run only after the deferral's follow-up exists on disk
|
|
355
|
+
// in the main repo. Seeded from the pre-flight policy-deferred ACs; set by any owner-gated review
|
|
356
|
+
// deferral or unmet-AC follow-up below.
|
|
357
|
+
let deferredOpen = ((node.policyDeferredACs) || []).length > 0
|
|
340
358
|
function g(name, decision, detail) { gates.push({ gate: name, decision, detail: detail || '' }); ledger(cardId, name, decision, detail) }
|
|
341
359
|
|
|
342
360
|
// F-026 — skip-completed: only if committed AND gates green for that sha AND no open follow-up.
|
|
@@ -361,10 +379,11 @@ async function runCard(cardId, cardPath, lessons) {
|
|
|
361
379
|
impl = await agentSafe(
|
|
362
380
|
`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` +
|
|
363
381
|
`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` +
|
|
364
|
-
`
|
|
382
|
+
`E4 OWNERSHIP RECONCILE (implement.md §11b — do this BEFORE returning): the card's MAY-EDIT includes files_likely_touched ∪ paths NAMED EXPLICITLY in this card's acceptance_criteria/definition_of_done (e.g. an ADR the DoD says to update, the data-model / ER doc for a schema change). Editing THOSE is in-scope. For any OTHER dirty file outside MAY-EDIT (another card's file, or unrelated): \`git checkout -- <file>\` to revert it (NEVER leave it orphaned), list it in revertedOutOfOwnership. Set fileDiffViolation:true ONLY if such an edit genuinely could not be reverted (then say why in note) — it is no longer a silent label.\n\n` +
|
|
383
|
+
`Return: { epic, buildBlocked, blockedGate, unmetACs:[{n,text}], scopeFiles, mayEditPaths, revertedOutOfOwnership:[paths], fileDiffViolation, note }`,
|
|
365
384
|
{ label: `implement:${cardId}`, phase: 'Implement', agentType: ownerAgent,
|
|
366
385
|
schema: { type: 'object', required: ['epic', 'buildBlocked', 'unmetACs', 'scopeFiles'], additionalProperties: true,
|
|
367
|
-
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' } } } }
|
|
386
|
+
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' } } } }
|
|
368
387
|
)
|
|
369
388
|
} catch (e) {
|
|
370
389
|
if (e && e.transientExhausted) { noteDegraded('outage'); return { card: cardId, status: 'pending', gates, telemetry: tele } }
|
|
@@ -375,19 +394,28 @@ async function runCard(cardId, cardPath, lessons) {
|
|
|
375
394
|
|
|
376
395
|
const mayEdit = (impl && impl.mayEditPaths) || []
|
|
377
396
|
const scopeFiles = (impl && impl.scopeFiles) || []
|
|
378
|
-
|
|
397
|
+
// F-040 — E4 honest label: 'AUTO-REVERTED' used to be a no-op log (files were left orphaned).
|
|
398
|
+
// Now the owner agent reconciles out-of-ownership edits itself (implement.md §11b); we report
|
|
399
|
+
// what it actually did. A genuine unresolved violation becomes a tracked residual, never silent.
|
|
400
|
+
const reverted = (impl && impl.revertedOutOfOwnership) || []
|
|
401
|
+
if (reverted.length) g('E4-file-diff', 'REVERTED', `out-of-ownership reverted: ${reverted.join(', ')}`)
|
|
402
|
+
if (impl && impl.fileDiffViolation) {
|
|
403
|
+
g('E4-file-diff', 'FLAGGED', 'unresolved out-of-ownership edit — tracked as residual')
|
|
404
|
+
residuals.push({ card: cardId, kind: 'file-diff-violation', evidence: `unresolved out-of-ownership edit: ${(impl && impl.note) || ''}`, materialized: false })
|
|
405
|
+
}
|
|
379
406
|
|
|
380
407
|
if (impl && impl.buildBlocked) {
|
|
381
|
-
const s = await resolve('blocker', cardId, `Phase-2 gate failing: ${impl.blockedGate}`, { mayEditPaths: mayEdit, scopeFiles, domain: 'code' })
|
|
408
|
+
const s = (await resolve('blocker', cardId, `Phase-2 gate failing: ${impl.blockedGate}`, { mayEditPaths: mayEdit, scopeFiles, domain: 'code' })).status
|
|
382
409
|
g('G26-build', s === 'resolved' ? 'RESOLVED' : 'FOLLOWUP', impl.blockedGate)
|
|
383
410
|
if (s !== 'resolved') { await rollbackCard(cardId, mayEdit); return { card: cardId, status: 'followup', gates, commit: '-', scopeFiles, telemetry: tele } }
|
|
384
411
|
}
|
|
385
412
|
|
|
386
413
|
// F-010/F-016 — unmet ACs that are policy-deferred are skipped (already tracked).
|
|
387
414
|
for (const ac of (impl && impl.unmetACs) || []) {
|
|
388
|
-
if (acceptedDeferrals.has(sig(cardId, 'ac-unmet', `AC-${ac.n}: ${ac.text}`))) { g('G7-ac-closure', 'DEFERRED-BY-POLICY', `AC-${ac.n}`); continue }
|
|
389
|
-
const s = await resolve('ac-unmet', cardId, `AC-${ac.n}: ${ac.text}`, { mayEditPaths: mayEdit, scopeFiles, domain: 'code' })
|
|
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 s = (await resolve('ac-unmet', cardId, `AC-${ac.n}: ${ac.text}`, { mayEditPaths: mayEdit, scopeFiles, domain: 'code' })).status
|
|
390
417
|
g('G7-ac-closure', s === 'resolved' ? 'RESOLVED' : 'FOLLOWUP', `AC-${ac.n}`)
|
|
418
|
+
if (s !== 'resolved') deferredOpen = true // F-040/H — unmet AC tracked as follow-up → card stays NON-DONE
|
|
391
419
|
}
|
|
392
420
|
|
|
393
421
|
// --- Review fan-out (F-024/F-025): specialized agents, trimmed by review_profile. ---
|
|
@@ -437,23 +465,38 @@ async function runCard(cardId, cardPath, lessons) {
|
|
|
437
465
|
let cardBlocked = false
|
|
438
466
|
for (const b of blocks) {
|
|
439
467
|
const kind = /e2e/i.test(b.gate) ? 'e2e-blocked' : /qa/i.test(b.gate) ? 'qa-fail' : 'blocker'
|
|
440
|
-
const
|
|
441
|
-
|
|
442
|
-
|
|
468
|
+
const r = await resolve(kind, cardId, `${b.gate}: ${b.evidence}`, { mayEditPaths: mayEdit, scopeFiles, domain: b.domain || 'code' })
|
|
469
|
+
// F-040 — THE primary fix. An owner-gated / not-a-code-defect deferral means the card's OWN
|
|
470
|
+
// code is complete and correct; the residual is an external/infra step (e.g. a remote db push)
|
|
471
|
+
// already tracked as a follow-up. Do NOT roll the card back — it proceeds to commit, NON-DONE
|
|
472
|
+
// (the skill marks it DONE post-run once the follow-up exists on disk). This replaces the old
|
|
473
|
+
// `s !== 'resolved' → cardBlocked` which destroyed a completed migration card's work over a db:push gate.
|
|
474
|
+
// A genuine unresolved CODE defect (or out-of-ownership/baseline/outage) still blocks + rolls back.
|
|
475
|
+
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
|
|
478
|
+
else if (r.status !== 'resolved') cardBlocked = true
|
|
443
479
|
}
|
|
444
480
|
for (const sx of scopeExp) {
|
|
445
|
-
const s = await resolve('scope-expansion', cardId, sx.evidence || '', { mayEditPaths: mayEdit, scopeFiles, domain: sx.domain || 'code' })
|
|
481
|
+
const s = (await resolve('scope-expansion', cardId, sx.evidence || '', { mayEditPaths: mayEdit, scopeFiles, domain: sx.domain || 'code' })).status
|
|
446
482
|
g('scope-expansion', s === 'resolved' ? 'INTEGRATED' : 'FOLLOWUP', sx.evidence || '')
|
|
447
483
|
}
|
|
448
484
|
|
|
449
485
|
if (cardBlocked) { await rollbackCard(cardId, mayEdit); return { card: cardId, status: 'followup', gates, commit: '-', scopeFiles, archBaselinePath: `/tmp/arch-baseline-${cardId}.md`, telemetry: tele } }
|
|
450
486
|
|
|
451
487
|
// --- Phase 4 — commit (F-023: Haiku + git-status reconcile, never git add -A). ---
|
|
488
|
+
// F-040/H — DONE policy. A card with an OPEN owner-gated/policy-deferred AC commits its code but
|
|
489
|
+
// must NOT be marked DONE here (its own DoD isn't met yet — e.g. the remote db:push is pending).
|
|
490
|
+
// The new2 SKILL marks it DONE post-run, ONLY after the deferral's follow-up exists on disk in the
|
|
491
|
+
// main repo (so a card is never DONE with a silently-dropped requirement — F-029).
|
|
492
|
+
const doneStep = deferredOpen
|
|
493
|
+
? `(4) DO NOT mark the card DONE: it has an OPEN owner-gated/policy-deferred AC. Keep status IN_PROGRESS and add an implementation_note "deferred — DONE pending follow-up (new2 skill reconciles post-run)". STILL add the ssot-registry row for the committed code.`
|
|
494
|
+
: `(4) mark the card DONE in its YAML + add the ssot-registry row.`
|
|
452
495
|
let commitRes
|
|
453
496
|
try {
|
|
454
497
|
commitRes = await agentSafe(
|
|
455
498
|
`Commit card ${cardId} in worktree ${sharedCtx.worktreePath}. MECHANICAL — do NOT re-read reference modules.\n` +
|
|
456
|
-
`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>\`;
|
|
499
|
+
`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>\`; ${doneStep} (5) 'nothing to commit' = already committed (record HEAD).\n` +
|
|
457
500
|
`On COMMIT_LOCK: clear stale lock + retry once. Still locked → committed:false.\n\n` +
|
|
458
501
|
`Return: { committed, commit, filesChanged, reconcileNote }`,
|
|
459
502
|
{ label: `commit:${cardId}`, phase: 'Implement', agentType: 'general-purpose', model: 'haiku',
|
|
@@ -465,17 +508,20 @@ async function runCard(cardId, cardPath, lessons) {
|
|
|
465
508
|
}
|
|
466
509
|
|
|
467
510
|
if (!commitRes || !commitRes.committed) {
|
|
468
|
-
const s = await resolve('blocker', cardId, 'commit blocked after retries', { mayEditPaths: mayEdit, scopeFiles, domain: 'code' })
|
|
511
|
+
const s = (await resolve('blocker', cardId, 'commit blocked after retries', { mayEditPaths: mayEdit, scopeFiles, domain: 'code' })).status
|
|
469
512
|
g('G16-commit', s === 'resolved' ? 'RESOLVED' : 'FOLLOWUP')
|
|
470
513
|
if (s !== 'resolved') { await rollbackCard(cardId, mayEdit); return { card: cardId, status: 'followup', gates, commit: '-', scopeFiles, archBaselinePath: `/tmp/arch-baseline-${cardId}.md`, telemetry: tele } }
|
|
471
514
|
}
|
|
472
515
|
if (commitRes && commitRes.reconcileNote) g('commit-reconcile', 'NOTE', commitRes.reconcileNote)
|
|
473
516
|
|
|
474
|
-
g('commit', 'COMMITTED', (commitRes && commitRes.commit) || '')
|
|
517
|
+
g('commit', 'COMMITTED', `${(commitRes && commitRes.commit) || ''}${deferredOpen ? ' (NON-DONE — deferred, skill reconciles)' : ''}`)
|
|
475
518
|
return {
|
|
476
519
|
card: cardId, status: 'committed',
|
|
477
520
|
commit: (commitRes && commitRes.commit) || '-',
|
|
478
521
|
filesChanged: (commitRes && commitRes.filesChanged) || [],
|
|
522
|
+
// 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.
|
|
524
|
+
deferred: deferredOpen,
|
|
479
525
|
scopeFiles, archBaselinePath: `/tmp/arch-baseline-${cardId}.md`, gates, telemetry: tele,
|
|
480
526
|
}
|
|
481
527
|
}
|
|
@@ -594,14 +640,14 @@ if (committed.length && !batchFatal && !degraded) {
|
|
|
594
640
|
}
|
|
595
641
|
for (const area of Object.keys(byArea)) {
|
|
596
642
|
const group = byArea[area]
|
|
597
|
-
const s = await resolve('merge-blocker', group[0].finding_id || firstCard,
|
|
643
|
+
const s = (await resolve('merge-blocker', group[0].finding_id || firstCard,
|
|
598
644
|
group.map((f) => `${f.severity} ${f.title}: ${f.evidence}`).join(' || '),
|
|
599
645
|
{ mayEditPaths: reviewScopeFiles, scopeFiles: reviewScopeFiles, domain: group[0].domain || 'code',
|
|
600
|
-
findings: group.map((f) => ({ kind: 'merge-blocker', evidence: `${f.title}: ${f.evidence}`, domain: f.domain || 'code' })) })
|
|
646
|
+
findings: group.map((f) => ({ kind: 'merge-blocker', evidence: `${f.title}: ${f.evidence}`, domain: f.domain || 'code' })) })).status
|
|
601
647
|
if (s !== 'resolved') mergeBlocked = true
|
|
602
648
|
}
|
|
603
649
|
if (finalSummary && finalSummary.failingGates && finalSummary.failingGates.length) {
|
|
604
|
-
const s = await resolve('qa-fail', firstCard, `final gates failing: ${finalSummary.failingGates.join(', ')}`, { mayEditPaths: reviewScopeFiles, scopeFiles: reviewScopeFiles, domain: 'code' })
|
|
650
|
+
const s = (await resolve('qa-fail', firstCard, `final gates failing: ${finalSummary.failingGates.join(', ')}`, { mayEditPaths: reviewScopeFiles, scopeFiles: reviewScopeFiles, domain: 'code' })).status
|
|
605
651
|
if (s !== 'resolved') mergeBlocked = true
|
|
606
652
|
}
|
|
607
653
|
} else {
|
|
@@ -621,6 +667,10 @@ if (committed.length && !batchFatal && !degraded) {
|
|
|
621
667
|
phase('Merge')
|
|
622
668
|
let mergeResult = null
|
|
623
669
|
const incomplete = runnableCards.filter((id) => state[id] !== 'committed' && state[id] !== 'epic-skipped')
|
|
670
|
+
// F-040/H — committed cards intentionally left NON-DONE (open owner-gated/policy-deferred AC). They
|
|
671
|
+
// ARE merged (their code is complete), but Phase 6b must NOT force them to DONE; the SKILL does that
|
|
672
|
+
// post-run once the deferral's follow-up exists on disk. They count as complete for the merge gate.
|
|
673
|
+
const deferredCards = committed.filter((r) => r.deferred).map((r) => r.card)
|
|
624
674
|
const integrityOK = committed.length > 0 && !mergeBlocked && !batchFatal && !degraded && incomplete.length === 0
|
|
625
675
|
if (!committed.length) {
|
|
626
676
|
ledger(firstCard, 'merge', 'SKIPPED', 'no committed cards')
|
|
@@ -635,15 +685,23 @@ if (!committed.length) {
|
|
|
635
685
|
`• G24 → auto-merge via merge_strategy.\n` +
|
|
636
686
|
`• 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` +
|
|
637
687
|
`• 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
|
+
`• 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` +
|
|
638
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` +
|
|
639
690
|
`• 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` +
|
|
640
|
-
`Return: { merged, mergeCommit, mergeTs, reconciliation, forcedDone:[], uncommittedLeft, note }`,
|
|
691
|
+
`Return: { merged, mergeCommit, mergeTs, reconciliation, forcedDone:[], deferredLeftOpen:[], uncommittedLeft, note }`,
|
|
641
692
|
{ label: 'merge', phase: 'Merge', agentType: 'general-purpose', schema: MERGE_SCHEMA }
|
|
642
693
|
)
|
|
643
694
|
} catch (e) { if (e && e.transientExhausted) noteDegraded('outage'); mergeResult = null }
|
|
644
695
|
if (mergeResult && (mergeResult.forcedDone || []).length) { noteDegraded('false_done'); ledger(firstCard, 'F029-guard', 'VIOLATION', `forcedDone: ${mergeResult.forcedDone.join(' ')}`) }
|
|
645
696
|
if (mergeResult && mergeResult.uncommittedLeft) ledger(firstCard, 'F030-guard', 'LEFT-UNCOMMITTED', 'dirty code left (not swept) + reported')
|
|
646
697
|
if (mergeResult && (mergeResult.epicsClosed || []).length) ledger(firstCard, 'epic-closure', 'CLOSED', `epics marked DONE (all children DONE): ${mergeResult.epicsClosed.join(' ')}`)
|
|
698
|
+
if (deferredCards.length) {
|
|
699
|
+
ledger(firstCard, 'F040-deferred', 'LEFT-NON-DONE', `${deferredCards.join(' ')} — skill marks DONE post-run after follow-up materialises`)
|
|
700
|
+
// F-040 guard — catch a merge agent that ignored the instruction and force-DONE'd a deferred card.
|
|
701
|
+
const leftOpen = (mergeResult && mergeResult.deferredLeftOpen) || []
|
|
702
|
+
const wronglyDone = deferredCards.filter((c) => !leftOpen.includes(c))
|
|
703
|
+
if (mergeResult && wronglyDone.length) { noteDegraded('false_done'); ledger(firstCard, 'F040-guard', 'VIOLATION', `deferred cards force-DONE by merge: ${wronglyDone.join(' ')}`) }
|
|
704
|
+
}
|
|
647
705
|
ledger(firstCard, 'G24-merge', (mergeResult && mergeResult.merged) ? 'MERGED' : 'INCOMPLETE', (mergeResult && (mergeResult.mergeCommit || mergeResult.note)) || '')
|
|
648
706
|
if (mergeResult && mergeResult.reconciliation) ledger(firstCard, 'G19-23-reconcile', 'AUTO', mergeResult.reconciliation)
|
|
649
707
|
}
|
|
@@ -686,6 +744,9 @@ function buildTelemetry() {
|
|
|
686
744
|
ts: TS || null,
|
|
687
745
|
cards_total: cardIds.length,
|
|
688
746
|
cards_real_done: perCardResults.filter((r) => r.status === 'committed').length,
|
|
747
|
+
// F-040/H — committed cards left NON-DONE pending their owner-gated follow-up (the skill marks
|
|
748
|
+
// them DONE post-run). Surfaced so the A/B telemetry distinguishes "code landed" from "DONE".
|
|
749
|
+
cards_deferred_done_pending: perCardResults.filter((r) => r.deferred).length,
|
|
689
750
|
cards_force_done: 0, // F-029 — force-DONE forbidden; always 0.
|
|
690
751
|
cards_followup: perCardResults.filter((r) => r.status === 'followup').length,
|
|
691
752
|
cards_blocked: runnableCards.filter((id) => state[id] === 'blocked').length,
|
|
@@ -716,7 +777,7 @@ function buildReport(o) {
|
|
|
716
777
|
L.push(``, `## Esito card`)
|
|
717
778
|
L.push(`| Card | Status | Commit | File |`)
|
|
718
779
|
L.push(`|------|--------|--------|------|`)
|
|
719
|
-
for (const r of perCardResults) L.push(`| ${r.card} | ${r.status} | ${r.commit || '-'} | ${(r.filesChanged || []).length} |`)
|
|
780
|
+
for (const r of perCardResults) L.push(`| ${r.card} | ${r.status}${r.deferred ? ' (NON-DONE: deferred)' : ''} | ${r.commit || '-'} | ${(r.filesChanged || []).length} |`)
|
|
720
781
|
const blockedIds = runnableCards.filter((id) => state[id] === 'blocked' || state[id] === 'pending')
|
|
721
782
|
for (const id of blockedIds) L.push(`| ${id} | ${state[id]} | - | 0 |`)
|
|
722
783
|
if (finalSummary) {
|