@wanbicoi/next-steps 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -2,10 +2,16 @@
2
2
  // @bun
3
3
 
4
4
  // src/cli.ts
5
- import { join as join4 } from "path";
5
+ import { existsSync as existsSync5 } from "fs";
6
+ import { basename, dirname as dirname5, join as join3 } from "path";
6
7
  import { pathToFileURL } from "url";
7
8
  import { parseArgs } from "util";
8
9
 
10
+ // src/commands/agents.ts
11
+ import { existsSync, readFileSync } from "node:fs";
12
+ import { dirname, join } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+
9
15
  // src/types.ts
10
16
  var END = "END";
11
17
 
@@ -22,6 +28,40 @@ class AppError extends Error {
22
28
  }
23
29
  }
24
30
 
31
+ // src/commands/shared.ts
32
+ function usageError(message) {
33
+ return new AppError("usage", message, 2);
34
+ }
35
+ function requireString(value, flag) {
36
+ if (typeof value !== "string" || value.length === 0) {
37
+ throw usageError(`--${flag} is required`);
38
+ }
39
+ return value;
40
+ }
41
+ function toJson(value) {
42
+ return JSON.stringify(value, null, 2);
43
+ }
44
+
45
+ // src/commands/agents.ts
46
+ var SAMPLE = join("docs", "AGENTS.sample.md");
47
+ function findSample() {
48
+ let dir = dirname(fileURLToPath(import.meta.url));
49
+ for (let depth = 0;depth < 6; depth++) {
50
+ const candidate = join(dir, SAMPLE);
51
+ if (existsSync(candidate))
52
+ return candidate;
53
+ const parent = dirname(dir);
54
+ if (parent === dir)
55
+ break;
56
+ dir = parent;
57
+ }
58
+ throw new AppError("agents_sample_missing", "could not find docs/AGENTS.sample.md next to the package", 3);
59
+ }
60
+ function cmdAgents(json) {
61
+ const markdown = readFileSync(findSample(), "utf8");
62
+ return json ? toJson({ markdown }) : markdown.trimEnd();
63
+ }
64
+
25
65
  // src/format.ts
26
66
  function table(headers, rows) {
27
67
  const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => (row[index] ?? "").length)));
@@ -29,16 +69,13 @@ function table(headers, rows) {
29
69
  return [render(headers), ...rows.map(render)].join(`
30
70
  `);
31
71
  }
32
- function formatScope(scope) {
33
- return scope.length > 0 ? scope.join(" > ") : "-";
34
- }
35
72
  function formatTime(iso) {
36
73
  return iso.slice(0, 19).replace("T", " ");
37
74
  }
38
75
  function optionLine(option, index) {
39
- const label = option.edge ? `${option.edge} -> ${option.node}` : option.node;
40
76
  const title = option.node === END ? "" : option.title;
41
- const head = ` ${index + 1}. ${label}${title ? ` ${title}` : ""}`.trimEnd();
77
+ const review = option.reviewRequired ? " [review]" : "";
78
+ const head = ` ${index + 1}. ${option.node}${title ? ` ${title}` : ""}${review}`.trimEnd();
42
79
  const lines = [head, ` ${option.description}`];
43
80
  if (option.condition)
44
81
  lines.push(` when: ${option.condition}`);
@@ -57,34 +94,17 @@ function formatNext(next) {
57
94
  `);
58
95
  }
59
96
  function formatCurrent(next) {
60
- if (next.done) {
61
- return `current: ${next.current ?? END} (finished) scope: ${formatScope(next.scope)}`;
62
- }
63
- if (next.current === null) {
64
- return `current: (not started) scope: ${formatScope(next.scope)}`;
65
- }
97
+ if (next.done)
98
+ return `current: ${next.current ?? END} (finished)`;
99
+ if (next.current === null)
100
+ return "current: (not started)";
66
101
  const title = next.currentTitle ? ` (${next.currentTitle})` : "";
67
- return `current: ${next.current}${title} scope: ${formatScope(next.scope)}`;
68
- }
69
-
70
- // src/commands/shared.ts
71
- function usageError(message) {
72
- return new AppError("usage", message, 2);
73
- }
74
- function requireString(value, flag) {
75
- if (typeof value !== "string" || value.length === 0) {
76
- throw usageError(`--${flag} is required`);
77
- }
78
- return value;
79
- }
80
- function toJson(value) {
81
- return JSON.stringify(value, null, 2);
102
+ return `current: ${next.current}${title}`;
82
103
  }
83
104
 
84
105
  // src/commands/session.ts
85
106
  function cmdCreate(service, input) {
86
- const workflowId = requireString(input.workflowId, "workflow");
87
- const { session, workflowTitle, next } = service.create({ workflowId, key: input.key });
107
+ const { session, workflowTitle, next } = service.create({ key: input.key });
88
108
  if (input.json) {
89
109
  return toJson({
90
110
  key: session.key,
@@ -92,7 +112,6 @@ function cmdCreate(service, input) {
92
112
  workflowTitle,
93
113
  status: session.status,
94
114
  current: null,
95
- scope: next.scope,
96
115
  done: next.done,
97
116
  next: next.next,
98
117
  statePath: service.statePath
@@ -107,10 +126,12 @@ function cmdCreate(service, input) {
107
126
  ].join(`
108
127
  `);
109
128
  }
110
- function cmdLog(service, input) {
129
+ function cmdLog(service, input, onLogged) {
111
130
  const key = requireString(input.key, "key");
112
131
  const node = requireString(input.node, "node");
113
- const { session, step, next } = service.log(key, node);
132
+ const result = service.log(key, node);
133
+ const { session, step, next } = result;
134
+ onLogged?.(result);
114
135
  if (input.json) {
115
136
  return toJson({
116
137
  step,
@@ -120,10 +141,10 @@ function cmdLog(service, input) {
120
141
  statePath: service.statePath
121
142
  });
122
143
  }
123
- const origin = step.from ? ` (from ${step.from}${step.edge ? ` via ${step.edge}` : ""})` : " (entry)";
144
+ const origin = step.from ? ` (from ${step.from})` : " (entry)";
124
145
  return [
125
146
  `logged: #${step.seq} ${step.node}${origin}`,
126
- `scope: ${formatScope(step.scope)} status: ${session.status}`,
147
+ `status: ${session.status}`,
127
148
  "",
128
149
  formatNext(next)
129
150
  ].join(`
@@ -138,7 +159,6 @@ function cmdNext(service, input) {
138
159
  workflowId: session.workflowId,
139
160
  status: session.status,
140
161
  current: next.current === null ? null : { node: next.current, title: next.currentTitle, at: session.updatedAt },
141
- scope: next.scope,
142
162
  done: next.done,
143
163
  next: next.next,
144
164
  statePath: service.statePath
@@ -156,31 +176,28 @@ function cmdSteps(service, input) {
156
176
  workflowId: session.workflowId,
157
177
  status: session.status,
158
178
  current: session.current,
159
- scope: session.stack.map((frame) => frame.workflowId),
160
179
  steps,
161
180
  statePath: service.statePath
162
181
  });
163
182
  }
164
- const head = [
165
- `session: ${key} status: ${session.status} current: ${session.current ?? "(not started)"}`,
166
- `scope: ${formatScope(session.stack.map((frame) => frame.workflowId))}`,
167
- ""
168
- ].join(`
169
- `);
183
+ const head = `session: ${key} status: ${session.status} current: ${session.current ?? "(not started)"}`;
170
184
  if (steps.length === 0)
171
- return `${head}no steps yet`;
185
+ return `${head}
186
+
187
+ no steps yet`;
172
188
  const rows = steps.map((step) => [
173
189
  String(step.seq),
174
190
  step.node,
175
191
  step.edge ?? "-",
176
192
  step.from ?? "-",
177
- formatScope(step.scope),
178
193
  formatTime(step.at)
179
194
  ]);
180
- return head + table(["SEQ", "NODE", "EDGE", "FROM", "SCOPE", "AT"], rows);
195
+ return `${head}
196
+
197
+ ${table(["SEQ", "NODE", "EDGE", "FROM", "AT"], rows)}`;
181
198
  }
182
199
  function cmdKeys(service, input) {
183
- const sessions = service.keys(input.workflowId);
200
+ const sessions = service.keys();
184
201
  if (input.json) {
185
202
  return toJson({ statePath: service.statePath, sessions });
186
203
  }
@@ -223,38 +240,34 @@ function toJson2(value) {
223
240
  }
224
241
 
225
242
  // src/commands/viz.ts
226
- import { existsSync, readFileSync } from "node:fs";
243
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
227
244
  import { createServer } from "node:http";
228
245
  import { createRequire } from "node:module";
229
- import { dirname, join } from "node:path";
230
- import { fileURLToPath } from "node:url";
246
+ import { dirname as dirname2, join as join2 } from "node:path";
247
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
231
248
  var require2 = createRequire(import.meta.url);
232
249
  function findTemplate() {
233
- let dir = dirname(fileURLToPath(import.meta.url));
250
+ let dir = dirname2(fileURLToPath2(import.meta.url));
234
251
  for (let depth = 0;depth < 6; depth++) {
235
- const candidate = join(dir, "viz", "template.html");
236
- if (existsSync(candidate))
252
+ const candidate = join2(dir, "viz", "template.html");
253
+ if (existsSync2(candidate))
237
254
  return candidate;
238
- const parent = dirname(dir);
255
+ const parent = dirname2(dir);
239
256
  if (parent === dir)
240
257
  break;
241
258
  dir = parent;
242
259
  }
243
260
  throw new AppError("viz_template_missing", "could not find viz/template.html next to the package", 3);
244
261
  }
245
- function renderVizHtml(template, workflows) {
246
- const order = Object.keys(workflows).sort();
247
- const data = JSON.stringify({ workflows, order }).replaceAll("<", "\\u003c");
262
+ function renderVizHtml(template, workflow) {
263
+ const data = JSON.stringify({ workflow }).replaceAll("<", "\\u003c");
248
264
  return template.replace("__WORKFLOW_DATA__", data);
249
265
  }
250
- function startVizServer(registry, port) {
251
- const template = readFileSync(findTemplate(), "utf8");
252
- const cytoscapeJs = readFileSync(require2.resolve("cytoscape/dist/cytoscape.min.js"), "utf8");
253
- const dagreJs = readFileSync(require2.resolve("cytoscape-dagre"), "utf8");
254
- const workflows = {};
255
- for (const workflow of registry.values())
256
- workflows[workflow.id] = workflow;
257
- const html = renderVizHtml(template, workflows);
266
+ function startVizServer(workflow, port) {
267
+ const template = readFileSync2(findTemplate(), "utf8");
268
+ const cytoscapeJs = readFileSync2(require2.resolve("cytoscape/dist/cytoscape.min.js"), "utf8");
269
+ const dagreJs = readFileSync2(require2.resolve("cytoscape-dagre"), "utf8");
270
+ const html = renderVizHtml(template, workflow);
258
271
  const server = createServer((request, response) => {
259
272
  const path = (request.url ?? "/").split("?")[0];
260
273
  if (path === "/" || path === "/index.html") {
@@ -280,42 +293,147 @@ function startVizServer(registry, port) {
280
293
  process.exitCode = 1;
281
294
  });
282
295
  server.listen(port, "127.0.0.1");
283
- return { url: `http://127.0.0.1:${port}`, workflows: Object.keys(workflows).sort() };
296
+ return { url: `http://127.0.0.1:${port}`, workflow: workflow.id };
284
297
  }
285
298
 
286
299
  // src/commands/workflow.ts
287
- import { existsSync as existsSync2, mkdirSync, writeFileSync } from "node:fs";
288
- import { join as join2 } from "node:path";
300
+ import { existsSync as existsSync3, mkdirSync, writeFileSync } from "node:fs";
301
+ import { dirname as dirname3 } from "node:path";
302
+ var ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
303
+ function skeleton(id) {
304
+ return {
305
+ id,
306
+ title: id,
307
+ start: "start",
308
+ nodes: [
309
+ {
310
+ id: "start",
311
+ title: "Start",
312
+ description: "Describe the first step.",
313
+ next: [{ to: END, description: "Done." }]
314
+ }
315
+ ]
316
+ };
317
+ }
318
+ function cmdWorkflowNew(graphPath, id, json) {
319
+ if (existsSync3(graphPath)) {
320
+ throw usageError(`workflow file already exists: ${graphPath}`);
321
+ }
322
+ const workflowId = ID_PATTERN.test(id) ? id : "workflow";
323
+ mkdirSync(dirname3(graphPath), { recursive: true });
324
+ writeFileSync(graphPath, `${JSON.stringify(skeleton(workflowId), null, 2)}
325
+ `, "utf8");
326
+ if (json)
327
+ return toJson({ created: graphPath, workflowId });
328
+ return `created: ${graphPath}`;
329
+ }
330
+ function requireValid(load) {
331
+ if (load.errors.length > 0 || !load.workflow) {
332
+ throw new AppError("invalid_workflow", `workflow file is invalid: ${load.path}`, 5, {
333
+ errors: load.errors
334
+ });
335
+ }
336
+ return load.workflow;
337
+ }
338
+ function cmdWorkflowShow(load, json) {
339
+ const wf = requireValid(load);
340
+ if (json)
341
+ return toJson({ workflow: wf, warnings: load.warnings });
342
+ const lines = [`${wf.id} ${wf.title}`, `start: ${wf.start}`];
343
+ let group;
344
+ for (const node of wf.nodes) {
345
+ if (node.group !== group) {
346
+ group = node.group;
347
+ if (group)
348
+ lines.push("", group);
349
+ }
350
+ const review = node.reviewRequired ? " [review]" : "";
351
+ lines.push(` ${node.id} ${node.title}${review}`);
352
+ for (const move of node.next) {
353
+ const when = move.condition ? ` [when: ${move.condition}]` : "";
354
+ lines.push(` -> ${move.to} ${move.description}${when}`);
355
+ }
356
+ }
357
+ return lines.join(`
358
+ `);
359
+ }
360
+ function cmdWorkflowCheck(load, json) {
361
+ if (load.errors.length > 0 || !load.workflow) {
362
+ throw new AppError("invalid_workflow", "workflow validation failed", 5, {
363
+ errors: load.errors
364
+ });
365
+ }
366
+ const wf = load.workflow;
367
+ const moves = wf.nodes.reduce((count, node) => count + node.next.length, 0);
368
+ if (json) {
369
+ return toJson({
370
+ ok: true,
371
+ workflowId: wf.id,
372
+ title: wf.title,
373
+ nodes: wf.nodes.length,
374
+ moves,
375
+ warnings: load.warnings
376
+ });
377
+ }
378
+ const lines = [`ok: ${wf.id} (${wf.nodes.length} nodes, ${moves} moves)`];
379
+ for (const warning of load.warnings)
380
+ lines.push(`warning: ${warning}`);
381
+ return lines.join(`
382
+ `);
383
+ }
384
+
385
+ // src/runs/hook.ts
386
+ import { spawnSync } from "node:child_process";
387
+ import { existsSync as existsSync4, statSync } from "node:fs";
289
388
 
290
389
  // src/workflow/validate.ts
291
390
  import { z } from "zod";
292
- var nodeSchema = z.object({
293
- id: z.string().min(1),
294
- type: z.enum(["node", "subgraph"]),
295
- title: z.string().min(1),
391
+ var moveSchema = z.object({
392
+ to: z.string().min(1),
296
393
  description: z.string(),
297
- workflow: z.string().min(1).optional()
394
+ condition: z.string().min(1).optional()
298
395
  }).strict();
299
- var edgeSchema = z.object({
396
+ var nodeSchema = z.object({
300
397
  id: z.string().min(1),
301
- from: z.string().min(1),
302
- to: z.string().min(1),
398
+ title: z.string().min(1),
303
399
  description: z.string(),
304
- condition: z.string().min(1).optional()
400
+ group: z.string().min(1).optional(),
401
+ reviewRequired: z.boolean().optional(),
402
+ next: z.array(moveSchema).min(1)
305
403
  }).strict();
306
404
  var workflowSchema = z.object({
307
405
  id: z.string().min(1),
308
406
  title: z.string().min(1),
309
407
  start: z.string().min(1),
310
- nodes: z.array(nodeSchema).min(1),
311
- edges: z.array(edgeSchema)
408
+ nodes: z.array(nodeSchema).min(1)
312
409
  }).strict();
410
+ function reportLegacyShape(raw, report) {
411
+ if (typeof raw !== "object" || raw === null)
412
+ return;
413
+ const file = raw;
414
+ if (Array.isArray(file.edges)) {
415
+ report(`edges are inline now: move each edge into its node's "next" list`);
416
+ }
417
+ if (!Array.isArray(file.nodes))
418
+ return;
419
+ for (const node of file.nodes) {
420
+ if (typeof node !== "object" || node === null)
421
+ continue;
422
+ const candidate = node;
423
+ if (candidate.type === "subgraph") {
424
+ report(`node "${String(candidate.id)}" is a subgraph: inline its nodes and use "group" for the label`);
425
+ } else if ("type" in candidate) {
426
+ report(`node "${String(candidate.id)}" has a "type" field; nodes are flat, drop it`);
427
+ }
428
+ }
429
+ }
313
430
  function validateWorkflow(raw, source) {
314
431
  const errors = [];
315
432
  const warnings = [];
316
433
  const report = (message) => {
317
434
  errors.push(`${source}: ${message}`);
318
435
  };
436
+ reportLegacyShape(raw, report);
319
437
  const parsed = workflowSchema.safeParse(raw);
320
438
  if (!parsed.success) {
321
439
  for (const issue of parsed.error.issues) {
@@ -328,66 +446,46 @@ function validateWorkflow(raw, source) {
328
446
  const nodes = new Map;
329
447
  for (const node of wf.nodes) {
330
448
  if (node.id === END) {
331
- report(`node id "${END}" is reserved; point an edge at END instead`);
449
+ report(`node id "${END}" is reserved; point a move at END instead`);
332
450
  }
333
451
  if (nodes.has(node.id)) {
334
452
  report(`duplicate node id "${node.id}"`);
335
453
  }
336
454
  nodes.set(node.id, node);
337
- if (node.type === "subgraph") {
338
- if (!node.workflow) {
339
- report(`subgraph node "${node.id}" needs a "workflow" field`);
340
- } else if (node.workflow === wf.id) {
341
- report(`subgraph node "${node.id}" references its own workflow "${wf.id}"`);
342
- }
343
- } else if (node.workflow) {
344
- report(`node "${node.id}" is not a subgraph but has a "workflow" field`);
345
- }
346
455
  }
347
456
  if (wf.start === END) {
348
457
  report(`start must be a node, not END`);
349
458
  } else if (!nodes.has(wf.start)) {
350
459
  report(`start "${wf.start}" is not a node`);
351
460
  }
352
- const edgeIds = new Set;
353
- const pairs = new Set;
354
- for (const edge of wf.edges) {
355
- if (edgeIds.has(edge.id)) {
356
- report(`duplicate edge id "${edge.id}"`);
357
- }
358
- edgeIds.add(edge.id);
359
- const pair = `${edge.from} -> ${edge.to}`;
360
- if (pairs.has(pair)) {
361
- report(`duplicate edge ${pair}; transitions must be unambiguous`);
362
- }
363
- pairs.add(pair);
364
- if (!nodes.has(edge.from)) {
365
- report(`edge "${edge.id}" starts at unknown node "${edge.from}"`);
366
- }
367
- if (edge.to !== END && !nodes.has(edge.to)) {
368
- report(`edge "${edge.id}" targets unknown node "${edge.to}" (use END to finish)`);
369
- }
370
- }
461
+ let reachesEnd = false;
371
462
  for (const node of wf.nodes) {
372
- if (!wf.edges.some((edge) => edge.from === node.id)) {
373
- report(`node "${node.id}" has no outgoing edge (add one to END if it is the last step)`);
463
+ const seen = new Set;
464
+ for (const move of node.next) {
465
+ if (move.to === END) {
466
+ reachesEnd = true;
467
+ } else if (!nodes.has(move.to)) {
468
+ report(`move from "${node.id}" targets unknown node "${move.to}" (use END to finish)`);
469
+ }
470
+ if (seen.has(move.to)) {
471
+ report(`duplicate move ${node.id} -> ${move.to}; transitions must be unambiguous`);
472
+ }
473
+ seen.add(move.to);
374
474
  }
375
475
  }
376
- if (!wf.edges.some((edge) => edge.to === END)) {
377
- report(`workflow "${wf.id}" has no edge to END`);
476
+ if (!reachesEnd) {
477
+ report(`workflow "${wf.id}" has no move to END`);
378
478
  }
379
479
  if (errors.length === 0) {
380
480
  const reachable = new Set([wf.start]);
381
481
  const queue = [wf.start];
382
482
  while (queue.length > 0) {
383
483
  const id = queue.shift();
384
- for (const edge of wf.edges) {
385
- if (edge.from !== id)
484
+ for (const move of movesFor(wf, id)) {
485
+ if (move.to === END || reachable.has(move.to))
386
486
  continue;
387
- if (edge.to === END || reachable.has(edge.to))
388
- continue;
389
- reachable.add(edge.to);
390
- queue.push(edge.to);
487
+ reachable.add(move.to);
488
+ queue.push(move.to);
391
489
  }
392
490
  }
393
491
  for (const node of wf.nodes) {
@@ -396,19 +494,19 @@ function validateWorkflow(raw, source) {
396
494
  }
397
495
  }
398
496
  const canFinish = new Set;
399
- for (const edge of wf.edges) {
400
- if (edge.to === END)
401
- canFinish.add(edge.from);
497
+ for (const node of wf.nodes) {
498
+ if (node.next.some((move) => move.to === END))
499
+ canFinish.add(node.id);
402
500
  }
403
501
  let grew = true;
404
502
  while (grew) {
405
503
  grew = false;
406
- for (const edge of wf.edges) {
407
- if (edge.to === END)
504
+ for (const node of wf.nodes) {
505
+ if (canFinish.has(node.id))
408
506
  continue;
409
- if (!canFinish.has(edge.to) || canFinish.has(edge.from))
507
+ if (!node.next.some((move) => canFinish.has(move.to)))
410
508
  continue;
411
- canFinish.add(edge.from);
509
+ canFinish.add(node.id);
412
510
  grew = true;
413
511
  }
414
512
  }
@@ -420,305 +518,145 @@ function validateWorkflow(raw, source) {
420
518
  }
421
519
  return { workflow: wf, errors, warnings };
422
520
  }
423
- function outgoingEdges(wf, nodeId) {
424
- return wf.edges.filter((edge) => edge.from === nodeId);
521
+ function movesFor(wf, nodeId) {
522
+ return wf.nodes.find((node) => node.id === nodeId)?.next ?? [];
425
523
  }
426
524
  function onlyEnd(wf, nodeId) {
427
- const out = outgoingEdges(wf, nodeId);
428
- return out.length === 1 && out[0].to === END;
429
- }
430
-
431
- // src/workflow/load.ts
432
- function requireWorkflow(workflows, id) {
433
- const workflow = workflows.get(id);
434
- if (!workflow) {
435
- throw new AppError("workflow_not_found", `workflow "${id}" not found in ./workflows`, 3, {
436
- available: [...workflows.keys()].sort()
437
- });
438
- }
439
- return workflow;
440
- }
441
-
442
- // src/commands/workflow.ts
443
- var ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
444
- function skeleton(id) {
445
- return {
446
- id,
447
- title: id,
448
- start: "start",
449
- nodes: [
450
- {
451
- id: "start",
452
- type: "node",
453
- title: "Start",
454
- description: "Describe the first step."
455
- }
456
- ],
457
- edges: [
458
- {
459
- id: "e1",
460
- from: "start",
461
- to: END,
462
- description: "Done."
463
- }
464
- ]
465
- };
466
- }
467
- function cmdWorkflowNew(workflowsDir, id, json) {
468
- if (!id)
469
- throw usageError("workflow id is required: next-steps workflow new <id>");
470
- if (!ID_PATTERN.test(id)) {
471
- throw usageError(`invalid workflow id "${id}"; use A-Z a-z 0-9 . _ - (max 64)`);
472
- }
473
- const file = join2(workflowsDir, `${id}.json`);
474
- if (existsSync2(file)) {
475
- throw usageError(`workflow file already exists: ${file}`);
476
- }
477
- mkdirSync(workflowsDir, { recursive: true });
478
- writeFileSync(file, `${JSON.stringify(skeleton(id), null, 2)}
479
- `, "utf8");
480
- if (json)
481
- return toJson({ created: file, workflowId: id });
482
- return `created: ${file}`;
483
- }
484
- function cmdWorkflowList(load, json) {
485
- if (json) {
486
- return toJson({
487
- dir: load.dir,
488
- workflows: load.reports.map((report) => ({
489
- file: report.file,
490
- id: report.id,
491
- title: report.title,
492
- nodes: report.nodes,
493
- edges: report.edges,
494
- valid: report.valid,
495
- errors: report.errors
496
- })),
497
- errors: load.errors,
498
- warnings: load.warnings
499
- });
500
- }
501
- if (load.reports.length === 0) {
502
- return `no workflows in ${load.dir} (run "next-steps workflow new <id>")`;
503
- }
504
- const rows = load.reports.map((report) => [
505
- report.id,
506
- report.title,
507
- String(report.nodes),
508
- String(report.edges),
509
- report.valid ? "ok" : "invalid"
510
- ]);
511
- const body = table(["ID", "TITLE", "NODES", "EDGES", "STATUS"], rows);
512
- const failures = load.reports.filter((report) => !report.valid);
513
- if (failures.length === 0)
514
- return body;
515
- const details = failures.flatMap((report) => report.errors.map((error) => ` ${error}`));
516
- return `${body}
517
-
518
- invalid:
519
- ${details.join(`
520
- `)}`;
521
- }
522
- function findReport(load, id) {
523
- if (!id)
524
- throw usageError("workflow id is required");
525
- const report = load.reports.find((candidate) => candidate.id === id);
526
- if (!report) {
527
- throw new AppError("workflow_not_found", `workflow "${id}" not found in ${load.dir}`, 3, {
528
- available: load.reports.map((candidate) => candidate.id).sort()
529
- });
530
- }
531
- return report;
532
- }
533
- function cmdWorkflowShow(load, id, json) {
534
- const report = findReport(load, id);
535
- if (!report.valid) {
536
- throw new AppError("invalid_workflow", `workflow "${report.id}" is invalid`, 5, {
537
- errors: report.errors
538
- });
539
- }
540
- const wf = requireWorkflow(load.registry, report.id);
541
- if (json)
542
- return toJson({ workflow: wf, warnings: report.warnings });
543
- const nodeRows = wf.nodes.map((node) => [
544
- node.id,
545
- node.type,
546
- node.type === "subgraph" ? `${node.title} -> ${node.workflow ?? "?"}` : node.title
547
- ]);
548
- const edgeRows = wf.edges.map((edge) => [
549
- edge.id,
550
- `${edge.from} -> ${edge.to}`,
551
- edge.condition ? `${edge.description} [when: ${edge.condition}]` : edge.description
552
- ]);
553
- const indent = (block) => block.split(`
554
- `).map((line) => ` ${line}`).join(`
555
- `);
556
- return [
557
- `${wf.id} ${wf.title}`,
558
- `start: ${wf.start}`,
559
- "",
560
- "nodes:",
561
- indent(table(["ID", "TYPE", "TITLE"], nodeRows)),
562
- "",
563
- "edges:",
564
- indent(table(["ID", "TRANSITION", "DESCRIPTION"], edgeRows))
565
- ].join(`
566
- `);
567
- }
568
- function cmdWorkflowCheck(load, id, json) {
569
- const reports = id ? [findReport(load, id)] : load.reports;
570
- const errors = reports.flatMap((report) => report.errors);
571
- const warnings = reports.flatMap((report) => report.warnings);
572
- if (errors.length > 0) {
573
- throw new AppError("invalid_workflow", "workflow validation failed", 5, { errors });
574
- }
575
- if (json) {
576
- return toJson({
577
- ok: true,
578
- workflows: reports.map((report) => ({
579
- id: report.id,
580
- title: report.title,
581
- nodes: report.nodes,
582
- edges: report.edges
583
- })),
584
- warnings
585
- });
586
- }
587
- if (reports.length === 0) {
588
- return `no workflows in ${load.dir}`;
589
- }
590
- const lines = reports.map((report) => `ok: ${report.id} (${report.nodes} nodes, ${report.edges} edges)`);
591
- for (const warning of warnings)
592
- lines.push(`warning: ${warning}`);
593
- return lines.join(`
594
- `);
525
+ const moves = movesFor(wf, nodeId);
526
+ return moves.length === 1 && moves[0]?.to === END;
595
527
  }
596
528
 
597
529
  // src/workflow/graph.ts
598
530
  function getNode(wf, id) {
599
531
  return wf.nodes.find((node) => node.id === id);
600
532
  }
601
- function effectiveStack(lookup, stack) {
602
- const frames = stack.map((frame) => ({ ...frame }));
603
- while (frames.length > 1) {
604
- const top = frames[frames.length - 1];
605
- if (!top || top.nodeId === null)
606
- break;
607
- const wf = lookup.get(top.workflowId);
608
- if (!wf || !onlyEnd(wf, top.nodeId))
609
- break;
610
- frames.pop();
611
- }
612
- return frames;
613
- }
614
- function computeNext(lookup, session) {
533
+ function computeNext(wf, session) {
615
534
  if (session.status === "done") {
616
- return {
617
- done: true,
618
- current: session.current,
619
- currentTitle: null,
620
- scope: session.stack.map((frame) => frame.workflowId),
621
- next: []
622
- };
535
+ return { done: true, current: session.current, currentTitle: null, next: [] };
623
536
  }
624
- const frames = effectiveStack(lookup, session.stack);
625
- const scope = frames.map((frame) => frame.workflowId);
626
- const top = frames[frames.length - 1];
627
- if (!top) {
628
- return { done: true, current: null, currentTitle: null, scope, next: [] };
629
- }
630
- const wf = lookup.get(top.workflowId);
631
- if (!wf) {
632
- return { done: true, current: null, currentTitle: null, scope, next: [] };
633
- }
634
- if (top.nodeId === null) {
537
+ if (session.current === null) {
635
538
  const entry = getNode(wf, wf.start);
636
539
  return {
637
540
  done: false,
638
541
  current: null,
639
542
  currentTitle: null,
640
- scope,
641
543
  next: [
642
544
  {
643
545
  edge: null,
644
546
  node: wf.start,
645
547
  title: entry?.title ?? wf.start,
646
- description: entry?.description ?? ""
548
+ description: entry?.description ?? "",
549
+ reviewRequired: entry?.reviewRequired ?? false
647
550
  }
648
551
  ]
649
552
  };
650
553
  }
651
- const node = getNode(wf, top.nodeId);
652
- if (onlyEnd(wf, top.nodeId)) {
554
+ const node = getNode(wf, session.current);
555
+ if (onlyEnd(wf, session.current)) {
653
556
  return {
654
557
  done: true,
655
- current: top.nodeId,
558
+ current: session.current,
656
559
  currentTitle: node?.title ?? null,
657
- scope,
658
560
  next: []
659
561
  };
660
562
  }
661
- const next = outgoingEdges(wf, top.nodeId).map((edge) => {
662
- if (edge.to === END) {
563
+ const current = session.current;
564
+ const next = movesFor(wf, current).map((move) => {
565
+ const edge = `${current}->${move.to}`;
566
+ if (move.to === END) {
663
567
  return {
664
- edge: edge.id,
568
+ edge,
665
569
  node: END,
666
570
  title: "finish",
667
- description: edge.description,
668
- ...edge.condition ? { condition: edge.condition } : {}
571
+ description: move.description,
572
+ ...move.condition ? { condition: move.condition } : {},
573
+ reviewRequired: false
669
574
  };
670
575
  }
671
- const target = getNode(wf, edge.to);
576
+ const target = getNode(wf, move.to);
672
577
  return {
673
- edge: edge.id,
674
- node: edge.to,
675
- title: target?.title ?? edge.to,
676
- description: edge.description,
677
- ...edge.condition ? { condition: edge.condition } : {}
578
+ edge,
579
+ node: move.to,
580
+ title: target?.title ?? move.to,
581
+ description: move.description,
582
+ ...move.condition ? { condition: move.condition } : {},
583
+ reviewRequired: target?.reviewRequired ?? false
678
584
  };
679
585
  });
680
586
  return {
681
587
  done: false,
682
- current: top.nodeId,
588
+ current,
683
589
  currentTitle: node?.title ?? null,
684
- scope,
685
590
  next
686
591
  };
687
592
  }
688
593
 
689
- // src/runs/service.ts
690
- var KEY_PATTERN = /^[A-Za-z0-9._-]{1,128}$/;
691
- var MAX_DEPTH = 16;
692
- function legalOptions(wf, current) {
693
- if (current === null) {
694
- const entry = getNode(wf, wf.start);
695
- return [{ edge: null, node: wf.start, description: entry?.description ?? "" }];
594
+ // src/runs/hook.ts
595
+ var TIMEOUT_MS = 5000;
596
+ function isExecutable(path) {
597
+ try {
598
+ return (statSync(path).mode & 73) !== 0;
599
+ } catch {
600
+ return false;
696
601
  }
697
- return outgoingEdges(wf, current).map((edge) => ({
698
- edge: edge.id,
699
- node: edge.to,
700
- description: edge.description
701
- }));
702
602
  }
703
- function invalidTransition(wf, current, node, scope) {
704
- return new AppError("invalid_transition", `cannot move to "${node}" from "${current ?? "(start)"}" in workflow "${wf.id}"`, 4, { current, node, scope, legal: legalOptions(wf, current) });
603
+ function warn(message) {
604
+ process.stderr.write(`hook: ${message}
605
+ `);
705
606
  }
706
- function keyNotFound(key) {
707
- return new AppError("key_not_found", `session "${key}" does not exist`, 3);
607
+ function runPostLogHook(config, result) {
608
+ if (!existsSync4(config.path))
609
+ return;
610
+ if (!isExecutable(config.path)) {
611
+ warn(`${config.path} is not executable (chmod +x)`);
612
+ return;
613
+ }
614
+ const { done, next } = result.next;
615
+ const review = next.filter((option) => option.reviewRequired);
616
+ const payload = {
617
+ event: "log",
618
+ key: result.session.key,
619
+ status: done ? "done" : review.length > 0 ? "review" : "running",
620
+ last: {
621
+ id: result.step.node,
622
+ title: getNode(config.workflow, result.step.node)?.title ?? result.step.node
623
+ },
624
+ next: next.map((option) => ({
625
+ id: option.node,
626
+ title: option.title,
627
+ reviewRequired: option.reviewRequired
628
+ }))
629
+ };
630
+ const run = spawnSync(config.path, [], {
631
+ cwd: config.cwd,
632
+ input: JSON.stringify(payload),
633
+ encoding: "utf8",
634
+ timeout: TIMEOUT_MS
635
+ });
636
+ if (run.error) {
637
+ warn(`${config.path} failed: ${run.error.message}`);
638
+ return;
639
+ }
640
+ if (run.status !== 0) {
641
+ const detail = (run.stderr ?? "").trim();
642
+ warn(`${config.path} exited ${run.status}${detail ? `: ${detail}` : ""}`);
643
+ }
708
644
  }
709
645
 
646
+ // src/runs/service.ts
647
+ var KEY_PATTERN = /^[A-Za-z0-9._-]{1,128}$/;
648
+
710
649
  class SessionService {
711
- registry;
650
+ workflow;
712
651
  store;
713
- constructor(registry, store) {
714
- this.registry = registry;
652
+ constructor(workflow, store) {
653
+ this.workflow = workflow;
715
654
  this.store = store;
716
655
  }
717
656
  get statePath() {
718
657
  return this.store.path;
719
658
  }
720
- create(input) {
721
- const workflow = requireWorkflow(this.registry, input.workflowId);
659
+ create(input = {}) {
722
660
  const key = (input.key ?? "").trim() || crypto.randomUUID();
723
661
  if (!KEY_PATTERN.test(key)) {
724
662
  throw new AppError("key_invalid", `key "${key}" is invalid; use 1-128 characters from A-Z a-z 0-9 . _ -`, 2);
@@ -726,10 +664,9 @@ class SessionService {
726
664
  const now = new Date().toISOString();
727
665
  const session = {
728
666
  key,
729
- workflowId: workflow.id,
667
+ workflowId: this.workflow.id,
730
668
  status: "running",
731
669
  current: null,
732
- stack: [{ workflowId: workflow.id, nodeId: null }],
733
670
  version: 0,
734
671
  createdAt: now,
735
672
  updatedAt: now
@@ -739,13 +676,14 @@ class SessionService {
739
676
  }
740
677
  return {
741
678
  session,
742
- workflowTitle: workflow.title,
743
- next: computeNext(this.registry, session)
679
+ workflowTitle: this.workflow.title,
680
+ next: computeNext(this.workflow, session)
744
681
  };
745
682
  }
746
683
  log(key, node) {
747
684
  return this.store.transaction(() => {
748
685
  const session = this.requireSession(key);
686
+ const wf = this.workflow;
749
687
  if (session.status === "done") {
750
688
  throw new AppError("invalid_transition", `session "${key}" is already done`, 4, {
751
689
  current: END,
@@ -753,82 +691,48 @@ class SessionService {
753
691
  legal: []
754
692
  });
755
693
  }
756
- const frames = effectiveStack(this.registry, session.stack);
757
- const top = frames[frames.length - 1];
758
- if (!top) {
759
- throw new AppError("invalid_transition", `session "${key}" has no active scope`, 4);
760
- }
761
- const wf = requireWorkflow(this.registry, top.workflowId);
762
- const scope = frames.map((frame) => frame.workflowId);
763
694
  const now = new Date().toISOString();
764
- const stack = frames.map((frame) => ({ ...frame }));
695
+ const from = session.current;
765
696
  let stepInput;
766
- let current;
767
- let endsRun = false;
768
697
  if (node === END) {
769
- if (top.nodeId === null)
770
- throw invalidTransition(wf, top.nodeId, node, scope);
771
- const edge = outgoingEdges(wf, top.nodeId).find((candidate) => candidate.to === END);
772
- if (!edge)
773
- throw invalidTransition(wf, top.nodeId, node, scope);
698
+ if (from === null || !movesFor(wf, from).some((move) => move.to === END)) {
699
+ throw this.invalidTransition(from, node);
700
+ }
774
701
  stepInput = {
775
702
  key,
776
703
  node: END,
777
- from: top.nodeId,
778
- edge: edge.id,
779
- workflowId: top.workflowId,
780
- scope,
704
+ from,
705
+ edge: `${from}->${END}`,
706
+ workflowId: wf.id,
781
707
  at: now
782
708
  };
783
- if (stack.length === 1) {
784
- current = END;
785
- endsRun = true;
786
- } else {
787
- stack.pop();
788
- current = stack[stack.length - 1]?.nodeId ?? null;
789
- }
709
+ session.current = END;
710
+ session.status = "done";
790
711
  } else {
791
712
  const target = getNode(wf, node);
792
713
  if (!target) {
793
- throw new AppError("unknown_node", `node "${node}" does not exist in workflow "${wf.id}"`, 4, { legal: legalOptions(wf, top.nodeId) });
714
+ throw new AppError("unknown_node", `node "${node}" does not exist in workflow "${wf.id}"`, 4, { legal: this.legalOptions(from) });
794
715
  }
795
716
  let edge = null;
796
- if (top.nodeId === null) {
717
+ if (from === null) {
797
718
  if (node !== wf.start)
798
- throw invalidTransition(wf, top.nodeId, node, scope);
719
+ throw this.invalidTransition(from, node);
799
720
  } else {
800
- const found = outgoingEdges(wf, top.nodeId).find((candidate) => candidate.to === node);
801
- if (!found)
802
- throw invalidTransition(wf, top.nodeId, node, scope);
803
- edge = found;
804
- }
805
- const last = stack[stack.length - 1];
806
- if (last)
807
- last.nodeId = node;
808
- if (target.type === "subgraph") {
809
- if (stack.length >= MAX_DEPTH) {
810
- throw new AppError("max_depth_exceeded", `subgraph nesting exceeded ${MAX_DEPTH} levels`, 4, { scope });
721
+ if (!movesFor(wf, from).some((move) => move.to === node)) {
722
+ throw this.invalidTransition(from, node);
811
723
  }
812
- const child = requireWorkflow(this.registry, target.workflow);
813
- stack.push({ workflowId: child.id, nodeId: null });
724
+ edge = `${from}->${node}`;
725
+ }
726
+ stepInput = { key, node, from, edge, workflowId: wf.id, at: now };
727
+ session.current = node;
728
+ if (onlyEnd(wf, node)) {
729
+ session.current = END;
730
+ session.status = "done";
814
731
  }
815
- current = node;
816
- stepInput = {
817
- key,
818
- node,
819
- from: top.nodeId,
820
- edge: edge?.id ?? null,
821
- workflowId: top.workflowId,
822
- scope,
823
- at: now
824
- };
825
732
  }
826
- session.stack = stack;
827
- session.current = current;
828
- session.status = endsRun ? "done" : "running";
829
733
  session.version += 1;
830
734
  session.updatedAt = now;
831
- const next = this.settle(session);
735
+ const next = computeNext(wf, session);
832
736
  const step = this.store.appendStep(stepInput);
833
737
  this.store.updateSession(session);
834
738
  return { session, step, next };
@@ -836,40 +740,44 @@ class SessionService {
836
740
  }
837
741
  next(key) {
838
742
  const session = this.requireSession(key);
839
- return { session, next: computeNext(this.registry, session) };
743
+ return { session, next: computeNext(this.workflow, session) };
840
744
  }
841
745
  steps(key, query = {}) {
842
746
  const session = this.requireSession(key);
843
747
  return { session, steps: this.store.listSteps(key, query) };
844
748
  }
845
- keys(workflowId) {
846
- if (workflowId)
847
- requireWorkflow(this.registry, workflowId);
848
- return this.store.listSessions(workflowId);
749
+ keys() {
750
+ return this.store.listSessions();
849
751
  }
850
752
  reset(key) {
851
753
  this.requireSession(key);
852
754
  return { key, deletedSteps: this.store.reset(key) };
853
755
  }
854
- settle(session) {
855
- const next = computeNext(this.registry, session);
856
- if (!next.done)
857
- return next;
858
- session.status = "done";
859
- session.current = END;
860
- return computeNext(this.registry, session);
756
+ legalOptions(from) {
757
+ if (from === null) {
758
+ const entry = getNode(this.workflow, this.workflow.start);
759
+ return [{ edge: null, node: this.workflow.start, description: entry?.description ?? "" }];
760
+ }
761
+ return movesFor(this.workflow, from).map((move) => ({
762
+ edge: `${from}->${move.to}`,
763
+ node: move.to,
764
+ description: move.description
765
+ }));
766
+ }
767
+ invalidTransition(from, node) {
768
+ return new AppError("invalid_transition", `cannot move to "${node}" from "${from ?? "(start)"}" in workflow "${this.workflow.id}"`, 4, { current: from, node, legal: this.legalOptions(from) });
861
769
  }
862
770
  requireSession(key) {
863
771
  const session = this.store.getSession(key);
864
772
  if (!session)
865
- throw keyNotFound(key);
773
+ throw new AppError("key_not_found", `session "${key}" does not exist`, 3);
866
774
  return session;
867
775
  }
868
776
  }
869
777
 
870
778
  // src/runs/store.ts
871
- import { mkdirSync as mkdirSync2 } from "node:fs";
872
- import { dirname as dirname2 } from "node:path";
779
+ import { mkdirSync as mkdirSync2, unlinkSync } from "node:fs";
780
+ import { dirname as dirname4 } from "node:path";
873
781
 
874
782
  // src/runs/driver.ts
875
783
  import { createRequire as createRequire2 } from "node:module";
@@ -948,7 +856,6 @@ CREATE TABLE IF NOT EXISTS runs (
948
856
  workflow_id TEXT NOT NULL,
949
857
  status TEXT NOT NULL,
950
858
  current TEXT,
951
- stack TEXT NOT NULL,
952
859
  version INTEGER NOT NULL DEFAULT 0,
953
860
  created_at TEXT NOT NULL,
954
861
  updated_at TEXT NOT NULL
@@ -961,18 +868,17 @@ CREATE TABLE IF NOT EXISTS steps (
961
868
  from_node TEXT,
962
869
  edge TEXT,
963
870
  workflow_id TEXT NOT NULL,
964
- scope TEXT NOT NULL,
965
871
  at TEXT NOT NULL,
966
872
  PRIMARY KEY (key, seq)
967
873
  );
968
874
  `;
875
+ var LEGACY_COLUMNS = new Set(["stack", "scope"]);
969
876
  function toSession(row) {
970
877
  return {
971
878
  key: row.key,
972
879
  workflowId: row.workflow_id,
973
880
  status: row.status,
974
881
  current: row.current,
975
- stack: JSON.parse(row.stack),
976
882
  version: row.version,
977
883
  createdAt: row.created_at,
978
884
  updatedAt: row.updated_at
@@ -986,7 +892,6 @@ function toStep(row) {
986
892
  from: row.from_node,
987
893
  edge: row.edge,
988
894
  workflowId: row.workflow_id,
989
- scope: JSON.parse(row.scope),
990
895
  at: row.at
991
896
  };
992
897
  }
@@ -997,25 +902,42 @@ class SqliteRunStore {
997
902
  constructor(path) {
998
903
  this.path = path;
999
904
  if (path !== ":memory:") {
1000
- mkdirSync2(dirname2(path), { recursive: true });
905
+ mkdirSync2(dirname4(path), { recursive: true });
1001
906
  }
1002
907
  this.db = openDriver(path);
1003
- this.db.exec("PRAGMA foreign_keys = ON;");
1004
- this.db.exec("PRAGMA busy_timeout = 5000;");
1005
908
  if (path !== ":memory:") {
909
+ if (this.isOutdated())
910
+ this.recreate(path);
1006
911
  this.db.exec("PRAGMA journal_mode = WAL;");
1007
912
  }
913
+ this.db.exec("PRAGMA foreign_keys = ON;");
914
+ this.db.exec("PRAGMA busy_timeout = 5000;");
1008
915
  this.db.exec(SCHEMA);
1009
916
  }
917
+ isOutdated() {
918
+ const columns = [
919
+ ...this.db.all("PRAGMA table_info(runs)", []),
920
+ ...this.db.all("PRAGMA table_info(steps)", [])
921
+ ];
922
+ return columns.some((column) => LEGACY_COLUMNS.has(column.name));
923
+ }
924
+ recreate(path) {
925
+ this.db.close();
926
+ for (const suffix of ["", "-wal", "-shm"]) {
927
+ try {
928
+ unlinkSync(`${path}${suffix}`);
929
+ } catch {}
930
+ }
931
+ this.db = openDriver(path);
932
+ }
1010
933
  createSession(session) {
1011
934
  return this.db.run(`INSERT OR IGNORE INTO runs
1012
- (key, workflow_id, status, current, stack, version, created_at, updated_at)
1013
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [
935
+ (key, workflow_id, status, current, version, created_at, updated_at)
936
+ VALUES (?, ?, ?, ?, ?, ?, ?)`, [
1014
937
  session.key,
1015
938
  session.workflowId,
1016
939
  session.status,
1017
940
  session.current,
1018
- JSON.stringify(session.stack),
1019
941
  session.version,
1020
942
  session.createdAt,
1021
943
  session.updatedAt
@@ -1027,32 +949,16 @@ class SqliteRunStore {
1027
949
  }
1028
950
  updateSession(session) {
1029
951
  this.db.run(`UPDATE runs
1030
- SET status = ?, current = ?, stack = ?, version = ?, updated_at = ?
1031
- WHERE key = ?`, [
1032
- session.status,
1033
- session.current,
1034
- JSON.stringify(session.stack),
1035
- session.version,
1036
- session.updatedAt,
1037
- session.key
1038
- ]);
952
+ SET status = ?, current = ?, version = ?, updated_at = ?
953
+ WHERE key = ?`, [session.status, session.current, session.version, session.updatedAt, session.key]);
1039
954
  }
1040
955
  appendStep(step) {
1041
956
  const row = this.db.get("SELECT COALESCE(MAX(seq), 0) + 1 AS next FROM steps WHERE key = ?", [
1042
957
  step.key
1043
958
  ]);
1044
959
  const seq = row?.next ?? 1;
1045
- this.db.run(`INSERT INTO steps (key, seq, node, from_node, edge, workflow_id, scope, at)
1046
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [
1047
- step.key,
1048
- seq,
1049
- step.node,
1050
- step.from,
1051
- step.edge,
1052
- step.workflowId,
1053
- JSON.stringify(step.scope),
1054
- step.at
1055
- ]);
960
+ this.db.run(`INSERT INTO steps (key, seq, node, from_node, edge, workflow_id, at)
961
+ VALUES (?, ?, ?, ?, ?, ?, ?)`, [step.key, seq, step.node, step.from, step.edge, step.workflowId, step.at]);
1056
962
  return { ...step, seq };
1057
963
  }
1058
964
  listSteps(key, query = {}) {
@@ -1061,10 +967,8 @@ class SqliteRunStore {
1061
967
  const rows = this.db.all("SELECT * FROM steps WHERE key = ? AND seq > ? ORDER BY seq ASC LIMIT ?", [key, after, limit]);
1062
968
  return rows.map(toStep);
1063
969
  }
1064
- listSessions(workflowId) {
1065
- const rows = workflowId ? this.db.all("SELECT * FROM runs WHERE workflow_id = ? ORDER BY updated_at DESC", [
1066
- workflowId
1067
- ]) : this.db.all("SELECT * FROM runs ORDER BY updated_at DESC", []);
970
+ listSessions() {
971
+ const rows = this.db.all("SELECT * FROM runs ORDER BY updated_at DESC", []);
1068
972
  return rows.map(toSession);
1069
973
  }
1070
974
  reset(key) {
@@ -1095,130 +999,51 @@ class AppError2 extends Error {
1095
999
  }
1096
1000
 
1097
1001
  // src/workflow/load.ts
1098
- import { existsSync as existsSync3, readdirSync, readFileSync as readFileSync2 } from "node:fs";
1099
- import { join as join3 } from "node:path";
1100
- function rawId(raw) {
1101
- return typeof raw === "object" && raw !== null && typeof raw.id === "string" ? raw.id : "(invalid)";
1102
- }
1103
- function loadWorkflows(dir) {
1104
- const registry = new Map;
1105
- const reports = [];
1106
- if (!existsSync3(dir)) {
1107
- const message = `workflow directory not found: ${dir} (run "next-steps workflow new <id>")`;
1108
- return { dir, registry, reports, errors: [message], warnings: [] };
1109
- }
1110
- const parsed = [];
1111
- for (const file of readdirSync(dir).filter((name) => name.endsWith(".json")).sort()) {
1112
- const path = join3(dir, file);
1113
- let raw;
1114
- try {
1115
- raw = JSON.parse(readFileSync2(path, "utf8"));
1116
- } catch (error) {
1117
- const message = `${file}: invalid JSON (${error instanceof Error ? error.message : String(error)})`;
1118
- reports.push({
1119
- file,
1120
- id: "(unreadable)",
1121
- title: "",
1122
- nodes: 0,
1123
- edges: 0,
1124
- valid: false,
1125
- errors: [message],
1126
- warnings: []
1127
- });
1128
- continue;
1129
- }
1130
- const check = validateWorkflow(raw, file);
1131
- if (check.workflow) {
1132
- parsed.push({
1133
- file,
1134
- workflow: check.workflow,
1135
- errors: check.errors,
1136
- warnings: check.warnings
1137
- });
1138
- } else {
1139
- reports.push({
1140
- file,
1141
- id: rawId(raw),
1142
- title: "",
1143
- nodes: 0,
1144
- edges: 0,
1145
- valid: false,
1146
- errors: check.errors,
1147
- warnings: check.warnings
1148
- });
1149
- }
1150
- }
1151
- const byId = new Map;
1152
- for (const entry of parsed) {
1153
- const group = byId.get(entry.workflow.id) ?? [];
1154
- group.push(entry);
1155
- byId.set(entry.workflow.id, group);
1156
- }
1157
- for (const [id, group] of byId) {
1158
- if (group.length < 2)
1159
- continue;
1160
- const message = `workflow id "${id}" is defined in more than one file: ${group.map((entry) => entry.file).join(", ")}`;
1161
- for (const entry of group)
1162
- entry.errors.push(message);
1163
- }
1164
- const knownIds = new Set(parsed.map((entry) => entry.workflow.id));
1165
- for (const entry of parsed) {
1166
- for (const node of entry.workflow.nodes) {
1167
- if (node.type !== "subgraph" || !node.workflow)
1168
- continue;
1169
- if (!knownIds.has(node.workflow)) {
1170
- entry.errors.push(`${entry.file}: subgraph node "${node.id}" references unknown workflow "${node.workflow}"`);
1171
- }
1172
- }
1173
- }
1174
- for (const entry of parsed) {
1175
- const valid = entry.errors.length === 0;
1176
- reports.push({
1177
- file: entry.file,
1178
- id: entry.workflow.id,
1179
- title: entry.workflow.title,
1180
- nodes: entry.workflow.nodes.length,
1181
- edges: entry.workflow.edges.length,
1182
- valid,
1183
- errors: entry.errors,
1184
- warnings: entry.warnings
1185
- });
1186
- if (valid)
1187
- registry.set(entry.workflow.id, entry.workflow);
1002
+ import { readFileSync as readFileSync3 } from "node:fs";
1003
+ function loadWorkflowFile(path) {
1004
+ let raw;
1005
+ try {
1006
+ raw = JSON.parse(readFileSync3(path, "utf8"));
1007
+ } catch (error) {
1008
+ const message = error instanceof Error ? error.message : String(error);
1009
+ const missing = error.code === "ENOENT";
1010
+ return {
1011
+ path,
1012
+ errors: [
1013
+ missing ? `workflow file not found: ${path} (run "next-steps workflow new")` : `${path}: invalid JSON (${message})`
1014
+ ],
1015
+ warnings: []
1016
+ };
1188
1017
  }
1189
- return {
1190
- dir,
1191
- registry,
1192
- reports,
1193
- errors: reports.flatMap((report) => report.errors),
1194
- warnings: reports.flatMap((report) => report.warnings)
1195
- };
1018
+ const check = validateWorkflow(raw, path);
1019
+ return { path, workflow: check.workflow, errors: check.errors, warnings: check.warnings };
1196
1020
  }
1197
1021
 
1198
1022
  // src/cli.ts
1199
- var VERSION = "0.1.0";
1023
+ var VERSION = "0.1.1";
1200
1024
  var HELP = `next-steps \u2014 record the current step, ask what comes next
1201
1025
 
1202
1026
  usage:
1203
- next-steps create --workflow <id> [--key <key>] [--json]
1027
+ next-steps create [--key <key>] [--json]
1204
1028
  next-steps log --key <key> --node <node> [--json]
1205
1029
  next-steps next --key <key> [--json]
1206
1030
  next-steps steps --key <key> [--limit <n>] [--after <seq>] [--json]
1207
- next-steps keys [--workflow <id>] [--json]
1031
+ next-steps keys [--json]
1208
1032
  next-steps reset --key <key> [--json]
1033
+ next-steps agents [--json] (print the agent playbook)
1209
1034
 
1210
- next-steps workflow new <id> [--json]
1211
- next-steps workflow list [--json]
1212
- next-steps workflow show <id> [--json]
1213
- next-steps workflow check [id] [--json]
1035
+ next-steps workflow new [--json]
1036
+ next-steps workflow show [--json]
1037
+ next-steps workflow check [--json]
1214
1038
 
1215
- next-steps viz [--port <n>] [--json] (serve a graph viewer on a local port)
1039
+ next-steps viz [--port <n>] [--json] (serve the graph viewer on a local port)
1216
1040
 
1217
1041
  next-steps help
1218
1042
  next-steps version
1219
1043
 
1220
- state: ./.next-steps/state.db (created on first use, gitignored)
1221
- graphs: ./workflows/*.json
1044
+ state: ./.next-steps/state.db (created on first use, gitignored)
1045
+ graph: ./.next-steps/workflow.json (one graph per project, committed)
1046
+ hook: ./.next-steps/hook (optional, runs after each log)
1222
1047
 
1223
1048
  exit codes:
1224
1049
  0 ok 2 usage 3 not found 4 invalid transition 5 invalid workflow`;
@@ -1232,19 +1057,25 @@ function parseOptions(args, options, allowPositionals = false) {
1232
1057
  function asString(value) {
1233
1058
  return typeof value === "string" ? value : undefined;
1234
1059
  }
1235
- function requireRegistry(workflowsDir) {
1236
- const load = loadWorkflows(workflowsDir);
1237
- if (load.errors.length > 0) {
1238
- throw new AppError2("invalid_workflow", "workflow files are invalid", 5, {
1060
+ function requireGraphFile(graphPath) {
1061
+ if (existsSync5(graphPath))
1062
+ return;
1063
+ throw new AppError2("workflow_not_found", `workflow file not found: ${graphPath} (run "next-steps workflow new")`, 3);
1064
+ }
1065
+ function requireWorkflow(graphPath) {
1066
+ requireGraphFile(graphPath);
1067
+ const load = loadWorkflowFile(graphPath);
1068
+ if (load.errors.length > 0 || !load.workflow) {
1069
+ throw new AppError2("invalid_workflow", "workflow file is invalid", 5, {
1239
1070
  errors: load.errors
1240
1071
  });
1241
1072
  }
1242
- return load.registry;
1073
+ return load.workflow;
1243
1074
  }
1244
1075
  function execute(argv, options) {
1245
1076
  const cwd = options.cwd ?? process.cwd();
1246
- const workflowsDir = options.workflowsDir ?? join4(cwd, "workflows");
1247
- const dbPath = options.dbPath ?? join4(cwd, ".next-steps", "state.db");
1077
+ const graphPath = options.graphPath ?? join3(cwd, ".next-steps", "workflow.json");
1078
+ const dbPath = options.dbPath ?? join3(cwd, ".next-steps", "state.db");
1248
1079
  const [command, ...rest] = argv;
1249
1080
  if (!command || command === "help" || command === "--help" || command === "-h") {
1250
1081
  return HELP;
@@ -1252,23 +1083,27 @@ function execute(argv, options) {
1252
1083
  if (command === "version" || command === "--version" || command === "-v") {
1253
1084
  return `next-steps ${VERSION}`;
1254
1085
  }
1086
+ if (command === "agents") {
1087
+ const { values } = parseOptions(rest, { json: { type: "boolean" } });
1088
+ return cmdAgents(Boolean(values.json));
1089
+ }
1255
1090
  if (command === "workflow") {
1256
1091
  const { positionals, values } = parseOptions(rest, { json: { type: "boolean" } }, true);
1257
- const [subcommand, id] = positionals;
1092
+ const [subcommand] = positionals;
1258
1093
  const json = Boolean(values.json);
1259
- const load = loadWorkflows(workflowsDir);
1260
- switch (subcommand) {
1261
- case "new":
1262
- return cmdWorkflowNew(workflowsDir, id, json);
1263
- case "list":
1264
- return cmdWorkflowList(load, json);
1265
- case "show":
1266
- return cmdWorkflowShow(load, id, json);
1267
- case "check":
1268
- return cmdWorkflowCheck(load, id, json);
1269
- default:
1270
- throw usageError2("usage: next-steps workflow <new|list|show|check> ...");
1094
+ if (subcommand === "new") {
1095
+ if (positionals.length > 1)
1096
+ throw usageError2(`unexpected argument "${positionals[1]}"`);
1097
+ return cmdWorkflowNew(graphPath, basename(cwd), json);
1271
1098
  }
1099
+ if (subcommand !== "show" && subcommand !== "check") {
1100
+ throw usageError2("usage: next-steps workflow <new|show|check> [--json]");
1101
+ }
1102
+ if (positionals.length > 1)
1103
+ throw usageError2(`unexpected argument "${positionals[1]}"`);
1104
+ requireGraphFile(graphPath);
1105
+ const load = loadWorkflowFile(graphPath);
1106
+ return subcommand === "show" ? cmdWorkflowShow(load, json) : cmdWorkflowCheck(load, json);
1272
1107
  }
1273
1108
  if (command === "viz") {
1274
1109
  const { values } = parseOptions(rest, { port: { type: "string" }, json: { type: "boolean" } });
@@ -1276,27 +1111,21 @@ function execute(argv, options) {
1276
1111
  if (port < 1 || port > 65535) {
1277
1112
  throw usageError2("--port must be between 1 and 65535");
1278
1113
  }
1279
- const registry2 = requireRegistry(workflowsDir);
1280
- if (registry2.size === 0) {
1281
- throw new AppError2("no_workflows", `no workflows in ${workflowsDir}`, 3);
1282
- }
1283
- const result = startVizServer(registry2, port);
1284
- return values.json ? toJson2({ url: result.url, workflows: result.workflows }) : `viewer: ${result.url}
1114
+ const result = startVizServer(requireWorkflow(graphPath), port);
1115
+ return values.json ? toJson2({ url: result.url, workflow: result.workflow }) : `viewer: ${result.url}
1285
1116
  press Ctrl+C to stop`;
1286
1117
  }
1287
- const registry = requireRegistry(workflowsDir);
1118
+ const workflow = requireWorkflow(graphPath);
1288
1119
  const store = new SqliteRunStore(dbPath);
1289
1120
  try {
1290
- const service = new SessionService(registry, store);
1121
+ const service = new SessionService(workflow, store);
1291
1122
  switch (command) {
1292
1123
  case "create": {
1293
1124
  const { values } = parseOptions(rest, {
1294
- workflow: { type: "string" },
1295
1125
  key: { type: "string" },
1296
1126
  json: { type: "boolean" }
1297
1127
  });
1298
1128
  return cmdCreate(service, {
1299
- workflowId: asString(values.workflow),
1300
1129
  key: asString(values.key),
1301
1130
  json: Boolean(values.json)
1302
1131
  });
@@ -1311,7 +1140,7 @@ press Ctrl+C to stop`;
1311
1140
  key: asString(values.key),
1312
1141
  node: asString(values.node),
1313
1142
  json: Boolean(values.json)
1314
- });
1143
+ }, (result) => runPostLogHook({ path: join3(dirname5(graphPath), "hook"), cwd, workflow }, result));
1315
1144
  }
1316
1145
  case "next": {
1317
1146
  const { values } = parseOptions(rest, {
@@ -1338,14 +1167,8 @@ press Ctrl+C to stop`;
1338
1167
  });
1339
1168
  }
1340
1169
  case "keys": {
1341
- const { values } = parseOptions(rest, {
1342
- workflow: { type: "string" },
1343
- json: { type: "boolean" }
1344
- });
1345
- return cmdKeys(service, {
1346
- workflowId: asString(values.workflow),
1347
- json: Boolean(values.json)
1348
- });
1170
+ const { values } = parseOptions(rest, { json: { type: "boolean" } });
1171
+ return cmdKeys(service, { json: Boolean(values.json) });
1349
1172
  }
1350
1173
  case "reset": {
1351
1174
  const { values } = parseOptions(rest, {