@bongos/core 1.19.1032 → 1.19.1033

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 (68) hide show
  1. package/.bongos-core.json +159 -54
  2. package/.claude/skills/grade-audit/SKILL.md +1 -1
  3. package/.claude/skills/grader-health/SKILL.md +1 -1
  4. package/docs/adr/0175-the-board-room-full-idea-ratification.md +3 -2
  5. package/docs/adr/0178-the-copy-desk-flag-and-queue.md +3 -3
  6. package/docs/adr/0188-patrol-runs-on-ci-not-the-server.md +2 -2
  7. package/docs/adr/0189-baseline-floors-and-the-freeze-detector.md +1 -1
  8. package/docs/adr/0233-a-copy-proposal-is-a-task-carrying-a-patch.md +1 -1
  9. package/docs/adr/0241-the-artist-gate-is-a-per-project-deploy-gate-that-reads-a-state.md +11 -3
  10. package/docs/adr/README.md +3 -3
  11. package/docs/architecture.md +1 -1
  12. package/docs/file-map.md +4 -3
  13. package/docs/module-api-changelog.md +2 -0
  14. package/docs/recipes/autobongos-windows-host.md +3 -1
  15. package/docs/recipes/packaging-the-core.md +2 -0
  16. package/docs/recipes/redteam-patrol.md +2 -2
  17. package/docs/recipes/upgrading-the-core.md +1 -1
  18. package/modules/autonomy/gauge.js +57 -9
  19. package/modules/hall-ui/records/builders-roster.md +10 -0
  20. package/modules/hall-ui/records/catalog.md +10 -0
  21. package/modules/hall-ui/records/collaboration.md +14 -0
  22. package/modules/hall-ui/records/goal-dependency-forest.md +11 -0
  23. package/modules/hall-ui/records/oversight-family.md +19 -0
  24. package/modules/hall-ui/records/panel-family.md +19 -0
  25. package/modules/hall-ui/records/reading-rooms.md +12 -0
  26. package/modules/hall-ui/records/record-family.md +13 -0
  27. package/modules/hall-ui/records/reference-room.md +9 -0
  28. package/modules/hall-ui/records/roster-and-ledger.md +9 -0
  29. package/modules/hall-ui/records/sky.md +23 -0
  30. package/modules/lifecycle/dependency-advisory.js +2 -1
  31. package/modules/lifecycle/ship-preflight.js +91 -0
  32. package/package-lock.json +2 -2
  33. package/package.json +1 -1
  34. package/release-notes.json +7411 -0
  35. package/scripts/gds/address-park-sweep.sh +3 -1
  36. package/scripts/gds/autobongos-run.js +81 -1
  37. package/scripts/gds/baseline-staleness.js +1 -1
  38. package/scripts/gds/db-backup-nightly.sh +9 -4
  39. package/scripts/gds/db-backup-offsite.sh +4 -2
  40. package/scripts/gds/package-core.js +72 -1
  41. package/scripts/gds/provision-core-upgrade.js +128 -77
  42. package/scripts/gds/redteam-patrol.js +4 -4
  43. package/scripts/gds/release-notes.js +271 -0
  44. package/scripts/gds/seed-board-room-tasks.js +1 -1
  45. package/scripts/gds/ship-flow.js +5 -3
  46. package/scripts/gds/ship-land.js +86 -7
  47. package/scripts/gds/ship-preflight-steps.js +56 -3
  48. package/scripts/gds/stray-unit-sweep.sh +3 -1
  49. package/scripts/gds/upgrade-migrate.js +67 -0
  50. package/scripts/gds/upgrade.js +6 -4
  51. package/src/module-api.js +1 -1
  52. package/tests/adr_0162_citation.mjs +184 -0
  53. package/tests/autobongos_loop.mjs +114 -0
  54. package/tests/autonomy_gauge.mjs +56 -0
  55. package/tests/core_upgrade_runner.mjs +89 -1
  56. package/tests/db_backup_nightly.mjs +21 -3
  57. package/tests/db_backup_offsite.mjs +81 -0
  58. package/tests/hall_records.mjs +85 -0
  59. package/tests/helpers.mjs +25 -0
  60. package/tests/instance_db_migrated_on_code_move.mjs +8 -1
  61. package/tests/plain_cards.mjs +1 -1
  62. package/tests/redteam_patrol.mjs +1 -1
  63. package/tests/release_notes.mjs +327 -0
  64. package/tests/shell_stat_portability.mjs +49 -0
  65. package/tests/ship_land_conflict.mjs +181 -0
  66. package/tests/ship_preflight.mjs +160 -3
  67. package/tests/sweep_app_account.mjs +15 -2
  68. package/tests/upgrade_migrate.mjs +195 -0
@@ -0,0 +1,271 @@
1
+ 'use strict';
2
+
3
+ // scripts/gds/release-notes.js — what each core release brought in, as the summaries its
4
+ // ships wrote, generated when the core is PACKED and shipped inside the package as
5
+ // release-notes.json (task 1004218).
6
+ //
7
+ // WHY INSIDE THE PACKAGE. The Deploy page's "what this brings in" list used to be read out
8
+ // of a core git checkout on the machine asking (task 1004173), and no tenant box will ever
9
+ // have one: every project the wizard creates is a checkout of its OWN repo with the core
10
+ // vendored in at node_modules. The one machine that always has the full history is the one
11
+ // that builds the package — publish.yml checks out at fetch-depth 0 because package-core
12
+ // archives from git — so that is where the notes are written down, once, and every project
13
+ // that can download a core version can read what it carries.
14
+ //
15
+ // THREE PARTS, NO I/O OF ITS OWN. The PARSER (parseChangeRecords, moved here from
16
+ // provision-core-upgrade.js so there is exactly one), the GENERATOR (package-core.js hands
17
+ // it the ref's history), and the RANGE READER (the upgrade runner asks it for from→to).
18
+ // Git is injected, never required, so loading this costs package-core nothing.
19
+ //
20
+ // THE SHAPE. `{ "<version>": [ { id?, text }, … ] }`, ascending by version, one key per
21
+ // release — including a release whose merges were all housekeeping, which keeps an EMPTY
22
+ // list. That is the difference between "nothing landed" and "not covered": a range is
23
+ // answerable only when its target is a key, so a missing key can never be read as silence.
24
+
25
+ const CHANGE_TEXT_MAX = 400;
26
+
27
+ // The file's name at the package root, beside package.json.
28
+ const RELEASE_NOTES_FILE = 'release-notes.json';
29
+
30
+ // THE BOUND. The whole history to 1.19.1029 is ~400 KB pretty-printed (1,033 releases,
31
+ // 1,399 summaries, measured 2026-09-25), growing roughly 200 KB a month at 20–30 releases a
32
+ // day. Nothing needs trimming today; the bound exists so the file cannot grow unwatched.
33
+ // Past it, the OLDEST releases are dropped — the ranges an owner asks about are recent ones
34
+ // — and the reader then says those older ranges are not covered, rather than guessing.
35
+ const RELEASE_NOTES_MAX_BYTES = 1024 * 1024;
36
+
37
+ // A release commit's subject — the lane's own carrier AND a human release both begin this
38
+ // way. The same marker findCoreCheckout's probe uses to recognise core history.
39
+ const RELEASE_SUBJECT_RE = /^release: core (\d+\.\d+\.\d+) /;
40
+ const CORE_VERSION_RE = /^[0-9]+\.[0-9]+\.[0-9]+$/;
41
+
42
+ // One record per commit: sha, parents, subject, body. `--topo-order` is load-bearing — see
43
+ // buildReleaseNotes for why the order releases are visited in must be topological.
44
+ const LOG_ARGS = ['log', '--topo-order', '--format=%H%x1f%P%x1f%s%x1f%b%x1e'];
45
+
46
+ /**
47
+ * The merge summaries in a `git log --merges --format=%s%x1f%b%x1e` dump. PURE.
48
+ *
49
+ * TWO MERGE SHAPES CARRY A SUMMARY AND THE SECOND IS THE COMMON ONE. A hand-landed ship
50
+ * writes `Merge <branch>: <summary>`; the CI auto-merge writes `Merge pull request #N
51
+ * from <owner>/<branch>` with `task <id>: <summary>` in the BODY. Measured over
52
+ * 1.19.881→1.19.972: 114 usable summaries, of which 98 were the second shape. A parser
53
+ * that read subjects only would have found eleven of them and quietly called that the
54
+ * release.
55
+ *
56
+ * EVERYTHING ELSE IS DROPPED, and the drops are the majority: `Merge origin/main into
57
+ * <branch> (pre-stage freshness…)`, `Merge remote-tracking branch…`, a regen chore. Those
58
+ * are the mechanics of landing work, not the work, and listing them would bury the
59
+ * eleven sentences the owner actually wants under a hundred they do not.
60
+ */
61
+ function parseChangeRecords(stdout) {
62
+ const out = [];
63
+ const seen = new Set();
64
+ for (const rec of String(stdout || '').split('\x1e')) {
65
+ const r = rec.trim();
66
+ if (!r) continue;
67
+ const i = r.indexOf('\x1f');
68
+ const subject = (i < 0 ? r : r.slice(0, i)).trim();
69
+ const body = i < 0 ? '' : r.slice(i + 1);
70
+ let text = null;
71
+ let id = null;
72
+ // The negative lookahead is what keeps the two housekeeping shapes out: both begin
73
+ // "Merge " and both would otherwise match a `<token>:` somewhere later in the line.
74
+ let m = /^Merge\s+(?!pull request\b|remote-tracking\b)([^\s:]+):\s*(\S.*)$/.exec(subject);
75
+ if (m) {
76
+ text = m[2];
77
+ } else {
78
+ const first = body.split('\n').map((l) => l.trim()).find(Boolean) || '';
79
+ m = /^task\s+(\d+):\s*(\S.*)$/.exec(first);
80
+ if (m) { id = m[1]; text = m[2]; }
81
+ }
82
+ if (!text) continue;
83
+ // It reaches a DOM, and a ship summary is occasionally a paragraph.
84
+ text = boundText(text);
85
+ if (seen.has(text)) continue; // a branch merged twice says its sentence once
86
+ seen.add(text);
87
+ out.push(id ? { id, text } : { text });
88
+ }
89
+ return out;
90
+ }
91
+
92
+ function boundText(text) {
93
+ return text.length > CHANGE_TEXT_MAX ? `${text.slice(0, CHANGE_TEXT_MAX - 1)}…` : text;
94
+ }
95
+
96
+ /** Order two three-number core versions. PURE; both must already match CORE_VERSION_RE. */
97
+ function compareCoreVersions(a, b) {
98
+ const x = String(a).split('.').map(Number);
99
+ const y = String(b).split('.').map(Number);
100
+ for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] - y[i];
101
+ return 0;
102
+ }
103
+
104
+ /** Split a LOG_ARGS dump into `{ sha, parents, subject, body }` records, in log order. PURE. */
105
+ function parseLogRecords(stdout) {
106
+ const out = [];
107
+ for (const rec of String(stdout || '').split('\x1e')) {
108
+ const r = rec.replace(/^\n+/, ''); // git terminates each record with a newline
109
+ if (!r.trim()) continue;
110
+ const [sha, parents, subject, ...body] = r.split('\x1f');
111
+ out.push({
112
+ sha: sha.trim(),
113
+ parents: String(parents || '').split(' ').filter(Boolean),
114
+ subject: subject || '',
115
+ body: body.join('\x1f'),
116
+ });
117
+ }
118
+ return out;
119
+ }
120
+
121
+ function uniqueByText(entries) {
122
+ const seen = new Set();
123
+ return entries.filter((e) => (seen.has(e.text) ? false : seen.add(e.text)));
124
+ }
125
+
126
+ /**
127
+ * What each release brought in, from a whole history. PURE.
128
+ *
129
+ * `commits` is every commit the packed ref reaches, NEWEST FIRST in topological order (what
130
+ * LOG_ARGS asks git for). Returns `{ notes, releases, tail }`.
131
+ *
132
+ * EXACTLY `git log --merges <previous release>..<release>`, FOR EVERY RELEASE AT ONCE — the
133
+ * range the checkout-based reader asks for one pair at a time. Splitting the log wherever a
134
+ * release subject appears would be simpler and is wrong: a PR branch's commits are not
135
+ * ordered against main's by the log, so a merge made on a branch before one release but
136
+ * landed after it would be credited to the earlier release. So each release, oldest first,
137
+ * claims everything it can reach that no earlier release reached. The visiting order has to
138
+ * be topological for that to be the previous-release range — hence `--topo-order`.
139
+ *
140
+ * THE UNRELEASED TAIL — commits the ref reaches that no release commit does — belongs to the
141
+ * version being packed when that version has no release commit of its own yet (a pack of a
142
+ * hand-declared version), and to nobody otherwise: on a work merge the tail is the NEXT
143
+ * release's, whose number does not exist yet.
144
+ */
145
+ function buildReleaseNotes(commits, { version = null } = {}) {
146
+ const bySha = new Map(commits.map((c) => [c.sha, c]));
147
+ const position = new Map(commits.map((c, i) => [c.sha, i]));
148
+ const claimed = new Set();
149
+ const claim = (tip) => {
150
+ const mine = [];
151
+ const stack = [tip];
152
+ while (stack.length) {
153
+ const sha = stack.pop();
154
+ if (claimed.has(sha) || !bySha.has(sha)) continue;
155
+ claimed.add(sha);
156
+ const c = bySha.get(sha);
157
+ mine.push(c);
158
+ for (const p of c.parents) if (!claimed.has(p)) stack.push(p);
159
+ }
160
+ return mine;
161
+ };
162
+ // Newest first within a release, as `git log` itself would list them.
163
+ const summarize = (segment) => parseChangeRecords(segment
164
+ .filter((c) => c.parents.length > 1)
165
+ .sort((a, b) => position.get(a.sha) - position.get(b.sha))
166
+ .map((c) => `${c.subject}\x1f${c.body}\x1e`)
167
+ .join(''));
168
+
169
+ const byVersion = new Map();
170
+ let releases = 0;
171
+ for (let i = commits.length - 1; i >= 0; i--) {
172
+ const m = RELEASE_SUBJECT_RE.exec(commits[i].subject);
173
+ if (!m) continue;
174
+ releases++;
175
+ // A version released twice keeps both halves; the second claims only what the first
176
+ // could not reach.
177
+ byVersion.set(m[1], uniqueByText([...(byVersion.get(m[1]) || []), ...summarize(claim(commits[i].sha))]));
178
+ }
179
+ const tail = commits.filter((c) => !claimed.has(c.sha));
180
+ if (version && CORE_VERSION_RE.test(version) && !byVersion.has(version)
181
+ && [...byVersion.keys()].every((v) => compareCoreVersions(v, version) < 0)) {
182
+ byVersion.set(version, summarize(tail));
183
+ }
184
+ const notes = {};
185
+ for (const v of [...byVersion.keys()].sort(compareCoreVersions)) notes[v] = byVersion.get(v);
186
+ return { notes, releases, tail: tail.length };
187
+ }
188
+
189
+ /**
190
+ * Read a ref's history through an injected `git(args) → stdout` and build its notes.
191
+ * Returns `{ notes, releases, tail }`, or `{ skipped }` saying why there are none — never
192
+ * a partial answer presented as a whole one.
193
+ */
194
+ function generateReleaseNotes({ git, ref, version }) {
195
+ let shallow;
196
+ try { shallow = String(git(['rev-parse', '--is-shallow-repository'])).trim() === 'true'; }
197
+ catch (e) { return { skipped: `could not ask git about this clone (${firstLine(e)})` }; }
198
+ // A depth-limited clone reaches a handful of commits, and notes built from it would
199
+ // credit the newest release with everything the clone happens to hold.
200
+ if (shallow) return { skipped: 'this is a shallow clone, so it carries no release history' };
201
+ let out;
202
+ try { out = git([...LOG_ARGS, ref]); }
203
+ catch (e) { return { skipped: `could not read the history of ${ref} (${firstLine(e)})` }; }
204
+ const built = buildReleaseNotes(parseLogRecords(out), { version });
205
+ if (!built.releases) return { skipped: 'this history holds no release commits' };
206
+ return built;
207
+ }
208
+
209
+ function firstLine(e) {
210
+ return String((e && e.message) || e || '').trim().split('\n')[0].slice(0, 160);
211
+ }
212
+
213
+ /** The file's bytes: the same pretty-printed JSON the package's other generated files use. */
214
+ function serializeReleaseNotes(notes) {
215
+ return Buffer.from(`${JSON.stringify(notes, null, 2)}\n`, 'utf8');
216
+ }
217
+
218
+ /**
219
+ * Hold the file under `maxBytes` by dropping the OLDEST releases. PURE.
220
+ * Returns `{ notes, dropped }`; the newest release is always kept.
221
+ */
222
+ function boundReleaseNotes(notes, maxBytes = RELEASE_NOTES_MAX_BYTES) {
223
+ const versions = Object.keys(notes);
224
+ const keep = (from) => Object.fromEntries(versions.slice(from).map((v) => [v, notes[v]]));
225
+ const fits = (from) => serializeReleaseNotes(keep(from)).length <= maxBytes;
226
+ if (versions.length <= 1 || fits(0)) return { notes, dropped: [] };
227
+ // The fewest drops that fit, by bisection — the size only shrinks as more are dropped.
228
+ let lo = 1;
229
+ let hi = versions.length - 1;
230
+ while (lo < hi) {
231
+ const mid = (lo + hi) >> 1;
232
+ if (fits(mid)) hi = mid; else lo = mid + 1;
233
+ }
234
+ return { notes: keep(lo), dropped: versions.slice(0, lo) };
235
+ }
236
+
237
+ /**
238
+ * The entries for every release in `(from, to]`, newest release first. PURE.
239
+ *
240
+ * Returns `{ entries }` or `{ uncovered }` — a phrase saying why these notes cannot answer,
241
+ * worded to follow "the release notes …". NEVER an empty list for a range the notes do not
242
+ * reach: an empty list reads as "nothing changed", which is exactly what a restart must not
243
+ * be sold as. They reach a range when they list its target and begin at or before its
244
+ * start: the oldest release they hold is where their knowledge begins.
245
+ *
246
+ * The file may have come off the network, so every entry is re-checked here, not trusted.
247
+ */
248
+ function notesBetween(notes, from, to) {
249
+ if (!notes || typeof notes !== 'object' || Array.isArray(notes)) return { uncovered: 'are not a list of releases' };
250
+ const versions = Object.keys(notes)
251
+ .filter((v) => CORE_VERSION_RE.test(v) && Array.isArray(notes[v]))
252
+ .sort(compareCoreVersions);
253
+ if (!versions.includes(to)) return { uncovered: `do not list core ${to}` };
254
+ if (compareCoreVersions(versions[0], from) > 0) return { uncovered: `begin at core ${versions[0]}, after ${from}` };
255
+ const inRange = versions.filter((v) => compareCoreVersions(v, from) > 0 && compareCoreVersions(v, to) <= 0);
256
+ const entries = [];
257
+ for (const v of inRange.reverse()) {
258
+ for (const e of notes[v]) {
259
+ if (!e || typeof e.text !== 'string' || !e.text.trim()) continue;
260
+ const text = boundText(e.text.trim());
261
+ entries.push(/^\d+$/.test(String(e.id || '')) ? { id: String(e.id), text } : { text });
262
+ }
263
+ }
264
+ return { entries: uniqueByText(entries) };
265
+ }
266
+
267
+ module.exports = {
268
+ RELEASE_NOTES_FILE, RELEASE_NOTES_MAX_BYTES,
269
+ parseChangeRecords, parseLogRecords, buildReleaseNotes,
270
+ generateReleaseNotes, serializeReleaseNotes, boundReleaseNotes, notesBetween,
271
+ };
@@ -43,7 +43,7 @@ const TASKS = [
43
43
  // ---- Phase A — decide + foundation (P5) ----
44
44
  { ref:'R01', kind:'decision', disc:'engineer', prio:5, est:150, crit:[C1,C3,C4], deps:[],
45
45
  title:'R01 · ADR: the Board Room government model',
46
- desc:`Record the government model as one decision so the schema has something to encode.\n\nMUST STATE: the board's TWO subjects (a Full Idea, and a change to the constitution itself) and why nothing else is votable; membership as a configured predicate resolved per request and uncached; the timed SITTING (absent votes are nulled, not blocking) and why that replaces quorum — the clock IS the deadlock exit; CONSENT rather than majority, with silence as consent and one reasoned objection returning the idea; a zero-vote close PASSING; the OPEN ballot; Discord as an outbound notification mirror that casts nothing; and the amendment rule — a constitutional change is decided under whatever rule the CURRENT constitution names, so flipping out of monarchy is a legal monarchy act.\n\nAlso record the two owner decisions taken against recommendation with their trade-offs intact: vote-karma (yes-clicking bias, accepted because item volume is too low to farm) and automatic window-open on grade pass (author loses timing control; concurrent windows become normal).\n\nGrounding: idea 1000738, idea 1000754, the Six Variables of Governance synthesis, ADR 0151 (permissions-as-atoms), ADR 0157, ADR 0162 (verdicts advisory), ADR 0174 (government/ranks/roles). ${SPEC}` },
46
+ desc:`Record the government model as one decision so the schema has something to encode.\n\nMUST STATE: the board's TWO subjects (a Full Idea, and a change to the constitution itself) and why nothing else is votable; membership as a configured predicate resolved per request and uncached; the timed SITTING (absent votes are nulled, not blocking) and why that replaces quorum — the clock IS the deadlock exit; CONSENT rather than majority, with silence as consent and one reasoned objection returning the idea; a zero-vote close PASSING; the OPEN ballot; Discord as an outbound notification mirror that casts nothing; and the amendment rule — a constitutional change is decided under whatever rule the CURRENT constitution names, so flipping out of monarchy is a legal monarchy act.\n\nAlso record the two owner decisions taken against recommendation with their trade-offs intact: vote-karma (yes-clicking bias, accepted because item volume is too low to farm) and automatic window-open on grade pass (author loses timing control; concurrent windows become normal).\n\nGrounding: idea 1000738, idea 1000754, the Six Variables of Governance synthesis, ADR 0151 (permissions-as-atoms), ADR 0157, ADR 0158 §2 (verdicts advisory), ADR 0162 (no human approval in the land path), ADR 0174 (government/ranks/roles). ${SPEC}` },
47
47
 
48
48
  { ref:'R02', kind:'decision', disc:'engineer', prio:5, est:90, crit:[C1], deps:['R01'],
49
49
  title:'R02 · The pass-event contract the ideator credit lane consumes',
@@ -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.`);
@@ -354,6 +354,59 @@ function redPrLines(md) {
354
354
  return red ? [red, ...plainCardLines((md && md.reason) || 'failing_check')] : [];
355
355
  }
356
356
 
357
+ // task 1003499 — a CONFLICTING PR is a land that cannot happen, however green it is.
358
+ // GitHub's auto-merge waits for a clean merge as well as for green checks, so a PR
359
+ // whose branch conflicts with main sits open under a fully green rollup and never
360
+ // merges. PR #497 sat like that for ~25 minutes while both polls below said nothing
361
+ // and then printed "auto-merge is armed ... NOT a failure", the banner a land that IS
362
+ // on its way gets. The server already knew (mergeBlockReason -> 'merge_conflict'); the
363
+ // ship never asked. Pure + exported. Takes both shapes it is handed: `gh pr view`'s
364
+ // camelCase (mergeable, mergeStateStatus) and publish-status's snake_case merge_driver
365
+ // (mergeable, merge_state, reason). A read that failed (null) is not a conflict.
366
+ function prConflicting(pr) {
367
+ if (!pr) return false;
368
+ const state = pr.mergeStateStatus ?? pr.merge_state;
369
+ return pr.mergeable === 'CONFLICTING' || state === 'DIRTY' || pr.reason === 'merge_conflict';
370
+ }
371
+
372
+ // What the poll says the moment it sees the conflict. Pure + exported so the wording
373
+ // is pinned by tests/ship_land_conflict.mjs. The poll keeps waiting afterwards on
374
+ // purpose: a conflict only in generated files is healed by the server's own resolver
375
+ // on its next sweep (ADR 0082), and the PR then merges inside this same window.
376
+ function conflictPrLines(taskId) {
377
+ return [
378
+ ' ci-land: the PR CONFLICTS with main, so auto-merge cannot fire until that is resolved, however green its checks are.',
379
+ ' ci-land: a conflict only in generated files is healed by the server on its next sweep (~5 min), so this keeps waiting.',
380
+ ' ci-land: anything hand-written waits for you. From this worktree:',
381
+ ' git fetch -q origin && git merge origin/main # resolve the conflicts, commit',
382
+ ` node scripts/gds/ship.js ${taskId} # resumes the land; it does not re-grade`,
383
+ ...plainCardLines('merge_conflict'),
384
+ ];
385
+ }
386
+
387
+ // noteConflict — one poll's worth of conflict tracking, shared by both loops. Prints
388
+ // the lines above when the PR BECOMES conflicting (not on every 10s poll, and once
389
+ // more if main moves on and undoes a heal), and returns what the PR looks like now.
390
+ // A failed read keeps the last answer rather than inventing a new one.
391
+ function noteConflict(taskId, pr, wasConflicting) {
392
+ if (!pr) return wasConflicting;
393
+ const now = prConflicting(pr);
394
+ if (now && !wasConflicting) for (const line of conflictPrLines(taskId)) console.log(line);
395
+ return now;
396
+ }
397
+
398
+ // landConflicted — the verdict when the wait ends with the PR STILL conflicting. It is
399
+ // not landing on its own, so it must not print the "landing automatically" banner or
400
+ // the land_pending card ("Nothing to do. This is normal, not a failure."): task
401
+ // 1002541's rule that a land which is not happening must not announce itself as
402
+ // success. No bailId, so ship-merge never reroutes it: the PR already exists and is
403
+ // the problem. /strand-fix walks the cure (its case C: merge main in, resolve, re-ship).
404
+ function landConflicted(taskId) {
405
+ console.log('');
406
+ 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).`);
407
+ return landBailed('the PR conflicts with main, so it cannot merge until the conflict is resolved', `/strand-fix ${taskId}`);
408
+ }
409
+
357
410
  // One module-scope sleep command shared by both poll loops (task 1093 / C9). A
358
411
  // child `node -e setTimeout` is used (not `await new Promise`) so it blocks
359
412
  // identically in the SYNCHRONOUS pollCiDeploy and the async pollServerPublish —
@@ -366,8 +419,12 @@ const POLL_SLEEP_CMD = 'node -e "setTimeout(()=>{}, 10000)"';
366
419
  // reconciles the task to shipped when it observes merge+deploy — task 1049) until
367
420
  // the PR merges + deploy-prod succeeds. Returns true when prod is confirmed live;
368
421
  // a timeout with an armed PR is success-PENDING (the publish worked), not failure.
369
- async function pollServerPublish(taskId, branch) {
422
+ // A timeout with a CONFLICTING PR is not (task 1003499): see landConflicted.
423
+ // `deps` exists for tests/ship_land_conflict.mjs; a real ship passes none.
424
+ async function pollServerPublish(taskId, branch, deps = {}) {
370
425
  const TRIES = 54; // ~9 min at 10s — checks + auto-merge + Actions deploy
426
+ const readStatus = deps.readStatus || (async (q) => (await cliClient()).tasks.getTasksIdPublishStatus(q));
427
+ const pause = deps.pause || (() => shellOk(POLL_SLEEP_CMD));
371
428
  console.log(' ci-land: waiting for auto-merge + prod deploy (polling the server)...');
372
429
  // task 1002645: GitHub can report merged+deployed while the SERVER refused the
373
430
  // confirmed→shipped flip (reconcile_blocked — the task-1547 land proof missed).
@@ -376,14 +433,16 @@ async function pollServerPublish(taskId, branch) {
376
433
  // pushed tip, so the proof usually heals within the window.
377
434
  let blockedReason = null;
378
435
  let redLine = null;
436
+ let conflicting = false;
379
437
  for (let i = 0; i < TRIES; i++) {
380
- const r = await (await cliClient()).tasks.getTasksIdPublishStatus({ id: taskId, query: { branch } });
438
+ const r = await readStatus({ id: taskId, query: { branch } });
381
439
  if (r.ok && r.data) {
382
440
  const red = redPrLine(r.data.merge_driver);
383
441
  if (red && red !== redLine) {
384
442
  redLine = red;
385
443
  for (const line of redPrLines(r.data.merge_driver)) console.log(line);
386
444
  }
445
+ conflicting = noteConflict(taskId, r.data.merge_driver, conflicting);
387
446
  const outcome = classifyLandOutcome(r.data);
388
447
  if (outcome === 'shipped') {
389
448
  if (r.data.reconcile_blocked) {
@@ -401,7 +460,7 @@ async function pollServerPublish(taskId, branch) {
401
460
  return landBailed('the PR was closed without merging (a check failed, or it was closed manually)', `node scripts/gds/ship.js ${taskId}`);
402
461
  }
403
462
  }
404
- shellOk(POLL_SLEEP_CMD);
463
+ pause();
405
464
  }
406
465
  if (blockedReason) {
407
466
  console.log('');
@@ -409,6 +468,7 @@ async function pollServerPublish(taskId, branch) {
409
468
  for (const line of plainCardLines('reconcile_blocked')) console.log(line);
410
469
  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
470
  }
471
+ if (conflicting) return landConflicted(taskId);
412
472
  printLandPending(taskId);
413
473
  return landPending();
414
474
  }
@@ -723,14 +783,22 @@ async function ciLandLocal(taskId, branch, valueSummary) {
723
783
  // 3. public edge healthz -> 200 (belt-and-braces, a different vantage).
724
784
  // Times out conservatively; on any timeout the task stays at confirmed with a
725
785
  // precise next step rather than a false "shipped".
726
- function pollCiDeploy(taskId, branch, headSha) {
786
+ // `deps` exists for tests/ship_land_conflict.mjs; a real ship passes none.
787
+ function pollCiDeploy(taskId, branch, headSha, deps = {}) {
727
788
  const MERGE_TRIES = 30; // ~5 min at 10s — checks + auto-merge
728
789
  const DEPLOY_TRIES = 24; // ~4 min at 10s — Actions deploy
790
+ const readJson = deps.ghJson || ghJson;
791
+ const pause = deps.pause || (() => shellOk(POLL_SLEEP_CMD));
729
792
 
730
793
  console.log(' ci-land: waiting for auto-merge (checks + branch protection)...');
731
794
  let mergeSha = null;
795
+ let conflicting = false;
732
796
  for (let i = 0; i < MERGE_TRIES; i++) {
733
- const pr = ghJson(['pr', 'view', branch, '--json', 'state,mergeCommit']);
797
+ // task 1003499: mergeable + mergeStateStatus ride the same read, so a conflict is
798
+ // named on the poll it appears; the two-field read is the fallback for a gh that
799
+ // predates those fields, which keeps this no worse than before on any machine.
800
+ const pr = readJson(['pr', 'view', branch, '--json', 'state,mergeCommit,mergeable,mergeStateStatus'])
801
+ || readJson(['pr', 'view', branch, '--json', 'state,mergeCommit']);
734
802
  if (pr && pr.state === 'MERGED') {
735
803
  mergeSha = pr.mergeCommit && pr.mergeCommit.oid;
736
804
  break;
@@ -739,11 +807,14 @@ function pollCiDeploy(taskId, branch, headSha) {
739
807
  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
808
  return landBailed('the PR was closed without merging (a check failed, or it was closed manually)', `node scripts/gds/ship.js ${taskId}`);
741
809
  }
742
- shellOk(POLL_SLEEP_CMD);
810
+ conflicting = noteConflict(taskId, pr, conflicting);
811
+ pause();
743
812
  }
744
813
  if (!mergeSha) {
745
814
  // 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.
815
+ // SUCCEEDED and is still in CI — success-pending, not a failure. Unless it is
816
+ // CONFLICTING (task 1003499): then nothing is still in CI, and it never merges.
817
+ if (conflicting) return landConflicted(taskId);
747
818
  printLandPending(taskId);
748
819
  return landPending();
749
820
  }
@@ -811,6 +882,14 @@ module.exports = {
811
882
  prAssigneeLine,
812
883
  redPrLine,
813
884
  redPrLines,
885
+ // task 1003499: a CONFLICTING PR is named while the land waits, and a wait that
886
+ // ends conflicting is a bail, not "landing automatically". The polls are exported
887
+ // with their optional `deps` so the test drives the real loops.
888
+ prConflicting,
889
+ conflictPrLines,
890
+ landConflicted,
891
+ pollCiDeploy,
892
+ pollServerPublish,
814
893
  reconciledCardStatus,
815
894
  strandCommand,
816
895
  strandNextStep,
@@ -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,36 @@ 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
+ // opts: { force, inputFiles } — inputFiles is shipInputPaths(args) from the caller;
72
+ // omitted (the resume path has no prose flags), nothing is excluded from the
73
+ // untracked-file warning.
43
74
  function preflight(claim, opts = {}) {
44
75
  // (a) Working-tree check — refuse if any TRACKED file carries an uncommitted
45
76
  // modification or staged change. This fires post-commit, so a clean tree
@@ -145,8 +176,29 @@ function preflight(claim, opts = {}) {
145
176
  }
146
177
  }
147
178
 
148
- console.log(` preflight: working tree clean, branch ${behind} commits behind main, ship tooling current — OK`);
149
- return { ok: true };
179
+ // (e) Untracked-file warning (task 1001436) — WARN-ONLY, never blocks, never
180
+ // changes the exit code. Step (a) ignores untracked files so scratch cannot
181
+ // false-refuse a ship, which also meant a NEW deliverable left un-added beside
182
+ // committed work shipped without it, in silence (the residual of the task-1363
183
+ // phantom ship). This names those files while stopping is still free. It needs
184
+ // committed work to warn about — upstreamDelTouched is this branch's committed
185
+ // diff from step (d); with none, the empty-diff guard already refuses and lists
186
+ // them. Its own `git status`, on purpose: --untracked-files=all names each file
187
+ // rather than a collapsed `dir/`, so the ship's input files can be excluded
188
+ // exactly; and a failed or oversized read here stays a skipped warning, never a
189
+ // blank porcelain for the refusal in step (a).
190
+ const untrackedPorcelain = gitOk('git status --porcelain --untracked-files=all', { maxBuffer: 64 * 1024 * 1024 });
191
+ const leftBehind = untrackedLeftBehind(untrackedPorcelain, {
192
+ committedFileCount: upstreamDelTouched.length,
193
+ inputs: opts.inputFiles || [],
194
+ });
195
+ for (const line of untrackedWarningLines(leftBehind, { taskId: claim && claim.task_id })) console.error(line);
196
+
197
+ const treeState = leftBehind.length > 0
198
+ ? `tracked files clean (${leftBehind.length} untracked file(s) will not ship — see the warning)`
199
+ : 'working tree clean';
200
+ console.log(` preflight: ${treeState}, branch ${behind} commits behind main, ship tooling current — OK`);
201
+ return { ok: true, untrackedLeftBehind: leftBehind };
150
202
  }
151
203
 
152
204
  // mergeMainIntoBranch — pull origin/main INTO the current feature branch BEFORE
@@ -320,4 +372,5 @@ module.exports = {
320
372
  mergeMainIntoBranch,
321
373
  parseDeletedUpstreamPaths,
322
374
  preflight,
375
+ shipInputPaths,
323
376
  };
@@ -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
@@ -0,0 +1,67 @@
1
+ 'use strict';
2
+
3
+ // scripts/gds/upgrade-migrate.js — WHICH command `bongos upgrade` migrates an instance
4
+ // with (task 1004274, found preparing the task 1004047 catch-up pass). Kept out of
5
+ // upgrade.js, which sits at the 1500-line cap.
6
+ //
7
+ // THE GAP. upgrade.js migrated with `npm run migrate`, and that only works where the
8
+ // instance's package.json DECLARES a migrate script. A greenfield scaffold always does
9
+ // (init.js buildInstancePackageJson writes INSTANCE_MIGRATE_SCRIPT), but the ADOPT layer
10
+ // does not: init.js mergeCoreDependency adds the core dependency and nothing else, and
11
+ // for a repo that had no package.json of its own it mints `{ name, private,
12
+ // dependencies }`. So an adopted project that is not a JavaScript app has no scripts at
13
+ // all. There `npm run migrate` answers "Missing script" — AFTER the pin has moved and
14
+ // the new core is installed — and the upgrade rolls itself back, restarting someone's
15
+ // live app to change nothing. mercury (a Python/Flask project; its package.json is
16
+ // exactly name/private/dependencies) is that shape, and it is why the catch-up pass
17
+ // could not reach it. The dry run could not warn either, because it never looked.
18
+ //
19
+ // THE FALLBACK IS NOT A NEW MIGRATION PATH. It is the command a scaffold's own script
20
+ // IS, and the one the control plane ran to create that instance's database at stand-up
21
+ // (provision-repo.js standaloneMigrateCmd): the installed core's scripts/migrate.sh,
22
+ // run from the instance root. A declared script still wins, whatever it says — an
23
+ // owner who wrote their own migrate step meant it — and `--skip-migrate` stays the
24
+ // only way to run no migration at all, which on a real jump leaves the new code
25
+ // running against the old schema.
26
+
27
+ const fs = require('node:fs');
28
+ const path = require('node:path');
29
+
30
+ // Relative to the instance root, POSIX on purpose: it is an argument to bash, and it
31
+ // must stay byte-identical to the scaffold's script (tests/upgrade_migrate.mjs pins it
32
+ // against init.js INSTANCE_MIGRATE_SCRIPT).
33
+ const CORE_MIGRATE_SCRIPT = 'node_modules/@bongos/core/scripts/migrate.sh';
34
+
35
+ /**
36
+ * How this instance is migrated: its own declared script, or the core's migrate.sh.
37
+ *
38
+ * Only a package.json we could READ, and that names no migrate script, earns the
39
+ * fallback. A missing or unparseable one keeps the old command, which then fails the
40
+ * way it always did — guessing at a file this could not parse would be worse.
41
+ *
42
+ * `env` exists only on the fallback, and only to hand migrate.sh what `npm run` would:
43
+ * migrate.sh finds the instance from INIT_CWD before its own cwd (task 2180), and npm
44
+ * sets it to the directory the script runs in. Launched directly, a stray INIT_CWD
45
+ * inherited from whoever started this upgrade would aim it at the wrong project.
46
+ *
47
+ * `note` is a pre-flight line, so the dry run says which command the move will run.
48
+ */
49
+ function migratePlan(instanceDir, { fsImpl = fs, env = process.env } = {}) {
50
+ let pkg = null;
51
+ try { pkg = JSON.parse(fsImpl.readFileSync(path.join(instanceDir, 'package.json'), 'utf8')); } catch { /* absent or unreadable */ }
52
+ const scripts = pkg && typeof pkg.scripts === 'object' && pkg.scripts ? pkg.scripts : {};
53
+ const declared = typeof scripts.migrate === 'string' && scripts.migrate.trim() !== '';
54
+ if (!pkg || declared) {
55
+ return { via: 'npm-script', cmd: 'npm', args: ['run', 'migrate'], label: 'npm run migrate', env: null, note: null };
56
+ }
57
+ return {
58
+ via: 'core-script',
59
+ cmd: 'bash',
60
+ args: [CORE_MIGRATE_SCRIPT],
61
+ label: `bash ${CORE_MIGRATE_SCRIPT}`,
62
+ env: { ...env, INIT_CWD: instanceDir },
63
+ note: 'migrate: this project declares no migrate script (an adopted repo), so the core\'s own scripts/migrate.sh will run — the command a scaffold\'s script runs',
64
+ };
65
+ }
66
+
67
+ module.exports = { migratePlan, CORE_MIGRATE_SCRIPT };