@bongos/core 1.19.315 → 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 +24 -14
- package/.claude/skills/merge-mode/SKILL.md +16 -0
- package/.claude/skills/planning-session/SKILL.md +10 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/fitness.js +1 -1
- package/scripts/gds/preflight.js +25 -2
- package/scripts/gds/skill-preflight.js +336 -0
- package/src/module-api.js +3 -1
- 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",
|
|
@@ -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",
|
|
@@ -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",
|
|
@@ -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",
|
|
@@ -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:
|
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/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/src/module-api.js
CHANGED
|
@@ -1057,8 +1057,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
1057
1057
|
// landed since 1.19.313 with no explicit bump. run 32668599111. (task 1002620)
|
|
1058
1058
|
// 1.19.315 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1059
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)
|
|
1060
1062
|
// ---------------------------------------------------------------------------
|
|
1061
|
-
const CORE_VERSION = '1.19.
|
|
1063
|
+
const CORE_VERSION = '1.19.316'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
1062
1064
|
|
|
1063
1065
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
1064
1066
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -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
|
+
});
|