@bongos/core 1.19.752 → 1.19.754
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/docs/adr/0287-a-backlog-task-that-nothing-gates-promotes-itself.md +35 -0
- package/docs/adr/README.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/modules/lifecycle/db-tasks.js +61 -0
- package/modules/lifecycle/publish-reconciler.js +38 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/promotion_sweep.mjs +210 -0
- package/tests/session-cards.mjs +52 -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.754",
|
|
6
|
+
"core_contract": "1.19.754",
|
|
7
|
+
"source_commit": "8fd1dd9b3ddbc313d9e3bb09ba2a339cb9793e7f",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-16T00:24:38.617Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
|
-
"docs_redacted":
|
|
12
|
+
"docs_redacted": 483,
|
|
13
13
|
"agent_docs_stubbed": 25,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 2111,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2619,
|
|
20
|
+
"tree_sha256": "a2f5f1ae36d83e33d7e61bab1264bf62287f10d175f2c9e1f3cabd695bc2b757",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -1939,10 +1939,15 @@
|
|
|
1939
1939
|
"mode": "0000644",
|
|
1940
1940
|
"sha256": "df40904ac989d54ea6bea561649a35a98ec7dc1ba96ea65395cb314ffe2059a9"
|
|
1941
1941
|
},
|
|
1942
|
+
{
|
|
1943
|
+
"path": "docs/adr/0287-a-backlog-task-that-nothing-gates-promotes-itself.md",
|
|
1944
|
+
"mode": "0000644",
|
|
1945
|
+
"sha256": "3990568d9257c263d629527bd4bfb143cbf211c5a61544bf8db527c9e3c11249"
|
|
1946
|
+
},
|
|
1942
1947
|
{
|
|
1943
1948
|
"path": "docs/adr/README.md",
|
|
1944
1949
|
"mode": "0000644",
|
|
1945
|
-
"sha256": "
|
|
1950
|
+
"sha256": "6e19a62c805b92eda9a318fe7d67cac22927a80d4ad6bdd681256f0cd06281cd"
|
|
1946
1951
|
},
|
|
1947
1952
|
{
|
|
1948
1953
|
"path": "docs/api-reference.md",
|
|
@@ -2507,7 +2512,7 @@
|
|
|
2507
2512
|
{
|
|
2508
2513
|
"path": "docs/module-api-changelog.md",
|
|
2509
2514
|
"mode": "0000644",
|
|
2510
|
-
"sha256": "
|
|
2515
|
+
"sha256": "f367478f9031a67c77c1b43521aa215ea65068e9ad1b6b21540c81abd8a22dde"
|
|
2511
2516
|
},
|
|
2512
2517
|
{
|
|
2513
2518
|
"path": "docs/modules-contract.md",
|
|
@@ -5467,7 +5472,7 @@
|
|
|
5467
5472
|
{
|
|
5468
5473
|
"path": "modules/lifecycle/db-tasks.js",
|
|
5469
5474
|
"mode": "0000644",
|
|
5470
|
-
"sha256": "
|
|
5475
|
+
"sha256": "2ec7c2bec4ef96b5ded094c69104fc30e53b7659cb2b2444ed4491df3f3836dc"
|
|
5471
5476
|
},
|
|
5472
5477
|
{
|
|
5473
5478
|
"path": "modules/lifecycle/db-versions.js",
|
|
@@ -5627,7 +5632,7 @@
|
|
|
5627
5632
|
{
|
|
5628
5633
|
"path": "modules/lifecycle/publish-reconciler.js",
|
|
5629
5634
|
"mode": "0000644",
|
|
5630
|
-
"sha256": "
|
|
5635
|
+
"sha256": "dce7913d2635c40ce9557e7cb554aa152dbbe4c40e88676faa3f5e5b34bc26ae"
|
|
5631
5636
|
},
|
|
5632
5637
|
{
|
|
5633
5638
|
"path": "modules/lifecycle/rot.js",
|
|
@@ -7562,12 +7567,12 @@
|
|
|
7562
7567
|
{
|
|
7563
7568
|
"path": "package-lock.json",
|
|
7564
7569
|
"mode": "0000644",
|
|
7565
|
-
"sha256": "
|
|
7570
|
+
"sha256": "0b25947dcf9f2906d39aec4e4ad10e63dc42831cf593c5404ba77dc51c2be946"
|
|
7566
7571
|
},
|
|
7567
7572
|
{
|
|
7568
7573
|
"path": "package.json",
|
|
7569
7574
|
"mode": "0000644",
|
|
7570
|
-
"sha256": "
|
|
7575
|
+
"sha256": "f340a8707e0abe69d0e7d1028a0a96675b66099ce87af7932004393e02dc2a19"
|
|
7571
7576
|
},
|
|
7572
7577
|
{
|
|
7573
7578
|
"path": "public-docs/index.html",
|
|
@@ -9367,7 +9372,7 @@
|
|
|
9367
9372
|
{
|
|
9368
9373
|
"path": "src/module-api.js",
|
|
9369
9374
|
"mode": "0000644",
|
|
9370
|
-
"sha256": "
|
|
9375
|
+
"sha256": "df078d4bd7a6e0fda01ce242821141c66b6dc0f2d07ebfaf8de1a89ae279499e"
|
|
9371
9376
|
},
|
|
9372
9377
|
{
|
|
9373
9378
|
"path": "src/module-loader/catalog.js",
|
|
@@ -11994,6 +11999,11 @@
|
|
|
11994
11999
|
"mode": "0000644",
|
|
11995
12000
|
"sha256": "5444df365780c33d61ca9a18de08ed89ed2ff74d8af198d51edbaed19d6010c2"
|
|
11996
12001
|
},
|
|
12002
|
+
{
|
|
12003
|
+
"path": "tests/promotion_sweep.mjs",
|
|
12004
|
+
"mode": "0000644",
|
|
12005
|
+
"sha256": "43246276a75ec8e7d01610b4f6c6988434144338804614b37b062c7a43a8beca"
|
|
12006
|
+
},
|
|
11997
12007
|
{
|
|
11998
12008
|
"path": "tests/prose_edits.mjs",
|
|
11999
12009
|
"mode": "0000644",
|
|
@@ -12417,7 +12427,7 @@
|
|
|
12417
12427
|
{
|
|
12418
12428
|
"path": "tests/session-cards.mjs",
|
|
12419
12429
|
"mode": "0000644",
|
|
12420
|
-
"sha256": "
|
|
12430
|
+
"sha256": "9035468dcf1b6ef0bc0aaf5a665196a0eab321563ab84aa1133b1446ff1ce9cd"
|
|
12421
12431
|
},
|
|
12422
12432
|
{
|
|
12423
12433
|
"path": "tests/session_506_regression.mjs",
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# 0287 — A backlog task that nothing gates promotes itself
|
|
2
|
+
|
|
3
|
+
**Status:** accepted · 2026-09-15 · [task 1003932](https://cloudbongos.com/builders#/task/1003932) (owner decision, same session)
|
|
4
|
+
|
|
5
|
+
## Context
|
|
6
|
+
|
|
7
|
+
Auto-promotion has been reactive-only since [migration 163](../../migrations/163_polymorphic_dependencies.sql). All three paths are `AFTER UPDATE` triggers that fire at the **dependency** end of an edge — a task ships, a criterion is satisfied, a goal is achieved — and then re-check `task_is_fully_unblocked` for that node's dependents.
|
|
8
|
+
|
|
9
|
+
Two states are unreachable by any of them, and neither is rare:
|
|
10
|
+
|
|
11
|
+
1. **No gating edge at all.** `createTask` inserts at `status='backlog'`. A task with no edges is nobody's dependent, so no trigger ever evaluates it.
|
|
12
|
+
2. **An edge added after the dependency shipped.** The ship event already happened; creating the edge later re-evaluates nothing.
|
|
13
|
+
|
|
14
|
+
Measured on the live database, 2026-09-15: **155 backlog tasks were promotable and parked** — 33 of 41 in goal 1000106 alone, the rest spread over ten other goals. 117 more were correctly waiting on unfinished work. The owner's framing was blunt: *"nothing should be owner held backlog, are things not auto promoting?"*
|
|
15
|
+
|
|
16
|
+
This is the same failure `runCriterionSweep` was built for on the criterion axis ([ADR 0183](0183-criteria-close-themselves.md)): a reactive rule cannot drain rows whose event has already passed, and nothing will ever fire for them again.
|
|
17
|
+
|
|
18
|
+
## Decision
|
|
19
|
+
|
|
20
|
+
**A backlog task with no unmet dependency and no open blocker becomes `ready` on its own**, via a `runPromotionSweep` on the publish-reconciler tick, beside the criterion sweep it copies.
|
|
21
|
+
|
|
22
|
+
The owner chose the widest rule: the sweep covers **spikes** (which migration 163 excluded on purpose) and the cascade's **artist-review** tasks (which `cascade.js` files at `backlog` deliberately). One rule, no carve-outs. Promoting only makes a task claimable; it compels nobody to take it, and `/builder-sequence` still halts on a `judgement_kind` spike.
|
|
23
|
+
|
|
24
|
+
**It ships disarmed.** The sweep reports what it would promote until `BONGOS_PROMOTION_SWEEP_ARMED=1`. The owner asked to see the list before ~155 rows change status at once, and a mass status change with no preview is exactly the kind of thing that is easy to do and tedious to unpick.
|
|
25
|
+
|
|
26
|
+
**The open-blocker guard lives in the sweep's own SQL**, not in `task_is_fully_unblocked`. [Migration 176](../../migrations/176_promotion_respects_open_blockers.sql) folded that clause into the predicate for V4.R15; [migration 178](../../migrations/178_reward_gate_auto_assign_revert_autopromote.sql) then replaced the body with one it described as "byte-identical to the 163 body" and dropped it. Production was verified during this task: the live predicate has no blocker clause, and one task already sits at `ready` carrying an open blocker. Restoring it is [task 1003934](https://cloudbongos.com/builders#/task/1003934). A sweep that promotes in bulk must not inherit a single point of failure that has already been reverted once, so it re-checks, and keeps re-checking even after 1003934 lands.
|
|
27
|
+
|
|
28
|
+
## Consequences
|
|
29
|
+
|
|
30
|
+
- **`backlog` stops being a resting state.** It becomes the brief gap between filing and being claimable. `/backlog-review` — "the state a human must say go on" — loses most of its purpose; it is not deleted here, but the next session to touch it should expect little left to walk.
|
|
31
|
+
- **The claimable feed grows by roughly 155 rows once armed**, across eleven goals. Nothing is force-assigned; the queue simply stops lying about what is available.
|
|
32
|
+
- **The `UPDATE` repeats the `WHERE`** rather than trusting the ids the `SELECT` returned, so a row that stops qualifying between the two statements is not promoted.
|
|
33
|
+
- **Idempotent**: it reads only `status='backlog'` rows, and an empty candidate set issues no write at all.
|
|
34
|
+
- Rejected — **promoting at creation time** (`createTask` inserting `ready` when a task has no edges): it would break `cascade.js`'s tested invariant that a cascade-filed review lands at `backlog`, and it fixes nothing already parked. A sweep handles both the backlog drain and the self-heal, which is why the criterion axis chose one too.
|
|
35
|
+
- Rejected — **scoping the first run to one goal**: it proves the mechanism on 37 rows while leaving the same defect live in ten other goals, and needs a second task to finish.
|
package/docs/adr/README.md
CHANGED
|
@@ -378,3 +378,4 @@ This keeps the decision history honest and traceable.
|
|
|
378
378
|
| 0284 | [**The control plane carries no metrics stack** ([task 1003876](https://cloudbongos.com/builders#/task/1003876) · goal 1000106). Supersedes ADR 0127. Grafana (187 MB, the box's largest process) + Prometheus (110 MB, 2.5 GB data) were removed from the `s-1vcpu-2gb` control plane every provisioned project shares; they scraped none of those projects and nothing in the core read them. Owner chose remove over move-to-own-droplet. The `/metrics` exporter stays in every instance, so a scraper can return from a box that is not the control plane. Leftovers (DNS record, co-hosting-box socat forwarders, instance `infra/` copies) filed as a follow-up.](0284-the-control-plane-carries-no-metrics-stack.md) | infra / Cloud Bongos |
|
|
379
379
|
| 0285 | [**A shared box holds about twelve projects per GB, and memory is the wall** ([task 1003877](https://cloudbongos.com/builders#/task/1003877) · goal 1000106, a spike measured read-only on the live control plane at the owner's call). A project costs ~80 MB warm (70 MB Node + one busy Postgres backend); idle projects look like ~25 MB only because swap holds their cold pages, and swap is not capacity. Fixed base ~750 MB on a control plane. Ceiling per tier `floor((usable − fixed) / 80)`: 15 on today's `s-1vcpu-2gb` (11 live), 39 on `s-2vcpu-4gb`; resize at 12. RAM binds first, Postgres connections (pool default 10 × N vs 97) second; CPU, ports, units, disk and the backup window do not bind. Keep the three shapes, add placement ("standalone until the box is full, then the next box"); a shared slot costs box price ÷ ceiling ($0.62–0.80/mo), `dedicated` 8–40× that.](0285-a-shared-box-holds-about-twelve-projects-per-gb-and-memory-is-the-wall.md) | infra / Cloud Bongos |
|
|
380
380
|
| 0286 | [**A gated project's manifest tells a stranger how to sign in, and nothing else** ([task 1003652](https://cloudbongos.com/builders#/task/1003652) · goal 1000106, from idea 1000845; closes ADR 0192's deferred item). `GET /instance` stays exempt from the member door, but on a gated project a caller with no live session gets `{ schema, name, brand: { identity.productName, project }, auth (incl. idp), admission }` — no modules, ranks, repo coordinates, dev-box flag or branding strings. Members get the full manifest; a failed lookup answers as a stranger; gated responses are `private, no-store` + `Vary: Cookie, Authorization`. Schema id stays `instance-manifest/v1` (a subset; `bongos login` refuses any other). Owner's call: sign-in basics only.](0286-a-gated-project-s-manifest-tells-a-stranger-how-to-sign-in-and-nothing-else.md) | security / Cloud Bongos |
|
|
381
|
+
| 0287 | [**A backlog task that nothing gates promotes itself** ([task 1003932](https://cloudbongos.com/builders#/task/1003932), owner decision 2026-09-15). Every promotion path in migration 163 is an `AFTER UPDATE` trigger at the DEPENDENCY end of an edge, so two states are unreachable by all of them: a task with no gating edge is nobody's dependent and is never evaluated, and an edge added AFTER its dependency shipped missed the only event that would have fired. Measured live: **155 backlog tasks promotable and parked**, 33 of 41 in one goal. Same failure `runCriterionSweep` exists for on the criterion axis (ADR 0183) — a reactive rule cannot drain rows whose event has passed. **Decision: `runPromotionSweep` joins the publish-reconciler tick** and promotes any backlog task with no unmet dependency and no open blocker. The owner chose the widest rule — spikes (excluded by 163 on purpose) and cascade-filed artist reviews included, no carve-outs; promoting only makes a task claimable, it compels nobody. **It ships DISARMED**, reporting what it would promote until `BONGOS_PROMOTION_SWEEP_ARMED=1`, because ~155 rows changing status at once deserves a preview. The **open-blocker guard is re-checked in the sweep's own SQL**: migration 176 folded that clause into `task_is_fully_unblocked` and migration 178 dropped it while claiming to restore "the 163 body" — verified against production, where one task already sits at `ready` with an open blocker (its fix is task 1003934). A bulk promoter must not inherit a single point of failure that has been reverted once. Consequence the owner accepted: **`backlog` stops being a resting state** and `/backlog-review` loses most of its purpose. Rejected: promoting at creation (breaks `cascade.js`'s tested backlog invariant and fixes nothing already parked); scoping the first run to one goal (leaves the defect live in ten others).](0287-a-backlog-task-that-nothing-gates-promotes-itself.md) | lifecycle / work tracking |
|
|
@@ -1978,5 +1978,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1978
1978
|
landed since 1.19.750 with no explicit bump. run 35033016299. (task 1002620)
|
|
1979
1979
|
1.19.752 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1980
1980
|
landed since 1.19.751 with no explicit bump. run 35034524356. (task 1002620)
|
|
1981
|
+
1.19.753 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1982
|
+
landed since 1.19.752 with no explicit bump. run 35036151976. (task 1002620)
|
|
1983
|
+
1.19.754 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1984
|
+
landed since 1.19.753 with no explicit bump. run 35039836268. (task 1002620)
|
|
1981
1985
|
---------------------------------------------------------------------------
|
|
1982
1986
|
```
|
|
@@ -1037,6 +1037,66 @@ async function updateTaskStatus(id, status) {
|
|
|
1037
1037
|
await pool.query(`UPDATE tasks SET status = $2 WHERE id = $1`, [id, status]);
|
|
1038
1038
|
}
|
|
1039
1039
|
|
|
1040
|
+
// The UNSCOPED half of auto-promotion (task 1003932) — the backlog drain that
|
|
1041
|
+
// `runCriterionSweep` already is for criteria.
|
|
1042
|
+
//
|
|
1043
|
+
// Every promotion path migration 163 defines is an AFTER UPDATE trigger at the
|
|
1044
|
+
// DEPENDENCY end of an edge: a task ships, a criterion is satisfied, a goal is
|
|
1045
|
+
// achieved. Two states are therefore unreachable by any trigger, and neither is
|
|
1046
|
+
// rare: a task with NO gating edge is nobody's dependent, so nothing ever
|
|
1047
|
+
// evaluates it; and an edge added AFTER its dependency shipped missed the only
|
|
1048
|
+
// event that would have. Measured on the live DB 2026-09-15: 155 backlog tasks
|
|
1049
|
+
// project-wide were promotable and parked, 33 of 41 in one goal alone.
|
|
1050
|
+
//
|
|
1051
|
+
// TWO GUARDS, BOTH IN SQL, and the second is not redundant. `task_blockers` is
|
|
1052
|
+
// re-checked here rather than trusted to `task_is_fully_unblocked`, because
|
|
1053
|
+
// migration 176 folded that clause into the predicate and migration 178 replaced
|
|
1054
|
+
// the body with one it called "byte-identical to the 163 body" — dropping it.
|
|
1055
|
+
// Verified against production: the live predicate has no blocker clause (its own
|
|
1056
|
+
// fix is task 1003934). A sweep that promotes in bulk must not inherit a single
|
|
1057
|
+
// point of failure that has already been reverted once.
|
|
1058
|
+
//
|
|
1059
|
+
// The UPDATE repeats the WHERE rather than trusting the ids it just read, so a
|
|
1060
|
+
// row that stops qualifying between the two statements is not promoted anyway.
|
|
1061
|
+
const PROMOTABLE_BACKLOG_WHERE = `
|
|
1062
|
+
WHERE t.status = 'backlog'
|
|
1063
|
+
AND task_is_fully_unblocked(t.id)
|
|
1064
|
+
AND NOT EXISTS (
|
|
1065
|
+
SELECT 1
|
|
1066
|
+
FROM task_blockers tb
|
|
1067
|
+
JOIN blockers b ON b.id = tb.blocker_id
|
|
1068
|
+
WHERE tb.task_id = t.id
|
|
1069
|
+
AND b.status = 'open')`;
|
|
1070
|
+
|
|
1071
|
+
// Runaway guard, not an expected limit (the runSweep precedent).
|
|
1072
|
+
const PROMOTION_SWEEP_LIMIT = 500;
|
|
1073
|
+
|
|
1074
|
+
async function promoteUnblockedBacklogTasks(exec, { armed = false, limit = PROMOTION_SWEEP_LIMIT } = {}) {
|
|
1075
|
+
const client = exec || pool;
|
|
1076
|
+
const { rows: candidates } = await client.query(
|
|
1077
|
+
`SELECT t.id, t.title, t.goal_id
|
|
1078
|
+
FROM tasks t
|
|
1079
|
+
${PROMOTABLE_BACKLOG_WHERE}
|
|
1080
|
+
ORDER BY t.id
|
|
1081
|
+
LIMIT $1`,
|
|
1082
|
+
[limit]
|
|
1083
|
+
);
|
|
1084
|
+
// Disarmed is the DEFAULT: this sweep can move hundreds of rows at once, so it
|
|
1085
|
+
// reports first and promotes only once explicitly armed.
|
|
1086
|
+
if (!armed || candidates.length === 0) return { armed, candidates, promoted: [] };
|
|
1087
|
+
const { rows: promoted } = await client.query(
|
|
1088
|
+
`UPDATE tasks t
|
|
1089
|
+
SET status = 'ready',
|
|
1090
|
+
promoted_at = COALESCE(t.promoted_at, now()),
|
|
1091
|
+
updated_at = now()
|
|
1092
|
+
${PROMOTABLE_BACKLOG_WHERE}
|
|
1093
|
+
AND t.id = ANY($1::bigint[])
|
|
1094
|
+
RETURNING t.id, t.title, t.goal_id`,
|
|
1095
|
+
[candidates.map((r) => r.id)]
|
|
1096
|
+
);
|
|
1097
|
+
return { armed, candidates, promoted };
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1040
1100
|
// The cascade-filed ARTIST REVIEW rows that are still open (ADR 0241, task
|
|
1041
1101
|
// 1003575) — the state the deploy gate reads. Narrow projection on purpose: the
|
|
1042
1102
|
// caller needs an id and a filing date to name what holds a deploy and to apply
|
|
@@ -1111,6 +1171,7 @@ module.exports = {
|
|
|
1111
1171
|
listClaimableTasks,
|
|
1112
1172
|
listOpenArtistReviews,
|
|
1113
1173
|
listTasks,
|
|
1174
|
+
promoteUnblockedBacklogTasks,
|
|
1114
1175
|
rankCrossDisciplineRecommendations,
|
|
1115
1176
|
rewardGateAssignment,
|
|
1116
1177
|
rewardGateError,
|
|
@@ -886,6 +886,42 @@ async function runCriterionSweep(deps = {}) {
|
|
|
886
886
|
}
|
|
887
887
|
}
|
|
888
888
|
|
|
889
|
+
// runPromotionSweep — the backlog drain (task 1003932). Same shape and same
|
|
890
|
+
// reason as runCriterionSweep above: the three migration-163 promotion triggers
|
|
891
|
+
// only fire at the DEPENDENCY end of an edge, so a backlog task with no edges is
|
|
892
|
+
// never evaluated by anything and a late-added edge missed its only event.
|
|
893
|
+
//
|
|
894
|
+
// DISARMED BY DEFAULT. It can move hundreds of rows in one tick (155 were
|
|
895
|
+
// promotable on 2026-09-15), so it reports what it WOULD promote until
|
|
896
|
+
// BONGOS_PROMOTION_SWEEP_ARMED=1 says otherwise. Pure DB like runCostSweep — no
|
|
897
|
+
// push credential — so it runs on hosts where getPublishStatus 503s.
|
|
898
|
+
async function runPromotionSweep(deps = {}) {
|
|
899
|
+
const { logFn, errFn } = loggers(deps);
|
|
900
|
+
// Injected deps.tasksDb is the test seam; the sibling module is production.
|
|
901
|
+
const tasksDb = (deps.tasksDb && typeof deps.tasksDb.promoteUnblockedBacklogTasks === 'function')
|
|
902
|
+
? deps.tasksDb
|
|
903
|
+
: require('./db-tasks.js');
|
|
904
|
+
const exec = deps.pool || seams.pool;
|
|
905
|
+
const armed = typeof deps.promotionArmed === 'boolean'
|
|
906
|
+
? deps.promotionArmed
|
|
907
|
+
: process.env.BONGOS_PROMOTION_SWEEP_ARMED === '1';
|
|
908
|
+
try {
|
|
909
|
+
const out = await tasksDb.promoteUnblockedBacklogTasks(exec, { armed });
|
|
910
|
+
for (const t of out.promoted) {
|
|
911
|
+
logFn(`[publish-reconciler] task ${t.id} backlog→ready — every dependency is done and no blocker is open`);
|
|
912
|
+
}
|
|
913
|
+
if (!out.armed && out.candidates.length) {
|
|
914
|
+
const ids = out.candidates.map((t) => t.id).join(', ');
|
|
915
|
+
logFn(`[publish-reconciler] promotion sweep (report-only): ${out.candidates.length} task(s) would promote — ${ids}`);
|
|
916
|
+
logFn('[publish-reconciler] set BONGOS_PROMOTION_SWEEP_ARMED=1 to let it promote them');
|
|
917
|
+
}
|
|
918
|
+
return out;
|
|
919
|
+
} catch (e) {
|
|
920
|
+
errFn(`[publish-reconciler] promotion sweep (non-blocking): ${e && e.message}`);
|
|
921
|
+
return { skipped: 'error', error: e && e.message };
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
|
|
889
925
|
// Start the periodic sweep. Idempotent (a second call is a no-op). Kill-switched
|
|
890
926
|
// by PUBLISH_RECONCILER_DISABLED=1 (purely additive bookkeeping, so it's ON by
|
|
891
927
|
// default whenever a push credential is configured; the route's getPublishStatus
|
|
@@ -905,6 +941,7 @@ function start(deps = {}) {
|
|
|
905
941
|
runSweep(deps).catch(crashHandler('sweep'));
|
|
906
942
|
runCostSweep(deps).catch(crashHandler('cost sweep'));
|
|
907
943
|
runCriterionSweep(deps).catch(crashHandler('criterion sweep'));
|
|
944
|
+
runPromotionSweep(deps).catch(crashHandler('promotion sweep'));
|
|
908
945
|
};
|
|
909
946
|
|
|
910
947
|
firstTimer = setTimeout(tick, FIRST_SWEEP_DELAY_MS);
|
|
@@ -941,6 +978,7 @@ module.exports = {
|
|
|
941
978
|
stop,
|
|
942
979
|
runSweep,
|
|
943
980
|
runCostSweep,
|
|
981
|
+
runPromotionSweep,
|
|
944
982
|
runCriterionSweep,
|
|
945
983
|
couldBeStaleGenerated,
|
|
946
984
|
describeTarget,
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.754",
|
|
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.754",
|
|
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.754",
|
|
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
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.754'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// tests/promotion_sweep.mjs
|
|
2
|
+
//
|
|
3
|
+
// The backlog promotion sweep (task 1003932) — the UNSCOPED half of
|
|
4
|
+
// auto-promotion, the exact shape runCriterionSweep already has for criteria.
|
|
5
|
+
//
|
|
6
|
+
// Why it exists: every promotion path in migration 163 is an AFTER UPDATE
|
|
7
|
+
// trigger at the DEPENDENCY end of an edge (a task ships, a criterion is
|
|
8
|
+
// satisfied, a goal is achieved). A task with NO gating edge is nobody's
|
|
9
|
+
// dependent, so no trigger ever evaluates it and it cannot leave 'backlog' —
|
|
10
|
+
// measured 2026-09-15: 33 of 41 backlog tasks in goal 1000106, and 155
|
|
11
|
+
// project-wide. A task whose edge was added AFTER its dependency shipped is
|
|
12
|
+
// stuck the same way: the ship event already happened.
|
|
13
|
+
//
|
|
14
|
+
// HOW THESE TESTS PROVE A GUARD, and where that proof stops. CI runs a DB-free
|
|
15
|
+
// unit gate (scripts/gds/run-unit-tests.js excludes anything needing Postgres,
|
|
16
|
+
// and no workflow provides a Postgres service), so a fixture-in-a-real-database
|
|
17
|
+
// test would run nowhere. Instead the fake pool below answers the way the
|
|
18
|
+
// database would GIVEN THE GUARDS THE STATEMENT ACTUALLY CARRIES: a blocked row
|
|
19
|
+
// is withheld only because the SQL contains the blocker clause. Drop the clause
|
|
20
|
+
// and that row starts coming back, and the "is NOT promoted" cases go red.
|
|
21
|
+
//
|
|
22
|
+
// The residual gap, stated rather than papered over: a guard NEUTERED while
|
|
23
|
+
// keeping its wording (`AND b.status = 'open' AND FALSE`) still reads as present
|
|
24
|
+
// here. Closing that needs Postgres. What stands in for it is a live read-only
|
|
25
|
+
// verification recorded in the task's ship notes — against production, the
|
|
26
|
+
// sweep's own candidate query returned 155 rows, of which 0 carried an open
|
|
27
|
+
// blocker and 0 were not fully unblocked, while 2 backlog rows were excluded by
|
|
28
|
+
// the blocker clause alone.
|
|
29
|
+
//
|
|
30
|
+
// Run: node tests/promotion_sweep.mjs
|
|
31
|
+
|
|
32
|
+
import { strict as assert } from 'node:assert';
|
|
33
|
+
import { createRequire } from 'node:module';
|
|
34
|
+
import { readFileSync } from 'node:fs';
|
|
35
|
+
|
|
36
|
+
const require = createRequire(import.meta.url);
|
|
37
|
+
const dbTasks = require('../modules/lifecycle/db-tasks.js');
|
|
38
|
+
const reconciler = require('../modules/lifecycle/publish-reconciler.js');
|
|
39
|
+
|
|
40
|
+
let passed = 0;
|
|
41
|
+
let failed = 0;
|
|
42
|
+
async function test(name, fn) {
|
|
43
|
+
try {
|
|
44
|
+
await fn();
|
|
45
|
+
passed++;
|
|
46
|
+
console.log(` ok ${name}`);
|
|
47
|
+
} catch (err) {
|
|
48
|
+
failed++;
|
|
49
|
+
console.log(` FAIL ${name}`);
|
|
50
|
+
console.log(` ${err.message}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// The two shapes that are stuck today, plus one row per guard the sweep must
|
|
55
|
+
// respect. `why` is the reason the row must NOT be promoted.
|
|
56
|
+
const QUALIFIES = [
|
|
57
|
+
{ id: 1003930, title: 'no dependency edges at all', goal_id: 1000106 },
|
|
58
|
+
{ id: 1002348, title: 'edge added after the dependency shipped', goal_id: 1000106 },
|
|
59
|
+
];
|
|
60
|
+
const EXCLUDED = [
|
|
61
|
+
{ id: 2000001, title: 'a dependency has not shipped', goal_id: 1000106, why: 'unmetDep' },
|
|
62
|
+
{ id: 2000002, title: 'carries an open blocker', goal_id: 1000106, why: 'openBlocker' },
|
|
63
|
+
{ id: 2000003, title: 'already out of backlog', goal_id: 1000106, why: 'notBacklog' },
|
|
64
|
+
];
|
|
65
|
+
const FIXTURE = [...QUALIFIES, ...EXCLUDED];
|
|
66
|
+
|
|
67
|
+
// Answer as the database would, given the guards this statement carries.
|
|
68
|
+
function admittedBy(sql, rows) {
|
|
69
|
+
const backlogGuard = /status\s*=\s*'backlog'/i.test(sql);
|
|
70
|
+
const depGuard = /task_is_fully_unblocked\s*\(/i.test(sql);
|
|
71
|
+
const blockerGuard = /task_blockers/i.test(sql) && /b\.status\s*=\s*'open'/i.test(sql);
|
|
72
|
+
return rows.filter((r) => {
|
|
73
|
+
if (r.why === 'notBacklog' && backlogGuard) return false;
|
|
74
|
+
if (r.why === 'unmetDep' && depGuard) return false;
|
|
75
|
+
if (r.why === 'openBlocker' && blockerGuard) return false;
|
|
76
|
+
return true;
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function mkPool({ rows = FIXTURE } = {}) {
|
|
81
|
+
const calls = [];
|
|
82
|
+
return {
|
|
83
|
+
calls,
|
|
84
|
+
async query(sql, params) {
|
|
85
|
+
const text = String(sql);
|
|
86
|
+
calls.push({ sql: text, params });
|
|
87
|
+
if (/^\s*UPDATE/i.test(text)) {
|
|
88
|
+
// The UPDATE re-checks the guards AND narrows to the ids it was handed.
|
|
89
|
+
const ids = Array.isArray(params && params[0]) ? params[0] : null;
|
|
90
|
+
const scoped = ids ? rows.filter((r) => ids.includes(r.id)) : rows;
|
|
91
|
+
return { rows: admittedBy(text, scoped) };
|
|
92
|
+
}
|
|
93
|
+
return { rows: admittedBy(text, rows) };
|
|
94
|
+
},
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const updatesIn = (pool) => pool.calls.filter((c) => /^\s*UPDATE/i.test(c.sql));
|
|
99
|
+
const idsOf = (rows) => rows.map((r) => r.id);
|
|
100
|
+
|
|
101
|
+
// ---------- what gets promoted, and what does not ----------
|
|
102
|
+
|
|
103
|
+
await test('report-only by default: it names candidates and writes NOTHING', async () => {
|
|
104
|
+
const pool = mkPool();
|
|
105
|
+
const out = await dbTasks.promoteUnblockedBacklogTasks(pool, {});
|
|
106
|
+
assert.equal(out.armed, false, 'default is report-only — arming is explicit');
|
|
107
|
+
assert.deepEqual(idsOf(out.candidates), idsOf(QUALIFIES));
|
|
108
|
+
assert.deepEqual(out.promoted, [], 'nothing is promoted while disarmed');
|
|
109
|
+
assert.equal(updatesIn(pool).length, 0, 'a disarmed sweep must never issue an UPDATE');
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
await test('armed: it promotes exactly the qualifying rows', async () => {
|
|
113
|
+
const pool = mkPool();
|
|
114
|
+
const out = await dbTasks.promoteUnblockedBacklogTasks(pool, { armed: true });
|
|
115
|
+
assert.equal(out.armed, true);
|
|
116
|
+
assert.deepEqual(idsOf(out.promoted), idsOf(QUALIFIES));
|
|
117
|
+
const update = updatesIn(pool)[0];
|
|
118
|
+
assert.ok(update, 'an armed sweep issues the UPDATE');
|
|
119
|
+
assert.match(update.sql, /status\s*=\s*'ready'/i, "it moves the task to 'ready'");
|
|
120
|
+
assert.match(update.sql, /promoted_at\s*=\s*COALESCE\(/i,
|
|
121
|
+
'and stamps promoted_at without overwriting an existing one (mirrors the 163 triggers)');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
await test('a task whose dependency has not shipped is NOT promoted', async () => {
|
|
125
|
+
const pool = mkPool();
|
|
126
|
+
const out = await dbTasks.promoteUnblockedBacklogTasks(pool, { armed: true });
|
|
127
|
+
assert.ok(!idsOf(out.candidates).includes(2000001), 'never a candidate');
|
|
128
|
+
assert.ok(!idsOf(out.promoted).includes(2000001), 'and never promoted');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
await test('a task carrying an open blocker is NOT promoted', async () => {
|
|
132
|
+
const pool = mkPool();
|
|
133
|
+
const out = await dbTasks.promoteUnblockedBacklogTasks(pool, { armed: true });
|
|
134
|
+
assert.ok(!idsOf(out.candidates).includes(2000002), 'never a candidate');
|
|
135
|
+
assert.ok(!idsOf(out.promoted).includes(2000002), 'and never promoted');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
await test('a task already out of backlog is NOT touched', async () => {
|
|
139
|
+
const pool = mkPool();
|
|
140
|
+
const out = await dbTasks.promoteUnblockedBacklogTasks(pool, { armed: true });
|
|
141
|
+
assert.ok(!idsOf(out.promoted).includes(2000003));
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
await test('the UPDATE re-checks the guards rather than trusting the ids it read', async () => {
|
|
145
|
+
const pool = mkPool();
|
|
146
|
+
await dbTasks.promoteUnblockedBacklogTasks(pool, { armed: true });
|
|
147
|
+
const update = updatesIn(pool)[0].sql;
|
|
148
|
+
assert.match(update, /status\s*=\s*'backlog'/i, 'backlog-only, restated in the write');
|
|
149
|
+
assert.match(update, /task_is_fully_unblocked\s*\(/i, 'dependency doneness, restated');
|
|
150
|
+
assert.match(update, /task_blockers/i, 'open-blocker guard, restated');
|
|
151
|
+
assert.match(update, /id = ANY\(/i, 'and narrowed to the candidates it just read');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
await test('the guards are in the read as well as the write', async () => {
|
|
155
|
+
const pool = mkPool();
|
|
156
|
+
await dbTasks.promoteUnblockedBacklogTasks(pool, {});
|
|
157
|
+
const select = pool.calls.find((c) => /^\s*SELECT/i.test(c.sql)).sql;
|
|
158
|
+
assert.match(select, /status\s*=\s*'backlog'/i);
|
|
159
|
+
assert.match(select, /task_is_fully_unblocked\s*\(/i);
|
|
160
|
+
// The shared predicate LOST its blocker clause when migration 178 replaced
|
|
161
|
+
// migration 176's body (verified against production, task 1003934), so the
|
|
162
|
+
// sweep carries its own rather than inheriting a single point of failure that
|
|
163
|
+
// has already been reverted once.
|
|
164
|
+
assert.match(select, /task_blockers/i);
|
|
165
|
+
assert.match(select, /b\.status\s*=\s*'open'/i);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
await test('nothing to do: no candidates means no UPDATE, even armed', async () => {
|
|
169
|
+
const pool = mkPool({ rows: EXCLUDED });
|
|
170
|
+
const out = await dbTasks.promoteUnblockedBacklogTasks(pool, { armed: true });
|
|
171
|
+
assert.deepEqual(out.candidates, [], 'every fixture row is excluded by a guard');
|
|
172
|
+
assert.deepEqual(out.promoted, []);
|
|
173
|
+
assert.equal(updatesIn(pool).length, 0,
|
|
174
|
+
'an empty candidate set must not issue a write (the idempotent second run)');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// ---------- the sweep + its wiring ----------
|
|
178
|
+
|
|
179
|
+
await test('runPromotionSweep reports through the injected logger and never throws', async () => {
|
|
180
|
+
const lines = [];
|
|
181
|
+
const out = await reconciler.runPromotionSweep({
|
|
182
|
+
pool: mkPool(),
|
|
183
|
+
promotionArmed: false,
|
|
184
|
+
log: (m) => lines.push(m),
|
|
185
|
+
error: (m) => lines.push(`ERR ${m}`),
|
|
186
|
+
});
|
|
187
|
+
assert.equal(out.armed, false);
|
|
188
|
+
assert.match(lines.join('\n'), /1003930/, 'it names the tasks it would promote');
|
|
189
|
+
assert.ok(!lines.some((l) => l.startsWith('ERR')), 'a clean run logs no error');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
await test('a throwing pool is reported, not propagated (a sweep can never crash the tick)', async () => {
|
|
193
|
+
const errs = [];
|
|
194
|
+
const boom = { query: async () => { throw new Error('db exploded'); } };
|
|
195
|
+
const out = await reconciler.runPromotionSweep({ pool: boom, log: () => {}, error: (m) => errs.push(m) });
|
|
196
|
+
assert.equal(out.skipped, 'error', 'the failure is returned, not thrown');
|
|
197
|
+
assert.match(errs.join('\n'), /db exploded/, 'and it says what happened');
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
await test('the sweep runs on the tick, fire-and-forget like its siblings', async () => {
|
|
201
|
+
const src = readFileSync(new URL('../modules/lifecycle/publish-reconciler.js', import.meta.url), 'utf8')
|
|
202
|
+
.replace(/\r\n/g, '\n');
|
|
203
|
+
assert.match(src, /runPromotionSweep\(deps\)\.catch\(/, 'wired into tick() the way runCriterionSweep is');
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// ---------- Summary ----------
|
|
207
|
+
|
|
208
|
+
console.log('');
|
|
209
|
+
console.log(`promotion-sweep tests: ${passed} passed, ${failed} failed`);
|
|
210
|
+
if (failed > 0) process.exitCode = 1;
|
package/tests/session-cards.mjs
CHANGED
|
@@ -136,6 +136,58 @@ test('empty / malformed envelope or missing title → empty string', () => {
|
|
|
136
136
|
assert.equal(hook.buildAdditionalContext(onEnv, null), '', 'no title → nothing');
|
|
137
137
|
});
|
|
138
138
|
|
|
139
|
+
// ---------- buildAdditionalContext: delivery mode (task 1003933) ----------
|
|
140
|
+
//
|
|
141
|
+
// A prompt-MATCHED card is the whole answer — the builder typed /builder-start,
|
|
142
|
+
// so rendering it and nothing else is right. A card delivered by the
|
|
143
|
+
// prompt-INDEPENDENT land-watch hook is not: it rides whatever the builder
|
|
144
|
+
// happened to say next, and the "verbatim as your reply" directive made it
|
|
145
|
+
// displace a real question twice on 2026-09-15.
|
|
146
|
+
|
|
147
|
+
test("default mode claims the reply; 'append' yields to the builder's message", () => {
|
|
148
|
+
const reply = hook.buildAdditionalContext(onEnv, 'status_card');
|
|
149
|
+
assert.ok(/as your reply/i.test(reply), 'default still says the card IS the reply');
|
|
150
|
+
const append = hook.buildAdditionalContext(onEnv, 'task_card', 'append');
|
|
151
|
+
assert.ok(/answer[\s\S]{0,40}first/i.test(append), "append tells the model to answer the builder first");
|
|
152
|
+
assert.ok(/after (?:you )?answer|end of your reply/i.test(append), 'and to place the card after that answer');
|
|
153
|
+
assert.ok(!/verbatim as your reply/i.test(append), 'append never claims the whole reply');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("'append' still embeds both payloads verbatim, each fence exactly twice", () => {
|
|
157
|
+
const ctx = hook.buildAdditionalContext(onEnv, 'task_card', 'append');
|
|
158
|
+
assert.ok(ctx.includes('<div class="st-card">WIDGET-BODY</div>'), 'HTML verbatim');
|
|
159
|
+
assert.ok(ctx.includes('MARKDOWN-BODY'), 'markdown verbatim');
|
|
160
|
+
assert.equal(count(ctx, '<<<WIDGET_HTML>>>'), 2);
|
|
161
|
+
assert.equal(count(ctx, '<<<MARKDOWN_CARD>>>'), 2);
|
|
162
|
+
assert.ok(/do NOT run the script yourself/i.test(ctx), 'still forbids re-running the script');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("'append' holds with widgets OFF (the markdown-only branch)", () => {
|
|
166
|
+
const ctx = hook.buildAdditionalContext(
|
|
167
|
+
{ widgets_enabled: false, html: null, markdown: 'MD-ONLY', warnings: [] }, 'task_card', 'append');
|
|
168
|
+
assert.ok(ctx.includes('MD-ONLY'));
|
|
169
|
+
assert.ok(/answer[\s\S]{0,40}first/i.test(ctx), 'append wording reaches the widgets-off branch too');
|
|
170
|
+
assert.equal(count(ctx, '<<<WIDGET_HTML>>>'), 0);
|
|
171
|
+
assert.equal(count(ctx, '<<<MARKDOWN_CARD>>>'), 2);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test('an unknown mode falls back to the reply directive (fail-safe)', () => {
|
|
175
|
+
const ctx = hook.buildAdditionalContext(onEnv, 'status_card', 'nonsense');
|
|
176
|
+
assert.ok(/as your reply/i.test(ctx), 'unknown mode must not silently drop the directive');
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test('land-watch-card.js requests the append mode', () => {
|
|
180
|
+
// The prompt-independent hook is the whole reason 'append' exists; a call
|
|
181
|
+
// without it would reintroduce the hijack. Source-level because the hook
|
|
182
|
+
// requires its sibling inside main() at runtime. CRLF-normalised: this
|
|
183
|
+
// checkout reads back \r\n and an anchored match would silently pass.
|
|
184
|
+
const src = require('node:fs')
|
|
185
|
+
.readFileSync(new URL('../.claude/hooks/land-watch-card.js', import.meta.url), 'utf8')
|
|
186
|
+
.replace(/\r\n/g, '\n');
|
|
187
|
+
assert.match(src, /buildAdditionalContext\(\s*envelope\s*,[^)]*,\s*'append'\s*\)/,
|
|
188
|
+
"land-watch-card must pass 'append' when building its directive");
|
|
189
|
+
});
|
|
190
|
+
|
|
139
191
|
// ---------- Summary ----------
|
|
140
192
|
|
|
141
193
|
console.log('');
|