@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.
@@ -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
- if (isWorker(target) && (!existing.has(target) || hasEmptyVerifyCommands(existing.get(target)))) {
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
@@ -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;
@@ -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 = ".NET: dotnet restore && dotnet build -c Release && dotnet test | Web: pnpm lint && pnpm test && pnpm build";
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
- | [$comments[] | select(.body | test("\\*\\*Verdict:\\*\\*\\s*(assessed|remediated)"; "i"))] as $verdicts
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("\\*\\*Verdict:\\*\\*\\s*(?<v>assessed|remediated)"; "i").v | ascii_downcase) as $verdict
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: "dotnet restore && dotnet build -c Release --no-restore && dotnet test -c Release --no-build"
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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plainconceptsplatform/workflows",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Install and update Platform GitHub agentic workflows.",
5
5
  "keywords": [
6
6
  "github-actions",