nightralph 0.0.53 → 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.
Files changed (60) hide show
  1. package/README.md +181 -19
  2. package/dist/blockers.js +149 -0
  3. package/dist/blockers.js.map +7 -0
  4. package/dist/display.js +12 -4
  5. package/dist/display.js.map +2 -2
  6. package/dist/docs-templates/issue-tracker.md +9 -1
  7. package/dist/index.js +2735 -415
  8. package/dist/index.js.map +4 -4
  9. package/dist/learnings.js +360 -0
  10. package/dist/learnings.js.map +7 -0
  11. package/dist/meta.json +289 -16
  12. package/dist/orchestrator.js +753 -188
  13. package/dist/orchestrator.js.map +3 -3
  14. package/dist/preflight.js +34 -0
  15. package/dist/preflight.js.map +7 -0
  16. package/dist/progress.js +5 -4
  17. package/dist/progress.js.map +2 -2
  18. package/dist/resolve.js +98 -9
  19. package/dist/resolve.js.map +2 -2
  20. package/dist/retry.js +83 -0
  21. package/dist/retry.js.map +7 -0
  22. package/dist/review.js +669 -0
  23. package/dist/review.js.map +7 -0
  24. package/dist/rules.js +67 -0
  25. package/dist/rules.js.map +7 -0
  26. package/dist/skills/to-tickets/SKILL.md +5 -5
  27. package/dist/src/blockers.d.ts +45 -0
  28. package/dist/src/blockers.d.ts.map +1 -0
  29. package/dist/src/display.d.ts +9 -0
  30. package/dist/src/display.d.ts.map +1 -1
  31. package/dist/src/index.d.ts.map +1 -1
  32. package/dist/src/learnings.d.ts +70 -0
  33. package/dist/src/learnings.d.ts.map +1 -0
  34. package/dist/src/orchestrator.d.ts +85 -28
  35. package/dist/src/orchestrator.d.ts.map +1 -1
  36. package/dist/src/preflight.d.ts +6 -0
  37. package/dist/src/preflight.d.ts.map +1 -0
  38. package/dist/src/progress.d.ts +1 -1
  39. package/dist/src/progress.d.ts.map +1 -1
  40. package/dist/src/resolve.d.ts +24 -0
  41. package/dist/src/resolve.d.ts.map +1 -1
  42. package/dist/src/retry.d.ts +26 -0
  43. package/dist/src/retry.d.ts.map +1 -0
  44. package/dist/src/review.d.ts +131 -0
  45. package/dist/src/review.d.ts.map +1 -0
  46. package/dist/src/rules.d.ts +11 -0
  47. package/dist/src/rules.d.ts.map +1 -0
  48. package/dist/src/stats.d.ts +43 -0
  49. package/dist/src/stats.d.ts.map +1 -0
  50. package/dist/src/testcmd.d.ts +1 -0
  51. package/dist/src/testcmd.d.ts.map +1 -1
  52. package/dist/src/worktree.d.ts +30 -1
  53. package/dist/src/worktree.d.ts.map +1 -1
  54. package/dist/stats.js +111 -0
  55. package/dist/stats.js.map +7 -0
  56. package/dist/testcmd.js +13 -0
  57. package/dist/testcmd.js.map +2 -2
  58. package/dist/worktree.js +236 -32
  59. package/dist/worktree.js.map +2 -2
  60. package/package.json +3 -2
package/README.md CHANGED
@@ -22,22 +22,28 @@ so they can't drift as long as you install the skills with this package.
22
22
  <!-- toc -->
23
23
 
24
24
  - [Install](#install)
25
+ * [Requirements](#requirements)
25
26
  * [Install Dependency Skills](#install-dependency-skills)
26
27
  - [Workflow](#workflow)
27
28
  * [Create Issues](#create-issues)
28
- * [Examples](#examples)
29
- * [Interface](#interface)
30
- + [`--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)
31
34
  - [How It Works](#how-it-works)
32
35
  * [Select a ticket](#select-a-ticket)
33
36
  * [Worktrees](#worktrees)
34
37
  * [Progress](#progress)
38
+ * [Integration branch](#integration-branch)
39
+ * [Review](#review)
35
40
  * [Status display](#status-display)
36
41
  - [Keep Your Machine Awake](#keep-your-machine-awake)
37
42
  * [Mac OS](#mac-os)
38
43
  + [Claude Opus 4.6](#claude-opus-46)
39
44
  + [Claude Opus 5.5](#claude-opus-55)
40
- + [Claude Sonnet 5](#claude-sonnet-5)
45
+ + [Claude Sonnet 5.5](#claude-sonnet-55)
46
+ + [Claude Haiku 5.5](#claude-haiku-55)
41
47
  + [Claude Fable 5.1](#claude-fable-51)
42
48
  + [`pi` + OpenRouter model](#pi--openrouter-model)
43
49
  + [`codex` + Luna](#codex--luna)
@@ -52,6 +58,19 @@ so they can't drift as long as you install the skills with this package.
52
58
  npm i -S nightralph
53
59
  ```
54
60
 
61
+ ### Requirements
62
+
63
+ - Node 24 or newer (see `engines` in `package.json`) and git.
64
+ - The agent CLI you name on the command line (`claude`, `codex` or
65
+ `pi`) installed and on your `PATH`. After it finds tickets,
66
+ nightralph checks for this command before it touches git, and
67
+ exits with code 2 if the command is not found. A run with no
68
+ tickets stops before the check. With `--dry-run` it prints the
69
+ problem and keeps going.
70
+ - macOS or Linux. nightralph runs agents in their own process group
71
+ and uses `sh -c` for test and install commands, so it does not
72
+ run on Windows.
73
+
55
74
  ### Install Dependency Skills
56
75
 
57
76
  ```sh
@@ -100,8 +119,8 @@ a prose text description.
100
119
  The `nightralph` CLI does the `tdd` part of the workflow: the agent
101
120
  prompt tells the agent to write a failing test per acceptance
102
121
  criterion at the seams named in the spec's Testing Decisions, and
103
- the orchestrator runs the project's test command in the worktree
104
- before a ticket can be marked `done`.
122
+ the orchestrator runs the project's test command, when there is
123
+ one, in the worktree before a ticket can be marked `done`.
105
124
 
106
125
  >
107
126
  > [!NOTE]
@@ -118,7 +137,7 @@ before a ticket can be marked `done`.
118
137
  > then follow with `to-tickets`.
119
138
  >
120
139
 
121
- ### Examples
140
+ ## Examples
122
141
 
123
142
  After you have the tickets, use the `nightralph` loop to execute.
124
143
  The `to-tickets` skill produces markdown files at
@@ -150,29 +169,31 @@ npx nightralph claude -m claude-opus-4-6 30
150
169
  npx nightralph pi -m openrouter/z-ai/glm-5.3-flash --effort medium --retries 1
151
170
  ```
152
171
 
153
- ### Interface
172
+ ## Command Line Arguments
154
173
 
155
174
  Flags:
156
175
 
157
176
  | Flag | Required | Default | Description |
158
177
  |------|----------|---------|-------------|
159
178
  | `<provider>` | yes | -- | Agent harness to spawn (e.g. `claude`, `codex`, `pi`) |
160
- | `[turns]` | no | -- | Max agentic turns per ticket (positional shorthand for `--max-turns`) |
179
+ | `[turns]` | no | -- | Max agentic turns per agent run (positional shorthand for `--max-turns`) |
161
180
  | `-m`, `--model` | no | -- | Model flag passed to the agent |
162
181
  | `--spec <name>` | no | auto | Feature name (resolves `.scratch/<name>/`) |
163
182
  | `--dry-run` | no | -- | Show wave order and prompt without running |
183
+ | `--no-review` | no | review on | Skip the review and fix pass at the end of a run |
164
184
  | `-X` | no | -- | Stop on merge conflict instead of re-spawning |
165
185
  | `--timeout` | no | `3600` | Kill the agent after N seconds. A killed agent is not a failure: its work is kept (see Notes) |
166
- | `--test-cmd <cmd>` | no | auto | Run in each worktree after the agent exits; non-zero exit rejects the ticket. Auto-detects `npm test`. Pass `""` to disable. Shares the agent's `--timeout`. Dependencies are already installed by `--install-cmd` |
186
+ | `--test-cmd <cmd>` | no | auto | Run in each worktree after the agent exits; non-zero exit rejects the ticket. Defaults to `npm test` when `package.json` has a `test` script. When nothing is detected the run has no test gate and says so at startup. Pass `""` to run without a gate. Shares the agent's `--timeout`. Dependencies are already installed by `--install-cmd` |
167
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` |
168
- | `--max-turns <n>` | no | -- | Max agentic turns per ticket. Forwarded verbatim as `--max-turns <n>` to the provider CLI. Only `claude` accepts it; `codex` and `pi` reject it as an unknown option and the agent exits immediately. Applies to every agent spawn, including a re-spawn after a merge conflict. When omitted, `claude` applies no turn limit, so `--timeout` is the only cap. Reaching the limit is an error exit, so the ticket is recorded as failed |
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 |
169
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) |
170
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 |
171
192
  | `--retry-delay <n>` | no | `30` | Seconds to wait before the first retry; doubles each further retry (30s, 60s, 120s, ...). `0` disables the wait |
172
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 |
173
194
 
174
195
 
175
- **Notes**:
196
+ ### Notes
176
197
 
177
198
  * When `--spec <name>` is omitted, the CLI looks for
178
199
  `.scratch/<feature>/issues/` directories. If multiple features exist,
@@ -184,7 +205,8 @@ Flags:
184
205
  tickets start in the next wave.
185
206
  * An agent run only counts as a success when the agent exits 0,
186
207
  its worktree branch has new commits relative to the base branch,
187
- and the test command (see `--test-cmd`) exits 0 in that worktree.
208
+ and the test command (see `--test-cmd`), when there is one, exits
209
+ 0 in that worktree.
188
210
  Uncommitted changes left by the agent are auto-committed before
189
211
  the check. On success, the ticket's status is updated to `done`
190
212
  in-place. If the agent exits 0 but made no commits, or the test
@@ -209,6 +231,20 @@ Flags:
209
231
  logs are written to `<dir>/../logs/<ticket>.attempt<n>.log`
210
232
  (and `.attempt<n>.test.log` for the test command) so the first
211
233
  attempt's log is kept.
234
+ * Each attempt at a ticket appends an entry to
235
+ `.scratch/<feature>/learnings.md`, next to `progress.md`. The
236
+ entry records what the orchestrator saw (the outcome, the agent's
237
+ exit code, the checklist items it reported, and the last lines of
238
+ the test or install log when one of those failed) and, when an
239
+ agent ran, the agent's own notes. The file is committed along with
240
+ `progress.md` and persists across runs. Each agent's prompt
241
+ includes the most
242
+ recent entries for its own ticket and the last successful entry of
243
+ each ticket that directly blocks it, so a retry can see why the
244
+ previous attempt failed. Agents keep their notes in
245
+ `.nightralph/notes.md` inside the worktree. nightralph collects
246
+ that file when the agent exits, and a self-ignoring
247
+ `.nightralph/.gitignore` keeps it out of the agent's commits.
212
248
  * A ticket that exhausts all its retry attempts is excluded from
213
249
  later waves in the same run. It stays `ready-for-agent` on disk
214
250
  so a future run can pick it up, but it will not be re-started
@@ -249,13 +285,95 @@ Flags:
249
285
  tool-use proposals so the agent runs non-interactively)
250
286
 
251
287
 
252
- #### `--spec <name>` option
288
+ ### `--spec <name>` option
253
289
 
254
290
  A feature name that resolves to `.scratch/<name>/`.
255
291
  The spec file is `.scratch/<name>/spec.md` and the issues directory
256
292
  is `.scratch/<name>/issues/`.
257
293
 
258
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
+
259
377
  ## How It Works
260
378
 
261
379
  Tickets run in waves. A wave is a batch of tickets whose blockers are
@@ -287,6 +405,13 @@ completes, successful branches are merged back into the base branch.
287
405
  The first branch merges directly, and subsequent branches rebase
288
406
  onto the updated base before merging.
289
407
 
408
+ Worktrees are created next to the repository, not inside it: a
409
+ ticket's worktree is the sibling directory
410
+ `<repo>.<feature>-<num>-<slug>` on the branch
411
+ `<repo>--<feature>-<num>-<slug>`. A run that is interrupted leaves
412
+ them in place; the next run removes a stale worktree and branch of
413
+ the same name before creating a new one.
414
+
290
415
  A fresh worktree has only tracked files, so there is no
291
416
  `node_modules`. Before the agent starts, the orchestrator installs
292
417
  dependencies with the command that matches the project's lockfile
@@ -304,6 +429,33 @@ A file `progress.md` is written to the `.scratch/<feature>/` directory
304
429
  and committed after each ticket completes, so you can monitor the run from
305
430
  another terminal by reading the file.
306
431
 
432
+ ### Integration branch
433
+
434
+ Launched from your default branch (`main`), nightralph does not merge
435
+ into it. It creates `nightralph--<feature>` (or `nightralph--all` for
436
+ several features), merges every ticket there, and leaves you on that
437
+ branch. Running again from `main` resumes the same branch, so finished
438
+ tickets stay finished. Launched from any other branch, it merges into
439
+ that branch.
440
+
441
+ After you merge the integration branch into `main`, the next run
442
+ fast-forwards it to `main` and resumes. If the branch has commits `main`
443
+ lacks, nightralph reports how far behind it is; merge `main` into it or
444
+ delete it with `git branch -D`.
445
+
446
+ If `.scratch/` is gitignored, the bookkeeping files (tickets, progress,
447
+ learnings, review) are force-added and live on the integration branch, so
448
+ they disappear from disk when you check out `main`.
449
+
450
+ ### Review
451
+
452
+ After the last wave, an agent reviews everything merged since the last
453
+ review and writes its findings to `.scratch/<feature>/review.md` (`.scratch/review.md`
454
+ for a multi-feature run). A
455
+ second agent fixes them test-first; its work merges only if the test
456
+ command passes. Findings it could not fix stay unchecked in
457
+ `review.md`. Skip this with `--no-review`.
458
+
307
459
  ### Status display
308
460
 
309
461
  In a terminal, nightralph shows a header line, one row per ticket, and
@@ -327,6 +479,9 @@ Each row starts with a dot that shows the ticket's state:
327
479
  run in this session. The row names the blocking tickets, as in
328
480
  `auth/03 (blocked by auth/01)`.
329
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
+
330
485
  The header looks like this:
331
486
 
332
487
  ```
@@ -360,24 +515,31 @@ keep the machine awake, but the display can still go dark.
360
515
  #### Claude Opus 4.6
361
516
 
362
517
  ```sh
363
- 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
364
519
  ```
365
520
 
366
521
  #### Claude Opus 5.5
367
522
 
368
523
  ```sh
369
- 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
525
+ ```
526
+
527
+ #### Claude Sonnet 5.5
528
+
529
+ ```sh
530
+ caffeinate -i npx nightralph claude -m claude-sonnet-5-5 --effort medium
370
531
  ```
371
- #### Claude Sonnet 5
532
+
533
+ #### Claude Haiku 5.5
372
534
 
373
535
  ```sh
374
- caffeinate -i npx nightralph claude -m claude-sonnet-5 --effort high
536
+ caffeinate -i npx nightralph claude -m claude-haiku-5-5 --effort high
375
537
  ```
376
538
 
377
539
  #### Claude Fable 5.1
378
540
 
379
541
  ```sh
380
- 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
381
543
  ```
382
544
 
383
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
  }