@bongos/core 1.19.314 → 1.19.316
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 +28 -18
- package/.claude/skills/merge-mode/SKILL.md +16 -0
- package/.claude/skills/planning-session/SKILL.md +10 -0
- package/docs/recipes/upgrading-the-core.md +2 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/fitness.js +1 -1
- package/scripts/gds/npmrc.js +32 -1
- package/scripts/gds/preflight.js +25 -2
- package/scripts/gds/skill-preflight.js +336 -0
- package/scripts/gds/upgrade.js +22 -7
- package/src/module-api.js +5 -1
- package/tests/npmrc.mjs +49 -0
- package/tests/skill_preflight.mjs +251 -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.316",
|
|
6
|
+
"core_contract": "1.19.316",
|
|
7
|
+
"source_commit": "8248fac14dda1af6df3a5414027a4bf9d6ba23e0",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-
|
|
9
|
+
"built_at": "2026-08-23T22:02:43.920Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 323,
|
|
13
13
|
"agent_docs_stubbed": 22,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1423,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 1768,
|
|
20
|
+
"tree_sha256": "6e3e95467f3b58d962df6fc2490011be711c43787e43adff339b3da3390c2da6",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -182,7 +182,7 @@
|
|
|
182
182
|
{
|
|
183
183
|
"path": ".claude/skills/merge-mode/SKILL.md",
|
|
184
184
|
"mode": "0000644",
|
|
185
|
-
"sha256": "
|
|
185
|
+
"sha256": "a1f797a9267428facc357c59b09d8eafd6b6d8ca36e6d6d7ae0c6bf60c11ab58"
|
|
186
186
|
},
|
|
187
187
|
{
|
|
188
188
|
"path": ".claude/skills/new-project/SKILL.md",
|
|
@@ -222,7 +222,7 @@
|
|
|
222
222
|
{
|
|
223
223
|
"path": ".claude/skills/planning-session/SKILL.md",
|
|
224
224
|
"mode": "0000644",
|
|
225
|
-
"sha256": "
|
|
225
|
+
"sha256": "f51cb3fec1448883300f5f966dc83f6b9636935059503250ed8a1f944526e743"
|
|
226
226
|
},
|
|
227
227
|
{
|
|
228
228
|
"path": ".claude/skills/priority-session/SKILL.md",
|
|
@@ -1797,7 +1797,7 @@
|
|
|
1797
1797
|
{
|
|
1798
1798
|
"path": "docs/recipes/upgrading-the-core.md",
|
|
1799
1799
|
"mode": "0000644",
|
|
1800
|
-
"sha256": "
|
|
1800
|
+
"sha256": "82a8a5b17d465b9048ce645e2bc13acaa492f766726cc79981fff1352a92d1b0"
|
|
1801
1801
|
},
|
|
1802
1802
|
{
|
|
1803
1803
|
"path": "docs/recipes/windows-builders.md",
|
|
@@ -4582,12 +4582,12 @@
|
|
|
4582
4582
|
{
|
|
4583
4583
|
"path": "package-lock.json",
|
|
4584
4584
|
"mode": "0000644",
|
|
4585
|
-
"sha256": "
|
|
4585
|
+
"sha256": "6c10d47c4db2e1e159b3c4aad631491bf7818c3a89fbf3f02bc1de512b1163b3"
|
|
4586
4586
|
},
|
|
4587
4587
|
{
|
|
4588
4588
|
"path": "package.json",
|
|
4589
4589
|
"mode": "0000644",
|
|
4590
|
-
"sha256": "
|
|
4590
|
+
"sha256": "2bfd8f95e547fbbf060361a024395b256fe5eb7bde0796600d44494a718da5cb"
|
|
4591
4591
|
},
|
|
4592
4592
|
{
|
|
4593
4593
|
"path": "public-docs/index.html",
|
|
@@ -5237,7 +5237,7 @@
|
|
|
5237
5237
|
{
|
|
5238
5238
|
"path": "scripts/gds/fitness.js",
|
|
5239
5239
|
"mode": "0000644",
|
|
5240
|
-
"sha256": "
|
|
5240
|
+
"sha256": "a300efecfa69f8ad597a8125df6dd1999d7cdffc8317ac8616b7081555c8b303"
|
|
5241
5241
|
},
|
|
5242
5242
|
{
|
|
5243
5243
|
"path": "scripts/gds/gate-review.js",
|
|
@@ -5477,7 +5477,7 @@
|
|
|
5477
5477
|
{
|
|
5478
5478
|
"path": "scripts/gds/npmrc.js",
|
|
5479
5479
|
"mode": "0000644",
|
|
5480
|
-
"sha256": "
|
|
5480
|
+
"sha256": "1beffeed111e8528cfa0a629d5ef81c766023de5c6820a81659a390070868228"
|
|
5481
5481
|
},
|
|
5482
5482
|
{
|
|
5483
5483
|
"path": "scripts/gds/oauth-secret.js",
|
|
@@ -5527,7 +5527,7 @@
|
|
|
5527
5527
|
{
|
|
5528
5528
|
"path": "scripts/gds/preflight.js",
|
|
5529
5529
|
"mode": "0000644",
|
|
5530
|
-
"sha256": "
|
|
5530
|
+
"sha256": "9898b6f8e60b9f5ee4aeb9a023d1b68bb5bfbdf589eed10923a8e492716340ed"
|
|
5531
5531
|
},
|
|
5532
5532
|
{
|
|
5533
5533
|
"path": "scripts/gds/provision.js",
|
|
@@ -5804,6 +5804,11 @@
|
|
|
5804
5804
|
"mode": "0000644",
|
|
5805
5805
|
"sha256": "f3005175abc0ba20e958381df6aee02402267e9956da466386656dd4e86ad8cf"
|
|
5806
5806
|
},
|
|
5807
|
+
{
|
|
5808
|
+
"path": "scripts/gds/skill-preflight.js",
|
|
5809
|
+
"mode": "0000644",
|
|
5810
|
+
"sha256": "2c8297314642a459adb31d4253540fac4a8b3b32a4b79dddd9f0e6cdb87b51fc"
|
|
5811
|
+
},
|
|
5807
5812
|
{
|
|
5808
5813
|
"path": "scripts/gds/smoke-batch-claim.sh",
|
|
5809
5814
|
"mode": "0000644",
|
|
@@ -5877,7 +5882,7 @@
|
|
|
5877
5882
|
{
|
|
5878
5883
|
"path": "scripts/gds/upgrade.js",
|
|
5879
5884
|
"mode": "0000644",
|
|
5880
|
-
"sha256": "
|
|
5885
|
+
"sha256": "80856db9974df9b3451decf55385eeb78bcba61e523ca09a2ea070deb48991ab"
|
|
5881
5886
|
},
|
|
5882
5887
|
{
|
|
5883
5888
|
"path": "scripts/gds/validate-design.js",
|
|
@@ -6257,7 +6262,7 @@
|
|
|
6257
6262
|
{
|
|
6258
6263
|
"path": "src/module-api.js",
|
|
6259
6264
|
"mode": "0000644",
|
|
6260
|
-
"sha256": "
|
|
6265
|
+
"sha256": "049a62ecc239e85005e2f79fea63f46edf406b53eb2b4c36e21e81281365afc7"
|
|
6261
6266
|
},
|
|
6262
6267
|
{
|
|
6263
6268
|
"path": "src/module-loader/catalog.js",
|
|
@@ -7977,7 +7982,7 @@
|
|
|
7977
7982
|
{
|
|
7978
7983
|
"path": "tests/npmrc.mjs",
|
|
7979
7984
|
"mode": "0000644",
|
|
7980
|
-
"sha256": "
|
|
7985
|
+
"sha256": "1aaa68ee1a4c4a58563cef86aac44cfa0e046ad7376d3a2108bf6dd455580942"
|
|
7981
7986
|
},
|
|
7982
7987
|
{
|
|
7983
7988
|
"path": "tests/oauth_secret.mjs",
|
|
@@ -8624,6 +8629,11 @@
|
|
|
8624
8629
|
"mode": "0000644",
|
|
8625
8630
|
"sha256": "4557c934704a009612ed8858c0f59c5b53facb5d92fa2c0740b1aecb079aa5c9"
|
|
8626
8631
|
},
|
|
8632
|
+
{
|
|
8633
|
+
"path": "tests/skill_preflight.mjs",
|
|
8634
|
+
"mode": "0000644",
|
|
8635
|
+
"sha256": "df2c86d02d560b242a341c905b9ee5fbb25c6479c506736807947749e0a033fa"
|
|
8636
|
+
},
|
|
8627
8637
|
{
|
|
8628
8638
|
"path": "tests/skill_prefs.mjs",
|
|
8629
8639
|
"mode": "0000644",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: merge-mode
|
|
3
3
|
description: Manual fallback for the auto-merge in /builder-ship. Walks every task at status='confirmed' (= verified but not yet on main) and runs the merge + smoke + deploy chain. Triggers when the user says "/merge-mode", "merge the queue", "land confirmed tasks", "land the queue", "do a merge run", or after a /builder-ship reported the auto-merge bailed.
|
|
4
|
+
requires: [main-checkout, push-credential, gh, droplet-ssh]
|
|
4
5
|
---
|
|
5
6
|
|
|
6
7
|
You are running the manual merge-mode session for Example. **`/merge-mode` is a rare last resort, not a routine sweep — and not something to push on other builders.** The server lands merges on its own: `/builder-ship` chains into auto-merge, green PRs auto-merge, a 5-min reconciler flips `confirmed → shipped`, and the [ADR 0082](../../../docs/adr/<redacted>.md) resolver self-heals generated-file conflicts. **Before running this skill, confirm the task is genuinely stuck** — still at `confirmed` more than ~5 minutes after its ship because of a conflict or regression the server couldn't auto-resolve. If you haven't waited for the sweep yet, stop and wait. This skill is for the strand that survives all of that — usually a cross-branch / non-generated-file conflict, a smoke regression, or a `--no-merge` ship.
|
|
@@ -17,6 +18,21 @@ The queue this skill works against is **tasks at status='confirmed'**. Those are
|
|
|
17
18
|
|
|
18
19
|
## How to use
|
|
19
20
|
|
|
21
|
+
**Step 0 — preflight this machine, before anything else.**
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
node scripts/gds/skill-preflight.js merge-mode # exits 1 naming what is MISSING
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
If it exits non-zero, **stop**: this machine cannot finish the procedure, and the
|
|
28
|
+
partial run is the dangerous outcome (it mutates the main worktree). The skill's
|
|
29
|
+
`requires:` frontmatter is what it checks — `main-checkout`, `push-credential`,
|
|
30
|
+
`gh`, `droplet-ssh`. This gate exists because the note below was prose only, and
|
|
31
|
+
prose cannot refuse: merge-mode was run on a dev box that had none of them, while
|
|
32
|
+
`ship.js` separately already knew (`strandNextStep` tells a box "/merge-mode is
|
|
33
|
+
NOT available here"). The knowledge existed twice and neither copy stopped the run
|
|
34
|
+
(task 1003167).
|
|
35
|
+
|
|
20
36
|
> **Environment note:** merge-mode is an **ops procedure** — it mutates the single
|
|
21
37
|
> main worktree and deploys to the Linux droplet (owner-gated SSH). The git /
|
|
22
38
|
> `ssh` / `deploy.sh` / branch-inventory steps below assume a bash environment
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: planning-session
|
|
3
3
|
description: Run a structured planning session for an upcoming Bongos version — set scope criteria, draft a written spec, interview the owner in depth to resolve every non-obvious decision, then seed the ranked task list from that spec. Triggers when the user says "/planning-session", "open a planning session", "let's plan V[N]", "plan the next version", or "scope a new version". Metic+ rank only (Metic and Archon; not Xenos).
|
|
4
|
+
requires: [droplet-ssh]
|
|
4
5
|
---
|
|
5
6
|
|
|
6
7
|
You are running a planning session for Bongos. The goal is to leave the session with: (a) a clear set of done-when criteria for a version, (b) a **written spec** (`docs/specs/<ver>-<goal-slug>.md`) that an in-depth interview refines until every non-obvious decision is resolved, (c) a ranked task list **seeded from that spec** that lives in the Bongos database, and (d) a durable record of the session for the audit trail.
|
|
@@ -193,9 +194,18 @@ a builder-local one (so the bash/`ssh`/`psql` here is server-side by design, not
|
|
|
193
194
|
a Windows concern):
|
|
194
195
|
|
|
195
196
|
```bash
|
|
197
|
+
node scripts/gds/skill-preflight.js planning-session # stops here if this box has no ssh
|
|
196
198
|
ssh lars@REDACTED_IP "psql -d example -c '\\d tasks' | grep -i check"
|
|
197
199
|
```
|
|
198
200
|
|
|
201
|
+
The preflight sits at THIS step rather than at the top of the skill on purpose:
|
|
202
|
+
the `droplet-ssh` this skill declares is needed for one optional lookup, not for
|
|
203
|
+
the whole session, so gating the entire planning session on it would refuse work
|
|
204
|
+
that does not need it. Compare `/merge-mode`, where the capabilities are
|
|
205
|
+
preconditions for the whole procedure and the gate is Step 0. Both placements are
|
|
206
|
+
legitimate; what is not legitimate is declaring a capability and never checking it
|
|
207
|
+
(task 1003167).
|
|
208
|
+
|
|
199
209
|
Confirm what the `priority` column actually allows.
|
|
200
210
|
|
|
201
211
|
**Encoding (V3 convention):** priority is `1-5` (CHECK constraint). Use priority by tier:
|
|
@@ -116,6 +116,8 @@ Each subscribed instance: read its installed core → ask the registry (`npm vie
|
|
|
116
116
|
|
|
117
117
|
## Gotchas
|
|
118
118
|
|
|
119
|
+
- **`--registry` no longer needs a hand-exported `NPM_TOKEN`** (task 1002754). The instance `.npmrc` reads `_authToken=<redacted>, and npm gives that per-project key precedence — so with the env var unset it expanded EMPTY and **masked a perfectly valid literal token in `~/.npmrc`** (npm went anonymous; the private core 404'd; the error blamed a "missing" credential the box actually held). `bongos upgrade --registry` now resolves the credential itself: env `NPM_TOKEN` wins, else the **user-level `~/.npmrc`'s literal `//registry.npmjs.org/:_authToken=…`** is injected into the install's environment (forward install and rollback reinstall both). A pin bump on a box whose `~/.npmrc` authenticates therefore succeeds with **no exported env var**. Resolver: `resolveNpmToken()` in [`scripts/gds/npmrc.js`](../../scripts/gds/npmrc.js).
|
|
120
|
+
- **A green `publish` run is not evidence of a publish.** The publish-on-merge lane succeeds on its no-op paths too (cascade terminator, armed-gate). Read the run's **job summary**, which now states the verdict explicitly: `PUBLISHED @bongos/core@<v>` vs `NO-OP — nothing was published this run`.
|
|
119
121
|
- **`PGDATABASE` is load-bearing on a co-tenant droplet.** `migrate.sh` defaults `PGDATABASE` to `example` — **production**. Always set `PGDATABASE=<instance-db>` explicitly, or you migrate the wrong database.
|
|
120
122
|
- **The health check polls the restart** (fixed in task [#1984](https://example.com/builders#/task/1984)). The service binds its port ~1s after `systemctl restart`, so `--health-url` now polls up to ~30s for a 2xx before reporting a warning — an immediate `fetch failed` no longer means a failed upgrade. (Pre-1984 cores fetched once and spuriously reported `fetch failed`; there, confirm with `is-active` + a manual `curl`.)
|
|
121
123
|
- **The ledger auto-inserts via `PGDATABASE` too** (fixed in task [#1984](https://example.com/builders#/task/1984)). A consumer on `PGDATABASE` + peer auth now gets its `core_upgrades` row written automatically — the channel delegates the insert to `psql -d "$PGDATABASE"`, exactly like `migrate.sh`, so no manual `psql INSERT` is needed. (`DATABASE_URL` still works, via the `pg` driver.) Pre-1984 cores only auto-wrote with `DATABASE_URL`; on those, record the row manually.
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.316",
|
|
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.316",
|
|
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.316",
|
|
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/scripts/gds/fitness.js
CHANGED
|
@@ -1655,7 +1655,6 @@ function checkWriteRoutesValidated() {
|
|
|
1655
1655
|
};
|
|
1656
1656
|
}
|
|
1657
1657
|
|
|
1658
|
-
// ---- runner ----------------------------------------------------------------
|
|
1659
1658
|
|
|
1660
1659
|
// ── Check 19 — the ADR 0174 vocabulary wall (goal 1000068 R07, task 1003008) ──
|
|
1661
1660
|
//
|
|
@@ -1899,6 +1898,7 @@ const CHECKS = [
|
|
|
1899
1898
|
checkWriteRoutesValidated, // Check 18 — BV1 R12 / task 1999: every body-reading write route validates (ADR 0118)
|
|
1900
1899
|
require('./fitness-ratchets.js').checkQualityRatchets, // Check 20 — task 1003129: quality budgets only tighten (mechanics + knip CI step live in fitness-ratchets.js)
|
|
1901
1900
|
require('./doc-cli-guard.js').checkDocNamesRealCli, // Check 22 — task 1003165 / G01: a doc that names a CLI invocation must match the real CLI
|
|
1901
|
+
require('./skill-preflight.js').checkOpsSkillsDeclareMachine, // Check 23 — task 1003167 / G03: an ops skill declares the machine it needs
|
|
1902
1902
|
require('./control-manifest.js').checkControlsExist, // Check 21 — task 1003131: goal-1000076 controls assert their own existence (the ADR 0022 assurance-of-assurance)
|
|
1903
1903
|
];
|
|
1904
1904
|
|
package/scripts/gds/npmrc.js
CHANGED
|
@@ -48,6 +48,37 @@ function hasNpmToken(env = process.env) {
|
|
|
48
48
|
return !!String(env[TOKEN_ENV] || '').trim();
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
+
// resolveNpmToken (task 1002754) — the install-time credential resolver that removes
|
|
52
|
+
// the hand-exported-NPM_TOKEN step from deploys.
|
|
53
|
+
//
|
|
54
|
+
// THE TRAP. The committed instance `.npmrc` reads `_authToken=${NPM_TOKEN}`; npm gives
|
|
55
|
+
// a per-project key precedence over the user-level one, so with NPM_TOKEN unset the
|
|
56
|
+
// expansion is EMPTY and it MASKS a perfectly valid literal token in ~/.npmrc — npm
|
|
57
|
+
// goes anonymous and the private core 404s. Every deploy then silently depended on the
|
|
58
|
+
// operator remembering `export NPM_TOKEN=…` (the ADR-0162 shape: a human-gated step in
|
|
59
|
+
// the ship path). npm's config syntax cannot express "env var, else fall through", so
|
|
60
|
+
// the fix is to RESOLVE the credential here and feed it to the child install's env —
|
|
61
|
+
// the committed file stays secret-free and unchanged.
|
|
62
|
+
//
|
|
63
|
+
// Order: env NPM_TOKEN wins (explicit operator intent), else the user-level ~/.npmrc's
|
|
64
|
+
// literal `//<host>/:_authToken=<value>` (ignoring env-fed `${…}` values — those are
|
|
65
|
+
// the trap, not a credential). Returns { token, source: 'env'|'user-npmrc'|null }.
|
|
66
|
+
function resolveNpmToken({ env = process.env, homedir, fsImpl = fs, host = REGISTRY_HOST } = {}) {
|
|
67
|
+
const fromEnv = String(env[TOKEN_ENV] || '').trim();
|
|
68
|
+
if (fromEnv) return { token: fromEnv, source: 'env' };
|
|
69
|
+
const home = homedir || (env.HOME || env.USERPROFILE || '');
|
|
70
|
+
if (home) {
|
|
71
|
+
try {
|
|
72
|
+
const raw = fsImpl.readFileSync(path.join(home, '.npmrc'), 'utf8');
|
|
73
|
+
const re = new RegExp(`^\\s*//${host.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/:_authToken\\s*=\\s*(.+?)\\s*$`, 'm');
|
|
74
|
+
const m = re.exec(raw);
|
|
75
|
+
const val = m && m[1].replace(/^["']|["']$/g, '');
|
|
76
|
+
if (val && !val.includes('${')) return { token: val, source: 'user-npmrc' };
|
|
77
|
+
} catch { /* no user npmrc, unreadable — fall through to null */ }
|
|
78
|
+
}
|
|
79
|
+
return { token: null, source: null };
|
|
80
|
+
}
|
|
81
|
+
|
|
51
82
|
// Has the operator opted the standalone scaffold into the registry path? OFF by
|
|
52
83
|
// default — the vendored tarball stays the default until the registry path is proven
|
|
53
84
|
// end-to-end (ADR 0134). '1'/'true'/'yes'/'on' (case-insensitive) → true.
|
|
@@ -76,7 +107,7 @@ function writeNpmrc(dir, { fsImpl = fs, dryRun = false, overwrite = true, log =
|
|
|
76
107
|
|
|
77
108
|
module.exports = {
|
|
78
109
|
REGISTRY_HOST, CORE_SCOPE, TOKEN_ENV, REQUEST_ENV,
|
|
79
|
-
npmrcContent, hasNpmToken, registryCoreRequested, writeNpmrc,
|
|
110
|
+
npmrcContent, hasNpmToken, resolveNpmToken, registryCoreRequested, writeNpmrc,
|
|
80
111
|
};
|
|
81
112
|
|
|
82
113
|
// Tiny CLI: `node scripts/gds/npmrc.js [--print | --write <dir>]` — print the `.npmrc`
|
package/scripts/gds/preflight.js
CHANGED
|
@@ -40,11 +40,34 @@ const HEADLESS_AUTH_ENV = ['CLAUDE_CODE_OAUTH_TOKEN', 'ANTHROPIC_API_KEY'];
|
|
|
40
40
|
// caller — /builder-start runs on every session open — can reuse THIS exact
|
|
41
41
|
// finding without paying for the `claude --version` spawn the full
|
|
42
42
|
// preflightChecks() does. Pure: injectable fs + repoRoot.
|
|
43
|
+
//
|
|
44
|
+
// WORKTREES (task 1003167 / idea 1000788): this used to test `repoRoot/node_modules`
|
|
45
|
+
// ONLY, and a git worktree never has its own. But node's module resolution walks UP
|
|
46
|
+
// from the requiring file, so every require in a worktree resolves against the real
|
|
47
|
+
// repo root and works fine — meaning the check fired on essentially every claim
|
|
48
|
+
// (worktree-per-claim is the project default), telling the builder to burn an
|
|
49
|
+
// `npm ci` they did not need. A preflight that cries wolf is worse than none: it
|
|
50
|
+
// trains people to read past the one time it is right. So resolve the way node
|
|
51
|
+
// itself does — walk up — and only then call it missing.
|
|
43
52
|
function depsFinding({ repoRoot = REPO_ROOT, fs = nodeFs } = {}) {
|
|
44
53
|
let hasDeps = false;
|
|
45
|
-
|
|
54
|
+
let foundAt = null;
|
|
55
|
+
try {
|
|
56
|
+
let dir = nodePath.resolve(repoRoot);
|
|
57
|
+
for (;;) {
|
|
58
|
+
if (fs.existsSync(nodePath.join(dir, 'node_modules'))) { hasDeps = true; foundAt = dir; break; }
|
|
59
|
+
const up = nodePath.dirname(dir);
|
|
60
|
+
if (up === dir) break; // hit the filesystem root
|
|
61
|
+
dir = up;
|
|
62
|
+
}
|
|
63
|
+
} catch (_) { /* treat as missing */ }
|
|
46
64
|
if (hasDeps) {
|
|
47
|
-
|
|
65
|
+
const here = nodePath.resolve(repoRoot) === foundAt;
|
|
66
|
+
return {
|
|
67
|
+
level: 'ok',
|
|
68
|
+
title: 'Dependencies',
|
|
69
|
+
detail: here ? 'node_modules present.' : `node_modules resolved from a parent (${foundAt}) — normal in a git worktree; requires resolve fine.`,
|
|
70
|
+
};
|
|
48
71
|
}
|
|
49
72
|
return {
|
|
50
73
|
level: 'warn',
|
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/gds/skill-preflight.js — an ops skill declares the machine it needs,
|
|
3
|
+
// and refuses early when this machine is not it (task 1003167 / G03, goal
|
|
4
|
+
// 1000077; criterion guards-skill-declares-machine).
|
|
5
|
+
//
|
|
6
|
+
// THE INCIDENT. /merge-mode states in its own prose that it assumes the Archon's
|
|
7
|
+
// Mac or the droplet. It was run on a dev box anyway. Worse, ship.js ALREADY knew:
|
|
8
|
+
// strandNextStep() tells a box "/merge-mode is NOT available here — it needs gh +
|
|
9
|
+
// a main checkout this machine doesn't have." So the knowledge existed in two
|
|
10
|
+
// places and neither stopped the run, because prose cannot refuse and the one
|
|
11
|
+
// executable copy was on a different code path. A capability a procedure needs
|
|
12
|
+
// must be a declaration the machine can CHECK, not a sentence a reader can skim.
|
|
13
|
+
//
|
|
14
|
+
// THREE PARTS, matching that diagnosis:
|
|
15
|
+
// 1. `requires:` in the skill's own YAML frontmatter — the declaration.
|
|
16
|
+
// 2. this preflight — the check, naming the MISSING capability and nothing else.
|
|
17
|
+
// 3. checkOpsSkillsDeclareMachine() in fitness.js — the assertion that a skill
|
|
18
|
+
// whose fenced commands touch main/deploy actually carries a declaration.
|
|
19
|
+
//
|
|
20
|
+
// TRI-STATE, deliberately. A probe returns true / false / null, and null means
|
|
21
|
+
// "could not tell" (the context-pack isMainCheckout precedent). We REFUSE on
|
|
22
|
+
// false and WARN on null: refusing on unknown would block legitimate machines,
|
|
23
|
+
// and passing on unknown silently would rebuild the hole this closes.
|
|
24
|
+
//
|
|
25
|
+
// The preflight NEVER connects to anything. Proving an SSH key reaches a host
|
|
26
|
+
// means using it, and a preflight with side effects is not a preflight.
|
|
27
|
+
|
|
28
|
+
'use strict';
|
|
29
|
+
|
|
30
|
+
const fs = require('fs');
|
|
31
|
+
const path = require('path');
|
|
32
|
+
const { spawnSync } = require('node:child_process');
|
|
33
|
+
const { resolveWorktreeRoot } = require('./context-pack.js');
|
|
34
|
+
const { depsFinding } = require('./preflight.js');
|
|
35
|
+
|
|
36
|
+
const REPO_ROOT = path.resolve(__dirname, '..', '..');
|
|
37
|
+
const runGit = (args) => spawnSync('git', args, { cwd: process.cwd(), encoding: 'utf8' }).stdout || '';
|
|
38
|
+
|
|
39
|
+
// Memoized for the life of the process. merge-mode declares BOTH `gh` and
|
|
40
|
+
// `push-credential`, and push-credential's probe also consults gh — so without
|
|
41
|
+
// this, one preflight spawned `gh auth status` twice, each with a 15s timeout.
|
|
42
|
+
// A preflight slow enough to be worth skipping is a preflight nobody runs, which
|
|
43
|
+
// would defeat the entire point of this task.
|
|
44
|
+
let _ghAuthed = null;
|
|
45
|
+
function ghAuthed({ fresh = false } = {}) {
|
|
46
|
+
if (!fresh && _ghAuthed !== null) return _ghAuthed;
|
|
47
|
+
_ghAuthed = ghAuthedUncached();
|
|
48
|
+
return _ghAuthed;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function ghAuthedUncached() {
|
|
52
|
+
try {
|
|
53
|
+
const a = spawnSync('gh', ['auth', 'status'], {
|
|
54
|
+
stdio: 'ignore', shell: process.platform === 'win32', windowsHide: true, timeout: 15000,
|
|
55
|
+
});
|
|
56
|
+
return !!a && !a.error && a.status === 0;
|
|
57
|
+
} catch { return false; }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function binaryPresent(name) {
|
|
61
|
+
try {
|
|
62
|
+
const probe = process.platform === 'win32' ? ['where', [name]] : ['which', [name]];
|
|
63
|
+
const r = spawnSync(probe[0], probe[1], { stdio: 'ignore', shell: process.platform === 'win32', timeout: 10000 });
|
|
64
|
+
return !!r && !r.error && r.status === 0;
|
|
65
|
+
} catch { return false; }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// The registry. `probe` returns true | false | null. `missing` is the sentence a
|
|
69
|
+
// builder reads when it comes back false — it must name the remedy, because the
|
|
70
|
+
// whole failure mode here was a reader who knew the rule and still had nowhere
|
|
71
|
+
// to go with it.
|
|
72
|
+
const CAPABILITIES = {
|
|
73
|
+
gh: {
|
|
74
|
+
what: 'an authenticated GitHub CLI',
|
|
75
|
+
probe: () => ghAuthed(),
|
|
76
|
+
missing: 'no authenticated `gh` on this machine. Run `gh auth login`, or use a machine that has it — '
|
|
77
|
+
+ 'a dev box normally does not, by design (it publishes through the server instead, ADR 0055).',
|
|
78
|
+
},
|
|
79
|
+
'main-checkout': {
|
|
80
|
+
what: 'the main checkout, not a linked worktree',
|
|
81
|
+
// Tri-state on purpose: resolveWorktreeRoot cannot always tell.
|
|
82
|
+
probe: () => resolveWorktreeRoot({ runGit, cwd: process.cwd() }).isMainCheckout,
|
|
83
|
+
missing: 'this is a linked worktree, not the main checkout. Procedures that mutate `main` must run in the '
|
|
84
|
+
+ 'main checkout — a worktree holding the branch has nowhere to merge INTO (task 1002572).',
|
|
85
|
+
},
|
|
86
|
+
'push-credential': {
|
|
87
|
+
// Mirrors ship.js pushVia(): the project's operative definition of "can push"
|
|
88
|
+
// is an authed gh (or an explicit token), and everything else routes through
|
|
89
|
+
// the server. Following that rather than inventing a stricter rule keeps one
|
|
90
|
+
// answer to the question instead of two that can disagree.
|
|
91
|
+
what: 'a credential that can push to origin',
|
|
92
|
+
probe: () => ghAuthed() || !!(process.env.GITHUB_TOKEN || process.env.GH_TOKEN),
|
|
93
|
+
missing: 'no push credential here. This machine lands work through the server-mediated publish '
|
|
94
|
+
+ '(`node scripts/gds/ship.js <id>` resumes it) — it cannot push `main` directly.',
|
|
95
|
+
},
|
|
96
|
+
'droplet-ssh': {
|
|
97
|
+
what: 'SSH access to the deploy host',
|
|
98
|
+
// false when there is no ssh at all; null when there is, because proving the
|
|
99
|
+
// key and host actually work would mean connecting.
|
|
100
|
+
probe: () => (binaryPresent('ssh') ? null : false),
|
|
101
|
+
missing: 'no `ssh` binary on this machine, so a step that reaches the deploy host cannot run. '
|
|
102
|
+
+ 'In `ci` deploy mode nothing should need it — check config/deploy.json before proceeding.',
|
|
103
|
+
},
|
|
104
|
+
tty: {
|
|
105
|
+
what: 'an interactive terminal',
|
|
106
|
+
probe: () => !!(process.stdin.isTTY && process.stdout.isTTY),
|
|
107
|
+
missing: 'no TTY — this procedure prompts, and a prompt with nobody to answer it hangs. '
|
|
108
|
+
+ 'Use the non-interactive form (a --*-file flag or an explicit flag) instead.',
|
|
109
|
+
},
|
|
110
|
+
'node-modules': {
|
|
111
|
+
what: 'resolvable dependencies',
|
|
112
|
+
// DELEGATES to preflight.js rather than testing repoRoot/node_modules itself.
|
|
113
|
+
// Writing that test here is exactly the bug idea 1000788 reported against
|
|
114
|
+
// depsFinding: a worktree has no node_modules of its own, yet every require
|
|
115
|
+
// resolves fine because node walks up. Two copies of a probe are two chances
|
|
116
|
+
// to disagree, and this task exists because knowledge lived in two places.
|
|
117
|
+
probe: () => depsFinding().level === 'ok',
|
|
118
|
+
missing: 'dependencies are not resolvable from this tree — a step needing pg/express/pino will fail. '
|
|
119
|
+
+ 'Run `npm ci` in the repo root.',
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
// ---- the declaration ------------------------------------------------------
|
|
124
|
+
// Read `requires:` out of a skill's YAML frontmatter. Accepts the inline form
|
|
125
|
+
// (`requires: [gh, main-checkout]`) and the dash-list form. Returns
|
|
126
|
+
// { requires, declared, error }: `declared` distinguishes "declared nothing"
|
|
127
|
+
// from "declared an empty list", which the fitness check needs to tell apart.
|
|
128
|
+
function parseRequires(md) {
|
|
129
|
+
const m = /^---\n([\s\S]*?)\n---/.exec(String(md || ''));
|
|
130
|
+
if (!m) return { requires: [], declared: false, error: null };
|
|
131
|
+
const fm = m[1];
|
|
132
|
+
const inline = /^requires:[ \t]*\[(.*?)\][ \t]*$/m.exec(fm);
|
|
133
|
+
if (inline) {
|
|
134
|
+
const items = inline[1].split(',').map((s) => s.trim().replace(/^['"]|['"]$/g, '')).filter(Boolean);
|
|
135
|
+
return { requires: items, declared: true, error: null };
|
|
136
|
+
}
|
|
137
|
+
const block = /^requires:[ \t]*\n((?:[ \t]*-[ \t]*\S.*\n?)+)/m.exec(fm);
|
|
138
|
+
if (block) {
|
|
139
|
+
const items = block[1].split('\n')
|
|
140
|
+
.map((l) => /^[ \t]*-[ \t]*(.+?)[ \t]*$/.exec(l))
|
|
141
|
+
.filter(Boolean).map((x) => x[1].replace(/^['"]|['"]$/g, ''));
|
|
142
|
+
return { requires: items, declared: true, error: null };
|
|
143
|
+
}
|
|
144
|
+
if (/^requires:/m.test(fm)) {
|
|
145
|
+
// Present but unparseable — say so rather than reporting zero requirements,
|
|
146
|
+
// which would read as "declared, needs nothing" (the docs-entropy lesson: a
|
|
147
|
+
// scan that finds nothing must not be indistinguishable from a clean pass).
|
|
148
|
+
return { requires: [], declared: true, error: 'requires: is present but not a list ([a, b] or a dash list)' };
|
|
149
|
+
}
|
|
150
|
+
return { requires: [], declared: false, error: null };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---- what a skill's fenced commands imply it needs -------------------------
|
|
154
|
+
// FENCED BLOCKS ONLY, and comments stripped. Prose that NAMES a command is
|
|
155
|
+
// discussion — dev/SKILL.md forbids `git push origin main`, and an earlier draft
|
|
156
|
+
// of this scan flagged it for saying so. A fenced line is an instruction to run.
|
|
157
|
+
const SIGNALS = [
|
|
158
|
+
[/(?:^|[\s|(])node\s+scripts\/gds\/push-main\.js\b/m, 'push-credential'],
|
|
159
|
+
[/(?:^|[\s|(])git\s+push\s+origin\s+main\b/m, 'push-credential'],
|
|
160
|
+
[/(?:^|[\s|(])node\s+scripts\/gds\/go-live\.js\b/m, 'push-credential'],
|
|
161
|
+
[/(?:^|[\s|(])ssh\s+(?:-o\s+\S+\s+)?\S+@/m, 'droplet-ssh'],
|
|
162
|
+
[/~\/deploy\.sh/m, 'droplet-ssh'],
|
|
163
|
+
[/(?:^|[\s|(])node\s+scripts\/gds\/merge-lock\.js\b/m, 'main-checkout'],
|
|
164
|
+
[/(?:^|[\s|(])bash\s+scripts\/gds\/smoke-write\.sh\b/m, 'main-checkout'],
|
|
165
|
+
[/(?:^|[\s|(])gh\s+(?:auth|pr|secret|api|run|repo|workflow)\b/m, 'gh'],
|
|
166
|
+
];
|
|
167
|
+
|
|
168
|
+
function fencedCode(md) {
|
|
169
|
+
const fences = String(md || '').match(/```[a-z]*\n[\s\S]*?```/g) || [];
|
|
170
|
+
return fences
|
|
171
|
+
.map((f) => f.replace(/^```[a-z]*\n/, '').replace(/```$/, ''))
|
|
172
|
+
.join('\n')
|
|
173
|
+
.split('\n')
|
|
174
|
+
.map((l) => l.replace(/(^|\s)#.*$/, '$1')) // a shell comment is discussion
|
|
175
|
+
.join('\n');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function inferRequired(md) {
|
|
179
|
+
const code = fencedCode(md);
|
|
180
|
+
const out = new Set();
|
|
181
|
+
for (const [rx, cap] of SIGNALS) if (rx.test(code)) out.add(cap);
|
|
182
|
+
return [...out].sort();
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ---- the check ------------------------------------------------------------
|
|
186
|
+
// Returns { ok, missing, unknown, checked }. `missing` is what came back FALSE —
|
|
187
|
+
// the only thing the CLI prints, because the task's own wording is that the
|
|
188
|
+
// preflight must name the MISSING capability, not recite the list.
|
|
189
|
+
function preflight(requires, caps = CAPABILITIES) {
|
|
190
|
+
const missing = [];
|
|
191
|
+
const unknown = [];
|
|
192
|
+
const checked = [];
|
|
193
|
+
for (const name of requires) {
|
|
194
|
+
const cap = caps[name];
|
|
195
|
+
if (!cap) {
|
|
196
|
+
missing.push({ name, why: `unknown capability "${name}" — not in the registry (typo in the skill's requires:?)` });
|
|
197
|
+
continue;
|
|
198
|
+
}
|
|
199
|
+
let verdict;
|
|
200
|
+
try { verdict = cap.probe(); } catch (err) { verdict = null; }
|
|
201
|
+
checked.push(name);
|
|
202
|
+
if (verdict === false) missing.push({ name, why: cap.missing });
|
|
203
|
+
else if (verdict === null) unknown.push({ name, why: `could not confirm ${cap.what} — proceeding, but verify before a destructive step.` });
|
|
204
|
+
}
|
|
205
|
+
return { ok: missing.length === 0, missing, unknown, checked };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// A skill name is one path SEGMENT. Containment is checked rather than assumed:
|
|
209
|
+
// `skill-preflight.js ../../../etc` would otherwise read outside .claude/skills.
|
|
210
|
+
// Harmless today (a local, read-only CLI the caller could equally well `cat`), but
|
|
211
|
+
// this helper is exactly the kind that gets reused somewhere less trusting.
|
|
212
|
+
// Returns null for anything that is not a plain segment.
|
|
213
|
+
function skillPath(name) {
|
|
214
|
+
const n = String(name || '');
|
|
215
|
+
if (!n || n !== path.basename(n) || n === '.' || n === '..') return null;
|
|
216
|
+
const full = path.join(REPO_ROOT, '.claude', 'skills', n, 'SKILL.md');
|
|
217
|
+
const base = path.join(REPO_ROOT, '.claude', 'skills') + path.sep;
|
|
218
|
+
return full.startsWith(base) ? full : null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function preflightSkill(name, caps = CAPABILITIES) {
|
|
222
|
+
const p = skillPath(name);
|
|
223
|
+
if (!p) {
|
|
224
|
+
return { ok: false, requires: [], missing: [{ name: 'skill', why: `"${name}" is not a skill name — it must be a single directory name under .claude/skills/.` }], unknown: [], checked: [] };
|
|
225
|
+
}
|
|
226
|
+
let md;
|
|
227
|
+
try { md = fs.readFileSync(p, 'utf8'); } catch {
|
|
228
|
+
return { ok: false, missing: [{ name: 'skill', why: `no skill named "${name}" at .claude/skills/${name}/SKILL.md` }], unknown: [], checked: [] };
|
|
229
|
+
}
|
|
230
|
+
const { requires, error } = parseRequires(md);
|
|
231
|
+
if (error) return { ok: false, missing: [{ name: 'requires', why: `${name}: ${error}` }], unknown: [], checked: [] };
|
|
232
|
+
return { ...preflight(requires, caps), requires };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ---- the assertion (fitness check 23) --------------------------------------
|
|
236
|
+
// A skill whose FENCED commands need a capability must DECLARE it. This is the
|
|
237
|
+
// part that stops the contract rotting: without it, a new ops step gets added to
|
|
238
|
+
// a skill years from now and the declaration silently falls behind the commands.
|
|
239
|
+
//
|
|
240
|
+
// It compares inferRequired() (what the fenced commands imply) against
|
|
241
|
+
// parseRequires() (what the frontmatter claims) and reds the build on a gap. It
|
|
242
|
+
// does NOT complain about a skill declaring MORE than its fences imply — a human
|
|
243
|
+
// knows things the scan cannot see, and punishing an over-declaration would train
|
|
244
|
+
// people to declare less.
|
|
245
|
+
const SKILLS_DIR = path.join(REPO_ROOT, '.claude', 'skills');
|
|
246
|
+
|
|
247
|
+
function checkOpsSkillsDeclareMachine({ dir = SKILLS_DIR, read = null, names: injected = null } = {}) {
|
|
248
|
+
const violations = [];
|
|
249
|
+
const warnings = [];
|
|
250
|
+
let names = injected;
|
|
251
|
+
if (!names) {
|
|
252
|
+
try {
|
|
253
|
+
names = fs.readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name).sort();
|
|
254
|
+
} catch { names = []; }
|
|
255
|
+
}
|
|
256
|
+
// A scan that enumerates nothing must fail, not pass (the docs-entropy lesson).
|
|
257
|
+
if (!names.length) {
|
|
258
|
+
return {
|
|
259
|
+
name: 'ops skills declare the machine they need (G03)',
|
|
260
|
+
ok: false, hardFail: true,
|
|
261
|
+
violations: ['scan defect — enumerated 0 skills under .claude/skills/.'],
|
|
262
|
+
warnings: [], note: 'static scan of skill frontmatter vs the commands each skill actually fences.',
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
const readOne = read || ((n) => {
|
|
266
|
+
try { return fs.readFileSync(path.join(dir, n, 'SKILL.md'), 'utf8'); } catch { return null; }
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
let declaring = 0;
|
|
270
|
+
for (const n of names) {
|
|
271
|
+
const md = readOne(n);
|
|
272
|
+
if (md === null) continue;
|
|
273
|
+
const { requires, error } = parseRequires(md);
|
|
274
|
+
if (error) { violations.push(`${n}: ${error}`); continue; }
|
|
275
|
+
if (requires.length) declaring += 1;
|
|
276
|
+
for (const r of requires) {
|
|
277
|
+
if (!CAPABILITIES[r]) {
|
|
278
|
+
violations.push(`${n} declares an unknown capability "${r}" — it can never be probed, so the declaration is decorative. Registry: ${Object.keys(CAPABILITIES).join(', ')}.`);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
// DECLARING is not ENFORCING. planning-session shipped a `requires:` with no
|
|
282
|
+
// preflight call, which is the same shape as the original bug: a contract
|
|
283
|
+
// stated where nothing checks it. If a skill names a machine, it must also
|
|
284
|
+
// run the check (the grader caught this one on the first pass).
|
|
285
|
+
if (requires.length && !/skill-preflight\.js\s+\S/.test(md)) {
|
|
286
|
+
violations.push(
|
|
287
|
+
`${n} declares requires: [${requires.join(', ')}] but never runs \`node scripts/gds/skill-preflight.js ${n}\`. `
|
|
288
|
+
+ 'A declaration nothing enforces is prose, and prose cannot refuse — the exact failure this check exists for.'
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
const inferred = inferRequired(md);
|
|
292
|
+
const undeclared = inferred.filter((c) => !requires.includes(c));
|
|
293
|
+
if (undeclared.length) {
|
|
294
|
+
violations.push(
|
|
295
|
+
`${n} fences commands needing [${undeclared.join(', ')}] but does not declare them. `
|
|
296
|
+
+ 'Add them to `requires:` in the frontmatter so the preflight can refuse early — '
|
|
297
|
+
+ 'prose cannot refuse, which is how /merge-mode came to be run on a dev box (task 1003167).'
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return {
|
|
302
|
+
name: 'ops skills declare the machine they need (G03)',
|
|
303
|
+
ok: violations.length === 0,
|
|
304
|
+
hardFail: violations.length > 0,
|
|
305
|
+
violations,
|
|
306
|
+
warnings,
|
|
307
|
+
note: `${names.length} skill(s) scanned; ${declaring} declare a machine contract. A skill whose FENCED commands need gh / a main checkout / a push credential / the deploy host must name it in \`requires:\`, so \`node scripts/gds/skill-preflight.js <skill>\` can refuse before the procedure starts. Prose in a skill body cannot refuse.`,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function main() {
|
|
312
|
+
const name = process.argv[2];
|
|
313
|
+
if (!name || name.startsWith('--')) {
|
|
314
|
+
console.error('usage: node scripts/gds/skill-preflight.js <skill-name>');
|
|
315
|
+
console.error(' exits 1 naming the capability this machine is MISSING for that skill.');
|
|
316
|
+
process.exit(2);
|
|
317
|
+
}
|
|
318
|
+
const r = preflightSkill(name);
|
|
319
|
+
for (const u of r.unknown) console.error(`? ${u.name}: ${u.why}`);
|
|
320
|
+
if (r.ok) {
|
|
321
|
+
console.log(`preflight OK — ${name}: ${r.checked.length ? r.checked.join(', ') : 'no machine requirements declared'}.`);
|
|
322
|
+
process.exit(0);
|
|
323
|
+
}
|
|
324
|
+
console.error(`STOP — /${name} cannot run on this machine.`);
|
|
325
|
+
for (const m of r.missing) console.error(`✗ ${m.name}: ${m.why}`);
|
|
326
|
+
process.exit(1);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (require.main === module) main();
|
|
330
|
+
|
|
331
|
+
module.exports = {
|
|
332
|
+
CAPABILITIES, SIGNALS,
|
|
333
|
+
parseRequires, inferRequired, fencedCode,
|
|
334
|
+
preflight, preflightSkill, skillPath, checkOpsSkillsDeclareMachine,
|
|
335
|
+
ghAuthed, binaryPresent,
|
|
336
|
+
};
|
package/scripts/gds/upgrade.js
CHANGED
|
@@ -30,7 +30,7 @@ const fs = require('node:fs');
|
|
|
30
30
|
const path = require('node:path');
|
|
31
31
|
const { spawnSync } = require('node:child_process');
|
|
32
32
|
const { arg, hasFlag } = require('./cli-lib');
|
|
33
|
-
const { writeNpmrc, hasNpmToken } = require('./npmrc'); // ADR 0134 (task 2090): the --registry channel's env-fed .npmrc
|
|
33
|
+
const { writeNpmrc, hasNpmToken, resolveNpmToken } = require('./npmrc'); // ADR 0134 (task 2090): the --registry channel's env-fed .npmrc; resolveNpmToken task 1002754
|
|
34
34
|
|
|
35
35
|
const CORE_PKG = '@bongos/core';
|
|
36
36
|
const ARTIFACT = 'bongos-core';
|
|
@@ -284,10 +284,14 @@ function reportModulePreflight(mods, targetVersion, { log = console.log, err = c
|
|
|
284
284
|
return lines;
|
|
285
285
|
}
|
|
286
286
|
|
|
287
|
-
function npmInstall(instanceDir, run = spawnSync) {
|
|
287
|
+
function npmInstall(instanceDir, run = spawnSync, extraEnv = null) {
|
|
288
288
|
// `npm install` (NOT `npm ci`): the pin just changed, so the lockfile must be regenerated from the
|
|
289
289
|
// new tarball; `npm ci` would refuse on the package.json↔lock divergence.
|
|
290
|
-
|
|
290
|
+
// extraEnv (task 1002754): the registry path injects a resolved NPM_TOKEN here so the
|
|
291
|
+
// instance `.npmrc`'s `${NPM_TOKEN}` expansion never shadows a valid user-level token.
|
|
292
|
+
const opts = { cwd: instanceDir, stdio: 'inherit' };
|
|
293
|
+
if (extraEnv) opts.env = { ...process.env, ...extraEnv };
|
|
294
|
+
const r = run('npm', ['install', '--no-audit', '--no-fund'], opts);
|
|
291
295
|
return !r || r.error ? { ok: false, reason: r && r.error ? r.error.message : 'spawn failed' } : { ok: r.status === 0, code: r.status };
|
|
292
296
|
}
|
|
293
297
|
|
|
@@ -703,8 +707,11 @@ async function rollback({ instanceDir, snapshot, fromVersion, toVersion, opts },
|
|
|
703
707
|
try { restore(instanceDir, snapshot.prevPin, fsImpl); result.restoreOk = true; log(` ✓ restored pin → ${snapshot.prevPin}`); }
|
|
704
708
|
catch (e) { result.restoreOk = false; err(` ! could not restore pin (${e.message}) — package.json may need manual repair`); }
|
|
705
709
|
|
|
706
|
-
// 2. reinstall the previous core.
|
|
707
|
-
|
|
710
|
+
// 2. reinstall the previous core. Same credential resolution as the forward path
|
|
711
|
+
// (task 1002754): the previous pin may be a registry pin too, and a rollback
|
|
712
|
+
// must never fail on the env-shadowing trap the forward install just avoided.
|
|
713
|
+
const rbCred = resolveNpmToken();
|
|
714
|
+
const inst = npmInstall(instanceDir, run, rbCred.source === 'user-npmrc' ? { NPM_TOKEN: rbCred.token } : null);
|
|
708
715
|
result.reinstallOk = !!inst.ok;
|
|
709
716
|
if (inst.ok) log(' ✓ reinstalled previous core');
|
|
710
717
|
else err(` ! reinstall of previous core failed (${inst.reason || 'exit ' + inst.code}) — the instance may need manual repair`);
|
|
@@ -813,9 +820,17 @@ async function runUpgrade(opts, deps = {}) {
|
|
|
813
820
|
// REGISTRY (--registry): pin @bongos/core@<version> + ensure the env-fed .npmrc, so
|
|
814
821
|
// the `npm install` below pulls the PRIVATE package from the registry (needs NPM_TOKEN).
|
|
815
822
|
// VENDORED (default, --from <tgz>): copy the .tgz into vendor/ + rewrite the file: dep.
|
|
823
|
+
let installEnv = null;
|
|
816
824
|
if (opts.registry) {
|
|
817
825
|
if (opts.from) return { ok: false, error: '--registry and --from are mutually exclusive (registry pull vs vendored tarball) — pick one core source' };
|
|
818
|
-
|
|
826
|
+
// task 1002754 — resolve the credential HERE instead of demanding a hand-exported
|
|
827
|
+
// env var: env NPM_TOKEN wins, else the user-level ~/.npmrc's literal token is
|
|
828
|
+
// injected into the install's env so the instance `.npmrc`'s `${NPM_TOKEN}`
|
|
829
|
+
// expansion (which OUTRANKS the user-level key and expands EMPTY when unset)
|
|
830
|
+
// can never shadow a working credential again.
|
|
831
|
+
const cred = resolveNpmToken();
|
|
832
|
+
if (!cred.token) log(' ! no NPM_TOKEN in env and no literal token in ~/.npmrc — `npm install` will 404 on the PRIVATE @bongos/core until a read-only automation token is provided (ADR 0134).');
|
|
833
|
+
else if (cred.source === 'user-npmrc') { installEnv = { NPM_TOKEN: cred.token }; log(' ✓ registry credential: inherited from the user-level ~/.npmrc (no NPM_TOKEN export needed — task 1002754)'); }
|
|
819
834
|
const prevPin = dryRun ? null : writeRegistryPin(instanceDir, targetVersion, fsImpl);
|
|
820
835
|
if (!dryRun) { writeNpmrc(instanceDir, { fsImpl, overwrite: false, log }); snapshot = { prevPin, prevVersion: fromVersion }; }
|
|
821
836
|
log(` ${dryRun ? '[dry-run] would pin' : '✓ pinned'} ${CORE_PKG} → ${targetVersion} (registry)${dryRun ? ' + write an env-fed .npmrc' : ''}${prevPin ? ` (was ${prevPin})` : ''}`);
|
|
@@ -837,7 +852,7 @@ async function runUpgrade(opts, deps = {}) {
|
|
|
837
852
|
if (dryRun) { log(' [dry-run] would: npm install → npm run migrate → materialize .claude → regen API docs+client → restart → verify'); return { ok: true, dryRun: true, fromVersion, toVersion: targetVersion }; }
|
|
838
853
|
|
|
839
854
|
// 3. npm install.
|
|
840
|
-
const inst = npmInstall(instanceDir, run);
|
|
855
|
+
const inst = npmInstall(instanceDir, run, installEnv);
|
|
841
856
|
if (!inst.ok) {
|
|
842
857
|
const error = `npm install failed (${inst.reason || 'exit ' + inst.code})`;
|
|
843
858
|
if (rollbackOnFailure && snapshot) {
|
package/src/module-api.js
CHANGED
|
@@ -1055,8 +1055,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
1055
1055
|
// landed since 1.19.312 with no explicit bump. run 32668457836. (task 1002620)
|
|
1056
1056
|
// 1.19.314 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1057
1057
|
// landed since 1.19.313 with no explicit bump. run 32668599111. (task 1002620)
|
|
1058
|
+
// 1.19.315 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1059
|
+
// landed since 1.19.314 with no explicit bump. run 32668969037. (task 1002620)
|
|
1060
|
+
// 1.19.316 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1061
|
+
// landed since 1.19.315 with no explicit bump. run 32669347711. (task 1002620)
|
|
1058
1062
|
// ---------------------------------------------------------------------------
|
|
1059
|
-
const CORE_VERSION = '1.19.
|
|
1063
|
+
const CORE_VERSION = '1.19.316'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
1060
1064
|
|
|
1061
1065
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
1062
1066
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
package/tests/npmrc.mjs
CHANGED
|
@@ -100,6 +100,55 @@ test('writeNpmrc: overwrite:true (default) replaces an existing .npmrc', () => {
|
|
|
100
100
|
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
101
101
|
});
|
|
102
102
|
|
|
103
|
+
// ---- resolveNpmToken (task 1002754) ----------------------------------------
|
|
104
|
+
// The install-time credential resolver that removes the hand-exported-NPM_TOKEN
|
|
105
|
+
// deploy step: env wins, else the user-level ~/.npmrc's LITERAL token; env-fed
|
|
106
|
+
// `${…}` values in the user file are the trap, never a credential.
|
|
107
|
+
test('resolveNpmToken: env NPM_TOKEN wins over everything', () => {
|
|
108
|
+
const home = mkdtempSync(join(tmpdir(), 'npmrc-home-'));
|
|
109
|
+
try {
|
|
110
|
+
writeFileSync(join(home, '.npmrc'), '//registry.npmjs.org/:_authToken=npm_userlevel\n');
|
|
111
|
+
const r = n.resolveNpmToken({ env: { NPM_TOKEN: ' npm_fromenv ' }, homedir: home });
|
|
112
|
+
assert.deepEqual(r, { token: 'npm_fromenv', source: 'env' });
|
|
113
|
+
} finally { rmSync(home, { recursive: true, force: true }); }
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('resolveNpmToken: falls back to the user-level ~/.npmrc literal token', () => {
|
|
117
|
+
const home = mkdtempSync(join(tmpdir(), 'npmrc-home-'));
|
|
118
|
+
try {
|
|
119
|
+
writeFileSync(join(home, '.npmrc'),
|
|
120
|
+
'registry=https://registry.npmjs.org/\n//registry.npmjs.org/:_authToken=npm_ABC123\nfund=false\n');
|
|
121
|
+
const r = n.resolveNpmToken({ env: {}, homedir: home });
|
|
122
|
+
assert.deepEqual(r, { token: 'npm_ABC123', source: 'user-npmrc' });
|
|
123
|
+
} finally { rmSync(home, { recursive: true, force: true }); }
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('resolveNpmToken: an env-fed ${…} value in the user file is the trap, not a credential', () => {
|
|
127
|
+
const home = mkdtempSync(join(tmpdir(), 'npmrc-home-'));
|
|
128
|
+
try {
|
|
129
|
+
writeFileSync(join(home, '.npmrc'), '//registry.npmjs.org/:_authToken=${NPM_TOKEN}\n');
|
|
130
|
+
const r = n.resolveNpmToken({ env: {}, homedir: home });
|
|
131
|
+
assert.deepEqual(r, { token: null, source: null });
|
|
132
|
+
} finally { rmSync(home, { recursive: true, force: true }); }
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test('resolveNpmToken: a token for a DIFFERENT host is not borrowed', () => {
|
|
136
|
+
const home = mkdtempSync(join(tmpdir(), 'npmrc-home-'));
|
|
137
|
+
try {
|
|
138
|
+
writeFileSync(join(home, '.npmrc'), '//npm.pkg.github.com/:_authToken=ghp_other\n');
|
|
139
|
+
const r = n.resolveNpmToken({ env: {}, homedir: home });
|
|
140
|
+
assert.deepEqual(r, { token: null, source: null });
|
|
141
|
+
} finally { rmSync(home, { recursive: true, force: true }); }
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('resolveNpmToken: no user npmrc at all → null, never a throw', () => {
|
|
145
|
+
const home = mkdtempSync(join(tmpdir(), 'npmrc-home-'));
|
|
146
|
+
try {
|
|
147
|
+
const r = n.resolveNpmToken({ env: {}, homedir: home });
|
|
148
|
+
assert.deepEqual(r, { token: null, source: null });
|
|
149
|
+
} finally { rmSync(home, { recursive: true, force: true }); }
|
|
150
|
+
});
|
|
151
|
+
|
|
103
152
|
console.log('');
|
|
104
153
|
console.log(`npmrc tests: ${passed} passed, ${failed} failed`);
|
|
105
154
|
process.exit(failed ? 1 : 0);
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
// tests/skill_preflight.mjs — G03, task 1003167, goal 1000077, criterion
|
|
2
|
+
// guards-skill-declares-machine.
|
|
3
|
+
//
|
|
4
|
+
// THE INCIDENT. /merge-mode states in its own prose that it assumes the Archon's
|
|
5
|
+
// Mac or the droplet. It was run on a dev box anyway — and ship.js ALREADY knew,
|
|
6
|
+
// because strandNextStep() tells a box "/merge-mode is NOT available here". The
|
|
7
|
+
// knowledge existed in two places and neither stopped the run: prose cannot
|
|
8
|
+
// refuse, and the one executable copy sat on a different code path.
|
|
9
|
+
//
|
|
10
|
+
// So these tests care about two things: that the preflight actually REFUSES, and
|
|
11
|
+
// that the scan behind the fitness check does not cry wolf. Both false-positive
|
|
12
|
+
// classes below were real defects in my own first drafts.
|
|
13
|
+
import { strict as assert } from 'node:assert';
|
|
14
|
+
import { test } from 'node:test';
|
|
15
|
+
import { createRequire } from 'node:module';
|
|
16
|
+
|
|
17
|
+
const require = createRequire(import.meta.url);
|
|
18
|
+
const sp = require('../scripts/gds/skill-preflight.js');
|
|
19
|
+
|
|
20
|
+
// ---- the declaration ------------------------------------------------------
|
|
21
|
+
test('parseRequires reads the inline form', () => {
|
|
22
|
+
const r = sp.parseRequires('---\nname: x\nrequires: [gh, main-checkout]\n---\nbody');
|
|
23
|
+
assert.deepEqual(r.requires, ['gh', 'main-checkout']);
|
|
24
|
+
assert.equal(r.declared, true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('parseRequires reads the dash-list form', () => {
|
|
28
|
+
const r = sp.parseRequires('---\nname: x\nrequires:\n - gh\n - tty\n---\nbody');
|
|
29
|
+
assert.deepEqual(r.requires, ['gh', 'tty']);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('no requires: is "declared nothing", NOT "declared an empty list"', () => {
|
|
33
|
+
// The fitness check needs to tell those apart.
|
|
34
|
+
const r = sp.parseRequires('---\nname: x\n---\nbody');
|
|
35
|
+
assert.equal(r.declared, false);
|
|
36
|
+
assert.deepEqual(r.requires, []);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('a malformed requires: is an ERROR, not silently zero requirements', () => {
|
|
40
|
+
// Reporting zero would read as "declared, needs nothing" — a scan that finds
|
|
41
|
+
// nothing must never be indistinguishable from a clean pass.
|
|
42
|
+
const r = sp.parseRequires('---\nname: x\nrequires: gh\n---\nbody');
|
|
43
|
+
assert.ok(r.error, 'a scalar requires: must be flagged');
|
|
44
|
+
assert.equal(r.declared, true);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// ---- the scan must not cry wolf -------------------------------------------
|
|
48
|
+
test('PROSE that names a command is not an instruction to run it', () => {
|
|
49
|
+
// dev/SKILL.md FORBIDS `git push origin main`. An earlier draft flagged it for
|
|
50
|
+
// saying so — the scan read code spans and prose alike.
|
|
51
|
+
const md = '---\nname: dev\n---\n- never run raw `git push origin main` or `~/deploy.sh`; that is Bongos\'s job.\n';
|
|
52
|
+
assert.deepEqual(sp.inferRequired(md), [], 'a prohibition must not imply a requirement');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('a FENCED command is an instruction, and is picked up', () => {
|
|
56
|
+
const md = '---\nname: x\n---\n```bash\nnode scripts/gds/push-main.js\n```\n';
|
|
57
|
+
assert.deepEqual(sp.inferRequired(md), ['push-credential']);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('a shell COMMENT inside a fence is discussion, not an instruction', () => {
|
|
61
|
+
const md = '---\nname: x\n---\n```bash\n# node scripts/gds/push-main.js is the sanctioned push\necho hi\n```\n';
|
|
62
|
+
assert.deepEqual(sp.inferRequired(md), []);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('"through" does not match the gh signal', () => {
|
|
66
|
+
// `gh ` is a substring of "throu(gh )". The first draft flagged
|
|
67
|
+
// priority-session for a comment containing the word "through".
|
|
68
|
+
// NOT in a comment: comment-stripping would remove it and the boundary would
|
|
69
|
+
// never be exercised. The first version of this test made exactly that mistake
|
|
70
|
+
// and stayed green when the boundary was deleted.
|
|
71
|
+
const md = '---\nname: x\n---\n```bash\necho "routed through the gateway"\n```\n';
|
|
72
|
+
assert.deepEqual(sp.inferRequired(md), [], 'a word containing "gh " is not the gh CLI');
|
|
73
|
+
const inComment = '---\nname: x\n---\n```bash\n# API through it rather than re-deriving the token\necho ok\n```\n';
|
|
74
|
+
assert.deepEqual(sp.inferRequired(inComment), [], 'and a comment is discussion regardless');
|
|
75
|
+
const real = '---\nname: y\n---\n```bash\ngh pr view 12 --json state\n```\n';
|
|
76
|
+
assert.deepEqual(sp.inferRequired(real), ['gh'], 'the real gh command still matches');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test('ssh and the deploy host both imply droplet-ssh', () => {
|
|
80
|
+
for (const cmd of ['ssh -o ConnectTimeout=10 someone@host "~/deploy.sh"', 'ssh someone@host "psql -d x"']) {
|
|
81
|
+
assert.deepEqual(sp.inferRequired(`---\nname: x\n---\n\`\`\`bash\n${cmd}\n\`\`\`\n`), ['droplet-ssh'], cmd);
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// ---- the refusal ----------------------------------------------------------
|
|
86
|
+
const caps = {
|
|
87
|
+
present: { what: 'a present thing', probe: () => true, missing: 'absent' },
|
|
88
|
+
absent: { what: 'an absent thing', probe: () => false, missing: 'go get it' },
|
|
89
|
+
unknowable: { what: 'an unprovable thing', probe: () => null, missing: 'unreachable' },
|
|
90
|
+
thrower: { what: 'a probe that dies', probe: () => { throw new Error('boom'); }, missing: 'never printed' },
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
test('a FALSE probe refuses, and names the remedy', () => {
|
|
94
|
+
const r = sp.preflight(['present', 'absent'], caps);
|
|
95
|
+
assert.equal(r.ok, false);
|
|
96
|
+
assert.equal(r.missing.length, 1);
|
|
97
|
+
assert.equal(r.missing[0].name, 'absent');
|
|
98
|
+
assert.match(r.missing[0].why, /go get it/);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('the refusal names ONLY what is missing, not the whole list', () => {
|
|
102
|
+
// The task's wording: name the MISSING capability, not the list. A satisfied
|
|
103
|
+
// capability must not appear in `missing`.
|
|
104
|
+
const r = sp.preflight(['present', 'absent'], caps);
|
|
105
|
+
assert.ok(!r.missing.some((m) => m.name === 'present'));
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('a NULL probe warns and does NOT block', () => {
|
|
109
|
+
// Refusing on "could not tell" would block legitimate machines; passing it
|
|
110
|
+
// silently would rebuild the hole. So: warn, proceed.
|
|
111
|
+
const r = sp.preflight(['unknowable'], caps);
|
|
112
|
+
assert.equal(r.ok, true, 'unknown must not refuse');
|
|
113
|
+
assert.equal(r.unknown.length, 1);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('a probe that THROWS degrades to unknown, never to a pass-as-satisfied', () => {
|
|
117
|
+
const r = sp.preflight(['thrower'], caps);
|
|
118
|
+
assert.equal(r.unknown.length, 1);
|
|
119
|
+
assert.equal(r.missing.length, 0);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('an undeclared capability name is caught rather than ignored', () => {
|
|
123
|
+
// A typo in a skill's requires: would otherwise be a silent no-op.
|
|
124
|
+
const r = sp.preflight(['no-such-capability'], caps);
|
|
125
|
+
assert.equal(r.ok, false);
|
|
126
|
+
assert.match(r.missing[0].why, /unknown capability/);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
// ---- the live tree --------------------------------------------------------
|
|
130
|
+
test('every registry capability has a probe and a remedy sentence', () => {
|
|
131
|
+
for (const [name, cap] of Object.entries(sp.CAPABILITIES)) {
|
|
132
|
+
assert.equal(typeof cap.probe, 'function', `${name} needs a probe`);
|
|
133
|
+
assert.ok(cap.what && cap.missing, `${name} needs what/missing text`);
|
|
134
|
+
assert.ok(cap.missing.length > 40, `${name}'s remedy must be actionable, not a label`);
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test('the live tree passes the declare-check', () => {
|
|
139
|
+
const r = sp.checkOpsSkillsDeclareMachine();
|
|
140
|
+
assert.equal(r.hardFail, false, `unexpected:\n ${r.violations.join('\n ')}`);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test('merge-mode really declares the machine it needs', () => {
|
|
144
|
+
// The skill at the centre of the incident.
|
|
145
|
+
const r = sp.preflightSkill('merge-mode', caps);
|
|
146
|
+
assert.ok(r.requires.includes('main-checkout'));
|
|
147
|
+
assert.ok(r.requires.includes('push-credential'));
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('an UNDECLARED fenced ops command is caught', () => {
|
|
151
|
+
// Injected names, so this exercises the real comparison rather than
|
|
152
|
+
// short-circuiting on an empty enumeration — the first version of this test
|
|
153
|
+
// passed a bad dir and therefore proved nothing about the undeclared path.
|
|
154
|
+
const r = sp.checkOpsSkillsDeclareMachine({
|
|
155
|
+
names: ['rogue'],
|
|
156
|
+
read: () => '---\nname: rogue\n---\n```bash\nnode scripts/gds/push-main.js\nssh me@host "~/deploy.sh"\n```\n',
|
|
157
|
+
});
|
|
158
|
+
assert.equal(r.hardFail, true, 'a skill fencing a main-push with no declaration must fail');
|
|
159
|
+
const text = r.violations.join('\n');
|
|
160
|
+
assert.match(text, /push-credential/);
|
|
161
|
+
assert.match(text, /droplet-ssh/);
|
|
162
|
+
assert.match(text, /requires:/, 'the violation must name the remedy');
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('a skill that DOES declare its fenced commands passes', () => {
|
|
166
|
+
const r = sp.checkOpsSkillsDeclareMachine({
|
|
167
|
+
names: ['good'],
|
|
168
|
+
read: () => '---\nname: good\nrequires: [push-credential]\n---\n```bash\nnode scripts/gds/skill-preflight.js good\nnode scripts/gds/push-main.js\n```\n',
|
|
169
|
+
});
|
|
170
|
+
assert.equal(r.hardFail, false, r.violations.join('\n'));
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test('declaring MORE than the fences imply is allowed', () => {
|
|
174
|
+
// A human knows things the scan cannot see; punishing over-declaration would
|
|
175
|
+
// train people to declare less.
|
|
176
|
+
const r = sp.checkOpsSkillsDeclareMachine({
|
|
177
|
+
names: ['careful'],
|
|
178
|
+
read: () => '---\nname: careful\nrequires: [gh, tty, main-checkout]\n---\nRun `node scripts/gds/skill-preflight.js careful` first.\n',
|
|
179
|
+
});
|
|
180
|
+
assert.equal(r.hardFail, false, r.violations.join('\n'));
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('a declared capability that is not in the registry is caught', () => {
|
|
184
|
+
const r = sp.checkOpsSkillsDeclareMachine({
|
|
185
|
+
names: ['typo'],
|
|
186
|
+
read: () => '---\nname: typo\nrequires: [man-checkout]\n---\nbody\n',
|
|
187
|
+
});
|
|
188
|
+
assert.equal(r.hardFail, true);
|
|
189
|
+
assert.match(r.violations.join('\n'), /unknown capability "man-checkout"/);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test('an empty skills dir FAILS rather than reporting all-clear', () => {
|
|
193
|
+
const r = sp.checkOpsSkillsDeclareMachine({ dir: '/definitely/not/here' });
|
|
194
|
+
assert.equal(r.hardFail, true);
|
|
195
|
+
assert.match(r.violations[0], /scan defect/);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
// ---- the three findings from the first grader pass ------------------------
|
|
199
|
+
test('ghAuthed is memoized — one preflight must not spawn `gh` twice', () => {
|
|
200
|
+
// merge-mode declares BOTH gh and push-credential, and push-credential's probe
|
|
201
|
+
// also consults gh. Unmemoized that was two 15s-timeout subprocesses in one
|
|
202
|
+
// run, and a preflight slow enough to skip is one nobody runs.
|
|
203
|
+
const a = sp.ghAuthed();
|
|
204
|
+
const b = sp.ghAuthed();
|
|
205
|
+
assert.equal(a, b, 'repeat calls must agree');
|
|
206
|
+
assert.equal(typeof a, 'boolean');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test('a skill name must be a single path segment', () => {
|
|
210
|
+
// `skill-preflight.js ../../../etc` must not read outside .claude/skills.
|
|
211
|
+
for (const bad of ['../../../etc', '..', '.', 'a/b', '', 'x/../../y']) {
|
|
212
|
+
assert.equal(sp.skillPath(bad), null, `${JSON.stringify(bad)} must be refused`);
|
|
213
|
+
}
|
|
214
|
+
assert.ok(String(sp.skillPath('merge-mode')).endsWith('merge-mode/SKILL.md'.replace('/', require('node:path').sep)));
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('a traversal name is refused with a message, not a crash', () => {
|
|
218
|
+
const r = sp.preflightSkill('../../../etc');
|
|
219
|
+
assert.equal(r.ok, false);
|
|
220
|
+
assert.match(r.missing[0].why, /not a skill name/);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('DECLARING without ENFORCING is caught', () => {
|
|
224
|
+
// The gap the grader found in planning-session: a `requires:` with no
|
|
225
|
+
// preflight call is a contract stated where nothing checks it — the same
|
|
226
|
+
// shape as the original bug.
|
|
227
|
+
const r = sp.checkOpsSkillsDeclareMachine({
|
|
228
|
+
names: ['claims-but-never-checks'],
|
|
229
|
+
read: () => '---\nname: x\nrequires: [gh]\n---\nprose only, no preflight call\n',
|
|
230
|
+
});
|
|
231
|
+
assert.equal(r.hardFail, true);
|
|
232
|
+
assert.match(r.violations.join('\n'), /never runs/);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test('invoking the preflight anywhere satisfies enforcement — top OR at the step', () => {
|
|
236
|
+
// merge-mode gates at Step 0 (capabilities are preconditions for the whole
|
|
237
|
+
// procedure); planning-session gates at the one step that needs ssh. Both are
|
|
238
|
+
// legitimate, so the check must not demand a particular position.
|
|
239
|
+
const md = '---\nname: x\nrequires: [gh]\n---\nlater on:\n```bash\nnode scripts/gds/skill-preflight.js x\ngh pr view 1 --json state\n```\n';
|
|
240
|
+
const r = sp.checkOpsSkillsDeclareMachine({ names: ['x'], read: () => md });
|
|
241
|
+
assert.equal(r.hardFail, false, r.violations.join('\n'));
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
test('both live declaring skills actually invoke the preflight', () => {
|
|
245
|
+
const r = sp.checkOpsSkillsDeclareMachine();
|
|
246
|
+
assert.equal(r.hardFail, false, r.violations.join('\n'));
|
|
247
|
+
for (const name of ['merge-mode', 'planning-session']) {
|
|
248
|
+
const md = require('node:fs').readFileSync(sp.skillPath(name), 'utf8');
|
|
249
|
+
assert.match(md, /skill-preflight\.js\s+\S/, `${name} must run its own preflight`);
|
|
250
|
+
}
|
|
251
|
+
});
|