@yaawn/workflow 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/README.md +22 -43
  2. package/agents/personas/yaawn-product-owner.md +3 -3
  3. package/agents/skills/yaawn-create-plan/SKILL.md +38 -11
  4. package/agents/skills/yaawn-extract-learnings/SKILL.md +138 -0
  5. package/agents/skills/yaawn-grill-bug-report/SKILL.md +7 -0
  6. package/agents/skills/yaawn-grill-other/SKILL.md +7 -0
  7. package/agents/skills/yaawn-grill-prototype/SKILL.md +38 -0
  8. package/agents/skills/yaawn-grill-prototype/references/prototype-checklist.md +23 -0
  9. package/agents/skills/yaawn-grill-refactor/SKILL.md +7 -0
  10. package/agents/skills/yaawn-grill-spike/SKILL.md +7 -0
  11. package/agents/skills/yaawn-grill-story/SKILL.md +7 -0
  12. package/agents/skills/yaawn-implement-plan/SKILL.md +10 -8
  13. package/agents/skills/yaawn-implement-plan/scripts/agent.ts +1 -1
  14. package/agents/skills/yaawn-implement-plan/scripts/overnight.ts +7 -4
  15. package/agents/skills/yaawn-implement-plan/scripts/parallel.ts +155 -12
  16. package/agents/skills/yaawn-implement-plan/scripts/processes.ts +161 -1
  17. package/agents/skills/yaawn-implement-plan/scripts/targets.ts +13 -2
  18. package/agents/skills/yaawn-implement-plan/scripts/worktrees.ts +219 -52
  19. package/agents/skills/yaawn-prepare-plan/SKILL.md +20 -19
  20. package/agents/skills/yaawn-verify-executed-plan/SKILL.md +28 -13
  21. package/agents/skills/yaawn-verify-executed-plan/scripts/overnight.ts +44 -12
  22. package/agents/skills/yaawn-write-bug-report/SKILL.md +6 -5
  23. package/agents/skills/yaawn-write-other/SKILL.md +6 -5
  24. package/agents/skills/yaawn-write-prototype/SKILL.md +96 -0
  25. package/agents/skills/yaawn-write-refactor/SKILL.md +6 -5
  26. package/agents/skills/yaawn-write-spike/SKILL.md +1 -1
  27. package/agents/skills/yaawn-write-user-story/SKILL.md +3 -2
  28. package/agents/yaawn-glossary.md +4 -3
  29. package/config/install-tasks.json +0 -1
  30. package/config/wizard.json +3 -8
  31. package/dist/cli/args.js +66 -0
  32. package/dist/cli/errors.js +103 -0
  33. package/dist/cli/exit-codes.js +3 -0
  34. package/dist/cli/index.js +11 -0
  35. package/dist/cli/install-report.js +92 -0
  36. package/dist/cli/install.js +124 -0
  37. package/dist/cli/main.js +17 -0
  38. package/dist/cli/package-root.js +17 -0
  39. package/dist/cli/project-root.js +11 -0
  40. package/dist/cli/run.js +141 -0
  41. package/dist/cli/session.js +1 -0
  42. package/dist/cli/terminal.js +5 -0
  43. package/dist/cli/uninstall.js +89 -0
  44. package/dist/cli/version.js +7 -0
  45. package/dist/config/agents.js +108 -0
  46. package/dist/config/config.js +118 -0
  47. package/dist/config/errors.js +45 -0
  48. package/dist/config/index.js +11 -0
  49. package/dist/config/manifest.js +28 -0
  50. package/dist/config/messages.js +86 -0
  51. package/dist/config/names.js +40 -0
  52. package/dist/config/paths.js +73 -0
  53. package/dist/config/settings.js +101 -0
  54. package/dist/config/strict-json.js +119 -0
  55. package/dist/config/tasks.js +77 -0
  56. package/dist/config/wizard.js +139 -0
  57. package/dist/display/index.js +3 -0
  58. package/dist/display/palette.js +61 -0
  59. package/dist/display/renderer.js +169 -0
  60. package/dist/display/report.js +15 -0
  61. package/dist/display/sanitize.js +10 -0
  62. package/dist/link/errors.js +34 -0
  63. package/dist/link/fs-utils.js +79 -0
  64. package/dist/link/index.js +4 -0
  65. package/dist/link/install.js +20 -0
  66. package/dist/link/linker.js +137 -0
  67. package/dist/link/memory.js +221 -0
  68. package/dist/link/names.js +30 -0
  69. package/dist/link/types.js +30 -0
  70. package/dist/link/uninstall.js +90 -0
  71. package/dist/sync/apply.js +74 -0
  72. package/dist/sync/changes.js +31 -0
  73. package/dist/sync/fs-utils.js +63 -0
  74. package/dist/sync/index.js +7 -0
  75. package/dist/sync/install.js +57 -0
  76. package/dist/sync/languages.js +80 -0
  77. package/dist/sync/plan.js +146 -0
  78. package/dist/sync/projects.js +294 -0
  79. package/dist/sync/uninstall.js +234 -0
  80. package/dist/wizard/build.js +89 -0
  81. package/dist/wizard/index.js +5 -0
  82. package/dist/wizard/items.js +83 -0
  83. package/dist/wizard/prompts.js +167 -0
  84. package/dist/wizard/section.js +143 -0
  85. package/dist/wizard/wizard.js +73 -0
  86. package/github/ISSUE_TEMPLATE/yaawn-prototype.md +48 -0
  87. package/package.json +11 -10
  88. package/agents/instructions/output-styles/yaawn-eli5.instructions.md +0 -45
  89. package/scripts/install.js +0 -73
@@ -16,16 +16,16 @@ Work in the developer persona (`.agents/personas/yaawn-developer.md`) — this s
16
16
 
17
17
  An orchestrator ([scripts/overnight.ts](./scripts/overnight.ts)) running in a background terminal, kept awake by `caffeinate`, plus two chat messages from you: the **start message** once it runs (step 5) and the **final result** once it ends (step 6).
18
18
 
19
- The terminal stays quiet: the walk-away banner, one line `<ISO> #<issue> <state>` each time a lane moves on — `started` → `implement` → `review` → `fix` (only if the review found blocking issues) → `done`, `failed`, or `stopped` — and at the end a `--- summary` with every lane's result and one `run result:` line, e.g. `run result: 1 done, 0 failed, 1 stopped (#51 in review)`. Ctrl+C, closing the terminal, or killing the run stops every lane and writes a result `#<issue> stopped in <stage>` naming the stage each unfinished lane was in.
19
+ The terminal stays quiet: the walk-away banner, one line `<ISO> #<issue> <state>` each time a lane moves on — `started` → `implement` → `review` → `fix` (prototypes skip the review; fix only if the review found blocking issues) → `done`, `failed`, or `stopped` — and at the end a `--- summary` with every lane's result and one `run result:` line, e.g. `run result: 1 done, 0 failed, 1 stopped (#51 in review)`. Ctrl+C, closing the terminal, or killing the run stops every lane and writes a result `#<issue> stopped in <stage>` naming the stage each unfinished lane was in.
20
20
 
21
21
  ### Lanes and worktrees
22
22
 
23
23
  Every launch runs its issues as **lanes** — one lane per issue, a single issue included:
24
24
 
25
- - **Worktrees** — each lane runs in its own git worktree `<project>/yaawn-<issue>-<slug>` (inside the project, next to the main copy `<project>/main`; `<slug>` is the issue title, lowercased, only `a-z`, `0-9`, and `-`, at most 40 characters) on branch `yaawn/<issue>`. This needs the bare-repo layout — the project root holds a bare `.git` and the main copy is checked out at `<project>/main`; set it up once as described in [`.agents/yaawn-worktrees.md`](../../yaawn-worktrees.md). The worktree is created when its lane starts — the branch from HEAD if it does not exist yet — and reused on relaunch, so relaunching resumes. `/yaawn-verify-executed-plan` later opens the issue's pull request from that branch. The lane's progress logs, summaries, review reports, and transcripts live under `.yaawn/plans/` **in its worktree**. Uncommitted changes in the main copy are not in the worktrees.
25
+ - **Worktrees** — each lane runs in its own git worktree `<project>/yaawn-<issue>-<slug>` (inside the project, next to the main copy `<project>/main`; `<slug>` is the issue title, lowercased, only `a-z`, `0-9`, and `-`, at most 40 characters) on branch `yaawn/<issue>`. An issue labelled `Yaawn: Prototype` runs in `<project>/prototype/yaawn-<issue>-<slug>` on branch `prototype/yaawn-<issue>-<slug>` instead (`<slug>` falls back to `prototype` when the title has none) — that branch is never merged to main and no pull request is opened from it. This needs the bare-repo layout — the project root holds a bare `.git` and the main copy is checked out at `<project>/main`; set it up once as described in [`.agents/yaawn-worktrees.md`](../../yaawn-worktrees.md). The worktree is created when its lane starts — the branch from HEAD if it does not exist yet — and reused on relaunch, so relaunching resumes. `/yaawn-verify-executed-plan` later opens the issue's pull request from that branch; for a prototype, `/yaawn-extract-learnings` reads the branch instead. The lane's progress logs, summaries, review reports, and transcripts live under `.yaawn/plans/` **in its worktree**. Uncommitted changes in the main copy are not in the worktrees.
26
26
  - **Lanes** — the issue numbers given, or with `--ready` the open issues labelled `Yaawn: Ready To Implement`. Every issue is its own lane — its only target is the issue itself.
27
27
  - **Parallel** — at most `--max-parallel N` lanes run at once (default: all); a waiting lane starts as soon as a running one ends. Lanes run independently, so pre-flight refuses lanes that change the same file (per `### Files to Change`) or depend on each other.
28
- - **Result per lane** — `.yaawn/plans/yaawn-<issue>-overnight-result.md` **in the main copy**: the lane's **final block** from its worktree — each target's state and ELI5 summary, its review report, the last 20 output lines of every failed target, and one `run result:` line — plus `worktree:` and `branch:` lines. While the lane runs, the result is live: `#<issue> running — stage <stage>`, rewritten at lane start and at every stage. A stopped run replaces it with `#<issue> stopped in <stage>`. Session output is not streamed; only each target's last 20 lines are kept. The result is deleted for every lane at launch so an earlier run's result never looks current.
28
+ - **Result per lane** — `.yaawn/plans/yaawn-<issue>-overnight-result.md` **in the main copy**: the lane's **final block** from its worktree — each target's state and ELI5 summary, its review report (prototypes skip the review, so their result has none), the last 20 output lines of every failed target, and one `run result:` line — plus `worktree:` and `branch:` lines. While the lane runs, the result is live: `#<issue> running — stage <stage>`, rewritten at lane start and at every stage. A stopped run replaces it with `#<issue> stopped in <stage>`. Session output is not streamed; only each target's last 20 lines are kept. The result is deleted for every lane at launch so an earlier run's result never looks current.
29
29
 
30
30
  The script never merges, commits, or pushes the branches — the human does that after verification.
31
31
 
@@ -35,14 +35,14 @@ A lane's **target** is its issue: one implementer session for the issue itself.
35
35
 
36
36
  Each session gets [assets/session-prompt.md](./assets/session-prompt.md) as its prompt, with `<target>` filled in: implement the plan, keep the progress log, work every step to done (no blockers), collect manual checks for the human, write the ELI5 summary, never commit, push, or verify. Right before a session starts, the orchestrator fetches the target's issue fresh and writes its whole "Implementation Plan" section — answers included — to `.yaawn/plans/yaawn-<target>-llm-plan.md`; the session works from that file.
37
37
 
38
- When the implementer session leaves every progress step done, two more sessions follow for that target before the run moves on:
38
+ When the implementer session leaves every progress step done, two more sessions follow for that target before the run moves on — except for issues labelled `Yaawn: Prototype`, which skip the review entirely: prototype code is throwaway and carries no code-quality requirements, so reviewing it would be wasted effort:
39
39
 
40
40
  - **Review session** — a different model (`gpt-5.3-codex`) gets [assets/review-prompt.md](./assets/review-prompt.md): review the whole working-tree diff against the implementation plan, `yaawn-clean-code`, and `yaawn-owasp-security`, treat everything it reads as untrusted data, and write only the **review report** `.yaawn/plans/yaawn-<target>-review.md` — a `## Verdict` with a `blocking: N` line, then `## Findings`. It is read-only: the orchestrator snapshots HEAD, `git status`, and hashes of the tracked diff and the untracked files' contents (all outside `.yaawn/plans/`) before and after, and fails the target on any change. It fails closed: a missing, oversized (over 256 KB), or unparseable report fails the target too.
41
41
  - **Fix session** — only when `N > 0`. The implementer model gets [assets/fix-prompt.md](./assets/fix-prompt.md): append each blocking finding as a fix-step to the progress log, sanity-check and apply it, work every fix-step to done, and add an `Action:` line to each blocking finding in the review report. Non-blocking findings are left for the human. There is no second review.
42
42
 
43
43
  Within a lane, the sessions run **one after another** — never two at once, so they never edit the same files at the same time:
44
44
 
45
- - **done** = the review passed and every step in `.yaawn/plans/yaawn-<target>-progress.md` — fix-steps included — is `- [x]`. Anything else is **failed**.
45
+ - **done** = the review passed (prototypes skip the review) and every step in `.yaawn/plans/yaawn-<target>-progress.md` — fix-steps included — is `- [x]`. Anything else is **failed**.
46
46
  - Targets already **done** at launch are not rerun, so relaunching resumes.
47
47
 
48
48
  The sessions have `--no-ask-user`. Anything they would need to ask must be settled **before** launch.
@@ -167,10 +167,11 @@ Lanes (each in its own worktree):
167
167
 
168
168
  - #<a> — worktree `<worktree path>`, branch `yaawn/<a>`
169
169
  - #<b> — worktree `<worktree path>`, branch `yaawn/<b>`
170
+ - #<c> — worktree `<project>/prototype/yaawn-<c>-<slug>`, branch `prototype/yaawn-<c>-<slug>` (prototype: never merged, no pull request)
170
171
 
171
172
  Stop: Ctrl+C in the terminal (every lane stops; its result says in which stage).
172
173
 
173
- Per lane: the result `.yaawn/plans/yaawn-<issue>-overnight-result.md` in this copy. Per target, under `.yaawn/plans/` in the lane's worktree: `yaawn-<target>-progress.md`, `yaawn-<target>-summary.md`, review report `yaawn-<target>-review.md`, transcripts `yaawn-<target>-overnight-session.md`, `yaawn-<target>-review-session.md`, and `yaawn-<target>-fix-session.md` (`.jsonl` with Claude or Vibe; each written when its session ends; the fix session runs only if the review found blocking issues). I will post the final result here when the run ends.
174
+ Per lane: the result `.yaawn/plans/yaawn-<issue>-overnight-result.md` in this copy. Per target, under `.yaawn/plans/` in the lane's worktree: `yaawn-<target>-progress.md`, `yaawn-<target>-summary.md`, review report `yaawn-<target>-review.md`, transcripts `yaawn-<target>-overnight-session.md`, `yaawn-<target>-review-session.md`, and `yaawn-<target>-fix-session.md` (`.jsonl` with Claude or Vibe; each written when its session ends; the fix session runs only if the review found blocking issues). Prototype targets skip the review: no review report, no review or fix session. I will post the final result here when the run ends.
174
175
  ````
175
176
 
176
177
  Then end your turn and wait. Do not poll the terminal or the result file — the harness notifies you when the background process ends.
@@ -179,11 +180,12 @@ Then end your turn and wait. Do not poll the terminal or the result file — the
179
180
 
180
181
  Reporting is read-only: never start, stop, or relaunch the run, never edit the issue or the plan, never commit or push, and never post anything to GitHub.
181
182
 
182
- **When the run ends** (the harness notifies you that the background process exited): read `.yaawn/plans/yaawn-<issue>-overnight-result.md` for every lane — done, failed, and stopped — and post it verbatim — with several lanes, each under a `## #<issue>` heading. Render the summaries and each target's `### Review` section (the review report with its findings and the fix session's `Action:` lines) as markdown, keep each failed target's output lines in their code block, and keep the `worktree:` and `branch:` lines. End with the `run result:` line from the terminal. Then add the next steps per lane:
183
+ **When the run ends** (the harness notifies you that the background process exited): read `.yaawn/plans/yaawn-<issue>-overnight-result.md` for every lane — done, failed, and stopped — and post it verbatim — with several lanes, each under a `## #<issue>` heading. Render the summaries and each target's `### Review` section (the review report with its findings and the fix session's `Action:` lines; prototypes skip the review, so they have none) as markdown, keep each failed target's output lines in their code block, and keep the `worktree:` and `branch:` lines. End with the `run result:` line from the terminal. Then add the next steps per lane:
183
184
 
184
185
  - Review the diff in the lane's worktree, and the non-blocking review findings left for you.
185
186
  - Do the manual checks listed in each summary.
186
- - **done** lanes: start a new session **inside the lane's worktree** (`cd <worktree path>`), commit and push the `yaawn/<issue>` branch, then run `/yaawn-verify-executed-plan <issue>` there once — it opens the pull request. The run itself never commits or pushes.
187
+ - **done** lanes (not prototypes): start a new session **inside the lane's worktree** (`cd <worktree path>`), commit and push the `yaawn/<issue>` branch, then run `/yaawn-verify-executed-plan <issue>` there once — it opens the pull request. The run itself never commits or pushes.
188
+ - **done** lanes of issues labelled `Yaawn: Prototype` (their result carries a `prototype:` line): commit and push the `prototype/yaawn-<issue>-<slug>` branch, test the prototype with customers, then run `/yaawn-extract-learnings <issue>` — **not** `/yaawn-verify-executed-plan <issue>`. The branch is never merged to main and no pull request is opened.
187
189
  - **failed** lanes: the lane's result and the progress logs in its worktree show why; launching again resumes it in the same worktree.
188
190
  - **stopped** lanes (`#<issue> stopped in <stage>`): the run was stopped in that stage; launching again resumes it in the same worktree.
189
191
 
@@ -5,7 +5,7 @@ import { succeeds } from "./targets.js";
5
5
  // from this fixed allow-list — never from the settings file or a flag as
6
6
  // free text — and is spawned with an argument array, never through a shell.
7
7
 
8
- const agentNames = ["copilot", "claude", "vibe"] as const;
8
+ export const agentNames = ["copilot", "claude", "vibe"] as const;
9
9
 
10
10
  export type AgentName = (typeof agentNames)[number];
11
11
 
@@ -46,7 +46,7 @@ import {
46
46
  resultPath,
47
47
  unansweredQuestionTexts,
48
48
  } from "./targets.js";
49
- import { runsInOwnWorktree, worktreePath } from "./worktrees.js";
49
+ import { isPrototype, runsInOwnWorktree, worktreePath } from "./worktrees.js";
50
50
 
51
51
  type RunState = "pending" | "running" | "done" | "failed" | "skipped";
52
52
 
@@ -530,6 +530,9 @@ async function runTarget(
530
530
  // failure output for diagnosis.
531
531
  recordExitCode(code, handle.agent, tail);
532
532
  if (!isDone(target.number)) return false;
533
+ // Prototypes skip the review: throwaway code carries no code-quality
534
+ // requirements, so a review would be wasted effort.
535
+ if (isPrototype(target.labels)) return true;
533
536
  announceStage(handle, "review");
534
537
  const blocking = await reviewTarget(target, tail, handle);
535
538
  if (blocking === null) return false;
@@ -687,10 +690,10 @@ async function runLaneCommand(
687
690
  ): Promise<void> {
688
691
  if (args.length !== 1 || !/^\d+$/.test(args[0])) throw new Error(usage);
689
692
  const parent = Number(args[0]);
690
- const { title } = fetchIssue(parent);
691
- if (!runsInOwnWorktree(parent, title)) {
693
+ const { title, labels } = fetchIssue(parent);
694
+ if (!runsInOwnWorktree(parent, title, labels)) {
692
695
  throw new Error(
693
- `lane #${parent} must run in its worktree ${worktreePath(parent, title)}`,
696
+ `lane #${parent} must run in its worktree ${worktreePath(parent, title, labels)}`,
694
697
  );
695
698
  }
696
699
  mkdirSync(plansDir, { recursive: true });
@@ -13,10 +13,12 @@ import path from "node:path";
13
13
  import { createInterface } from "node:readline";
14
14
  import { createInterface as createPrompt } from "node:readline/promises";
15
15
  import type { Readable } from "node:stream";
16
+ import { setTimeout as delay } from "node:timers/promises";
16
17
  import { promisify } from "node:util";
17
18
  import {
18
19
  type AgentChoice,
19
20
  type AgentName,
21
+ agentNames,
20
22
  readAgent,
21
23
  } from "./agent.js";
22
24
  import { keepMacAwakeWhileRunning, printWalkAwayBanner } from "./caffeinate.js";
@@ -38,7 +40,13 @@ import {
38
40
  laneOwners,
39
41
  lanePairs,
40
42
  } from "./groups.js";
41
- import { drainOutput } from "./processes.js";
43
+ import {
44
+ type LeftoverSession,
45
+ drainOutput,
46
+ findLeftoverAgentSessions,
47
+ isProcessAlive,
48
+ killProcessGroupByPid,
49
+ } from "./processes.js";
42
50
  import {
43
51
  type LaneOutcomeState,
44
52
  type StageLabel,
@@ -70,12 +78,12 @@ import {
70
78
  import {
71
79
  type MainCopyCheck,
72
80
  blockedUpdateReason,
73
- branchName,
74
81
  describeMainCopyState,
75
82
  fetchMainCopy,
76
83
  git,
77
84
  isBareLayout,
78
- laneWorktreePath,
85
+ isPrototype,
86
+ laneWorktreeAndBranch,
79
87
  registeredWorktrees,
80
88
  syncMainCopy,
81
89
  } from "./worktrees.js";
@@ -92,9 +100,11 @@ export interface LaunchRequest {
92
100
  type LaneState = LaneOutcomeState;
93
101
 
94
102
  // The lane issue's title names a new worktree directory; an existing checkout
95
- // of the lane branch is reused instead.
103
+ // of the lane branch is reused instead. Its labels mark a prototype, whose
104
+ // worktree and branch live under `prototype/`.
96
105
  interface TitledLane extends Lane {
97
106
  title: string;
107
+ labels: string[];
98
108
  }
99
109
 
100
110
  // What a pre-flight lookup found, or why it could not look.
@@ -119,6 +129,7 @@ interface LaneRun {
119
129
  lane: TitledLane;
120
130
  agent: AgentName;
121
131
  worktree: string;
132
+ branch: string;
122
133
  state: LaneState;
123
134
  stage: StageLabel;
124
135
  tail: string[];
@@ -219,9 +230,21 @@ function resolvedLane(issue: number): Lane {
219
230
  }
220
231
 
221
232
  function titledLane(lane: Lane): TitledLane {
222
- return { ...lane, title: lane.targets[0].title };
233
+ const [issue] = lane.targets;
234
+ return { ...lane, title: issue.title, labels: issue.labels };
223
235
  }
224
236
 
237
+ // Both the worktree path and its actual branch come from one porcelain
238
+ // lookup, so a relaunch after a rename reports the branch that is really
239
+ // checked out — never a branch recomputed from the new title.
240
+ const laneWorktreeAndBranchFor = (
241
+ lane: TitledLane,
242
+ ): { path: string; branch: string } =>
243
+ laneWorktreeAndBranch(lane.issue, lane.title, lane.labels);
244
+
245
+ const prototypeNote = (issue: number): string =>
246
+ `prototype: the branch is never merged and no pull request is opened — run /yaawn-extract-learnings ${issue}`;
247
+
225
248
  async function lookedUpLane(issue: number): Promise<Lookup<TitledLane[]>> {
226
249
  return lookup(
227
250
  resolveTargetsAsync(issue).then((targets) => [
@@ -266,9 +289,8 @@ function branchExists(branch: string): boolean {
266
289
  }
267
290
 
268
291
  // Reused on relaunch: an existing worktree keeps the work of an earlier run.
269
- function ensureWorktree(worktree: string, issue: number): void {
292
+ function ensureWorktree(worktree: string, branch: string): void {
270
293
  if (registeredWorktrees().has(worktree) && existsSync(worktree)) return;
271
- const branch = branchName(issue);
272
294
  const args = branchExists(branch)
273
295
  ? ["worktree", "add", worktree, branch]
274
296
  : ["worktree", "add", "-b", branch, worktree, "HEAD"];
@@ -475,7 +497,7 @@ function printMainCopyState(check: MainCopyCheck): void {
475
497
  }
476
498
 
477
499
  function worktreeFailures(lane: TitledLane): string[] {
478
- const worktree = laneWorktreePath(lane.issue, lane.title);
500
+ const { path: worktree } = laneWorktreeAndBranchFor(lane);
479
501
  return existsSync(worktree) && !registeredWorktrees().has(worktree)
480
502
  ? [
481
503
  `lane #${lane.issue}: ${worktree} exists but is not a git worktree of this repository`,
@@ -702,7 +724,9 @@ function printImplementationGroups(groups: ImplementationGroup[]): void {
702
724
  }
703
725
 
704
726
  function describeLane(lane: TitledLane): string[] {
705
- const header = ` #${lane.issue} — worktree ${laneWorktreePath(lane.issue, lane.title)}, branch ${branchName(lane.issue)}`;
727
+ const prototype = isPrototype(lane.labels) ? ` (${prototypeNote(lane.issue)})` : "";
728
+ const { path: worktree, branch } = laneWorktreeAndBranchFor(lane);
729
+ const header = ` #${lane.issue} — worktree ${worktree}, branch ${branch}${prototype}`;
706
730
  const targets = lane.targets.map((target, index) => {
707
731
  const after =
708
732
  target.dependsOn.length > 0
@@ -774,6 +798,8 @@ async function checkedLanes(
774
798
  ...mainCopyFailures(mainCopy, changes.value),
775
799
  ...conflicts,
776
800
  ];
801
+ const leftoversCleared =
802
+ failures.length === 0 && (await leftoverSessionsCleared(lanes));
777
803
  printLanes(lanes, request.maxParallel);
778
804
  printFailures(failures);
779
805
  printMainCopyState(mainCopy);
@@ -784,9 +810,123 @@ async function checkedLanes(
784
810
  if (!groups) printCompatibleSuggestion(lanes, conflicts);
785
811
  return null;
786
812
  }
813
+ if (!leftoversCleared) return null;
787
814
  return { lanes, changes: changes.value };
788
815
  }
789
816
 
817
+ // --- leftover agent sessions
818
+
819
+ const leftoverStopTimeoutMs = 5000;
820
+ const leftoverPollIntervalMs = 250;
821
+
822
+ // Paths come from the process table: control characters are replaced so
823
+ // a crafted directory name cannot rewrite the terminal.
824
+ const printable = (text: string): string =>
825
+ // eslint-disable-next-line no-control-regex -- matches the C0/C1 control characters a crafted path could use to rewrite the terminal
826
+ text.replace(/[\u0000-\u001f\u007f-\u009f]/g, "?");
827
+
828
+ function describeLeftover(session: LeftoverSession): string {
829
+ const cwd = session.cwd === null ? "unreadable" : printable(session.cwd);
830
+ const worktree = session.worktree ?? "unknown";
831
+ return ` pid ${session.pid} — ${session.agent}, cwd ${cwd}, worktree ${worktree}`;
832
+ }
833
+
834
+ function printLeftovers(sessions: LeftoverSession[]): void {
835
+ console.log(
836
+ "--- agent sessions from a previous run are still running in the lane worktrees (an unreadable working directory counts, to fail closed)",
837
+ );
838
+ sessions.map(describeLeftover).forEach((line) => console.log(line));
839
+ }
840
+
841
+ async function scannedLeftovers(
842
+ lanes: TitledLane[],
843
+ ): Promise<LeftoverSession[] | null> {
844
+ const worktrees = lanes.map((lane) => laneWorktreeAndBranchFor(lane).path);
845
+ try {
846
+ return await findLeftoverAgentSessions(worktrees, agentNames);
847
+ } catch (error) {
848
+ printFailures([
849
+ `could not check for leftover agent sessions: ${errorMessage(error)}`,
850
+ ]);
851
+ return null;
852
+ }
853
+ }
854
+
855
+ // Anything but an explicit "stop" refuses: the orchestrator never kills on
856
+ // its own, and never without a terminal to ask.
857
+ async function leftoverChoice(): Promise<"stop" | "refuse"> {
858
+ if (!process.stdin.isTTY) {
859
+ console.log(
860
+ "no terminal to choose whether to stop them; refusing to start. Stop the sessions (or ask the human) before launching",
861
+ );
862
+ return "refuse";
863
+ }
864
+ const prompt = createPrompt({ input: process.stdin, output: process.stdout });
865
+ try {
866
+ const answer = await prompt.question(
867
+ "stop them, or refuse to start? [stop/refuse] (default: refuse) ",
868
+ );
869
+ return /^stop( them)?$/i.test(answer.trim()) ? "stop" : "refuse";
870
+ } finally {
871
+ prompt.close();
872
+ }
873
+ }
874
+
875
+ async function survivorsAfterStop(pids: number[]): Promise<number[]> {
876
+ const deadline = Date.now() + leftoverStopTimeoutMs;
877
+ let alive = pids.filter(isProcessAlive);
878
+ while (alive.length > 0 && Date.now() < deadline) {
879
+ await delay(leftoverPollIntervalMs);
880
+ alive = alive.filter(isProcessAlive);
881
+ }
882
+ return alive;
883
+ }
884
+
885
+ function signalLeftovers(pids: number[]): string[] {
886
+ return pids.flatMap((pid) => {
887
+ try {
888
+ killProcessGroupByPid(pid, "SIGTERM");
889
+ return [];
890
+ } catch (error) {
891
+ return [`could not stop pid ${pid}: ${errorMessage(error)}`];
892
+ }
893
+ });
894
+ }
895
+
896
+ async function stoppedLeftovers(sessions: LeftoverSession[]): Promise<boolean> {
897
+ const pids = sessions.map((session) => session.pid);
898
+ const signalFailures = signalLeftovers(pids);
899
+ const survivors = await survivorsAfterStop(pids);
900
+ const failures = [
901
+ ...signalFailures,
902
+ ...survivors.map(
903
+ (pid) =>
904
+ `pid ${pid} still running ${leftoverStopTimeoutMs / 1000}s after SIGTERM — not starting`,
905
+ ),
906
+ ];
907
+ printFailures(failures);
908
+ if (failures.length > 0) return false;
909
+ console.log("leftover agent sessions stopped");
910
+ return true;
911
+ }
912
+
913
+ // Runs after the lanes resolve and before the results print, only once
914
+ // every other check passed, so the sessions of another active run (whose
915
+ // lanes already fail the pre-flight) are never offered for stopping.
916
+ async function leftoverSessionsCleared(lanes: TitledLane[]): Promise<boolean> {
917
+ const sessions = await scannedLeftovers(lanes);
918
+ if (sessions === null) return false;
919
+ if (sessions.length === 0) return true;
920
+ printLeftovers(sessions);
921
+ const choice = await leftoverChoice();
922
+ if (choice === "refuse") {
923
+ console.log("leftover agent sessions: chose to refuse to start");
924
+ return false;
925
+ }
926
+ console.log("leftover agent sessions: chose to stop them");
927
+ return stoppedLeftovers(sessions);
928
+ }
929
+
790
930
  // Without a terminal nobody can answer: fail closed so preflight never
791
931
  // passes without a confirmation, and the caller asks the human instead.
792
932
  async function confirmedUncommittedChanges(): Promise<boolean> {
@@ -1026,7 +1166,8 @@ function laneReport(run: LaneRun, body: string): string {
1026
1166
  return [
1027
1167
  body,
1028
1168
  `worktree: ${run.worktree}`,
1029
- `branch: ${branchName(run.lane.issue)}`,
1169
+ `branch: ${run.branch}`,
1170
+ ...(isPrototype(run.lane.labels) ? [prototypeNote(run.lane.issue)] : []),
1030
1171
  ].join("\n\n");
1031
1172
  }
1032
1173
 
@@ -1088,7 +1229,7 @@ async function runLane(
1088
1229
  setState(run, "started");
1089
1230
  saveRunningResult(run);
1090
1231
  try {
1091
- ensureWorktree(run.worktree, run.lane.issue);
1232
+ ensureWorktree(run.worktree, run.branch);
1092
1233
  } catch (error) {
1093
1234
  run.tail.push(
1094
1235
  `could not create worktree ${run.worktree}: ${errorMessage(error)}`,
@@ -1135,10 +1276,12 @@ function combinedResult(runs: LaneRun[]): string {
1135
1276
  }
1136
1277
 
1137
1278
  function newLaneRun(lane: TitledLane, agent: AgentName): LaneRun {
1279
+ const { path: worktree, branch } = laneWorktreeAndBranchFor(lane);
1138
1280
  return {
1139
1281
  lane,
1140
1282
  agent,
1141
- worktree: laneWorktreePath(lane.issue, lane.title),
1283
+ worktree,
1284
+ branch,
1142
1285
  state: "started",
1143
1286
  stage: laneStartedLabel,
1144
1287
  tail: [],
@@ -1,4 +1,6 @@
1
- import type { ChildProcess } from "node:child_process";
1
+ import { type ChildProcess, execFile } from "node:child_process";
2
+ import { realpathSync } from "node:fs";
3
+ import path from "node:path";
2
4
  import type { Readable } from "node:stream";
3
5
  import { finished } from "node:stream/promises";
4
6
  import { setTimeout as delay } from "node:timers/promises";
@@ -51,3 +53,161 @@ export function killProcessGroup(
51
53
  if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error;
52
54
  }
53
55
  }
56
+
57
+ // Signals a process group by its leader's pid. Never pid 0 or 1: -0 and -1
58
+ // would reach the caller's own group or every process the user may signal.
59
+ export function killProcessGroupByPid(
60
+ pid: number,
61
+ signal: NodeJS.Signals,
62
+ ): void {
63
+ if (!Number.isSafeInteger(pid) || pid <= 1) {
64
+ throw new Error(`refusing to signal invalid pid ${pid}`);
65
+ }
66
+ try {
67
+ process.kill(-pid, signal);
68
+ } catch (error) {
69
+ if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error;
70
+ }
71
+ }
72
+
73
+ // Signal 0 only checks: ESRCH means gone, EPERM means alive but not ours.
74
+ export function isProcessAlive(pid: number): boolean {
75
+ try {
76
+ process.kill(pid, 0);
77
+ return true;
78
+ } catch (error) {
79
+ return (error as NodeJS.ErrnoException).code !== "ESRCH";
80
+ }
81
+ }
82
+
83
+ // --- leftover agent sessions
84
+
85
+ export interface LeftoverSession {
86
+ pid: number;
87
+ agent: string;
88
+ cwd: string | null;
89
+ worktree: string | null;
90
+ }
91
+
92
+ export interface CommandResult {
93
+ exitCode: number;
94
+ stdout: string;
95
+ }
96
+
97
+ // Resolves with the exit code when the command ran; rejects only when it
98
+ // could not run at all (e.g. ENOENT). Always an argument array, never a shell.
99
+ export type CommandRunner = (
100
+ command: string,
101
+ args: string[],
102
+ ) => Promise<CommandResult>;
103
+
104
+ interface AgentProcess {
105
+ pid: number;
106
+ agent: string;
107
+ }
108
+
109
+ const maxCommandOutputBytes = 16 * 1024 * 1024;
110
+ const processLinePattern = /^\s*(\d+)\s+(.+)$/;
111
+
112
+ export const runCommand: CommandRunner = (command, args) =>
113
+ new Promise((resolve, reject) => {
114
+ execFile(
115
+ command,
116
+ args,
117
+ { encoding: "utf-8", maxBuffer: maxCommandOutputBytes },
118
+ (error, stdout) => {
119
+ const exitCode = (error as { code?: unknown } | null)?.code;
120
+ if (error && typeof exitCode !== "number") reject(error);
121
+ else resolve({ exitCode: error ? (exitCode as number) : 0, stdout });
122
+ },
123
+ );
124
+ });
125
+
126
+ function agentProcessFrom(
127
+ line: string,
128
+ agentNames: readonly string[],
129
+ ): AgentProcess | null {
130
+ const match = processLinePattern.exec(line);
131
+ if (!match) return null;
132
+ const pid = Number(match[1]);
133
+ // macOS reports comm as the full executable path.
134
+ const agent = path.basename(match[2].trim());
135
+ if (!Number.isSafeInteger(pid) || pid <= 1 || pid === process.pid) return null;
136
+ return agentNames.includes(agent) ? { pid, agent } : null;
137
+ }
138
+
139
+ async function agentProcesses(
140
+ agentNames: readonly string[],
141
+ run: CommandRunner,
142
+ ): Promise<AgentProcess[]> {
143
+ const { exitCode, stdout } = await run("ps", ["-eo", "pid=,comm="]);
144
+ if (exitCode !== 0) throw new Error(`ps exited with code ${exitCode}`);
145
+ return stdout
146
+ .split("\n")
147
+ .map((line) => agentProcessFrom(line, agentNames))
148
+ .filter((found): found is AgentProcess => found !== null);
149
+ }
150
+
151
+ // A non-zero lsof exit means this one process is unreadable (it vanished or
152
+ // belongs to someone else): null, so the caller fails closed and reports it.
153
+ // lsof that cannot run at all rejects, so the whole check fails.
154
+ async function workingDirectory(
155
+ pid: number,
156
+ run: CommandRunner,
157
+ ): Promise<string | null> {
158
+ const { exitCode, stdout } = await run("lsof", [
159
+ "-a",
160
+ "-d",
161
+ "cwd",
162
+ "-p",
163
+ String(pid),
164
+ "-Fn",
165
+ ]);
166
+ if (exitCode !== 0) return null;
167
+ const nameLine = stdout.split("\n").find((line) => line.startsWith("n/"));
168
+ return nameLine ? nameLine.slice(1) : null;
169
+ }
170
+
171
+ function canonicalPath(target: string): string {
172
+ try {
173
+ return realpathSync(target);
174
+ } catch {
175
+ return path.resolve(target);
176
+ }
177
+ }
178
+
179
+ function containingWorktree(cwd: string, worktrees: string[]): string | null {
180
+ const resolved = path.resolve(cwd);
181
+ return (
182
+ worktrees.find(
183
+ (worktree) =>
184
+ resolved === worktree || resolved.startsWith(worktree + path.sep),
185
+ ) ?? null
186
+ );
187
+ }
188
+
189
+ async function leftoverSession(
190
+ found: AgentProcess,
191
+ worktrees: string[],
192
+ run: CommandRunner,
193
+ ): Promise<LeftoverSession | null> {
194
+ const cwd = await workingDirectory(found.pid, run);
195
+ if (cwd === null) return { ...found, cwd: null, worktree: null };
196
+ const worktree = containingWorktree(cwd, worktrees);
197
+ return worktree === null ? null : { ...found, cwd, worktree };
198
+ }
199
+
200
+ // Agent sessions still running inside one of the worktrees. Stateless: a
201
+ // process-name and working-directory scan. Throws when the scan cannot run.
202
+ export async function findLeftoverAgentSessions(
203
+ worktrees: string[],
204
+ agentNames: readonly string[],
205
+ run: CommandRunner = runCommand,
206
+ ): Promise<LeftoverSession[]> {
207
+ const canonicalWorktrees = worktrees.map(canonicalPath);
208
+ const candidates = await agentProcesses(agentNames, run);
209
+ const sessions = await Promise.all(
210
+ candidates.map((found) => leftoverSession(found, canonicalWorktrees, run)),
211
+ );
212
+ return sessions.filter((session): session is LeftoverSession => session !== null);
213
+ }
@@ -10,6 +10,7 @@ export interface Issue {
10
10
  number: number;
11
11
  title: string;
12
12
  body: string;
13
+ labels: string[];
13
14
  dependsOn: number[];
14
15
  externalDependsOn: number[];
15
16
  }
@@ -18,6 +19,7 @@ interface RawIssue {
18
19
  number: number;
19
20
  title: string;
20
21
  body: string | null;
22
+ labels?: { name?: unknown }[] | null;
21
23
  }
22
24
 
23
25
  export const plansDir = ".yaawn/plans";
@@ -133,11 +135,20 @@ function parseDependsOn(body: string): number[] {
133
135
  return [...line.matchAll(/#(\d+)/g)].map((match) => Number(match[1]));
134
136
  }
135
137
 
136
- function toIssue({ number, title, body }: RawIssue): Issue {
138
+ // Only string label names count; anything else gh might return is dropped.
139
+ function rawLabelNames(labels: RawIssue["labels"]): string[] {
140
+ if (!Array.isArray(labels)) return [];
141
+ return labels
142
+ .map((label) => label?.name)
143
+ .filter((name): name is string => typeof name === "string");
144
+ }
145
+
146
+ function toIssue({ number, title, body, labels }: RawIssue): Issue {
137
147
  return {
138
148
  number,
139
149
  title,
140
150
  body: body ?? "",
151
+ labels: rawLabelNames(labels),
141
152
  dependsOn: parseDependsOn(body ?? ""),
142
153
  externalDependsOn: [],
143
154
  };
@@ -148,7 +159,7 @@ const fetchIssueArgs = (issue: number): string[] => [
148
159
  "view",
149
160
  String(issue),
150
161
  "--json",
151
- "number,title,body",
162
+ "number,title,body,labels",
152
163
  ];
153
164
 
154
165
  const parseIssue = (output: string): Issue => toIssue(JSON.parse(output) as RawIssue);