@plainconceptsplatform/workflows 0.19.2 → 0.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/catalog-installation.js +12 -1
  2. package/dist/index.js +0 -0
  3. package/dist/stack-defaults.js +16 -16
  4. package/dist/worker-env.js +14 -0
  5. package/loops/actions/add-issue-labels/action.yml +50 -50
  6. package/loops/actions/agent-output.cjs +17 -17
  7. package/loops/actions/apply-agent-bundle/action.yml +24 -24
  8. package/loops/actions/apply-agent-comments/action.yml +42 -42
  9. package/loops/actions/apply-agent-labels/action.yml +55 -55
  10. package/loops/actions/apply-agent-output/action.yml +108 -108
  11. package/loops/actions/assess-blast-radius/action.yml +148 -0
  12. package/loops/actions/assess-blast-radius/assess-blast-radius.sh +138 -0
  13. package/loops/actions/classify-route/action.yml +100 -100
  14. package/loops/actions/cleanup-artifacts/action.yml +91 -91
  15. package/loops/actions/close-agent-issues/action.yml +43 -43
  16. package/loops/actions/create-agent-issues/action.yml +52 -52
  17. package/loops/actions/create-issue-comment/action.yml +29 -29
  18. package/loops/actions/download-agent-output/action.yml +53 -53
  19. package/loops/actions/housekeeping/action.yml +55 -2
  20. package/loops/actions/link-pr-to-issue/action.yml +40 -40
  21. package/loops/actions/list-open-issues/action.yml +33 -33
  22. package/loops/actions/load-issue-context/action.yml +45 -45
  23. package/loops/actions/merge-agent-pr/action.yml +49 -49
  24. package/loops/actions/push-agent-branch/action.yml +45 -45
  25. package/loops/actions/remove-issue-labels/action.yml +37 -37
  26. package/loops/actions/update-agent-issues/action.yml +58 -58
  27. package/loops/actions/validate-merge-gate-output/action.yml +62 -40
  28. package/loops/actions/validate-merge-gate-output/validate-merge-gate-output.sh +147 -31
  29. package/loops/actions/validate-refine-output/action.yml +48 -44
  30. package/loops/actions/validate-refine-output/validate-refine-output.sh +15 -4
  31. package/loops/actions/validate-review-output/action.yml +35 -35
  32. package/loops/actions/validate-triage-output/action.yml +36 -36
  33. package/loops/actions/verify-composite-actions/action.yml +9 -9
  34. package/loops/actions/verify-refine-output/action.yml +9 -9
  35. package/loops/actions/verify-refine-output/verify-refine-output.sh +6 -1
  36. package/loops/actions/verify-route-matrix/action.yml +9 -9
  37. package/loops/actions/verify-route-matrix/verify-gate-metrics.mjs +51 -0
  38. package/loops/actions/verify-route-matrix/verify-route-matrix.sh +329 -29
  39. package/loops/scripts/compile-agent-workflows.mjs +331 -331
  40. package/loops/templates/agentics/agentics-maintenance.yml +121 -121
  41. package/loops/templates/ci/app-ci-dotnet-next.yml +330 -330
  42. package/loops/templates/ci/app-ci-node-monorepo.yml +260 -260
  43. package/loops/templates/issues/bug_report.yml +109 -109
  44. package/loops/templates/issues/feature_request.yml +75 -75
  45. package/loops/templates/opencode/opencode.ci.json +55 -49
  46. package/loops/templates/opencode/opencode.ci.json.md +59 -49
  47. package/loops/templates/release/github-release.yml +30 -30
  48. package/loops/workflows/agent-merge-gate.md +367 -148
  49. package/loops/workflows/agent-refine.md +60 -16
  50. package/loops/workflows/authorize-bot-work.yml +105 -105
  51. package/loops/workflows/shared/opencode-ci.md +206 -206
  52. package/loops/workflows/shared/platform-defaults.md +19 -19
  53. package/package.json +12 -11
@@ -1,37 +1,37 @@
1
- # Managed by @plainconceptsplatform/workflows. Source: loops/actions/remove-issue-labels/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
- name: Remove issue labels
3
- description: Remove one or more labels from an issue or pull request.
4
- inputs:
5
- token:
6
- description: GitHub token used by github-script.
7
- required: true
8
- issue-number:
9
- description: Issue or pull request number.
10
- required: true
11
- labels:
12
- description: Labels to remove, one per line. A comma-separated list is accepted as well.
13
- required: true
14
- runs:
15
- using: composite
16
- steps:
17
- - name: Remove labels
18
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
19
- env:
20
- ISSUE_NUMBER: ${{ inputs.issue-number }}
21
- LABELS: ${{ inputs.labels }}
22
- with:
23
- github-token: ${{ inputs.token }}
24
- script: |
25
- // Callers wrote `labels: a,b` while this split on newlines, so one label named "a,b"
26
- // was removed: a 404, swallowed below, and neither label ever came off.
27
- for (const label of process.env.LABELS.split(/\r?\n|,/).map((label) => label.trim()).filter(Boolean)) {
28
- try {
29
- await github.rest.issues.removeLabel({
30
- ...context.repo,
31
- issue_number: Number(process.env.ISSUE_NUMBER),
32
- name: label,
33
- });
34
- } catch (error) {
35
- if (error.status !== 404) throw error;
36
- }
37
- }
1
+ # Managed by @plainconceptsplatform/workflows. Source: loops/actions/remove-issue-labels/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
+ name: Remove issue labels
3
+ description: Remove one or more labels from an issue or pull request.
4
+ inputs:
5
+ token:
6
+ description: GitHub token used by github-script.
7
+ required: true
8
+ issue-number:
9
+ description: Issue or pull request number.
10
+ required: true
11
+ labels:
12
+ description: Labels to remove, one per line. A comma-separated list is accepted as well.
13
+ required: true
14
+ runs:
15
+ using: composite
16
+ steps:
17
+ - name: Remove labels
18
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
19
+ env:
20
+ ISSUE_NUMBER: ${{ inputs.issue-number }}
21
+ LABELS: ${{ inputs.labels }}
22
+ with:
23
+ github-token: ${{ inputs.token }}
24
+ script: |
25
+ // Callers wrote `labels: a,b` while this split on newlines, so one label named "a,b"
26
+ // was removed: a 404, swallowed below, and neither label ever came off.
27
+ for (const label of process.env.LABELS.split(/\r?\n|,/).map((label) => label.trim()).filter(Boolean)) {
28
+ try {
29
+ await github.rest.issues.removeLabel({
30
+ ...context.repo,
31
+ issue_number: Number(process.env.ISSUE_NUMBER),
32
+ name: label,
33
+ });
34
+ } catch (error) {
35
+ if (error.status !== 404) throw error;
36
+ }
37
+ }
@@ -1,58 +1,58 @@
1
- # Managed by @plainconceptsplatform/workflows. Source: loops/actions/update-agent-issues/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
- name: Update agent issues
3
- description: Apply every update_issue item from agent_output.json.
4
- inputs:
5
- output-file:
6
- description: Path to agent_output.json.
7
- required: true
8
- token:
9
- description: GitHub token with issues:write.
10
- required: true
11
- fallback-issue-number:
12
- description: Issue an item targets when it names none.
13
- required: false
14
- default: ''
15
- runs:
16
- using: composite
17
- steps:
18
- - name: Update issues
19
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
20
- env:
21
- AGENT_OUTPUT_LIB: ${{ github.action_path }}/../agent-output.cjs
22
- OUTPUT_FILE: ${{ inputs.output-file }}
23
- FALLBACK_ISSUE_NUMBER: ${{ inputs.fallback-issue-number }}
24
- with:
25
- github-token: ${{ inputs.token }}
26
- script: |
27
- const { readAgentItems } = require(process.env.AGENT_OUTPUT_LIB);
28
- const items = readAgentItems(process.env.OUTPUT_FILE, 'update_issue');
29
-
30
- let updated = 0;
31
-
32
- for (const item of items) {
33
- const target = item.item_number ?? process.env.FALLBACK_ISSUE_NUMBER;
34
-
35
- if (!target) {
36
- core.warning(`update_issue item has no item_number and no fallback, skipping: ${JSON.stringify(item)}`);
37
- continue;
38
- }
39
-
40
- const changes = {};
41
- if (item.title) changes.title = item.title;
42
- if (item.body) changes.body = item.body;
43
-
44
- if (Object.keys(changes).length === 0) {
45
- core.warning(`update_issue item changes nothing, skipping: ${JSON.stringify(item)}`);
46
- continue;
47
- }
48
-
49
- await github.rest.issues.update({
50
- ...context.repo,
51
- issue_number: Number(target),
52
- ...changes,
53
- });
54
-
55
- updated += 1;
56
- }
57
-
58
- core.info(`Updated ${updated} issue(s).`);
1
+ # Managed by @plainconceptsplatform/workflows. Source: loops/actions/update-agent-issues/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
+ name: Update agent issues
3
+ description: Apply every update_issue item from agent_output.json.
4
+ inputs:
5
+ output-file:
6
+ description: Path to agent_output.json.
7
+ required: true
8
+ token:
9
+ description: GitHub token with issues:write.
10
+ required: true
11
+ fallback-issue-number:
12
+ description: Issue an item targets when it names none.
13
+ required: false
14
+ default: ''
15
+ runs:
16
+ using: composite
17
+ steps:
18
+ - name: Update issues
19
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
20
+ env:
21
+ AGENT_OUTPUT_LIB: ${{ github.action_path }}/../agent-output.cjs
22
+ OUTPUT_FILE: ${{ inputs.output-file }}
23
+ FALLBACK_ISSUE_NUMBER: ${{ inputs.fallback-issue-number }}
24
+ with:
25
+ github-token: ${{ inputs.token }}
26
+ script: |
27
+ const { readAgentItems } = require(process.env.AGENT_OUTPUT_LIB);
28
+ const items = readAgentItems(process.env.OUTPUT_FILE, 'update_issue');
29
+
30
+ let updated = 0;
31
+
32
+ for (const item of items) {
33
+ const target = item.item_number ?? process.env.FALLBACK_ISSUE_NUMBER;
34
+
35
+ if (!target) {
36
+ core.warning(`update_issue item has no item_number and no fallback, skipping: ${JSON.stringify(item)}`);
37
+ continue;
38
+ }
39
+
40
+ const changes = {};
41
+ if (item.title) changes.title = item.title;
42
+ if (item.body) changes.body = item.body;
43
+
44
+ if (Object.keys(changes).length === 0) {
45
+ core.warning(`update_issue item changes nothing, skipping: ${JSON.stringify(item)}`);
46
+ continue;
47
+ }
48
+
49
+ await github.rest.issues.update({
50
+ ...context.repo,
51
+ issue_number: Number(target),
52
+ ...changes,
53
+ });
54
+
55
+ updated += 1;
56
+ }
57
+
58
+ core.info(`Updated ${updated} issue(s).`);
@@ -1,40 +1,62 @@
1
- # Managed by @plainconceptsplatform/workflows. Source: loops/actions/validate-merge-gate-output/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
- name: Validate merge-gate output
3
- description: Verify agent_output.json contains a usable merge-gate verdict before any state changes.
4
- inputs:
5
- output-file:
6
- description: Path to agent_output.json.
7
- required: true
8
- issue-number:
9
- description: Issue number that the merge-gate outcome must target.
10
- required: true
11
- ci-conclusion:
12
- description: CI conclusion supplied to the merge gate.
13
- required: true
14
- outputs:
15
- valid:
16
- description: Whether the output contains a usable merge-gate comment with a verdict.
17
- value: ${{ steps.validate.outputs.valid }}
18
- outcome:
19
- description: "Deterministic outcome: merge, review, remediated, or invalid."
20
- value: ${{ steps.validate.outputs.outcome }}
21
- runs:
22
- using: composite
23
- steps:
24
- - name: Validate merge-gate outcome
25
- id: validate
26
- shell: bash
27
- env:
28
- ISSUE_NUMBER: ${{ inputs.issue-number }}
29
- CI_CONCLUSION: ${{ inputs.ci-conclusion }}
30
- OUTPUT_FILE: ${{ inputs.output-file }}
31
- run: |
32
- set -euo pipefail
33
-
34
- outcome="$(bash "${{ github.action_path }}/validate-merge-gate-output.sh" "$OUTPUT_FILE" "$ISSUE_NUMBER" "$CI_CONCLUSION")"
35
- echo "outcome=$outcome" >> "$GITHUB_OUTPUT"
36
- echo "valid=$([ "$outcome" != 'invalid' ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
37
-
38
- if [ "$outcome" = 'invalid' ]; then
39
- echo "::warning::Agent output has no usable merge-gate outcome. It will not be applied."
40
- fi
1
+ # Managed by @plainconceptsplatform/workflows. Source: loops/actions/validate-merge-gate-output/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
+ name: Validate merge-gate output
3
+ description: Compute the merge-gate disposition from measured facts and the agent's structured report, before any state changes.
4
+ inputs:
5
+ output-file:
6
+ description: Path to agent_output.json.
7
+ required: true
8
+ issue-number:
9
+ description: Issue number that the merge-gate report must target.
10
+ required: true
11
+ ci-conclusion:
12
+ description: CI conclusion supplied to the merge gate.
13
+ required: true
14
+ blast-level:
15
+ description: "Measured blast radius: low, medium or high."
16
+ required: false
17
+ default: low
18
+ protected-hit:
19
+ description: Whether a protected path was touched.
20
+ required: false
21
+ default: 'false'
22
+ owner-hit:
23
+ description: Whether an owner path was touched.
24
+ required: false
25
+ default: 'false'
26
+ confidence-threshold:
27
+ description: Agent confidence below which the pull request goes to a human.
28
+ required: false
29
+ default: '0.8'
30
+ outputs:
31
+ valid:
32
+ description: Whether the output produced a usable disposition.
33
+ value: ${{ steps.validate.outputs.valid }}
34
+ outcome:
35
+ description: "Disposition: auto-merge, human-review, owner-review, blocked, remediated, or invalid."
36
+ value: ${{ steps.validate.outputs.outcome }}
37
+ runs:
38
+ using: composite
39
+ steps:
40
+ - name: Compute the merge-gate disposition
41
+ id: validate
42
+ shell: bash
43
+ env:
44
+ ISSUE_NUMBER: ${{ inputs.issue-number }}
45
+ CI_CONCLUSION: ${{ inputs.ci-conclusion }}
46
+ OUTPUT_FILE: ${{ inputs.output-file }}
47
+ BLAST_LEVEL: ${{ inputs.blast-level }}
48
+ PROTECTED_HIT: ${{ inputs.protected-hit }}
49
+ OWNER_HIT: ${{ inputs.owner-hit }}
50
+ CONFIDENCE_THRESHOLD: ${{ inputs.confidence-threshold }}
51
+ run: |
52
+ set -euo pipefail
53
+
54
+ outcome="$(bash "${{ github.action_path }}/validate-merge-gate-output.sh" \
55
+ "$OUTPUT_FILE" "$ISSUE_NUMBER" "$CI_CONCLUSION" \
56
+ "$BLAST_LEVEL" "$PROTECTED_HIT" "$OWNER_HIT" "$CONFIDENCE_THRESHOLD")"
57
+ echo "outcome=$outcome" >> "$GITHUB_OUTPUT"
58
+ echo "valid=$([ "$outcome" != 'invalid' ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
59
+
60
+ if [ "$outcome" = 'invalid' ]; then
61
+ echo "::warning::Agent output has no usable merge-gate report. It will not be applied."
62
+ fi
@@ -1,46 +1,162 @@
1
1
  #!/usr/bin/env bash
2
2
  # Managed by @plainconceptsplatform/workflows. Source: loops/actions/validate-merge-gate-output/validate-merge-gate-output.sh. Update with `workflows update --force`; consumer edits may be overwritten.
3
- # Print the deterministic merge-gate outcome: merge, review, remediated, or invalid.
3
+ # Print the merge-gate disposition: auto-merge, human-review, owner-review, blocked,
4
+ # remediated, or invalid.
5
+ #
6
+ # This file used to read one word out of the agent's prose and call it the decision. It now
7
+ # computes the decision from two sources that cannot be confused with each other: facts the
8
+ # workflow measured before the agent ran (CI, protected and owner paths, blast radius), and
9
+ # structured evidence the agent produced (verified findings, recoverability, confidence). The
10
+ # agent no longer names the outcome. It reports what it found; the rules below decide.
11
+ #
12
+ # The shape rules are strict on purpose. Every permissive default in here is a way for a
13
+ # malformed report to merge code nobody assessed, and the shapes that matter are near misses
14
+ # rather than nonsense: `"verified": "true"` as a string, a severity spelled `blocker`, an
15
+ # `acceptanceCriteriaMet` of `"false"`. Each of those read as the permissive value once. A
16
+ # report that does not match the contract is `invalid`, which parks the pull request; only a
17
+ # report that does match gets to decide anything.
18
+ #
19
+ # Usage:
20
+ # validate-merge-gate-output.sh OUTPUT_FILE ISSUE CI_CONCLUSION \
21
+ # BLAST_LEVEL PROTECTED_HIT OWNER_HIT CONFIDENCE_THRESHOLD
4
22
 
5
23
  set -euo pipefail
6
24
 
7
25
  output_file="$1"
8
26
  issue_number="$2"
9
27
  ci_conclusion="$3"
28
+ # `${4-low}`, not `${4:-low}`: the colon form substitutes the default for an argument that was
29
+ # passed as an empty string, which is exactly the case that has to be caught. A skipped or
30
+ # failed `protected_changes` reaches this script as empty arguments, and reading those as
31
+ # "low, nothing protected" is how an unmeasured pull request would merge.
32
+ blast_level="${4-low}"
33
+ protected_hit="${5-false}"
34
+ owner_hit="${6-false}"
35
+ confidence_threshold="${7-0.8}"
36
+
37
+ # An empty measured level is not a low-risk pull request, it is a job that did not report. The
38
+ # defaults above exist for a caller that genuinely has nothing to say; an empty string arriving
39
+ # from a skipped or failed `protected_changes` must not read as the most permissive value.
40
+ [ -n "$blast_level" ] || blast_level=unmeasured
41
+ [ -n "$protected_hit" ] || protected_hit=unmeasured
42
+ [ -n "$owner_hit" ] || owner_hit=unmeasured
43
+ [ -n "$confidence_threshold" ] || confidence_threshold=0.8
10
44
 
11
45
  if [ ! -f "$output_file" ] || ! jq -e '.items | arrays' "$output_file" >/dev/null 2>&1; then
12
46
  echo invalid
13
47
  exit 0
14
48
  fi
15
49
 
16
- # The agent emits exactly one comment on the source issue. Its verdict tells the
17
- # workflow which App-token state transition to perform.
18
- jq -r --arg issue "$issue_number" --arg conclusion "$ci_conclusion" '
19
- .items as $items
20
- | ($items
21
- | [.[] | select(.type == "add_comment" and (.item_number | tostring) == $issue and (.body | type == "string"))]
22
- | map(.body |
23
- if test("\\*\\*Verdict:\\*\\*\\s*(merge|review|remediated)"; "i") then
24
- capture("\\*\\*Verdict:\\*\\*\\s*(?<v>merge|review|remediated)"; "i").v | ascii_downcase
25
- else empty end
26
- )
27
- | .[0] // "invalid") as $outcome
28
- | ([$items[] | select(.type == "push_to_pull_request_branch")] | length) as $pushes
29
- # remediated used to require conclusion == "failure", which threw correct work away. The prompt
30
- # tells the agent to merge main in, verify and push when CI is green but the pull request
31
- # conflicts. That is a real and common state: a conflicting pull request has no merge ref, so
32
- # GitHub can never run CI on that head, and the belt falls back to the last verdict on the
33
- # branch, which is usually success. The agent did the job, the validator called it invalid,
34
- # conclude was skipped, and because this worker stages its outputs the resolved merge commit
35
- # was discarded. The belt then dispatched again on the same verdict, up to six times, each a
36
- # full run on the single-slot merge belt. A push carrying a remediated verdict is remediation
37
- # whatever CI last said; what still matters is that exactly one push comes with it.
38
- #
39
- # No apostrophes in here. This block sits inside the single-quoted jq program, and one
40
- # apostrophe closes that string and breaks the script.
41
- | if $outcome == "merge" and $conclusion == "success" and $pushes == 0 then "merge"
42
- elif $outcome == "remediated" and $pushes == 1 then "remediated"
43
- elif $outcome == "review" and $pushes == 0 then "review"
44
- else "invalid"
45
- end
50
+ # Every jq error becomes `invalid` rather than a non-zero exit. A report shaped so badly that it
51
+ # crashes the program used to fail the step, which skipped `conclude` and left the belt to spend
52
+ # up to six full agent runs on what was a formatting mistake the first time.
53
+ jq -r \
54
+ --arg issue "$issue_number" \
55
+ --arg conclusion "$ci_conclusion" \
56
+ --arg blast "$blast_level" \
57
+ --arg protected "$protected_hit" \
58
+ --arg owner "$owner_hit" \
59
+ --arg threshold "$confidence_threshold" '
60
+
61
+ def level_rank: {"low": 0, "medium": 1, "high": 2}[.];
62
+ def is_bool: type == "boolean";
63
+ def known_severity: type == "string" and (ascii_downcase | . == "critical" or . == "high" or . == "medium" or . == "low");
64
+
65
+ # A finding the rules can act on. Anything else means the report is not the contract, and the
66
+ # whole report is refused rather than the finding being quietly dropped to the safe side.
67
+ def well_formed_finding:
68
+ (has("verified") and (.verified | is_bool))
69
+ and (has("severity") and (.severity | known_severity));
70
+
71
+ def blocks: .verified == true and (.severity | ascii_downcase | . == "critical" or . == "high");
72
+
73
+ def decide:
74
+ .items as $items
75
+
76
+ | [$items[] | select(.type == "add_comment"
77
+ and (.item_number | tostring) == $issue
78
+ and (.body | type == "string"))] as $comments
79
+
80
+ # Exactly one comment may carry a verdict. Taking the first of several let a second comment
81
+ # reporting a verified critical finding be discarded, and let a comment with no verdict at
82
+ # all supply the report for a verdict written in another.
83
+ | [$comments[] | select(.body | test("\\*\\*Verdict:\\*\\*\\s*(assessed|remediated)"; "i"))] as $verdicts
84
+ | if ($verdicts | length) != 1 then "invalid" else
85
+
86
+ ($verdicts[0].body) as $body
87
+ | ($body | capture("\\*\\*Verdict:\\*\\*\\s*(?<v>assessed|remediated)"; "i").v | ascii_downcase) as $verdict
88
+ | ([$items[] | select(.type == "push_to_pull_request_branch")] | length) as $pushes
89
+
90
+ # The LAST fenced json block in that comment, because the prompt puts the report last and
91
+ # the prose above it routinely quotes json from the diff under review. Reading the first
92
+ # fence handed the decision to whatever the agent happened to quote, and PROTECTED_PATHS
93
+ # names package.json and global.json, so the reviewed diff is often json.
94
+ | ([$body | scan("```json\\s*(.*?)```"; "m") | .[0]] | last) as $fence
95
+
96
+ # remediated used to require conclusion == "failure", which threw correct work away. The
97
+ # prompt tells the agent to merge main in, verify and push when CI is green but the pull
98
+ # request conflicts. That is a real and common state: a conflicting pull request has no
99
+ # merge ref, so GitHub can never run CI on that head, and the belt falls back to the last
100
+ # verdict on the branch, which is usually success. The agent did the job, the validator
101
+ # called it invalid, conclude was skipped, and because this worker stages its outputs the
102
+ # resolved merge commit was discarded. The belt then dispatched again on the same verdict,
103
+ # up to six times, each a full run on the single-slot merge belt.
104
+ #
105
+ # No apostrophes in here. This block sits inside the single-quoted jq program, and one
106
+ # apostrophe closes that string and breaks the script.
107
+ | if $verdict == "remediated" and $pushes == 1 then "remediated"
108
+ elif $verdict != "assessed" or $pushes != 0 then "invalid"
109
+ elif $fence == null then "invalid"
110
+ else
111
+ ($fence | fromjson) as $report
112
+ | if ($report | type) != "object" then "invalid" else
113
+
114
+ ($report.findings // []) as $findings
115
+ | (($report.blastRadiusRaise // {}) | if type == "object" then (.to // $blast) else null end) as $raise
116
+
117
+ # Every field the decision reads is checked before any of it is read. A near miss
118
+ # here is not a small problem: each one of these used to resolve to the permissive
119
+ # value and merge.
120
+ | if ($findings | type) != "array" then "invalid"
121
+ elif ([$findings[] | select(well_formed_finding | not)] | length) > 0 then "invalid"
122
+ elif ($report | has("recoverability")) and (($report.recoverability | type != "string") or (($report.recoverability | ascii_downcase) | level_rank) == null) then "invalid"
123
+ elif ($report | has("acceptanceCriteriaMet")) and (($report.acceptanceCriteriaMet | is_bool) | not) then "invalid"
124
+ elif ($report | has("confidence")) and (($report.confidence | type) != "number") then "invalid"
125
+ elif $raise == null or ($raise | type != "string") or (($raise | ascii_downcase) | level_rank) == null then "invalid"
126
+ elif ($blast | level_rank) == null then "invalid"
127
+ else
128
+ ([$findings[] | select(blocks)] | length) as $blockers
129
+
130
+ # The agent may raise the measured blast radius when it finds something the path
131
+ # rules could not see. It may never lower it.
132
+ | ([($blast | level_rank), ($raise | ascii_downcase | level_rank)] | max) as $level
133
+
134
+ # The same rule the findings live by, applied to the one judgement field that
135
+ # can park a pull request on its own. An unevidenced "low" is the old category
136
+ # escalation wearing a new name: replayed against a consumer, a model that rated
137
+ # everything low dropped the auto-merge rate straight back to 27%, which is
138
+ # where it started. Saying a change cannot be undone means naming what cannot.
139
+ | (($report.recoverability // "medium") | ascii_downcase) as $claimed
140
+ | ((($report.recoverabilitySignals // []) | (type == "array") and (length > 0))) as $evidenced
141
+ | (if $claimed == "low" and ($evidenced | not) then "medium" else $claimed end) as $recoverability
142
+ | (($report.confidence // 0)) as $confidence
143
+
144
+ | if $conclusion != "success" then "blocked"
145
+ elif $blockers > 0 then "blocked"
146
+ elif $protected == "true" or $owner == "true" or $level == 2 then "owner-review"
147
+ # A fact the workflow could not measure is not a fact. Anything other than a
148
+ # clean true or false here means protected_changes did not report, and the
149
+ # pull request goes to a person rather than through on a default.
150
+ elif $protected != "false" or $owner != "false" then "human-review"
151
+ elif $level == 1 and $recoverability == "low" then "human-review"
152
+ elif ($report | has("acceptanceCriteriaMet")) and $report.acceptanceCriteriaMet == false then "human-review"
153
+ elif $confidence < ($threshold | tonumber) then "human-review"
154
+ else "auto-merge"
155
+ end
156
+ end
157
+ end
158
+ end
159
+ end;
160
+
161
+ try decide catch "invalid"
46
162
  ' "$output_file"
@@ -1,44 +1,48 @@
1
- # Managed by @plainconceptsplatform/workflows. Source: loops/actions/validate-refine-output/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
- name: Validate refine output
3
- description: Verify agent_output.json contains a usable refinement outcome before any GitHub writes.
4
- inputs:
5
- output-file:
6
- description: Path to agent_output.json.
7
- required: true
8
- marker:
9
- description: Comment marker removed before evaluating clarification content.
10
- required: true
11
- comment-prefix:
12
- description: Comment prefix removed before evaluating clarification content.
13
- required: true
14
- issue-number:
15
- description: Issue number that every refinement outcome must target.
16
- required: true
17
- outputs:
18
- valid:
19
- description: Whether the output contains a usable replacement body or clarification comment.
20
- value: ${{ steps.validate.outputs.valid }}
21
- outcome:
22
- description: "Deterministic refinement outcome: complete, questions, or invalid."
23
- value: ${{ steps.validate.outputs.outcome }}
24
- runs:
25
- using: composite
26
- steps:
27
- - name: Validate refinement outcome
28
- id: validate
29
- shell: bash
30
- env:
31
- COMMENT_PREFIX: ${{ inputs.comment-prefix }}
32
- ISSUE_NUMBER: ${{ inputs.issue-number }}
33
- MARKER: ${{ inputs.marker }}
34
- OUTPUT_FILE: ${{ inputs.output-file }}
35
- run: |
36
- set -euo pipefail
37
-
38
- outcome="$(bash "${{ github.action_path }}/validate-refine-output.sh" "$OUTPUT_FILE" "$MARKER" "$COMMENT_PREFIX" "$ISSUE_NUMBER")"
39
- echo "outcome=$outcome" >> "$GITHUB_OUTPUT"
40
- echo "valid=$([ "$outcome" != 'invalid' ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
41
-
42
- if [ "$outcome" = 'invalid' ]; then
43
- echo "::warning::Agent output has no usable refinement outcome. It will not be applied."
44
- fi
1
+ # Managed by @plainconceptsplatform/workflows. Source: loops/actions/validate-refine-output/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
+ name: Validate refine output
3
+ description: Verify agent_output.json contains a usable refinement outcome before any GitHub writes.
4
+ inputs:
5
+ output-file:
6
+ description: Path to agent_output.json.
7
+ required: true
8
+ marker:
9
+ description: Comment marker removed before evaluating clarification content.
10
+ required: true
11
+ draft-marker:
12
+ description: Marker that identifies a temporal draft body written while questions remain.
13
+ required: true
14
+ comment-prefix:
15
+ description: Comment prefix removed before evaluating clarification content.
16
+ required: true
17
+ issue-number:
18
+ description: Issue number that every refinement outcome must target.
19
+ required: true
20
+ outputs:
21
+ valid:
22
+ description: Whether the output contains a usable replacement body or clarification comment.
23
+ value: ${{ steps.validate.outputs.valid }}
24
+ outcome:
25
+ description: "Deterministic refinement outcome: complete, split, questions, or invalid."
26
+ value: ${{ steps.validate.outputs.outcome }}
27
+ runs:
28
+ using: composite
29
+ steps:
30
+ - name: Validate refinement outcome
31
+ id: validate
32
+ shell: bash
33
+ env:
34
+ COMMENT_PREFIX: ${{ inputs.comment-prefix }}
35
+ DRAFT_MARKER: ${{ inputs.draft-marker }}
36
+ ISSUE_NUMBER: ${{ inputs.issue-number }}
37
+ MARKER: ${{ inputs.marker }}
38
+ OUTPUT_FILE: ${{ inputs.output-file }}
39
+ run: |
40
+ set -euo pipefail
41
+
42
+ outcome="$(bash "${{ github.action_path }}/validate-refine-output.sh" "$OUTPUT_FILE" "$MARKER" "$COMMENT_PREFIX" "$ISSUE_NUMBER" "$DRAFT_MARKER")"
43
+ echo "outcome=$outcome" >> "$GITHUB_OUTPUT"
44
+ echo "valid=$([ "$outcome" != 'invalid' ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
45
+
46
+ if [ "$outcome" = 'invalid' ]; then
47
+ echo "::warning::Agent output has no usable refinement outcome. It will not be applied."
48
+ fi
@@ -8,13 +8,14 @@ output_file="$1"
8
8
  marker="$2"
9
9
  comment_prefix="$3"
10
10
  issue_number="$4"
11
+ draft_marker="$5"
11
12
 
12
13
  if [ ! -f "$output_file" ] || ! jq -e '.items | arrays' "$output_file" >/dev/null 2>&1; then
13
14
  echo invalid
14
15
  exit 0
15
16
  fi
16
17
 
17
- jq -r --arg marker "$marker" --arg prefix "$comment_prefix" --arg issue "$issue_number" '
18
+ jq -r --arg marker "$marker" --arg prefix "$comment_prefix" --arg issue "$issue_number" --arg draft_marker "$draft_marker" '
18
19
  def has_replacement_body:
19
20
  any(.items[]; .type == "update_issue" and
20
21
  (.item_number == null or (.item_number | tostring) == $issue) and
@@ -23,6 +24,14 @@ jq -r --arg marker "$marker" --arg prefix "$comment_prefix" --arg issue "$issue_
23
24
  def has_update:
24
25
  any(.items[]; .type == "update_issue");
25
26
 
27
+ # A temporal draft is a replacement body the worker still expects to grow: the draft
28
+ # marker distinguishes it from a finished body.
29
+ def has_draft_body:
30
+ any(.items[]; .type == "update_issue" and
31
+ (.item_number == null or (.item_number | tostring) == $issue) and
32
+ (.body | type == "string") and
33
+ (.body | contains($draft_marker)));
34
+
26
35
  # A split writes children, which are the only items allowed to target something other than
27
36
  # the source issue: they do not exist yet, so they carry no number at all.
28
37
  def child_count:
@@ -65,11 +74,13 @@ jq -r --arg marker "$marker" --arg prefix "$comment_prefix" --arg issue "$issue_
65
74
 
66
75
  # Order matters: a run that wrote children is a split even though it also replaced the
67
76
  # parent body, and a lone child with no parent update is an incomplete split, not a
68
- # complete refinement.
77
+ # complete refinement. A body carrying the draft marker is a temporal draft, not a
78
+ # finished refinement: it only counts as the questions outcome when the matching
79
+ # batched-questions comment is also there.
69
80
  if child_count >= 2 and has_replacement_body and has_only_source_items then "split"
70
81
  elif child_count > 0 then "invalid"
71
- elif has_replacement_body and has_only_source_items then "complete"
72
- elif has_clarification and (has_update | not) and has_only_source_items
82
+ elif has_replacement_body and (has_draft_body | not) and has_only_source_items then "complete"
83
+ elif has_clarification and ((has_update | not) or has_draft_body) and has_only_source_items
73
84
  and (reported_incomplete | not) then "questions"
74
85
  else "invalid"
75
86
  end