@bongos/core 1.19.834 → 1.19.836

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.
@@ -0,0 +1,348 @@
1
+ // scripts/gds/plain-cards.js — the plain-language card for every failure a builder can
2
+ // be parked behind (task 1003989).
3
+ //
4
+ // WHY. The CLIs explain a refusal in engineer prose, and the lifecycle skills told the
5
+ // agent to relay it verbatim — so a non-technical builder met `REBASE_REQUIRED` or
6
+ // `strand:pr_not_landed` raw, at the moment they were most stuck, whatever their
7
+ // interaction profile said. A card says the same thing four ways a person can use:
8
+ // what happened, what the agent will do next, whether a PERSON is needed (and who), and
9
+ // whether this is a rule rather than a fault. That last one is the expensive mistake:
10
+ // an agent that reads a deliberate restriction as breakage goes looking for a way round
11
+ // it (the box session that asked the owner for a personal access token).
12
+ //
13
+ // STATIC TEXT ONLY. A card interpolates nothing — no task title, no server message — so
14
+ // printing it adds no prompt-injection surface (the strand-watch / push-path-brief rule).
15
+ // The engineer text above it keeps the ids. tests/plain_cards.mjs fails when a CLI gains
16
+ // a failure code with no card, and when a card drifts back into the vocabulary it exists
17
+ // to translate (it reuses the accessibility scorer's jargon list, ADR 0291).
18
+
19
+ 'use strict';
20
+
21
+ const { isTerminal } = require('./surface');
22
+
23
+ // The line a skill looks for. Agent-surface marker, same family as [otb-confirm].
24
+ const PLAIN_MARKER = '[otb-plain]';
25
+
26
+ const RULE = true; // deliberate: never look for a way round it
27
+ const FAULT = false; // something is actually off; diagnosing it is fine
28
+
29
+ const card = (happened, next, person, byDesign) => Object.freeze({ happened, next, person, by_design: byDesign });
30
+
31
+ const ONE_TASK_PER_SESSION = card(
32
+ 'This chat session is already working on another task. Each session handles one task at a time, so finished work never gets mixed together.',
33
+ 'I will finish or hand back the task we already have — or tell you how to open a second session for this one.',
34
+ null, RULE);
35
+
36
+ const CARDS = Object.freeze({
37
+ // ── starting a task ─────────────────────────────────────────────────────────
38
+ stage_required: card(
39
+ 'There is a getting-started step to finish before this task can be started.',
40
+ 'I will show you the step the server named and help you complete it.',
41
+ null, RULE),
42
+ session_already_has_active_claim: ONE_TASK_PER_SESSION,
43
+ session_claim_check_failed: ONE_TASK_PER_SESSION,
44
+ session_already_has_bound_claim: ONE_TASK_PER_SESSION,
45
+ newcomer_must_ship_first: card(
46
+ 'New builders finish their first task before starting a second one.',
47
+ 'I will help you finish the task you already have.',
48
+ null, RULE),
49
+ REBASE_REQUIRED: card(
50
+ 'An earlier task of yours was approved but has not made it into the project yet, so starting new tasks is paused until it does.',
51
+ 'I will run /strand-fix on the stuck task. That is the supported way to land it and it needs nothing technical from you.',
52
+ 'Only if /strand-fix says it needs the owner.', RULE),
53
+ ALREADY_CLAIMED: card(
54
+ 'Someone else started this task a moment before us.',
55
+ 'I will pull a fresh list and suggest another task. Trying again would not help.',
56
+ null, RULE),
57
+ MODULE_CONFLICT: card(
58
+ 'Another task in progress is changing the same part of the project.',
59
+ 'I will suggest a task in a different area, or we wait for the other one to finish.',
60
+ null, RULE),
61
+ TOUCHES_CONFLICT: card(
62
+ 'Another task in progress is changing some of the same files.',
63
+ 'I will suggest a task that does not overlap, or we wait for the other one to finish.',
64
+ null, RULE),
65
+ TASK_NOT_READY: card(
66
+ 'This task is not open for work yet — its status says so.',
67
+ 'I will tell you its status and find a task that is ready.',
68
+ null, RULE),
69
+ TASK_NOT_FOUND: card(
70
+ 'No task has that number.',
71
+ 'I will list the tasks that exist so we can pick the right one.',
72
+ null, FAULT),
73
+ DEPS_NOT_SHIPPED: card(
74
+ 'This task builds on other tasks that are not finished yet.',
75
+ 'I will show which ones. If one of them was cancelled, the link between them needs a person to re-decide it.',
76
+ 'Sometimes — whoever owns the goal, when a task it depends on was cancelled.', RULE),
77
+ OPEN_BLOCKER: card(
78
+ 'This task is waiting on a decision or some input that was written down as a blocker.',
79
+ 'I will show you the blocker. /blocker-solve can drive it to done.',
80
+ 'Usually the owner, for the decision the blocker names.', RULE),
81
+ GOAL_MEMBERSHIP_REQUIRED: card(
82
+ 'This task belongs to a goal you have not joined.',
83
+ 'I will offer to join it for you — or apply, if it is private. I will not do either without your yes.',
84
+ 'For a private goal, its owner approves the request.', RULE),
85
+ archon_approval_required: card(
86
+ 'This goal covers a protected part of the project, so joining it needs an Archon to say yes.',
87
+ 'I will file the request for you and we pick other work while it waits.',
88
+ 'An Archon approves the request.', RULE),
89
+ goal_private: card(
90
+ 'This goal is private, so joining it is by request.',
91
+ 'I will file the request if you want, and we pick other work while it waits.',
92
+ 'The goal owner approves the request.', RULE),
93
+ request_pending: card(
94
+ 'Your request to join this goal is already waiting for an answer.',
95
+ 'Nothing more to send. I will suggest other work in the meantime.',
96
+ 'The goal owner, who has the request.', RULE),
97
+ BUILDER_INACTIVE: card(
98
+ 'Your builder account is switched off, so it cannot start tasks.',
99
+ 'I cannot change that from here. Ask an Archon to switch the account back on.',
100
+ 'An Archon.', RULE),
101
+ INSUFFICIENT_RANK: card(
102
+ 'This task needs a higher rank than yours.',
103
+ 'I will find tasks open to your rank. I will not look for a way round the rank rule.',
104
+ 'An Archon grants ranks.', RULE),
105
+ rank_too_low_for_task: card(
106
+ 'Your rank cannot start this kind of task yet.',
107
+ 'I will find tasks open to your rank. I will not look for a way round the rank rule.',
108
+ 'An Archon grants ranks.', RULE),
109
+ BATCH_DUPLICATE_IDS: card(
110
+ 'The same task was listed twice in one request.',
111
+ 'I will send the list again with each task once.',
112
+ null, FAULT),
113
+ BATCH_EMPTY: card(
114
+ 'The request to start several tasks had no tasks in it.',
115
+ 'I will ask which tasks you meant.',
116
+ null, FAULT),
117
+ BATCH_FAILED: card(
118
+ 'One of the tasks in the group could not be started, so none of them were.',
119
+ 'I will show which one and why, then retry with the rest if you want.',
120
+ null, RULE),
121
+ main_checkout_claim: card(
122
+ 'Tasks are worked on in their own separate copy of the project, and this session is standing in the main copy.',
123
+ 'I will make the separate copy with one command and start the task from there.',
124
+ null, RULE),
125
+
126
+ board_unavailable: card(
127
+ 'The list of tasks could not be loaded — the project answered with an error.',
128
+ 'I will check that the project is reachable and that you are signed in, then try once more.',
129
+ null, FAULT),
130
+
131
+ // ── handing a task back ─────────────────────────────────────────────────────
132
+ CLAIM_SESSION_MISMATCH: card(
133
+ 'This task was started from a different chat session, so this one is not allowed to hand it back by accident.',
134
+ 'If you are sure it is yours to give up, I will repeat the command with the override it names.',
135
+ null, RULE),
136
+ no_active_claim: card(
137
+ 'You are not working on that task right now, so there is nothing to hand back.',
138
+ 'I will show what you do have in progress.',
139
+ null, FAULT),
140
+
141
+ // ── why an approved task has not landed ─────────────────────────────────────
142
+ 'strand:pr_not_landed': card(
143
+ 'Your work was approved and sent for the automatic checks, but it never made it into the project.',
144
+ 'I will look up what stopped it and fix that, then land it.',
145
+ null, FAULT),
146
+ 'strand:pr_closed_unmerged': card(
147
+ 'The request to add your work was closed without being accepted, so it will never land by waiting.',
148
+ 'I will send the work again. If a check failed, I fix that first.',
149
+ null, FAULT),
150
+ 'strand:no_branch_no_tip': card(
151
+ 'The task was approved, but its work never left the computer it was written on.',
152
+ 'I will send the work from this computer. If it lives on another machine, it has to be sent from there.',
153
+ 'Only if the work is on a machine I cannot reach.', FAULT),
154
+ 'strand:land_not_proven': card(
155
+ 'The project could not prove that your approved work actually arrived.',
156
+ 'I will check what arrived and send the work again if it did not.',
157
+ null, FAULT),
158
+ 'strand:unresolvable_conflict': card(
159
+ 'Your work and newer work changed the same lines, and the automatic fix could not choose between them.',
160
+ 'I will bring your work up to date by hand, keeping both changes, and send it again.',
161
+ null, FAULT),
162
+ 'strand:branch_modifies_executed_code': card(
163
+ 'Your work changes the tools that would be used to update it, so the automatic update was refused on purpose.',
164
+ 'I will bring your work up to date on this computer instead, and send it again.',
165
+ null, RULE),
166
+
167
+ // ── why a request to add work has not been accepted ─────────────────────────
168
+ failing_check: card(
169
+ 'An automatic test failed on your work, so it has not been added to the project.',
170
+ 'I will run that test here, fix what it found, and send the work again. Sending the same work again would fail the same way.',
171
+ null, FAULT),
172
+ non_required_check_failing: card(
173
+ 'An optional automatic test failed on your work.',
174
+ 'I will look at what it found. It does not stop the work being added, but it is usually worth fixing.',
175
+ null, FAULT),
176
+ non_required_check_pending: card(
177
+ 'An optional automatic test is still running on your work.',
178
+ 'Nothing to do yet — I will check again shortly.',
179
+ null, RULE),
180
+ failing_check_unreadable: card(
181
+ 'An automatic test failed on your work, but this project is not allowed to see which one.',
182
+ 'I will run all the tests here to find it, fix it, and send the work again. Sending the same work again cannot help.',
183
+ 'The owner can switch on the permission that shows which test failed.', FAULT),
184
+ blocked_required_review_or_pending_check: card(
185
+ 'Your work is waiting on a test that has not finished, or on a review.',
186
+ 'Nothing to do yet — I will check again shortly.',
187
+ null, RULE),
188
+ review_required: card(
189
+ 'Your work needs a person to review it before it can be added.',
190
+ 'I will tell you who can review it. I cannot approve my own work.',
191
+ 'A reviewer with the right to approve.', RULE),
192
+ changes_requested: card(
193
+ 'A reviewer asked for changes before your work can be added.',
194
+ 'I will read what they asked for and make the changes.',
195
+ null, RULE),
196
+ merge_conflict: card(
197
+ 'Your work and newer work changed the same lines, so they cannot be combined automatically.',
198
+ 'I will bring your work up to date, keeping both changes, and send it again.',
199
+ null, FAULT),
200
+ branch_behind_base: card(
201
+ 'The project moved on while your work was being checked, so your work needs refreshing first.',
202
+ 'The server usually does this by itself. If it has not, I will refresh it and send it again.',
203
+ null, RULE),
204
+ draft_pr: card(
205
+ 'Your work was sent as a draft, and drafts are not added to the project.',
206
+ 'I will mark it ready.',
207
+ null, FAULT),
208
+ mergeability_computing: card(
209
+ 'The project is still working out whether your work can be added.',
210
+ 'Nothing to do yet — I will check again in a moment.',
211
+ null, RULE),
212
+ no_open_pr: card(
213
+ 'There is no open request to add your work to the project.',
214
+ 'I will check whether it was already added, and send it if it was not.',
215
+ null, FAULT),
216
+ unknown: card(
217
+ 'The project did not say why your work has not been added.',
218
+ 'I will check again, and look at it directly if the answer stays empty.',
219
+ null, FAULT),
220
+
221
+ // ── sending work from this computer ─────────────────────────────────────────
222
+ 'ship:workflow_scope': card(
223
+ 'Your work changes the project\'s automatic test setup, and the sign-in this computer uses is not allowed to send that kind of change.',
224
+ 'Sending it again will fail the same way. I will show the one-time command that sends it another way.',
225
+ 'You, once, to run that command or refresh the sign-in.', RULE),
226
+ 'ship:protected_branch': card(
227
+ 'Nobody is allowed to put work straight into the project\'s main copy — everything goes through the automatic checks first.',
228
+ 'I will send your work through the checked route instead. I will not try to get round the protection.',
229
+ null, RULE),
230
+ 'ship:auth': card(
231
+ 'This computer\'s sign-in to the code host was refused.',
232
+ 'I will show you how to sign in again. I will not ask anyone for a password or a key.',
233
+ 'You, to sign in again.', RULE),
234
+ 'ship:stale_branch': card(
235
+ 'The project moved on since your work started, so it has to be refreshed before it can be sent.',
236
+ 'I will refresh it and send it again.',
237
+ null, FAULT),
238
+ 'ship:unknown': card(
239
+ 'Sending your work failed for a reason I do not recognise.',
240
+ 'I will read the message above, fix what it names, and try again once.',
241
+ null, FAULT),
242
+ 'ship:main-checkout-on-branch': card(
243
+ 'This task was worked on in the project\'s main copy instead of its own separate copy, so it cannot be added the usual way.',
244
+ 'I will send it through the checked route instead. Next time I will start the task in its own copy.',
245
+ null, FAULT),
246
+ 'ship:no-task-branch': card(
247
+ 'I could not find the saved work for this task on this computer.',
248
+ 'I will look for where it was saved. If it is on another machine, it has to be sent from there.',
249
+ 'Only if the work is on a machine I cannot reach.', FAULT),
250
+ land_pending: card(
251
+ 'Your work passed review and is waiting for the automatic tests. The server adds it to the project by itself when they pass.',
252
+ 'Nothing to do. This is normal, not a failure.',
253
+ null, RULE),
254
+ reconcile_blocked: card(
255
+ 'Your work was added to the project, but the server could not prove it was this task\'s, so the task is not marked finished yet.',
256
+ 'The server re-checks every few minutes. If it stays stuck I will run the ship command again.',
257
+ null, FAULT),
258
+ smoke_failed: card(
259
+ 'A quick health check of the project failed before your work was handed in.',
260
+ 'I will find what the check tripped on and fix it before handing the work in.',
261
+ null, FAULT),
262
+ nothing_to_ship: card(
263
+ 'There is no saved work for this task, so there is nothing to hand in.',
264
+ 'I will check whether the work was saved. If the task really needed no changes, I will say so explicitly.',
265
+ null, RULE),
266
+ uncommitted_work: card(
267
+ 'Some of your work has not been saved into the project\'s history yet, so handing it in now would leave it behind.',
268
+ 'I will save it, then hand it in.',
269
+ null, RULE),
270
+
271
+ // ── the review of your work ─────────────────────────────────────────────────
272
+ grade_unavailable: card(
273
+ 'The automatic reviewers could not run. Your work was not judged — this is an outage, not a rejection.',
274
+ 'I will ask for the review again later. There is nothing in your work to fix.',
275
+ 'Only if the outage lasts — an Archon can accept the work by hand.', FAULT),
276
+ grade_lens_unavailable: card(
277
+ 'One of the automatic reviewers did not run; the others found nothing wrong. Your work was not rejected.',
278
+ 'I will ask for the review again when the reviewers are healthy.',
279
+ 'Only if it keeps happening — an Archon can accept the work by hand.', FAULT),
280
+ grade_refused_authorization: card(
281
+ 'Your work touches a protected part of the project that your rank may not change, so the server would not accept it.',
282
+ 'Asking again will be refused again. I will take the protected files out and hand in the rest, or we pass the task to someone with the rank.',
283
+ 'A builder with a high enough rank, for the protected part.', RULE),
284
+ grade_failed: card(
285
+ 'The automatic reviewers found problems in your work, so it is not accepted yet.',
286
+ 'I will fix what they listed, save it, and ask for the review again. That costs nothing extra.',
287
+ null, FAULT),
288
+
289
+ // ── signing in ──────────────────────────────────────────────────────────────
290
+ no_session: card(
291
+ 'This computer is not signed in to the project yet.',
292
+ 'I will walk you through signing in — it happens in your browser.',
293
+ 'You, to sign in.', RULE),
294
+ session_invalid: card(
295
+ 'Your sign-in to the project has ended.',
296
+ 'I will walk you through signing in again — Settings, then one copy and paste.',
297
+ 'You, to sign in again.', RULE),
298
+ box_scope: card(
299
+ 'This sign-in is limited on purpose: it can do the work, but not hand it in.',
300
+ 'I will walk you through the full sign-in, then hand the work in.',
301
+ 'You, to sign in again.', RULE),
302
+ instance_mismatch: card(
303
+ 'This computer is signed in to a different project than the one this folder belongs to.',
304
+ 'I will show which project each one is, and help you sign in to the right one.',
305
+ 'You, to choose the project.', FAULT),
306
+ dev_box_unavailable: card(
307
+ 'This project does not offer hosted computers to work from.',
308
+ 'We can work from this computer instead.',
309
+ null, RULE),
310
+ no_push_credential: card(
311
+ 'This computer is not allowed to send work to the code host itself. That is deliberate: the server sends it for you.',
312
+ 'I will hand the work in with /builder-ship and read its progress from the server. I will never ask anyone for a password, key or sign-in to work round this.',
313
+ null, RULE),
314
+ });
315
+
316
+ // cardFor(code) → the card, or null. Reasons that carry a name or a file list after a
317
+ // colon (`failing_check:unit`, `strand:unresolvable_conflict:a.md,b.md`) resolve on
318
+ // their stem. `XENOS_RESTRICTED` is the legacy spelling of rank_too_low_for_task.
319
+ function cardFor(code) {
320
+ if (typeof code !== 'string' || !code) return null;
321
+ if (code === 'XENOS_RESTRICTED') return CARDS.rank_too_low_for_task;
322
+ if (Object.prototype.hasOwnProperty.call(CARDS, code)) return CARDS[code];
323
+ const parts = code.split(':');
324
+ for (let n = parts.length - 1; n >= 1; n--) {
325
+ const stem = parts.slice(0, n).join(':');
326
+ if (Object.prototype.hasOwnProperty.call(CARDS, stem)) return CARDS[stem];
327
+ }
328
+ return null;
329
+ }
330
+
331
+ // plainCardLines(code) → the printable card, or [] for a code with none. Never throws:
332
+ // a missing card must not be able to break the refusal it was meant to explain.
333
+ // AGENT SURFACE ONLY: a card is written in the agent's voice ("I will…") for a
334
+ // session to relay. A person running the `bongos` CLI reads the text above it directly,
335
+ // and several cards name slash commands that do not exist there (tests/cli_surface.mjs).
336
+ function plainCardLines(code, env = process.env) {
337
+ const c = isTerminal(env) ? null : cardFor(code);
338
+ if (!c) return [];
339
+ return [
340
+ ` ${PLAIN_MARKER} in plain words`,
341
+ ` What happened ${c.happened}`,
342
+ ` What I will do ${c.next}`,
343
+ ` Needs a person ${c.person || 'No.'}`,
344
+ ` By design ${c.by_design ? 'Yes — this is a rule, not a fault. Do not look for a way round it.' : 'No — something is actually off, so looking into it is right.'}`,
345
+ ];
346
+ }
347
+
348
+ module.exports = { CARDS, cardFor, plainCardLines, PLAIN_MARKER };
@@ -512,6 +512,8 @@ async function scaffoldStandaloneRepo(inst, deps) {
512
512
  throw new Error(`standalone instance ${inst.slug}: target_ref "${inst.target_ref || '(unset)'}" is not an owner/repo — set it (e.g. <owner>/${inst.slug}) so the scaffold knows which repo to clone + push.`);
513
513
  }
514
514
  if (alreadyScaffolded(root, mode)) {
515
+ const resumed = deps.apply ? await resumeUnpushedScaffold(inst, deps, { root, repo, mode }) : null;
516
+ if (resumed) return resumed;
515
517
  log(` [scaffold] ${root} already scaffolded (mode=${mode}) — skipping`);
516
518
  return { already: true };
517
519
  }
@@ -541,13 +543,8 @@ async function scaffoldStandaloneRepo(inst, deps) {
541
543
  // command or makeExec's env (makeExec surfaces both), and is removed in a `finally` so a
542
544
  // failed git op can't leave it on disk. No token (org-owned repo, or secret-box off) →
543
545
  // fall back to the SSH `origin` remote.
544
- const ownerToken = await resolveOwnerGithubToken(inst, deps, { tag: '[scaffold]' });
545
- // The credential-store file + the credential-LESS URL git resolves against it. The URL
546
- // carries only the `x-access-token` username (no secret), so it is safe in a logged argv.
547
- const credFile = ownerToken ? path.join(os.tmpdir(), `bongos-gitcred-${inst.slug}-${process.pid}`) : null;
548
- const httpsUrl = `https://x-access-token@github.com/${repo.owner}/${repo.name}.git`;
549
- const credOpt = credFile ? `-c credential.helper='store --file=${credFile}' ` : '';
550
- if (credFile) writeFile(credFile, `https://x-access-token:${ownerToken}@github.com\n`, { mode: 0o600 });
546
+ const cred = await scaffoldCredential(inst, repo, deps, writeFile);
547
+ const { credFile, httpsUrl, credOpt } = cred;
551
548
 
552
549
  try {
553
550
  // 1. Materialize the working tree.
@@ -680,13 +677,7 @@ async function scaffoldStandaloneRepo(inst, deps) {
680
677
  // destroying their work. Adopt's commit is a descendant of the tip it cloned, so an
681
678
  // ordinary push fast-forwards; if the remote moved underneath us the rejection is the
682
679
  // correct outcome and a re-run picks up the new tip.
683
- if (credFile) {
684
- exec(`git -C ${root} ${credOpt}push ${httpsUrl} HEAD:main`);
685
- log(` [scaffold] ${mode === 'adopt' ? 'pushed the adopt layer' : 'pushed'} to ${repo.owner}/${repo.name}@main via the owner's GitHub token (task 1002317)`);
686
- } else {
687
- exec(`git -C ${root} push -u origin main`);
688
- log(` [scaffold] pushed ${mode === 'adopt' ? 'the adopt layer' : 'initial scaffold'} to ${repo.owner}/${repo.name}@main (SSH origin)`);
689
- }
680
+ pushScaffold({ exec, log, root, repo, mode, cred });
690
681
  return { ok: true, scaffolded: true, mode };
691
682
  } finally {
692
683
  // Always remove the credential file — a failed clone/push must not leave it on disk.
@@ -694,6 +685,54 @@ async function scaffoldStandaloneRepo(inst, deps) {
694
685
  }
695
686
  }
696
687
 
688
+ // The owner-token credential the scaffold's git network ops ride (rationale at the call site in
689
+ // scaffoldStandaloneRepo). The URL carries only the `x-access-token` username, so it is safe in a
690
+ // logged argv. credFile null → no token → SSH origin fallback; the caller `rm -f`s it in a finally.
691
+ async function scaffoldCredential(inst, repo, deps, writeFile) {
692
+ const ownerToken = await resolveOwnerGithubToken(inst, deps, { tag: '[scaffold]' });
693
+ const credFile = ownerToken ? path.join(os.tmpdir(), `bongos-gitcred-${inst.slug}-${process.pid}`) : null;
694
+ const httpsUrl = `https://x-access-token@github.com/${repo.owner}/${repo.name}.git`;
695
+ const credOpt = credFile ? `-c credential.helper='store --file=${credFile}' ` : '';
696
+ if (credFile) writeFile(credFile, `https://x-access-token:${ownerToken}@github.com\n`, { mode: 0o600 });
697
+ return { credFile, httpsUrl, credOpt };
698
+ }
699
+
700
+ function pushScaffold({ exec, log, root, repo, mode, cred }) {
701
+ if (cred.credFile) {
702
+ exec(`git -C ${root} ${cred.credOpt}push ${cred.httpsUrl} HEAD:main`);
703
+ log(` [scaffold] ${mode === 'adopt' ? 'pushed the adopt layer' : 'pushed'} to ${repo.owner}/${repo.name}@main via the owner's GitHub token (task 1002317)`);
704
+ } else {
705
+ exec(`git -C ${root} push -u origin main`);
706
+ log(` [scaffold] pushed ${mode === 'adopt' ? 'the adopt layer' : 'initial scaffold'} to ${repo.owner}/${repo.name}@main (SSH origin)`);
707
+ }
708
+ }
709
+
710
+ // alreadyScaffolded is a LOCAL marker, and a scaffold whose push failed leaves it behind: the
711
+ // re-run skipped the never-done push, then wedged at step 2's `git pull --ff-only` against the
712
+ // still-empty remote (task 1002718, F12). Only a remote that ANSWERS with no main re-pushes — an
713
+ // unreachable one keeps the old skip, as it can't tell "never pushed" from "offline". Returns
714
+ // null when there is nothing to resume.
715
+ async function resumeUnpushedScaffold(inst, deps, { root, repo, mode }) {
716
+ const exec = deps.controlExec || deps.exec;
717
+ const writeFile = deps.controlWriteFile || deps.writeFile;
718
+ let cred = null;
719
+ let probe = { softFailed: true };
720
+ try {
721
+ // This runs on every re-provision of a LIVE instance, so a probe that cannot even start
722
+ // (token lookup, credential write) must keep the skip — never fail a healthy instance.
723
+ try {
724
+ cred = await scaffoldCredential(inst, repo, deps, writeFile);
725
+ probe = exec(`git -C ${root} ${cred.credOpt}ls-remote --heads ${cred.credFile ? cred.httpsUrl : 'origin'} main`, { allowFail: true });
726
+ } catch { deps.log(` [scaffold] could not probe ${repo.owner}/${repo.name} for main — keeping the skip`); }
727
+ if (probe.softFailed || String(probe.stdout || '').trim()) return null;
728
+ deps.log(` [scaffold] ${root} is scaffolded but ${repo.owner}/${repo.name} has no main — re-pushing the scaffold that never landed`);
729
+ pushScaffold({ exec, log: deps.log, root, repo, mode, cred });
730
+ return { ok: true, resumed: true, mode };
731
+ } finally {
732
+ if (cred && cred.credFile) exec(`rm -f ${cred.credFile}`, { allowFail: true });
733
+ }
734
+ }
735
+
697
736
  // ── private-repo deploy key (ADR 0176, task 1002735; walk finding N4) ────────────
698
737
 
699
738
  function deployKeyTitle(inst) { return `cloudbongos-deploy-${inst.slug}`; }
@@ -43,7 +43,7 @@ async function teardownInstance(inst, deps) {
43
43
  // Left for an operator (data is precious), naming ONLY what this row created —
44
44
  // teardown now takes 'requested'/'error'. `port` + `domain` stay held: ADR 0181.
45
45
  if (inst.db_name) log(` (left for the operator: dropdb ${dbName(inst)}, prune ${CONFIG.backupDir}/${dbName(inst)}-*.sql.gz)`);
46
- } else if (apply && deps.doApi && inst.droplet_id) {
46
+ } else if (apply && inst.droplet_id && deps.doApi) {
47
47
  await deps.doApi.deleteDroplet(inst.droplet_id);
48
48
  }
49
49
  parkAddress(inst, { controlExec, log, apply });
@@ -1393,12 +1393,14 @@ async function main() {
1393
1393
  if (CONFIG.remoteHost && !CONFIG.remoteCwd) { console.error('PROVISION_REMOTE_HOST is set but PROVISION_REMOTE_CHECKOUT is not — name the co-hosting box\'s checkout dir (the old hardcoded default was removed, task 1002756).'); process.exit(2); }
1394
1394
  const exec = makeExec({ apply, remoteHost: CONFIG.remoteHost, remoteCwd: CONFIG.remoteCwd, sshOpts: CONFIG.sshOpts });
1395
1395
  const controlExec = makeExec({ apply });
1396
+ let doApiClient = null;
1396
1397
  const deps = {
1397
1398
  db: pool, provisioning, apply, privileged: CONFIG.privileged, log: console.log,
1398
1399
  exec, controlExec,
1399
1400
  writeFile: makeWriteFile({ apply, privileged: CONFIG.privileged, exec, remoteHost: CONFIG.remoteHost }),
1400
1401
  controlWriteFile: makeWriteFile({ apply, privileged: CONFIG.privileged, exec: controlExec }),
1401
- doApi: DoApi ? new DoApi({ dryRun: !apply, token: CONFIG.doToken, log: (m) => console.log(' ' + m) }) : null,
1402
+ // Built on first READ (task 1002718 F6): an --apply tick whose intents never call DO needs no DO_API_TOKEN.
1403
+ get doApi() { return DoApi ? (doApiClient = doApiClient || new DoApi({ dryRun: !apply, token: CONFIG.doToken, log: (m) => console.log(' ' + m) })) : null; },
1402
1404
  // Cross-site identity (ADR 0141 §4). Null on a non-hub control plane → federation
1403
1405
  // dormant; shouldFederate() then skips and the instance owns its auth (as today).
1404
1406
  platformIdentity, hubKeys,
@@ -33,6 +33,7 @@
33
33
  // Detection override (tests / forcing): OTB_PUSH_VIA_SERVER=1 (server) / =0 (local).
34
34
 
35
35
  const { hasFlag } = require('./cli-lib');
36
+ const { plainCardLines } = require('./plain-cards');
36
37
 
37
38
  // ---- pure core ---------------------------------------------------------------
38
39
 
@@ -70,6 +71,7 @@ function report(via) {
70
71
  if (via === 'server') {
71
72
  console.log(' server-mediated (no authenticated gh — a dev box or credential-less checkout).');
72
73
  console.log(` ${BRIEF}`);
74
+ for (const line of plainCardLines('no_push_credential')) console.log(line);
73
75
  } else if (via === 'local') {
74
76
  console.log(' local — this machine pushes with its own git + gh credentials.');
75
77
  } else {
@@ -10,6 +10,7 @@ const { execFileSync } = require('node:child_process');
10
10
  const path = require('node:path');
11
11
  const { cliClient, requireSession, arg } = require('./cli-lib');
12
12
  const { parkedMainCheckoutWarning } = require('./worktree-claim-guard');
13
+ const { plainCardLines } = require('./plain-cards');
13
14
 
14
15
  // gitAt(args, cwd) — run git with an ARGV ARRAY and no shell.
15
16
  //
@@ -73,6 +74,7 @@ function mainCheckoutRoot() {
73
74
  console.error(`Your active claims:`);
74
75
  for (const c of claims) console.error(` #${c.task_id}: ${c.title}`);
75
76
  }
77
+ for (const line of plainCardLines('no_active_claim')) console.error(line);
76
78
  process.exit(1);
77
79
  }
78
80
 
@@ -97,6 +99,7 @@ function mainCheckoutRoot() {
97
99
  console.error(`This claim was created in a different session${wt ? ` (worktree: ${wt})` : ''}.`);
98
100
  console.error('Only the original session can release it.');
99
101
  console.error('If that session is gone, re-run with --force to override.');
102
+ for (const line of plainCardLines('CLAIM_SESSION_MISMATCH')) console.error(line);
100
103
  } else {
101
104
  console.error(`release failed (${r.status}):`, r.data);
102
105
  }
@@ -10,6 +10,7 @@
10
10
 
11
11
  const { cliClient, requireSession, arg, detectWorktreeName, cardFlagsFromMe } = require('./cli-lib');
12
12
  const { resolveProse, markShipProgress, smokeGateMessages, proseFlagPresent, regradeNotesRefusal, staleSummaryNotice } = require('./ship-honesty.js');
13
+ const { plainCardLines } = require('./plain-cards.js');
13
14
  const { classifyEmptyShip } = require('../../modules/lifecycle/ship-preflight');
14
15
  const { isModuleKey, scopeForModule, moduleDriftFiles: computeModuleDrift } = require('../../src/bongos/module-scope-map');
15
16
  const { runShipGate } = require('./sandbox-stage');
@@ -251,12 +252,14 @@ async function shipMain() {
251
252
  console.error('');
252
253
  console.error('Your work was never committed — shipping now would mark the task "shipped" with NO artifact on main (the ledger would lie).');
253
254
  console.error('Stage + commit your work (e.g. `git add -A && git commit`), then re-run /builder-ship. Your claim is still active.');
255
+ for (const line of plainCardLines('uncommitted_work')) console.error(line);
254
256
  process.exit(1);
255
257
  }
256
258
  if (verdict.reason === 'nothing-to-ship') {
257
259
  console.error(`Nothing to ship: the committed diff vs ${graderBaseline || 'baseline'} is empty and the working tree is clean.`);
258
260
  console.error('The grader reads committed history, not the working tree — commit your work on this branch, then re-run /builder-ship. Your claim is still active.');
259
261
  console.error('If this task legitimately has no code/doc artifact (a pure DB-only or no-op task), re-run with --allow-empty.');
262
+ for (const line of plainCardLines('nothing_to_ship')) console.error(line);
260
263
  process.exit(1);
261
264
  }
262
265
  // verdict.reason === 'allow-empty': a deliberate no-artifact / DB-only ship.
@@ -425,6 +428,7 @@ async function shipMain() {
425
428
  if (!passed && !allowSmokeFail) {
426
429
  console.error('');
427
430
  for (const line of smokeGateMessages(taskId).abort) console.error(line);
431
+ for (const line of plainCardLines('smoke_failed')) console.error(line);
428
432
  process.exit(1);
429
433
  }
430
434
 
@@ -14,6 +14,7 @@ const os = require('node:os');
14
14
  const { cliClient, rewardPolicy } = require('./cli-lib');
15
15
  const { gitOk } = require('./ship-git.js');
16
16
  const { resolveInstanceBase } = require('./ship-deploy-target.js');
17
+ const { plainCardLines } = require('./plain-cards.js');
17
18
  // task 1003313: the shared onboarding/doctor preflight — reused (not duplicated)
18
19
  // so the grader outage explains itself with the same `claude CLI` verdict doctor
19
20
  // gives, and the two can never drift.
@@ -294,6 +295,7 @@ function formatGradeOutcome(g, taskId, cliFinding = null) {
294
295
  out.push(` node scripts/gds/ship.js ${taskId} --regrade`);
295
296
  out.push('Or land it now via manual-confirm:');
296
297
  out.push(` curl -X POST -H "Authorization: Bearer <token>" ${resolveInstanceBase() || '<instance-origin>'}/api/bongos/tasks/${taskId}/confirm`);
298
+ out.push(...plainCardLines('grade_unavailable'));
297
299
  return out.join('\n');
298
300
  }
299
301
 
@@ -324,6 +326,7 @@ function formatGradeOutcome(g, taskId, cliFinding = null) {
324
326
  out.push(' - Split the protected files out of your diff and re-ship the rest, or');
325
327
  out.push(' - hand this task to a builder at/above the floor the refusal names.');
326
328
  out.push(`(--regrade re-runs the same server check and will refuse again unless the diff changes.)`);
329
+ out.push(...plainCardLines('grade_refused_authorization'));
327
330
  return out.join('\n');
328
331
  }
329
332
 
@@ -351,6 +354,7 @@ function formatGradeOutcome(g, taskId, cliFinding = null) {
351
354
  out.push(` node scripts/gds/ship.js ${taskId} --regrade`);
352
355
  out.push('Or land it now via manual-confirm:');
353
356
  out.push(` curl -X POST -H "Authorization: Bearer <token>" ${resolveInstanceBase() || '<instance-origin>'}/api/bongos/tasks/${taskId}/confirm`);
357
+ out.push(...plainCardLines('grade_lens_unavailable'));
354
358
  return out.join('\n');
355
359
  }
356
360
 
@@ -378,6 +382,7 @@ function formatGradeOutcome(g, taskId, cliFinding = null) {
378
382
  out.push(` node scripts/gds/ship.js ${taskId} --regrade`);
379
383
  out.push('Last resort (only if the grade was wrong):');
380
384
  out.push(` curl -X POST -H "Authorization: Bearer <token>" ${resolveInstanceBase() || '<instance-origin>'}/api/bongos/tasks/${taskId}/confirm`);
385
+ out.push(...plainCardLines('grade_failed'));
381
386
  }
382
387
  return out.join('\n');
383
388
  }
@@ -15,6 +15,7 @@ const { cliClient, isBoxScope } = require('./cli-lib');
15
15
  const { gitArgvOk, gitOk, sanitizeGitHubRefs, shellOk } = require('./ship-git.js');
16
16
  const { buildPrCreateArgv, buildPrMergeArgv, deployRunSucceeded, deployedCommitStatus, fetchDeployedVersion, ghArgvOk, ghJson, pushVia, resolveInstanceBase } = require('./ship-deploy-target.js');
17
17
  const { normalizeApiError } = require('./ship-io.js');
18
+ const { plainCardLines } = require('./plain-cards.js');
18
19
 
19
20
  // ---------- the land outcome (task 1002541, part 1) ----------
20
21
  //
@@ -311,6 +312,7 @@ function landPendingLines(taskId, { merged } = {}) {
311
312
  function printLandPending(taskId, opts = {}) {
312
313
  console.log('');
313
314
  for (const line of landPendingLines(taskId, opts)) console.log(line);
315
+ for (const line of plainCardLines('land_pending')) console.log(line);
314
316
  }
315
317
 
316
318
  // task 1002645 — the honest close-out when the PR MERGED but the server refused the
@@ -349,6 +351,14 @@ function redPrLine(md) {
349
351
  return named ? ` ci-land: the PR is RED — the \`${named[1]}\` check failed; ${fix}` : null;
350
352
  }
351
353
 
354
+ // redPrLines — the red line followed by the same reason in plain words (task 1003989).
355
+ // The card is keyed on the merge reason; when the failing tests were readable that
356
+ // reason is `failing_check:<name>`, which resolves to the failing_check card.
357
+ function redPrLines(md) {
358
+ const red = redPrLine(md);
359
+ return red ? [red, ...plainCardLines((md && md.reason) || 'failing_check')] : [];
360
+ }
361
+
352
362
  // One module-scope sleep command shared by both poll loops (task 1093 / C9). A
353
363
  // child `node -e setTimeout` is used (not `await new Promise`) so it blocks
354
364
  // identically in the SYNCHRONOUS pollCiDeploy and the async pollServerPublish —
@@ -377,7 +387,7 @@ async function pollServerPublish(taskId, branch) {
377
387
  const red = redPrLine(r.data.merge_driver);
378
388
  if (red && red !== redLine) {
379
389
  redLine = red;
380
- console.log(red);
390
+ for (const line of redPrLines(r.data.merge_driver)) console.log(line);
381
391
  }
382
392
  const outcome = classifyLandOutcome(r.data);
383
393
  if (outcome === 'shipped') {
@@ -401,6 +411,7 @@ async function pollServerPublish(taskId, branch) {
401
411
  if (blockedReason) {
402
412
  console.log('');
403
413
  for (const line of reconcileBlockedLines(taskId, blockedReason)) console.log(line);
414
+ for (const line of plainCardLines('reconcile_blocked')) console.log(line);
404
415
  return landBailed(`the PR merged but the server could not prove the land is this task's: ${blockedReason}`, `node scripts/gds/ship.js ${taskId}`);
405
416
  }
406
417
  printLandPending(taskId);
@@ -562,6 +573,7 @@ async function ciLandLocal(taskId, branch, valueSummary) {
562
573
  } else {
563
574
  console.log(` ci-land: to retry — ${d.fix}`);
564
575
  }
576
+ for (const line of plainCardLines(`ship:${d.id}`)) console.log(line);
565
577
  return landBailed(
566
578
  `could not push ${branch} to origin (${d.id})`,
567
579
  d.terminal ? d.fix.split('\n')[0] : `node scripts/gds/ship.js ${taskId}`,
@@ -713,6 +725,7 @@ module.exports = {
713
725
  pickStaleGateReviewRun,
714
726
  reconcileBlockedLines,
715
727
  redPrLine,
728
+ redPrLines,
716
729
  reconciledCardStatus,
717
730
  strandCommand,
718
731
  strandNextStep,