@plainconceptsplatform/workflows 0.4.34 → 0.6.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 (61) hide show
  1. package/README.md +88 -88
  2. package/dist/action-validation.test.js +5 -2
  3. package/dist/catalog-installation.js +50 -7
  4. package/dist/catalog-installation.test.js +59 -22
  5. package/dist/catalog-listing.test.js +4 -4
  6. package/dist/index.js +48 -48
  7. package/dist/index.test.js +9 -9
  8. package/dist/repository-inspection.test.js +2 -2
  9. package/dist/route-processing.js +24 -25
  10. package/dist/route-processing.test.js +166 -233
  11. package/dist/stack-defaults.js +14 -14
  12. package/dist/stack-defaults.test.js +79 -79
  13. package/dist/tui.test.js +10 -7
  14. package/dist/workflow-catalog.d.ts +1 -1
  15. package/dist/workflow-catalog.js +1 -5
  16. package/loops/actions/add-issue-labels/action.yml +2 -2
  17. package/loops/actions/agent-output.cjs +17 -17
  18. package/loops/actions/apply-agent-bundle/apply-bundle.sh +14 -1
  19. package/loops/actions/audit-close/action.yml +24 -0
  20. package/loops/actions/classify-route/action.yml +8 -3
  21. package/loops/actions/classify-route/classify-route.sh +58 -38
  22. package/loops/actions/cleanup-artifacts/action.yml +38 -12
  23. package/loops/actions/identify-gate-subject/action.yml +3 -1
  24. package/loops/actions/remove-issue-labels/action.yml +4 -2
  25. package/loops/actions/update-changelog/action.yml +113 -0
  26. package/loops/actions/validate-merge-gate-output/action.yml +40 -40
  27. package/loops/actions/validate-refine-output/validate-refine-output.sh +34 -5
  28. package/loops/actions/validate-review-output/action.yml +35 -35
  29. package/loops/actions/validate-triage-output/action.yml +36 -36
  30. package/loops/actions/verify-refine-output/verify-refine-output.sh +20 -0
  31. package/loops/actions/verify-route-matrix/verify-route-matrix.sh +375 -36
  32. package/loops/scripts/compile-agent-workflows.mjs +290 -6
  33. package/loops/scripts/merge-changelog.mjs +76 -0
  34. package/loops/templates/agentics/actionlint.yaml +13 -0
  35. package/loops/templates/agentics/agentics-checks.yml +125 -8
  36. package/loops/templates/agentics/agentics-maintenance.yml +121 -121
  37. package/loops/templates/ci/app-ci-dotnet-next.yml +330 -171
  38. package/loops/templates/ci/app-ci-node-monorepo.yml +260 -178
  39. package/loops/templates/issues/bug_report.yml +109 -109
  40. package/loops/templates/issues/feature_request.yml +75 -75
  41. package/loops/templates/opencode/opencode.ci.json +49 -47
  42. package/loops/templates/opencode/opencode.ci.json.md +49 -41
  43. package/loops/templates/release/github-release.yml +30 -30
  44. package/loops/workflows/agent-apply-review.md +469 -443
  45. package/loops/workflows/agent-audit.md +213 -197
  46. package/loops/workflows/agent-implement.md +640 -414
  47. package/loops/workflows/agent-merge-gate.md +844 -584
  48. package/loops/workflows/agent-refine.md +633 -418
  49. package/loops/workflows/agent-release.md +258 -0
  50. package/loops/workflows/agent-triage.md +447 -439
  51. package/loops/workflows/authorize-bot-work.yml +85 -82
  52. package/loops/workflows/shared/opencode-ci.md +206 -197
  53. package/loops/workflows/shared/platform-defaults.md +19 -16
  54. package/loops/workflows/work-router.yml +1038 -632
  55. package/package.json +7 -8
  56. package/dist/repository-state.d.ts +0 -18
  57. package/dist/repository-state.js +0 -77
  58. package/dist/repository-state.test.d.ts +0 -1
  59. package/dist/repository-state.test.js +0 -96
  60. package/loops/workflows/agent-direct.md +0 -375
  61. package/loops/workflows/agent-propose.md +0 -342
@@ -36,17 +36,32 @@ runs:
36
36
  const failed = [];
37
37
 
38
38
  for (const artifact of expired) {
39
- try {
40
- await github.rest.actions.deleteArtifact({
41
- ...context.repo,
42
- artifact_id: artifact.id,
43
- });
39
+ // GitHub answers a delete with a 5xx now and then. Three tries, because the
40
+ // alternative is a red daily run over one artifact that tomorrow's run would
41
+ // have removed anyway.
42
+ let lastError;
43
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
44
+ try {
45
+ await github.rest.actions.deleteArtifact({
46
+ ...context.repo,
47
+ artifact_id: artifact.id,
48
+ });
44
49
 
45
- core.info(`Deleted ${artifact.name} (created ${artifact.created_at})`);
46
- deleted += 1;
47
- } catch (error) {
48
- core.warning(`Could not delete ${artifact.name} (${artifact.id}): ${error.message}`);
49
- failed.push(artifact.name);
50
+ core.info(`Deleted ${artifact.name} (created ${artifact.created_at})`);
51
+ deleted += 1;
52
+ lastError = undefined;
53
+ break;
54
+ } catch (error) {
55
+ lastError = error;
56
+ if (attempt < 3) await new Promise((wait) => setTimeout(wait, attempt * 2000));
57
+ }
58
+ }
59
+
60
+ if (lastError) {
61
+ core.warning(
62
+ `Could not delete ${artifact.name} (${artifact.id}) after 3 tries: ${lastError.message}`,
63
+ );
64
+ failed.push({ name: artifact.name, status: lastError.status });
50
65
  }
51
66
  }
52
67
 
@@ -60,6 +75,17 @@ runs:
60
75
  ])
61
76
  .write();
62
77
 
63
- if (failed.length > 0) {
64
- core.setFailed(`${failed.length} artifact(s) could not be deleted: ${failed.join(', ')}`);
78
+ // Deleting nothing at all while expired artifacts exist is a permission or
79
+ // configuration problem, and has to be loud. One stubborn artifact among many is
80
+ // the API having a bad minute: tomorrow's run picks it up, and failing here only
81
+ // teaches people to ignore a red cleanup. A 4xx is never transient, so it fails
82
+ // whatever else succeeded.
83
+ const names = failed.map((entry) => entry.name).join(', ');
84
+ const structural = failed.some((entry) => entry.status && entry.status < 500);
85
+ if (failed.length > 0 && (deleted === 0 || structural)) {
86
+ core.setFailed(`${failed.length} artifact(s) could not be deleted: ${names}`);
87
+ } else if (failed.length > 0) {
88
+ core.warning(
89
+ `${failed.length} artifact(s) survived this run and will be retried tomorrow: ${names}`,
90
+ );
65
91
  }
@@ -96,8 +96,10 @@ runs:
96
96
  ci_run_id=""
97
97
  if [ -z "$ci_conclusion" ]; then
98
98
  head_sha="$(gh pr view "$PR_NUMBER" --repo "$REPO" --json headRefOid --jq '.headRefOid')"
99
+ # A run keeps the name it had when it ran, so a repository whose CI was renamed still
100
+ # carries runs under the old casing. Fold the name before comparing.
99
101
  ci_run="$(gh api "repos/$REPO/actions/runs?head_sha=$head_sha&per_page=100" \
100
- --jq '[.workflow_runs[] | select(.name == "App: CI" or .name == "CI")] | sort_by(.created_at) | last')"
102
+ --jq '[.workflow_runs[] | select((.name | ascii_downcase) == "app: ci" or (.name | ascii_downcase) == "ci")] | sort_by(.created_at) | last')"
101
103
 
102
104
  if [ -n "$ci_run" ] && [ "$ci_run" != "null" ]; then
103
105
  ci_conclusion="$(jq -r '.conclusion // "pending"' <<<"$ci_run")"
@@ -9,7 +9,7 @@ inputs:
9
9
  description: Issue or pull request number.
10
10
  required: true
11
11
  labels:
12
- description: Newline-delimited labels to remove.
12
+ description: Labels to remove, one per line. A comma-separated list is accepted as well.
13
13
  required: true
14
14
  runs:
15
15
  using: composite
@@ -22,7 +22,9 @@ runs:
22
22
  with:
23
23
  github-token: ${{ inputs.token }}
24
24
  script: |
25
- for (const label of process.env.LABELS.split(/\r?\n/).filter(Boolean)) {
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)) {
26
28
  try {
27
29
  await github.rest.issues.removeLabel({
28
30
  ...context.repo,
@@ -0,0 +1,113 @@
1
+ # Managed by @plainconceptsplatform/workflows. Source: loops/actions/update-changelog/action.yml. Update with `workflows update --force`; consumer edits may be overwritten.
2
+ name: Update changelog
3
+ description: Prepend a changelog entry for a landed issue and push it to the default branch.
4
+
5
+ inputs:
6
+ token:
7
+ description: Token with contents write on the repository.
8
+ required: true
9
+ issue-number:
10
+ description: Issue the landed commit implements.
11
+ required: true
12
+ commit-sha:
13
+ description: Commit the work landed as. Its subject becomes the summary.
14
+ required: true
15
+ changelog-path:
16
+ description: Path to changelog.json.
17
+ required: false
18
+ default: apps/web/src/shared/data/changelog.json
19
+ max-entries:
20
+ description: Newest entries to keep.
21
+ required: false
22
+ default: "20"
23
+
24
+ runs:
25
+ using: composite
26
+ steps:
27
+ - name: Prepend the entry and push
28
+ shell: bash
29
+ env:
30
+ GH_TOKEN: ${{ inputs.token }}
31
+ REPO: ${{ github.repository }}
32
+ ISSUE_NUMBER: ${{ inputs.issue-number }}
33
+ COMMIT_SHA: ${{ inputs.commit-sha }}
34
+ CHANGELOG_PATH: ${{ inputs.changelog-path }}
35
+ MAX_ENTRIES: ${{ inputs.max-entries }}
36
+ DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
37
+ run: |
38
+ set -euo pipefail
39
+
40
+ # This used to be part of the implement prompt. It is deterministic work, and leaving it
41
+ # to the agent made every implement touch the same file: two runs whose branches were cut
42
+ # before the other merged conflicted here, produced correct code, and still failed to open
43
+ # a pull request. Doing it after the merge means exactly one writer, always against a
44
+ # current default branch.
45
+
46
+ host="${GITHUB_SERVER_URL#https://}"
47
+ git config user.name "github-actions[bot]"
48
+ git config user.email "github-actions[bot]@users.noreply.github.com"
49
+ git remote set-url origin "https://x-access-token:${GH_TOKEN}@${host}/${GITHUB_REPOSITORY}.git"
50
+
51
+ git fetch --quiet origin "$DEFAULT_BRANCH"
52
+ git checkout --quiet -B "$DEFAULT_BRANCH" "origin/$DEFAULT_BRANCH"
53
+
54
+ if [ ! -f "$CHANGELOG_PATH" ]; then
55
+ echo "::notice::$CHANGELOG_PATH does not exist; nothing to update."
56
+ exit 0
57
+ fi
58
+
59
+ # Idempotent: a re-run, a retry, or a second call for the same commit must not add a
60
+ # duplicate row.
61
+ short_sha="${COMMIT_SHA:0:7}"
62
+ if jq -e --arg c "$short_sha" '.changes[]? | select(.commit == $c)' "$CHANGELOG_PATH" >/dev/null 2>&1; then
63
+ echo "::notice::$short_sha is already in the changelog."
64
+ exit 0
65
+ fi
66
+
67
+ title=$(gh issue view "$ISSUE_NUMBER" --repo "$REPO" --json title --jq '.title')
68
+
69
+ # The commit subject carries what actually changed. Strip the conventional-commit prefix
70
+ # and the trailing issue reference so the summary reads as prose rather than as a commit.
71
+ subject=$(git log -1 --format=%s "$COMMIT_SHA")
72
+ summary=$(printf '%s' "$subject" \
73
+ | sed -E 's/^[a-z]+(\([^)]*\))?!?: *//' \
74
+ | sed -E 's/ *\(#[0-9]+\) *$//')
75
+ [ -n "$summary" ] || summary="$title"
76
+ # Capitalise, because a commit subject conventionally starts lowercase and this is read
77
+ # by people rather than by tooling.
78
+ summary="$(printf '%s' "${summary:0:1}" | tr '[:lower:]' '[:upper:]')${summary:1}"
79
+
80
+ timestamp=$(git log -1 --format=%cI "$COMMIT_SHA")
81
+
82
+ tmp=$(mktemp)
83
+ jq --argjson n "$MAX_ENTRIES" \
84
+ --arg ts "$timestamp" \
85
+ --argjson issue "$ISSUE_NUMBER" \
86
+ --arg title "$title" \
87
+ --arg summary "$summary" \
88
+ --arg commit "$short_sha" \
89
+ '.changes = ([{timestamp: $ts, issue: $issue, title: $title, summary: $summary, commit: $commit}] + (.changes // []))[:$n]' \
90
+ "$CHANGELOG_PATH" > "$tmp"
91
+ mv "$tmp" "$CHANGELOG_PATH"
92
+
93
+ if git diff --quiet -- "$CHANGELOG_PATH"; then
94
+ echo "::notice::changelog unchanged."
95
+ exit 0
96
+ fi
97
+
98
+ git add -- "$CHANGELOG_PATH"
99
+ git commit --quiet -m "docs(changelog): record #${ISSUE_NUMBER}"
100
+
101
+ # The default branch may have moved while this ran. Rebase and retry rather than fail:
102
+ # only this action writes the file, so a rebase here cannot produce a content conflict.
103
+ for attempt in 1 2 3; do
104
+ if git push --quiet origin "$DEFAULT_BRANCH"; then
105
+ echo "Recorded #${ISSUE_NUMBER} at ${short_sha} in the changelog."
106
+ exit 0
107
+ fi
108
+ echo "::notice::push rejected, rebasing (attempt ${attempt})"
109
+ git fetch --quiet origin "$DEFAULT_BRANCH"
110
+ git rebase --quiet "origin/$DEFAULT_BRANCH" || { git rebase --abort || true; break; }
111
+ done
112
+
113
+ echo "::warning::Could not push the changelog entry for #${ISSUE_NUMBER}."
@@ -1,40 +1,40 @@
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: 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,6 +1,6 @@
1
1
  #!/usr/bin/env bash
2
2
  # Managed by @plainconceptsplatform/workflows. Source: loops/actions/validate-refine-output/validate-refine-output.sh. Update with `workflows update --force`; consumer edits may be overwritten.
3
- # Print the deterministic Refine outcome: complete, questions, or invalid.
3
+ # Print the deterministic Refine outcome: complete, split, questions, or invalid.
4
4
 
5
5
  set -euo pipefail
6
6
 
@@ -23,8 +23,24 @@ jq -r --arg marker "$marker" --arg prefix "$comment_prefix" --arg issue "$issue_
23
23
  def has_update:
24
24
  any(.items[]; .type == "update_issue");
25
25
 
26
+ # A split writes children, which are the only items allowed to target something other than
27
+ # the source issue: they do not exist yet, so they carry no number at all.
28
+ def child_count:
29
+ [.items[] | select(.type == "create_issue" and
30
+ (.body | type == "string") and (.body | test("[^[:space:]]")) and
31
+ (.title | type == "string") and (.title | test("[^[:space:]]")))] | length;
32
+
33
+ # Signals about the run rather than about the issue. A worker reports these alongside
34
+ # its real work, and they say nothing about where that work was aimed, so they are not
35
+ # counted when deciding whether a run stayed on its own issue. Counting them meant a run
36
+ # that refined the issue perfectly and also noted a difficulty was judged invalid and
37
+ # its work discarded.
38
+ def is_run_signal:
39
+ .type == "report_incomplete" or .type == "missing_tool"
40
+ or .type == "missing_data" or .type == "noop";
41
+
26
42
  def has_only_source_items:
27
- all(.items[];
43
+ all(.items[] | select(is_run_signal | not);
28
44
  (
29
45
  .type == "update_issue" and
30
46
  (.item_number == null or (.item_number | tostring) == $issue)
@@ -32,7 +48,14 @@ jq -r --arg marker "$marker" --arg prefix "$comment_prefix" --arg issue "$issue_
32
48
  (
33
49
  .type == "add_comment" and
34
50
  (.item_number | tostring) == $issue
35
- ));
51
+ ) or
52
+ .type == "create_issue");
53
+
54
+ # The agent saying it did not finish. Survivable when it left real work behind, never
55
+ # when all it left was a comment: a failed run must not be read as a question, or the
56
+ # loop waits for an answer to something nobody asked.
57
+ def reported_incomplete:
58
+ any(.items[]; .type == "report_incomplete");
36
59
 
37
60
  def has_clarification:
38
61
  any(.items[]; .type == "add_comment" and (.item_number | tostring) == $issue and
@@ -40,8 +63,14 @@ jq -r --arg marker "$marker" --arg prefix "$comment_prefix" --arg issue "$issue_
40
63
  (.body | split($marker) | join("") | split($prefix) | join("") |
41
64
  gsub("<[^>]*>"; "") | test("[[:alnum:]]")));
42
65
 
43
- if has_replacement_body and has_only_source_items then "complete"
44
- elif has_clarification and (has_update | not) and has_only_source_items then "questions"
66
+ # Order matters: a run that wrote children is a split even though it also replaced the
67
+ # parent body, and a lone child with no parent update is an incomplete split, not a
68
+ # complete refinement.
69
+ if child_count >= 2 and has_replacement_body and has_only_source_items then "split"
70
+ 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
73
+ and (reported_incomplete | not) then "questions"
45
74
  else "invalid"
46
75
  end
47
76
  ' "$output_file"
@@ -1,35 +1,35 @@
1
- # Managed by @plainconceptsplatform/workflows. Source: loops/actions/validate-review-output/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
- name: Validate review output
3
- description: Verify an apply-review outcome accounts for every unresolved review thread.
4
- inputs:
5
- output-file:
6
- description: Path to agent_output.json.
7
- required: true
8
- pr-number:
9
- description: Pull request receiving the review feedback.
10
- required: true
11
- review-threads-file:
12
- description: JSON file containing unresolved review threads.
13
- required: true
14
- outputs:
15
- valid:
16
- description: Whether the agent output is a valid review outcome.
17
- value: ${{ steps.validate.outputs.valid }}
18
- outcome:
19
- description: "Deterministic review outcome: implemented, already-satisfied, needs-human, or invalid."
20
- value: ${{ steps.validate.outputs.outcome }}
21
- runs:
22
- using: composite
23
- steps:
24
- - name: Validate review outcome
25
- id: validate
26
- shell: bash
27
- env:
28
- OUTPUT_FILE: ${{ inputs.output-file }}
29
- PR_NUMBER: ${{ inputs.pr-number }}
30
- REVIEW_THREADS_FILE: ${{ inputs.review-threads-file }}
31
- run: |
32
- set -euo pipefail
33
- outcome="$(bash "${{ github.action_path }}/validate-review-output.sh" "$OUTPUT_FILE" "$PR_NUMBER" "$REVIEW_THREADS_FILE")"
34
- echo "outcome=$outcome" >> "$GITHUB_OUTPUT"
35
- echo "valid=$([ "$outcome" != 'invalid' ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
1
+ # Managed by @plainconceptsplatform/workflows. Source: loops/actions/validate-review-output/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
+ name: Validate review output
3
+ description: Verify an apply-review outcome accounts for every unresolved review thread.
4
+ inputs:
5
+ output-file:
6
+ description: Path to agent_output.json.
7
+ required: true
8
+ pr-number:
9
+ description: Pull request receiving the review feedback.
10
+ required: true
11
+ review-threads-file:
12
+ description: JSON file containing unresolved review threads.
13
+ required: true
14
+ outputs:
15
+ valid:
16
+ description: Whether the agent output is a valid review outcome.
17
+ value: ${{ steps.validate.outputs.valid }}
18
+ outcome:
19
+ description: "Deterministic review outcome: implemented, already-satisfied, needs-human, or invalid."
20
+ value: ${{ steps.validate.outputs.outcome }}
21
+ runs:
22
+ using: composite
23
+ steps:
24
+ - name: Validate review outcome
25
+ id: validate
26
+ shell: bash
27
+ env:
28
+ OUTPUT_FILE: ${{ inputs.output-file }}
29
+ PR_NUMBER: ${{ inputs.pr-number }}
30
+ REVIEW_THREADS_FILE: ${{ inputs.review-threads-file }}
31
+ run: |
32
+ set -euo pipefail
33
+ outcome="$(bash "${{ github.action_path }}/validate-review-output.sh" "$OUTPUT_FILE" "$PR_NUMBER" "$REVIEW_THREADS_FILE")"
34
+ echo "outcome=$outcome" >> "$GITHUB_OUTPUT"
35
+ echo "valid=$([ "$outcome" != 'invalid' ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
@@ -1,36 +1,36 @@
1
- # Managed by @plainconceptsplatform/workflows. Source: loops/actions/validate-triage-output/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
- name: Validate triage output
3
- description: Verify agent_output.json contains a usable triage verdict before any GitHub writes.
4
- inputs:
5
- output-file:
6
- description: Path to agent_output.json.
7
- required: true
8
- issue-number:
9
- description: Issue number that every triage outcome must target.
10
- required: true
11
- outputs:
12
- valid:
13
- description: Whether the output contains a usable triage comment with a verdict.
14
- value: ${{ steps.validate.outputs.valid }}
15
- outcome:
16
- description: "Deterministic triage outcome: pass, needs-info, block, or invalid."
17
- value: ${{ steps.validate.outputs.outcome }}
18
- runs:
19
- using: composite
20
- steps:
21
- - name: Validate triage outcome
22
- id: validate
23
- shell: bash
24
- env:
25
- ISSUE_NUMBER: ${{ inputs.issue-number }}
26
- OUTPUT_FILE: ${{ inputs.output-file }}
27
- run: |
28
- set -euo pipefail
29
-
30
- outcome="$(bash "${{ github.action_path }}/validate-triage-output.sh" "$OUTPUT_FILE" "$ISSUE_NUMBER")"
31
- echo "outcome=$outcome" >> "$GITHUB_OUTPUT"
32
- echo "valid=$([ "$outcome" != 'invalid' ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
33
-
34
- if [ "$outcome" = 'invalid' ]; then
35
- echo "::warning::Agent output has no usable triage outcome. It will not be applied."
36
- fi
1
+ # Managed by @plainconceptsplatform/workflows. Source: loops/actions/validate-triage-output/action.yml. Update with workflows update --force; consumer edits may be overwritten.
2
+ name: Validate triage output
3
+ description: Verify agent_output.json contains a usable triage verdict before any GitHub writes.
4
+ inputs:
5
+ output-file:
6
+ description: Path to agent_output.json.
7
+ required: true
8
+ issue-number:
9
+ description: Issue number that every triage outcome must target.
10
+ required: true
11
+ outputs:
12
+ valid:
13
+ description: Whether the output contains a usable triage comment with a verdict.
14
+ value: ${{ steps.validate.outputs.valid }}
15
+ outcome:
16
+ description: "Deterministic triage outcome: pass, needs-info, block, or invalid."
17
+ value: ${{ steps.validate.outputs.outcome }}
18
+ runs:
19
+ using: composite
20
+ steps:
21
+ - name: Validate triage outcome
22
+ id: validate
23
+ shell: bash
24
+ env:
25
+ ISSUE_NUMBER: ${{ inputs.issue-number }}
26
+ OUTPUT_FILE: ${{ inputs.output-file }}
27
+ run: |
28
+ set -euo pipefail
29
+
30
+ outcome="$(bash "${{ github.action_path }}/validate-triage-output.sh" "$OUTPUT_FILE" "$ISSUE_NUMBER")"
31
+ echo "outcome=$outcome" >> "$GITHUB_OUTPUT"
32
+ echo "valid=$([ "$outcome" != 'invalid' ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
33
+
34
+ if [ "$outcome" = 'invalid' ]; then
35
+ echo "::warning::Agent output has no usable triage outcome. It will not be applied."
36
+ fi
@@ -56,6 +56,26 @@ assert_output 'wrong issue output is invalid' invalid \
56
56
  assert_output 'complete output cannot update another issue' invalid \
57
57
  '{"items":[{"type":"update_issue","item_number":42,"body":"# User story"},{"type":"update_issue","item_number":7,"body":"# Other story"}]}'
58
58
 
59
+ # From a real run. The agent's first update_issue went out malformed, the bridge counted it
60
+ # as spent, the retry carrying the body was refused, and the "Refinement complete" comment
61
+ # went out regardless. Invalid is the only safe reading: the body was never replaced. Note
62
+ # what it must not be read as — a question. Nobody asked one, so "questions" would leave the
63
+ # issue waiting on an answer that is never coming.
64
+ assert_output 'a completion claim with no body is invalid' invalid \
65
+ '{"items":[{"type":"add_comment","item_number":42,"body":"<!-- agent-refine -->\nRefinement update\nRefinement complete. The implement label has been added and the implement workflow will start shortly."},{"type":"report_incomplete","reason":"update_issue limit reached"}]}'
66
+
67
+ # The other side of it: a run that did replace the body and also reported a difficulty has
68
+ # done the work, and its work is not thrown away for having said so.
69
+ assert_output 'a refined body survives a reported difficulty' complete \
70
+ '{"items":[{"type":"update_issue","item_number":42,"body":"# User story"},{"type":"report_incomplete","reason":"a tool was slow"}]}'
71
+ assert_output 'a split survives a note about a missing tool' split \
72
+ '{"items":[{"type":"update_issue","item_number":42,"body":"# Epic"},{"type":"create_issue","title":"One","body":"First"},{"type":"create_issue","title":"Two","body":"Second"},{"type":"missing_tool","reason":"no browser"}]}'
73
+
74
+ # And a run that left nothing but a signal has done nothing.
75
+ assert_output 'only a run signal is invalid' invalid \
76
+ '{"items":[{"type":"report_incomplete","reason":"gave up"}]}'
77
+
78
+
59
79
  echo
60
80
  if [ "$FAIL" -eq 0 ]; then
61
81
  echo "Refine output validation: ${PASS} passed"