@plainconceptsplatform/workflows 0.16.2 → 0.17.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.
@@ -26,6 +26,12 @@ export function mandatoryFileSpecs(sourcePath) {
26
26
  export function catalogSourcePath(modulePath = fileURLToPath(import.meta.url)) {
27
27
  return resolve(dirname(modulePath), "..", "loops");
28
28
  }
29
+ /**
30
+ * Where `gh aw` writes the pinned-action lock, and the only place it should be named.
31
+ * `generatedConsumerTargets` in the catalog declares the same path; two literals is how the
32
+ * pre-commit hook ended up guarding one that exists nowhere.
33
+ */
34
+ const ACTIONS_LOCK = ".github/aw/actions-lock.json";
29
35
  const isWorker = (target) => target.startsWith(".github/workflows/agent-") && target.endsWith(".md");
30
36
  const isRouter = (target) => target === ".github/workflows/work-router.yml";
31
37
  const carriesHeader = (target) => /\.(ya?ml|md|sh|mjs|cjs)$/.test(target);
@@ -287,7 +293,13 @@ async function preCommitHookUpdate(repositoryPath) {
287
293
  const hookPath = join(repositoryPath, target);
288
294
  const compileLine = "node scripts/compile-agent-workflows.mjs";
289
295
  const stageLine = "git add -- .github/workflows/*.lock.yml";
290
- const actionLockLine = "[ ! -f .github/actions/actions-lock.json ] || git add -- .github/actions/actions-lock.json";
296
+ // gh aw writes this at .github/aw/, which is what every consumer tracks and what
297
+ // `generatedConsumerTargets` in the catalog declares. The line used to name
298
+ // `.github/actions/actions-lock.json`, a path that exists nowhere, so `[ ! -f ... ]` was
299
+ // always true, the `||` short-circuited, and the real lock was never staged: a compile that
300
+ // bumped an action pin left the lock out of the commit and the tree dirty behind it. Nothing
301
+ // failed, which is why it survived. Found by the first audit run in the dogfood repository.
302
+ const actionLockLine = `[ ! -f ${ACTIONS_LOCK} ] || git add -- ${ACTIONS_LOCK}`;
291
303
  const managedLines = `if git diff --cached --name-only -- .github | grep -q .; then\n ${compileLine}\n ${stageLine}\n ${actionLockLine}\nfi\n`;
292
304
  if (!await exists(hookPath)) {
293
305
  return { target, content: managedLines };
@@ -363,9 +375,9 @@ async function generatedFiles(repositoryPath) {
363
375
  updates.push({ target: `.github/workflows/${file.replaceAll("\\", "/")}`, content: await readFile(join(workflowPath, file), "utf8") });
364
376
  }
365
377
  }
366
- const actionsLock = join(repositoryPath, ".github", "actions", "actions-lock.json");
378
+ const actionsLock = join(repositoryPath, ...ACTIONS_LOCK.split("/"));
367
379
  if (await exists(actionsLock)) {
368
- updates.push({ target: ".github/actions/actions-lock.json", content: await readFile(actionsLock, "utf8") });
380
+ updates.push({ target: ACTIONS_LOCK, content: await readFile(actionsLock, "utf8") });
369
381
  }
370
382
  return updates.sort((left, right) => left.target.localeCompare(right.target));
371
383
  }
@@ -75,6 +75,13 @@ runs:
75
75
  const names = (issue) => issue.labels.map((l) => (typeof l === 'string' ? l : l.name));
76
76
 
77
77
  const needsHuman = [];
78
+ // Stalled work still inside its retry window. Separate from needsHuman because nobody
79
+ // needs to act on it yet, and reported anyway because the alternative is silence: the
80
+ // window is 6 hours and the budget is 3 attempts, so an issue that keeps stalling was
81
+ // invisible for eighteen hours before it surfaced as retries-exhausted. Watched that
82
+ // happen to an audit report in the dogfood repository -- parked, and the digest said
83
+ // one item while three issues were sitting there.
84
+ const retrying = [];
78
85
  let retried = 0;
79
86
 
80
87
  // 1. Retry what the machine stalled on. `stalled` marks a park caused by a crash, a
@@ -96,7 +103,10 @@ runs:
96
103
  needsHuman.push({ kind: 'decided', issue, labels, waited });
97
104
  continue;
98
105
  }
99
- if (waited < retryAfterMs) continue;
106
+ if (waited < retryAfterMs) {
107
+ retrying.push({ issue, waited, attempts, work, due: retryAfterMs - waited });
108
+ continue;
109
+ }
100
110
  if (attempts >= maxRetries || !work) {
101
111
  needsHuman.push({ kind: attempts >= maxRetries ? 'retries-exhausted' : 'no-work-label', issue, labels, waited, attempts });
102
112
  continue;
@@ -203,39 +213,64 @@ runs:
203
213
 
204
214
  // 6. One digest. Everything above either fixed itself or is listed here, so there is a
205
215
  // single place to look instead of four repositories and three label queries.
216
+ // Audit reports nobody actioned. audit-close labels these `stale-audit` once they are
217
+ // past their budget, which frees the audit's backpressure slot. Freeing the slot
218
+ // without listing the report anywhere would only move the silence, so they land here.
219
+ for (const issue of issues) {
220
+ if (issue.pull_request) continue;
221
+ if (!names(issue).includes('stale-audit')) continue;
222
+ needsHuman.push({ kind: 'stale-audit', issue, waited: age(issue.created_at) });
223
+ }
224
+
206
225
  const line = (entry) => {
207
226
  if (entry.kind === 'pr-parked') return `- PR #${entry.pull.number} · parked ${days(entry.waited)}d · gate verdict \`${entry.verdict}\` · ${entry.pull.title}`;
208
227
  if (entry.kind === 'pr-stale') return `- PR #${entry.pull.number} · ${days(entry.waited)}d old · no gate verdict yet · ${entry.pull.title}`;
209
228
  if (entry.kind === 'retries-exhausted') return `- #${entry.issue.number} · stalled ${days(entry.waited)}d · retried ${entry.attempts}× and still stalling · ${entry.issue.title}`;
210
229
  if (entry.kind === 'no-work-label') return `- #${entry.issue.number} · stalled ${days(entry.waited)}d · no work label to retry with · ${entry.issue.title}`;
230
+ if (entry.kind === 'stale-audit') return `- #${entry.issue.number} · open ${days(entry.waited)}d · audit report nobody actioned · ${entry.issue.title}`;
211
231
  return `- #${entry.issue.number} · waiting ${days(entry.waited)}d · ${entry.labels.filter((l) => l !== 'review').join(', ') || 'no other labels'} · ${entry.issue.title}`;
212
232
  };
213
233
  const section = (heading, kinds) => {
214
234
  const rows = needsHuman.filter((e) => kinds.includes(e.kind));
215
235
  return rows.length === 0 ? '' : `\n### ${heading} (${rows.length})\n\n${rows.map(line).join('\n')}\n`;
216
236
  };
237
+ // Listed, but not as something to act on: the machine is going to try again by itself.
238
+ // The heading says so, because a reader who acts on these duplicates the retry.
239
+ const retryingSection = () => {
240
+ if (retrying.length === 0) return '';
241
+ const hours = (ms) => Math.max(1, Math.round(ms / 3600000));
242
+ const rows = retrying.map((entry) =>
243
+ `- #${entry.issue.number} · stalled ${hours(entry.waited)}h ago · attempt ${entry.attempts + 1} of ${maxRetries} due in ~${hours(entry.due)}h · ${entry.issue.title}`);
244
+ return `\n### Retrying by itself, no action needed (${retrying.length})\n\n${rows.join('\n')}\n`;
245
+ };
217
246
  const body = [
218
247
  DIGEST_MARKER,
219
248
  `_Updated ${new Date().toISOString().slice(0, 16).replace('T', ' ')} UTC. This issue is rewritten by the housekeeping job; edits to it are lost._`,
220
249
  section('Waiting on a decision', ['decided']),
221
250
  section('Pull requests parked by the gate', ['pr-parked', 'pr-stale']),
222
251
  section('Stalled beyond the retry budget', ['retries-exhausted', 'no-work-label']),
252
+ section('Audit reports nobody actioned', ['stale-audit']),
253
+ retryingSection(),
223
254
  needsHuman.length === 0 ? '\nNothing is waiting on a person right now.\n' : '',
224
255
  `\n---\n\nThis run retried ${retried} stalled issue(s) and made ${planned.length} change(s) in total.`,
225
256
  ].filter(Boolean).join('\n');
226
257
 
227
258
  if (digestTitle) {
259
+ // The digest exists whenever there is anything to show, including work the machine is
260
+ // about to retry. Gating it on needsHuman alone is what left three parked issues
261
+ // with a digest that named one.
262
+ const anythingToShow = needsHuman.length + retrying.length;
228
263
  const existing = issues.find((i) => !i.pull_request && i.title === digestTitle && (i.body ?? '').includes(DIGEST_MARKER));
229
264
  if (existing) {
230
- if (needsHuman.length === 0) {
265
+ if (anythingToShow === 0) {
231
266
  await act(`closing the digest #${existing.number}: nothing is waiting`, () =>
232
267
  github.rest.issues.update({ ...context.repo, issue_number: existing.number, body, state: 'closed', state_reason: 'completed' }));
233
268
  } else {
234
- await act(`updating the digest #${existing.number}: ${needsHuman.length} item(s)`, () =>
269
+ await act(`updating the digest #${existing.number}: ${anythingToShow} item(s)`, () =>
235
270
  github.rest.issues.update({ ...context.repo, issue_number: existing.number, body }));
236
271
  }
237
- } else if (needsHuman.length > 0) {
238
- await act(`opening the digest: ${needsHuman.length} item(s)`, () =>
272
+ } else if (anythingToShow > 0) {
273
+ await act(`opening the digest: ${anythingToShow} item(s)`, () =>
239
274
  github.rest.issues.create({ ...context.repo, title: digestTitle, body }));
240
275
  }
241
276
  }
@@ -245,6 +280,7 @@ runs:
245
280
  .addList([
246
281
  `Retried: ${retried}`,
247
282
  `Waiting on a person: ${needsHuman.length}`,
283
+ `Stalled, retrying by itself: ${retrying.length}`,
248
284
  `Changes ${dryRun ? 'planned' : 'made'}: ${planned.length}`,
249
285
  ])
250
286
  .addRaw(planned.length ? `\n\n${planned.map((p) => `- ${p}`).join('\n')}` : '')
@@ -62,10 +62,17 @@ runs:
62
62
  const since = new Date(Date.now() - Number(process.env.LOOKBACK_HOURS) * 3600 * 1000);
63
63
 
64
64
  // Only workflows this package ships. A consumer's own workflow name is theirs and can
65
- // describe a product, a customer or an environment -- Lyceum really does have
66
- // `name: Deploy app to ${{ inputs.environment }}` -- so an allowlist by filename is the
67
- // boundary, and it is the only thing keeping those names out. Anything not matching is
68
- // counted and otherwise ignored.
65
+ // describe a product, a customer or an environment -- one consumer really does have a
66
+ // job named "Deploy app to" followed by an interpolated environment input -- so an
67
+ // allowlist by filename is the boundary, and it is the only thing keeping those names
68
+ // out. Anything not matching is counted and otherwise ignored.
69
+ //
70
+ // That example is described rather than quoted on purpose. An Actions expression is
71
+ // interpolated into this script by the runner BEFORE the JavaScript is parsed, comments
72
+ // included, so quoting one here made the comment a live substitution -- flagged by
73
+ // semgrep as github-script-injection, and invisible to a `node --check` pass because
74
+ // the checker has to mask the expression to parse the file at all. Everything this
75
+ // script needs arrives through `env:` above; nothing is interpolated into it.
69
76
  //
70
77
  // Exactly the filenames the package installs, and no pattern that admits a name it does
71
78
  // not. The previous version was written as a shape (`\.(yml|lock\.yml)$` over a group of
@@ -405,6 +405,83 @@ else
405
405
  fi
406
406
  if [ "$BELT_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
407
407
 
408
+ # The two guards in the stale-reservation sweep, executed rather than read.
409
+ #
410
+ # Both were dead in production for as long as they existed. They matched `#N` with a `\b` word
411
+ # boundary written as `\\b` inside a double-quoted bash string -- and bash halves that to `\b`,
412
+ # which jq's string parser reads as a BACKSPACE (0x08), not as a boundary. So the regex hunted
413
+ # for a character no title or body contains, both guards returned 0 every time, and the sweep
414
+ # cleared `bot-working` off every issue that carried it: live queued runs included. Watched it
415
+ # happen in the dogfood repository twenty minutes after that repository existed.
416
+ #
417
+ # Implement carries the same filter to decide whether a pull request already closes its issue,
418
+ # and it was dead the same way, which is the most likely source of the same-day duplicate pull
419
+ # requests in the backlog.
420
+ #
421
+ # There is no backslash in the fix and no way to reintroduce one by accident: the subject gains a
422
+ # trailing space, so a number at the end of a body is still followed by a non-digit, and the
423
+ # pattern matches `[^0-9]`. Four quoting layers cannot eat a character class.
424
+ SWEEP_OK=1
425
+ if ! grep -qF 'test(\"#${issue}[^0-9]\")' "$ROUTER_YML"; then
426
+ SWEEP_OK=0
427
+ echo "FAIL: the stale-reservation sweep does not match a run title with a character class; a word boundary there is eaten by bash and jq" >&2
428
+ else
429
+ sweep_runs='{"workflow_runs":[
430
+ {"status":"queued","display_title":"Working (Refine): a thing (#1)"},
431
+ {"status":"completed","display_title":"Working (Refine): another (#2)"},
432
+ {"status":"in_progress","display_title":"Working (Implement): more (#10)"}
433
+ ]}'
434
+ # The same program the router runs, with the same escaping, expanded the same way.
435
+ live_for() {
436
+ jq "[.workflow_runs[]
437
+ | select(.status == \"queued\" or .status == \"in_progress\" or .status == \"pending\" or .status == \"waiting\")
438
+ | select((.display_title + \" \") | test(\"#${1}[^0-9]\"))] | length" <<<"$sweep_runs"
439
+ }
440
+ for probe in "1:1" "2:0" "10:1" "3:0"; do
441
+ issue="${probe%%:*}"; want="${probe##*:}"
442
+ got=$(live_for "$issue" 2>&1)
443
+ if [ "$got" != "$want" ]; then
444
+ SWEEP_OK=0
445
+ echo "FAIL: the sweep's live-run check said ${got} live run(s) for #${issue}, expected ${want}" >&2
446
+ fi
447
+ done
448
+
449
+ sweep_pulls='[
450
+ {"number":11,"body":"Closes #1 and some detail."},
451
+ {"number":12,"body":"fixes #22"},
452
+ {"number":13,"body":"Mentions #9 but closes nothing."},
453
+ {"number":14,"body":"closes #5"}
454
+ ]'
455
+ has_pr_for() {
456
+ jq "[.[] | select(((.body // \"\") + \" \") | ascii_downcase | test(\"clos(e|es|ed) #${1}[^0-9]|fix(es|ed)? #${1}[^0-9]|resolves? #${1}[^0-9]\"))] | length" <<<"$sweep_pulls"
457
+ }
458
+ # The last cases are what the boundary is for: a bare mention is not a close, `#22` must not
459
+ # answer for `#2`, and the trailing space is what makes `closes #5` at the end of a body match.
460
+ for probe in "1:1" "22:1" "9:0" "2:0" "5:1" "999:0"; do
461
+ issue="${probe%%:*}"; want="${probe##*:}"
462
+ got=$(has_pr_for "$issue" 2>&1)
463
+ if [ "$got" != "$want" ]; then
464
+ SWEEP_OK=0
465
+ echo "FAIL: the open-pull-request guard found ${got} for #${issue}, expected ${want}" >&2
466
+ fi
467
+ done
468
+ fi
469
+ if [ "$SWEEP_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
470
+
471
+ # And nothing may go back to a word boundary in a double-quoted jq program anywhere, because the
472
+ # failure is silent in both directions: the filter matches nothing and the job stays green.
473
+ BOUNDARY_OK=1
474
+ for candidate in "$ROUTER_YML" "${WORKFLOWS_DIR}"/agent-*.md; do
475
+ [ -f "$candidate" ] || continue
476
+ offenders=$(grep -n -- '--jq "' "$candidate" 2>/dev/null | grep -F '\b' || true)
477
+ if [ -n "$offenders" ]; then
478
+ BOUNDARY_OK=0
479
+ echo "FAIL: $(basename "$candidate") uses a word boundary inside a double-quoted jq program, which bash and jq turn into a backspace:" >&2
480
+ printf ' %s\n' "$offenders" | cut -c1-160 >&2
481
+ fi
482
+ done
483
+ if [ "$BOUNDARY_OK" -eq 1 ]; then PASS=$((PASS + 1)); else FAIL=$((FAIL + 1)); fi
484
+
408
485
  # `review` must not be a one-way door. It used to be: authorize-bot-work refused to fire on an
409
486
  # issue carrying it, and the classifier refuses to route while it is set, so a person adding
410
487
  # `refine` to a parked issue got nothing at all — no run, no comment, no error. Triage's own
@@ -42,9 +42,9 @@ jobs:
42
42
  steps:
43
43
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
44
44
  - uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4
45
- with: { version: ${{ env.PNPM_VERSION }}, run_install: false }
45
+ with: { version: "${{ env.PNPM_VERSION }}", run_install: false }
46
46
  - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
47
- with: { node-version: ${{ env.NODE_VERSION }}, cache: pnpm }
47
+ with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
48
48
  - run: pnpm install --frozen-lockfile
49
49
  - run: pnpm exec biome check .
50
50
 
@@ -55,9 +55,9 @@ jobs:
55
55
  steps:
56
56
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
57
57
  - uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4
58
- with: { version: ${{ env.PNPM_VERSION }}, run_install: false }
58
+ with: { version: "${{ env.PNPM_VERSION }}", run_install: false }
59
59
  - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
60
- with: { node-version: ${{ env.NODE_VERSION }}, cache: pnpm }
60
+ with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
61
61
  - run: pnpm install --frozen-lockfile
62
62
  - run: pnpm --filter "$WEB_PACKAGE" run typecheck
63
63
  - run: pnpm --filter "$WEB_PACKAGE" run test
@@ -80,9 +80,9 @@ jobs:
80
80
  with:
81
81
  fetch-depth: 0
82
82
  - uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4
83
- with: { version: ${{ env.PNPM_VERSION }}, run_install: false }
83
+ with: { version: "${{ env.PNPM_VERSION }}", run_install: false }
84
84
  - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
85
- with: { node-version: ${{ env.NODE_VERSION }}, cache: pnpm }
85
+ with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
86
86
  - name: Detect web changes
87
87
  id: scope
88
88
  run: |
@@ -107,9 +107,9 @@ jobs:
107
107
  steps:
108
108
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
109
109
  - uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4
110
- with: { version: ${{ env.PNPM_VERSION }}, run_install: false }
110
+ with: { version: "${{ env.PNPM_VERSION }}", run_install: false }
111
111
  - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
112
- with: { node-version: ${{ env.NODE_VERSION }}, cache: pnpm }
112
+ with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
113
113
  - run: pnpm install --frozen-lockfile
114
114
  - uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6
115
115
  with:
@@ -132,9 +132,9 @@ jobs:
132
132
  steps:
133
133
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
134
134
  - uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4
135
- with: { version: ${{ env.PNPM_VERSION }}, run_install: false }
135
+ with: { version: "${{ env.PNPM_VERSION }}", run_install: false }
136
136
  - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
137
- with: { node-version: ${{ env.NODE_VERSION }}, cache: pnpm }
137
+ with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
138
138
  - run: pnpm install --frozen-lockfile
139
139
  - run: pnpm --filter "$DESKTOP_PACKAGE" run typecheck
140
140
  - run: pnpm --filter "$DESKTOP_PACKAGE" run build
@@ -156,9 +156,9 @@ jobs:
156
156
  steps:
157
157
  - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
158
158
  - uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4
159
- with: { version: ${{ env.PNPM_VERSION }}, run_install: false }
159
+ with: { version: "${{ env.PNPM_VERSION }}", run_install: false }
160
160
  - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
161
- with: { node-version: ${{ env.NODE_VERSION }}, cache: pnpm }
161
+ with: { node-version: "${{ env.NODE_VERSION }}", cache: pnpm }
162
162
  - run: pnpm install --frozen-lockfile
163
163
  - run: pnpm --filter "$MOBILE_PACKAGE" run typecheck
164
164
  - run: |
@@ -404,7 +404,7 @@ jobs:
404
404
  | grep -oE '<!-- implement-pr: [0-9]+ -->' | head -1 | grep -oE '[0-9]+' || true)
405
405
  if [ -z "$pr" ]; then
406
406
  pr=$(gh pr list --repo "$REPO" --state open --json number,body \
407
- --jq "[.[] | select((.body // \"\") | ascii_downcase | test(\"clos(e|es|ed) #${ISSUE}\\b|fix(es|ed)? #${ISSUE}\\b|resolves? #${ISSUE}\\b\"))][0].number // empty")
407
+ --jq "[.[] | select(((.body // \"\") + \" \") | ascii_downcase | test(\"clos(e|es|ed) #${ISSUE}[^0-9]|fix(es|ed)? #${ISSUE}[^0-9]|resolves? #${ISSUE}[^0-9]\"))][0].number // empty")
408
408
  fi
409
409
  if [ -z "$pr" ]; then
410
410
  echo "::notice::No pull request found for #$ISSUE; nothing to wait for."
@@ -943,11 +943,11 @@ jobs:
943
943
  live=$(gh api "repos/$REPO/actions/runs?per_page=100" \
944
944
  --jq "[.workflow_runs[]
945
945
  | select(.status == \"queued\" or .status == \"in_progress\" or .status == \"pending\" or .status == \"waiting\")
946
- | select(.display_title | test(\"#${issue}\\b\"))] | length")
946
+ | select((.display_title + \" \") | test(\"#${issue}[^0-9]\"))] | length")
947
947
  [ "${live:-0}" -eq 0 ] || continue
948
948
 
949
949
  has_pr=$(gh pr list --repo "$REPO" --state open --json body \
950
- --jq "[.[] | select((.body // \"\") | ascii_downcase | test(\"clos(e|es|ed) #${issue}\\b|fix(es|ed)? #${issue}\\b|resolves? #${issue}\\b\"))] | length")
950
+ --jq "[.[] | select(((.body // \"\") + \" \") | ascii_downcase | test(\"clos(e|es|ed) #${issue}[^0-9]|fix(es|ed)? #${issue}[^0-9]|resolves? #${issue}[^0-9]\"))] | length")
951
951
  [ "${has_pr:-0}" -eq 0 ] || continue
952
952
 
953
953
  echo "Clearing a stale bot-working reservation on #$issue: no run, no pull request."
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plainconceptsplatform/workflows",
3
- "version": "0.16.2",
3
+ "version": "0.17.0",
4
4
  "description": "Install and update Platform GitHub agentic workflows.",
5
5
  "keywords": [
6
6
  "github-actions",