@bongos/core 1.19.465 → 1.19.467
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 +30 -20
- package/docs/adr/0224-module-skills-reach-the-core-checkout-as-untracked-self-ignored-copies.md +30 -0
- package/docs/adr/README.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/modules/economy/credits.js +116 -0
- package/modules/economy/reward.js +5 -0
- package/modules/ui-design/skills/README.md +2 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/claude-materialize.js +86 -3
- package/scripts/gds/gen-file-map.js +12 -2
- package/src/module-api.js +1 -1
- package/tests/claude_materialize.mjs +81 -0
- package/tests/file_map.mjs +4 -1
- package/tests/idea_governance_pass_credit.mjs +243 -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.467",
|
|
6
|
+
"core_contract": "1.19.467",
|
|
7
|
+
"source_commit": "ca27d74624afc9f0a8310e29b2efaa2c5bb993c8",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-02T13:55:51.607Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
|
-
"docs_redacted":
|
|
12
|
+
"docs_redacted": 407,
|
|
13
13
|
"agent_docs_stubbed": 24,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1823,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2254,
|
|
20
|
+
"tree_sha256": "8d432d170e40887e5a113bf2531114e2e0b29f4e8dd8aada1b61ae25b764b597",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -1624,10 +1624,15 @@
|
|
|
1624
1624
|
"mode": "0000644",
|
|
1625
1625
|
"sha256": "736f27c4a9e86d3169954a2555209e75dfc93f84b4581417188c71a02954e731"
|
|
1626
1626
|
},
|
|
1627
|
+
{
|
|
1628
|
+
"path": "docs/adr/0224-module-skills-reach-the-core-checkout-as-untracked-self-ignored-copies.md",
|
|
1629
|
+
"mode": "0000644",
|
|
1630
|
+
"sha256": "717bdc174891ccf5bc8756b25f53eca016d4d6dfaeebec8d12bb2a6bfef56976"
|
|
1631
|
+
},
|
|
1627
1632
|
{
|
|
1628
1633
|
"path": "docs/adr/README.md",
|
|
1629
1634
|
"mode": "0000644",
|
|
1630
|
-
"sha256": "
|
|
1635
|
+
"sha256": "ec731cb900dc5b30287a370325042a4ead509759aba5688c32d91f293047c491"
|
|
1631
1636
|
},
|
|
1632
1637
|
{
|
|
1633
1638
|
"path": "docs/api-reference.md",
|
|
@@ -2117,7 +2122,7 @@
|
|
|
2117
2122
|
{
|
|
2118
2123
|
"path": "docs/module-api-changelog.md",
|
|
2119
2124
|
"mode": "0000644",
|
|
2120
|
-
"sha256": "
|
|
2125
|
+
"sha256": "f5622e8a177e977aedade86d47f76e7669f2f417a1d88d5cd63c66949fe9d8c2"
|
|
2121
2126
|
},
|
|
2122
2127
|
{
|
|
2123
2128
|
"path": "docs/modules-contract.md",
|
|
@@ -3607,7 +3612,7 @@
|
|
|
3607
3612
|
{
|
|
3608
3613
|
"path": "modules/economy/credits.js",
|
|
3609
3614
|
"mode": "0000644",
|
|
3610
|
-
"sha256": "
|
|
3615
|
+
"sha256": "06f1cc7080509864ea4f474627a7a35de8348fcf4f935a13fc62da38e2a67fa8"
|
|
3611
3616
|
},
|
|
3612
3617
|
{
|
|
3613
3618
|
"path": "modules/economy/migrations/economy_001_fix_gds_shipper_description.sql",
|
|
@@ -3627,7 +3632,7 @@
|
|
|
3627
3632
|
{
|
|
3628
3633
|
"path": "modules/economy/reward.js",
|
|
3629
3634
|
"mode": "0000644",
|
|
3630
|
-
"sha256": "
|
|
3635
|
+
"sha256": "13cc932402c3cf0d2154507c6e6a462aeb98520a2214b8561ec01a6bb9245fd6"
|
|
3631
3636
|
},
|
|
3632
3637
|
{
|
|
3633
3638
|
"path": "modules/economy/routes/cost.js",
|
|
@@ -6387,7 +6392,7 @@
|
|
|
6387
6392
|
{
|
|
6388
6393
|
"path": "modules/ui-design/skills/README.md",
|
|
6389
6394
|
"mode": "0000644",
|
|
6390
|
-
"sha256": "
|
|
6395
|
+
"sha256": "ddc9a2b8788d3e41f1d3ae285d56c75ecb6e7a9d4aa003a0db55f4d38ff24d4a"
|
|
6391
6396
|
},
|
|
6392
6397
|
{
|
|
6393
6398
|
"path": "modules/ui-design/skills/brandkit/SKILL.md",
|
|
@@ -6617,12 +6622,12 @@
|
|
|
6617
6622
|
{
|
|
6618
6623
|
"path": "package-lock.json",
|
|
6619
6624
|
"mode": "0000644",
|
|
6620
|
-
"sha256": "
|
|
6625
|
+
"sha256": "fecffff8fe1b80ab540c5212c13d5056d722e897b824afdc2ebeaae24738935b"
|
|
6621
6626
|
},
|
|
6622
6627
|
{
|
|
6623
6628
|
"path": "package.json",
|
|
6624
6629
|
"mode": "0000644",
|
|
6625
|
-
"sha256": "
|
|
6630
|
+
"sha256": "f4a08b3e124eba41cb2e39cea9a79ede0858c5e1496f2ce1531030814df04d12"
|
|
6626
6631
|
},
|
|
6627
6632
|
{
|
|
6628
6633
|
"path": "public-docs/index.html",
|
|
@@ -6857,7 +6862,7 @@
|
|
|
6857
6862
|
{
|
|
6858
6863
|
"path": "scripts/gds/claude-materialize.js",
|
|
6859
6864
|
"mode": "0000644",
|
|
6860
|
-
"sha256": "
|
|
6865
|
+
"sha256": "450c1a12c5e8c125db6794d74bb2c25251804d8613d83fa50a463e4007a46ddd"
|
|
6861
6866
|
},
|
|
6862
6867
|
{
|
|
6863
6868
|
"path": "scripts/gds/claude-md-template.js",
|
|
@@ -7107,7 +7112,7 @@
|
|
|
7107
7112
|
{
|
|
7108
7113
|
"path": "scripts/gds/gen-file-map.js",
|
|
7109
7114
|
"mode": "0000644",
|
|
7110
|
-
"sha256": "
|
|
7115
|
+
"sha256": "620188a2edcd4aec580193b3d97f0c4e19871db4d31af27cb0b7e0571457a73a"
|
|
7111
7116
|
},
|
|
7112
7117
|
{
|
|
7113
7118
|
"path": "scripts/gds/gen-freshness.js",
|
|
@@ -8272,7 +8277,7 @@
|
|
|
8272
8277
|
{
|
|
8273
8278
|
"path": "src/module-api.js",
|
|
8274
8279
|
"mode": "0000644",
|
|
8275
|
-
"sha256": "
|
|
8280
|
+
"sha256": "30e8f24ef0a0f45e8c4172c98daee61756ba74acf0d55f100ae8cc6c7b7540e2"
|
|
8276
8281
|
},
|
|
8277
8282
|
{
|
|
8278
8283
|
"path": "src/module-loader/catalog.js",
|
|
@@ -8787,7 +8792,7 @@
|
|
|
8787
8792
|
{
|
|
8788
8793
|
"path": "tests/claude_materialize.mjs",
|
|
8789
8794
|
"mode": "0000644",
|
|
8790
|
-
"sha256": "
|
|
8795
|
+
"sha256": "6599b89948d3289d869f94fd87072f26a37d10a1e67727faf6c05b5052a035c8"
|
|
8791
8796
|
},
|
|
8792
8797
|
{
|
|
8793
8798
|
"path": "tests/clawback_confirmed_backlog.mjs",
|
|
@@ -9177,7 +9182,7 @@
|
|
|
9177
9182
|
{
|
|
9178
9183
|
"path": "tests/file_map.mjs",
|
|
9179
9184
|
"mode": "0000644",
|
|
9180
|
-
"sha256": "
|
|
9185
|
+
"sha256": "b44f040f21d1dbb978add1a96e392a0f3c1b5a46302c10616b2304505ca66097"
|
|
9181
9186
|
},
|
|
9182
9187
|
{
|
|
9183
9188
|
"path": "tests/filing_abuse_matrix.mjs",
|
|
@@ -9789,6 +9794,11 @@
|
|
|
9789
9794
|
"mode": "0000644",
|
|
9790
9795
|
"sha256": "63afe2afd59ff64e290234a0cc8d33a0f77b784d8c10c2d7147fe2ab3c114cd1"
|
|
9791
9796
|
},
|
|
9797
|
+
{
|
|
9798
|
+
"path": "tests/idea_governance_pass_credit.mjs",
|
|
9799
|
+
"mode": "0000644",
|
|
9800
|
+
"sha256": "75af0cfce88da50c74a1c499e7308774b70eb05e31a3274246dd6b4c8fdb1b34"
|
|
9801
|
+
},
|
|
9792
9802
|
{
|
|
9793
9803
|
"path": "tests/idea_grades.mjs",
|
|
9794
9804
|
"mode": "0000644",
|
package/docs/adr/0224-module-skills-reach-the-core-checkout-as-untracked-self-ignored-copies.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# ADR 0224 — Module-owned skills reach the core checkout as untracked, self-ignored copies with a manifest: the self-copy guard stays, the dev path is a flag, and the file map skips what the module documents
|
|
2
|
+
|
|
3
|
+
- **Status:** Accepted (2026-09-02)
|
|
4
|
+
- **Task:** [task 1003330](https://cloudbongos.com/builders#/task/1003330) (goal 1000083 — the ui-design module as a platform capability; from idea 1000864, filed by task 1003321)
|
|
5
|
+
- **Context:** [ADR 0198](<redacted>.md) landed module-owned skills for INSTANCES (`claude-materialize.js` step 1b, on `bongos init` / `upgrade`); [ADR 0220](<redacted>.md) and its successors noted each time that "the core checkout itself still does not see module-owned skills".
|
|
6
|
+
|
|
7
|
+
## Context
|
|
8
|
+
|
|
9
|
+
`materializeClaude`'s self-copy guard is a deliberate no-op when the instance dir is the core root: a core copying itself onto itself would be a no-op at best and corrupting at worst. Claude Code discovers only `.claude/skills/` at the project root. So a builder working in the core checkout, where all nine `ui-design` skills were written, cannot invoke a single one of them; the only copies on this machine were the upstream ones under the builder's home directory, by the same names, which is the collision ADR 0220 called "by design". Two constraints shaped the fix: nothing under `.claude/skills/` may be committed twice (the module's `skills/<name>/` is the source and the file-map generator lists every dir there and demands a note for each), and `bongos init` from a checkout that carries the copies must still land them through the module path.
|
|
10
|
+
|
|
11
|
+
## Decision
|
|
12
|
+
|
|
13
|
+
1. **The dev path is a flag on the library, not a change to the guard.** `node scripts/gds/claude-materialize.js --module-skills-only [--dry-run] [--clean]` calls `materializeModuleSkillsIntoCore`, which walks the same roster and passes the same `vendoredSkillGate` and `SKILL_NAME_RE` as step 1b, copies every ENABLED module's skill tree **verbatim** into `.claude/skills/<name>/` (no `bongosify`: the checkout runs `node scripts/gds/…`), leaves a name the core's own tracked skills carry to the core, and excludes a disabled module's. The self-copy guard in `materializeClaude` is untouched; init and upgrade are unchanged.
|
|
14
|
+
2. **Untracked, self-ignored, manifest-named.** The run writes `.claude/skills/.module-skills.json` (each materialised name with its module, source dir, vendored flag and file count, sorted) and `.claude/skills/.gitignore` (ignoring itself, the manifest and every materialised dir), so `git status` shows nothing new and nothing can be committed by accident. Every run first removes what the last manifest named, then lands the current set, so a skill a module stopped declaring does not linger and a second run is byte-identical; `--clean` stops after the removal.
|
|
15
|
+
3. **The file map skips what the module documents.** `gen-file-map.js` reads the manifest beside `.claude/skills/` and excludes those names from its listing and from its notes contract (a module skill is documented by its module's README, not by `docs/file-map.notes.json`), and `tests/file_map.mjs` filters its own exhaustiveness walk the same way through the exported `materializedNames`. `--check` stays green with the copies present and without them.
|
|
16
|
+
4. **Init from a checkout that carries the copies still lands them through the module path.** `materializeClaude` reads the same manifest: step 1 skips the materialised names and the two dotfiles, and step 1b's "also exists under the core" note ignores a materialised copy, so an instance receives the module skills transformed (`bongos …`) as before and never the checkout's dotfiles.
|
|
17
|
+
5. **The pins** (`tests/claude_materialize.mjs`): an enabled module's skills land verbatim with the manifest and the `.gitignore` lines, a disabled module's are excluded, a core-owned name is kept; a second run is byte-identical, a dropped declaration is removed, `--clean` removes exactly what was made and nothing of the core's; an instance materialised from such a core gets the skill through step 1b, transformed, and never the dotfiles; the bare CLI refuses with usage; `gen-file-map`'s `materializedNames` reads the manifest.
|
|
18
|
+
|
|
19
|
+
## Consequences
|
|
20
|
+
|
|
21
|
+
- **A builder in the core checkout sees all nine `ui-design` skills** after one command; the roster ADRs' standing consequence is closed. The command is not run automatically: a checkout is a workbench, and a builder who wants the module's skills in front of them runs it (or `--clean` to take them away).
|
|
22
|
+
- **The upstream copies under the home directory are now shadowed by the project-level ones**, which is the collision ADR 0220 described, now resolved in the module's favour on this machine too.
|
|
23
|
+
- **`docs/file-map.md` does not list materialised skills.** It never should have: they are the module's, and the module's README is their roster.
|
|
24
|
+
|
|
25
|
+
## Rejected
|
|
26
|
+
|
|
27
|
+
- **Dropping the self-copy guard** so `materializeClaude` writes into the core: it would `bongosify` the checkout's own core skills onto themselves and copy hooks and settings onto their sources.
|
|
28
|
+
- **Committing the module skills under `.claude/skills/` as well:** two sources of truth for every skill, and the file-map notes contract doubled.
|
|
29
|
+
- **A symlink or junction per skill:** Windows junctions into a tracked tree are the trap `worktree.js` exists to avoid, and a symlink is a tracked file the moment it lands in the index.
|
|
30
|
+
- **Adding every materialised name to `docs/file-map.notes.json`:** a note for a dir that is not in the repository is a ghost by the file map's own definition.
|
package/docs/adr/README.md
CHANGED
|
@@ -310,5 +310,6 @@ This keeps the decision history honest and traceable.
|
|
|
310
310
|
| 0221 | [**The impeccable method is one skill with sub-command playbooks: one preamble, one playbook per request, the three homes are the instance's own files, and the origin's mechanisms are dropped rather than disabled** ([task 1003326](https://cloudbongos.com/builders#/task/1003326) · goal 1000083). `pbakaus/impeccable` scanned `dangerous` (ADR 0198: hooks written into five harness configs, a child agent with permissions bypassed, an instruction override, a live-server credential in page source, an unverified remote bundle, default-on telemetry), so its method is rebuilt from the spec. ADR 0220 fixed where a rebuild lives; it had not said how a family of sub-commands lands, what the origin's three persisted homes become, or what to do with the six spec areas whose substance is the condemned mechanism. Decisions: **D1** one skill at `skills/impeccable/`: `SKILL.md` (invocation, the menu from live signals, session setup, the three homes, the routing table, the craft floor, the bounded finish, what is not here) plus `reference/<sub-command>.md`, eighteen playbooks, exactly one loaded per request (the spec's own entry rule); the preamble pinned once in `SKILL.md` and governing every playbook; the materialiser's tree copy ships `reference/` unchanged. **D2** the three homes are the instance's files (product truth = `docs/project-context.template.md` + the pack's identity; the visual system = the root `DESIGN.md` and a look's; the brief = the task's body + a fixed brief block in the ship notes); the skill creates none, `shape` proposes diffs, `document` drafts on the claim's branch with the refresh / overwrite / merge choice. **D3** dropped, not disabled, each with a destination: hooks → none (the ship gate is the sweep, the pre-flight is the kit); the bypass agent → an ordinary read-only subagent via the Agent tool; telemetry, the remote bundle, the credential → none; the live loop → the playwright plugin driving the kit's stub, variants as states, accept = commit, discard = checkout, nothing injected; the detector → task 1003329; the asset producer → the hero-plates rule and task 1003328; native → declines to `/design`; `init`/`doctor`/`pin`/installer → the materialiser is the install. **D4** the finish is the kit in bounded rounds (build, one batched inspection, one batch of fixes, at most one confirming round, stop, one budget) and the reviewer answers with one of `clean` / `recapture` / `defects` / `contradicted` after an evidence check and its own mock inventory. **D5** the craft floor is mechanics loaded late and never recited, translated for a world that may be hairlines by rule. **D6** the pins: sixteen routed playbooks, no orphan, no preamble copy in a playbook, and nothing in the tree directing a bypass, a hook write, a settings edit, telemetry, a remote download, a credential or an override. Rejected: eighteen skills; one inline file; a hooks-lite opt-in; an elevated reviewer; a `doctor`; the native playbooks.](<redacted>.md) | ui-design / skills |
|
|
311
311
|
| 0222 | [**The image family under the hero-only rule: plates and hero objects, never screens; the mark is drawn; the board is a contact sheet; the pair and the sidecar are the output contract** ([task 1003328](https://cloudbongos.com/builders#/task/1003328) · goal 1000083). The four image skills of `Leonxlnx/taste-skill` (scanned `dangerous`, ADR 0198) are built around pictures of interfaces: one reference image per section, one app screen per flow step in a phone mockup, an identity board with the brand name rendered in, and a build loop whose truth is the generated image; the panel flagged the eagerness mandate, the image-count rule and the must-invoke-any-tool directive by name, and the owner's rule (2026-08-26/27) forbids the category. Decisions: **D1** the subject is the composition's plate slots and the count is the slot count; variety survives as anchors (field, beside-bleeding, masthead, orb, backdrop; text-left image-right refused), the palette lock is the pack, cross-frame consistency is one family (material, light, lens, ground). **D2** a screen is the surface itself: the mobile skill makes portrait plates for 390 / 320, the flow is the states file rendered by the kit, the bible is `DESIGN.md`, no phone mockup, native declines to `/design`. **D3** the source of truth for UI is the owner's references and the world; `image-to-code` reads them (analysis, extraction spec, faithful translation, drift as the failure mode, the clarity checklist) and the only image it may cause is the hero plate. **D4** the mark is drawn as inline SVG, the colour system is the pack, the type specimen is the faces on a real page, the applications are the kit's renders, the image direction is a hero-object family, the nine-panel board is a contact sheet of real parts. **D5** the output contract is DESIGN.md's pair and sidecar (JPEG dark plate, WebP alpha cutout, `<file>.<ext>.json` beside each, `--plate-*` tokens on `body`) through the instance's art pipeline key, the spend logged, a white ground through the remover, alpha verified with ffmpeg, never a crop. **D6** the worked example is the grove specimen's hero object, not the front door. **D7** the pins: the four declared, each naming the sidecar, "text-free" and the art key; no eagerness / count / per-section / must-invoke directive. Rejected: per-section images for reference only; phone mockups around real shots; a generated logo as a start; the plate on the front door; a shared base skill with wrappers.](<redacted>.md) | ui-design / skills / imagery |
|
|
312
312
|
| 0223 | [**The anti-pattern detector is two tiers inside the kit: mechanical floors fail, craft tells warn, a false positive is waived in the file with its reason, and nothing new is installed** ([task 1003329](https://cloudbongos.com/builders#/task/1003329) · goal 1000083). The [detector] group of impeccable's rebuild spec (about sixty rules, a CLI plus a browser bundle wired to hooks, per-project ignores, in-file waivers, "a clean scan is a floor and never proof") lands as RULES in the kit that already exists, per the task body: no second tool, no bundle, no hook. Decisions: **D1** `kit/tells.js` carries the static rules and `check-mock.js` runs them after the platform rules and taste bans; `lib.js`'s `factsInPage` collects the computed-value twins as `tells` and `render.js` prints them as `~ tell:` lines per render, recorded in `report.json`, never counted as problems. Two tiers: floors FAIL (a bounce easing; a card nested in a card) and tells WARN (the icon-heading-text grid, hero metrics, section numbering, a kicker above the h1, a side stripe, a hard offset shadow, a coloured halo, decorative blur, a default typeface, a wide measure, a long headline, the same entrance everywhere, a geometric mask on a plate, glyphs as icons, a rainbow gradient, the browser surfaces nobody themed, proportional numerals, tiny type; rendered: measure, tiny text, numerals in cells, headline lines, an overshooting curve). **D2** the world decides which tells are floors: a kicker above the h1 is a FAIL where the instance's `DESIGN.md` bans the eyebrow (a new `eyebrow` taste key), a WARN elsewhere. **D3** a false positive is waived in the file with its reason (`kit-ignore: <rule prefix> (reason)` → a WAIVE row), `--raw` runs with none, `--no-tells` runs the platform rules only, `--json` prints rows with `fails` / `warns` counts; no per-project ignore config. **D4** not ported: the purple-to-blue tell (a literal colour already fails; hue is unresolvable under the fifteen), an injected overlay, the screenshot sampler (`contrast.js --url` is that pass), a cascade resolver (the browser is the resolver), native code, every hook. **D5** the pins: a clean fixture passes every rule, every rule class trips at its tier with the case count equal to the rule count, the promotion, the waiver / raw / json paths through the real CLI, `craftTells` as warnings; the hub mock and the three specimens still pass, `/projects` and the grove specimen still render ALL CLEAN. Rejected: a second CLI; every tell as a floor (the kit as the taste and the instance as the exception); a project-wide ignore config; an edit-time hook.](0223-the-detector-is-two-tiers-in-the-kit.md) | ui-design / kit |
|
|
313
|
+
| 0224 | [**Module-owned skills reach the core checkout as untracked, self-ignored copies with a manifest: the self-copy guard stays, the dev path is a flag, and the file map skips what the module documents** ([task 1003330](https://cloudbongos.com/builders#/task/1003330) · goal 1000083). `materializeClaude`'s self-copy guard is right (a core copying itself is a no-op at best), but Claude Code discovers only `.claude/skills/` at the project root, so a builder IN the core checkout could not invoke any of the nine ui-design skills written there. Decisions: **D1** `node scripts/gds/claude-materialize.js --module-skills-only [--dry-run] [--clean]` calls `materializeModuleSkillsIntoCore`: the same roster, gate and slug rule as step 1b, verbatim copies (no bongosify: the checkout runs `node scripts/gds/…`), a core-owned name kept, a disabled module's excluded; the guard untouched. **D2** untracked and self-ignored: a generated `.claude/skills/.module-skills.json` manifest and a `.gitignore` that ignores itself, the manifest and every materialised dir; each run removes what the last manifest named then lands the current set (byte-identical on repeat; a dropped declaration disappears); `--clean` stops after the removal. **D3** `gen-file-map.js` and `tests/file_map.mjs` skip materialised names by the manifest (a module skill is documented by its module's README, not by the notes sidecar). **D4** init from a checkout that carries the copies still lands them through step 1b, transformed, and never the dotfiles. **D5** pinned end to end in `tests/claude_materialize.mjs`. Rejected: dropping the guard; committing the copies; symlinks or junctions; notes for dirs not in the repository.](<redacted>.md) | materialiser / skills |
|
|
313
314
|
|
|
314
315
|
> ⚠️ **Numbering collisions are now machine-enforced, not narrated.** Twenty numbers are shared by two ADRs each — assigned in parallel sessions before anything checked. The files keep their filenames (renumbering would break every existing citation, and a number once assigned is never reused), and this table disambiguates each pair as `NNNN-a` / `NNNN-b`. The authoritative list is `LEGACY_DUPLICATE_ADRS` in [`scripts/gds/adr-namespace.js`](../../scripts/gds/adr-namespace.js), frozen by exact filename: a **new** collision — or a third file joining a legacy number, or a rename of either half — hard-fails the `unit` CI gate, as does an index row that points at no file or an ADR with no row. The list may only shrink. This paragraph used to narrate the collisions one by one and had fallen eight behind reality; see [ADR 0195](<redacted>.md) for why the check exists and why the pairs are grandfathered rather than renumbered.
|
|
@@ -1367,5 +1367,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1367
1367
|
landed since 1.19.463 with no explicit bump. run 33578746178. (task 1002620)
|
|
1368
1368
|
1.19.465 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1369
1369
|
landed since 1.19.464 with no explicit bump. run 33581328823. (task 1002620)
|
|
1370
|
+
1.19.466 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1371
|
+
landed since 1.19.465 with no explicit bump. run 33583122775. (task 1002620)
|
|
1372
|
+
1.19.467 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1373
|
+
landed since 1.19.466 with no explicit bump. run 33638703282. (task 1002620)
|
|
1370
1374
|
---------------------------------------------------------------------------
|
|
1371
1375
|
```
|
|
@@ -119,6 +119,11 @@ const IDEA_FULL_PASS_CREDIT = 5; // owner: "gives 5 credits"
|
|
|
119
119
|
const IDEA_FULL_PASS_BAR = 60; // completeness 0-100; ADR 0172 proposed default
|
|
120
120
|
const IDEA_TASK_SHIP_BASE = 5; // stream 1: base credit per shipped task under an idea
|
|
121
121
|
const QUICK_IDEA_FACTOR = 0.6; // a quick/spark idea's grade factor (ADR 0172 default)
|
|
122
|
+
// Stream 3 (IC5): a board-ratified Full Idea pays a share of a 100-credit
|
|
123
|
+
// ceiling, in proportion to how complete the idea was. Owner: "30-100 credits
|
|
124
|
+
// ... paid as a percent on the 100 credits for its fullness."
|
|
125
|
+
const IDEA_GOVERNANCE_PASS_MAX = 100; // owner: "up to 100 credits"
|
|
126
|
+
const IDEA_GOVERNANCE_PASS_FLOOR = 30; // owner: floor ~30 — see the note on the award fn
|
|
122
127
|
|
|
123
128
|
// =========================================================================
|
|
124
129
|
// Session token reward (flat cost-plus) — task 1005 (ADR 0054).
|
|
@@ -577,6 +582,114 @@ async function awardIdeaTaskShipCredit(client, { taskId, config = rewardConfig()
|
|
|
577
582
|
: { credited: 0, reason: 'already_paid', ideaId: Number(ideaId), authorId: Number(authorId) };
|
|
578
583
|
}
|
|
579
584
|
|
|
585
|
+
// Stream 3 (ADR 0172 / goal 1000067 IC5): a Full Idea that PASSES the Board
|
|
586
|
+
// Room's ratification vote pays a share of a 100-credit ceiling, scaled by the
|
|
587
|
+
// idea's R142 completeness — "70%-complete + passes = 70". The ideator lane, so
|
|
588
|
+
// it gates on config.ideatorCredit, never perTaskCredit.
|
|
589
|
+
//
|
|
590
|
+
// THE BOARD CALLS THIS THROUGH THE `reward` PORT, INSIDE ITS CLOSE TRANSACTION
|
|
591
|
+
// (docs/specs/board-room-pass-event-contract.md, Decision 1) — not off the
|
|
592
|
+
// `board.item.passed` seam event, because emitAsync swallows listener errors,
|
|
593
|
+
// which is right for a Discord post and disqualifying for a payment. If this
|
|
594
|
+
// write fails the close rolls back and the item stays open, so the vote is
|
|
595
|
+
// retried rather than the author silently unpaid.
|
|
596
|
+
//
|
|
597
|
+
// RE-DRIVABLE BY DESIGN (contract Decision 5): the durable fact is the
|
|
598
|
+
// `board_items` row with outcome='passed', not a delivered event. Nothing here
|
|
599
|
+
// reads board state or requires a live listener, and the reason key makes a
|
|
600
|
+
// blanket re-run safe, so a missed award can be reconciled by sweeping the
|
|
601
|
+
// passed items and calling this again. No such sweep is scheduled in v1 — the
|
|
602
|
+
// requirement is only that the shape permits one.
|
|
603
|
+
//
|
|
604
|
+
// client — pg client / pool (transaction participant)
|
|
605
|
+
// ideaId, authorId — the ratified idea and its captured_by builder
|
|
606
|
+
// completenessScore — the R142 score (0-100)
|
|
607
|
+
// subjectType — the board subject, when the caller knows it; only
|
|
608
|
+
// 'full_idea' pays (contract Decision 2). Omitted means
|
|
609
|
+
// "already filtered", which is how the board calls it.
|
|
610
|
+
// config — resolved reward policy; gates on config.ideatorCredit
|
|
611
|
+
//
|
|
612
|
+
// Returns { credited, reason }:
|
|
613
|
+
// { credited: N, reason: 'paid' } — first pass for this idea
|
|
614
|
+
// { credited: 0, reason: 'already_paid' } — a prior row for this idea
|
|
615
|
+
// { credited: 0, reason: 'not_a_full_idea' } — an amendment pays nothing
|
|
616
|
+
// { credited: 0, reason: 'ideator_lane_off' } — instance opted the lane out
|
|
617
|
+
// { credited: 0, reason: 'missing_author_or_idea' }
|
|
618
|
+
// { credited: 0, reason: 'no_completeness_score' }
|
|
619
|
+
//
|
|
620
|
+
// NO `below_bar` RETURN, deliberately (contract Decision 4): ADR 0175 §5 makes
|
|
621
|
+
// the completeness bar the gate on OPENING a vote window at all, so an idea
|
|
622
|
+
// below the bar can never reach a pass. Re-checking it here would imply the
|
|
623
|
+
// board might send something it cannot.
|
|
624
|
+
async function awardIdeaGovernancePassCredit(client, {
|
|
625
|
+
ideaId, authorId, completenessScore, subjectType, config = rewardConfig(),
|
|
626
|
+
} = {}) {
|
|
627
|
+
// SUBJECT TYPE FIRST, ahead of the lane check that opens both sibling streams:
|
|
628
|
+
// a constitutional amendment traverses the same close path and is not an
|
|
629
|
+
// ideator payment at all, so whether the ideator lane is on is irrelevant to
|
|
630
|
+
// it. A re-drive sweep reading these reasons gets the truthful one.
|
|
631
|
+
if (subjectType != null && subjectType !== 'full_idea') {
|
|
632
|
+
return { credited: 0, reason: 'not_a_full_idea' };
|
|
633
|
+
}
|
|
634
|
+
if (config && config.ideatorCredit === false) {
|
|
635
|
+
return { credited: 0, reason: 'ideator_lane_off' };
|
|
636
|
+
}
|
|
637
|
+
if (ideaId == null) {
|
|
638
|
+
return { credited: 0, reason: 'missing_author_or_idea' };
|
|
639
|
+
}
|
|
640
|
+
if (completenessScore == null || !Number.isFinite(Number(completenessScore))) {
|
|
641
|
+
return { credited: 0, reason: 'no_completeness_score' };
|
|
642
|
+
}
|
|
643
|
+
// The parties come from the DB, not the call site (the R147 rule): there is
|
|
644
|
+
// exactly one place that decides who earns from an idea. `authorId` is a
|
|
645
|
+
// fallback for a caller holding an idea this lookup cannot see.
|
|
646
|
+
const parties = await ideaCreditParties(client, ideaId);
|
|
647
|
+
const resolvedAuthor = (parties && parties.authorId != null) ? parties.authorId : authorId;
|
|
648
|
+
if (resolvedAuthor == null) {
|
|
649
|
+
return { credited: 0, reason: 'missing_author_or_idea' };
|
|
650
|
+
}
|
|
651
|
+
// CLAMPED to [0,1] before scaling, so a malformed score can never pay out
|
|
652
|
+
// more than the ceiling (the stream-1 precedent). The floor then applies:
|
|
653
|
+
// it is UNREACHABLE under default config — ADR 0175 §5 gates window-opening
|
|
654
|
+
// on IDEA_FULL_PASS_BAR (60), so a real payout lands in [60,100] and max()
|
|
655
|
+
// never binds — but it ships because it is the owner's stated intent and goes
|
|
656
|
+
// live the moment either constant moves.
|
|
657
|
+
const pct = Math.max(0, Math.min(1, Number(completenessScore) / 100));
|
|
658
|
+
const delta = Math.max(
|
|
659
|
+
IDEA_GOVERNANCE_PASS_FLOOR,
|
|
660
|
+
Math.round(pct * IDEA_GOVERNANCE_PASS_MAX)
|
|
661
|
+
);
|
|
662
|
+
// Split with whoever DEVELOPED the spark, the same 70/30 as streams 1 and 2
|
|
663
|
+
// (ADR 0185) — the contract predates that split and names the author alone;
|
|
664
|
+
// mirroring the sibling streams, as it asks, means splitting here too.
|
|
665
|
+
const payees = splitIdeaCredit(delta, {
|
|
666
|
+
authorId: resolvedAuthor,
|
|
667
|
+
developedBy: parties ? parties.developedBy : null,
|
|
668
|
+
});
|
|
669
|
+
if (payees.length === 0) {
|
|
670
|
+
return { credited: 0, reason: 'zero_after_scaling' };
|
|
671
|
+
}
|
|
672
|
+
// ONCE PER IDEA, FOREVER. A resubmission that passes again, a re-close, a
|
|
673
|
+
// replay, or a concurrent request all resolve to this same key and book
|
|
674
|
+
// nothing further. Builder-scoped so the NOT EXISTS hits
|
|
675
|
+
// idx_credit_log_builder (credit_log is a CORE table — a module migration may
|
|
676
|
+
// not add an index on `reason`), and because a split books one row per payee
|
|
677
|
+
// under ONE reason, a reason-only predicate would refuse the second payee.
|
|
678
|
+
// task_id is NULL: an idea credit has no task.
|
|
679
|
+
const reason = `idea.credit.governance_pass:${ideaId}`;
|
|
680
|
+
const scorePct = Math.round(Number(completenessScore));
|
|
681
|
+
const { credited, payees: booked } = await bookIdeaSplit(client, {
|
|
682
|
+
payees,
|
|
683
|
+
reason,
|
|
684
|
+
describe: (p) => `Full Idea #${ideaId} ratified by the board (completeness ${scorePct}/100) → ${p.delta}c to its ${p.role.replace(/_/g, ' ')} (ideator lane stream 3, ADR 0172${p.role === 'developer' || p.role === 'spark_author' ? '; split ADR 0185' : ''})`,
|
|
685
|
+
});
|
|
686
|
+
return {
|
|
687
|
+
credited,
|
|
688
|
+
reason: credited > 0 ? 'paid' : 'already_paid',
|
|
689
|
+
payees: booked,
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
|
|
580
693
|
// Award the flat cost-plus session token reward, idempotent per session_id
|
|
581
694
|
// (task 1005, ADR 0054). Called inside the same transaction as the session
|
|
582
695
|
// upsert (see upsertSessionRecordWithReward).
|
|
@@ -824,6 +937,7 @@ module.exports = {
|
|
|
824
937
|
maybeAwardIdeaPromotionBonus,
|
|
825
938
|
awardIdeaFullPassCredit,
|
|
826
939
|
awardIdeaTaskShipCredit,
|
|
940
|
+
awardIdeaGovernancePassCredit,
|
|
827
941
|
// the spark-handoff split (task 1002923 / BV1.R147, ADR 0185)
|
|
828
942
|
splitIdeaCredit,
|
|
829
943
|
IDEA_DEVELOPER_SHARE,
|
|
@@ -831,6 +945,8 @@ module.exports = {
|
|
|
831
945
|
IDEA_FULL_PASS_BAR,
|
|
832
946
|
IDEA_TASK_SHIP_BASE,
|
|
833
947
|
QUICK_IDEA_FACTOR,
|
|
948
|
+
IDEA_GOVERNANCE_PASS_MAX,
|
|
949
|
+
IDEA_GOVERNANCE_PASS_FLOOR,
|
|
834
950
|
awardSessionTokenReward,
|
|
835
951
|
NET_NEGATIVE_BONUS,
|
|
836
952
|
SUBTRACTOR_THRESHOLD,
|
|
@@ -28,6 +28,11 @@ module.exports = {
|
|
|
28
28
|
awardIdeaFullPassCredit: (...a) => credits.awardIdeaFullPassCredit(...a),
|
|
29
29
|
// ADR 0172 IC4 — stream 1: pay the idea's author per shipped task, grade-scaled (replaces the 25% slice at ship).
|
|
30
30
|
awardIdeaTaskShipCredit: (...a) => credits.awardIdeaTaskShipCredit(...a),
|
|
31
|
+
// ADR 0172 IC5 — stream 3: a board-ratified Full Idea pays up to 100, scaled by completeness.
|
|
32
|
+
// The board's close transaction resolves this port and calls this with its own
|
|
33
|
+
// `client` (docs/specs/board-room-pass-event-contract.md) — the port is how
|
|
34
|
+
// money crosses the wall; the `board.item.passed` event never carries a payment.
|
|
35
|
+
awardIdeaGovernancePassCredit: (...a) => credits.awardIdeaGovernancePassCredit(...a),
|
|
31
36
|
awardNetNegativeBonus: (...a) => credits.awardNetNegativeBonus(...a),
|
|
32
37
|
awardSessionTokenReward: (...a) => credits.awardSessionTokenReward(...a),
|
|
33
38
|
// gamification hooks the lifecycle fires at claim/ship
|
|
@@ -43,6 +43,8 @@ A rebuild spec becomes a **first-party skill** at `<name>/` — no `vendor/` seg
|
|
|
43
43
|
|
|
44
44
|
What every rebuild does with the upstream's opinions, in the same words: the palette is the pack and a variant is a look from [`../styles/`](../styles/README.md); the design system is the instance's `DESIGN.md`; images are hero plates and hero objects by token and never a step; every motion rule lives inside the Kill Switch (springs, magnetic hover, pointer-tracked borders are named *not available*); the upstream's bans are defaults an instance's `DESIGN.md` overrides; the kit's renders are the pre-flight. The image family (task 1003328, [ADR 0222](../../../docs/adr/<redacted>.md)) carries the one rewrite the upstream never had: images are for text-free hero plates and hero objects only, so a screen is the surface itself, the mark is drawn, the board is a contact sheet, and what ships is the pair (a black-ground JPEG for dark, an alpha WebP cutout for light) with a provenance sidecar beside every file, through the instance's art pipeline key. Still specs, not skills: the detector (task 1003329), the four looks (1003327).
|
|
45
45
|
|
|
46
|
+
**In the core checkout itself**, where these skills are written, Claude Code reads only `.claude/skills/` at the repo root and the materialiser's self-copy guard leaves it empty of module skills. `node scripts/gds/claude-materialize.js --module-skills-only` lands every enabled module's skills there as untracked, self-ignored copies (a generated `.claude/skills/.module-skills.json` manifest and a `.gitignore` beside them; `--clean` removes them; the file map skips them by the manifest) so a builder can invoke them here too ([task 1003330](https://cloudbongos.com/builders#/task/1003330), [ADR 0224](../../../docs/adr/<redacted>.md)). Edit the module's `skills/<name>/`, never the copy, and re-run to refresh.
|
|
47
|
+
|
|
46
48
|
**The playwright plugin** (`claude-plugins-official`, an MCP plugin) is the LIVE browser-driving loop, where the kit is the headless one. It is a prerequisite, documented — `claude plugin install playwright@claude-plugins-official` — never vendored.
|
|
47
49
|
|
|
48
50
|
## How to land one (when a scan says it may)
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.467",
|
|
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.467",
|
|
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.467",
|
|
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",
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
|
|
26
26
|
const fs = require('node:fs');
|
|
27
27
|
const path = require('node:path');
|
|
28
|
+
const { spawnSync } = require('node:child_process');
|
|
28
29
|
const { VERBS } = require('../../bin/bongos.js'); // side-effect-free require (guarded main)
|
|
29
30
|
const ic = require('../../src/instance-config'); // neutral configDir/envPrefix fallbacks (never a founder literal)
|
|
30
31
|
const { renderInstanceClaudeMd } = require('./claude-md-template'); // task 2104 — the starter CLAUDE.md
|
|
@@ -340,7 +341,11 @@ function materializeClaude({ coreRoot, instanceDir, dryRun = false, overwrite =
|
|
|
340
341
|
const roster = moduleSkillRoster({ coreRoot, instanceDir });
|
|
341
342
|
const skipSkills = new Set();
|
|
342
343
|
for (const m of roster) if (!m.enabled) for (const s of m.skills) skipSkills.add(s);
|
|
343
|
-
|
|
344
|
+
// A core checkout that materialised module skills into its own .claude/skills/ (task 1003330)
|
|
345
|
+
// carries them as untracked copies plus two dotfiles; step 1 must not ship those as core
|
|
346
|
+
// skills — step 1b lands them from the module, transformed, like everywhere else.
|
|
347
|
+
const materialisedInCore = readModuleSkillsManifest(path.join(coreRoot, '.claude', 'skills')).skills;
|
|
348
|
+
const skillSkip = (rel) => { const top = String(rel).split(/[\\/]/)[0]; return skipSkills.has(top) || top === MODULE_SKILLS_MANIFEST || top === '.gitignore' || Object.prototype.hasOwnProperty.call(materialisedInCore, top); };
|
|
344
349
|
|
|
345
350
|
// 1. skills — ship in the core package; copy, rewrite invocations→bongos, rebrand
|
|
346
351
|
// identity, and exclude any skill owned by a disabled module.
|
|
@@ -369,7 +374,7 @@ function materializeClaude({ coreRoot, instanceDir, dryRun = false, overwrite =
|
|
|
369
374
|
}
|
|
370
375
|
for (const s of sources) {
|
|
371
376
|
if (!s.enabled) { if (!summary.excludedSkills.includes(s.name)) summary.excludedSkills.push(s.name); continue; }
|
|
372
|
-
if (fs.existsSync(path.join(skillsSrc, s.name))) {
|
|
377
|
+
if (fs.existsSync(path.join(skillsSrc, s.name)) && !Object.prototype.hasOwnProperty.call(materialisedInCore, s.name)) {
|
|
373
378
|
summary.notes.push(`module skill "${s.name}" (${s.module}) also exists under the core's .claude/skills/ — the core copy was materialized, the module copy ignored.`);
|
|
374
379
|
continue;
|
|
375
380
|
}
|
|
@@ -452,4 +457,82 @@ function materializeClaude({ coreRoot, instanceDir, dryRun = false, overwrite =
|
|
|
452
457
|
return summary;
|
|
453
458
|
}
|
|
454
459
|
|
|
455
|
-
|
|
460
|
+
// ---- the dev checkout: module-owned skills materialised INTO the core (task 1003330, ADR 0224) ----
|
|
461
|
+
// materializeClaude's self-copy guard is right for init/upgrade (instance == core is a no-op),
|
|
462
|
+
// but Claude Code discovers only .claude/skills/ at the project root, so a builder working IN
|
|
463
|
+
// the core checkout never sees modules/<key>/skills/. This lands every ENABLED module's skills
|
|
464
|
+
// there as UNTRACKED, self-ignored dirs: verbatim copies (no bongosify — the checkout runs
|
|
465
|
+
// `node scripts/gds/…`), a generated .claude/skills/.module-skills.json manifest naming what
|
|
466
|
+
// was materialised and from where, and a generated .claude/skills/.gitignore covering the
|
|
467
|
+
// manifest, itself and every materialised dir, so `git status` shows nothing new and nothing
|
|
468
|
+
// can be committed by accident. The same vendoredSkillGate + SKILL_NAME_RE apply; a name the
|
|
469
|
+
// core's own tracked .claude/skills/ carries is left to the core. Idempotent: every run first
|
|
470
|
+
// removes what the last manifest named (so a skill a module stopped declaring does not
|
|
471
|
+
// linger), then lands the current set; `clean` stops after the removal.
|
|
472
|
+
const MODULE_SKILLS_MANIFEST = '.module-skills.json';
|
|
473
|
+
function readModuleSkillsManifest(skillsDir) {
|
|
474
|
+
const m = readJsonSafe(path.join(skillsDir, MODULE_SKILLS_MANIFEST));
|
|
475
|
+
return m && m.skills && typeof m.skills === 'object' ? m : { skills: {} };
|
|
476
|
+
}
|
|
477
|
+
function materializeModuleSkillsIntoCore({ coreRoot, dryRun = false, clean = false, log = () => {} }) {
|
|
478
|
+
const skillsDir = path.join(coreRoot, '.claude', 'skills');
|
|
479
|
+
const summary = { materialized: [], removed: [], refused: [], excluded: [], keptCore: [], files: 0, notes: [] };
|
|
480
|
+
const previous = readModuleSkillsManifest(skillsDir);
|
|
481
|
+
// A dir git tracks is the core's, whatever an older manifest said (a core skill can take
|
|
482
|
+
// over a name a module once owned); it is never removed or overwritten here.
|
|
483
|
+
const tracked = (name) => { try { return spawnSync('git', ['-C', coreRoot, 'ls-files', '--error-unmatch', '--', `.claude/skills/${name}`], { encoding: 'utf8' }).status === 0; } catch { return false; } };
|
|
484
|
+
for (const name of Object.keys(previous.skills)) {
|
|
485
|
+
const dir = path.join(skillsDir, name);
|
|
486
|
+
if (!SKILL_NAME_RE.test(name) || !fs.existsSync(dir)) continue;
|
|
487
|
+
if (tracked(name)) { summary.keptCore.push(name); summary.notes.push(`"${name}" is tracked by git now — the last manifest's copy was not removed; the core's dir is kept.`); continue; }
|
|
488
|
+
if (!dryRun) fs.rmSync(dir, { recursive: true, force: true });
|
|
489
|
+
summary.removed.push(name);
|
|
490
|
+
}
|
|
491
|
+
if (clean) {
|
|
492
|
+
for (const f of [MODULE_SKILLS_MANIFEST, '.gitignore']) { const p = path.join(skillsDir, f); if (fs.existsSync(p) && !dryRun) fs.rmSync(p); }
|
|
493
|
+
log(` ${dryRun ? '[dry-run] would remove' : '✓ removed'} ${summary.removed.length} materialised module skill dir(s), the manifest and .claude/skills/.gitignore`);
|
|
494
|
+
return summary;
|
|
495
|
+
}
|
|
496
|
+
const roster = moduleSkillRoster({ coreRoot, instanceDir: coreRoot });
|
|
497
|
+
const sources = moduleSkillSources({ roster });
|
|
498
|
+
for (const r of sources.refused) { summary.refused.push(`${r.name} (${r.module}): ${r.reason}`); log(` ✗ refused module skill "${r.name}" (${r.module}): ${r.reason}`); }
|
|
499
|
+
const manifest = { _doc: 'GENERATED by `node scripts/gds/claude-materialize.js --module-skills-only` (task 1003330, ADR 0224): the module-owned skills materialised into this core checkout as UNTRACKED copies so Claude Code sees them here. Every dir named below is ignored by the sibling .gitignore and removed by --clean; edit the module\'s skills/<name>/ source, never these copies, and re-run to refresh.', skills: {} };
|
|
500
|
+
for (const s of sources) {
|
|
501
|
+
if (!s.enabled) { summary.excluded.push(s.name); continue; }
|
|
502
|
+
const dest = path.join(skillsDir, s.name);
|
|
503
|
+
if (fs.existsSync(dest) && (!Object.prototype.hasOwnProperty.call(previous.skills, s.name) || tracked(s.name))) {
|
|
504
|
+
summary.keptCore.push(s.name);
|
|
505
|
+
summary.notes.push(`"${s.name}" (${s.module}) already exists under .claude/skills/ and is not a materialised copy — the core's own dir is kept.`);
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
const r = copyTreeTransformed(s.dir, dest, { dryRun, overwrite: true, transform: (t) => t });
|
|
509
|
+
summary.files += r.files;
|
|
510
|
+
summary.materialized.push(s.name);
|
|
511
|
+
manifest.skills[s.name] = { module: s.module, source: path.relative(coreRoot, s.dir).split(path.sep).join('/'), vendored: s.vendored, files: r.files };
|
|
512
|
+
}
|
|
513
|
+
const names = Object.keys(manifest.skills).sort();
|
|
514
|
+
manifest.skills = Object.fromEntries(names.map((n) => [n, manifest.skills[n]]));
|
|
515
|
+
const ignore = ['# GENERATED by scripts/gds/claude-materialize.js --module-skills-only (task 1003330): the module-owned skills', '# materialised into this checkout are untracked copies; this file and the manifest ignore themselves. Never commit these.', '/.gitignore', `/${MODULE_SKILLS_MANIFEST}`, ...names.map((n) => `/${n}/`)].join('\n') + '\n';
|
|
516
|
+
if (!dryRun) {
|
|
517
|
+
fs.mkdirSync(skillsDir, { recursive: true });
|
|
518
|
+
fs.writeFileSync(path.join(skillsDir, MODULE_SKILLS_MANIFEST), JSON.stringify(manifest, null, 2) + '\n');
|
|
519
|
+
fs.writeFileSync(path.join(skillsDir, '.gitignore'), ignore);
|
|
520
|
+
}
|
|
521
|
+
log(` ${dryRun ? '[dry-run] would materialize' : '✓ materialized'} ${summary.materialized.length} module-owned skill(s) (${summary.files} file(s)) → .claude/skills/ as untracked copies: ${names.join(', ') || 'none'}`);
|
|
522
|
+
return summary;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// The CLI entry is the dev-checkout path only; init/upgrade call materializeClaude directly.
|
|
526
|
+
if (require.main === module) {
|
|
527
|
+
const argv = process.argv.slice(2);
|
|
528
|
+
if (!argv.includes('--module-skills-only') || argv.includes('--help')) {
|
|
529
|
+
process.stderr.write('usage: node scripts/gds/claude-materialize.js --module-skills-only [--dry-run] [--clean]\n Lands every enabled module\'s skills (modules/<key>/skills/<name>/) into THIS checkout\'s .claude/skills/ as untracked,\n self-ignored copies so Claude Code sees them here (task 1003330). --clean removes them. The library behind bongos init / upgrade otherwise.\n');
|
|
530
|
+
process.exit(argv.includes('--help') ? 0 : 2);
|
|
531
|
+
}
|
|
532
|
+
const s = materializeModuleSkillsIntoCore({ coreRoot: path.resolve(__dirname, '..', '..'), dryRun: argv.includes('--dry-run'), clean: argv.includes('--clean'), log: (l) => process.stdout.write(l + '\n') });
|
|
533
|
+
for (const n of s.notes) process.stdout.write(` • ${n}\n`);
|
|
534
|
+
process.stdout.write(`module skills → .claude/skills/: ${s.materialized.length} materialised (${s.files} file(s)), ${s.removed.length} refreshed or removed, ${s.excluded.length} excluded (module off), ${s.keptCore.length} kept as the core's own, ${s.refused.length} refused\n`);
|
|
535
|
+
process.exit(s.refused.length ? 1 : 0);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
module.exports = { scriptToVerb, bongosify, transformSettings, copyTreeTransformed, materializeClaude, materializeModuleSkillsIntoCore, readModuleSkillsManifest, MODULE_SKILLS_MANIFEST, GDS_INVOCATION_RE, buildRebrandRules, rebrandText, disabledModuleSkills, moduleSkillRoster, moduleSkillSources, vendoredSkillGate, SKILL_NAME_RE, VENDOR_DEFAULT_POLICY };
|
|
@@ -63,12 +63,21 @@ function loadNotes() {
|
|
|
63
63
|
try { return JSON.parse(fs.readFileSync(NOTES_PATH, 'utf8')); } catch { return {}; }
|
|
64
64
|
}
|
|
65
65
|
|
|
66
|
-
//
|
|
66
|
+
// Module-owned skills materialised INTO this checkout (scripts/gds/claude-materialize.js
|
|
67
|
+
// --module-skills-only, task 1003330) are untracked copies the module documents itself; they
|
|
68
|
+
// are not repo skills needing a note here. One reader of the manifest exists — the
|
|
69
|
+
// materialiser's — so this asks it rather than parsing the file a second way.
|
|
70
|
+
const { readModuleSkillsManifest } = require('./claude-materialize.js');
|
|
71
|
+
function materializedNames(absDir) { return new Set(Object.keys(readModuleSkillsManifest(absDir).skills)); }
|
|
72
|
+
|
|
73
|
+
// Immediate subdirectories of an absolute dir, sorted by name (deterministic); materialised
|
|
74
|
+
// module copies excluded.
|
|
67
75
|
function listSubdirs(absDir) {
|
|
68
76
|
let ents = [];
|
|
69
77
|
try { ents = fs.readdirSync(absDir, { withFileTypes: true }); } catch { return []; }
|
|
78
|
+
const materialised = materializedNames(absDir);
|
|
70
79
|
return ents
|
|
71
|
-
.filter((e) => e.isDirectory() && !e.name.startsWith('.') && !isConflictCopy(e.name))
|
|
80
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith('.') && !isConflictCopy(e.name) && !materialised.has(e.name))
|
|
72
81
|
.map((e) => e.name)
|
|
73
82
|
.sort((a, b) => a.localeCompare(b));
|
|
74
83
|
}
|
|
@@ -186,6 +195,7 @@ function main() {
|
|
|
186
195
|
if (require.main === module) main();
|
|
187
196
|
|
|
188
197
|
module.exports = {
|
|
198
|
+
materializedNames,
|
|
189
199
|
SECTIONS,
|
|
190
200
|
begin,
|
|
191
201
|
end,
|
package/src/module-api.js
CHANGED
|
@@ -33,7 +33,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
33
33
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
34
34
|
// the entry to that file. Look for a version's history there, not here.
|
|
35
35
|
// ---------------------------------------------------------------------------
|
|
36
|
-
const CORE_VERSION = '1.19.
|
|
36
|
+
const CORE_VERSION = '1.19.467'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
37
37
|
|
|
38
38
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
39
39
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -403,5 +403,86 @@ t('materializeClaude: NEVER clobbers an existing CLAUDE.md (init / adopt / upgra
|
|
|
403
403
|
rmSync(dir, { recursive: true, force: true });
|
|
404
404
|
});
|
|
405
405
|
|
|
406
|
+
// ---- the dev checkout: module skills materialised INTO the core (task 1003330, ADR 0224) ----
|
|
407
|
+
function fakeCore() {
|
|
408
|
+
const core = mkdtempSync(join(tmpdir(), 'cm-core-'));
|
|
409
|
+
const mod = (key, dflt, skills) => { mkdirSync(join(core, 'modules', key, 'skills'), { recursive: true }); writeFileSync(join(core, 'modules', key, 'module.json'), JSON.stringify({ key, default: dflt, contributes: { skills } })); };
|
|
410
|
+
const skill = (key, name, body = 'run `node scripts/gds/ship.js 1`') => { mkdirSync(join(core, 'modules', key, 'skills', name, 'reference'), { recursive: true }); writeFileSync(join(core, 'modules', key, 'skills', name, 'SKILL.md'), `---\nname: ${name}\n---\n${body}\n`); writeFileSync(join(core, 'modules', key, 'skills', name, 'reference', 'x.md'), 'a playbook\n'); };
|
|
411
|
+
mod('m1', true, ['alpha', 'shared']); skill('m1', 'alpha'); skill('m1', 'shared');
|
|
412
|
+
mod('m2', false, ['beta']); skill('m2', 'beta');
|
|
413
|
+
mkdirSync(join(core, '.claude', 'skills', 'shared'), { recursive: true }); writeFileSync(join(core, '.claude', 'skills', 'shared', 'SKILL.md'), 'the core\'s own shared\n');
|
|
414
|
+
mkdirSync(join(core, '.claude', 'skills', 'core-one'), { recursive: true }); writeFileSync(join(core, '.claude', 'skills', 'core-one', 'SKILL.md'), 'core one\n');
|
|
415
|
+
return core;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
t('--module-skills-only: an enabled module\'s skills land in the core\'s own .claude/skills/ verbatim, with a manifest and a self-ignoring .gitignore; a disabled module\'s are excluded; a core-owned name is kept', () => {
|
|
419
|
+
const core = fakeCore();
|
|
420
|
+
const res = m.materializeModuleSkillsIntoCore({ coreRoot: core });
|
|
421
|
+
assert.deepEqual(res.materialized, ['alpha']); assert.deepEqual(res.excluded, ['beta']); assert.deepEqual(res.keptCore, ['shared']);
|
|
422
|
+
assert.equal(readFileSync(join(core, '.claude', 'skills', 'alpha', 'SKILL.md'), 'utf8'), '---\nname: alpha\n---\nrun `node scripts/gds/ship.js 1`\n', 'verbatim: no bongosify in the checkout');
|
|
423
|
+
assert.ok(existsSync(join(core, '.claude', 'skills', 'alpha', 'reference', 'x.md')), 'the tree is copied');
|
|
424
|
+
assert.equal(readFileSync(join(core, '.claude', 'skills', 'shared', 'SKILL.md'), 'utf8'), 'the core\'s own shared\n', 'the core copy is untouched');
|
|
425
|
+
const manifest = JSON.parse(readFileSync(join(core, '.claude', 'skills', m.MODULE_SKILLS_MANIFEST), 'utf8'));
|
|
426
|
+
assert.deepEqual(Object.keys(manifest.skills), ['alpha']); assert.equal(manifest.skills.alpha.module, 'm1'); assert.equal(manifest.skills.alpha.source, 'modules/m1/skills/alpha');
|
|
427
|
+
const ignore = readFileSync(join(core, '.claude', 'skills', '.gitignore'), 'utf8');
|
|
428
|
+
for (const line of ['/.gitignore', '/.module-skills.json', '/alpha/']) assert.ok(ignore.split('\n').includes(line), `.gitignore carries ${line}`);
|
|
429
|
+
assert.ok(!ignore.includes('/shared/') && !ignore.includes('/core-one/'), 'only materialised dirs are ignored');
|
|
430
|
+
rmSync(core, { recursive: true, force: true });
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
t('--module-skills-only: a second run is idempotent (byte-identical manifest + .gitignore), a skill the module stopped declaring is removed, and --clean removes everything it made', () => {
|
|
434
|
+
const core = fakeCore();
|
|
435
|
+
m.materializeModuleSkillsIntoCore({ coreRoot: core });
|
|
436
|
+
const before = [m.MODULE_SKILLS_MANIFEST, '.gitignore'].map((f) => readFileSync(join(core, '.claude', 'skills', f), 'utf8'));
|
|
437
|
+
const again = m.materializeModuleSkillsIntoCore({ coreRoot: core });
|
|
438
|
+
assert.deepEqual(again.removed, ['alpha']); assert.deepEqual(again.materialized, ['alpha']);
|
|
439
|
+
assert.deepEqual([m.MODULE_SKILLS_MANIFEST, '.gitignore'].map((f) => readFileSync(join(core, '.claude', 'skills', f), 'utf8')), before, 'byte-identical on the second run');
|
|
440
|
+
writeFileSync(join(core, 'modules', 'm1', 'module.json'), JSON.stringify({ key: 'm1', default: true, contributes: { skills: ['shared'] } }));
|
|
441
|
+
const dropped = m.materializeModuleSkillsIntoCore({ coreRoot: core });
|
|
442
|
+
assert.ok(!existsSync(join(core, '.claude', 'skills', 'alpha')), 'a skill no longer declared is removed, not left to linger');
|
|
443
|
+
assert.deepEqual(dropped.materialized, []);
|
|
444
|
+
writeFileSync(join(core, 'modules', 'm1', 'module.json'), JSON.stringify({ key: 'm1', default: true, contributes: { skills: ['alpha', 'shared'] } }));
|
|
445
|
+
m.materializeModuleSkillsIntoCore({ coreRoot: core });
|
|
446
|
+
const cleaned = m.materializeModuleSkillsIntoCore({ coreRoot: core, clean: true });
|
|
447
|
+
assert.deepEqual(cleaned.removed, ['alpha']);
|
|
448
|
+
for (const p of ['alpha', m.MODULE_SKILLS_MANIFEST, '.gitignore']) assert.ok(!existsSync(join(core, '.claude', 'skills', p)), `--clean removed ${p}`);
|
|
449
|
+
assert.ok(existsSync(join(core, '.claude', 'skills', 'core-one', 'SKILL.md')), 'the core\'s own skills survive --clean');
|
|
450
|
+
rmSync(core, { recursive: true, force: true });
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
t('--module-skills-only: an instance materialised FROM a core that carries materialised copies gets them through the module path (transformed), never as core skills, and never the two dotfiles', () => {
|
|
454
|
+
const core = fakeCore();
|
|
455
|
+
m.materializeModuleSkillsIntoCore({ coreRoot: core });
|
|
456
|
+
const inst = mkdtempSync(join(tmpdir(), 'cm-inst-'));
|
|
457
|
+
const res = m.materializeClaude({ coreRoot: core, instanceDir: inst, dryRun: false });
|
|
458
|
+
assert.ok(!res.notes.some((n) => /"alpha".*also exists under the core/.test(n)), 'the materialised copy is not mistaken for a core skill');
|
|
459
|
+
assert.ok(res.moduleSkills >= 2, 'alpha landed through step 1b');
|
|
460
|
+
assert.match(readFileSync(join(inst, '.claude', 'skills', 'alpha', 'SKILL.md'), 'utf8'), /bongos ship 1/, 'transformed on the way into the instance');
|
|
461
|
+
for (const f of [m.MODULE_SKILLS_MANIFEST, '.gitignore']) assert.ok(!existsSync(join(inst, '.claude', 'skills', f)), `${f} is not shipped to an instance`);
|
|
462
|
+
assert.ok(existsSync(join(inst, '.claude', 'skills', 'core-one', 'SKILL.md')) && existsSync(join(inst, '.claude', 'skills', 'shared', 'SKILL.md')), 'the core skills still land');
|
|
463
|
+
rmSync(core, { recursive: true, force: true }); rmSync(inst, { recursive: true, force: true });
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
t('--module-skills-only: the CLI entry refuses without the flag (usage, exit 2) and gen-file-map skips materialised names by the manifest', () => {
|
|
467
|
+
const { spawnSync } = require('node:child_process');
|
|
468
|
+
const bare = spawnSync(process.execPath, [join(ROOT, 'scripts', 'gds', 'claude-materialize.js')], { encoding: 'utf8' });
|
|
469
|
+
assert.equal(bare.status, 2); assert.match(bare.stderr, /--module-skills-only/);
|
|
470
|
+
// end to end through the real entry point, on the real checkout, writing nothing: --dry-run
|
|
471
|
+
// reports what it would land, --clean --dry-run what it would remove, both exit 0
|
|
472
|
+
const cli = join(ROOT, 'scripts', 'gds', 'claude-materialize.js');
|
|
473
|
+
const dry = spawnSync(process.execPath, [cli, '--module-skills-only', '--dry-run'], { encoding: 'utf8' });
|
|
474
|
+
assert.equal(dry.status, 0, dry.stderr); assert.match(dry.stdout, /\[dry-run\] would materialize \d+ module-owned skill/); assert.match(dry.stdout, /module skills → \.claude\/skills\//);
|
|
475
|
+
const dryClean = spawnSync(process.execPath, [cli, '--module-skills-only', '--clean', '--dry-run'], { encoding: 'utf8' });
|
|
476
|
+
assert.equal(dryClean.status, 0, dryClean.stderr); assert.match(dryClean.stdout, /\[dry-run\] would remove \d+ materialised module skill dir/);
|
|
477
|
+
assert.ok(!existsSync(join(ROOT, '.claude', 'skills', m.MODULE_SKILLS_MANIFEST)) || readFileSync(join(ROOT, '.claude', 'skills', m.MODULE_SKILLS_MANIFEST), 'utf8').length > 0, 'a dry run never wrote or emptied the real manifest');
|
|
478
|
+
const gen = require('../scripts/gds/gen-file-map.js');
|
|
479
|
+
const dir = mkdtempSync(join(tmpdir(), 'cm-fm-'));
|
|
480
|
+
for (const n of ['real', 'copy']) { mkdirSync(join(dir, n)); writeFileSync(join(dir, n, 'SKILL.md'), 'x'); }
|
|
481
|
+
writeFileSync(join(dir, m.MODULE_SKILLS_MANIFEST), JSON.stringify({ skills: { copy: { module: 'm1' } } }));
|
|
482
|
+
assert.deepEqual([...gen.materializedNames(dir)], ['copy']);
|
|
483
|
+
assert.deepEqual([...gen.materializedNames(join(dir, 'real'))], [], 'no manifest, nothing skipped');
|
|
484
|
+
rmSync(dir, { recursive: true, force: true });
|
|
485
|
+
});
|
|
486
|
+
|
|
406
487
|
console.log(`\nclaude_materialize: ${passed} passed, ${failed} failed`);
|
|
407
488
|
process.exit(failed ? 1 : 0);
|
package/tests/file_map.mjs
CHANGED
|
@@ -37,8 +37,11 @@ test('EVERY skill + scheduled-task dir on disk is listed AND has a note — no s
|
|
|
37
37
|
// core checkout carries neither (task 1002470). Check the sections that exist.
|
|
38
38
|
if (!existsSync(path.join(ROOT, section.dir))) continue;
|
|
39
39
|
checked += 1;
|
|
40
|
+
// A module skill materialised into this checkout (task 1003330) is an untracked copy the
|
|
41
|
+
// module documents; the generator skips it by the manifest beside it, and so does this.
|
|
42
|
+
const materialised = gen.materializedNames(path.join(ROOT, section.dir));
|
|
40
43
|
const onDisk = readdirSync(path.join(ROOT, section.dir), { withFileTypes: true })
|
|
41
|
-
.filter((e) => e.isDirectory() && !e.name.startsWith('.'))
|
|
44
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith('.') && !materialised.has(e.name))
|
|
42
45
|
.map((e) => e.name)
|
|
43
46
|
.sort();
|
|
44
47
|
const listed = section.entries.map((e) => e.name).sort();
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// tests/idea_governance_pass_credit.mjs
|
|
2
|
+
//
|
|
3
|
+
// task 1002999 (BV1.R175 / IC5, goal 1000067) — stream 3 of the ideator credit
|
|
4
|
+
// lane: a Full Idea that PASSES the Board Room's ratification vote pays a share
|
|
5
|
+
// of a 100-credit ceiling, scaled by its R142 completeness. ADR 0172 §3, and
|
|
6
|
+
// docs/specs/board-room-pass-event-contract.md (the agreement with the board).
|
|
7
|
+
//
|
|
8
|
+
// The done-when as assertions: a ratified Full Idea books round(completeness%)
|
|
9
|
+
// once; a constitutional amendment books nothing; re-running never double-pays;
|
|
10
|
+
// cost-plus-only does NOT suppress it (it gates on ideatorCredit, never
|
|
11
|
+
// perTaskCredit); an instance that opts the ideator lane out pays nothing.
|
|
12
|
+
//
|
|
13
|
+
// The credit fn is exercised over a stub client that records statements and
|
|
14
|
+
// simulates the WHERE-NOT-EXISTS idempotency, so the idempotency logic is proven
|
|
15
|
+
// without a DB — the tests/idea_full_pass_credit.mjs harness, unchanged.
|
|
16
|
+
//
|
|
17
|
+
// Run: node tests/idea_governance_pass_credit.mjs
|
|
18
|
+
|
|
19
|
+
import { strict as assert } from 'node:assert';
|
|
20
|
+
import { createRequire } from 'node:module';
|
|
21
|
+
|
|
22
|
+
const require = createRequire(import.meta.url);
|
|
23
|
+
const credits = require('../modules/economy/credits.js');
|
|
24
|
+
|
|
25
|
+
let passed = 0;
|
|
26
|
+
let failed = 0;
|
|
27
|
+
async function test(name, fn) {
|
|
28
|
+
try { await fn(); passed++; console.log(` ok ${name}`); }
|
|
29
|
+
catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// A stub client whose credit_log "table" is an in-memory set of (builder, reason)
|
|
33
|
+
// pairs, so INSERT ... WHERE NOT EXISTS behaves exactly like Postgres: a second
|
|
34
|
+
// insert for the same pair is a no-op (rowCount 0). `developedBy` defaults to
|
|
35
|
+
// null — an idea nobody else developed — and is set explicitly by the split test.
|
|
36
|
+
function stubClient({ authorId = null, developedBy = null } = {}) {
|
|
37
|
+
const rows = [];
|
|
38
|
+
return {
|
|
39
|
+
rows,
|
|
40
|
+
async query(sql, params) {
|
|
41
|
+
// The parties lookup — who authored this idea, and who developed it.
|
|
42
|
+
if (/FROM idea_inbox i/i.test(sql) && /developed_by/i.test(sql)) {
|
|
43
|
+
return { rows: authorId == null ? [] : [{ captured_by: authorId, developed_by: developedBy }] };
|
|
44
|
+
}
|
|
45
|
+
// The idempotency predicate must scope by builder_id AND reason: a split
|
|
46
|
+
// books two rows under ONE reason, so a reason-only predicate would
|
|
47
|
+
// silently refuse the second payee (and credit_log is a CORE table, so a
|
|
48
|
+
// module migration cannot add an index on `reason` anyway).
|
|
49
|
+
const builderId = params[0];
|
|
50
|
+
const reason = params[3];
|
|
51
|
+
assert.match(sql, /builder_id = \$1 AND reason = \$4/, 'idempotency check must scope by builder_id AND reason');
|
|
52
|
+
const exists = rows.some((r) => r.builder_id === builderId && r.reason === reason);
|
|
53
|
+
if (exists) return { rowCount: 0, rows: [] };
|
|
54
|
+
rows.push({ builder_id: builderId, task_id: params[1], delta: params[2], reason, description: params[4] });
|
|
55
|
+
return { rowCount: 1, rows: [] };
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const ON = { ideatorCredit: true };
|
|
61
|
+
|
|
62
|
+
await test('a ratified Full Idea pays its completeness percent of the 100 ceiling', async () => {
|
|
63
|
+
const c = stubClient({ authorId: 3 });
|
|
64
|
+
const r = await credits.awardIdeaGovernancePassCredit(c, { ideaId: 9, authorId: 3, completenessScore: 70, config: ON });
|
|
65
|
+
assert.equal(r.credited, 70, 'the owner\'s worked example: 70%-complete + passes = 70');
|
|
66
|
+
assert.equal(r.reason, 'paid');
|
|
67
|
+
assert.equal(c.rows.length, 1);
|
|
68
|
+
assert.equal(c.rows[0].delta, 70);
|
|
69
|
+
assert.equal(c.rows[0].builder_id, 3);
|
|
70
|
+
assert.equal(c.rows[0].reason, 'idea.credit.governance_pass:9');
|
|
71
|
+
assert.equal(c.rows[0].task_id, null, 'an idea credit has no task');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
await test('a fully-complete idea pays the whole 100-credit ceiling, never more', async () => {
|
|
75
|
+
const c = stubClient({ authorId: 3 });
|
|
76
|
+
const r = await credits.awardIdeaGovernancePassCredit(c, { ideaId: 1, authorId: 3, completenessScore: 100, config: ON });
|
|
77
|
+
assert.equal(r.credited, credits.IDEA_GOVERNANCE_PASS_MAX);
|
|
78
|
+
assert.equal(r.credited, 100);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
await test('a malformed over-100 score is clamped to the ceiling, not paid out', async () => {
|
|
82
|
+
const c = stubClient({ authorId: 3 });
|
|
83
|
+
const r = await credits.awardIdeaGovernancePassCredit(c, { ideaId: 2, authorId: 3, completenessScore: 420, config: ON });
|
|
84
|
+
assert.equal(r.credited, 100, 'the ceiling holds even when the input is nonsense');
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
await test('re-running pays nothing — once per idea, forever', async () => {
|
|
88
|
+
const c = stubClient({ authorId: 3 });
|
|
89
|
+
const opts = { ideaId: 9, authorId: 3, completenessScore: 80, config: ON };
|
|
90
|
+
const first = await credits.awardIdeaGovernancePassCredit(c, opts);
|
|
91
|
+
const second = await credits.awardIdeaGovernancePassCredit(c, opts);
|
|
92
|
+
const third = await credits.awardIdeaGovernancePassCredit(c, opts);
|
|
93
|
+
assert.equal(first.credited, 80);
|
|
94
|
+
assert.equal(second.credited, 0);
|
|
95
|
+
assert.equal(second.reason, 'already_paid');
|
|
96
|
+
assert.equal(third.credited, 0);
|
|
97
|
+
assert.equal(c.rows.length, 1, 'exactly one credit_log row ever, across three calls');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
await test('a constitutional amendment books nothing — subject_type is load-bearing', async () => {
|
|
101
|
+
const c = stubClient({ authorId: 3 });
|
|
102
|
+
const r = await credits.awardIdeaGovernancePassCredit(c, {
|
|
103
|
+
ideaId: null, authorId: 3, completenessScore: null,
|
|
104
|
+
subjectType: 'constitutional_amendment', config: ON,
|
|
105
|
+
});
|
|
106
|
+
assert.equal(r.credited, 0);
|
|
107
|
+
assert.equal(r.reason, 'not_a_full_idea');
|
|
108
|
+
assert.equal(c.rows.length, 0, 'an amendment traverses the same close path and must pay nobody');
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
await test('subject_type is checked BEFORE the lane flag, so a sweep gets the truthful reason', async () => {
|
|
112
|
+
const c = stubClient({ authorId: 3 });
|
|
113
|
+
const r = await credits.awardIdeaGovernancePassCredit(c, {
|
|
114
|
+
ideaId: 5, authorId: 3, completenessScore: 90,
|
|
115
|
+
subjectType: 'constitutional_amendment', config: { ideatorCredit: false },
|
|
116
|
+
});
|
|
117
|
+
assert.equal(r.reason, 'not_a_full_idea', 'an amendment is not an ideator payment at all');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
await test('an explicit full_idea subject pays exactly as an omitted one does', async () => {
|
|
121
|
+
const c = stubClient({ authorId: 3 });
|
|
122
|
+
const r = await credits.awardIdeaGovernancePassCredit(c, {
|
|
123
|
+
ideaId: 7, authorId: 3, completenessScore: 65, subjectType: 'full_idea', config: ON,
|
|
124
|
+
});
|
|
125
|
+
assert.equal(r.credited, 65, 'the board omits subjectType because it has already filtered; a sweep passes it');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
await test('COST-PLUS-ONLY does NOT suppress it: the ideator lane pays regardless of perTaskCredit', async () => {
|
|
129
|
+
// the live cloudbongos.com policy: builder estimate off, ideator lane on
|
|
130
|
+
const policy = credits.rewardPolicy({ mode: 'cost-plus-only' });
|
|
131
|
+
assert.equal(policy.perTaskCredit, false);
|
|
132
|
+
assert.equal(policy.ideatorCredit, true);
|
|
133
|
+
const c = stubClient({ authorId: 3 });
|
|
134
|
+
const r = await credits.awardIdeaGovernancePassCredit(c, { ideaId: 5, authorId: 3, completenessScore: 75, config: policy });
|
|
135
|
+
assert.equal(r.credited, 75, 'paid despite cost-plus-only — this is the whole point of ADR 0172');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
await test('an instance that opts the ideator lane out pays nothing', async () => {
|
|
139
|
+
const c = stubClient({ authorId: 3 });
|
|
140
|
+
const r = await credits.awardIdeaGovernancePassCredit(c, { ideaId: 6, authorId: 3, completenessScore: 90, config: { ideatorCredit: false } });
|
|
141
|
+
assert.equal(r.credited, 0);
|
|
142
|
+
assert.equal(r.reason, 'ideator_lane_off');
|
|
143
|
+
assert.equal(c.rows.length, 0);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
await test('an unscorable idea books 0 — and it is NOT reported as below a bar', async () => {
|
|
147
|
+
// There is deliberately no `below_bar` return: ADR 0175 §5 makes the
|
|
148
|
+
// completeness bar the gate on OPENING a window, so a sub-bar idea can never
|
|
149
|
+
// reach a pass. A null score means the score is MISSING, not low.
|
|
150
|
+
const c = stubClient({ authorId: 3 });
|
|
151
|
+
assert.equal((await credits.awardIdeaGovernancePassCredit(c, { ideaId: 1, authorId: 3, completenessScore: null, config: ON })).reason, 'no_completeness_score');
|
|
152
|
+
assert.equal((await credits.awardIdeaGovernancePassCredit(c, { ideaId: 2, authorId: 3, completenessScore: undefined, config: ON })).reason, 'no_completeness_score');
|
|
153
|
+
assert.equal((await credits.awardIdeaGovernancePassCredit(c, { ideaId: 3, authorId: 3, completenessScore: 'x', config: ON })).reason, 'no_completeness_score');
|
|
154
|
+
assert.equal(c.rows.length, 0, 'nothing written when there is no score to scale by');
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
await test('a missing author or idea is a safe 0, never a throw', async () => {
|
|
158
|
+
const noIdea = stubClient({ authorId: 3 });
|
|
159
|
+
assert.equal((await credits.awardIdeaGovernancePassCredit(noIdea, { ideaId: null, authorId: 3, completenessScore: 90, config: ON })).reason, 'missing_author_or_idea');
|
|
160
|
+
// An idea the lookup cannot see AND no caller-supplied author → still a safe 0.
|
|
161
|
+
const unknown = stubClient(); // lookup returns no row
|
|
162
|
+
assert.equal((await credits.awardIdeaGovernancePassCredit(unknown, { ideaId: 6, authorId: null, completenessScore: 90, config: ON })).reason, 'missing_author_or_idea');
|
|
163
|
+
assert.equal(unknown.rows.length, 0);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
await test('the DB is authoritative for the author; the argument is only a fallback', async () => {
|
|
167
|
+
const known = stubClient({ authorId: 3 });
|
|
168
|
+
const r = await credits.awardIdeaGovernancePassCredit(known, { ideaId: 6, authorId: null, completenessScore: 90, config: ON });
|
|
169
|
+
assert.equal(r.credited, 90);
|
|
170
|
+
assert.equal(known.rows[0].builder_id, 3, 'paid the author the DB names');
|
|
171
|
+
|
|
172
|
+
// ...and when the lookup finds nothing, the passed author is still honoured,
|
|
173
|
+
// so a caller holding an idea this module cannot see is not silently unpaid.
|
|
174
|
+
const invisible = stubClient();
|
|
175
|
+
const f = await credits.awardIdeaGovernancePassCredit(invisible, { ideaId: 6, authorId: 7, completenessScore: 90, config: ON });
|
|
176
|
+
assert.equal(f.credited, 90);
|
|
177
|
+
assert.equal(invisible.rows[0].builder_id, 7);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
await test('a developed spark splits 70/30 with its developer, conserving the total', async () => {
|
|
181
|
+
// ADR 0185, the same rule streams 1 and 2 apply. The contract for this stream
|
|
182
|
+
// predates the split and names the author alone; mirroring the sibling
|
|
183
|
+
// streams, as it asks, means splitting here too.
|
|
184
|
+
const c = stubClient({ authorId: 3, developedBy: 8 });
|
|
185
|
+
const r = await credits.awardIdeaGovernancePassCredit(c, { ideaId: 4, authorId: 3, completenessScore: 70, config: ON });
|
|
186
|
+
assert.equal(r.credited, 70, 'the total is conserved across the two payees');
|
|
187
|
+
assert.equal(c.rows.length, 2);
|
|
188
|
+
const dev = c.rows.find((x) => x.builder_id === 8);
|
|
189
|
+
const author = c.rows.find((x) => x.builder_id === 3);
|
|
190
|
+
assert.equal(dev.delta, 49, '70% of 70, rounded');
|
|
191
|
+
assert.equal(author.delta, 21, 'the remainder — never independently rounded');
|
|
192
|
+
assert.equal(dev.reason, author.reason, 'one reason key, two rows — hence the builder-scoped predicate');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
await test('a self-developed spark collapses to one payee, so nobody is under-paid', async () => {
|
|
196
|
+
const c = stubClient({ authorId: 3, developedBy: 3 });
|
|
197
|
+
const r = await credits.awardIdeaGovernancePassCredit(c, { ideaId: 11, authorId: 3, completenessScore: 70, config: ON });
|
|
198
|
+
assert.equal(r.credited, 70);
|
|
199
|
+
assert.equal(c.rows.length, 1, 'two rows for one builder would hit the idempotency predicate and UNDER-pay');
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
await test('the floor pays 30 on a sub-30 score — a branch unreachable under default config', async () => {
|
|
203
|
+
// NOT A REAL SCENARIO, and deliberately not faked into one: ADR 0175 §5 gates
|
|
204
|
+
// window-opening on IDEA_FULL_PASS_BAR, so a real payout lands in [60,100]
|
|
205
|
+
// and max() never binds. The branch is exercised by handing the function an
|
|
206
|
+
// input the board cannot currently send — never by lowering the bar in a
|
|
207
|
+
// fixture, which would look like a scenario it is not. It ships because it is
|
|
208
|
+
// the owner's stated intent and goes live the moment either constant moves.
|
|
209
|
+
const c = stubClient({ authorId: 3 });
|
|
210
|
+
const r = await credits.awardIdeaGovernancePassCredit(c, { ideaId: 12, authorId: 3, completenessScore: 10, config: ON });
|
|
211
|
+
assert.equal(r.credited, credits.IDEA_GOVERNANCE_PASS_FLOOR);
|
|
212
|
+
assert.equal(r.credited, 30);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
await test('the floor really is unreachable at the shipped constants — the finding, as an assertion', async () => {
|
|
216
|
+
const lowestPassablePayout = Math.round(credits.IDEA_FULL_PASS_BAR / 100 * credits.IDEA_GOVERNANCE_PASS_MAX);
|
|
217
|
+
assert.ok(
|
|
218
|
+
lowestPassablePayout > credits.IDEA_GOVERNANCE_PASS_FLOOR,
|
|
219
|
+
`bar ${credits.IDEA_FULL_PASS_BAR} pays ${lowestPassablePayout}, above the ${credits.IDEA_GOVERNANCE_PASS_FLOOR} floor — if this ever fails, the floor has become live and the branch above is a real scenario`
|
|
220
|
+
);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
await test('all three ideator streams use distinct reason keys, so one idea can earn on each', async () => {
|
|
224
|
+
const c = stubClient({ authorId: 3 });
|
|
225
|
+
await credits.awardIdeaFullPassCredit(c, { ideaId: 20, authorId: 3, completenessScore: 90, config: ON });
|
|
226
|
+
await credits.awardIdeaGovernancePassCredit(c, { ideaId: 20, authorId: 3, completenessScore: 90, config: ON });
|
|
227
|
+
assert.equal(c.rows.length, 2, 'stream 2 and stream 3 both paid the same idea');
|
|
228
|
+
assert.equal(c.rows[0].reason, 'idea.credit.full_pass:20');
|
|
229
|
+
assert.equal(c.rows[1].reason, 'idea.credit.governance_pass:20');
|
|
230
|
+
assert.equal(c.rows[0].delta + c.rows[1].delta, 95, '5 for the grade, 90 for the ratification');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
await test('the award rides the reward PORT, so the board can resolve it', async () => {
|
|
234
|
+
const reward = require('../modules/economy/reward.js');
|
|
235
|
+
assert.equal(typeof reward.awardIdeaGovernancePassCredit, 'function',
|
|
236
|
+
'board.js duck-types on exactly this name; without it the close is a silent no-op');
|
|
237
|
+
const c = stubClient({ authorId: 3 });
|
|
238
|
+
const r = await reward.awardIdeaGovernancePassCredit(c, { ideaId: 30, authorId: 3, completenessScore: 60, config: ON });
|
|
239
|
+
assert.equal(r.credited, 60, 'delegated, not spread — the port reaches the live function');
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
console.log(`\nidea_governance_pass_credit: ${passed} passed, ${failed} failed`);
|
|
243
|
+
if (failed > 0) process.exit(1);
|