@bongos/core 1.19.278 → 1.19.280
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 +23 -18
- package/.claude/skills/builder-claim/SKILL.md +5 -0
- package/modules/lifecycle/claim-feed.js +142 -0
- package/modules/lifecycle/db.js +52 -53
- package/modules/lifecycle/goal-authz.js +56 -1
- package/modules/lifecycle/routes/claims.js +6 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/claim.js +173 -16
- package/src/module-api.js +5 -1
- package/tests/claim_error_surface.mjs +55 -2
- package/tests/goal_abuse_matrix.mjs +72 -0
- package/tests/reward_gate.mjs +4 -3
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.280",
|
|
6
|
+
"core_contract": "1.19.280",
|
|
7
|
+
"source_commit": "60f4ffc6a9e2ee27fd0325d8f332658692b5d954",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-
|
|
9
|
+
"built_at": "2026-08-21T20:22:42.788Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 319,
|
|
13
13
|
"agent_docs_stubbed": 22,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1377,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 1718,
|
|
20
|
+
"tree_sha256": "81c6c6bebc79cb9374d40181db1cb84c1bd035f0bd2c1062f261078df64042e8",
|
|
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": "057a5bac75084ea29e5dc3da1cf457089d77c882bfb34936f3c0b37889edc1c3"
|
|
46
46
|
},
|
|
47
47
|
{
|
|
48
48
|
"path": ".claude/skills/builder-connect/SKILL.md",
|
|
@@ -3744,6 +3744,11 @@
|
|
|
3744
3744
|
"mode": "0000644",
|
|
3745
3745
|
"sha256": "0c52ae165d304f0f08ca04c9a288ef7d8ffb07288282dcd7b2d503fced7cd744"
|
|
3746
3746
|
},
|
|
3747
|
+
{
|
|
3748
|
+
"path": "modules/lifecycle/claim-feed.js",
|
|
3749
|
+
"mode": "0000644",
|
|
3750
|
+
"sha256": "3586cc2aa07913870ad99073c534e70ef2e2c5ff6a5ef2ddeb141cfd397269cd"
|
|
3751
|
+
},
|
|
3747
3752
|
{
|
|
3748
3753
|
"path": "modules/lifecycle/conflict-resolve.js",
|
|
3749
3754
|
"mode": "0000644",
|
|
@@ -3757,7 +3762,7 @@
|
|
|
3757
3762
|
{
|
|
3758
3763
|
"path": "modules/lifecycle/db.js",
|
|
3759
3764
|
"mode": "0000644",
|
|
3760
|
-
"sha256": "
|
|
3765
|
+
"sha256": "74eedb9ee1c423c9ecdea6f09d8555fa3200ac9e3a86a194182774cb72f0cd0b"
|
|
3761
3766
|
},
|
|
3762
3767
|
{
|
|
3763
3768
|
"path": "modules/lifecycle/done-when.js",
|
|
@@ -3782,7 +3787,7 @@
|
|
|
3782
3787
|
{
|
|
3783
3788
|
"path": "modules/lifecycle/goal-authz.js",
|
|
3784
3789
|
"mode": "0000644",
|
|
3785
|
-
"sha256": "
|
|
3790
|
+
"sha256": "3eb9d818e0e0d45a896a5c0a515842b5f5a01e102d02f8a9a605c271088c8e5d"
|
|
3786
3791
|
},
|
|
3787
3792
|
{
|
|
3788
3793
|
"path": "modules/lifecycle/goal-conflicts.js",
|
|
@@ -3837,7 +3842,7 @@
|
|
|
3837
3842
|
{
|
|
3838
3843
|
"path": "modules/lifecycle/routes/claims.js",
|
|
3839
3844
|
"mode": "0000644",
|
|
3840
|
-
"sha256": "
|
|
3845
|
+
"sha256": "e02abac52a82fb02603dd92074d4ebd060ec03195803d704b021614806ef00fe"
|
|
3841
3846
|
},
|
|
3842
3847
|
{
|
|
3843
3848
|
"path": "modules/lifecycle/routes/dependencies.js",
|
|
@@ -4492,12 +4497,12 @@
|
|
|
4492
4497
|
{
|
|
4493
4498
|
"path": "package-lock.json",
|
|
4494
4499
|
"mode": "0000644",
|
|
4495
|
-
"sha256": "
|
|
4500
|
+
"sha256": "db9e4dfef2e02b0e40fa3535f1af51d53071128b960698cf48b04fd6995eb54c"
|
|
4496
4501
|
},
|
|
4497
4502
|
{
|
|
4498
4503
|
"path": "package.json",
|
|
4499
4504
|
"mode": "0000644",
|
|
4500
|
-
"sha256": "
|
|
4505
|
+
"sha256": "99c8deb7b7edff1c7a5560a59dd9887aefa080786d3f2c9acd6c87c5b495c114"
|
|
4501
4506
|
},
|
|
4502
4507
|
{
|
|
4503
4508
|
"path": "public-docs/index.html",
|
|
@@ -4972,7 +4977,7 @@
|
|
|
4972
4977
|
{
|
|
4973
4978
|
"path": "scripts/gds/claim.js",
|
|
4974
4979
|
"mode": "0000644",
|
|
4975
|
-
"sha256": "
|
|
4980
|
+
"sha256": "3e830a3e319c4b67d87547ae4ad2d647ee3ce74725c84d7f64788c094f0974c7"
|
|
4976
4981
|
},
|
|
4977
4982
|
{
|
|
4978
4983
|
"path": "scripts/gds/classify-discipline.js",
|
|
@@ -6142,7 +6147,7 @@
|
|
|
6142
6147
|
{
|
|
6143
6148
|
"path": "src/module-api.js",
|
|
6144
6149
|
"mode": "0000644",
|
|
6145
|
-
"sha256": "
|
|
6150
|
+
"sha256": "afc9e2e6432d8a8f2e6a9812e92d1673f637c18b93555313c4881c9c44839914"
|
|
6146
6151
|
},
|
|
6147
6152
|
{
|
|
6148
6153
|
"path": "src/module-loader/catalog.js",
|
|
@@ -6577,7 +6582,7 @@
|
|
|
6577
6582
|
{
|
|
6578
6583
|
"path": "tests/claim_error_surface.mjs",
|
|
6579
6584
|
"mode": "0000644",
|
|
6580
|
-
"sha256": "
|
|
6585
|
+
"sha256": "33b0bbbb4c1a1f8b44a71c692a40b8a7e9cdde2e32483226484427ccf24238b5"
|
|
6581
6586
|
},
|
|
6582
6587
|
{
|
|
6583
6588
|
"path": "tests/claim_gate_open_blocker.mjs",
|
|
@@ -7042,7 +7047,7 @@
|
|
|
7042
7047
|
{
|
|
7043
7048
|
"path": "tests/goal_abuse_matrix.mjs",
|
|
7044
7049
|
"mode": "0000644",
|
|
7045
|
-
"sha256": "
|
|
7050
|
+
"sha256": "daf3a5942ce3f154d29618e4242557758db50d8c70046562995fc2c10d5ee954"
|
|
7046
7051
|
},
|
|
7047
7052
|
{
|
|
7048
7053
|
"path": "tests/goal_achievement.mjs",
|
|
@@ -8062,7 +8067,7 @@
|
|
|
8062
8067
|
{
|
|
8063
8068
|
"path": "tests/reward_gate.mjs",
|
|
8064
8069
|
"mode": "0000644",
|
|
8065
|
-
"sha256": "
|
|
8070
|
+
"sha256": "a21d9f4d3ff937d59b9361b330a2e5e93ded9c9a12d73434eb843cae9d1a3c74"
|
|
8066
8071
|
},
|
|
8067
8072
|
{
|
|
8068
8073
|
"path": "tests/reward_mode_cost_plus_only.mjs",
|
|
@@ -66,6 +66,11 @@ The server now **enforces** the one-claim half (task 1642): `POST /api/gds/claim
|
|
|
66
66
|
|
|
67
67
|
11. **On `session_already_has_active_claim`** (task 1642 — applies to **all ranks**): this *session* already holds an active claim, and a session may hold only one (one worktree → one branch → no commingled ships). The body names `active_task_id` + `active_task_title`. Surface them with `what_to_do`: either `/builder-ship N` (or `/builder-release N`) the in-flight task first, **or** open a fresh worktree/session for this new task and claim there. Do not retry in the same worktree — the refusal is the guard working. (`session_claim_check_failed` is the rare fail-closed variant when the server couldn't verify the session state; same remedy — retry, or use a fresh session/worktree.)
|
|
68
68
|
|
|
69
|
+
12. **On `GOAL_MEMBERSHIP_REQUIRED`** (task 1003137/1003138): the task lives in a goal the builder hasn't joined. The refusal is resolvable **in-session — never send the builder to the hall UI**. The body carries `goal_id`, `visibility`, and the `action` (`join` | `apply`); the CLI already renders the offer and, on a TTY, prompts `[Y/n]` itself. In an agent session (no TTY) the CLI prints the one-command path instead — narrate the same offer to the user:
|
|
70
|
+
- **`action: join`** (public goal): offer *"Task N lives in goal G ("title", public) — you're not a member. Join and claim?"* On yes, run `node scripts/gds/claim.js <task-id> --join` — it joins the goal and lands the claim in one command. On no, stop: declining leaves no membership row.
|
|
71
|
+
- **`action: apply`** (private goal, or a public goal with Archon-mediated protected scope): offer to file the application. On yes, run the same `--join` command — it files exactly one pending request and states plainly that **nothing can be claimed until an owner approves** (do not retry the claim; check the goal inbox later). Re-running reports "already pending", not an error. A private goal's refusal deliberately carries no title or scope — don't guess at them.
|
|
72
|
+
- **Never pass `--join` without the user's yes.** The owner chose the explicit-join model: the offer is a prompt, not a silent side effect.
|
|
73
|
+
|
|
69
74
|
## Constraints
|
|
70
75
|
|
|
71
76
|
- **Don't retry on conflict.** Failures here are intentional — they protect us from race conditions.
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/lifecycle/claim-feed.js — shaping + gating helpers for the claimable
|
|
4
|
+
// feed and the two claim paths, carved out of the oversized db.js (task 1003137;
|
|
5
|
+
// the fitness excess-lines ratchet is why these live here and not inline).
|
|
6
|
+
//
|
|
7
|
+
// Three residents, two halves of one rule plus a feed cap:
|
|
8
|
+
//
|
|
9
|
+
// - goalMembershipFailures — the CLAIM-time goal-membership gate (task
|
|
10
|
+
// 1003137, owner ruling 2026-08-19: a task that lives in a goal is
|
|
11
|
+
// claimable only by that goal's members — every goal, catch-alls included).
|
|
12
|
+
// EXTRACTED so BOTH `INSERT INTO claims` sites (db.claimTask and
|
|
13
|
+
// db.claimTasksBatch — the batch path duplicates claimTask's gates one by
|
|
14
|
+
// one, so an inline-only gate would be trivially bypassable via
|
|
15
|
+
// /claims/batch) run the identical check. The DECISION itself is the pure
|
|
16
|
+
// goal-authz.goalMembershipClaimRefusal; this wrapper only fetches the
|
|
17
|
+
// goal + membership facts on the caller's open txn client.
|
|
18
|
+
// - decorateGoalMembership — the FEED half of the same rule (owner chose
|
|
19
|
+
// filter-plus-show-as-joinable): a ready task whose goal the builder has
|
|
20
|
+
// not joined is never silently advertised as claimable — the exact trap
|
|
21
|
+
// task 1002650 closed for open blockers. The row stays in the feed but
|
|
22
|
+
// carries goal_membership { required, goal_id, visibility, action } so the
|
|
23
|
+
// caller renders it joinable. Reuses the SAME pure refusal fn, so feed and
|
|
24
|
+
// gate cannot disagree on join-vs-apply or on the leak rule (a private
|
|
25
|
+
// goal's title/scope never leave the server).
|
|
26
|
+
// - capNewcomerTasks — the newcomer-chore display cap (task 1266), moved
|
|
27
|
+
// verbatim from db.js.
|
|
28
|
+
//
|
|
29
|
+
// db.js re-exports all three, so callers and tests keep their import site.
|
|
30
|
+
|
|
31
|
+
// Pure kernel-adjacent matcher via the doorway (never the core file); the pure
|
|
32
|
+
// membership decision from the authority-model sibling.
|
|
33
|
+
const api = require('../../src/module-api');
|
|
34
|
+
const { scopeIncludesProtected } = api.moduleScopeMap;
|
|
35
|
+
const { goalMembershipClaimRefusal } = require('./goal-authz.js');
|
|
36
|
+
|
|
37
|
+
// The claim-txn half. `tasks` rows need { id, goal_id }; goal_id NULL rows are
|
|
38
|
+
// skipped (nullable by migration 160 — a NULL goal has no membership to check;
|
|
39
|
+
// a dangling goal_id can't occur, tasks.goal_id is FK'd). An Archon passes as
|
|
40
|
+
// the implicit member (the isMemberOrArchon convention). Runs on the caller's
|
|
41
|
+
// open txn `client` so the read shares the claim transaction's snapshot.
|
|
42
|
+
// Returns [{ task_id, refusal }] — empty when every task is claimable.
|
|
43
|
+
async function goalMembershipFailures({ client, tasks, builderId, builderRank }) {
|
|
44
|
+
const gated = tasks.filter((t) => t.goal_id != null);
|
|
45
|
+
if (gated.length === 0 || builderRank === 'archon') return [];
|
|
46
|
+
const goalIds = Array.from(new Set(gated.map((t) => Number(t.goal_id))));
|
|
47
|
+
const { rows: goalRows } = await client.query(
|
|
48
|
+
`SELECT g.id, g.title, g.visibility, g.scope_modules,
|
|
49
|
+
EXISTS (SELECT 1 FROM goal_members gm
|
|
50
|
+
WHERE gm.goal_id = g.id AND gm.builder_id = $2) AS is_member
|
|
51
|
+
FROM goals g
|
|
52
|
+
WHERE g.id = ANY($1::bigint[])`,
|
|
53
|
+
[goalIds, builderId]
|
|
54
|
+
);
|
|
55
|
+
const byId = new Map(goalRows.map((r) => [Number(r.id), r]));
|
|
56
|
+
const failures = [];
|
|
57
|
+
for (const t of gated) {
|
|
58
|
+
const goal = byId.get(Number(t.goal_id)) ?? null;
|
|
59
|
+
const refusal = goalMembershipClaimRefusal({
|
|
60
|
+
goal,
|
|
61
|
+
isMember: goal ? goal.is_member === true : false,
|
|
62
|
+
actorRank: builderRank,
|
|
63
|
+
scopeProtected: goal ? scopeIncludesProtected(goal.scope_modules) : false,
|
|
64
|
+
});
|
|
65
|
+
if (refusal) failures.push({ task_id: Number(t.id), refusal });
|
|
66
|
+
}
|
|
67
|
+
return failures;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The feed half. Consumes (and strips) the g_* scratch columns db.js's
|
|
71
|
+
// LEFT JOIN rides along; member / Archon / goal-less rows pass through shaped
|
|
72
|
+
// exactly as before task 1003137. The SQL folds "Archon" into g_is_member, so
|
|
73
|
+
// actorRank is passed null here — membership is the only remaining question.
|
|
74
|
+
function decorateGoalMembership(row) {
|
|
75
|
+
const { g_visibility, g_scope_modules, g_title, g_is_member, ...task } = row;
|
|
76
|
+
if (task.goal_id == null || g_visibility == null) return task; // no goal → not gated
|
|
77
|
+
const refusal = goalMembershipClaimRefusal({
|
|
78
|
+
goal: { id: task.goal_id, title: g_title, visibility: g_visibility },
|
|
79
|
+
isMember: g_is_member === true,
|
|
80
|
+
actorRank: null,
|
|
81
|
+
scopeProtected: scopeIncludesProtected(g_scope_modules),
|
|
82
|
+
});
|
|
83
|
+
if (!refusal) return task; // member/Archon → as today
|
|
84
|
+
return {
|
|
85
|
+
...task,
|
|
86
|
+
goal_membership: {
|
|
87
|
+
required: true,
|
|
88
|
+
goal_id: refusal.goal_id,
|
|
89
|
+
visibility: refusal.visibility,
|
|
90
|
+
action: refusal.action,
|
|
91
|
+
...('goal_title' in refusal ? { goal_title: refusal.goal_title } : {}),
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Cap the number of newcomer_friendly rows in a claimable feed to `cap`
|
|
97
|
+
// (task 1266). Pure + exported for unit testing. Non-newcomer (real version)
|
|
98
|
+
// rows pass through untouched and unbounded. When newcomer rows exceed the cap
|
|
99
|
+
// they're selected round-robin across the disciplines PRESENT among them — so
|
|
100
|
+
// each of the builder's lanes is represented before a slot goes to a duplicate
|
|
101
|
+
// of an already-shown discipline. Input ordering (priority, est, created) is
|
|
102
|
+
// preserved: we compute a keep-set of ids and then filter the original array.
|
|
103
|
+
function capNewcomerTasks(rows, cap = 3) {
|
|
104
|
+
const all = Array.isArray(rows) ? rows : [];
|
|
105
|
+
const newcomer = all.filter((t) => t && t.newcomer_friendly === true);
|
|
106
|
+
if (newcomer.length <= cap) return all;
|
|
107
|
+
|
|
108
|
+
// Group newcomer rows by discipline, preserving (a) input order within each
|
|
109
|
+
// group and (b) first-appearance order of the groups themselves — so the
|
|
110
|
+
// highest-priority discipline's chore is picked first.
|
|
111
|
+
const groups = new Map(); // discipline -> [rows], insertion-ordered
|
|
112
|
+
for (const t of newcomer) {
|
|
113
|
+
const disc = t.discipline || 'unclassified';
|
|
114
|
+
if (!groups.has(disc)) groups.set(disc, []);
|
|
115
|
+
groups.get(disc).push(t);
|
|
116
|
+
}
|
|
117
|
+
const lists = Array.from(groups.values());
|
|
118
|
+
|
|
119
|
+
// Round-robin: take index 0 from each group, then index 1, etc., until we've
|
|
120
|
+
// kept `cap` rows. Duplicate disciplines fill later passes once each lane has
|
|
121
|
+
// been represented.
|
|
122
|
+
const keep = new Set();
|
|
123
|
+
let depth = 0;
|
|
124
|
+
let progressed = true;
|
|
125
|
+
while (keep.size < cap && progressed) {
|
|
126
|
+
progressed = false;
|
|
127
|
+
for (const list of lists) {
|
|
128
|
+
if (depth < list.length) {
|
|
129
|
+
keep.add(list[depth].id);
|
|
130
|
+
progressed = true;
|
|
131
|
+
if (keep.size >= cap) break;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
depth += 1;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Filter the original (ordered) array: every non-newcomer row, plus the kept
|
|
138
|
+
// newcomer rows. Preserves the original feed ordering.
|
|
139
|
+
return all.filter((t) => t.newcomer_friendly !== true || keep.has(t.id));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = { goalMembershipFailures, decorateGoalMembership, capNewcomerTasks };
|
package/modules/lifecycle/db.js
CHANGED
|
@@ -46,6 +46,9 @@ const { claimEligibilityAllows, CLAIM_ANY, CLAIM_NEWCOMER } = require('./claim-e
|
|
|
46
46
|
// Pure kernel-adjacent matchers via the doorway (never the core file).
|
|
47
47
|
const { matchProtected } = api.permissionPathCheck;
|
|
48
48
|
const { touchesOverlap } = api.pathMatch;
|
|
49
|
+
// Claimable-feed shaping + the goal-membership claim gate (task 1003137; the
|
|
50
|
+
// newcomer cap of task 1266 moved there too) — see claim-feed.js.
|
|
51
|
+
const { goalMembershipFailures, decorateGoalMembership, capNewcomerTasks } = require('./claim-feed.js');
|
|
49
52
|
|
|
50
53
|
|
|
51
54
|
// ---------------------------------------------------------------------------
|
|
@@ -1923,6 +1926,8 @@ const SESSION_FIT_SQL = `
|
|
|
1923
1926
|
async function listClaimableTasks({ versionId = null, builderId = null, discipline = null, fit = null } = {}) {
|
|
1924
1927
|
const params = [];
|
|
1925
1928
|
const where = [];
|
|
1929
|
+
let goalJoin = '';
|
|
1930
|
+
let selectCols = 'v.*';
|
|
1926
1931
|
if (versionId) {
|
|
1927
1932
|
params.push(versionId);
|
|
1928
1933
|
where.push(`v.version_id = $${params.length}`);
|
|
@@ -1971,6 +1976,16 @@ async function listClaimableTasks({ versionId = null, builderId = null, discipli
|
|
|
1971
1976
|
WHEN 'archon' THEN 4 WHEN 'metic' THEN 3 WHEN 'thetes' THEN 2 WHEN 'xenos' THEN 1 ELSE 0
|
|
1972
1977
|
END
|
|
1973
1978
|
)`);
|
|
1979
|
+
// task 1003137: ride the goal + own-membership facts along so
|
|
1980
|
+
// decorateGoalMembership (claim-feed.js) can flag non-member rows joinable.
|
|
1981
|
+
// Same parameter index — all three reads are the same builderId. The
|
|
1982
|
+
// goal_members PK (goal_id, builder_id) means the LEFT JOIN never
|
|
1983
|
+
// multiplies rows; an Archon reads as member (isMemberOrArchon convention).
|
|
1984
|
+
goalJoin = ` LEFT JOIN goals g ON g.id = v.goal_id
|
|
1985
|
+
LEFT JOIN goal_members gm ON gm.goal_id = g.id AND gm.builder_id = $${params.length}`;
|
|
1986
|
+
selectCols = `v.*, g.visibility AS g_visibility, g.scope_modules AS g_scope_modules, g.title AS g_title,
|
|
1987
|
+
(gm.builder_id IS NOT NULL
|
|
1988
|
+
OR (SELECT rank FROM builders WHERE id = $${params.length}) = 'archon') AS g_is_member`;
|
|
1974
1989
|
}
|
|
1975
1990
|
// Autonomy / parallel-eligibility filter. Joining the session-fit relation
|
|
1976
1991
|
// (also a SELECT t.* shape) makes bare column names like priority/created_at
|
|
@@ -1988,12 +2003,14 @@ async function listClaimableTasks({ versionId = null, builderId = null, discipli
|
|
|
1988
2003
|
// recent actuals; ordering on the raw estimate puts oversized tasks at the
|
|
1989
2004
|
// bottom even when their actual time-to-ship is short. See migration 015.
|
|
1990
2005
|
const sql =
|
|
1991
|
-
`SELECT
|
|
2006
|
+
`SELECT ${selectCols} FROM (${CLAIMABLE_SQL}) v` + goalJoin + fitJoin +
|
|
1992
2007
|
(where.length ? ` WHERE ${where.join(' AND ')}` : '') +
|
|
1993
2008
|
` ORDER BY v.priority NULLS LAST,
|
|
1994
2009
|
COALESCE(v.est_minutes_calibrated, v.est_minutes) NULLS LAST,
|
|
1995
2010
|
v.created_at`;
|
|
1996
|
-
const { rows } = await pool.query(sql, params);
|
|
2011
|
+
const { rows: rawRows } = await pool.query(sql, params);
|
|
2012
|
+
// task 1003137: strip g_* scratch columns, flag non-member rows joinable.
|
|
2013
|
+
const rows = rawRows.map(decorateGoalMembership);
|
|
1997
2014
|
// SR-12 / G2 (task 994): when the autonomous/parallel runner is asking, drop
|
|
1998
2015
|
// any task whose declared touches[] reach the permission/methodology/deploy
|
|
1999
2016
|
// core. The runner ships as Archon straight to main, so these must never be
|
|
@@ -2002,7 +2019,12 @@ async function listClaimableTasks({ versionId = null, builderId = null, discipli
|
|
|
2002
2019
|
// with the grader pre-pass, pre-push hook, and main-audit — no second source
|
|
2003
2020
|
// of truth to drift.
|
|
2004
2021
|
if (fit === 'autonomous' || fit === 'parallel') {
|
|
2005
|
-
|
|
2022
|
+
// task 1003137: membership-required rows are DROPPED, not flagged — the
|
|
2023
|
+
// claim gate will refuse them and an autonomous runner can't answer a join
|
|
2024
|
+
// prompt. (No builderId → no membership facts; the claim gate stays the
|
|
2025
|
+
// hard control.)
|
|
2026
|
+
return rows.filter((t) => !taskTouchesProtectedPath(t)
|
|
2027
|
+
&& !(t.goal_membership && t.goal_membership.required === true));
|
|
2006
2028
|
}
|
|
2007
2029
|
// Newcomer-chore display cap (task 1266, owner decision 2026-06-19). The
|
|
2008
2030
|
// newcomer-floor keeps a deep supply pool (floor: 3 artist + 2 ideator) so
|
|
@@ -2017,53 +2039,6 @@ async function listClaimableTasks({ versionId = null, builderId = null, discipli
|
|
|
2017
2039
|
return capNewcomerTasks(rows);
|
|
2018
2040
|
}
|
|
2019
2041
|
|
|
2020
|
-
// Cap the number of newcomer_friendly rows in a claimable feed to `cap`
|
|
2021
|
-
// (task 1266). Pure + exported for unit testing. Non-newcomer (real version)
|
|
2022
|
-
// rows pass through untouched and unbounded. When newcomer rows exceed the cap
|
|
2023
|
-
// they're selected round-robin across the disciplines PRESENT among them — so
|
|
2024
|
-
// each of the builder's lanes is represented before a slot goes to a duplicate
|
|
2025
|
-
// of an already-shown discipline. Input ordering (priority, est, created) is
|
|
2026
|
-
// preserved: we compute a keep-set of ids and then filter the original array.
|
|
2027
|
-
|
|
2028
|
-
function capNewcomerTasks(rows, cap = 3) {
|
|
2029
|
-
const all = Array.isArray(rows) ? rows : [];
|
|
2030
|
-
const newcomer = all.filter((t) => t && t.newcomer_friendly === true);
|
|
2031
|
-
if (newcomer.length <= cap) return all;
|
|
2032
|
-
|
|
2033
|
-
// Group newcomer rows by discipline, preserving (a) input order within each
|
|
2034
|
-
// group and (b) first-appearance order of the groups themselves — so the
|
|
2035
|
-
// highest-priority discipline's chore is picked first.
|
|
2036
|
-
const groups = new Map(); // discipline -> [rows], insertion-ordered
|
|
2037
|
-
for (const t of newcomer) {
|
|
2038
|
-
const disc = t.discipline || 'unclassified';
|
|
2039
|
-
if (!groups.has(disc)) groups.set(disc, []);
|
|
2040
|
-
groups.get(disc).push(t);
|
|
2041
|
-
}
|
|
2042
|
-
const lists = Array.from(groups.values());
|
|
2043
|
-
|
|
2044
|
-
// Round-robin: take index 0 from each group, then index 1, etc., until we've
|
|
2045
|
-
// kept `cap` rows. Duplicate disciplines fill later passes once each lane has
|
|
2046
|
-
// been represented.
|
|
2047
|
-
const keep = new Set();
|
|
2048
|
-
let depth = 0;
|
|
2049
|
-
let progressed = true;
|
|
2050
|
-
while (keep.size < cap && progressed) {
|
|
2051
|
-
progressed = false;
|
|
2052
|
-
for (const list of lists) {
|
|
2053
|
-
if (depth < list.length) {
|
|
2054
|
-
keep.add(list[depth].id);
|
|
2055
|
-
progressed = true;
|
|
2056
|
-
if (keep.size >= cap) break;
|
|
2057
|
-
}
|
|
2058
|
-
}
|
|
2059
|
-
depth += 1;
|
|
2060
|
-
}
|
|
2061
|
-
|
|
2062
|
-
// Filter the original (ordered) array: every non-newcomer row, plus the kept
|
|
2063
|
-
// newcomer rows. Preserves the original feed ordering.
|
|
2064
|
-
return all.filter((t) => t.newcomer_friendly !== true || keep.has(t.id));
|
|
2065
|
-
}
|
|
2066
|
-
|
|
2067
2042
|
// SR-12 / G2 (task 994): does this task's declared touches[] overlap any
|
|
2068
2043
|
// permission-sensitive (ADR 0043) path? Pure + exported for unit testing.
|
|
2069
2044
|
// Returns true if ANY declared touch matches a protected glob — such a task is
|
|
@@ -2659,7 +2634,7 @@ async function claimTask({ taskId, builderId, worktreeName, headSha, creatorSess
|
|
|
2659
2634
|
}
|
|
2660
2635
|
|
|
2661
2636
|
const { rows: taskRows } = await client.query(
|
|
2662
|
-
`SELECT id, status, touches, newcomer_friendly, requires_rank, needs_migration, module_key, credits_reward, reward_gate_exempt, est_minutes, kind FROM tasks WHERE id = $1 FOR UPDATE`,
|
|
2637
|
+
`SELECT id, status, touches, newcomer_friendly, requires_rank, needs_migration, module_key, credits_reward, reward_gate_exempt, est_minutes, kind, goal_id FROM tasks WHERE id = $1 FOR UPDATE`,
|
|
2663
2638
|
[taskId]
|
|
2664
2639
|
);
|
|
2665
2640
|
if (taskRows.length === 0) {
|
|
@@ -2730,6 +2705,15 @@ async function claimTask({ taskId, builderId, worktreeName, headSha, creatorSess
|
|
|
2730
2705
|
};
|
|
2731
2706
|
}
|
|
2732
2707
|
|
|
2708
|
+
// task 1003137 (owner ruling 2026-08-19): a task's goal requires MEMBERSHIP —
|
|
2709
|
+
// every goal, catch-alls included; goal_id NULL is NOT gated. After the rank
|
|
2710
|
+
// gates (membership only narrows — it must never mask a rank refusal),
|
|
2711
|
+
// before deps. Decision + leak rule: goal-authz.goalMembershipClaimRefusal;
|
|
2712
|
+
// the SAME extracted check runs in claimTasksBatch (the only other INSERT
|
|
2713
|
+
// INTO claims site) — see claim-feed.js.
|
|
2714
|
+
const goalRefusals = await goalMembershipFailures({ client, tasks: [task], builderId, builderRank });
|
|
2715
|
+
if (goalRefusals.length > 0) throw goalRefusals[0].refusal;
|
|
2716
|
+
|
|
2733
2717
|
// The claim gate (ADR 0015, generalized in task 1516 / ADR 0086 §5): a task
|
|
2734
2718
|
// can't be claimed while any dependency is un-done — its OWN edges plus those
|
|
2735
2719
|
// inherited from its goal/criteria, where "done" = task shipped | criterion
|
|
@@ -2986,7 +2970,7 @@ async function claimTasksBatch({ taskIds, builderId, worktreePrefix, creatorSess
|
|
|
2986
2970
|
|
|
2987
2971
|
// Lock every requested task row up front (sorted to avoid deadlocks).
|
|
2988
2972
|
const { rows: taskRows } = await client.query(
|
|
2989
|
-
`SELECT id, status, touches, newcomer_friendly, requires_rank, needs_migration, module_key FROM tasks WHERE id = ANY($1::bigint[]) ORDER BY id FOR UPDATE`,
|
|
2973
|
+
`SELECT id, status, touches, newcomer_friendly, requires_rank, needs_migration, module_key, goal_id FROM tasks WHERE id = ANY($1::bigint[]) ORDER BY id FOR UPDATE`,
|
|
2990
2974
|
[uniqIds]
|
|
2991
2975
|
);
|
|
2992
2976
|
|
|
@@ -3069,6 +3053,14 @@ async function claimTasksBatch({ taskIds, builderId, worktreePrefix, creatorSess
|
|
|
3069
3053
|
}
|
|
3070
3054
|
}
|
|
3071
3055
|
|
|
3056
|
+
// task 1003137: the SAME extracted goal-membership gate as claimTask — this
|
|
3057
|
+
// path owns the repo's only other INSERT INTO claims, so an inline-only gate
|
|
3058
|
+
// would be bypassable via batch. Same ladder position: after rank, before deps.
|
|
3059
|
+
for (const f of await goalMembershipFailures({ client, tasks: taskRows, builderId, builderRank })) {
|
|
3060
|
+
const { code, ...rest } = f.refusal;
|
|
3061
|
+
failures.push({ task_id: f.task_id, reason: code, ...rest });
|
|
3062
|
+
}
|
|
3063
|
+
|
|
3072
3064
|
// Per-task dependency check (ADR 0015, generalized task 1516). An EXTERNAL dep
|
|
3073
3065
|
// (not in this batch) must already be done; a batch-internal dep isn't done yet
|
|
3074
3066
|
// at claim time but doesn't block — the batch is worked serially. "Internal"
|
|
@@ -3351,7 +3343,7 @@ async function validateClaimBatch({ taskIds, builderId }) {
|
|
|
3351
3343
|
const builderRank = builderRows[0] ? builderRows[0].rank : null;
|
|
3352
3344
|
|
|
3353
3345
|
const { rows: taskRows } = await pool.query(
|
|
3354
|
-
`SELECT id, status, touches, newcomer_friendly, requires_rank, module_key
|
|
3346
|
+
`SELECT id, status, touches, newcomer_friendly, requires_rank, module_key, goal_id
|
|
3355
3347
|
FROM tasks WHERE id = ANY($1::bigint[])`,
|
|
3356
3348
|
[uniqIds]
|
|
3357
3349
|
);
|
|
@@ -3393,6 +3385,12 @@ async function validateClaimBatch({ taskIds, builderId }) {
|
|
|
3393
3385
|
}
|
|
3394
3386
|
}
|
|
3395
3387
|
|
|
3388
|
+
// task 1003137: goal-membership, kept in sync with claimTasksBatch (see claim-feed.js).
|
|
3389
|
+
for (const f of await goalMembershipFailures({ client: pool, tasks: taskRows, builderId, builderRank })) {
|
|
3390
|
+
const { code, ...rest } = f.refusal;
|
|
3391
|
+
push(f.task_id, { reason: code, ...rest });
|
|
3392
|
+
}
|
|
3393
|
+
|
|
3396
3394
|
// Dependencies — external to the batch only (a batch-internal dep is fine;
|
|
3397
3395
|
// the batch is worked serially, same rule as claimTasksBatch).
|
|
3398
3396
|
const batchIdSet = new Set(uniqIds);
|
|
@@ -5688,6 +5686,7 @@ module.exports = {
|
|
|
5688
5686
|
listClaimableTasks,
|
|
5689
5687
|
capNewcomerTasks,
|
|
5690
5688
|
taskTouchesProtectedPath, // SR-12 / G2 (task 994): autonomous-fit protected-path exclusion (pure, testable)
|
|
5689
|
+
decorateGoalMembership, // task 1003137: feed-side joinable flag (pure, testable)
|
|
5691
5690
|
rankCrossDisciplineRecommendations,
|
|
5692
5691
|
listActiveClaims,
|
|
5693
5692
|
updateTaskStatus,
|
|
@@ -62,4 +62,59 @@ function authorizeGoalManagement({ goal, actorId, actorRank, actorMembership })
|
|
|
62
62
|
return { ok: true };
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
-
|
|
65
|
+
// goalMembershipClaimRefusal — the CLAIM-time membership gate (task 1003137).
|
|
66
|
+
// Owner ruling 2026-08-19: a task that lives in a goal is claimable only by that
|
|
67
|
+
// goal's members — EVERY goal, the '<version> — general' catch-alls included.
|
|
68
|
+
// Safe precisely because the refusal is actionable in-session: joining a public
|
|
69
|
+
// goal costs one 'yes' (POST /goals/:id/join), a private/protected one an
|
|
70
|
+
// application (POST /goals/:id/requests).
|
|
71
|
+
//
|
|
72
|
+
// Returns null when the claim may proceed, else the refusal payload the claim
|
|
73
|
+
// paths throw (code GOAL_MEMBERSHIP_REQUIRED, HTTP 403 at the route). PURE —
|
|
74
|
+
// both INSERT INTO claims sites (claimTask + claimTasksBatch, db.js) feed it
|
|
75
|
+
// through db.goalMembershipFailures so the batch path cannot drift from the
|
|
76
|
+
// single-claim path.
|
|
77
|
+
//
|
|
78
|
+
// The decision, in order (fail-closed on the membership question itself):
|
|
79
|
+
// - no goal → not gated. tasks.goal_id is NULLABLE (migration 160) and every
|
|
80
|
+
// version created after it gets no catch-all, so goal_id IS NULL is COMMON —
|
|
81
|
+
// a NULL goal has no membership to check. (A dangling goal_id can't occur:
|
|
82
|
+
// tasks.goal_id is FK'd to goals(id).)
|
|
83
|
+
// - Archon → pass. An Archon is an implicit member everywhere in the goal
|
|
84
|
+
// surface (isMemberOrArchon, POST /goals/:id/join, isGoalOwnerOrManager);
|
|
85
|
+
// the claim gate follows that single convention rather than minting a
|
|
86
|
+
// second membership model. Membership only NARROWS what the rank gates
|
|
87
|
+
// (which run before this in claimTask's ladder) already allow — it never
|
|
88
|
+
// widens what a Xenos reaches.
|
|
89
|
+
// - member → pass.
|
|
90
|
+
// - else refuse, naming the action: 'join' for a public unprotected goal,
|
|
91
|
+
// 'apply' for a private one OR a protected-scope one (self-join on
|
|
92
|
+
// protected scope is Archon-mediated — routes/goals.js POST /:id/join).
|
|
93
|
+
//
|
|
94
|
+
// LEAK RULE (the goal_abuse_matrix ordering discipline, gotcha d): a PRIVATE
|
|
95
|
+
// goal's refusal carries goal_id, visibility and the action ONLY — never the
|
|
96
|
+
// title, never scope. A public goal's title is public; it rides along so the
|
|
97
|
+
// CLI can name what to join.
|
|
98
|
+
function goalMembershipClaimRefusal({ goal, isMember, actorRank, scopeProtected }) {
|
|
99
|
+
if (!goal) return null; // goal_id NULL — no membership to check, NOT gated
|
|
100
|
+
if (actorRank === 'archon') return null;
|
|
101
|
+
if (isMember) return null;
|
|
102
|
+
const isPrivate = goal.visibility === 'private';
|
|
103
|
+
const action = (isPrivate || scopeProtected === true) ? 'apply' : 'join';
|
|
104
|
+
const goalId = Number(goal.id);
|
|
105
|
+
return {
|
|
106
|
+
code: 'GOAL_MEMBERSHIP_REQUIRED',
|
|
107
|
+
goal_id: goalId,
|
|
108
|
+
visibility: goal.visibility,
|
|
109
|
+
action,
|
|
110
|
+
...(isPrivate ? {} : { goal_title: goal.title }),
|
|
111
|
+
what_to_do: action === 'join'
|
|
112
|
+
? `Join the goal (POST /api/bongos/goals/${goalId}/join), then retry the claim.`
|
|
113
|
+
: `Apply to join (POST /api/bongos/goals/${goalId}/requests); nothing can be claimed until an owner approves.`,
|
|
114
|
+
message: isPrivate
|
|
115
|
+
? `This task lives in goal ${goalId}, which is private — you are not a member.`
|
|
116
|
+
: `This task lives in goal ${goalId} ("${goal.title}") — you are not a member.`,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
module.exports = { isGoalOwner, isGoalOwnerOrManager, authorizeGoalManagement, goalMembershipClaimRefusal };
|
|
@@ -301,6 +301,12 @@ module.exports = function buildClaimsRouter() {
|
|
|
301
301
|
// builder's. Same 403 semantics as the Xenos gate; the body carries
|
|
302
302
|
// { required, actual } so the CLI can render a clean message.
|
|
303
303
|
err.code === 'INSUFFICIENT_RANK' ? 403 :
|
|
304
|
+
// task 1003137: the task's goal requires membership. The body carries
|
|
305
|
+
// goal_id, visibility, and the action (join|apply) + what_to_do —
|
|
306
|
+
// enough for the CLI to offer the join inline (task 1003138). A
|
|
307
|
+
// private goal's body deliberately omits its title/scope (leak rule
|
|
308
|
+
// in goal-authz.goalMembershipClaimRefusal).
|
|
309
|
+
err.code === 'GOAL_MEMBERSHIP_REQUIRED' ? 403 :
|
|
304
310
|
500;
|
|
305
311
|
// V4.R14 (#952): a TASK_NOT_READY 409 names the way forward instead of
|
|
306
312
|
// leaving the caller to discover the promote route by spelunking (#506).
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.280",
|
|
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.280",
|
|
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.280",
|
|
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]
|
|
5
|
+
// node scripts/gds/claim.js <task-id> [--worktree NAME] [--enforce-touches] [--join]
|
|
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.
|
|
@@ -165,22 +165,31 @@ function printClaimCard(task, widgetsEnabled = true) {
|
|
|
165
165
|
} catch (_) { /* never block a claim on the card */ }
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
+
// task 1003138: the envelope/flat normalization from formatClaimFailure, pulled
|
|
169
|
+
// out so the goal-join flow reads the same { code, message, d } regardless of
|
|
170
|
+
// response shape. Pure + exported (tested through both consumers).
|
|
171
|
+
function claimErrorInfo(r) {
|
|
172
|
+
const errRaw = r && r.data ? r.data.error : undefined;
|
|
173
|
+
const isEnvelope = errRaw !== null && typeof errRaw === 'object';
|
|
174
|
+
return {
|
|
175
|
+
code: isEnvelope ? errRaw.code : errRaw,
|
|
176
|
+
message: isEnvelope ? errRaw.message : undefined,
|
|
177
|
+
// Structured fields live under .details in the envelope; fall back to the
|
|
178
|
+
// top-level body for the legacy flat shape (so both keep working).
|
|
179
|
+
d: { ...(r && r.data ? r.data : {}), ...((isEnvelope && errRaw.details) || {}) },
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
168
183
|
// task 1476: render a failed claim as GUIDANCE, not a raw object dump. The API
|
|
169
184
|
// returns the R01 error envelope (ADR 0116): { error: { code, message, details } }.
|
|
170
|
-
// Older paths returned a flat { error: 'CODE', ...fields } shape.
|
|
171
|
-
// BOTH — without it `r.data.error` is now an object (never === a
|
|
172
|
-
// branch below silently missed and the refusal printed as
|
|
173
|
-
// (no console / no process.exit) + exported so it can be
|
|
174
|
-
// real envelope shapes. Returns { lines, exit }.
|
|
185
|
+
// Older paths returned a flat { error: 'CODE', ...fields } shape. claimErrorInfo
|
|
186
|
+
// normalizes BOTH — without it `r.data.error` is now an object (never === a
|
|
187
|
+
// string), so every branch below silently missed and the refusal printed as
|
|
188
|
+
// `[object Object]`. Pure (no console / no process.exit) + exported so it can be
|
|
189
|
+
// unit-tested against the real envelope shapes. Returns { lines, exit }.
|
|
175
190
|
function formatClaimFailure(r, taskId) {
|
|
176
191
|
const lines = [];
|
|
177
|
-
const
|
|
178
|
-
const isEnvelope = errRaw !== null && typeof errRaw === 'object';
|
|
179
|
-
const code = isEnvelope ? errRaw.code : errRaw;
|
|
180
|
-
const message = isEnvelope ? errRaw.message : undefined;
|
|
181
|
-
// Structured fields live under .details in the envelope; fall back to the
|
|
182
|
-
// top-level body for the legacy flat shape (so both keep working).
|
|
183
|
-
const d = { ...(r && r.data ? r.data : {}), ...((isEnvelope && errRaw.details) || {}) };
|
|
192
|
+
const { code, message, d } = claimErrorInfo(r);
|
|
184
193
|
|
|
185
194
|
// #766: a generic onboarding stage gate (kept rendering cleanly, not as raw JSON).
|
|
186
195
|
if (code === 'stage_required') {
|
|
@@ -263,17 +272,147 @@ function formatClaimFailure(r, taskId) {
|
|
|
263
272
|
lines.push('Resolve it first (/blocker-solve N drives one to done; resolving auto-promotes its linked tasks) — or pick another task from /builder-start.');
|
|
264
273
|
return { lines, exit: 1 };
|
|
265
274
|
}
|
|
275
|
+
if (code === 'GOAL_MEMBERSHIP_REQUIRED') {
|
|
276
|
+
// task 1003138: the non-interactive rendering of the membership refusal —
|
|
277
|
+
// the interactive offer (runGoalJoinFlow) normally intercepts this code
|
|
278
|
+
// before it gets here; this branch covers a retry that re-refused, an
|
|
279
|
+
// unexpected body, or a decline.
|
|
280
|
+
lines.push(`Task #${taskId} lives in goal ${d.goal_id}${d.goal_title ? ` ("${d.goal_title}")` : ''}${d.visibility ? ` (${d.visibility})` : ''} — you are not a member.`);
|
|
281
|
+
lines.push(d.action === 'apply'
|
|
282
|
+
? ` Apply to join (POST /api/bongos/goals/${d.goal_id}/requests) — nothing can be claimed until an owner approves. Re-run with --join to file the application from here.`
|
|
283
|
+
: ` Join and claim in one command: node scripts/gds/claim.js ${taskId} --join`);
|
|
284
|
+
return { lines, exit: 1 };
|
|
285
|
+
}
|
|
266
286
|
// Fallback — still readable: the message + code, never a raw [object Object].
|
|
267
287
|
lines.push(`Claim failed (${r ? r.status : '?'})${code ? ` [${code}]` : ''}${message ? `: ${message}` : ''}.`);
|
|
268
288
|
return { lines, exit: 1 };
|
|
269
289
|
}
|
|
270
290
|
|
|
291
|
+
// task 1003138 (owner ask, verbatim: 'claude should offer to join so they dont
|
|
292
|
+
// need to go through the ui') — the goal-membership refusal (403
|
|
293
|
+
// GOAL_MEMBERSHIP_REQUIRED, the task-1003137 gate) is resolvable in-session.
|
|
294
|
+
// goalMembershipOfferPlan is the PURE half: given the refusal details it names
|
|
295
|
+
// the offer to make. Exported for unit tests; the I/O lives in runGoalJoinFlow.
|
|
296
|
+
// Returns null when the body is missing the fields the flow needs (falls back
|
|
297
|
+
// to formatClaimFailure), else { action, headerLines, question }.
|
|
298
|
+
function goalMembershipOfferPlan(d, taskId) {
|
|
299
|
+
if (!d || d.goal_id == null || (d.action !== 'join' && d.action !== 'apply')) return null;
|
|
300
|
+
const g = d.goal_id;
|
|
301
|
+
if (d.action === 'join') {
|
|
302
|
+
return {
|
|
303
|
+
action: 'join',
|
|
304
|
+
headerLines: [
|
|
305
|
+
`✗ Task ${taskId} lives in goal ${g}`,
|
|
306
|
+
...(d.goal_title ? [` "${d.goal_title}" (${d.visibility || 'public'})`] : []),
|
|
307
|
+
' You are not a member.',
|
|
308
|
+
],
|
|
309
|
+
question: `Join goal ${g} and claim? [Y/n] `,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
// 'apply' — a private goal, or a public one whose scope is Archon-mediated
|
|
313
|
+
// (the server folds both to apply; a pending request grants nothing yet).
|
|
314
|
+
return {
|
|
315
|
+
action: 'apply',
|
|
316
|
+
headerLines: d.visibility === 'private'
|
|
317
|
+
? [`✗ Goal ${g} is private.`]
|
|
318
|
+
: [
|
|
319
|
+
`✗ Task ${taskId} lives in goal ${g}${d.goal_title ? ` ("${d.goal_title}")` : ''} — joining needs approval (Archon-mediated scope).`,
|
|
320
|
+
],
|
|
321
|
+
question: 'Apply to join? [Y/n] ',
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// One yes/no on the real terminal (default yes — the owner-approved [Y/n]
|
|
326
|
+
// shape). Returns null when there is no TTY to ask — the caller then prints
|
|
327
|
+
// the --join re-run hint instead of hanging a non-interactive session.
|
|
328
|
+
async function promptYesNo(question) {
|
|
329
|
+
if (!process.stdin.isTTY) return null;
|
|
330
|
+
const readline = require('node:readline/promises');
|
|
331
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stderr });
|
|
332
|
+
try {
|
|
333
|
+
const a = (await rl.question(question)).trim().toLowerCase();
|
|
334
|
+
return a === '' || a === 'y' || a === 'yes';
|
|
335
|
+
} finally {
|
|
336
|
+
rl.close();
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// The I/O half of the offer. Outcomes: 'retry' (joined — re-POST the claim),
|
|
341
|
+
// 'stop' (declined / filed / blocked — everything already printed, exit 1),
|
|
342
|
+
// 'unhandled' (fall through to formatClaimFailure). Never joins or files
|
|
343
|
+
// without a yes: --join is the standing yes for non-interactive sessions.
|
|
344
|
+
async function runGoalJoinFlow({ api, d, taskId, assumeYes }) {
|
|
345
|
+
const plan = goalMembershipOfferPlan(d, taskId);
|
|
346
|
+
if (!plan) return 'unhandled';
|
|
347
|
+
const goalId = d.goal_id;
|
|
348
|
+
for (const line of plan.headerLines) console.error(line);
|
|
349
|
+
|
|
350
|
+
const yes = assumeYes ? true : await promptYesNo(plan.question);
|
|
351
|
+
if (yes === null) {
|
|
352
|
+
// Non-interactive (an agent session): make the one-command path explicit.
|
|
353
|
+
console.error(plan.action === 'join'
|
|
354
|
+
? ` To join goal ${goalId} and claim in one command: node scripts/gds/claim.js ${taskId} --join`
|
|
355
|
+
: ` To file the application from here: node scripts/gds/claim.js ${taskId} --join (nothing claimable until approved)`);
|
|
356
|
+
return 'stop';
|
|
357
|
+
}
|
|
358
|
+
if (!yes) {
|
|
359
|
+
// The owner chose the explicit-join model — declining leaves NO membership
|
|
360
|
+
// row and NO request.
|
|
361
|
+
console.error(plan.action === 'join'
|
|
362
|
+
? ' Declined — nothing joined, nothing claimed.'
|
|
363
|
+
: ' Declined — no application filed, nothing claimed.');
|
|
364
|
+
return 'stop';
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
if (plan.action === 'join') {
|
|
368
|
+
const jr = await api.goals.postGoalsIdJoin({ id: goalId, body: {} });
|
|
369
|
+
if (jr.status === 200 || jr.status === 201) {
|
|
370
|
+
console.error(`+ joined goal ${goalId} (member)`);
|
|
371
|
+
return 'retry';
|
|
372
|
+
}
|
|
373
|
+
const j = claimErrorInfo(jr);
|
|
374
|
+
if (j.code === 'archon_approval_required') {
|
|
375
|
+
// The third branch, handled honestly (routes/goals.js:615): a PUBLIC goal
|
|
376
|
+
// with protected scope is not self-joinable — don't loop, name the path.
|
|
377
|
+
console.error(`✗ Goal ${goalId}'s scope is Archon-mediated — self-join is not allowed.`);
|
|
378
|
+
console.error(` Request to join instead: node scripts/gds/claim.js ${taskId} --join (after the server re-offers apply), or ask an Archon to add you.`);
|
|
379
|
+
return 'stop';
|
|
380
|
+
}
|
|
381
|
+
if (j.code === 'goal_private') {
|
|
382
|
+
// Visibility flipped between refusal and join — report, don't loop.
|
|
383
|
+
console.error(`✗ Goal ${goalId} is private now — request to join (POST /api/bongos/goals/${goalId}/requests) and wait for approval.`);
|
|
384
|
+
return 'stop';
|
|
385
|
+
}
|
|
386
|
+
console.error(`✗ join failed (${jr.status})${j.code ? ` [${j.code}]` : ''}${j.message ? `: ${j.message}` : ''}.`);
|
|
387
|
+
return 'stop';
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// 'apply' — file the request; a pending row grants nothing, so never retry
|
|
391
|
+
// the claim here.
|
|
392
|
+
const ar = await api.goals.postGoalsIdRequests({ id: goalId, body: {} });
|
|
393
|
+
if (ar.status === 201) {
|
|
394
|
+
console.error('+ request filed — pending owner approval');
|
|
395
|
+
console.error(' (nothing to claim until approved)');
|
|
396
|
+
return 'stop';
|
|
397
|
+
}
|
|
398
|
+
const a = claimErrorInfo(ar);
|
|
399
|
+
if (a.code === 'request_pending') {
|
|
400
|
+
// The core_188 partial-unique: one pending ask per goal per builder —
|
|
401
|
+
// re-running reports, it does not error.
|
|
402
|
+
console.error('• already pending — your earlier request for this goal awaits owner approval.');
|
|
403
|
+
console.error(' (nothing to claim until approved)');
|
|
404
|
+
return 'stop';
|
|
405
|
+
}
|
|
406
|
+
console.error(`✗ application failed (${ar.status})${a.code ? ` [${a.code}]` : ''}${a.message ? `: ${a.message}` : ''}.`);
|
|
407
|
+
return 'stop';
|
|
408
|
+
}
|
|
409
|
+
|
|
271
410
|
async function main() {
|
|
272
411
|
await requireSession();
|
|
273
412
|
const args = process.argv.slice(2);
|
|
274
413
|
const taskId = Number(args[0]);
|
|
275
414
|
if (!Number.isFinite(taskId)) {
|
|
276
|
-
console.error('usage: node scripts/gds/claim.js <task-id> [--worktree NAME] [--enforce-touches]');
|
|
415
|
+
console.error('usage: node scripts/gds/claim.js <task-id> [--worktree NAME] [--enforce-touches] [--join]');
|
|
277
416
|
process.exit(2);
|
|
278
417
|
}
|
|
279
418
|
const wIdx = args.indexOf('--worktree');
|
|
@@ -287,10 +426,15 @@ async function main() {
|
|
|
287
426
|
// --enforce-touches so adjacency becomes a HARD refusal (it fans concurrent
|
|
288
427
|
// unsupervised agents that could clobber each other on shared files).
|
|
289
428
|
const enforceTouches = args.includes('--enforce-touches');
|
|
429
|
+
// task 1003138: --join is the standing yes to the goal-membership offer —
|
|
430
|
+
// join (public) or file the application (private/protected) and, for a join,
|
|
431
|
+
// retry the claim, all in one command. Without it an interactive session is
|
|
432
|
+
// asked [Y/n]; a non-TTY session is shown the --join re-run line.
|
|
433
|
+
const assumeYes = args.includes('--join');
|
|
290
434
|
const headSha = captureHeadSha();
|
|
291
435
|
|
|
292
436
|
const api = await cliClient();
|
|
293
|
-
const
|
|
437
|
+
const postClaim = () => api.claims.postClaims({
|
|
294
438
|
body: {
|
|
295
439
|
task_id: taskId,
|
|
296
440
|
worktree_name: worktree,
|
|
@@ -298,6 +442,19 @@ async function main() {
|
|
|
298
442
|
enforce_touches: enforceTouches,
|
|
299
443
|
},
|
|
300
444
|
});
|
|
445
|
+
let r = await postClaim();
|
|
446
|
+
// task 1003138: a goal-membership refusal (the task-1003137 gate) is
|
|
447
|
+
// resolvable in-session — offer the join/application, and on a successful
|
|
448
|
+
// join retry the claim ONCE (a second refusal falls through to the plain
|
|
449
|
+
// rendering; never loop).
|
|
450
|
+
if (r.status !== 201 && claimErrorInfo(r).code === 'GOAL_MEMBERSHIP_REQUIRED') {
|
|
451
|
+
const outcome = await runGoalJoinFlow({ api, d: claimErrorInfo(r).d, taskId, assumeYes });
|
|
452
|
+
if (outcome === 'retry') {
|
|
453
|
+
r = await postClaim();
|
|
454
|
+
} else if (outcome === 'stop') {
|
|
455
|
+
process.exit(1); // everything already printed; no claim landed
|
|
456
|
+
}
|
|
457
|
+
}
|
|
301
458
|
if (r.status === 201) {
|
|
302
459
|
const { claim, task } = r.data;
|
|
303
460
|
console.log(`Claimed task #${task.id}: ${task.title}`);
|
|
@@ -433,4 +590,4 @@ if (require.main === module) {
|
|
|
433
590
|
});
|
|
434
591
|
}
|
|
435
592
|
|
|
436
|
-
module.exports = { formatClaimFailure };
|
|
593
|
+
module.exports = { formatClaimFailure, claimErrorInfo, goalMembershipOfferPlan };
|
package/src/module-api.js
CHANGED
|
@@ -983,8 +983,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
983
983
|
// landed since 1.19.276 with no explicit bump. run 32503053532. (task 1002620)
|
|
984
984
|
// 1.19.278 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
985
985
|
// landed since 1.19.277 with no explicit bump. run 32508270995. (task 1002620)
|
|
986
|
+
// 1.19.279 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
987
|
+
// landed since 1.19.278 with no explicit bump. run 32521664476. (task 1002620)
|
|
988
|
+
// 1.19.280 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
989
|
+
// landed since 1.19.279 with no explicit bump. run 32523185119. (task 1002620)
|
|
986
990
|
// ---------------------------------------------------------------------------
|
|
987
|
-
const CORE_VERSION = '1.19.
|
|
991
|
+
const CORE_VERSION = '1.19.280'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
988
992
|
|
|
989
993
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
990
994
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -14,7 +14,7 @@ import { strict as assert } from 'node:assert';
|
|
|
14
14
|
import { createRequire } from 'node:module';
|
|
15
15
|
|
|
16
16
|
const require = createRequire(import.meta.url);
|
|
17
|
-
const { formatClaimFailure } = require('../scripts/gds/claim.js');
|
|
17
|
+
const { formatClaimFailure, claimErrorInfo, goalMembershipOfferPlan } = require('../scripts/gds/claim.js');
|
|
18
18
|
|
|
19
19
|
let passed = 0;
|
|
20
20
|
let failed = 0;
|
|
@@ -36,7 +36,7 @@ t('never dumps [object Object] for any known code', () => {
|
|
|
36
36
|
'session_already_has_bound_claim',
|
|
37
37
|
'newcomer_must_ship_first', 'ALREADY_CLAIMED', 'MODULE_CONFLICT',
|
|
38
38
|
'TOUCHES_CONFLICT', 'TASK_NOT_READY', 'TASK_NOT_FOUND', 'DEPS_NOT_SHIPPED',
|
|
39
|
-
'OPEN_BLOCKER',
|
|
39
|
+
'OPEN_BLOCKER', 'GOAL_MEMBERSHIP_REQUIRED',
|
|
40
40
|
'some_unmapped_future_code',
|
|
41
41
|
];
|
|
42
42
|
for (const c of codes) {
|
|
@@ -138,5 +138,58 @@ t('unknown code: fallback carries code + message, exit 1', () => {
|
|
|
138
138
|
assert.match(text(r), /boom/);
|
|
139
139
|
});
|
|
140
140
|
|
|
141
|
+
// ---- task 1003138: the goal-membership refusal is resolvable in-session ----
|
|
142
|
+
// The task-1003137 gate returns 403 GOAL_MEMBERSHIP_REQUIRED with { goal_id,
|
|
143
|
+
// visibility, action: join|apply, goal_title? }. claim.js offers the join (or
|
|
144
|
+
// files the application) inline; these pin the pure halves — the offer plan and
|
|
145
|
+
// the non-interactive fallthrough rendering.
|
|
146
|
+
|
|
147
|
+
t('GOAL_MEMBERSHIP_REQUIRED fallthrough: join names the one-command path, apply names the approval wait', () => {
|
|
148
|
+
const join = text(formatClaimFailure(
|
|
149
|
+
envelope('GOAL_MEMBERSHIP_REQUIRED', { goal_id: 1000045, visibility: 'public', action: 'join', goal_title: 'Public builder profiles' }, 'not a member', 403), 1002784));
|
|
150
|
+
assert.match(join, /goal 1000045/);
|
|
151
|
+
assert.match(join, /Public builder profiles/);
|
|
152
|
+
assert.match(join, /--join/, 'join path advertises the one-command re-run');
|
|
153
|
+
const apply = text(formatClaimFailure(
|
|
154
|
+
envelope('GOAL_MEMBERSHIP_REQUIRED', { goal_id: 1000099, visibility: 'private', action: 'apply' }, 'not a member', 403), 5));
|
|
155
|
+
assert.match(apply, /goal 1000099/);
|
|
156
|
+
assert.match(apply, /approv/i, 'apply path says approval gates the claim');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
t('goalMembershipOfferPlan: join offer matches the owner-approved shape (goal named, title shown, [Y/n])', () => {
|
|
160
|
+
const p = goalMembershipOfferPlan({ goal_id: 1000045, visibility: 'public', action: 'join', goal_title: 'Public builder profiles' }, 1002784);
|
|
161
|
+
assert.equal(p.action, 'join');
|
|
162
|
+
assert.match(p.headerLines[0], /Task 1002784 lives in goal 1000045/);
|
|
163
|
+
assert.match(p.headerLines[1], /"Public builder profiles" \(public\)/);
|
|
164
|
+
assert.match(p.headerLines[2], /not a member/i);
|
|
165
|
+
assert.equal(p.question, 'Join goal 1000045 and claim? [Y/n] ');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
t('goalMembershipOfferPlan: private goal offers apply and shows no title (there is none to leak)', () => {
|
|
169
|
+
const p = goalMembershipOfferPlan({ goal_id: 1000099, visibility: 'private', action: 'apply' }, 5);
|
|
170
|
+
assert.equal(p.action, 'apply');
|
|
171
|
+
assert.deepEqual(p.headerLines, ['✗ Goal 1000099 is private.']);
|
|
172
|
+
assert.equal(p.question, 'Apply to join? [Y/n] ');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
t('goalMembershipOfferPlan: public protected-scope goal offers apply and says why', () => {
|
|
176
|
+
const p = goalMembershipOfferPlan({ goal_id: 7, visibility: 'public', action: 'apply', goal_title: 'Core wall' }, 5);
|
|
177
|
+
assert.equal(p.action, 'apply');
|
|
178
|
+
assert.match(p.headerLines[0], /Archon-mediated/i);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
t('goalMembershipOfferPlan: a malformed body plans nothing (falls back to formatClaimFailure)', () => {
|
|
182
|
+
assert.equal(goalMembershipOfferPlan(null, 5), null);
|
|
183
|
+
assert.equal(goalMembershipOfferPlan({ action: 'join' }, 5), null, 'no goal_id → no plan');
|
|
184
|
+
assert.equal(goalMembershipOfferPlan({ goal_id: 7, action: 'teleport' }, 5), null, 'unknown action → no plan');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
t('claimErrorInfo: reads envelope and flat shapes identically', () => {
|
|
188
|
+
const env = claimErrorInfo(envelope('X', { a: 1 }, 'msg'));
|
|
189
|
+
assert.equal(env.code, 'X'); assert.equal(env.message, 'msg'); assert.equal(env.d.a, 1);
|
|
190
|
+
const flat = claimErrorInfo({ status: 409, data: { error: 'X', a: 1 } });
|
|
191
|
+
assert.equal(flat.code, 'X'); assert.equal(flat.d.a, 1);
|
|
192
|
+
});
|
|
193
|
+
|
|
141
194
|
console.log(`\nclaim_error_surface: ${passed} passed, ${failed} failed`);
|
|
142
195
|
process.exit(failed === 0 ? 0 : 1);
|
|
@@ -29,6 +29,10 @@
|
|
|
29
29
|
// O1 ordering: non-member + out-of-scope → not_a_member (no scope hint leaks)
|
|
30
30
|
// O2 ordering: a closed goal short-circuits first → goal_not_open
|
|
31
31
|
// P Metic+ on an in-scope protected path → ALLOW (rank clears the floor)
|
|
32
|
+
// Q* CLAIM a task in a goal you never joined (task 1003137)→ 403 GOAL_MEMBERSHIP_REQUIRED
|
|
33
|
+
// (join for public, apply for private/protected; a private goal's refusal
|
|
34
|
+
// leaks no title and no scope; goal_id NULL is not gated; both INSERT
|
|
35
|
+
// INTO claims sites run the same extracted gate)
|
|
32
36
|
//
|
|
33
37
|
// Ordering is fail-closed (a missing/ambiguous input denies), and membership is
|
|
34
38
|
// checked BEFORE scope so a non-member never learns the scope (O1 vs B). Per-branch
|
|
@@ -155,6 +159,74 @@ await test('[K] a protected-scope goal is recognized as protected; a normal goal
|
|
|
155
159
|
assert.equal(scopeIncludesProtected(G_IDEAS.scope_modules), false, 'ideas is a normal, open-join module');
|
|
156
160
|
});
|
|
157
161
|
|
|
162
|
+
// ---- [Q] the CLAIM-time membership wall (task 1003137) ----
|
|
163
|
+
// A task that lives in a goal is claimable only by that goal's members — every
|
|
164
|
+
// goal, the '<version> — general' catch-alls included (owner ruling 2026-08-19).
|
|
165
|
+
// The decision is the pure goalMembershipClaimRefusal (goal-authz.js); db.js
|
|
166
|
+
// feeds BOTH claim paths through the one extracted db.goalMembershipFailures.
|
|
167
|
+
const { goalMembershipClaimRefusal } = require('../modules/lifecycle/goal-authz.js');
|
|
168
|
+
const G_PUBLIC = { id: 21, title: 'Public builder profiles', visibility: 'public' };
|
|
169
|
+
const G_PRIVATE = { id: 22, title: 'SECRET-TITLE-MUST-NOT-LEAK', visibility: 'private', scope_modules: ['lifecycle'] };
|
|
170
|
+
|
|
171
|
+
await test('[Q1] goal_id NULL is NOT gated — a NULL goal has no membership to check (migration 160 nullable)', () => {
|
|
172
|
+
assert.equal(goalMembershipClaimRefusal({ goal: null, isMember: false, actorRank: 'xenos', scopeProtected: false }), null);
|
|
173
|
+
});
|
|
174
|
+
await test('[Q2] a member claims freely', () => {
|
|
175
|
+
assert.equal(goalMembershipClaimRefusal({ goal: G_PUBLIC, isMember: true, actorRank: 'xenos', scopeProtected: false }), null);
|
|
176
|
+
});
|
|
177
|
+
await test('[Q3] an Archon is an implicit member (the isMemberOrArchon convention)', () => {
|
|
178
|
+
assert.equal(goalMembershipClaimRefusal({ goal: G_PRIVATE, isMember: false, actorRank: 'archon', scopeProtected: true }), null);
|
|
179
|
+
});
|
|
180
|
+
await test('[Q4] non-member on a public unprotected goal → GOAL_MEMBERSHIP_REQUIRED, action=join, title rides along', () => {
|
|
181
|
+
const r = goalMembershipClaimRefusal({ goal: G_PUBLIC, isMember: false, actorRank: 'metic', scopeProtected: false });
|
|
182
|
+
assert.equal(r.code, 'GOAL_MEMBERSHIP_REQUIRED');
|
|
183
|
+
assert.equal(r.action, 'join');
|
|
184
|
+
assert.equal(r.goal_id, 21);
|
|
185
|
+
assert.equal(r.visibility, 'public');
|
|
186
|
+
assert.equal(r.goal_title, 'Public builder profiles');
|
|
187
|
+
assert.ok(/join/i.test(r.what_to_do), 'the way forward names the join route');
|
|
188
|
+
});
|
|
189
|
+
await test('[Q5] non-member on a PRIVATE goal → action=apply, and the refusal leaks NO title and NO scope', () => {
|
|
190
|
+
const r = goalMembershipClaimRefusal({ goal: G_PRIVATE, isMember: false, actorRank: 'metic', scopeProtected: true });
|
|
191
|
+
assert.equal(r.code, 'GOAL_MEMBERSHIP_REQUIRED');
|
|
192
|
+
assert.equal(r.action, 'apply');
|
|
193
|
+
assert.equal(r.visibility, 'private');
|
|
194
|
+
assert.ok(!('goal_title' in r), 'a private goal\'s title must not leak to a non-member');
|
|
195
|
+
const flat = JSON.stringify(r);
|
|
196
|
+
assert.ok(!flat.includes('SECRET-TITLE-MUST-NOT-LEAK'), 'no field may carry the private title');
|
|
197
|
+
assert.ok(!flat.includes('scope_modules') && !flat.includes('lifecycle'), 'no field may carry the private scope');
|
|
198
|
+
});
|
|
199
|
+
await test('[Q6] non-member on a public PROTECTED-scope goal → action=apply (self-join is Archon-mediated)', () => {
|
|
200
|
+
const r = goalMembershipClaimRefusal({ goal: G_PUBLIC, isMember: false, actorRank: 'metic', scopeProtected: true });
|
|
201
|
+
assert.equal(r.action, 'apply', 'a protected-scope goal is never offered as open-join');
|
|
202
|
+
});
|
|
203
|
+
await test('[Q7] source contract: BOTH INSERT INTO claims sites run the extracted gate (batch cannot bypass)', () => {
|
|
204
|
+
const dbSrc = readFileSync(new URL('../modules/lifecycle/db.js', import.meta.url), 'utf8');
|
|
205
|
+
const insertSites = dbSrc.match(/INSERT INTO claims \(/g) || [];
|
|
206
|
+
assert.equal(insertSites.length, 2, 'exactly two claim-insert sites (claimTask + claimTasksBatch) — a third needs the gate too');
|
|
207
|
+
const gateCalls = dbSrc.match(/await goalMembershipFailures\(/g) || [];
|
|
208
|
+
assert.equal(gateCalls.length, 3, 'the membership gate must run on both claim paths + validateClaimBatch (the dry-run twin that must stay in sync)');
|
|
209
|
+
});
|
|
210
|
+
await test('[Q8] feed contract: a non-member row is flagged joinable, never silently claimable (leak rule holds)', () => {
|
|
211
|
+
const { decorateGoalMembership } = require('../modules/lifecycle/db.js');
|
|
212
|
+
// member / goal-less rows pass through shaped as today
|
|
213
|
+
assert.deepEqual(decorateGoalMembership({ id: 1, goal_id: null }), { id: 1, goal_id: null });
|
|
214
|
+
assert.deepEqual(
|
|
215
|
+
decorateGoalMembership({ id: 2, goal_id: 21, g_visibility: 'public', g_scope_modules: ['ideas'], g_title: 'T', g_is_member: true }),
|
|
216
|
+
{ id: 2, goal_id: 21 }
|
|
217
|
+
);
|
|
218
|
+
// non-member public → flagged join, title carried, scope stripped
|
|
219
|
+
const pub = decorateGoalMembership({ id: 3, goal_id: 21, g_visibility: 'public', g_scope_modules: ['ideas'], g_title: 'T', g_is_member: false });
|
|
220
|
+
assert.deepEqual(pub.goal_membership, { required: true, goal_id: 21, visibility: 'public', action: 'join', goal_title: 'T' });
|
|
221
|
+
assert.ok(!('g_scope_modules' in pub) && !('g_title' in pub), 'scratch columns must not leave the feed');
|
|
222
|
+
// non-member private → flagged apply, NO title
|
|
223
|
+
const priv = decorateGoalMembership({ id: 4, goal_id: 22, g_visibility: 'private', g_scope_modules: ['ideas'], g_title: 'SECRET', g_is_member: false });
|
|
224
|
+
assert.deepEqual(priv.goal_membership, { required: true, goal_id: 22, visibility: 'private', action: 'apply' });
|
|
225
|
+
// non-member public but protected-scope → apply (matches the join route's Archon-mediation)
|
|
226
|
+
const prot = decorateGoalMembership({ id: 5, goal_id: 23, g_visibility: 'public', g_scope_modules: ['lifecycle'], g_title: 'L', g_is_member: false });
|
|
227
|
+
assert.equal(prot.goal_membership.action, 'apply');
|
|
228
|
+
});
|
|
229
|
+
|
|
158
230
|
// ---- the pins that make a rank drift on a goal route a red build (BV1.R61) ----
|
|
159
231
|
// The wall is only drift-proof if the goal routes are PINNED and the LIVE routes
|
|
160
232
|
// classify to those pins. The catch-violation (a downgrade → blocker) is proven in
|
package/tests/reward_gate.mjs
CHANGED
|
@@ -163,9 +163,10 @@ t('claim path AUTO-ASSIGNS inside the FOR UPDATE transaction (never refuses)', (
|
|
|
163
163
|
// pre-flight half of claimTask (which spans ~13k chars). Widened 5000 -> 6000 in
|
|
164
164
|
// BV1.R106, when the permission-expressed claim gate pushed the reward-gate block
|
|
165
165
|
// past the old edge; 6000 -> 7000 in task 1002613, when the enriched
|
|
166
|
-
// rank_too_low_for_task denial did the same
|
|
167
|
-
//
|
|
168
|
-
|
|
166
|
+
// rank_too_low_for_task denial did the same; 7000 -> 8500 in task 1003137, when
|
|
167
|
+
// the goal-membership gate landed between the rank gates and deps. Keep it
|
|
168
|
+
// comfortably below the claimTasksBatch boundary (~16.5k chars in).
|
|
169
|
+
const body = dbSrc.slice(start, start + 8500);
|
|
169
170
|
assert.match(body, /rewardGateAssignment\(task\)/, 'claimTask must consult the auto-assign decision');
|
|
170
171
|
assert.match(body, /UPDATE tasks SET credits_reward/, 'claimTask must persist the assigned reward in-tx');
|
|
171
172
|
// it must SELECT the columns the gate needs.
|