@bongos/core 1.19.389 → 1.19.391
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 +36 -16
- package/.claude/skills/builder-claim/SKILL.md +8 -2
- package/docs/file-map.md +2 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/claim.js +37 -4
- package/scripts/gds/worktree-claim-guard.js +104 -0
- package/scripts/hall-preview/README.md +34 -1
- package/scripts/hall-preview/live-identity.js +121 -0
- package/scripts/hall-preview/refresh-fixtures.js +1 -0
- package/scripts/hall-preview/server.js +74 -10
- package/src/module-api.js +5 -1
- package/tests/hall_preview_identity.mjs +163 -0
- package/tests/worktree_claim_guard.mjs +105 -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.391",
|
|
6
|
+
"core_contract": "1.19.391",
|
|
7
|
+
"source_commit": "340dd8cf2dc62375b464f1deb56fa203cb37e47a",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-
|
|
9
|
+
"built_at": "2026-08-28T01:59:37.546Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 339,
|
|
13
13
|
"agent_docs_stubbed": 22,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1624,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 1985,
|
|
20
|
+
"tree_sha256": "5e47c4db4acc79aeebd5d9582aa0d5bc78dfac50ac7fceab7fb64269a6c0f76b",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
{
|
|
43
43
|
"path": ".claude/skills/builder-claim/SKILL.md",
|
|
44
44
|
"mode": "0000644",
|
|
45
|
-
"sha256": "
|
|
45
|
+
"sha256": "248ba892c506cb2ba5787dd9bc11978623d7116e9eb911e79d9c6b33b2d7374e"
|
|
46
46
|
},
|
|
47
47
|
{
|
|
48
48
|
"path": ".claude/skills/builder-connect/SKILL.md",
|
|
@@ -1792,7 +1792,7 @@
|
|
|
1792
1792
|
{
|
|
1793
1793
|
"path": "docs/file-map.md",
|
|
1794
1794
|
"mode": "0000644",
|
|
1795
|
-
"sha256": "
|
|
1795
|
+
"sha256": "4da98da4c1d5bc5e2a1c1949775f362d8867faa5a2a0321ccd954dc0a5b71202"
|
|
1796
1796
|
},
|
|
1797
1797
|
{
|
|
1798
1798
|
"path": "docs/handoff-template.md",
|
|
@@ -5122,12 +5122,12 @@
|
|
|
5122
5122
|
{
|
|
5123
5123
|
"path": "package-lock.json",
|
|
5124
5124
|
"mode": "0000644",
|
|
5125
|
-
"sha256": "
|
|
5125
|
+
"sha256": "fa2614ac06762df93cebbd6d3b31ee9aa8610187156003199b894dedb875587c"
|
|
5126
5126
|
},
|
|
5127
5127
|
{
|
|
5128
5128
|
"path": "package.json",
|
|
5129
5129
|
"mode": "0000644",
|
|
5130
|
-
"sha256": "
|
|
5130
|
+
"sha256": "7ba34a8faeb6cf944e2603900caf3f203dc032a1a1eae04b87b93cf5a28cc293"
|
|
5131
5131
|
},
|
|
5132
5132
|
{
|
|
5133
5133
|
"path": "public-docs/index.html",
|
|
@@ -5707,7 +5707,7 @@
|
|
|
5707
5707
|
{
|
|
5708
5708
|
"path": "scripts/gds/claim.js",
|
|
5709
5709
|
"mode": "0000644",
|
|
5710
|
-
"sha256": "
|
|
5710
|
+
"sha256": "d2710556d771f5dcc7298806c91f3a33c7ad16b9f7738eef7edd14b7868d143b"
|
|
5711
5711
|
},
|
|
5712
5712
|
{
|
|
5713
5713
|
"path": "scripts/gds/classify-discipline.js",
|
|
@@ -6729,6 +6729,11 @@
|
|
|
6729
6729
|
"mode": "0000644",
|
|
6730
6730
|
"sha256": "be4fec619d5a0c76ba05bcc75042c1497f70da21b54a3d646884e2228f90b2ba"
|
|
6731
6731
|
},
|
|
6732
|
+
{
|
|
6733
|
+
"path": "scripts/gds/worktree-claim-guard.js",
|
|
6734
|
+
"mode": "0000644",
|
|
6735
|
+
"sha256": "1ad4dfc90a2c5cd8afe40cd43c6b3d119e9bb7710785e024b4dd8d45e7ab823f"
|
|
6736
|
+
},
|
|
6732
6737
|
{
|
|
6733
6738
|
"path": "scripts/gds/worktree.js",
|
|
6734
6739
|
"mode": "0000644",
|
|
@@ -6737,17 +6742,22 @@
|
|
|
6737
6742
|
{
|
|
6738
6743
|
"path": "scripts/hall-preview/README.md",
|
|
6739
6744
|
"mode": "0000644",
|
|
6740
|
-
"sha256": "
|
|
6745
|
+
"sha256": "880fb5ec7749e6e3092d5869b1f6baa64adbb956933f139eabfaf25347e3b9d8"
|
|
6746
|
+
},
|
|
6747
|
+
{
|
|
6748
|
+
"path": "scripts/hall-preview/live-identity.js",
|
|
6749
|
+
"mode": "0000644",
|
|
6750
|
+
"sha256": "69edca9e3fd584f5e8f29aaf7451611e80c663be32114dc03cce1c5da22a9c5d"
|
|
6741
6751
|
},
|
|
6742
6752
|
{
|
|
6743
6753
|
"path": "scripts/hall-preview/refresh-fixtures.js",
|
|
6744
6754
|
"mode": "0000644",
|
|
6745
|
-
"sha256": "
|
|
6755
|
+
"sha256": "b5819ccfabd209ced41613c6f9109faa2723a49c017c115a27383d907f777f3b"
|
|
6746
6756
|
},
|
|
6747
6757
|
{
|
|
6748
6758
|
"path": "scripts/hall-preview/server.js",
|
|
6749
6759
|
"mode": "0000644",
|
|
6750
|
-
"sha256": "
|
|
6760
|
+
"sha256": "a55d67cab28ca941ad47a016bb5ed66d2fe1f410d0557544ee79ab57adc70006"
|
|
6751
6761
|
},
|
|
6752
6762
|
{
|
|
6753
6763
|
"path": "scripts/migrate.sh",
|
|
@@ -7107,7 +7117,7 @@
|
|
|
7107
7117
|
{
|
|
7108
7118
|
"path": "src/module-api.js",
|
|
7109
7119
|
"mode": "0000644",
|
|
7110
|
-
"sha256": "
|
|
7120
|
+
"sha256": "b5e645e16766a357b39033daec49842b257e6ce4450718d2f4e76667e4c6f304"
|
|
7111
7121
|
},
|
|
7112
7122
|
{
|
|
7113
7123
|
"path": "src/module-loader/catalog.js",
|
|
@@ -8449,6 +8459,11 @@
|
|
|
8449
8459
|
"mode": "0000644",
|
|
8450
8460
|
"sha256": "a53080637382b2313c6ed16d013fec6102a604fe7b8c72260945b5c85cb9303b"
|
|
8451
8461
|
},
|
|
8462
|
+
{
|
|
8463
|
+
"path": "tests/hall_preview_identity.mjs",
|
|
8464
|
+
"mode": "0000644",
|
|
8465
|
+
"sha256": "4c2dadec059bd6ae7c1f16ec67f9ce6dd1d39eaa10aa7d1ad6219619947ffab4"
|
|
8466
|
+
},
|
|
8452
8467
|
{
|
|
8453
8468
|
"path": "tests/hall_signout.mjs",
|
|
8454
8469
|
"mode": "0000644",
|
|
@@ -9899,6 +9914,11 @@
|
|
|
9899
9914
|
"mode": "0000644",
|
|
9900
9915
|
"sha256": "43c7c42ffe92143443c45d5ba382d522efa0a16ef14f58675e6a86233e3f8e74"
|
|
9901
9916
|
},
|
|
9917
|
+
{
|
|
9918
|
+
"path": "tests/worktree_claim_guard.mjs",
|
|
9919
|
+
"mode": "0000644",
|
|
9920
|
+
"sha256": "28662ce24931e2623c8d54e6931d80f400715a6661a6e78d638f7e8cdaa6db87"
|
|
9921
|
+
},
|
|
9902
9922
|
{
|
|
9903
9923
|
"path": "tests/worktree_disambiguation.mjs",
|
|
9904
9924
|
"mode": "0000644",
|
|
@@ -9,7 +9,7 @@ You are claiming a task on behalf of the current builder. The claim is atomic
|
|
|
9
9
|
|
|
10
10
|
## What this skill does
|
|
11
11
|
|
|
12
|
-
Runs `node scripts/gds/claim.js <task-id> [--worktree NAME]`.
|
|
12
|
+
Runs `node scripts/gds/claim.js <task-id> [--worktree NAME]`. Before it hits the API at all, the CLI enforces **worktree-per-claim locally** (task 1002579 — see below). Then it hits `POST /api/gds/claims`, which transactionally:
|
|
13
13
|
|
|
14
14
|
1. Verifies the task exists and is `ready`.
|
|
15
15
|
2. Verifies all declared `dependencies[]` are in `shipped` status (V3.R02 / migration 020).
|
|
@@ -37,7 +37,13 @@ The server now **enforces** the one-claim half (task 1642): `POST /api/gds/claim
|
|
|
37
37
|
|
|
38
38
|
1. **Confirm the task id with the user** if they didn't give a number directly.
|
|
39
39
|
|
|
40
|
-
2. **
|
|
40
|
+
2. **Be in a per-claim worktree, not the main checkout.** `claim.js` **refuses** (exit 2) when it is run in the primary worktree — worktree-per-claim is the project default (task 1642) and task 1002579 is the check behind it. Branching in the main checkout parks it off `main`, and `ship.js`'s local auto-merge then has nowhere to merge INTO: the ship strands at `confirmed` until the server reconciler or `/merge-mode` catches it. The refusal names the exact fix; run it and claim from the new tree:
|
|
41
|
+
```
|
|
42
|
+
node scripts/gds/worktree.js add <task-id>
|
|
43
|
+
```
|
|
44
|
+
A linked worktree claims normally, and a tree git can't classify is allowed through (the guard never blocks on a guess). The escape hatch — for a builder who genuinely means to work in the main checkout — is `--here`, which downgrades the refusal to a loud warning and accepts the `confirmed`/`/merge-mode` finish.
|
|
45
|
+
|
|
46
|
+
**Determine the worktree name** (cross-platform). The worktree name is the leaf folder of the current path. Resolve it portably with node (works on Windows/macOS/Linux — no `basename`/`$PWD` shell-isms):
|
|
41
47
|
```
|
|
42
48
|
node -e "console.log(require('path').basename(process.cwd()))"
|
|
43
49
|
```
|
package/docs/file-map.md
CHANGED
|
@@ -152,7 +152,7 @@
|
|
|
152
152
|
│ ├── discord-channels.js ← Archon CLI: plan/apply the Discord channel layout via the API (ADR 0036, #727)
|
|
153
153
|
│ ├── setup.js ← one-time GitHub Device Flow → ~/.config/otb/gds-session.json
|
|
154
154
|
│ ├── start.js ← list claimable tasks + active claims (`/builder-start`)
|
|
155
|
-
│ ├── claim.js ← atomic POST /api/gds/claims (`/builder-claim`); prints the claim-time context pack incl. next-free ADR/migration numbers (V4.R26); printDisciplineMode() reads discipline-modes.json to route a claimed task to its playbook skill
|
|
155
|
+
│ ├── claim.js ← atomic POST /api/gds/claims (`/builder-claim`); runs the worktree-per-claim guard (worktree-claim-guard.js) BEFORE the session check and the POST; prints the claim-time context pack incl. next-free ADR/migration numbers (V4.R26); printDisciplineMode() reads discipline-modes.json to route a claimed task to its playbook skill
|
|
156
156
|
│ ├── discipline-modes.json ← discipline→operating-playbook map read by claim.js: ideator→/ideate, artist→/paint, engineer→/dev; absence of an entry = the default build-and-ship loop, fail-open ([#1267](https://example.com/builders#/task/1267))
|
|
157
157
|
│ ├── next-number.js ← deterministic next-free ADR + migration number from origin/main ∪ local working tree; supersedes the drift-prone claim-time DB counter (V4.R26, [#964](https://example.com/builders#/task/964)). `node scripts/gds/next-number.js [--json]`
|
|
158
158
|
│ ├── gen-repo-map.js ← Path B (ADR 0063, [#1224](https://example.com/builders#/task/1224)): zero-dep, deterministic symbol-skeleton generator → docs/repo-map.md + the generated factual block in each nested CLAUDE.md; ranks exported symbols by reference frequency. `--check` = CI gate; regenerated on deploy by ship.js regenerateRepoMap()
|
|
@@ -181,6 +181,7 @@
|
|
|
181
181
|
│ ├── recall.js ← `/recall` CLI: two-call progressive-disclosure search (summary → `--expand <id>`) over Bongos retrieval layer (ADR 0060); `node scripts/gds/recall.js <query>`
|
|
182
182
|
│ ├── capture.js ← file an idea_inbox row from the CLI (the ideas.capture kernel port); backs the "capture a tangent" flow across skills. Task 1002927 / R151 taught it the GRADES: `--full --fields-file f.json` files a Full Idea, `--spark` is capture-then-mark (two requests — the route refuses a pre-marked capture), and after a plain quick capture it OFFERS to develop further. That offer is TTY-gated (`--no-prompt` too): capture.js is run by agents with no terminal and reads its body from stdin, so an unguarded prompt would hang every non-interactive caller
|
|
183
183
|
│ ├── spark.js ← (tasks 1002920 / R144 + 1002921 / R145 + 1002922 / R146) the whole spark handoff from the terminal: mark one of your OWN quick ideas as a Big Idea Spark (`--undo` withdraws it), `--list` is the SPARK QUEUE, `--template` prints the five Full Idea fields, `--develop <id> --fields-file <json>` is the PICKUP (any builder; author + developer both recorded). Author-only server-side for the mark (it is the consent); its own command because sparking acts on an EXISTING idea, which is why capture.js refuses grade='spark'
|
|
184
|
+
│ ├── worktree-claim-guard.js ← (task 1002579) the PURE decision behind worktree-per-claim, enforced at claim time: claiming in the MAIN CHECKOUT is refused (escape hatch `--here` → loud warning), a linked worktree passes, an unclassifiable tree fails open. Stops the ship-time "the main checkout is on <branch>, not main" strand at its cause
|
|
184
185
|
│ ├── worktree.js ← (task 1002809) junction-safe worktree add/remove/list — outside-repo worktrees get a node_modules junction via node (no shell quoting), removed BEFORE `git worktree remove`
|
|
185
186
|
│ ├── learning-capture.js ← file a `learnings` row (DB-backed short-form rule capture, replacing hand-edited recipe files for a single gotcha)
|
|
186
187
|
│ ├── docs-entropy.js ← ([#…] docs-entropy) markdown-corpus linter: broken internal links + cross-file duplicate-prose detector across every tracked `.md`; `--verbose` / `--file-ideas`
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.391",
|
|
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.391",
|
|
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.391",
|
|
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/claim.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Builder claim — atomically claim a task.
|
|
3
3
|
//
|
|
4
4
|
// Usage:
|
|
5
|
-
// node scripts/gds/claim.js <task-id> [--worktree NAME] [--enforce-touches] [--join]
|
|
5
|
+
// node scripts/gds/claim.js <task-id> [--worktree NAME] [--enforce-touches] [--join] [--here]
|
|
6
6
|
//
|
|
7
7
|
// On success: prints the claim id, the task title, and the touches[] so the
|
|
8
8
|
// builder knows the parallel-safety boundary they're working within.
|
|
@@ -13,8 +13,9 @@ const path = require('node:path');
|
|
|
13
13
|
const { cliClient, requireSession, fmtTouches, printOnboardingNudge, rewardPolicy, costPlusOnlyNote } = require('./cli-lib');
|
|
14
14
|
const { nextNumbers, padAdr, padMig } = require('./next-number');
|
|
15
15
|
const { CORE_NS } = require('./migration-namespace');
|
|
16
|
-
const { contextPack, formatContextPack } = require('./context-pack');
|
|
16
|
+
const { contextPack, formatContextPack, resolveWorktreeRoot } = require('./context-pack');
|
|
17
17
|
const { printClaimRecall } = require('./claim-recall');
|
|
18
|
+
const { decideWorktreeClaim, ESCAPE_FLAG } = require('./worktree-claim-guard');
|
|
18
19
|
// task 1003153 — the DEAD dependency edge, one definition shared with the route
|
|
19
20
|
// and the detector. A lifecycle CLI may require the lifecycle module directly
|
|
20
21
|
// (the documented exception in modules/lifecycle/CLAUDE.md).
|
|
@@ -81,6 +82,34 @@ function printDisciplineMode(task) {
|
|
|
81
82
|
}
|
|
82
83
|
}
|
|
83
84
|
|
|
85
|
+
// task 1002579 — the I/O half of the worktree-per-claim guard. Probes the tree
|
|
86
|
+
// with resolveWorktreeRoot (the same signal the context pack and skill-preflight
|
|
87
|
+
// read, so there is one answer to "is this the main checkout") plus the current
|
|
88
|
+
// branch, hands both to the pure decision, prints it, and exits 2 on a refusal.
|
|
89
|
+
// Every probe failure degrades to isMainCheckout=null, which the decision treats
|
|
90
|
+
// as allow — a guard that cannot see the tree must never block a real claim.
|
|
91
|
+
function enforceWorktreeClaim(taskId, { allowMainCheckout }) {
|
|
92
|
+
let tree = { isMainCheckout: null };
|
|
93
|
+
let branch = null;
|
|
94
|
+
try {
|
|
95
|
+
const runGit = (a) => execSync(`git ${a.join(' ')}`, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
96
|
+
tree = resolveWorktreeRoot({ runGit, cwd: process.cwd() });
|
|
97
|
+
branch = runGit(['rev-parse', '--abbrev-ref', 'HEAD']).trim() || null;
|
|
98
|
+
} catch (_) {
|
|
99
|
+
/* fail-open: an unclassifiable tree is allowed through */
|
|
100
|
+
}
|
|
101
|
+
const d = decideWorktreeClaim({
|
|
102
|
+
isMainCheckout: tree.isMainCheckout,
|
|
103
|
+
branch,
|
|
104
|
+
allowMainCheckout,
|
|
105
|
+
taskId,
|
|
106
|
+
});
|
|
107
|
+
if (d.verdict === 'allow') return;
|
|
108
|
+
for (const line of d.lines) console.error(line);
|
|
109
|
+
if (d.verdict === 'refuse') process.exit(2);
|
|
110
|
+
console.error('');
|
|
111
|
+
}
|
|
112
|
+
|
|
84
113
|
function captureHeadSha() {
|
|
85
114
|
try {
|
|
86
115
|
return execSync('git rev-parse HEAD', { encoding: 'utf8' }).trim();
|
|
@@ -423,13 +452,17 @@ async function runGoalJoinFlow({ api, d, taskId, assumeYes }) {
|
|
|
423
452
|
}
|
|
424
453
|
|
|
425
454
|
async function main() {
|
|
426
|
-
await requireSession();
|
|
427
455
|
const args = process.argv.slice(2);
|
|
428
456
|
const taskId = Number(args[0]);
|
|
429
457
|
if (!Number.isFinite(taskId)) {
|
|
430
|
-
console.error('usage: node scripts/gds/claim.js <task-id> [--worktree NAME] [--enforce-touches] [--join]');
|
|
458
|
+
console.error('usage: node scripts/gds/claim.js <task-id> [--worktree NAME] [--enforce-touches] [--join] [--here]');
|
|
431
459
|
process.exit(2);
|
|
432
460
|
}
|
|
461
|
+
// task 1002579: worktree-per-claim, enforced BEFORE anything happens — before
|
|
462
|
+
// the session check, before the POST. The whole point is that the builder
|
|
463
|
+
// learns at claim time, not from a stranded ship hours later.
|
|
464
|
+
enforceWorktreeClaim(taskId, { allowMainCheckout: args.includes(ESCAPE_FLAG) });
|
|
465
|
+
await requireSession();
|
|
433
466
|
const wIdx = args.indexOf('--worktree');
|
|
434
467
|
// Explicit --worktree always wins; otherwise auto-detect a linked worktree so
|
|
435
468
|
// parallel worktrees off one login each hold their own claim (the server guard
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// scripts/gds/worktree-claim-guard.js — worktree-per-claim, enforced at CLAIM
|
|
2
|
+
// time instead of discovered at ship time (task 1002579).
|
|
3
|
+
//
|
|
4
|
+
// THE FAILURE THIS KILLS. A builder with direct repo access runs
|
|
5
|
+
// `git checkout -b task-N` in the PRIMARY worktree (the main checkout) rather
|
|
6
|
+
// than in a dedicated `.claude/worktrees/<name>`. Everything looks fine for the
|
|
7
|
+
// whole session — and then ship.js's local auto-merge finds the receiving tree
|
|
8
|
+
// is the very tree holding the feature branch, so there is nowhere to merge
|
|
9
|
+
// INTO. The ship bails with "the main checkout is on <branch>, not main"
|
|
10
|
+
// (ship-merge.js) and the task strands at `confirmed` until the server
|
|
11
|
+
// reconciler or /merge-mode catches it. The cause is at claim time; the symptom
|
|
12
|
+
// was hours away at ship time.
|
|
13
|
+
//
|
|
14
|
+
// Worktree-per-claim has been the documented default since task 1642 and the
|
|
15
|
+
// claim-time context pack has warned about it since task 1002573 — but both
|
|
16
|
+
// speak AFTER the claim lands, and neither stops anything. Owner directive
|
|
17
|
+
// (2026-08-05): make it the enforced project default for ALL builders.
|
|
18
|
+
//
|
|
19
|
+
// The whole decision is the pure function below so it can be unit-tested without
|
|
20
|
+
// a git repo; claim.js supplies the two facts (which kind of tree, which branch)
|
|
21
|
+
// from resolveWorktreeRoot() — the SAME probe the context pack and
|
|
22
|
+
// skill-preflight already use, so there is one answer to "is this the main
|
|
23
|
+
// checkout", not three that can disagree.
|
|
24
|
+
|
|
25
|
+
'use strict';
|
|
26
|
+
|
|
27
|
+
// The documented escape hatch. Named here so the refusal text and claim.js's
|
|
28
|
+
// argv parse can never drift apart.
|
|
29
|
+
const ESCAPE_FLAG = '--here';
|
|
30
|
+
|
|
31
|
+
// Decide whether a claim may proceed in the tree it is being run from.
|
|
32
|
+
//
|
|
33
|
+
// isMainCheckout true | false | null — null means "could not tell" (git
|
|
34
|
+
// unavailable / odd checkout)
|
|
35
|
+
// branch the current branch name, or null when unknown
|
|
36
|
+
// defaultBranch the branch the main checkout must stay parked on
|
|
37
|
+
// allowMainCheckout the builder passed the escape hatch
|
|
38
|
+
// taskId only used to spell the remedy commands concretely
|
|
39
|
+
//
|
|
40
|
+
// Returns { verdict: 'allow' | 'warn' | 'refuse', reason, lines }.
|
|
41
|
+
// - 'allow' — a linked worktree (the correct setup), or a tree we cannot
|
|
42
|
+
// classify. NEVER refuse on a guess: an unknown tree fails open,
|
|
43
|
+
// because a false refusal blocks real work and the ship-time bail
|
|
44
|
+
// still catches the real thing.
|
|
45
|
+
// - 'refuse' — the main checkout, no escape hatch. Exit before the claim lands.
|
|
46
|
+
// - 'warn' — the main checkout WITH the escape hatch: the builder has said
|
|
47
|
+
// they mean it, so say loudly what it costs and continue.
|
|
48
|
+
function decideWorktreeClaim({
|
|
49
|
+
isMainCheckout,
|
|
50
|
+
branch = null,
|
|
51
|
+
defaultBranch = 'main',
|
|
52
|
+
allowMainCheckout = false,
|
|
53
|
+
taskId = null,
|
|
54
|
+
} = {}) {
|
|
55
|
+
if (isMainCheckout !== true) {
|
|
56
|
+
return { verdict: 'allow', reason: isMainCheckout === false ? 'linked worktree' : 'tree kind unknown', lines: [] };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Two shapes of the same mistake, and the second is already broken: a main
|
|
60
|
+
// checkout parked OFF its default branch has ALREADY lost the merge target,
|
|
61
|
+
// so say that rather than warning about a future that has happened.
|
|
62
|
+
const parked = branch && branch !== defaultBranch && branch !== 'HEAD';
|
|
63
|
+
const headline = parked
|
|
64
|
+
? `the main checkout is already parked on \`${branch}\`, not \`${defaultBranch}\``
|
|
65
|
+
: `you are in the main checkout (on \`${branch || defaultBranch}\`), not a per-claim worktree`;
|
|
66
|
+
const cost = parked
|
|
67
|
+
? `The local auto-merge at ship time merges INTO this tree — while it holds \`${branch}\` there is nowhere to merge to, so the ship strands at \`confirmed\`.`
|
|
68
|
+
: `Branching here parks the main checkout off \`${defaultBranch}\`, and the local auto-merge at ship time then has nowhere to merge INTO: the ship strands at \`confirmed\` until the server reconciler (or /merge-mode) catches it.`;
|
|
69
|
+
|
|
70
|
+
const id = taskId == null ? '<task-id>' : String(taskId);
|
|
71
|
+
const remedy = [
|
|
72
|
+
' Work this claim in its own worktree instead:',
|
|
73
|
+
` node scripts/gds/worktree.js add ${id}`,
|
|
74
|
+
` cd .claude/worktrees/task-${id}`,
|
|
75
|
+
` node scripts/gds/claim.js ${id}`,
|
|
76
|
+
];
|
|
77
|
+
|
|
78
|
+
if (allowMainCheckout) {
|
|
79
|
+
return {
|
|
80
|
+
verdict: 'warn',
|
|
81
|
+
reason: headline,
|
|
82
|
+
lines: [
|
|
83
|
+
`⚠ Claiming in the MAIN CHECKOUT (${ESCAPE_FLAG} given) — ${headline}.`,
|
|
84
|
+
` ${cost}`,
|
|
85
|
+
' Automation that resets this tree mid-session can also take your uncommitted work with it.',
|
|
86
|
+
...remedy,
|
|
87
|
+
],
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
verdict: 'refuse',
|
|
93
|
+
reason: headline,
|
|
94
|
+
lines: [
|
|
95
|
+
`✗ Refusing to claim here — ${headline}.`,
|
|
96
|
+
` ${cost}`,
|
|
97
|
+
' Worktree-per-claim is the project default (task 1642); this is the check behind it.',
|
|
98
|
+
...remedy,
|
|
99
|
+
` If you genuinely mean to work in the main checkout, re-run with ${ESCAPE_FLAG} (you will land at \`confirmed\` and finish through /merge-mode).`,
|
|
100
|
+
],
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
module.exports = { decideWorktreeClaim, ESCAPE_FLAG };
|
|
@@ -21,11 +21,43 @@ node scripts/hall-preview/server.js # neutral pack, port 4600
|
|
|
21
21
|
| `--brand config/branding.json` | serve the instance pack (default: `config/branding.neutral.json`, the vanilla skin) |
|
|
22
22
|
| `--modules-off dev-box,provisioning` | report those modules disabled in `window.__MODULES__` — verifies `moduleOn()` gating (Harbor nav item, Instances/Start-a-project widgets must vanish) |
|
|
23
23
|
| `--empty` | drain every array in every fixture — the empty-state screenshot pass |
|
|
24
|
+
| `--fixture-me` | don't read your own account live — serve the checked-in `me.json` snapshot (see below) |
|
|
24
25
|
|
|
25
26
|
Writes are not wired: any `POST/PATCH/DELETE` answers `501 preview_readonly`.
|
|
26
27
|
A GET with no fixture answers 404 and logs
|
|
27
28
|
`no fixture: <path>` — that log line names the exact file to add.
|
|
28
29
|
|
|
30
|
+
## The account is yours (task 1003315)
|
|
31
|
+
|
|
32
|
+
The fixtures are one particular builder's snapshot, so the harness used to greet
|
|
33
|
+
everyone as that builder — their name, credits, achievements, goals and **rank**.
|
|
34
|
+
A metic snapshot also sealed the Archon-gated pages, so a UI change to Watch /
|
|
35
|
+
Harbor / Gate / Permissions could not be reviewed at all.
|
|
36
|
+
|
|
37
|
+
So at boot the harness reads **your** account: if this machine holds a GDS CLI
|
|
38
|
+
session, the me-scoped endpoints (`/me`, `/me/*`, `/builders/<you>/…`,
|
|
39
|
+
`/analytics/builder/<you>`, `/inbox/by-builder/<you>`, `/inbox/awaiting-nod`) are
|
|
40
|
+
fetched live — read-only GETs — and layered over the fixtures in memory. The boot
|
|
41
|
+
banner names whose hall you are looking at:
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
account: @scootermack (archon) — your own, live
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Nothing is written to `fixtures/` (that's `refresh-fixtures.js`'s job), so the
|
|
48
|
+
checked-in snapshot stays the fallback. It serves whenever the live read can't
|
|
49
|
+
happen — signed out, expired session, offline, or an endpoint your rank can't
|
|
50
|
+
call (that one keeps its own fixture and the rest of your identity still lands).
|
|
51
|
+
Every fallback says why, and says the hall is about to show you someone else:
|
|
52
|
+
|
|
53
|
+
```
|
|
54
|
+
account: @jaxri (metic) — NOT YOURS, the checked-in fixture: this machine has no Bongos CLI session — run /builder-setup
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
**`--fixture-me` pins the snapshot.** Use it for the screenshot matrix below —
|
|
58
|
+
a live account makes the images vary run to run — and to review the signed-out
|
|
59
|
+
fallback deliberately. `--rank <rank>` still overrides whichever `/me` is served.
|
|
60
|
+
|
|
29
61
|
## Fixtures
|
|
30
62
|
|
|
31
63
|
`fixtures/<path>.json`, slashes as `__` (`/me/recent-ships` → `me__recent-ships.json`).
|
|
@@ -46,7 +78,8 @@ Box/terminal endpoints are deliberately never snapshotted (connection details).
|
|
|
46
78
|
|
|
47
79
|
Screenshot every changed page at **{light, dark} × {1280w, 390w}**, plus one
|
|
48
80
|
instance-pack run (`--brand config/branding.json`) proving the theming contract
|
|
49
|
-
survives.
|
|
81
|
+
survives. Run the harness with **`--fixture-me`** for these — the pinned snapshot
|
|
82
|
+
is what makes two runs comparable. With the Playwright MCP (`npx playwright install chrome` once):
|
|
50
83
|
|
|
51
84
|
1. `browser_navigate` → the page. `browser_console_messages` must show **0 errors**.
|
|
52
85
|
2. 1280w: `browser_resize 1280×900` → `browser_take_screenshot` (fullPage for tall pages).
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// scripts/hall-preview/live-identity.js — serve the SIGNED-IN builder's own
|
|
3
|
+
// account in the hall preview harness (task 1003315).
|
|
4
|
+
//
|
|
5
|
+
// The harness answers every read from checked-in fixtures, and fixtures/me.json
|
|
6
|
+
// is one particular builder's snapshot. So every preview greeted whoever ran it
|
|
7
|
+
// as somebody else ("Welcome back, Jackson Rini"), with that builder's credits,
|
|
8
|
+
// achievements, goals and RANK — and because that snapshot is a metic, the
|
|
9
|
+
// Archon-gated pages rendered their sealed notice, so a UI change to them could
|
|
10
|
+
// not be reviewed at all. Reviewing your own UI change against someone else's
|
|
11
|
+
// account is the bug.
|
|
12
|
+
//
|
|
13
|
+
// The fix: at boot, when the machine has a GDS CLI session, the me-scoped reads
|
|
14
|
+
// are fetched LIVE (read-only GETs) and held in memory as overrides layered on
|
|
15
|
+
// top of the fixtures. Nothing is ever written to fixtures/ — the checked-in
|
|
16
|
+
// snapshot stays the signed-out fallback and the deterministic pass for the
|
|
17
|
+
// screenshot matrix (`--fixture-me` pins it).
|
|
18
|
+
//
|
|
19
|
+
// Every dependency is injected, so this file is testable with no disk, no
|
|
20
|
+
// network and no session (tests/hall_preview_identity.mjs).
|
|
21
|
+
|
|
22
|
+
// The me-scoped reads — everything the hall renders as "you". ':id' expands to
|
|
23
|
+
// the signed-in builder's id. Box/terminal endpoints are deliberately absent,
|
|
24
|
+
// the same rule refresh-fixtures.js follows: they carry connection details.
|
|
25
|
+
const ME_SCOPED_PATHS = [
|
|
26
|
+
'me',
|
|
27
|
+
'me/recent-ships?limit=30',
|
|
28
|
+
'me/cross-project',
|
|
29
|
+
'me/profile-nudge',
|
|
30
|
+
'me/render',
|
|
31
|
+
'me/wandering',
|
|
32
|
+
'me/skill-prefs',
|
|
33
|
+
'me/sound-prefs',
|
|
34
|
+
'me/event-sound-prefs',
|
|
35
|
+
'me/art-key/own',
|
|
36
|
+
'builders/:id/profile',
|
|
37
|
+
'builders/:id/active-claims',
|
|
38
|
+
'builders/:id/activity?section=tasks',
|
|
39
|
+
'builders/:id/activity?section=goals',
|
|
40
|
+
'analytics/builder/:id?days=90',
|
|
41
|
+
'inbox/by-builder/:id',
|
|
42
|
+
// Permission-gated (idea.triage, Metic+), so whether this queue exists at all
|
|
43
|
+
// is a fact about YOUR rank — the needs-attention inbox renders it or not.
|
|
44
|
+
'inbox/awaiting-nod?limit=20',
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
// The key an override is stored under, and the key a request looks itself up
|
|
48
|
+
// by. The api path alone — EXCEPT where ?section= picks the row shape rather
|
|
49
|
+
// than the path (the profile's activity log), which is the same discrimination
|
|
50
|
+
// the harness's FIXTURE_FALLBACKS already makes for that one endpoint.
|
|
51
|
+
function overrideKey(apiPath, query = {}) {
|
|
52
|
+
const p = String(apiPath || '').replace(/\/$/, '');
|
|
53
|
+
return query && query.section ? `${p}?section=${query.section}` : p;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// A ':id' template → the concrete request path plus the key it lands under.
|
|
57
|
+
function expandPath(template, id) {
|
|
58
|
+
const [rawPath, search = ''] = String(template).split('?');
|
|
59
|
+
const p = rawPath.replace(':id', encodeURIComponent(String(id)));
|
|
60
|
+
const query = Object.fromEntries(new URLSearchParams(search));
|
|
61
|
+
return { key: overrideKey(p, query), url: search ? `${p}?${search}` : p };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Why we fell back, in the builder's words rather than an HTTP status — this
|
|
65
|
+
// line is printed in the boot banner and is the only signal that the hall is
|
|
66
|
+
// about to greet them as someone else.
|
|
67
|
+
function fallbackReason(status) {
|
|
68
|
+
if (status === 401 || status === 403) return 'your CLI session is no longer valid — run /builder-reauth';
|
|
69
|
+
return `the instance answered GET /me with ${status}`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// resolveLiveIdentity — fetch the caller's own me-scoped reads, or explain why
|
|
73
|
+
// not. NEVER throws and never exits: a preview harness that dies because the
|
|
74
|
+
// laptop is offline is worse than one showing fixture data, so every failure
|
|
75
|
+
// path degrades to { ok: false, why } and the fixtures keep serving.
|
|
76
|
+
async function resolveLiveIdentity({
|
|
77
|
+
session,
|
|
78
|
+
fetchImpl = globalThis.fetch,
|
|
79
|
+
apiPrefix = '/api/bongos/v1',
|
|
80
|
+
timeoutMs = 8000,
|
|
81
|
+
} = {}) {
|
|
82
|
+
if (!session || !session.token) {
|
|
83
|
+
return { ok: false, why: 'this machine has no Bongos CLI session — run /builder-setup' };
|
|
84
|
+
}
|
|
85
|
+
const base = String(session.apiBase || '').replace(/\/+$/, '');
|
|
86
|
+
if (!base) return { ok: false, why: 'the CLI session names no instance to read from' };
|
|
87
|
+
|
|
88
|
+
const get = async (url) => {
|
|
89
|
+
try {
|
|
90
|
+
const res = await fetchImpl(`${base}${apiPrefix}/${url}`, {
|
|
91
|
+
headers: { authorization: `Bearer ${session.token}`, accept: 'application/json' },
|
|
92
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
93
|
+
});
|
|
94
|
+
if (!res.ok) return { ok: false, status: res.status };
|
|
95
|
+
return { ok: true, data: await res.json() };
|
|
96
|
+
} catch (e) {
|
|
97
|
+
return { ok: false, status: 0, error: e && e.message };
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const me = await get('me');
|
|
102
|
+
if (!me.ok) return { ok: false, why: me.status ? fallbackReason(me.status) : `could not reach ${base} (${me.error || 'network error'})` };
|
|
103
|
+
const builder = me.data && me.data.builder;
|
|
104
|
+
if (!builder || !builder.id) return { ok: false, why: 'the instance returned no builder on GET /me' };
|
|
105
|
+
|
|
106
|
+
const overrides = new Map([['me', me.data]]);
|
|
107
|
+
const skipped = [];
|
|
108
|
+
const rest = ME_SCOPED_PATHS.filter((t) => t !== 'me').map((t) => expandPath(t, builder.id));
|
|
109
|
+
const results = await Promise.all(rest.map(({ url }) => get(url)));
|
|
110
|
+
rest.forEach(({ key }, i) => {
|
|
111
|
+
// A read this builder's rank can't make (or one this instance doesn't
|
|
112
|
+
// serve) simply keeps its fixture — a partial identity still shows YOUR
|
|
113
|
+
// name, rank and credits, which is the whole point.
|
|
114
|
+
if (results[i].ok) overrides.set(key, results[i].data);
|
|
115
|
+
else skipped.push(key);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
return { ok: true, builder, base, overrides, skipped };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
module.exports = { ME_SCOPED_PATHS, overrideKey, expandPath, fallbackReason, resolveLiveIdentity };
|
|
@@ -24,6 +24,7 @@ const SNAPSHOTS = [
|
|
|
24
24
|
['versions', 'versions'],
|
|
25
25
|
['leaderboard', 'leaderboard'],
|
|
26
26
|
['inbox', 'inbox', trimList('ideas', 25)],
|
|
27
|
+
['inbox/awaiting-nod?limit=20', 'inbox__awaiting-nod', trimList('awaiting', 25)],
|
|
27
28
|
['goals', 'goals'],
|
|
28
29
|
['goals/inbox', 'goals__inbox'],
|
|
29
30
|
// goal details: a handful of REAL ids (exact-match fixtures) + goal 25
|
|
@@ -19,8 +19,16 @@
|
|
|
19
19
|
// window.__MODULES__ (verifies moduleOn() gating)
|
|
20
20
|
// --empty serve every fixture with its arrays emptied — the
|
|
21
21
|
// empty-state screenshot pass
|
|
22
|
-
// --rank archon override the /me
|
|
23
|
-
// pages (watch, harbor, gate) render
|
|
22
|
+
// --rank archon override the served /me builder.rank (live or fixture) so the
|
|
23
|
+
// Archon-gated pages (watch, harbor, gate) render in preview
|
|
24
|
+
// --fixture-me do NOT read your own account live; serve the checked-in
|
|
25
|
+
// fixtures/me.json snapshot (deterministic screenshot passes,
|
|
26
|
+
// and the offline/signed-out path) — task 1003315
|
|
27
|
+
//
|
|
28
|
+
// The account is YOURS: when this machine holds a GDS CLI session the me-scoped
|
|
29
|
+
// reads are fetched live at boot and layered over the fixtures, so the hall
|
|
30
|
+
// greets the builder previewing the change rather than whoever's snapshot is
|
|
31
|
+
// checked in (task 1003315). Signed out, offline, or --fixture-me → fixtures.
|
|
24
32
|
//
|
|
25
33
|
// Browse http://builders.localhost:<port>/ — Chromium resolves *.localhost,
|
|
26
34
|
// and the `builders.` host prefix exercises the real subdomain serving path.
|
|
@@ -41,9 +49,11 @@ function arg(name) {
|
|
|
41
49
|
}
|
|
42
50
|
const PORT = Number(arg('--port')) || 4600;
|
|
43
51
|
const EMPTY = process.argv.includes('--empty');
|
|
44
|
-
// --rank: view the hall as a different rank — the /me
|
|
52
|
+
// --rank: view the hall as a different rank — the served /me builder.rank is
|
|
45
53
|
// overridden in the response, so the Archon-gated pages (watch, harbor, gate)
|
|
46
|
-
// render their real content instead of the sealed notice.
|
|
54
|
+
// render their real content instead of the sealed notice. Since task 1003315 the
|
|
55
|
+
// served /me is normally your OWN account, so this is now only needed to view
|
|
56
|
+
// the hall as a rank you are not. Preview-only theatre:
|
|
47
57
|
// the real pages are server-gated (requireGovernmentPage / ADR 0016).
|
|
48
58
|
const RANK_OVERRIDE = arg('--rank');
|
|
49
59
|
const MODULES_OFF = String(arg('--modules-off') || '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
@@ -51,6 +61,10 @@ const MODULES_OFF = String(arg('--modules-off') || '').split(',').map((s) => s.t
|
|
|
51
61
|
// often lacks prod's instance modules (provisioning, dev-box), and their
|
|
52
62
|
// widgets can't be reviewed without it (task 2169).
|
|
53
63
|
const MODULES_ON = String(arg('--modules-on') || '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
64
|
+
// --fixture-me: pin the checked-in /me snapshot instead of reading the caller's
|
|
65
|
+
// own account live. The screenshot matrix in the README wants a stable subject,
|
|
66
|
+
// and it is the explicit way to preview the signed-out fallback (task 1003315).
|
|
67
|
+
const FIXTURE_ME = process.argv.includes('--fixture-me');
|
|
54
68
|
const BRAND_FILE = arg('--brand') || 'config/branding.neutral.json';
|
|
55
69
|
process.env.GDS_BRANDING_FILE = path.resolve(ROOT, BRAND_FILE);
|
|
56
70
|
|
|
@@ -65,6 +79,21 @@ catch (_) { express = require('express'); }
|
|
|
65
79
|
const { buildStampedHtml } = require(path.join(ROOT, 'src', 'asset-version.js'));
|
|
66
80
|
const { injectBrandingGlobal, mountInternalSurfaces } = require(path.join(ROOT, 'src', 'bongos', 'serve-internal.js'));
|
|
67
81
|
const { clientModules } = require(path.join(ROOT, 'src', 'modules.js'));
|
|
82
|
+
const { overrideKey, resolveLiveIdentity } = require('./live-identity');
|
|
83
|
+
|
|
84
|
+
// The session is the ONE thing this harness reads from outside its own tree, and
|
|
85
|
+
// it reads it through the FAIL-OPEN helper on purpose: apiCall/cliClient exit the
|
|
86
|
+
// process on a 401, which would kill a preview over a cosmetic feature.
|
|
87
|
+
function readSession() {
|
|
88
|
+
try { return require(path.join(ROOT, 'scripts', 'gds', 'cli-lib')).readSessionToken(); }
|
|
89
|
+
catch (_) { return null; }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Live me-scoped payloads, keyed by overrideKey(); empty until boot resolves
|
|
93
|
+
// them, and empty forever when signed out / offline / --fixture-me.
|
|
94
|
+
let liveOverrides = new Map();
|
|
95
|
+
let liveBuilder = null;
|
|
96
|
+
let liveWhy = null;
|
|
68
97
|
|
|
69
98
|
// ---- fixtures ---------------------------------------------------------------
|
|
70
99
|
// File naming: the API path with slashes as "__" — /me/recent-ships →
|
|
@@ -151,7 +180,11 @@ app.use((req, res, next) => {
|
|
|
151
180
|
if (req.method !== 'GET') {
|
|
152
181
|
return res.status(501).json({ error: { code: 'preview_readonly', message: 'the preview harness serves fixtures; writes are not wired' } });
|
|
153
182
|
}
|
|
154
|
-
|
|
183
|
+
// Your own account wins over the checked-in snapshot; a me-scoped read that
|
|
184
|
+
// wasn't resolved live falls through to its fixture exactly as before.
|
|
185
|
+
const q = req.query || {};
|
|
186
|
+
const live = liveOverrides.get(overrideKey(apiPath, q)) ?? liveOverrides.get(apiPath);
|
|
187
|
+
const fix = live !== undefined ? live : fixtureFor(apiPath, q);
|
|
155
188
|
if (fix === undefined) {
|
|
156
189
|
console.log(` no fixture: ${apiPath} (add scripts/hall-preview/fixtures/${apiPath.replace(/\//g, '__')}.json)`);
|
|
157
190
|
return res.status(404).json({ error: { code: 'no_fixture', message: apiPath } });
|
|
@@ -197,10 +230,41 @@ app.get(/^\/([a-z-]+)$/, (req, res, next) => {
|
|
|
197
230
|
// the page routes we already served.
|
|
198
231
|
mountInternalSurfaces(app);
|
|
199
232
|
|
|
233
|
+
// Whose hall this is, said plainly in the banner. When it is NOT yours, that is
|
|
234
|
+
// the line that says so — the old harness printed nothing and the substitution
|
|
235
|
+
// was only visible as a stranger's name on the page (task 1003315).
|
|
236
|
+
function accountLine() {
|
|
237
|
+
if (liveBuilder) {
|
|
238
|
+
const who = liveBuilder.github_login ? `@${liveBuilder.github_login}` : liveBuilder.display_name;
|
|
239
|
+
return ` account: ${who} (${liveBuilder.rank}) — your own, live${RANK_OVERRIDE ? ` [rank forced to ${RANK_OVERRIDE}]` : ''}`;
|
|
240
|
+
}
|
|
241
|
+
const why = FIXTURE_ME ? '--fixture-me' : liveWhy;
|
|
242
|
+
// A published core carves fixtures/ out (publish-manifest.js) — there the
|
|
243
|
+
// fallback is not "someone else's account", it is NO account, and the hall
|
|
244
|
+
// will 404 its way through /me. Say which of the two this is.
|
|
245
|
+
const snap = (loadFixture('me') || {}).builder;
|
|
246
|
+
if (!snap) return ` account: NONE — no live read (${why}) and this checkout ships no me fixture`;
|
|
247
|
+
const who = snap.github_login ? `@${snap.github_login}` : (snap.display_name || 'a checked-in snapshot');
|
|
248
|
+
return ` account: ${who} (${RANK_OVERRIDE || snap.rank}) — NOT YOURS, the checked-in fixture: ${why}`;
|
|
249
|
+
}
|
|
250
|
+
|
|
200
251
|
// Loopback only — this is a pre-auth preview of hall pages with fixture data;
|
|
201
252
|
// *.localhost resolves to 127.0.0.1 so nothing else is needed.
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
253
|
+
(async () => {
|
|
254
|
+
if (!FIXTURE_ME) {
|
|
255
|
+
const res = await resolveLiveIdentity({ session: readSession() });
|
|
256
|
+
if (res.ok) {
|
|
257
|
+
liveOverrides = res.overrides;
|
|
258
|
+
liveBuilder = res.builder;
|
|
259
|
+
if (res.skipped.length) console.log(` (kept fixtures for: ${res.skipped.join(', ')})`);
|
|
260
|
+
} else {
|
|
261
|
+
liveWhy = res.why;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
app.listen(PORT, '127.0.0.1', () => {
|
|
265
|
+
console.log(`hall-preview: http://builders.localhost:${PORT}/`);
|
|
266
|
+
console.log(accountLine());
|
|
267
|
+
console.log(` brand: ${BRAND_FILE}${EMPTY ? ' (EMPTY fixtures)' : ''}${MODULES_OFF.length ? ` modules off: ${MODULES_OFF.join(',')}` : ''}`);
|
|
268
|
+
console.log(' pages:', Object.keys(stamped).map((f) => f.replace(/\.html$/, '')).sort().join(' '));
|
|
269
|
+
});
|
|
270
|
+
})();
|
package/src/module-api.js
CHANGED
|
@@ -1218,8 +1218,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
1218
1218
|
// landed since 1.19.387 with no explicit bump. run 33125589814. (task 1002620)
|
|
1219
1219
|
// 1.19.389 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1220
1220
|
// landed since 1.19.388 with no explicit bump. run 33126279278. (task 1002620)
|
|
1221
|
+
// 1.19.390 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1222
|
+
// landed since 1.19.389 with no explicit bump. run 33133575931. (task 1002620)
|
|
1223
|
+
// 1.19.391 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1224
|
+
// landed since 1.19.390 with no explicit bump. run 33134514193. (task 1002620)
|
|
1221
1225
|
// ---------------------------------------------------------------------------
|
|
1222
|
-
const CORE_VERSION = '1.19.
|
|
1226
|
+
const CORE_VERSION = '1.19.391'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
1223
1227
|
|
|
1224
1228
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
1225
1229
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// tests/hall_preview_identity.mjs — hermetic unit test for the hall preview
|
|
2
|
+
// harness's live-identity layer (task 1003315: the preview greeted every
|
|
3
|
+
// builder as whoever's snapshot was checked into fixtures/me.json).
|
|
4
|
+
//
|
|
5
|
+
// NO disk, NO network, NO session: the session object and fetch are injected.
|
|
6
|
+
//
|
|
7
|
+
// Run: node tests/hall_preview_identity.mjs
|
|
8
|
+
|
|
9
|
+
import { strict as assert } from 'node:assert';
|
|
10
|
+
import { createRequire } from 'node:module';
|
|
11
|
+
|
|
12
|
+
const require = createRequire(import.meta.url);
|
|
13
|
+
const lib = require('../scripts/hall-preview/live-identity.js');
|
|
14
|
+
|
|
15
|
+
let passed = 0, failed = 0;
|
|
16
|
+
function t(name, fn) {
|
|
17
|
+
try { fn(); passed++; console.log(` PASS ${name}`); }
|
|
18
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.stack || e.message}`); }
|
|
19
|
+
}
|
|
20
|
+
async function ta(name, fn) {
|
|
21
|
+
try { await fn(); passed++; console.log(` PASS ${name}`); }
|
|
22
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.stack || e.message}`); }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const SESSION = { token: 'tok', apiBase: 'https://example.test' };
|
|
26
|
+
const ME = { builder: { id: '1000094', github_login: 'scootermack', rank: 'archon' } };
|
|
27
|
+
|
|
28
|
+
// A fetch stub: routes are keyed by the api path after /api/bongos/v1/. A number
|
|
29
|
+
// means "answer that status"; a missing key means 404.
|
|
30
|
+
function stubFetch(routes, { record } = {}) {
|
|
31
|
+
return async (url) => {
|
|
32
|
+
const p = String(url).split('/api/bongos/v1/')[1];
|
|
33
|
+
if (record) record.push(p);
|
|
34
|
+
const hit = Object.prototype.hasOwnProperty.call(routes, p) ? routes[p] : undefined;
|
|
35
|
+
if (hit === undefined) return { ok: false, status: 404, json: async () => ({}) };
|
|
36
|
+
if (typeof hit === 'number') return { ok: false, status: hit, json: async () => ({}) };
|
|
37
|
+
return { ok: true, status: 200, json: async () => hit };
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
const allOk = (extra = {}) => stubFetch(Object.assign({ me: ME }, extra));
|
|
41
|
+
|
|
42
|
+
// ---------- key derivation ----------
|
|
43
|
+
t('overrideKey: the path alone, except where ?section= picks the row shape', () => {
|
|
44
|
+
assert.equal(lib.overrideKey('me'), 'me');
|
|
45
|
+
assert.equal(lib.overrideKey('me/'), 'me', 'a trailing slash is the same key');
|
|
46
|
+
assert.equal(lib.overrideKey('builders/7/activity', { section: 'goals' }), 'builders/7/activity?section=goals');
|
|
47
|
+
assert.equal(lib.overrideKey('builders/7/activity', { limit: '20' }), 'builders/7/activity',
|
|
48
|
+
'a query that is NOT ?section= does not fork the key');
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
t('expandPath: :id expands, and the key drops the non-discriminating query', () => {
|
|
52
|
+
assert.deepEqual(lib.expandPath('builders/:id/profile', '1000094'),
|
|
53
|
+
{ key: 'builders/1000094/profile', url: 'builders/1000094/profile' });
|
|
54
|
+
assert.deepEqual(lib.expandPath('me/recent-ships?limit=30', '1'),
|
|
55
|
+
{ key: 'me/recent-ships', url: 'me/recent-ships?limit=30' },
|
|
56
|
+
'limit is a fetch detail, not part of the key the harness looks up');
|
|
57
|
+
assert.deepEqual(lib.expandPath('builders/:id/activity?section=goals', '9'),
|
|
58
|
+
{ key: 'builders/9/activity?section=goals', url: 'builders/9/activity?section=goals' });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
t('ME_SCOPED_PATHS: /me leads, and no box/terminal read is snapshotted', () => {
|
|
62
|
+
assert.equal(lib.ME_SCOPED_PATHS[0], 'me');
|
|
63
|
+
const box = lib.ME_SCOPED_PATHS.filter((p) => /(^|\/)box(\/|$)|terminal/.test(p));
|
|
64
|
+
assert.deepEqual(box, [], 'box endpoints carry connection details and are never fetched here');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
// ---------- the fail-open paths (a preview must never die over this) ----------
|
|
68
|
+
await ta('no session: falls back, and never touches the network', async () => {
|
|
69
|
+
let called = 0;
|
|
70
|
+
const res = await lib.resolveLiveIdentity({ session: null, fetchImpl: async () => { called += 1; } });
|
|
71
|
+
assert.equal(res.ok, false);
|
|
72
|
+
assert.equal(called, 0);
|
|
73
|
+
assert.match(res.why, /builder-setup/);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
await ta('a session with no apiBase: falls back rather than fetching a bare path', async () => {
|
|
77
|
+
const res = await lib.resolveLiveIdentity({
|
|
78
|
+
session: { token: 'tok' },
|
|
79
|
+
fetchImpl: async () => { throw new Error('should not fetch'); },
|
|
80
|
+
});
|
|
81
|
+
assert.equal(res.ok, false);
|
|
82
|
+
assert.match(res.why, /names no instance/);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
await ta('401: falls back with the reauth line, NOT an exit (cli-lib apiCall would exit)', async () => {
|
|
86
|
+
const res = await lib.resolveLiveIdentity({ session: SESSION, fetchImpl: stubFetch({ me: 401 }) });
|
|
87
|
+
assert.equal(res.ok, false);
|
|
88
|
+
assert.match(res.why, /builder-reauth/);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
await ta('offline: falls back with the unreachable instance named', async () => {
|
|
92
|
+
const res = await lib.resolveLiveIdentity({
|
|
93
|
+
session: SESSION,
|
|
94
|
+
fetchImpl: async () => { throw new Error('ECONNREFUSED'); },
|
|
95
|
+
});
|
|
96
|
+
assert.equal(res.ok, false);
|
|
97
|
+
assert.match(res.why, /example\.test/);
|
|
98
|
+
assert.match(res.why, /ECONNREFUSED/);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
await ta('a /me with no builder: falls back (a half-identity is worse than the fixture)', async () => {
|
|
102
|
+
const res = await lib.resolveLiveIdentity({ session: SESSION, fetchImpl: stubFetch({ me: { builder: null } }) });
|
|
103
|
+
assert.equal(res.ok, false);
|
|
104
|
+
assert.match(res.why, /no builder/);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// ---------- the happy path ----------
|
|
108
|
+
await ta('signed in: /me is YOUR account, keyed for the harness lookup', async () => {
|
|
109
|
+
const res = await lib.resolveLiveIdentity({ session: SESSION, fetchImpl: allOk() });
|
|
110
|
+
assert.equal(res.ok, true);
|
|
111
|
+
assert.equal(res.builder.github_login, 'scootermack');
|
|
112
|
+
assert.equal(res.builder.rank, 'archon', 'the RANK is what unseals the Archon-gated pages in preview');
|
|
113
|
+
assert.deepEqual(res.overrides.get('me'), ME);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
await ta('id-bearing reads are fetched for YOUR id, never the fixture builder id', async () => {
|
|
117
|
+
const record = [];
|
|
118
|
+
const fetchImpl = stubFetch({ me: ME, 'builders/1000094/profile': { bio: 'mine' } }, { record });
|
|
119
|
+
const res = await lib.resolveLiveIdentity({ session: SESSION, fetchImpl });
|
|
120
|
+
assert.deepEqual(res.overrides.get('builders/1000094/profile'), { bio: 'mine' });
|
|
121
|
+
assert.ok(record.includes('builders/1000094/profile'));
|
|
122
|
+
assert.ok(!record.some((p) => p.includes('/95/')), 'never the checked-in fixture builder id');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
await ta('the two activity sections land under DIFFERENT keys (row shapes differ)', async () => {
|
|
126
|
+
const fetchImpl = stubFetch({
|
|
127
|
+
me: ME,
|
|
128
|
+
'builders/1000094/activity?section=tasks': { rows: ['task'] },
|
|
129
|
+
'builders/1000094/activity?section=goals': { rows: ['goal'] },
|
|
130
|
+
});
|
|
131
|
+
const res = await lib.resolveLiveIdentity({ session: SESSION, fetchImpl });
|
|
132
|
+
assert.deepEqual(res.overrides.get('builders/1000094/activity?section=tasks'), { rows: ['task'] });
|
|
133
|
+
assert.deepEqual(res.overrides.get('builders/1000094/activity?section=goals'), { rows: ['goal'] });
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
await ta('a read this rank cannot make keeps its fixture; the rest still resolves', async () => {
|
|
137
|
+
const res = await lib.resolveLiveIdentity({
|
|
138
|
+
session: SESSION,
|
|
139
|
+
fetchImpl: stubFetch({ me: ME, 'inbox/awaiting-nod?limit=20': 403 }),
|
|
140
|
+
});
|
|
141
|
+
assert.equal(res.ok, true, 'a partial identity still shows YOUR name, rank and credits');
|
|
142
|
+
assert.ok(res.skipped.includes('inbox/awaiting-nod'), 'skipped, so the fixture serves it');
|
|
143
|
+
assert.equal(res.overrides.has('inbox/awaiting-nod'), false);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
await ta('every request carries the session bearer, is a GET, and is time-boxed', async () => {
|
|
147
|
+
const seen = [];
|
|
148
|
+
const fetchImpl = async (url, init) => {
|
|
149
|
+
seen.push(init);
|
|
150
|
+
const p = String(url).split('/api/bongos/v1/')[1];
|
|
151
|
+
return { ok: p === 'me', status: p === 'me' ? 200 : 404, json: async () => ME };
|
|
152
|
+
};
|
|
153
|
+
await lib.resolveLiveIdentity({ session: SESSION, fetchImpl });
|
|
154
|
+
assert.ok(seen.length > 1);
|
|
155
|
+
for (const init of seen) {
|
|
156
|
+
assert.equal(init.headers.authorization, 'Bearer tok');
|
|
157
|
+
assert.equal(init.method, undefined, 'no method set means GET; the harness never writes');
|
|
158
|
+
assert.ok(init.signal, 'every read is time-boxed so boot cannot hang');
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
console.log(`\nhall_preview_identity: ${passed} passed, ${failed} failed`);
|
|
163
|
+
process.exit(failed ? 1 : 0);
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// tests/worktree_claim_guard.mjs
|
|
2
|
+
//
|
|
3
|
+
// task 1002579 — worktree-per-claim, enforced at CLAIM time. The failure it
|
|
4
|
+
// kills: branching in the PRIMARY worktree (the main checkout) instead of a
|
|
5
|
+
// dedicated `.claude/worktrees/<name>` leaves the main checkout parked off
|
|
6
|
+
// `main`, so ship.js's local auto-merge has nowhere to merge INTO and the task
|
|
7
|
+
// strands at `confirmed` — a symptom hours away from its cause.
|
|
8
|
+
//
|
|
9
|
+
// decideWorktreeClaim() is the pure core; these tests pin every branch with
|
|
10
|
+
// injected facts (no git, no repo, no claim).
|
|
11
|
+
//
|
|
12
|
+
// Run: node tests/worktree_claim_guard.mjs
|
|
13
|
+
|
|
14
|
+
import { strict as assert } from 'node:assert';
|
|
15
|
+
import { createRequire } from 'node:module';
|
|
16
|
+
|
|
17
|
+
const require = createRequire(import.meta.url);
|
|
18
|
+
const { decideWorktreeClaim, ESCAPE_FLAG } = require('../scripts/gds/worktree-claim-guard.js');
|
|
19
|
+
|
|
20
|
+
let passed = 0;
|
|
21
|
+
let failed = 0;
|
|
22
|
+
function t(name, fn) {
|
|
23
|
+
try { fn(); passed++; console.log(` PASS ${name}`); }
|
|
24
|
+
catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const text = (d) => d.lines.join('\n');
|
|
28
|
+
|
|
29
|
+
// ---- the core case: the primary checkout, on main -------------------------
|
|
30
|
+
t('REFUSE: the main checkout on `main` — the exact shape task 1642 documents', () => {
|
|
31
|
+
const d = decideWorktreeClaim({ isMainCheckout: true, branch: 'main', taskId: 42 });
|
|
32
|
+
assert.equal(d.verdict, 'refuse');
|
|
33
|
+
assert.match(text(d), /main checkout/i);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
t('REFUSE names the fix concretely, with the task id spelled into the commands', () => {
|
|
37
|
+
const d = decideWorktreeClaim({ isMainCheckout: true, branch: 'main', taskId: 42 });
|
|
38
|
+
const s = text(d);
|
|
39
|
+
assert.match(s, /worktree\.js add 42/, 'names the worktree-create command');
|
|
40
|
+
assert.match(s, /cd \.claude\/worktrees\/task-42/, 'names where to go');
|
|
41
|
+
assert.match(s, /claim\.js 42/, 'names the re-run');
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
t('REFUSE names the escape hatch, so the refusal is never a dead end', () => {
|
|
45
|
+
const d = decideWorktreeClaim({ isMainCheckout: true, branch: 'main', taskId: 42 });
|
|
46
|
+
assert.match(text(d), new RegExp(ESCAPE_FLAG.replace(/-/g, '\-')));
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
t('a missing task id still yields runnable-shaped guidance, not "undefined"', () => {
|
|
50
|
+
const s = text(decideWorktreeClaim({ isMainCheckout: true, branch: 'main' }));
|
|
51
|
+
assert.match(s, /worktree\.js add <task-id>/);
|
|
52
|
+
assert.doesNotMatch(s, /undefined|null/);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
// ---- the already-broken shape: primary checkout parked off main -----------
|
|
56
|
+
t('REFUSE: the main checkout ALREADY parked on a task branch reads as already-broken', () => {
|
|
57
|
+
const d = decideWorktreeClaim({ isMainCheckout: true, branch: 'task-99', taskId: 42 });
|
|
58
|
+
assert.equal(d.verdict, 'refuse');
|
|
59
|
+
const s = text(d);
|
|
60
|
+
assert.match(s, /already parked on `task-99`/, 'names the branch it is stuck on');
|
|
61
|
+
assert.match(s, /nowhere to merge/, 'names the ship-time consequence');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
t('a detached HEAD in the main checkout is not reported as "parked on HEAD"', () => {
|
|
65
|
+
const s = text(decideWorktreeClaim({ isMainCheckout: true, branch: 'HEAD', taskId: 42 }));
|
|
66
|
+
assert.doesNotMatch(s, /parked on `HEAD`/);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
t('defaultBranch is honoured — a `master` instance is not told main', () => {
|
|
70
|
+
const d = decideWorktreeClaim({ isMainCheckout: true, branch: 'master', defaultBranch: 'master', taskId: 7 });
|
|
71
|
+
assert.equal(d.verdict, 'refuse');
|
|
72
|
+
assert.doesNotMatch(text(d), /already parked/, 'master IS the default branch here');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
// ---- the escape hatch: loud, but it proceeds ------------------------------
|
|
76
|
+
t('WARN, not refuse, when the escape hatch is given — and it still costs something out loud', () => {
|
|
77
|
+
const d = decideWorktreeClaim({ isMainCheckout: true, branch: 'main', allowMainCheckout: true, taskId: 42 });
|
|
78
|
+
assert.equal(d.verdict, 'warn');
|
|
79
|
+
assert.match(text(d), /confirmed/, 'says what it will cost at ship time');
|
|
80
|
+
assert.match(text(d), /worktree\.js add 42/, 'still names the better path');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// ---- no false positives ---------------------------------------------------
|
|
84
|
+
t('ALLOW: a real linked worktree is untouched — silently, no lines', () => {
|
|
85
|
+
const d = decideWorktreeClaim({ isMainCheckout: false, branch: 'claude/task-42', taskId: 42 });
|
|
86
|
+
assert.equal(d.verdict, 'allow');
|
|
87
|
+
assert.deepEqual(d.lines, []);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
t('ALLOW: a linked worktree that happens to be on `main` is still fine', () => {
|
|
91
|
+
assert.equal(decideWorktreeClaim({ isMainCheckout: false, branch: 'main' }).verdict, 'allow');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
t('ALLOW: tree kind UNKNOWN fails open — a guard that cannot see must not block', () => {
|
|
95
|
+
const d = decideWorktreeClaim({ isMainCheckout: null, branch: null, taskId: 42 });
|
|
96
|
+
assert.equal(d.verdict, 'allow');
|
|
97
|
+
assert.deepEqual(d.lines, []);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
t('ALLOW: called with no facts at all (every probe threw) still fails open', () => {
|
|
101
|
+
assert.equal(decideWorktreeClaim().verdict, 'allow');
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
console.log(`\nworktree_claim_guard: ${passed} passed, ${failed} failed`);
|
|
105
|
+
process.exit(failed ? 1 : 0);
|