claude-dev-env 1.60.0 → 1.62.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.
Files changed (41) hide show
  1. package/CLAUDE.md +12 -0
  2. package/audit-rubrics/category_rubrics/category-f-silent-failures.md +1 -1
  3. package/audit-rubrics/prompts/category-e-dead-code.md +17 -4
  4. package/audit-rubrics/prompts/category-f-silent-failures.md +1 -0
  5. package/bin/install.mjs +1 -1
  6. package/docs/CODE_RULES.md +2 -2
  7. package/hooks/blocking/code_rules_annotations_length.py +189 -10
  8. package/hooks/blocking/code_rules_dead_config_field.py +321 -0
  9. package/hooks/blocking/code_rules_enforcer.py +14 -0
  10. package/hooks/blocking/code_rules_orphan_css_class.py +196 -0
  11. package/hooks/blocking/config/verified_commit_constants.py +15 -2
  12. package/hooks/blocking/destructive_command_blocker.py +483 -61
  13. package/hooks/blocking/test_code_rules_enforcer_annotations.py +240 -0
  14. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +1 -0
  15. package/hooks/blocking/test_code_rules_enforcer_dead_config_field.py +432 -0
  16. package/hooks/blocking/test_code_rules_enforcer_dispatch_wiring.py +82 -0
  17. package/hooks/blocking/test_code_rules_enforcer_orphan_css_class.py +196 -0
  18. package/hooks/blocking/test_destructive_command_blocker.py +213 -0
  19. package/hooks/blocking/test_verification_verdict_store.py +212 -0
  20. package/hooks/blocking/test_verified_commit_gate.py +159 -0
  21. package/hooks/blocking/test_verifier_verdict_minter.py +74 -95
  22. package/hooks/blocking/verification_verdict_store.py +240 -0
  23. package/hooks/blocking/verified_commit_gate.py +31 -9
  24. package/hooks/blocking/verifier_verdict_minter.py +46 -124
  25. package/hooks/hooks_constants/code_rules_enforcer_constants.py +6 -0
  26. package/hooks/hooks_constants/dead_config_field_constants.py +39 -0
  27. package/hooks/hooks_constants/destructive_command_segment_constants.py +15 -0
  28. package/hooks/hooks_constants/orphan_css_class_constants.py +40 -0
  29. package/hooks/validation/mypy_validator.py +59 -7
  30. package/hooks/validation/test_mypy_validator.py +94 -0
  31. package/package.json +1 -1
  32. package/rules/orphan-css-class.md +23 -0
  33. package/skills/autoconverge/reference/gotchas.md +11 -0
  34. package/skills/autoconverge/workflow/autoconverge_report_constants/render_report_constants.py +5 -1
  35. package/skills/autoconverge/workflow/converge.contract.test.mjs +202 -13
  36. package/skills/autoconverge/workflow/converge.mjs +392 -51
  37. package/skills/autoconverge/workflow/test_render_report.py +55 -0
  38. package/skills/doc-gist/SKILL.md +3 -2
  39. package/skills/doc-gist/references/examples/21-decision-signoff.html +546 -0
  40. package/skills/doc-gist/references/examples/README.md +2 -2
  41. package/skills/task-build/SKILL.md +31 -0
@@ -105,6 +105,52 @@ const FIX_SCHEMA = {
105
105
  required: ['newSha', 'pushed', 'resolvedWithoutCommit', 'summary'],
106
106
  }
107
107
 
108
+ const EDIT_SCHEMA = {
109
+ type: 'object',
110
+ additionalProperties: false,
111
+ properties: {
112
+ edited: { type: 'boolean', description: 'true when the step edited code to fix at least one finding' },
113
+ resolvedWithoutCommit: { type: 'boolean', description: 'true when every finding was already addressed so no code change was made, yet each finding thread was still resolved' },
114
+ summary: { type: 'string' },
115
+ },
116
+ required: ['edited', 'resolvedWithoutCommit', 'summary'],
117
+ }
118
+
119
+ const REPAIR_EDIT_SCHEMA = {
120
+ type: 'object',
121
+ additionalProperties: false,
122
+ properties: {
123
+ edited: { type: 'boolean', description: 'true when a still-applicable bot-thread concern was fixed test-first in the working tree' },
124
+ rebased: { type: 'boolean', description: 'true when the branch was rebased onto origin/main to restore mergeability' },
125
+ resolvedWithoutCommit: { type: 'boolean', description: 'true when the failing gates were addressed with neither a code change nor a rebase — bot threads resolved only, so there is nothing for the commit step to push' },
126
+ summary: { type: 'string' },
127
+ },
128
+ required: ['edited', 'rebased', 'resolvedWithoutCommit', 'summary'],
129
+ }
130
+
131
+ const STANDARDS_EDIT_SCHEMA = {
132
+ type: 'object',
133
+ additionalProperties: false,
134
+ properties: {
135
+ issueUrl: { type: 'string', description: 'the follow-up fix issue URL, or an empty string when the issue could not be filed' },
136
+ hardeningRepoPath: { type: 'string', description: 'absolute path of the environment-config repo checkout the hardening branch was edited in, or an empty string when no hardening edit was made' },
137
+ hardeningBranch: { type: 'string', description: 'the hardening branch name created in that repo, or an empty string when no hardening edit was made' },
138
+ hardeningEdited: { type: 'boolean', description: 'true when hooks or rules were edited in the working tree of the hardening repo, uncommitted, so the verify and commit steps have a surface to bind and push' },
139
+ summary: { type: 'string' },
140
+ },
141
+ required: ['issueUrl', 'hardeningRepoPath', 'hardeningBranch', 'hardeningEdited', 'summary'],
142
+ }
143
+
144
+ const VERDICT_FENCE_STEPS =
145
+ `Compute the binding hash for the live surface by running exactly:\n` +
146
+ ` "C:\\Python313\\python.exe" "<REPO>/packages/claude-dev-env/hooks/blocking/verification_verdict_store.py" --manifest-hash "<REPO>"\n` +
147
+ ` (substitute the REPO path you resolved). That prints a single 64-char hex hash on stdout — capture it.\n` +
148
+ `Then END your message with a fenced verdict block exactly in this shape, on its own, carrying that hash:\n` +
149
+ " ```verdict\n" +
150
+ ` {"all_pass": true, "findings": [], "manifest_sha256": "<that hash>"}\n` +
151
+ " ```\n" +
152
+ ` When verification fails, set all_pass to false and list the unresolved concerns in findings; still include the manifest_sha256. The verdict fence must be the last thing in your message.`
153
+
108
154
  const CONVERGENCE_SUMMARY_SCHEMA = {
109
155
  type: 'object',
110
156
  additionalProperties: false,
@@ -313,6 +359,33 @@ function normalizeShaForComparison(sha) {
313
359
  return sha.slice(0, SHA_COMPARISON_PREFIX_LENGTH).toLowerCase()
314
360
  }
315
361
 
362
+ /**
363
+ * Decide whether a workflow code-verifier transcript ended in a passing
364
+ * verdict. The verify step runs with no schema so its verdict lands as plain
365
+ * assistant text; this reads the LAST ```verdict ...``` fenced JSON block and
366
+ * returns true only when it parses to an object with all_pass true. A missing
367
+ * fence, a parse failure, or all_pass false reads as not-passed so the commit
368
+ * step is skipped and the round reads as not-progressed.
369
+ * @param {string|null|undefined} verifyTranscript the verifier's transcript text
370
+ * @returns {boolean} true only when the last verdict fence reports all_pass true
371
+ */
372
+ function verdictPassed(verifyTranscript) {
373
+ if (typeof verifyTranscript !== 'string') return false
374
+ const fencePattern = /```verdict\s*\n([\s\S]*?)```/g
375
+ let lastFenceBody = null
376
+ let eachMatch
377
+ while ((eachMatch = fencePattern.exec(verifyTranscript)) !== null) {
378
+ lastFenceBody = eachMatch[1]
379
+ }
380
+ if (lastFenceBody === null) return false
381
+ try {
382
+ const verdictRecord = JSON.parse(lastFenceBody)
383
+ return verdictRecord != null && verdictRecord.all_pass === true
384
+ } catch {
385
+ return false
386
+ }
387
+ }
388
+
316
389
  /**
317
390
  * Decide whether a fix lens actually advanced the round: a pushed fix that moved
318
391
  * HEAD progressed, and so did an all-stale round whose findings were every one
@@ -576,15 +649,12 @@ function runAuditLens(head) {
576
649
  }
577
650
 
578
651
  /**
579
- * Fix lens: one clean-coder applies every finding in a single TDD commit,
580
- * pushes, then replies to and resolves any real GitHub review threads.
581
- * @param {string} head PR HEAD SHA the findings were raised against
582
- * @param {Array<object>} findings deduped findings across all lenses
583
- * @param {string} sourceLabel short description of where the findings came from
584
- * @returns {Promise<object>} FIX_SCHEMA result
652
+ * Render the numbered findings block shared by the fix steps.
653
+ * @param {Array<object>} findings deduped findings to render
654
+ * @returns {string} one numbered line per finding, with any thread-id note
585
655
  */
586
- function applyFixes(head, findings, sourceLabel) {
587
- const findingsBlock = findings
656
+ function renderFindingsBlock(findings) {
657
+ return findings
588
658
  .map((each, position) => {
589
659
  const eachThreadIds = collectFindingThreadIds(each)
590
660
  const threadNote = eachThreadIds.length
@@ -593,26 +663,119 @@ function applyFixes(head, findings, sourceLabel) {
593
663
  return `${position + 1}. [${each.severity}] ${each.file}:${each.line} — ${each.title}\n ${each.detail}${threadNote}`
594
664
  })
595
665
  .join('\n')
666
+ }
667
+
668
+ /**
669
+ * Edit step: one clean-coder fixes every finding test-first in the working
670
+ * tree and resolves the GitHub review threads, making NO commit or push so the
671
+ * verify step can bind a verdict to the unstaged fixes.
672
+ * @param {string} head PR HEAD SHA the findings were raised against
673
+ * @param {Array<object>} findings deduped findings across all lenses
674
+ * @param {string} sourceLabel short description of where the findings came from
675
+ * @returns {Promise<object>} EDIT_SCHEMA result
676
+ */
677
+ function applyFixesEdit(head, findings, sourceLabel) {
678
+ const findingsBlock = renderFindingsBlock(findings)
596
679
  const threadIds = findings
597
680
  .flatMap((each) => collectFindingThreadIds(each))
598
681
  .filter((each) => typeof each === 'number')
599
682
  return convergeAgent(
600
- `You are fixing ${findings.length} finding(s) (${sourceLabel}) on ${prCoordinates}, HEAD ${head}.\n\n` +
683
+ `You are the EDIT step fixing ${findings.length} finding(s) (${sourceLabel}) on ${prCoordinates}, HEAD ${head}. A separate verify step then a separate commit step run after you.\n\n` +
601
684
  `Findings:\n${findingsBlock}\n\n` +
602
685
  `Rules:\n` +
603
686
  `- Confirm the working tree is on the PR branch at HEAD ${head} with no unrelated edits before you start.\n` +
604
687
  `- Fix every finding test-first (failing test, then minimum code to pass) per CODE_RULES. Verify each concern against current code; a finding whose concern no longer applies needs no code change but still needs its thread resolved.\n` +
605
- `- Make ONE commit for all fixes, then push to the PR branch.\n` +
688
+ `- Leave all fixes in the working tree. Do NOT commit and do NOT push the commit step does that after verification. Committing or pushing here would change the surface the verifier binds to.\n` +
606
689
  `- For each finding that carries a GitHub review comment id (${threadIds.length ? threadIds.join(', ') : 'none this batch'}): post an inline reply with python "${CONFIG.sharedScripts}/post_fix_reply.py" --owner ${input.owner} --repo ${input.repo} --pr-number ${input.prNumber} --in-reply-to <id> --body "<what changed>". Then resolve the PR review thread by thread node id (PRRT_...): look up the thread id for that comment via GraphQL (match on comment databaseId == <id> in the pull request's reviewThreads), then call the github MCP pull_request_review_write method=resolve_thread with threadId=<PRRT_...> (not the numeric comment id), or run the resolveReviewThread GraphQL mutation with the same threadId.\n` +
607
690
  `- Findings with replyToCommentId null are in-memory audit findings: fix them, no reply needed.\n\n` +
608
691
  `Return values:\n` +
609
- `- When you commit and push a fix: newSha=the new HEAD SHA after your push, pushed=true, resolvedWithoutCommit=false.\n` +
610
- `- When every finding was already addressed so no code change is needed — yet you still resolved each GitHub review thread above: newSha=${head} (the unchanged HEAD), pushed=false, resolvedWithoutCommit=true. Only set this when every thread that carries a comment id is resolved; otherwise the round is treated as stalled.\n` +
692
+ `- When you edited code to fix at least one finding: edited=true, resolvedWithoutCommit=false.\n` +
693
+ `- When every finding was already addressed so no code change was needed — yet you still resolved each GitHub review thread above: edited=false, resolvedWithoutCommit=true. Only set this when every thread that carries a comment id is resolved; otherwise the round is treated as stalled.\n` +
611
694
  `Always include a one-line summary.`,
612
- { label: `fix:${sourceLabel}`, phase: 'Converge', schema: FIX_SCHEMA, agentType: 'clean-coder' },
695
+ { label: `fix-edit:${sourceLabel}`, phase: 'Converge', schema: EDIT_SCHEMA, agentType: 'clean-coder' },
696
+ )
697
+ }
698
+
699
+ /**
700
+ * Verify step: a code-verifier checks the working-tree fixes against the
701
+ * findings, computes the binding surface hash, and ends with a verdict fence
702
+ * as plain assistant text (NO schema, so the fence is not consumed as
703
+ * structured output). The fence's manifest_sha256 is what unlocks the
704
+ * verified-commit gate for the commit step. The verifier makes no edits.
705
+ * @param {string} head PR HEAD SHA the findings were raised against
706
+ * @param {Array<object>} findings deduped findings the fixes must address
707
+ * @param {string} sourceLabel short description of where the findings came from
708
+ * @returns {Promise<string>} the verifier transcript carrying the verdict fence
709
+ */
710
+ function verifyFixesInWorkingTree(head, findings, sourceLabel) {
711
+ const findingsBlock = renderFindingsBlock(findings)
712
+ return convergeAgent(
713
+ `You are the VERIFY step for ${findings.length} finding(s) (${sourceLabel}) on ${prCoordinates}, HEAD ${head}. The edit step left fixes in the working tree, uncommitted. Do NO edits of any kind — verification only; any edit invalidates the verdict you are about to emit.\n\n` +
714
+ `Findings the working-tree fixes must address:\n${findingsBlock}\n\n` +
715
+ `Steps:\n` +
716
+ `1. Resolve the worktree repo root: REPO=$(git rev-parse --show-toplevel).\n` +
717
+ `2. Verify the uncommitted working-tree changes resolve every finding above: run the relevant tests and the named gates against the working tree. Read the diff (git diff) and confirm each finding is fixed test-first per CODE_RULES.\n` +
718
+ `3. ${VERDICT_FENCE_STEPS}`,
719
+ { label: `fix-verify:${sourceLabel}`, phase: 'Converge', agentType: 'code-verifier' },
720
+ )
721
+ }
722
+
723
+ /**
724
+ * Commit step: one clean-coder commits the already-verified working-tree fixes
725
+ * in a single commit and pushes to the PR branch, making NO further file edits
726
+ * — any edit changes the surface and invalidates the verifier verdict that
727
+ * unlocks the commit gate.
728
+ * @param {string} head PR HEAD SHA before the fix commit
729
+ * @param {string} sourceLabel short description of where the findings came from
730
+ * @returns {Promise<object>} FIX_SCHEMA result
731
+ */
732
+ function commitVerifiedFixes(head, sourceLabel) {
733
+ return convergeAgent(
734
+ `You are the COMMIT step for fixes (${sourceLabel}) on ${prCoordinates}, HEAD ${head}. The edit step left fixes in the working tree and the verify step passed, so a verifier verdict already binds to this exact working tree.\n\n` +
735
+ `Rules:\n` +
736
+ `- Make NO further file edits of any kind. Any edit changes the surface and invalidates the verdict that unlocks the commit gate, so the commit would be blocked. Do not run a formatter, do not touch a test, do not re-fix anything — only commit and push what is already there.\n` +
737
+ `- Make ONE commit for all the working-tree fixes, then push to the PR branch.\n\n` +
738
+ `Return values: newSha=the new HEAD SHA after your push, pushed=true, resolvedWithoutCommit=false, and a one-line summary. If the commit or push is blocked or fails, return newSha=${head}, pushed=false, resolvedWithoutCommit=false, and a summary naming the failure.`,
739
+ { label: `fix-commit:${sourceLabel}`, phase: 'Converge', schema: FIX_SCHEMA, agentType: 'clean-coder' },
613
740
  )
614
741
  }
615
742
 
743
+ /**
744
+ * Fix lens: edit (clean-coder, no commit) -> verify (code-verifier emits a
745
+ * verdict fence binding the working tree) -> commit (clean-coder, one commit +
746
+ * push, no edits). Splitting the single editing-and-committing agent lets a
747
+ * workflow code-verifier produce the verdict the verified-commit gate requires,
748
+ * which the SubagentStop minter cannot mint for workflow-spawned agents. When
749
+ * verification fails (or the edit step stalled with no thread to resolve), the
750
+ * commit step is skipped and the unchanged HEAD is returned so the round reads
751
+ * as not-progressed.
752
+ * @param {string} head PR HEAD SHA the findings were raised against
753
+ * @param {Array<object>} findings deduped findings across all lenses
754
+ * @param {string} sourceLabel short description of where the findings came from
755
+ * @returns {Promise<object>} FIX_SCHEMA result
756
+ */
757
+ async function applyFixes(head, findings, sourceLabel) {
758
+ const editResult = await applyFixesEdit(head, findings, sourceLabel)
759
+ if (editResult?.resolvedWithoutCommit === true && editResult?.edited !== true) {
760
+ return {
761
+ newSha: head,
762
+ pushed: false,
763
+ resolvedWithoutCommit: true,
764
+ summary: editResult?.summary || 'fixes resolved without a code change',
765
+ }
766
+ }
767
+ const verifyTranscript = await verifyFixesInWorkingTree(head, findings, sourceLabel)
768
+ if (!verdictPassed(verifyTranscript)) {
769
+ return {
770
+ newSha: head,
771
+ pushed: false,
772
+ resolvedWithoutCommit: false,
773
+ summary: `verify step did not pass the working-tree fixes for ${findings.length} finding(s) — not committing`,
774
+ }
775
+ }
776
+ return commitVerifiedFixes(head, sourceLabel)
777
+ }
778
+
616
779
  /**
617
780
  * Post the terminal CLEAN bugteam audit artifact so check_convergence.py sees
618
781
  * a clean bugteam review on the converged HEAD. The post is load-bearing: the
@@ -724,28 +887,121 @@ function markReady(head, copilotDown) {
724
887
  }
725
888
 
726
889
  /**
727
- * Address the gates a convergence check reported as failing, then hand control
728
- * back to the converge phase. Resolves lingering bot threads and rebases when
729
- * the PR is not mergeable.
890
+ * Repair edit step: one clean-coder resolves the lingering bot review threads
891
+ * the convergence check flagged, fixes any still-applicable bot-thread concern
892
+ * test-first in the working tree, and rebases onto origin/main when the PR is
893
+ * not mergeable — making NO commit and NO push, so the verify step can bind a
894
+ * verdict to the resulting surface before the commit step pushes it. Human
895
+ * reviewer threads are never touched.
730
896
  * @param {string} head current PR HEAD SHA
731
897
  * @param {Array<string>} failures FAIL lines from the convergence check
732
- * @returns {Promise<object>} FIX_SCHEMA result
898
+ * @returns {Promise<object>} REPAIR_EDIT_SCHEMA result
733
899
  */
734
- function repairConvergence(head, failures) {
900
+ function repairConvergenceEdit(head, failures) {
735
901
  const failureBlock = failures.length
736
902
  ? failures.map((each, position) => `${position + 1}. ${each}`).join('\n')
737
903
  : 'none reported'
738
904
  return convergeAgent(
739
- `The convergence check for ${prCoordinates} failed these gates on HEAD ${head}:\n${failureBlock}\n\n` +
740
- `Address only the failing gates:\n` +
741
- `- Unresolved bot review threads: fetch the threads where isResolved is false (gh api graphql, or the github MCP pull_request_read get_review_comments), then keep only the bot-authored ones a thread whose root comment author login contains "cursor", "claude", or "copilot" (case-insensitive substring). Explicitly skip every human reviewer thread; the convergence gate counts only unresolved bot threads, so touching a human thread is out of scope. For each bot thread, verify the concern against current code; if it still applies, fix it test-first; either way post an inline reply and resolve the thread.\n` +
742
- `- PR not mergeable: rebase onto origin/main and force-push (git fetch origin main; git rebase origin/main; resolve conflicts; git push --force-with-lease).\n` +
743
- `- A dirty bot review or a still-pending requested reviewer: leave it; the next round re-runs that reviewer.\n` +
744
- `Make at most one commit for any code fix. Return the HEAD SHA after any push in newSha (the unchanged ${head} when nothing was pushed), pushed true/false, resolvedWithoutCommit=false (this gate already accepts an unchanged HEAD), and a one-line summary.`,
745
- { label: 'repair-convergence', phase: 'Finalize', schema: FIX_SCHEMA, agentType: 'clean-coder' },
905
+ `You are the EDIT step repairing the convergence gates that failed for ${prCoordinates} on HEAD ${head}. A separate verify step then a separate commit step run after you.\n\n` +
906
+ `Failing gates:\n${failureBlock}\n\n` +
907
+ `Address only the failing gates, and make NO commit and NO pushleave every code change in the working tree (a rebase necessarily creates local commits, which is fine; just do not push them):\n` +
908
+ `- Unresolved bot review threads: fetch the threads where isResolved is false (gh api graphql, or the github MCP pull_request_read get_review_comments), then keep only the bot-authored ones — a thread whose root comment author login contains "cursor", "claude", or "copilot" (case-insensitive substring). Explicitly skip every human reviewer thread; the convergence gate counts only unresolved bot threads, so touching a human thread is out of scope. For each bot thread, verify the concern against current code; if it still applies, fix it test-first in the working tree and leave the fix uncommitted; either way post an inline reply and resolve the thread by its PRRT_ node id (GraphQL lookup matching the comment databaseId, then resolveReviewThread or the github MCP pull_request_review_write method=resolve_thread — not the numeric comment id).\n` +
909
+ `- PR not mergeable: rebase onto origin/main FIRST, before applying any uncommitted bot-thread fix, so the rebase runs on a clean tree (git fetch origin main; git rebase origin/main; resolve conflicts). Do NOT force-push — the commit step does that after verification.\n` +
910
+ `- A dirty bot review or a still-pending requested reviewer: leave it; the next round re-runs that reviewer.\n\n` +
911
+ `Return values:\n` +
912
+ `- edited=true when you changed code in the working tree to fix a bot-thread concern.\n` +
913
+ `- rebased=true when you rebased the branch onto origin/main.\n` +
914
+ `- resolvedWithoutCommit=true only when you addressed the gates with neither a code change nor a rebase (bot threads resolved only), so there is nothing for the commit step to push.\n` +
915
+ `Always include a one-line summary.`,
916
+ { label: 'repair-edit', phase: 'Finalize', schema: REPAIR_EDIT_SCHEMA, agentType: 'clean-coder' },
917
+ )
918
+ }
919
+
920
+ /**
921
+ * Repair verify step: a code-verifier confirms the working-tree repair (any
922
+ * bot-thread fix plus any rebase result) is sound, computes the binding surface
923
+ * hash, and ends with a verdict fence as plain assistant text (NO schema). The
924
+ * fence's manifest_sha256 unlocks the verified-commit gate for the commit step's
925
+ * push. The verifier makes no edits.
926
+ * @param {string} head PR HEAD SHA the repair started from
927
+ * @param {Array<string>} failures FAIL lines the repair addressed
928
+ * @returns {Promise<string>} the verifier transcript carrying the verdict fence
929
+ */
930
+ function verifyRepairChanges(head, failures) {
931
+ const failureBlock = failures.length
932
+ ? failures.map((each, position) => `${position + 1}. ${each}`).join('\n')
933
+ : 'none reported'
934
+ return convergeAgent(
935
+ `You are the VERIFY step for the convergence repair on ${prCoordinates}, HEAD ${head}. The edit step left its repair in the working tree (a bot-thread fix uncommitted, and/or a rebase onto origin/main), unpushed. Do NO edits of any kind — verification only; any edit invalidates the verdict you are about to emit.\n\n` +
936
+ `Concerns the working-tree repair must resolve (the gates the convergence check flagged):\n${failureBlock}\n\n` +
937
+ `Steps:\n` +
938
+ `1. Resolve the worktree repo root: REPO=$(git rev-parse --show-toplevel).\n` +
939
+ `2. Verify the working tree against origin/main: any bot-thread code fix is correct test-first per CODE_RULES, and a rebase (if any) left a clean, conflict-free tree. Read the diff (git diff origin/main) and run the relevant tests and named gates.\n` +
940
+ `3. ${VERDICT_FENCE_STEPS}`,
941
+ { label: 'repair-verify', phase: 'Finalize', agentType: 'code-verifier' },
942
+ )
943
+ }
944
+
945
+ /**
946
+ * Repair commit step: one clean-coder commits any uncommitted bot-thread fix in
947
+ * a single commit and pushes to the PR branch (force-with-lease when the edit
948
+ * step rebased), making NO further file edits — any edit changes the surface and
949
+ * invalidates the verifier verdict that unlocks the commit gate.
950
+ * @param {string} head PR HEAD SHA before the repair push
951
+ * @param {boolean} wasRebased true when the edit step rebased the branch, so the push must be force-with-lease
952
+ * @returns {Promise<object>} FIX_SCHEMA result
953
+ */
954
+ function commitRepairFixes(head, wasRebased) {
955
+ const pushInstruction = wasRebased
956
+ ? 'The edit step rebased the branch, so push with git push --force-with-lease.'
957
+ : 'Push to the PR branch with a plain git push.'
958
+ return convergeAgent(
959
+ `You are the COMMIT step for the convergence repair on ${prCoordinates}, HEAD ${head}. The edit step left its repair in the working tree and the verify step passed, so a verifier verdict already binds to this exact working tree.\n\n` +
960
+ `Rules:\n` +
961
+ `- Make NO further file edits of any kind. Any edit changes the surface and invalidates the verdict that unlocks the commit gate, so the push would be blocked. Do not run a formatter, do not re-fix anything — only commit and push what is already there.\n` +
962
+ `- Commit any uncommitted bot-thread fix in ONE commit (skip the commit when the working tree carries only already-committed rebase results). ${pushInstruction}\n\n` +
963
+ `Return values: newSha=the new HEAD SHA after your push, pushed=true, resolvedWithoutCommit=false, and a one-line summary. If the commit or push is blocked or fails, return newSha=${head}, pushed=false, resolvedWithoutCommit=false, and a summary naming the failure.`,
964
+ { label: 'repair-commit', phase: 'Finalize', schema: FIX_SCHEMA, agentType: 'clean-coder' },
746
965
  )
747
966
  }
748
967
 
968
+ /**
969
+ * Address the gates a convergence check reported as failing, then hand control
970
+ * back to the converge phase: edit (clean-coder resolves bot threads, applies
971
+ * any fix and rebase in the working tree, no push) -> verify (code-verifier
972
+ * emits a verdict fence binding the working tree) -> commit (clean-coder, one
973
+ * commit + push, no edits). Splitting the edit from the push lets a workflow
974
+ * code-verifier produce the verdict the verified-commit gate requires for the
975
+ * bot-thread fix commit and the post-rebase force-push. When the edit resolved
976
+ * the gates with neither a code change nor a rebase, or the verify step fails,
977
+ * the commit step is skipped and the unchanged HEAD is returned.
978
+ * @param {string} head current PR HEAD SHA
979
+ * @param {Array<string>} failures FAIL lines from the convergence check
980
+ * @returns {Promise<object>} FIX_SCHEMA result
981
+ */
982
+ async function repairConvergence(head, failures) {
983
+ const editResult = await repairConvergenceEdit(head, failures)
984
+ const hasPushWork = editResult?.edited === true || editResult?.rebased === true
985
+ if (!hasPushWork) {
986
+ return {
987
+ newSha: head,
988
+ pushed: false,
989
+ resolvedWithoutCommit: true,
990
+ summary: editResult?.summary || 'convergence gates resolved without a code change or rebase',
991
+ }
992
+ }
993
+ const verifyTranscript = await verifyRepairChanges(head, failures)
994
+ if (!verdictPassed(verifyTranscript)) {
995
+ return {
996
+ newSha: head,
997
+ pushed: false,
998
+ resolvedWithoutCommit: false,
999
+ summary: `repair verify step did not pass the working-tree repair on HEAD ${head} — not pushing`,
1000
+ }
1001
+ }
1002
+ return commitRepairFixes(head, editResult?.rebased === true)
1003
+ }
1004
+
749
1005
  /**
750
1006
  * Decide whether a review round surfaced ONLY code-standard violations — pure
751
1007
  * CODE_RULES/style findings with no behavioral impact. Such a round passes for
@@ -759,37 +1015,122 @@ function isStandardsOnlyRound(findings) {
759
1015
  }
760
1016
 
761
1017
  /**
762
- * Defer a standards-only round: one agent files a GitHub issue listing every
763
- * code-standard finding, opens a draft PR hardening the Claude environment
764
- * (hooks/rules) so those violation classes are blocked before code is written,
765
- * and replies to / resolves any GitHub threads the findings carry, noting the
766
- * deferral. This PR's branch is never touched.
1018
+ * Standards-deferral edit step: one clean-coder files the follow-up fix issue,
1019
+ * stages an environment-hardening hooks/rules change in the config repo's
1020
+ * working tree WITHOUT committing, and resolves the PR's code-standard threads.
1021
+ * Leaving the hardening edit uncommitted lets the verify step bind a verdict to
1022
+ * it before the commit step opens the PR. The PR's own branch is never touched.
767
1023
  * @param {string} head PR HEAD SHA the findings were raised against
768
1024
  * @param {Array<object>} findings deduped code-standard-only findings
769
1025
  * @param {string} sourceLabel short description of where the findings came from
770
- * @returns {Promise<string>} agent transcript (unused)
1026
+ * @returns {Promise<object>} STANDARDS_EDIT_SCHEMA result
771
1027
  */
772
- function spawnStandardsFollowUp(head, findings, sourceLabel) {
773
- const findingsBlock = findings
774
- .map((each, position) => {
775
- const eachThreadIds = collectFindingThreadIds(each)
776
- const threadNote = eachThreadIds.length
777
- ? `\n (GitHub review comment ids: ${eachThreadIds.join(', ')})`
778
- : ''
779
- return `${position + 1}. [${each.severity}] ${each.file}:${each.line} — ${each.title}\n ${each.detail}${threadNote}`
780
- })
781
- .join('\n')
1028
+ function standardsFollowUpEdit(head, findings, sourceLabel) {
1029
+ const findingsBlock = renderFindingsBlock(findings)
1030
+ const threadIds = findings
1031
+ .flatMap((each) => collectFindingThreadIds(each))
1032
+ .filter((each) => typeof each === 'number')
782
1033
  return convergeAgent(
783
- `A review round on ${prCoordinates}, HEAD ${head}, surfaced ONLY code-standard violations (CODE_RULES/style, no behavioral impact). The convergence run treats the round as passed and defers these to follow-up work, which you now create. Do NOT commit or push to the PR's own branch.\n\n` +
1034
+ `You are the EDIT step deferring a code-standard-only round on ${prCoordinates}, HEAD ${head} (${sourceLabel}). The round surfaced ONLY code-standard violations (CODE_RULES/style, no behavioral impact); the run treats it as passed and defers the fixes to follow-up work, which you now stage. A separate verify step then a separate commit step open the hardening PR after you. Do NOT commit or push to the PR's own branch.\n\n` +
784
1035
  `Findings:\n${findingsBlock}\n\n` +
785
- `1. Follow-up fix issue: file a GitHub issue on ${input.owner}/${input.repo} (gh issue create --body-file with a temp file) titled "Deferred code-standard fixes from PR #${input.prNumber}". The body references the PR and lists each finding with its file:line, severity, and detail. The issue carries the fix work; do not open a fix PR.\n` +
786
- `2. Environment-hardening PR: in the Claude environment config repo (the repo owning ~/.claude hooks and rules — JonEcho/llm-settings for hooks, jl-cmd/claude-code-config for rules/skills; pick whichever owns the needed surface), create a branch and open a DRAFT PR that hardens hooks/rules so each violation class found here is blocked at Write/Edit time, before code is written or reviewed. Reference the issue from step 1 in the PR body.\n` +
787
- `3. For each finding that carries a GitHub review comment id: post an inline reply via python "${CONFIG.sharedScripts}/post_fix_reply.py" --owner ${input.owner} --repo ${input.repo} --pr-number ${input.prNumber} --in-reply-to <id> --body "Code-standard-only finding — deferred to follow-up issue <url>." Then resolve the thread by its PRRT_ node id (GraphQL lookup on comment databaseId, then resolveReviewThread or the github MCP pull_request_review_write method=resolve_thread).\n\n` +
788
- `Return a one-line summary naming the follow-up issue URL and the hardening PR URL.`,
789
- { label: `standards-followup:${sourceLabel}`, phase: 'Converge', agentType: 'clean-coder' },
1036
+ `1. Follow-up fix issue: file a GitHub issue on ${input.owner}/${input.repo} (gh issue create --body-file with a temp file) titled "Deferred code-standard fixes from PR #${input.prNumber}". The body references the PR and lists each finding with its file:line, severity, and detail. The issue carries the fix work; do not open a fix PR. Capture the issue URL.\n` +
1037
+ `2. Stage the environment-hardening change: in the Claude environment config repo (the repo owning ~/.claude hooks and rules — JonEcho/llm-settings for hooks, jl-cmd/claude-code-config for rules/skills; pick whichever owns the surface that would block these violation classes), find or clone a local checkout, fetch origin, and create a branch off origin/main. Edit the hooks/rules in that checkout's WORKING TREE so each violation class found here is blocked at Write/Edit time, before code is written. Do NOT commit and do NOT push — the commit step does that after the verify step binds a verdict to the working tree. Return the checkout's absolute path in hardeningRepoPath, the branch name in hardeningBranch, and set hardeningEdited=true. When no hardening is feasible for these classes, leave hardeningRepoPath and hardeningBranch empty and hardeningEdited=false; the follow-up issue still stands.\n` +
1038
+ `3. For each finding that carries a GitHub review comment id (${threadIds.length ? threadIds.join(', ') : 'none this batch'}): post an inline reply via python "${CONFIG.sharedScripts}/post_fix_reply.py" --owner ${input.owner} --repo ${input.repo} --pr-number ${input.prNumber} --in-reply-to <id> --body "Code-standard-only finding — deferred to follow-up issue <url>." Then resolve the thread by its PRRT_ node id (GraphQL lookup on comment databaseId, then resolveReviewThread or the github MCP pull_request_review_write method=resolve_thread — not the numeric comment id).\n\n` +
1039
+ `Return the issue URL in issueUrl (empty string when it could not be filed), the hardening checkout path and branch, hardeningEdited, and a one-line summary.`,
1040
+ { label: `standards-edit:${sourceLabel}`, phase: 'Converge', schema: STANDARDS_EDIT_SCHEMA, agentType: 'clean-coder' },
1041
+ )
1042
+ }
1043
+
1044
+ /**
1045
+ * Standards-hardening verify step: a code-verifier confirms the uncommitted
1046
+ * hooks/rules change staged in the hardening repo blocks the deferred violation
1047
+ * classes, computes the binding surface hash for that repo, and ends with a
1048
+ * verdict fence as plain assistant text (NO schema) — unlocking the
1049
+ * verified-commit gate for the cross-repo hardening commit. The verifier makes
1050
+ * no edits.
1051
+ * @param {string} hardeningRepoPath absolute path of the hardening repo checkout the edit staged
1052
+ * @param {string} sourceLabel short description of where the findings came from
1053
+ * @returns {Promise<string>} the verifier transcript carrying the verdict fence
1054
+ */
1055
+ function verifyHardeningChanges(hardeningRepoPath, sourceLabel) {
1056
+ return convergeAgent(
1057
+ `You are the VERIFY step for an environment-hardening change (${sourceLabel}) staged in the working tree of ${hardeningRepoPath}. The edit step left the hooks/rules edits uncommitted there. Do NO edits of any kind — verification only; any edit invalidates the verdict you are about to emit.\n\n` +
1058
+ `Concern the working-tree change must resolve: the edited hooks/rules block the code-standard violation classes from the deferred round at Write/Edit time, and a hook change carries a passing test per CODE_RULES.\n\n` +
1059
+ `Steps:\n` +
1060
+ `1. cd into ${hardeningRepoPath}, then resolve its repo root: REPO=$(git rev-parse --show-toplevel).\n` +
1061
+ `2. Verify the uncommitted working-tree change in REPO: read the diff (git diff) and run the hook/rule tests in that repo, confirming each violation class is now blocked.\n` +
1062
+ `3. ${VERDICT_FENCE_STEPS}`,
1063
+ { label: `standards-verify:${sourceLabel}`, phase: 'Converge', agentType: 'code-verifier' },
1064
+ )
1065
+ }
1066
+
1067
+ /**
1068
+ * Standards-hardening commit step: one clean-coder commits the verified
1069
+ * working-tree hooks/rules change in a single commit, pushes the hardening
1070
+ * branch, and opens the DRAFT hardening PR — making NO further file edits, since
1071
+ * any edit changes the surface and invalidates the verdict that unlocks the
1072
+ * commit gate. The PR's own branch is never touched.
1073
+ * @param {string} hardeningRepoPath absolute path of the hardening repo checkout
1074
+ * @param {string} hardeningBranch the hardening branch the edit step created
1075
+ * @param {string} issueUrl the follow-up fix issue URL the PR body references
1076
+ * @param {string} sourceLabel short description of where the findings came from
1077
+ * @returns {Promise<string>} agent transcript (unused)
1078
+ */
1079
+ function commitHardeningPr(hardeningRepoPath, hardeningBranch, issueUrl, sourceLabel) {
1080
+ return convergeAgent(
1081
+ `You are the COMMIT step opening the environment-hardening PR (${sourceLabel}) for the change staged in ${hardeningRepoPath} on branch ${hardeningBranch}. The edit step left the hooks/rules edits in the working tree and the verify step passed, so a verifier verdict already binds to this exact working tree. Do NOT touch the PR's own branch.\n\n` +
1082
+ `Rules:\n` +
1083
+ `- Make NO further file edits of any kind. Any edit changes the surface and invalidates the verdict that unlocks the commit gate, so the push would be blocked. Only commit and push what is already there.\n` +
1084
+ `- In ${hardeningRepoPath}: make ONE commit of the staged hooks/rules change on branch ${hardeningBranch}, push it, then open a DRAFT PR. The PR body references the follow-up issue ${issueUrl || '(none)'} and states the PR hardens the environment so the deferred violation classes are blocked at Write/Edit time. Honor the gh-body-file rule: write a BOM-free temp file and pass --body-file.\n\n` +
1085
+ `Return a one-line summary naming the hardening PR URL.`,
1086
+ { label: `standards-commit:${sourceLabel}`, phase: 'Converge', agentType: 'clean-coder' },
790
1087
  )
791
1088
  }
792
1089
 
1090
+ /**
1091
+ * Build the standards-deferral note for the closing report, naming the
1092
+ * environment-hardening PR only when one was opened this round so the note
1093
+ * never claims a PR the skip paths did not produce.
1094
+ * @param {number} findingsCount count of deferred code-standard findings
1095
+ * @param {boolean} hardeningPrOpened true when the hardening PR was opened this round
1096
+ * @returns {string} the human-facing deferral note
1097
+ */
1098
+ function standardsDeferralNote(findingsCount, hardeningPrOpened) {
1099
+ const base = `${findingsCount} code-standard finding(s) deferred to a follow-up fix issue`
1100
+ return hardeningPrOpened
1101
+ ? `${base} plus an environment-hardening PR — verify both land`
1102
+ : `${base} — verify it lands (no environment-hardening PR was opened this round)`
1103
+ }
1104
+
1105
+ /**
1106
+ * Defer a standards-only round: edit (clean-coder files the follow-up fix issue,
1107
+ * stages an environment-hardening hooks/rules change in the config repo's
1108
+ * working tree without committing, and resolves the PR's code-standard threads)
1109
+ * -> verify (code-verifier binds a verdict to the hardening working tree) ->
1110
+ * commit (clean-coder makes one commit, pushes, and opens the DRAFT hardening
1111
+ * PR). Splitting the edit from the push lets a workflow code-verifier produce the
1112
+ * verdict the verified-commit gate requires for the cross-repo hardening commit.
1113
+ * This PR's own branch is never touched. When the edit staged no hardening, or
1114
+ * the verify step fails, the follow-up issue still stands and the commit step is
1115
+ * skipped.
1116
+ * @param {string} head PR HEAD SHA the findings were raised against
1117
+ * @param {Array<object>} findings deduped code-standard-only findings
1118
+ * @param {string} sourceLabel short description of where the findings came from
1119
+ * @returns {Promise<object>} `{ hardeningPrOpened }` — true only when the hardening PR was opened this round
1120
+ */
1121
+ async function spawnStandardsFollowUp(head, findings, sourceLabel) {
1122
+ const editResult = await standardsFollowUpEdit(head, findings, sourceLabel)
1123
+ if (editResult?.hardeningEdited !== true || !editResult?.hardeningRepoPath) {
1124
+ return { hardeningPrOpened: false }
1125
+ }
1126
+ const verifyTranscript = await verifyHardeningChanges(editResult.hardeningRepoPath, sourceLabel)
1127
+ if (!verdictPassed(verifyTranscript)) {
1128
+ return { hardeningPrOpened: false }
1129
+ }
1130
+ await commitHardeningPr(editResult.hardeningRepoPath, editResult.hardeningBranch, editResult.issueUrl, sourceLabel)
1131
+ return { hardeningPrOpened: true }
1132
+ }
1133
+
793
1134
  /**
794
1135
  * Spawn the convergence-summary agent at finalize so its StructuredOutput is
795
1136
  * recorded into the run journal for the closing report to read. The agent groups
@@ -873,8 +1214,8 @@ while (iterations < CONFIG.maxIterations) {
873
1214
  if (isStandardsOnlyRound(findings)) {
874
1215
  log(`Round ${rounds}: ${findings.length} code-standard-only finding(s) — deferring to follow-up PRs and treating the round as passed`)
875
1216
  allRoundFindings.push(...findings)
876
- await spawnStandardsFollowUp(head, findings, 'converge-round')
877
- standardsNote = `${findings.length} code-standard finding(s) deferred to a follow-up fix issue plus an environment-hardening PR — verify both land`
1217
+ const standardsOutcome = await spawnStandardsFollowUp(head, findings, 'converge-round')
1218
+ standardsNote = standardsDeferralNote(findings.length, standardsOutcome?.hardeningPrOpened === true)
878
1219
  const auditResult = await postCleanAudit(head)
879
1220
  if (!auditResult?.posted) {
880
1221
  blocker = cleanAuditBlocker(head, auditResult)
@@ -932,8 +1273,8 @@ while (iterations < CONFIG.maxIterations) {
932
1273
  if (isStandardsOnlyRound(copilotOutcome.findings)) {
933
1274
  log(`Copilot raised ${copilotOutcome.findings.length} code-standard-only finding(s) — deferring to follow-up PRs and treating the gate as passed`)
934
1275
  allRoundFindings.push(...copilotOutcome.findings)
935
- await spawnStandardsFollowUp(head, copilotOutcome.findings, 'copilot')
936
- standardsNote = `${copilotOutcome.findings.length} code-standard finding(s) deferred to a follow-up fix issue plus an environment-hardening PR — verify both land`
1276
+ const standardsOutcome = await spawnStandardsFollowUp(head, copilotOutcome.findings, 'copilot')
1277
+ standardsNote = standardsDeferralNote(copilotOutcome.findings.length, standardsOutcome?.hardeningPrOpened === true)
937
1278
  copilotDown = false
938
1279
  copilotNote = null
939
1280
  phase = 'FINALIZE'
@@ -43,6 +43,31 @@ def _render_cli(journal_path: Path, out_path: Path) -> subprocess.CompletedProce
43
43
  )
44
44
 
45
45
 
46
+ def test_rendered_report_defines_every_referenced_css_class(tmp_path: Path) -> None:
47
+ """Every class the rendered report markup references resolves to a CSS selector.
48
+
49
+ Renders the report from the findings fixture so the raw-findings appendix is
50
+ present, then asserts no class attribute names a style the stylesheet omits,
51
+ keeping the report markup and HTML_STYLE_BLOCK from drifting apart.
52
+ """
53
+ out_path = tmp_path / "report.html"
54
+ completed = _render_cli(FIXTURE_JOURNAL, out_path)
55
+ assert completed.returncode == 0, f"CLI failed:\n{completed.stderr}"
56
+ html_content = out_path.read_text(encoding="utf-8")
57
+
58
+ style_match = re.search(r"<style>(.*?)</style>", html_content, re.DOTALL)
59
+ assert style_match is not None
60
+ defined_classes = set(re.findall(r"\.([A-Za-z][\w-]*)", style_match.group(1)))
61
+ referenced_classes = {
62
+ each_name
63
+ for attribute_value in re.findall(r'class="([^"]*)"', html_content)
64
+ for each_name in attribute_value.split()
65
+ }
66
+
67
+ orphan_classes = referenced_classes - defined_classes
68
+ assert not orphan_classes, f"classes referenced but undefined: {sorted(orphan_classes)}"
69
+
70
+
46
71
  def _copy_run_tree_without_summary_entry(destination_root: Path) -> Path:
47
72
  """Copy the fixture run tree, dropping the convergence-summary workflowProgress entry.
48
73
 
@@ -179,6 +204,36 @@ def test_render_issue_class_panels_for_each_medium() -> None:
179
204
  assert "1 finding" in panels_html
180
205
 
181
206
 
207
+ def test_render_issue_class_panels_draws_text_panel_for_text_medium() -> None:
208
+ """Should draw a text panel holding the supplied lines when the medium is text."""
209
+ convergence_summary = {
210
+ "verdictLine": "Converged.",
211
+ "problemScenes": [],
212
+ "fixScenes": [],
213
+ "issueClasses": [
214
+ {
215
+ "plainName": "A plain-text symptom",
216
+ "count": 1,
217
+ "severity": "P2",
218
+ "category": "bug",
219
+ "status": "fixed",
220
+ "cause": "A grounded cause sentence.",
221
+ "medium": "text",
222
+ "beforeLines": ["pages reloaded every visit"],
223
+ "afterLines": ["pages reuse the saved copy"],
224
+ }
225
+ ],
226
+ }
227
+
228
+ panels_html = render_report._render_issue_class_panels(convergence_summary)
229
+
230
+ assert 'class="text-panel"' in panels_html
231
+ assert "pages reloaded every visit" in panels_html
232
+ assert "pages reuse the saved copy" in panels_html
233
+ assert 'class="terminal"' not in panels_html
234
+ assert 'class="code-panel"' not in panels_html
235
+
236
+
182
237
  def test_cli_renders_cause_line_with_severity_parenthetical(tmp_path: Path) -> None:
183
238
  """Should render a cause line carrying the plain cause and a muted parenthetical."""
184
239
  out_path = tmp_path / "report-cause.html"