@bongos/core 1.19.525 → 1.19.527
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 +89 -49
- package/.claude/skills/blocker-solve/SKILL.md +2 -1
- package/.claude/skills/builder-box/SKILL.md +2 -1
- package/.claude/skills/builder-connect/SKILL.md +2 -1
- package/.claude/skills/builder-end/SKILL.md +2 -1
- package/.claude/skills/builder-exit/SKILL.md +2 -1
- package/.claude/skills/builder-key/SKILL.md +2 -1
- package/.claude/skills/builder-sequence/SKILL.md +2 -1
- package/.claude/skills/builder-stage/SKILL.md +2 -1
- package/.claude/skills/design/SKILL.md +2 -1
- package/.claude/skills/goal-create/SKILL.md +2 -1
- package/.claude/skills/goal-review/SKILL.md +2 -1
- package/.claude/skills/grade-audit/SKILL.md +2 -1
- package/.claude/skills/grade-recover/SKILL.md +2 -1
- package/.claude/skills/ideate/SKILL.md +2 -1
- package/.claude/skills/new-project/SKILL.md +2 -1
- package/.claude/skills/otb-character-review/SKILL.md +2 -1
- package/.claude/skills/paint/SKILL.md +2 -1
- package/.claude/skills/scan-before-install/SKILL.md +2 -1
- package/.claude/skills/ship-check/SKILL.md +44 -0
- package/.claude/skills/strand-fix/SKILL.md +2 -1
- package/.claude/skills/worktree-clean/SKILL.md +2 -1
- package/.gitattributes +6 -2
- package/docs/adr/0242-a-cascade-is-a-declaration-table-on-the-event-that-already-exists.md +107 -0
- package/docs/adr/README.md +1 -0
- package/docs/file-map.md +4 -1
- package/docs/module-api-changelog.md +4 -0
- package/docs/recipes/windows-builders.md +46 -0
- package/modules/grading/generated-provenance.js +1 -1
- package/modules/lifecycle/cascade.js +238 -0
- package/modules/lifecycle/routes/lifecycle.js +25 -0
- package/modules/lifecycle/tests/cascade.mjs +197 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/claude-materialize.js +7 -2
- package/scripts/gds/fitness.js +40 -1
- package/scripts/gds/gen-repo-map.js +3 -1
- package/scripts/gds/session-cost.js +10 -7
- package/scripts/gds/session-digest.js +7 -4
- package/scripts/gds/ship-check.js +78 -0
- package/scripts/gds/ship-regen.js +2 -2
- package/scripts/gds/skill-lint.js +171 -0
- package/scripts/gds/strand-watch.js +3 -0
- package/src/module-api.js +1 -1
- package/tests/claude_materialize.mjs +10 -0
- package/tests/edit_hints.mjs +78 -0
- package/tests/git_merge_regen.mjs +1 -1
- package/tests/hooks_drain.mjs +8 -2
- package/tests/repo_map.mjs +1 -1
- package/tests/skill_lint.mjs +73 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: otb-character-review
|
|
3
|
-
description:
|
|
3
|
+
description: >-
|
|
4
|
+
Review a painterly character ANIMATION (many frames of one character) against the ADR 0088 criteria — same character throughout, correct anatomy, each frame on its pose, clean edges, smooth in-order motion — with a plain-language verdict per frame and for the sequence. Triggers: "review this animation", "is this animation consistent", "rate these frames". Sibling of otb-design-review (one tile).
|
|
4
5
|
---
|
|
5
6
|
|
|
6
7
|
You are reviewing a painterly **character animation** — a `character_anim` family: many frames of the *same* character in *different* poses (e.g. the Bongo Buddha idle-breathing cycle). This is the animation sibling of `otb-design-review` (which scores one pixel-art tile). Do NOT judge it as pixel art — there is no palette to match, no FireRed reference, no 32×32 grid.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: paint
|
|
3
|
-
description:
|
|
3
|
+
description: >-
|
|
4
|
+
The art session — the operating playbook for artist-discipline work. Triggers when the user says "/paint", "let's make art", "I want to work on a tile/sprite", "start an art session", or when a claimed task's discipline routes here (claim.js prints a directive to invoke /paint for artist tasks). Show-first, low-text: Claude communicates in pictures, not prose, and drives the existing pixel-art pipeline underneath.
|
|
4
5
|
---
|
|
5
6
|
|
|
6
7
|
You are running an **art session** for Example. This is not the engineer's text-heavy build loop. Read this whole file before acting — it changes how you *communicate* for the rest of the session.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: scan-before-install
|
|
3
|
-
description:
|
|
3
|
+
description: >-
|
|
4
|
+
Scan a third-party GitHub repo or git-hosted Claude plugin BEFORE installing anything: quarantined fetch, deterministic floor, tiered subagent panel over a scrubbed mirror, arithmetic verdict, honest-limits report, three evidence-gated landings. The scanner never installs. Triggers: "/scan-before-install", "is this plugin safe to install", "vet this GitHub repo".
|
|
4
5
|
---
|
|
5
6
|
|
|
6
7
|
You are scanning a third-party artifact someone wants to install. The engine lives in `modules/security/artifact-scan/` — this skill is the orchestration wrapper: it runs the pipeline, spawns the panel, and lands one of three offers. Every load-bearing decision below is an owner decision; do not soften any of them.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: ship-check
|
|
3
|
+
description: >-
|
|
4
|
+
Run every freshness and fitness check ship.js will run, in one command, before shipping — and get the healing command for each stale generated file. Triggers: "/ship-check", "am I ready to ship", "what will ship trip on", "check before I ship", "is anything stale".
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# /ship-check — the pre-ship table
|
|
8
|
+
|
|
9
|
+
**Script skill (authoritative).** The whole action is one command, run from the worktree you will ship from:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
node scripts/gds/ship-check.js
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Add `--tests` to also run the DB-free unit lane (slow; minutes). Add `--json` for machine output.
|
|
16
|
+
|
|
17
|
+
## Why this exists
|
|
18
|
+
|
|
19
|
+
Builders were running the same eight generators and checks by hand, one Bash call at a time — 223
|
|
20
|
+
invocations across 140 sessions in three days — because a ship that fails preflight costs a grader
|
|
21
|
+
run, and the last 30 days of session logs name a stale generated file (`docs/copy-registry.json`,
|
|
22
|
+
a nested `CLAUDE.md` symbol block, `docs/file-map.md`, `docs/api/openapi.json`, the §13 session
|
|
23
|
+
snippet) in roughly a quarter of all sessions. This is that sequence as one call with one table.
|
|
24
|
+
It changes nothing: every step is a `--check`; the **fix →** column is what to run.
|
|
25
|
+
|
|
26
|
+
## What to do with the output
|
|
27
|
+
|
|
28
|
+
1. Every `✗` row names the generator to run. Run it, look at the diff it produced, commit it on your
|
|
29
|
+
branch. Then re-run `/ship-check` until every row is `✓`.
|
|
30
|
+
2. `fitness.js` failures name their own fix inline; warnings are advisory (budgets, ratchets).
|
|
31
|
+
3. With `--tests`: a failing test is only "environmental" if the SAME test fails on an untouched
|
|
32
|
+
checkout of `origin/main`. Compare by test name, never by count — a Windows baseline of ~25
|
|
33
|
+
environment failures has masked real regressions before (see `docs/recipes/windows-builders.md`).
|
|
34
|
+
4. When the table is all `✓`, ship: `/builder-ship`. The grader and CI are the remaining gates;
|
|
35
|
+
this skill removes the preflight trip, not the review.
|
|
36
|
+
|
|
37
|
+
## Relationship to other tools
|
|
38
|
+
|
|
39
|
+
- `ship.js` runs these checks itself at ship time and refuses on a stale one; `/ship-check` is the
|
|
40
|
+
same gate run early, when fixing is cheap.
|
|
41
|
+
- The PostToolUse hook `.claude/hooks/edit-hints.js` tells you the moment an edit makes a generated
|
|
42
|
+
file stale; `/ship-check` is the sweep that catches whatever the hints did not.
|
|
43
|
+
- `node scripts/gds/preflight.js` checks the MACHINE (node version, node_modules, `claude` auth);
|
|
44
|
+
this checks the TREE.
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: strand-fix
|
|
3
|
-
description:
|
|
3
|
+
description: >-
|
|
4
|
+
Walk ONE stranded ship to landed — a task parked at confirmed that the reconciler gave up on (strand:no_branch_no_tip, strand:land_not_proven). Finds where the work is, makes the one sanctioned move, verifies the flip to shipped. Also the fix for a claim refused 409 REBASE_REQUIRED. Triggers: "/strand-fix N", "task N is stranded", "land the strand", "my claim says REBASE_REQUIRED".
|
|
4
5
|
requires: [gh]
|
|
5
6
|
---
|
|
6
7
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: worktree-clean
|
|
3
|
-
description:
|
|
3
|
+
description: >-
|
|
4
|
+
Remove the per-claim worktrees whose work already landed (branch merged into origin/main, or deleted by the ship): lists them, then removes each through the junction-safe worktree.js remove — never raw git worktree remove, which follows the node_modules junction (task 1002809). Triggers: "/worktree-clean", "prune stale worktrees", or the SessionStart line "Stale worktrees: N".
|
|
4
5
|
---
|
|
5
6
|
|
|
6
7
|
**Script skill (authoritative).** Two scripts do the work; this skill only sequences them and shows the list before anything is removed.
|
package/.gitattributes
CHANGED
|
@@ -58,7 +58,12 @@
|
|
|
58
58
|
# that conflicts only on these files lands without a human (GitHub's own auto-merge
|
|
59
59
|
# can't run a custom driver, hence the server does the merge locally then pushes).
|
|
60
60
|
# NB: the TOP-LEVEL CLAUDE.md is hand-written and deliberately NOT listed here —
|
|
61
|
-
# only the
|
|
61
|
+
# only the nested CLAUDE.md docs that carry a generated block are. scripts/gds/CLAUDE.md
|
|
62
|
+
# LEFT that set in task 1003548 (its ~40k-char block was 76% of a file loaded on every
|
|
63
|
+
# visit to that directory; docs/repo-map.md still carries the full index), so its entry
|
|
64
|
+
# came out with it — a path listed here whose generator no longer owns it would route a
|
|
65
|
+
# merge through a driver that cannot regenerate it, which tests/git_merge_regen.mjs
|
|
66
|
+
# asserts against. docs/file-map.md
|
|
62
67
|
# carries two generated section blocks (.claude/skills/ + .claude/scheduled-tasks/,
|
|
63
68
|
# ADR 0066, gen-file-map.js), so it joins the set; docs/session-log-index.md is
|
|
64
69
|
# whole-file generated (gen-session-index.js), so it joins too.
|
|
@@ -72,7 +77,6 @@ docs/file-map.md merge=otb-regen
|
|
|
72
77
|
docs/session-log-index.md merge=otb-regen
|
|
73
78
|
src/bongos/CLAUDE.md merge=otb-regen
|
|
74
79
|
src/bongos/routes/CLAUDE.md merge=otb-regen
|
|
75
|
-
scripts/gds/CLAUDE.md merge=otb-regen
|
|
76
80
|
|
|
77
81
|
# docs/copy-registry.json + docs/copy-inventory.md (task 1003543): the copy
|
|
78
82
|
# registry's committed pair (scripts/gds/copy-inventory.js) — fully generated from
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# 0242 — A cross-role cascade is a declaration table on the event that already exists
|
|
2
|
+
|
|
3
|
+
- **Status:** Accepted
|
|
4
|
+
- **Date:** 2026-09-04
|
|
5
|
+
- **Tasks:** [#1003549](https://cloudbongos.com/builders#/task/1003549) (goal 1000074, the artist's loop) — the split [#1003115](https://cloudbongos.com/builders#/task/1003115) (R04) invited in its own brief: *"if that generalisation is bigger than this task, split it — a cascade mechanism is its own task and possibly its own goal."* Encoded as a real dependency edge by [#1003264](https://cloudbongos.com/builders#/task/1003264).
|
|
6
|
+
- **Related:** 0241 (the artist gate — why a cascade may never fail a ship), 0083 (the module system's one-way rule and the kernel seams), 0233 (a proposal is a task carrying a patch — the same `(source, source_ref)` idempotency), 0086 (goals), 0120 (pay on land)
|
|
7
|
+
|
|
8
|
+
## Context
|
|
9
|
+
|
|
10
|
+
R04 needs "every ship with copy or visuals generates one [review task]" — automatic on ship, never
|
|
11
|
+
hand-filed. Its brief said plainly that the infrastructure for that **did not exist**, and named the
|
|
12
|
+
spark queue as the prototype to extend. Reading both surfaces closely shows the prototype is the
|
|
13
|
+
wrong half of the problem:
|
|
14
|
+
|
|
15
|
+
- The **spark queue** (goal 1000062 / [task 1002921](https://cloudbongos.com/builders#/task/1002921))
|
|
16
|
+
returns open Big Idea Sparks not yet developed.
|
|
17
|
+
- The **copy-desk flag queue** (R02 / [task 1003113](https://cloudbongos.com/builders#/task/1003113))
|
|
18
|
+
returns surfaces anyone flagged as needing touch-up.
|
|
19
|
+
|
|
20
|
+
Both are **PULL**: a filtered listing of rows another role had already created. They are how one role
|
|
21
|
+
*finds* work another role left. Nothing anywhere **generated** work on a state transition, which is
|
|
22
|
+
the **PUSH** R04 actually asks for. (`cascade-dispatch.js` is model-cost routing and unrelated —
|
|
23
|
+
named here only because its name has sent more than one reader down the wrong path.)
|
|
24
|
+
|
|
25
|
+
## Decision
|
|
26
|
+
|
|
27
|
+
### 1. No new bus — `task.shipped` already exists
|
|
28
|
+
|
|
29
|
+
The kernel seam registry's own header uses this exact scenario as its worked example: *"the lifecycle
|
|
30
|
+
emits `task.shipped` and the discord module may want to react."* The event is emitted post-commit by
|
|
31
|
+
both ship paths (the publish reconciler and the ship routes), and already has two consumers —
|
|
32
|
+
discord's ship broadcast and sessions' deferred reward.
|
|
33
|
+
|
|
34
|
+
So a cascade is **one more listener on the event that already says a ship happened**, registered in
|
|
35
|
+
`routes/lifecycle.js` beside the kernel port, for the reason that file already documents: the route
|
|
36
|
+
mount is the one boot hook that runs both in the live server and whenever a test rebuilds the router.
|
|
37
|
+
Building a second notification path would have been a parallel spine to keep in sync with this one.
|
|
38
|
+
|
|
39
|
+
### 2. A declaration table, not a rules engine
|
|
40
|
+
|
|
41
|
+
A cascade is one frozen entry: `{ key, discipline, when(task), title(task), body(task) }`. There is
|
|
42
|
+
no condition language, no stored rules, no per-project configuration, no UI, and no priority
|
|
43
|
+
ordering. Adding a cascade means appending an entry; the runner does not change.
|
|
44
|
+
|
|
45
|
+
This is the [ADR 0237](<redacted>.md)
|
|
46
|
+
/ [ADR 0239](<redacted>.md) shape applied to a
|
|
47
|
+
different problem, and for the same reason: the generalisations worth building are the ones two or
|
|
48
|
+
three real entries have shown they share. With one consumer in existence, a rules engine would be
|
|
49
|
+
inventing the requirements it then satisfied.
|
|
50
|
+
|
|
51
|
+
### 3. `CASCADES` ships EMPTY, and R04 writes the first rule
|
|
52
|
+
|
|
53
|
+
The mechanism lands with no production cascade. Its four properties are proved against the tests'
|
|
54
|
+
own declarations rather than against one example — which is the stronger proof, because a mechanism
|
|
55
|
+
pinned to its first consumer's shape tends to fit only that shape.
|
|
56
|
+
|
|
57
|
+
This is deliberate and is asserted, so the emptiness is a decision on the record rather than
|
|
58
|
+
something nobody noticed. The artist review cascade is R04's content: this task built the thing R04
|
|
59
|
+
said was missing, and stopped there.
|
|
60
|
+
|
|
61
|
+
### 4. Four properties, all of them load-bearing
|
|
62
|
+
|
|
63
|
+
- **It can never fail the ship it reacts to.** The event fires post-commit, both emitters already
|
|
64
|
+
wrap it, listener errors are isolated by the seam, and `runCascadesForShip` catches everything and
|
|
65
|
+
returns a report. Every failure shape — a predicate that throws, a malformed rule, a database that
|
|
66
|
+
is down, a junk payload — is a logged report and nothing else. This is not defensive habit: ADR
|
|
67
|
+
0241 settled that an artist's review may hold a **deploy** and never a builder's **ship**, and a
|
|
68
|
+
generator that could fail a ship would smuggle that gate back in through the back door. Verified
|
|
69
|
+
against a real failure, not a stub: with no database reachable, emitting `task.shipped` logs one
|
|
70
|
+
error line and resolves.
|
|
71
|
+
- **It is idempotent, on machinery that already exists.** Generated tasks carry `source: 'cascade'`
|
|
72
|
+
and `source_ref: cascade-<key>-<taskId>`, so `createTask`'s `(source, source_ref)` read — the same
|
|
73
|
+
one a copy proposal uses (ADR 0233) — makes a reconciler re-sweep, a re-ship, or both emitters
|
|
74
|
+
firing a no-op. The runner checks first, so the ordinary case is a read.
|
|
75
|
+
- **It says where it came from.** Every generated task inherits the source task's `goal_id` and
|
|
76
|
+
`version_id`, carries the originating id in its ref, and opens with the source task and the rule
|
|
77
|
+
key. NOT `parent_task_id`: that column models a **subtask**, and cascaded work is not part of the
|
|
78
|
+
ship that triggered it — it is separate work, for a different role, that outlives its trigger.
|
|
79
|
+
- **It files at `backlog`, never `ready`.** Generated work is a request and goes through ordinary
|
|
80
|
+
triage, like a copy proposal or a capture. Auto-filing claimable work would let a rule nobody
|
|
81
|
+
re-read mint claims.
|
|
82
|
+
|
|
83
|
+
## Consequences
|
|
84
|
+
|
|
85
|
+
- **R04 is unblocked**, and narrowed: it appends one entry to `CASCADES` and writes the review task's
|
|
86
|
+
content and the ADR 0241 gate. It does not build a generation mechanism.
|
|
87
|
+
- **A rule key is permanent.** It is half the `source_ref`, so renaming one re-files every task that
|
|
88
|
+
rule ever generated. Stated in the file and pinned by the duplicate-key check.
|
|
89
|
+
- **The mechanism is discipline-agnostic.** Nothing about it is artist-specific; an ideator or
|
|
90
|
+
engineer cascade is the same entry with a different `discipline`. That is the generalisation the
|
|
91
|
+
brief asked for, and the reason this is not simply part of R04.
|
|
92
|
+
- **Accepted limit:** cascades are declared in code, not configured per project. A project that
|
|
93
|
+
wants a cascade the platform does not declare cannot have one. That is the right trade while the
|
|
94
|
+
count is zero, and the point at which it stops being right is when the second and third entries
|
|
95
|
+
disagree about what they need.
|
|
96
|
+
|
|
97
|
+
## Rejected
|
|
98
|
+
|
|
99
|
+
- **Extending the spark queue.** The brief's own suggestion, and the wrong half: the queue is a
|
|
100
|
+
read-side filter over rows that already exist. Nothing in it generates anything.
|
|
101
|
+
- **A new event or a poller.** `task.shipped` exists, is post-commit, and has two consumers already.
|
|
102
|
+
- **A rules engine, or per-project cascade configuration.** See §2 — both are generalisations ahead
|
|
103
|
+
of their evidence.
|
|
104
|
+
- **Filing generated work at `ready`.** See §4.
|
|
105
|
+
- **`parent_task_id` as the link.** See §4 — it would claim a subtask relationship that is not true.
|
|
106
|
+
- **Running the cascade inside the ship transaction.** It would make a generation failure a ship
|
|
107
|
+
failure, which is exactly the coupling ADR 0241 rules out.
|
package/docs/adr/README.md
CHANGED
|
@@ -329,5 +329,6 @@ This keeps the decision history honest and traceable.
|
|
|
329
329
|
| 0239 | [**A recommendation is a table that carries its own reason; governance is a preview and invites are a seam** ([task 1002336](https://cloudbongos.com/builders#/task/1002336) · goal 1000046, criteria `proj-module-select` / `proj-governance-reco` / `proj-invite-builders`). R19 ([ADR 0237](<redacted>.md)) declared the per-type starter bundle and said outright it is "a declaration and not a rule engine — R17/R22 own the layer that reasons"; this is that layer, and the goal pins it **rule-based**, with the AI/embeddings pass deferred ([task 1003500](https://cloudbongos.com/builders#/task/1003500), idea 1000552). **Two inputs, and the free-text description is not one of them:** the DECLARED `type` (a column) and `team_shape` (the one closed-answer key the R15 interview collects, [task 1002334](https://cloudbongos.com/builders#/task/1002334)) — inferring from prose is the deferred layer ADR 0237 §7 already rejected once. The engine is three frozen tables (`TEAM_SHAPE_RULES` add/remove ONE opt-in module; `GOVERNANCE_RULES` name a preset; `INVITE_RULES` emit a seek) and **every row carries its own `reason` sentence**, shipped with the effect rather than reconstructed by whichever surface renders it — a recommendation an owner cannot be told the reason for is one they cannot disagree with. `bundle.adjustments` is a **DELTA**: a rule whose condition matched but whose module the bundle already carried records NOTHING, because "we switched discord on because you said community" over a module a game would have had anyway is a claim the owner can check and find false. **Governance is a named preview, never a setting** — the governance module is planned and unbuilt (goal 1000030), so a preset is a name plus one sentence of what it feels like (`owner-only` / `trusted-team` / `open-contribution`) and every result carries `applied: false`, one flag instead of the same fact hardcoded in R25, R18, the wizard and the project page. **Invites are a SEAM**: `suggested` is always `[]` because ranking builders means querying the leaderboard in the sibling `economy` module, which the one-way rule ([ADR 0083](<redacted>.md)) forbids importing — the engine emits `seeking: { kind: 'top-builders', limit }` (or `null` = suggest nobody, a real answer) and R28 ([task 1002347](https://cloudbongos.com/builders#/task/1002347)) turns it into names. **Skipping is an answer:** every table states a default and each is the NARROWEST option (widening later costs one setting; a role granted to strangers is not recovered by setting it back), an off-vocabulary `team_shape` reads as no answer exactly as `normalizeProjectDetail` drops it, and the route answers 200-with-defaults rather than 400 — refusing would make the supported path ([ADR 0182](<redacted>.md) D4) an error. `tailored: boolean` is the machine-readable half of `proj-detail-interview`'s details-vs-skip split that R18 ([task 1002337](https://cloudbongos.com/builders#/task/1002337)) proves. Served at public `GET /provisioning/recommendations?type=&team_shape=` (same absent gate as `starter-bundles`; no database, pinned by a `pool`-throws stub), returning ADR 0237 §5's bundle shape plus `adjustments` so a recommendation and R20's preselect can never name different modules. Rejected: reading the description, filling `suggested` from `GET /public/leaderboard`, a weighted scoring function, a reason per matched condition, and a 400 on an unknown shape.](<redacted>.md) | provisioning / project creation |
|
|
330
330
|
| 0240 | [**A skipped module picker stores nothing, and the bundle is resolved on read** ([task 1002339](https://cloudbongos.com/builders#/task/1002339) · goal 1000046, criterion `proj-module-select`). [ADR 0237](<redacted>.md) declared the starter bundles and closed with "where a chosen selection lands is R20's decision". It lands in a new **nullable** `modules jsonb` column on `provisioning_instances` (`provisioning_014`), read through `starter-bundles.effectiveModules(row)` and **three-valued**: `NULL` = nobody answered (the picker was skipped, or the caller does not ask) and the declared type's bundle is resolved AT READ TIME; `[]` = the owner switched them all off, a real answer; `["dev-box",…]` = what they picked. `NOT NULL DEFAULT '[]'` was rejected because it collapses the first two forever — the `PROJECT_DETAIL_VOCAB` rule ("absent stays absent, skipped stays skipped") applied to a list. **Skipping is safe because the preselect and the fallback are the same function**, `bundleForProjectType`, called twice — not two lists kept in agreement — so `bongos onboard` and any API caller inherit the bundle by omitting the field, and the canonical sequence in `onboard-plan.js` grows **no new step** (the picker refines `type`, an answer already in it). NOT `detail` (its vocabulary is closed + scalar by construction, and [ADR 0237](<redacted>.md) §2 ruled the interview must not ask this) and NOT `settings` (a setting is a policy the RUNNING instance obeys — web.env, a settings-apply intent, a restart). The picker's catalog copy (`OPTIONAL_MODULES`) is **declared beside the bundles**, because `module.json` descriptions are written for builders, `GET /modules` needs a session the signed-out wizard has not got, and `GET /public/modules` serves booleans — pinned the ADR 0237 way, so a new `default: false` module cannot ship without owner-facing copy. `GET /provisioning/starter-bundles` gains `optional` so the preselect and the catalog arrive in **one** read. The route **refuses** an unknown / always-on / hub-only key (`400 bad_modules`) rather than dropping it silently (`badProjectDetail`'s rule), and a re-request reports `modules_ignored`. The column is a DECLARATION, not yet an input to the control-plane runner. Rejected: storing the preselect on a skip (it writes down an answer nobody gave), a new `onboardPlan` step, and rendering the always-on core as sixteen disabled toggles (a disabled control reads as a choice the owner failed to make).](<redacted>.md) | provisioning / project creation |
|
|
331
331
|
| 0241 | [**The artist gate holds the DEPLOY, not the ship, and it reads a state rather than a judgement** ([task 1003116](https://cloudbongos.com/builders#/task/1003116) · goal 1000074, criterion `artist-blocking-is-per-project` — R05, the DECISION task the goal placed before R04 so no code would be wired to an unsettled gate). Goal 1000074 carries two rules on a collision course: the owner's "blocking strictness is PER PROJECT, not global", and [ADR 0162](<redacted>.md), which retired review gates on `main` after a code-owner requirement stranded two graded-PASS ships in a day. **0162 is usually cited by a paraphrase — "a verdict is advisory, never self-executing" — and the paraphrase is wrong as a blanket claim:** 0162's own table of compensating controls marks three of four as *fails closed*, the subagent grader among them. What it actually retired was a gate GitHub could only express as ACCOUNTS — identity-pinned against [ADR 0016](<redacted>.md), and stranding with no escape hatch. So the surviving test is: **a gate is admissible when it is evaluated automatically from readable state, consistently for every builder, with a named escape hatch; inadmissible when a particular person must act and it stalls when they do not.** Decision: (1) the gate reads a **STATE** — "is there an unresolved artist review task?" — never an opinion, so the artist's judgement is what they write INTO the task and stays advisory in exactly 0162's sense, while what the system reads is ordinary GDS status; (2) strictness is a project setting `artist_gate ∈ off | advisory | strict` and at `strict` it holds the **DEPLOY, not the ship** (owner ruling) — the builder's work is graded, merged, credited and landed on CI alone exactly as today, so 0162's strand class cannot recur, and the gate attaches to the boundary the artist's ABI mandate is actually about (users see the deployed instance, not `main`; [ADR 0137](<redacted>.md) already splits land/release/deploy); (3) the escape hatch is the **project OWNER only** — not the builder, not any Metic+, not the artist — via the existing `POST /tasks/:id/override-request`, because a person can be both artist and builder and could pass their own work; a self-release is permitted but never quiet (required rationale, audit-logged as a self-override) so a solo instance cannot deadlock; (4) the default is **`strict`** (owner ruling, against the author's `advisory` recommendation), which is safe only because §2 holds the deploy — the worst case is a pin that waits, and this instance is included once R04 ships; a project with no artist must still be able to deploy, and the refusal must name what holds it and which control releases it; (5) tightening is **not retroactive** — re-gating history would strand the next deploy behind reviews for work nobody remembers and make the setting frightening to raise; R02's flag queue is the right tool for already-shipped copy. Rejected: blocking the ship (recreates 0162's incident exactly, with credits unpaid — [ADR 0120](<redacted>.md)); advisory at every level (configures nothing); any Metic+ as the hatch (makes `strict` indistinguishable from `advisory`); making the artist's VERDICT the gate (the one thing 0162 genuinely forbids); retroactive tightening; a global constant (ruled out by the owner's constraint by name).](<redacted>.md) | goal 1000074 / the artist's loop |
|
|
332
|
+
| 0242 | [**A cross-role cascade is a declaration table on the event that already exists** ([task 1003549](https://cloudbongos.com/builders#/task/1003549) · goal 1000074, criterion `artist-review-task-on-ship` — the split R04 invited in its own brief and [ADR 0241](<redacted>.md)'s dependency). R04 needs "every ship with copy or visuals GENERATES one", and its brief said the infrastructure did not exist, naming the spark queue as the prototype. Reading both surfaces shows the prototype is the wrong half: the spark queue ([task 1002921](https://cloudbongos.com/builders#/task/1002921)) and the copy-desk flag queue ([task 1003113](https://cloudbongos.com/builders#/task/1003113)) are both **PULL** — filtered listings of rows another role already created — and nothing anywhere generated work on a state transition, which is the **PUSH** R04 asks for. (`cascade-dispatch.js` is model-cost routing; named only because its name has misled more than one reader.) **Decision: no new bus.** The kernel seam registry's own header uses this exact scenario as its worked example, `task.shipped` is already emitted post-commit by both ship paths and already has two consumers (discord's broadcast, sessions' deferred reward) — so a cascade is ONE MORE LISTENER on it, registered in `routes/lifecycle.js` beside the port because that mount is the one boot hook tests also run. A cascade is a frozen entry `{ key, discipline, when(task), title(task), body(task) }` — **a declaration table, not a rules engine**: no condition language, no stored rules, no per-project config, no priority ordering; adding one appends an entry and the runner does not change (the [ADR 0237](<redacted>.md)/[0239](<redacted>.md) shape, for their reason — the generalisations worth building are the ones two or three real entries have shown they share). **`CASCADES` ships EMPTY and R04 writes the first rule**, asserted so the emptiness is a decision on the record; the mechanism is proved against the tests' own declarations rather than against its first consumer's shape. Four load-bearing properties, all pinned: (1) **it can never fail the ship it reacts to** — post-commit event, isolated listeners, and a runner that catches everything and returns a report, because ADR 0241 settled that an artist may hold a DEPLOY and never a builder's SHIP and a generator that could fail a ship would smuggle that gate back in; verified against a real failure (no database reachable → one log line, resolves); (2) **idempotent on machinery that already exists** — `source: 'cascade'` + `source_ref: cascade-<key>-<taskId>` through `createTask`'s `(source, source_ref)` read, the same one a copy proposal uses ([ADR 0233](<redacted>.md)), so a reconciler re-sweep or a double emit is a no-op; (3) **it says where it came from** — inherits `goal_id`/`version_id`, carries the origin id in the ref and the prose, but NOT `parent_task_id`, which models a SUBTASK, and cascaded work is not part of the ship that triggered it; (4) **files at `backlog`, never `ready`** — generated work is a request, and auto-filing claimable work would let a rule nobody re-read mint claims. A rule key is PERMANENT (half the source_ref; renaming re-files everything it ever generated), pinned by a duplicate-key check. The mechanism is discipline-agnostic — an ideator or engineer cascade is the same entry with a different `discipline`, which is the generalisation that made this its own task. Accepted limit: cascades are declared in code, not configured per project. Rejected: extending the spark queue (a read-side filter that generates nothing), a new event or poller, a rules engine or per-project config, filing at `ready`, `parent_task_id` as the link, and running the cascade inside the ship transaction (which would make a generation failure a ship failure — the exact coupling ADR 0241 rules out).](<redacted>.md) | goal 1000074 / the artist's loop |
|
|
332
333
|
|
|
333
334
|
> ⚠️ **Numbering collisions are now machine-enforced, not narrated.** Twenty numbers are shared by two ADRs each — assigned in parallel sessions before anything checked. The files keep their filenames (renumbering would break every existing citation, and a number once assigned is never reused), and this table disambiguates each pair as `NNNN-a` / `NNNN-b`. The authoritative list is `LEGACY_DUPLICATE_ADRS` in [`scripts/gds/adr-namespace.js`](../../scripts/gds/adr-namespace.js), frozen by exact filename: a **new** collision — or a third file joining a legacy number, or a rename of either half — hard-fails the `unit` CI gate, as does an index row that points at no file or an ADR with no row. The list may only shrink. This paragraph used to narrate the collisions one by one and had fallen eight behind reality; see [ADR 0195](<redacted>.md) for why the check exists and why the pairs are grandfathered rather than renumbered.
|
package/docs/file-map.md
CHANGED
|
@@ -419,6 +419,7 @@ tests/
|
|
|
419
419
|
├── recall/SKILL.md ← /recall — one-call project-knowledge search across repo docs + DB prose (tasks, learnings, session logs)
|
|
420
420
|
├── scan-before-install/SKILL.md ← /scan-before-install — quarantine-fetch a third-party repo/plugin, deterministic floor + tiered subagent panel over the scrubbed mirror, arithmetic verdict + honest-limits report to the builder's own config dir; prints install commands (pinned SHA only), never installs (goal 1000055)
|
|
421
421
|
├── session-handoff/SKILL.md ← /session-handoff — compose a clean, paste-ready next-steps prompt to START a fresh session with (keeps context light by curating what carries forward); read-only, distinct from /builder-ship's ledger notes and /builder-end's close-out
|
|
422
|
+
├── ship-check/SKILL.md ← /ship-check — run every freshness + fitness check ship.js will run (repo-map/session-index/file-map/api-docs/copy-registry --check, linkify, skill-lint, fitness; --tests adds the DB-free lane) in ONE command before shipping, with the healing command per stale artifact (task 1003548; script scripts/gds/ship-check.js)
|
|
422
423
|
├── status/SKILL.md ← /status — what's the status of X / what's left for criterion Cn (read-only rollup)
|
|
423
424
|
├── strand-fix/SKILL.md ← /strand-fix N — walk ONE stranded confirmed task (strand:no_branch_no_tip / land_not_proven — the shape the reconciler gave up on) to shipped: find the work (main commit / unpushed branch / conflicting PR / nowhere), do the one sanctioned move, verify the flip; also clears a 409 REBASE_REQUIRED claim gate (task 1003365)
|
|
424
425
|
└── worktree-clean/SKILL.md ← /worktree-clean — list the per-claim worktrees whose branch already landed on main (scripts/gds/stale-worktrees.js) and remove each through the junction-safe worktree.js remove; never raw git worktree remove (task 1003365)
|
|
@@ -454,11 +455,13 @@ tests/
|
|
|
454
455
|
|
|
455
456
|
To install on a new machine, symlink each routine's directory under `~/.claude/scheduled-tasks/`, e.g. `ln -s "<repo>/.claude/scheduled-tasks/overnight-code-review" ~/.claude/scheduled-tasks/overnight-code-review`.
|
|
456
457
|
|
|
457
|
-
**Claude Code hooks** at `.claude/hooks/` (wired in `.claude/settings.json`; all invoked
|
|
458
|
+
**Claude Code hooks** at `.claude/hooks/` (wired in `.claude/settings.json`; all invoked through `run-node.sh`, which finds node even when the hook shell's PATH lacks it, so they run on Windows/macOS/Linux alike (task 1003548) — [#671](https://example.com/builders#/task/671)):
|
|
458
459
|
|
|
459
460
|
```
|
|
460
461
|
.claude/hooks/
|
|
461
462
|
├── secret-gate.js ← PreToolUse (Edit|Write|MultiEdit|NotebookEdit|Bash): cooperative, fail-OPEN block of secret-shaped content before it reaches the tree or a shell (task 1003125, ADR 0022)
|
|
463
|
+
├── run-node.sh ← the node resolver every hook command runs through (task 1003548): $OTB_NODE_BIN → PATH → known install dirs → nvm; one actionable line + exit 1 when none is found. 1,055 hook runs had died silently with `node: command not found`
|
|
464
|
+
├── edit-hints.js ← PostToolUse (Edit|Write|MultiEdit): lints a saved SKILL.md's frontmatter (scripts/gds/skill-lint.js) and names the generator an edited path makes stale (copy-registry, repo-map blocks, file-map, openapi, §13 snippet) with its command (task 1003548)
|
|
462
465
|
├── git-guard.js ← PreToolUse (Bash): same contract as secret-gate — blocks `git stash` (repo-global stack, learning 1000215), raw `git worktree remove` (follows the node_modules junction, task 1002809), `git push origin main` (PR-only) and `git branch -D`, each with its remedy; kill-switch BONGOS_GIT_GUARD_OFF=1 (task 1003365)
|
|
463
466
|
├── _core-resolve.js ← shared helper, not a hook: locates a core module/script across the monolith vs `node_modules/@cloudbongos/core` layouts; required by most hooks here and by four routine scripts under `.claude/scheduled-tasks/`
|
|
464
467
|
├── cascade-route.js ← PreToolUse (Task|Agent): cheapest-sufficient start-model advisory for a subagent spawn (ADR 0024); inert unless OTB_AUTONOMY_ENABLED=1
|
|
@@ -1487,5 +1487,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1487
1487
|
landed since 1.19.523 with no explicit bump. run 33818285201. (task 1002620)
|
|
1488
1488
|
1.19.525 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1489
1489
|
landed since 1.19.524 with no explicit bump. run 33823218193. (task 1002620)
|
|
1490
|
+
1.19.526 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1491
|
+
landed since 1.19.525 with no explicit bump. run 33824700579. (task 1002620)
|
|
1492
|
+
1.19.527 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1493
|
+
landed since 1.19.526 with no explicit bump. run 33827382028. (task 1002620)
|
|
1490
1494
|
---------------------------------------------------------------------------
|
|
1491
1495
|
```
|
|
@@ -101,3 +101,49 @@ because the server already landed the task first (reconcile-on-read or the ~5-mi
|
|
|
101
101
|
`ship.js` now normalizes both API error shapes and **verifies the live task status with a GET
|
|
102
102
|
before reporting failure** (task 1002809) — a verified `shipped` prints as the success it is. If
|
|
103
103
|
you see a ship-call failure from older tooling, check `GET /tasks/<id>` before reacting.
|
|
104
|
+
|
|
105
|
+
## The hook shell can start without node — hooks were silently off (task 1003548)
|
|
106
|
+
|
|
107
|
+
- **Symptom:** every hook run reports `Failed with non-blocking status code: /usr/bin/bash: line 1: node: command not found`. A transcript scan of 2026-09-01..03 counted **1,055** such runs, which meant `secret-gate`, `git-guard`, `worktree-guard`, `lifecycle-card` and `conductor` were off for whole sessions while Claude Code showed only a muted warning.
|
|
108
|
+
- **Root cause:** Claude Code runs hook commands through Git Bash, and on this machine that shell intermittently starts with a PATH that predates the Node install. The Bash tool inherits the same PATH, which is why sessions also prepended `export PATH="/c/Program Files/nodejs:…"` to 1,404 commands.
|
|
109
|
+
- **What the tooling does now:** every hook command in `.claude/settings.json` goes through `.claude/hooks/run-node.sh`, which finds node on PATH or in the known install dirs (`C:\Program Files\nodejs`, `%LOCALAPPDATA%\Programs\nodejs`, nvm, Homebrew, `/usr/local/bin`) and, when it truly cannot, prints one line naming the fix instead of failing quietly. Override with `OTB_NODE_BIN` in the `env` block of `.claude/settings.local.json` if your node lives somewhere exotic.
|
|
110
|
+
- **For the Bash tool itself:** check once with `command -v node`; prepend PATH only if it is missing. Do not put the prepend on every command — see the next section for why that also collides with worktree isolation.
|
|
111
|
+
|
|
112
|
+
## Compound `export PATH=…; cd …; …` commands collide with worktree isolation
|
|
113
|
+
|
|
114
|
+
- **Symptom:** `This session is isolated in the worktree … but this command is too complex to verify that it stays inside` (25×), `… changes directory to the shared checkout` (7×), `… runs export PATH= with a value every later program …` (6×), `… runs a command whose name is computed at runtime` (3×). 1,931 of 2,370 Bash calls in the scan were compound.
|
|
115
|
+
- **Root cause:** a session that entered a worktree (EnterWorktree) has Claude Code checking that each Bash command stays inside it. A chain of `export … ; cd … && …` defeats the checker, so it refuses the whole command.
|
|
116
|
+
- **Fix:** one purpose per command; use paths relative to the worktree (the Bash tool's cwd is the worktree); no `export PATH=` prefix once node is on PATH; never `cd` into the main checkout from a worktree session (learning 2026-08-29: `../../../` checks and dirties the MAIN checkout — run the worktree's own `scripts/gds/` copy).
|
|
117
|
+
|
|
118
|
+
## `git fetch` / `git push` / `ls-remote` fail here BY DESIGN
|
|
119
|
+
|
|
120
|
+
- This machine holds no GitHub push credential (ADR 0031 §6 / ADR 0055). The scan found 22 `git fetch` attempts in three days, all wasted. Landing is server-mediated: `/builder-ship` uploads a bundle and the server pushes, opens the PR and lands it. To see whether work reached GitHub use `GET /api/gds/tasks/:id/publish-status`, never a raw git probe. To base a new worktree on fresh main, `node scripts/gds/worktree.js add <task-id> --base origin/main` (worktree.js fetches through the sanctioned path).
|
|
121
|
+
|
|
122
|
+
## A worktree without `node_modules` fails 300+ tests — and the count is not the signal
|
|
123
|
+
|
|
124
|
+
- **Symptom:** `Cannot find module 'pg'` / `'express'`; `npm test` reports ~300 FAILED; 9 `npm install`s and 2 dead junctions in three days of transcripts.
|
|
125
|
+
- **What the tooling does now:** SessionStart prints one `[otb] node_modules is missing …` line (from `scripts/gds/preflight.js`'s check) when neither the worktree nor the main checkout has it. Install ONCE in the main checkout (`npm ci`); inside-repo worktrees resolve from it.
|
|
126
|
+
- **Rule:** when comparing a local run against CI, compare failing test NAMES against an untouched checkout of `origin/main`, never counts — a ~25-failure Windows baseline has masked real regressions (session logs 2026-08-14, 2026-08-19). `node scripts/gds/ship-check.js --tests` prints the names.
|
|
127
|
+
|
|
128
|
+
## `tests/git_merge_regen.mjs` — when it reds, suspect `.gitattributes` first, not Windows
|
|
129
|
+
|
|
130
|
+
This test is the consistency gate between **two** sources that must agree, and it is the one that
|
|
131
|
+
caught a real bug during task 1003548, so read its assertion before blaming the platform:
|
|
132
|
+
|
|
133
|
+
- **Every path declared `merge=otb-regen` in `.gitattributes` must be in `REGENERABLE`**
|
|
134
|
+
(`scripts/gds/git-merge-regen.js`, derived from `gen-repo-map.NESTED_DIRS` + the whole-file set).
|
|
135
|
+
Drop a doc from the generators and its `.gitattributes` line has to come out in the same commit —
|
|
136
|
+
otherwise a merge on that path routes through a driver that cannot regenerate it. The failure reads
|
|
137
|
+
`.gitattributes declares merge=otb-regen for <path> but REGENERABLE … does not know it`.
|
|
138
|
+
- **A `real git merge must auto-resolve via the driver; got 1` failure** with a mangled driver path
|
|
139
|
+
(`Cannot find module 'C:\…\Temp\otb-gitmerge-*\<redacted>…'`) has been seen on this
|
|
140
|
+
machine and then stopped reproducing in all three trees (worktree, baseline worktree, main
|
|
141
|
+
checkout) — so treat it as intermittent, not as a standing Windows defect. Do NOT record it as a
|
|
142
|
+
known baseline failure: doing so is what let a genuine regression hide behind it for a whole
|
|
143
|
+
session. Re-run it, and follow the naming rule above — if it fails on your branch and passes on an
|
|
144
|
+
untouched `origin/main` worktree, it is yours.
|
|
145
|
+
|
|
146
|
+
## CRLF: a multi-line find silently misses, and a whole-file diff is usually line endings
|
|
147
|
+
|
|
148
|
+
- Several hall files (`people.css`, `people.html`, `style.css`, the privacy spec) and every `.claude/skills/*/SKILL.md` are CRLF; `scripts/gds/*.js` and the tests are LF. An exact-string patch must detect the file's EOL before joining lines (`text.includes('\r\n') ? '\r\n' : '\n'`), or the find matches nothing and the edit is a silent no-op (session log 2026-08-24).
|
|
149
|
+
- A `git diff --stat` that touches every line of a file you barely edited is an EOL flip — check with `git diff --stat --ignore-cr-at-eol`, and restore the original EOL before committing.
|
|
@@ -58,7 +58,7 @@ const GENERATORS = [
|
|
|
58
58
|
owns: [
|
|
59
59
|
{ file: 'src/bongos/CLAUDE.md', scope: 'fence', marker: 'BEGIN GENERATED REPO-MAP' },
|
|
60
60
|
{ file: 'src/bongos/routes/CLAUDE.md', scope: 'fence', marker: 'BEGIN GENERATED REPO-MAP' },
|
|
61
|
-
|
|
61
|
+
// scripts/gds/CLAUDE.md left NESTED_DIRS in task 1003548 — it carries no fence any more.
|
|
62
62
|
],
|
|
63
63
|
},
|
|
64
64
|
{
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/lifecycle/cascade.js — a ship in one role GENERATES work in another
|
|
4
|
+
// (task 1003549, goal 1000074's the artist's loop; the split R04 asked for).
|
|
5
|
+
//
|
|
6
|
+
// WHAT WAS MISSING, precisely — the gap is PUSH vs PULL. Two surfaces already
|
|
7
|
+
// let one role take work from another, and both are LISTINGS of rows somebody
|
|
8
|
+
// had already created: the spark queue (goal 1000062 / task 1002921) returns
|
|
9
|
+
// open sparks awaiting development, and the copy-desk flag queue (R02 / task
|
|
10
|
+
// 1003113) returns surfaces anyone flagged. A role could READ what another
|
|
11
|
+
// produced; nothing GENERATED work on a state transition. R04's requirement is
|
|
12
|
+
// the other direction — "every ship with copy or visuals generates one [review
|
|
13
|
+
// task]", automatic, never hand-filed — and there was no mechanism for it.
|
|
14
|
+
//
|
|
15
|
+
// WHAT THIS IS, AND WHAT IT DELIBERATELY IS NOT. It is a DECLARATION TABLE plus
|
|
16
|
+
// a runner, not a rules engine. A cascade is one frozen entry saying: when a
|
|
17
|
+
// shipped task looks like THIS, file THAT task, for THAT discipline. There is no
|
|
18
|
+
// condition language, no stored rules, no per-project configuration and no UI —
|
|
19
|
+
// every one of those is a thing to add when a second and third cascade exist and
|
|
20
|
+
// have shown what they have in common. Today `CASCADES` ships EMPTY: the
|
|
21
|
+
// mechanism, its idempotency and its non-blocking property are proved by the
|
|
22
|
+
// tests' own declarations, and R04 (task 1003115) writes the first production
|
|
23
|
+
// entry. A mechanism whose first real rule is written by the task that needs it
|
|
24
|
+
// is honest; one that ships a rule nobody asked for is a guess.
|
|
25
|
+
//
|
|
26
|
+
// THE FOUR PROPERTIES THAT MAKE IT SAFE, each of which the tests pin:
|
|
27
|
+
//
|
|
28
|
+
// 1. IT NEVER BLOCKS THE SHIP IT REACTS TO. The runner is driven by the
|
|
29
|
+
// `task.shipped` kernel event, which fires POST-COMMIT and whose emitters
|
|
30
|
+
// already wrap it in try/catch; on top of that `runCascadesForShip` catches
|
|
31
|
+
// everything and returns a report. A cascade that throws, or a database
|
|
32
|
+
// that is down, changes nothing about the ship — the work is landed, graded
|
|
33
|
+
// and paid before this code runs at all. This is not a nicety: ADR 0241
|
|
34
|
+
// settled that an artist's review may hold a DEPLOY and never a builder's
|
|
35
|
+
// ship, and a generator that could fail a ship would smuggle that gate back
|
|
36
|
+
// in through the back door.
|
|
37
|
+
//
|
|
38
|
+
// 2. IT IS IDEMPOTENT, using machinery that already exists. Each generated
|
|
39
|
+
// task carries `source: 'cascade'` and `source_ref: cascade-<key>-<id>`,
|
|
40
|
+
// and `createTask`'s (source, source_ref) idempotency read returns the
|
|
41
|
+
// original row instead of minting a second one. So a reconciler re-sweep, a
|
|
42
|
+
// re-ship, or both emitters firing for the same task are no-ops. The
|
|
43
|
+
// runner ALSO checks first, so the common case is a read rather than a
|
|
44
|
+
// write that gets rejected.
|
|
45
|
+
//
|
|
46
|
+
// 3. IT SAYS WHERE IT CAME FROM. Every generated task inherits the source
|
|
47
|
+
// task's `goal_id` and `version_id`, carries the originating task's id
|
|
48
|
+
// inside its `source_ref`, and opens its description with that id and the
|
|
49
|
+
// rule key. A task that appears on the board with no author has to be able
|
|
50
|
+
// to explain itself, or the next person deletes it. NOT `parent_task_id`:
|
|
51
|
+
// that column models a SUBTASK, and cascaded work is not a part of the ship
|
|
52
|
+
// that triggered it — it is separate work, for a different role, that
|
|
53
|
+
// outlives its trigger. The attribution belongs in the ref and the prose.
|
|
54
|
+
//
|
|
55
|
+
// 4. IT FILES AT `backlog`, NEVER `ready`. Generated work is a REQUEST, and
|
|
56
|
+
// it goes through the board's ordinary triage like a copy proposal (ADR
|
|
57
|
+
// 0233 §6) or any capture. Auto-filing claimable work would let a rule
|
|
58
|
+
// nobody re-read mint claims.
|
|
59
|
+
//
|
|
60
|
+
// PURE ABOVE `runCascadesForShip`: the declaration shape, the matching and the
|
|
61
|
+
// task spec are all synchronous functions over plain objects, so a cascade can
|
|
62
|
+
// be pinned without a database.
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// The declaration
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
// A cascade rule:
|
|
69
|
+
// key string, stable + unique. It is half the source_ref, so RENAMING
|
|
70
|
+
// a key re-files every task the rule ever generated. Treat it as
|
|
71
|
+
// permanent.
|
|
72
|
+
// discipline the role the generated work is FOR ('artist', 'ideator',
|
|
73
|
+
// 'engineer'). This is the whole point of the mechanism: the
|
|
74
|
+
// discipline of the generated task is not the discipline of the
|
|
75
|
+
// ship that caused it.
|
|
76
|
+
// when(task) a predicate over the shipped task row. Must be pure and total —
|
|
77
|
+
// a throw is caught and treated as "no match", because a broken
|
|
78
|
+
// predicate must not stop the other rules.
|
|
79
|
+
// title(task) / body(task) the generated task's title and description.
|
|
80
|
+
// kind optional task kind; defaults to 'chore'.
|
|
81
|
+
//
|
|
82
|
+
// Production rules live here. It is empty by design — see the header.
|
|
83
|
+
const CASCADES = Object.freeze([]);
|
|
84
|
+
|
|
85
|
+
// The (source, source_ref) pair the generated task is keyed on. `source` is a
|
|
86
|
+
// constant so every cascade-generated row is findable as a class; the ref pins
|
|
87
|
+
// it to one rule and one originating ship.
|
|
88
|
+
const CASCADE_SOURCE = 'cascade';
|
|
89
|
+
|
|
90
|
+
function cascadeSourceRef(ruleKey, sourceTaskId) {
|
|
91
|
+
return `cascade-${ruleKey}-${sourceTaskId}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Validate a declaration at the moment it is used rather than trusting it. A
|
|
95
|
+
// malformed rule is a wiring bug that should be loud in a test, not a silent
|
|
96
|
+
// missing task in production six weeks later.
|
|
97
|
+
function assertRuleShape(rule) {
|
|
98
|
+
const problems = [];
|
|
99
|
+
if (!rule || typeof rule !== 'object') return ['rule is not an object'];
|
|
100
|
+
if (typeof rule.key !== 'string' || !rule.key) problems.push('key must be a non-empty string');
|
|
101
|
+
if (typeof rule.discipline !== 'string' || !rule.discipline) problems.push('discipline must be a non-empty string');
|
|
102
|
+
if (typeof rule.when !== 'function') problems.push('when must be a function');
|
|
103
|
+
if (typeof rule.title !== 'function') problems.push('title must be a function');
|
|
104
|
+
if (typeof rule.body !== 'function') problems.push('body must be a function');
|
|
105
|
+
return problems;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Every rule in a table, checked, with duplicate keys reported — two rules
|
|
109
|
+
// sharing a key would collide on source_ref and silently suppress one another.
|
|
110
|
+
function validateCascades(rules = CASCADES) {
|
|
111
|
+
const errors = [];
|
|
112
|
+
const seen = new Set();
|
|
113
|
+
rules.forEach((rule, i) => {
|
|
114
|
+
for (const p of assertRuleShape(rule)) errors.push(`cascade[${i}]: ${p}`);
|
|
115
|
+
const key = rule && rule.key;
|
|
116
|
+
if (typeof key === 'string' && key) {
|
|
117
|
+
if (seen.has(key)) errors.push(`cascade[${i}]: duplicate key "${key}" — keys are half the source_ref and must be unique`);
|
|
118
|
+
seen.add(key);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
return errors;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// Matching + the generated task spec
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
// The task rows a shipped task cascades into. Pure: no database, no clock, no
|
|
129
|
+
// side effect — hand it a task-shaped object and a rule table and it returns
|
|
130
|
+
// the specs that would be filed.
|
|
131
|
+
//
|
|
132
|
+
// A rule whose `when` THROWS is treated as no-match and reported in `errors`,
|
|
133
|
+
// never propagated: one broken predicate must not cost the other rules their
|
|
134
|
+
// tasks, and it certainly must not reach the ship.
|
|
135
|
+
function cascadesFor(task, rules = CASCADES) {
|
|
136
|
+
const matched = [];
|
|
137
|
+
const errors = [];
|
|
138
|
+
if (!task || task.id == null) return { matched, errors: ['no task'] };
|
|
139
|
+
for (const rule of rules) {
|
|
140
|
+
const shape = assertRuleShape(rule);
|
|
141
|
+
if (shape.length) { errors.push(`cascade "${rule && rule.key}": ${shape.join('; ')}`); continue; }
|
|
142
|
+
let hit = false;
|
|
143
|
+
try {
|
|
144
|
+
hit = rule.when(task) === true;
|
|
145
|
+
} catch (e) {
|
|
146
|
+
errors.push(`cascade "${rule.key}": when() threw (${e && e.message}) — treated as no match`);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (!hit) continue;
|
|
150
|
+
try {
|
|
151
|
+
matched.push({ rule, spec: cascadeTaskSpec(rule, task) });
|
|
152
|
+
} catch (e) {
|
|
153
|
+
errors.push(`cascade "${rule.key}": title/body threw (${e && e.message})`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return { matched, errors };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// The createTask payload for one matched rule. The attribution preamble is
|
|
160
|
+
// prepended here rather than left to each rule, so no rule can forget it.
|
|
161
|
+
function cascadeTaskSpec(rule, task) {
|
|
162
|
+
const body = String(rule.body(task) || '').trim();
|
|
163
|
+
const preamble =
|
|
164
|
+
`Filed automatically when task ${task.id} shipped — the "${rule.key}" cascade `
|
|
165
|
+
+ `(modules/lifecycle/cascade.js). Nobody typed this; it is a REQUEST, filed at backlog, `
|
|
166
|
+
+ `and it goes through the board's ordinary triage. If the rule that made it is wrong, fix `
|
|
167
|
+
+ `the rule rather than only this task.`;
|
|
168
|
+
return {
|
|
169
|
+
versionId: task.version_id,
|
|
170
|
+
goalId: task.goal_id != null ? Number(task.goal_id) : null,
|
|
171
|
+
title: String(rule.title(task) || '').trim(),
|
|
172
|
+
description: `${preamble}\n\n${body}`,
|
|
173
|
+
discipline: rule.discipline,
|
|
174
|
+
kind: rule.kind || 'chore',
|
|
175
|
+
status: 'backlog',
|
|
176
|
+
source: CASCADE_SOURCE,
|
|
177
|
+
sourceRef: cascadeSourceRef(rule.key, task.id),
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
// The runner — the only part that touches a database
|
|
183
|
+
// ---------------------------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
// React to one shipped task. Returns a report, NEVER throws and never rejects:
|
|
186
|
+
// property 1 in the header. `deps` is injected so the whole runner is testable
|
|
187
|
+
// with two stubs and no database.
|
|
188
|
+
//
|
|
189
|
+
// deps.getTask(id) -> the full task row (the event payload is
|
|
190
|
+
// the ship RETURNING row and carries no
|
|
191
|
+
// goal_id / version_id / discipline)
|
|
192
|
+
// deps.findTaskBySourceRef({...}) -> an existing row or null
|
|
193
|
+
// deps.createTask(spec) -> the created row
|
|
194
|
+
// deps.log -> optional { info, error }
|
|
195
|
+
async function runCascadesForShip(shipped, deps = {}) {
|
|
196
|
+
const report = { created: [], skipped: [], errors: [] };
|
|
197
|
+
const log = deps.log || null;
|
|
198
|
+
try {
|
|
199
|
+
const id = shipped && (shipped.id ?? shipped.task_id);
|
|
200
|
+
if (id == null) { report.errors.push('cascade: ship payload carried no task id'); return report; }
|
|
201
|
+
|
|
202
|
+
const task = deps.getTask ? await deps.getTask(id) : null;
|
|
203
|
+
if (!task) { report.errors.push(`cascade: task ${id} not readable after ship`); return report; }
|
|
204
|
+
|
|
205
|
+
const { matched, errors } = cascadesFor(task, deps.rules || CASCADES);
|
|
206
|
+
report.errors.push(...errors);
|
|
207
|
+
|
|
208
|
+
for (const { rule, spec } of matched) {
|
|
209
|
+
try {
|
|
210
|
+
const existing = deps.findTaskBySourceRef
|
|
211
|
+
? await deps.findTaskBySourceRef({ source: CASCADE_SOURCE, sourceRef: spec.sourceRef })
|
|
212
|
+
: null;
|
|
213
|
+
if (existing) { report.skipped.push({ key: rule.key, taskId: existing.id, reason: 'already filed' }); continue; }
|
|
214
|
+
const created = await deps.createTask(spec);
|
|
215
|
+
report.created.push({ key: rule.key, taskId: created && created.id, discipline: rule.discipline });
|
|
216
|
+
log?.info?.(`[cascade] task ${id} shipped → filed ${created && created.id} for ${rule.discipline} ("${rule.key}")`);
|
|
217
|
+
} catch (e) {
|
|
218
|
+
// One rule failing costs that rule its task and nothing else.
|
|
219
|
+
report.errors.push(`cascade "${rule.key}": ${e && e.message}`);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
} catch (e) {
|
|
223
|
+
report.errors.push(`cascade: ${e && e.message}`);
|
|
224
|
+
}
|
|
225
|
+
if (report.errors.length) log?.error?.(`[cascade] ${report.errors.length} problem(s): ${report.errors.join(' | ')}`);
|
|
226
|
+
return report;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
module.exports = {
|
|
230
|
+
CASCADES,
|
|
231
|
+
CASCADE_SOURCE,
|
|
232
|
+
cascadeSourceRef,
|
|
233
|
+
assertRuleShape,
|
|
234
|
+
validateCascades,
|
|
235
|
+
cascadesFor,
|
|
236
|
+
cascadeTaskSpec,
|
|
237
|
+
runCascadesForShip,
|
|
238
|
+
};
|