@ai-dossier/cli 0.9.1 → 0.11.0

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 (50) hide show
  1. package/README.md +345 -0
  2. package/dist/cache-resolver.d.ts.map +1 -1
  3. package/dist/cache-resolver.js +4 -16
  4. package/dist/cache-resolver.js.map +1 -1
  5. package/dist/cli.js +2 -0
  6. package/dist/cli.js.map +1 -1
  7. package/dist/commands/cache.d.ts.map +1 -1
  8. package/dist/commands/cache.js +2 -14
  9. package/dist/commands/cache.js.map +1 -1
  10. package/dist/commands/keys.d.ts.map +1 -1
  11. package/dist/commands/keys.js +113 -28
  12. package/dist/commands/keys.js.map +1 -1
  13. package/dist/commands/run.d.ts.map +1 -1
  14. package/dist/commands/run.js +4 -3
  15. package/dist/commands/run.js.map +1 -1
  16. package/dist/commands/runstate.d.ts +12 -0
  17. package/dist/commands/runstate.d.ts.map +1 -0
  18. package/dist/commands/runstate.js +819 -0
  19. package/dist/commands/runstate.js.map +1 -0
  20. package/dist/commands/traces.d.ts.map +1 -1
  21. package/dist/commands/traces.js +6 -7
  22. package/dist/commands/traces.js.map +1 -1
  23. package/dist/duration.d.ts +29 -0
  24. package/dist/duration.d.ts.map +1 -0
  25. package/dist/duration.js +63 -0
  26. package/dist/duration.js.map +1 -0
  27. package/dist/helpers.d.ts +8 -0
  28. package/dist/helpers.d.ts.map +1 -1
  29. package/dist/helpers.js +17 -0
  30. package/dist/helpers.js.map +1 -1
  31. package/dist/issue-selection.d.ts +22 -0
  32. package/dist/issue-selection.d.ts.map +1 -0
  33. package/dist/issue-selection.js +88 -0
  34. package/dist/issue-selection.js.map +1 -0
  35. package/dist/runstate-stats.d.ts +157 -0
  36. package/dist/runstate-stats.d.ts.map +1 -0
  37. package/dist/runstate-stats.js +359 -0
  38. package/dist/runstate-stats.js.map +1 -0
  39. package/dist/runstate.d.ts +177 -0
  40. package/dist/runstate.d.ts.map +1 -0
  41. package/dist/runstate.js +438 -0
  42. package/dist/runstate.js.map +1 -0
  43. package/dist/table.d.ts +26 -0
  44. package/dist/table.d.ts.map +1 -0
  45. package/dist/table.js +49 -0
  46. package/dist/table.js.map +1 -0
  47. package/dist/verify-dossier.d.ts.map +1 -1
  48. package/dist/verify-dossier.js +43 -22
  49. package/dist/verify-dossier.js.map +1 -1
  50. package/package.json +1 -1
@@ -0,0 +1,819 @@
1
+ "use strict";
2
+ /**
3
+ * `ai-dossier runstate` — post, read, and verify `runstate:v1` milestones.
4
+ *
5
+ * Issue-workflow dossiers used to ask agents to hand-reproduce a markdown heredoc after
6
+ * every phase. These subcommands make the milestone a command instead of a template, so
7
+ * the format, the timestamp, and the per-phase required keys are enforced rather than
8
+ * hoped for.
9
+ */
10
+ var __importDefault = (this && this.__importDefault) || function (mod) {
11
+ return (mod && mod.__esModule) ? mod : { "default": mod };
12
+ };
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.registerRunstateCommand = registerRunstateCommand;
15
+ const node_child_process_1 = require("node:child_process");
16
+ const node_fs_1 = __importDefault(require("node:fs"));
17
+ const duration_1 = require("../duration");
18
+ const issue_selection_1 = require("../issue-selection");
19
+ const runstate_1 = require("../runstate");
20
+ const runstate_stats_1 = require("../runstate-stats");
21
+ const table_1 = require("../table");
22
+ /**
23
+ * Fail on stderr and exit 1, so a calling dossier can detect it.
24
+ *
25
+ * Each entry is one problem. An entry may span lines: the first gets the ❌, the rest are
26
+ * indented under it, which is how a cause line and its "Fix:" line stay visibly one item.
27
+ */
28
+ function fail(lines) {
29
+ for (const line of lines) {
30
+ const [first, ...rest] = line.split('\n');
31
+ console.error(`❌ ${first}`);
32
+ for (const cont of rest)
33
+ console.error(` ${cont}`);
34
+ }
35
+ process.exit(1);
36
+ }
37
+ /**
38
+ * Run a command, keeping WHY it failed.
39
+ *
40
+ * The whole point of this subcommand is that agents were silently getting things wrong,
41
+ * so a discarded stderr is the one thing we cannot afford: "gh is not installed", "gh is
42
+ * not logged in", and "issue 440 does not exist" need three different fixes and are
43
+ * indistinguishable without it.
44
+ */
45
+ function exec(file, args) {
46
+ try {
47
+ const stdout = (0, node_child_process_1.execFileSync)(file, args, {
48
+ encoding: 'utf8',
49
+ stdio: ['ignore', 'pipe', 'pipe'],
50
+ });
51
+ return { ok: true, stdout: String(stdout).trim() };
52
+ }
53
+ catch (err) {
54
+ const e = err;
55
+ const stderr = String(e?.stderr ?? '').trim() || String(e?.message ?? '').trim();
56
+ return {
57
+ ok: false,
58
+ error: {
59
+ notFound: e?.code === 'ENOENT',
60
+ status: typeof e?.status === 'number' ? e.status : null,
61
+ stderr,
62
+ },
63
+ };
64
+ }
65
+ }
66
+ /** Characters of command output kept in a top-level failure message. */
67
+ const SNIPPET_LENGTH = 300;
68
+ /** Characters kept in a `verify` warning, which is one line among several. */
69
+ const WARNING_SNIPPET_LENGTH = 120;
70
+ /** Collapse output to one bounded line so a huge or binary payload stays readable. */
71
+ function snippet(text, max = SNIPPET_LENGTH) {
72
+ const oneLine = text.replace(/\s+/g, ' ').trim();
73
+ if (oneLine === '')
74
+ return '(no output)';
75
+ return oneLine.length > max
76
+ ? `${oneLine.slice(0, max)}… (${oneLine.length} characters total)`
77
+ : oneLine;
78
+ }
79
+ const GH_FAILURE_CASES = [
80
+ {
81
+ match: [
82
+ 'gh auth login',
83
+ 'not logged',
84
+ 'authentication',
85
+ 'unauthorized',
86
+ 'http 401',
87
+ 'bad credentials',
88
+ ],
89
+ cause: () => 'gh is not authenticated.',
90
+ fix: `Fix: run 'gh auth login', confirm with 'gh auth status', then re-run.`,
91
+ },
92
+ {
93
+ match: ['could not resolve to', 'not found', 'http 404'],
94
+ cause: (where) => `GitHub could not find it in ${where}.`,
95
+ fix: `Fix: check the number is right and that the repository is the one you mean — pass --repo <owner/name> when running outside it.`,
96
+ },
97
+ {
98
+ // Ahead of the 403 case on purpose: gh reports rate limiting AS an HTTP 403, so without
99
+ // this the operator is told to fix their permissions — which are fine. `stats --issues`
100
+ // can fire up to 200 sequential reads, which makes this a realistic failure.
101
+ match: ['rate limit', 'secondary rate', 'abuse detection', 'http 429'],
102
+ cause: () => 'GitHub rate-limited the request.',
103
+ fix: `Fix: wait for the window to reset ('gh api rate_limit' shows when), then re-run; narrow --issues to read fewer issues per run.`,
104
+ },
105
+ {
106
+ match: ['http 403', 'permission', 'write access', 'forbidden'],
107
+ cause: (where) => `GitHub refused the request — the authenticated account lacks access to ${where}.`,
108
+ fix: `Fix: check the account and its scopes with 'gh auth status'; reading an issue needs read access to the repository, and posting a milestone needs write.`,
109
+ },
110
+ {
111
+ match: ['dial tcp', 'no such host', 'timeout', 'connection refused', 'network is unreachable'],
112
+ cause: () => 'gh could not reach GitHub.',
113
+ fix: `Fix: check network/proxy access to github.com and re-run; this is transient, so a retry is usually enough.`,
114
+ },
115
+ ];
116
+ /**
117
+ * `JSON.parse` for a `gh --json` payload: `null` instead of a throw, and the caller's
118
+ * declared shape instead of `any`. Every gh call here reads a `--json` payload, so all
119
+ * three call sites need the same "gh exited 0 but printed something that is not JSON"
120
+ * branch — they differ only in what they say about it. Fields stay `unknown` because gh's
121
+ * output is remote data — {@link asString} narrows each one at the point of use.
122
+ */
123
+ function parseGhJson(raw) {
124
+ try {
125
+ return JSON.parse(raw);
126
+ }
127
+ catch {
128
+ return null;
129
+ }
130
+ }
131
+ /** A JSON field as a string, or `fallback` when gh omitted it or sent another type. */
132
+ function asString(value, fallback = '') {
133
+ return typeof value === 'string' ? value : fallback;
134
+ }
135
+ /**
136
+ * Turn a failed `gh` invocation into a message that names the cause and the fix.
137
+ *
138
+ * `action` is what we were trying to do, e.g. "Could not read issue #440".
139
+ */
140
+ function ghFailure(action, err, repo) {
141
+ if (err.notFound) {
142
+ return [
143
+ `${action}: 'gh' is not installed, or is not on PATH.`,
144
+ `Fix: install the GitHub CLI (https://cli.github.com), then re-run. Check with: gh --version`,
145
+ ].join('\n');
146
+ }
147
+ const hint = err.stderr.toLowerCase();
148
+ const where = repo ? `--repo ${repo}` : 'the current repository (no --repo was passed)';
149
+ const matched = GH_FAILURE_CASES.find((c) => c.match.some((needle) => hint.includes(needle)));
150
+ const cause = matched
151
+ ? `${action}: ${matched.cause(where)}`
152
+ : `${action}: gh exited ${err.status === null ? 'abnormally' : `with status ${err.status}`}.`;
153
+ const fix = matched?.fix ?? `Fix: run the same gh command by hand to see the full error.`;
154
+ return [cause, fix, `gh said: ${snippet(err.stderr)}`].join('\n');
155
+ }
156
+ /** Reject a non-numeric `--issue` here rather than letting gh (or `mint`) fail obscurely. */
157
+ function requireIssueNumber(issue) {
158
+ if (!(0, runstate_1.isIssueNumber)(issue)) {
159
+ fail([
160
+ `Invalid --issue '${issue}' — expected a positive GitHub issue number.\nFix: pass the number only, e.g. --issue 440 (not a URL, a '#', or a branch name).`,
161
+ ]);
162
+ }
163
+ }
164
+ /**
165
+ * The `--issue`/`--repo` checks every issue-targeting subcommand runs before it starts.
166
+ * Kept together so a new subcommand cannot pick up one guard and miss the other.
167
+ */
168
+ function requireIssueTarget(options) {
169
+ requireIssueNumber(options.issue);
170
+ requireRepoSlug(options.repo);
171
+ }
172
+ /** `--repo owner/name` appended to a gh invocation when the caller supplied one. */
173
+ function repoArgs(repo) {
174
+ return repo ? ['--repo', repo] : [];
175
+ }
176
+ /** `owner/name`, or `host/owner/name` for a GitHub Enterprise host. */
177
+ const REPO_SLUG_RE = /^(?:[A-Za-z0-9][A-Za-z0-9.-]*\/)?[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/;
178
+ /** Reject a `--repo` that is not a repository slug before handing it to gh. */
179
+ function requireRepoSlug(repo) {
180
+ if (repo === undefined)
181
+ return;
182
+ if (!REPO_SLUG_RE.test(repo)) {
183
+ fail([
184
+ `Invalid --repo '${repo}' — expected owner/name.\nFix: pass the slug only, e.g. --repo imboard-ai/ai-dossier (not a URL, and not a shell expansion).`,
185
+ ]);
186
+ }
187
+ }
188
+ /** Whether `value` carries a control character, which has no place in a milestone value. */
189
+ function hasControlChar(value) {
190
+ for (let i = 0; i < value.length; i += 1) {
191
+ const code = value.charCodeAt(i);
192
+ if (code < 0x20 || code === 0x7f)
193
+ return true;
194
+ }
195
+ return false;
196
+ }
197
+ /**
198
+ * Whether a value read back OUT of a milestone comment may be passed to `git`/`gh`.
199
+ *
200
+ * These values are attacker-reachable: anyone who can comment on the issue can post a
201
+ * `<!-- runstate:v1 -->` body, so `branch=`, `worktree=` and `pr=` arrive from the network
202
+ * rather than from the caller. Every invocation here is `execFileSync` with an argument
203
+ * array and no shell, so there is no command injection — but a value that begins with `-`
204
+ * is still read as a FLAG rather than as data. `gh` (cobra) parses flags interspersed with
205
+ * positionals, so `pr=--repo=someone/else` or `pr=--web` would silently retarget, or add a
206
+ * side effect to, a subcommand documented as strictly read-only.
207
+ */
208
+ function isSafeArg(value) {
209
+ return value !== '' && !value.startsWith('-') && !/\s/.test(value) && !hasControlChar(value);
210
+ }
211
+ /**
212
+ * Paths are only ever passed in value position (`git -C <path>`) or to `fs.statSync`, so
213
+ * they cannot be mistaken for a flag — but the protocol requires them absolute, and a
214
+ * relative path from a forged comment would resolve against whatever directory the agent
215
+ * happens to be in.
216
+ */
217
+ function isSafePath(value) {
218
+ return value.startsWith('/') && !hasControlChar(value);
219
+ }
220
+ /** Parse `--kv k=v` occurrences into ordered pairs. */
221
+ function parseKvPairs(raw) {
222
+ const pairs = [];
223
+ const errors = [];
224
+ for (const item of raw) {
225
+ const kv = (0, runstate_1.splitPair)(item);
226
+ if (!kv) {
227
+ errors.push(`Malformed --kv '${item}' — expected key=value, e.g. --kv base_branch=main`);
228
+ continue;
229
+ }
230
+ pairs.push(kv);
231
+ }
232
+ return { pairs, errors };
233
+ }
234
+ /**
235
+ * Read every runstate milestone on an issue, returning WHY rather than exiting.
236
+ *
237
+ * The single-issue subcommands turn a failure here into an immediate exit; `stats --issues`
238
+ * cannot, because one unreadable issue in a set of nine must not discard the other eight.
239
+ * So the decision of what a failure means belongs to the caller, and this function only
240
+ * reports it.
241
+ */
242
+ function tryFetchMilestones(issue, repo) {
243
+ const res = exec('gh', ['issue', 'view', issue, '--json', 'comments', ...repoArgs(repo)]);
244
+ if (!res.ok) {
245
+ return { ok: false, error: ghFailure(`Could not read issue #${issue}`, res.error, repo) };
246
+ }
247
+ const parsed = parseGhJson(res.stdout);
248
+ if (parsed === null) {
249
+ return {
250
+ ok: false,
251
+ error: [
252
+ `Could not read issue #${issue}: gh exited 0 but did not print JSON.`,
253
+ `Expected a {"comments":[…]} object from: gh issue view ${issue} --json comments${repo ? ` --repo ${repo}` : ''}`,
254
+ `Fix: run that command by hand — a gh older than 2.0 (no --json), or an interactive prompt landing on stdout, both look like this.`,
255
+ `gh printed: ${snippet(res.stdout)}`,
256
+ ].join('\n'),
257
+ };
258
+ }
259
+ // A milestone-less issue and a shape we cannot read must not look the same: the first
260
+ // means "fresh run", the second means the tool is broken, and silently returning [] for
261
+ // both would make a resume start over and destroy work.
262
+ if (!Array.isArray(parsed?.comments)) {
263
+ return {
264
+ ok: false,
265
+ error: [
266
+ `Could not read issue #${issue}: gh's JSON has no "comments" array.`,
267
+ `Fix: confirm your gh supports 'gh issue view <n> --json comments' (gh --version); if it does, re-run — this is not an issue with no comments, it is an unreadable response.`,
268
+ `gh printed: ${snippet(res.stdout)}`,
269
+ ].join('\n'),
270
+ };
271
+ }
272
+ const bodies = parsed.comments.map((c) => typeof c?.body === 'string' ? c.body : '');
273
+ return { ok: true, milestones: (0, runstate_1.parseMilestones)(bodies) };
274
+ }
275
+ /** Fetch every runstate milestone on an issue, oldest first, or exit 1 explaining why. */
276
+ function fetchMilestones(issue, repo) {
277
+ const result = tryFetchMilestones(issue, repo);
278
+ if (!result.ok)
279
+ fail([result.error]);
280
+ return result.milestones;
281
+ }
282
+ /** `git ls-remote --exit-code` exits 2 for "no such ref" — an answer, not a fault. */
283
+ const GIT_NO_MATCHING_REF = 2;
284
+ /** The `state` value `gh issue view --json state` reports for a closed issue. */
285
+ const GH_STATE_CLOSED = 'CLOSED';
286
+ /**
287
+ * What `verify` does when a probe cannot answer. Each is shared by the "the command
288
+ * failed" and "the value was unusable" paths of the same probe, so the consequence a
289
+ * reader is told stays the same however the check fell over.
290
+ */
291
+ const TREATED_MISSING = 'treating it as missing';
292
+ const IMPLEMENT_UNVERIFIED = 'treating the implement milestone as unverified';
293
+ const SHIP_UNVERIFIED = `resuming at 'ship' rather than assuming it merged`;
294
+ const RUN_INCOMPLETE = 'treating the run as not yet complete';
295
+ /**
296
+ * The warning for a probe whose command failed: one place for the format, so a new probe
297
+ * supplies only `subject` (what could not be read) and `consequence` (what verify does
298
+ * instead).
299
+ */
300
+ function probeFailure(tool, subject, err, consequence) {
301
+ return `could not ${subject} (${tool} exited ${err.status ?? 'abnormally'}: ${snippet(err.stderr, WARNING_SNIPPET_LENGTH)}) — ${consequence}`;
302
+ }
303
+ /** The warning for a gh call that exited 0 but did not print JSON. */
304
+ function nonJsonFailure(subject, stdout, consequence) {
305
+ return `gh returned non-JSON for ${subject} (${snippet(stdout, WARNING_SNIPPET_LENGTH)}) — ${consequence}`;
306
+ }
307
+ function probeBranchOnRemote(branch, warn) {
308
+ if (!isSafeArg(branch)) {
309
+ warn(`milestone branch '${branch}' is not a usable branch name — refusing to pass it to git, and ${TREATED_MISSING}`);
310
+ return false;
311
+ }
312
+ const res = exec('git', ['ls-remote', '--exit-code', 'origin', branch]);
313
+ if (res.ok)
314
+ return true;
315
+ if (res.error.notFound) {
316
+ warn(`git is not installed or not on PATH — could not confirm branch '${branch}'`);
317
+ }
318
+ else if (res.error.status !== GIT_NO_MATCHING_REF) {
319
+ // `--exit-code` reports 2 for "no such ref", which is a real answer, not a fault.
320
+ warn(probeFailure('git', `reach 'origin' to confirm branch '${branch}'`, res.error, TREATED_MISSING));
321
+ }
322
+ return false;
323
+ }
324
+ /**
325
+ * Backs both `dirExists` and `fileExists`, which differ only in the milestone key the
326
+ * path came from (`label`) and the kind of entry they expect.
327
+ */
328
+ function probePathExists(path, label, kind, warn) {
329
+ if (!isSafePath(path)) {
330
+ warn(`milestone ${label} '${path}' is not an absolute path — ${TREATED_MISSING}`);
331
+ return false;
332
+ }
333
+ try {
334
+ const stat = node_fs_1.default.statSync(path);
335
+ return kind === 'directory' ? stat.isDirectory() : stat.isFile();
336
+ }
337
+ catch {
338
+ return false;
339
+ }
340
+ }
341
+ function probeHeadOf(worktree, warn) {
342
+ if (!isSafePath(worktree)) {
343
+ warn(`milestone worktree '${worktree}' is not an absolute path — refusing to run git in it, and ${IMPLEMENT_UNVERIFIED}`);
344
+ return null;
345
+ }
346
+ const res = exec('git', ['-C', worktree, 'rev-parse', '--short', 'HEAD']);
347
+ if (res.ok)
348
+ return res.stdout;
349
+ // The worktree directory already passed dirExists, so a failure here is surprising.
350
+ warn(probeFailure('git', `read HEAD in '${worktree}'`, res.error, IMPLEMENT_UNVERIFIED));
351
+ return null;
352
+ }
353
+ function probePrState(pr, repo, warn) {
354
+ if (!isSafeArg(pr)) {
355
+ warn(`milestone pr '${pr}' is not a usable PR reference — refusing to pass it to gh, and ${SHIP_UNVERIFIED}`);
356
+ return null;
357
+ }
358
+ const res = exec('gh', [
359
+ 'pr',
360
+ 'view',
361
+ pr,
362
+ '--json',
363
+ 'state,mergedAt,mergeable',
364
+ ...repoArgs(repo),
365
+ ]);
366
+ if (!res.ok) {
367
+ warn(probeFailure('gh', `read PR ${pr}`, res.error, SHIP_UNVERIFIED));
368
+ return null;
369
+ }
370
+ const data = parseGhJson(res.stdout);
371
+ if (data === null) {
372
+ warn(nonJsonFailure(`PR ${pr}`, res.stdout, SHIP_UNVERIFIED));
373
+ return null;
374
+ }
375
+ return {
376
+ state: asString(data.state),
377
+ mergedAt: typeof data.mergedAt === 'string' ? data.mergedAt : null,
378
+ mergeable: asString(data.mergeable),
379
+ };
380
+ }
381
+ function probeIssueClosed(issue, repo, warn) {
382
+ const res = exec('gh', ['issue', 'view', issue, '--json', 'state', ...repoArgs(repo)]);
383
+ if (!res.ok) {
384
+ warn(probeFailure('gh', `read the state of issue #${issue}`, res.error, RUN_INCOMPLETE));
385
+ return false;
386
+ }
387
+ const data = parseGhJson(res.stdout);
388
+ if (data === null) {
389
+ warn(nonJsonFailure(`the state of issue #${issue}`, res.stdout, RUN_INCOMPLETE));
390
+ return false;
391
+ }
392
+ return asString(data.state) === GH_STATE_CLOSED;
393
+ }
394
+ /**
395
+ * The real-world probe backing `runstate verify`. All reads — never needs push access.
396
+ *
397
+ * A probe that cannot answer degrades to "not verified", which is the safe direction (the
398
+ * run redoes a phase rather than skipping one). But silently redoing a phase is expensive,
399
+ * so every degradation appends to `warnings` and `verify` reports them.
400
+ */
401
+ function makeProbe(issue, repo, warnings = []) {
402
+ const warn = (line) => {
403
+ if (!warnings.includes(line))
404
+ warnings.push(line);
405
+ };
406
+ return {
407
+ branchOnRemote: (branch) => probeBranchOnRemote(branch, warn),
408
+ dirExists: (path) => probePathExists(path, 'worktree', 'directory', warn),
409
+ fileExists: (path) => probePathExists(path, 'planning path', 'file', warn),
410
+ headOf: (worktree) => probeHeadOf(worktree, warn),
411
+ prState: (pr) => probePrState(pr, repo, warn),
412
+ issueClosed: () => probeIssueClosed(issue, repo, warn),
413
+ };
414
+ }
415
+ /** `--dry-run`: show the exact body that would have been posted, and post nothing. */
416
+ function printDryRun(body, json) {
417
+ if (json) {
418
+ console.log(JSON.stringify({ posted: false, dryRun: true, body }, null, 2));
419
+ }
420
+ else {
421
+ process.stdout.write(body);
422
+ }
423
+ }
424
+ /** Comment the built body onto the issue and report where it landed. */
425
+ function postMilestone(body, options) {
426
+ const res = exec('gh', [
427
+ 'issue',
428
+ 'comment',
429
+ options.issue,
430
+ '--body',
431
+ body,
432
+ ...repoArgs(options.repo),
433
+ ]);
434
+ if (!res.ok) {
435
+ fail([
436
+ ghFailure(`Failed to post the ${options.phase}/${options.status} milestone to issue #${options.issue}`, res.error, options.repo),
437
+ // The milestone is the only durable record of the phase, so hand back the body it
438
+ // could not post: re-running the phase is far more expensive than a retry.
439
+ `The milestone was NOT posted. Retry, or post it by hand:\ngh issue comment ${options.issue}${options.repo ? ` --repo ${options.repo}` : ''} --body ${JSON.stringify(body)}`,
440
+ ]);
441
+ }
442
+ if (options.json) {
443
+ console.log(JSON.stringify({ posted: true, url: res.stdout, body }, null, 2));
444
+ }
445
+ else {
446
+ console.log(`✅ ${options.phase} ${options.status} → ${res.stdout}`);
447
+ }
448
+ }
449
+ /**
450
+ * `post` refuses an over-long body itself: GitHub answers a too-large comment with an
451
+ * opaque 422, and `execFileSync` can hit E2BIG even earlier, so without this the failure
452
+ * arrives as "gh exited abnormally" with no mention of size.
453
+ */
454
+ function requirePostableBody(body, pairs) {
455
+ if (body.length <= runstate_1.MAX_BODY_LENGTH)
456
+ return;
457
+ const biggest = [...pairs].sort((a, b) => b[1].length - a[1].length)[0];
458
+ fail([
459
+ [
460
+ `Milestone is ${body.length} characters — GitHub rejects an issue comment over ${runstate_1.MAX_BODY_LENGTH}.`,
461
+ biggest
462
+ ? `Largest key: ${biggest[0]}= at ${biggest[1].length} characters.`
463
+ : `No --kv pairs to trim.`,
464
+ `Fix: replace the long value with a count or a path to the full text — a milestone is an index, not a report.`,
465
+ ].join('\n'),
466
+ ]);
467
+ }
468
+ /** `runstate post` — validate a milestone, then comment it onto the issue. */
469
+ function registerPostSubcommand(cmd) {
470
+ cmd
471
+ .command('post')
472
+ .description('Build and post a runstate milestone comment (validates before posting)')
473
+ .requiredOption('--issue <number>', 'GitHub issue number')
474
+ .requiredOption('--phase <phase>', 'gate, setup, plan, implement, review, ship, or report')
475
+ .requiredOption('--status <status>', 'done, partial, blocked, or awaiting-merge')
476
+ .requiredOption('--run <id>', 'Run id minted by the gate phase (r-<issue>-<hex>)')
477
+ .option('--kv <key=value...>', 'Phase-specific key=value pair (repeatable)')
478
+ .option('--next <phase>', 'Override the computed next= value')
479
+ .option('--repo <owner/name>', 'Target repository (defaults to the current one)')
480
+ .option('--dry-run', 'Print the comment body without posting it')
481
+ .option('--json', 'Output the result as JSON')
482
+ .action((options) => {
483
+ requireIssueTarget(options);
484
+ const { pairs, errors: kvErrors } = parseKvPairs(options.kv ?? []);
485
+ const input = {
486
+ phase: options.phase,
487
+ status: options.status,
488
+ run: options.run,
489
+ keys: pairs,
490
+ next: options.next,
491
+ };
492
+ const errors = [...kvErrors, ...(0, runstate_1.validateMilestone)(input)];
493
+ if (errors.length > 0)
494
+ fail(errors);
495
+ const body = (0, runstate_1.buildMilestone)(input);
496
+ requirePostableBody(body, pairs);
497
+ if (options.dryRun) {
498
+ printDryRun(body, options.json);
499
+ return;
500
+ }
501
+ postMilestone(body, options);
502
+ });
503
+ }
504
+ /** `runstate last` — print the most recent milestone on an issue. */
505
+ function registerLastSubcommand(cmd) {
506
+ cmd
507
+ .command('last')
508
+ .description('Print the most recent runstate milestone on an issue (read-only)')
509
+ .requiredOption('--issue <number>', 'GitHub issue number')
510
+ .option('--repo <owner/name>', 'Target repository (defaults to the current one)')
511
+ .option('--json', 'Output the parsed milestone as JSON')
512
+ .action((options) => {
513
+ requireIssueTarget(options);
514
+ const milestones = fetchMilestones(options.issue, options.repo);
515
+ if (milestones.length === 0) {
516
+ if (options.json) {
517
+ console.log(JSON.stringify(null));
518
+ }
519
+ else {
520
+ console.log(`No runstate milestones on issue #${options.issue}.`);
521
+ }
522
+ return;
523
+ }
524
+ const last = milestones[milestones.length - 1];
525
+ if (options.json) {
526
+ console.log(JSON.stringify({ phase: last.phase, status: last.status, run: last.run, at: last.at, ...last.keys }, null, 2));
527
+ return;
528
+ }
529
+ for (const [key, value] of Object.entries(last.keys)) {
530
+ console.log(`${key}=${value}`);
531
+ }
532
+ });
533
+ }
534
+ /** `runstate verify` — re-check a run's claims and report where it should resume. */
535
+ function registerVerifySubcommand(cmd) {
536
+ cmd
537
+ .command('verify')
538
+ .description("Run the gate's resume verification and print resume_from (read-only)")
539
+ .requiredOption('--issue <number>', 'GitHub issue number')
540
+ .option('--repo <owner/name>', 'Target repository (defaults to the current one)')
541
+ .option('--json', 'Output the result as JSON')
542
+ .action((options) => {
543
+ requireIssueTarget(options);
544
+ const milestones = fetchMilestones(options.issue, options.repo);
545
+ const warnings = [];
546
+ const result = (0, runstate_1.computeResume)(milestones, makeProbe(options.issue, options.repo, warnings));
547
+ if (options.json) {
548
+ console.log(JSON.stringify({
549
+ resume_from: result.resume_from,
550
+ run_id: result.run_id,
551
+ verified: result.verified,
552
+ resume_context: result.resume_context,
553
+ ...(result.hard_block ? { hard_block: result.hard_block } : {}),
554
+ ...(result.note ? { note: result.note } : {}),
555
+ ...(warnings.length > 0 ? { warnings } : {}),
556
+ }, null, 2));
557
+ }
558
+ else {
559
+ console.log(`resume_from=${result.resume_from}`);
560
+ console.log(`run_id=${result.run_id ?? 'none'}`);
561
+ console.log(`verified=${result.verified.length > 0 ? result.verified.join(',') : 'none'}`);
562
+ if (result.hard_block)
563
+ console.log(`hard_block=${result.hard_block}`);
564
+ if (result.note)
565
+ console.log(`note=${result.note}`);
566
+ console.log(`resume_context=${JSON.stringify(result.resume_context)}`);
567
+ }
568
+ // On stderr so stdout stays parseable. `verify` still exits 0: an unanswerable
569
+ // probe is a degraded read, not a failure — but the reader must be told that
570
+ // `resume_from` is conservative BECAUSE a check could not run, not because the
571
+ // work is genuinely missing.
572
+ for (const warning of warnings) {
573
+ console.error(`⚠️ verify could not check everything: ${warning}`);
574
+ }
575
+ });
576
+ }
577
+ /**
578
+ * Resolve `--issue` / `--issues` into the issue numbers to fetch.
579
+ *
580
+ * Exactly one of the two is required: silently preferring one when both are passed would
581
+ * quietly analyse a different set than the operator asked for, and defaulting to something
582
+ * when neither is passed would hit the network on a typo.
583
+ */
584
+ function resolveStatsIssues(options) {
585
+ if (options.issue !== undefined && options.issues !== undefined) {
586
+ fail([
587
+ `Pass --issue or --issues, not both.\nFix: --issue ${options.issue} for one issue, or --issues ${options.issues} for a set.`,
588
+ ]);
589
+ }
590
+ if (options.issue !== undefined) {
591
+ requireIssueTarget({ issue: options.issue, repo: options.repo });
592
+ return [Number(options.issue)];
593
+ }
594
+ if (options.issues === undefined) {
595
+ fail([
596
+ `Missing --issue or --issues.\nFix: 'runstate stats --issue 451', or a set: 'runstate stats --issues 440,448,451' (ranges too: 440..451).`,
597
+ ]);
598
+ }
599
+ requireRepoSlug(options.repo);
600
+ try {
601
+ return (0, issue_selection_1.parseIssueSelection)(options.issues);
602
+ }
603
+ catch (err) {
604
+ // Selection errors already carry their own `Fix:` line — appending a generic one told
605
+ // the operator to do the very thing that just failed ("pass a range" on a cap error).
606
+ return fail([err instanceof Error ? err.message : String(err)]);
607
+ }
608
+ }
609
+ /** Column layout of the per-run phase table. */
610
+ const PHASE_TABLE_HEADERS = ['phase', 'status', 'started', 'ended', 'duration'];
611
+ const PHASE_TABLE_ALIGN = ['left', 'left', 'left', 'left', 'right'];
612
+ /** Per-phase spread: a label, a sample count, then three durations. */
613
+ const SPREAD_TABLE_ALIGN = ['left', 'right', 'right', 'right', 'right'];
614
+ const TOTALS_TABLE_HEADERS = ['run', 'issue', 'model', 'last', 'total'];
615
+ const TOTALS_TABLE_ALIGN = ['left', 'right', 'left', 'left', 'right'];
616
+ /** Indent applied to every table so its rows sit visibly under their heading. */
617
+ const TABLE_INDENT = ' ';
618
+ function indent(block) {
619
+ return block
620
+ .split('\n')
621
+ .map((line) => `${TABLE_INDENT}${line}`)
622
+ .join('\n');
623
+ }
624
+ /** One run's heading line: which run, on which issue, from which model. */
625
+ function runHeading(run) {
626
+ const model = run.model ? `, model ${(0, runstate_stats_1.renderValue)(run.model)}` : '';
627
+ const total = run.total_seconds === null ? 'unknown' : (0, duration_1.formatDurationCell)(run.total_seconds);
628
+ return `Issue #${run.issue} — run ${(0, runstate_stats_1.renderValue)(run.run)}${model} — total ${total}`;
629
+ }
630
+ /** The per-run phase table: phase, status, started, ended, duration. */
631
+ function printRunTable(run) {
632
+ console.log(runHeading(run));
633
+ if (run.phases.length === 0) {
634
+ console.log(`${TABLE_INDENT}(no milestone in this run carried a usable timestamp)`);
635
+ return;
636
+ }
637
+ const rows = run.phases.map((phase) => [
638
+ (0, runstate_stats_1.renderValue)(phase.phase),
639
+ (0, runstate_stats_1.renderValue)(phase.status),
640
+ phase.started_at ?? '-',
641
+ phase.ended_at,
642
+ (0, duration_1.formatDurationCell)(phase.seconds),
643
+ ]);
644
+ console.log(indent((0, table_1.renderTable)(PHASE_TABLE_HEADERS, rows, { align: PHASE_TABLE_ALIGN })));
645
+ }
646
+ /**
647
+ * A titled, indented table. Sections are separated by a blank line, but the first one only
648
+ * needs it when something was printed above — otherwise the output opens on an empty line.
649
+ */
650
+ function makeSectionPrinter(precededByOutput) {
651
+ let printed = precededByOutput;
652
+ return (title, headers, rows, align) => {
653
+ console.log(printed ? `\n${title}` : title);
654
+ printed = true;
655
+ console.log(indent((0, table_1.renderTable)(headers, rows, { align })));
656
+ };
657
+ }
658
+ /** A trailing note marking an aggregate row whose samples include clock-skewed spans. */
659
+ function skewCell(negativeSamples) {
660
+ const note = (0, runstate_stats_1.skewNote)(negativeSamples);
661
+ return note ? `⚠ ${note}` : '';
662
+ }
663
+ /** The cross-run aggregates: per-phase spread, per-run totals, and totals by model. */
664
+ function printAggregates(report, precededByTables) {
665
+ const { phases, models } = report.aggregates;
666
+ const section = makeSectionPrinter(precededByTables);
667
+ if (phases.length > 0) {
668
+ section(`Per-phase duration across ${report.runs.length} run(s):`, ['phase', 'n', 'median', 'min', 'max', ''], phases.map((phase) => [
669
+ (0, runstate_stats_1.renderValue)(phase.phase),
670
+ String(phase.samples),
671
+ (0, duration_1.formatDurationCell)(phase.median_seconds),
672
+ (0, duration_1.formatDurationCell)(phase.min_seconds),
673
+ (0, duration_1.formatDurationCell)(phase.max_seconds),
674
+ skewCell(phase.negative_samples),
675
+ ]), [...SPREAD_TABLE_ALIGN, 'left']);
676
+ }
677
+ section('Per-run total:', TOTALS_TABLE_HEADERS, report.runs.map((run) => [
678
+ (0, runstate_stats_1.renderValue)(run.run),
679
+ `#${run.issue}`,
680
+ run.model === null ? '-' : (0, runstate_stats_1.renderValue)(run.model),
681
+ `${(0, runstate_stats_1.renderValue)(run.last_phase) || '-'}/${(0, runstate_stats_1.renderValue)(run.last_status) || '-'}`,
682
+ (0, duration_1.formatDurationCell)(run.total_seconds),
683
+ ]), TOTALS_TABLE_ALIGN);
684
+ if (models.length > 0) {
685
+ section('By model:', ['model', 'runs', 'n', 'median total', 'min', 'max', ''], models.map((model) => [
686
+ (0, runstate_stats_1.renderValue)(model.model),
687
+ String(model.runs),
688
+ String(model.samples),
689
+ (0, duration_1.formatDurationCell)(model.median_total_seconds),
690
+ (0, duration_1.formatDurationCell)(model.min_total_seconds),
691
+ (0, duration_1.formatDurationCell)(model.max_total_seconds),
692
+ skewCell(model.negative_samples),
693
+ ]), ['left', 'right', 'right', 'right', 'right', 'right', 'left']);
694
+ }
695
+ }
696
+ /**
697
+ * Render the human report.
698
+ *
699
+ * Per-run phase tables are printed for a single issue; a multi-issue selection prints the
700
+ * aggregates instead, since a fleet of nine issues would otherwise bury them under ~70
701
+ * rows. The aggregates always run for a multi-issue selection even when it resolved to one
702
+ * run — the "Per-run total" table is then the only place that run appears at all.
703
+ */
704
+ function printStatsHuman(report, multiIssue) {
705
+ for (const issue of report.issues_without_trail) {
706
+ console.log(`Issue #${issue} has no runstate milestones — nothing to measure.`);
707
+ }
708
+ if (report.runs.length === 0)
709
+ return;
710
+ if (!multiIssue) {
711
+ report.runs.forEach((run, i) => {
712
+ if (i > 0)
713
+ console.log('');
714
+ printRunTable(run);
715
+ });
716
+ }
717
+ // A single run's aggregates restate its own phase table with median = min = max on every
718
+ // row — but only when that table was actually printed.
719
+ if (multiIssue || report.runs.length > 1)
720
+ printAggregates(report, !multiIssue);
721
+ }
722
+ /**
723
+ * Read every selected issue's trail, keeping going past one that cannot be read.
724
+ *
725
+ * A selection pasted from a fleet dispatch routinely contains an issue that was since
726
+ * transferred, deleted, or made private. Aborting on the first one throws away every gh
727
+ * round trip already spent and reports nothing — so a failure becomes a per-issue fact
728
+ * here, and only a selection where EVERY issue failed is a failure of the command.
729
+ */
730
+ function readTrails(issues, repo) {
731
+ const trails = [];
732
+ const failed = [];
733
+ // Progress on stderr: 200 serial gh calls are otherwise indistinguishable from a hang,
734
+ // and if one fails there is nothing to say how far the run got. Only on a TTY — the
735
+ // carriage returns that make it a single updating line become literal escape noise in a
736
+ // redirected log, which is exactly where the output is read later.
737
+ const showProgress = issues.length > 1 && process.stderr.isTTY === true;
738
+ issues.forEach((issue, i) => {
739
+ if (showProgress) {
740
+ process.stderr.write(`\rstats: reading issue #${issue} (${i + 1}/${issues.length})…`);
741
+ }
742
+ const result = tryFetchMilestones(String(issue), repo);
743
+ if (result.ok)
744
+ trails.push({ issue, milestones: result.milestones });
745
+ else
746
+ failed.push({ issue, error: result.error });
747
+ });
748
+ if (showProgress)
749
+ process.stderr.write('\r\u001b[K');
750
+ return { trails, failed };
751
+ }
752
+ /**
753
+ * `runstate stats` — per-phase durations derived from a trail's `at=` stamps.
754
+ *
755
+ * Read-only by construction: the only subprocess it can reach is the `gh issue view` inside
756
+ * {@link fetchMilestones}, which is also where the auth/404/network failure taxonomy lives.
757
+ */
758
+ function registerStatsSubcommand(cmd) {
759
+ cmd
760
+ .command('stats')
761
+ .description("Report per-phase durations from an issue's runstate trail (read-only)")
762
+ .option('--issue <number>', 'GitHub issue number')
763
+ .option('--issues <list>', 'Issue list or range to aggregate, e.g. 1,2,5..8')
764
+ .option('--repo <owner/name>', 'Target repository (defaults to the current one)')
765
+ .option('--json', 'Output the report as JSON')
766
+ .action((options) => {
767
+ const issues = resolveStatsIssues(options);
768
+ const { trails, failed } = readTrails(issues, options.repo);
769
+ // Every issue unreadable is a genuine failure, not a degraded read — there is no
770
+ // report to hand back, so say why rather than printing an empty one and exiting 0.
771
+ if (trails.length === 0 && failed.length > 0) {
772
+ fail(failed.map((f) => f.error));
773
+ }
774
+ const report = (0, runstate_stats_1.buildStatsReport)({ trails, failed, repo: options.repo });
775
+ if (options.json) {
776
+ console.log(JSON.stringify(report, null, 2));
777
+ }
778
+ else {
779
+ printStatsHuman(report, issues.length > 1);
780
+ }
781
+ // On stderr in BOTH modes so stdout stays a parseable table or parseable JSON, and
782
+ // so a `--json` consumer watching stderr still learns the report is degraded — the
783
+ // same shape `verify` uses. A skipped milestone, a skew, or an issue with no trail is
784
+ // a degraded read, not a failure, so `stats` exits 0 in every one of those cases.
785
+ for (const f of report.issues_failed) {
786
+ console.error(`❌ stats could not read issue #${f.issue}, and left it out of the report:`);
787
+ for (const line of f.error.split('\n'))
788
+ console.error(` ${line}`);
789
+ }
790
+ for (const warning of report.warnings) {
791
+ console.error(`⚠️ stats could not measure everything: ${warning}`);
792
+ }
793
+ });
794
+ }
795
+ /** `runstate mint` — print a fresh run id. */
796
+ function registerMintSubcommand(cmd) {
797
+ cmd
798
+ .command('mint')
799
+ .description('Print a fresh run id for an issue (r-<issue>-<hex>)')
800
+ .requiredOption('--issue <number>', 'GitHub issue number')
801
+ .action((options) => {
802
+ // Without this, `mint --issue not-a-number` happily prints `r-not-a-number-ab56`
803
+ // and the mistake only surfaces one phase later as "Invalid run id" from `post`.
804
+ requireIssueNumber(options.issue);
805
+ console.log((0, runstate_1.mintRunId)(options.issue));
806
+ });
807
+ }
808
+ /** Registers the `runstate` command tree (post, last, verify, mint, stats). */
809
+ function registerRunstateCommand(program) {
810
+ const runstateCmd = program
811
+ .command('runstate')
812
+ .description('Post, read, and verify runstate:v1 workflow milestones on a GitHub issue');
813
+ registerPostSubcommand(runstateCmd);
814
+ registerLastSubcommand(runstateCmd);
815
+ registerVerifySubcommand(runstateCmd);
816
+ registerMintSubcommand(runstateCmd);
817
+ registerStatsSubcommand(runstateCmd);
818
+ }
819
+ //# sourceMappingURL=runstate.js.map