@nextcommerce/campaigns-os 1.37.3 → 1.41.2

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 (49) hide show
  1. package/AGENTS.md +114 -10
  2. package/CHANGELOG.md +530 -0
  3. package/README.md +38 -27
  4. package/contracts/agent-relevant-change-policy.v1.json +11 -1
  5. package/contracts/effects.v1.json +4794 -0
  6. package/contracts/release-ledger.json +789 -0
  7. package/contracts/supported-surface.json +25 -5
  8. package/docs/build-packet.md +27 -16
  9. package/docs/demo-preview.md +1 -1
  10. package/docs/diagnostics.md +7 -4
  11. package/docs/effects.md +281 -0
  12. package/docs/gateway-login.md +113 -0
  13. package/docs/orientation-contract-reference.md +4 -1
  14. package/docs/progress-snapshots.md +3 -3
  15. package/docs/qa-and-test-orders.md +3 -3
  16. package/docs/readback.md +523 -0
  17. package/docs/runtime-readiness.md +1 -1
  18. package/docs/sdk-storage-compatibility.md +1 -1
  19. package/docs/skills-revision.md +364 -0
  20. package/docs/supported-surface.md +11 -3
  21. package/docs/versioning.md +8 -4
  22. package/package.json +8 -3
  23. package/schemas/campaign-runtime-build-packet.v0.schema.json +5 -0
  24. package/schemas/campaigns-os-effects.v1.schema.json +211 -0
  25. package/schemas/campaigns-os-readback.v2.schema.json +267 -0
  26. package/skills/campaign-lifecycle-orientation/SKILL.md +174 -0
  27. package/skills/campaign-readback-classification/SKILL.md +230 -0
  28. package/skills/campaign-run-evidence/SKILL.md +140 -0
  29. package/skills/contribution-intake/SKILL.md +85 -0
  30. package/skills/next-campaigns-build/SKILL.md +33 -12
  31. package/skills/next-campaigns-os/SKILL.md +45 -21
  32. package/skills/next-campaigns-os-setup/SKILL.md +35 -14
  33. package/skills/next-campaigns-polish/SKILL.md +43 -17
  34. package/skills/next-campaigns-qa/SKILL.md +48 -24
  35. package/skills.json +39 -6
  36. package/src/admin-transport.mjs +123 -0
  37. package/src/cli.mjs +991 -200
  38. package/src/credential-store.mjs +183 -0
  39. package/src/deviation.mjs +3 -2
  40. package/src/diagnostic.mjs +4 -1
  41. package/src/gate-actions.mjs +2 -2
  42. package/src/install-mode.mjs +17 -9
  43. package/src/lifecycle.mjs +95 -0
  44. package/src/login.mjs +152 -0
  45. package/src/package-install-fixture.mjs +3 -2
  46. package/src/qa-node.mjs +56 -19
  47. package/src/qa-publish.mjs +108 -2
  48. package/src/readback.mjs +1936 -0
  49. package/src/remit.mjs +17 -3
@@ -0,0 +1,1936 @@
1
+ /**
2
+ * Read-only projection of one run's emitted Campaigns OS artifacts.
3
+ *
4
+ * `campaigns-os readback <target>` is a deterministic, side-effect-free
5
+ * projection over files a Campaigns OS run has already emitted (the Build
6
+ * Packet, the doctor output sidecar, the build context, the assembly report, a
7
+ * QA verdict, and a findings export when present). It reads each named file at
8
+ * most once, plus two fixed Git metadata files for the staleness comparison
9
+ * (the nearest `.git` entry at the target or one of its ancestors — funnels are
10
+ * usually subdirectories of their enclosing campaign repository — to locate the
11
+ * Git directory, and that directory's `logs/HEAD` reflog). It writes nothing,
12
+ * starts no process, and touches no network. That contract is why the CLI
13
+ * exempts `readback` from lifecycle-journal capture the way it exempts doctor
14
+ * inspection: a command declared read-only must not append a journal entry.
15
+ *
16
+ * Campaigns OS remains the lifecycle and verdict authority. The readback never
17
+ * reinterprets a verdict and never proposes or performs remediation; where it
18
+ * adds anything beyond the artifacts' own words — the contract-static warning
19
+ * labels, the fail-to-skip cascade provenance, the staleness assessment — the
20
+ * rendered output marks that content as the readback's own projection layer.
21
+ *
22
+ * The default output is the rendered human view. `--json` emits the same
23
+ * projection as one `campaigns-os-readback/v2` object on stdout so a caller can
24
+ * gate on it; the JSON serializes what the text view already computes and adds
25
+ * no new interpretation. `docs/readback.md` is that contract's prose twin,
26
+ * including the exact rule behind its `clean` flag, and
27
+ * `schemas/campaigns-os-readback.v2.schema.json` is its shape.
28
+ *
29
+ * This module is a port of the Python readback this command replaces. The port
30
+ * keeps that module's decomposition section by section so the two can be
31
+ * diffed, and fixes one defect in it: staleness is now assessed per artifact.
32
+ * The Python version compared only the newest loaded artifact against HEAD, so
33
+ * a single freshly regenerated artifact hid every stale sibling behind
34
+ * `stale: false`. Here every loaded artifact with a parseable `generated_at`
35
+ * gets its own verdict, `stale_keys` names the stale ones in render order, and
36
+ * the aggregate `stale` is true when ANY of them is stale. That is a change of
37
+ * meaning in a published field, so the payload is `v2`, not `v1`. An artifact
38
+ * that recorded a `generated_at` this readback cannot parse is the same defect
39
+ * one step further out — its age was never established, so it is named in
40
+ * `unparseable_keys` and makes `clean` false rather than leaving a fresh
41
+ * sibling to speak for it.
42
+ *
43
+ * Same-user artifact files are inside the repository's trust boundary, so this
44
+ * module deliberately has no symlink or tamper ceremony: a caller who wants to
45
+ * feed it other bytes can already read those bytes directly.
46
+ */
47
+
48
+ import { closeSync, fstatSync, openSync, readdirSync, readSync, statSync } from "node:fs";
49
+ import { isAbsolute, join, resolve } from "node:path";
50
+ import { fileURLToPath } from "node:url";
51
+
52
+ export const MAX_ARTIFACT_BYTES = 32 * 1024 * 1024;
53
+ export const MAX_GIT_METADATA_BYTES = 64 * 1024;
54
+
55
+ /** A bounded read refused because the file is larger than its limit. */
56
+ export class ReadLimitError extends Error {
57
+ constructor(message) {
58
+ super(message);
59
+ this.name = "ReadLimitError";
60
+ }
61
+ }
62
+
63
+ /** A file's bytes are not valid UTF-8. */
64
+ export class UnicodeDecodeError extends Error {
65
+ constructor(message) {
66
+ super(message);
67
+ this.name = "UnicodeDecodeError";
68
+ }
69
+ }
70
+
71
+ /** A bounded reflog read could not recover a complete final entry. */
72
+ export class ReflogTailError extends Error {
73
+ constructor(message) {
74
+ super(message);
75
+ this.name = "ReflogTailError";
76
+ }
77
+ }
78
+
79
+ /** Raised when the caller's paths cannot form a projection at all. */
80
+ export class ReadbackUsageError extends Error {
81
+ constructor(message) {
82
+ super(message);
83
+ this.name = "ReadbackUsageError";
84
+ }
85
+ }
86
+
87
+ // `ignoreBOM: true` means "do not strip a leading U+FEFF", which is what the
88
+ // Python reader's `payload.decode("utf-8")` does: the BOM survives decoding as
89
+ // a character, and `json.loads` then refuses the value outright ("Unexpected
90
+ // UTF-8 BOM"). The default TextDecoder swallows that BOM instead, which made a
91
+ // BOM-prefixed packet parse here and fail there — and a packet that parses is a
92
+ // discovery candidate, so the byte order mark silently changed which artifact
93
+ // the readback projected. Keeping the BOM keeps a BOM-prefixed file unreadable
94
+ // in both implementations, which is what discovery records as rejected.
95
+ const UTF8 = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true });
96
+
97
+ function decodeUtf8(bytes) {
98
+ try {
99
+ return UTF8.decode(bytes);
100
+ } catch {
101
+ throw new UnicodeDecodeError("'utf-8' codec cannot decode the file's bytes: invalid UTF-8");
102
+ }
103
+ }
104
+
105
+ function readExactly(fd, length, position) {
106
+ const buffer = Buffer.allocUnsafe(length);
107
+ let filled = 0;
108
+ while (filled < length) {
109
+ const read = readSync(fd, buffer, filled, length - filled, position + filled);
110
+ if (read === 0) break;
111
+ filled += read;
112
+ }
113
+ return buffer.subarray(0, filled);
114
+ }
115
+
116
+ /**
117
+ * Read a whole file as UTF-8 text, refusing anything past `maxBytes`.
118
+ *
119
+ * The limit is a read bound, not a truncation: a file one byte over it is
120
+ * refused rather than silently shortened, because a half-read artifact would
121
+ * project as malformed JSON and read as the run's fault rather than ours.
122
+ */
123
+ export function readBoundedText(path, maxBytes) {
124
+ const fd = openSync(path, "r");
125
+ let payload;
126
+ try {
127
+ const size = fstatSync(fd).size;
128
+ payload = readExactly(fd, Math.min(size, maxBytes + 1), 0);
129
+ } finally {
130
+ closeSync(fd);
131
+ }
132
+ if (payload.length > maxBytes) throw new ReadLimitError(`file exceeds the ${maxBytes}-byte read limit`);
133
+ return decodeUtf8(payload);
134
+ }
135
+
136
+ const BLANK_BYTES = new Set([0x20, 0x09, 0x0a, 0x0d, 0x0b, 0x0c]);
137
+
138
+ function isBlank(bytes) {
139
+ for (const byte of bytes) if (!BLANK_BYTES.has(byte)) return false;
140
+ return true;
141
+ }
142
+
143
+ function splitLines(bytes) {
144
+ const lines = [];
145
+ let start = 0;
146
+ for (let index = 0; index < bytes.length; index += 1) {
147
+ if (bytes[index] === 0x0a) {
148
+ lines.push(bytes.subarray(start, index));
149
+ start = index + 1;
150
+ }
151
+ }
152
+ if (start < bytes.length) lines.push(bytes.subarray(start));
153
+ return lines;
154
+ }
155
+
156
+ /**
157
+ * The last complete entry of a reflog, reading only the file's final tail.
158
+ *
159
+ * A long-lived checkout's reflog is unbounded, and only its last line is the
160
+ * signal, so the read is bounded from the end. The leading fragment of a
161
+ * bounded tail may start mid-entry or mid-codepoint, so it is discarded up to
162
+ * the first newline; only the final line is decoded, which keeps a corrupt
163
+ * older entry from failing a read whose answer does not depend on it.
164
+ */
165
+ export function readReflogTail(path) {
166
+ const fd = openSync(path, "r");
167
+ let payload;
168
+ let start;
169
+ try {
170
+ const size = fstatSync(fd).size;
171
+ start = Math.max(0, size - MAX_GIT_METADATA_BYTES);
172
+ payload = readExactly(fd, Math.min(size - start, MAX_GIT_METADATA_BYTES), start);
173
+ } finally {
174
+ closeSync(fd);
175
+ }
176
+ if (start) {
177
+ const newline = payload.indexOf(0x0a);
178
+ if (newline === -1) throw new ReflogTailError("no newline found within the bounded tail");
179
+ payload = payload.subarray(newline + 1);
180
+ if (isBlank(payload)) throw new ReflogTailError("no complete entry remains after the leading fragment");
181
+ }
182
+ const entries = splitLines(payload).filter((line) => !isBlank(line));
183
+ return entries.length ? decodeUtf8(entries[entries.length - 1]) : "";
184
+ }
185
+
186
+ // Artifact keys, in render order, with the default location of each artifact
187
+ // relative to the target repository root. The packet lives at the root; the
188
+ // sidecars live under .campaign-runtime/.
189
+ export const DEFAULT_RELATIVE_PATHS = {
190
+ packet: "campaign-runtime.build.json",
191
+ doctor: ".campaign-runtime/doctor-output.json",
192
+ context: ".campaign-runtime/build-context.json",
193
+ report: ".campaign-runtime/assembly-report.json",
194
+ qa_verdict: ".campaign-runtime/qa-verdict.json",
195
+ findings: ".campaign-runtime/findings-export.json",
196
+ };
197
+
198
+ export const ARTIFACT_TITLES = {
199
+ packet: "build packet",
200
+ doctor: "doctor output",
201
+ context: "build context",
202
+ report: "assembly report",
203
+ qa_verdict: "QA verdict",
204
+ findings: "findings export",
205
+ };
206
+
207
+ export const RECOGNIZED_SCHEMA_VERSIONS = {
208
+ packet: ["campaign-runtime-build-packet/v0"],
209
+ context: ["campaign-runtime-build-context/v0"],
210
+ report: ["campaign-runtime-assembly-report/v0"],
211
+ // Campaigns OS has emitted both spellings for the QA verdict.
212
+ qa_verdict: ["1.0", "campaigns-os-qa-verdict/v0"],
213
+ };
214
+
215
+ // Root-level Build Packets other than the default-named one. A second run in
216
+ // the same repository leaves its record under a suffixed name; both shapes are
217
+ // discovery candidates when no `--packet` was given.
218
+ const PACKET_CANDIDATE_PATTERN = /^campaign-runtime-.*\.build\.json$/;
219
+
220
+ // Sidecar path some write-ends have used instead of the contracted root home.
221
+ // Discovery never selects this file: Campaigns OS writes the packet at the
222
+ // repository root, and auto-selecting the sidecar would paper over that
223
+ // mismatch. When no root candidate exists and this file is present, discovery
224
+ // records it as rejected so the operator can pass --packet instead of staring
225
+ // at an empty folder.
226
+ const SIDECAR_PACKET_RELATIVE = ".campaign-runtime/campaign-runtime.build.json";
227
+
228
+ // The readback's own interpretation layer, applied to doctor warning codes.
229
+ // Doctor warnings under the frontmatter.* codes restate the template family's
230
+ // shared frontmatter vocabulary (the contract), not an observation of this
231
+ // repository: they repeat verbatim on every doctor pass while the contract is
232
+ // in force, so their persistence does not mean a flagged value is still
233
+ // unfixed, and their disappearance is not how a fix is confirmed. Every other
234
+ // code is labeled repo-observed: not in the contract-static table, so its
235
+ // message reflects this repository, spec, or build as doctor saw it.
236
+ const CONTRACT_STATIC_CODE_PREFIXES = ["frontmatter."];
237
+
238
+ // Stage blockers render in full up to this cap, then collapse to a count, so a
239
+ // pathological report cannot flood the view. Known-schema object blockers
240
+ // (code/message/stage/page_id/detail) render their human-readable text in full
241
+ // — the stage cap is the volume bound — while unknown shapes fall back to a
242
+ // bounded JSON rendering.
243
+ const BLOCKER_RENDER_CAP = 10;
244
+ const NON_STRING_BLOCKER_RENDER_CAP = 120;
245
+
246
+ // The human-readable and identifier fields of the assembly report's object
247
+ // blocker schema, in render order. detail substitutes when message is absent.
248
+ // This tuple mirrors the upstream assembly-report schema and has to move with
249
+ // it: a renamed or removed field is skipped silently rather than reported, and
250
+ // a newly added identifier is not rendered until it is listed here.
251
+ const BLOCKER_IDENTIFIER_FIELDS = ["stage", "page_id"];
252
+
253
+ const CONTRACT_STATIC_LABEL_NOTE =
254
+ "contract-static: restates the template-family contract; repeats verbatim " +
255
+ "on every doctor pass, so its presence does not track this repository's " +
256
+ "current state";
257
+ const REPO_OBSERVED_LABEL_NOTE =
258
+ "repo-observed: reflects this repository, spec, or build as doctor saw it";
259
+
260
+ // The machine-readable projection's schema identifier. Contract and field
261
+ // semantics live in docs/readback.md and
262
+ // schemas/campaigns-os-readback.v2.schema.json; those documents and this
263
+ // constant move together.
264
+ export const JSON_SCHEMA_VERSION = "campaigns-os-readback/v2";
265
+
266
+ // Artifact states that do not, on their own, make a projection unclean: a
267
+ // loaded artifact was read and recognized, and an absent one is a file the run
268
+ // simply did not emit here. `unreadable` and `unrecognized` mean the readback
269
+ // cannot see what the artifact says, which is never clean.
270
+ const CLEAN_ARTIFACT_STATES = new Set(["loaded", "absent"]);
271
+
272
+ // Python's datetime range (year 1 through year 9999), kept so an absurd reflog
273
+ // epoch is refused with a reason instead of formatting as a nonsense instant.
274
+ const MIN_EPOCH_SECONDS = -62135596800;
275
+ const MAX_EPOCH_SECONDS = 253402300799;
276
+
277
+ // ---------------------------------------------------------------------------
278
+ // ISO-8601 acceptance, ported from CPython
279
+ //
280
+ // The Python readback parses `generated_at` with `datetime.fromisoformat`
281
+ // (runner/artifact_readback.py:192-209, `_parse_iso_timestamp`), so acceptance
282
+ // here has to match that function in BOTH directions, not merely cover the
283
+ // shapes Campaigns OS emits. A value Python parses and this does not drops the
284
+ // artifact out of the staleness map, which reports a stale sibling as clean; a
285
+ // value Python refuses and this accepts turns a required unknown-candidate
286
+ // refusal into a silent packet selection. A regex tuned to the emitted shape
287
+ // got both wrong — it capped the fraction at nine digits and never bounded the
288
+ // UTC offset — so the grammar below is a transcription of CPython's helpers
289
+ // (Lib/datetime.py, 3.11) rather than an approximation of them: one function
290
+ // per Python helper, named for it, in the same order.
291
+ //
292
+ // Where the pure-Python reference and the C accelerator disagree, this follows
293
+ // the C, because the accelerator is the implementation that actually runs when
294
+ // the Python readback calls `fromisoformat`. The disagreements this grammar
295
+ // carries, each checked against CPython 3.11's accelerator rather than inferred
296
+ // from the pure-Python source:
297
+ //
298
+ // - `int()` accepts surrounding whitespace, a sign, non-ASCII digits, and a
299
+ // one-digit slice where the C `parse_digits` demands an exact count; this
300
+ // takes strict ASCII digits at exact widths.
301
+ // - a fraction may follow `HH` or `HH:MM`, not only `HH:MM:SS`: the C parser
302
+ // starts the fraction wherever the time components stop, so `T10.5` and
303
+ // `T10:00.5` are instants. Pure Python calls both an invalid separator.
304
+ // - the date/time separator is one Unicode character, not one UTF-16 code
305
+ // unit, so an astral separator is consumed whole.
306
+ // - a separator with no time behind it (`2026-09-22T`) is malformed. Pure
307
+ // Python reads it as midnight; the C parser refuses it, and a bare date
308
+ // with no separator at all is still midnight in both.
309
+ // - an offset whose WHOLE-second part is zero is UTC, and its sub-second
310
+ // part is discarded (`+00:00:00.5` is UTC, not half a second east). Pure
311
+ // Python builds a half-second timezone. An offset with a non-zero
312
+ // whole-second part keeps its fraction in both (`+00:00:01.5`).
313
+ //
314
+ // One C quirk is deliberately NOT ported: the accelerator also reads `:` as a
315
+ // fraction separator after the seconds (`T10:00:00:12` is 10:00:00.12), which
316
+ // pure Python refuses. No finding covers it, no artifact is written that way,
317
+ // and this grammar refuses it as the pure-Python reference does.
318
+ // ---------------------------------------------------------------------------
319
+
320
+ const MIN_ISO_YEAR = 1;
321
+ const MAX_ISO_YEAR = 9999;
322
+ const MICROS_PER_SECOND = 1_000_000n;
323
+ const MILLIS_PER_DAY = 86_400_000;
324
+ // The ordinal of 1970-01-01 in Python's proleptic Gregorian calendar, where
325
+ // 0001-01-01 is ordinal 1. JavaScript's Date uses the same calendar, so day
326
+ // arithmetic can cross between the two through this constant alone.
327
+ const UNIX_EPOCH_ORDINAL = 719163;
328
+ // `timezone()`'s own bound: strictly between -24h and +24h, which is at most
329
+ // 23:59:59.999999 either way (Lib/datetime.py `timezone._maxoffset`). This is
330
+ // the check the regex had no equivalent of, and the reason `+25:00` has to be
331
+ // unparseable rather than a 25-hour shift.
332
+ const MAX_OFFSET_MICROS = 24n * 3600n * MICROS_PER_SECOND - 1n;
333
+ // Scale for a fraction shorter than six digits, indexed by digits - 1
334
+ // (Lib/datetime.py `_FRACTION_CORRECTION`).
335
+ const FRACTION_CORRECTION = [100000, 10000, 1000, 100, 10];
336
+ const DAYS_IN_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
337
+ // The characters that can start a UTC offset, in the order `_parse_isoformat_time`
338
+ // consults them: a `-` anywhere in the time wins over a `+` or a `Z` that comes
339
+ // earlier, and `Z` is reached only when neither sign appears. See the scan in
340
+ // parseIsoformatTime for the measurement behind that order.
341
+ const OFFSET_MARKERS = ["-", "+", "Z"];
342
+
343
+ function isAsciiDigit(code) {
344
+ return code >= 48 && code <= 57;
345
+ }
346
+
347
+ /** CPython `parse_digits`: exactly `count` ASCII digits at `pos`, else null. */
348
+ function parseDigits(text, pos, count) {
349
+ if (pos + count > text.length) return null;
350
+ let value = 0;
351
+ for (let index = 0; index < count; index += 1) {
352
+ const code = text.charCodeAt(pos + index);
353
+ if (!isAsciiDigit(code)) return null;
354
+ value = value * 10 + (code - 48);
355
+ }
356
+ return value;
357
+ }
358
+
359
+ function isLeapYear(year) {
360
+ return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
361
+ }
362
+
363
+ function daysInMonth(year, month) {
364
+ return month === 2 && isLeapYear(year) ? 29 : DAYS_IN_MONTH[month - 1];
365
+ }
366
+
367
+ /**
368
+ * Milliseconds since the epoch for a UTC calendar date and time of day.
369
+ *
370
+ * `Date.UTC` maps years 0-99 onto 1900-1999, which would put every year Python
371
+ * accepts below 100 in the wrong millennium; the round-trip through
372
+ * `setUTCFullYear` is how the literal year is kept.
373
+ */
374
+ function utcMillis(year, month, day, hour = 0, minute = 0, second = 0) {
375
+ const stamp = Date.UTC(year, month - 1, day, hour, minute, second);
376
+ if (year > 99) return stamp;
377
+ const corrected = new Date(stamp);
378
+ corrected.setUTCFullYear(year);
379
+ return corrected.getTime();
380
+ }
381
+
382
+ /** CPython `_ymd2ord`: days since 0001-01-01, counting that day as 1. */
383
+ function ymdToOrdinal(year, month, day) {
384
+ return utcMillis(year, month, day) / MILLIS_PER_DAY + UNIX_EPOCH_ORDINAL;
385
+ }
386
+
387
+ /** CPython `_ord2ymd`: the inverse of ymdToOrdinal. */
388
+ function ordinalToYmd(ordinal) {
389
+ const moment = new Date((ordinal - UNIX_EPOCH_ORDINAL) * MILLIS_PER_DAY);
390
+ return { year: moment.getUTCFullYear(), month: moment.getUTCMonth() + 1, day: moment.getUTCDate() };
391
+ }
392
+
393
+ /** CPython `_isoweek1monday`: the ordinal of the Monday starting ISO week 1. */
394
+ function isoWeek1Monday(year) {
395
+ const firstDay = ymdToOrdinal(year, 1, 1);
396
+ const firstWeekday = (firstDay + 6) % 7;
397
+ return firstWeekday > 3 ? firstDay - firstWeekday + 7 : firstDay - firstWeekday;
398
+ }
399
+
400
+ /** CPython `_isoweek_to_gregorian`: a week date as a calendar date, else null. */
401
+ function isoWeekToGregorian(year, week, day) {
402
+ // Bounded this way because 9999-12-31 is (9999, 52, 5).
403
+ if (year < MIN_ISO_YEAR || year > MAX_ISO_YEAR) return null;
404
+ if (week < 1 || week > 53) return null;
405
+ if (week === 53) {
406
+ // ISO years have 53 weeks when they start on a Thursday, and when a leap
407
+ // year starts on a Wednesday. Ordinal 1 (0001-01-01) is a Monday, so the
408
+ // ordinal modulo 7 is 1 for Monday, 3 for Wednesday, 4 for Thursday.
409
+ const firstWeekday = ymdToOrdinal(year, 1, 1) % 7;
410
+ if (!(firstWeekday === 4 || (firstWeekday === 3 && isLeapYear(year)))) return null;
411
+ }
412
+ if (day < 1 || day > 7) return null;
413
+ return ordinalToYmd(isoWeek1Monday(year) + (week - 1) * 7 + (day - 1));
414
+ }
415
+
416
+ /**
417
+ * CPython `_find_isoformat_datetime_separator`: the index of the character
418
+ * between the date and the time; null where CPython raises.
419
+ *
420
+ * The character AT that index is never examined — `T`, a space, and any other
421
+ * single character all separate a date from a time, which is why this returns
422
+ * a position rather than matching a separator.
423
+ */
424
+ function findIsoformatDatetimeSeparator(text) {
425
+ const length = text.length;
426
+ if (length === 7) return 7;
427
+ if (text[4] === "-") {
428
+ if (text[5] === "W") {
429
+ if (length > 8 && text[8] === "-") {
430
+ if (length === 9) return null;
431
+ // YYYY-Www-## is ambiguous; CPython resolves it toward the hyphen at 8
432
+ // and calls that best effort, so the port inherits the same guess.
433
+ if (length > 10 && isAsciiDigit(text.charCodeAt(10))) return 8;
434
+ return 10;
435
+ }
436
+ return 8; // YYYY-Www
437
+ }
438
+ return 10; // YYYY-MM-DD
439
+ }
440
+ if (text[4] === "W") {
441
+ // YYYYWww (7) or YYYYWwwd (8): the run of digits decides which.
442
+ let index = 7;
443
+ while (index < length && isAsciiDigit(text.charCodeAt(index))) index += 1;
444
+ if (index < 9) return index;
445
+ return index % 2 === 0 ? 7 : 8;
446
+ }
447
+ return 8; // YYYYMMDD
448
+ }
449
+
450
+ /** CPython `_parse_isoformat_date`: `{ year, month, day }`, else null. */
451
+ function parseIsoformatDate(text) {
452
+ // CPython asserts this; the C accelerator reports a parse failure instead,
453
+ // which is what null is here.
454
+ if (text.length !== 7 && text.length !== 8 && text.length !== 10) return null;
455
+ const year = parseDigits(text, 0, 4);
456
+ if (year === null) return null;
457
+ const hasSeparator = text[4] === "-";
458
+ let pos = 4 + (hasSeparator ? 1 : 0);
459
+ if (text[pos] === "W") {
460
+ pos += 1;
461
+ const week = parseDigits(text, pos, 2);
462
+ if (week === null) return null;
463
+ pos += 2;
464
+ let day = 1;
465
+ if (text.length > pos) {
466
+ if ((text[pos] === "-") !== hasSeparator) return null; // inconsistent dash
467
+ pos += hasSeparator ? 1 : 0;
468
+ day = parseDigits(text, pos, 1);
469
+ if (day === null) return null;
470
+ }
471
+ return isoWeekToGregorian(year, week, day);
472
+ }
473
+ const month = parseDigits(text, pos, 2);
474
+ if (month === null) return null;
475
+ pos += 2;
476
+ if ((text[pos] === "-") !== hasSeparator) return null; // inconsistent dash
477
+ pos += hasSeparator ? 1 : 0;
478
+ const day = parseDigits(text, pos, 2);
479
+ if (day === null) return null;
480
+ return { year, month, day };
481
+ }
482
+
483
+ /**
484
+ * CPython `_parse_hh_mm_ss_ff`: `HH[:?MM[:?SS]][.,]f+` as
485
+ * `[hour, minute, second, microsecond]`, else null.
486
+ *
487
+ * The fraction is where the regex this replaces was wrong: CPython reads the
488
+ * first six digits, truncates the rest, and only requires that the truncated
489
+ * remainder BE digits — so a fraction of any length parses. Capping it at nine
490
+ * made `.0000010000Z` unparseable, and an unparseable `generated_at` is an
491
+ * artifact that silently leaves the staleness comparison.
492
+ */
493
+ function parseHhMmSsFf(text) {
494
+ const length = text.length;
495
+ const comps = [0, 0, 0, 0];
496
+ let pos = 0;
497
+ let hasSeparator = false;
498
+ for (let comp = 0; comp < 3; comp += 1) {
499
+ if (length - pos < 2) return null; // incomplete time component
500
+ const value = parseDigits(text, pos, 2);
501
+ if (value === null) return null;
502
+ comps[comp] = value;
503
+ pos += 2;
504
+ const nextChar = text[pos] ?? "";
505
+ if (comp === 0) hasSeparator = nextChar === ":";
506
+ if (!nextChar || comp >= 2) break;
507
+ // A fraction ends the time components wherever they have got to, so `10.5`
508
+ // and `10:00.5` are half a second past the hour just as `10:00:00.5` is.
509
+ // Reading this position as a component separator instead refused both, and
510
+ // an unparseable generated_at drops its artifact out of the staleness
511
+ // comparison entirely — which is how a stale artifact reads as clean.
512
+ if (nextChar === "." || nextChar === ",") break;
513
+ if (hasSeparator && nextChar !== ":") return null; // invalid time separator
514
+ pos += hasSeparator ? 1 : 0;
515
+ }
516
+ if (pos < length) {
517
+ // ISO-8601 allows either fraction separator and CPython takes both.
518
+ if (text[pos] !== "." && text[pos] !== ",") return null;
519
+ pos += 1;
520
+ const remainder = length - pos;
521
+ if (remainder === 0) return null; // a separator with no fraction behind it
522
+ const parsed = Math.min(remainder, 6);
523
+ const fraction = parseDigits(text, pos, parsed);
524
+ if (fraction === null) return null;
525
+ comps[3] = parsed < 6 ? fraction * FRACTION_CORRECTION[parsed - 1] : fraction;
526
+ // Digits past the sixth are dropped rather than rounded, but they still
527
+ // have to be digits.
528
+ for (let index = pos + parsed; index < length; index += 1) {
529
+ if (!isAsciiDigit(text.charCodeAt(index))) return null;
530
+ }
531
+ }
532
+ return comps;
533
+ }
534
+
535
+ /**
536
+ * CPython `_parse_isoformat_time`: the time of day and its UTC offset in whole
537
+ * microseconds, else null.
538
+ *
539
+ * The offset accepts `±HH`, `±HHMM`, `±HH:MM`, `±HHMMSS`, `±HH:MM:SS` and any
540
+ * of the last four with a fraction, and its magnitude must stay strictly under
541
+ * 24 hours — the bound `timezone()` enforces on the far side of the Python
542
+ * call, which is why `+25:00` is a refusal and not an offset. A trailing `Z`
543
+ * never reaches here: `parseIsoInstant` has already rewritten it to `+00:00`.
544
+ */
545
+ function parseIsoformatTime(text) {
546
+ const length = text.length;
547
+ if (length < 2) return null;
548
+ // Where the offset starts, as CPython's
549
+ // `tz_pos = (tstr.find('-') + 1 or tstr.find('+') + 1 or tstr.find('Z') + 1)`
550
+ // actually behaves — written as an explicit scan, and with "absent" kept
551
+ // distinct from "at index 0" rather than riding on `+ 1` being falsy only for
552
+ // `-1`.
553
+ //
554
+ // Measured against the pure-Python `_parse_isoformat_time` in this machine's
555
+ // CPython 3.11.15: the chain picks by CHARACTER, not by position. A `-`
556
+ // anywhere wins over an earlier `+` or `Z` — `10:00+05-30` splits at the `-`
557
+ // and then refuses `10:00+05` as a time, and `10:00Z-05` refuses `10:00Z` the
558
+ // same way — and `Z` is consulted only when neither sign appears at all.
559
+ // (CPython's own comment there calls the chain equivalent to
560
+ // `re.search('[+-Z]', tstr)`, which would be the lowest position of the
561
+ // three; it is not what the chain does, and the rows in the ISO table measure
562
+ // the chain.) An offset character at index 0 leaves an empty time, which
563
+ // `_parse_hh_mm_ss_ff` refuses — the same refusal CPython gives `-10:00`.
564
+ let offsetAt = -1;
565
+ for (const marker of OFFSET_MARKERS) {
566
+ const at = text.indexOf(marker);
567
+ if (at !== -1) {
568
+ offsetAt = at;
569
+ break;
570
+ }
571
+ }
572
+ const comps = parseHhMmSsFf(offsetAt === -1 ? text : text.slice(0, offsetAt));
573
+ if (comps === null) return null;
574
+ let offsetMicros = 0n;
575
+ if (offsetAt !== -1) {
576
+ // CPython's `tz_pos == len_str and tstr[-1] == 'Z'` branch is not ported:
577
+ // `parseIsoInstant` rewrites a trailing `Z` to `+00:00` before this
578
+ // function ever sees the time, so by construction no fragment reaching here
579
+ // ends in `Z` and the branch could only ever have been dead code. `Z` stays
580
+ // in the scan above because CPython splits on an EMBEDDED one and this
581
+ // follows it there, sign included: `10:00Z05` reads as `10:00+05:00`,
582
+ // exactly as the pure-Python reference does (the C accelerator refuses it —
583
+ // a divergence this port has always had, unrelated to the scan).
584
+ const tzText = text.slice(offsetAt + 1);
585
+ // Valid offset lengths are 2, 4, 5, 6, 7+, 8 and 10+; 0, 1 and 3 are not.
586
+ if (tzText.length === 0 || tzText.length === 1 || tzText.length === 3) return null;
587
+ const tzComps = parseHhMmSsFf(tzText);
588
+ if (tzComps === null) return null;
589
+ // The C accelerator's UTC special case: it converts the offset to WHOLE
590
+ // seconds and returns UTC when that is zero, so a sub-second-only offset
591
+ // like `+00:00:00.5` is UTC and its fraction is discarded — while an offset
592
+ // whose whole-second part is non-zero keeps its fraction (`+00:00:01.5` is
593
+ // one and a half seconds). Carrying the discarded half-second reordered two
594
+ // packets recorded half a second apart, and packet order decides which run
595
+ // the readback projects.
596
+ const wholeSeconds = BigInt(tzComps[0]) * 3600n + BigInt(tzComps[1]) * 60n + BigInt(tzComps[2]);
597
+ if (wholeSeconds !== 0n) {
598
+ const magnitude = wholeSeconds * MICROS_PER_SECOND + BigInt(tzComps[3]);
599
+ if (magnitude > MAX_OFFSET_MICROS) return null;
600
+ offsetMicros = text[offsetAt] === "-" ? -magnitude : magnitude;
601
+ }
602
+ }
603
+ return { hour: comps[0], minute: comps[1], second: comps[2], micros: comps[3], offsetMicros };
604
+ }
605
+
606
+ /**
607
+ * Parse an artifact's ISO-8601 timestamp into an instant; null when unparseable.
608
+ *
609
+ * Ports `_parse_iso_timestamp`, which rewrites a trailing `Z` to `+00:00` and
610
+ * hands the result to `datetime.fromisoformat`; the helpers above are that
611
+ * function's grammar and this is its body.
612
+ *
613
+ * Returns `{ date, micros }`: a millisecond `Date`, which is what every
614
+ * rendering path formats, and the same instant in whole microseconds since the
615
+ * epoch as a BigInt, which is what every comparison and ordering path uses.
616
+ * The two are separate because JavaScript's Date cannot hold sub-millisecond
617
+ * precision at all, and truncating to it silently made two packets a
618
+ * microsecond apart a tie — a refusal to choose, exit 2, over a difference the
619
+ * artifacts had recorded. The Python readback this module ports compares with
620
+ * `datetime.fromisoformat`, which keeps microseconds, so the truncation was a
621
+ * parity defect as well as a defect on its own terms. Precision stops at six
622
+ * fractional digits, and a seventh or later digit is truncated rather than
623
+ * rounded, which is what `fromisoformat` does with the same input.
624
+ *
625
+ * A value without a timezone is assumed UTC. Campaigns OS emits `generated_at`
626
+ * with a Z suffix, so the assumption is documentation for hand-authored
627
+ * artifacts, not a branch the emitted format exercises.
628
+ */
629
+ export function parseIsoInstant(value) {
630
+ if (typeof value !== "string" || !value) return null;
631
+ const text = value.endsWith("Z") ? `${value.slice(0, -1)}+00:00` : value;
632
+ if (text.length < 7) return null;
633
+ const separator = findIsoformatDatetimeSeparator(text);
634
+ if (separator === null) return null;
635
+ const date = parseIsoformatDate(text.slice(0, separator));
636
+ if (date === null) return null;
637
+ let time;
638
+ if (separator >= text.length) {
639
+ // A bare date, with no separator character at all: midnight.
640
+ time = { hour: 0, minute: 0, second: 0, micros: 0, offsetMicros: 0n };
641
+ } else {
642
+ // The separator is one Unicode character, which may be a surrogate pair —
643
+ // skipping a single UTF-16 code unit left its trailing half at the head of
644
+ // the time string and made an otherwise valid timestamp unparseable.
645
+ const separatorLength = text.codePointAt(separator) > 0xffff ? 2 : 1;
646
+ const tail = text.slice(separator + separatorLength);
647
+ // A separator with nothing behind it is malformed, not midnight: the run
648
+ // wrote a date and started a time it never finished, and reading that as
649
+ // 00:00:00 invents an instant no artifact recorded — one that beats every
650
+ // real timestamp from the day before. Unparseable is the honest answer, and
651
+ // for packet discovery it is the unknown candidate that forces a refusal.
652
+ if (!tail) return null;
653
+ time = parseIsoformatTime(tail);
654
+ }
655
+ if (time === null) return null;
656
+ // The `datetime` constructor's own range checks, which run after parsing and
657
+ // raise the same ValueError the caller reads as "unparseable".
658
+ if (date.year < MIN_ISO_YEAR || date.year > MAX_ISO_YEAR) return null;
659
+ if (date.month < 1 || date.month > 12) return null;
660
+ if (date.day < 1 || date.day > daysInMonth(date.year, date.month)) return null;
661
+ if (time.hour > 23 || time.minute > 59 || time.second > 59) return null;
662
+ const stamp = utcMillis(date.year, date.month, date.day, time.hour, time.minute, time.second);
663
+ if (!Number.isFinite(stamp)) return null;
664
+ const micros = BigInt(stamp) * 1000n + BigInt(time.micros) - time.offsetMicros;
665
+ // Floor rather than truncate toward zero so the rendered second of a
666
+ // pre-epoch instant is the second it falls in, not the one after it.
667
+ const millis = micros / 1000n - (micros % 1000n < 0n ? 1n : 0n);
668
+ return { date: new Date(Number(millis)), micros };
669
+ }
670
+
671
+ /**
672
+ * Parse an artifact's ISO-8601 timestamp as a Date; null when unparseable.
673
+ *
674
+ * The millisecond half of parseIsoInstant, kept for callers that only render.
675
+ * Anything that compares or orders two timestamps must use parseIsoInstant:
676
+ * this Date cannot distinguish two artifacts less than a millisecond apart.
677
+ */
678
+ export function parseIsoTimestamp(value) {
679
+ return parseIsoInstant(value)?.date ?? null;
680
+ }
681
+
682
+ /** Render one instant as the readback's single timestamp format. */
683
+ export function formatUtc(value) {
684
+ return `${value.toISOString().slice(0, 19)}Z`;
685
+ }
686
+
687
+ function statOrNull(path) {
688
+ try {
689
+ return statSync(path, { throwIfNoEntry: false }) ?? null;
690
+ } catch {
691
+ // An unreadable ancestor is "no .git here", the same answer a missing one
692
+ // gives; the walk continues upward rather than failing the projection.
693
+ return null;
694
+ }
695
+ }
696
+
697
+ /**
698
+ * Find the nearest `.git` entry at root or one of its ancestors.
699
+ *
700
+ * Funnel targets are usually subdirectories of their enclosing campaign
701
+ * repository, so Git metadata rarely sits at the target itself. Walks upward
702
+ * from the target and stops at the first `.git` entry (directory or worktree
703
+ * pointer file). File-metadata checks only.
704
+ */
705
+ export function discoverGitEntry(root) {
706
+ let current = resolve(root);
707
+ for (;;) {
708
+ const gitEntry = join(current, ".git");
709
+ const info = statOrNull(gitEntry);
710
+ if (info && (info.isFile() || info.isDirectory())) {
711
+ return { gitEntry, containingDir: current, isFile: info.isFile() };
712
+ }
713
+ const parent = resolve(current, "..");
714
+ if (parent === current) return { gitEntry: null, containingDir: null, isFile: false };
715
+ current = parent;
716
+ }
717
+ }
718
+
719
+ /**
720
+ * When the enclosing checkout's HEAD last moved, from the reflog.
721
+ *
722
+ * Returns `{ time, detail }`: an instant and an empty detail on success, or a
723
+ * null time and a reason when the signal is unavailable. The reflog's last
724
+ * entry advances on commit, checkout, pull, and reset alike; any of those can
725
+ * invalidate previously emitted artifacts, so "HEAD last moved" is deliberately
726
+ * the coarsest local signal, not "last commit authored". The checkout is the
727
+ * nearest `.git` entry at the target or an ancestor. Reading `.git` and the
728
+ * reflog keeps the module's no-process contract; nothing shells out to git.
729
+ */
730
+ export function readHeadMovement(root) {
731
+ const { gitEntry, containingDir, isFile } = discoverGitEntry(root);
732
+ if (gitEntry === null) {
733
+ return { time: null, detail: "the target root is not a Git checkout and no ancestor contains .git" };
734
+ }
735
+ let gitDir = gitEntry;
736
+ if (isFile) {
737
+ let pointerText;
738
+ try {
739
+ pointerText = readBoundedText(gitEntry, MAX_GIT_METADATA_BYTES);
740
+ } catch (error) {
741
+ return { time: null, detail: `could not read the .git pointer file (${error.code ?? error.name})` };
742
+ }
743
+ gitDir = null;
744
+ for (const line of pointerText.split(/\r?\n/)) {
745
+ if (!line.startsWith("gitdir:")) continue;
746
+ const candidate = line.slice("gitdir:".length).trim();
747
+ // A relative gitdir resolves against the .git-bearing ancestor, not the
748
+ // nested target: were the base the target, a sibling worktree pointer
749
+ // would resolve under the funnel directory and the signal would go dark.
750
+ gitDir = isAbsolute(candidate) ? candidate : join(containingDir, candidate);
751
+ break;
752
+ }
753
+ if (gitDir === null) return { time: null, detail: "the .git file carries no gitdir pointer" };
754
+ }
755
+
756
+ let raw;
757
+ try {
758
+ raw = readReflogTail(join(gitDir, "logs", "HEAD"));
759
+ } catch (error) {
760
+ if (error?.code === "ENOENT") return { time: null, detail: "the Git checkout has no HEAD reflog" };
761
+ if (error instanceof ReflogTailError) {
762
+ return { time: null, detail: `could not read the HEAD reflog (${error.message})` };
763
+ }
764
+ return { time: null, detail: `could not read the HEAD reflog (${error.code ?? error.name})` };
765
+ }
766
+ const entries = raw.split(/\r?\n/).filter((line) => line.trim());
767
+ if (!entries.length) return { time: null, detail: "the HEAD reflog is empty" };
768
+ const identity = entries[entries.length - 1].split("\t")[0];
769
+ const words = identity.split(" ");
770
+ if (words.length < 3) return { time: null, detail: "the last HEAD reflog entry is not in reflog format" };
771
+ const epochText = words[words.length - 2];
772
+ if (!/^[+-]?\d+$/.test(epochText)) {
773
+ return { time: null, detail: "the last HEAD reflog entry carries no epoch timestamp" };
774
+ }
775
+ const epoch = BigInt(epochText);
776
+ if (epoch < BigInt(MIN_EPOCH_SECONDS) || epoch > BigInt(MAX_EPOCH_SECONDS)) {
777
+ return { time: null, detail: "the last HEAD reflog entry's timestamp is out of range" };
778
+ }
779
+ return { time: new Date(Number(epoch) * 1000), detail: "" };
780
+ }
781
+
782
+ /**
783
+ * Compare EVERY loaded artifact's generated_at against the HEAD reflog.
784
+ *
785
+ * Part of the readback's own projection layer. Deterministic: the result is a
786
+ * pure function of the artifact contents and two Git metadata files; no wall
787
+ * clock is consulted.
788
+ *
789
+ * Per-artifact by design (the v1 defect this port fixes). v1 compared only the
790
+ * newest loaded artifact, so one freshly regenerated artifact reported the
791
+ * whole set fresh while its siblings predated the same HEAD movement. Each
792
+ * artifact now carries its own verdict, and the aggregate `stale` is true when
793
+ * any of them is stale. An artifact whose `generated_at` this readback cannot
794
+ * parse is neither fresh nor stale: it stays out of the map, and if it is the
795
+ * only artifact the assessment is not computable.
796
+ *
797
+ * Two kinds of missing age are kept apart, because they say different things
798
+ * about the run. An artifact that RECORDED a `generated_at` this parser refuses
799
+ * has an age the readback failed to establish — `unparseable_keys` names it,
800
+ * `unparseable_details` describes the value's shape, and `computeClean` reads
801
+ * the list, because "unknown age" must never render as "not older than the
802
+ * checkout". An artifact with no `generated_at` key at all recorded no age to
803
+ * establish: it is simply absent from the comparison, as it has always been,
804
+ * and is not by itself unclean.
805
+ *
806
+ * `headMovement` overrides the Git read for a caller that already knows the
807
+ * answer — `--example` projects a packaged fixture directory, which is not a
808
+ * checkout and must say so identically wherever the package is installed.
809
+ */
810
+ export function assessStaleness(root, views, { headMovement = null } = {}) {
811
+ const artifactTimes = {};
812
+ // Comparison and ordering run on microseconds, never on the rendered Dates:
813
+ // two artifacts under a millisecond apart are two instants, not one.
814
+ const artifactMicros = {};
815
+ const loadedKeys = [];
816
+ const unparseableKeys = [];
817
+ const unparseableDetails = {};
818
+ for (const [key, view] of Object.entries(views)) {
819
+ if (view.state !== "loaded") continue;
820
+ loadedKeys.push(key);
821
+ const data = isPlainObject(view.data) ? view.data : {};
822
+ const parsed = parseIsoInstant(data.generated_at);
823
+ if (parsed !== null) {
824
+ artifactTimes[key] = parsed.date;
825
+ artifactMicros[key] = parsed.micros;
826
+ } else if ("generated_at" in data) {
827
+ // The key is present and its value did not parse: the artifact claims an
828
+ // age this readback could not read. Recorded by key rather than dropped,
829
+ // so the projection can say so instead of quietly comparing the rest.
830
+ unparseableKeys.push(key);
831
+ unparseableDetails[key] = describeUnparseableAge(data.generated_at);
832
+ }
833
+ }
834
+ const { time: headTime, detail: headDetail } = headMovement ?? readHeadMovement(root);
835
+ // The reflog records whole seconds, so the head instant is exact in
836
+ // milliseconds and scaling it loses nothing: this comparison is unchanged by
837
+ // the artifact side's added precision.
838
+ const headMicros = headTime === null ? null : BigInt(headTime.getTime()) * 1000n;
839
+ const keys = Object.keys(artifactTimes);
840
+ const artifacts = {};
841
+ const staleKeys = [];
842
+ for (const key of keys) {
843
+ // Absence of the HEAD signal is never evidence of freshness, but it is not
844
+ // evidence of staleness either: with no comparison point nothing is stale.
845
+ const stale = headMicros !== null && headMicros > artifactMicros[key];
846
+ artifacts[key] = { generated_at: artifactTimes[key], stale };
847
+ if (stale) staleKeys.push(key);
848
+ }
849
+ let newestKey = null;
850
+ for (const key of keys) {
851
+ if (newestKey === null || artifactMicros[key] > artifactMicros[newestKey]) newestKey = key;
852
+ }
853
+ const computable = keys.length > 0 && headTime !== null;
854
+ return {
855
+ artifact_times: artifactTimes,
856
+ artifacts,
857
+ stale_keys: staleKeys,
858
+ // The loaded artifacts, and the ones whose recorded age did not parse, both
859
+ // in render order. `loaded_keys` is what the rendered counts are drawn from
860
+ // — a sentence about "every loaded artifact" must not count the comparable
861
+ // ones — and `unparseable_details` carries each refused value's shape for
862
+ // the artifact row that reports it. Neither is serialized: the JSON payload
863
+ // names the artifacts through `unparseable_keys` and their states through
864
+ // the artifact rows it already carries.
865
+ loaded_keys: loadedKeys,
866
+ unparseable_keys: unparseableKeys,
867
+ unparseable_details: unparseableDetails,
868
+ head_time: headTime,
869
+ head_detail: headDetail,
870
+ computable,
871
+ stale: computable && staleKeys.length > 0,
872
+ newest_key: computable ? newestKey : null,
873
+ };
874
+ }
875
+
876
+ /**
877
+ * Name the SHAPE of a `generated_at` this readback could not parse.
878
+ *
879
+ * The value itself is not rendered: a hand-edited or foreign artifact can carry
880
+ * an arbitrarily long string there, and the projection's line-bounded sections
881
+ * are not the place to reproduce it. The shape is enough for a reader to tell a
882
+ * mistyped timestamp from a value of the wrong type, and the artifact's own file
883
+ * is one read away for the rest.
884
+ */
885
+ function describeUnparseableAge(value) {
886
+ if (value === null) return "null";
887
+ if (Array.isArray(value)) return `an array of ${value.length} item(s)`;
888
+ if (typeof value === "object") return "an object";
889
+ if (typeof value === "string") {
890
+ return value.length
891
+ ? `a ${value.length}-character string that is not an ISO-8601 instant`
892
+ : "an empty string";
893
+ }
894
+ return `a ${typeof value}`;
895
+ }
896
+
897
+ /**
898
+ * Name the loaded artifacts whose recorded age did not parse.
899
+ *
900
+ * Rendered under both the computable and the not-computable branch: an artifact
901
+ * whose age was never established is the same fact either way, and it is the
902
+ * one the reader would otherwise have to infer from an artifact's absence from
903
+ * the sentences above.
904
+ */
905
+ function renderUnknownAges(staleness, lines) {
906
+ const keys = staleness.unparseable_keys;
907
+ if (!keys.length) return;
908
+ lines.push(" *** UNKNOWN ARTIFACT AGE ***");
909
+ lines.push(
910
+ ` ${keys.length} loaded artifact(s) recorded a generated_at this readback cannot read, so`,
911
+ );
912
+ lines.push(" nothing above shows whether they predate the checkout:");
913
+ for (const key of keys) {
914
+ lines.push(` ${ARTIFACT_TITLES[key]} (generated_at is ${staleness.unparseable_details[key]})`);
915
+ }
916
+ }
917
+
918
+ function renderStaleness(staleness, lines) {
919
+ if (!staleness) return;
920
+ lines.push(
921
+ "STALENESS [the readback's own projection layer: EACH loaded artifact's " +
922
+ "generated_at versus the checkout's HEAD reflog]",
923
+ );
924
+ // Loaded and compared are two counts, and the sentences below say which is
925
+ // which. Rendering the comparable count as the loaded one asserted something
926
+ // about artifacts the comparison never examined.
927
+ const loadedCount = staleness.loaded_keys.length;
928
+ const comparedCount = Object.keys(staleness.artifacts).length;
929
+ if (staleness.computable) {
930
+ const headText = formatUtc(staleness.head_time);
931
+ if (staleness.stale) {
932
+ lines.push(" *** STALE ARTIFACTS ***");
933
+ lines.push(
934
+ ` this checkout's HEAD last moved ${headText}; of ${loadedCount} loaded artifact(s), ` +
935
+ `${comparedCount} could be compared and ${staleness.stale_keys.length} predate it:`,
936
+ );
937
+ for (const key of staleness.stale_keys) {
938
+ lines.push(` ${ARTIFACT_TITLES[key]} (generated ${formatUtc(staleness.artifacts[key].generated_at)})`);
939
+ }
940
+ lines.push(" Every section below describes the repository as it was when the artifacts");
941
+ lines.push(" were generated, not necessarily as it is now; a new run must regenerate");
942
+ lines.push(" them before this view is current.");
943
+ } else {
944
+ lines.push(
945
+ ` of ${loadedCount} loaded artifact(s), ${comparedCount} could be compared, and none of ` +
946
+ `those is older than the last recorded HEAD movement (${headText}).`,
947
+ );
948
+ const newestKey = staleness.newest_key;
949
+ lines.push(
950
+ ` newest: ${ARTIFACT_TITLES[newestKey]}, generated ` +
951
+ `${formatUtc(staleness.artifacts[newestKey].generated_at)}.`,
952
+ );
953
+ }
954
+ } else {
955
+ const reasons = [];
956
+ if (staleness.head_time === null) reasons.push(staleness.head_detail);
957
+ if (!Object.keys(staleness.artifact_times).length) {
958
+ reasons.push("no loaded artifact carries a parseable generated_at");
959
+ }
960
+ lines.push(` not computable: ${reasons.join("; ")}.`);
961
+ lines.push(" Treat artifact age as unknown; check the artifacts' generated_at values");
962
+ lines.push(" against repository history before reading this view as current.");
963
+ }
964
+ renderUnknownAges(staleness, lines);
965
+ lines.push("");
966
+ }
967
+
968
+ /** Label one doctor warning code; part of the readback's own layer. */
969
+ export function classifyDoctorWarning(code) {
970
+ if (typeof code === "string" && CONTRACT_STATIC_CODE_PREFIXES.some((prefix) => code.startsWith(prefix))) {
971
+ return "contract-static";
972
+ }
973
+ return "repo-observed";
974
+ }
975
+
976
+ function isPlainObject(value) {
977
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
978
+ }
979
+
980
+ // A field an artifact did not record renders as a named absence rather than as
981
+ // the language's word for nothing: "status: undefined" reads like a defect in
982
+ // the readback, and the Python original's "status: None" read no better.
983
+ function recorded(value, fallback = "(not recorded)") {
984
+ return value === undefined || value === null ? fallback : value;
985
+ }
986
+
987
+ /** Load one artifact into a view object; never throws for file problems. */
988
+ export function loadArtifact(key, path, { maxBytes = MAX_ARTIFACT_BYTES } = {}) {
989
+ const view = { key, path: String(path), state: "loaded", detail: "", data: null };
990
+ let raw;
991
+ try {
992
+ raw = readBoundedText(view.path, maxBytes);
993
+ } catch (error) {
994
+ if (error?.code === "ENOENT") {
995
+ view.state = "absent";
996
+ return view;
997
+ }
998
+ view.state = "unreadable";
999
+ view.detail = `${error.name}: ${error.message}`;
1000
+ return view;
1001
+ }
1002
+ let data;
1003
+ try {
1004
+ data = JSON.parse(raw);
1005
+ } catch (error) {
1006
+ view.state = "unreadable";
1007
+ view.detail = `invalid JSON: ${error.message}`;
1008
+ return view;
1009
+ }
1010
+ if (!isPlainObject(data)) {
1011
+ view.state = "unrecognized";
1012
+ view.detail = "top-level JSON value is not an object";
1013
+ return view;
1014
+ }
1015
+
1016
+ const recognized = RECOGNIZED_SCHEMA_VERSIONS[key];
1017
+ if (recognized !== undefined) {
1018
+ const declared = data.schema_version;
1019
+ if (!recognized.includes(declared)) {
1020
+ view.state = "unrecognized";
1021
+ view.detail =
1022
+ `unrecognized schema_version ${JSON.stringify(declared ?? null)}; ` +
1023
+ `this readback projects ${recognized.join(" or ")}`;
1024
+ return view;
1025
+ }
1026
+ }
1027
+ if (key === "doctor" && !(typeof data.status === "string" && Array.isArray(data.warnings))) {
1028
+ view.state = "unrecognized";
1029
+ view.detail = "doctor output must carry a status string and a warnings list";
1030
+ return view;
1031
+ }
1032
+ if (key === "qa_verdict" && !(typeof data.disposition === "string" && Array.isArray(data.assertions))) {
1033
+ view.state = "unrecognized";
1034
+ view.detail = "QA verdict must carry a disposition and an assertions list";
1035
+ return view;
1036
+ }
1037
+
1038
+ view.data = data;
1039
+ return view;
1040
+ }
1041
+
1042
+ /**
1043
+ * Load every artifact path in the fixed render order.
1044
+ *
1045
+ * `preloaded` supplies views already loaded from those same paths — Build
1046
+ * Packet discovery reads its candidates, so the chosen one would otherwise be
1047
+ * read twice. A preloaded view is used only when it names the path this call
1048
+ * would have read; anything else is loaded here.
1049
+ */
1050
+ export function loadArtifacts(paths, preloaded = {}) {
1051
+ const views = {};
1052
+ for (const key of Object.keys(DEFAULT_RELATIVE_PATHS)) {
1053
+ if (!(key in paths)) continue;
1054
+ const path = String(paths[key]);
1055
+ const candidate = preloaded?.[key] ?? null;
1056
+ views[key] = candidate !== null && candidate.path === path ? candidate : loadArtifact(key, path);
1057
+ }
1058
+ return views;
1059
+ }
1060
+
1061
+ function artifactData(views, key) {
1062
+ const view = views[key];
1063
+ if (view === undefined || view.state !== "loaded") return null;
1064
+ return view.data;
1065
+ }
1066
+
1067
+ /**
1068
+ * Bucket the QA verdict's assertions by recorded status.
1069
+ *
1070
+ * `other` collects any status this readback does not project (it renders those
1071
+ * rows as written rather than reclassifying them). Non-object entries are
1072
+ * dropped: an assertion the readback cannot address by status is not an
1073
+ * assertion it can project. Returns empty buckets when no QA verdict loaded, so
1074
+ * every caller can treat "no verdict" as "no failures" without a separate
1075
+ * branch.
1076
+ */
1077
+ export function partitionAssertions(views) {
1078
+ const buckets = { fail: [], pass: [], skipped: [], other: [] };
1079
+ const verdict = artifactData(views, "qa_verdict");
1080
+ if (verdict === null) return buckets;
1081
+ for (const assertion of verdict.assertions ?? []) {
1082
+ if (!isPlainObject(assertion)) continue;
1083
+ const status = assertion.status;
1084
+ const bucket = status === "fail" || status === "pass" || status === "skipped" ? status : "other";
1085
+ buckets[bucket].push(assertion);
1086
+ }
1087
+ return buckets;
1088
+ }
1089
+
1090
+ /**
1091
+ * Group skipped verdict families by the failure that blocked them.
1092
+ *
1093
+ * Part of the readback's own projection layer: the grouping is derived from
1094
+ * each skipped assertion's recorded `blocked_by`, so a blocked verdict reads as
1095
+ * one cascade rather than a dozen independent skips. Returns
1096
+ * `{ blocked_by, families }` records in the order the blockers were first seen,
1097
+ * which is the order the text view renders.
1098
+ */
1099
+ export function computeSkipCascades(views) {
1100
+ const cascades = new Map();
1101
+ for (const assertion of partitionAssertions(views).skipped) {
1102
+ const evidence = assertion.evidence;
1103
+ const blocker = (isPlainObject(evidence) ? evidence.blocked_by : null) || "(no blocked_by recorded)";
1104
+ if (!cascades.has(blocker)) cascades.set(blocker, []);
1105
+ cascades.get(blocker).push(assertion.family || assertion.id || "(unnamed)");
1106
+ }
1107
+ return [...cascades].map(([blocked_by, families]) => ({ blocked_by, families }));
1108
+ }
1109
+
1110
+ /**
1111
+ * Find where two artifacts record different states for one stage.
1112
+ *
1113
+ * Part of the readback's own projection layer, and deliberately not an
1114
+ * adjudication: a divergence says the assembly report calls a stage completed
1115
+ * while the QA verdict fails an assertion named for that stage. Returns
1116
+ * `{ stage, assertion_ids }` records, one per stage, in the order the failures
1117
+ * were seen.
1118
+ */
1119
+ export function computeDivergences(views) {
1120
+ const report = artifactData(views, "report");
1121
+ const failed = partitionAssertions(views).fail;
1122
+ if (report === null || !failed.length) return [];
1123
+ const stages = report.stages;
1124
+ if (!isPlainObject(stages)) return [];
1125
+ const divergent = new Map();
1126
+ for (const assertion of failed) {
1127
+ const identifier = assertion.id;
1128
+ if (typeof identifier !== "string" || !identifier.includes(".")) continue;
1129
+ const stageName = identifier.slice(0, identifier.indexOf("."));
1130
+ const stage = stages[stageName];
1131
+ if (isPlainObject(stage) && stage.status === "completed") {
1132
+ if (!divergent.has(stageName)) divergent.set(stageName, []);
1133
+ divergent.get(stageName).push(identifier);
1134
+ }
1135
+ }
1136
+ return [...divergent].map(([stage, assertion_ids]) => ({ stage, assertion_ids }));
1137
+ }
1138
+
1139
+ /**
1140
+ * Say how the projected Build Packet was chosen, when that is not obvious.
1141
+ *
1142
+ * Silent for the ordinary target — one packet, at the default name, nothing
1143
+ * ignored — and explicit whenever discovery had more than one file in front of
1144
+ * it, so an operator can see which packet this view describes.
1145
+ */
1146
+ function renderPacketSelection(selection, lines) {
1147
+ if (!selection) return;
1148
+ const indent = ` ${"".padEnd(16)} `;
1149
+ const candidates = selection.candidates_considered;
1150
+ if (candidates.length > 1) {
1151
+ lines.push(
1152
+ `${indent}chose ${selection.selected} by generated_at from ${candidates.length} ` +
1153
+ `root-level Build Packet candidate(s): ${candidates.join(", ")}`,
1154
+ );
1155
+ }
1156
+ for (const entry of selection.rejected) {
1157
+ lines.push(`${indent}ignored candidate ${entry.path}: ${entry.reason}`);
1158
+ }
1159
+ }
1160
+
1161
+ function renderArtifactTable(views, lines, packetSelection = null) {
1162
+ lines.push("ARTIFACTS");
1163
+ for (const [key, view] of Object.entries(views)) {
1164
+ const title = ARTIFACT_TITLES[key];
1165
+ let status;
1166
+ if (view.state === "loaded") {
1167
+ const generated = (view.data || {}).generated_at;
1168
+ status = typeof generated === "string" && generated ? `loaded — generated_at ${generated}` : "loaded";
1169
+ } else if (view.state === "absent") {
1170
+ status = "absent";
1171
+ } else {
1172
+ status = `${view.state} — ${view.detail}`;
1173
+ }
1174
+ lines.push(` ${title.padEnd(16)} ${view.path}`);
1175
+ lines.push(` ${"".padEnd(16)} ${status}`);
1176
+ if (key === "packet") renderPacketSelection(packetSelection, lines);
1177
+ }
1178
+ lines.push("");
1179
+ }
1180
+
1181
+ function renderIdentity(views, lines) {
1182
+ const entries = [];
1183
+ const packet = artifactData(views, "packet");
1184
+ const doctor = artifactData(views, "doctor");
1185
+ const verdict = artifactData(views, "qa_verdict");
1186
+ if (packet) {
1187
+ const spec = packet.spec || {};
1188
+ const campaign = packet.campaign || {};
1189
+ if (spec.map_id) entries.push(["map_id", spec.map_id, "build packet"]);
1190
+ if (campaign.public_route_slug) {
1191
+ entries.push(["public_route_slug", campaign.public_route_slug, "build packet"]);
1192
+ }
1193
+ const assembly = packet.assembly || {};
1194
+ if (assembly.template_family) entries.push(["template_family", assembly.template_family, "build packet"]);
1195
+ } else if (doctor) {
1196
+ const derived = doctor.derived || {};
1197
+ for (const field of ["map_id", "public_route_slug", "template_family"]) {
1198
+ if (derived[field]) entries.push([field, derived[field], "doctor output"]);
1199
+ }
1200
+ }
1201
+ if (verdict && verdict.run_id) entries.push(["qa run_id", verdict.run_id, "QA verdict"]);
1202
+ if (!entries.length) return;
1203
+ lines.push("RUN IDENTITY");
1204
+ for (const [field, value, source] of entries) lines.push(` ${field} = ${value} [${source}]`);
1205
+ lines.push("");
1206
+ }
1207
+
1208
+ /** A blocker field is usable when it is a non-empty, non-blank string. */
1209
+ function usableText(value) {
1210
+ return typeof value === "string" && value.trim() ? value : null;
1211
+ }
1212
+
1213
+ /** Collapse embedded CR/LF so one blocker stays one rendered line. */
1214
+ function oneLine(value) {
1215
+ return value
1216
+ .split(/\r\n|\r|\n/)
1217
+ .filter((part) => part.trim())
1218
+ .join(" ");
1219
+ }
1220
+
1221
+ /**
1222
+ * Render one stage blocker as one rendered entry.
1223
+ *
1224
+ * Campaigns OS assembly reports emit blockers as plain strings or as objects
1225
+ * carrying code/message/stage/page_id/detail. Known-schema text renders in full
1226
+ * with embedded CR/LF collapsed, so an object blocker is always one line
1227
+ * (BLOCKER_RENDER_CAP is the volume bound); only unknown shapes fall back to a
1228
+ * bounded JSON rendering, and that fallback never dumps an arbitrarily large
1229
+ * object into normal output.
1230
+ *
1231
+ * A plain string blocker is returned verbatim per the rendering contract, so a
1232
+ * string that already carries newlines still spans several lines.
1233
+ */
1234
+ export function renderBlocker(blocker) {
1235
+ if (typeof blocker === "string") return blocker;
1236
+ if (isPlainObject(blocker)) {
1237
+ const text = usableText(blocker.message) ?? usableText(blocker.detail);
1238
+ if (text !== null) {
1239
+ const parts = [];
1240
+ const code = usableText(blocker.code);
1241
+ if (code !== null) parts.push(`[${oneLine(code)}]`);
1242
+ parts.push(oneLine(text));
1243
+ const identifiers = [];
1244
+ for (const field of BLOCKER_IDENTIFIER_FIELDS) {
1245
+ const value = usableText(blocker[field]);
1246
+ if (value !== null) identifiers.push(`${field}=${oneLine(value)}`);
1247
+ }
1248
+ if (identifiers.length) parts.push(`(${identifiers.join(", ")})`);
1249
+ return parts.join(" ");
1250
+ }
1251
+ }
1252
+ let rendered;
1253
+ try {
1254
+ rendered = JSON.stringify(blocker);
1255
+ } catch {
1256
+ rendered = undefined;
1257
+ }
1258
+ if (rendered === undefined) rendered = String(blocker);
1259
+ if (rendered.length > NON_STRING_BLOCKER_RENDER_CAP) {
1260
+ rendered = `${rendered.slice(0, NON_STRING_BLOCKER_RENDER_CAP)}... (truncated)`;
1261
+ }
1262
+ return `(non-string entry, shown as written) ${rendered}`;
1263
+ }
1264
+
1265
+ function renderStages(views, lines) {
1266
+ const report = artifactData(views, "report");
1267
+ if (report === null) return;
1268
+ lines.push(`STAGES [assembly report; report status: ${recorded(report.status)}]`);
1269
+ const stages = report.stages;
1270
+ if (isPlainObject(stages)) {
1271
+ for (const [name, stage] of Object.entries(stages)) {
1272
+ if (!isPlainObject(stage)) continue;
1273
+ const status = stage.status ?? "(no status recorded)";
1274
+ const counters = [];
1275
+ const blockers = stage.blockers;
1276
+ const warnings = stage.warnings;
1277
+ if (Array.isArray(blockers) && blockers.length) counters.push(`${blockers.length} blocker(s)`);
1278
+ if (Array.isArray(warnings) && warnings.length) counters.push(`${warnings.length} warning(s)`);
1279
+ const suffix = counters.length ? ` (${counters.join(", ")})` : "";
1280
+ lines.push(` ${name.padEnd(14)} ${status}${suffix}`);
1281
+ if (Array.isArray(blockers) && blockers.length) {
1282
+ for (const blocker of blockers.slice(0, BLOCKER_RENDER_CAP)) {
1283
+ lines.push(` blocker: ${renderBlocker(blocker)}`);
1284
+ }
1285
+ const hidden = blockers.length - BLOCKER_RENDER_CAP;
1286
+ if (hidden > 0) lines.push(` ... and ${hidden} more blocker(s) recorded in the assembly report`);
1287
+ }
1288
+ }
1289
+ }
1290
+ lines.push("");
1291
+ }
1292
+
1293
+ function renderContext(views, lines) {
1294
+ const context = artifactData(views, "context");
1295
+ if (context === null) return;
1296
+ lines.push(
1297
+ `BUILD CONTEXT [build context; source adapter: ${recorded(context.source_adapter)}, ` +
1298
+ `status: ${recorded(context.status)}]`,
1299
+ );
1300
+ const prompts = context.prompts_required;
1301
+ if (Array.isArray(prompts) && prompts.length) {
1302
+ lines.push(` prompts recorded as required before first-shot assembly: ${prompts.length}`);
1303
+ }
1304
+ const brief = context.build_brief;
1305
+ if (isPlainObject(brief) && brief.status) {
1306
+ lines.push(
1307
+ ` build brief status: ${brief.status} (${brief.question_count ?? 0} question(s), ` +
1308
+ `${brief.gate_count ?? 0} gate(s))`,
1309
+ );
1310
+ }
1311
+ lines.push("");
1312
+ }
1313
+
1314
+ function doctorWarningGroups(doctor) {
1315
+ const groups = { "contract-static": [], "repo-observed": [] };
1316
+ for (const warning of doctor.warnings ?? []) {
1317
+ if (!isPlainObject(warning)) continue;
1318
+ groups[classifyDoctorWarning(warning.code)].push(warning);
1319
+ }
1320
+ return groups;
1321
+ }
1322
+
1323
+ /**
1324
+ * Summarize the doctor output: status, counts, and warning grouping.
1325
+ *
1326
+ * `present` is false when no doctor output loaded, and the counts are then zero
1327
+ * — the readback reports what it can see, and an absent doctor output is an
1328
+ * absence, not an observation of zero errors. Warnings are carried through as
1329
+ * doctor recorded them; only the two-way grouping is added.
1330
+ */
1331
+ export function computeDoctorSummary(views) {
1332
+ const doctor = artifactData(views, "doctor");
1333
+ if (doctor === null) {
1334
+ return {
1335
+ present: false,
1336
+ status: null,
1337
+ error_count: 0,
1338
+ warning_count: 0,
1339
+ warning_groups: { "contract-static": [], "repo-observed": [] },
1340
+ };
1341
+ }
1342
+ const groups = doctorWarningGroups(doctor);
1343
+ const errors = doctor.errors;
1344
+ return {
1345
+ present: true,
1346
+ status: doctor.status ?? null,
1347
+ error_count: Array.isArray(errors) ? errors.length : 0,
1348
+ warning_count: groups["contract-static"].length + groups["repo-observed"].length,
1349
+ warning_groups: groups,
1350
+ };
1351
+ }
1352
+
1353
+ function renderDoctor(views, lines) {
1354
+ const doctor = artifactData(views, "doctor");
1355
+ if (doctor === null) return;
1356
+ lines.push(`DOCTOR [doctor output; status: ${doctor.status}]`);
1357
+ const errors = doctor.errors;
1358
+ if (Array.isArray(errors) && errors.length) {
1359
+ lines.push(` errors (${errors.length}):`);
1360
+ for (const error of errors) {
1361
+ if (isPlainObject(error)) {
1362
+ lines.push(` ${error.code ?? "(no code)"} — ${error.message ?? "(no message)"}`);
1363
+ }
1364
+ }
1365
+ }
1366
+ const groups = doctorWarningGroups(doctor);
1367
+ const total = groups["contract-static"].length + groups["repo-observed"].length;
1368
+ lines.push(
1369
+ ` warnings (${total}) — the contract-static / repo-observed labels are the ` +
1370
+ "readback's own projection layer, not doctor's",
1371
+ );
1372
+ for (const [label, note] of [
1373
+ ["contract-static", CONTRACT_STATIC_LABEL_NOTE],
1374
+ ["repo-observed", REPO_OBSERVED_LABEL_NOTE],
1375
+ ]) {
1376
+ const group = groups[label];
1377
+ if (!group.length) continue;
1378
+ lines.push(` ${label} (${group.length}) — ${note}`);
1379
+ for (const warning of group) {
1380
+ lines.push(` ${warning.code ?? "(no code)"} — ${warning.message ?? "(no message)"}`);
1381
+ }
1382
+ }
1383
+ const nextState = doctor.next;
1384
+ if (isPlainObject(nextState)) {
1385
+ const blocked = nextState.blocked_stages;
1386
+ const blockedText = Array.isArray(blocked) && blocked.length ? `; blocked stages: ${blocked.join(", ")}` : "";
1387
+ // The status parenthetical is dropped rather than filled with a placeholder
1388
+ // when doctor recorded no status: "(not recorded)" inside parentheses reads
1389
+ // as a rendering fault, and the absence is already visible without it.
1390
+ const statusText = nextState.status === undefined || nextState.status === null ? "" : ` (${nextState.status})`;
1391
+ lines.push(` doctor's recorded next stage: ${recorded(nextState.stage)}${statusText}${blockedText}`);
1392
+ }
1393
+ lines.push("");
1394
+ }
1395
+
1396
+ /** Show, without adjudicating, where two artifacts record different states. */
1397
+ function renderDivergences(views, lines) {
1398
+ const divergences = computeDivergences(views);
1399
+ if (!divergences.length) return;
1400
+ lines.push(" cross-artifact divergence (readback's own layer):");
1401
+ for (const divergence of divergences) {
1402
+ lines.push(
1403
+ ` the assembly report records stage '${divergence.stage}' as completed, while the ` +
1404
+ `QA verdict fails ${divergence.assertion_ids.join(", ")}; both records are shown as ` +
1405
+ "written — the readback does not adjudicate between artifacts",
1406
+ );
1407
+ }
1408
+ }
1409
+
1410
+ function renderVerdict(views, lines) {
1411
+ const verdict = artifactData(views, "qa_verdict");
1412
+ if (verdict === null) return;
1413
+ lines.push(
1414
+ `QA VERDICT [QA verdict; disposition: ${verdict.disposition} — Campaigns OS is the verdict authority]`,
1415
+ );
1416
+ const { fail: failed, pass: passed, skipped, other } = partitionAssertions(views);
1417
+ let countLine = ` assertions: ${failed.length} fail, ${passed.length} pass, ${skipped.length} skipped`;
1418
+ if (other.length) countLine += `, ${other.length} unrecognized status`;
1419
+ lines.push(countLine);
1420
+ for (const assertion of failed) {
1421
+ const severity = assertion.severity;
1422
+ const severityText = severity ? `, severity ${severity}` : "";
1423
+ lines.push(
1424
+ ` fail ${recorded(assertion.id, "(no id)")} ` +
1425
+ `(family ${recorded(assertion.family, "(no family)")}${severityText})`,
1426
+ );
1427
+ const actual = assertion.actual;
1428
+ if (actual) lines.push(` recorded by Campaigns OS: ${actual}`);
1429
+ const evidence = assertion.evidence;
1430
+ const problems = isPlainObject(evidence) ? evidence.problems : null;
1431
+ if (Array.isArray(problems) && problems.length) {
1432
+ lines.push(` recorded problems (${problems.length}):`);
1433
+ for (const problem of problems) lines.push(` - ${problem}`);
1434
+ }
1435
+ }
1436
+ for (const assertion of passed) {
1437
+ const family = assertion.family || assertion.id || "(no family)";
1438
+ lines.push(` pass ${recorded(assertion.id, "(no id)")} (family ${family})`);
1439
+ }
1440
+ for (const assertion of other) {
1441
+ lines.push(
1442
+ ` unrecognized status ${JSON.stringify(assertion.status ?? null)} ${recorded(assertion.id, "(no id)")} ` +
1443
+ `(family ${assertion.family || "(no family)"}) — shown as written; this readback ` +
1444
+ "projects fail, pass, and skipped statuses",
1445
+ );
1446
+ }
1447
+
1448
+ if (skipped.length) {
1449
+ lines.push(
1450
+ " skip provenance — the cascade grouping below is the readback's own " +
1451
+ "projection layer, derived from each skipped assertion's blocked_by field:",
1452
+ );
1453
+ for (const cascade of computeSkipCascades(views)) {
1454
+ const families = cascade.families;
1455
+ lines.push(
1456
+ ` ${cascade.blocked_by} -> ${families.length} skipped ` +
1457
+ `famil${families.length === 1 ? "y" : "ies"}:`,
1458
+ );
1459
+ for (const family of families) lines.push(` - ${family}`);
1460
+ }
1461
+ }
1462
+ renderDivergences(views, lines);
1463
+ lines.push("");
1464
+ }
1465
+
1466
+ function renderFindings(views, lines) {
1467
+ const findingsExport = artifactData(views, "findings");
1468
+ if (findingsExport === null) return;
1469
+ const findings = (findingsExport.findings ?? []).filter((finding) => isPlainObject(finding));
1470
+ lines.push(`FINDINGS [findings export; ${findings.length} finding(s)]`);
1471
+ for (const finding of findings) {
1472
+ lines.push(
1473
+ ` ${finding.stage ?? "(no stage)"} / ${finding.kind ?? "(no kind)"} — ` +
1474
+ `${finding.summary ?? "(no summary)"}`,
1475
+ );
1476
+ }
1477
+ lines.push("");
1478
+ }
1479
+
1480
+ /**
1481
+ * Render the one-view projection; pure function of its arguments.
1482
+ *
1483
+ * `staleness` is the optional result of assessStaleness; when omitted the
1484
+ * projection carries no staleness section, and the CLI always supplies one.
1485
+ * `packetSelection` is the optional selectPacketPath record; when omitted the
1486
+ * artifact table says nothing about how the packet was chosen. The
1487
+ * one-argument form keeps working: the artifact table shows each loaded
1488
+ * artifact's generated_at regardless of whether an assessment was supplied.
1489
+ */
1490
+ export function projectReadback(views, staleness = null, packetSelection = null) {
1491
+ const lines = [
1492
+ "CAMPAIGNS OS RUN-ARTIFACT READBACK",
1493
+ "A read-only projection of this run's emitted artifacts. Campaigns OS",
1494
+ "remains the lifecycle and verdict authority; content marked as the",
1495
+ "readback's own projection layer is interpretation added by this view,",
1496
+ "not by Campaigns OS. This readback proposes no remediation.",
1497
+ "",
1498
+ ];
1499
+ renderStaleness(staleness, lines);
1500
+ renderArtifactTable(views, lines, packetSelection);
1501
+ renderIdentity(views, lines);
1502
+ renderStages(views, lines);
1503
+ renderContext(views, lines);
1504
+ renderDoctor(views, lines);
1505
+ renderVerdict(views, lines);
1506
+ renderFindings(views, lines);
1507
+ if (Object.values(views).every((view) => view.state === "absent")) {
1508
+ lines.push(
1509
+ "No run artifacts were found at the projected paths. Either no run has " +
1510
+ "emitted artifacts here yet, or this is not a target campaign repository root.",
1511
+ );
1512
+ lines.push("");
1513
+ }
1514
+ return `${lines.join("\n").replace(/\n+$/, "")}\n`;
1515
+ }
1516
+
1517
+ /**
1518
+ * Render one assessStaleness result as JSON-safe values.
1519
+ *
1520
+ * Instants become ISO-8601 UTC strings through the same formatter the text view
1521
+ * uses, so both modes name the same instants the same way.
1522
+ */
1523
+ export function serializeStaleness(staleness) {
1524
+ if (!staleness) return null;
1525
+ const headTime = staleness.head_time;
1526
+ const artifacts = {};
1527
+ for (const [key, entry] of Object.entries(staleness.artifacts)) {
1528
+ artifacts[key] = { generated_at: formatUtc(entry.generated_at), stale: entry.stale };
1529
+ }
1530
+ const artifactTimes = {};
1531
+ for (const [key, value] of Object.entries(staleness.artifact_times)) artifactTimes[key] = formatUtc(value);
1532
+ return {
1533
+ computable: staleness.computable,
1534
+ stale: staleness.stale,
1535
+ stale_keys: [...staleness.stale_keys],
1536
+ unparseable_keys: [...staleness.unparseable_keys],
1537
+ artifacts,
1538
+ newest_key: staleness.newest_key,
1539
+ head_time: headTime === null ? null : formatUtc(headTime),
1540
+ head_detail: staleness.head_detail,
1541
+ artifact_times: artifactTimes,
1542
+ };
1543
+ }
1544
+
1545
+ /**
1546
+ * Whether this projection shows nothing the readback can call wrong.
1547
+ *
1548
+ * True only when all five conditions hold: every artifact the readback found is
1549
+ * loaded and recognized (absent artifacts are not counted against it — a run
1550
+ * that emitted no findings export is not thereby unclean, while an unreadable
1551
+ * or unrecognized one always is); the staleness comparison is computable and NO
1552
+ * loaded artifact is stale; NO loaded artifact recorded a `generated_at` this
1553
+ * readback could not parse; no cross-artifact divergence was found; and the
1554
+ * doctor output records zero errors.
1555
+ *
1556
+ * The fourth of those is the unknown-age rule: an artifact whose recorded age
1557
+ * did not parse was never shown to be current, and `clean` states that the
1558
+ * readback CAN show every artifact is at least as new as the checkout. Without
1559
+ * it a fresh sibling carried the aggregate and an artifact of unestablished age
1560
+ * shipped inside a `clean: true` payload. An artifact that recorded no
1561
+ * `generated_at` at all is a different case and does not make the projection
1562
+ * unclean on its own — there is no claim about its age to fail to check — though
1563
+ * with no other artifact carrying one the comparison is not computable and
1564
+ * condition 2 fails anyway.
1565
+ *
1566
+ * This is a readback-integrity flag, not a verdict. Campaigns OS remains the
1567
+ * verdict authority: a QA verdict of `blocked` whose artifacts all read cleanly
1568
+ * is still `clean: true` here, because the readback saw exactly what Campaigns
1569
+ * OS recorded. docs/readback.md states the rule and its limits for callers
1570
+ * gating on it.
1571
+ */
1572
+ export function computeClean(views, staleness) {
1573
+ if (Object.values(views).some((view) => !CLEAN_ARTIFACT_STATES.has(view.state))) return false;
1574
+ if (!staleness || !staleness.computable || staleness.stale) return false;
1575
+ if (staleness.unparseable_keys.length) return false;
1576
+ if (computeDivergences(views).length) return false;
1577
+ return computeDoctorSummary(views).error_count === 0;
1578
+ }
1579
+
1580
+ /**
1581
+ * The `detail` an artifact row carries.
1582
+ *
1583
+ * A non-loaded state carries the readback's own explanation for it, as it
1584
+ * always has. A LOADED artifact whose recorded `generated_at` did not parse
1585
+ * carries the shape of that value: its row is the one place a consumer reading
1586
+ * artifacts alone would otherwise see nothing at all about an age the readback
1587
+ * failed to establish (`staleness.unparseable_keys` names it too, and `clean`
1588
+ * is false either way). Needs the assessment, so a payload built without one
1589
+ * leaves the row as the load left it.
1590
+ */
1591
+ function artifactRowDetail(view, staleness) {
1592
+ const shape = view.state === "loaded" ? staleness?.unparseable_details?.[view.key] : null;
1593
+ if (!shape) return view.detail;
1594
+ return `generated_at is ${shape}, so this artifact's age could not be compared against the checkout`;
1595
+ }
1596
+
1597
+ /**
1598
+ * Build the campaigns-os-readback/v2 object; pure function.
1599
+ *
1600
+ * Serializes what the text projection computes and interprets nothing further.
1601
+ * Artifact rows carry the state the readback assigned and never the artifact's
1602
+ * own `data` payload: a caller who wants an artifact's contents should read
1603
+ * that artifact.
1604
+ *
1605
+ * `packet_selection` and `staleness` are `null` when a programmatic caller
1606
+ * builds a payload without them; the CLI always supplies both.
1607
+ */
1608
+ export function buildJsonPayload(views, staleness = null, packetSelection = null) {
1609
+ return {
1610
+ schema_version: JSON_SCHEMA_VERSION,
1611
+ artifacts: Object.values(views).map((view) => ({
1612
+ key: view.key,
1613
+ path: view.path,
1614
+ state: view.state,
1615
+ detail: artifactRowDetail(view, staleness),
1616
+ })),
1617
+ packet_selection: packetSelection,
1618
+ staleness: serializeStaleness(staleness),
1619
+ doctor: computeDoctorSummary(views),
1620
+ skip_cascades: computeSkipCascades(views),
1621
+ divergences: computeDivergences(views),
1622
+ clean: computeClean(views, staleness),
1623
+ };
1624
+ }
1625
+
1626
+ function isFilePath(path) {
1627
+ const info = statOrNull(path);
1628
+ return Boolean(info && info.isFile());
1629
+ }
1630
+
1631
+ /** Root-level Build Packet files, default first, then suffixed by name. */
1632
+ function packetCandidatePaths(rootPath) {
1633
+ const candidates = [];
1634
+ const defaultName = DEFAULT_RELATIVE_PATHS.packet;
1635
+ if (isFilePath(join(rootPath, defaultName))) candidates.push(defaultName);
1636
+ let names = [];
1637
+ try {
1638
+ names = readdirSync(rootPath);
1639
+ } catch {
1640
+ // An unreadable root has no candidates; resolveProjection has already
1641
+ // refused a root that is not a directory at all.
1642
+ names = [];
1643
+ }
1644
+ const suffixed = names.filter((name) => PACKET_CANDIDATE_PATTERN.test(name)).sort();
1645
+ for (const name of suffixed) if (isFilePath(join(rootPath, name))) candidates.push(name);
1646
+ return candidates;
1647
+ }
1648
+
1649
+ /**
1650
+ * Choose the Build Packet to project, and record how it was chosen.
1651
+ *
1652
+ * Returns `{ path, selection, view }`: the packet to project, how it was
1653
+ * chosen, and the already-loaded view of it when discovery read it, so the
1654
+ * caller does not read the same packet a second time (`view` is null for an
1655
+ * explicit `--packet` and for a target with no valid candidate, which discovery
1656
+ * never read).
1657
+ *
1658
+ * `selection` is the record the text and JSON views both publish: `mode` is
1659
+ * `explicit` when the caller passed `--packet`, `default` when discovery landed
1660
+ * on the fixed default name (including the no-candidate case, where the default
1661
+ * path is still what the readback reports as absent), and `discovered` when
1662
+ * freshness picked a packet out of several. `signal` names what decided it —
1663
+ * `explicit`, `sole_candidate`, `generated_at`, or `none` when nothing was
1664
+ * there to choose between.
1665
+ *
1666
+ * Freshness is the packet's own recorded `generated_at`, never the file's
1667
+ * modification time: mtimes are rewritten by clones, checkouts, and copies
1668
+ * without any run having recorded anything, while `generated_at` is what the
1669
+ * emitting run wrote down. The consequence is that selection stays a pure
1670
+ * function of file contents, so two callers reading the same packets always
1671
+ * select the same one.
1672
+ *
1673
+ * Throws ReadbackUsageError when several valid candidates exist and
1674
+ * `generated_at` does not single one out — a tie, or any candidate missing a
1675
+ * parseable value. A stale packet chosen silently is the failure this refusal
1676
+ * exists to prevent, so the caller is told to pass `--packet`.
1677
+ */
1678
+ export function selectPacketPath(rootPath, override) {
1679
+ if (override) {
1680
+ return {
1681
+ path: String(override),
1682
+ selection: {
1683
+ mode: "explicit",
1684
+ signal: "explicit",
1685
+ candidates_considered: [],
1686
+ rejected: [],
1687
+ selected: null,
1688
+ },
1689
+ view: null,
1690
+ };
1691
+ }
1692
+
1693
+ const considered = [];
1694
+ const rejected = [];
1695
+ const views = new Map();
1696
+ const times = new Map();
1697
+ for (const name of packetCandidatePaths(rootPath)) {
1698
+ const view = loadArtifact("packet", join(rootPath, name));
1699
+ if (view.state !== "loaded") {
1700
+ rejected.push({ path: name, reason: view.detail });
1701
+ continue;
1702
+ }
1703
+ considered.push(name);
1704
+ views.set(name, view);
1705
+ times.set(name, parseIsoInstant((view.data || {}).generated_at));
1706
+ }
1707
+
1708
+ if (!considered.length) {
1709
+ if (isFilePath(join(rootPath, SIDECAR_PACKET_RELATIVE))) {
1710
+ rejected.push({
1711
+ path: SIDECAR_PACKET_RELATIVE,
1712
+ reason:
1713
+ "file present at sidecar location; contracted packet home is the " +
1714
+ "repository root. Pass --packet to project it.",
1715
+ });
1716
+ }
1717
+ return {
1718
+ path: join(rootPath, DEFAULT_RELATIVE_PATHS.packet),
1719
+ selection: { mode: "default", signal: "none", candidates_considered: [], rejected, selected: null },
1720
+ view: null,
1721
+ };
1722
+ }
1723
+
1724
+ const chose = (name, signal) => ({
1725
+ path: join(rootPath, name),
1726
+ selection: {
1727
+ mode: name === DEFAULT_RELATIVE_PATHS.packet ? "default" : "discovered",
1728
+ signal,
1729
+ candidates_considered: [...considered],
1730
+ rejected,
1731
+ selected: name,
1732
+ },
1733
+ view: views.get(name),
1734
+ });
1735
+
1736
+ if (considered.length === 1) return chose(considered[0], "sole_candidate");
1737
+
1738
+ if (considered.some((name) => times.get(name) === null)) {
1739
+ const detail = considered
1740
+ .map((name) => {
1741
+ const time = times.get(name);
1742
+ return `${name} ${time === null ? "(no parseable generated_at)" : `(generated_at ${formatUtc(time.date)})`}`;
1743
+ })
1744
+ .join(", ");
1745
+ throw new ReadbackUsageError(
1746
+ "one or more Build Packets at the target root carry no parseable generated_at, " +
1747
+ `so freshness cannot single out a packet: ${detail}. ` +
1748
+ "Pass --packet to name the Build Packet to project.",
1749
+ );
1750
+ }
1751
+
1752
+ // Freshness is compared in microseconds: a tie here means the packets record
1753
+ // the same instant to the microsecond, not merely the same millisecond.
1754
+ let newest = times.get(considered[0]).micros;
1755
+ for (const name of considered) {
1756
+ if (times.get(name).micros > newest) newest = times.get(name).micros;
1757
+ }
1758
+ const freshest = considered.filter((name) => times.get(name).micros === newest);
1759
+ if (freshest.length > 1) {
1760
+ throw new ReadbackUsageError(
1761
+ `several Build Packets at the target root share the newest generated_at ` +
1762
+ `(${formatUtc(times.get(freshest[0]).date)}): ${freshest.join(", ")}. ` +
1763
+ "Pass --packet to name the Build Packet to project.",
1764
+ );
1765
+ }
1766
+ return chose(freshest[0], "generated_at");
1767
+ }
1768
+
1769
+ /**
1770
+ * Return `{ paths, packetSelection, packetView }` for one target root.
1771
+ *
1772
+ * `packetView` is the Build Packet view discovery already loaded, ready to hand
1773
+ * to loadArtifacts as `preloaded` so no artifact is read twice; it is null when
1774
+ * discovery read no packet.
1775
+ */
1776
+ export function resolveProjection(root, overrides = {}) {
1777
+ const info = statOrNull(root);
1778
+ if (!info || !info.isDirectory()) {
1779
+ throw new ReadbackUsageError(`target repository root must name an existing directory: ${root}`);
1780
+ }
1781
+ const { path: packetPath, selection, view } = selectPacketPath(root, overrides.packet);
1782
+ const paths = { packet: packetPath };
1783
+ for (const [key, relative] of Object.entries(DEFAULT_RELATIVE_PATHS)) {
1784
+ if (key === "packet") continue;
1785
+ paths[key] = overrides[key] ? String(overrides[key]) : join(root, relative);
1786
+ }
1787
+ return { paths, packetSelection: selection, packetView: view };
1788
+ }
1789
+
1790
+ /** The artifact paths to project; discovery result discarded. */
1791
+ export function resolvePaths(root, overrides = {}) {
1792
+ return resolveProjection(root, overrides).paths;
1793
+ }
1794
+
1795
+ /** Project one target root: load every artifact once, then assess freshness. */
1796
+ export function projectTarget(root, overrides = {}) {
1797
+ const { paths, packetSelection, packetView } = resolveProjection(root, overrides);
1798
+ const views = loadArtifacts(paths, { packet: packetView });
1799
+ return { views, staleness: assessStaleness(root, views), packetSelection };
1800
+ }
1801
+
1802
+ // The bundled synthetic sample `--example` projects. It is already on the
1803
+ // supported surface as the sidecar-bundle conformance fixture, so the readback
1804
+ // reuses it rather than shipping a second copy of the same artifact set.
1805
+ export const EXAMPLE_RELATIVE_ROOT = "contracts/fixtures/sidecar-bundle/production-shaped";
1806
+ export const EXAMPLE_ROOT = fileURLToPath(
1807
+ new URL(`../${EXAMPLE_RELATIVE_ROOT}/`, import.meta.url),
1808
+ );
1809
+
1810
+ // The sample is a packaged fixture directory, not a Git checkout, so there is
1811
+ // no HEAD movement to compare its artifacts against. Stating that as a fixed
1812
+ // detail keeps `--example` identical wherever the package is installed: were
1813
+ // the sample's freshness read from the filesystem, the answer would depend on
1814
+ // whether the installing repository happens to be a checkout.
1815
+ export const EXAMPLE_HEAD_DETAIL =
1816
+ "the bundled sample is a packaged fixture directory, not a Git checkout: " +
1817
+ "freshness is not computable for it by design";
1818
+
1819
+ /**
1820
+ * Project the bundled synthetic sample; reads only files inside the package.
1821
+ *
1822
+ * Artifact rows report the sample's paths relative to the package root rather
1823
+ * than where the package happens to be installed. An absolute path would make
1824
+ * the sample's own output different on every machine, and the point of a
1825
+ * bundled sample is that everyone reading the docs sees what they ran.
1826
+ */
1827
+ export function projectExample() {
1828
+ const { paths, packetSelection, packetView } = resolveProjection(EXAMPLE_ROOT, {});
1829
+ const views = loadArtifacts(paths, { packet: packetView });
1830
+ for (const view of Object.values(views)) {
1831
+ const within = view.path.slice(EXAMPLE_ROOT.length).replace(/\\/g, "/").replace(/^\/+/, "");
1832
+ view.path = `${EXAMPLE_RELATIVE_ROOT}/${within}`;
1833
+ }
1834
+ const staleness = assessStaleness(EXAMPLE_ROOT, views, {
1835
+ headMovement: { time: null, detail: EXAMPLE_HEAD_DETAIL },
1836
+ });
1837
+ return { views, staleness, packetSelection };
1838
+ }
1839
+
1840
+ const OVERRIDE_FLAGS = {
1841
+ packet: "packet",
1842
+ doctor: "doctor",
1843
+ context: "context",
1844
+ report: "report",
1845
+ "qa-verdict": "qa_verdict",
1846
+ findings: "findings",
1847
+ };
1848
+
1849
+ // `--example <anything>` parses as a flag carrying a value, so the refusal has
1850
+ // to name the shape the caller probably meant: a target written after
1851
+ // `--example` is swallowed as its value and never reaches the positional list.
1852
+ const EXAMPLE_USAGE =
1853
+ "--example projects the bundled synthetic sample and takes no target or path override. " +
1854
+ "Use `campaigns-os readback --example [--json]`, or name a target without --example.";
1855
+
1856
+ function booleanFlag(args, flag, extra = "") {
1857
+ const value = args[flag];
1858
+ if (value === undefined) return false;
1859
+ if (value !== true) throw new ReadbackUsageError(`--${flag} is a boolean flag and takes no value.${extra}`);
1860
+ return true;
1861
+ }
1862
+
1863
+ /**
1864
+ * Validate one `campaigns-os readback` invocation into a projection request.
1865
+ *
1866
+ * Throws ReadbackUsageError for anything that cannot form a projection; the
1867
+ * dispatcher turns that into the exit-2 usage path.
1868
+ */
1869
+ export function readbackRequest(args) {
1870
+ // `--example` is settled before anything else is validated: where both
1871
+ // refusals apply, the one naming --example is the actionable one. In
1872
+ // `readback --example --json <target>` the target is swallowed as --json's
1873
+ // value by the same parser rule that swallows it after --example itself, and
1874
+ // "--json is a boolean flag and takes no value" sends the caller to fix the
1875
+ // wrong flag; the same goes for an override flag left without a value.
1876
+ const example = booleanFlag(args, "example", ` ${EXAMPLE_USAGE}`);
1877
+ const positionals = (args._ ?? []).slice(1);
1878
+ if (example) {
1879
+ const named = Object.keys(OVERRIDE_FLAGS).filter((flag) => args[flag] !== undefined);
1880
+ // A --json carrying a value is a target the parser ate, not a misused
1881
+ // boolean, so it is reported here with the value the caller wrote.
1882
+ const swallowed = args.json !== undefined && args.json !== true ? [`--json ${args.json}`] : [];
1883
+ if (positionals.length || named.length || swallowed.length) {
1884
+ throw new ReadbackUsageError(
1885
+ `${EXAMPLE_USAGE} Got ${[...positionals, ...named.map((flag) => `--${flag}`), ...swallowed].join(", ")}.`,
1886
+ );
1887
+ }
1888
+ return { example: true, json: booleanFlag(args, "json"), target: null, overrides: {} };
1889
+ }
1890
+ const json = booleanFlag(args, "json");
1891
+ const overrides = {};
1892
+ for (const [flag, key] of Object.entries(OVERRIDE_FLAGS)) {
1893
+ const value = args[flag];
1894
+ if (value === undefined) continue;
1895
+ if (typeof value !== "string" || !value.trim()) throw new ReadbackUsageError(`Missing value for --${flag}.`);
1896
+ overrides[key] = value;
1897
+ }
1898
+ if (positionals.length > 1) {
1899
+ throw new ReadbackUsageError(
1900
+ `readback projects one target repository root; got ${positionals.length}: ${positionals.join(", ")}.`,
1901
+ );
1902
+ }
1903
+ if (!positionals.length) {
1904
+ throw new ReadbackUsageError(
1905
+ "Use: campaigns-os readback <target-repo-root> [--json] [--packet <path>] " +
1906
+ "[--doctor <path>] [--context <path>] [--report <path>] [--qa-verdict <path>] " +
1907
+ "[--findings <path>], or campaigns-os readback --example [--json].",
1908
+ );
1909
+ }
1910
+ return { example: false, json, target: positionals[0], overrides };
1911
+ }
1912
+
1913
+ /**
1914
+ * Run one readback invocation and return what to print.
1915
+ *
1916
+ * Returns `{ exitCode, text }`: exit 0 with the projection for any target the
1917
+ * readback could form a view of — an unreadable artifact is a state it reports,
1918
+ * not an error — and exit 2 with a one-line reason for a caller request that
1919
+ * cannot form a projection at all.
1920
+ */
1921
+ export function runReadbackCommand(args) {
1922
+ let request;
1923
+ let projection;
1924
+ try {
1925
+ request = readbackRequest(args);
1926
+ projection = request.example ? projectExample() : projectTarget(request.target, request.overrides);
1927
+ } catch (error) {
1928
+ if (error instanceof ReadbackUsageError) return { exitCode: 2, text: `${error.message}\n` };
1929
+ throw error;
1930
+ }
1931
+ const { views, staleness, packetSelection } = projection;
1932
+ const text = request.json
1933
+ ? `${JSON.stringify(buildJsonPayload(views, staleness, packetSelection), null, 2)}\n`
1934
+ : projectReadback(views, staleness, packetSelection);
1935
+ return { exitCode: 0, text };
1936
+ }