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 +100 -11
- package/dist/blockers.js +149 -0
- package/dist/blockers.js.map +7 -0
- package/dist/display.js +12 -4
- package/dist/display.js.map +2 -2
- package/dist/docs-templates/issue-tracker.md +9 -1
- package/dist/index.js +740 -137
- package/dist/index.js.map +4 -4
- package/dist/learnings.js +4 -0
- package/dist/learnings.js.map +2 -2
- package/dist/meta.json +69 -16
- package/dist/orchestrator.js +325 -93
- package/dist/orchestrator.js.map +2 -2
- package/dist/resolve.js +98 -9
- package/dist/resolve.js.map +2 -2
- package/dist/review.js +9 -2
- package/dist/review.js.map +2 -2
- package/dist/rules.js +13 -0
- package/dist/rules.js.map +2 -2
- package/dist/skills/to-tickets/SKILL.md +5 -5
- package/dist/src/blockers.d.ts +45 -0
- package/dist/src/blockers.d.ts.map +1 -0
- package/dist/src/display.d.ts +9 -0
- package/dist/src/display.d.ts.map +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/learnings.d.ts +2 -0
- package/dist/src/learnings.d.ts.map +1 -1
- package/dist/src/orchestrator.d.ts +71 -17
- package/dist/src/orchestrator.d.ts.map +1 -1
- package/dist/src/resolve.d.ts +24 -0
- package/dist/src/resolve.d.ts.map +1 -1
- package/dist/src/review.d.ts +4 -0
- package/dist/src/review.d.ts.map +1 -1
- package/dist/src/rules.d.ts +1 -0
- package/dist/src/rules.d.ts.map +1 -1
- package/dist/src/stats.d.ts +43 -0
- package/dist/src/stats.d.ts.map +1 -0
- package/dist/stats.js +111 -0
- package/dist/stats.js.map +7 -0
- package/package.json +3 -3
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
542
|
+
caffeinate -i npx nightralph claude -m claude-fable-5-1 --effort medium
|
|
454
543
|
```
|
|
455
544
|
|
|
456
545
|
#### `pi` + OpenRouter model
|
package/dist/blockers.js
ADDED
|
@@ -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
|
-
|
|
64
|
-
|
|
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
|
}
|
package/dist/display.js.map
CHANGED
|
@@ -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": ";;
|
|
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
|
|