@plainconceptsplatform/workflows 1.9.0 → 1.10.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 (49) hide show
  1. package/README.md +1 -1
  2. package/dist/catalog-installation.js +2 -2
  3. package/dist/package-baseline.js +4 -1
  4. package/dist/route-processing.js +4 -1
  5. package/dist/worker-env.d.ts +1 -1
  6. package/dist/worker-env.js +6 -2
  7. package/dist/workflow-catalog.d.ts +3 -1
  8. package/dist/workflow-catalog.js +6 -0
  9. package/loops/actions/add-issue-labels/action.yml +50 -50
  10. package/loops/actions/agent-output.cjs +17 -17
  11. package/loops/actions/apply-agent-bundle/action.yml +24 -24
  12. package/loops/actions/apply-agent-comments/action.yml +42 -42
  13. package/loops/actions/apply-agent-labels/action.yml +55 -55
  14. package/loops/actions/audit-close/action.yml +13 -6
  15. package/loops/actions/classify-route/classify-route.sh +3 -1
  16. package/loops/actions/cleanup-artifacts/action.yml +91 -91
  17. package/loops/actions/close-agent-issues/action.yml +43 -43
  18. package/loops/actions/collect-doc-drift/action.yml +29 -0
  19. package/loops/actions/collect-doc-drift/collect-doc-drift.mjs +133 -0
  20. package/loops/actions/create-issue-comment/action.yml +29 -29
  21. package/loops/actions/download-agent-output/action.yml +53 -53
  22. package/loops/actions/identify-gate-subject/action.yml +4 -0
  23. package/loops/actions/land-pull-request/action.yml +10 -0
  24. package/loops/actions/land-pull-request/land-pull-request.sh +14 -0
  25. package/loops/actions/link-pr-to-issue/action.yml +40 -40
  26. package/loops/actions/list-open-issues/action.yml +33 -33
  27. package/loops/actions/load-issue-context/action.yml +45 -45
  28. package/loops/actions/merge-agent-pr/action.yml +49 -49
  29. package/loops/actions/remove-issue-labels/action.yml +37 -37
  30. package/loops/actions/report-workflow-errors/action.yml +1 -1
  31. package/loops/actions/update-agent-issues/action.yml +58 -58
  32. package/loops/actions/validate-refine-output/action.yml +6 -1
  33. package/loops/actions/validate-refine-output/validate-refine-output.sh +20 -2
  34. package/loops/actions/validate-review-output/action.yml +35 -35
  35. package/loops/actions/validate-triage-output/action.yml +36 -36
  36. package/loops/actions/verify-composite-actions/action.yml +9 -9
  37. package/loops/actions/verify-refine-output/action.yml +9 -9
  38. package/loops/actions/verify-route-matrix/action.yml +9 -9
  39. package/loops/actions/verify-route-matrix/verify-route-matrix.sh +38 -5
  40. package/loops/docs/diagrams.md +45 -2
  41. package/loops/templates/agentics/agentics-maintenance.yml +121 -121
  42. package/loops/templates/issues/bug_report.yml +109 -109
  43. package/loops/templates/issues/feature_request.yml +75 -75
  44. package/loops/workflows/agent-docs-audit.md +266 -0
  45. package/loops/workflows/agent-merge-gate.md +11 -7
  46. package/loops/workflows/agent-refine.md +61 -11
  47. package/loops/workflows/shared/platform-defaults.md +53 -53
  48. package/loops/workflows/work-router.yml +120 -3
  49. package/package.json +1 -1
@@ -25,7 +25,9 @@ env:
25
25
  RISK_INDICATORS: "Any diff touching authentication, authorization or session handling. Any change to a calculation or pricing engine, or to code handling money. Any database migration, or a change to an entity or schema. Any change to an audit or event log, or anything that could break its continuity. Any change to a public API contract or a shared library other repositories consume."
26
26
  # Paths a bot may change but never merge on its own: an extended regular expression matched
27
27
  # against every changed path in the pull request. A match holds the merge for a human; it does
28
- # not stop the agent repairing failed CI on the same files.
28
+ # not stop the agent repairing failed CI on the same files. A pull request a person with write
29
+ # access opened is not held for them: that person could push the same change to the branch, and
30
+ # GitHub does not let them approve their own pull request, so the hold only stranded it.
29
31
  #
30
32
  # Only the automation itself. This list used to hold every dependency manifest, lockfile and
31
33
  # dotted directory as well, and with a size threshold and an owner list as wide it parked most
@@ -188,6 +190,7 @@ jobs:
188
190
  closes: ${{ steps.subject.outputs.closes }}
189
191
  conclusion: ${{ steps.subject.outputs.conclusion }}
190
192
  run-id: ${{ steps.subject.outputs.run-id }}
193
+ human_author: ${{ steps.subject.outputs.human-author }}
191
194
  review_blocked: ${{ steps.review.outputs.review_blocked }}
192
195
  pr_base: ${{ steps.stack.outputs.pr_base }}
193
196
  stack_root: ${{ steps.stack.outputs.stack_root }}
@@ -311,7 +314,7 @@ jobs:
311
314
  with:
312
315
  token: ${{ github.token }}
313
316
  pr-number: ${{ needs.subject.outputs.pr }}
314
- protected-paths: ${{ env.PROTECTED_PATHS }}
317
+ protected-paths: ${{ needs.subject.outputs.human_author != 'true' && env.PROTECTED_PATHS || '' }}
315
318
  owner-paths: ${{ env.OWNER_PATHS }}
316
319
  sensitive-paths: ${{ env.SENSITIVE_PATHS }}
317
320
  high-files: ${{ env.BLAST_HIGH_FILES }}
@@ -426,13 +429,13 @@ jobs:
426
429
  body: |
427
430
  ${{ env.GATE_MARKER }}
428
431
  PR #${{ needs.subject.outputs.pr }} changes protected files and cannot be auto-merged.
429
- The `review` and `owner-review` labels are set: the person who owns these files
430
- decides, and merges.
432
+ The `review` and `owner-review` labels are set. Anyone with write access can review it:
433
+ approving the pull request merges it.
431
434
 
432
435
  Protected files:
433
436
  ${{ needs.protected_changes.outputs.files }}
434
437
 
435
- Required owners: `${{ needs.protected_changes.outputs.required_owners || 'none configured' }}`
438
+ Asked to review: `${{ needs.protected_changes.outputs.required_owners || 'nobody named in CODEOWNERS' }}`
436
439
 
437
440
  **Verdict:** owner-review
438
441
  - name: Replay the verification plan for the owner
@@ -613,7 +616,7 @@ jobs:
613
616
  && format('| Accepted unattended | {0} |', needs.validate_output.outputs.flags)
614
617
  || '' }}
615
618
  ${{ needs.validate_output.outputs.outcome == 'owner-review'
616
- && format('| Required owners | `{0}` |', needs.protected_changes.outputs.required_owners != '' && needs.protected_changes.outputs.required_owners || 'none configured, see the matched paths above')
619
+ && format('| Asked to review | `{0}` |', needs.protected_changes.outputs.required_owners != '' && needs.protected_changes.outputs.required_owners || 'nobody named in CODEOWNERS, see the matched paths above')
617
620
  || '' }}
618
621
 
619
622
  Why this blast radius:
@@ -622,7 +625,8 @@ jobs:
622
625
  ```
623
626
 
624
627
  ${{ needs.subject.outputs.issue != '' && format('Findings and verification on the linked issue: #{0}.', needs.subject.outputs.issue) || 'Findings and verification are in the review comment above.' }} [View this workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
625
- ${{ env.EVIDENCE_BEFORE_REVIEW == 'true' && contains(fromJson('["human-review","owner-review"]'), needs.validate_output.outputs.outcome) && 'Screenshots of this head follow in their own comment if it carries a verification plan. Approving the pull request merges it.' || '' }}
628
+ ${{ env.EVIDENCE_BEFORE_REVIEW == 'true' && contains(fromJson('["human-review","owner-review"]'), needs.validate_output.outputs.outcome) && 'Screenshots of this head follow in their own comment if it carries a verification plan.' || '' }}
629
+ ${{ contains(fromJson('["human-review","owner-review"]'), needs.validate_output.outputs.outcome) && 'Anyone with write access can review it: approving the pull request merges it.' || '' }}
626
630
  - name: Read the head to land
627
631
  id: landing_head
628
632
  if: needs.validate_output.outputs.outcome == 'auto-merge' && (needs.subject.outputs.stack_root != 'true' || needs.subject.outputs.root_complete == 'true')
@@ -58,6 +58,17 @@ env:
58
58
  DRAFT_MARKER: "<!-- agent-refine-draft -->"
59
59
  INITIAL_MODE: first
60
60
  RESPONSE_MODE: rerefine
61
+ # The pass the router starts when nobody answered the questions in time, or the author accepted
62
+ # them with a thumbs-up: every proposed answer is taken as given. See REFINE_ANSWER_TIMEOUT_HOURS
63
+ # in work-router.yml.
64
+ ASSUME_MODE: assume
65
+ ASSUMED_LABEL: assumed
66
+ # The sentence that opens the batched questions, and the two ways a question ends. The validator
67
+ # reads the first to decide the outcome; the router reads the other two to know whether the
68
+ # questions can be taken as answered.
69
+ QUESTIONS_LINE: "I have some questions about this issue"
70
+ PROPOSED_TAG: "**Proposed:**"
71
+ DECISION_TAG: "**Your decision needed:**"
61
72
  MAX_SELF_QUESTIONS: "5"
62
73
  TRIVIAL_MARKER: "<!-- complexity: trivial -->"
63
74
  ESTIMATE_MARKER_PREFIX: "<!-- estimate: "
@@ -76,8 +87,9 @@ env:
76
87
  GIT_COMMITTER_NAME: "github-actions[bot]"
77
88
  GIT_COMMITTER_EMAIL: "github-actions[bot]@users.noreply.github.com"
78
89
  description: |
79
- Refines an issue into a user story, on a first pass or after the author has answered the
80
- bot's questions. Replaces .loops/recipes/refine-loop.yaml.
90
+ Refines an issue into a user story, on a first pass, after the author has answered the
91
+ bot's questions, or once the bot's proposed answers stand because nobody answered in time.
92
+ Replaces .loops/recipes/refine-loop.yaml.
81
93
 
82
94
  The refined story is wrapped in the repository's own issue template when one matches.
83
95
  When questions remain, the worker leaves that template-wrapped temporal draft on the
@@ -92,7 +104,7 @@ description: |
92
104
  still parks an issue for a person.
93
105
 
94
106
  Router-only worker: triggered exclusively via workflow_call from work-router.yml.
95
- Contract inputs: issue-number, mode(first|rerefine).
107
+ Contract inputs: issue-number, mode(first|rerefine|assume).
96
108
 
97
109
  name: "Agent: Refine Issue"
98
110
 
@@ -114,7 +126,7 @@ on:
114
126
  required: true
115
127
  type: string
116
128
  mode:
117
- description: Refinement pass mode (first or rerefine).
129
+ description: Refinement pass mode (first, rerefine or assume).
118
130
  required: false
119
131
  type: string
120
132
  default: first
@@ -177,9 +189,10 @@ jobs:
177
189
  labels=$(gh issue view "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --json labels \
178
190
  --jq '[.labels[].name]')
179
191
  if jq -e 'index("review")' >/dev/null <<<"$labels"; then
180
- if [ "$MODE" = "rerefine" ]; then
192
+ if [ "$MODE" = "rerefine" ] || [ "$MODE" = "assume" ]; then
181
193
  # A rerefine is triggered by a human comment on an issue the bot parked for
182
- # review. The comment is the review: clear the label and proceed.
194
+ # review. The comment is the review: clear the label and proceed. An assume pass
195
+ # is the router's answer to the same park once the deadline passed.
183
196
  gh issue edit "$ISSUE_NUMBER" --remove-label "review" 2>/dev/null ||
184
197
  echo "::notice::review label already gone on #$ISSUE_NUMBER"
185
198
  echo "eligible=true" >> "$GITHUB_OUTPUT"
@@ -323,6 +336,8 @@ jobs:
323
336
  token: ${{ github.token }}
324
337
  issue-number: ${{ inputs.issue-number }}
325
338
  labels: ${{ env.WORKING_LABEL }}
339
+ # `assumed` says the router already spent its assume pass on this issue. A refinement from
340
+ # scratch earns a new one.
326
341
  - name: Clear the human-needed flag
327
342
  uses: ./.github/actions/remove-issue-labels
328
343
  with:
@@ -331,6 +346,7 @@ jobs:
331
346
  labels: |-
332
347
  ${{ env.REVIEW_LABEL }}
333
348
  ${{ env.STALLED_LABEL }}
349
+ ${{ inputs.mode == env.INITIAL_MODE && env.ASSUMED_LABEL || '' }}
334
350
  validate_output:
335
351
  needs: [activation, agent, safe_outputs]
336
352
  if: >
@@ -362,6 +378,7 @@ jobs:
362
378
  draft-marker: ${{ env.DRAFT_MARKER }}
363
379
  comment-prefix: ${{ env.SAFE_OUTPUT_COMMENT_PREFIX }}
364
380
  issue-number: ${{ inputs.issue-number }}
381
+ questions-line: ${{ env.QUESTIONS_LINE }}
365
382
  conclude:
366
383
  needs: [activation, agent, safe_outputs, validate_output]
367
384
  # `always()` is load-bearing: without it GitHub prepends `success()`, which returns false
@@ -418,6 +435,7 @@ jobs:
418
435
  labels: |-
419
436
  ${{ env.REFINED_LABEL }}
420
437
  ${{ env.IMPLEMENT_LABEL }}
438
+ ${{ inputs.mode == env.ASSUME_MODE && env.ASSUMED_LABEL || '' }}
421
439
  - name: Clear complete refinement labels
422
440
  if: needs.validate_output.outputs.outcome == 'complete'
423
441
  uses: ./.github/actions/remove-issue-labels
@@ -645,13 +663,18 @@ jobs:
645
663
  gh issue edit "$child" --repo "$REPO" --add-label "$IMPLEMENT_LABEL" >/dev/null
646
664
  echo "#$child handed to implement"
647
665
  done
666
+ # An assume pass that still asks leaves `assumed` beside `review`, which the router reads as
667
+ # "already assumed once": the questions then wait for a person instead of being assumed
668
+ # again every working day.
648
669
  - name: Flag questions for review
649
670
  if: needs.validate_output.outputs.outcome == 'questions'
650
671
  uses: ./.github/actions/add-issue-labels
651
672
  with:
652
673
  token: ${{ steps.app-token.outputs.token }}
653
674
  issue-number: ${{ inputs.issue-number }}
654
- labels: ${{ env.REVIEW_LABEL }}
675
+ labels: |-
676
+ ${{ env.REVIEW_LABEL }}
677
+ ${{ inputs.mode == env.ASSUME_MODE && env.ASSUMED_LABEL || '' }}
655
678
  - name: Release questions for the author
656
679
  if: needs.validate_output.outputs.outcome == 'questions'
657
680
  uses: ./.github/actions/remove-issue-labels
@@ -903,7 +926,15 @@ timeout-minutes: 90
903
926
  - On a `${{ env.RESPONSE_MODE }}` pass, incorporate only the supplied answers from the issue author or an
904
927
  assignee. Do not use answers from other commenters. The body may already hold the
905
928
  temporal draft from the earlier pass: reuse what still holds, and resolve its pending
906
- marks with the author's answers.
929
+ marks with the author's answers. A question the author left unanswered keeps the answer
930
+ you proposed for it. A reply that accepts your proposals as a whole ("ok", "yes", "go
931
+ ahead", in any language) answers every question with its proposal.
932
+ - On a `${{ env.ASSUME_MODE }}` pass, nobody answered your last batch of questions in time, or the
933
+ author accepted it with a thumbs-up. Take each question's `${{ env.PROPOSED_TAG }}` answer as
934
+ given, except where the author or an assignee did answer in a comment: their answer wins.
935
+ Do not ask again: write the finished story. Add a section titled `Assumed without a reply`
936
+ at the end of the body, before any related links, listing each question in a short line
937
+ with the answer you took, so whoever reviews the pull request sees what nobody confirmed.
907
938
 
908
939
  3. **Explore and write the story.** Split the issue into work units first. If the issue body is
909
940
  a bullet list of distinct tasks, treat each bullet as its own work unit. Otherwise treat the
@@ -915,6 +946,14 @@ timeout-minutes: 90
915
946
  issue. At most ${{ env.MAX_SELF_QUESTIONS }} self-asked questions per work unit, and stop
916
947
  sooner once more exploring stops changing your understanding.
917
948
 
949
+ Answer your own questions from the codebase and the docs. Set one aside for the author when
950
+ it is a business or product decision the code cannot settle: which of two reasonable
951
+ behaviours the users want, who sees or may do something, a threshold or a limit, which figure
952
+ a calculation uses, what a message says to a customer. Choosing one of those yourself because
953
+ the code would allow either is not answering it; it is the question. A wrong guess costs a
954
+ pull request, a question costs a reply, and the author can accept every proposal with a
955
+ thumbs-up.
956
+
918
957
  Then classify the change complexity. A change is **trivial** only if every one of these
919
958
  holds: ${{ env.TRIVIAL_CRITERIA }}
920
959
 
@@ -1044,14 +1083,23 @@ timeout-minutes: 90
1044
1083
  The comment carries:
1045
1084
  1. `${{ env.REFINE_MARKER }}`
1046
1085
  2. `${{ env.SAFE_OUTPUT_COMMENT_PREFIX }}`
1047
- 3. `I have some questions about this issue. Please reply in one comment and I'll process your answers.`
1048
- 4. Every set-aside question, gathered from all work units, immediately below it, each answerable in a sentence.
1086
+ 3. `${{ env.QUESTIONS_LINE }}. Each one carries the answer I would take. Reply in one comment to change any of them, or react with 👍 to accept them all.`
1087
+ 4. Every set-aside question, gathered from all work units, as a numbered list, each answerable in a sentence.
1088
+ 5. A last line saying that if nobody replies the proposed answers stand and refinement carries on, except for any question that needs a decision.
1089
+
1090
+ Every question ends in one of two ways, on its own line under the question:
1091
+
1092
+ - `${{ env.PROPOSED_TAG }} <the answer you would take> -- <why, in one short clause: the convention, the existing behaviour or the issue text it follows>`. This is the usual case: you can name a sensible answer, and getting it wrong costs a change, not a loss.
1093
+ - `${{ env.DECISION_TAG }} <why no answer can be assumed>`. Only for a question where a wrong assumption is hard to undo: data deleted or rewritten, money or prices, permissions or security, personal data, or a contract another team or system consumes. These are never assumed; refinement waits for a person.
1049
1094
 
1050
1095
  Write the questions in **plain business language, not technical jargon**. The person reading
1051
1096
  them is a domain expert, not an engineer.
1052
1097
 
1053
1098
  **The story is complete.** You answered every exploration question yourself and none remain
1054
- for the author. Call `update_issue` first, with the wrapped replacement body, and wait
1099
+ for the author. A complete story has no open question anywhere in it: if the issue form has an
1100
+ `Open questions` field, it says `None`, and a question you settled goes into the acceptance
1101
+ criteria as the behaviour you chose. A question still open in the body is a question for the
1102
+ author, so that is the outcome above, not this one. Call `update_issue` first, with the wrapped replacement body, and wait
1055
1103
  for it to come back. Send the whole body in that one call: it is the only thing this
1056
1104
  step has to get right.
1057
1105
 
@@ -1063,6 +1111,8 @@ timeout-minutes: 90
1063
1111
  - If the array includes the exact label `future`: `Refinement complete. The implement label has been added. Implementation is paused until the future label is removed.`
1064
1112
  - Otherwise: `Refinement complete. The implement label has been added and the implement workflow will start shortly.`
1065
1113
 
1114
+ On a `${{ env.ASSUME_MODE }}` pass, add one sentence after that message: `The proposed answers were taken because nobody replied in time, and they are listed under "Assumed without a reply". If one is wrong, say so on the pull request.`
1115
+
1066
1116
  If `update_issue` did not succeed, do not post either message: an issue that reads as
1067
1117
  refined with its body untouched is worse than one that says the run failed. Call
1068
1118
  `report_incomplete` with what the tool told you, and let the run be retried.
@@ -1,53 +1,53 @@
1
- ---
2
- # Managed by @plainconceptsplatform/workflows. Source: loops/workflows/shared/platform-defaults.md. Update with `workflows update --force`; consumer edits may be overwritten.
3
- description: Shared network and safe-output defaults for catalog agent workflows, plus the verification rules every worker that builds or pushes has to follow. The body below is runtime-imported into each worker's prompt ahead of its own steps.
4
-
5
- network:
6
- allowed:
7
- - defaults
8
- - forge.plainconcepts.com
9
- # opencode fetches its model registry from models.opencode.ai at startup; without
10
- # this host on the allowlist the fetch blocks bootstrap and the run stalls after
11
- # "init" until the runner kills it. The apex "opencode.ai" is not enough.
12
- - models.opencode.ai
13
- - node
14
- - github
15
- - dotnet
16
- - fonts
17
- - login.microsoftonline.com
18
- # centralised AgentMemory (App Service); the MCP shim needs it through the egress proxy
19
- - agentmemory-pro-01.azurewebsites.net
20
-
21
- safe-outputs:
22
- threat-detection: false
23
- ---
24
-
25
- ## These runs are not interactive
26
-
27
- - Nothing resumes you. A turn that ends with text and no tool call ends the whole run, and the work
28
- is discarded, whatever the text promised. "I'll read the context files first." as a last message
29
- is a finished run: the Pliny-Bot merge gate did exactly that on #801, ended in twenty-three
30
- seconds with exit code 0 and no outcome, and was retried twice before the pull request was handed
31
- to a person for nothing.
32
- - So every message you write carries a tool call until the last one, which is the safe-output call
33
- the task names. Say what you are about to do and do it in the same message. Never announce a step
34
- and stop, never wait for a command, a reviewer or a "next turn".
35
- - A command the bash tool reports as moved to the background is still running: poll its output file
36
- with tool calls (`sleep 40; tail -n 15 <file>`) until it shows an exit status, then go on.
37
-
38
- ## Verification, for every worker that builds or pushes
39
-
40
- - Scope every check to the files you changed. This is the runner, not a preference: a
41
- whole-repository lint, build or test run exhausts its memory and gets killed mid-run, which
42
- fails the job with no useful output. Pass the changed paths to the linter (`pnpm exec biome
43
- check <files>`), build only the projects containing them, and run only the test project that
44
- covers them. Escalate to the full suite only after the scoped run passes and only when the
45
- change crosses a project boundary.
46
- - Run nothing at all for a change that touches only documentation. A cold Release build on a
47
- shared runner is minutes the run does not get back.
48
- - Never make a check pass by weakening it: not a deleted test, not a lowered threshold, not a
49
- skipped step. Fix the cause and run it again.
50
- - Once the checks pass, run the project's lint fix command over the files you changed
51
- (`pnpm lint:fix`, `pnpm exec biome check --write <changed-files>`, or its equivalent), or
52
- correct what lint reports where no fix command exists. A branch that arrives with lint errors
53
- gets sent back for them.
1
+ ---
2
+ # Managed by @plainconceptsplatform/workflows. Source: loops/workflows/shared/platform-defaults.md. Update with `workflows update --force`; consumer edits may be overwritten.
3
+ description: Shared network and safe-output defaults for catalog agent workflows, plus the verification rules every worker that builds or pushes has to follow. The body below is runtime-imported into each worker's prompt ahead of its own steps.
4
+
5
+ network:
6
+ allowed:
7
+ - defaults
8
+ - forge.plainconcepts.com
9
+ # opencode fetches its model registry from models.opencode.ai at startup; without
10
+ # this host on the allowlist the fetch blocks bootstrap and the run stalls after
11
+ # "init" until the runner kills it. The apex "opencode.ai" is not enough.
12
+ - models.opencode.ai
13
+ - node
14
+ - github
15
+ - dotnet
16
+ - fonts
17
+ - login.microsoftonline.com
18
+ # centralised AgentMemory (App Service); the MCP shim needs it through the egress proxy
19
+ - agentmemory-pro-01.azurewebsites.net
20
+
21
+ safe-outputs:
22
+ threat-detection: false
23
+ ---
24
+
25
+ ## These runs are not interactive
26
+
27
+ - Nothing resumes you. A turn that ends with text and no tool call ends the whole run, and the work
28
+ is discarded, whatever the text promised. "I'll read the context files first." as a last message
29
+ is a finished run: the Pliny-Bot merge gate did exactly that on #801, ended in twenty-three
30
+ seconds with exit code 0 and no outcome, and was retried twice before the pull request was handed
31
+ to a person for nothing.
32
+ - So every message you write carries a tool call until the last one, which is the safe-output call
33
+ the task names. Say what you are about to do and do it in the same message. Never announce a step
34
+ and stop, never wait for a command, a reviewer or a "next turn".
35
+ - A command the bash tool reports as moved to the background is still running: poll its output file
36
+ with tool calls (`sleep 40; tail -n 15 <file>`) until it shows an exit status, then go on.
37
+
38
+ ## Verification, for every worker that builds or pushes
39
+
40
+ - Scope every check to the files you changed. This is the runner, not a preference: a
41
+ whole-repository lint, build or test run exhausts its memory and gets killed mid-run, which
42
+ fails the job with no useful output. Pass the changed paths to the linter (`pnpm exec biome
43
+ check <files>`), build only the projects containing them, and run only the test project that
44
+ covers them. Escalate to the full suite only after the scoped run passes and only when the
45
+ change crosses a project boundary.
46
+ - Run nothing at all for a change that touches only documentation. A cold Release build on a
47
+ shared runner is minutes the run does not get back.
48
+ - Never make a check pass by weakening it: not a deleted test, not a lowered threshold, not a
49
+ skipped step. Fix the cause and run it again.
50
+ - Once the checks pass, run the project's lint fix command over the files you changed
51
+ (`pnpm lint:fix`, `pnpm exec biome check --write <changed-files>`, or its equivalent), or
52
+ correct what lint reports where no fix command exists. A branch that arrives with lint errors
53
+ gets sent back for them.
@@ -83,6 +83,9 @@ run-name: >-
83
83
  && inputs.operation == 'audit'
84
84
  && format('Agent: Audit - manual{0}', '')
85
85
  || github.event_name == 'workflow_dispatch'
86
+ && inputs.operation == 'docs-audit'
87
+ && format('Agent: Docs Audit - manual{0}', '')
88
+ || github.event_name == 'workflow_dispatch'
86
89
  && inputs.operation == 'release'
87
90
  && format('Agent: Release{0}', '')
88
91
  || github.event_name == 'workflow_dispatch'
@@ -91,6 +94,9 @@ run-name: >-
91
94
  && github.event.schedule == '17 1 * * 1'
92
95
  && format('Agent: Audit - weekly{0}', '')
93
96
  || github.event_name == 'schedule'
97
+ && github.event.schedule == '47 2 1,15 * *'
98
+ && format('Agent: Docs Audit - fortnightly{0}', '')
99
+ || github.event_name == 'schedule'
94
100
  && github.event.schedule == '43 3 * * *'
95
101
  && format('Agentics: Audit Close - daily{0}', '')
96
102
  || github.event_name == 'schedule'
@@ -125,6 +131,9 @@ env:
125
131
  # first and the two of them consume the whole fleet. Pick a free slot per repository: see
126
132
  # "Scheduling across repositories" in the package README.
127
133
  AUDIT_CRON: "17 1 * * 1"
134
+ # The same idea for the documentation audit, if it is installed: see the same section of the
135
+ # package README. Two days a month, so a slot is a pair of days rather than a weekday.
136
+ DOCS_AUDIT_CRON: "47 2 1,15 * *"
128
137
  # The bot identities whose label changes and pull requests this router trusts. Space
129
138
  # separated, and every login the same account can present: GitHub reports an App as
130
139
  # `name[bot]` on some endpoints and `app/name` on others, so both spellings belong here.
@@ -185,6 +194,12 @@ env:
185
194
  # `pnpm run dev`, and a pull request with no plan costs one API call. Set it to "false" for a
186
195
  # repository whose app cannot start on a runner; operation=evidence still works by hand.
187
196
  EVIDENCE_ENABLED: "true"
197
+ # Working hours, Monday to Friday, that refine's questions wait for an answer. Every question
198
+ # carries the answer the bot would take; once this passes with no reply, or as soon as the
199
+ # author reacts with a thumbs-up, refine carries on with those answers and labels the issue
200
+ # `assumed`. A question marked as needing a decision is never assumed. "0" turns this off, and
201
+ # questions wait for a person however long that takes.
202
+ REFINE_ANSWER_TIMEOUT_HOURS: "24"
188
203
 
189
204
  on:
190
205
  issues:
@@ -221,6 +236,7 @@ on:
221
236
  # Cron to route mapping lives in .github/actions/classify-route/classify-route.sh.
222
237
  schedule:
223
238
  - cron: "17 1 * * 1" # audit slot, mirrored from env.AUDIT_CRON by the installer
239
+ - cron: "47 2 1,15 * *" # docs-audit slot, mirrored from env.DOCS_AUDIT_CRON by the installer
224
240
  - cron: "43 3 * * *"
225
241
  - cron: "0 6 * * *"
226
242
  - cron: "17 * * * *"
@@ -241,6 +257,7 @@ on:
241
257
  - merge-train
242
258
  - evidence
243
259
  - audit
260
+ - docs-audit
244
261
  - audit-close
245
262
  - cleanup-artifacts
246
263
  - reconcile-bot-pr-runs
@@ -256,12 +273,13 @@ on:
256
273
  required: false
257
274
  type: string
258
275
  mode:
259
- description: "Refine mode: first or rerefine"
276
+ description: "Refine mode: first, rerefine or assume"
260
277
  required: false
261
278
  type: choice
262
279
  options:
263
280
  - first
264
281
  - rerefine
282
+ - assume
265
283
  ci-conclusion:
266
284
  description: "CI conclusion for merge-gate dispatch"
267
285
  required: false
@@ -684,7 +702,12 @@ jobs:
684
702
  gh issue view "$feature" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null \
685
703
  | grep -oiE 'blocked by #[0-9]+' | grep -oE '[0-9]+' || true
686
704
  } | sort -u ); do
687
- gh issue view "$b" --repo "$REPO" --json state --jq '.state' 2>/dev/null | grep -qix open || continue
705
+ # A feature's own children are not what the feature waits on. Refine's tracker lists them
706
+ # in the feature body as "#N ... Blocked by #M", and reading those lines as the feature's
707
+ # blockers held every child behind its siblings, and the first one behind itself.
708
+ info=$(gh issue view "$b" --repo "$REPO" --json state,body 2>/dev/null) || continue
709
+ printf '%s' "$info" | jq -e '(.state // "" | ascii_downcase) == "open"' >/dev/null || continue
710
+ printf '%s' "$info" | jq -r '.body // ""' | grep -qF "<!-- split-parent: ${feature} -->" && continue
688
711
  waiting="$waiting #$b"
689
712
  done
690
713
  fi
@@ -741,6 +764,7 @@ jobs:
741
764
  gh issue edit "$ISSUE_NUMBER" --repo "$REPO" --remove-label bot-working >/dev/null 2>&1 || true
742
765
  marker='<!-- stack-held -->'
743
766
  if ! gh api "repos/$REPO/issues/$ISSUE_NUMBER/comments?per_page=100" --jq '.[].body' 2>/dev/null | grep -qF "$marker"; then
767
+ # shellcheck disable=SC2016
744
768
  printf '%s\nHeld until%s have landed on the default branch: this feature builds on them. It keeps `implement` and is released when they merge.\n' "$marker" "$waiting" > /tmp/held.md
745
769
  gh issue comment "$ISSUE_NUMBER" --repo "$REPO" --body-file /tmp/held.md >/dev/null || true
746
770
  fi
@@ -1148,6 +1172,24 @@ jobs:
1148
1172
  BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
1149
1173
  AGENTMEMORY_SECRET: ${{ secrets.AGENTMEMORY_SECRET }}
1150
1174
 
1175
+ call-docs-audit:
1176
+ needs: classify
1177
+ if: needs.classify.outputs.route == 'docs-audit'
1178
+ uses: ./.github/workflows/agent-docs-audit.lock.yml
1179
+ concurrency:
1180
+ group: docs-audit
1181
+ cancel-in-progress: false
1182
+ permissions: write-all
1183
+ with:
1184
+ trigger-kind: ${{ needs.classify.outputs.trigger-kind }}
1185
+ secrets:
1186
+ OPENAI_API_KEY: ${{ secrets.FORGE_API_KEY }}
1187
+ CODEX_API_KEY: ${{ secrets.FORGE_API_KEY }}
1188
+ COPILOT_GITHUB_TOKEN: ${{ secrets.FORGE_API_KEY }}
1189
+ BOT_APP_ID: ${{ secrets.BOT_APP_ID }}
1190
+ BOT_PRIVATE_KEY: ${{ secrets.BOT_PRIVATE_KEY }}
1191
+ AGENTMEMORY_SECRET: ${{ secrets.AGENTMEMORY_SECRET }}
1192
+
1151
1193
  call-release:
1152
1194
  needs: [classify, authorize]
1153
1195
  if: needs.classify.outputs.route == 'release' && needs.authorize.outputs.trusted == 'true'
@@ -1298,6 +1340,7 @@ jobs:
1298
1340
  # each cut from the same branch, and every landing left the others conflicting. The
1299
1341
  # next landing releases the next child. A held child with no `Blocked by` of its
1300
1342
  # own (held because a sibling was in flight) is free once it carries the hold marker.
1343
+ # shellcheck disable=SC2086
1301
1344
  for child in $(printf '%s\n' $children | sort -n); do
1302
1345
  [ "$child" = "$closed_issue" ] && continue
1303
1346
  meta=$(gh issue view "$child" --repo "$REPO" --json state,labels,body 2>/dev/null || echo '{}')
@@ -1424,7 +1467,11 @@ jobs:
1424
1467
  gh api "repos/$REPO/issues/$1/dependencies/blocked_by" --jq '.[] | select(.state == "open") | .number' 2>/dev/null || true
1425
1468
  for b in $(gh issue view "$1" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null \
1426
1469
  | grep -oiE 'blocked by #[0-9]+' | grep -oE '[0-9]+' | sort -u || true); do
1427
- gh issue view "$b" --repo "$REPO" --json state --jq '.state' 2>/dev/null | grep -qix open && echo "$b" || true
1470
+ # The feature's own children, listed in its tracker with their own "Blocked by", are not blockers.
1471
+ info=$(gh issue view "$b" --repo "$REPO" --json state,body 2>/dev/null) || continue
1472
+ printf '%s' "$info" | jq -e '(.state // "" | ascii_downcase) == "open"' >/dev/null || continue
1473
+ printf '%s' "$info" | jq -r '.body // ""' | grep -qF "<!-- split-parent: $1 -->" && continue
1474
+ echo "$b"
1428
1475
  done
1429
1476
  } | grep -vx "$parent" | sort -u || true
1430
1477
  }
@@ -1476,6 +1523,7 @@ jobs:
1476
1523
  fi
1477
1524
  # One child per feature, the lowest-numbered free one; each landing on the feature branch
1478
1525
  # releases the next (land-stack-child), so siblings never build side by side.
1526
+ # shellcheck disable=SC2086
1479
1527
  for child in $(printf '%s\n' ${next_children[$feature]} | sort -n); do
1480
1528
  own=$(gh issue view "$child" --repo "$REPO" --json body --jq '.body // ""' 2>/dev/null \
1481
1529
  | grep -oiE 'blocked by #[0-9]+' | grep -oE '[0-9]+' | sort -u || true)
@@ -1645,6 +1693,75 @@ jobs:
1645
1693
  gh api "repos/$REPO/actions/runs/$run_id/approve" --method POST
1646
1694
  done
1647
1695
  done
1696
+ # Refine's questions do not wait forever. Nine of the 21 batches that were really asked in
1697
+ # Odyssey, Numa, Bots and DeliveryMetrics up to November 2026 never got a reply, and those
1698
+ # issues stayed parked. Each question now carries the answer the bot would take, and once
1699
+ # the working hours pass, or the author or an assignee reacts with a thumbs-up, refine runs
1700
+ # again in assume mode and takes them. Batches written before the proposals existed, and any
1701
+ # batch holding a question that needs a decision, still wait for a person.
1702
+ - name: Take refine's proposed answers once nobody replied in time
1703
+ if: env.REFINE_ANSWER_TIMEOUT_HOURS != '0'
1704
+ env:
1705
+ GH_TOKEN: ${{ steps.app-token.outputs.token }}
1706
+ REPO: ${{ github.repository }}
1707
+ REFINE_MARKER: "<!-- agent-refine -->"
1708
+ QUESTIONS_LINE: "I have some questions about this issue"
1709
+ PROPOSED_TAG: "**Proposed:**"
1710
+ DECISION_TAG: "**Your decision needed:**"
1711
+ run: |
1712
+ set -euo pipefail
1713
+ timeout_hours="${REFINE_ANSWER_TIMEOUT_HOURS:-24}"
1714
+
1715
+ # Hours since $1 that fall on a weekday, in UTC, counted up to the timeout and no further.
1716
+ working_hours_since() {
1717
+ local t now h=0
1718
+ t=$(date -u -d "$1" +%s)
1719
+ now=$(date -u +%s)
1720
+ while [ "$t" -lt "$now" ] && [ "$h" -lt "$timeout_hours" ]; do
1721
+ [ "$(date -u -d "@$t" +%u)" -le 5 ] && h=$((h + 1))
1722
+ t=$((t + 3600))
1723
+ done
1724
+ echo "$h"
1725
+ }
1726
+
1727
+ gh issue list --repo "$REPO" --state open --label refine --label review --limit 100 \
1728
+ --json number,author,assignees,labels \
1729
+ --jq '.[] | select([.labels[].name] | (index("bot-working") or index("assumed")) | not)
1730
+ | [.number, ([.author.login] + [.assignees[].login] | join(" "))] | @tsv' |
1731
+ while IFS=$'\t' read -r issue people; do
1732
+ [ -n "$issue" ] || continue
1733
+ comments=$(gh api --paginate "repos/$REPO/issues/$issue/comments?per_page=100" | jq -s 'add // []')
1734
+ asked=$(jq -c --arg marker "$REFINE_MARKER" --arg line "$QUESTIONS_LINE" '
1735
+ [.[] | select(.user.type == "Bot" and (.body | contains($marker)) and (.body | contains($line)))]
1736
+ | last // empty | {id, created_at, body}' <<<"$comments")
1737
+ [ -n "$asked" ] || continue
1738
+ asked_at=$(jq -r '.created_at' <<<"$asked")
1739
+ body=$(jq -r '.body' <<<"$asked")
1740
+
1741
+ # A person spoke after the questions: that reply is refine's to read, not a silence.
1742
+ replied=$(jq --arg at "$asked_at" '[.[] | select(.user.type != "Bot" and .created_at > $at)] | length' <<<"$comments")
1743
+ [ "$replied" -eq 0 ] || continue
1744
+ case "$body" in *"$DECISION_TAG"*) echo "#$issue waits for a decision; not assuming."; continue ;; esac
1745
+ case "$body" in *"$PROPOSED_TAG"*) ;; *) continue ;; esac
1746
+
1747
+ accepted=false
1748
+ thumbs=$(gh api "repos/$REPO/issues/comments/$(jq -r '.id' <<<"$asked")/reactions" \
1749
+ --jq '[.[] | select(.content == "+1") | .user.login] | join(" ")' 2>/dev/null || true)
1750
+ for login in $thumbs; do
1751
+ case " $people " in *" $login "*) accepted=true ;; esac
1752
+ done
1753
+ hours=$(working_hours_since "$asked_at")
1754
+ if [ "$accepted" = true ]; then
1755
+ echo "#$issue: the proposed answers were accepted with a thumbs-up; refining with them."
1756
+ elif [ "$hours" -ge "$timeout_hours" ]; then
1757
+ echo "#$issue: no reply in ${timeout_hours} working hours; refining with the proposed answers."
1758
+ else
1759
+ continue
1760
+ fi
1761
+ gh api "repos/$REPO/actions/workflows/work-router.yml/dispatches" --method POST \
1762
+ -f ref="$(gh api "repos/$REPO" --jq .default_branch)" \
1763
+ -f "inputs[operation]=refine" -f "inputs[issue-number]=$issue" -f "inputs[mode]=assume"
1764
+ done
1648
1765
 
1649
1766
  audit-close:
1650
1767
  needs: classify
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plainconceptsplatform/workflows",
3
- "version": "1.9.0",
3
+ "version": "1.10.0",
4
4
  "description": "Install and update Platform GitHub agentic workflows.",
5
5
  "keywords": [
6
6
  "github-actions",