baldart 5.5.0 → 5.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +64 -0
- package/VERSION +1 -1
- package/framework/.claude/agents/CHANGELOG.md +16 -0
- package/framework/.claude/agents/prd-card-writer.md +37 -3
- package/framework/.claude/skills/new/CHANGELOG.md +23 -0
- package/framework/.claude/skills/new/SKILL.md +1 -1
- package/framework/.claude/skills/new/references/commit.md +12 -8
- package/framework/.claude/skills/new/references/completeness.md +10 -0
- package/framework/.claude/skills/new/references/final-review.md +23 -0
- package/framework/.claude/skills/new/references/implement.md +14 -4
- package/framework/.claude/skills/new/references/merge-cleanup.md +35 -14
- package/framework/.claude/skills/new2/CHANGELOG.md +36 -0
- package/framework/.claude/skills/new2/SKILL.md +52 -12
- package/framework/.claude/skills/prd/CHANGELOG.md +36 -0
- package/framework/.claude/skills/prd/SKILL.md +17 -5
- package/framework/.claude/skills/prd/assets/state-template.md +8 -1
- package/framework/.claude/skills/prd/references/audit-phase.md +13 -1
- package/framework/.claude/skills/prd/references/backlog-phase.md +20 -0
- package/framework/.claude/skills/prd/references/discovery-phase.md +35 -0
- package/framework/.claude/skills/prd/references/ui-design-phase.md +28 -0
- package/framework/.claude/skills/prd/references/validation-phase.md +45 -1
- package/framework/.claude/skills/worktree-manager/CHANGELOG.md +13 -0
- package/framework/.claude/skills/worktree-manager/SKILL.md +11 -1
- package/framework/.claude/skills/worktree-manager/scripts/merge-worktree.sh +82 -6
- package/framework/.claude/workflows/new-final-review.js +9 -7
- package/framework/.claude/workflows/new2-resolve.js +29 -1
- package/framework/.claude/workflows/new2.js +153 -17
- package/framework/agents/card-schema.md +18 -1
- package/framework/agents/research-protocol.md +6 -1
- package/framework/scripts/stamp-holistic-audit.js +53 -10
- package/package.json +1 -1
|
@@ -85,7 +85,13 @@ const schemaDeployFromTrunkOnly = stack.schema_deploy_from_trunk_only === true
|
|
|
85
85
|
const firstCard = cardIds[0] || 'BATCH'
|
|
86
86
|
const gateLedger = [] // { card, gate, decision, detail }
|
|
87
87
|
const residualFollowups = [] // { card, kind, followupCard, reason }
|
|
88
|
-
const residuals = [] // F-020 OFFLINE-SAFE ledger: { card, kind, evidence, materialized, deferralClass, domain, remedyFiles }
|
|
88
|
+
const residuals = [] // F-020 OFFLINE-SAFE ledger: { card, kind, evidence, materialized, deferralClass, domain, remedyFiles, severity? }
|
|
89
|
+
// v5.6.0 (FEAT-0068 post-mortem) — epic-closure integrity state. closureInvalidating collects final
|
|
90
|
+
// findings that assert an epic goal/AC is NOT met (a STATE defect: a doc fix resolves the text, not
|
|
91
|
+
// the epic). closureBlockedEpics is the JS-computed deny-list the merge agent must honor: the
|
|
92
|
+
// workflow sees the residual ledger; the merge agent's grep cannot see unmaterialized residuals.
|
|
93
|
+
const closureInvalidating = [] // strings: "SEVERITY title: evidence"
|
|
94
|
+
const closureBlockedEpics = new Set()
|
|
89
95
|
const cardMayEdit = {} // v4.30.0 — per-card MAY-EDIT, for the cross-card integration union
|
|
90
96
|
const perCardResults = []
|
|
91
97
|
let prodReadiness = null
|
|
@@ -209,6 +215,10 @@ const PREFLIGHT_SCHEMA = {
|
|
|
209
215
|
designSrcDir: { type: 'string', description: "abs path of links.design_src / mockups/_src dir ('' when absent)" },
|
|
210
216
|
designHtml: { type: 'string', description: "abs path of an .html links.design mockup ('' when absent)" },
|
|
211
217
|
hasBindings: { type: 'boolean', description: 'card carries a non-empty component_bindings map' },
|
|
218
|
+
// v5.6.0 (FEAT-0068 post-mortem) — the card's parent epic id (group.parent / parent:).
|
|
219
|
+
// Drives the epic-closure blocklist (an epic with open scope residuals/follow-ups must
|
|
220
|
+
// not close) and the Epic goal section of the final report.
|
|
221
|
+
parentEpic: { type: 'string', description: "parent epic id from group.parent or parent: ('' when none)" },
|
|
212
222
|
},
|
|
213
223
|
},
|
|
214
224
|
},
|
|
@@ -237,7 +247,10 @@ const MERGE_SCHEMA = {
|
|
|
237
247
|
reconciliation: { type: 'string' },
|
|
238
248
|
forcedDone: { type: 'array', items: { type: 'string' }, description: 'MUST be empty — false-DONE is forbidden (F-029)' },
|
|
239
249
|
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' },
|
|
240
|
-
epicsClosed: { type: 'array', items: { type: 'string' }, description: 'Epic/parent cards marked DONE by Phase 6b step 5e (
|
|
250
|
+
epicsClosed: { type: 'array', items: { type: 'string' }, description: 'Epic/parent cards marked DONE by Phase 6b step 5e (children DONE + no open follow-up + epic ACs verified) — NOT a forcedDone violation' },
|
|
251
|
+
// v5.6.0 (FEAT-0068) — epics deliberately NOT closed, each with the concrete reason (workflow
|
|
252
|
+
// blocklist / open child or follow-up / unverified epic AC). Feeds the Epic goal report section.
|
|
253
|
+
epicsLeftOpen: { type: 'array', items: { type: 'object', additionalProperties: true, properties: { epic: { type: 'string' }, reason: { type: 'string' } } }, description: 'epics left open by the closure predicate, with reasons' },
|
|
241
254
|
uncommittedLeft: { type: 'boolean', description: 'true if dirty code was left (NOT committed) + reported (F-030)' },
|
|
242
255
|
status: { type: 'string', description: 'merge-worktree.sh terminal status: success|code_conflict|build_fail|sync_needs_decision|error' },
|
|
243
256
|
conflictFiles: { type: 'array', items: { type: 'string' }, description: 'on status:code_conflict — the UNRESOLVED code/test files the script paused on (left for a human / the resolver; new2 OPS agent does NOT hand-resolve)' },
|
|
@@ -292,7 +305,7 @@ try {
|
|
|
292
305
|
g3Bullet +
|
|
293
306
|
`• G4 card-field validation (setup.md 1b/1c, card-schema.md consumer contract): card missing acceptance_criteria/files_likely_touched/scope → EXCLUDE (excluded[] + reason). \`requirements\` is CONDITIONAL, not an auto-exclude: if it is missing BUT acceptance_criteria + scope are both present and non-empty, FAITHFULLY DERIVE it (a concrete restatement/decomposition of the existing AC + scope — faithful, never inventing new scope) and WRITE it back to the card YAML on disk in ${MAIN}/${paths.backlog_dir || 'backlog'} (F-040: main repo, not the worktree copy; the card artifact is NOT a source/doc file, so this is allowed despite the role boundary), logging \`[BACKFILL] <id>: requirements=<N items, AC+scope-derived>\`. Only EXCLUDE for missing requirements when acceptance_criteria OR scope is ALSO empty (a genuinely thin card). Never HALT for one bad card.\n` +
|
|
294
307
|
`• 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` +
|
|
295
|
-
`• cardGraph (REQUIRED, F-021): for every runnable card return { id, dependsOn:[IN-BATCH deps only], ownerAgent (the card's owner_agent; G25 unknown→'coder'), hasMockup (BOOLEAN — true IFF the card
|
|
308
|
+
`• cardGraph (REQUIRED, F-021): for every runnable card return { id, dependsOn:[IN-BATCH deps only], ownerAgent (the card's owner_agent; G25 unknown→'coder'), hasMockup (BOOLEAN — true IFF the card BUILDS the mocked UI: links.design_src non-empty, OR links.design non-empty AND (component_bindings non-empty OR files_likely_touched intersects component/style/UI source paths). A bare links.design on a docs/test/meta-only card is a POINTER to the epic's explainer, not a build target — v5.6.0, FEAT-0068: the bare test clamped a docs+ADR card to ui-expert; drives the mockup-first build override — implement.md §6a), parentEpic (the card's parent epic id from group.parent or a top-level parent: field; '' when none — drives the epic-closure blocklist + the Epic goal report section), designSrcDir (ABS path of links.design_src / the PRD mockups/_src dir; '' when absent), designHtml (ABS path when links.design is an .html file; '' otherwise), hasBindings (BOOLEAN — non-empty component_bindings), reviewProfile (the card's review_profile; default 'balanced'), policyDeferredACs, alreadyCommitted, alreadyCommittedSha, isEpic (implement.md §6b epic guard: id ends '-00' OR filename ends '-epic.yml' OR group.is_epic:true OR review_profile 'skip' with no requirements), filesLikelyTouched (verbatim from the YAML) }.\n` +
|
|
296
309
|
`• 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` +
|
|
297
310
|
`• 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` +
|
|
298
311
|
(migrationApplied
|
|
@@ -944,19 +957,28 @@ async function runCard(cardId, cardPath) {
|
|
|
944
957
|
// must NOT be marked DONE here (its own DoD isn't met yet — e.g. the remote db:push is pending).
|
|
945
958
|
// The new2 SKILL marks it DONE post-run, ONLY after the deferral's follow-up exists on disk in the
|
|
946
959
|
// main repo (so a card is never DONE with a silently-dropped requirement — F-029).
|
|
960
|
+
// v5.6.0 (FEAT-0068 post-mortem) — step 4b (epic closure) REMOVED from the commit agent: closing
|
|
961
|
+
// at the last card's commit ran structurally BEFORE the final review could disprove the goal, and
|
|
962
|
+
// from a context that could not see the worktree's untracked follow-up YAMLs. final-review.md:451
|
|
963
|
+
// already assigns closure to the merge phase ONLY — new2 now honors its own SSOT (Phase 6b 5e).
|
|
947
964
|
const doneStep = deferredOpen
|
|
948
965
|
? `(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.`
|
|
949
|
-
: `(4) mark the card DONE in its YAML + add the ssot-registry row.${provLine}
|
|
966
|
+
: `(4) mark the card DONE in its YAML + add the ssot-registry row.${provLine}`
|
|
967
|
+
// The card YAML the agent edits MUST be the WORKTREE copy — the FEAT-0068 commit agent `find`-ed
|
|
968
|
+
// the main-repo copies and flipped stale siblings there (79 turns of detour, main checkout dirtied).
|
|
969
|
+
const wtCardPath = `${sharedCtx.worktreePath}/${paths.backlog_dir || 'backlog'}/${String(cardPath).split('/').pop()}`
|
|
950
970
|
let commitRes
|
|
951
971
|
try {
|
|
952
972
|
commitRes = await agentSafe(
|
|
953
|
-
`Commit card ${cardId} in worktree ${sharedCtx.worktreePath}. MECHANICAL — do NOT re-read reference modules. ROLE BOUNDARY: you NEVER modify file contents except
|
|
973
|
+
`Commit card ${cardId} in worktree ${sharedCtx.worktreePath}. MECHANICAL — do NOT re-read reference modules. ROLE BOUNDARY: you NEVER modify file contents except THIS card's YAML status/note fields and the ssot-registry entries — source/doc changes are not yours; sibling and parent-epic card YAMLs are READ-ONLY (epic closure happens in the merge phase, NOT here).\n` +
|
|
974
|
+
`Card YAML (edit THIS copy and no other): ${wtCardPath}\n` +
|
|
954
975
|
`HARD RULE — cwd: EVERY shell command MUST begin with \`cd ${sharedCtx.worktreePath} && \` IN THE SAME Bash invocation. The shell cwd RESETS between Bash calls to the MAIN repo: a bare \`git commit\` executes against the main checkout's current branch and corrupts it (observed in FEAT-0067 — a card-DONE commit landed on the trunk mid-run). No exceptions, including 'quick' status/log commands.\n` +
|
|
955
|
-
`
|
|
976
|
+
`WORKTREE ISOLATION — TOOLS TOO (v5.6.0): every path you pass to Read/Edit/Write MUST be under ${sharedCtx.worktreePath}. The cwd rule does NOT protect tool calls with absolute paths — NEVER Read/Edit/Write a path under the main repo ${MAIN}, never \`find\` files there, never cp/rsync between checkouts. A file you need that is missing from the worktree is a reconcileNote, not a fetch.\n` +
|
|
977
|
+
`Steps: (0) isolation evidence: run \`git -C ${MAIN} status --porcelain\` and record its LITERAL output as mainStatusBefore; (1) \`git status --porcelain\`; (2) stage = MAY-EDIT (${JSON.stringify(mayEdit)}) ∩ dirty — NEVER \`git add -A\`, NEVER \`git stash\` (anywhere, worktree or main); 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); (6) VERIFY placement: \`cd ${sharedCtx.worktreePath} && git log -1 --format=%H\` MUST equal the sha you return — if it does not, your commit landed outside the worktree: report committed:false with reconcileNote naming the stray sha, do NOT retry blindly; (7) LAST action: re-run \`git -C ${MAIN} status --porcelain\` and record it VERBATIM as mainStatusAfter.\n` +
|
|
956
978
|
`On COMMIT_LOCK: clear stale lock + retry once. Still locked → committed:false.\n\n` +
|
|
957
|
-
`Return: { committed, commit, filesChanged, reconcileNote }`,
|
|
979
|
+
`Return: { committed, commit, filesChanged, reconcileNote, mainStatusBefore, mainStatusAfter }`,
|
|
958
980
|
{ label: `commit:${cardId}`, phase: 'Implement', agentType: 'general-purpose', model: 'sonnet',
|
|
959
|
-
schema: { type: 'object', required: ['committed'], additionalProperties: true, properties: { committed: { type: 'boolean' }, commit: { type: 'string' }, filesChanged: { type: 'array', items: { type: 'string' } }, reconcileNote: { type: 'string' } } } }
|
|
981
|
+
schema: { type: 'object', required: ['committed'], additionalProperties: true, properties: { committed: { type: 'boolean' }, commit: { type: 'string' }, filesChanged: { type: 'array', items: { type: 'string' } }, reconcileNote: { type: 'string' }, mainStatusBefore: { type: 'string', description: 'literal `git -C <main> status --porcelain` BEFORE any other action' }, mainStatusAfter: { type: 'string', description: 'same command as the LAST action' } } } }
|
|
960
982
|
)
|
|
961
983
|
} catch (e) {
|
|
962
984
|
if (e && e.transientExhausted) { noteDegraded('outage'); await rollbackCard(cardId, mayEdit); return { card: cardId, status: 'pending', gates } }
|
|
@@ -970,6 +992,20 @@ async function runCard(cardId, cardPath) {
|
|
|
970
992
|
}
|
|
971
993
|
if (commitRes && commitRes.reconcileNote) g('commit-reconcile', 'NOTE', commitRes.reconcileNote)
|
|
972
994
|
|
|
995
|
+
// v5.6.0 isolation guard (FEAT-0068 #60: prompts alone did not stop the main-repo detour) — the
|
|
996
|
+
// porcelain evidence is mechanical literal output (same trust model as E2.5): new lines in the
|
|
997
|
+
// main repo's status after the spawn = the agent dirtied the live checkout → VIOLATION + a HIGH
|
|
998
|
+
// residual so the skill reconciles the contamination post-run (it can run git; this JS cannot).
|
|
999
|
+
{
|
|
1000
|
+
const msb = String((commitRes && commitRes.mainStatusBefore) || '').split('\n').filter(Boolean)
|
|
1001
|
+
const msa = String((commitRes && commitRes.mainStatusAfter) || '').split('\n').filter(Boolean)
|
|
1002
|
+
const dirtied = msa.filter((l) => !msb.includes(l))
|
|
1003
|
+
if (dirtied.length) {
|
|
1004
|
+
g('isolation-guard', 'VIOLATION', `main checkout dirtied during commit spawn: ${dirtied.slice(0, 5).join(' | ')}${dirtied.length > 5 ? ` (+${dirtied.length - 5})` : ''}`)
|
|
1005
|
+
residuals.push({ card: cardId, kind: 'main-repo-contamination', severity: 'HIGH', evidence: `commit agent dirtied the main checkout (${dirtied.join(', ')}) — reconcile: restore/inspect these paths in ${MAIN}, never stash-and-forget`, materialized: false })
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
973
1009
|
g('commit', 'COMMITTED', `${(commitRes && commitRes.commit) || ''}${deferredOpen ? ' (NON-DONE — deferred, skill reconciles)' : ''}`)
|
|
974
1010
|
return {
|
|
975
1011
|
card: cardId, status: 'committed',
|
|
@@ -1200,6 +1236,18 @@ if (committed.length && !degraded && !skipFinalLight) {
|
|
|
1200
1236
|
finalSummary = final.summary || null
|
|
1201
1237
|
ledger(firstCard, 'final-review', 'DONE', `engine=${final.codexEngine}; verified=${final.summary ? final.summary.verified : '?'}`)
|
|
1202
1238
|
const all = (final.findings || []).filter((f) => (f.classification === 'VERIFIED' || f.classification === 'NEEDS_MANUAL_CONFIRMATION') && (f.severity === 'BLOCKER' || f.severity === 'HIGH'))
|
|
1239
|
+
// v5.6.0 — closure-invalidating detection (final-review.md § closure-invalidating): a finding
|
|
1240
|
+
// asserting an epic goal/AC is unmet blocks/reopens the epic even after its text fix resolves.
|
|
1241
|
+
// FEAT-0068: DOC-F001 ("SSOT overclaim: epic goal not achieved") was fixed as prose 13 minutes
|
|
1242
|
+
// AFTER the epic closed DONE — the state contradiction merged. Scans ALL verified findings
|
|
1243
|
+
// (any severity): an overclaim is state-relevant regardless of the reviewer's severity call.
|
|
1244
|
+
const CLOSURE_INVALIDATING_RE = /goal[^.\n]{0,60}not[^.\n]{0,25}(achiev|met\b|reach|complete)|not[^.\n]{0,25}achiev[^.\n]{0,40}goal|overclaim|DONE[^.\n]{0,40}(unmet|not[^.\n]{0,20}(achiev|implement|complete))|epic[^.\n]{0,40}(unmet|not[^.\n]{0,30}(achiev|complete|reach))/i
|
|
1245
|
+
for (const f of (final.findings || [])) {
|
|
1246
|
+
if ((f.classification === 'VERIFIED' || f.classification === 'NEEDS_MANUAL_CONFIRMATION') && CLOSURE_INVALIDATING_RE.test(`${f.title || ''} ${f.evidence || ''}`)) {
|
|
1247
|
+
closureInvalidating.push(`${f.severity} ${f.title}: ${String(f.evidence || '').slice(0, 200)}`)
|
|
1248
|
+
ledger(f.finding_id || firstCard, 'closure-invalidating', 'EPIC-CLOSURE-BLOCKED', `${f.title} — the text fix resolves the finding, NOT the epic state`)
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1203
1251
|
// F-035 — drop merge-artifact doc findings (content exists in worktree, the merge carries it).
|
|
1204
1252
|
const actionable = all.filter((f) => !/in (the )?main repo|not (in|on) (the )?main|merge[- ]?time|worktree.*(not|missing).*main/i.test(`${f.title} ${f.evidence}`))
|
|
1205
1253
|
// G3 — the merge-artifact regex is loose; a BLOCKER/HIGH it over-matches must NOT vanish.
|
|
@@ -1250,6 +1298,26 @@ if (committed.length && !degraded && !skipFinalLight) {
|
|
|
1250
1298
|
// ───────────────────────────────────────────────────────────────────────────
|
|
1251
1299
|
phase('Merge')
|
|
1252
1300
|
let mergeResult = null
|
|
1301
|
+
// v5.6.0 — epic-closure deny-list (FEAT-0068 post-mortem): an epic must NOT close while (a) a
|
|
1302
|
+
// closure-invalidating finding stands, (b) a child carries an open scope-expansion/unresolved/
|
|
1303
|
+
// ac-unmet/out-of-ownership residual, or (c) a residual follow-up was minted for a child. Computed
|
|
1304
|
+
// in JS from the ledger (the merge agent's disk grep cannot see unmaterialized residuals).
|
|
1305
|
+
// Conservative by design: a later-retracted residual keeps the epic open this run — the skill's
|
|
1306
|
+
// post-run reconciliation (REOPEN/close branch) settles it against the disk.
|
|
1307
|
+
{
|
|
1308
|
+
const epicOf = (cid) => (graphById[cid] && graphById[cid].parentEpic) || ''
|
|
1309
|
+
const BLOCKING_RESIDUAL = new Set(['scope-expansion', 'unresolved', 'ac-unmet', 'out-of-ownership', 'main-repo-contamination'])
|
|
1310
|
+
if (closureInvalidating.length) for (const n of cardGraph) { const e = epicOf(n.id); if (e) closureBlockedEpics.add(e) }
|
|
1311
|
+
for (const r of residuals) { if (BLOCKING_RESIDUAL.has(r.deferralClass || r.kind)) { const e = epicOf(r.card); if (e) closureBlockedEpics.add(e) } }
|
|
1312
|
+
for (const fu of residualFollowups) { const e = epicOf(fu.card); if (e) closureBlockedEpics.add(e) }
|
|
1313
|
+
if (closureBlockedEpics.size) ledger(firstCard, 'epic-closure-blocklist', 'ACTIVE', `${[...closureBlockedEpics].join(' ')} — open residuals/follow-ups/closure-invalidating findings; merge MUST leave these epics open`)
|
|
1314
|
+
}
|
|
1315
|
+
// v5.6.0 — safety-sweep allowlist (F-030 by construction): at merge time the only legitimately
|
|
1316
|
+
// uncommitted files are batch bookkeeping (follow-up YAMLs materialized in the worktree, metrics,
|
|
1317
|
+
// run artifacts). Dirty CODE here by definition did not pass the per-card gates → the script must
|
|
1318
|
+
// leave+quarantine it (manifest `uncommitted_left`/`quarantined`), never sweep it into the trunk
|
|
1319
|
+
// under an opaque "[safety]" commit (FEAT-0068 5f618347d, FEAT-0067 5a5f1c135).
|
|
1320
|
+
const sweepAllowlist = dedupe([`${paths.backlog_dir || 'backlog'}/*.yml`, `${METRICS}/*`, '.baldart/*']).join(',')
|
|
1253
1321
|
// A2 — 'followup'/'blocked' cards were rolled back and their residuals live in the offline-safe
|
|
1254
1322
|
// ledger: the worktree holds ONLY gate-passing committed work, so they must not strand the batch
|
|
1255
1323
|
// (the old filter contradicted the gate's own comment and orphaned the worktree with no resume
|
|
@@ -1274,8 +1342,9 @@ if (!committed.length) {
|
|
|
1274
1342
|
`Auto-merge the batch worktree to ${TRUNK} per ${REF}/merge-cleanup.md: Phase 6 via the DETERMINISTIC script merge-worktree.sh, then Phase 6b status reconciliation + Phase 6c hygiene. Run git yourself.\n\n${projectBrief}\nWorktree: ${sharedCtx.worktreePath}\nBranch: ${sharedCtx.branch}\nmerge_strategy: ${mergeStrategy}\nCommitted cards: ${committed.map((r) => r.card).join(' ')}\nPhase-0 stash to restore (if any): see "$(git -C "${MAIN}" rev-parse --git-common-dir)/baldart/run/batch-tracker-${firstCard}.md".\n\n` +
|
|
1275
1343
|
`PHASE 6 — run the SSOT script (the merge analogue of setup-worktree.sh; NOT a hand-rolled merge, NOT /mw inline):\n` +
|
|
1276
1344
|
` MERGE_SH="$(ls .framework/framework/.claude/skills/worktree-manager/scripts/merge-worktree.sh .claude/skills/worktree-manager/scripts/merge-worktree.sh 2>/dev/null | head -1)"\n` +
|
|
1277
|
-
` bash "$MERGE_SH" --worktree ${sharedCtx.worktreePath} --manifest /tmp/new2-merge-status-${firstCard}.txt --skip-checks\n` +
|
|
1278
|
-
`
|
|
1345
|
+
` bash "$MERGE_SH" --worktree ${sharedCtx.worktreePath} --manifest /tmp/new2-merge-status-${firstCard}.txt --skip-checks --safety-sweep "allowlist:${sweepAllowlist}"\n` +
|
|
1346
|
+
` (--safety-sweep allowlist, v5.6.0: the script commits ONLY matching uncommitted files — batch bookkeeping — with an honest enumerating message; non-matching tracked changes are QUARANTINED to a labeled dir + restored, untracked left in place, both reported in the manifest keys uncommitted_left/quarantined. If the resolved script predates the flag it errors on the unknown arg → re-run WITHOUT the flag and treat any "[safety] Auto-commit" it then produces as reconciliation to report. F-030 stays binding either way.)\n` +
|
|
1347
|
+
` Read /tmp/new2-merge-status-${firstCard}.txt and set status:<its status:>. Also read uncommitted_left:/quarantined: and, when non-empty, set uncommittedLeft:true + note them.\n` +
|
|
1279
1348
|
` • status:success → set merged:true + mergeCommit/mergeTs from the file, then do Phase 6b/6c below. Read sync_marker:/sync_detail: from the FILE (not stdout) for the G19-23 hygiene gate.\n` +
|
|
1280
1349
|
` • status:code_conflict → set merged:false, status:'code_conflict', conflictFiles:[the file's conflict_files split on comma]. The script ALREADY resolved every additive doc/registry/JSONL conflict and PAUSED the rebase on the code/test files. Per your ROLE BOUNDARY you do NOT hand-resolve code — leave the worktree paused (do NOT abort), SKIP 6b/6c, and return (the workflow tracks it as a merge blocker → follow-up).\n` +
|
|
1281
1350
|
` • status:build_fail/error/sync_needs_decision → merged:false, note:<the file's error:>, leave the worktree intact, return.\n` +
|
|
@@ -1286,9 +1355,13 @@ if (!committed.length) {
|
|
|
1286
1355
|
`• 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` +
|
|
1287
1356
|
`• 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` +
|
|
1288
1357
|
`• 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` +
|
|
1289
|
-
`• EPIC CLOSURE (Phase 6b step 5e): the epic/parent card
|
|
1290
|
-
|
|
1291
|
-
`
|
|
1358
|
+
`• EPIC CLOSURE (Phase 6b step 5e — the ONLY place an epic may close; v5.6.0 predicate, FEAT-0068 post-mortem): the epic/parent card is NOT in the batch and stays open unless closed here. For each distinct parent epic of the batch cards (and any epic card in the batch itself), close it ONLY when ALL of (a)(b)(c) hold:\n` +
|
|
1359
|
+
` (a) WORKFLOW BLOCKLIST — these epics have open scope residuals / pending follow-ups / closure-invalidating findings and MUST NOT be closed regardless of what the greps below say: ${closureBlockedEpics.size ? [...closureBlockedEpics].join(' ') : '(none)'}. A blocklisted epic goes in epicsLeftOpen with reason 'workflow blocklist (open residuals/follow-ups)'.\n` +
|
|
1360
|
+
` (b) NO OPEN CHILD — run \`grep -RIl -- "parent: <EPIC-ID>" ${paths.backlog_dir || 'backlog'}/\` in the WORKTREE (where in-batch follow-up YAMLs materialize — include untracked: also \`git ls-files --others --exclude-standard -- ${paths.backlog_dir || 'backlog'}/\`) AND in the main repo ${MAIN}; every hit (follow-up cards INCLUDED — a follow-up with that parent IS an open child) must have \`status: DONE\`. Any open hit → epicsLeftOpen with reason 'open child: <id>'.\n` +
|
|
1361
|
+
` (c) EPIC ACs VERIFIED — Read the epic YAML's acceptance_criteria: for each item run its ac_verification oracle when present (else a targeted grep/check that proves it) and flip \`[ ]\`→\`[x]\` ONLY with concrete evidence; an AC you cannot verify stays \`[ ]\` and the epic stays OPEN (epicsLeftOpen with reason 'unverified epic AC: <text>'). Never close an epic whose own checklist still has \`[ ]\` items.\n` +
|
|
1362
|
+
` Close = status:DONE + completed_date + note "epic-closure gate — children DONE + epic ACs verified", folded into the reconciliation commit. This is NOT a forcedDone violation (the epic is a tracker). Return epicsClosed:[<ids>] AND epicsLeftOpen:[{epic, reason}].\n` +
|
|
1363
|
+
`• 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. NEVER \`git stash\` — in the worktree OR the main checkout: an ff-pull blocked by dirty files is leave+report (v5.6.0 — the FEAT-0068 run fabricated a dangling pre-ffpull stash from an unscoped sync mandate; a constraint with no escape hatch breeds fabrication).\n\n` +
|
|
1364
|
+
`Return: { merged, mergeCommit, mergeTs, status, conflictFiles, reconciliation, forcedDone:[], deferredLeftOpen:[], epicsClosed:[], epicsLeftOpen:[], uncommittedLeft, note }`,
|
|
1292
1365
|
// Sonnet, not the inherited opus: this is a deterministic OPS/GIT executor (git merge +
|
|
1293
1366
|
// YAML status reconciliation + grep-based epic closure + leave-and-report hygiene gates), and
|
|
1294
1367
|
// the correctness-critical checks (F-029 forcedDone guard, F-040 deferred guard) are enforced
|
|
@@ -1299,7 +1372,19 @@ if (!committed.length) {
|
|
|
1299
1372
|
} catch (e) { if (e && e.transientExhausted) noteDegraded('outage'); mergeResult = null }
|
|
1300
1373
|
if (mergeResult && (mergeResult.forcedDone || []).length) { noteDegraded('false_done'); ledger(firstCard, 'F029-guard', 'VIOLATION', `forcedDone: ${mergeResult.forcedDone.join(' ')}`) }
|
|
1301
1374
|
if (mergeResult && mergeResult.uncommittedLeft) ledger(firstCard, 'F030-guard', 'LEFT-UNCOMMITTED', 'dirty code left (not swept) + reported')
|
|
1302
|
-
|
|
1375
|
+
// v5.6.0 epic-closure guard (the F-029 twin — `epicsClosed` was trusted blindly): an epic closed
|
|
1376
|
+
// against the workflow deny-list is a false DONE → degrade + HIGH residual so the skill REOPENs it
|
|
1377
|
+
// in the post-run reconciliation (this JS cannot run git; the skill can).
|
|
1378
|
+
if (mergeResult && (mergeResult.epicsClosed || []).length) {
|
|
1379
|
+
const wrongly = (mergeResult.epicsClosed || []).filter((e) => closureBlockedEpics.has(e))
|
|
1380
|
+
if (wrongly.length) {
|
|
1381
|
+
noteDegraded('false_done')
|
|
1382
|
+
ledger(firstCard, 'epic-closure-guard', 'VIOLATION', `epic(s) closed against the blocklist: ${wrongly.join(' ')} — open residuals/follow-ups exist; skill must REOPEN`)
|
|
1383
|
+
for (const e of wrongly) residuals.push({ card: e, kind: 'false-epic-closure', severity: 'HIGH', evidence: `epic ${e} was marked DONE despite open scope residuals/follow-ups — REOPEN it (status IN_PROGRESS) in the post-run reconciliation commit`, materialized: false })
|
|
1384
|
+
}
|
|
1385
|
+
ledger(firstCard, 'epic-closure', 'CLOSED', `epics marked DONE (children DONE + ACs verified): ${mergeResult.epicsClosed.join(' ')}`)
|
|
1386
|
+
}
|
|
1387
|
+
if (mergeResult && (mergeResult.epicsLeftOpen || []).length) ledger(firstCard, 'epic-closure', 'LEFT-OPEN', mergeResult.epicsLeftOpen.map((x) => `${x.epic}: ${x.reason}`).join(' · '))
|
|
1303
1388
|
if (deferredCards.length) {
|
|
1304
1389
|
ledger(firstCard, 'F040-deferred', 'LEFT-NON-DONE', `${deferredCards.join(' ')} — skill marks DONE post-run after follow-up materialises`)
|
|
1305
1390
|
// F-040 guard — catch a merge agent that ignored the instruction and force-DONE'd a deferred card.
|
|
@@ -1543,20 +1628,67 @@ function buildTelemetry() {
|
|
|
1543
1628
|
}
|
|
1544
1629
|
}
|
|
1545
1630
|
|
|
1631
|
+
// v5.6.0 — residual severity (FEAT-0068 §3.2: residuals carried no severity, so a goal-void run
|
|
1632
|
+
// and a clean run produced the same headline). Explicit severity wins; else class-derived.
|
|
1633
|
+
function residualSeverity(r) {
|
|
1634
|
+
if (r.severity) return String(r.severity).toUpperCase()
|
|
1635
|
+
const cls = r.deferralClass || r.kind || ''
|
|
1636
|
+
if (/^(scope-expansion|unresolved|ac-unmet|false-epic-closure|main-repo-contamination)$/.test(cls)) return 'HIGH'
|
|
1637
|
+
if (/^\s*BLOCKER\b/i.test(String(r.evidence || ''))) return 'BLOCKER'
|
|
1638
|
+
if (/^\s*HIGH\b/i.test(String(r.evidence || ''))) return 'HIGH'
|
|
1639
|
+
if (/^(out-of-ownership|merge-artifact-skipped|out-of-scope|baseline-not-reached|outage)$/.test(cls)) return 'MEDIUM'
|
|
1640
|
+
return 'LOW' // owner-gated / not-a-code-defect / policy-deferred-ac — external actions, tracked
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1546
1643
|
function buildReport(o) {
|
|
1547
1644
|
const L = []
|
|
1548
1645
|
L.push(`# new2 batch — ${cardIds.join(' ')}`)
|
|
1549
1646
|
L.push(`Variant: **new2** · Mode: sequential · Trunk: ${TRUNK}${degraded ? ' · ⚠️ DEGRADED (' + degradationReasons.join(',') + ')' : ''}`)
|
|
1550
1647
|
if (o.fatal) { L.push(``, `## ⛔ BATCH FATAL`, o.reason || 'workspace unworkable'); return L.join('\n') }
|
|
1648
|
+
// v5.6.0 PARTIAL verdict (FEAT-0068 §3.2) — the HARD headline rule: any BLOCKER/HIGH residual,
|
|
1649
|
+
// any epic left open / blocklisted / falsely closed, or a false_done degradation ⇒ the FIRST
|
|
1650
|
+
// line after the title says ⚠️ PARTIAL and names the gap. ✅ is RESERVED for zero-HIGH +
|
|
1651
|
+
// no open epic. The FEAT-0068 report said "✅ 6/6 DONE" over a disclosed-but-buried HIGH gap;
|
|
1652
|
+
// the defect was headline framing, and this is where the headline is built.
|
|
1653
|
+
const sevRank = { BLOCKER: 3, HIGH: 2, MEDIUM: 1, LOW: 0 }
|
|
1654
|
+
const highResiduals = residuals.filter((r) => (sevRank[residualSeverity(r)] || 0) >= 2)
|
|
1655
|
+
const epicsInPlay = dedupe(cardGraph.map((n) => n.parentEpic || '').filter(Boolean).concat(cardGraph.filter((n) => n.isEpic).map((n) => n.id)))
|
|
1656
|
+
const closedEpics = (mergeResult && mergeResult.epicsClosed) || []
|
|
1657
|
+
const leftOpen = ((mergeResult && mergeResult.epicsLeftOpen) || []).filter((x) => x && x.epic)
|
|
1658
|
+
const openEpics = epicsInPlay.filter((e) => !closedEpics.includes(e) || closureBlockedEpics.has(e))
|
|
1659
|
+
const falseDone = degradationReasons.includes('false_done')
|
|
1660
|
+
const partial = highResiduals.length > 0 || openEpics.length > 0 || falseDone
|
|
1661
|
+
if (partial) {
|
|
1662
|
+
const top = falseDone ? 'epic chiusa contro la blocklist (false DONE — da riaprire)'
|
|
1663
|
+
: highResiduals.length ? `${residualSeverity(highResiduals[0])} residuo aperto: ${String(highResiduals[0].evidence || highResiduals[0].kind).slice(0, 140)}`
|
|
1664
|
+
: `epic ${openEpics.join(' ')} NON chiusa (goal non ancora raggiunto/verificato)`
|
|
1665
|
+
L.push(``, `## ⚠️ PARTIAL — obiettivo NON completamente raggiunto`)
|
|
1666
|
+
L.push(`**${top}**`)
|
|
1667
|
+
if (highResiduals.length) L.push(`Il lavoro sostanziale rimanente vive in ${highResiduals.filter((r) => r.materialized).length}/${highResiduals.length} follow-up già su disco — vedi § Residui. NON considerare la feature consegnata finché le follow-up HIGH non sono implementate.`)
|
|
1668
|
+
} else {
|
|
1669
|
+
L.push(``, `## ✅ COMPLETO`)
|
|
1670
|
+
L.push(`Tutte le card committate, epic ${epicsInPlay.length ? 'chiuse con AC verificati' : 'non presenti'}, nessun residuo HIGH.`)
|
|
1671
|
+
}
|
|
1551
1672
|
L.push(``, `## Esito card`)
|
|
1552
1673
|
L.push(`| Card | Status | Commit | File |`)
|
|
1553
1674
|
L.push(`|------|--------|--------|------|`)
|
|
1554
1675
|
for (const r of perCardResults) L.push(`| ${r.card} | ${r.status}${r.deferred ? ' (NON-DONE: deferred)' : ''} | ${r.commit || '-'} | ${(r.filesChanged || []).length} |`)
|
|
1555
1676
|
const blockedIds = runnableCards.filter((id) => state[id] === 'blocked' || state[id] === 'pending')
|
|
1556
1677
|
for (const id of blockedIds) L.push(`| ${id} | ${state[id]} | - | 0 |`)
|
|
1678
|
+
// v5.6.0 Epic goal section — per-epic truth: closed/open + why + the open work attached to it.
|
|
1679
|
+
if (epicsInPlay.length) {
|
|
1680
|
+
L.push(``, `## Epic goal`)
|
|
1681
|
+
for (const e of epicsInPlay) {
|
|
1682
|
+
const reason = (leftOpen.find((x) => x.epic === e) || {}).reason
|
|
1683
|
+
const epicHigh = residuals.filter((r) => ((graphById[r.card] && graphById[r.card].parentEpic) === e || r.card === e) && (sevRank[residualSeverity(r)] || 0) >= 2)
|
|
1684
|
+
if (closedEpics.includes(e) && !closureBlockedEpics.has(e)) L.push(`- **${e}: CHIUSA** (children DONE + AC epic verificati dal merge agent)`)
|
|
1685
|
+
else if (closedEpics.includes(e)) L.push(`- **${e}: ⚠️ CHIUSA CONTRO BLOCKLIST (false DONE)** — riaprire: ${epicHigh.length} residuo/i HIGH aperti`)
|
|
1686
|
+
else L.push(`- **${e}: APERTA** — ${reason || (closureBlockedEpics.has(e) ? 'blocklist: residui/follow-up aperti' : 'merge non eseguito o children aperti')}${epicHigh.length ? ` · ${epicHigh.length} residuo/i HIGH` : ''}`)
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1557
1689
|
if (finalSummary) {
|
|
1558
1690
|
L.push(``, `## Final review`)
|
|
1559
|
-
L.push(`Verified: ${finalSummary.verified} (BLOCKER ${finalSummary.blockers} / HIGH ${finalSummary.highs}) · failing gates: ${(finalSummary.failingGates || []).join(', ') || 'none'}`)
|
|
1691
|
+
L.push(`Verified: ${finalSummary.verified} (BLOCKER ${finalSummary.blockers} / HIGH ${finalSummary.highs}) · failing gates: ${(finalSummary.failingGates || []).join(', ') || 'none'}${closureInvalidating.length ? ` · closure-invalidating: ${closureInvalidating.length}` : ''}`)
|
|
1560
1692
|
}
|
|
1561
1693
|
L.push(``, `## Merge`)
|
|
1562
1694
|
if (mergeResult && mergeResult.merged) L.push(`Merged → ${TRUNK} @ ${mergeResult.mergeCommit || '?'}${mergeResult.reconciliation ? ' · ' + mergeResult.reconciliation : ''}`)
|
|
@@ -1567,10 +1699,14 @@ function buildReport(o) {
|
|
|
1567
1699
|
for (const m of man) L.push(`- [ ] ${m}`)
|
|
1568
1700
|
}
|
|
1569
1701
|
if (residuals.length) {
|
|
1570
|
-
|
|
1702
|
+
// v5.6.0 — "nulla perso" is a tracking claim, not a completion claim: it may only headline
|
|
1703
|
+
// when no HIGH residual exists (a HIGH follow-up is real work NOT delivered, not bookkeeping).
|
|
1704
|
+
L.push(``, highResiduals.length
|
|
1705
|
+
? `## ⚠️ Residui — ATTENZIONE: ${highResiduals.length} HIGH+ = lavoro sostanziale NON consegnato (le follow-up sono il lavoro, non burocrazia)`
|
|
1706
|
+
: `## Residui (bassa severità — il skill materializza le follow-up mancanti; nulla perso)`)
|
|
1571
1707
|
const bd = residuals.reduce((b, x) => { const k = x.deferralClass || x.kind || 'unknown'; b[k] = (b[k] || 0) + 1; return b }, {})
|
|
1572
1708
|
L.push(`Ripartizione per classe: ${Object.entries(bd).map(([k, n]) => `${k}=${n}`).join(' · ')} — una classe dominante è il segnale di causa (out-of-ownership → MAY-EDIT troppo strette · unresolved → fix difficili · scope-expansion → card sotto-specificate).`)
|
|
1573
|
-
for (const f of residuals) L.push(`- ${f.card} (${f.kind})${f.materialized ? ' ✓' : ' — DA MATERIALIZZARE'}: ${f.evidence}`)
|
|
1709
|
+
for (const f of residuals.slice().sort((a, b) => (sevRank[residualSeverity(b)] || 0) - (sevRank[residualSeverity(a)] || 0))) L.push(`- [${residualSeverity(f)}] ${f.card} (${f.kind})${f.materialized ? ' ✓' : ' — DA MATERIALIZZARE'}: ${f.evidence}`)
|
|
1574
1710
|
}
|
|
1575
1711
|
const excluded = gateLedger.filter((x) => x.decision === 'EXCLUDED')
|
|
1576
1712
|
if (excluded.length) { L.push(``, `## Card escluse in pre-flight`); for (const e of excluded) L.push(`- ${e.card}: ${e.detail}`) }
|
|
@@ -57,7 +57,7 @@ Legend: **R** = required, present **and non-empty** · **E** = required key, val
|
|
|
57
57
|
| `scope_boundaries` | R | C | C | "Omit for standalone cards with no siblings" (`prd-card-writer.md`) |
|
|
58
58
|
| `requirements` | C | R | R | EPIC tracks via AC-EPIC; CHILD/STANDALONE: faithfully derivable from AC+scope when both present — see consumer contract |
|
|
59
59
|
| `acceptance_criteria` | R (AC-EPIC) | R | R | CHILD/STANDALONE: EARS grammar — see § "Acceptance-criteria grammar (EARS)" |
|
|
60
|
-
| `ac_verification` |
|
|
60
|
+
| `ac_verification` | R (≥1 per goal AC, v5.6.0) | C | C | EPIC: REQUIRED — ≥1 executable oracle (or `manual:`) per top-level goal AC, consumed by the epic-closure gate; CHILD/STANDALONE: OPTIONAL map `AC-N → executable oracle` (or `manual: <check>`); authored by `/prd`, executed by `/new` Phase 2.5 — see § below |
|
|
61
61
|
| `definition_of_done` | R | R | R | |
|
|
62
62
|
| `depends_on` / `blocks` | E (`[]`) | E | E | |
|
|
63
63
|
| `estimated_complexity` | C | R | R | |
|
|
@@ -131,6 +131,23 @@ downstream cost in a real batch:
|
|
|
131
131
|
when most cards omit it). The back-fill stays as the safety net for legacy cards; a freshly
|
|
132
132
|
authored `/prd` card must not trigger it.
|
|
133
133
|
|
|
134
|
+
- **Epic goal ACs are machine-checkable (v5.6.0 — FEAT-0068).** The epic's `[AC-EPIC-N]`
|
|
135
|
+
items: (1) NEVER hardcode a child count ("Tutte le 6 sub-card" broke the moment follow-up
|
|
136
|
+
cards appeared — write "all child cards including any added later"); (2) an AC with a
|
|
137
|
+
**universal quantifier over a surface** ("every screen", "tutte le route", "nessun seam su
|
|
138
|
+
ogni pagina") must carry `invariant_owners:` — the surface's owner components resolved from
|
|
139
|
+
the fresh registry/ISA census — so the epic-closure gate can verify the owners' coverage
|
|
140
|
+
instead of trusting the child count; (3) the epic carries `ac_verification` with ≥1
|
|
141
|
+
executable oracle per goal AC (field-state matrix above). An epic whose goal cannot be
|
|
142
|
+
checked is an epic that closes on bookkeeping — the FEAT-0068 epic closed DONE with all 7
|
|
143
|
+
ACs unchecked and the goal unmet on ~90% of its surface.
|
|
144
|
+
- **Snapshot-suite lifecycle (v5.6.0 — FEAT-0068).** A card that CREATES a visual-regression /
|
|
145
|
+
snapshot / screenshot-diff suite MUST declare the baseline lifecycle: EITHER "baselines
|
|
146
|
+
generated and committed in-card" (the generate command in `validation_commands`, the baseline
|
|
147
|
+
dir in `files_likely_touched`) OR "assertion-based, screenshots advisory". "Baselines arrive
|
|
148
|
+
later", undeclared, is an authoring BLOCKER — it detonates as a merge-blocker and invites a
|
|
149
|
+
repair that disables the suite (`/new` completeness Phase 2.5 step 1b enforces the read-side).
|
|
150
|
+
|
|
134
151
|
A back-fill never rescues a card missing a HALT field: a card without `scope` blocks regardless
|
|
135
152
|
of how many derivable fields were filled.
|
|
136
153
|
|
|
@@ -38,7 +38,12 @@ dependency.
|
|
|
38
38
|
The `PROFILE=<decision|deep|compare|regulatory>` token in the spawn prompt
|
|
39
39
|
selects the research contract. **Token absent → `deep`** (preserves the legacy
|
|
40
40
|
full-report semantics for direct invocation and for existing spawners that
|
|
41
|
-
pass no token).
|
|
41
|
+
pass no token). **Defense-in-depth (v5.6.0):** when spawned WITHOUT a `PROFILE`
|
|
42
|
+
token, the agent runs the SECTION: reuse library pre-flight ITSELF before
|
|
43
|
+
searching (a caller that forgot the token also forgot the pre-flight — on a
|
|
44
|
+
real `/prd` session the research ran contract-less and the library reuse loop
|
|
45
|
+
silently no-oped), and states in the report where the output landed
|
|
46
|
+
(`${paths.research_dir}` path) so the caller never hunts for it.
|
|
42
47
|
|
|
43
48
|
| Profile | Typical consumer | Output contract | Depth |
|
|
44
49
|
|---|---|---|---|
|
|
@@ -22,7 +22,17 @@
|
|
|
22
22
|
* Usage:
|
|
23
23
|
* node framework/scripts/stamp-holistic-audit.js \
|
|
24
24
|
* --at "2026-07-02T10:00:00Z" --commit "<sha-or-empty>" \
|
|
25
|
-
* --set "FEAT-0070-01,FEAT-0070-02"
|
|
25
|
+
* --set "FEAT-0070-01,FEAT-0070-02" \
|
|
26
|
+
* [--audit-report /tmp/prd-audit-<slug>.md] <card1.yml> [<card2.yml> ...]
|
|
27
|
+
*
|
|
28
|
+
* EVIDENCE GATE (v5.6.0 — the FEAT-0068 phantom stamp): the stamp is only worth
|
|
29
|
+
* anything if the audit team actually ran. `--audit-report <path>` names the
|
|
30
|
+
* audit-phase report artifact; the script reads its `agents_run:` line (falling
|
|
31
|
+
* back to counting `agent:`/`###` sections) and writes `agents_run: [...]` +
|
|
32
|
+
* `findings_count` into the stamp. WITHOUT the flag — or when the file does not
|
|
33
|
+
* exist / names no agents — the script writes `status: "skipped"` and NO
|
|
34
|
+
* `agents_run`, which `/new` (implement.md Phase 1 step 4 P1) treats as ABSENT:
|
|
35
|
+
* the plan-auditor runs. A stamp can no longer claim an audit that never happened.
|
|
26
36
|
*
|
|
27
37
|
* Exit 0 = every non-epic card carries the stamp afterwards; exit 1 = a write or
|
|
28
38
|
* parse failed (per-file errors printed). Zero-dep, node-core only.
|
|
@@ -61,19 +71,49 @@ function stripExistingBlock(text) {
|
|
|
61
71
|
return lines.slice(0, start).concat(lines.slice(end)).join('\n');
|
|
62
72
|
}
|
|
63
73
|
|
|
64
|
-
|
|
74
|
+
/**
|
|
75
|
+
* v5.6.0 — extract audit evidence from the audit-phase report artifact.
|
|
76
|
+
* Returns { agentsRun: string[], findingsCount: number|null } or null when the
|
|
77
|
+
* report is absent/unreadable/empty of agents (→ the stamp degrades to skipped).
|
|
78
|
+
*/
|
|
79
|
+
function readAuditEvidence(reportPath) {
|
|
80
|
+
if (!reportPath) return null;
|
|
81
|
+
let text;
|
|
82
|
+
try { text = fs.readFileSync(reportPath, 'utf8'); } catch (_) { return null; }
|
|
83
|
+
// primary: an explicit `agents_run: a, b, c` (or YAML list) line in the report
|
|
84
|
+
const m = text.match(/^agents_run:\s*\[?([^\]\n]+)\]?/m);
|
|
85
|
+
let agents = m ? m[1].split(',').map((s) => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean) : [];
|
|
86
|
+
if (!agents.length) {
|
|
87
|
+
// fallback: agent names cited as report sections/spawn lines
|
|
88
|
+
const KNOWN = ['plan-auditor', 'codebase-architect', 'security-reviewer', 'api-perf-cost-auditor', 'qa-sentinel', 'code-reviewer', 'doc-reviewer'];
|
|
89
|
+
agents = KNOWN.filter((a) => new RegExp(`\\b${a}\\b`).test(text));
|
|
90
|
+
}
|
|
91
|
+
if (!agents.length) return null;
|
|
92
|
+
const fc = text.match(/^findings(?:_count)?:\s*(\d+)/m);
|
|
93
|
+
return { agentsRun: Array.from(new Set(agents)), findingsCount: fc ? Number(fc[1]) : null };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function buildBlock(at, commit, set, childIndent, evidence) {
|
|
65
97
|
const pad = ' '.repeat(childIndent);
|
|
66
98
|
const inner = ' '.repeat(childIndent + 2);
|
|
67
99
|
const ids = set.map((s) => `"${s}"`).join(', ');
|
|
68
|
-
|
|
100
|
+
const lines = [
|
|
69
101
|
`${pad}holistic_audit:`,
|
|
70
102
|
`${inner}audited_at: "${at}"`,
|
|
71
103
|
`${inner}audited_commit: "${commit}"`,
|
|
72
104
|
`${inner}audited_set: [${ids}]`,
|
|
73
|
-
]
|
|
105
|
+
];
|
|
106
|
+
if (evidence) {
|
|
107
|
+
lines.push(`${inner}agents_run: [${evidence.agentsRun.map((a) => `"${a}"`).join(', ')}]`);
|
|
108
|
+
if (evidence.findingsCount !== null) lines.push(`${inner}findings_count: ${evidence.findingsCount}`);
|
|
109
|
+
} else {
|
|
110
|
+
// no audit artifact → the stamp is explicit about it; /new P1 treats this as ABSENT
|
|
111
|
+
lines.push(`${inner}status: "skipped"`);
|
|
112
|
+
}
|
|
113
|
+
return lines.join('\n');
|
|
74
114
|
}
|
|
75
115
|
|
|
76
|
-
function stampFile(file, at, commit, set) {
|
|
116
|
+
function stampFile(file, at, commit, set, evidence) {
|
|
77
117
|
let text = fs.readFileSync(file, 'utf8');
|
|
78
118
|
let card;
|
|
79
119
|
try { card = parseCardYaml(text); } catch (e) { return { file, status: 'error', detail: `YAML parse error — ${e.message}` }; }
|
|
@@ -94,11 +134,11 @@ function stampFile(file, at, commit, set) {
|
|
|
94
134
|
if (li > 0) childIndent = li;
|
|
95
135
|
break;
|
|
96
136
|
}
|
|
97
|
-
lines.splice(metaIdx + 1, 0, buildBlock(at, commit, set, childIndent));
|
|
137
|
+
lines.splice(metaIdx + 1, 0, buildBlock(at, commit, set, childIndent, evidence));
|
|
98
138
|
text = lines.join('\n');
|
|
99
139
|
} else {
|
|
100
140
|
if (!text.endsWith('\n')) text += '\n';
|
|
101
|
-
text += `\nmetadata:\n${buildBlock(at, commit, set, 2)}\n`;
|
|
141
|
+
text += `\nmetadata:\n${buildBlock(at, commit, set, 2, evidence)}\n`;
|
|
102
142
|
}
|
|
103
143
|
fs.writeFileSync(file, text);
|
|
104
144
|
// verify the write landed (the 5b "re-read one card" contract, made universal)
|
|
@@ -113,20 +153,23 @@ function main(argv) {
|
|
|
113
153
|
const at = argValue(args, '--at');
|
|
114
154
|
const commit = argValue(args, '--commit'); // may legitimately be "" — must still be PASSED
|
|
115
155
|
const setRaw = argValue(args, '--set');
|
|
156
|
+
const auditReport = argValue(args, '--audit-report'); // v5.6.0 evidence gate (optional flag, binding semantics)
|
|
116
157
|
const files = [];
|
|
117
158
|
for (let i = 0; i < args.length; i++) {
|
|
118
|
-
if (args[i] === '--at' || args[i] === '--commit' || args[i] === '--set') { i++; continue; }
|
|
159
|
+
if (args[i] === '--at' || args[i] === '--commit' || args[i] === '--set' || args[i] === '--audit-report') { i++; continue; }
|
|
119
160
|
files.push(args[i]);
|
|
120
161
|
}
|
|
121
162
|
if (!at || commit === null || setRaw === null || !files.length) {
|
|
122
|
-
process.stderr.write('Usage: stamp-holistic-audit.js --at <iso8601> --commit <sha-or-empty> --set "ID1,ID2" <card.yml> [...]\n');
|
|
163
|
+
process.stderr.write('Usage: stamp-holistic-audit.js --at <iso8601> --commit <sha-or-empty> --set "ID1,ID2" [--audit-report <path>] <card.yml> [...]\n');
|
|
123
164
|
return 2;
|
|
124
165
|
}
|
|
125
166
|
const set = setRaw.split(',').map((s) => s.trim()).filter(Boolean).sort();
|
|
167
|
+
const evidence = readAuditEvidence(auditReport);
|
|
168
|
+
if (!evidence) process.stdout.write(`⚠ no audit evidence (${auditReport ? 'report unreadable/agent-less: ' + auditReport : '--audit-report not passed'}) → stamping status:"skipped" (no agents_run; /new will RUN the plan-auditor)\n`);
|
|
126
169
|
let failed = 0, stamped = 0, already = 0, epics = 0;
|
|
127
170
|
for (const f of files) {
|
|
128
171
|
let r;
|
|
129
|
-
try { r = stampFile(f, at, commit, set); } catch (e) { r = { file: f, status: 'error', detail: e.message }; }
|
|
172
|
+
try { r = stampFile(f, at, commit, set, evidence); } catch (e) { r = { file: f, status: 'error', detail: e.message }; }
|
|
130
173
|
if (r.status === 'error') { failed++; process.stdout.write(`✖ ${f}: ${r.detail}\n`); }
|
|
131
174
|
else if (r.status === 'stamped') { stamped++; process.stdout.write(`✓ ${f}: stamped\n`); }
|
|
132
175
|
else if (r.status === 'already-stamped') { already++; process.stdout.write(`· ${f}: already stamped\n`); }
|