@bongos/core 1.19.279 → 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 CHANGED
@@ -2,11 +2,11 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.279",
6
- "core_contract": "1.19.279",
7
- "source_commit": "20fdd2b00fa489173ea330aeec453d56dcb6999a",
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-21T20:04:41.616Z",
9
+ "built_at": "2026-08-21T20:22:42.788Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 319,
@@ -17,7 +17,7 @@
17
17
  "gate": "passed"
18
18
  },
19
19
  "file_count": 1718,
20
- "tree_sha256": "98ca3398579326bf1dc3356202895e6911341413dbf4e0238347e3357888565f",
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": "076e3143a87c2257389a710de69e7b5b2c14cd353e0eff4d53b7b01444fc5ae0"
45
+ "sha256": "057a5bac75084ea29e5dc3da1cf457089d77c882bfb34936f3c0b37889edc1c3"
46
46
  },
47
47
  {
48
48
  "path": ".claude/skills/builder-connect/SKILL.md",
@@ -4497,12 +4497,12 @@
4497
4497
  {
4498
4498
  "path": "package-lock.json",
4499
4499
  "mode": "0000644",
4500
- "sha256": "66c790ddb5f66b9c7e5961f8402a508bdb36d5dddf35215401eda673a115c003"
4500
+ "sha256": "db9e4dfef2e02b0e40fa3535f1af51d53071128b960698cf48b04fd6995eb54c"
4501
4501
  },
4502
4502
  {
4503
4503
  "path": "package.json",
4504
4504
  "mode": "0000644",
4505
- "sha256": "ea43d45c4c7ef30f10935c5ec6e967e9a1c707a6f9a69ed7f5f6b2e5771fef8e"
4505
+ "sha256": "99c8deb7b7edff1c7a5560a59dd9887aefa080786d3f2c9acd6c87c5b495c114"
4506
4506
  },
4507
4507
  {
4508
4508
  "path": "public-docs/index.html",
@@ -4977,7 +4977,7 @@
4977
4977
  {
4978
4978
  "path": "scripts/gds/claim.js",
4979
4979
  "mode": "0000644",
4980
- "sha256": "6f09d9344f57039c07d2de943dabb48dbf717226aa2667f4163dab47fb74428a"
4980
+ "sha256": "3e830a3e319c4b67d87547ae4ad2d647ee3ce74725c84d7f64788c094f0974c7"
4981
4981
  },
4982
4982
  {
4983
4983
  "path": "scripts/gds/classify-discipline.js",
@@ -6147,7 +6147,7 @@
6147
6147
  {
6148
6148
  "path": "src/module-api.js",
6149
6149
  "mode": "0000644",
6150
- "sha256": "e58b6609edc9a144ab403e149a715aebdc324b598244833d1ef3a6aa3713f88a"
6150
+ "sha256": "afc9e2e6432d8a8f2e6a9812e92d1673f637c18b93555313c4881c9c44839914"
6151
6151
  },
6152
6152
  {
6153
6153
  "path": "src/module-loader/catalog.js",
@@ -6582,7 +6582,7 @@
6582
6582
  {
6583
6583
  "path": "tests/claim_error_surface.mjs",
6584
6584
  "mode": "0000644",
6585
- "sha256": "365f498d290298e0cd74888aff080cfbf3439e5e0cf2dd883291f39dface27e7"
6585
+ "sha256": "33b0bbbb4c1a1f8b44a71c692a40b8a7e9cdde2e32483226484427ccf24238b5"
6586
6586
  },
6587
6587
  {
6588
6588
  "path": "tests/claim_gate_open_blocker.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.
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.279",
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.279",
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.279",
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",
@@ -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. This normalizes
171
- // BOTH — without it `r.data.error` is now an object (never === a string), so every
172
- // branch below silently missed and the refusal printed as `[object Object]`. Pure
173
- // (no console / no process.exit) + exported so it can be unit-tested against the
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 errRaw = r && r.data ? r.data.error : undefined;
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 r = await api.claims.postClaims({
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
@@ -985,8 +985,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
985
985
  // landed since 1.19.277 with no explicit bump. run 32508270995. (task 1002620)
986
986
  // 1.19.279 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
987
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)
988
990
  // ---------------------------------------------------------------------------
989
- const CORE_VERSION = '1.19.279'; // CI auto-patch carrier (ADR 0161); see changelog above
991
+ const CORE_VERSION = '1.19.280'; // CI auto-patch carrier (ADR 0161); see changelog above
990
992
 
991
993
  // A namespaced logger so a module's log lines are attributable + consistent.
992
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);