@bongos/core 1.19.287 → 1.19.288
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 +26 -16
- package/.claude/skills/goal-review/SKILL.md +15 -2
- package/docs/adr/0183-criteria-close-themselves.md +58 -0
- package/docs/adr/README.md +1 -0
- package/modules/lifecycle/db.js +1 -1
- package/modules/lifecycle/done-when.js +145 -0
- package/modules/lifecycle/publish-reconciler.js +51 -6
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +3 -1
- package/tests/auto_satisfy_criteria.mjs +212 -0
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.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.288",
|
|
6
|
+
"core_contract": "1.19.288",
|
|
7
|
+
"source_commit": "9ad9d2000bfddb7710afeb83f8a33e072ffee604",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-
|
|
9
|
+
"built_at": "2026-08-22T02:33:44.096Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
|
-
"docs_redacted":
|
|
12
|
+
"docs_redacted": 320,
|
|
13
13
|
"agent_docs_stubbed": 22,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1386,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 1728,
|
|
20
|
+
"tree_sha256": "40a6f311bd544cbfef6bc326a7d0eb6767685f660dea1dbb1ece69f205603cce",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -152,7 +152,7 @@
|
|
|
152
152
|
{
|
|
153
153
|
"path": ".claude/skills/goal-review/SKILL.md",
|
|
154
154
|
"mode": "0000644",
|
|
155
|
-
"sha256": "
|
|
155
|
+
"sha256": "71bc5b89e2b2793a8f30c0510f4c30249436a9dd26c0eda6d1bd0634620c26ce"
|
|
156
156
|
},
|
|
157
157
|
{
|
|
158
158
|
"path": ".claude/skills/grade-audit/SKILL.md",
|
|
@@ -1394,10 +1394,15 @@
|
|
|
1394
1394
|
"mode": "0000644",
|
|
1395
1395
|
"sha256": "119637165d2d6b89f8c00afccd07d9f073629f1502f4b2f2cdc673001b2f8d2a"
|
|
1396
1396
|
},
|
|
1397
|
+
{
|
|
1398
|
+
"path": "docs/adr/0183-criteria-close-themselves.md",
|
|
1399
|
+
"mode": "0000644",
|
|
1400
|
+
"sha256": "e7d0245b21406ddcd118dce29ffb1df5a4797e33d66231aa6bc734316c19530f"
|
|
1401
|
+
},
|
|
1397
1402
|
{
|
|
1398
1403
|
"path": "docs/adr/README.md",
|
|
1399
1404
|
"mode": "0000644",
|
|
1400
|
-
"sha256": "
|
|
1405
|
+
"sha256": "5e8bcc7eff0a9940760952e4dffe3855474e506439d677625f411164ae226a35"
|
|
1401
1406
|
},
|
|
1402
1407
|
{
|
|
1403
1408
|
"path": "docs/api-reference.md",
|
|
@@ -3767,7 +3772,7 @@
|
|
|
3767
3772
|
{
|
|
3768
3773
|
"path": "modules/lifecycle/db.js",
|
|
3769
3774
|
"mode": "0000644",
|
|
3770
|
-
"sha256": "
|
|
3775
|
+
"sha256": "18b2053d9195936be86d98e00812ab69980986f91cf83bc9db054441036192fd"
|
|
3771
3776
|
},
|
|
3772
3777
|
{
|
|
3773
3778
|
"path": "modules/lifecycle/dead-deps.js",
|
|
@@ -3777,7 +3782,7 @@
|
|
|
3777
3782
|
{
|
|
3778
3783
|
"path": "modules/lifecycle/done-when.js",
|
|
3779
3784
|
"mode": "0000644",
|
|
3780
|
-
"sha256": "
|
|
3785
|
+
"sha256": "fab91acf54bea4fca254bfd420a9a934e6eae434101ff57ae2a1bed48d2342d5"
|
|
3781
3786
|
},
|
|
3782
3787
|
{
|
|
3783
3788
|
"path": "modules/lifecycle/est-advisory.js",
|
|
@@ -3847,7 +3852,7 @@
|
|
|
3847
3852
|
{
|
|
3848
3853
|
"path": "modules/lifecycle/publish-reconciler.js",
|
|
3849
3854
|
"mode": "0000644",
|
|
3850
|
-
"sha256": "
|
|
3855
|
+
"sha256": "900fc9ecfe91a0c1e9cd62b619f609c1416196d502c007d0bbb87c41387b6163"
|
|
3851
3856
|
},
|
|
3852
3857
|
{
|
|
3853
3858
|
"path": "modules/lifecycle/routes/analytics.js",
|
|
@@ -4512,12 +4517,12 @@
|
|
|
4512
4517
|
{
|
|
4513
4518
|
"path": "package-lock.json",
|
|
4514
4519
|
"mode": "0000644",
|
|
4515
|
-
"sha256": "
|
|
4520
|
+
"sha256": "d9e17adc0e87df8ff8a0c36f03b483d48824fe5eba09ff0859dbebad9f79a90c"
|
|
4516
4521
|
},
|
|
4517
4522
|
{
|
|
4518
4523
|
"path": "package.json",
|
|
4519
4524
|
"mode": "0000644",
|
|
4520
|
-
"sha256": "
|
|
4525
|
+
"sha256": "750961d5bd5a5377928326efbdd529d4d2c5be2ab7e864e55416645e97133086"
|
|
4521
4526
|
},
|
|
4522
4527
|
{
|
|
4523
4528
|
"path": "public-docs/index.html",
|
|
@@ -6162,7 +6167,7 @@
|
|
|
6162
6167
|
{
|
|
6163
6168
|
"path": "src/module-api.js",
|
|
6164
6169
|
"mode": "0000644",
|
|
6165
|
-
"sha256": "
|
|
6170
|
+
"sha256": "410c4b2cfa57fddbcfa3e4d6b2a66a8c3c286b49822cc49c53759f6fb6a6aea7"
|
|
6166
6171
|
},
|
|
6167
6172
|
{
|
|
6168
6173
|
"path": "src/module-loader/catalog.js",
|
|
@@ -6384,6 +6389,11 @@
|
|
|
6384
6389
|
"mode": "0000644",
|
|
6385
6390
|
"sha256": "4697e7e264023c148d1f18cac9695157de56b5dd3db4b60f2ab94d73a8d7af65"
|
|
6386
6391
|
},
|
|
6392
|
+
{
|
|
6393
|
+
"path": "tests/auto_satisfy_criteria.mjs",
|
|
6394
|
+
"mode": "0000644",
|
|
6395
|
+
"sha256": "1a5524b0e63631a0ebdc5e3e87c04eb862a9be7021b3b1caf5a44afb2dcb455b"
|
|
6396
|
+
},
|
|
6387
6397
|
{
|
|
6388
6398
|
"path": "tests/autonomous_fit_protected_paths.mjs",
|
|
6389
6399
|
"mode": "0000644",
|
|
@@ -1,13 +1,26 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: goal-review
|
|
3
|
-
description: Walk the Bongos criterion review queue
|
|
3
|
+
description: Walk the Bongos criterion review queue. As of task 1002069 (ADR 0183) this is NO LONGER A GATE — a criterion whose gating tasks have all shipped satisfies ITSELF, and a goal whose criteria are all satisfied achieves itself. What remains here is the residue automation deliberately refuses: chiefly a criterion whose linked tasks were ALL ABANDONED (nothing delivered), plus rejecting a criterion that is not actually met. Prompt to confirm (→ satisfied), reject, or note/defer. Triggers when the user says "/goal-review", "review goals", "review flagged criteria", "what criteria are ready to confirm", "confirm criteria", "walk the goal review queue", or runs as a scheduled daily task. Metic+ rank only (Metic and Archon; not Xenos).
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
You are running the **goal-review** session for this Cloud Bongos instance. It mirrors `/idea-triage`, but for the *closing* end of the work hierarchy (ADR 0086 §6): criteria that have **auto-flagged "met — pending review"** get walked one-at-a-time and dispositioned, so finished work actually closes out instead of sitting in limbo.
|
|
7
7
|
|
|
8
8
|
> **The other end of this lifecycle is [`/goal-create`](../goal-create/SKILL.md)** — it opens a goal (scope wall, criteria, seed tasks); this one closes it out.
|
|
9
9
|
|
|
10
|
-
**The auto-flag is derived, not stored.** When every task linked to a criterion has shipped — and the criterion has not yet been confirmed — the server flags it `pending_review` (deterministic from `task_criteria` + ship status; no manual propose step).
|
|
10
|
+
**The auto-flag is derived, not stored.** When every task linked to a criterion has shipped — and the criterion has not yet been confirmed — the server flags it `pending_review` (deterministic from `task_criteria` + ship status; no manual propose step). Confirming sets the criterion `satisfied=true`; confirming a goal's **last** unsatisfied criterion auto-flips the goal `open → achieved`.
|
|
11
|
+
|
|
12
|
+
## ⚠ This skill is no longer a GATE (task 1002069 / [ADR 0183](../../../docs/adr/<redacted>.md))
|
|
13
|
+
|
|
14
|
+
**A criterion now closes itself.** `doneWhen.autoSatisfyShippedCriteria` runs inside `shipTask`'s transaction (immediately, for the ship that completed it) and unscoped on every `publish-reconciler` tick (draining the backlog and self-healing). A goal whose criteria are all satisfied achieves itself the same way. Nobody has to run this skill for finished work to close — that dependence was producing a **lying board** (goal 1000072 measured `6/9 shipped, 0/5 criteria`).
|
|
15
|
+
|
|
16
|
+
So expect the queue to be **mostly empty**, and read what IS in it as a signal rather than a chore. Automation refuses exactly two shapes, and they are what you are here for:
|
|
17
|
+
|
|
18
|
+
1. **A criterion whose linked tasks were ALL ABANDONED.** Auto-close requires ≥1 task actually `shipped`, so cut work can never complete anything — but the review flag deliberately still surfaces it, because *somebody must decide whether the criterion still means anything now that its work is gone*. Options: confirm (it was satisfied another way), reject, or re-link it to the work that will actually deliver it. **This is the main reason the skill still exists.**
|
|
19
|
+
2. **A criterion with NO linked tasks.** Automation requires ≥1 linked task, so it never auto-closes. If a criterion is genuinely met by work that was never linked, link the tasks (better — it makes the board true) or confirm manually.
|
|
20
|
+
|
|
21
|
+
A criterion sitting here with shipped tasks and no explanation means the sweep has not run or errored — check the `publish-reconciler` logs rather than confirming by hand, or you will paper over a broken sweep.
|
|
22
|
+
|
|
23
|
+
**Rejecting is now the more valuable verdict.** Confirming mostly duplicates what automation already did; rejecting is information only a human has.
|
|
11
24
|
|
|
12
25
|
## Rank gate — Metic+ only
|
|
13
26
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# ADR 0183 — A done-when criterion closes itself; the review queue becomes a place to look
|
|
2
|
+
|
|
3
|
+
- **Status:** accepted
|
|
4
|
+
- **Date:** 2026-08-21
|
|
5
|
+
- **Task:** 1002069 (owner request 2026-07-05, scope owner-confirmed)
|
|
6
|
+
- **Supersedes part of:** [ADR 0086](<redacted>.md) §6 / BV1.R63 — the criterion auto-flag stays, but confirming it is no longer the only way a criterion closes.
|
|
7
|
+
|
|
8
|
+
## Context
|
|
9
|
+
|
|
10
|
+
Under ADR 0086 §6 a criterion became `satisfied` in exactly one way: a Metic+ called `POST /done-when/:id/satisfy`, and if it was the goal's last criterion the goal flipped `open→achieved`. The "met — pending review" flag was **derived** — every gating task shipped, nothing stored — and `/goal-review` walked that queue so a human could confirm each one.
|
|
11
|
+
|
|
12
|
+
That gate cost more than it bought. The signal it gated on is already unambiguous: *every task under this criterion has shipped.* A human confirming that adds no information, and when nobody runs `/goal-review` the consequence is not "a careful pause" but a **lying board** — criteria sitting unsatisfied and goals sitting `open` long after the work landed. The owner asked for the flip to be automatic (2026-07-05).
|
|
13
|
+
|
|
14
|
+
This session supplied the evidence for how bad the drift gets: goal 1000072 read `6/9 shipped, 0/5 criteria` — six tasks landed, not one criterion moved.
|
|
15
|
+
|
|
16
|
+
## Decision
|
|
17
|
+
|
|
18
|
+
**A criterion satisfies itself once its gating set is fully delivered, and a goal achieves itself once all its criteria are satisfied.** One helper, `doneWhen.autoSatisfyShippedCriteria(exec, { taskId })`, run from two places:
|
|
19
|
+
|
|
20
|
+
| Where | Scope | Why both |
|
|
21
|
+
|---|---|---|
|
|
22
|
+
| `shipTask`, inside its transaction | `{ taskId }` — only what this ship gates | Immediate. A builder shipping a goal's last task sees the goal close *now*. On the txn client, so a rolled-back ship cannot leave a goal achieved. |
|
|
23
|
+
| `publish-reconciler` tick, every ~5 min | `{}` — every unsatisfied criterion | **Backlog drain** (criteria whose tasks shipped before this existed — nothing will ship against them again) and **self-heal** (a criterion linked after its tasks shipped, a hand-edited status, a ship whose best-effort in-txn close was skipped). |
|
|
24
|
+
|
|
25
|
+
`exec` is caller-supplied on purpose; the helper never reaches for a module pool. It is idempotent — it only reads `satisfied = false` rows — which is what makes an every-tick sweep safe.
|
|
26
|
+
|
|
27
|
+
`/goal-review` is **not** deleted. It stops being a gate and becomes a place to look: what closed, and the ambiguous cases automation deliberately refuses (below).
|
|
28
|
+
|
|
29
|
+
### The delivery guard, and a deliberate asymmetry
|
|
30
|
+
|
|
31
|
+
Auto-close requires **both**:
|
|
32
|
+
|
|
33
|
+
1. no linked task outside `{shipped, abandoned}` — nothing is still pending; and
|
|
34
|
+
2. **at least one linked task actually `shipped`** — something was delivered.
|
|
35
|
+
|
|
36
|
+
The second is not redundant, and it is the whole reason this ADR exists rather than a one-line commit. Since [task 1002651](https://cloudbongos.com/builders#/task/1002651) made `abandoned` terminal-not-pending, a criterion whose linked tasks were **all cut** satisfies condition 1 *vacuously* — every task is accounted for, none is pending. Automating on condition 1 alone would satisfy criteria on **zero delivered work** and cascade their goals to `achieved`. Measured, not hypothetical: `buildCriterionProgress` returns `pending_review: true` for exactly that shape today.
|
|
37
|
+
|
|
38
|
+
**But `pending_review` keeps that wider meaning.** It answers "should a human look at this?", and task 1002651 chose on purpose that an all-abandoned criterion surfaces there — its work was cut, so someone must decide whether the criterion still means anything. Auto-close answers the narrower "may this close itself, unattended?"
|
|
39
|
+
|
|
40
|
+
> Everything that auto-closes was flagged for review. Not everything flagged for review auto-closes.
|
|
41
|
+
|
|
42
|
+
An earlier draft of this task "aligned" the two predicates and broke 1002651's intent; `tests/criterion_progress.mjs` caught it. **Do not re-align them.** The gap is the design.
|
|
43
|
+
|
|
44
|
+
## Consequences
|
|
45
|
+
|
|
46
|
+
- **Criteria and goals stop lying by default.** No cadence discipline required.
|
|
47
|
+
- **`/goal-review` keeps a real job** — the all-abandoned case, and any criterion a human wants to reject.
|
|
48
|
+
- **The manual `POST /satisfy` route is untouched**, still Metic+. It remains the way to close a criterion that automation cannot judge (e.g. one with no linked tasks at all — automation requires ≥1, so a task-less criterion never auto-closes).
|
|
49
|
+
- **A close is only as good as its task links.** Auto-satisfy cannot help a criterion nothing is linked to; goal 1000072's five criteria have zero linked tasks and will stay `0/5` until they are linked. Automating the flip makes that gap *more* visible, not less — which is the point.
|
|
50
|
+
- **`achieveGoalIfComplete` (db.js) now has a sibling** that re-implements its rules in SQL on a caller's `exec`, because it reads through the module pool and could not see an uncommitted in-txn flip. The duplication is deliberate and noted at both sites; collapsing it would mean either a require cycle (`done-when` ↔ `db`) or moving goal reads out of `db.js`.
|
|
51
|
+
- **Observability:** the in-txn close logs from `closeCompletedWorkForShip`, which means it prints *before* commit — the one wart, accepted and documented there, because threading the summary back out through `withTx` would have cost ~5 lines in the over-threshold `db.js` (shrink-only ratchet policy).
|
|
52
|
+
|
|
53
|
+
## Alternatives rejected
|
|
54
|
+
|
|
55
|
+
- **Sweep only, no ship hook.** Simpler, but a builder who ships a goal's last task would wait up to 5 minutes to see it close — losing the moment that makes the board feel alive.
|
|
56
|
+
- **Ship hook only, no sweep.** Leaves every pre-existing completed criterion unsatisfied forever, since nothing will ship against them again.
|
|
57
|
+
- **A stored `pending_review` column.** ADR 0086 §6 deliberately derives the flag; storing it adds a second source of truth to keep in sync.
|
|
58
|
+
- **Deleting `/goal-review`.** It still has the all-abandoned case and rejection to handle.
|
package/docs/adr/README.md
CHANGED
|
@@ -256,5 +256,6 @@ This keeps the decision history honest and traceable.
|
|
|
256
256
|
| 0180 | [**Dependency intake & drift policy: baseline-ratchet audit gate, install scripts off, cooldown via patrol, OIDC publish** ([task 1003127](https://cloudbongos.com/builders#/task/1003127), goal 1000076). The red-team baseline audit found 18 advisories (2 high) sitting unwatched in 9 production deps, the `undici ^6.27.0` override pinned INSIDE a vulnerable range while also breaking Node 26 CLIs, and nothing gated what an agent may install — against a 2025 npm threat wave (chalk/debug, Shai-Hulud, Nx) that ran on postinstall scripts and long-lived publish tokens. Four decisions. (1) `scripts/gds/audit-gate.js` runs in the required `unit` check: a high/critical advisory NOT in the committed `config/audit-baseline.json` fails the merge gate; the baseline is **shrink-only pre-gate debt** (10 findings at adoption) — adding an entry to silence a NEW advisory is out of policy. Fail-OPEN on registry/infra errors, fail-CLOSED on findings: the gate catches new advisories, it does not prove registry uptime. Notably a blanket `npm audit fix` cannot even run here (`workspace:` protocol, EUNSUPPORTEDPROTOCOL) — fixes are explicit tasks. (2) `.npmrc ignore-scripts=true`: the tree needs no install-lifecycle scripts — sharp installs prebuilds via optionalDependencies, proven by a script-less `npm ci` + full 430-test suite; a future dep needing one is an ADR-level exception. (3) Update cooldown is PATROL-enforced (task 1003128 flags versions younger than 7 days + stale overrides + action-pin drift) rather than `.npmrc`-enforced, because CI's npm 10 warns on the npm-11 `minimum-release-age` key for every builder; move it into `.npmrc` when the toolchain floor reaches npm ≥ 11.10. (4) Publish auth moves to npm **trusted publishing (OIDC)** — needs a one-time owner registration on npmjs.com (blocker filed); until then the granular 90-day `NPM_TOKEN` is a documented exception.](<redacted>.md) | devsecops / supply chain |
|
|
257
257
|
| 0181 | [**Teardown accepts every state but `torn_down`, and deliberately keeps the address** ([task 1003042](https://cloudbongos.com/builders#/task/1003042), goal 1000058). Walk finding N5: an instance at `status='error'` could never be removed — the owner's teardown answered 409 `not_teardownable` and the archon `force-teardown`, which exists as the escape hatch, answered the SAME refusal, because both read one hand-kept whitelist `['provisioning','active','tearing_down']` that omitted `requested` and `error`. No role had a path out, so errored rows accumulated permanently (instance 15 `swag` from 2026-08-07). The whitelist justified itself as "nothing running to tear down", which is FALSE for `error`: a provision failing mid-run lands there AFTER it may have created the database, taken a port, and written the systemd unit, Caddy block and DNS record — a partial standup is the thing most in need of reclaiming. `TEARDOWN_FROM` is now DERIVED from `INSTANCE_STATES` minus `torn_down`, so a future state is teardownable by DEFAULT — the bug was an allow-list that silently omitted states, and for a failure mode whose signature is "a row with no exit", the safe default is that an exit exists. The runner needed no change (its steps are each `allowFail`); only the web guard refused. The one surviving refusal is now ONE shared helper: the copies had drifted, and force-teardown's positional `res.fail` form put the bare code `not_teardownable` on the wire as the human text. **The `port` and `domain` are deliberately NOT released** — releasing the port alone would be WORSE than the status quo, because the `<slug>.caddy` block survives teardown and proxies the hostname to a PORT NUMBER, so a later instance given that port would answer at the torn-down project's still-live address (verified live: two projects torn down 2026-08-13 still resolved on 2026-08-19). Holding the port is what keeps the address inert at a harmless 502; releasing both is [task 1002897](https://cloudbongos.com/builders#/task/1002897). Retention is also correct for revive, which re-uses the same hostname. Copy was corrected to match: it had promised "the address stops answering", which was never true.](0181-teardown-state-gate-and-address-retention.md) | provisioning / lifecycle |
|
|
258
258
|
| 0182 | [**Project visibility is TWO axes: a derived publish gate and a stored join door** ([task 1002320](https://cloudbongos.com/builders#/task/1002320), goal 1000046). Goal 1000046 seeded 31 tasks each citing "per R01" — this ADR — so nothing in the goal could start until it landed: it gated **29 open tasks**, the largest single chokepoint in the repo, while itself parked at `backlog` with no dependencies. The spec its planning task cites was never written, so this is the design of record. The goal's criterion says **four** states (Public/Private/Stealth/Unlisted) and R04 says **three** stored plus a derived dark-matter; both are right and conflating them is the trap. There are two INDEPENDENT axes: **publishability** (DERIVED from the identity gate — name + description, per the owner interview) decides whether a project is on the map at all, and **the join door** (STORED `visibility ∈ public|private|stealth`, owner-set) decides how it renders and who gets in. Four user-facing states = 3 × publishable + one derived state that collapses all three. The column name `visibility` is a mild lie kept for consistency with `goals.visibility` (ADR 0112) — it does NOT decide visibility, publishability does — and the ADR is the mitigation. All three stored states are VISIBLE; only the derived state is invisible, which is what makes the 3+1 split honest rather than a workaround. `private` inherits ADR 0112's exact meaning (visible-but-closed); `stealth` exposes EXISTENCE but not IDENTITY, and its redaction is **server-side in the feed** — the naive client-side version leaks to anyone opening the network tab, and the orbs page is a static site OUTSIDE this repo (R09) so it could never be enforced there. Three mechanisms touch "does this appear" and are separated: `status` (operational), `visibility` (owner), `featured` (Archon editorial) — on the map iff `status='active' AND publishable`, with `featured` only ranking among those. That is a real behaviour change: today's predicate is `featured=true AND status='active'`, so **featuring is currently load-bearing for visibility at all** and the goal's owner-control premise silently does not work without it. Visibility is owned by the INSTANCE and the hub row is a CACHE (ADR 0141 §3 + the admin-console spike's D1) — so `owner_github_id` being "advisory, not an authority" is CORRECT rather than a gap, R05 moves to an instance surface, and R08's absent-report default is fail-closed (unreported ⇒ dark matter, off the map rather than exposed by omission). Per-state join rules compose with the separate `open|closed` join policy ([task 1002817](https://cloudbongos.com/builders#/task/1002817)) by one rule: **visibility sets the ceiling, join policy may only narrow** — otherwise the two settings are a coin-flip decided by evaluation order. Finally, `stealth` COLLIDES with the existing instance-wide pre-launch password gate (`STEALTH_PASSWORD`, `src/bongos/stealth-gate.js`), which means nearly the opposite (a whole instance made unreadable vs a project publicly on the map, anonymously); per ADR 0174 the product meaning keeps the word and the infra rename is filed separately rather than decided by an ADR about visibility. Rejected: a stored 4-valued enum (makes `unlisted` compete with `private`, and lets publishable+unlisted disagree), a separate `published` boolean (can drift from the identity it summarises; derived cannot), and keeping `featured` as the visibility gate (an owner who sets public and appears nowhere has not been given control).](<redacted>.md) | platform-identity / project visibility |
|
|
259
|
+
| 0183 | [**A done-when criterion closes itself; the review queue becomes a place to look** ([task 1002069](https://cloudbongos.com/builders#/task/1002069), goal 1000072, owner request 2026-07-05). ADR 0086 §6 made `satisfied` reachable exactly one way — a Metic+ calling `POST /done-when/:id/satisfy` — even though the signal it gated on is unambiguous (**every** gating task shipped) and a human confirming it adds no information. When nobody runs `/goal-review` the result is not a careful pause but a **lying board**: this session measured goal 1000072 at `6/9 shipped, 0/5 criteria`. Now one helper, `doneWhen.autoSatisfyShippedCriteria(exec, {taskId})`, runs from **two** places for two different reasons — inside `shipTask`'s txn (scoped, **immediate**, on the txn client so a rolled-back ship cannot leave a goal achieved) and unscoped on every `publish-reconciler` tick (**backlog drain** for criteria whose tasks shipped before this existed, since nothing will ship against them again, plus **self-heal**). Idempotent by construction — it reads only `satisfied=false` rows — which is what makes an every-tick sweep safe. **The load-bearing subtlety is the delivery guard.** Auto-close needs BOTH "nothing still pending" AND **≥ 1 task actually `shipped`**; the second is not redundant, because since [task 1002651](https://cloudbongos.com/builders#/task/1002651) made `abandoned` terminal-not-pending, a criterion whose tasks were **all cut** satisfies the first clause *vacuously* and would auto-close on ZERO delivered work, cascading its goal to `achieved` (measured: `buildCriterionProgress` returns `pending_review:true` for exactly that shape). Yet `pending_review` **keeps** that wider meaning on purpose — it asks "should a human LOOK at this", and 1002651 chose that an all-abandoned criterion surfaces so someone decides whether it still means anything. So: everything that auto-closes was flagged; not everything flagged auto-closes. An earlier draft "aligned" the two predicates and broke that intent — `tests/criterion_progress.mjs` caught it; **do not re-align them**, the gap is the design. `/goal-review` is kept, demoted from gate to viewing surface; manual `POST /satisfy` stays for what automation cannot judge (a criterion with no linked tasks never auto-closes). Honest limits recorded: auto-satisfy cannot help a criterion nothing is LINKED to, and the in-txn close logs before commit (accepted — threading the summary out through `withTx` would cost ~5 lines in the over-threshold `db.js` under shrink-only ratchet policy). Rejected: sweep-only (up to 5 min to see your own goal close), hook-only (never drains the backlog), a stored `pending_review` column (second source of truth), deleting `/goal-review`.](<redacted>.md) | lifecycle / goal tier |
|
|
259
260
|
|
|
260
261
|
> ⚠️ **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/modules/lifecycle/db.js
CHANGED
|
@@ -3863,7 +3863,7 @@ async function shipTask({ taskId, builderId }, deps = {}) {
|
|
|
3863
3863
|
justUnlocked = [];
|
|
3864
3864
|
}
|
|
3865
3865
|
}
|
|
3866
|
-
|
|
3866
|
+
await doneWhen.closeCompletedWorkForShip(client, taskId); // task 1002069/ADR 0183 — in-txn criterion+goal close
|
|
3867
3867
|
return {
|
|
3868
3868
|
shipRow: rows[0], graduationBuilder, rankGraduation, actualShipperId,
|
|
3869
3869
|
closedClaimCount: closedClaims.length,
|
|
@@ -32,6 +32,14 @@ const { pool } = require('../../src/module-api');
|
|
|
32
32
|
// (db.listCriteriaForGoal, task 1002164) both call it; listPendingReviewCriteria
|
|
33
33
|
// expresses the same rule as a SQL HAVING clause. Counts are coerced because
|
|
34
34
|
// node-postgres renders count() as a string unless cast.
|
|
35
|
+
// DELIBERATELY WIDER THAN AUTO-CLOSE (task 1002069). This flag means "a human
|
|
36
|
+
// should LOOK at this", and task 1002651 chose on purpose that a criterion whose
|
|
37
|
+
// linked tasks were all ABANDONED surfaces here — its work was cut, so someone
|
|
38
|
+
// must decide whether the criterion still means anything. Auto-close is the
|
|
39
|
+
// strictly NARROWER question ("may this close itself, unattended?") and carries
|
|
40
|
+
// an extra >=1-shipped guard of its own; see autoSatisfyShippedCriteria. So
|
|
41
|
+
// everything that auto-closes was flagged here, but not everything flagged here
|
|
42
|
+
// auto-closes — the all-abandoned case stays for a person. Do not "align" them.
|
|
35
43
|
function isPendingReview({ satisfied, linked, unshipped }) {
|
|
36
44
|
return !satisfied && Number(linked) > 0 && Number(unshipped) === 0;
|
|
37
45
|
}
|
|
@@ -357,6 +365,140 @@ async function unmarkCriterion(criterionId) {
|
|
|
357
365
|
return rows[0] ?? null;
|
|
358
366
|
}
|
|
359
367
|
|
|
368
|
+
// -------------------------------------------------------------------------
|
|
369
|
+
// Auto-satisfy (task 1002069 / ADR 0183)
|
|
370
|
+
// -------------------------------------------------------------------------
|
|
371
|
+
|
|
372
|
+
// autoSatisfyShippedCriteria — flip every criterion whose gating set is fully
|
|
373
|
+
// DELIVERED to satisfied=true, then cascade any goal whose criteria are now all
|
|
374
|
+
// satisfied to `achieved`. This retires the manual /goal-review confirm step as
|
|
375
|
+
// the ONLY way a criterion closes (owner request 2026-07-05): the review queue
|
|
376
|
+
// becomes a place to look, not a gate to pass.
|
|
377
|
+
//
|
|
378
|
+
// `exec` is a pg client OR the pool — the caller decides. That is the whole
|
|
379
|
+
// design: shipTask passes its OPEN TRANSACTION CLIENT so the criterion flip and
|
|
380
|
+
// the goal cascade commit atomically with the task's own status change (a
|
|
381
|
+
// half-applied close would leave a goal achieved on a task that rolled back),
|
|
382
|
+
// while the publish-reconciler sweep passes the pool. Never reach for the module
|
|
383
|
+
// pool in here.
|
|
384
|
+
//
|
|
385
|
+
// TWO CALL SHAPES:
|
|
386
|
+
// { taskId } — SCOPED: only criteria the just-shipped task gates. The hot
|
|
387
|
+
// path, inside the ship txn.
|
|
388
|
+
// {} — UNSCOPED: every unsatisfied criterion. The sweep, which both
|
|
389
|
+
// drains the backlog of criteria that completed before this
|
|
390
|
+
// feature existed and self-heals anything the hot path missed
|
|
391
|
+
// (a ship that predates the hook, a manual DB status change).
|
|
392
|
+
//
|
|
393
|
+
// IDEMPOTENT by construction — it only ever reads `satisfied = false` rows, so a
|
|
394
|
+
// second run over the same data is a no-op, which is what makes the sweep safe
|
|
395
|
+
// to run on every reconciler tick.
|
|
396
|
+
//
|
|
397
|
+
// THE DELIVERY GUARD. Two conditions, and BOTH are required:
|
|
398
|
+
// - nothing still pending: no linked task outside {shipped, abandoned}
|
|
399
|
+
// - something delivered: at least one linked task actually SHIPPED
|
|
400
|
+
// The second is not redundant. Since task 1002651 made `abandoned` terminal, a
|
|
401
|
+
// criterion whose linked tasks were ALL abandoned satisfies the first condition
|
|
402
|
+
// vacuously — every task is accounted for, none is pending — and would auto-close
|
|
403
|
+
// on ZERO delivered work, cascading its goal to `achieved`. Under the old manual
|
|
404
|
+
// gate that was merely a bad row in a review queue a human would reject; automatic,
|
|
405
|
+
// it silently declares goals complete. See isPendingReview, which carries the
|
|
406
|
+
// same guard so the queue and the automation cannot disagree.
|
|
407
|
+
//
|
|
408
|
+
// Returns { criteria: [...], goals: [...] } — the rows actually changed, so a
|
|
409
|
+
// caller can log or broadcast exactly what closed (empty arrays on a no-op).
|
|
410
|
+
async function autoSatisfyShippedCriteria(exec, { taskId = null } = {}) {
|
|
411
|
+
const scopedTaskId = taskId == null ? null : Number(taskId);
|
|
412
|
+
if (scopedTaskId != null && !Number.isFinite(scopedTaskId)) {
|
|
413
|
+
throw new Error('autoSatisfyShippedCriteria: taskId must be numeric');
|
|
414
|
+
}
|
|
415
|
+
const { rows: criteria } = await exec.query(
|
|
416
|
+
`WITH candidate AS (
|
|
417
|
+
SELECT c.id,
|
|
418
|
+
-- Attribute the close to a task that actually shipped. In scoped
|
|
419
|
+
-- mode that is the shipping task; unscoped, the highest shipped id
|
|
420
|
+
-- (deterministic, and the most recent contributor).
|
|
421
|
+
max(tc.task_id) FILTER (WHERE t.status = 'shipped') AS by_task
|
|
422
|
+
FROM done_when_criteria c
|
|
423
|
+
JOIN task_criteria tc ON tc.criterion_id = c.id
|
|
424
|
+
JOIN tasks t ON t.id = tc.task_id
|
|
425
|
+
WHERE c.satisfied = false
|
|
426
|
+
AND ($1::bigint IS NULL OR c.id IN (
|
|
427
|
+
SELECT criterion_id FROM task_criteria WHERE task_id = $1::bigint))
|
|
428
|
+
GROUP BY c.id
|
|
429
|
+
HAVING count(*) FILTER (WHERE t.status NOT IN ('shipped', 'abandoned')) = 0
|
|
430
|
+
AND count(*) FILTER (WHERE t.status = 'shipped') >= 1
|
|
431
|
+
)
|
|
432
|
+
UPDATE done_when_criteria c
|
|
433
|
+
SET satisfied = true,
|
|
434
|
+
satisfied_by_task_id = COALESCE($1::bigint, candidate.by_task),
|
|
435
|
+
checked_at = now()
|
|
436
|
+
FROM candidate
|
|
437
|
+
WHERE c.id = candidate.id
|
|
438
|
+
RETURNING c.id, c.version_id, c.criterion_id, c.criterion_md, c.goal_id,
|
|
439
|
+
c.satisfied_by_task_id`,
|
|
440
|
+
[scopedTaskId]
|
|
441
|
+
);
|
|
442
|
+
if (criteria.length === 0) return { criteria: [], goals: [] };
|
|
443
|
+
|
|
444
|
+
// Cascade. Deliberately re-derived from the table rather than from `criteria`:
|
|
445
|
+
// a goal completes when ALL of its criteria are satisfied, and the ones that
|
|
446
|
+
// closed in THIS call may not be all of them. Mirrors db.achieveGoalIfComplete
|
|
447
|
+
// exactly — open goals only, >=1 criterion, zero unsatisfied — but on `exec`,
|
|
448
|
+
// so it sees the flips above inside the same transaction.
|
|
449
|
+
const goalIds = [...new Set(criteria.map((c) => c.goal_id).filter((g) => g != null))].map(Number);
|
|
450
|
+
if (goalIds.length === 0) return { criteria, goals: [] };
|
|
451
|
+
const { rows: goals } = await exec.query(
|
|
452
|
+
`UPDATE goals g
|
|
453
|
+
SET status = 'achieved', updated_at = now()
|
|
454
|
+
WHERE g.id = ANY($1::bigint[])
|
|
455
|
+
AND g.status = 'open'
|
|
456
|
+
AND EXISTS (SELECT 1 FROM done_when_criteria d WHERE d.goal_id = g.id)
|
|
457
|
+
AND NOT EXISTS (SELECT 1 FROM done_when_criteria d WHERE d.goal_id = g.id AND NOT d.satisfied)
|
|
458
|
+
RETURNING g.id, g.title, g.version_id, g.status`,
|
|
459
|
+
[goalIds]
|
|
460
|
+
);
|
|
461
|
+
return { criteria, goals };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// describeAutoClose — the one-line summary of what a close actually closed, or
|
|
465
|
+
// null on a no-op. Pure + here rather than in db.js because db.js is over the
|
|
466
|
+
// fitness oversize threshold: growth there has to be paid for, and formatting is
|
|
467
|
+
// not state-machine work (task 1002069).
|
|
468
|
+
// closeCompletedWorkForShip — the whole ship-time hook behind ONE call, so the
|
|
469
|
+
// oversized db.js pays ~4 lines for it instead of ~15 (the fitness excess-lines
|
|
470
|
+
// ratchet is why that matters). Takes the ship's OPEN TXN CLIENT so the closes
|
|
471
|
+
// commit atomically with the task's flip to 'shipped'. Swallows its own failure
|
|
472
|
+
// and reports it in the returned line — a criterion close must never fail a
|
|
473
|
+
// ship, and the reconciler's unscoped sweep retries every tick. Returns a
|
|
474
|
+
// summary string, or null when nothing closed (the common case).
|
|
475
|
+
async function closeCompletedWorkForShip(client, taskId, log = console.log) {
|
|
476
|
+
let line;
|
|
477
|
+
try {
|
|
478
|
+
line = describeAutoClose(await autoSatisfyShippedCriteria(client, { taskId }));
|
|
479
|
+
} catch (e) {
|
|
480
|
+
line = `auto-satisfy skipped (sweep retries): ${e && e.message}`;
|
|
481
|
+
}
|
|
482
|
+
// Logged HERE, which means INSIDE the caller's transaction — the one honest
|
|
483
|
+
// wart in this design. shipTask's other observability is deliberately
|
|
484
|
+
// post-commit, so on the rare rolled-back ship this line describes closes that
|
|
485
|
+
// did not persist. Accepted because the alternative was threading the summary
|
|
486
|
+
// back out through withTx, and those ~5 extra lines land in db.js, which is
|
|
487
|
+
// over the fitness oversize threshold (shrink-only policy). A rolled-back ship
|
|
488
|
+
// fails loudly on its own, so a stray line here cannot be mistaken for success.
|
|
489
|
+
if (line) log(`[gds] shipTask ${taskId}: ${line}`);
|
|
490
|
+
return line || null;
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function describeAutoClose(closed) {
|
|
494
|
+
const criteria = closed?.criteria || [];
|
|
495
|
+
const goals = closed?.goals || [];
|
|
496
|
+
if (!criteria.length && !goals.length) return null;
|
|
497
|
+
const c = criteria.map((x) => x.criterion_id).join(', ') || 'none';
|
|
498
|
+
const g = goals.map((x) => `${x.id} "${x.title}"`).join(', ') || 'none';
|
|
499
|
+
return `auto-satisfied criteria [${c}]; auto-ACHIEVED goals [${g}]`;
|
|
500
|
+
}
|
|
501
|
+
|
|
360
502
|
module.exports = {
|
|
361
503
|
listCriteria,
|
|
362
504
|
listGoalsForVersion,
|
|
@@ -367,6 +509,9 @@ module.exports = {
|
|
|
367
509
|
buildCriterionProgress,
|
|
368
510
|
buildGoals,
|
|
369
511
|
isPendingReview,
|
|
512
|
+
autoSatisfyShippedCriteria,
|
|
513
|
+
describeAutoClose,
|
|
514
|
+
closeCompletedWorkForShip,
|
|
370
515
|
markCriterion,
|
|
371
516
|
unmarkCriterion,
|
|
372
517
|
};
|
|
@@ -777,9 +777,17 @@ async function runSweep(deps = {}) {
|
|
|
777
777
|
// prevents any double-count if the client also posted. Unlike the confirmed→
|
|
778
778
|
// shipped work, this is PURE DB — it needs no push credential — so it runs even
|
|
779
779
|
// on a host where getPublishStatus would 503. Best-effort + injected-deps pure.
|
|
780
|
+
// One logger resolver for every sweep (task 1002069). Was copy-pasted per sweep;
|
|
781
|
+
// three copies of `deps.log || console.log` is three places to drift.
|
|
782
|
+
function loggers(deps = {}) {
|
|
783
|
+
return { logFn: deps.log || console.log, errFn: deps.error || console.error };
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// A single crash handler for the tick's fire-and-forget sweeps — same reason.
|
|
787
|
+
const crashHandler = (name) => (e) => loggers().errFn(`[publish-reconciler] ${name} crashed: ${e && e.message}`);
|
|
788
|
+
|
|
780
789
|
async function runCostSweep(deps = {}) {
|
|
781
|
-
const logFn = deps
|
|
782
|
-
const errFn = deps.error || console.error;
|
|
790
|
+
const { logFn, errFn } = loggers(deps);
|
|
783
791
|
// reconcileSessionCosts carved into modules/economy/ (BV1.R77 / ADR 0093 §1-2):
|
|
784
792
|
// prefer an injected deps.db (the test seam — tests inject a db with the
|
|
785
793
|
// method), else resolve the `reward` kernel port (production; economy default-on).
|
|
@@ -804,6 +812,40 @@ async function runCostSweep(deps = {}) {
|
|
|
804
812
|
}
|
|
805
813
|
}
|
|
806
814
|
|
|
815
|
+
// runCriterionSweep — the UNSCOPED half of auto-satisfy (task 1002069 / ADR 0183).
|
|
816
|
+
// shipTask closes what its own ship completed, in its own txn; this closes
|
|
817
|
+
// everything else, on every tick. Two jobs, and the second is why it is not
|
|
818
|
+
// merely belt-and-braces:
|
|
819
|
+
// BACKLOG DRAIN — every criterion whose gating tasks shipped BEFORE this
|
|
820
|
+
// feature existed. Without the sweep those stay unsatisfied forever, since
|
|
821
|
+
// nothing will ship against them again.
|
|
822
|
+
// SELF-HEAL — a criterion linked AFTER its tasks shipped, a task status changed
|
|
823
|
+
// by hand, or a ship whose in-txn close was skipped by the best-effort catch.
|
|
824
|
+
// Pure DB like runCostSweep — no push credential — so it runs on hosts where
|
|
825
|
+
// getPublishStatus 503s. Idempotent: it only reads satisfied=false rows.
|
|
826
|
+
async function runCriterionSweep(deps = {}) {
|
|
827
|
+
const { logFn, errFn } = loggers(deps);
|
|
828
|
+
// Injected deps.doneWhen is the test seam; the sibling module is production.
|
|
829
|
+
const dw = (deps.doneWhen && typeof deps.doneWhen.autoSatisfyShippedCriteria === 'function')
|
|
830
|
+
? deps.doneWhen
|
|
831
|
+
: require('./done-when.js');
|
|
832
|
+
const exec = deps.pool || seams.pool;
|
|
833
|
+
if (typeof dw.autoSatisfyShippedCriteria !== 'function') return { skipped: 'unsupported' };
|
|
834
|
+
try {
|
|
835
|
+
const closed = await dw.autoSatisfyShippedCriteria(exec, {});
|
|
836
|
+
for (const c of closed.criteria) {
|
|
837
|
+
logFn(`[publish-reconciler] criterion ${c.criterion_id} (${c.version_id}) auto-satisfied — all gating tasks shipped`);
|
|
838
|
+
}
|
|
839
|
+
for (const g of closed.goals) {
|
|
840
|
+
logFn(`[publish-reconciler] goal ${g.id} "${g.title}" auto-ACHIEVED — every criterion satisfied`);
|
|
841
|
+
}
|
|
842
|
+
return closed;
|
|
843
|
+
} catch (e) {
|
|
844
|
+
errFn(`[publish-reconciler] criterion sweep (non-blocking): ${e && e.message}`);
|
|
845
|
+
return { skipped: 'error', error: e && e.message };
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
807
849
|
// Start the periodic sweep. Idempotent (a second call is a no-op). Kill-switched
|
|
808
850
|
// by PUBLISH_RECONCILER_DISABLED=1 (purely additive bookkeeping, so it's ON by
|
|
809
851
|
// default whenever a push credential is configured; the route's getPublishStatus
|
|
@@ -816,11 +858,13 @@ function start(deps = {}) {
|
|
|
816
858
|
}
|
|
817
859
|
if (intervalTimer) return; // already started
|
|
818
860
|
|
|
861
|
+
// Each sweep is INDEPENDENT on purpose: the cost (task 1366) and criterion
|
|
862
|
+
// (task 1002069) sweeps are pure DB — no push credential — and a failure in
|
|
863
|
+
// either must never touch the confirmed→shipped work.
|
|
819
864
|
const tick = () => {
|
|
820
|
-
runSweep(deps).catch((
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
runCostSweep(deps).catch((e) => console.error(`[publish-reconciler] cost sweep crashed: ${e && e.message}`));
|
|
865
|
+
runSweep(deps).catch(crashHandler('sweep'));
|
|
866
|
+
runCostSweep(deps).catch(crashHandler('cost sweep'));
|
|
867
|
+
runCriterionSweep(deps).catch(crashHandler('criterion sweep'));
|
|
824
868
|
};
|
|
825
869
|
|
|
826
870
|
firstTimer = setTimeout(tick, FIRST_SWEEP_DELAY_MS);
|
|
@@ -857,6 +901,7 @@ module.exports = {
|
|
|
857
901
|
stop,
|
|
858
902
|
runSweep,
|
|
859
903
|
runCostSweep,
|
|
904
|
+
runCriterionSweep,
|
|
860
905
|
couldBeStaleGenerated,
|
|
861
906
|
describeTarget,
|
|
862
907
|
INTERVAL_MS,
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.288",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.288",
|
|
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.
|
|
3
|
+
"version": "1.19.288",
|
|
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",
|
package/src/module-api.js
CHANGED
|
@@ -1001,8 +1001,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
1001
1001
|
// landed since 1.19.285 with no explicit bump. run 32533647172. (task 1002620)
|
|
1002
1002
|
// 1.19.287 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1003
1003
|
// landed since 1.19.286 with no explicit bump. run 32539649191. (task 1002620)
|
|
1004
|
+
// 1.19.288 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1005
|
+
// landed since 1.19.287 with no explicit bump. run 32546561232. (task 1002620)
|
|
1004
1006
|
// ---------------------------------------------------------------------------
|
|
1005
|
-
const CORE_VERSION = '1.19.
|
|
1007
|
+
const CORE_VERSION = '1.19.288'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
1006
1008
|
|
|
1007
1009
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
1008
1010
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// tests/auto_satisfy_criteria.mjs — task 1002069 / ADR 0183.
|
|
2
|
+
//
|
|
3
|
+
// A done-when criterion closes ITSELF once every gating task has shipped, and a
|
|
4
|
+
// goal whose criteria are all satisfied flips open→achieved. The manual
|
|
5
|
+
// /goal-review confirm stops being the only door (owner request 2026-07-05).
|
|
6
|
+
//
|
|
7
|
+
// What these tests actually protect, in order of how badly it would hurt:
|
|
8
|
+
//
|
|
9
|
+
// 1. THE DELIVERY GUARD, and the DELIBERATE ASYMMETRY around it. `abandoned` is
|
|
10
|
+
// terminal (task 1002651), so "no task is still pending" is TRUE for a
|
|
11
|
+
// criterion whose every linked task was CUT. Auto-closing on that alone would
|
|
12
|
+
// satisfy criteria on zero delivered work and cascade goals to `achieved`, so
|
|
13
|
+
// the auto-close SQL additionally requires >=1 SHIPPED.
|
|
14
|
+
// The review flag does NOT get that guard, on purpose. `pending_review` means
|
|
15
|
+
// "a human should LOOK at this", and task 1002651 chose that an all-abandoned
|
|
16
|
+
// criterion surfaces there — its work was cut, so someone must decide whether
|
|
17
|
+
// the criterion still means anything. Auto-close is the narrower question.
|
|
18
|
+
// Everything that auto-closes was flagged; not everything flagged auto-closes.
|
|
19
|
+
// An earlier draft of this task "aligned" the two and broke that intent —
|
|
20
|
+
// tests/criterion_progress.mjs caught it. Do not re-align them.
|
|
21
|
+
// 2. THE EXEC CONTRACT. The helper writes through the caller's `exec`, never a
|
|
22
|
+
// module pool, because shipTask passes its open transaction client — a
|
|
23
|
+
// rolled-back ship must not leave a goal achieved.
|
|
24
|
+
// 3. THE CASCADE RULE. Mirrors db.achieveGoalIfComplete: open goals only, >=1
|
|
25
|
+
// criterion, zero unsatisfied. Re-derived from the table, not from the rows
|
|
26
|
+
// that closed in this call.
|
|
27
|
+
//
|
|
28
|
+
// DB-free: a fake exec records the SQL and returns scripted rows, which is the
|
|
29
|
+
// right level for a pure-SQL helper — the shapes and the branching are the risk,
|
|
30
|
+
// and a live Postgres would test Postgres.
|
|
31
|
+
//
|
|
32
|
+
// Run: node tests/auto_satisfy_criteria.mjs
|
|
33
|
+
|
|
34
|
+
import { strict as assert } from 'node:assert';
|
|
35
|
+
import { createRequire } from 'node:module';
|
|
36
|
+
import { readFileSync } from 'node:fs';
|
|
37
|
+
import { makeRunner } from './helpers.mjs';
|
|
38
|
+
|
|
39
|
+
process.env.NODE_ENV = 'test';
|
|
40
|
+
const require = createRequire(import.meta.url);
|
|
41
|
+
const doneWhen = require('../modules/lifecycle/done-when.js');
|
|
42
|
+
const { autoSatisfyShippedCriteria, isPendingReview, buildCriterionProgress } = doneWhen;
|
|
43
|
+
const { test, summary } = makeRunner();
|
|
44
|
+
|
|
45
|
+
// A fake exec: hands back queued result sets in order, recording every call.
|
|
46
|
+
function fakeExec(...results) {
|
|
47
|
+
const calls = [];
|
|
48
|
+
return {
|
|
49
|
+
calls,
|
|
50
|
+
async query(sql, params) {
|
|
51
|
+
calls.push({ sql, params });
|
|
52
|
+
return results[calls.length - 1] || { rows: [] };
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const CRIT = { id: 10, version_id: 'BONGOS-V1', criterion_id: 'c-one', criterion_md: 'x', goal_id: 77, satisfied_by_task_id: 5 };
|
|
58
|
+
|
|
59
|
+
// ---- 1. the delivery guard, in all three derivations -----------------------
|
|
60
|
+
|
|
61
|
+
await test('GUARD (sql): the update requires BOTH nothing-pending AND >=1 shipped', () => {
|
|
62
|
+
const src = readFileSync(new URL('../modules/lifecycle/done-when.js', import.meta.url), 'utf8');
|
|
63
|
+
const body = src.slice(src.indexOf('async function autoSatisfyShippedCriteria'));
|
|
64
|
+
assert.match(body, /HAVING count\(\*\) FILTER \(WHERE t\.status NOT IN \('shipped', 'abandoned'\)\) = 0/,
|
|
65
|
+
'nothing may still be pending');
|
|
66
|
+
assert.match(body, /AND count\(\*\) FILTER \(WHERE t\.status = 'shipped'\) >= 1/,
|
|
67
|
+
'THE GUARD: an all-abandoned gating set delivered nothing and must not close a criterion');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
await test('ASYMMETRY (sql): the review queue deliberately does NOT carry the delivery guard', () => {
|
|
71
|
+
const src = readFileSync(new URL('../modules/lifecycle/done-when.js', import.meta.url), 'utf8');
|
|
72
|
+
const start = src.indexOf('async function listPendingReviewCriteria');
|
|
73
|
+
const body = src.slice(start, src.indexOf('async function criterionProgress', start));
|
|
74
|
+
assert.ok(body.length > 200, 'the slice must actually contain the function');
|
|
75
|
+
assert.match(body, /HAVING count\(\*\) FILTER \(WHERE t\.status NOT IN \('shipped', 'abandoned'\)\) = 0/);
|
|
76
|
+
assert.ok(!/status = 'shipped'\) >= 1/.test(body),
|
|
77
|
+
'task 1002651 intends an all-abandoned criterion to SURFACE for review; only auto-close is gated');
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
await test('ASYMMETRY (pure): isPendingReview stays the WIDER question — unchanged by this task', () => {
|
|
81
|
+
// An all-abandoned criterion still flags for review (1002651's intent) even
|
|
82
|
+
// though it will never auto-close. That gap is the feature, not a bug.
|
|
83
|
+
assert.equal(isPendingReview({ satisfied: false, linked: 1, unshipped: 0 }), true,
|
|
84
|
+
'all-abandoned still SURFACES — a human decides whether the criterion survives');
|
|
85
|
+
assert.equal(isPendingReview({ satisfied: false, linked: 2, unshipped: 1 }), false, 'still work pending');
|
|
86
|
+
assert.equal(isPendingReview({ satisfied: true, linked: 1, unshipped: 0 }), false, 'already confirmed');
|
|
87
|
+
assert.equal(isPendingReview({ satisfied: false, linked: 0, unshipped: 0 }), false, 'no linked tasks');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
await test('ASYMMETRY (rollup): the /status rollup is untouched by this task', () => {
|
|
91
|
+
const row = (tid, status) => ({
|
|
92
|
+
cnum: 1, id: 10, criterion_id: 'c-cut', criterion_md: 'x', satisfied: false, goal_id: 1,
|
|
93
|
+
task_id: tid, task_title: 't', task_status: status,
|
|
94
|
+
});
|
|
95
|
+
const g = [{ id: 1, title: 'g', status: 'open', scope_modules: [] }];
|
|
96
|
+
const cut = buildCriterionProgress('V1', [row(301, 'abandoned')], 0, g);
|
|
97
|
+
assert.equal(cut.criteria[0].counts.remaining, 0, 'abandoned is not "remaining" (task 1002651)');
|
|
98
|
+
assert.equal(cut.criteria[0].pending_review, true, 'and it still flags for review — pinned in criterion_progress.mjs');
|
|
99
|
+
assert.equal(buildCriterionProgress('V1', [row(302, 'shipped')], 0, g).criteria[0].pending_review, true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// ---- 2. the exec contract --------------------------------------------------
|
|
103
|
+
|
|
104
|
+
await test('EXEC: every write goes through the caller-supplied exec (so a ship txn is atomic)', async () => {
|
|
105
|
+
const exec = fakeExec({ rows: [CRIT] }, { rows: [{ id: 77, title: 'g', version_id: 'BONGOS-V1', status: 'achieved' }] });
|
|
106
|
+
const out = await autoSatisfyShippedCriteria(exec, { taskId: 5 });
|
|
107
|
+
assert.equal(exec.calls.length, 2, 'one criterion update + one goal cascade');
|
|
108
|
+
assert.deepEqual(out.criteria, [CRIT]);
|
|
109
|
+
assert.deepEqual(out.goals.map((g) => g.id), [77]);
|
|
110
|
+
const src = readFileSync(new URL('../modules/lifecycle/done-when.js', import.meta.url), 'utf8');
|
|
111
|
+
const body = src.slice(src.indexOf('async function autoSatisfyShippedCriteria'), src.indexOf('module.exports'));
|
|
112
|
+
assert.ok(!/\bpool\.query\b/.test(body), 'must never reach for the module pool — that would escape the caller txn');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
await test('EXEC: shipTask passes its transaction CLIENT, and the reconciler passes the pool', () => {
|
|
116
|
+
const db = readFileSync(new URL('../modules/lifecycle/db.js', import.meta.url), 'utf8');
|
|
117
|
+
// db.js is over the fitness oversize threshold, so the hook is ONE line there
|
|
118
|
+
// and its body lives in done-when.closeCompletedWorkForShip. What must hold is
|
|
119
|
+
// that the thing handed in is the txn client, not a pool.
|
|
120
|
+
assert.match(db, /closeCompletedWorkForShip\(client, taskId\)/,
|
|
121
|
+
'the ship hook must run on the txn client, not the pool');
|
|
122
|
+
const dw = readFileSync(new URL('../modules/lifecycle/done-when.js', import.meta.url), 'utf8');
|
|
123
|
+
assert.match(dw, /autoSatisfyShippedCriteria\(client, \{ taskId \}\)/, 'and it forwards that client through');
|
|
124
|
+
const rec = readFileSync(new URL('../modules/lifecycle/publish-reconciler.js', import.meta.url), 'utf8');
|
|
125
|
+
assert.match(rec, /autoSatisfyShippedCriteria\(exec, \{\}\)/, 'the sweep is UNSCOPED (no taskId)');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// ---- 3. scoping, cascade, idempotence -------------------------------------
|
|
129
|
+
|
|
130
|
+
await test('SCOPED vs UNSCOPED: the taskId parameter is what narrows the candidate set', async () => {
|
|
131
|
+
const scoped = fakeExec({ rows: [] });
|
|
132
|
+
await autoSatisfyShippedCriteria(scoped, { taskId: 42 });
|
|
133
|
+
assert.deepEqual(scoped.calls[0].params, [42]);
|
|
134
|
+
assert.match(scoped.calls[0].sql, /\$1::bigint IS NULL OR c\.id IN/, 'one SQL serves both shapes');
|
|
135
|
+
|
|
136
|
+
const sweep = fakeExec({ rows: [] });
|
|
137
|
+
await autoSatisfyShippedCriteria(sweep, {});
|
|
138
|
+
assert.deepEqual(sweep.calls[0].params, [null], 'unscoped passes NULL, which disables the narrowing clause');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
await test('NO-OP: nothing closed means no cascade query at all', async () => {
|
|
142
|
+
const exec = fakeExec({ rows: [] });
|
|
143
|
+
const out = await autoSatisfyShippedCriteria(exec, {});
|
|
144
|
+
assert.deepEqual(out, { criteria: [], goals: [] });
|
|
145
|
+
assert.equal(exec.calls.length, 1, 'must not run the goal cascade when no criterion moved');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
await test('CASCADE: only OPEN goals, only with >=1 criterion and zero unsatisfied', async () => {
|
|
149
|
+
const exec = fakeExec({ rows: [CRIT] }, { rows: [] });
|
|
150
|
+
await autoSatisfyShippedCriteria(exec, { taskId: 5 });
|
|
151
|
+
const cascade = exec.calls[1].sql;
|
|
152
|
+
assert.match(cascade, /g\.status = 'open'/, 'never re-touch an achieved/archived goal');
|
|
153
|
+
assert.match(cascade, /EXISTS\s*\(SELECT 1 FROM done_when_criteria d WHERE d\.goal_id = g\.id\)/, 'an empty goal cannot auto-achieve');
|
|
154
|
+
assert.match(cascade, /NOT EXISTS \(SELECT 1 FROM done_when_criteria d WHERE d\.goal_id = g\.id AND NOT d\.satisfied\)/, 'every criterion must be satisfied');
|
|
155
|
+
assert.deepEqual(exec.calls[1].params, [[77]]);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
await test('CASCADE: a criterion with no goal closes nothing (goal_id NULL completes no goal)', async () => {
|
|
159
|
+
const exec = fakeExec({ rows: [{ ...CRIT, goal_id: null }] });
|
|
160
|
+
const out = await autoSatisfyShippedCriteria(exec, { taskId: 5 });
|
|
161
|
+
assert.equal(exec.calls.length, 1, 'no cascade query for a goal-less criterion');
|
|
162
|
+
assert.deepEqual(out.goals, []);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
await test('CASCADE: goal ids are deduped, so N criteria under one goal is ONE cascade', async () => {
|
|
166
|
+
const exec = fakeExec({ rows: [CRIT, { ...CRIT, id: 11, criterion_id: 'c-two' }] }, { rows: [] });
|
|
167
|
+
await autoSatisfyShippedCriteria(exec, {});
|
|
168
|
+
assert.deepEqual(exec.calls[1].params, [[77]], 'one goal id, not two');
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
await test('IDEMPOTENT: the candidate set reads only unsatisfied rows, so a re-run is a no-op', () => {
|
|
172
|
+
const src = readFileSync(new URL('../modules/lifecycle/done-when.js', import.meta.url), 'utf8');
|
|
173
|
+
const body = src.slice(src.indexOf('async function autoSatisfyShippedCriteria'), src.indexOf('module.exports'));
|
|
174
|
+
assert.match(body, /WHERE c\.satisfied = false/, 'this is what makes the every-tick sweep safe');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
await test('a non-numeric taskId is rejected rather than silently becoming an unscoped sweep', async () => {
|
|
178
|
+
const exec = fakeExec({ rows: [] });
|
|
179
|
+
await assert.rejects(() => autoSatisfyShippedCriteria(exec, { taskId: 'oops' }), /must be numeric/);
|
|
180
|
+
assert.equal(exec.calls.length, 0, 'nothing ran');
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
// ---- the ship hook is best-effort, the sweep is the retry ------------------
|
|
184
|
+
|
|
185
|
+
await test('the ship hook cannot fail a ship, and the sweep exists to retry it', async () => {
|
|
186
|
+
// Behavioural, not textual: a throwing exec must not propagate out of the hook.
|
|
187
|
+
const boom = { query: async () => { throw new Error('db exploded'); } };
|
|
188
|
+
const logged = [];
|
|
189
|
+
const line = await doneWhen.closeCompletedWorkForShip(boom, 5, (m) => logged.push(m));
|
|
190
|
+
assert.match(line, /auto-satisfy skipped \(sweep retries\)/, 'the failure is reported, not thrown');
|
|
191
|
+
assert.match(logged.join('\n'), /shipTask 5/, 'and it names the ship it happened on');
|
|
192
|
+
const rec = readFileSync(new URL('../modules/lifecycle/publish-reconciler.js', import.meta.url), 'utf8');
|
|
193
|
+
assert.match(rec, /runCriterionSweep\(deps\)\.catch\(/, 'the sweep is fire-and-forget on the tick, like runCostSweep');
|
|
194
|
+
assert.match(rec, /BACKLOG DRAIN/, 'and documents why it is not merely belt-and-braces');
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
await test('closeCompletedWorkForShip: silent on the common no-op, and logs what closed', async () => {
|
|
198
|
+
const quiet = [];
|
|
199
|
+
const none = await doneWhen.closeCompletedWorkForShip({ query: async () => ({ rows: [] }) }, 5, (m) => quiet.push(m));
|
|
200
|
+
assert.equal(none, null, 'nothing closed => nothing returned');
|
|
201
|
+
assert.deepEqual(quiet, [], 'and nothing logged — a ship that closes nothing stays quiet');
|
|
202
|
+
|
|
203
|
+
const loud = [];
|
|
204
|
+
let call = 0;
|
|
205
|
+
const exec = { async query() { call += 1; return call === 1 ? { rows: [CRIT] } : { rows: [{ id: 77, title: 'G' }] }; } };
|
|
206
|
+
const line = await doneWhen.closeCompletedWorkForShip(exec, 9, (m) => loud.push(m));
|
|
207
|
+
assert.match(line, /auto-satisfied criteria \[c-one\]/);
|
|
208
|
+
assert.match(line, /auto-ACHIEVED goals \[77 "G"\]/);
|
|
209
|
+
assert.match(loud[0], /shipTask 9/);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
summary();
|