@wanbicoi/next-steps 0.1.0 → 0.1.1

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,7 +2,8 @@
2
2
  // @bun
3
3
 
4
4
  // src/cli.ts
5
- import { join as join4 } from "path";
5
+ import { existsSync as existsSync3 } from "fs";
6
+ import { basename, join as join2 } from "path";
6
7
  import { pathToFileURL } from "url";
7
8
  import { parseArgs } from "util";
8
9
 
@@ -29,16 +30,13 @@ function table(headers, rows) {
29
30
  return [render(headers), ...rows.map(render)].join(`
30
31
  `);
31
32
  }
32
- function formatScope(scope) {
33
- return scope.length > 0 ? scope.join(" > ") : "-";
34
- }
35
33
  function formatTime(iso) {
36
34
  return iso.slice(0, 19).replace("T", " ");
37
35
  }
38
36
  function optionLine(option, index) {
39
- const label = option.edge ? `${option.edge} -> ${option.node}` : option.node;
40
37
  const title = option.node === END ? "" : option.title;
41
- const head = ` ${index + 1}. ${label}${title ? ` ${title}` : ""}`.trimEnd();
38
+ const review = option.reviewRequired ? " [review]" : "";
39
+ const head = ` ${index + 1}. ${option.node}${title ? ` ${title}` : ""}${review}`.trimEnd();
42
40
  const lines = [head, ` ${option.description}`];
43
41
  if (option.condition)
44
42
  lines.push(` when: ${option.condition}`);
@@ -57,14 +55,12 @@ function formatNext(next) {
57
55
  `);
58
56
  }
59
57
  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
- }
58
+ if (next.done)
59
+ return `current: ${next.current ?? END} (finished)`;
60
+ if (next.current === null)
61
+ return "current: (not started)";
66
62
  const title = next.currentTitle ? ` (${next.currentTitle})` : "";
67
- return `current: ${next.current}${title} scope: ${formatScope(next.scope)}`;
63
+ return `current: ${next.current}${title}`;
68
64
  }
69
65
 
70
66
  // src/commands/shared.ts
@@ -83,8 +79,7 @@ function toJson(value) {
83
79
 
84
80
  // src/commands/session.ts
85
81
  function cmdCreate(service, input) {
86
- const workflowId = requireString(input.workflowId, "workflow");
87
- const { session, workflowTitle, next } = service.create({ workflowId, key: input.key });
82
+ const { session, workflowTitle, next } = service.create({ key: input.key });
88
83
  if (input.json) {
89
84
  return toJson({
90
85
  key: session.key,
@@ -92,7 +87,6 @@ function cmdCreate(service, input) {
92
87
  workflowTitle,
93
88
  status: session.status,
94
89
  current: null,
95
- scope: next.scope,
96
90
  done: next.done,
97
91
  next: next.next,
98
92
  statePath: service.statePath
@@ -120,10 +114,10 @@ function cmdLog(service, input) {
120
114
  statePath: service.statePath
121
115
  });
122
116
  }
123
- const origin = step.from ? ` (from ${step.from}${step.edge ? ` via ${step.edge}` : ""})` : " (entry)";
117
+ const origin = step.from ? ` (from ${step.from})` : " (entry)";
124
118
  return [
125
119
  `logged: #${step.seq} ${step.node}${origin}`,
126
- `scope: ${formatScope(step.scope)} status: ${session.status}`,
120
+ `status: ${session.status}`,
127
121
  "",
128
122
  formatNext(next)
129
123
  ].join(`
@@ -138,7 +132,6 @@ function cmdNext(service, input) {
138
132
  workflowId: session.workflowId,
139
133
  status: session.status,
140
134
  current: next.current === null ? null : { node: next.current, title: next.currentTitle, at: session.updatedAt },
141
- scope: next.scope,
142
135
  done: next.done,
143
136
  next: next.next,
144
137
  statePath: service.statePath
@@ -156,31 +149,28 @@ function cmdSteps(service, input) {
156
149
  workflowId: session.workflowId,
157
150
  status: session.status,
158
151
  current: session.current,
159
- scope: session.stack.map((frame) => frame.workflowId),
160
152
  steps,
161
153
  statePath: service.statePath
162
154
  });
163
155
  }
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
- `);
156
+ const head = `session: ${key} status: ${session.status} current: ${session.current ?? "(not started)"}`;
170
157
  if (steps.length === 0)
171
- return `${head}no steps yet`;
158
+ return `${head}
159
+
160
+ no steps yet`;
172
161
  const rows = steps.map((step) => [
173
162
  String(step.seq),
174
163
  step.node,
175
164
  step.edge ?? "-",
176
165
  step.from ?? "-",
177
- formatScope(step.scope),
178
166
  formatTime(step.at)
179
167
  ]);
180
- return head + table(["SEQ", "NODE", "EDGE", "FROM", "SCOPE", "AT"], rows);
168
+ return `${head}
169
+
170
+ ${table(["SEQ", "NODE", "EDGE", "FROM", "AT"], rows)}`;
181
171
  }
182
172
  function cmdKeys(service, input) {
183
- const sessions = service.keys(input.workflowId);
173
+ const sessions = service.keys();
184
174
  if (input.json) {
185
175
  return toJson({ statePath: service.statePath, sessions });
186
176
  }
@@ -242,19 +232,15 @@ function findTemplate() {
242
232
  }
243
233
  throw new AppError("viz_template_missing", "could not find viz/template.html next to the package", 3);
244
234
  }
245
- function renderVizHtml(template, workflows) {
246
- const order = Object.keys(workflows).sort();
247
- const data = JSON.stringify({ workflows, order }).replaceAll("<", "\\u003c");
235
+ function renderVizHtml(template, workflow) {
236
+ const data = JSON.stringify({ workflow }).replaceAll("<", "\\u003c");
248
237
  return template.replace("__WORKFLOW_DATA__", data);
249
238
  }
250
- function startVizServer(registry, port) {
239
+ function startVizServer(workflow, port) {
251
240
  const template = readFileSync(findTemplate(), "utf8");
252
241
  const cytoscapeJs = readFileSync(require2.resolve("cytoscape/dist/cytoscape.min.js"), "utf8");
253
242
  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);
243
+ const html = renderVizHtml(template, workflow);
258
244
  const server = createServer((request, response) => {
259
245
  const path = (request.url ?? "/").split("?")[0];
260
246
  if (path === "/" || path === "/index.html") {
@@ -280,42 +266,143 @@ function startVizServer(registry, port) {
280
266
  process.exitCode = 1;
281
267
  });
282
268
  server.listen(port, "127.0.0.1");
283
- return { url: `http://127.0.0.1:${port}`, workflows: Object.keys(workflows).sort() };
269
+ return { url: `http://127.0.0.1:${port}`, workflow: workflow.id };
284
270
  }
285
271
 
286
272
  // src/commands/workflow.ts
287
273
  import { existsSync as existsSync2, mkdirSync, writeFileSync } from "node:fs";
288
- import { join as join2 } from "node:path";
274
+ import { dirname as dirname2 } from "node:path";
275
+ var ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
276
+ function skeleton(id) {
277
+ return {
278
+ id,
279
+ title: id,
280
+ start: "start",
281
+ nodes: [
282
+ {
283
+ id: "start",
284
+ title: "Start",
285
+ description: "Describe the first step.",
286
+ next: [{ to: END, description: "Done." }]
287
+ }
288
+ ]
289
+ };
290
+ }
291
+ function cmdWorkflowNew(graphPath, id, json) {
292
+ if (existsSync2(graphPath)) {
293
+ throw usageError(`workflow file already exists: ${graphPath}`);
294
+ }
295
+ const workflowId = ID_PATTERN.test(id) ? id : "workflow";
296
+ mkdirSync(dirname2(graphPath), { recursive: true });
297
+ writeFileSync(graphPath, `${JSON.stringify(skeleton(workflowId), null, 2)}
298
+ `, "utf8");
299
+ if (json)
300
+ return toJson({ created: graphPath, workflowId });
301
+ return `created: ${graphPath}`;
302
+ }
303
+ function requireValid(load) {
304
+ if (load.errors.length > 0 || !load.workflow) {
305
+ throw new AppError("invalid_workflow", `workflow file is invalid: ${load.path}`, 5, {
306
+ errors: load.errors
307
+ });
308
+ }
309
+ return load.workflow;
310
+ }
311
+ function cmdWorkflowShow(load, json) {
312
+ const wf = requireValid(load);
313
+ if (json)
314
+ return toJson({ workflow: wf, warnings: load.warnings });
315
+ const lines = [`${wf.id} ${wf.title}`, `start: ${wf.start}`];
316
+ let group;
317
+ for (const node of wf.nodes) {
318
+ if (node.group !== group) {
319
+ group = node.group;
320
+ if (group)
321
+ lines.push("", group);
322
+ }
323
+ const review = node.reviewRequired ? " [review]" : "";
324
+ lines.push(` ${node.id} ${node.title}${review}`);
325
+ for (const move of node.next) {
326
+ const when = move.condition ? ` [when: ${move.condition}]` : "";
327
+ lines.push(` -> ${move.to} ${move.description}${when}`);
328
+ }
329
+ }
330
+ return lines.join(`
331
+ `);
332
+ }
333
+ function cmdWorkflowCheck(load, json) {
334
+ if (load.errors.length > 0 || !load.workflow) {
335
+ throw new AppError("invalid_workflow", "workflow validation failed", 5, {
336
+ errors: load.errors
337
+ });
338
+ }
339
+ const wf = load.workflow;
340
+ const moves = wf.nodes.reduce((count, node) => count + node.next.length, 0);
341
+ if (json) {
342
+ return toJson({
343
+ ok: true,
344
+ workflowId: wf.id,
345
+ title: wf.title,
346
+ nodes: wf.nodes.length,
347
+ moves,
348
+ warnings: load.warnings
349
+ });
350
+ }
351
+ const lines = [`ok: ${wf.id} (${wf.nodes.length} nodes, ${moves} moves)`];
352
+ for (const warning of load.warnings)
353
+ lines.push(`warning: ${warning}`);
354
+ return lines.join(`
355
+ `);
356
+ }
289
357
 
290
358
  // src/workflow/validate.ts
291
359
  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),
360
+ var moveSchema = z.object({
361
+ to: z.string().min(1),
296
362
  description: z.string(),
297
- workflow: z.string().min(1).optional()
363
+ condition: z.string().min(1).optional()
298
364
  }).strict();
299
- var edgeSchema = z.object({
365
+ var nodeSchema = z.object({
300
366
  id: z.string().min(1),
301
- from: z.string().min(1),
302
- to: z.string().min(1),
367
+ title: z.string().min(1),
303
368
  description: z.string(),
304
- condition: z.string().min(1).optional()
369
+ group: z.string().min(1).optional(),
370
+ reviewRequired: z.boolean().optional(),
371
+ next: z.array(moveSchema).min(1)
305
372
  }).strict();
306
373
  var workflowSchema = z.object({
307
374
  id: z.string().min(1),
308
375
  title: z.string().min(1),
309
376
  start: z.string().min(1),
310
- nodes: z.array(nodeSchema).min(1),
311
- edges: z.array(edgeSchema)
377
+ nodes: z.array(nodeSchema).min(1)
312
378
  }).strict();
379
+ function reportLegacyShape(raw, report) {
380
+ if (typeof raw !== "object" || raw === null)
381
+ return;
382
+ const file = raw;
383
+ if (Array.isArray(file.edges)) {
384
+ report(`edges are inline now: move each edge into its node's "next" list`);
385
+ }
386
+ if (!Array.isArray(file.nodes))
387
+ return;
388
+ for (const node of file.nodes) {
389
+ if (typeof node !== "object" || node === null)
390
+ continue;
391
+ const candidate = node;
392
+ if (candidate.type === "subgraph") {
393
+ report(`node "${String(candidate.id)}" is a subgraph: inline its nodes and use "group" for the label`);
394
+ } else if ("type" in candidate) {
395
+ report(`node "${String(candidate.id)}" has a "type" field; nodes are flat, drop it`);
396
+ }
397
+ }
398
+ }
313
399
  function validateWorkflow(raw, source) {
314
400
  const errors = [];
315
401
  const warnings = [];
316
402
  const report = (message) => {
317
403
  errors.push(`${source}: ${message}`);
318
404
  };
405
+ reportLegacyShape(raw, report);
319
406
  const parsed = workflowSchema.safeParse(raw);
320
407
  if (!parsed.success) {
321
408
  for (const issue of parsed.error.issues) {
@@ -328,66 +415,46 @@ function validateWorkflow(raw, source) {
328
415
  const nodes = new Map;
329
416
  for (const node of wf.nodes) {
330
417
  if (node.id === END) {
331
- report(`node id "${END}" is reserved; point an edge at END instead`);
418
+ report(`node id "${END}" is reserved; point a move at END instead`);
332
419
  }
333
420
  if (nodes.has(node.id)) {
334
421
  report(`duplicate node id "${node.id}"`);
335
422
  }
336
423
  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
424
  }
347
425
  if (wf.start === END) {
348
426
  report(`start must be a node, not END`);
349
427
  } else if (!nodes.has(wf.start)) {
350
428
  report(`start "${wf.start}" is not a node`);
351
429
  }
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
- }
430
+ let reachesEnd = false;
371
431
  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)`);
432
+ const seen = new Set;
433
+ for (const move of node.next) {
434
+ if (move.to === END) {
435
+ reachesEnd = true;
436
+ } else if (!nodes.has(move.to)) {
437
+ report(`move from "${node.id}" targets unknown node "${move.to}" (use END to finish)`);
438
+ }
439
+ if (seen.has(move.to)) {
440
+ report(`duplicate move ${node.id} -> ${move.to}; transitions must be unambiguous`);
441
+ }
442
+ seen.add(move.to);
374
443
  }
375
444
  }
376
- if (!wf.edges.some((edge) => edge.to === END)) {
377
- report(`workflow "${wf.id}" has no edge to END`);
445
+ if (!reachesEnd) {
446
+ report(`workflow "${wf.id}" has no move to END`);
378
447
  }
379
448
  if (errors.length === 0) {
380
449
  const reachable = new Set([wf.start]);
381
450
  const queue = [wf.start];
382
451
  while (queue.length > 0) {
383
452
  const id = queue.shift();
384
- for (const edge of wf.edges) {
385
- if (edge.from !== id)
386
- continue;
387
- if (edge.to === END || reachable.has(edge.to))
453
+ for (const move of movesFor(wf, id)) {
454
+ if (move.to === END || reachable.has(move.to))
388
455
  continue;
389
- reachable.add(edge.to);
390
- queue.push(edge.to);
456
+ reachable.add(move.to);
457
+ queue.push(move.to);
391
458
  }
392
459
  }
393
460
  for (const node of wf.nodes) {
@@ -396,19 +463,19 @@ function validateWorkflow(raw, source) {
396
463
  }
397
464
  }
398
465
  const canFinish = new Set;
399
- for (const edge of wf.edges) {
400
- if (edge.to === END)
401
- canFinish.add(edge.from);
466
+ for (const node of wf.nodes) {
467
+ if (node.next.some((move) => move.to === END))
468
+ canFinish.add(node.id);
402
469
  }
403
470
  let grew = true;
404
471
  while (grew) {
405
472
  grew = false;
406
- for (const edge of wf.edges) {
407
- if (edge.to === END)
473
+ for (const node of wf.nodes) {
474
+ if (canFinish.has(node.id))
408
475
  continue;
409
- if (!canFinish.has(edge.to) || canFinish.has(edge.from))
476
+ if (!node.next.some((move) => canFinish.has(move.to)))
410
477
  continue;
411
- canFinish.add(edge.from);
478
+ canFinish.add(node.id);
412
479
  grew = true;
413
480
  }
414
481
  }
@@ -420,305 +487,93 @@ function validateWorkflow(raw, source) {
420
487
  }
421
488
  return { workflow: wf, errors, warnings };
422
489
  }
423
- function outgoingEdges(wf, nodeId) {
424
- return wf.edges.filter((edge) => edge.from === nodeId);
490
+ function movesFor(wf, nodeId) {
491
+ return wf.nodes.find((node) => node.id === nodeId)?.next ?? [];
425
492
  }
426
493
  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
- `);
494
+ const moves = movesFor(wf, nodeId);
495
+ return moves.length === 1 && moves[0]?.to === END;
595
496
  }
596
497
 
597
498
  // src/workflow/graph.ts
598
499
  function getNode(wf, id) {
599
500
  return wf.nodes.find((node) => node.id === id);
600
501
  }
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) {
502
+ function computeNext(wf, session) {
615
503
  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
- };
623
- }
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: [] };
504
+ return { done: true, current: session.current, currentTitle: null, next: [] };
629
505
  }
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) {
506
+ if (session.current === null) {
635
507
  const entry = getNode(wf, wf.start);
636
508
  return {
637
509
  done: false,
638
510
  current: null,
639
511
  currentTitle: null,
640
- scope,
641
512
  next: [
642
513
  {
643
514
  edge: null,
644
515
  node: wf.start,
645
516
  title: entry?.title ?? wf.start,
646
- description: entry?.description ?? ""
517
+ description: entry?.description ?? "",
518
+ reviewRequired: entry?.reviewRequired ?? false
647
519
  }
648
520
  ]
649
521
  };
650
522
  }
651
- const node = getNode(wf, top.nodeId);
652
- if (onlyEnd(wf, top.nodeId)) {
523
+ const node = getNode(wf, session.current);
524
+ if (onlyEnd(wf, session.current)) {
653
525
  return {
654
526
  done: true,
655
- current: top.nodeId,
527
+ current: session.current,
656
528
  currentTitle: node?.title ?? null,
657
- scope,
658
529
  next: []
659
530
  };
660
531
  }
661
- const next = outgoingEdges(wf, top.nodeId).map((edge) => {
662
- if (edge.to === END) {
532
+ const current = session.current;
533
+ const next = movesFor(wf, current).map((move) => {
534
+ const edge = `${current}->${move.to}`;
535
+ if (move.to === END) {
663
536
  return {
664
- edge: edge.id,
537
+ edge,
665
538
  node: END,
666
539
  title: "finish",
667
- description: edge.description,
668
- ...edge.condition ? { condition: edge.condition } : {}
540
+ description: move.description,
541
+ ...move.condition ? { condition: move.condition } : {},
542
+ reviewRequired: false
669
543
  };
670
544
  }
671
- const target = getNode(wf, edge.to);
545
+ const target = getNode(wf, move.to);
672
546
  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 } : {}
547
+ edge,
548
+ node: move.to,
549
+ title: target?.title ?? move.to,
550
+ description: move.description,
551
+ ...move.condition ? { condition: move.condition } : {},
552
+ reviewRequired: target?.reviewRequired ?? false
678
553
  };
679
554
  });
680
555
  return {
681
556
  done: false,
682
- current: top.nodeId,
557
+ current,
683
558
  currentTitle: node?.title ?? null,
684
- scope,
685
559
  next
686
560
  };
687
561
  }
688
562
 
689
563
  // src/runs/service.ts
690
564
  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 ?? "" }];
696
- }
697
- return outgoingEdges(wf, current).map((edge) => ({
698
- edge: edge.id,
699
- node: edge.to,
700
- description: edge.description
701
- }));
702
- }
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) });
705
- }
706
- function keyNotFound(key) {
707
- return new AppError("key_not_found", `session "${key}" does not exist`, 3);
708
- }
709
565
 
710
566
  class SessionService {
711
- registry;
567
+ workflow;
712
568
  store;
713
- constructor(registry, store) {
714
- this.registry = registry;
569
+ constructor(workflow, store) {
570
+ this.workflow = workflow;
715
571
  this.store = store;
716
572
  }
717
573
  get statePath() {
718
574
  return this.store.path;
719
575
  }
720
- create(input) {
721
- const workflow = requireWorkflow(this.registry, input.workflowId);
576
+ create(input = {}) {
722
577
  const key = (input.key ?? "").trim() || crypto.randomUUID();
723
578
  if (!KEY_PATTERN.test(key)) {
724
579
  throw new AppError("key_invalid", `key "${key}" is invalid; use 1-128 characters from A-Z a-z 0-9 . _ -`, 2);
@@ -726,10 +581,9 @@ class SessionService {
726
581
  const now = new Date().toISOString();
727
582
  const session = {
728
583
  key,
729
- workflowId: workflow.id,
584
+ workflowId: this.workflow.id,
730
585
  status: "running",
731
586
  current: null,
732
- stack: [{ workflowId: workflow.id, nodeId: null }],
733
587
  version: 0,
734
588
  createdAt: now,
735
589
  updatedAt: now
@@ -739,13 +593,14 @@ class SessionService {
739
593
  }
740
594
  return {
741
595
  session,
742
- workflowTitle: workflow.title,
743
- next: computeNext(this.registry, session)
596
+ workflowTitle: this.workflow.title,
597
+ next: computeNext(this.workflow, session)
744
598
  };
745
599
  }
746
600
  log(key, node) {
747
601
  return this.store.transaction(() => {
748
602
  const session = this.requireSession(key);
603
+ const wf = this.workflow;
749
604
  if (session.status === "done") {
750
605
  throw new AppError("invalid_transition", `session "${key}" is already done`, 4, {
751
606
  current: END,
@@ -753,82 +608,48 @@ class SessionService {
753
608
  legal: []
754
609
  });
755
610
  }
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
611
  const now = new Date().toISOString();
764
- const stack = frames.map((frame) => ({ ...frame }));
612
+ const from = session.current;
765
613
  let stepInput;
766
- let current;
767
- let endsRun = false;
768
614
  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);
615
+ if (from === null || !movesFor(wf, from).some((move) => move.to === END)) {
616
+ throw this.invalidTransition(from, node);
617
+ }
774
618
  stepInput = {
775
619
  key,
776
620
  node: END,
777
- from: top.nodeId,
778
- edge: edge.id,
779
- workflowId: top.workflowId,
780
- scope,
621
+ from,
622
+ edge: `${from}->${END}`,
623
+ workflowId: wf.id,
781
624
  at: now
782
625
  };
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
- }
626
+ session.current = END;
627
+ session.status = "done";
790
628
  } else {
791
629
  const target = getNode(wf, node);
792
630
  if (!target) {
793
- throw new AppError("unknown_node", `node "${node}" does not exist in workflow "${wf.id}"`, 4, { legal: legalOptions(wf, top.nodeId) });
631
+ throw new AppError("unknown_node", `node "${node}" does not exist in workflow "${wf.id}"`, 4, { legal: this.legalOptions(from) });
794
632
  }
795
633
  let edge = null;
796
- if (top.nodeId === null) {
634
+ if (from === null) {
797
635
  if (node !== wf.start)
798
- throw invalidTransition(wf, top.nodeId, node, scope);
636
+ throw this.invalidTransition(from, node);
799
637
  } 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 });
638
+ if (!movesFor(wf, from).some((move) => move.to === node)) {
639
+ throw this.invalidTransition(from, node);
811
640
  }
812
- const child = requireWorkflow(this.registry, target.workflow);
813
- stack.push({ workflowId: child.id, nodeId: null });
641
+ edge = `${from}->${node}`;
642
+ }
643
+ stepInput = { key, node, from, edge, workflowId: wf.id, at: now };
644
+ session.current = node;
645
+ if (onlyEnd(wf, node)) {
646
+ session.current = END;
647
+ session.status = "done";
814
648
  }
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
649
  }
826
- session.stack = stack;
827
- session.current = current;
828
- session.status = endsRun ? "done" : "running";
829
650
  session.version += 1;
830
651
  session.updatedAt = now;
831
- const next = this.settle(session);
652
+ const next = computeNext(wf, session);
832
653
  const step = this.store.appendStep(stepInput);
833
654
  this.store.updateSession(session);
834
655
  return { session, step, next };
@@ -836,40 +657,44 @@ class SessionService {
836
657
  }
837
658
  next(key) {
838
659
  const session = this.requireSession(key);
839
- return { session, next: computeNext(this.registry, session) };
660
+ return { session, next: computeNext(this.workflow, session) };
840
661
  }
841
662
  steps(key, query = {}) {
842
663
  const session = this.requireSession(key);
843
664
  return { session, steps: this.store.listSteps(key, query) };
844
665
  }
845
- keys(workflowId) {
846
- if (workflowId)
847
- requireWorkflow(this.registry, workflowId);
848
- return this.store.listSessions(workflowId);
666
+ keys() {
667
+ return this.store.listSessions();
849
668
  }
850
669
  reset(key) {
851
670
  this.requireSession(key);
852
671
  return { key, deletedSteps: this.store.reset(key) };
853
672
  }
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);
673
+ legalOptions(from) {
674
+ if (from === null) {
675
+ const entry = getNode(this.workflow, this.workflow.start);
676
+ return [{ edge: null, node: this.workflow.start, description: entry?.description ?? "" }];
677
+ }
678
+ return movesFor(this.workflow, from).map((move) => ({
679
+ edge: `${from}->${move.to}`,
680
+ node: move.to,
681
+ description: move.description
682
+ }));
683
+ }
684
+ invalidTransition(from, node) {
685
+ 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
686
  }
862
687
  requireSession(key) {
863
688
  const session = this.store.getSession(key);
864
689
  if (!session)
865
- throw keyNotFound(key);
690
+ throw new AppError("key_not_found", `session "${key}" does not exist`, 3);
866
691
  return session;
867
692
  }
868
693
  }
869
694
 
870
695
  // src/runs/store.ts
871
- import { mkdirSync as mkdirSync2 } from "node:fs";
872
- import { dirname as dirname2 } from "node:path";
696
+ import { mkdirSync as mkdirSync2, unlinkSync } from "node:fs";
697
+ import { dirname as dirname3 } from "node:path";
873
698
 
874
699
  // src/runs/driver.ts
875
700
  import { createRequire as createRequire2 } from "node:module";
@@ -948,7 +773,6 @@ CREATE TABLE IF NOT EXISTS runs (
948
773
  workflow_id TEXT NOT NULL,
949
774
  status TEXT NOT NULL,
950
775
  current TEXT,
951
- stack TEXT NOT NULL,
952
776
  version INTEGER NOT NULL DEFAULT 0,
953
777
  created_at TEXT NOT NULL,
954
778
  updated_at TEXT NOT NULL
@@ -961,18 +785,17 @@ CREATE TABLE IF NOT EXISTS steps (
961
785
  from_node TEXT,
962
786
  edge TEXT,
963
787
  workflow_id TEXT NOT NULL,
964
- scope TEXT NOT NULL,
965
788
  at TEXT NOT NULL,
966
789
  PRIMARY KEY (key, seq)
967
790
  );
968
791
  `;
792
+ var LEGACY_COLUMNS = new Set(["stack", "scope"]);
969
793
  function toSession(row) {
970
794
  return {
971
795
  key: row.key,
972
796
  workflowId: row.workflow_id,
973
797
  status: row.status,
974
798
  current: row.current,
975
- stack: JSON.parse(row.stack),
976
799
  version: row.version,
977
800
  createdAt: row.created_at,
978
801
  updatedAt: row.updated_at
@@ -986,7 +809,6 @@ function toStep(row) {
986
809
  from: row.from_node,
987
810
  edge: row.edge,
988
811
  workflowId: row.workflow_id,
989
- scope: JSON.parse(row.scope),
990
812
  at: row.at
991
813
  };
992
814
  }
@@ -997,25 +819,42 @@ class SqliteRunStore {
997
819
  constructor(path) {
998
820
  this.path = path;
999
821
  if (path !== ":memory:") {
1000
- mkdirSync2(dirname2(path), { recursive: true });
822
+ mkdirSync2(dirname3(path), { recursive: true });
1001
823
  }
1002
824
  this.db = openDriver(path);
1003
- this.db.exec("PRAGMA foreign_keys = ON;");
1004
- this.db.exec("PRAGMA busy_timeout = 5000;");
1005
825
  if (path !== ":memory:") {
826
+ if (this.isOutdated())
827
+ this.recreate(path);
1006
828
  this.db.exec("PRAGMA journal_mode = WAL;");
1007
829
  }
830
+ this.db.exec("PRAGMA foreign_keys = ON;");
831
+ this.db.exec("PRAGMA busy_timeout = 5000;");
1008
832
  this.db.exec(SCHEMA);
1009
833
  }
834
+ isOutdated() {
835
+ const columns = [
836
+ ...this.db.all("PRAGMA table_info(runs)", []),
837
+ ...this.db.all("PRAGMA table_info(steps)", [])
838
+ ];
839
+ return columns.some((column) => LEGACY_COLUMNS.has(column.name));
840
+ }
841
+ recreate(path) {
842
+ this.db.close();
843
+ for (const suffix of ["", "-wal", "-shm"]) {
844
+ try {
845
+ unlinkSync(`${path}${suffix}`);
846
+ } catch {}
847
+ }
848
+ this.db = openDriver(path);
849
+ }
1010
850
  createSession(session) {
1011
851
  return this.db.run(`INSERT OR IGNORE INTO runs
1012
- (key, workflow_id, status, current, stack, version, created_at, updated_at)
1013
- VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [
852
+ (key, workflow_id, status, current, version, created_at, updated_at)
853
+ VALUES (?, ?, ?, ?, ?, ?, ?)`, [
1014
854
  session.key,
1015
855
  session.workflowId,
1016
856
  session.status,
1017
857
  session.current,
1018
- JSON.stringify(session.stack),
1019
858
  session.version,
1020
859
  session.createdAt,
1021
860
  session.updatedAt
@@ -1027,32 +866,16 @@ class SqliteRunStore {
1027
866
  }
1028
867
  updateSession(session) {
1029
868
  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
- ]);
869
+ SET status = ?, current = ?, version = ?, updated_at = ?
870
+ WHERE key = ?`, [session.status, session.current, session.version, session.updatedAt, session.key]);
1039
871
  }
1040
872
  appendStep(step) {
1041
873
  const row = this.db.get("SELECT COALESCE(MAX(seq), 0) + 1 AS next FROM steps WHERE key = ?", [
1042
874
  step.key
1043
875
  ]);
1044
876
  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
- ]);
877
+ this.db.run(`INSERT INTO steps (key, seq, node, from_node, edge, workflow_id, at)
878
+ VALUES (?, ?, ?, ?, ?, ?, ?)`, [step.key, seq, step.node, step.from, step.edge, step.workflowId, step.at]);
1056
879
  return { ...step, seq };
1057
880
  }
1058
881
  listSteps(key, query = {}) {
@@ -1061,10 +884,8 @@ class SqliteRunStore {
1061
884
  const rows = this.db.all("SELECT * FROM steps WHERE key = ? AND seq > ? ORDER BY seq ASC LIMIT ?", [key, after, limit]);
1062
885
  return rows.map(toStep);
1063
886
  }
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", []);
887
+ listSessions() {
888
+ const rows = this.db.all("SELECT * FROM runs ORDER BY updated_at DESC", []);
1068
889
  return rows.map(toSession);
1069
890
  }
1070
891
  reset(key) {
@@ -1095,130 +916,49 @@ class AppError2 extends Error {
1095
916
  }
1096
917
 
1097
918
  // 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);
919
+ import { readFileSync as readFileSync2 } from "node:fs";
920
+ function loadWorkflowFile(path) {
921
+ let raw;
922
+ try {
923
+ raw = JSON.parse(readFileSync2(path, "utf8"));
924
+ } catch (error) {
925
+ const message = error instanceof Error ? error.message : String(error);
926
+ const missing = error.code === "ENOENT";
927
+ return {
928
+ path,
929
+ errors: [
930
+ missing ? `workflow file not found: ${path} (run "next-steps workflow new")` : `${path}: invalid JSON (${message})`
931
+ ],
932
+ warnings: []
933
+ };
1188
934
  }
1189
- return {
1190
- dir,
1191
- registry,
1192
- reports,
1193
- errors: reports.flatMap((report) => report.errors),
1194
- warnings: reports.flatMap((report) => report.warnings)
1195
- };
935
+ const check = validateWorkflow(raw, path);
936
+ return { path, workflow: check.workflow, errors: check.errors, warnings: check.warnings };
1196
937
  }
1197
938
 
1198
939
  // src/cli.ts
1199
- var VERSION = "0.1.0";
940
+ var VERSION = "0.1.1";
1200
941
  var HELP = `next-steps \u2014 record the current step, ask what comes next
1201
942
 
1202
943
  usage:
1203
- next-steps create --workflow <id> [--key <key>] [--json]
944
+ next-steps create [--key <key>] [--json]
1204
945
  next-steps log --key <key> --node <node> [--json]
1205
946
  next-steps next --key <key> [--json]
1206
947
  next-steps steps --key <key> [--limit <n>] [--after <seq>] [--json]
1207
- next-steps keys [--workflow <id>] [--json]
948
+ next-steps keys [--json]
1208
949
  next-steps reset --key <key> [--json]
1209
950
 
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]
951
+ next-steps workflow new [--json]
952
+ next-steps workflow show [--json]
953
+ next-steps workflow check [--json]
1214
954
 
1215
- next-steps viz [--port <n>] [--json] (serve a graph viewer on a local port)
955
+ next-steps viz [--port <n>] [--json] (serve the graph viewer on a local port)
1216
956
 
1217
957
  next-steps help
1218
958
  next-steps version
1219
959
 
1220
- state: ./.next-steps/state.db (created on first use, gitignored)
1221
- graphs: ./workflows/*.json
960
+ state: ./.next-steps/state.db (created on first use, gitignored)
961
+ graph: ./.next-steps/workflow.json (one graph per project, committed)
1222
962
 
1223
963
  exit codes:
1224
964
  0 ok 2 usage 3 not found 4 invalid transition 5 invalid workflow`;
@@ -1232,19 +972,25 @@ function parseOptions(args, options, allowPositionals = false) {
1232
972
  function asString(value) {
1233
973
  return typeof value === "string" ? value : undefined;
1234
974
  }
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, {
975
+ function requireGraphFile(graphPath) {
976
+ if (existsSync3(graphPath))
977
+ return;
978
+ throw new AppError2("workflow_not_found", `workflow file not found: ${graphPath} (run "next-steps workflow new")`, 3);
979
+ }
980
+ function requireWorkflow(graphPath) {
981
+ requireGraphFile(graphPath);
982
+ const load = loadWorkflowFile(graphPath);
983
+ if (load.errors.length > 0 || !load.workflow) {
984
+ throw new AppError2("invalid_workflow", "workflow file is invalid", 5, {
1239
985
  errors: load.errors
1240
986
  });
1241
987
  }
1242
- return load.registry;
988
+ return load.workflow;
1243
989
  }
1244
990
  function execute(argv, options) {
1245
991
  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");
992
+ const graphPath = options.graphPath ?? join2(cwd, ".next-steps", "workflow.json");
993
+ const dbPath = options.dbPath ?? join2(cwd, ".next-steps", "state.db");
1248
994
  const [command, ...rest] = argv;
1249
995
  if (!command || command === "help" || command === "--help" || command === "-h") {
1250
996
  return HELP;
@@ -1254,21 +1000,21 @@ function execute(argv, options) {
1254
1000
  }
1255
1001
  if (command === "workflow") {
1256
1002
  const { positionals, values } = parseOptions(rest, { json: { type: "boolean" } }, true);
1257
- const [subcommand, id] = positionals;
1003
+ const [subcommand] = positionals;
1258
1004
  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> ...");
1005
+ if (subcommand === "new") {
1006
+ if (positionals.length > 1)
1007
+ throw usageError2(`unexpected argument "${positionals[1]}"`);
1008
+ return cmdWorkflowNew(graphPath, basename(cwd), json);
1009
+ }
1010
+ if (subcommand !== "show" && subcommand !== "check") {
1011
+ throw usageError2("usage: next-steps workflow <new|show|check> [--json]");
1271
1012
  }
1013
+ if (positionals.length > 1)
1014
+ throw usageError2(`unexpected argument "${positionals[1]}"`);
1015
+ requireGraphFile(graphPath);
1016
+ const load = loadWorkflowFile(graphPath);
1017
+ return subcommand === "show" ? cmdWorkflowShow(load, json) : cmdWorkflowCheck(load, json);
1272
1018
  }
1273
1019
  if (command === "viz") {
1274
1020
  const { values } = parseOptions(rest, { port: { type: "string" }, json: { type: "boolean" } });
@@ -1276,27 +1022,21 @@ function execute(argv, options) {
1276
1022
  if (port < 1 || port > 65535) {
1277
1023
  throw usageError2("--port must be between 1 and 65535");
1278
1024
  }
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}
1025
+ const result = startVizServer(requireWorkflow(graphPath), port);
1026
+ return values.json ? toJson2({ url: result.url, workflow: result.workflow }) : `viewer: ${result.url}
1285
1027
  press Ctrl+C to stop`;
1286
1028
  }
1287
- const registry = requireRegistry(workflowsDir);
1029
+ const workflow = requireWorkflow(graphPath);
1288
1030
  const store = new SqliteRunStore(dbPath);
1289
1031
  try {
1290
- const service = new SessionService(registry, store);
1032
+ const service = new SessionService(workflow, store);
1291
1033
  switch (command) {
1292
1034
  case "create": {
1293
1035
  const { values } = parseOptions(rest, {
1294
- workflow: { type: "string" },
1295
1036
  key: { type: "string" },
1296
1037
  json: { type: "boolean" }
1297
1038
  });
1298
1039
  return cmdCreate(service, {
1299
- workflowId: asString(values.workflow),
1300
1040
  key: asString(values.key),
1301
1041
  json: Boolean(values.json)
1302
1042
  });
@@ -1338,14 +1078,8 @@ press Ctrl+C to stop`;
1338
1078
  });
1339
1079
  }
1340
1080
  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
- });
1081
+ const { values } = parseOptions(rest, { json: { type: "boolean" } });
1082
+ return cmdKeys(service, { json: Boolean(values.json) });
1349
1083
  }
1350
1084
  case "reset": {
1351
1085
  const { values } = parseOptions(rest, {