@bongos/core 1.19.1032 → 1.19.1034

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.
Files changed (151) hide show
  1. package/.bongos-core.json +322 -117
  2. package/.claude/skills/builder-start/SKILL.md +1 -1
  3. package/.claude/skills/grade-audit/SKILL.md +1 -1
  4. package/.claude/skills/grade-recover/SKILL.md +2 -1
  5. package/.claude/skills/grader-health/SKILL.md +5 -1
  6. package/.claude/skills/merge-mode/SKILL.md +1 -1
  7. package/docs/adr/0089-modular-multi-model-adversarial-grader.md +15 -0
  8. package/docs/adr/0175-the-board-room-full-idea-ratification.md +3 -2
  9. package/docs/adr/0178-the-copy-desk-flag-and-queue.md +3 -3
  10. package/docs/adr/0188-patrol-runs-on-ci-not-the-server.md +2 -2
  11. package/docs/adr/0189-baseline-floors-and-the-freeze-detector.md +1 -1
  12. package/docs/adr/0233-a-copy-proposal-is-a-task-carrying-a-patch.md +1 -1
  13. package/docs/adr/0241-the-artist-gate-is-a-per-project-deploy-gate-that-reads-a-state.md +11 -3
  14. package/docs/adr/README.md +4 -4
  15. package/docs/api/openapi.json +1 -1
  16. package/docs/architecture.md +4 -3
  17. package/docs/canonical-permissions.md +2 -2
  18. package/docs/copy-inventory.md +34 -30
  19. package/docs/copy-registry.json +69 -33
  20. package/docs/file-map.md +4 -3
  21. package/docs/module-api-changelog.md +4 -0
  22. package/docs/recipes/autobongos-windows-host.md +3 -1
  23. package/docs/recipes/local-dev.md +45 -3
  24. package/docs/recipes/packaging-the-core.md +3 -1
  25. package/docs/recipes/redteam-patrol.md +2 -2
  26. package/docs/recipes/upgrading-the-core.md +1 -1
  27. package/modules/autonomy/cadence.js +52 -0
  28. package/modules/autonomy/gauge.js +57 -9
  29. package/modules/government/protected-surfaces.json +1 -0
  30. package/modules/grading/grade-engine.js +267 -0
  31. package/modules/grading/grader-subagent.js +4 -3
  32. package/modules/grading/grader-workers/index.js +67 -11
  33. package/modules/grading/grader-workers/worker-verdict.js +32 -2
  34. package/modules/grading/grader.js +109 -88
  35. package/modules/hall-ui/public/deploy.js +35 -0
  36. package/modules/hall-ui/public/project-settings.css +7 -0
  37. package/modules/hall-ui/records/builders-roster.md +10 -0
  38. package/modules/hall-ui/records/catalog.md +10 -0
  39. package/modules/hall-ui/records/collaboration.md +14 -0
  40. package/modules/hall-ui/records/goal-dependency-forest.md +11 -0
  41. package/modules/hall-ui/records/oversight-family.md +19 -0
  42. package/modules/hall-ui/records/panel-family.md +19 -0
  43. package/modules/hall-ui/records/reading-rooms.md +12 -0
  44. package/modules/hall-ui/records/record-family.md +13 -0
  45. package/modules/hall-ui/records/reference-room.md +9 -0
  46. package/modules/hall-ui/records/roster-and-ledger.md +9 -0
  47. package/modules/hall-ui/records/sky.md +23 -0
  48. package/modules/lifecycle/dependency-advisory.js +2 -1
  49. package/modules/lifecycle/routes/tasks.js +19 -4
  50. package/modules/lifecycle/ship-preflight.js +91 -0
  51. package/modules/provisioning/control-plane-guard.js +14 -13
  52. package/modules/provisioning/core-upgrade.js +30 -17
  53. package/modules/provisioning/public-refusal.js +44 -0
  54. package/modules/provisioning/routes/disconnect.js +2 -1
  55. package/modules/provisioning/routes/env-manifest.js +5 -4
  56. package/modules/provisioning/routes/provisioning.js +16 -15
  57. package/modules/provisioning/routes/repo-private.js +2 -1
  58. package/package-lock.json +2 -2
  59. package/package.json +1 -1
  60. package/release-notes.json +7481 -0
  61. package/scripts/gds/address-park-sweep.sh +3 -1
  62. package/scripts/gds/autobongos-run.js +91 -3
  63. package/scripts/gds/backfill-cost.js +20 -9
  64. package/scripts/gds/baseline-staleness.js +1 -1
  65. package/scripts/gds/claim.js +2 -2
  66. package/scripts/gds/credential-presence.js +281 -0
  67. package/scripts/gds/db-backup-nightly.sh +9 -4
  68. package/scripts/gds/db-backup-offsite.sh +4 -2
  69. package/scripts/gds/deploy-main-audit-gate.sh +11 -4
  70. package/scripts/gds/dev-lib.js +212 -5
  71. package/scripts/gds/dev.js +102 -50
  72. package/scripts/gds/doctor.js +3 -2
  73. package/scripts/gds/grade-correlation-audit.js +280 -0
  74. package/scripts/gds/grade-replay.js +237 -0
  75. package/scripts/gds/hooks/pre-push.js +7 -3
  76. package/scripts/gds/main-audit.js +19 -10
  77. package/scripts/gds/memory-map.js +3 -1
  78. package/scripts/gds/onboarding-config.js +3 -2
  79. package/scripts/gds/package-core.js +72 -1
  80. package/scripts/gds/plain-cards.js +4 -0
  81. package/scripts/gds/preflight.js +12 -4
  82. package/scripts/gds/provision-core-upgrade.js +282 -80
  83. package/scripts/gds/provision-repo-auth.js +15 -0
  84. package/scripts/gds/provision.js +1 -1
  85. package/scripts/gds/publish-credential-check.js +3 -0
  86. package/scripts/gds/push-main.js +14 -3
  87. package/scripts/gds/push-path-brief.js +3 -2
  88. package/scripts/gds/redteam-patrol.js +4 -4
  89. package/scripts/gds/release-notes.js +271 -0
  90. package/scripts/gds/run-routine.js +18 -5
  91. package/scripts/gds/run-unit-tests.js +14 -3
  92. package/scripts/gds/seed-board-room-tasks.js +1 -1
  93. package/scripts/gds/session-cost.js +12 -3
  94. package/scripts/gds/ship-deploy-target.js +11 -6
  95. package/scripts/gds/ship-flow.js +5 -3
  96. package/scripts/gds/ship-grade-steps.js +33 -0
  97. package/scripts/gds/ship-land.js +241 -16
  98. package/scripts/gds/ship-merge.js +3 -2
  99. package/scripts/gds/ship-preflight-steps.js +86 -11
  100. package/scripts/gds/ship.js +1 -1
  101. package/scripts/gds/stray-unit-sweep.sh +3 -1
  102. package/scripts/gds/upgrade-migrate.js +67 -0
  103. package/scripts/gds/upgrade.js +9 -7
  104. package/scripts/hall-preview/README.md +20 -3
  105. package/scripts/hall-preview/live-identity.js +1 -1
  106. package/scripts/hall-preview/refresh-fixtures.js +71 -11
  107. package/scripts/hall-preview/scrub-identity.js +296 -0
  108. package/src/bongos/routes/security.js +3 -3
  109. package/src/build-info.js +4 -2
  110. package/src/module-api.js +1 -1
  111. package/tests/adr_0089_scope.mjs +70 -0
  112. package/tests/adr_0162_citation.mjs +184 -0
  113. package/tests/autobongos_cadence.mjs +74 -0
  114. package/tests/autobongos_loop.mjs +114 -0
  115. package/tests/autonomy_gauge.mjs +56 -0
  116. package/tests/consumer_layout_boot.mjs +363 -31
  117. package/tests/core_upgrade_door.mjs +40 -6
  118. package/tests/core_upgrade_runner.mjs +329 -5
  119. package/tests/credential_presence.mjs +153 -0
  120. package/tests/db_backup_nightly.mjs +21 -3
  121. package/tests/db_backup_offsite.mjs +81 -0
  122. package/tests/deploy_audit_gate.mjs +5 -2
  123. package/tests/deploy_page_projects.mjs +63 -2
  124. package/tests/dev_lib.mjs +2 -1
  125. package/tests/dev_windows.mjs +324 -0
  126. package/tests/env_knobs_neutral_prefix.mjs +203 -0
  127. package/tests/fixtures/grade-replay/recorded-panel-grades.json +27 -0
  128. package/tests/government_protected_surfaces.mjs +10 -0
  129. package/tests/grade_correlation_audit.mjs +159 -0
  130. package/tests/grade_engine.mjs +249 -0
  131. package/tests/grade_engine_replay.mjs +99 -0
  132. package/tests/grader_prompt_window_bound.mjs +132 -0
  133. package/tests/grader_unbacked_score.mjs +194 -0
  134. package/tests/hall_preview_scrub.mjs +214 -0
  135. package/tests/hall_records.mjs +85 -0
  136. package/tests/helpers.mjs +25 -0
  137. package/tests/instance_db_migrated_on_code_move.mjs +8 -1
  138. package/tests/integration_parked_ratchet.mjs +24 -1
  139. package/tests/plain_cards.mjs +1 -1
  140. package/tests/provisioning_public_refusal.mjs +195 -0
  141. package/tests/publish_status_branch.mjs +128 -0
  142. package/tests/redteam_patrol.mjs +1 -1
  143. package/tests/release_notes.mjs +327 -0
  144. package/tests/shell_stat_portability.mjs +49 -0
  145. package/tests/ship_land_conflict.mjs +181 -0
  146. package/tests/ship_land_red_pr.mjs +236 -0
  147. package/tests/ship_preflight.mjs +226 -3
  148. package/tests/start_env_guard.mjs +16 -0
  149. package/tests/sweep_app_account.mjs +15 -2
  150. package/tests/upgrade.mjs +20 -1
  151. package/tests/upgrade_migrate.mjs +195 -0
@@ -18,7 +18,7 @@ const { captureSurpriseLearning, promptSurprise } = require('./ship-surprise.js'
18
18
  // task 1003109: the optional `--visual` leg of a ship (goal 1000072).
19
19
  const shipVisual = require('./ship-visual.js');
20
20
  const { gitOk } = require('./ship-git.js');
21
- const { autoLinkifyChangedDocs, mergeMainIntoBranch, preflight } = require('./ship-preflight-steps.js');
21
+ const { autoLinkifyChangedDocs, mergeMainIntoBranch, preflight, shipInputPaths } = require('./ship-preflight-steps.js');
22
22
  const { applyBaseOverride, computeChangedFiles, computeGraderBaseline, runSmokeAsync } = require('./ship-grade.js');
23
23
  const { buildPriorRoundContext, computeGradeResult, gradeAndRecord, printCreditsAndAchievements, printGradeOutcome } = require('./ship-grade-steps.js');
24
24
  const { apiErrorLine, normalizeApiError, isUnknownFieldRejection } = require('./ship-io.js');
@@ -196,7 +196,9 @@ async function shipMain() {
196
196
  // ---- Step 0: preflight (working tree + branch freshness) ----
197
197
  console.log(`Verifying claim #${ac.claim_id} on task #${ac.task_id}: ${ac.title}`);
198
198
  console.log('Preflight (working tree + branch freshness)...');
199
- const pre = preflight(ac, { force });
199
+ // task 1001436: inputFiles keeps --notes-file/--summary-file/--visual out of the
200
+ // preflight's untracked-file warning — they are this ship's input, not its artifact.
201
+ const pre = preflight(ac, { force, inputFiles: shipInputPaths(args) });
200
202
  if (!pre.ok) {
201
203
  console.error('');
202
204
  console.error(`Ship aborted: ${pre.reason}. No claim resolved, no credits awarded.`);
@@ -929,7 +931,7 @@ async function regradeMain() {
929
931
  // if the local ship/grade tooling is behind main (a stale grader silently
930
932
  // fails the same way — the #662 lesson). --force overrides the freshness leg.
931
933
  console.log('Preflight (working tree + branch freshness)...');
932
- const pre = preflight(claimLike, { force });
934
+ const pre = preflight(claimLike, { force, inputFiles: shipInputPaths(args) });
933
935
  if (!pre.ok) {
934
936
  console.error('');
935
937
  console.error(`Re-grade aborted: ${pre.reason}. Task stays at completed.`);
@@ -20,6 +20,8 @@ const { plainCardLines } = require('./plain-cards.js');
20
20
  // gives, and the two can never drift.
21
21
  const { preflightChecks } = require('./preflight.js');
22
22
  const { computeRawDiffHash, reconstructPanelIssues, runNoArtifactGrader, runSubagentGrader, shouldReuseGrade } = require('./ship-grade.js');
23
+ // task 1002903: the severity gate's own reading of which low scores had no finding.
24
+ const { unbackedLowScores } = require('../../modules/grading/grader-workers/worker-verdict.js');
23
25
 
24
26
  // ---------- Shared grade-step helpers (used by shipMain AND regradeMain) ----------
25
27
  //
@@ -396,6 +398,37 @@ function formatGradeOutcome(g, taskId, cliFinding = null) {
396
398
  return out.join('\n');
397
399
  }
398
400
 
401
+ // task 1002903: a reviewer scored below threshold and named NO major/blocker
402
+ // finding, so the severity gate (task 1002663) downgraded it — the ship parks for
403
+ // a human rather than hard-failing. The ordinary block below would print "FAIL"
404
+ // and "Fix the issues above" over an empty list, which is how task 1002721's
405
+ // builder read a real grade as a broken one. Sits below the outage and
406
+ // authorization shapes, and steps aside when any finding IS substantive or a
407
+ // gating worker hard-failed (then the ordinary block and its findings apply).
408
+ // The record's own list wins; the workers are the fallback for a grade written
409
+ // before signals.unbacked_scores existed.
410
+ const gradeThreshold = Number(g.threshold);
411
+ const unbacked = Array.isArray(g.signals?.unbacked_scores)
412
+ ? g.signals.unbacked_scores
413
+ : Number.isFinite(gradeThreshold) ? unbackedLowScores(g.signals?.workers, { threshold: gradeThreshold }) : [];
414
+ const hasSubstantive = (g.issues || []).some((i) => i && (i.severity === 'blocker' || i.severity === 'major'));
415
+ if (!g.passed && unbacked.length > 0 && !hasSubstantive && g.signals?.panel_outcome !== 'fail') {
416
+ out.push('');
417
+ out.push(`Subagent grade: NOT PASSED — UNBACKED LOW SCORE · a reviewer scored below ${g.threshold ?? '?'} without naming a finding — NOT a findings-backed rejection`);
418
+ for (const u of unbacked) {
419
+ out.push(` ${u.kind}: ${u.axis} ${u.score}/10 — no major or blocker finding stands behind it`);
420
+ }
421
+ if (g.notes_md) out.push(g.notes_md.split('\n').map((l) => ' ' + l).join('\n'));
422
+ out.push('');
423
+ out.push('There is nothing listed to fix. The severity gate does not let an unbacked score hard-fail a ship, so the task stays at completed for a human instead.');
424
+ out.push('Re-grade once — an unbacked score often does not repeat:');
425
+ out.push(` node scripts/gds/ship.js ${taskId} --regrade`);
426
+ out.push('If it repeats on work you can defend, file an override-request with your evidence (recorded; an Archon decides):');
427
+ out.push(` node scripts/gds/api.js POST /api/bongos/tasks/${taskId}/override-request --body-file rationale.json # {"rationale":"<why the grade is wrong, specifically>"}`);
428
+ out.push(...plainCardLines('grade_unbacked_score'));
429
+ return out.join('\n');
430
+ }
431
+
399
432
  const verdict = g.passed ? 'PASS' : 'FAIL';
400
433
  out.push('');
401
434
  out.push(`Subagent grade: ${verdict} · blended ${g.score}/10 · threshold ${g.threshold} (both axes)`);
@@ -16,6 +16,9 @@ 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
18
  const { plainCardLines } = require('./plain-cards.js');
19
+ // The server's own reader of a PR's check rollup (task 1003988), reused so the laptop
20
+ // poll names a red PR exactly the way publish-status does (task 1003764). Dependency-free.
21
+ const { readCheckRollup, UNIT_REPORT_CONTEXT } = require('../../modules/lifecycle/check-rollup.js');
19
22
 
20
23
  // ---------- the land outcome (task 1002541, part 1) ----------
21
24
  //
@@ -284,7 +287,19 @@ function strandNextStep(taskId, via) {
284
287
  // too: the server flags a strand needs_rebase (definitive shapes within a sweep,
285
288
  // ambiguous ones at ~30 min), /builder-start warns, and new claims gate on it.
286
289
  // Pure + exported so the wording is pinned by tests/ship_ci_deploy.mjs.
287
- function landPendingLines(taskId, { merged } = {}) {
290
+ //
291
+ // task 1003764: `red` — the PR was RED on the poll's last read. "NOT a failure" is then
292
+ // false: a required check that failed does not pass by waiting, so the banner says the
293
+ // land is stuck on it and defers to the red lines above for the remedy.
294
+ function landPendingLines(taskId, { merged, red } = {}) {
295
+ if (red) {
296
+ return [
297
+ ' ci-land: Published, but the PR is RED (the check named above), so it has NOT merged and will not',
298
+ ' until that check passes. The task stays at \'confirmed\'. Do what the lines above say, then re-run',
299
+ ` \`node scripts/gds/ship.js ${taskId}\` — it resumes from here (task 941). If it stays red, the server flags`,
300
+ ' the task needs_rebase — /builder-start will warn you and new claims are gated until it lands or is released.',
301
+ ];
302
+ }
288
303
  return [
289
304
  ` ci-land: ✓ Published — auto-merge is armed${merged ? ' and the PR has merged' : ''}. This is NOT a failure.`,
290
305
  ` CI is still finishing; GitHub will ${merged ? 'deploy' : 'merge + deploy'} automatically once the required checks pass,`,
@@ -298,7 +313,9 @@ function landPendingLines(taskId, { merged } = {}) {
298
313
  function printLandPending(taskId, opts = {}) {
299
314
  console.log('');
300
315
  for (const line of landPendingLines(taskId, opts)) console.log(line);
301
- for (const line of plainCardLines('land_pending')) console.log(line);
316
+ // The land_pending card says there is nothing to do, which a red PR makes untrue; the
317
+ // red line already printed the failing_check card (task 1003764).
318
+ if (!opts.red) for (const line of plainCardLines('land_pending')) console.log(line);
302
319
  }
303
320
 
304
321
  // task 1002645 — the honest close-out when the PR MERGED but the server refused the
@@ -349,9 +366,150 @@ function redPrLine(md) {
349
366
  // redPrLines — the red line followed by the same reason in plain words (task 1003989).
350
367
  // The card is keyed on the merge reason; when the failing tests were readable that
351
368
  // reason is `failing_check:<name>`, which resolves to the failing_check card.
352
- function redPrLines(md) {
369
+ // task 1003764: the merge trap sits between them. `opts` = { taskId, via } for its wording.
370
+ function redPrLines(md, opts = {}) {
353
371
  const red = redPrLine(md);
354
- return red ? [red, ...plainCardLines((md && md.reason) || 'failing_check')] : [];
372
+ return red ? [red, ...mergeTrapLines(md, opts), ...plainCardLines((md && md.reason) || 'failing_check')] : [];
373
+ }
374
+
375
+ // The unit tests that gate GENERATED files — each compares a committed artifact with a
376
+ // fresh build of it. `fitness` holds the combined gate (fitness.js "generated artifacts
377
+ // fresh": copy registry, API docs, file map, nested CLAUDE.md blocks, the §13 snippet).
378
+ // tests/ship_land_red_pr.mjs fails if one of these names stops being a real test file.
379
+ const FRESHNESS_TESTS = new Set(['fitness', 'api_docs', 'api_client', 'file_map', 'repo_map', 'session_index', 'interaction_posture']);
380
+
381
+ // failingTestNames — "2 failed: a, b" (the unit-report line, run-unit-tests.js
382
+ // describeFailures) → ['a', 'b']. A line cut at the status limit ends in '…'; the cut
383
+ // name is dropped rather than guessed at. Pure.
384
+ function failingTestNames(text) {
385
+ const m = /^\s*\d+\s+failed:\s*(.*)$/.exec(String(text || ''));
386
+ if (!m) return [];
387
+ const names = m[1].split(',').map((s) => s.trim()).filter(Boolean);
388
+ if (names.length && names[names.length - 1].endsWith('…')) names.pop();
389
+ return names;
390
+ }
391
+
392
+ // mergeTrapLines — task 1003764. CI tests the MERGE, not the branch: a pull_request run
393
+ // checks out the PR merged into the CURRENT main, so a branch that was green when pushed
394
+ // goes red when main moves under it, and re-running the test on the branch shows nothing
395
+ // wrong. Task 1003747's land sat "waiting" for ~20 minutes of git archaeology on exactly
396
+ // that — main moved six commits, the copy registry went stale in the merged tree only.
397
+ // Every red PR gets the trap named; a failing freshness gate also gets its healing command,
398
+ // which differs by push path (a credential-less machine cannot merge main locally, and the
399
+ // server heals it for them — publish-reconciler refreshStaleGenerated). Pure + exported.
400
+ function mergeTrapLines(md, { taskId = '<id>', via = 'local' } = {}) {
401
+ const lines = [
402
+ ' ci-land: CI tests your branch MERGED with the current main, not the branch alone — if the failure',
403
+ via === 'server'
404
+ ? ' does not reproduce on your branch, main moved under the PR after it was pushed.'
405
+ : ' does not reproduce on your branch, main moved under it: `git fetch origin && git merge origin/main`, then re-run it.',
406
+ ];
407
+ const gates = failingTestNames(md && md.failing_tests).filter((n) => FRESHNESS_TESTS.has(n));
408
+ if (!gates.length) return lines;
409
+ const named = gates.map((g) => `\`${g}\``).join(', ');
410
+ lines.push(` ci-land: ${named} ${gates.length > 1 ? 'are freshness gates' : 'is a freshness gate'}, so "fix it" here means regenerate: a generated file (copy`);
411
+ lines.push(' registry, API docs, repo map…) is stale in the MERGED tree even though your branch was fresh.');
412
+ if (via === 'server') {
413
+ lines.push(' The server regenerates stale generated files on the merged tree itself (every ~5 min, up to 3 tries),');
414
+ lines.push(' so this usually clears without you; if it stays red the server flags the task needs_rebase.');
415
+ } else {
416
+ lines.push(` Heal it: \`git fetch origin && git merge origin/main\`, then re-run \`node scripts/gds/ship.js ${taskId}\` — the ship`);
417
+ lines.push(' regenerates every generated file on the merged tree before it pushes (`node scripts/gds/ship-check.js` shows which).');
418
+ }
419
+ lines.push(' A busy main can re-stale it while CI re-runs; a repeat is that race, not a new fault.');
420
+ return lines;
421
+ }
422
+
423
+ // redBlocksMerge — is this red the kind that stops the merge? A failing REQUIRED check
424
+ // (`failing_check…`) does; a failing optional one does not (GitHub still merges an UNSTABLE
425
+ // PR), so only the first may turn the closing banner red (task 1003764). Pure + exported.
426
+ function redBlocksMerge(md) {
427
+ return /^failing_check/.test(String((md && md.reason) || ''));
428
+ }
429
+
430
+ // A check conclusion (CheckRun) or state (status context) that means FAILED. Pending and
431
+ // expected are waiting, not red — reporting them as failures would be the mirror image of
432
+ // the bug this names (a failure dressed as waiting).
433
+ const RED_CONCLUSIONS = new Set(['FAILURE', 'ERROR', 'TIMED_OUT', 'CANCELLED', 'ACTION_REQUIRED', 'STARTUP_FAILURE']);
434
+
435
+ // localMergeDriver — task 1003764. The laptop path watches its PR with the builder's own
436
+ // gh, and read only state + mergeCommit, so a red required check was invisible for the
437
+ // whole wait. This reads `gh pr view --json statusCheckRollup,mergeStateStatus,headRefOid`
438
+ // through the server's parser and answers in publish-status's merge_driver shape, so both
439
+ // poll paths hand redPrLines the same thing. `failingTestsFor(sha)` reads the unit-report
440
+ // status (gh pr view omits a status's description) and is asked only once that status is
441
+ // itself red, i.e. posted. Null when nothing is red. Pure + exported.
442
+ function localMergeDriver(pr, failingTestsFor = () => null) {
443
+ if (!pr || pr.state !== 'OPEN' || !Array.isArray(pr.statusCheckRollup)) return null;
444
+ const { checks } = readCheckRollup({ contexts: { nodes: pr.statusCheckRollup } });
445
+ const red = checks.filter((c) => RED_CONCLUSIONS.has(String(c.conclusion || '').toUpperCase()));
446
+ if (!red.length) return null;
447
+ const kind = pr.mergeStateStatus === 'UNSTABLE' ? 'non_required_check_failing' : 'failing_check';
448
+ const reported = red.some((c) => c.name === UNIT_REPORT_CONTEXT);
449
+ return { reason: `${kind}:${red[0].name}`, failing_tests: (reported && failingTestsFor(pr.headRefOid)) || null };
450
+ }
451
+
452
+ // unitReportFor — the failing-test line the `unit` workflow posted on `sha`, read with this
453
+ // machine's gh through the same parser the server uses. Null when unreadable; the red line
454
+ // then names the check instead, as it did before the report existed. `gh` is injectable.
455
+ function unitReportFor(sha, gh = ghJson) {
456
+ if (!/^[0-9a-f]{7,64}$/i.test(String(sha || ''))) return null;
457
+ const combined = gh(['api', `repos/{owner}/{repo}/commits/${sha}/status`]);
458
+ const nodes = ((combined && combined.statuses) || []).map((s) => ({ ...s, __typename: 'StatusContext' }));
459
+ return readCheckRollup({ contexts: { nodes } }).failing_tests;
460
+ }
461
+
462
+ // task 1003499 — a CONFLICTING PR is a land that cannot happen, however green it is.
463
+ // GitHub's auto-merge waits for a clean merge as well as for green checks, so a PR
464
+ // whose branch conflicts with main sits open under a fully green rollup and never
465
+ // merges. PR #497 sat like that for ~25 minutes while both polls below said nothing
466
+ // and then printed "auto-merge is armed ... NOT a failure", the banner a land that IS
467
+ // on its way gets. The server already knew (mergeBlockReason -> 'merge_conflict'); the
468
+ // ship never asked. Pure + exported. Takes both shapes it is handed: `gh pr view`'s
469
+ // camelCase (mergeable, mergeStateStatus) and publish-status's snake_case merge_driver
470
+ // (mergeable, merge_state, reason). A read that failed (null) is not a conflict.
471
+ function prConflicting(pr) {
472
+ if (!pr) return false;
473
+ const state = pr.mergeStateStatus ?? pr.merge_state;
474
+ return pr.mergeable === 'CONFLICTING' || state === 'DIRTY' || pr.reason === 'merge_conflict';
475
+ }
476
+
477
+ // What the poll says the moment it sees the conflict. Pure + exported so the wording
478
+ // is pinned by tests/ship_land_conflict.mjs. The poll keeps waiting afterwards on
479
+ // purpose: a conflict only in generated files is healed by the server's own resolver
480
+ // on its next sweep (ADR 0082), and the PR then merges inside this same window.
481
+ function conflictPrLines(taskId) {
482
+ return [
483
+ ' ci-land: the PR CONFLICTS with main, so auto-merge cannot fire until that is resolved, however green its checks are.',
484
+ ' ci-land: a conflict only in generated files is healed by the server on its next sweep (~5 min), so this keeps waiting.',
485
+ ' ci-land: anything hand-written waits for you. From this worktree:',
486
+ ' git fetch -q origin && git merge origin/main # resolve the conflicts, commit',
487
+ ` node scripts/gds/ship.js ${taskId} # resumes the land; it does not re-grade`,
488
+ ...plainCardLines('merge_conflict'),
489
+ ];
490
+ }
491
+
492
+ // noteConflict — one poll's worth of conflict tracking, shared by both loops. Prints
493
+ // the lines above when the PR BECOMES conflicting (not on every 10s poll, and once
494
+ // more if main moves on and undoes a heal), and returns whether it conflicts now.
495
+ // A failed read keeps the last answer rather than inventing a new one.
496
+ function noteConflict(taskId, pr, wasConflicting) {
497
+ if (!pr) return wasConflicting;
498
+ const now = prConflicting(pr);
499
+ if (now && !wasConflicting) for (const line of conflictPrLines(taskId)) console.log(line);
500
+ return now;
501
+ }
502
+
503
+ // landConflicted — the verdict when the wait ends with the PR STILL conflicting. It is
504
+ // not landing on its own, so it must not print the "landing automatically" banner or
505
+ // the land_pending card ("Nothing to do. This is normal, not a failure."): task
506
+ // 1002541's rule that a land which is not happening must not announce itself as
507
+ // success. No bailId, so ship-merge never reroutes it: the PR already exists and is
508
+ // the problem. /strand-fix walks the cure (its case C: merge main in, resolve, re-ship).
509
+ function landConflicted(taskId) {
510
+ console.log('');
511
+ console.log(` ci-land: still CONFLICTING when the wait ended, so this will NOT land on its own. The task stays at 'confirmed' until the conflict is resolved (/strand-fix ${taskId} walks it).`);
512
+ return landBailed('the PR conflicts with main, so it cannot merge until the conflict is resolved', `/strand-fix ${taskId}`);
355
513
  }
356
514
 
357
515
  // One module-scope sleep command shared by both poll loops (task 1093 / C9). A
@@ -366,8 +524,12 @@ const POLL_SLEEP_CMD = 'node -e "setTimeout(()=>{}, 10000)"';
366
524
  // reconciles the task to shipped when it observes merge+deploy — task 1049) until
367
525
  // the PR merges + deploy-prod succeeds. Returns true when prod is confirmed live;
368
526
  // a timeout with an armed PR is success-PENDING (the publish worked), not failure.
369
- async function pollServerPublish(taskId, branch) {
527
+ // A timeout with a CONFLICTING PR is not (task 1003499): see landConflicted.
528
+ // `deps` exists for tests/ship_land_conflict.mjs; a real ship passes none.
529
+ async function pollServerPublish(taskId, branch, deps = {}) {
370
530
  const TRIES = 54; // ~9 min at 10s — checks + auto-merge + Actions deploy
531
+ const readStatus = deps.readStatus || (async (q) => (await cliClient()).tasks.getTasksIdPublishStatus(q));
532
+ const pause = deps.pause || (() => shellOk(POLL_SLEEP_CMD));
371
533
  console.log(' ci-land: waiting for auto-merge + prod deploy (polling the server)...');
372
534
  // task 1002645: GitHub can report merged+deployed while the SERVER refused the
373
535
  // confirmed→shipped flip (reconcile_blocked — the task-1547 land proof missed).
@@ -376,14 +538,29 @@ async function pollServerPublish(taskId, branch) {
376
538
  // pushed tip, so the proof usually heals within the window.
377
539
  let blockedReason = null;
378
540
  let redLine = null;
541
+ let conflicting = false;
542
+ let stuckRed = false; // task 1003764: a failing required check on the LAST read turns the closing banner red
543
+ let readFailure = null;
379
544
  for (let i = 0; i < TRIES; i++) {
380
- const r = await (await cliClient()).tasks.getTasksIdPublishStatus({ id: taskId, query: { branch } });
545
+ const r = await readStatus({ id: taskId, query: { branch } });
546
+ if (!r.ok) {
547
+ // task 1003764: a failed status read used to be skipped in silence, so a broken
548
+ // diagnostic looked exactly like slow CI. Say so once per distinct failure.
549
+ const { code } = normalizeApiError(r.data);
550
+ const why = `HTTP ${r.status}${code ? ` ${code}` : ''}`;
551
+ if (why !== readFailure) {
552
+ readFailure = why;
553
+ console.log(` ci-land: could not read the land status from the server (${why}) — still polling.`);
554
+ }
555
+ }
381
556
  if (r.ok && r.data) {
382
557
  const red = redPrLine(r.data.merge_driver);
383
558
  if (red && red !== redLine) {
384
559
  redLine = red;
385
- for (const line of redPrLines(r.data.merge_driver)) console.log(line);
560
+ for (const line of redPrLines(r.data.merge_driver, { taskId, via: 'server' })) console.log(line);
386
561
  }
562
+ stuckRed = !!red && redBlocksMerge(r.data.merge_driver);
563
+ conflicting = noteConflict(taskId, r.data.merge_driver, conflicting);
387
564
  const outcome = classifyLandOutcome(r.data);
388
565
  if (outcome === 'shipped') {
389
566
  if (r.data.reconcile_blocked) {
@@ -401,7 +578,7 @@ async function pollServerPublish(taskId, branch) {
401
578
  return landBailed('the PR was closed without merging (a check failed, or it was closed manually)', `node scripts/gds/ship.js ${taskId}`);
402
579
  }
403
580
  }
404
- shellOk(POLL_SLEEP_CMD);
581
+ pause();
405
582
  }
406
583
  if (blockedReason) {
407
584
  console.log('');
@@ -409,7 +586,8 @@ async function pollServerPublish(taskId, branch) {
409
586
  for (const line of plainCardLines('reconcile_blocked')) console.log(line);
410
587
  return landBailed(`the PR merged but the server could not prove the land is this task's: ${blockedReason}`, `node scripts/gds/ship.js ${taskId}`);
411
588
  }
412
- printLandPending(taskId);
589
+ if (conflicting) return landConflicted(taskId);
590
+ printLandPending(taskId, { red: stuckRed });
413
591
  return landPending();
414
592
  }
415
593
 
@@ -723,14 +901,33 @@ async function ciLandLocal(taskId, branch, valueSummary) {
723
901
  // 3. public edge healthz -> 200 (belt-and-braces, a different vantage).
724
902
  // Times out conservatively; on any timeout the task stays at confirmed with a
725
903
  // precise next step rather than a false "shipped".
726
- function pollCiDeploy(taskId, branch, headSha) {
904
+ // `deps` exists for tests/ship_land_conflict.mjs; a real ship passes none.
905
+ function pollCiDeploy(taskId, branch, headSha, deps = {}) {
727
906
  const MERGE_TRIES = 30; // ~5 min at 10s — checks + auto-merge
728
907
  const DEPLOY_TRIES = 24; // ~4 min at 10s — Actions deploy
908
+ const readJson = deps.ghJson || ghJson;
909
+ const pause = deps.pause || (() => shellOk(POLL_SLEEP_CMD));
729
910
 
730
911
  console.log(' ci-land: waiting for auto-merge (checks + branch protection)...');
731
912
  let mergeSha = null;
913
+ let conflicting = false;
914
+ // task 1003764: say WHY it has not merged. This loop read only state + mergeCommit,
915
+ // so a red required check — the likeliest reason — went unnamed for the whole wait.
916
+ // The unit-report line (which failing tests) is fetched once per head sha.
917
+ let redLine = null;
918
+ let stuckRed = false;
919
+ let report = { sha: undefined, text: null };
920
+ const failingTestsFor = (sha) => {
921
+ if (sha !== report.sha) report = { sha, text: unitReportFor(sha, readJson) };
922
+ return report.text;
923
+ };
732
924
  for (let i = 0; i < MERGE_TRIES; i++) {
733
- const pr = ghJson(['pr', 'view', branch, '--json', 'state,mergeCommit']);
925
+ // task 1003499: mergeable + mergeStateStatus ride the same read, so a conflict is
926
+ // named on the poll it appears; the two-field read is the fallback for a gh that
927
+ // predates those fields, which keeps this no worse than before on any machine.
928
+ // task 1003764: so do headRefOid + statusCheckRollup, so a red check is named too.
929
+ const pr = readJson(['pr', 'view', branch, '--json', 'state,mergeCommit,mergeable,mergeStateStatus,headRefOid,statusCheckRollup'])
930
+ || readJson(['pr', 'view', branch, '--json', 'state,mergeCommit']);
734
931
  if (pr && pr.state === 'MERGED') {
735
932
  mergeSha = pr.mergeCommit && pr.mergeCommit.oid;
736
933
  break;
@@ -739,18 +936,31 @@ function pollCiDeploy(taskId, branch, headSha) {
739
936
  console.log(` ci-land: PR was CLOSED without merging (a required check failed, or it was closed manually). Fix it and re-run \`node scripts/gds/ship.js ${taskId}\` to resume. Task stays at 'confirmed'.`);
740
937
  return landBailed('the PR was closed without merging (a check failed, or it was closed manually)', `node scripts/gds/ship.js ${taskId}`);
741
938
  }
742
- shellOk(POLL_SLEEP_CMD);
939
+ if (pr) {
940
+ const md = localMergeDriver(pr, failingTestsFor);
941
+ const red = redPrLine(md);
942
+ if (red && red !== redLine) {
943
+ redLine = red;
944
+ for (const line of redPrLines(md, { taskId, via: 'local' })) console.log(line);
945
+ }
946
+ stuckRed = !!red && redBlocksMerge(md);
947
+ }
948
+ conflicting = noteConflict(taskId, pr, conflicting);
949
+ pause();
743
950
  }
744
951
  if (!mergeSha) {
745
952
  // task 1049: PR open + auto-merge armed but not merged in-window = the publish
746
- // SUCCEEDED and is still in CI — success-pending, not a failure.
747
- printLandPending(taskId);
953
+ // SUCCEEDED and is still in CI — success-pending, not a failure. Unless it is
954
+ // CONFLICTING (task 1003499): then nothing is still in CI, and it never merges.
955
+ // Or RED (task 1003764): then the banner says the land is stuck on that check.
956
+ if (conflicting) return landConflicted(taskId);
957
+ printLandPending(taskId, { red: stuckRed });
748
958
  return landPending();
749
959
  }
750
960
  console.log(` ci-land: merged to main (${mergeSha.slice(0, 8)}). Waiting for the prod deploy...`);
751
961
 
752
962
  for (let i = 0; i < DEPLOY_TRIES; i++) {
753
- const runs = ghJson([
963
+ const runs = readJson([
754
964
  'run', 'list', '--workflow', 'deploy-prod.yml', '--branch', 'main',
755
965
  '--json', 'headSha,status,conclusion', '--limit', '15',
756
966
  ]);
@@ -775,7 +985,7 @@ function pollCiDeploy(taskId, branch, headSha) {
775
985
  }
776
986
  return landed();
777
987
  }
778
- shellOk(POLL_SLEEP_CMD);
988
+ pause();
779
989
  }
780
990
  // task 1049: merged but the deploy run hasn't confirmed in-window — still
781
991
  // success-pending (it deploys on its own), not a failure.
@@ -811,6 +1021,21 @@ module.exports = {
811
1021
  prAssigneeLine,
812
1022
  redPrLine,
813
1023
  redPrLines,
1024
+ // task 1003499: a CONFLICTING PR is named while the land waits, and a wait that
1025
+ // ends conflicting is a bail, not "landing automatically". The polls are exported
1026
+ // with their optional `deps` so the test drives the real loops.
1027
+ prConflicting,
1028
+ conflictPrLines,
1029
+ landConflicted,
1030
+ pollCiDeploy,
1031
+ pollServerPublish,
1032
+ // task 1003764: name WHY a published PR has not merged when a check is red.
1033
+ FRESHNESS_TESTS,
1034
+ failingTestNames,
1035
+ mergeTrapLines,
1036
+ redBlocksMerge,
1037
+ localMergeDriver,
1038
+ unitReportFor,
814
1039
  reconciledCardStatus,
815
1040
  strandCommand,
816
1041
  strandNextStep,
@@ -32,6 +32,7 @@ const { gateMergeResult } = require('./ship-merge-gate.js');
32
32
  const { dbOnlyIgnoredNotice } = require('./ship-honesty.js');
33
33
  const { currentBranch, mainWorktreeBusy, regenerateApiClient, regenerateApiDocs, regenerateCopyInventory, regenerateDiagrams, regenerateFileMap, regenerateRepoMap, regenerateSessionIndex } = require('./ship-regen.js');
34
34
  const { REPO_ROOT } = require('./ship-state.js');
35
+ const { envPrefix } = require('../../src/instance-config');
35
36
 
36
37
  // zeroCommitVerdict — dbOnly is deliberately consulted ONLY at zero commits
37
38
  // ahead: a branch carrying real commits must be merged, whatever flag was typed.
@@ -287,7 +288,7 @@ async function mergeLocally(taskId, valueSummary, { dbOnly = false } = {}) {
287
288
 
288
289
  console.log(' pushing merged main to origin...');
289
290
  // SEC #863: route through push-main.js, which carries the
290
- // OTB_ALLOW_MAIN_PUSH override so the pre-push permission gate lets THIS
291
+ // <PREFIX>_ALLOW_MAIN_PUSH override so the pre-push permission gate lets THIS
291
292
  // graded+confirmed pipeline push through while still blocking ad-hoc
292
293
  // `git push origin main`. process.execPath (quoted) for the same
293
294
  // cross-platform reason as the other node spawns here (#660).
@@ -298,7 +299,7 @@ async function mergeLocally(taskId, valueSummary, { dbOnly = false } = {}) {
298
299
  // the ci PR path before this point; reaching this with protection on
299
300
  // means an env-forced 'laptop' or a failed protection probe.
300
301
  if (mainIsProtected() === true) {
301
- return landBailed('main is branch-protected — the direct push can never land; unset OTB_DEPLOY_MODE (the ci PR path lands it)', `node scripts/gds/ship.js ${taskId}`);
302
+ return landBailed(`main is branch-protected — the direct push can never land; unset the DEPLOY_MODE env override (${envPrefix()}_DEPLOY_MODE, or the legacy OTB_DEPLOY_MODE) — the ci PR path lands it`, `node scripts/gds/ship.js ${taskId}`);
302
303
  }
303
304
  return landBailed('could not push the merged main to origin', '/merge-mode');
304
305
  }
@@ -11,7 +11,8 @@
11
11
  const { execFileSync } = require('node:child_process');
12
12
  const fs = require('node:fs');
13
13
  const path = require('node:path');
14
- const { trackedDirty, cleanupMacFinderDupRefs } = require('../../modules/lifecycle/ship-preflight');
14
+ const { trackedDirty, cleanupMacFinderDupRefs, untrackedLeftBehind, untrackedWarningLines } = require('../../modules/lifecycle/ship-preflight');
15
+ const { arg } = require('./cli-lib');
15
16
  const mergeRegen = require('./git-merge-regen');
16
17
  const { argvOut, gitArgvOk, gitOk } = require('./ship-git.js');
17
18
  const { pushVia } = require('./ship-deploy-target.js');
@@ -40,6 +41,63 @@ function parseDeletedUpstreamPaths(rawLogOutput) {
40
41
  return [...new Set(rawLogOutput.split('\n').map((s) => s.trim()).filter(Boolean))];
41
42
  }
42
43
 
44
+ // shipInputPaths — the repo-relative paths of the files a ship was HANDED as input
45
+ // (task 1001436), so preflight's untracked-file warning never lists them. The
46
+ // builder-ship skill writes notes.md + summary.txt into the worktree and passes them
47
+ // here; left in, they would put the warning on nearly every ship and teach everyone
48
+ // to skip it. `-` (stdin), a missing value and a path outside the checkout add
49
+ // nothing — a file outside the tree cannot be an untracked file in it. Both sides go
50
+ // through realpath, so a /var vs /private/var spelling of one file still matches.
51
+ // `cwd`, `repoRoot` and `realpath` are injectable for the unit test.
52
+ const SHIP_INPUT_FLAGS = ['--notes-file', '--summary-file', '--response-file', '--visual'];
53
+
54
+ function safeRealpath(p) {
55
+ try { return fs.realpathSync.native(p); } catch (_) { return path.resolve(p); }
56
+ }
57
+
58
+ function shipInputPaths(args, { cwd = process.cwd(), repoRoot = null, realpath = safeRealpath } = {}) {
59
+ const root = realpath(repoRoot || gitOk('git rev-parse --show-toplevel') || cwd);
60
+ const out = [];
61
+ for (const flag of SHIP_INPUT_FLAGS) {
62
+ const value = arg(flag, args || []);
63
+ if (typeof value !== 'string' || value === '' || value === '-' || value.startsWith('--')) continue;
64
+ const rel = path.relative(root, realpath(path.resolve(cwd, value)));
65
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) continue;
66
+ out.push(rel.split(path.sep).join('/'));
67
+ }
68
+ return out;
69
+ }
70
+
71
+ // The code that RUNS the ship and the grade on this machine — what step (c) of
72
+ // preflight() refuses to run from a stale copy. PATHSPECS, not a file list (task
73
+ // 1004273): the list used to name ship.js and grader.js alone, and two carves moved
74
+ // the logic out from under it (task 1003205 split ship.js into ~20 ship-*.js files;
75
+ // grader.js now delegates to grader-*.js, grader-workers/ and grade-engine.js), so a
76
+ // branch behind main on ship-land.js or grader-score.js sailed through. A prefix
77
+ // covers the next carve too. They reach git as argv, never through a shell, so a
78
+ // file that exists ONLY on origin/main (a new carve file) still matches — a shell
79
+ // would have expanded the glob against this checkout and missed exactly that one.
80
+ // tests/ship_preflight.mjs fails if a ship*.js / grader* / grade-engine file is not covered.
81
+ const TOOLING_PATHSPECS = Object.freeze([
82
+ 'scripts/gds/ship*.js', // ship.js + every ship-*.js carved out of it
83
+ 'scripts/gds/smoke-gds.js',
84
+ 'scripts/gds/cli-lib.js',
85
+ 'modules/grading/grader*', // grader.js, grader-*.js, grader-rubric.json, grader-workers/
86
+ 'modules/grading/grade-engine.js', // grader.js requires it (task 1002839)
87
+ ]);
88
+
89
+ // staleToolingFiles — the tooling files origin/main has changed since this branch's
90
+ // merge-base. Three-dot (HEAD...origin/main) scopes to the origin/main side, so the
91
+ // builder's OWN edits to these files (shipping a ship fix) do not count. A failed git
92
+ // call reads as nothing stale, as it always has. `cwd` is injectable for the test.
93
+ function staleToolingFiles({ cwd } = {}) {
94
+ const out = argvOut('git', ['diff', '--name-only', 'HEAD...origin/main', '--', ...TOOLING_PATHSPECS], cwd ? { cwd } : {});
95
+ return out ? out.split('\n').map((s) => s.trim()).filter(Boolean) : [];
96
+ }
97
+
98
+ // opts: { force, inputFiles } — inputFiles is shipInputPaths(args) from the caller;
99
+ // omitted (the resume path has no prose flags), nothing is excluded from the
100
+ // untracked-file warning.
43
101
  function preflight(claim, opts = {}) {
44
102
  // (a) Working-tree check — refuse if any TRACKED file carries an uncommitted
45
103
  // modification or staged change. This fires post-commit, so a clean tree
@@ -92,14 +150,7 @@ function preflight(claim, opts = {}) {
92
150
  // (HEAD...origin/main) scopes to changes on the origin/main side since the
93
151
  // merge-base, so the builder's OWN edits to these files (e.g. shipping a
94
152
  // ship.js fix) do not trip it.
95
- const TOOLING_PATHS = [
96
- 'scripts/gds/ship.js',
97
- 'scripts/gds/smoke-gds.js',
98
- 'scripts/gds/cli-lib.js',
99
- 'modules/grading/grader.js', // BV1.R73: grading carved to modules/grading/
100
- ];
101
- const staleToolingStr = gitOk(`git diff --name-only HEAD...origin/main -- ${TOOLING_PATHS.join(' ')}`);
102
- const staleTooling = staleToolingStr ? staleToolingStr.trim().split('\n').filter(Boolean) : [];
153
+ const staleTooling = staleToolingFiles();
103
154
  if (staleTooling.length > 0) {
104
155
  if (opts.force) {
105
156
  console.log(` preflight: ship tooling is behind origin/main (${staleTooling.join(', ')}) — proceeding due to --force`);
@@ -145,8 +196,29 @@ function preflight(claim, opts = {}) {
145
196
  }
146
197
  }
147
198
 
148
- console.log(` preflight: working tree clean, branch ${behind} commits behind main, ship tooling current — OK`);
149
- return { ok: true };
199
+ // (e) Untracked-file warning (task 1001436) — WARN-ONLY, never blocks, never
200
+ // changes the exit code. Step (a) ignores untracked files so scratch cannot
201
+ // false-refuse a ship, which also meant a NEW deliverable left un-added beside
202
+ // committed work shipped without it, in silence (the residual of the task-1363
203
+ // phantom ship). This names those files while stopping is still free. It needs
204
+ // committed work to warn about — upstreamDelTouched is this branch's committed
205
+ // diff from step (d); with none, the empty-diff guard already refuses and lists
206
+ // them. Its own `git status`, on purpose: --untracked-files=all names each file
207
+ // rather than a collapsed `dir/`, so the ship's input files can be excluded
208
+ // exactly; and a failed or oversized read here stays a skipped warning, never a
209
+ // blank porcelain for the refusal in step (a).
210
+ const untrackedPorcelain = gitOk('git status --porcelain --untracked-files=all', { maxBuffer: 64 * 1024 * 1024 });
211
+ const leftBehind = untrackedLeftBehind(untrackedPorcelain, {
212
+ committedFileCount: upstreamDelTouched.length,
213
+ inputs: opts.inputFiles || [],
214
+ });
215
+ for (const line of untrackedWarningLines(leftBehind, { taskId: claim && claim.task_id })) console.error(line);
216
+
217
+ const treeState = leftBehind.length > 0
218
+ ? `tracked files clean (${leftBehind.length} untracked file(s) will not ship — see the warning)`
219
+ : 'working tree clean';
220
+ console.log(` preflight: ${treeState}, branch ${behind} commits behind main, ship tooling current — OK`);
221
+ return { ok: true, untrackedLeftBehind: leftBehind };
150
222
  }
151
223
 
152
224
  // mergeMainIntoBranch — pull origin/main INTO the current feature branch BEFORE
@@ -320,4 +392,7 @@ module.exports = {
320
392
  mergeMainIntoBranch,
321
393
  parseDeletedUpstreamPaths,
322
394
  preflight,
395
+ shipInputPaths,
396
+ staleToolingFiles,
397
+ TOOLING_PATHSPECS,
323
398
  };
@@ -354,7 +354,7 @@ module.exports = {
354
354
  // task 1045: the gate that defers the broadcast to the server on the server-
355
355
  // mediated publish path — exported so the truth table (ci+server → true, laptop
356
356
  // / non-ci → false) is unit-tested with no gh/network (env-driven via
357
- // OTB_DEPLOY_MODE + OTB_PUSH_VIA_SERVER).
357
+ // <PREFIX>_DEPLOY_MODE + <PREFIX>_PUSH_VIA_SERVER).
358
358
  broadcastHandledByServer,
359
359
  fatalRecoveryLines, // G04 re-export (definition in ship-honesty.js)
360
360
  };
@@ -31,7 +31,9 @@ ETC=/etc/systemd/system
31
31
  CORE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
32
32
  case "$CORE_DIR" in */node_modules/@bongos/core) INSTANCE_DIR="${INSTANCE_DIR:-${CORE_DIR%/node_modules/@bongos/core}}" ;; esac
33
33
  [ -n "${PROVISION_APP_USER:-}${INSTANCE_DIR:-}" ] || { echo "set INSTANCE_DIR to the control plane's instance checkout (or PROVISION_APP_USER) — this sweep is not running from one"; exit 1; }
34
- APP_USER="${PROVISION_APP_USER:-$(stat -c %U "$INSTANCE_DIR" 2>/dev/null)}"
34
+ # Owner by name: GNU stat `-c %U`, else BSD/macOS stat `-f %Su` — each refuses the other's
35
+ # flag (task 1004170), the same lookup address-park-sweep.sh makes.
36
+ APP_USER="${PROVISION_APP_USER:-$(stat -c %U "$INSTANCE_DIR" 2>/dev/null || stat -f %Su "$INSTANCE_DIR" 2>/dev/null)}"
35
37
  if [ "$APP_USER" = root ] || ! [[ "$APP_USER" =~ ^[a-z_][a-z0-9_-]{0,31}$ ]]; then
36
38
  echo "cannot use '$APP_USER' as the app account — set PROVISION_APP_USER to the unprivileged account that owns ${INSTANCE_DIR:-the instance checkout}"; exit 1
37
39
  fi