issue-flow 0.11.1 → 0.12.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 (112) hide show
  1. package/dist/{analyze-XV3K25K3.js → analyze-OEET7KGE.js} +15 -10
  2. package/dist/{analyze-XV3K25K3.js.map → analyze-OEET7KGE.js.map} +1 -1
  3. package/dist/{apply-WUJHDD7A.js → apply-ZIA3C52S.js} +6 -4
  4. package/dist/{apply-WUJHDD7A.js.map → apply-ZIA3C52S.js.map} +1 -1
  5. package/dist/{chunk-6N7LZZ5U.js → chunk-3QHK4YQA.js} +204 -107
  6. package/dist/chunk-3QHK4YQA.js.map +1 -0
  7. package/dist/chunk-42PJFMAR.js +605 -0
  8. package/dist/chunk-42PJFMAR.js.map +1 -0
  9. package/dist/{chunk-EWDJENFW.js → chunk-4JSOYE4G.js} +77 -12
  10. package/dist/chunk-4JSOYE4G.js.map +1 -0
  11. package/dist/chunk-56KS3TFQ.js +703 -0
  12. package/dist/chunk-56KS3TFQ.js.map +1 -0
  13. package/dist/{chunk-AAY6KM7N.js → chunk-73ATPIYX.js} +160 -154
  14. package/dist/chunk-73ATPIYX.js.map +1 -0
  15. package/dist/{chunk-KQ4JQN7T.js → chunk-A4SLHOW7.js} +68 -12
  16. package/dist/chunk-A4SLHOW7.js.map +1 -0
  17. package/dist/chunk-BAUPQXPE.js +173 -0
  18. package/dist/chunk-BAUPQXPE.js.map +1 -0
  19. package/dist/{chunk-HC2MT7BX.js → chunk-BYZDPR7N.js} +14 -581
  20. package/dist/chunk-BYZDPR7N.js.map +1 -0
  21. package/dist/chunk-CD5ULRVC.js +615 -0
  22. package/dist/chunk-CD5ULRVC.js.map +1 -0
  23. package/dist/chunk-DIDUVL6O.js +1365 -0
  24. package/dist/chunk-DIDUVL6O.js.map +1 -0
  25. package/dist/{chunk-47VIN33V.js → chunk-EZATO3DW.js} +7 -5
  26. package/dist/{chunk-47VIN33V.js.map → chunk-EZATO3DW.js.map} +1 -1
  27. package/dist/{chunk-BXCPXRBR.js → chunk-FADTCQGM.js} +46 -11
  28. package/dist/chunk-FADTCQGM.js.map +1 -0
  29. package/dist/{chunk-GANSDJV7.js → chunk-FJP3UQGD.js} +15 -13
  30. package/dist/chunk-FJP3UQGD.js.map +1 -0
  31. package/dist/chunk-KMMASOA6.js +134 -0
  32. package/dist/chunk-KMMASOA6.js.map +1 -0
  33. package/dist/{chunk-HQYXVIH6.js → chunk-LAFNLLQP.js} +3 -3
  34. package/dist/chunk-LWRSMTTZ.js +18 -0
  35. package/dist/chunk-LWRSMTTZ.js.map +1 -0
  36. package/dist/{chunk-IVHZEYAX.js → chunk-M5UCXTIT.js} +13 -11
  37. package/dist/{chunk-IVHZEYAX.js.map → chunk-M5UCXTIT.js.map} +1 -1
  38. package/dist/chunk-M66SJHQQ.js +206 -0
  39. package/dist/chunk-M66SJHQQ.js.map +1 -0
  40. package/dist/{chunk-OBLVLIUS.js → chunk-MJULIKQU.js} +42 -6
  41. package/dist/chunk-MJULIKQU.js.map +1 -0
  42. package/dist/{chunk-RKTEV6EG.js → chunk-NS6R37II.js} +4 -4
  43. package/dist/{run-DBF2VWUN.js → chunk-NTMMF5UD.js} +515 -544
  44. package/dist/chunk-NTMMF5UD.js.map +1 -0
  45. package/dist/{chunk-EY6WMHV2.js → chunk-QCPGHBSZ.js} +14 -12
  46. package/dist/{chunk-EY6WMHV2.js.map → chunk-QCPGHBSZ.js.map} +1 -1
  47. package/dist/{chunk-LVKQ2AKT.js → chunk-TSOLWK7Y.js} +8 -6
  48. package/dist/chunk-TSOLWK7Y.js.map +1 -0
  49. package/dist/{chunk-34WNTK3K.js → chunk-XQUSIEKA.js} +11 -9
  50. package/dist/{chunk-34WNTK3K.js.map → chunk-XQUSIEKA.js.map} +1 -1
  51. package/dist/chunk-Y5ZDJ4PJ.js +76 -0
  52. package/dist/chunk-Y5ZDJ4PJ.js.map +1 -0
  53. package/dist/cli.js +156 -26
  54. package/dist/cli.js.map +1 -1
  55. package/dist/{config-4THHOXNN.js → config-PRJGWW7J.js} +15 -3
  56. package/dist/execute-XBNVL753.js +18 -0
  57. package/dist/{generate-TBVTLMOP.js → generate-EGB4KV2B.js} +16 -10
  58. package/dist/{generate-TBVTLMOP.js.map → generate-EGB4KV2B.js.map} +1 -1
  59. package/dist/init-AJHCXJBP.js +10 -0
  60. package/dist/operations-BYT626KQ.js +365 -0
  61. package/dist/operations-BYT626KQ.js.map +1 -0
  62. package/dist/plan-QOZ3RIO6.js +21 -0
  63. package/dist/{policy-XQW5NUIE.js → policy-6S4GFC76.js} +6 -4
  64. package/dist/{policy-XQW5NUIE.js.map → policy-6S4GFC76.js.map} +1 -1
  65. package/dist/pr-EBBHD2ZX.js +26 -0
  66. package/dist/pr-review-VVMW5PO4.js +20 -0
  67. package/dist/prd-JQSZF7PR.js +21 -0
  68. package/dist/resume-PPCNQVVV.js +221 -0
  69. package/dist/resume-PPCNQVVV.js.map +1 -0
  70. package/dist/review-3F6MARWH.js +19 -0
  71. package/dist/run-RXE5GDQO.js +36 -0
  72. package/dist/run-RXE5GDQO.js.map +1 -0
  73. package/dist/state-manager-2EDVDBAR.js +34 -0
  74. package/dist/state-manager-2EDVDBAR.js.map +1 -0
  75. package/dist/{web-ANQQTTRD.js → web-EF5WIIGG.js} +13 -8
  76. package/dist/{web-ANQQTTRD.js.map → web-EF5WIIGG.js.map} +1 -1
  77. package/package.json +1 -1
  78. package/prompts/pr.md +11 -4
  79. package/web/public/app.js +12 -0
  80. package/dist/chunk-3BOHTH4V.js +0 -58
  81. package/dist/chunk-3BOHTH4V.js.map +0 -1
  82. package/dist/chunk-6N7LZZ5U.js.map +0 -1
  83. package/dist/chunk-AAY6KM7N.js.map +0 -1
  84. package/dist/chunk-BXCPXRBR.js.map +0 -1
  85. package/dist/chunk-EWDJENFW.js.map +0 -1
  86. package/dist/chunk-GANSDJV7.js.map +0 -1
  87. package/dist/chunk-HC2MT7BX.js.map +0 -1
  88. package/dist/chunk-HDV3YDTG.js +0 -52
  89. package/dist/chunk-HDV3YDTG.js.map +0 -1
  90. package/dist/chunk-KQ4JQN7T.js.map +0 -1
  91. package/dist/chunk-LVKQ2AKT.js.map +0 -1
  92. package/dist/chunk-OBLVLIUS.js.map +0 -1
  93. package/dist/chunk-VQ5VN7DF.js +0 -1018
  94. package/dist/chunk-VQ5VN7DF.js.map +0 -1
  95. package/dist/execute-5OEKLM5X.js +0 -15
  96. package/dist/init-PRMOTQ7N.js +0 -9
  97. package/dist/plan-Y2BYZD24.js +0 -17
  98. package/dist/pr-H6GLOSXF.js +0 -21
  99. package/dist/pr-review-CK5FR6WX.js +0 -17
  100. package/dist/prd-4PWLCGH5.js +0 -17
  101. package/dist/review-QGVAHEZF.js +0 -15
  102. package/dist/run-DBF2VWUN.js.map +0 -1
  103. /package/dist/{chunk-HQYXVIH6.js.map → chunk-LAFNLLQP.js.map} +0 -0
  104. /package/dist/{chunk-RKTEV6EG.js.map → chunk-NS6R37II.js.map} +0 -0
  105. /package/dist/{config-4THHOXNN.js.map → config-PRJGWW7J.js.map} +0 -0
  106. /package/dist/{execute-5OEKLM5X.js.map → execute-XBNVL753.js.map} +0 -0
  107. /package/dist/{init-PRMOTQ7N.js.map → init-AJHCXJBP.js.map} +0 -0
  108. /package/dist/{plan-Y2BYZD24.js.map → plan-QOZ3RIO6.js.map} +0 -0
  109. /package/dist/{pr-H6GLOSXF.js.map → pr-EBBHD2ZX.js.map} +0 -0
  110. /package/dist/{pr-review-CK5FR6WX.js.map → pr-review-VVMW5PO4.js.map} +0 -0
  111. /package/dist/{prd-4PWLCGH5.js.map → prd-JQSZF7PR.js.map} +0 -0
  112. /package/dist/{review-QGVAHEZF.js.map → review-3F6MARWH.js.map} +0 -0
@@ -0,0 +1,221 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ runPipeline
4
+ } from "./chunk-NTMMF5UD.js";
5
+ import "./chunk-FJP3UQGD.js";
6
+ import "./chunk-FADTCQGM.js";
7
+ import "./chunk-A4SLHOW7.js";
8
+ import "./chunk-M5UCXTIT.js";
9
+ import "./chunk-XQUSIEKA.js";
10
+ import {
11
+ PIPELINE_PHASES,
12
+ PipelineManager,
13
+ loadExecutionPlan,
14
+ nextQueueIssue,
15
+ parseJournal
16
+ } from "./chunk-56KS3TFQ.js";
17
+ import {
18
+ acquireRunLock,
19
+ describeRunLockOwner
20
+ } from "./chunk-BAUPQXPE.js";
21
+ import "./chunk-NS6R37II.js";
22
+ import "./chunk-3QHK4YQA.js";
23
+ import "./chunk-TSOLWK7Y.js";
24
+ import "./chunk-QCPGHBSZ.js";
25
+ import "./chunk-Y5ZDJ4PJ.js";
26
+ import "./chunk-73ATPIYX.js";
27
+ import "./chunk-4JSOYE4G.js";
28
+ import "./chunk-KMMASOA6.js";
29
+ import "./chunk-LWRSMTTZ.js";
30
+ import "./chunk-MJULIKQU.js";
31
+ import "./chunk-LAFNLLQP.js";
32
+ import "./chunk-M66SJHQQ.js";
33
+ import "./chunk-EZATO3DW.js";
34
+ import "./chunk-42PJFMAR.js";
35
+ import {
36
+ QUEUES_DIR_NAME,
37
+ describePreflight,
38
+ getIssuePaths,
39
+ preflightRepository,
40
+ resolveProjectPaths
41
+ } from "./chunk-DIDUVL6O.js";
42
+ import {
43
+ printError,
44
+ printInfo,
45
+ printWarning
46
+ } from "./chunk-BYZDPR7N.js";
47
+ import {
48
+ loadTaskPlan
49
+ } from "./chunk-CD5ULRVC.js";
50
+
51
+ // src/commands/resume.ts
52
+ import { readdir, readFile } from "fs/promises";
53
+ import { join } from "path";
54
+ async function runResume(issue, options = {}) {
55
+ const mode = options.mode ?? "auto";
56
+ let project;
57
+ try {
58
+ project = await resolveProjectPaths();
59
+ } catch (err) {
60
+ printError(`Cannot resume: ${err instanceof Error ? err.message : String(err)}`);
61
+ return 1;
62
+ }
63
+ const acquisition = await acquireRunLock(project.runLockFile, { target: issue ?? "resume" });
64
+ if (!acquisition.ok) {
65
+ printError(
66
+ `Another issue-flow run owns this project: ${describeRunLockOwner(acquisition.owner)}.`
67
+ );
68
+ printInfo("Wait for it to finish, or stop that process before resuming.");
69
+ return 1;
70
+ }
71
+ const handle = acquisition.handle;
72
+ if (handle.reclaimedFrom !== null) {
73
+ printWarning(
74
+ `Taking over an interrupted run: ${describeRunLockOwner(handle.reclaimedFrom)}. Its lock was stale.`
75
+ );
76
+ }
77
+ try {
78
+ const targets = await findTargets(project, issue, options.all === true);
79
+ if (targets.length === 0) {
80
+ printInfo(
81
+ issue === void 0 ? "Nothing to resume: no issue in this project has an unfinished pipeline." : `Nothing to resume: issue #${issue} has no task plan in this project.`
82
+ );
83
+ return 0;
84
+ }
85
+ for (const target of targets) {
86
+ const code = await resumeOne(target, mode);
87
+ if (code !== 0) return code;
88
+ }
89
+ return 0;
90
+ } finally {
91
+ await handle.release();
92
+ }
93
+ }
94
+ async function resumeOne(target, mode) {
95
+ const phases = activePhases(target.plan);
96
+ const manager = new PipelineManager(target.plan, target.tasksFile, phases);
97
+ const next = manager.getNextPhase();
98
+ if (next === null) {
99
+ printInfo(`Issue #${target.issue} has every phase complete; nothing to resume.`);
100
+ return 0;
101
+ }
102
+ const interrupted = await lastUnfinishedPhase(target);
103
+ if (interrupted !== null) {
104
+ printInfo(`Issue #${target.issue} was interrupted during the '${interrupted}' phase.`);
105
+ }
106
+ const sameWork = interrupted === next;
107
+ const preflight = await preflightRepository({
108
+ expectedBranch: target.plan.noBranch === true ? null : target.plan.branchName,
109
+ intent: sameWork ? "resume-same-phase" : "new-phase"
110
+ });
111
+ if (!preflight.ok) {
112
+ printError(`Cannot resume issue #${target.issue}: the repository needs attention first.`);
113
+ for (const line of describePreflight(preflight)) {
114
+ printError(line);
115
+ }
116
+ return 1;
117
+ }
118
+ printInfo(`Resuming issue #${target.issue} from the '${next}' phase.`);
119
+ return runPipeline(target.issue, mode, next, void 0, void 0, { only: true });
120
+ }
121
+ function activePhases(plan) {
122
+ const base = plan.noBranch === true ? PIPELINE_PHASES.filter((phase) => phase !== "pr") : [...PIPELINE_PHASES];
123
+ return plan.prReview?.enabled === true ? [...base, "pr-review"] : base;
124
+ }
125
+ async function lastUnfinishedPhase(target) {
126
+ const content = `${await readIfPresent(target.rotatedEventsFile)}${await readIfPresent(target.eventsFile)}`;
127
+ if (content === "") return null;
128
+ const open = [];
129
+ for (const entry of parseJournal(content)) {
130
+ const event = entry.event;
131
+ if (event.type === "phase:start") {
132
+ open.push(event.phase);
133
+ } else if (event.type === "phase:end") {
134
+ const index = open.lastIndexOf(event.phase);
135
+ if (index >= 0) open.splice(index, 1);
136
+ }
137
+ }
138
+ return open.at(-1) ?? null;
139
+ }
140
+ async function readIfPresent(path) {
141
+ try {
142
+ return await readFile(path, "utf-8");
143
+ } catch {
144
+ return "";
145
+ }
146
+ }
147
+ async function findTargets(project, issue, all) {
148
+ if (issue !== void 0) {
149
+ const target = await loadTarget(project.projectId, issue);
150
+ return target === null ? [] : [target];
151
+ }
152
+ const queued = await queueTarget(project);
153
+ if (queued !== null && !all) return [queued];
154
+ const unfinished = await unfinishedTargets(project);
155
+ if (all) return unfinished;
156
+ return unfinished.slice(0, 1);
157
+ }
158
+ async function queueTarget(project) {
159
+ const queuesDir = join(project.projectDir, QUEUES_DIR_NAME);
160
+ let entries;
161
+ try {
162
+ entries = await readdir(queuesDir);
163
+ } catch {
164
+ return null;
165
+ }
166
+ for (const entry of entries) {
167
+ const plan = await loadExecutionPlan(join(queuesDir, entry, "execution-plan.json"));
168
+ if (plan === null) continue;
169
+ const next = nextQueueIssue(plan);
170
+ if (next === null) continue;
171
+ const target = await loadTarget(project.projectId, next.id);
172
+ if (target !== null) return target;
173
+ }
174
+ return null;
175
+ }
176
+ async function unfinishedTargets(project) {
177
+ let ids;
178
+ try {
179
+ ids = await readdir(project.issuesDir);
180
+ } catch {
181
+ return [];
182
+ }
183
+ const targets = [];
184
+ for (const id of ids) {
185
+ const target = await loadTarget(project.projectId, id);
186
+ if (target === null) continue;
187
+ if (target.plan.issueStatus === "completed") continue;
188
+ const manager = new PipelineManager(target.plan, target.tasksFile, activePhases(target.plan));
189
+ if (manager.getNextPhase() === null) continue;
190
+ targets.push(target);
191
+ }
192
+ return targets.sort((a, b) => attemptedAt(b.plan) - attemptedAt(a.plan));
193
+ }
194
+ function attemptedAt(plan) {
195
+ const at = Date.parse(plan.lastAttemptAt ?? "");
196
+ return Number.isNaN(at) ? 0 : at;
197
+ }
198
+ async function loadTarget(projectId, issue) {
199
+ let paths;
200
+ try {
201
+ paths = getIssuePaths(projectId, issue);
202
+ } catch {
203
+ return null;
204
+ }
205
+ try {
206
+ const plan = await loadTaskPlan(paths.tasksFile);
207
+ return {
208
+ issue,
209
+ plan,
210
+ tasksFile: paths.tasksFile,
211
+ eventsFile: paths.eventsFile,
212
+ rotatedEventsFile: paths.rotatedEventsFile
213
+ };
214
+ } catch {
215
+ return null;
216
+ }
217
+ }
218
+ export {
219
+ runResume
220
+ };
221
+ //# sourceMappingURL=resume-PPCNQVVV.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/commands/resume.ts"],"sourcesContent":["import { readdir, readFile } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport { parseJournal } from '../core/journal.js';\nimport { PIPELINE_PHASES, PipelineManager, type PipelinePhase } from '../core/pipeline.js';\nimport { loadTaskPlan } from '../core/state-manager.js';\nimport { loadExecutionPlan, nextQueueIssue } from '../execution/plan.js';\nimport { acquireRunLock, describeRunLockOwner, type RunLockHandle } from '../storage/lock.js';\nimport { getIssuePaths, QUEUES_DIR_NAME } from '../storage/paths.js';\nimport { resolveProjectPaths } from '../storage/resolve.js';\nimport type { TaskPlan } from '../types.js';\nimport { printError, printInfo, printWarning } from '../ui/logger.js';\nimport { describePreflight, preflightRepository } from '../utils/git.js';\nimport { runPipeline } from './run.js';\n\n/**\n * `issue-flow resume` — explicit recovery.\n *\n * Resumption already worked before this command existed, but only *implicitly*:\n * the user re-ran the same `run` and the pipeline happened to pick up where it\n * left off, because `PipelineManager` skips the phases already marked complete\n * and `nextQueueIssue()` treats a stale `in_progress` as \"do this one first\".\n * That is a coincidence of two mechanisms, not a contract — nothing said what\n * had been interrupted, nothing checked whether the repository was still in a\n * state to continue, and nothing distinguished \"carry on\" from \"start over\".\n *\n * This command makes each of those steps explicit, in this order:\n *\n * 1. **ownership** — a live owner refuses, a dead one is taken over;\n * 2. **plans** — `execution-plan.json` for a queue, `tasks.json` for an issue;\n * 3. **the journal** — the last `phase:start` with no `phase:end` is what was\n * running when the process died, which is the one fact no snapshot keeps;\n * 4. **the preflight** — the repository is described, and a state a human has to\n * settle stops the resume instead of being repaired;\n * 5. **the phase** — `PipelineManager.getNextPhase()`, the same answer `run`\n * has always used, now stated out loud before anything runs.\n *\n * `run` is untouched: its auto-resume still works exactly as it did.\n */\n\nexport interface ResumeOptions {\n /** Resume every unfinished issue of the project, not just one. */\n all?: boolean;\n /** Execution mode handed to the pipeline. Same values as `run`. */\n mode?: string;\n}\n\ninterface ResumeTarget {\n issue: string;\n plan: TaskPlan;\n tasksFile: string;\n eventsFile: string;\n rotatedEventsFile: string;\n}\n\nexport async function runResume(issue?: string, options: ResumeOptions = {}): Promise<number> {\n const mode = options.mode ?? 'auto';\n\n let project: Awaited<ReturnType<typeof resolveProjectPaths>>;\n try {\n project = await resolveProjectPaths();\n } catch (err) {\n printError(`Cannot resume: ${err instanceof Error ? err.message : String(err)}`);\n return 1;\n }\n\n // 1. Ownership, before reading anything else: a resume that runs beside a\n // live owner is the collision this whole layer exists to prevent.\n const acquisition = await acquireRunLock(project.runLockFile, { target: issue ?? 'resume' });\n if (!acquisition.ok) {\n printError(\n `Another issue-flow run owns this project: ${describeRunLockOwner(acquisition.owner)}.`,\n );\n printInfo('Wait for it to finish, or stop that process before resuming.');\n return 1;\n }\n const handle: RunLockHandle = acquisition.handle;\n if (handle.reclaimedFrom !== null) {\n printWarning(\n `Taking over an interrupted run: ${describeRunLockOwner(handle.reclaimedFrom)}. Its lock was stale.`,\n );\n }\n\n try {\n // 2. What is there to resume.\n const targets = await findTargets(project, issue, options.all === true);\n if (targets.length === 0) {\n printInfo(\n issue === undefined\n ? 'Nothing to resume: no issue in this project has an unfinished pipeline.'\n : `Nothing to resume: issue #${issue} has no task plan in this project.`,\n );\n return 0;\n }\n\n for (const target of targets) {\n const code = await resumeOne(target, mode);\n if (code !== 0) return code;\n }\n return 0;\n } finally {\n await handle.release();\n }\n}\n\nasync function resumeOne(target: ResumeTarget, mode: string): Promise<number> {\n const phases = activePhases(target.plan);\n const manager = new PipelineManager(target.plan, target.tasksFile, phases);\n const next = manager.getNextPhase();\n\n if (next === null) {\n printInfo(`Issue #${target.issue} has every phase complete; nothing to resume.`);\n return 0;\n }\n\n // 3. What was running when the process died. The snapshot cannot answer this\n // — it only knows the phase failed or never ended — so the journal does.\n const interrupted = await lastUnfinishedPhase(target);\n if (interrupted !== null) {\n printInfo(`Issue #${target.issue} was interrupted during the '${interrupted}' phase.`);\n }\n\n // 4. The repository, described and never repaired. Continuing the very phase\n // that was interrupted is the one case where uncommitted work is expected;\n // anything else would be carrying changes into work they do not belong to.\n const sameWork = interrupted === next;\n const preflight = await preflightRepository({\n expectedBranch: target.plan.noBranch === true ? null : target.plan.branchName,\n intent: sameWork ? 'resume-same-phase' : 'new-phase',\n });\n if (!preflight.ok) {\n printError(`Cannot resume issue #${target.issue}: the repository needs attention first.`);\n for (const line of describePreflight(preflight)) {\n printError(line);\n }\n return 1;\n }\n\n // 5. The phase, stated before anything runs.\n printInfo(`Resuming issue #${target.issue} from the '${next}' phase.`);\n return runPipeline(target.issue, mode, next, undefined, undefined, { only: true });\n}\n\n/** The phase set this plan's pipeline actually has, so `pr-review` is honoured. */\nfunction activePhases(plan: TaskPlan): readonly PipelinePhase[] {\n const base =\n plan.noBranch === true\n ? PIPELINE_PHASES.filter((phase) => phase !== 'pr')\n : [...PIPELINE_PHASES];\n return plan.prReview?.enabled === true ? [...base, 'pr-review'] : base;\n}\n\n/**\n * The phase the journal shows as started and never ended.\n *\n * Both generations are read, oldest first, because a run long enough to rotate\n * its journal is exactly the kind that gets interrupted. A missing or\n * unreadable journal answers `null`: the resume continues from the plan, which\n * is what it did before a journal existed.\n */\nasync function lastUnfinishedPhase(target: ResumeTarget): Promise<string | null> {\n const content = `${await readIfPresent(target.rotatedEventsFile)}${await readIfPresent(target.eventsFile)}`;\n if (content === '') return null;\n\n const open: string[] = [];\n for (const entry of parseJournal(content)) {\n const event = entry.event;\n if (event.type === 'phase:start') {\n open.push(event.phase);\n } else if (event.type === 'phase:end') {\n const index = open.lastIndexOf(event.phase);\n if (index >= 0) open.splice(index, 1);\n }\n }\n return open.at(-1) ?? null;\n}\n\nasync function readIfPresent(path: string): Promise<string> {\n try {\n return await readFile(path, 'utf-8');\n } catch {\n return '';\n }\n}\n\n/**\n * Which issues to resume.\n *\n * A named issue is taken at its word. Without one, the queue decides when there\n * is a queue — `nextQueueIssue()` is already the project's answer to \"which one\n * first\" — and otherwise the unfinished issues are ordered by when they were\n * last attempted, so `resume` alone picks up the most recent work.\n */\nasync function findTargets(\n project: Awaited<ReturnType<typeof resolveProjectPaths>>,\n issue: string | undefined,\n all: boolean,\n): Promise<ResumeTarget[]> {\n if (issue !== undefined) {\n const target = await loadTarget(project.projectId, issue);\n return target === null ? [] : [target];\n }\n\n const queued = await queueTarget(project);\n if (queued !== null && !all) return [queued];\n\n const unfinished = await unfinishedTargets(project);\n if (all) return unfinished;\n return unfinished.slice(0, 1);\n}\n\n/** The issue a queue would hand out next, when this project has a queue. */\nasync function queueTarget(\n project: Awaited<ReturnType<typeof resolveProjectPaths>>,\n): Promise<ResumeTarget | null> {\n const queuesDir = join(project.projectDir, QUEUES_DIR_NAME);\n let entries: string[];\n try {\n entries = await readdir(queuesDir);\n } catch {\n return null;\n }\n\n for (const entry of entries) {\n const plan = await loadExecutionPlan(join(queuesDir, entry, 'execution-plan.json'));\n if (plan === null) continue;\n const next = nextQueueIssue(plan);\n if (next === null) continue;\n const target = await loadTarget(project.projectId, next.id);\n if (target !== null) return target;\n }\n return null;\n}\n\n/** Every issue whose plan is not finished, most recently attempted first. */\nasync function unfinishedTargets(\n project: Awaited<ReturnType<typeof resolveProjectPaths>>,\n): Promise<ResumeTarget[]> {\n let ids: string[];\n try {\n ids = await readdir(project.issuesDir);\n } catch {\n return [];\n }\n\n const targets: ResumeTarget[] = [];\n for (const id of ids) {\n const target = await loadTarget(project.projectId, id);\n if (target === null) continue;\n if (target.plan.issueStatus === 'completed') continue;\n const manager = new PipelineManager(target.plan, target.tasksFile, activePhases(target.plan));\n if (manager.getNextPhase() === null) continue;\n targets.push(target);\n }\n\n return targets.sort((a, b) => attemptedAt(b.plan) - attemptedAt(a.plan));\n}\n\nfunction attemptedAt(plan: TaskPlan): number {\n const at = Date.parse(plan.lastAttemptAt ?? '');\n return Number.isNaN(at) ? 0 : at;\n}\n\nasync function loadTarget(projectId: string, issue: string): Promise<ResumeTarget | null> {\n let paths: ReturnType<typeof getIssuePaths>;\n try {\n paths = getIssuePaths(projectId, issue);\n } catch {\n // Not a usable issue identifier — a stray file in `issues/`, for instance.\n return null;\n }\n\n try {\n const plan = await loadTaskPlan(paths.tasksFile);\n return {\n issue,\n plan,\n tasksFile: paths.tasksFile,\n eventsFile: paths.eventsFile,\n rotatedEventsFile: paths.rotatedEventsFile,\n };\n } catch {\n return null;\n }\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ runReview
4
+ } from "./chunk-XQUSIEKA.js";
5
+ import "./chunk-73ATPIYX.js";
6
+ import "./chunk-4JSOYE4G.js";
7
+ import "./chunk-KMMASOA6.js";
8
+ import "./chunk-MJULIKQU.js";
9
+ import "./chunk-LAFNLLQP.js";
10
+ import "./chunk-M66SJHQQ.js";
11
+ import "./chunk-EZATO3DW.js";
12
+ import "./chunk-42PJFMAR.js";
13
+ import "./chunk-DIDUVL6O.js";
14
+ import "./chunk-BYZDPR7N.js";
15
+ import "./chunk-CD5ULRVC.js";
16
+ export {
17
+ runReview
18
+ };
19
+ //# sourceMappingURL=review-3F6MARWH.js.map
@@ -0,0 +1,36 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ publishIssueDetails,
4
+ publishStorySeed,
5
+ runPipeline
6
+ } from "./chunk-NTMMF5UD.js";
7
+ import "./chunk-FJP3UQGD.js";
8
+ import "./chunk-FADTCQGM.js";
9
+ import "./chunk-A4SLHOW7.js";
10
+ import "./chunk-M5UCXTIT.js";
11
+ import "./chunk-XQUSIEKA.js";
12
+ import "./chunk-56KS3TFQ.js";
13
+ import "./chunk-BAUPQXPE.js";
14
+ import "./chunk-NS6R37II.js";
15
+ import "./chunk-3QHK4YQA.js";
16
+ import "./chunk-TSOLWK7Y.js";
17
+ import "./chunk-QCPGHBSZ.js";
18
+ import "./chunk-Y5ZDJ4PJ.js";
19
+ import "./chunk-73ATPIYX.js";
20
+ import "./chunk-4JSOYE4G.js";
21
+ import "./chunk-KMMASOA6.js";
22
+ import "./chunk-LWRSMTTZ.js";
23
+ import "./chunk-MJULIKQU.js";
24
+ import "./chunk-LAFNLLQP.js";
25
+ import "./chunk-M66SJHQQ.js";
26
+ import "./chunk-EZATO3DW.js";
27
+ import "./chunk-42PJFMAR.js";
28
+ import "./chunk-DIDUVL6O.js";
29
+ import "./chunk-BYZDPR7N.js";
30
+ import "./chunk-CD5ULRVC.js";
31
+ export {
32
+ publishIssueDetails,
33
+ publishStorySeed,
34
+ runPipeline
35
+ };
36
+ //# sourceMappingURL=run-RXE5GDQO.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ allStoriesPass,
4
+ applyStoryMetrics,
5
+ clearLastError,
6
+ hasPendingCorrection,
7
+ initializeState,
8
+ isoNow,
9
+ loadTaskPlan,
10
+ markIssueCompleted,
11
+ markIssueInProgress,
12
+ markStoryPassing,
13
+ saveTaskPlan,
14
+ selectActiveStory,
15
+ setLastError,
16
+ trimErrorMessage
17
+ } from "./chunk-CD5ULRVC.js";
18
+ export {
19
+ allStoriesPass,
20
+ applyStoryMetrics,
21
+ clearLastError,
22
+ hasPendingCorrection,
23
+ initializeState,
24
+ isoNow,
25
+ loadTaskPlan,
26
+ markIssueCompleted,
27
+ markIssueInProgress,
28
+ markStoryPassing,
29
+ saveTaskPlan,
30
+ selectActiveStory,
31
+ setLastError,
32
+ trimErrorMessage
33
+ };
34
+ //# sourceMappingURL=state-manager-2EDVDBAR.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
@@ -4,20 +4,25 @@ import {
4
4
  getWebLockFile,
5
5
  readWebLock,
6
6
  removeWebLock
7
- } from "./chunk-GANSDJV7.js";
8
- import "./chunk-HQYXVIH6.js";
7
+ } from "./chunk-FJP3UQGD.js";
8
+ import "./chunk-BAUPQXPE.js";
9
+ import "./chunk-LAFNLLQP.js";
10
+ import {
11
+ loadWebConfig
12
+ } from "./chunk-42PJFMAR.js";
9
13
  import {
10
14
  ISSUES_DIR_NAME,
11
15
  PROJECTS_DIR_NAME,
12
- getGlobalRoot,
13
- loadWebConfig
14
- } from "./chunk-VQ5VN7DF.js";
16
+ getGlobalRoot
17
+ } from "./chunk-DIDUVL6O.js";
15
18
  import {
16
19
  printError,
17
20
  printInfo,
18
- printWarning,
21
+ printWarning
22
+ } from "./chunk-BYZDPR7N.js";
23
+ import {
19
24
  sessionSnapshotSchema
20
- } from "./chunk-HC2MT7BX.js";
25
+ } from "./chunk-CD5ULRVC.js";
21
26
 
22
27
  // src/web/session-directory.ts
23
28
  import { readdir, readFile, stat as statFile } from "fs/promises";
@@ -185,4 +190,4 @@ export {
185
190
  runWebServe,
186
191
  runWebStop
187
192
  };
188
- //# sourceMappingURL=web-ANQQTTRD.js.map
193
+ //# sourceMappingURL=web-EF5WIIGG.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/web/session-directory.ts","../src/commands/web.ts"],"sourcesContent":["import { readdir, readFile, stat as statFile } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport type { ValidatedSessionSnapshot } from '../schemas.js';\nimport { sessionSnapshotSchema } from '../schemas.js';\nimport {\n type GetGlobalRootOptions,\n getGlobalRoot,\n ISSUES_DIR_NAME,\n PROJECTS_DIR_NAME,\n} from '../storage/paths.js';\n\n/**\n * Multi-session discovery for the web monitoring server (US-003).\n *\n * The single-instance server (`web/lock.ts`) runs decoupled from any one\n * pipeline invocation, so it cannot hold a `SessionPublisher` in memory for\n * \"the\" run being monitored — there may be zero, one or several running at\n * once, each in its own process. Instead this module polls every\n * `~/.issue-flow/projects/<project>/issues/<n>/session.json` on disk (the\n * same file `FilePublisher` already writes) and keeps an in-memory map of the\n * ones that are both well-formed and recently updated.\n *\n * Polling rather than `fs.watch` is a deliberate choice, not a placeholder:\n * `fs.watch`'s `recursive` option is only reliable on macOS and Windows (Linux\n * needs a manual per-directory watch tree, and the whole thing still degrades\n * to polling on network filesystems), while a `~/.issue-flow` tree is small\n * and local — a cheap poll of it is simpler and behaves identically on every\n * platform, which is what the US-003 acceptance criteria ask to validate.\n */\n\n/** How often the directory tree is rescanned, unless overridden. */\nexport const DEFAULT_POLL_INTERVAL_MS = 3000;\n\n/**\n * A session stops being reported once its file has gone this long without an\n * update. `FilePublisher` throttles writes to ~1s and force-flushes on every\n * phase/session boundary, so a live run's file is never quiet for long; this\n * threshold only needs to be comfortably above one throttle window plus one\n * poll interval to avoid flapping a session in and out on timing alone.\n */\nexport const DEFAULT_STALE_AFTER_MS = 30_000;\n\nexport interface ActiveSession {\n /** Directory the session.json was read from, one level up from the file. */\n issueDir: string;\n filePath: string;\n snapshot: ValidatedSessionSnapshot;\n /** mtime of the file at the last successful read, in epoch ms. */\n updatedAtMs: number;\n}\n\nexport interface SessionDirectoryOptions extends GetGlobalRootOptions {\n /** Poll interval, in ms. Default {@link DEFAULT_POLL_INTERVAL_MS}. */\n pollIntervalMs?: number;\n /** Staleness threshold, in ms. Default {@link DEFAULT_STALE_AFTER_MS}. */\n staleAfterMs?: number;\n /** Invoked (at most once) when a poll fails unexpectedly. Best-effort logging only. */\n onWarn?: (message: string) => void;\n}\n\nexport interface SessionDirectoryHandle {\n /** Snapshot of every session currently considered active. */\n sessions(): ActiveSession[];\n /** A single active session by id, or undefined. */\n getSession(sessionId: string): ActiveSession | undefined;\n /** Force an immediate rescan instead of waiting for the next tick. Never throws. */\n refresh(): Promise<void>;\n /** Stop polling. Idempotent. */\n close(): void;\n}\n\nasync function listSubdirectories(dir: string): Promise<string[]> {\n try {\n const entries = await readdir(dir, { withFileTypes: true });\n // Symlinks are skipped on purpose, same discipline as the storage\n // migration walk: following one could pull session data from outside the\n // global tree into the listing.\n return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);\n } catch {\n // Missing/unreadable projects (or issues) directory reads as \"none yet\" —\n // this is routinely the case before the first --web run ever happens.\n return [];\n }\n}\n\n/** Every `session.json` path currently on disk, one per project/issue pair. */\nasync function discoverSessionFiles(root: string): Promise<string[]> {\n const projectsDir = join(root, PROJECTS_DIR_NAME);\n const projectIds = await listSubdirectories(projectsDir);\n\n const perProject = await Promise.all(\n projectIds.map(async (projectId) => {\n const issuesDir = join(projectsDir, projectId, ISSUES_DIR_NAME);\n const issueIds = await listSubdirectories(issuesDir);\n return issueIds.map((issueId) => join(issuesDir, issueId, 'session.json'));\n }),\n );\n\n return perProject.flat();\n}\n\ninterface ReadResult {\n snapshot: ValidatedSessionSnapshot;\n updatedAtMs: number;\n}\n\n/**\n * Read and validate one `session.json`. Anything short of a fully valid file\n * (missing, mid-write, corrupted, written by an incompatible future version)\n * reads as \"not currently a session\" — the directory scan must never fail\n * because one file is momentarily inconsistent (`FilePublisher` writes via a\n * temp file + rename, but a reader can still race a concurrent write).\n */\nasync function readSessionFile(filePath: string): Promise<ReadResult | null> {\n let raw: string;\n let mtimeMs: number;\n try {\n const [content, stat] = await Promise.all([readFile(filePath, 'utf-8'), statFile(filePath)]);\n raw = content;\n mtimeMs = stat.mtimeMs;\n } catch {\n return null;\n }\n\n try {\n const parsed = sessionSnapshotSchema.safeParse(JSON.parse(raw));\n if (!parsed.success || parsed.data.sessionId === null) return null;\n return { snapshot: parsed.data, updatedAtMs: mtimeMs };\n } catch {\n return null;\n }\n}\n\n/**\n * Start polling the global storage tree for active sessions.\n *\n * Returns a handle immediately; the first scan happens synchronously in the\n * background and `sessions()` answers `[]` until it completes, same as \"no\n * sessions found yet\" — there is nothing to await here that would not also\n * delay the server's very first bind.\n */\nexport function watchSessionDirectory(\n options: SessionDirectoryOptions = {},\n): SessionDirectoryHandle {\n const root = getGlobalRoot(options);\n const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;\n const staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;\n const warn = options.onWarn;\n\n // Keyed by sessionId, not by file path: a session's identity is the id\n // inside its content, and that is what /api/status?session=<id> looks up.\n let sessions = new Map<string, ActiveSession>();\n let warned = false;\n\n async function scan(): Promise<void> {\n let files: string[];\n try {\n files = await discoverSessionFiles(root);\n } catch (err) {\n if (!warned) {\n warned = true;\n warn?.(\n `issue-flow: web monitor could not scan the session directory (will keep retrying silently): ${\n err instanceof Error ? err.message : String(err)\n }`,\n );\n }\n return;\n }\n\n const now = Date.now();\n const next = new Map<string, ActiveSession>();\n for (const filePath of files) {\n const result = await readSessionFile(filePath);\n if (result === null) continue;\n if (now - result.updatedAtMs > staleAfterMs) continue; // stopped updating: not active\n const sessionId = result.snapshot.sessionId as string;\n next.set(sessionId, {\n issueDir: join(filePath, '..'),\n filePath,\n snapshot: result.snapshot,\n updatedAtMs: result.updatedAtMs,\n });\n }\n sessions = next;\n }\n\n // The first scan runs immediately (not just after the first interval tick)\n // so a server started right after a session begins does not report \"no\n // sessions\" for a whole poll interval.\n void scan();\n const timer = setInterval(() => void scan(), pollIntervalMs);\n timer.unref();\n\n return {\n sessions: () => [...sessions.values()],\n getSession: (sessionId: string) => sessions.get(sessionId),\n refresh: scan,\n close: () => clearInterval(timer),\n };\n}\n","import { loadWebConfig } from '../config.js';\nimport type { WebConfig } from '../schemas.js';\nimport { printError, printInfo, printWarning } from '../ui/logger.js';\nimport { ensureSingleWebServer, getWebLockFile, readWebLock, removeWebLock } from '../web/lock.js';\nimport { watchSessionDirectory } from '../web/session-directory.js';\n\n/**\n * `issue-flow web serve` / `issue-flow web stop` (US-002).\n *\n * `serve` is the process `ensureWebMonitor()` (`web/lock.ts`) spawns detached\n * — it is not meant to be run interactively, though nothing stops a user from\n * doing so to watch every session on the machine without going through a\n * pipeline command first. It never exits on its own: the server stays bound\n * (see `unref: false` in `web/server.ts`) until `stop` sends it `SIGTERM`, at\n * which point `startWebServer`'s own signal handler closes it (removing the\n * lock) and re-raises the signal for the default termination behavior.\n */\n\nconst STOP_POLL_INTERVAL_MS = 100;\nconst STOP_TIMEOUT_MS = 5000;\n\nfunction sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nexport interface RunWebServeOptions {\n port?: number;\n host?: string;\n refresh?: number;\n}\n\n/**\n * Bind (or defer to) the single web monitor instance and keep the process\n * alive for as long as it stays bound. Logging is intentionally silent on the\n * happy path: this process is spawned with `stdio: 'ignore'`, so anything\n * printed here goes nowhere — the caller (`ensureWebMonitor`) is the one that\n * tells the user the server is up.\n */\nexport async function runWebServe(options: RunWebServeOptions): Promise<number> {\n // Built conditionally, not `{ port: options.port, ... }`: loadWebConfig()\n // spreads this object directly over the lower-precedence layers, and an\n // explicit `undefined` value would overwrite an env/.issue-flow.json\n // setting instead of falling through to it (only an *absent* key does).\n const cli: Partial<WebConfig> = {};\n if (options.port !== undefined) cli.port = options.port;\n if (options.host !== undefined) cli.host = options.host;\n if (options.refresh !== undefined) cli.refreshSeconds = options.refresh;\n const webConfig = await loadWebConfig({ cli });\n\n const sessions = watchSessionDirectory();\n\n const noop = (): void => {};\n const handle = await ensureSingleWebServer({\n sessions,\n port: webConfig.port,\n host: webConfig.host,\n refreshSeconds: webConfig.refreshSeconds,\n unref: false,\n info: noop,\n warn: noop,\n });\n\n if (handle === null) {\n sessions.close();\n return 1;\n }\n\n if (handle.server === undefined) {\n // Another instance already won the race (or was already running by the\n // time this process got here): nothing to serve, so it exits right away\n // instead of idling as a redundant detached process.\n sessions.close();\n return 0;\n }\n\n const originalClose = handle.close;\n handle.close = async () => {\n await originalClose();\n sessions.close();\n };\n\n return 0;\n}\n\n/** Stop the single running web monitor instance, if any. */\nexport async function runWebStop(): Promise<number> {\n const lockFile = getWebLockFile();\n const lock = await readWebLock(lockFile);\n if (lock === null) {\n printInfo('No web monitor is currently running.');\n return 0;\n }\n\n try {\n process.kill(lock.pid, 'SIGTERM');\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === 'ESRCH') {\n printWarning(\n 'The web monitor lock referenced a process that is no longer running; removing it.',\n );\n await removeWebLock(lockFile);\n return 0;\n }\n printError(\n `Failed to stop the web monitor: ${err instanceof Error ? err.message : String(err)}`,\n );\n return 1;\n }\n\n const deadline = Date.now() + STOP_TIMEOUT_MS;\n while (Date.now() < deadline) {\n if ((await readWebLock(lockFile)) === null) {\n printInfo(`Web monitor (pid ${lock.pid}) stopped.`);\n return 0;\n }\n await sleep(STOP_POLL_INTERVAL_MS);\n }\n\n printWarning('Sent the stop signal, but the web monitor did not confirm shutdown in time.');\n return 0;\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/web/session-directory.ts","../src/commands/web.ts"],"sourcesContent":["import { readdir, readFile, stat as statFile } from 'node:fs/promises';\nimport { join } from 'node:path';\nimport type { ValidatedSessionSnapshot } from '../schemas.js';\nimport { sessionSnapshotSchema } from '../schemas.js';\nimport {\n type GetGlobalRootOptions,\n getGlobalRoot,\n ISSUES_DIR_NAME,\n PROJECTS_DIR_NAME,\n} from '../storage/paths.js';\n\n/**\n * Multi-session discovery for the web monitoring server (US-003).\n *\n * The single-instance server (`web/lock.ts`) runs decoupled from any one\n * pipeline invocation, so it cannot hold a `SessionPublisher` in memory for\n * \"the\" run being monitored — there may be zero, one or several running at\n * once, each in its own process. Instead this module polls every\n * `~/.issue-flow/projects/<project>/issues/<n>/session.json` on disk (the\n * same file `FilePublisher` already writes) and keeps an in-memory map of the\n * ones that are both well-formed and recently updated.\n *\n * Polling rather than `fs.watch` is a deliberate choice, not a placeholder:\n * `fs.watch`'s `recursive` option is only reliable on macOS and Windows (Linux\n * needs a manual per-directory watch tree, and the whole thing still degrades\n * to polling on network filesystems), while a `~/.issue-flow` tree is small\n * and local — a cheap poll of it is simpler and behaves identically on every\n * platform, which is what the US-003 acceptance criteria ask to validate.\n */\n\n/** How often the directory tree is rescanned, unless overridden. */\nexport const DEFAULT_POLL_INTERVAL_MS = 3000;\n\n/**\n * A session stops being reported once its file has gone this long without an\n * update. `FilePublisher` throttles writes to ~1s and force-flushes on every\n * phase/session boundary, so a live run's file is never quiet for long; this\n * threshold only needs to be comfortably above one throttle window plus one\n * poll interval to avoid flapping a session in and out on timing alone.\n */\nexport const DEFAULT_STALE_AFTER_MS = 30_000;\n\nexport interface ActiveSession {\n /** Directory the session.json was read from, one level up from the file. */\n issueDir: string;\n filePath: string;\n snapshot: ValidatedSessionSnapshot;\n /** mtime of the file at the last successful read, in epoch ms. */\n updatedAtMs: number;\n}\n\nexport interface SessionDirectoryOptions extends GetGlobalRootOptions {\n /** Poll interval, in ms. Default {@link DEFAULT_POLL_INTERVAL_MS}. */\n pollIntervalMs?: number;\n /** Staleness threshold, in ms. Default {@link DEFAULT_STALE_AFTER_MS}. */\n staleAfterMs?: number;\n /** Invoked (at most once) when a poll fails unexpectedly. Best-effort logging only. */\n onWarn?: (message: string) => void;\n}\n\nexport interface SessionDirectoryHandle {\n /** Snapshot of every session currently considered active. */\n sessions(): ActiveSession[];\n /** A single active session by id, or undefined. */\n getSession(sessionId: string): ActiveSession | undefined;\n /** Force an immediate rescan instead of waiting for the next tick. Never throws. */\n refresh(): Promise<void>;\n /** Stop polling. Idempotent. */\n close(): void;\n}\n\nasync function listSubdirectories(dir: string): Promise<string[]> {\n try {\n const entries = await readdir(dir, { withFileTypes: true });\n // Symlinks are skipped on purpose, same discipline as the storage\n // migration walk: following one could pull session data from outside the\n // global tree into the listing.\n return entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name);\n } catch {\n // Missing/unreadable projects (or issues) directory reads as \"none yet\" —\n // this is routinely the case before the first --web run ever happens.\n return [];\n }\n}\n\n/** Every `session.json` path currently on disk, one per project/issue pair. */\nasync function discoverSessionFiles(root: string): Promise<string[]> {\n const projectsDir = join(root, PROJECTS_DIR_NAME);\n const projectIds = await listSubdirectories(projectsDir);\n\n const perProject = await Promise.all(\n projectIds.map(async (projectId) => {\n const issuesDir = join(projectsDir, projectId, ISSUES_DIR_NAME);\n const issueIds = await listSubdirectories(issuesDir);\n return issueIds.map((issueId) => join(issuesDir, issueId, 'session.json'));\n }),\n );\n\n return perProject.flat();\n}\n\ninterface ReadResult {\n snapshot: ValidatedSessionSnapshot;\n updatedAtMs: number;\n}\n\n/**\n * Read and validate one `session.json`. Anything short of a fully valid file\n * (missing, mid-write, corrupted, written by an incompatible future version)\n * reads as \"not currently a session\" — the directory scan must never fail\n * because one file is momentarily inconsistent (`FilePublisher` writes via a\n * temp file + rename, but a reader can still race a concurrent write).\n */\nasync function readSessionFile(filePath: string): Promise<ReadResult | null> {\n let raw: string;\n let mtimeMs: number;\n try {\n const [content, stat] = await Promise.all([readFile(filePath, 'utf-8'), statFile(filePath)]);\n raw = content;\n mtimeMs = stat.mtimeMs;\n } catch {\n return null;\n }\n\n try {\n const parsed = sessionSnapshotSchema.safeParse(JSON.parse(raw));\n if (!parsed.success || parsed.data.sessionId === null) return null;\n return { snapshot: parsed.data, updatedAtMs: mtimeMs };\n } catch {\n return null;\n }\n}\n\n/**\n * Start polling the global storage tree for active sessions.\n *\n * Returns a handle immediately; the first scan happens synchronously in the\n * background and `sessions()` answers `[]` until it completes, same as \"no\n * sessions found yet\" — there is nothing to await here that would not also\n * delay the server's very first bind.\n */\nexport function watchSessionDirectory(\n options: SessionDirectoryOptions = {},\n): SessionDirectoryHandle {\n const root = getGlobalRoot(options);\n const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;\n const staleAfterMs = options.staleAfterMs ?? DEFAULT_STALE_AFTER_MS;\n const warn = options.onWarn;\n\n // Keyed by sessionId, not by file path: a session's identity is the id\n // inside its content, and that is what /api/status?session=<id> looks up.\n let sessions = new Map<string, ActiveSession>();\n let warned = false;\n\n async function scan(): Promise<void> {\n let files: string[];\n try {\n files = await discoverSessionFiles(root);\n } catch (err) {\n if (!warned) {\n warned = true;\n warn?.(\n `issue-flow: web monitor could not scan the session directory (will keep retrying silently): ${\n err instanceof Error ? err.message : String(err)\n }`,\n );\n }\n return;\n }\n\n const now = Date.now();\n const next = new Map<string, ActiveSession>();\n for (const filePath of files) {\n const result = await readSessionFile(filePath);\n if (result === null) continue;\n if (now - result.updatedAtMs > staleAfterMs) continue; // stopped updating: not active\n const sessionId = result.snapshot.sessionId as string;\n next.set(sessionId, {\n issueDir: join(filePath, '..'),\n filePath,\n snapshot: result.snapshot,\n updatedAtMs: result.updatedAtMs,\n });\n }\n sessions = next;\n }\n\n // The first scan runs immediately (not just after the first interval tick)\n // so a server started right after a session begins does not report \"no\n // sessions\" for a whole poll interval.\n void scan();\n const timer = setInterval(() => void scan(), pollIntervalMs);\n timer.unref();\n\n return {\n sessions: () => [...sessions.values()],\n getSession: (sessionId: string) => sessions.get(sessionId),\n refresh: scan,\n close: () => clearInterval(timer),\n };\n}\n","import { loadWebConfig } from '../config.js';\nimport type { WebConfig } from '../schemas.js';\nimport { printError, printInfo, printWarning } from '../ui/logger.js';\nimport { ensureSingleWebServer, getWebLockFile, readWebLock, removeWebLock } from '../web/lock.js';\nimport { watchSessionDirectory } from '../web/session-directory.js';\n\n/**\n * `issue-flow web serve` / `issue-flow web stop` (US-002).\n *\n * `serve` is the process `ensureWebMonitor()` (`web/lock.ts`) spawns detached\n * — it is not meant to be run interactively, though nothing stops a user from\n * doing so to watch every session on the machine without going through a\n * pipeline command first. It never exits on its own: the server stays bound\n * (see `unref: false` in `web/server.ts`) until `stop` sends it `SIGTERM`, at\n * which point `startWebServer`'s own signal handler closes it (removing the\n * lock) and re-raises the signal for the default termination behavior.\n */\n\nconst STOP_POLL_INTERVAL_MS = 100;\nconst STOP_TIMEOUT_MS = 5000;\n\nfunction sleep(ms: number): Promise<void> {\n return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\nexport interface RunWebServeOptions {\n port?: number;\n host?: string;\n refresh?: number;\n}\n\n/**\n * Bind (or defer to) the single web monitor instance and keep the process\n * alive for as long as it stays bound. Logging is intentionally silent on the\n * happy path: this process is spawned with `stdio: 'ignore'`, so anything\n * printed here goes nowhere — the caller (`ensureWebMonitor`) is the one that\n * tells the user the server is up.\n */\nexport async function runWebServe(options: RunWebServeOptions): Promise<number> {\n // Built conditionally, not `{ port: options.port, ... }`: loadWebConfig()\n // spreads this object directly over the lower-precedence layers, and an\n // explicit `undefined` value would overwrite an env/.issue-flow.json\n // setting instead of falling through to it (only an *absent* key does).\n const cli: Partial<WebConfig> = {};\n if (options.port !== undefined) cli.port = options.port;\n if (options.host !== undefined) cli.host = options.host;\n if (options.refresh !== undefined) cli.refreshSeconds = options.refresh;\n const webConfig = await loadWebConfig({ cli });\n\n const sessions = watchSessionDirectory();\n\n const noop = (): void => {};\n const handle = await ensureSingleWebServer({\n sessions,\n port: webConfig.port,\n host: webConfig.host,\n refreshSeconds: webConfig.refreshSeconds,\n unref: false,\n info: noop,\n warn: noop,\n });\n\n if (handle === null) {\n sessions.close();\n return 1;\n }\n\n if (handle.server === undefined) {\n // Another instance already won the race (or was already running by the\n // time this process got here): nothing to serve, so it exits right away\n // instead of idling as a redundant detached process.\n sessions.close();\n return 0;\n }\n\n const originalClose = handle.close;\n handle.close = async () => {\n await originalClose();\n sessions.close();\n };\n\n return 0;\n}\n\n/** Stop the single running web monitor instance, if any. */\nexport async function runWebStop(): Promise<number> {\n const lockFile = getWebLockFile();\n const lock = await readWebLock(lockFile);\n if (lock === null) {\n printInfo('No web monitor is currently running.');\n return 0;\n }\n\n try {\n process.kill(lock.pid, 'SIGTERM');\n } catch (err) {\n if ((err as NodeJS.ErrnoException).code === 'ESRCH') {\n printWarning(\n 'The web monitor lock referenced a process that is no longer running; removing it.',\n );\n await removeWebLock(lockFile);\n return 0;\n }\n printError(\n `Failed to stop the web monitor: ${err instanceof Error ? err.message : String(err)}`,\n );\n return 1;\n }\n\n const deadline = Date.now() + STOP_TIMEOUT_MS;\n while (Date.now() < deadline) {\n if ((await readWebLock(lockFile)) === null) {\n printInfo(`Web monitor (pid ${lock.pid}) stopped.`);\n return 0;\n }\n await sleep(STOP_POLL_INTERVAL_MS);\n }\n\n printWarning('Sent the stop signal, but the web monitor did not confirm shutdown in time.');\n return 0;\n}\n"],"mappings":"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","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "issue-flow",
3
- "version": "0.11.1",
3
+ "version": "0.12.0",
4
4
  "description": "Unified CLI for orchestrating the full issue-flow pipeline via Claude Code Headless",
5
5
  "type": "module",
6
6
  "bin": {
package/prompts/pr.md CHANGED
@@ -14,10 +14,17 @@ __ISSUE_BODY__
14
14
  </issue-body>
15
15
 
16
16
  Steps:
17
- 1. Read the task plan from __TASKS_PATH__ if it exists
18
- 2. Review the git log for this branch: git log __BASE_BRANCH__..HEAD --oneline
19
- 3. Review the diff: git diff __BASE_BRANCH__...HEAD --stat
20
- 4. Create a well-structured PR using gh pr create
17
+ 1. Check whether this branch already has an open Pull Request:
18
+ `gh pr list --head __BRANCH_NAME__ --state open --json number,url`.
19
+ If it does, do NOT create a second one — report the existing URL and stop.
20
+ The pipeline checks this before invoking you and normally skips this phase
21
+ entirely, so reaching this point with a Pull Request already open means the
22
+ check could not run: it is the last guard against a duplicate, not a
23
+ formality.
24
+ 2. Read the task plan from __TASKS_PATH__ if it exists
25
+ 3. Review the git log for this branch: git log __BASE_BRANCH__..HEAD --oneline
26
+ 4. Review the diff: git diff __BASE_BRANCH__...HEAD --stat
27
+ 5. Create a well-structured PR using gh pr create
21
28
 
22
29
  The PR should:
23
30
  - Have a clear, concise title (under 70 characters)
package/web/public/app.js CHANGED
@@ -614,6 +614,18 @@
614
614
  ? formatAgo(session.startedAt)
615
615
  : '—';
616
616
  meta.appendChild(el('span', null, elapsed));
617
+ // Resilience: how hard this run has had to work, and when it last moved.
618
+ // A card that only shows a percentage cannot tell a run that is
619
+ // progressing from one that has been retrying for twenty minutes.
620
+ if (typeof session.retries === 'number' && session.retries > 0) {
621
+ meta.appendChild(el('span', null, session.retries + ' retry(s)'));
622
+ }
623
+ if (typeof session.correctionCycle === 'number' && session.correctionCycle > 0) {
624
+ meta.appendChild(el('span', null, 'correção ' + session.correctionCycle));
625
+ }
626
+ if (session.updatedAt) {
627
+ meta.appendChild(el('span', null, 'atividade ' + formatAgo(session.updatedAt)));
628
+ }
617
629
  card.appendChild(meta);
618
630
 
619
631
  const progress = el('span', 'dashboard-progress');
@@ -1,58 +0,0 @@
1
- #!/usr/bin/env node
2
- import {
3
- retryDelaySeconds,
4
- sleep
5
- } from "./chunk-HDV3YDTG.js";
6
- import {
7
- getSessionPublisher,
8
- isoNow,
9
- printRetry
10
- } from "./chunk-HC2MT7BX.js";
11
-
12
- // src/core/phase-runner.ts
13
- import { readFile } from "fs/promises";
14
- var DEFAULT_PHASE_RETRY_LIMIT = 3;
15
- var DEFAULT_BACKOFF_BASE_SECONDS = 15;
16
- var DEFAULT_BACKOFF_MAX_SECONDS = 120;
17
- async function runPhaseWithRetry(options) {
18
- const retryLimit = options.retryLimit ?? DEFAULT_PHASE_RETRY_LIMIT;
19
- const backoffBaseSeconds = options.backoffBaseSeconds ?? DEFAULT_BACKOFF_BASE_SECONDS;
20
- const backoffMaxSeconds = options.backoffMaxSeconds ?? DEFAULT_BACKOFF_MAX_SECONDS;
21
- let lastResult = { ok: false, error: "Phase never attempted" };
22
- let retryCount = 0;
23
- for (let attemptNumber = 1; attemptNumber <= retryLimit; attemptNumber++) {
24
- lastResult = await options.attempt(attemptNumber);
25
- if (lastResult.ok) return lastResult;
26
- if (!lastResult.transient || attemptNumber === retryLimit) return lastResult;
27
- retryCount++;
28
- const delaySeconds = retryDelaySeconds(retryCount, backoffBaseSeconds, backoffMaxSeconds);
29
- getSessionPublisher().publish({
30
- type: "retry",
31
- at: isoNow(),
32
- attempt: retryCount,
33
- delaySeconds,
34
- reason: lastResult.error
35
- });
36
- printRetry(
37
- `Phase ${options.phase} hit a recoverable failure (attempt ${attemptNumber}/${retryLimit}): ${lastResult.error}. Retrying in ${delaySeconds}s.`
38
- );
39
- await sleep(delaySeconds);
40
- }
41
- return lastResult;
42
- }
43
- async function readFileWithGrace(path, delaysMs = [300, 800]) {
44
- for (let i = 0; ; i++) {
45
- try {
46
- return await readFile(path, "utf-8");
47
- } catch (err) {
48
- if (i >= delaysMs.length) throw err;
49
- await new Promise((resolve) => setTimeout(resolve, delaysMs[i]));
50
- }
51
- }
52
- }
53
-
54
- export {
55
- runPhaseWithRetry,
56
- readFileWithGrace
57
- };
58
- //# sourceMappingURL=chunk-3BOHTH4V.js.map
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/core/phase-runner.ts"],"sourcesContent":["import { readFile } from 'node:fs/promises';\nimport { printRetry } from '../ui/logger.js';\nimport { retryDelaySeconds, sleep } from '../utils/retry.js';\nimport { getSessionPublisher } from './session-publisher.js';\nimport { isoNow } from './state-manager.js';\n\nexport interface PhaseAttemptResult {\n ok: boolean;\n /** Whether a failed attempt is worth retrying. Ignored when ok is true. */\n transient?: boolean;\n error?: string;\n}\n\nexport interface PhaseRetryOptions {\n phase: string;\n attempt: (attemptNumber: number) => Promise<PhaseAttemptResult>;\n retryLimit?: number;\n backoffBaseSeconds?: number;\n backoffMaxSeconds?: number;\n}\n\nconst DEFAULT_PHASE_RETRY_LIMIT = 3;\nconst DEFAULT_BACKOFF_BASE_SECONDS = 15;\nconst DEFAULT_BACKOFF_MAX_SECONDS = 120;\n\n/**\n * Bounded retry wrapper for single-shot phases (prd, plan, pr) that invoke\n * the Claude CLI once and validate an artifact. Mirrors the transient-retry\n * pattern already used by the execute phase's iteration loop (core/engine.ts:\n * isTransientFailure + retryDelaySeconds), applied here to phases that\n * previously aborted the whole pipeline on the first failure with no chance\n * to recover. Non-transient failures (or exhausting the retry budget) still\n * end the phase — this bounds recovery attempts, it never retries forever.\n */\nexport async function runPhaseWithRetry(options: PhaseRetryOptions): Promise<PhaseAttemptResult> {\n const retryLimit = options.retryLimit ?? DEFAULT_PHASE_RETRY_LIMIT;\n const backoffBaseSeconds = options.backoffBaseSeconds ?? DEFAULT_BACKOFF_BASE_SECONDS;\n const backoffMaxSeconds = options.backoffMaxSeconds ?? DEFAULT_BACKOFF_MAX_SECONDS;\n\n let lastResult: PhaseAttemptResult = { ok: false, error: 'Phase never attempted' };\n let retryCount = 0;\n\n for (let attemptNumber = 1; attemptNumber <= retryLimit; attemptNumber++) {\n lastResult = await options.attempt(attemptNumber);\n if (lastResult.ok) return lastResult;\n if (!lastResult.transient || attemptNumber === retryLimit) return lastResult;\n\n retryCount++;\n const delaySeconds = retryDelaySeconds(retryCount, backoffBaseSeconds, backoffMaxSeconds);\n getSessionPublisher().publish({\n type: 'retry',\n at: isoNow(),\n attempt: retryCount,\n delaySeconds,\n reason: lastResult.error,\n });\n printRetry(\n `Phase ${options.phase} hit a recoverable failure (attempt ${attemptNumber}/${retryLimit}): ${lastResult.error}. Retrying in ${delaySeconds}s.`,\n );\n await sleep(delaySeconds);\n }\n\n return lastResult;\n}\n\n/**\n * Re-read a file a few times with short delays before giving up. Absorbs a\n * brief filesystem-visibility lag between the Claude CLI's Write tool\n * completing and this process observing the result, without spending a full\n * phase retry (and a new Claude invocation) on what may just be a timing gap.\n */\nexport async function readFileWithGrace(\n path: string,\n delaysMs: number[] = [300, 800],\n): Promise<string> {\n for (let i = 0; ; i++) {\n try {\n return await readFile(path, 'utf-8');\n } catch (err) {\n if (i >= delaysMs.length) throw err;\n await new Promise((resolve) => setTimeout(resolve, delaysMs[i]));\n }\n }\n}\n"],"mappings":";;;;;;;;;;;;AAAA,SAAS,gBAAgB;AAqBzB,IAAM,4BAA4B;AAClC,IAAM,+BAA+B;AACrC,IAAM,8BAA8B;AAWpC,eAAsB,kBAAkB,SAAyD;AAC/F,QAAM,aAAa,QAAQ,cAAc;AACzC,QAAM,qBAAqB,QAAQ,sBAAsB;AACzD,QAAM,oBAAoB,QAAQ,qBAAqB;AAEvD,MAAI,aAAiC,EAAE,IAAI,OAAO,OAAO,wBAAwB;AACjF,MAAI,aAAa;AAEjB,WAAS,gBAAgB,GAAG,iBAAiB,YAAY,iBAAiB;AACxE,iBAAa,MAAM,QAAQ,QAAQ,aAAa;AAChD,QAAI,WAAW,GAAI,QAAO;AAC1B,QAAI,CAAC,WAAW,aAAa,kBAAkB,WAAY,QAAO;AAElE;AACA,UAAM,eAAe,kBAAkB,YAAY,oBAAoB,iBAAiB;AACxF,wBAAoB,EAAE,QAAQ;AAAA,MAC5B,MAAM;AAAA,MACN,IAAI,OAAO;AAAA,MACX,SAAS;AAAA,MACT;AAAA,MACA,QAAQ,WAAW;AAAA,IACrB,CAAC;AACD;AAAA,MACE,SAAS,QAAQ,KAAK,uCAAuC,aAAa,IAAI,UAAU,MAAM,WAAW,KAAK,iBAAiB,YAAY;AAAA,IAC7I;AACA,UAAM,MAAM,YAAY;AAAA,EAC1B;AAEA,SAAO;AACT;AAQA,eAAsB,kBACpB,MACA,WAAqB,CAAC,KAAK,GAAG,GACb;AACjB,WAAS,IAAI,KAAK,KAAK;AACrB,QAAI;AACF,aAAO,MAAM,SAAS,MAAM,OAAO;AAAA,IACrC,SAAS,KAAK;AACZ,UAAI,KAAK,SAAS,OAAQ,OAAM;AAChC,YAAM,IAAI,QAAQ,CAAC,YAAY,WAAW,SAAS,SAAS,CAAC,CAAC,CAAC;AAAA,IACjE;AAAA,EACF;AACF;","names":[]}