nightralph 0.0.54 → 0.0.56

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.
package/README.md CHANGED
@@ -26,9 +26,11 @@ so they can't drift as long as you install the skills with this package.
26
26
  * [Install Dependency Skills](#install-dependency-skills)
27
27
  - [Workflow](#workflow)
28
28
  * [Create Issues](#create-issues)
29
- * [Examples](#examples)
30
- * [Interface](#interface)
31
- + [`--spec ` option](#--spec--option)
29
+ - [Examples](#examples)
30
+ - [Command Line Arguments](#command-line-arguments)
31
+ * [Notes](#notes)
32
+ * [`--spec ` option](#--spec--option)
33
+ * [`--prefer-subagent` option](#--prefer-subagent-option)
32
34
  - [How It Works](#how-it-works)
33
35
  * [Select a ticket](#select-a-ticket)
34
36
  * [Worktrees](#worktrees)
@@ -135,7 +137,7 @@ one, in the worktree before a ticket can be marked `done`.
135
137
  > then follow with `to-tickets`.
136
138
  >
137
139
 
138
- ### Examples
140
+ ## Examples
139
141
 
140
142
  After you have the tickets, use the `nightralph` loop to execute.
141
143
  The `to-tickets` skill produces markdown files at
@@ -167,7 +169,7 @@ npx nightralph claude -m claude-opus-4-6 30
167
169
  npx nightralph pi -m openrouter/z-ai/glm-5.3-flash --effort medium --retries 1
168
170
  ```
169
171
 
170
- ### Interface
172
+ ## Command Line Arguments
171
173
 
172
174
  Flags:
173
175
 
@@ -185,12 +187,13 @@ Flags:
185
187
  | `--install-cmd <cmd>` | no | auto | Run in each fresh worktree before the agent starts, and again after a merge-conflict rebase; non-zero exit fails the attempt (it is retried like any other failure). Auto-detects from the lockfile: `pnpm-lock.yaml` -> `pnpm install --prefer-offline --frozen-lockfile`, `package-lock.json` -> `npm ci --prefer-offline --no-audit --no-fund`, a `package.json` with no lockfile -> `npm install --prefer-offline --no-audit --no-fund --no-package-lock`, no `package.json` -> no install. Pass `""` to disable. Shares the agent's `--timeout`. Output goes to `<ticket>.install.log` |
186
188
  | `--max-turns <n>` | no | -- | Max agentic turns per agent run. Forwarded as `--max-turns <n>` to `claude` only; for `codex` and `pi` the flag is skipped with a warning and the agent runs with no turn limit. The limit is per spawn, not a total for the ticket: every retry and every re-spawn after a merge conflict starts with a fresh budget of `n` turns, so with `--retries 3` one ticket can use up to 4 x `n`. When omitted (or `0`), no limit is passed, so `--timeout` is the only cap. Takes precedence over the `[turns]` positional when both are given. Reaching the limit is an error exit, so the attempt fails and is retried like any other failure |
187
189
  | `--effort <level>` | no | -- | Starting effort/reasoning level. Each provider has its own ladder: `claude` supports `low`, `medium`, `high`, `xhigh`, `max` (forwarded as `--effort`); `codex` supports `low`, `medium`, `high` (forwarded as `-c model_reasoning_effort=<level>`); `pi` supports `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `max` (forwarded as `--thinking`). Omitted = provider default. Each retry steps up from here (see `--retries`) |
190
+ | `--prefer-subagent <model>` | no | -- | `claude` only. Sets `CLAUDE_CODE_SUBAGENT_MODEL=<model>` for every claude agent nightralph spawns (tickets, merge-conflict re-spawns, the reviewer and the fixer) and adds a prompt rule: hand read-only work (exploring the codebase, searching code, test runs with long output) to subagents, and make every edit and commit yourself. The reviewer gets the rule without the edit and commit line, since it never edits. `""` is the same as omitting the flag. For `codex` and `pi` it is skipped with one warning at startup (`--prefer-subagent not supported by <provider>, skipping`). `--dry-run` prints `Prefer subagent: <model>`, or `(none)`. See [`--prefer-subagent` option](#--prefer-subagent-option) |
188
191
  | `--retries <n>` | no | `3` | Extra attempts per ticket after a failed run. Each retry starts from a fresh worktree and raises the effort level one rung (see Notes). `0` disables retries |
189
192
  | `--retry-delay <n>` | no | `30` | Seconds to wait before the first retry; doubles each further retry (30s, 60s, 120s, ...). `0` disables the wait |
190
193
  | `--concurrency <n>` | no | `5` | Max agents running at once within a wave. A wave with more ready tickets than this runs them through a pool: as one agent exits, the next ticket starts. The wave still finishes fully before its branches are merged |
191
194
 
192
195
 
193
- **Notes**:
196
+ ### Notes
194
197
 
195
198
  * When `--spec <name>` is omitted, the CLI looks for
196
199
  `.scratch/<feature>/issues/` directories. If multiple features exist,
@@ -282,13 +285,95 @@ Flags:
282
285
  tool-use proposals so the agent runs non-interactively)
283
286
 
284
287
 
285
- #### `--spec <name>` option
288
+ ### `--spec <name>` option
286
289
 
287
290
  A feature name that resolves to `.scratch/<name>/`.
288
291
  The spec file is `.scratch/<name>/spec.md` and the issues directory
289
292
  is `.scratch/<name>/issues/`.
290
293
 
291
294
 
295
+ ### `--prefer-subagent` option
296
+
297
+ Use this to use a strong model on the ticket while a cheaper, faster
298
+ model handles the reading. The main agent writes the code and makes
299
+ every commit. Subagents explore the codebase, search for code, and run
300
+ tests whose output would flood the main agent's context, then report
301
+ back a short answer.
302
+
303
+ The flag does two things for every claude agent nightralph spawns
304
+ (tickets, merge-conflict re-spawns, the reviewer and the fixer):
305
+
306
+ 1. It sets `CLAUDE_CODE_SUBAGENT_MODEL=<model>` in the agent's
307
+ environment.
308
+ 2. It adds a rule to the prompt: hand read-only work to subagents,
309
+ brief each one fully, do not delegate a lookup that takes a single
310
+ tool call, and never let a subagent edit files or commit. The
311
+ reviewer gets the same rule without the edit and commit line.
312
+
313
+ `<model>` is anything Claude Code accepts as a model: an alias such as
314
+ `sonnet` or `haiku`, or a full model ID. Sonnet is the safer choice
315
+ when subagents need judgment, such as tracing a call path across
316
+ modules. Haiku is cheaper and faster, and suits mechanical lookups and
317
+ summarizing long test output, but it is more likely to miss something.
318
+
319
+ ```sh
320
+ # Opus writes the code, Sonnet subagents explore and run tests
321
+ npx nightralph claude -m claude-opus-4-6 --prefer-subagent sonnet
322
+
323
+ # Cheaper subagents for searches and noisy test runs
324
+ npx nightralph claude -m claude-opus-4-6 --prefer-subagent haiku
325
+
326
+ # A Sonnet main agent with Haiku subagents
327
+ npx nightralph claude -m claude-sonnet-4-6 --prefer-subagent haiku
328
+
329
+ # Pin an exact model ID instead of an alias
330
+ npx nightralph claude -m claude-opus-4-6 --prefer-subagent claude-sonnet-5-5
331
+
332
+ # Check the setting and the prompt rule without running anything
333
+ npx nightralph claude --prefer-subagent claude-haiku-5-5 --dry-run
334
+ ```
335
+
336
+ Only the `claude` provider supports this flag. With `codex` or `pi` it
337
+ is skipped with one warning at startup.
338
+
339
+ `--prefer-subagent <model>` sets the model that a claude agent's
340
+ subagents default to. It's a fallback, not a mandate. A model passed on
341
+ an individual subagent call wins, and so does the `model` in a subagent
342
+ definition. The built-in Explore subagent picks its own model.
343
+ nightralph never sets `CLAUDE_CODE_SUBAGENT_MODEL_FORCE`, so without
344
+ the flag a value you exported in your shell still applies.
345
+
346
+ Subagent turns do not count toward `--max-turns`. That was observed on
347
+ Claude Code 2.1.296 and is not documented by Anthropic, so it may
348
+ change. Subagent time does count against `--timeout`, because the
349
+ timeout covers the whole agent process.
350
+
351
+ Spawned agents load your global `~/.claude` plugins and hooks. Those
352
+ can spend turns and steer delegation their own way, and nightralph does
353
+ not isolate agents from them.
354
+
355
+ Subagent stats are recorded for every claude run, whether or not
356
+ `--prefer-subagent` is set. When a claude agent ran, the attempt's
357
+ entry in `learnings.md` includes `Subagents: <n>.` and `Cost: $<x>.`,
358
+ each left out when the agent did not report it. The
359
+ ticket's row in the status display shows the running total across its
360
+ attempts, as in `a/03 - 2 subagents, $0.42`. After the finish summary,
361
+ one line totals the subagents, agent runs and cost over every ticket
362
+ attempt, merge-conflict re-spawn, the reviewer and the fixer:
363
+
364
+ ```
365
+ Subagents: 5 started across 7 agent runs, cost $3.18 (cost from 6 of 7 runs)
366
+ ```
367
+
368
+ The part in parentheses appears only when some runs reported no cost.
369
+ A claude agent reports cost only when it finishes, so one killed at
370
+ `--timeout` usually reports none. Codex and pi runs report nothing, so
371
+ a run that used only them gets no stats line. Stats are advisory: they
372
+ never change a ticket's outcome or the exit code.
373
+
374
+
375
+
376
+
292
377
  ## How It Works
293
378
 
294
379
  Tickets run in waves. A wave is a batch of tickets whose blockers are
@@ -394,6 +479,9 @@ Each row starts with a dot that shows the ticket's state:
394
479
  run in this session. The row names the blocking tickets, as in
395
480
  `auth/03 (blocked by auth/01)`.
396
481
 
482
+ A claude ticket's row can end with its subagent count and cost, as in
483
+ `auth/02 - 2 subagents, $0.42` (see [`--prefer-subagent` option](#--prefer-subagent-option)).
484
+
397
485
  The header looks like this:
398
486
 
399
487
  ```
@@ -427,18 +515,19 @@ keep the machine awake, but the display can still go dark.
427
515
  #### Claude Opus 4.6
428
516
 
429
517
  ```sh
430
- caffeinate -i npx nightralph claude -m claude-opus-4-6 --effort low
518
+ caffeinate -i npx nightralph claude -m claude-opus-4-6 --effort medium
431
519
  ```
432
520
 
433
521
  #### Claude Opus 5.5
434
522
 
435
523
  ```sh
436
- caffeinate -i npx nightralph claude -m claude-opus-5-5 --effort low
524
+ caffeinate -i npx nightralph claude -m claude-opus-5-5 --effort medium
437
525
  ```
526
+
438
527
  #### Claude Sonnet 5.5
439
528
 
440
529
  ```sh
441
- caffeinate -i npx nightralph claude -m claude-sonnet-5-5 --effort high
530
+ caffeinate -i npx nightralph claude -m claude-sonnet-5-5 --effort medium
442
531
  ```
443
532
 
444
533
  #### Claude Haiku 5.5
@@ -450,7 +539,7 @@ caffeinate -i npx nightralph claude -m claude-haiku-5-5 --effort high
450
539
  #### Claude Fable 5.1
451
540
 
452
541
  ```sh
453
- caffeinate -i npx nightralph claude -m claude-fable-5-1 --effort low
542
+ caffeinate -i npx nightralph claude -m claude-fable-5-1 --effort medium
454
543
  ```
455
544
 
456
545
  #### `pi` + OpenRouter model
@@ -0,0 +1,149 @@
1
+ var __defProp = Object.defineProperty;
2
+ var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
3
+ const FIELD_RE = /\*\*Blocked by:\*\*/i;
4
+ const NEXT_FIELD_RE = /^\s*(?:[-*]\s+)?\*\*[^*\n]+:\*\*/;
5
+ const HEADING_RE = /^\s*#{1,6}\s/;
6
+ const CHECKLIST_RE = /^\s*[-*+]\s+\[[ xX]\]/;
7
+ const TOKEN_RE = /[A-Za-z0-9_-]+/g;
8
+ function endsField(line) {
9
+ return line.trim() === "" || NEXT_FIELD_RE.test(line) || HEADING_RE.test(line) || CHECKLIST_RE.test(line);
10
+ }
11
+ __name(endsField, "endsField");
12
+ function fieldText(body) {
13
+ const lines = body.split(/\r?\n/);
14
+ const start = lines.findIndex((l) => FIELD_RE.test(l));
15
+ if (start === -1) return null;
16
+ const first = lines[start];
17
+ const m = FIELD_RE.exec(first);
18
+ const parts = [first.slice(m.index + m[0].length)];
19
+ for (const line of lines.slice(start + 1)) {
20
+ if (endsField(line)) break;
21
+ parts.push(line);
22
+ }
23
+ return parts.join("\n");
24
+ }
25
+ __name(fieldText, "fieldText");
26
+ function stripNotes(text) {
27
+ const chars = [...text];
28
+ const drop = new Array(chars.length).fill(false);
29
+ const ticks = [];
30
+ chars.forEach((ch, i) => {
31
+ if (ch === "`") ticks.push(i);
32
+ });
33
+ for (let i = 0; i + 1 < ticks.length; i += 2) {
34
+ for (let j = ticks[i]; j <= ticks[i + 1]; j++) {
35
+ drop[j] = true;
36
+ }
37
+ }
38
+ const open = [];
39
+ chars.forEach((ch, i) => {
40
+ if (drop[i]) return;
41
+ if (ch === "(") {
42
+ open.push(i);
43
+ } else if (ch === ")" && open.length > 0) {
44
+ const from = open.pop();
45
+ for (let j = from; j <= i; j++) drop[j] = true;
46
+ }
47
+ });
48
+ return chars.map((ch, i) => drop[i] ? " " : ch).join("");
49
+ }
50
+ __name(stripNotes, "stripNotes");
51
+ function looksLikeFeature(word) {
52
+ const segments = word.split("/");
53
+ return (segments.length > 1 || word.includes("-")) && /[a-z]/.test(word) && segments.every((s) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(s));
54
+ }
55
+ __name(looksLikeFeature, "looksLikeFeature");
56
+ function wordBefore(text, start) {
57
+ let i = start;
58
+ while (i > 0 && /\s/.test(text[i - 1])) i--;
59
+ if (i === start) return null;
60
+ const end = i;
61
+ while (i > 0 && /[A-Za-z0-9_/-]/.test(text[i - 1])) i--;
62
+ return i === end ? null : text.slice(i, end);
63
+ }
64
+ __name(wordBefore, "wordBefore");
65
+ function parseBlockerField(body, knownFeatures) {
66
+ const text = fieldText(body);
67
+ if (text === null) return { refs: [], typos: [] };
68
+ const clean = stripNotes(text);
69
+ if (/^\s*none\b/i.test(clean)) return { refs: [], typos: [] };
70
+ const refs = [];
71
+ const typos = [];
72
+ const seen = /* @__PURE__ */ new Set();
73
+ const add = /* @__PURE__ */ __name((num, feature) => {
74
+ const id = `${feature ?? ""}:${num}`;
75
+ if (seen.has(id)) return;
76
+ seen.add(id);
77
+ refs.push(feature === void 0 ? { num } : { feature, num });
78
+ }, "add");
79
+ for (const m of clean.matchAll(TOKEN_RE)) {
80
+ const word = m[0];
81
+ if (/^\d+(?:-\d+)*$/.test(word)) {
82
+ const before = wordBefore(clean, m.index ?? 0);
83
+ let feature;
84
+ if (before !== null && knownFeatures.has(before)) {
85
+ feature = before;
86
+ } else if (before !== null && looksLikeFeature(before) && !typos.includes(before)) {
87
+ typos.push(before);
88
+ }
89
+ for (const n of word.split("-").map(Number)) add(n, feature);
90
+ }
91
+ }
92
+ return { refs, typos };
93
+ }
94
+ __name(parseBlockerField, "parseBlockerField");
95
+ function refId(feature, num) {
96
+ return `${feature}:${num}`;
97
+ }
98
+ __name(refId, "refId");
99
+ function resolveBlockers(raw, ownFeature, selected, lookup) {
100
+ const out = [];
101
+ const seen = /* @__PURE__ */ new Set();
102
+ for (const r of raw) {
103
+ const feature = r.feature ?? ownFeature;
104
+ const id = refId(feature, r.num);
105
+ if (seen.has(id)) continue;
106
+ seen.add(id);
107
+ const nums = selected.get(feature);
108
+ if (nums) {
109
+ out.push({
110
+ feature,
111
+ num: r.num,
112
+ where: nums.has(r.num) ? "run" : "missing"
113
+ });
114
+ continue;
115
+ }
116
+ const status = lookup(feature, r.num);
117
+ if (status === void 0) {
118
+ out.push({ feature, num: r.num, where: "missing" });
119
+ } else {
120
+ out.push({
121
+ feature,
122
+ num: r.num,
123
+ where: status === "done" ? "done" : "pending",
124
+ status
125
+ });
126
+ }
127
+ }
128
+ return out;
129
+ }
130
+ __name(resolveBlockers, "resolveBlockers");
131
+ function provisionalRefs(raw, ownFeature) {
132
+ const out = [];
133
+ const seen = /* @__PURE__ */ new Set();
134
+ for (const r of raw) {
135
+ const feature = r.feature ?? ownFeature;
136
+ const id = refId(feature, r.num);
137
+ if (seen.has(id)) continue;
138
+ seen.add(id);
139
+ out.push({ feature, num: r.num, where: "run" });
140
+ }
141
+ return out;
142
+ }
143
+ __name(provisionalRefs, "provisionalRefs");
144
+ export {
145
+ parseBlockerField,
146
+ provisionalRefs,
147
+ resolveBlockers
148
+ };
149
+ //# sourceMappingURL=blockers.js.map
@@ -0,0 +1,7 @@
1
+ {
2
+ "version": 3,
3
+ "sources": ["../src/blockers.ts"],
4
+ "sourcesContent": ["// Blocker references in a ticket's **Blocked by:** field. Pure:\n// no file or process access and no imports, so orchestrator.ts\n// and resolve.ts can both depend on it.\n\nexport type RawRef = {\n feature?:string; // absent -> the ticket's own feature\n num:number;\n}\n\nexport type BlockerParse = {\n refs:RawRef[];\n typos:string[]; // feature-shaped words that name no feature\n}\n\nconst FIELD_RE = /\\*\\*Blocked by:\\*\\*/i\nconst NEXT_FIELD_RE = /^\\s*(?:[-*]\\s+)?\\*\\*[^*\\n]+:\\*\\*/\nconst HEADING_RE = /^\\s*#{1,6}\\s/\nconst CHECKLIST_RE = /^\\s*[-*+]\\s+\\[[ xX]\\]/\nconst TOKEN_RE = /[A-Za-z0-9_-]+/g\n\nfunction endsField (line:string):boolean {\n return line.trim() === '' ||\n NEXT_FIELD_RE.test(line) ||\n HEADING_RE.test(line) ||\n CHECKLIST_RE.test(line)\n}\n\n/**\n * The text of the **Blocked by:** field: the rest of its line\n * plus continuation lines, up to a blank line, the next\n * `**Field:**` line, a heading or a checklist item. `null` when\n * the body has no such field.\n */\nfunction fieldText (body:string):string|null {\n const lines = body.split(/\\r?\\n/)\n const start = lines.findIndex(l => FIELD_RE.test(l))\n if (start === -1) return null\n const first = lines[start]\n const m = FIELD_RE.exec(first)!\n const parts = [first.slice(m.index + m[0].length)]\n for (const line of lines.slice(start + 1)) {\n if (endsField(line)) break\n parts.push(line)\n }\n return parts.join('\\n')\n}\n\n/**\n * Blanks out backtick spans and `(...)` spans (nested, across\n * lines). Only paired delimiters count; an unpaired `(` or\n * backtick stays as text. Each blanked character becomes a space,\n * so the words on either side never merge.\n */\nfunction stripNotes (text:string):string {\n const chars = [...text]\n const drop = new Array<boolean>(chars.length).fill(false)\n const ticks:number[] = []\n chars.forEach((ch, i) => {\n if (ch === '`') ticks.push(i)\n })\n for (let i = 0; i + 1 < ticks.length; i += 2) {\n for (let j = ticks[i]; j <= ticks[i + 1]; j++) {\n drop[j] = true\n }\n }\n const open:number[] = []\n chars.forEach((ch, i) => {\n if (drop[i]) return\n if (ch === '(') {\n open.push(i)\n } else if (ch === ')' && open.length > 0) {\n const from = open.pop()!\n for (let j = from; j <= i; j++) drop[j] = true\n }\n })\n return chars.map((ch, i) => (drop[i] ? ' ' : ch)).join('')\n}\n\n/**\n * A word shaped like a feature dir name: lowercase letters and\n * digits joined by hyphens, or a nested path of such segments\n * joined by `/`, with at least one letter. Doc IDs such as\n * `FDR-018` are not.\n */\nfunction looksLikeFeature (word:string):boolean {\n const segments = word.split('/')\n return (segments.length > 1 || word.includes('-')) &&\n /[a-z]/.test(word) &&\n segments.every(s => /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(s))\n}\n\n/**\n * The word that ends right before `start`, separated from it by\n * whitespace only. A word may contain `/`, so a nested feature such\n * as `rollout/billing` is one word. `null` when the gap is empty or\n * holds anything but whitespace.\n */\nfunction wordBefore (text:string, start:number):string|null {\n let i = start\n while (i > 0 && /\\s/.test(text[i - 1])) i--\n if (i === start) return null\n const end = i\n while (i > 0 && /[A-Za-z0-9_/-]/.test(text[i - 1])) i--\n return i === end ? null : text.slice(i, end)\n}\n\n/**\n * Reads the **Blocked by:** field of a ticket body. A number\n * belongs to the feature named by the word directly before it\n * when that word is in `knownFeatures` (the names of\n * `.scratch/<name>/issues` dirs, `<name>` possibly nested, as in\n * `rollout/billing`); otherwise to the ticket's own feature.\n * Feature-shaped words that name no feature are returned as\n * `typos`.\n */\nexport function parseBlockerField (\n body:string,\n knownFeatures:ReadonlySet<string>\n):BlockerParse {\n const text = fieldText(body)\n if (text === null) return { refs:[], typos:[] }\n const clean = stripNotes(text)\n if (/^\\s*none\\b/i.test(clean)) return { refs:[], typos:[] }\n\n const refs:RawRef[] = []\n const typos:string[] = []\n const seen = new Set<string>()\n const add = (num:number, feature?:string) => {\n const id = `${feature ?? ''}:${num}`\n if (seen.has(id)) return\n seen.add(id)\n refs.push(feature === undefined ? { num } : { feature, num })\n }\n for (const m of clean.matchAll(TOKEN_RE)) {\n const word = m[0]\n if (/^\\d+(?:-\\d+)*$/.test(word)) {\n const before = wordBefore(clean, m.index ?? 0)\n let feature:string|undefined\n if (before !== null && knownFeatures.has(before)) {\n feature = before\n } else if (\n before !== null &&\n looksLikeFeature(before) &&\n !typos.includes(before)\n ) {\n typos.push(before)\n }\n // both ends of a range belong to the same feature\n for (const n of word.split('-').map(Number)) add(n, feature)\n }\n }\n return { refs, typos }\n}\n\nexport type BlockerWhere = 'run'|'done'|'pending'|'missing'\n\nexport type BlockerRef = {\n feature:string;\n num:number;\n // 'run' -> a ticket in this run; its live status decides\n // 'done' -> outside the run, done when loaded\n // 'pending' -> outside the run, not done (dead this session)\n // 'missing' -> no ticket matches (dead, reported)\n where:BlockerWhere;\n status?:string; // the outside ticket's status, for messages\n}\n\n// Same text as ticketKey in orchestrator.ts, which this module\n// must not import.\nfunction refId (feature:string, num:number):string {\n return `${feature}:${num}`\n}\n\n/**\n * Classifies raw refs. `selected` maps each feature in the run to\n * its ticket numbers; `lookup` returns the status of a ticket in a\n * feature outside the run, or undefined when there is none.\n * De-duplicates by (feature, num) after the own feature is filled\n * in.\n */\nexport function resolveBlockers (\n raw:ReadonlyArray<RawRef>,\n ownFeature:string,\n selected:ReadonlyMap<string, ReadonlySet<number>>,\n lookup:(feature:string, num:number) => string|undefined\n):BlockerRef[] {\n const out:BlockerRef[] = []\n const seen = new Set<string>()\n for (const r of raw) {\n const feature = r.feature ?? ownFeature\n const id = refId(feature, r.num)\n if (seen.has(id)) continue\n seen.add(id)\n const nums = selected.get(feature)\n if (nums) {\n out.push({\n feature,\n num:r.num,\n where:nums.has(r.num) ? 'run' : 'missing',\n })\n continue\n }\n const status = lookup(feature, r.num)\n if (status === undefined) {\n out.push({ feature, num:r.num, where:'missing' })\n } else {\n out.push({\n feature,\n num:r.num,\n where:status === 'done' ? 'done' : 'pending',\n status,\n })\n }\n }\n return out\n}\n\n/**\n * Refs for a bare scan, before the run is known: every ref is\n * `run`, so one that matches no ticket in the list is never\n * satisfied.\n */\nexport function provisionalRefs (\n raw:ReadonlyArray<RawRef>,\n ownFeature:string\n):BlockerRef[] {\n const out:BlockerRef[] = []\n const seen = new Set<string>()\n for (const r of raw) {\n const feature = r.feature ?? ownFeature\n const id = refId(feature, r.num)\n if (seen.has(id)) continue\n seen.add(id)\n out.push({ feature, num:r.num, where:'run' })\n }\n return out\n}\n\nexport type BlockerWarning = {\n ticket:string; // ticketLabel of the ticket with the problem\n kind:'missing'|'typo';\n text:string; // the ref label or the unknown word\n}\n"],
5
+ "mappings": "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",
6
+ "names": []
7
+ }
package/dist/display.js CHANGED
@@ -1,5 +1,6 @@
1
1
  var __defProp = Object.defineProperty;
2
2
  var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
3
+ import { formatStats } from "./stats.js";
3
4
  const ESC = "\x1B[";
4
5
  const ansi = {
5
6
  cursorUp(n) {
@@ -60,10 +61,9 @@ const MAX_MESSAGE_BUFFER = 200;
60
61
  const ANSI_RE = /\x1b\[[0-9;?]*[A-Za-z]/g;
61
62
  function rowText(ticket) {
62
63
  const label = ticketLabel(ticket);
63
- if (ticket.status !== "blocked" || !ticket.blockedBy?.length) {
64
- return label;
65
- }
66
- return `${label} (blocked by ${ticket.blockedBy.join(", ")})`;
64
+ const base = ticket.status === "blocked" && ticket.blockedBy?.length ? `${label} (blocked by ${ticket.blockedBy.join(", ")})` : label;
65
+ const suffix = formatStats(ticket.stats);
66
+ return suffix === null ? base : `${base} - ${suffix}`;
67
67
  }
68
68
  __name(rowText, "rowText");
69
69
  function fitLine(line, width) {
@@ -192,6 +192,12 @@ class StatusDisplay {
192
192
  ticket.blockedBy = [...blockedBy];
193
193
  this.render();
194
194
  }
195
+ setTicketStats(num, feature, stats) {
196
+ const ticket = this.findTicket(num, feature);
197
+ if (!ticket) return;
198
+ ticket.stats = { ...stats };
199
+ this.render();
200
+ }
195
201
  log(message) {
196
202
  if (!this.wave) {
197
203
  this.config.stream.write(message + "\n");
@@ -330,6 +336,8 @@ Wave ${waveNum}${pct}: ` + tickets.map((t) => ticketLabel(t)).join(", ")
330
336
  }
331
337
  setBlocked(_num, _feature, _blockedBy) {
332
338
  }
339
+ setTicketStats(_num, _feature, _stats) {
340
+ }
333
341
  log(message) {
334
342
  console.log(message);
335
343
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../src/display.ts"],
4
- "sourcesContent": ["export type TicketStatus =\n | 'pending'\n | 'in-progress'\n | 'awaiting-merge'\n | 'done'\n | 'failed'\n | 'blocked'\n\nexport type DisplayTicket = {\n readonly num:number\n readonly filename:string\n readonly feature?:string\n status:TicketStatus\n blockedBy?:ReadonlyArray<string>\n}\n\nexport type TicketRef = {\n readonly num:number\n readonly filename:string\n readonly feature?:string\n}\n\nexport type WaveState = {\n readonly waveNum:number\n readonly totalWaves:number\n}\n\nexport type DisplayConfig = {\n readonly stream:NodeJS.WriteStream\n}\n\n/**\n * Progress across the whole run, not just the current wave.\n * `completed` counts this run's tickets done before the wave\n * started; `total` is the size of this run's ticket list. Only\n * PlainDisplay prints it; StatusDisplay counts from its own list.\n */\nexport type RunProgress = {\n readonly completed:number\n readonly total:number\n}\n\nexport type Display = {\n /**\n * Called once at the start of the run, fixes the ticket list\n * for the entire session.\n */\n startRun(tickets:ReadonlyArray<TicketRef>):void\n startWave(\n waveNum:number,\n totalWaves:number,\n tickets:ReadonlyArray<TicketRef>,\n progress?:RunProgress,\n ):void\n setTicketStatus(\n num:number,\n status:TicketStatus,\n feature?:string,\n ):void\n /**\n * Mark a ticket as blocked; `blockedBy` holds display labels\n * of the upstream tickets that failed or need a human.\n */\n setBlocked(\n num:number,\n feature:string|undefined,\n blockedBy:ReadonlyArray<string>,\n ):void\n log(message:string):void\n cleanup():void\n}\n\nconst ESC = '\\x1b['\n\nexport const ansi = {\n cursorUp (n:number):string {\n return `${ESC}${n}A`\n },\n cursorTo (col:number):string {\n return `${ESC}${col}G`\n },\n eraseLine ():string {\n return `${ESC}2K`\n },\n eraseLines (n:number):string {\n let out = ''\n for (let i = 0; i < n; i++) {\n out += ansi.eraseLine()\n if (i < n - 1) out += ansi.cursorUp(1)\n }\n return out\n },\n cursorHide ():string {\n return `${ESC}?25l`\n },\n cursorShow ():string {\n return `${ESC}?25h`\n },\n} as const\n\nexport const color = {\n green (text:string):string {\n return `${ESC}32m${text}${ESC}0m`\n },\n yellow (text:string):string {\n return `${ESC}33m${text}${ESC}0m`\n },\n red (text:string):string {\n return `${ESC}31m${text}${ESC}0m`\n },\n cyan (text:string):string {\n return `${ESC}36m${text}${ESC}0m`\n },\n dim (text:string):string {\n return `${ESC}2m${text}${ESC}0m`\n },\n} as const\n\nexport function ticketLabel (\n ticket:{ num:number; filename:string; feature?:string },\n):string {\n if (ticket.feature) {\n const padded = String(ticket.num).padStart(2, '0')\n return `${ticket.feature}/${padded}`\n }\n return ticket.filename\n}\n\nconst FILLED_DOT = '\u25CF'\nconst EMPTY_DOT = '\u25CB'\nconst SEPARATOR_WIDTH = 50\nconst DEFAULT_COLUMNS = 80\nconst MIN_LOG_ROWS = 1\nconst MAX_MESSAGE_BUFFER = 200\n\n// eslint-disable-next-line no-control-regex\nconst ANSI_RE = /\\x1b\\[[0-9;?]*[A-Za-z]/g\n\nfunction rowText (ticket:DisplayTicket):string {\n const label = ticketLabel(ticket)\n if (ticket.status !== 'blocked' || !ticket.blockedBy?.length) {\n return label\n }\n return `${label} (blocked by ${ticket.blockedBy.join(', ')})`\n}\n\n/**\n * Clamp a line to `width` visible columns so it can never wrap.\n * A wrapped line occupies more terminal rows than `render`\n * counts, which leaves stale rows behind on the next erase.\n */\nexport function fitLine (line:string, width:number):string {\n const visible = line.replace(ANSI_RE, '')\n if (visible.length <= width) return line\n return visible.slice(0, width)\n}\n\n/**\n * Split a line into rows of at most `width` visible columns,\n * the way a terminal would wrap it. Short lines keep their\n * ANSI styling; long lines are wrapped on visible text only,\n * so `render` can count every row it emits.\n */\nexport function wrapLine (line:string, width:number):Array<string> {\n const visible = line.replace(ANSI_RE, '')\n if (visible.length <= width) return [line]\n const rows:Array<string> = []\n for (let i = 0; i < visible.length; i += width) {\n rows.push(visible.slice(i, i + width))\n }\n return rows\n}\n\nexport function renderDot (status:TicketStatus):string {\n switch (status) {\n case 'pending':\n return color.dim(EMPTY_DOT)\n case 'in-progress':\n return color.yellow(FILLED_DOT)\n // The agent and its test gate are finished; the branch\n // waits for the rest of the wave before it is merged. It\n // stays cyan through the merge and any conflict respawn.\n case 'awaiting-merge':\n return color.cyan(FILLED_DOT)\n case 'done':\n return color.green(FILLED_DOT)\n case 'failed':\n return color.red(FILLED_DOT)\n case 'blocked':\n // Ticket cannot run this session because an upstream\n // ticket failed or needs a human.\n return color.dim(FILLED_DOT)\n }\n}\n\nexport class StatusDisplay implements Display {\n private wave:WaveState|null = null\n private tickets:Array<DisplayTicket> = []\n private hasRun:boolean = false\n private messages:Array<string> = []\n private renderedLines:number = 0\n private config:DisplayConfig\n private viewportStart:number = 0\n private resizeListener:(() => void)|null = null\n\n constructor (config?:Readonly<Partial<DisplayConfig>>) {\n this.config = {\n stream:config?.stream ?? process.stdout,\n }\n }\n\n private getAvailableRows ():number {\n const termHeight = this.config.stream.rows ?? 24\n const overhead = 2 + MIN_LOG_ROWS\n const available = termHeight - overhead\n return Math.max(1, available)\n }\n\n private findTicket (\n num:number,\n feature?:string,\n ):DisplayTicket|null {\n const found = feature ?\n this.tickets.find(\n t => t.num === num && t.feature === feature,\n ) :\n this.tickets.find(t => t.num === num)\n return found ?? null\n }\n\n startRun (tickets:ReadonlyArray<TicketRef>):void {\n this.tickets = tickets.map(t => ({\n ...t,\n status:'pending' as const,\n }))\n this.hasRun = true\n this.viewportStart = 0\n }\n\n startWave (\n waveNum:number,\n totalWaves:number,\n tickets:ReadonlyArray<TicketRef>,\n _progress?:RunProgress,\n ):void {\n if (!this.hasRun) {\n this.startRun(tickets)\n }\n\n this.wave = {\n waveNum,\n totalWaves,\n }\n this.messages = []\n this.viewportStart = 0\n this.config.stream.write(ansi.cursorHide())\n\n if (this.config.stream.isTTY) {\n if (this.resizeListener) {\n this.config.stream.removeListener(\n 'resize', this.resizeListener,\n )\n }\n this.resizeListener = () => this.render()\n this.config.stream.on(\n 'resize', this.resizeListener,\n )\n }\n\n this.render()\n }\n\n setTicketStatus (\n num:number,\n status:TicketStatus,\n feature?:string,\n ):void {\n const ticket = this.findTicket(num, feature)\n if (!ticket) return\n\n ticket.status = status\n\n if (status === 'in-progress') {\n const ticketIndex = this.tickets.indexOf(ticket)\n const availableRows = this.getAvailableRows()\n const totalTickets = this.tickets.length\n\n if (ticketIndex < this.viewportStart) {\n this.viewportStart = ticketIndex\n } else if (ticketIndex >= this.viewportStart + availableRows) {\n // offset by 3 to keep context visible below\n // the active ticket\n this.viewportStart = ticketIndex - availableRows + 3\n }\n this.viewportStart = Math.max(\n 0,\n Math.min(this.viewportStart, totalTickets - 1),\n )\n }\n\n this.render()\n }\n\n setBlocked (\n num:number,\n feature:string|undefined,\n blockedBy:ReadonlyArray<string>,\n ):void {\n const ticket = this.findTicket(num, feature)\n if (!ticket) return\n\n ticket.status = 'blocked'\n ticket.blockedBy = [...blockedBy]\n this.render()\n }\n\n log (message:string):void {\n // Before the first wave there is no grid to repaint,\n // so a message would otherwise be dropped. Write it\n // straight to the stream instead. A later render\n // never erases these rows, since renderedLines is 0.\n if (!this.wave) {\n this.config.stream.write(message + '\\n')\n return\n }\n for (const line of message.split('\\n')) {\n this.messages.push(line)\n }\n while (this.messages.length > MAX_MESSAGE_BUFFER) {\n this.messages.shift()\n }\n this.render()\n }\n\n cleanup ():void {\n // Remove resize listener if it exists\n if (this.resizeListener && this.config.stream.isTTY) {\n this.config.stream.removeListener('resize', this.resizeListener)\n }\n this.config.stream.write(ansi.cursorShow())\n }\n\n private render ():void {\n if (!this.wave) return\n\n let output = ''\n\n if (this.renderedLines > 0) {\n output += ansi.eraseLines(this.renderedLines + 1)\n }\n\n const done = this.tickets.filter(\n t => t.status === 'done',\n ).length\n const total = this.tickets.length\n const pct = total === 0 ? 0 : Math.round((done / total) * 100)\n const width = Math.max(\n 1,\n (this.config.stream.columns ?? DEFAULT_COLUMNS) - 1,\n )\n\n const header = `Wave ${this.wave.waveNum}/` +\n `${this.wave.totalWaves} - ` +\n `${done}/${total} done - ${pct}%`\n output += fitLine(header, width) + '\\n'\n\n // Pagination logic\n const availableRows = this.getAvailableRows()\n let renderedTicketCount = 0\n\n if (this.tickets.length <= availableRows) {\n // All tickets fit, no pagination\n for (const ticket of this.tickets) {\n const dot = renderDot(ticket.status)\n const text = rowText(ticket)\n const name = fitLine(text, width - 2)\n output += `${dot} ${name}\\n`\n }\n renderedTicketCount = this.tickets.length\n } else {\n // Need pagination -- iterative clamp\n this.viewportStart = Math.max(\n 0,\n Math.min(\n this.viewportStart,\n this.tickets.length - 1,\n ),\n )\n\n let hasMoreAbove = this.viewportStart > 0\n let tentativeCount =\n availableRows - (hasMoreAbove ? 1 : 0)\n let hasMoreBelow =\n this.viewportStart + tentativeCount <\n this.tickets.length\n let ticketsToShow = Math.max(\n 0,\n tentativeCount - (hasMoreBelow ? 1 : 0),\n )\n\n const maxStart = Math.max(\n 0,\n this.tickets.length - Math.max(1, ticketsToShow),\n )\n if (this.viewportStart > maxStart) {\n this.viewportStart = maxStart\n hasMoreAbove = this.viewportStart > 0\n tentativeCount = availableRows - (hasMoreAbove ? 1 : 0)\n hasMoreBelow = (\n this.viewportStart +\n tentativeCount < this.tickets.length\n )\n ticketsToShow = Math.max(\n 0,\n tentativeCount - (hasMoreBelow ? 1 : 0)\n )\n }\n\n // Show \"more above\" indicator if needed\n if (hasMoreAbove) {\n const moreCount = this.viewportStart\n output += ` ... ${moreCount} more above\\n`\n }\n\n // Get visible tickets and render them\n const visibleTickets = this.tickets.slice(\n this.viewportStart,\n this.viewportStart + ticketsToShow,\n )\n\n for (const ticket of visibleTickets) {\n const dot = renderDot(ticket.status)\n const text = rowText(ticket)\n const name = fitLine(text, width - 2)\n output += `${dot} ${name}\\n`\n }\n\n // Show \"more below\" indicator if needed\n if (hasMoreBelow) {\n const moreCount = (\n this.tickets.length -\n (this.viewportStart + ticketsToShow)\n )\n output += ` ... ${moreCount} more below\\n`\n }\n\n renderedTicketCount = (hasMoreAbove ? 1 : 0) +\n visibleTickets.length +\n (hasMoreBelow ? 1 : 0)\n }\n\n output += '-'.repeat(Math.min(SEPARATOR_WIDTH, width)) + '\\n'\n\n const termHeight = this.config.stream.rows ?? 24\n const logRows = Math.max(\n 0,\n termHeight - 3 - renderedTicketCount,\n )\n const wrapped = this.messages.flatMap(\n msg => wrapLine(msg, width),\n )\n const visibleMessages = wrapped.slice(-logRows)\n for (const row of visibleMessages) {\n output += `${row}\\n`\n }\n const emptyRows = logRows - visibleMessages.length\n for (let i = 0; i < emptyRows; i++) {\n output += '\\n'\n }\n\n this.renderedLines = 2 + renderedTicketCount + logRows\n this.config.stream.write(output)\n }\n}\n\nexport class PlainDisplay implements Display {\n startRun (_tickets:ReadonlyArray<TicketRef>):void {\n // no-op: plain mode has no fixed list\n }\n\n startWave (\n waveNum:number,\n _totalWaves:number,\n tickets:ReadonlyArray<TicketRef>,\n progress?:RunProgress,\n ):void {\n const pct = progress && progress.total > 0 ?\n ` (${progress.completed}/${progress.total} done)` :\n ''\n console.log(\n `\\nWave ${waveNum}${pct}: ` +\n tickets.map(t => ticketLabel(t)).join(', ')\n )\n }\n\n setTicketStatus (\n _num:number,\n _status:TicketStatus,\n _feature?:string,\n ):void {\n // no-op: plain mode reports via log()\n }\n\n setBlocked (\n _num:number,\n _feature:string|undefined,\n _blockedBy:ReadonlyArray<string>,\n ):void {\n // no-op: plain mode has no fixed list\n }\n\n log (message:string):void {\n console.log(message)\n }\n\n cleanup ():void {\n // no-op\n }\n}\n\nexport function createDisplay (stream?:NodeJS.WriteStream):Display {\n const s = stream ?? process.stdout\n if (s.isTTY) {\n return new StatusDisplay({ stream:s })\n }\n\n return new PlainDisplay()\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { formatStats, type SubagentStats } from './stats.js'\n\nexport type TicketStatus =\n | 'pending'\n | 'in-progress'\n | 'awaiting-merge'\n | 'done'\n | 'failed'\n | 'blocked'\n\nexport type DisplayTicket = {\n readonly num:number\n readonly filename:string\n readonly feature?:string\n status:TicketStatus\n blockedBy?:ReadonlyArray<string>\n stats?:SubagentStats\n}\n\nexport type TicketRef = {\n readonly num:number\n readonly filename:string\n readonly feature?:string\n}\n\nexport type WaveState = {\n readonly waveNum:number\n readonly totalWaves:number\n}\n\nexport type DisplayConfig = {\n readonly stream:NodeJS.WriteStream\n}\n\n/**\n * Progress across the whole run, not just the current wave.\n * `completed` counts this run's tickets done before the wave\n * started; `total` is the size of this run's ticket list. Only\n * PlainDisplay prints it; StatusDisplay counts from its own list.\n */\nexport type RunProgress = {\n readonly completed:number\n readonly total:number\n}\n\nexport type Display = {\n /**\n * Called once at the start of the run, fixes the ticket list\n * for the entire session.\n */\n startRun(tickets:ReadonlyArray<TicketRef>):void\n startWave(\n waveNum:number,\n totalWaves:number,\n tickets:ReadonlyArray<TicketRef>,\n progress?:RunProgress,\n ):void\n setTicketStatus(\n num:number,\n status:TicketStatus,\n feature?:string,\n ):void\n /**\n * Mark a ticket as blocked; `blockedBy` holds display labels\n * of the upstream tickets that failed or need a human.\n */\n setBlocked(\n num:number,\n feature:string|undefined,\n blockedBy:ReadonlyArray<string>,\n ):void\n /**\n * Subagent stats for a ticket's row: the running total of\n * its attempts so far.\n */\n setTicketStats(\n num:number,\n feature:string|undefined,\n stats:SubagentStats,\n ):void\n log(message:string):void\n cleanup():void\n}\n\nconst ESC = '\\x1b['\n\nexport const ansi = {\n cursorUp (n:number):string {\n return `${ESC}${n}A`\n },\n cursorTo (col:number):string {\n return `${ESC}${col}G`\n },\n eraseLine ():string {\n return `${ESC}2K`\n },\n eraseLines (n:number):string {\n let out = ''\n for (let i = 0; i < n; i++) {\n out += ansi.eraseLine()\n if (i < n - 1) out += ansi.cursorUp(1)\n }\n return out\n },\n cursorHide ():string {\n return `${ESC}?25l`\n },\n cursorShow ():string {\n return `${ESC}?25h`\n },\n} as const\n\nexport const color = {\n green (text:string):string {\n return `${ESC}32m${text}${ESC}0m`\n },\n yellow (text:string):string {\n return `${ESC}33m${text}${ESC}0m`\n },\n red (text:string):string {\n return `${ESC}31m${text}${ESC}0m`\n },\n cyan (text:string):string {\n return `${ESC}36m${text}${ESC}0m`\n },\n dim (text:string):string {\n return `${ESC}2m${text}${ESC}0m`\n },\n} as const\n\nexport function ticketLabel (\n ticket:{ num:number; filename:string; feature?:string },\n):string {\n if (ticket.feature) {\n const padded = String(ticket.num).padStart(2, '0')\n return `${ticket.feature}/${padded}`\n }\n return ticket.filename\n}\n\nconst FILLED_DOT = '\u25CF'\nconst EMPTY_DOT = '\u25CB'\nconst SEPARATOR_WIDTH = 50\nconst DEFAULT_COLUMNS = 80\nconst MIN_LOG_ROWS = 1\nconst MAX_MESSAGE_BUFFER = 200\n\n// eslint-disable-next-line no-control-regex\nconst ANSI_RE = /\\x1b\\[[0-9;?]*[A-Za-z]/g\n\nfunction rowText (ticket:DisplayTicket):string {\n const label = ticketLabel(ticket)\n const base = ticket.status === 'blocked' &&\n ticket.blockedBy?.length ?\n `${label} (blocked by ${ticket.blockedBy.join(', ')})` :\n label\n const suffix = formatStats(ticket.stats)\n return suffix === null ? base : `${base} - ${suffix}`\n}\n\n/**\n * Clamp a line to `width` visible columns so it can never wrap.\n * A wrapped line occupies more terminal rows than `render`\n * counts, which leaves stale rows behind on the next erase.\n */\nexport function fitLine (line:string, width:number):string {\n const visible = line.replace(ANSI_RE, '')\n if (visible.length <= width) return line\n return visible.slice(0, width)\n}\n\n/**\n * Split a line into rows of at most `width` visible columns,\n * the way a terminal would wrap it. Short lines keep their\n * ANSI styling; long lines are wrapped on visible text only,\n * so `render` can count every row it emits.\n */\nexport function wrapLine (line:string, width:number):Array<string> {\n const visible = line.replace(ANSI_RE, '')\n if (visible.length <= width) return [line]\n const rows:Array<string> = []\n for (let i = 0; i < visible.length; i += width) {\n rows.push(visible.slice(i, i + width))\n }\n return rows\n}\n\nexport function renderDot (status:TicketStatus):string {\n switch (status) {\n case 'pending':\n return color.dim(EMPTY_DOT)\n case 'in-progress':\n return color.yellow(FILLED_DOT)\n // The agent and its test gate are finished; the branch\n // waits for the rest of the wave before it is merged. It\n // stays cyan through the merge and any conflict respawn.\n case 'awaiting-merge':\n return color.cyan(FILLED_DOT)\n case 'done':\n return color.green(FILLED_DOT)\n case 'failed':\n return color.red(FILLED_DOT)\n case 'blocked':\n // Ticket cannot run this session because an upstream\n // ticket failed or needs a human.\n return color.dim(FILLED_DOT)\n }\n}\n\nexport class StatusDisplay implements Display {\n private wave:WaveState|null = null\n private tickets:Array<DisplayTicket> = []\n private hasRun:boolean = false\n private messages:Array<string> = []\n private renderedLines:number = 0\n private config:DisplayConfig\n private viewportStart:number = 0\n private resizeListener:(() => void)|null = null\n\n constructor (config?:Readonly<Partial<DisplayConfig>>) {\n this.config = {\n stream:config?.stream ?? process.stdout,\n }\n }\n\n private getAvailableRows ():number {\n const termHeight = this.config.stream.rows ?? 24\n const overhead = 2 + MIN_LOG_ROWS\n const available = termHeight - overhead\n return Math.max(1, available)\n }\n\n private findTicket (\n num:number,\n feature?:string,\n ):DisplayTicket|null {\n const found = feature ?\n this.tickets.find(\n t => t.num === num && t.feature === feature,\n ) :\n this.tickets.find(t => t.num === num)\n return found ?? null\n }\n\n startRun (tickets:ReadonlyArray<TicketRef>):void {\n this.tickets = tickets.map(t => ({\n ...t,\n status:'pending' as const,\n }))\n this.hasRun = true\n this.viewportStart = 0\n }\n\n startWave (\n waveNum:number,\n totalWaves:number,\n tickets:ReadonlyArray<TicketRef>,\n _progress?:RunProgress,\n ):void {\n if (!this.hasRun) {\n this.startRun(tickets)\n }\n\n this.wave = {\n waveNum,\n totalWaves,\n }\n this.messages = []\n this.viewportStart = 0\n this.config.stream.write(ansi.cursorHide())\n\n if (this.config.stream.isTTY) {\n if (this.resizeListener) {\n this.config.stream.removeListener(\n 'resize', this.resizeListener,\n )\n }\n this.resizeListener = () => this.render()\n this.config.stream.on(\n 'resize', this.resizeListener,\n )\n }\n\n this.render()\n }\n\n setTicketStatus (\n num:number,\n status:TicketStatus,\n feature?:string,\n ):void {\n const ticket = this.findTicket(num, feature)\n if (!ticket) return\n\n ticket.status = status\n\n if (status === 'in-progress') {\n const ticketIndex = this.tickets.indexOf(ticket)\n const availableRows = this.getAvailableRows()\n const totalTickets = this.tickets.length\n\n if (ticketIndex < this.viewportStart) {\n this.viewportStart = ticketIndex\n } else if (ticketIndex >= this.viewportStart + availableRows) {\n // offset by 3 to keep context visible below\n // the active ticket\n this.viewportStart = ticketIndex - availableRows + 3\n }\n this.viewportStart = Math.max(\n 0,\n Math.min(this.viewportStart, totalTickets - 1),\n )\n }\n\n this.render()\n }\n\n setBlocked (\n num:number,\n feature:string|undefined,\n blockedBy:ReadonlyArray<string>,\n ):void {\n const ticket = this.findTicket(num, feature)\n if (!ticket) return\n\n ticket.status = 'blocked'\n ticket.blockedBy = [...blockedBy]\n this.render()\n }\n\n setTicketStats (\n num:number,\n feature:string|undefined,\n stats:SubagentStats,\n ):void {\n const ticket = this.findTicket(num, feature)\n if (!ticket) return\n\n ticket.stats = { ...stats }\n this.render()\n }\n\n log (message:string):void {\n // Before the first wave there is no grid to repaint,\n // so a message would otherwise be dropped. Write it\n // straight to the stream instead. A later render\n // never erases these rows, since renderedLines is 0.\n if (!this.wave) {\n this.config.stream.write(message + '\\n')\n return\n }\n for (const line of message.split('\\n')) {\n this.messages.push(line)\n }\n while (this.messages.length > MAX_MESSAGE_BUFFER) {\n this.messages.shift()\n }\n this.render()\n }\n\n cleanup ():void {\n // Remove resize listener if it exists\n if (this.resizeListener && this.config.stream.isTTY) {\n this.config.stream.removeListener('resize', this.resizeListener)\n }\n this.config.stream.write(ansi.cursorShow())\n }\n\n private render ():void {\n if (!this.wave) return\n\n let output = ''\n\n if (this.renderedLines > 0) {\n output += ansi.eraseLines(this.renderedLines + 1)\n }\n\n const done = this.tickets.filter(\n t => t.status === 'done',\n ).length\n const total = this.tickets.length\n const pct = total === 0 ? 0 : Math.round((done / total) * 100)\n const width = Math.max(\n 1,\n (this.config.stream.columns ?? DEFAULT_COLUMNS) - 1,\n )\n\n const header = `Wave ${this.wave.waveNum}/` +\n `${this.wave.totalWaves} - ` +\n `${done}/${total} done - ${pct}%`\n output += fitLine(header, width) + '\\n'\n\n // Pagination logic\n const availableRows = this.getAvailableRows()\n let renderedTicketCount = 0\n\n if (this.tickets.length <= availableRows) {\n // All tickets fit, no pagination\n for (const ticket of this.tickets) {\n const dot = renderDot(ticket.status)\n const text = rowText(ticket)\n const name = fitLine(text, width - 2)\n output += `${dot} ${name}\\n`\n }\n renderedTicketCount = this.tickets.length\n } else {\n // Need pagination -- iterative clamp\n this.viewportStart = Math.max(\n 0,\n Math.min(\n this.viewportStart,\n this.tickets.length - 1,\n ),\n )\n\n let hasMoreAbove = this.viewportStart > 0\n let tentativeCount =\n availableRows - (hasMoreAbove ? 1 : 0)\n let hasMoreBelow =\n this.viewportStart + tentativeCount <\n this.tickets.length\n let ticketsToShow = Math.max(\n 0,\n tentativeCount - (hasMoreBelow ? 1 : 0),\n )\n\n const maxStart = Math.max(\n 0,\n this.tickets.length - Math.max(1, ticketsToShow),\n )\n if (this.viewportStart > maxStart) {\n this.viewportStart = maxStart\n hasMoreAbove = this.viewportStart > 0\n tentativeCount = availableRows - (hasMoreAbove ? 1 : 0)\n hasMoreBelow = (\n this.viewportStart +\n tentativeCount < this.tickets.length\n )\n ticketsToShow = Math.max(\n 0,\n tentativeCount - (hasMoreBelow ? 1 : 0)\n )\n }\n\n // Show \"more above\" indicator if needed\n if (hasMoreAbove) {\n const moreCount = this.viewportStart\n output += ` ... ${moreCount} more above\\n`\n }\n\n // Get visible tickets and render them\n const visibleTickets = this.tickets.slice(\n this.viewportStart,\n this.viewportStart + ticketsToShow,\n )\n\n for (const ticket of visibleTickets) {\n const dot = renderDot(ticket.status)\n const text = rowText(ticket)\n const name = fitLine(text, width - 2)\n output += `${dot} ${name}\\n`\n }\n\n // Show \"more below\" indicator if needed\n if (hasMoreBelow) {\n const moreCount = (\n this.tickets.length -\n (this.viewportStart + ticketsToShow)\n )\n output += ` ... ${moreCount} more below\\n`\n }\n\n renderedTicketCount = (hasMoreAbove ? 1 : 0) +\n visibleTickets.length +\n (hasMoreBelow ? 1 : 0)\n }\n\n output += '-'.repeat(Math.min(SEPARATOR_WIDTH, width)) + '\\n'\n\n const termHeight = this.config.stream.rows ?? 24\n const logRows = Math.max(\n 0,\n termHeight - 3 - renderedTicketCount,\n )\n const wrapped = this.messages.flatMap(\n msg => wrapLine(msg, width),\n )\n const visibleMessages = wrapped.slice(-logRows)\n for (const row of visibleMessages) {\n output += `${row}\\n`\n }\n const emptyRows = logRows - visibleMessages.length\n for (let i = 0; i < emptyRows; i++) {\n output += '\\n'\n }\n\n this.renderedLines = 2 + renderedTicketCount + logRows\n this.config.stream.write(output)\n }\n}\n\nexport class PlainDisplay implements Display {\n startRun (_tickets:ReadonlyArray<TicketRef>):void {\n // no-op: plain mode has no fixed list\n }\n\n startWave (\n waveNum:number,\n _totalWaves:number,\n tickets:ReadonlyArray<TicketRef>,\n progress?:RunProgress,\n ):void {\n const pct = progress && progress.total > 0 ?\n ` (${progress.completed}/${progress.total} done)` :\n ''\n console.log(\n `\\nWave ${waveNum}${pct}: ` +\n tickets.map(t => ticketLabel(t)).join(', ')\n )\n }\n\n setTicketStatus (\n _num:number,\n _status:TicketStatus,\n _feature?:string,\n ):void {\n // no-op: plain mode reports via log()\n }\n\n setBlocked (\n _num:number,\n _feature:string|undefined,\n _blockedBy:ReadonlyArray<string>,\n ):void {\n // no-op: plain mode has no fixed list\n }\n\n setTicketStats (\n _num:number,\n _feature:string|undefined,\n _stats:SubagentStats,\n ):void {\n // no-op: plain mode has no fixed list\n }\n\n log (message:string):void {\n console.log(message)\n }\n\n cleanup ():void {\n // no-op\n }\n}\n\nexport function createDisplay (stream?:NodeJS.WriteStream):Display {\n const s = stream ?? process.stdout\n if (s.isTTY) {\n return new StatusDisplay({ stream:s })\n }\n\n return new PlainDisplay()\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -13,7 +13,15 @@ Issues and specs for this repo live as markdown files in `.scratch/`.
13
13
  issue file (see `triage-labels.md` for the role strings)
14
14
  - Dependencies are recorded as a `**Blocked by:**` line near the top of
15
15
  each issue file, listing the numbers of the tickets that must complete
16
- first, or `None`
16
+ first, or `None`. A ticket in another feature is `<feature> <NN>`,
17
+ where `<feature>` is its `.scratch/<feature>/` directory name
18
+ (a path such as `rollout/billing` for a nested feature),
19
+ repeated before each of its numbers. List every number: `03-05`
20
+ means 3 and 5, and a feature name before it applies to both. Notes
21
+ in parentheses are ignored. The list may wrap onto the next lines
22
+ and ends at a blank line, the next `**Field:**` line, a heading or
23
+ a checklist item. Example:
24
+ `**Blocked by:** 01, 02, newsletter-audit 16 (shares the import route)`
17
25
  - Comments and conversation history append to the bottom of the file under
18
26
  a `## Comments` heading
19
27