@bongos/core 1.19.340 → 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.
package/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.340",
6
- "core_contract": "1.19.340",
7
- "source_commit": "729dc8ef4deb583cef9971c4f3a6af6ed260bb33",
5
+ "core_version": "1.19.341",
6
+ "core_contract": "1.19.341",
7
+ "source_commit": "395ad8796967381d04b9f5ad9247da0cbf60d419",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-25T01:39:02.466Z",
9
+ "built_at": "2026-08-25T01:41:39.768Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 325,
12
+ "docs_redacted": 326,
13
13
  "agent_docs_stubbed": 22,
14
- "functional_verbatim": 1483,
14
+ "functional_verbatim": 1485,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1830,
20
- "tree_sha256": "5236060970e82dd343f21af8e59eeae712bcc806ea439937aeeb72054b081eef",
19
+ "file_count": 1833,
20
+ "tree_sha256": "29b405f9216626b6f363e10be67e90f2bde67fdd472151fc4eaf0a6c18384acd",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -1419,10 +1419,15 @@
1419
1419
  "mode": "0000644",
1420
1420
  "sha256": "d59906a447f2459efdef6c3eb97cf8629813ec74776ead700017f02ef76aed9e"
1421
1421
  },
1422
+ {
1423
+ "path": "docs/adr/0188-patrol-runs-on-ci-not-the-server.md",
1424
+ "mode": "0000644",
1425
+ "sha256": "4feacf7bc809cdff1a23a1ef5b57d5d28aa448b80bd1ad24fc70860c0fe6739f"
1426
+ },
1422
1427
  {
1423
1428
  "path": "docs/adr/README.md",
1424
1429
  "mode": "0000644",
1425
- "sha256": "87606f8e07f470f0ff55d7ac733de9d1004d3d15eddf1003a8958566b4147077"
1430
+ "sha256": "4c736c015e635ed0b4e78445f35acbd8b84a14708634e5caf4a957357b1e22b9"
1426
1431
  },
1427
1432
  {
1428
1433
  "path": "docs/api-reference.md",
@@ -1572,7 +1577,7 @@
1572
1577
  {
1573
1578
  "path": "docs/file-map.md",
1574
1579
  "mode": "0000644",
1575
- "sha256": "ffd052f893ea0ef47a65e66a6d260fd43c1ccc653fa277f11f13805099e73d45"
1580
+ "sha256": "bbe32c152ba31a68953f318f9a94e010252e70a6e076d64d7dca820f5c19777b"
1576
1581
  },
1577
1582
  {
1578
1583
  "path": "docs/handoff-template.md",
@@ -1772,7 +1777,7 @@
1772
1777
  {
1773
1778
  "path": "docs/recipes/redteam-patrol.md",
1774
1779
  "mode": "0000644",
1775
- "sha256": "3dbdc33eb1aa68da5e6569e836845f09fd14953d94794e005af67ce517fb6dad"
1780
+ "sha256": "656f30f568a56111ce6c2db8f3d61b0f9df3b168f41388b953a4552144579c94"
1776
1781
  },
1777
1782
  {
1778
1783
  "path": "docs/recipes/search-index-ingestion.md",
@@ -4727,12 +4732,12 @@
4727
4732
  {
4728
4733
  "path": "package-lock.json",
4729
4734
  "mode": "0000644",
4730
- "sha256": "d536e9a3a768714780abe658d3dd1e933deb5b1780f4ebc0e5b89c734ac5d0b9"
4735
+ "sha256": "7c3952267fd38da7241791d8f5c4e7a2d8226d4cf8d30473fd6fe56ca000a4af"
4731
4736
  },
4732
4737
  {
4733
4738
  "path": "package.json",
4734
4739
  "mode": "0000644",
4735
- "sha256": "c7f3a1d37b5cf6e9b0da46656d61cd8e9808f2ade90d8402a2f896ccde4f4c01"
4740
+ "sha256": "d2ed11ab8e90eb8293778ce19fe921737158c0ab9d8063a9842383b1cfc59e94"
4736
4741
  },
4737
4742
  {
4738
4743
  "path": "public-docs/index.html",
@@ -5262,7 +5267,7 @@
5262
5267
  {
5263
5268
  "path": "scripts/gds/control-manifest.js",
5264
5269
  "mode": "0000644",
5265
- "sha256": "189caa1e5c263beb5d2c251d924abfe6ea84b7f8b26409a93633c8466bd5801c"
5270
+ "sha256": "6c9538a110e45a184531e3ddd7725a8307da823411ef71f0d0b575dd3ee9571b"
5266
5271
  },
5267
5272
  {
5268
5273
  "path": "scripts/gds/copy-inventory.js",
@@ -5407,7 +5412,7 @@
5407
5412
  {
5408
5413
  "path": "scripts/gds/fitness.js",
5409
5414
  "mode": "0000644",
5410
- "sha256": "0181ba9d5595199777a84b1bc8363d5cda55df68c1eeecb647a18c4fbb6697df"
5415
+ "sha256": "925006ed968209632b20677d05594158ebc27e65229d96a7d69a53baf93f6ed0"
5411
5416
  },
5412
5417
  {
5413
5418
  "path": "scripts/gds/gate-review.js",
@@ -5772,7 +5777,7 @@
5772
5777
  {
5773
5778
  "path": "scripts/gds/redteam-patrol.js",
5774
5779
  "mode": "0000644",
5775
- "sha256": "4e4ce11a67de653550851d9e0b9931253c8b100dd4285f2a39c74ba2f932b23c"
5780
+ "sha256": "7460ee330a6bc53176a6ab83cb1019d50fe7d872d8a5bf31486e87edc86059af"
5776
5781
  },
5777
5782
  {
5778
5783
  "path": "scripts/gds/regen-instance-docs.js",
@@ -5799,10 +5804,15 @@
5799
5804
  "mode": "0000644",
5800
5805
  "sha256": "084fa5c4da1e755fa0edde5ec770e5151a9f66a247bca222bc7a2366ccf61748"
5801
5806
  },
5807
+ {
5808
+ "path": "scripts/gds/routine-schedule.js",
5809
+ "mode": "0000644",
5810
+ "sha256": "0d75c6b9be5a67738c91e55cda9084e10d7c64aa49c80ce9c0fa4219afcbd14c"
5811
+ },
5802
5812
  {
5803
5813
  "path": "scripts/gds/run-routine.js",
5804
5814
  "mode": "0000644",
5805
- "sha256": "275098d7069661885fcfc6470d690e4fd18134c475e3d03cb4361a7183f9de08"
5815
+ "sha256": "8b7765f52f4d6e6559a47bbc911bf5edc4acea81a3765b8811be9fd07be8dfb7"
5806
5816
  },
5807
5817
  {
5808
5818
  "path": "scripts/gds/run-unit-tests.js",
@@ -6557,7 +6567,7 @@
6557
6567
  {
6558
6568
  "path": "src/module-api.js",
6559
6569
  "mode": "0000644",
6560
- "sha256": "6a783af54daaa7252a170d835f921a6de968f4f6b7343a459696ddc70888bd58"
6570
+ "sha256": "96c4cf1842a773983825fb0a8639b3e73eb0a3e7646a38781962530f072f6457"
6561
6571
  },
6562
6572
  {
6563
6573
  "path": "src/module-loader/catalog.js",
@@ -8567,7 +8577,7 @@
8567
8577
  {
8568
8578
  "path": "tests/redteam_patrol.mjs",
8569
8579
  "mode": "0000644",
8570
- "sha256": "bf32ae5c734fc95873433b7c4e251a6b166c167ccc91c4684d061b97681c3357"
8580
+ "sha256": "2dbaca4570cdac06c9ebdf2447b9955edeb1afdd763f24d98faec2103001a9e9"
8571
8581
  },
8572
8582
  {
8573
8583
  "path": "tests/regrade_eligibility.mjs",
@@ -8619,6 +8629,11 @@
8619
8629
  "mode": "0000644",
8620
8630
  "sha256": "b56a6945713e738542d612f6d43dd9bd00ce1a370b3d7b3e130f4e59a056ee15"
8621
8631
  },
8632
+ {
8633
+ "path": "tests/routine_schedule.mjs",
8634
+ "mode": "0000644",
8635
+ "sha256": "ba7f5690b9a3649a4c4cd2571d316aa837ffc01874d57aaa418dc72683c6a1f1"
8636
+ },
8622
8637
  {
8623
8638
  "path": "tests/routine_timers.mjs",
8624
8639
  "mode": "0000644",
@@ -8632,7 +8647,7 @@
8632
8647
  {
8633
8648
  "path": "tests/run_routine.mjs",
8634
8649
  "mode": "0000644",
8635
- "sha256": "b27259a2ffa07f800d5e74aa6d35b623ecd7de2118f63fe4274e105bbbe81daf"
8650
+ "sha256": "a33afdf5b7cbe592f77d5162ad8129f449efb0872ed2b484725c6d1bf0ba27c7"
8636
8651
  },
8637
8652
  {
8638
8653
  "path": "tests/run_routine_two_root.mjs",
@@ -0,0 +1,162 @@
1
+ # 0188 — The weekly patrol runs on CI, and a declared cadence must have something that fires it
2
+
3
+ - **Status:** Accepted
4
+ - **Date:** 2026-08-24
5
+ - **Tasks:** [#1003209](https://cloudbongos.com/builders#/task/1003209) (goal 1000079 — pay down the frozen debt and make enforcement durable; criterion C4 `reduction-durable`)
6
+ - **Builds on:** [ADR 0078](<redacted>.md) (deterministic-first routines + the `run-routine.js` dispatcher), [ADR 0180](<redacted>.md) §3 (the patrol's dependency checks), [ADR 0022](0022-secrets-policy.md) (the prevent → gate → patrol layering)
7
+ - **Related:** [ADR 0162](<redacted>.md) (patrol findings are advisory — verdicts never self-execute), [ADR 0093](<redacted>.md) §1 (where `routine-timers.js` landed — the in-process poller seam this deliberately does *not* use)
8
+
9
+ ## 1. Context
10
+
11
+ Goal 1000076 shipped a weekly red-team patrol: `scripts/gds/redteam-patrol.js`
12
+ (full-git-history secret scan, dependency-drift review, workflow action-pin
13
+ check), the `.claude/scheduled-tasks/redteam-weekly` routine that describes how
14
+ to run it, a recipe, and a row in `control-manifest.js` asserting the control
15
+ still exists.
16
+
17
+ Nothing ever ran it. The follow-up audit
18
+ ([`docs/audits/ai-dev-redteam-followup.md`](../audits/ai-dev-redteam-followup.md) §7)
19
+ put the failure in three parts:
20
+
21
+ 1. **No committed scheduler** for `.claude/scheduled-tasks/` at all. ADR 0078
22
+ built the external dispatcher an operator was meant to wire into cron; the
23
+ wiring never happened, which `routine-timers.js`'s own header already admits
24
+ in so many words — *"nothing was ever durably scheduled."*
25
+ 2. **No cadence to read.** The SKILL.md declared `mode:` and `steps:` — *how* to
26
+ run — but never *how often*. There was nothing for a scheduler to honour even
27
+ if one had looked.
28
+ 3. **A manifest that was green anyway.** `control-manifest.js` proved the patrol
29
+ "wired" by finding the substring `redteam-patrol.js` inside the SKILL.md —
30
+ i.e. the routine mentioning its own scanner. The control read as connected
31
+ for months while it ran zero times.
32
+
33
+ This is the same shape the ADR 0022 audit found in the first place: a control
34
+ that can quietly stop existing (or, here, quietly never start) is not a control.
35
+ It is also the fifth instance in this goal of a more specific pattern — **an
36
+ enforcement surface that names a file goes VACUOUS, not red, when the thing it
37
+ names moves or stops.**
38
+
39
+ ## 2. Decision
40
+
41
+ ### 2.1 The runner is CI, not the always-on server
42
+
43
+ The obvious symmetry was to add the patrol beside `peer-vote-tally` in
44
+ `modules/autonomy/pollers/routine-timers.js` — the in-process timer built (as an
45
+ ADR 0078 amendment) for exactly the "deterministic routine nobody scheduled"
46
+ problem, whose header even reasons through why an in-process timer beat external
47
+ cron *there*.
48
+
49
+ **That reasoning does not transfer, and applying it here would have produced a
50
+ patrol that reports clean while scanning nothing.** All three checks read things
51
+ only the *repository* has:
52
+
53
+ | check | needs |
54
+ |---|---|
55
+ | history-secrets | the full git history |
56
+ | action-pins | `.github/workflows/` |
57
+ | dependency-review | a `package.json` + `package-lock.json` npm can audit |
58
+
59
+ A deployed instance runs a **vendored** core out of `node_modules/@bongos/core`.
60
+ That directory is not a git repo, carries no workflows, and ships no lockfile.
61
+ The timer would have found no history to scan, no workflows to check, and
62
+ nothing to audit — and every one of those degrades to *"nothing found"*. Worse
63
+ than unscheduled: confidently, weekly, wrong.
64
+
65
+ So the scheduler is `.github/workflows/redteam-patrol.yml` — a weekly GitHub
66
+ Actions lane with `fetch-depth: 0` and a pinned, checksum-verified gitleaks
67
+ binary (the missing dependency the audit listed as its own gap). It invokes
68
+ `run-routine.js redteam-weekly` rather than the patrol script directly, so the
69
+ SKILL.md's `steps:` remain the single source of truth for what a run does.
70
+
71
+ ### 2.2 `cadence:` is a real frontmatter field
72
+
73
+ `parseRoutineFrontmatter` now reads `cadence: hourly|daily|weekly|monthly`. It
74
+ is **declarative only** — `run-routine.js` still runs a routine exactly once per
75
+ invocation. The cadence is the contract a committed scheduler must honour.
76
+
77
+ ### 2.3 A cadence nothing fires is a red build
78
+
79
+ `scripts/gds/routine-schedule.js` (fitness Check 25) is the part that keeps
80
+ §7 from recurring. Its predicate is **relational, not existential**: it reads
81
+ the cadence each routine declares, reads the cron each scheduler carries,
82
+ classifies that cron into the period it actually realizes, and fails when the
83
+ two disagree in either direction —
84
+
85
+ - a routine declares a cadence and no scheduler fires it → red
86
+ - a scheduler dispatches a routine that no longer exists → red (rename rot)
87
+ - the cron's real period differs from the declared cadence → red
88
+
89
+ The third is the one that matters most, because **no existence or substring
90
+ predicate can ever see it.** Edit the cron from weekly to monthly and the file
91
+ still exists, the workflow still names the routine, and every "is it wired?"
92
+ check in the repo stays green.
93
+
94
+ The manifest row was re-pointed to match: "wired" for a scheduled control now
95
+ means the *scheduler* names the routine, not that the routine names itself.
96
+
97
+ ### 2.4 Red means "a finding was lost", not "a finding exists"
98
+
99
+ A CI runner has no GDS session, so `--file` would have hit `cli-lib`'s
100
+ interactive 401 path — printing *"run /builder-reauth"* and calling
101
+ `process.exit(2)` into a cron log, indistinguishable from a crash, before the
102
+ findings were ever shown. `--file` now preflights the session and follows the
103
+ `main-audit.js --require-roster` doctrine: report loudly rather than
104
+ green-and-vacuous.
105
+
106
+ The exit code is a small table (`disposition()`), and the choice inside it is
107
+ deliberate. Findings are **advisory** (ADR 0162) — they are filed for
108
+ adjudication, not acted on — so a run that finds three things and files them is
109
+ a **success**:
110
+
111
+ | state | exit |
112
+ |---|---|
113
+ | ran, nothing to file | 0 |
114
+ | ran, dry run | 0 |
115
+ | ran, findings **filed** | 0 |
116
+ | ran, findings it **cannot** file | **2** |
117
+
118
+ Red is reserved for the single state where evidence evaporates. The alternative
119
+ — failing whenever findings exist — would have gone red on the very first run
120
+ and stayed red: the staging credential in commit `092e473` is still in history,
121
+ unrotated, so the history scan has a standing hit. A weekly job that is always
122
+ red is a weekly job nobody reads, which is the failure this whole goal is about.
123
+
124
+ Filing itself is optional and credential-gated: with no `GDS_PATROL_SESSION`
125
+ secret the patrol still runs every week and writes findings to the job summary.
126
+ That default matters because this same goal's C1 is about *removing* long-lived
127
+ tokens — the patrol must not require a new one to be useful.
128
+
129
+ ## 3. Consequences
130
+
131
+ - The patrol runs unattended for the first time. The first scheduled runs are
132
+ expected to file a `history-secrets` report until commit `092e473` is scrubbed.
133
+ - Adding a routine to `.claude/scheduled-tasks/` now forces a choice: declare a
134
+ cadence and schedule it, or declare none and accept a warning that it is
135
+ manual-only. Silence is no longer an option.
136
+ - The check is tolerant of the two-repo split: `.claude/scheduled-tasks/` is
137
+ materialized into every instance, `.github/workflows/` is the core repo's and
138
+ does not travel — so in a checkout with no workflows directory, unscheduled
139
+ routines are warnings, not violations.
140
+ - `cronCadence()` refuses step values, lists and ranges rather than guessing.
141
+ An exotic schedule cannot be described by a cadence word and must be reworked
142
+ or left manual — the safe direction, since a clever cron silently counting as
143
+ `weekly` would reopen the drift this exists to catch.
144
+
145
+ ## 4. Alternatives rejected
146
+
147
+ - **An in-process timer in `routine-timers.js`.** §2.1 — the server cannot see
148
+ the evidence. Rejected on correctness, not cost.
149
+ - **Teaching `routine-timers.js` to discover `.claude/scheduled-tasks/` and
150
+ honour `cadence:` generically.** The task offered this first and it is the more
151
+ general mechanism, but its only candidate user cannot run there, so it would
152
+ have been a scheduler built for nobody. The `cadence:` field is deliberately
153
+ *scheduler-agnostic*, so an instance that later wants an in-process lane for a
154
+ server-side routine can read the same declaration.
155
+ - **Failing the job whenever findings exist.** Permanent red, then ignored. §2.4.
156
+ - **Requiring a GDS session secret.** Would have made the whole lane inert until
157
+ a credential was provisioned, and adds a long-lived token to a goal whose C1 is
158
+ about eliminating one.
159
+ - **A deeper `control-manifest.js` predicate instead of a new check.** The
160
+ manifest's shallowness is deliberate and documented (existence + one wiring
161
+ substring); the cadence↔cron agreement is relational and does not fit that
162
+ shape. Re-pointing the row *and* adding the check keeps each honest.
@@ -261,5 +261,6 @@ This keeps the decision history honest and traceable.
261
261
  | 0185 | [**The spark-handoff credit split: 70% developer, 30% spark author** ([task 1002923](https://cloudbongos.com/builders#/task/1002923), BV1.R147, goal 1000062 — completing the chain [task 1002920](https://cloudbongos.com/builders#/task/1002920) mark → [task 1002921](https://cloudbongos.com/builders#/task/1002921) queue → [task 1002922](https://cloudbongos.com/builders#/task/1002922) pickup). [ADR 0172](<redacted>.md) built the ideator streams and paid `captured_by`, the only person an idea then had. The spark handoff created a second: R146 develops a spark IN PLACE, preserving the author while recording a `developed_by`. **R146 therefore shipped paying NOTHING on purpose** — not an oversight but a refusal to guess, because `awardIdeaFullPassCredit`'s idempotency predicate is `(builder_id, reason)`, so paying the DEVELOPER would not have blocked a later payment to the AUTHOR and one idea would have paid twice; the cost of that caution was that a developed Full Idea received stream 2 **never** unless its author resubmitted. Owner decision (2026-08-22): **70/30 favouring the developer**, on BOTH existing streams, one ratio so there is one rule to explain. The reasoning is behavioural, not sentimental — developing someone else's spark is the scarce act the queue depends on, and at an even split it pays HALF what authoring your own Full Idea pays for the same writing work, so the rational move is always to write your own and the R145 queue stays a list nobody reads. The author keeps 30% plus their share of stream 1 on every task shipped under the idea, forever. `IDEA_DEVELOPER_SHARE = 0.70` is a **policy constant in `credits.js`** beside the pass bar and quick factor (ADR 0172's own consequence, the ADR 0023 precedent): a share of someone's earnings deserves a review and a commit, not a runtime edit. **The structural half is what retires R146's caution:** the economy module now derives the payees itself (`ideaCreditParties` reads `captured_by` + `developed_by`), so the caller can no longer choose a recipient — the develop route hands over an idea and a score, never a payee, and books the split like any other path (fail-open; the development has already committed). Three arithmetic properties, each a real bug if missed: the total is **conserved** (developer rounded, author gets the REMAINDER — rounding both pays 6 out of a 5-credit payout); a **self-developed** idea collapses to ONE payee, because two rows for one builder would silently UNDER-pay under the per-builder idempotency predicate, not double-pay (the comparison normalises via `String()` since `bigint` arrives as a string, so a type mismatch cannot become a phantom payee); and **zero-value rows are dropped**. Ids otherwise pass through unconverted — `Number()`-ing a `bigint` risks precision. `builder_id` in the idempotency predicate, which entered as an index optimisation (the R143/IC2 grader finding), is now **load-bearing for correctness**: two payees share one `reason`, so a reason-only predicate would refuse the second, and a partially-booked split completes on the next call. **Not decided here:** stream 3's split (ships with the Board Room, task 1002999), and whether `origin='develop'` in `ideas_task_lineage` belongs to whoever promotes a DEVELOPED idea into a task — ADR 0172 §5 says the row is "set at promotion and at develop (R146)", but R146 creates no task, so that origin stays deliberately unwired rather than invented; it changes who stream 1 pays.](<redacted>.md) | economy / ideator lane |
262
262
  | 0186 | [**Per-project idea templates: the seam is already built; the economics are the open problem** ([task 1002932](https://cloudbongos.com/builders#/task/1002932), BV1.R156, goal 1000062 — a SPIKE, output is this ADR plus a task list, no implementation). The literal question — how does a founder define their own idea shape without a schema change or forking the scorer — is **already answered by shipped work**: R141 gave every template function an optional `registry` defaulting to the built-in one, so validation and scoring both read the declaration as data; `ideas_grades.template_key` is per-row with a format CHECK whose own comment says a project key "stays addressable"; and `provisioning_instances.settings jsonb` is an existing per-project home. **No schema change, no fork.** Decision A: a **`project-settings` kernel port**, sibling of `builder-settings`, read via `resolveOptional` so a vanilla instance with no provisioning module degrades to the built-in registry; `base-v1` stays the default and the fallback for an unresolvable key. Rejected a new `ideas_templates` table (second home for data that has one) and a config file (per-instance, but one instance hosts many projects). **Decision B is the finding that matters.** Completeness is normalised 0–100 WITHIN its template (mean of per-field ratios), while karma reads it against an absolute bar (`IDEA_FULL_PASS_BAR = 60`, `IDEA_FULL_PASS_CREDIT = 5`) — so template choice becomes a credit-minting lever: `base-v1` needs ~200 characters for 5 credits, a 1-field template with `substanceMin` 10 needs ~10, a ~20× arbitrage handed to exactly the projects this ADR empowers. Shipping A without B would make the feature a way to print credits. The fix separates the two questions one number is answering: **completeness stays relative** (it is the right signal for triage ordering and the hall meter) and **karma stops reading it**, switching to an absolute substance total the scorer already computes per field and currently discards after averaging. An interim guard (≥3 required fields, ≥120 total `substanceMin`) bounds the arbitrage without fixing it. Decision C: an optional per-field `weight` (default 1) turning the unweighted mean into a weighted one — today's behaviour is the all-weights-1 case, so it is a data addition and one arithmetic line. Decision D: the five base fields are **not** mandatory (forcing them would make "define your own" cosmetic), replaced by two constraints — a template must declare ≥1 required field, and a stored template is validated at WRITE time so a malformed one is refused there rather than discovered inside the capture path, which must never throw. **Boundary note:** goal 1000062's wall is `[ideas, hall-ui]`, so the port task (provisioning + kernel) and the karma task (economy) cannot be claimed under it.](<redacted>.md) | ideas / templates |
263
263
  | 0187 | [**The OAuth web-handshake cookies live 30 minutes, and move together** ([task 1003234](https://cloudbongos.com/builders#/task/1003234), goal 1000058). The three cookies `/auth/web/start` sets — the CSRF state, the validated return target, the repo-scope opt-in — were 10 minutes each, a hall builder's timescale; a founder's first sign-in can include a GitHub SIGNUP with email verification and came back past ten minutes to "State mismatch" with the return + repo scope gone, and the page's retry door was a bare start. Decision: one `HANDSHAKE_COOKIE_MS = 30 min` for all three (they MUST move together — a state outliving its return cookie lands on the default landing with no scope, a return outliving its state lands on State mismatch), and every OAuth error page plus the access-pending auto-walk-in rebuilds its door from whatever cookies are still valid (`recoveryStartHref`, through the same open-redirect allowlist). What did NOT change: the state is still single-use, still `httpOnly`/`sameSite=lax`/apex-scoped, the code still the bearer — only the window for a legitimate handshake grew. Rejected: lengthening only return + repos (keeps the dead end and breaks the one-lifetime invariant the other way), a server-side state store, sixty minutes. The Discord-link state cookie is a different flow and keeps 10.](<redacted>.md) | auth / security posture |
264
+ | 0188 | [**The weekly patrol runs on CI, and a declared cadence must have something that fires it** ([task 1003209](https://cloudbongos.com/builders#/task/1003209), goal 1000079). Goal 1000076 shipped a weekly red-team patrol, a routine describing how to run it, a recipe, and a manifest row — and nothing ever ran it; the follow-up audit §7 named all three halves (no committed scheduler, no cadence for a scheduler to read, and a manifest green anyway because its wiring predicate was satisfied by the SKILL.md mentioning its own scanner). The symmetric fix — an in-process timer beside `peer-vote-tally` in `routine-timers.js` — is **wrong on correctness, not cost**: all three patrol checks read things only the REPO has (full git history, `.github/workflows/`, an auditable lockfile), and a deployed instance runs a VENDORED core out of `node_modules/@bongos/core` that has none of them, so the timer would have scanned nothing and reported clean — worse than unscheduled. The runner is therefore CI: a weekly Actions lane with `fetch-depth: 0` and a pinned, checksum-verified gitleaks (the missing binary the audit listed separately), invoking `run-routine.js redteam-weekly` so the SKILL.md's steps stay the single source of truth. `cadence:` becomes a real frontmatter field — declarative only, scheduler-agnostic. The load-bearing piece is `routine-schedule.js` (fitness Check 25), whose predicate is **relational, not existential**: it classifies the cron into the period it actually realizes and fails when that disagrees with the declared cadence — the one rot mode no substring or existence check can EVER see, since editing weekly→monthly leaves every "is it wired?" predicate green. The manifest row was re-pointed to match ("wired" now means the SCHEDULER names the routine). Finally the exit code says **a finding was LOST, not that a finding exists**: findings are advisory (ADR 0162), so filing three is a SUCCESS and red is reserved for findings that cannot be recorded — failing on findings would have gone red on run one and stayed there (the unrotated staging credential in `092e473` is a standing history hit), and an always-red weekly job is one nobody reads. Filing is credential-gated but OPTIONAL — with no session secret the patrol still runs and writes its findings to the job summary, which matters because this goal's C1 is about eliminating long-lived tokens, not adding one. Rejected: the in-process timer; teaching `routine-timers.js` generic cadence discovery (a scheduler built for nobody — its only candidate user cannot run there); failing whenever findings exist; requiring the secret; a deeper manifest predicate instead of a new check.](<redacted>.md) | security / enforcement durability |
264
265
 
265
266
  > ⚠️ **Numbering collision (known):** two ADRs share `0008`. The numbers were assigned in parallel sessions before the lock could be enforced. The files are kept under their existing filenames to preserve git history, but the table disambiguates them as `0008-a` / `0008-b`. New ADRs should resume at `0040` to avoid further collisions. (`0024` is also a real on-disk duplicate: the cloneable-repo/local-first-memory ADR and the multi-agent-system-architecture ADR were both numbered `0024` in parallel; both keep their filenames, disambiguated as `0024-a` / `0024-b` like `0008`. Likewise `0025` ended up with two on-disk files — the offsite-backup ADR and the structured-criterion↔task-link ADR — both kept under their existing filenames and disambiguated as `0025-a` / `0025-b`; separately, the BFG ADR had also claimed `0025` on 2026-05-30 and yielded to `0026`. A third happened 2026-06-06: `0036` was claimed in parallel by the builders-subdomain ADR ([#726](https://example.com/builders#/task/726)) and this Discord-channels ADR ([#727](https://example.com/builders#/task/727)); the Discord-channels ADR yielded to `0037`. A fourth landed 2026-06-10: `0044` was claimed in parallel by the palette-replacement ADR and the per-box game-preview ADR ([#897](https://example.com/builders#/task/897)); both keep their filenames, disambiguated as `0044-a` / `0044-b` like `0008`. A fifth was caught pre-land 2026-06-13: `0050` was claimed in parallel by the device-flow-admission ADR ([#1000](https://example.com/builders#/task/1000)) and the sandbox-for-everyone ADR ([#1001](https://example.com/builders#/task/1001)); the latter yielded to `0052` before merging, so there is no `0050` duplicate. A sixth was caught pre-land 2026-06-21: the tool-agnostic-design-layer ADR ([#1375](https://example.com/builders#/task/1375)) first collided at `0079` with the value-gated-autonomous-engagement ADR, then at `0080` with the llm-cache-api-transport ADR (both part of the 0075–0080 LLM-cost-reduction series, landed on `main` in parallel); it yielded to `0081` before merging, so there is no `0079`/`0080` design-layer duplicate. A seventh landed 2026-06-23: `0084` was claimed in parallel by the auto-derived-per-task-rank-floor ADR and the remove-default-spend-cap ADR (task 1499); the rank-floor ADR landed on `main` first, so the spend-cap ADR yielded to `0085`, leaving no `0084` duplicate. An eighth landed 2026-06-27: `0095` was claimed in parallel by the borrowed-memory-and-retrieval-concepts ADR (PR #459, landed first) and the cross-agent-context-management ADR ([#1651](https://example.com/builders#/task/1651), PR #460); both keep their filenames, disambiguated as `0095-a` / `0095-b` like `0008`. These two are companions — `0095-a` adopts the memory/retrieval concepts and explicitly defers the grep-vs-semantic question to `0095-b`.)
package/docs/file-map.md CHANGED
@@ -389,7 +389,7 @@ tests/
389
389
  <!-- BEGIN GENERATED FILE-MAP .claude/scheduled-tasks/ (scripts/gds/gen-file-map.js — do not hand-edit; notes live in docs/file-map.notes.json) -->
390
390
  ```
391
391
  .claude/scheduled-tasks/
392
- └── redteam-weekly/SKILL.md ← weekly deterministic red-team patrol (task 1003128): full-history gitleaks scan + dependency drift review (audit delta, cooldown, young pkgs, stale overrides) + action-pin check → files security reports (advisory, ADR 0162)
392
+ └── redteam-weekly/SKILL.md ← weekly deterministic red-team patrol (task 1003128): full-history gitleaks scan + dependency drift review (audit delta, cooldown, young pkgs, stale overrides) + action-pin check → files security reports (advisory, ADR 0162). FIRED BY .github/workflows/redteam-patrol.yml, Mondays 06:17 UTC — `cadence: weekly` here and that cron must agree or fitness Check 25 (scripts/gds/routine-schedule.js) fails the gate (task 1003209, ADR 0188)
393
393
  ```
394
394
  <!-- END GENERATED FILE-MAP .claude/scheduled-tasks/ -->
395
395
 
@@ -16,18 +16,69 @@ node scripts/gds/redteam-patrol.js --canary # self-test detection only
16
16
  node scripts/gds/redteam-patrol.js --canary --file # prove the full pipe (files + dispute-closes)
17
17
  ```
18
18
 
19
- The scheduled form lives at [`.claude/scheduled-tasks/redteam-weekly/`](../../.claude/scheduled-tasks/redteam-weekly/SKILL.md)
20
- (`mode: deterministic`, one step, sink `gds`). Install on a machine per the
21
- file-map convention: symlink the routine dir under `~/.claude/scheduled-tasks/`.
22
- Weekly is the cadence — the checks watch drift measured in days (release-age,
23
- new advisories), not minutes.
19
+ ## What actually fires it
20
+
21
+ **[`.github/workflows/redteam-patrol.yml`](../../.github/workflows/redteam-patrol.yml)
22
+ — Mondays 06:17 UTC** (task 1003209, ADR 0188). Before that lane the patrol was,
23
+ in the follow-up audit's words, a manual script: tracked, documented, wired into
24
+ the control manifest, and run zero times.
25
+
26
+ The routine itself lives at
27
+ [`.claude/scheduled-tasks/redteam-weekly/`](../../.claude/scheduled-tasks/redteam-weekly/SKILL.md)
28
+ (`mode: deterministic`, `cadence: weekly`, one step, sink `gds`) and the workflow
29
+ invokes it through `run-routine.js`, so the SKILL.md's `steps:` stay the single
30
+ source of truth for what a run does. Weekly is the cadence because the checks
31
+ watch drift measured in days (release-age, new advisories), not minutes.
32
+
33
+ **The cadence and the cron are checked against each other.** `cadence: weekly` in
34
+ the SKILL.md and `cron: '17 6 * * 1'` in the workflow must agree, or
35
+ `scripts/gds/routine-schedule.js` (fitness Check 25) fails the merge gate — as it
36
+ does if the workflow stops firing the routine, or fires one that no longer
37
+ exists. Change one and change the other in the same commit. The odd minute is
38
+ deliberate: GitHub queues scheduled workflows and top-of-hour is its worst
39
+ congestion window.
40
+
41
+ **Why CI and not the always-on server.** All three checks read things only the
42
+ *repo* has — full git history, `.github/workflows/`, an auditable lockfile. A
43
+ deployed instance runs a vendored core out of `node_modules/@bongos/core`, which
44
+ has none of them, so an in-process timer would have scanned nothing and reported
45
+ clean. ADR 0188 §2.1.
46
+
47
+ ### Filing from CI (optional)
48
+
49
+ A CI runner has no GDS session, so by default the scheduled run **reports but
50
+ cannot file**: findings go to the Actions job summary and the job exits 2. To let
51
+ it file, set the repo secret **`GDS_PATROL_SESSION`** to a GDS CLI session JSON
52
+ (`{"token":…,"api_base":…}`) for a builder holding `security.report.file`; the
53
+ workflow writes it to the session path `instance-config` resolves and the patrol
54
+ files normally. This is opt-in on purpose — the patrol must not *require* a
55
+ long-lived token to be useful.
56
+
57
+ **Red means a finding was LOST, not that a finding exists.** Findings are
58
+ advisory (ADR 0162), so a run that files three of them is a success:
59
+
60
+ | the run… | exit |
61
+ |---|---|
62
+ | found nothing | 0 |
63
+ | was a dry run | 0 |
64
+ | filed its findings | 0 |
65
+ | has findings it cannot file | **2** |
66
+
67
+ Expect a standing `history-secrets` hit until the staging credential in commit
68
+ `092e473` is scrubbed from history. That is the patrol working, not the lane
69
+ being broken.
24
70
 
25
71
  ## Prerequisites
26
72
 
27
73
  - **gitleaks** on PATH (`brew install gitleaks`) or `GITLEAKS_BIN=<path>`. The
28
74
  history scan degrades LOUDLY (a printed warning, no silent skip) when absent;
29
- the dependency + pin checks still run.
75
+ the dependency + pin checks still run. The scheduled lane installs it itself —
76
+ version-pinned and checksum-verified, the same binary and digest the unit gate
77
+ uses, rather than a mutable action tag (the tj-actions lesson).
30
78
  - A GDS session (the normal builder token) for `--file`; dry runs need nothing.
79
+ `--file` preflights it, so a sessionless run prints its findings and says it
80
+ could not file them — it never drops into the CLI's interactive re-auth wall,
81
+ which in a cron log is indistinguishable from a crash.
31
82
  - Network access to the npm registry for the dependency checks — registry
32
83
  failures print notes and skip that item (advisory layer: fail-open, loud).
33
84
 
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.340",
3
+ "version": "1.19.341",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.340",
9
+ "version": "1.19.341",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.340",
3
+ "version": "1.19.341",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -69,10 +69,17 @@ const CONTROLS = [
69
69
  wiredIn: { file: '.github/workflows/unit.yml', needle: '--knip' },
70
70
  },
71
71
  {
72
- label: 'patrol: weekly red-team routine wired to its scanner',
72
+ // Task 1003209 re-pointed this row. It used to prove the routine mentioned
73
+ // its own scanner — a wiring substring satisfied by the SKILL.md talking
74
+ // about itself, which is why the manifest read GREEN for months while the
75
+ // patrol never ran once (follow-up audit §7). "Wired" for a scheduled
76
+ // control has to mean SOMETHING FIRES IT, so the needle now lives in the
77
+ // scheduler. The period itself is checked relationally, where a substring
78
+ // can't reach: scripts/gds/routine-schedule.js (fitness Check 25).
79
+ label: 'patrol: weekly red-team routine is actually scheduled',
73
80
  file: '.claude/scheduled-tasks/redteam-weekly/SKILL.md',
74
- wiredIn: { file: '.claude/scheduled-tasks/redteam-weekly/SKILL.md', needle: 'redteam-patrol.js' },
75
- alsoExists: ['scripts/gds/redteam-patrol.js'],
81
+ wiredIn: { file: '.github/workflows/redteam-patrol.yml', needle: 'run-routine.js redteam-weekly' },
82
+ alsoExists: ['scripts/gds/redteam-patrol.js', 'scripts/gds/routine-schedule.js', 'tests/routine_schedule.mjs'],
76
83
  },
77
84
 
78
85
  // ── goal 1000077's four guards (task 1003169 / G05) ──────────────────────
@@ -1305,6 +1305,7 @@ const CHECKS = [
1305
1305
  // and the check gets its own line back.
1306
1306
  require('./version-literal-guard.js').checkNoVersionLiterals,
1307
1307
  require('./control-manifest.js').checkControlsExist, // Check 21 — task 1003131: goal-1000076 controls assert their own existence (the ADR 0022 assurance-of-assurance)
1308
+ require('./routine-schedule.js').checkRoutineCadencesScheduled, // Check 25 — task 1003209: a routine that declares a cadence must have a scheduler firing it AT that period (ADR 0188)
1308
1309
  ];
1309
1310
 
1310
1311
  function runAll() {
@@ -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
@@ -1110,8 +1110,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
1110
1110
  // landed since 1.19.338 with no explicit bump. run 32797325535. (task 1002620)
1111
1111
  // 1.19.340 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1112
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)
1113
1115
  // ---------------------------------------------------------------------------
1114
- const CORE_VERSION = '1.19.340'; // 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
1115
1117
 
1116
1118
  // A namespaced logger so a module's log lines are attributable + consistent.
1117
1119
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -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
+ });
@@ -0,0 +1,176 @@
1
+ // tests/routine_schedule.mjs — a declared cadence must have something that fires
2
+ // it (task 1003209, goal 1000079; ADR 0188, follow-up audit §7).
3
+ //
4
+ // The check exists because goal 1000076's patrol was "wired" by every predicate
5
+ // the repo had and still ran zero times. So these tests are written against the
6
+ // ways that state can come BACK, not merely against the happy path: the strongest
7
+ // one is the cron silently drifting off the declared cadence, which no
8
+ // existence/substring predicate can ever catch.
9
+ //
10
+ // Hermetic — the evaluator is pure and driven with fixtures; the two live-tree
11
+ // tests at the bottom assert the real repo is in the state this task shipped.
12
+ import assert from 'node:assert/strict';
13
+ import { test } from 'node:test';
14
+ import { createRequire } from 'node:module';
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+
19
+ const require = createRequire(import.meta.url);
20
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
21
+ const rs = require(path.join(ROOT, 'scripts', 'gds', 'routine-schedule.js'));
22
+ const { parseRoutineFrontmatter } = require(path.join(ROOT, 'scripts', 'gds', 'run-routine.js'));
23
+
24
+ const WEEKLY_WF = {
25
+ file: 'redteam-patrol.yml',
26
+ text: [
27
+ 'on:',
28
+ ' schedule:',
29
+ " - cron: '17 6 * * 1'",
30
+ ' workflow_dispatch:',
31
+ 'jobs:',
32
+ ' patrol:',
33
+ ' steps:',
34
+ ' - run: node scripts/gds/run-routine.js redteam-weekly',
35
+ ].join('\n'),
36
+ };
37
+ const routines = (withCadence, withoutCadence = []) => ({ withCadence, withoutCadence });
38
+ const WEEKLY = [{ name: 'redteam-weekly', cadence: 'weekly', steps: [] }];
39
+
40
+ // ---------------------------------------------------------------- cron parsing
41
+
42
+ test('cronCadence classifies the four periods a routine may declare', () => {
43
+ assert.equal(rs.cronCadence('17 6 * * 1'), 'weekly');
44
+ assert.equal(rs.cronCadence('17 6 * * *'), 'daily');
45
+ assert.equal(rs.cronCadence('17 6 3 * *'), 'monthly');
46
+ assert.equal(rs.cronCadence('17 * * * *'), 'hourly');
47
+ });
48
+
49
+ test('cronCadence refuses what its vocabulary cannot describe, rather than guessing', () => {
50
+ // Unclassifiable must mean "never matches a declared cadence" — the safe
51
+ // direction. A clever cron that silently counted as `weekly` would reopen
52
+ // exactly the drift this check exists to catch.
53
+ for (const expr of ['*/5 * * * *', '0 6 * * 1-5', '0 6 * * 1,4', '0 0 1 1 *', '17 6 * *', '', 'nonsense']) {
54
+ assert.equal(rs.cronCadence(expr), null, `${expr} should not classify`);
55
+ }
56
+ // A day-of-month AND day-of-week together is an OR in cron — neither period.
57
+ assert.equal(rs.cronCadence('17 6 3 * 1'), null);
58
+ });
59
+
60
+ test('workflowCrons reads every cron a workflow declares, quoted or bare, ignoring trailing comments', () => {
61
+ const y = [" schedule:", " - cron: '17 6 * * 1'", ' - cron: "0 3 * * *"', ' - cron: 5 * * * * # bare + comment'].join('\n');
62
+ assert.deepEqual(rs.workflowCrons(y), ['17 6 * * 1', '0 3 * * *', '5 * * * *']);
63
+ assert.deepEqual(rs.workflowCrons(''), []);
64
+ });
65
+
66
+ // ------------------------------------------------------------- what counts as
67
+ // an invocation
68
+
69
+ test('invokesRoutine accepts the dispatcher form and a verbatim step command', () => {
70
+ assert.equal(rs.invokesRoutine('run: node scripts/gds/run-routine.js redteam-weekly', 'redteam-weekly'), true);
71
+ assert.equal(rs.invokesRoutine('run: node scripts/gds/run-routine.js --dry-run redteam-weekly', 'redteam-weekly'), true);
72
+ assert.equal(
73
+ rs.invokesRoutine('run: node scripts/gds/redteam-patrol.js --file', 'redteam-weekly',
74
+ [{ command: 'node scripts/gds/redteam-patrol.js --file' }]),
75
+ true);
76
+ });
77
+
78
+ test('invokesRoutine does not accept a prefix match for a different routine', () => {
79
+ // `redteam-weekly-v2` must not satisfy `redteam-weekly` — that would let a
80
+ // renamed routine keep the old one's green.
81
+ assert.equal(rs.invokesRoutine('node scripts/gds/run-routine.js redteam-weekly-v2', 'redteam-weekly'), false);
82
+ });
83
+
84
+ // ------------------------------------------------------------------ the check
85
+
86
+ test('a routine fired weekly by a weekly cron passes', () => {
87
+ const { violations } = rs.evaluate(routines(WEEKLY), [WEEKLY_WF]);
88
+ assert.deepEqual(violations, []);
89
+ });
90
+
91
+ test('THE regression this check exists for: the cron drifts off the declared cadence', () => {
92
+ // Every existence/substring predicate in the repo stays green here — the file
93
+ // exists, the workflow still names the routine, the manifest needle still hits.
94
+ // Only a relational check sees it.
95
+ const drifted = { ...WEEKLY_WF, text: WEEKLY_WF.text.replace('17 6 * * 1', '17 6 3 * *') };
96
+ const { violations } = rs.evaluate(routines(WEEKLY), [drifted]);
97
+ assert.equal(violations.length, 1);
98
+ assert.match(violations[0], /declares cadence: weekly but no scheduler fires it at that period/);
99
+ assert.match(violations[0], /→ monthly/);
100
+ });
101
+
102
+ test('dispatch-only is not scheduled — the exact state the audit called "decorative"', () => {
103
+ const manual = { ...WEEKLY_WF, text: WEEKLY_WF.text.replace(/ schedule:\n.*\n/, '') };
104
+ const { violations } = rs.evaluate(routines(WEEKLY), [manual]);
105
+ assert.equal(violations.length, 1);
106
+ assert.match(violations[0], /carries no 'on: schedule:' cron \(manual dispatch only\)/);
107
+ });
108
+
109
+ test('a cadence with no scheduler at all is a violation where CI exists', () => {
110
+ const { violations } = rs.evaluate(routines(WEEKLY), []);
111
+ assert.equal(violations.length, 1);
112
+ assert.match(violations[0], /NOTHING fires it/);
113
+ });
114
+
115
+ test('rename rot is caught from the other side: a scheduler dispatching a routine that is gone', () => {
116
+ const { violations } = rs.evaluate(routines([], [{ name: 'something-else' }]), [WEEKLY_WF]);
117
+ assert.equal(violations.length, 1, violations.join(' | '));
118
+ assert.match(violations[0], /dispatches routine 'redteam-weekly', which has no/);
119
+ });
120
+
121
+ test('a workflow naming its own invocation twice reports one problem, not two', () => {
122
+ const twice = { ...WEEKLY_WF, text: `# see run-routine.js redteam-weekly below\n${WEEKLY_WF.text}` };
123
+ const { violations } = rs.evaluate(routines([], []), [twice]);
124
+ assert.equal(violations.length, 1);
125
+ });
126
+
127
+ test('an unknown cadence word is refused rather than silently unenforceable', () => {
128
+ const { violations } = rs.evaluate(routines([{ name: 'r', cadence: 'fortnightly', steps: [] }]), []);
129
+ assert.equal(violations.length, 1);
130
+ assert.match(violations[0], /unknown cadence "fortnightly"/);
131
+ });
132
+
133
+ test('an instance checkout (no .github/workflows) warns instead of red-building', () => {
134
+ // Routines are materialized into every instance; .github/workflows is the core
135
+ // repo's and does not travel. Hard-failing there would red every instance for
136
+ // owning a scheduler the core cannot see.
137
+ const { violations, warnings } = rs.evaluate(routines(WEEKLY), null);
138
+ assert.deepEqual(violations, []);
139
+ assert.equal(warnings.length, 1);
140
+ assert.match(warnings[0], /NOTHING fires it/);
141
+ });
142
+
143
+ test('a routine with no cadence is a warning — it can only ever be run by hand', () => {
144
+ const { violations, warnings } = rs.evaluate(routines([], [{ name: 'manual-thing' }]), []);
145
+ assert.deepEqual(violations, []);
146
+ assert.match(warnings.join(' '), /declares no cadence/);
147
+ });
148
+
149
+ // --------------------------------------------------------------- the live tree
150
+
151
+ test('the frontmatter parser reads cadence: alongside mode: and steps:', () => {
152
+ const md = ['---', 'mode: deterministic', 'cadence: weekly', 'description: d', 'steps:',
153
+ ' - command: node x.js', ' sink: gds', '---', '# body'].join('\n');
154
+ const fm = parseRoutineFrontmatter(md);
155
+ assert.equal(fm.cadence, 'weekly');
156
+ assert.equal(fm.mode, 'deterministic');
157
+ assert.equal(fm.steps.length, 1);
158
+ // Absent cadence must be null, not undefined — evaluate() branches on truthiness.
159
+ assert.equal(parseRoutineFrontmatter('---\nmode: deterministic\n---\n').cadence, null);
160
+ });
161
+
162
+ test('LIVE: redteam-weekly declares a cadence and this repo actually fires it', () => {
163
+ const r = rs.checkRoutineCadencesScheduled();
164
+ assert.equal(r.hardFail, false, r.violations.join(' | '));
165
+ assert.ok(rs.readRoutines().withCadence.some((x) => x.name === 'redteam-weekly'),
166
+ 'redteam-weekly must declare a cadence — without it nothing can schedule it');
167
+ });
168
+
169
+ test('LIVE: the patrol workflow installs gitleaks, which the history scan cannot run without', () => {
170
+ // The follow-up audit listed the missing binary as its own gap; a scheduled
171
+ // patrol without it degrades to "history scan SKIPPED" every week forever.
172
+ const wf = fs.readFileSync(path.join(ROOT, '.github', 'workflows', 'redteam-patrol.yml'), 'utf8');
173
+ assert.match(wf, /GITLEAKS_BIN=/, 'the patrol must be handed a gitleaks binary');
174
+ assert.match(wf, /sha256sum -c -/, 'the binary must be checksum-verified (the tj-actions lesson)');
175
+ assert.match(wf, /fetch-depth: 0/, 'a shallow clone makes the history scan a confident no-op');
176
+ });
@@ -49,8 +49,11 @@ test('a top-level key after steps ends the list', () => {
49
49
  });
50
50
 
51
51
  test('no frontmatter → empty contract', () => {
52
- assert.deepEqual(parseRoutineFrontmatter('just a body'), { mode: null, steps: [], description: null });
53
- assert.deepEqual(parseRoutineFrontmatter(''), { mode: null, steps: [], description: null });
52
+ // `cadence: null` joined the contract at task 1003209 — routine-schedule.js
53
+ // branches on its truthiness, so "absent" must be null, never undefined.
54
+ const empty = { mode: null, cadence: null, steps: [], description: null };
55
+ assert.deepEqual(parseRoutineFrontmatter('just a body'), empty);
56
+ assert.deepEqual(parseRoutineFrontmatter(''), empty);
54
57
  });
55
58
 
56
59
  test('mode-only frontmatter (judgment) → no steps', () => {