@yaawn/workflow 0.3.1 → 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 (96) hide show
  1. package/README.md +22 -43
  2. package/agents/personas/yaawn-product-owner.md +4 -4
  3. package/agents/skills/yaawn-create-plan/SKILL.md +83 -50
  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 +8 -1
  12. package/agents/skills/yaawn-grill-story/references/concept-challenge.md +12 -2
  13. package/agents/skills/yaawn-grill-story/references/story-checklist.md +2 -1
  14. package/agents/skills/yaawn-implement-plan/SKILL.md +10 -8
  15. package/agents/skills/yaawn-implement-plan/scripts/agent.ts +1 -1
  16. package/agents/skills/yaawn-implement-plan/scripts/overnight.ts +7 -4
  17. package/agents/skills/yaawn-implement-plan/scripts/parallel.ts +155 -12
  18. package/agents/skills/yaawn-implement-plan/scripts/processes.ts +161 -1
  19. package/agents/skills/yaawn-implement-plan/scripts/targets.ts +13 -2
  20. package/agents/skills/yaawn-implement-plan/scripts/worktrees.ts +219 -52
  21. package/agents/skills/yaawn-prepare-plan/SKILL.md +20 -19
  22. package/agents/skills/yaawn-save-plan/SKILL.md +26 -7
  23. package/agents/skills/yaawn-save-plan/scripts/plan-body.ts +75 -9
  24. package/agents/skills/yaawn-save-plan/scripts/save-plan.ts +97 -1
  25. package/agents/skills/yaawn-split-story/SKILL.md +1 -1
  26. package/agents/skills/yaawn-verify-executed-plan/SKILL.md +28 -13
  27. package/agents/skills/yaawn-verify-executed-plan/scripts/overnight.ts +44 -12
  28. package/agents/skills/yaawn-write-bug-report/SKILL.md +6 -5
  29. package/agents/skills/yaawn-write-other/SKILL.md +6 -5
  30. package/agents/skills/yaawn-write-prototype/SKILL.md +96 -0
  31. package/agents/skills/yaawn-write-refactor/SKILL.md +6 -5
  32. package/agents/skills/yaawn-write-spike/SKILL.md +1 -1
  33. package/agents/skills/yaawn-write-user-story/SKILL.md +4 -3
  34. package/agents/yaawn-glossary.md +6 -5
  35. package/config/install-tasks.json +0 -1
  36. package/config/wizard.json +5 -10
  37. package/dist/cli/args.js +66 -0
  38. package/dist/cli/errors.js +103 -0
  39. package/dist/cli/exit-codes.js +3 -0
  40. package/dist/cli/index.js +11 -0
  41. package/dist/cli/install-report.js +92 -0
  42. package/dist/cli/install.js +124 -0
  43. package/dist/cli/main.js +17 -0
  44. package/dist/cli/package-root.js +17 -0
  45. package/dist/cli/project-root.js +11 -0
  46. package/dist/cli/run.js +141 -0
  47. package/dist/cli/session.js +1 -0
  48. package/dist/cli/terminal.js +5 -0
  49. package/dist/cli/uninstall.js +89 -0
  50. package/dist/cli/version.js +7 -0
  51. package/dist/config/agents.js +108 -0
  52. package/dist/config/config.js +118 -0
  53. package/dist/config/errors.js +45 -0
  54. package/dist/config/index.js +11 -0
  55. package/dist/config/manifest.js +28 -0
  56. package/dist/config/messages.js +86 -0
  57. package/dist/config/names.js +40 -0
  58. package/dist/config/paths.js +73 -0
  59. package/dist/config/settings.js +101 -0
  60. package/dist/config/strict-json.js +119 -0
  61. package/dist/config/tasks.js +77 -0
  62. package/dist/config/wizard.js +139 -0
  63. package/dist/display/index.js +3 -0
  64. package/dist/display/palette.js +61 -0
  65. package/dist/display/renderer.js +169 -0
  66. package/dist/display/report.js +15 -0
  67. package/dist/display/sanitize.js +10 -0
  68. package/dist/link/errors.js +34 -0
  69. package/dist/link/fs-utils.js +79 -0
  70. package/dist/link/index.js +4 -0
  71. package/dist/link/install.js +20 -0
  72. package/dist/link/linker.js +137 -0
  73. package/dist/link/memory.js +221 -0
  74. package/dist/link/names.js +30 -0
  75. package/dist/link/types.js +30 -0
  76. package/dist/link/uninstall.js +90 -0
  77. package/dist/sync/apply.js +74 -0
  78. package/dist/sync/changes.js +31 -0
  79. package/dist/sync/fs-utils.js +63 -0
  80. package/dist/sync/index.js +7 -0
  81. package/dist/sync/install.js +57 -0
  82. package/dist/sync/languages.js +80 -0
  83. package/dist/sync/plan.js +146 -0
  84. package/dist/sync/projects.js +294 -0
  85. package/dist/sync/uninstall.js +234 -0
  86. package/dist/wizard/build.js +89 -0
  87. package/dist/wizard/index.js +5 -0
  88. package/dist/wizard/items.js +83 -0
  89. package/dist/wizard/prompts.js +167 -0
  90. package/dist/wizard/section.js +143 -0
  91. package/dist/wizard/wizard.js +73 -0
  92. package/github/ISSUE_TEMPLATE/yaawn-prototype.md +48 -0
  93. package/package.json +11 -10
  94. package/yaawn/yaawn-user-roles.md +1 -1
  95. package/agents/instructions/output-styles/yaawn-eli5.instructions.md +0 -45
  96. package/scripts/install.js +0 -73
@@ -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);