@bongos/core 1.19.339 → 1.19.341

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.
@@ -24,6 +24,24 @@
24
24
  // to /api/gds/security/reports. --canary proves the end-to-end pipe: plants a
25
25
  // constructed token, asserts the scanner catches it, files a low "[canary]"
26
26
  // report, and dispute-closes it so the queue stays clean.
27
+ //
28
+ // UNATTENDED RUNS (task 1003209). The scheduler is .github/workflows/redteam-patrol.yml
29
+ // — CI, not the always-on server, because all three checks read things only the
30
+ // REPO has: full git history, .github/workflows/, and a package-lock npm can audit
31
+ // (ADR 0188). A CI runner has no GDS session, so --file would otherwise hit
32
+ // cli-lib's interactive re-auth wall and print "run /builder-reauth" into a cron
33
+ // log. Instead --file preflights the session and, when there is none, follows the
34
+ // main-audit.js --require-roster doctrine: report LOUDLY rather than green-and-
35
+ // vacuous. The exit code says whether a finding was LOST, not whether findings
36
+ // exist — findings are advisory (ADR 0162) and a patrol that goes red merely for
37
+ // doing its job trains everyone to ignore it:
38
+ //
39
+ // ran + filed → 0
40
+ // ran, nothing to file → 0 (with or without a session)
41
+ // ran, has findings, cannot file → 2 — the one state where evidence evaporates
42
+ //
43
+ // Findings are ALSO appended to $GITHUB_STEP_SUMMARY when set, so a scheduled run
44
+ // is readable in the Actions UI without opening the log.
27
45
 
28
46
  'use strict';
29
47
 
@@ -211,6 +229,45 @@ function buildClassReport(className, findings, notes = []) {
211
229
  };
212
230
  }
213
231
 
232
+ // Append markdown to the Actions job summary when running under GitHub Actions.
233
+ // Best-effort and inert everywhere else — a patrol run must never fail because a
234
+ // log surface was unavailable.
235
+ function stepSummary(md) {
236
+ const dest = process.env.GITHUB_STEP_SUMMARY;
237
+ if (!dest) return;
238
+ try { fs.appendFileSync(dest, `${md}\n`); } catch { /* never fatal */ }
239
+ }
240
+
241
+ // Pure: what a completed sweep should DO with its findings, and the exit code
242
+ // that disposition deserves. Extracted so the honest-exit contract is a table a
243
+ // test can enumerate rather than branching buried in main().
244
+ //
245
+ // The load-bearing row is the last one. Findings are ADVISORY (ADR 0162), so a
246
+ // run that finds three things and files them is a SUCCESS — going red merely for
247
+ // doing its job is how a weekly lane becomes noise everyone filters. Red is
248
+ // reserved for the single state where evidence EVAPORATES: findings exist,
249
+ // filing was asked for, and it cannot happen.
250
+ function disposition({ findingClasses = 0, doFile = false, blocker = null } = {}) {
251
+ if (!findingClasses) return { action: 'clean', exitCode: 0 };
252
+ if (!doFile) return { action: 'dry-run', exitCode: 0 };
253
+ if (blocker) return { action: 'not-filed', exitCode: 2 };
254
+ return { action: 'file', exitCode: 0 };
255
+ }
256
+
257
+ // Is there a GDS session this run could file with? Returns the reason it cannot,
258
+ // or null when filing is possible. Read directly (readSessionToken) rather than
259
+ // probing with apiCall, because apiCall's 401 path exits the process with
260
+ // interactive re-auth guidance — useless in a cron log and indistinguishable from
261
+ // a crash.
262
+ function filingBlocker() {
263
+ try {
264
+ const { readSessionToken } = require('./cli-lib.js');
265
+ return readSessionToken() ? null : 'no GDS session on this runner';
266
+ } catch (e) {
267
+ return `session unreadable: ${e && e.message}`;
268
+ }
269
+ }
270
+
214
271
  async function fileReport(report) {
215
272
  const { apiCall } = require('./cli-lib.js');
216
273
  const r = await apiCall('POST', '/api/gds/security/reports', report);
@@ -283,10 +340,30 @@ async function main() {
283
340
  const pins = actionPinCheck();
284
341
  if (pins.findings.length) classes.push(buildClassReport('action-pins', pins.findings));
285
342
 
343
+ if (secrets.degraded) stepSummary(`> **DEGRADED** — ${secrets.degraded}`);
344
+
286
345
  if (!classes.length) {
287
346
  console.log('patrol: clean sweep — nothing to file.');
347
+ stepSummary('## red-team patrol\n\nClean sweep — no findings to file.');
288
348
  return;
289
349
  }
350
+
351
+ stepSummary(['## red-team patrol', ...classes.map((c) =>
352
+ `### ${c.title}\n\nseverity: **${c.severity}**\n\n${c.repro_steps_md}`)].join('\n\n'));
353
+
354
+ // Preflight the session ONCE, before the first POST: a half-filed sweep (class
355
+ // one lands, class two hits the auth wall and exits) is worse than not starting.
356
+ const blocker = doFile ? filingBlocker() : null;
357
+ const { action, exitCode } = disposition({ findingClasses: classes.length, doFile, blocker });
358
+ if (action === 'not-filed') {
359
+ console.error(`patrol: DID NOT FILE — ${blocker}. ${classes.length} finding class(es) above are NOT in the adjudication queue.`);
360
+ console.error('patrol: sign in on this runner (or set the scheduler\'s session secret) and re-run — see docs/recipes/redteam-patrol.md.');
361
+ for (const report of classes) console.error(` [${report.severity}] ${report.title}\n${report.repro_steps_md}`);
362
+ stepSummary(`\n> **NOT FILED** — ${blocker}. These findings exist only in this run's log.`);
363
+ process.exitCode = exitCode;
364
+ return;
365
+ }
366
+
290
367
  for (const report of classes) {
291
368
  if (doFile) {
292
369
  const id = await fileReport(report);
@@ -305,4 +382,5 @@ if (require.main === module) {
305
382
  module.exports = {
306
383
  parseGitleaksReport, unpinnedUses, ageDays, mapSeverity, maxSeverity,
307
384
  buildClassReport, lockedVersion, COOLDOWN_DAYS, YOUNG_PKG_DAYS,
385
+ stepSummary, filingBlocker, disposition,
308
386
  };
@@ -0,0 +1,231 @@
1
+ #!/usr/bin/env node
2
+ // scripts/gds/routine-schedule.js — a declared cadence must have something that
3
+ // fires it (task 1003209, goal 1000079; docs/audits/ai-dev-redteam-followup.md §7).
4
+ //
5
+ // THE GAP THIS CLOSES. Goal 1000076 shipped the redteam-weekly patrol, wired it
6
+ // into the control manifest, and documented it — and then nothing ever ran it.
7
+ // The follow-up audit's §7 named all three halves of the failure:
8
+ //
9
+ // 1. no committed scheduler for .claude/scheduled-tasks/ at all;
10
+ // 2. the SKILL.md declared `mode` and `steps` but no cadence — "nothing for a
11
+ // scheduler to read even if one looked";
12
+ // 3. control-manifest.js called the patrol "wired" on a substring (the SKILL.md
13
+ // mentions its own scanner), so the manifest was GREEN while the patrol was
14
+ // dormant.
15
+ //
16
+ // (2) is now a real frontmatter field (run-routine.js parseRoutineFrontmatter),
17
+ // (1) is .github/workflows/redteam-patrol.yml, and THIS file is (3): the check
18
+ // that refuses the combination of a declared cadence and nothing firing it.
19
+ //
20
+ // WHY A DEDICATED CHECK RATHER THAN ANOTHER MANIFEST ROW. The manifest's
21
+ // predicates are deliberately shallow — file exists, one wiring substring. That
22
+ // shape is exactly what let the patrol read as wired while dormant, and this goal
23
+ // has now hit the same failure five times: an enforcement surface that NAMES a
24
+ // file goes VACUOUS, not red, when the thing it names moves or stops. So the
25
+ // predicate here is relational, not existential: it reads the cadence each
26
+ // routine declares, reads the cron each scheduler actually carries, and fails
27
+ // when the two disagree in EITHER direction —
28
+ //
29
+ // • a routine declares `cadence:` and no scheduler fires it → red
30
+ // • a scheduler fires a routine that no longer exists → red (rename rot)
31
+ // • the cron's real period differs from the declared cadence → red
32
+ //
33
+ // The third is the one a substring check can never catch: edit the cron from
34
+ // weekly to yearly and every "is it wired?" predicate stays green.
35
+ //
36
+ // TWO-REPO TOLERANCE. Scheduled routines are TOPOLOGY — `bongos init/upgrade`
37
+ // materializes .claude/scheduled-tasks/ into each instance (claude-materialize.js),
38
+ // but .github/workflows/ is the CORE repo's and does not travel. So an instance
39
+ // checkout legitimately has routines and no workflows directory. There, the
40
+ // unscheduled routines are reported as WARNINGS (the instance's operator owns its
41
+ // own scheduler); in a repo that HAS workflows, they are violations. That keeps
42
+ // the check honest in the core without red-building every instance.
43
+
44
+ 'use strict';
45
+
46
+ const fs = require('fs');
47
+ const path = require('path');
48
+
49
+ const ROOT = path.resolve(__dirname, '..', '..');
50
+ const TASKS_DIR = path.join(ROOT, '.claude', 'scheduled-tasks');
51
+ const WORKFLOWS_DIR = path.join(ROOT, '.github', 'workflows');
52
+
53
+ const { parseRoutineFrontmatter } = require('./run-routine.js');
54
+
55
+ // The cadences a routine may declare. The value is the period a cron expression
56
+ // must resolve to for the two to agree. Add a name here and cronCadence() below
57
+ // must be able to produce it, or the pair can never match.
58
+ const CADENCES = new Set(['hourly', 'daily', 'weekly', 'monthly']);
59
+
60
+ // Pure: classify a 5-field cron expression into the cadence it actually realizes.
61
+ // Returns null for anything this vocabulary cannot describe (step values, lists,
62
+ // ranges) — a cadence can then never "match" it, which is the safe direction: an
63
+ // exotic cron must be described some other way rather than silently accepted.
64
+ //
65
+ // min hour dom mon dow
66
+ // 17 6 * * 1 → weekly (a specific day-of-week)
67
+ // 17 6 * * * → daily (a specific hour, every day)
68
+ // 17 6 3 * * → monthly (a specific day-of-month)
69
+ // 17 * * * * → hourly (a specific minute, every hour)
70
+ function cronCadence(expr) {
71
+ const f = String(expr == null ? '' : expr).trim().split(/\s+/);
72
+ if (f.length !== 5) return null;
73
+ const [min, hour, dom, mon, dow] = f;
74
+ const fixed = (v) => /^\d+$/.test(v);
75
+ const any = (v) => v === '*';
76
+ if (!fixed(min)) return null; // every cadence below pins the minute
77
+ if (!any(mon)) return null; // a specific month is a yearly shape
78
+ if (any(hour)) return any(dom) && any(dow) ? 'hourly' : null;
79
+ if (!fixed(hour)) return null;
80
+ if (fixed(dow) && any(dom)) return 'weekly';
81
+ if (fixed(dom) && any(dow)) return 'monthly';
82
+ if (any(dom) && any(dow)) return 'daily';
83
+ return null;
84
+ }
85
+
86
+ // Pure: every cron expression a workflow file declares under `on: schedule:`.
87
+ // A focused subset of YAML (the repo has no js-yaml dep and the parser in
88
+ // run-routine.js is the same shape): the `cron:` keys are unambiguous enough to
89
+ // read directly, and a workflow that hides one behind an anchor would simply
90
+ // read as unscheduled — the safe direction.
91
+ function workflowCrons(yamlText) {
92
+ const out = [];
93
+ for (const line of String(yamlText == null ? '' : yamlText).split('\n')) {
94
+ const m = /^\s*-?\s*cron:\s*['"]?([^'"#\n]+?)['"]?\s*(?:#.*)?$/.exec(line);
95
+ if (m) out.push(m[1].trim());
96
+ }
97
+ return out;
98
+ }
99
+
100
+ // Pure: does `yamlText` actually invoke routine `name`? Either the canonical
101
+ // dispatcher form (`run-routine.js <name>`) or one of the routine's own step
102
+ // commands verbatim. Both are real invocations; a mere mention in prose is not,
103
+ // which is the whole point — the substring predicate this replaces was satisfied
104
+ // by a comment.
105
+ function invokesRoutine(yamlText, name, steps = []) {
106
+ const hay = String(yamlText == null ? '' : yamlText);
107
+ const dispatched = new RegExp(`run-routine\\.js\\s+(?:--\\S+\\s+)*${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w-])`);
108
+ if (dispatched.test(hay)) return true;
109
+ return steps.some((s) => s.command && hay.includes(s.command));
110
+ }
111
+
112
+ // Read every routine that declares a cadence, plus the ones that don't.
113
+ function readRoutines(dir = TASKS_DIR) {
114
+ const withCadence = [];
115
+ const withoutCadence = [];
116
+ let names = [];
117
+ try {
118
+ names = fs.readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
119
+ } catch (_) { return { withCadence, withoutCadence }; }
120
+ for (const name of names.sort()) {
121
+ let md;
122
+ try { md = fs.readFileSync(path.join(dir, name, 'SKILL.md'), 'utf8'); } catch (_) { continue; }
123
+ const { cadence, steps } = parseRoutineFrontmatter(md);
124
+ if (cadence) withCadence.push({ name, cadence, steps });
125
+ else withoutCadence.push({ name });
126
+ }
127
+ return { withCadence, withoutCadence };
128
+ }
129
+
130
+ function readWorkflows(dir = WORKFLOWS_DIR) {
131
+ let files = [];
132
+ try { files = fs.readdirSync(dir).filter((f) => f.endsWith('.yml') || f.endsWith('.yaml')); }
133
+ catch (_) { return null; } // null = no workflows directory at all (instance repo)
134
+ return files.sort().map((f) => ({ file: f, text: fs.readFileSync(path.join(dir, f), 'utf8') }));
135
+ }
136
+
137
+ // Pure evaluation — exported for the test, which drives it with fixtures.
138
+ // `workflows === null` means "this checkout has no CI to schedule with", which
139
+ // downgrades unscheduled routines from violations to warnings (see the two-repo
140
+ // note in the header).
141
+ function evaluate({ withCadence, withoutCadence }, workflows) {
142
+ const violations = [];
143
+ const warnings = [];
144
+ const hasCI = workflows !== null;
145
+ const wf = workflows || [];
146
+
147
+ for (const r of withCadence) {
148
+ if (!CADENCES.has(r.cadence)) {
149
+ violations.push(`routine '${r.name}': unknown cadence "${r.cadence}" (want ${[...CADENCES].join('|')})`);
150
+ continue;
151
+ }
152
+ const firing = wf.filter((w) => invokesRoutine(w.text, r.name, r.steps));
153
+ if (!firing.length) {
154
+ const msg = `routine '${r.name}' declares cadence: ${r.cadence} but NOTHING fires it — no scheduled workflow invokes it`;
155
+ (hasCI ? violations : warnings).push(msg);
156
+ continue;
157
+ }
158
+ // It is invoked somewhere. At least one of those invocations must be on a
159
+ // schedule whose real period is the declared cadence. A workflow_dispatch-only
160
+ // lane is a human running it — exactly the state §7 called "decorative".
161
+ const seen = [];
162
+ let matched = false;
163
+ for (const w of firing) {
164
+ for (const expr of workflowCrons(w.text)) {
165
+ const actual = cronCadence(expr);
166
+ seen.push(`${w.file}: "${expr}" → ${actual || 'unclassifiable'}`);
167
+ if (actual === r.cadence) matched = true;
168
+ }
169
+ }
170
+ if (!matched) {
171
+ violations.push(
172
+ seen.length
173
+ ? `routine '${r.name}' declares cadence: ${r.cadence} but no scheduler fires it at that period — ${seen.join('; ')}`
174
+ : `routine '${r.name}' declares cadence: ${r.cadence} but ${firing.map((w) => w.file).join(', ')} carries no 'on: schedule:' cron (manual dispatch only)`
175
+ );
176
+ }
177
+ }
178
+
179
+ // Rename rot, the other direction: a scheduler that dispatches a routine name
180
+ // no longer on disk. run-routine.js exits 2 on a missing SKILL.md, so this
181
+ // would be a weekly red job nobody watches; catch it at merge time instead.
182
+ // Deduped: a workflow legitimately names its own invocation more than once (the
183
+ // step itself plus the header comment explaining why it dispatches rather than
184
+ // calling the script), and one rename should read as one problem.
185
+ const known = new Set([...withCadence, ...withoutCadence].map((r) => r.name));
186
+ const dangling = new Set();
187
+ for (const w of wf) {
188
+ for (const m of w.text.matchAll(/run-routine\.js\s+(?:--\S+\s+)*([A-Za-z0-9_-]+)/g)) {
189
+ if (!known.has(m[1])) dangling.add(`${w.file} dispatches routine '${m[1]}', which has no .claude/scheduled-tasks/${m[1]}/SKILL.md`);
190
+ }
191
+ }
192
+ violations.push(...dangling);
193
+
194
+ for (const r of withoutCadence) {
195
+ warnings.push(`routine '${r.name}' declares no cadence: — it can only ever be run by hand`);
196
+ }
197
+ return { violations, warnings };
198
+ }
199
+
200
+ // The fitness.js check.
201
+ function checkRoutineCadencesScheduled() {
202
+ const routines = readRoutines();
203
+ const workflows = readWorkflows();
204
+ const { violations, warnings } = evaluate(routines, workflows);
205
+ const scheduled = routines.withCadence.length - violations.filter((v) => v.startsWith("routine '")).length;
206
+ return {
207
+ name: 'a declared cron cadence has something that fires it (task 1003209)',
208
+ ok: violations.length === 0,
209
+ hardFail: violations.length > 0,
210
+ violations,
211
+ warnings,
212
+ note: violations.length
213
+ ? 'a scheduled routine is declared but dormant — add/repair the scheduler, or drop the cadence: field and stop claiming it runs'
214
+ : `${scheduled}/${routines.withCadence.length} cadence-declaring routine(s) fired by a committed scheduler at the declared period; ${routines.withoutCadence.length} manual-only.`,
215
+ };
216
+ }
217
+
218
+ function main() {
219
+ const r = checkRoutineCadencesScheduled();
220
+ for (const v of r.violations) console.error(`✗ ${v}`);
221
+ for (const w of r.warnings) console.warn(`⚠ ${w}`);
222
+ console.log(`${r.hardFail ? 'FAIL' : 'PASS'} ${r.note}`);
223
+ process.exit(r.hardFail ? 1 : 0);
224
+ }
225
+
226
+ if (require.main === module) main();
227
+
228
+ module.exports = {
229
+ checkRoutineCadencesScheduled, evaluate, cronCadence, workflowCrons,
230
+ invokesRoutine, readRoutines, readWorkflows, CADENCES,
231
+ };
@@ -47,12 +47,19 @@ function unquote(s) {
47
47
  return t;
48
48
  }
49
49
 
50
- // Pure: parse mode + steps[] from SKILL.md frontmatter. A focused YAML subset
51
- // (no js-yaml dep): scalar `mode:`/`description:` and a `steps:` list of
52
- // `- command: …` / `sink: …` items. Unknown keys are ignored. Exported + tested.
50
+ // Pure: parse mode + cadence + steps[] from SKILL.md frontmatter. A focused YAML
51
+ // subset (no js-yaml dep): scalar `mode:`/`cadence:`/`description:` and a `steps:`
52
+ // list of `- command: …` / `sink: …` items. Unknown keys are ignored. Exported + tested.
53
+ //
54
+ // `cadence:` (task 1003209) is the field the follow-up audit found missing — the
55
+ // routines declared HOW to run but never HOW OFTEN, so "there was nothing for a
56
+ // scheduler to read even if one looked." It is declarative only: run-routine.js
57
+ // still runs a routine exactly once per invocation. The cadence is the contract a
58
+ // committed scheduler must honour, and scripts/gds/routine-schedule.js is the check
59
+ // that refuses a cadence nothing fires.
53
60
  function parseRoutineFrontmatter(skillMd) {
54
61
  const norm = String(skillMd == null ? '' : skillMd).replace(/\r\n/g, '\n');
55
- const empty = { mode: null, steps: [], description: null };
62
+ const empty = { mode: null, cadence: null, steps: [], description: null };
56
63
  if (!norm.startsWith('---\n')) return empty;
57
64
  const lines = norm.split('\n');
58
65
  let close = -1;
@@ -62,6 +69,7 @@ function parseRoutineFrontmatter(skillMd) {
62
69
  if (close === -1) return empty;
63
70
 
64
71
  let mode = null;
72
+ let cadence = null;
65
73
  let description = null;
66
74
  const steps = [];
67
75
  let inSteps = false;
@@ -96,12 +104,13 @@ function parseRoutineFrontmatter(skillMd) {
96
104
 
97
105
  if (!inSteps && isTopLevel && topKv) {
98
106
  if (topKv[1] === 'mode') mode = unquote(topKv[2]) || null;
107
+ else if (topKv[1] === 'cadence') cadence = unquote(topKv[2]) || null;
99
108
  else if (topKv[1] === 'description') description = topKv[2].trim();
100
109
  else if (topKv[1] === 'steps' && topKv[2].trim() === '') inSteps = true;
101
110
  }
102
111
  }
103
112
  pushCur();
104
- return { mode, steps, description };
113
+ return { mode, cadence, steps, description };
105
114
  }
106
115
 
107
116
  // Pure: split a repo-authored command string into argv, honoring simple quotes.
package/src/module-api.js CHANGED
@@ -1108,8 +1108,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
1108
1108
  // landed since 1.19.337 with no explicit bump. run 32796733697. (task 1002620)
1109
1109
  // 1.19.339 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1110
1110
  // landed since 1.19.338 with no explicit bump. run 32797325535. (task 1002620)
1111
+ // 1.19.340 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1112
+ // landed since 1.19.339 with no explicit bump. run 32798342738. (task 1002620)
1113
+ // 1.19.341 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1114
+ // landed since 1.19.340 with no explicit bump. run 32798507686. (task 1002620)
1111
1115
  // ---------------------------------------------------------------------------
1112
- const CORE_VERSION = '1.19.339'; // CI auto-patch carrier (ADR 0161); see changelog above
1116
+ const CORE_VERSION = '1.19.341'; // CI auto-patch carrier (ADR 0161); see changelog above
1113
1117
 
1114
1118
  // A namespaced logger so a module's log lines are attributable + consistent.
1115
1119
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -293,7 +293,7 @@ test('the new view balances its own tree — the map-only walkers never covered
293
293
  // lives there too; a lopsided div in the wizard block would be caught by
294
294
  // nothing else while silently reparenting the footer.
295
295
  const start = HUB.indexOf('<section id="view-new"');
296
- const end = HUB.indexOf('<div class="foot"');
296
+ const end = HUB.indexOf('<footer class="foot"');
297
297
  assert.ok(start !== -1 && end > start, 'need the view-new span');
298
298
  const wiz = HUB.slice(start, end);
299
299
  assert.equal((wiz.match(/<div\b/g) || []).length, (wiz.match(/<\/div>/g) || []).length,
@@ -69,14 +69,14 @@ test('a queued standup lights "Request filed", not "Standing it up" — on the c
69
69
  const strip = fn('stripFor');
70
70
  assert.match(strip, /if \(s === 'requested'\) \{[\s\S]*?stripMarkup\(revive \? 'revive' : 'standup', 0, 0,/, 'card: requested → stage 0 current');
71
71
  assert.match(strip, /if \(s === 'provisioning'\) \{[\s\S]*?stripMarkup\(revive \? 'revive' : 'standup', 1, 1,/, 'card: provisioning → stage 1 current');
72
- assert.match(SCRIPT, /if \(s === 'requested'\) \{\n\s*el\.innerHTML = progressMarkup\(0, 0,/, 'done panel: requested → stage 0 current');
72
+ assert.match(SCRIPT, /if \(s === 'requested'\) \{\n\s*setProgress\(progressMarkup\(0, 0,/, 'done panel: requested → stage 0 current');
73
73
  assert.doesNotMatch(SCRIPT, /Waiting for a runner/, '"runner" is operator vocabulary');
74
74
  assert.doesNotMatch(SCRIPT, /a runner picks it up/, '"runner" is operator vocabulary (retry message)');
75
75
  });
76
76
 
77
77
  /* ── the done panel never lies at the end of its watch, and never opens a door early ── */
78
78
  test('the 33-minute hand-off keeps a snag honest and keeps its flow', () => {
79
- assert.match(SCRIPT, /if \(ticks > 400\) \{[\s\S]*?if \(live\.instStatus !== 'error'\) \{[\s\S]*?Still working[\s\S]*?watchFlow\);/, 'no "still building" over an error; the strip keeps its vocabulary');
79
+ assert.match(SCRIPT, /if \(ticks > 400\) \{[\s\S]*?if \(live\.instStatus !== 'error'\) \{[\s\S]*?Still working[\s\S]*?watchFlow\)/, 'no "still building" over an error; the strip keeps its vocabulary');
80
80
  assert.doesNotMatch(SCRIPT, /Still building — this tab will stop watching now/);
81
81
  assert.match(SCRIPT, /progressMarkup\(1, 1, m\.slice\(5\), 'isErr', watchFlow\)/, 'the session-expiry message keeps its flow too');
82
82
  });
@@ -190,7 +190,7 @@ test('a live private-repo project is not told it is broken', () => {
190
190
  test('Back moves between views — a deliberate view change is a history entry', () => {
191
191
  assert.match(SCRIPT, /history\.pushState\(null, '', '\?view=' \+ v \+ idPart\)/);
192
192
  assert.match(SCRIPT, /history\.replaceState\(null, '', '\?view=' \+ v \+ idPart\)/, 'boot + background settles still rewrite in place');
193
- assert.match(SCRIPT, /window\.addEventListener\('popstate', function \(\) \{ setView\(viewFromUrl\(\)\); \}\)/);
193
+ assert.match(SCRIPT, /window\.addEventListener\('popstate', function \(\) \{ closeCard\(\); setView\(viewFromUrl\(\)\); \}\)/);
194
194
  assert.match(SCRIPT, /else if \(v === 'manage' && a\.dataset\.nav === 'mine'\) a\.setAttribute\('aria-current', 'page'\)/, 'manage keeps the Your-projects pill lit');
195
195
  });
196
196
 
@@ -300,7 +300,7 @@ test('decision 9 — only mouse-driven focus on a wizard heading goes ringless',
300
300
 
301
301
  test('decision 10 — both footers carry the open-source (AGPL-3.0) link, in link ink', () => {
302
302
  const AGPL = /<a href="https:\/\/www\.gnu\.org\/licenses\/agpl-3\.0\.html" target="_blank" rel="noopener">Open source \(AGPL-3\.0\)<\/a>\./;
303
- const hubFoot = (HUB.match(/<div class="foot">[\s\S]*?<\/div>/) || [''])[0];
303
+ const hubFoot = (HUB.match(/<footer class="foot">[\s\S]*?<\/footer>/) || [''])[0];
304
304
  const indexFoot = (INDEX.match(/<div class="foot">[\s\S]*?<\/div>/) || [''])[0];
305
305
  assert.match(hubFoot, AGPL, 'projects.html');
306
306
  assert.match(indexFoot, AGPL, 'index.html');
@@ -362,3 +362,84 @@ test('card action results announce, nothing under the pointer is deleted, and fo
362
362
  assert.match(fn('focusKeyOf'), /data-teardown-yes'\) \|\| el\.hasAttribute\('data-teardown-no'\)\) return '\[data-teardown\]'/, 'Yes / Keep land back on the door that opened them');
363
363
  assert.match(fn('focusCardControl'), /li\.querySelector\('\.actions a, \.actions button'\) \|\|\n\s*li\.querySelector\('details\.attachFold summary'\)/, 'nearest control as the fallback');
364
364
  });
365
+
366
+ /* ── accessibility follow-ups (task 1003246) ── */
367
+ test('the done panel’s progress region is stable, change-only, and says each stage’s state', () => {
368
+ assert.match(HUB, /<div id="liveProgress" role="status" aria-live="polite"><\/div>/, 'the live region is the static wrapper');
369
+ assert.doesNotMatch(fn('progressMarkup'), /aria-live/, 'the rebuilt strip carries no live-region attributes');
370
+ assert.match(fn('progressMarkup'), /\(c === 'now' \? ' aria-current="step"' : ''\)/, 'the current stage is marked');
371
+ assert.match(fn('progressMarkup'), /<span class="vh">, ' \+ word \+ '<\/span>/, 'each stage says its state to a screen reader');
372
+ assert.match(fn('setProgress'), /if \(!el \|\| html === lastProgHtml\) return;/, 'an identical tick performs no DOM write');
373
+ assert.doesNotMatch(SCRIPT, /\$\('liveProgress'\)\.innerHTML =/, 'every write goes through setProgress');
374
+ assert.doesNotMatch(fn('renderProgress'), /el\.innerHTML =/, 'renderProgress included');
375
+ assert.match(SCRIPT, /lastStepsHtml = '';\n\s*lastProgHtml = '';/, 'starting another project resets both guards');
376
+ assert.match(COSMOS, /\.vh\{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset\(50%\);white-space:nowrap;\}/, 'the visually-hidden utility is in cosmos');
377
+ assert.match(fn('stripMarkup'), /role="status" aria-live="polite"/, 'the mine-card strip is left alone this pass');
378
+ });
379
+
380
+ test('the map table sorts from the keyboard and an owned row’s name is a real link', () => {
381
+ assert.match(HUB, /<th class="colName" data-sort="name" aria-sort="ascending"><button type="button" class="thSort">Project<\/button><\/th>/);
382
+ assert.match(HUB, /<th class="right" data-sort="builders"><button type="button" class="thSort">Builders<\/button><\/th>/);
383
+ assert.match(SCRIPT, /var btn = th\.querySelector\('button'\);\n\s*if \(!btn\) return;\n\s*btn\.addEventListener\('click'/, 'the listener sits on the button, not the th');
384
+ assert.doesNotMatch(SCRIPT, /th\.style\.cursor/, 'no JS-set cursor on the th');
385
+ assert.match(COSMOS, /\.tbl th button\{all:unset;cursor:pointer;\}/);
386
+ assert.match(COSMOS, /\.tbl th button:focus-visible\{outline:2px solid var\(--accent\);outline-offset:2px;\}/, 'keyboard focus on the sort button is visible');
387
+ const generated = (SCRIPT.match(/return '<tr data-generated([\s\S]*?)'<\/tr>';/) || [, ''])[1];
388
+ assert.match(generated, /<a class="ptitle" data-manage="' \+ esc\(String\(p\._inst\.id\)\) \+ '" href="\?view=manage&id=' \+ esc\(String\(p\._inst\.id\)\) \+ '">/, 'an owned row’s title is a manage anchor');
389
+ const rows = SCRIPT.slice(SCRIPT.indexOf("$('mapRows').addEventListener('click'"));
390
+ const branch = rows.indexOf("closest('a[data-manage]')");
391
+ const bail = rows.indexOf("if (ev.target.closest && ev.target.closest('a')) return;");
392
+ assert.ok(branch > 0 && bail > branch, 'the manage branch runs before the anchor bail-out');
393
+ assert.match(HUB, /Sort by Project or Builders\./, 'the hint names the two sortable columns');
394
+ assert.match(COSMOS, /\.tbl \.ptitle\{[^}]*text-decoration:none;\}/, 'the link wears the title’s ink, not the UA underline');
395
+ });
396
+
397
+ test('the hub has one main landmark, a heading for the map, a footer, and a skip link', () => {
398
+ const main = HUB.indexOf('<main id="hubMain">');
399
+ const map = HUB.indexOf('<section id="view-map">');
400
+ const close = HUB.indexOf('\n</main>');
401
+ const foot = HUB.indexOf('<footer class="foot">');
402
+ assert.ok(main > 0 && main < map && close > HUB.indexOf('<section id="view-new"') && foot > close, 'the four views sit inside #hubMain, the footer after it');
403
+ assert.equal((HUB.match(/<main\b/g) || []).length, 1, 'exactly one main');
404
+ assert.match(HUB, /<section id="view-map">\n\s*<h1 class="vh">The project map<\/h1>/, 'the map view has a heading');
405
+ assert.match(HUB, /<a class="skip vh" href="#hubMain">Skip to content<\/a>\n<header class="shell">/, 'the skip link is the first thing before the shell');
406
+ assert.match(HUB, /\.skip:focus\{position:fixed;top:8px;left:8px;/, 'it becomes a real pill on focus');
407
+ assert.doesNotMatch(HUB, /<main class="card">/, 'the old inner main is a div');
408
+ });
409
+
410
+ test('completed rail steps are a way back, the repo picker is honest ARIA, the card inerts the page', () => {
411
+ assert.match(fn('paintRail'), /if \(step < 6 && n < cur\) \{\n\s*li\.setAttribute\('role', 'button'\);\n\s*li\.setAttribute\('tabindex', '0'\);\n\s*li\.setAttribute\('data-goto', String\(n\)\);/, 'only answered steps become buttons, and none once the project is filed');
412
+ assert.match(SCRIPT, /railEl\.addEventListener\('click'/);
413
+ assert.match(SCRIPT, /railEl\.addEventListener\('keydown'/);
414
+ assert.match(SCRIPT, /show\(Number\(li\.getAttribute\('data-goto'\)\), true\)/, 'a deliberate step: focus moves');
415
+ assert.match(COSMOS, /\.steps li\[role=button\]\{cursor:pointer;\}/);
416
+ assert.match(COSMOS, /\.steps li\[role=button\]:focus-visible\{outline:2px solid var\(--accent\);/);
417
+ assert.doesNotMatch(HUB, /role="listbox"/);
418
+ assert.doesNotMatch(SCRIPT, /role="option"/);
419
+ assert.match(HUB, /<div id="ghList" class="ghlist" role="group" aria-label="Your repositories"><\/div>/, 'the label survives the listbox role');
420
+ assert.match(SCRIPT, /class="ghrow" data-full="' \+ esc\(r\.full_name\) \+ '" aria-current="' \+ \(r\.full_name === sel\) \+ '"/, 'the chosen row is aria-current');
421
+ assert.match(HUB, /\.ghrow\[aria-current=true\]\{/, 'and painted through it');
422
+ assert.doesNotMatch(HUB, /aria-selected/, 'aria-selected went with the option role');
423
+ assert.match(SCRIPT, /if \(ev\.key !== 'Escape' \|\| \$\('ghList'\)\.hidden\) return;\n\s*ev\.stopPropagation\(\);\n\s*\$\('ghSearch'\)\.focus\(\);\n\s*closeRepoList\(\);/, 'Escape closes the list after handing focus back');
424
+ assert.match(SCRIPT, /if \(ev\.relatedTarget && !\$\('ghAdopt'\)\.contains\(ev\.relatedTarget\)\) closeRepoList\(\);/, 'focus leaving the picker closes it; a null relatedTarget is left alone');
425
+ assert.match(fn('openCard'), /\$\('cardScrim'\)\.hidden = false;\n\s*pageInert\(true\);\n\s*\$\('cardClose'\)\.focus\(\);/);
426
+ assert.match(fn('closeCard'), /pageInert\(false\);[^\n]*\n\s*if \(cardReturn && cardReturn\.focus\) cardReturn\.focus\(\);/, 'inert is lifted before focus is handed back');
427
+ assert.match(fn('pageInert'), /'a\.skip, header\.shell, footer\.foot, #view-mine, #view-new, #view-manage, #view-map > :not\(#cardScrim\)'/, 'the map is inerted a child at a time — the card is its child; the skip link sits outside the shell and goes too');
428
+ assert.match(SCRIPT, /'popstate', function \(\) \{ closeCard\(\); setView\(viewFromUrl\(\)\); \}/, 'Back closes the card, so the page is never left inert');
429
+ assert.equal((HUB.match(/aria-controls="projectsList"/g) || []).length, 5, 'the five state filters name the list they filter');
430
+ });
431
+
432
+ test('control nits: orb cursor, caret under reduced motion, mode label, home label, held reduce-motion query, target sizes', () => {
433
+ assert.doesNotMatch(COSMOS, /\.orb\{flex:0 0 54px;[^}]*cursor:pointer/, 'the base orb is not a pointer');
434
+ assert.match(COSMOS, /\na\.orb\{cursor:pointer;\}/, 'only the linked orb is');
435
+ assert.doesNotMatch(COSMOS, /\.orb\.ghost\{[^}]*cursor:default/);
436
+ assert.match(HUB, /@media \(prefers-reduced-motion:reduce\)\{\.shell \.caret\{display:none;\}\}/, 'the caret hides instead of freezing solid');
437
+ assert.match(fn('paintModeToggle'), /el\.setAttribute\('aria-label', dark \? 'Switch to light mode' : 'Switch to dark mode'\);/);
438
+ assert.doesNotMatch(fn('paintModeToggle'), /aria-pressed/);
439
+ assert.match(SCRIPT, /\$\('markLeft'\)\.parentNode\.setAttribute\('aria-label', productName \+ ' — home'\);\n\s*if \(REDUCED \|\|/, 'the home link is labelled before the typing loop’s early return');
440
+ assert.match(SCRIPT, /var REDUCED_MQ = window\.matchMedia \? window\.matchMedia\('\(prefers-reduced-motion: reduce\)'\) : null;/);
441
+ assert.match(SCRIPT, /if \(REDUCED\) stopSky\(\); else startSky\(\);/, 'a mid-session flip stops or restarts the sky');
442
+ assert.match(SCRIPT, /REDUCED_MQ\.addEventListener\('change', onReducedChange\)/);
443
+ assert.match(HUB, /\.again\{[^}]*padding:6px 2px;min-height:24px;/);
444
+ assert.match(HUB, /\nsummary\{min-height:24px;padding:3px 0;\}/, 'every fold is a 24px target; the disclosure marker stays');
445
+ });
@@ -65,3 +65,66 @@ test('buildClassReport caps the item list, counts the remainder, and takes max s
65
65
  assert.match(r.repro_steps_md, /…and 3 more/);
66
66
  assert.match(r.repro_steps_md, /Notes:/);
67
67
  });
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Unattended-run behaviour (task 1003209): the patrol now runs on CI, which has
71
+ // no GDS session. These pin the two things that turns on — an exit code that
72
+ // means "a finding was LOST" rather than "a finding exists", and a job-summary
73
+ // surface so a scheduled run is readable without opening the log.
74
+ // ---------------------------------------------------------------------------
75
+ import fs from 'node:fs';
76
+ import os from 'node:os';
77
+ import { spawnSync } from 'node:child_process';
78
+
79
+ test('disposition: red is reserved for findings that could not be recorded', () => {
80
+ // A clean sweep is green even if filing was impossible — nothing was lost.
81
+ assert.deepEqual(p.disposition({ findingClasses: 0, doFile: true, blocker: 'no session' }),
82
+ { action: 'clean', exitCode: 0 });
83
+ // A dry run prints; it never claims to have filed, so it never fails.
84
+ assert.deepEqual(p.disposition({ findingClasses: 3, doFile: false, blocker: null }),
85
+ { action: 'dry-run', exitCode: 0 });
86
+ // Findings filed for adjudication = the patrol working as designed (ADR 0162).
87
+ // If this ever became a failure, a weekly red would train everyone to ignore it.
88
+ assert.deepEqual(p.disposition({ findingClasses: 3, doFile: true, blocker: null }),
89
+ { action: 'file', exitCode: 0 });
90
+ // The ONE red state: findings exist, filing was asked for, and it cannot happen.
91
+ assert.deepEqual(p.disposition({ findingClasses: 3, doFile: true, blocker: 'no GDS session on this runner' }),
92
+ { action: 'not-filed', exitCode: 2 });
93
+ assert.deepEqual(p.disposition(), { action: 'clean', exitCode: 0 });
94
+ });
95
+
96
+ test('stepSummary appends markdown for the Actions UI, and is inert off-CI', () => {
97
+ const f = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'patrol-')), 'summary.md');
98
+ const prev = process.env.GITHUB_STEP_SUMMARY;
99
+ try {
100
+ process.env.GITHUB_STEP_SUMMARY = f;
101
+ p.stepSummary('## one');
102
+ p.stepSummary('## two');
103
+ assert.equal(fs.readFileSync(f, 'utf8'), '## one\n## two\n');
104
+ delete process.env.GITHUB_STEP_SUMMARY;
105
+ p.stepSummary('## three'); // must not throw, must not write anywhere
106
+ assert.equal(fs.readFileSync(f, 'utf8'), '## one\n## two\n');
107
+ } finally {
108
+ if (prev === undefined) delete process.env.GITHUB_STEP_SUMMARY;
109
+ else process.env.GITHUB_STEP_SUMMARY = prev;
110
+ }
111
+ });
112
+
113
+ test('filingBlocker reports a sessionless runner instead of exiting with re-auth guidance', () => {
114
+ // The regression guarded here: cli-lib's apiCall answers 401 by PRINTING
115
+ // "run /builder-reauth" and calling process.exit(2). In a cron log that is
116
+ // indistinguishable from a crash, and it kills the run before the findings are
117
+ // ever printed. A subprocess with an empty HOME is the only honest way to
118
+ // simulate a runner with no session file — the suite's own machine has one.
119
+ const home = fs.mkdtempSync(path.join(os.tmpdir(), 'patrol-home-'));
120
+ const r = spawnSync(process.execPath, ['-e',
121
+ `const p = require(${JSON.stringify(path.join(ROOT, 'scripts', 'gds', 'redteam-patrol.js'))});
122
+ console.log(JSON.stringify({ blocker: p.filingBlocker() }));`,
123
+ ], { env: { ...process.env, HOME: home, USERPROFILE: home }, encoding: 'utf8' });
124
+ assert.equal(r.status, 0, `expected a clean return, not an exit: ${r.stderr}`);
125
+ const out = JSON.parse(r.stdout.trim().split('\n').pop());
126
+ assert.equal(typeof out.blocker, 'string');
127
+ assert.match(out.blocker, /no GDS session|session unreadable/);
128
+ assert.doesNotMatch(r.stdout + r.stderr, /builder-reauth/,
129
+ 'the sessionless path must not print interactive re-auth guidance into a cron log');
130
+ });