@plainconceptsplatform/workflows 1.4.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/catalog-installation.d.ts +2 -0
- package/dist/catalog-installation.js +44 -2
- package/dist/index.js +3 -0
- package/dist/stack-defaults.d.ts +1 -0
- package/dist/stack-defaults.js +13 -1
- package/loops/actions/validate-merge-gate-output/action.yml +5 -0
- package/loops/actions/validate-merge-gate-output/validate-merge-gate-output.sh +8 -2
- package/loops/actions/verify-route-matrix/verify-route-matrix.sh +7 -0
- package/loops/workflows/agent-implement.md +6 -1
- package/loops/workflows/agent-merge-gate.md +28 -0
- package/loops/workflows/work-router.yml +15 -0
- package/package.json +1 -1
|
@@ -30,6 +30,8 @@ export interface CatalogInstallResult {
|
|
|
30
30
|
readonly baselines: readonly BaselineStatus[];
|
|
31
31
|
readonly upToDate: boolean;
|
|
32
32
|
readonly dryRun: boolean;
|
|
33
|
+
/** Problems the update cannot fix on its own, for a person to act on. */
|
|
34
|
+
readonly warnings: readonly string[];
|
|
33
35
|
}
|
|
34
36
|
export interface CatalogInstallOptions {
|
|
35
37
|
/** Also overwrite files whose ownership header was removed, and changed templates. */
|
|
@@ -7,7 +7,7 @@ import { promisify } from "node:util";
|
|
|
7
7
|
import { parse as parseYaml } from "yaml";
|
|
8
8
|
import { catalogTemplates, mandatoryFiles, retiredWorkflowFiles, routeNames, templateNames, workflowRoutes } from "./workflow-catalog.js";
|
|
9
9
|
import { processRoutes, excludedWorkerFiles } from "./route-processing.js";
|
|
10
|
-
import { generateOpencodeCi, generateOpencodeConfig, generateStackDefaults, injectStackEnv } from "./stack-defaults.js";
|
|
10
|
+
import { generateOpencodeCi, generateOpencodeConfig, generateStackDefaults, injectStackEnv, shippedVerifyDefaults } from "./stack-defaults.js";
|
|
11
11
|
import { mergeRouter, mergeWorker } from "./worker-env.js";
|
|
12
12
|
import { fetchBaseline, hasOwnershipHeader, installedVersion, packageVersion, stampVersion } from "./package-baseline.js";
|
|
13
13
|
const execFileAsync = promisify(execFile);
|
|
@@ -133,6 +133,7 @@ export async function installCatalog(repositoryPath, options = {}) {
|
|
|
133
133
|
baselines,
|
|
134
134
|
upToDate: updates.length === 0 && removals.length === 0,
|
|
135
135
|
dryRun: options.dryRun ?? false,
|
|
136
|
+
warnings: await verifyScriptWarnings(repositoryPath, updates, existing),
|
|
136
137
|
};
|
|
137
138
|
if (options.dryRun)
|
|
138
139
|
return result;
|
|
@@ -158,12 +159,22 @@ export async function installCatalog(repositoryPath, options = {}) {
|
|
|
158
159
|
// the empty string as the consumer's deliberate choice. All four consuming repositories were in
|
|
159
160
|
// that state, so every merge-gate repair pushed a fix with the verification block interpolating
|
|
160
161
|
// to nothing. An empty value is the absence of a choice, so an update fills it.
|
|
162
|
+
//
|
|
163
|
+
// So is a value the package wrote itself. The implement worker shipped a dotnet-only line, and
|
|
164
|
+
// every full-stack consumer kept it as if someone had chosen it, so the bot never built the web
|
|
165
|
+
// app before opening a pull request. A consumer holding a shipped default gets the current one:
|
|
166
|
+
// its own value is blanked here, which the env merge then reads as no choice.
|
|
161
167
|
function applyStackDefaults(files, inspection, existing) {
|
|
162
168
|
const defaults = generateStackDefaults(inspection);
|
|
163
169
|
const result = new Map(files);
|
|
164
170
|
for (const [target, content] of result) {
|
|
165
|
-
|
|
171
|
+
const current = existing.get(target);
|
|
172
|
+
if (isWorker(target) && (current === undefined || hasEmptyVerifyCommands(current))) {
|
|
173
|
+
result.set(target, injectStackEnv(content, defaults));
|
|
174
|
+
}
|
|
175
|
+
else if (isWorker(target) && hasShippedVerifyCommands(current, defaults.verifyCommands)) {
|
|
166
176
|
result.set(target, injectStackEnv(content, defaults));
|
|
177
|
+
existing.set(target, current.replace(VERIFY_LINE, ' VERIFY_COMMANDS: ""'));
|
|
167
178
|
}
|
|
168
179
|
else if (target.endsWith("opencode-ci.md")) {
|
|
169
180
|
result.set(target, generateOpencodeCi(content, inspection));
|
|
@@ -174,11 +185,42 @@ function applyStackDefaults(files, inspection, existing) {
|
|
|
174
185
|
}
|
|
175
186
|
return result;
|
|
176
187
|
}
|
|
188
|
+
const VERIFY_LINE = /^ {2}VERIFY_COMMANDS: *(.*?) *$/m;
|
|
177
189
|
// A worker whose VERIFY_COMMANDS is present but empty. Anything else, including a value the
|
|
178
190
|
// consumer wrote, is left alone.
|
|
179
191
|
function hasEmptyVerifyCommands(current) {
|
|
180
192
|
return typeof current === "string" && /^ {2}VERIFY_COMMANDS: *(""|'')? *$/m.test(current);
|
|
181
193
|
}
|
|
194
|
+
function verifyCommandsOf(content) {
|
|
195
|
+
const raw = VERIFY_LINE.exec(content)?.[1];
|
|
196
|
+
return raw?.replace(/^(["'])(.*)\1$/, "$2");
|
|
197
|
+
}
|
|
198
|
+
function hasShippedVerifyCommands(current, replacement) {
|
|
199
|
+
if (current === undefined)
|
|
200
|
+
return false;
|
|
201
|
+
const value = verifyCommandsOf(current);
|
|
202
|
+
return value !== undefined && value !== replacement && shippedVerifyDefaults.has(value);
|
|
203
|
+
}
|
|
204
|
+
// `pnpm verify` is only a command if the repository defines it. The update still writes it, since
|
|
205
|
+
// it is the right default, and says what is missing instead of leaving the bot to find out.
|
|
206
|
+
async function verifyScriptWarnings(repositoryPath, updates, existing) {
|
|
207
|
+
const written = new Map(existing);
|
|
208
|
+
for (const update of updates)
|
|
209
|
+
written.set(update.target, update.content);
|
|
210
|
+
const workers = [...written].filter(([target, content]) => isWorker(target) && /\bpnpm verify\b/.test(verifyCommandsOf(content) ?? ""));
|
|
211
|
+
if (workers.length === 0)
|
|
212
|
+
return [];
|
|
213
|
+
let scripts = {};
|
|
214
|
+
try {
|
|
215
|
+
scripts = JSON.parse(await readFile(join(repositoryPath, "package.json"), "utf8")).scripts ?? {};
|
|
216
|
+
}
|
|
217
|
+
catch {
|
|
218
|
+
scripts = {};
|
|
219
|
+
}
|
|
220
|
+
if (typeof scripts.verify === "string")
|
|
221
|
+
return [];
|
|
222
|
+
return [`VERIFY_COMMANDS runs \`pnpm verify\` in ${workers.map(([target]) => target).sort().join(", ")}, but package.json has no "verify" script. Add one that runs what CI blocks on.`];
|
|
223
|
+
}
|
|
182
224
|
// Directories that belong wholly to the package. A file here that carries our ownership header
|
|
183
225
|
// and is no longer in the package was deleted upstream, and without this it would sit in every
|
|
184
226
|
// consumer forever: that is how `stale-recovery` and `update-changelog` outlived the code that
|
package/dist/index.js
CHANGED
|
@@ -139,6 +139,8 @@ export async function run(arguments_, repositoryPath = process.cwd()) {
|
|
|
139
139
|
console.error(`Template conflicts found. Re-run with --force to overwrite consumer-owned copies:\n${allConflicts.join("\n")}`);
|
|
140
140
|
return 1;
|
|
141
141
|
}
|
|
142
|
+
for (const warning of catalog?.warnings ?? [])
|
|
143
|
+
console.error(`warning: ${warning}`);
|
|
142
144
|
console.log(JSON.stringify({
|
|
143
145
|
command,
|
|
144
146
|
dryRun,
|
|
@@ -177,6 +179,7 @@ function summarize(result) {
|
|
|
177
179
|
baselines: result.baselines,
|
|
178
180
|
changes: result.changes.filter((change) => change.status !== "unchanged"),
|
|
179
181
|
unchanged: result.changes.filter((change) => change.status === "unchanged").length,
|
|
182
|
+
...(result.warnings.length > 0 ? { warnings: result.warnings } : {}),
|
|
180
183
|
};
|
|
181
184
|
}
|
|
182
185
|
// A fixed baseline for every recorded version, instead of asking npm. Rolling out a version that
|
package/dist/stack-defaults.d.ts
CHANGED
|
@@ -6,6 +6,7 @@ export interface StackDefaults {
|
|
|
6
6
|
readonly hasNodeOnly: boolean;
|
|
7
7
|
}
|
|
8
8
|
export declare function generateStackDefaults(inspection: RepositoryInspection): StackDefaults;
|
|
9
|
+
export declare const shippedVerifyDefaults: ReadonlySet<string>;
|
|
9
10
|
export declare function injectStackEnv(content: string, defaults: StackDefaults): string;
|
|
10
11
|
export declare function generateOpencodeCi(baseContent: string, inspection: RepositoryInspection): string;
|
|
11
12
|
export declare function generateOpencodeConfig(baseContent: string, inspection: RepositoryInspection): string;
|
package/dist/stack-defaults.js
CHANGED
|
@@ -4,8 +4,12 @@ export function generateStackDefaults(inspection) {
|
|
|
4
4
|
const hasNodeOnly = hasNode && !hasDotnet;
|
|
5
5
|
let verifyCommands;
|
|
6
6
|
let repoRulesBase;
|
|
7
|
+
// A full-stack repository verifies with its own `pnpm verify`, which runs what its CI blocks on.
|
|
8
|
+
// The command this replaced was prose the agent had to interpret, and the implement worker never
|
|
9
|
+
// got it at all: it kept the package's dotnet-only line, so bot pull requests shipped with the
|
|
10
|
+
// web build broken (Pliny-Bot #639).
|
|
7
11
|
if (hasDotnet && hasNode) {
|
|
8
|
-
verifyCommands = "
|
|
12
|
+
verifyCommands = "pnpm verify";
|
|
9
13
|
repoRulesBase = "Full-stack .NET + React/Next.js repository. Follow Clean Architecture layering: API → Application → Domain. Infrastructure implements Application ports. Do not reference EF Core or ASP.NET from Application. Frontend communicates exclusively via HTTP endpoints. Run both .NET and frontend verification.";
|
|
10
14
|
}
|
|
11
15
|
else if (hasDotnet) {
|
|
@@ -18,6 +22,14 @@ export function generateStackDefaults(inspection) {
|
|
|
18
22
|
}
|
|
19
23
|
return { verifyCommands, repoRulesBase, hasDotnet, hasNodeOnly };
|
|
20
24
|
}
|
|
25
|
+
// Every VERIFY_COMMANDS value the package itself has ever written. A consumer holding one of these
|
|
26
|
+
// never chose it, so an update may replace it with the current default for the stack.
|
|
27
|
+
export const shippedVerifyDefaults = new Set([
|
|
28
|
+
".NET: dotnet restore && dotnet build -c Release && dotnet test | Web: pnpm lint && pnpm test && pnpm build",
|
|
29
|
+
"dotnet restore && dotnet build -c Release --no-restore && dotnet test",
|
|
30
|
+
"dotnet restore && dotnet build -c Release --no-restore && dotnet test -c Release --no-build",
|
|
31
|
+
"pnpm verify",
|
|
32
|
+
]);
|
|
21
33
|
// Only a worker that prints a verification block declares VERIFY_COMMANDS. Adding the key to
|
|
22
34
|
// the others made byte-identical workers read as changed on every update, so a worker without
|
|
23
35
|
// the key is left without it.
|
|
@@ -38,6 +38,9 @@ outputs:
|
|
|
38
38
|
outcome:
|
|
39
39
|
description: "Disposition: auto-merge, human-review, owner-review, blocked, remediated, or invalid."
|
|
40
40
|
value: ${{ steps.validate.outputs.outcome }}
|
|
41
|
+
pushed:
|
|
42
|
+
description: Whether the agent proposed a push to the pull request branch, applied or not.
|
|
43
|
+
value: ${{ steps.validate.outputs.pushed }}
|
|
41
44
|
runs:
|
|
42
45
|
using: composite
|
|
43
46
|
steps:
|
|
@@ -61,6 +64,8 @@ runs:
|
|
|
61
64
|
"$BLAST_LEVEL" "$PROTECTED_HIT" "$OWNER_HIT" "$CONFIDENCE_THRESHOLD" "$CODE_RULES")"
|
|
62
65
|
echo "outcome=$outcome" >> "$GITHUB_OUTPUT"
|
|
63
66
|
echo "valid=$([ "$outcome" != 'invalid' ] && echo true || echo false)" >> "$GITHUB_OUTPUT"
|
|
67
|
+
pushed=$(jq -r '[.items[]? | select(.type == "push_to_pull_request_branch")] | length > 0' "$OUTPUT_FILE" 2>/dev/null || echo false)
|
|
68
|
+
echo "pushed=$pushed" >> "$GITHUB_OUTPUT"
|
|
64
69
|
|
|
65
70
|
if [ "$outcome" = 'invalid' ]; then
|
|
66
71
|
echo "::warning::Agent output has no usable merge-gate report. It will not be applied."
|
|
@@ -85,11 +85,17 @@ jq -r \
|
|
|
85
85
|
# Exactly one comment may carry a verdict. Taking the first of several let a second comment
|
|
86
86
|
# reporting a verified critical finding be discarded, and let a comment with no verdict at
|
|
87
87
|
# all supply the report for a verdict written in another.
|
|
88
|
-
|
|
88
|
+
#
|
|
89
|
+
# The line is matched by its shape, not its exact markdown. A model that wrote
|
|
90
|
+
# `**Verdict: remediated**` for `**Verdict:** remediated` had its pushed repair discarded
|
|
91
|
+
# twice on the same head (Pliny-Bot #639). The word has to open a line, so prose that
|
|
92
|
+
# mentions a verdict does not count.
|
|
93
|
+
| "(^|\\n)[^\\w\\n]*Verdict[^\\w\\n]{0,6}(?<v>assessed|remediated)\\b" as $verdict_line
|
|
94
|
+
| [$comments[] | select(.body | test($verdict_line; "i"))] as $verdicts
|
|
89
95
|
| if ($verdicts | length) != 1 then "invalid" else
|
|
90
96
|
|
|
91
97
|
($verdicts[0].body) as $body
|
|
92
|
-
| ($body | capture(
|
|
98
|
+
| ($body | capture($verdict_line; "i").v | ascii_downcase) as $verdict
|
|
93
99
|
| ([$items[] | select(.type == "push_to_pull_request_branch")] | length) as $pushes
|
|
94
100
|
|
|
95
101
|
# The LAST fenced json block in that comment, because the prompt puts the report last and
|
|
@@ -1613,6 +1613,13 @@ if [ -f "$GATE_VALIDATOR" ] && worker_installed merge-gate; then
|
|
|
1613
1613
|
human-review "$(gate_items "$(gate_comment assessed)")" success low false false ''
|
|
1614
1614
|
gate_case "remediating check-diff is accepted" remediated "$(gate_items "$(gate_comment remediated),${gate_push}")" success low false false fail
|
|
1615
1615
|
|
|
1616
|
+
# The verdict line is read by its shape. Pliny-Bot #639 wrote the bold around the whole line,
|
|
1617
|
+
# and the repair it pushed was discarded twice.
|
|
1618
|
+
gate_bold_line='{"type":"add_comment","item_number":7,"body":"## Remediation Report\n\n**Verdict: remediated**\n"}'
|
|
1619
|
+
gate_prose='{"type":"add_comment","item_number":7,"body":"The verdict is remediated, see the push."}'
|
|
1620
|
+
gate_case "a verdict line bolded whole is read" remediated "$(gate_items "${gate_bold_line},${gate_push}")" failure low
|
|
1621
|
+
gate_case "a verdict in prose is not a verdict" invalid "$(gate_items "${gate_prose},${gate_push}")" failure low
|
|
1622
|
+
|
|
1616
1623
|
# The agent's report decides the rest. This is the rule the whole redesign rests on: an
|
|
1617
1624
|
# unverified finding is a warning whatever severity it claims, so a model cannot fail the gate
|
|
1618
1625
|
# by asserting something it did not demonstrate, and cannot pass it by understating one it did.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
# Managed by @plainconceptsplatform/workflows. Source: loops/workflows/agent-implement.md. Update with `workflows update --force`; consumer edits may be overwritten.
|
|
3
3
|
env:
|
|
4
|
-
VERIFY_COMMANDS: "
|
|
4
|
+
VERIFY_COMMANDS: ""
|
|
5
5
|
REPO_RULES: "Implement only the selected issue. Follow repository documentation and existing conventions. Do not weaken tests, lower coverage thresholds, or bypass checks."
|
|
6
6
|
# Split out of REPO_RULES because one field asked to carry architecture, testing, coverage and
|
|
7
7
|
# conventions together, and measured on 2026-09-07 three of the four consuming repositories had
|
|
@@ -741,6 +741,11 @@ timeout-minutes: 180
|
|
|
741
741
|
change and makes you clean the files you touched; fix every line it lists. The merge gate
|
|
742
742
|
runs it again and does not merge while it fails.
|
|
743
743
|
|
|
744
|
+
Anything you change to satisfy `check-diff` is a change: run the verification commands
|
|
745
|
+
again afterwards, and open the pull request only from a tree where both pass. Do not delete
|
|
746
|
+
a test that covers a branch you added to fit the budget; the complexity gate in CI measures
|
|
747
|
+
that coverage.
|
|
748
|
+
|
|
744
749
|
5. Do not touch `changelog.json`. The workflow records the change itself once the work is on
|
|
745
750
|
the default branch. Every implement used to edit that one file, so two runs whose branches
|
|
746
751
|
were cut before the other merged conflicted on it and failed to open a pull request with the
|
|
@@ -455,6 +455,7 @@ jobs:
|
|
|
455
455
|
outputs:
|
|
456
456
|
valid: ${{ steps.validate.outputs.valid }}
|
|
457
457
|
outcome: ${{ steps.validate.outputs.outcome }}
|
|
458
|
+
pushed: ${{ steps.validate.outputs.pushed }}
|
|
458
459
|
steps:
|
|
459
460
|
- name: Checkout workflow actions
|
|
460
461
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
|
@@ -783,6 +784,26 @@ jobs:
|
|
|
783
784
|
|
|
784
785
|
**Verdict:** human-review
|
|
785
786
|
[View this workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
|
|
787
|
+
# The issue is where the belt reads verdicts, but the pull request is where people look. A
|
|
788
|
+
# parked pull request with nothing on it reads as abandoned, and a repair the agent pushed
|
|
789
|
+
# that the gate refused to apply sits in the run artifact where nobody finds it.
|
|
790
|
+
- name: Tell the pull request it needs a person
|
|
791
|
+
if: steps.budget.outputs.park == 'true'
|
|
792
|
+
uses: ./.github/actions/create-issue-comment
|
|
793
|
+
with:
|
|
794
|
+
token: ${{ steps.app-token.outputs.token }}
|
|
795
|
+
issue-number: ${{ needs.subject.outputs.pr }}
|
|
796
|
+
body: |
|
|
797
|
+
The merge gate stopped on this pull request and handed it to a person (#${{ needs.subject.outputs.issue }}).
|
|
798
|
+
${{ needs.validate_output.outputs.pushed == 'true' && 'The agent pushed a repair that was not applied because its report could not be read. The patch and bundle are in the `agent` artifact of the run below.' || '' }}
|
|
799
|
+
[View this workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})
|
|
800
|
+
- name: Label the pull request for review
|
|
801
|
+
if: steps.budget.outputs.park == 'true'
|
|
802
|
+
uses: ./.github/actions/add-issue-labels
|
|
803
|
+
with:
|
|
804
|
+
token: ${{ steps.app-token.outputs.token }}
|
|
805
|
+
issue-number: ${{ needs.subject.outputs.pr }}
|
|
806
|
+
labels: ${{ env.REVIEW_LABEL }}
|
|
786
807
|
# `stalled` means a park the machine caused, and the janitor retries those. An unusable
|
|
787
808
|
# report is a park the machine caused and retrying reproduces it, so it gets `review`
|
|
788
809
|
# alone: the janitor's own rule is retry a failure, report a decision.
|
|
@@ -979,6 +1000,13 @@ timeout-minutes: 120
|
|
|
979
1000
|
until it exits 0, then verify, push and report `remediated` as in step 6. An `assessed`
|
|
980
1001
|
verdict while it fails blocks the pull request.
|
|
981
1002
|
|
|
1003
|
+
When CI failed as well, repair CI first and `check-diff` second, and verify again after the
|
|
1004
|
+
`check-diff` repair. The two pull in opposite directions: a complexity or coverage gate wants
|
|
1005
|
+
more code and tests, `check-diff` wants fewer lines. Trimming to fit `check-diff` is how a
|
|
1006
|
+
pull request that once passed broke the formatter, Semgrep and the complexity gate on its
|
|
1007
|
+
last commit (Pliny-Bot #633). Never delete a test that covers a branch the change added; if
|
|
1008
|
+
both cannot hold, keep CI green and say so in `reason`.
|
|
1009
|
+
|
|
982
1010
|
- **success** → step 4, then step 5 (review the change).
|
|
983
1011
|
- **failure** → step 4, then step 6 (CI remediation).
|
|
984
1012
|
- **action_required, cancelled, timed_out, or anything else** → CI did not produce a usable
|
|
@@ -457,6 +457,21 @@ jobs:
|
|
|
457
457
|
if [ "$existing" -gt 0 ]; then
|
|
458
458
|
echo "has-open-pr=true" >> "$GITHUB_OUTPUT"
|
|
459
459
|
echo "::notice::Issue #$ISSUE_NUMBER already has an open bot PR. Skipping implement to prevent duplicates."
|
|
460
|
+
exit 0
|
|
461
|
+
fi
|
|
462
|
+
|
|
463
|
+
# A person working on the issue rarely writes a closing keyword: Pliny-Bot #633 said
|
|
464
|
+
# "Implements #627", and an hour later the bot opened #639 for the same issue. Any open
|
|
465
|
+
# pull request by a person that names the issue in its title, body or branch holds it.
|
|
466
|
+
human=$(gh pr list --repo "$REPO" --state open --json number,author,title,body,headRefName \
|
|
467
|
+
--jq "[.[]
|
|
468
|
+
| select((.author.login | startswith(\"app/\") or endswith(\"[bot]\")) | not)
|
|
469
|
+
| select(((.title + \"\n\" + (.body // \"\")) | test(\"#${ISSUE_NUMBER}([^0-9]|$)\"))
|
|
470
|
+
or (.headRefName | test(\"(issue|gh)[-_/]?${ISSUE_NUMBER}([^0-9]|$)\"; \"i\")))
|
|
471
|
+
| .number] | first // empty")
|
|
472
|
+
if [ -n "$human" ]; then
|
|
473
|
+
echo "has-open-pr=true" >> "$GITHUB_OUTPUT"
|
|
474
|
+
echo "::notice::Open PR #$human by a person names #$ISSUE_NUMBER. Skipping implement so the two do not collide."
|
|
460
475
|
else
|
|
461
476
|
echo "has-open-pr=false" >> "$GITHUB_OUTPUT"
|
|
462
477
|
fi
|