@wanbicoi/next-steps 0.1.1 → 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/README.md CHANGED
@@ -92,6 +92,7 @@ next-steps next --key <key> [--json]
92
92
  next-steps steps --key <key> [--limit <n>] [--after <seq>] [--json]
93
93
  next-steps keys [--json]
94
94
  next-steps reset --key <key> [--json]
95
+ next-steps agents [--json]
95
96
 
96
97
  next-steps workflow new [--json]
97
98
  next-steps workflow show [--json]
@@ -106,6 +107,68 @@ documented in `docs/AGENTS.sample.md`.
106
107
  `create` only opens a session (the key is the task id; a UUID is generated when
107
108
  omitted). It does not author the graph — use `workflow new` for that.
108
109
 
110
+ `agents` prints `docs/AGENTS.sample.md`, the playbook an agent reads to drive
111
+ this CLI from another repo. Copy the output into that repo's `AGENTS.md`:
112
+
113
+ ```sh
114
+ next-steps agents > /tmp/agents.md
115
+ ```
116
+
117
+ ## Hooks
118
+
119
+ If `.next-steps/hook` exists and is executable (`chmod +x`), every successful
120
+ `log` runs it after the state is written. Use it to mirror progress into a
121
+ status bar, window title, or agent sidebar. Any language works; a Python hook:
122
+
123
+ ```python
124
+ #!/usr/bin/env python3
125
+ # .next-steps/hook — mirror the state into a status file
126
+ import json
127
+ import sys
128
+
129
+ payload = json.load(sys.stdin)
130
+
131
+ if payload["status"] == "done":
132
+ label = "done"
133
+ elif payload["status"] == "review":
134
+ label = ", ".join(n["title"] for n in payload["next"] if n["reviewRequired"])
135
+ else:
136
+ label = " | ".join(n["title"] for n in payload["next"]) or payload["last"]["title"]
137
+
138
+ with open("/tmp/next-steps-status", "w") as out:
139
+ out.write(f'{payload["key"]} {payload["status"]} {label}\n')
140
+ ```
141
+
142
+ Replace the file write with a call to your status tool (a tmux status line, a
143
+ Herdr sidebar, a file watcher). The hook runs from the project root and inherits
144
+ the normal environment; its stdout and stderr are discarded, so report through
145
+ the tool it drives.
146
+
147
+ The payload on stdin is a status snapshot, not the full event:
148
+
149
+ ```json
150
+ {
151
+ "event": "log",
152
+ "key": "4f1c...",
153
+ "status": "review",
154
+ "last": { "id": "read-issue", "title": "Read the issue" },
155
+ "next": [{ "id": "implement", "title": "Implement", "reviewRequired": true }]
156
+ }
157
+ ```
158
+
159
+ `status` is derived: `done` when the run finished, `review` when any legal next
160
+ node has `reviewRequired`, otherwise `running`. `last` is the node just logged;
161
+ `next` is what the agent may do now. For anything richer (edges, descriptions,
162
+ conditions, history), call `next-steps next --key <key> --json` — the payload
163
+ carries the `key`.
164
+
165
+ Hooks run synchronously with a five-second timeout. A missing hook is silent; a
166
+ hook that fails, times out, or lacks the execute bit prints a warning to stderr
167
+ and never changes the log's exit code. Only `log` triggers the hook, so a fresh
168
+ session stays quiet until its first node is finished. Commit `.next-steps/hook`
169
+ when the whole team should share the behaviour; leave it untracked for a local
170
+ adapter.
171
+
109
172
  ## Workflow format
110
173
 
111
174
  ```json
package/dist/cli.js CHANGED
@@ -2,11 +2,16 @@
2
2
  // @bun
3
3
 
4
4
  // src/cli.ts
5
- import { existsSync as existsSync3 } from "fs";
6
- import { basename, join as join2 } from "path";
5
+ import { existsSync as existsSync5 } from "fs";
6
+ import { basename, dirname as dirname5, join as join3 } from "path";
7
7
  import { pathToFileURL } from "url";
8
8
  import { parseArgs } from "util";
9
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
+
10
15
  // src/types.ts
11
16
  var END = "END";
12
17
 
@@ -23,6 +28,40 @@ class AppError extends Error {
23
28
  }
24
29
  }
25
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
+
26
65
  // src/format.ts
27
66
  function table(headers, rows) {
28
67
  const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => (row[index] ?? "").length)));
@@ -63,20 +102,6 @@ function formatCurrent(next) {
63
102
  return `current: ${next.current}${title}`;
64
103
  }
65
104
 
66
- // src/commands/shared.ts
67
- function usageError(message) {
68
- return new AppError("usage", message, 2);
69
- }
70
- function requireString(value, flag) {
71
- if (typeof value !== "string" || value.length === 0) {
72
- throw usageError(`--${flag} is required`);
73
- }
74
- return value;
75
- }
76
- function toJson(value) {
77
- return JSON.stringify(value, null, 2);
78
- }
79
-
80
105
  // src/commands/session.ts
81
106
  function cmdCreate(service, input) {
82
107
  const { session, workflowTitle, next } = service.create({ key: input.key });
@@ -101,10 +126,12 @@ function cmdCreate(service, input) {
101
126
  ].join(`
102
127
  `);
103
128
  }
104
- function cmdLog(service, input) {
129
+ function cmdLog(service, input, onLogged) {
105
130
  const key = requireString(input.key, "key");
106
131
  const node = requireString(input.node, "node");
107
- const { session, step, next } = service.log(key, node);
132
+ const result = service.log(key, node);
133
+ const { session, step, next } = result;
134
+ onLogged?.(result);
108
135
  if (input.json) {
109
136
  return toJson({
110
137
  step,
@@ -213,19 +240,19 @@ function toJson2(value) {
213
240
  }
214
241
 
215
242
  // src/commands/viz.ts
216
- import { existsSync, readFileSync } from "node:fs";
243
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
217
244
  import { createServer } from "node:http";
218
245
  import { createRequire } from "node:module";
219
- import { dirname, join } from "node:path";
220
- import { fileURLToPath } from "node:url";
246
+ import { dirname as dirname2, join as join2 } from "node:path";
247
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
221
248
  var require2 = createRequire(import.meta.url);
222
249
  function findTemplate() {
223
- let dir = dirname(fileURLToPath(import.meta.url));
250
+ let dir = dirname2(fileURLToPath2(import.meta.url));
224
251
  for (let depth = 0;depth < 6; depth++) {
225
- const candidate = join(dir, "viz", "template.html");
226
- if (existsSync(candidate))
252
+ const candidate = join2(dir, "viz", "template.html");
253
+ if (existsSync2(candidate))
227
254
  return candidate;
228
- const parent = dirname(dir);
255
+ const parent = dirname2(dir);
229
256
  if (parent === dir)
230
257
  break;
231
258
  dir = parent;
@@ -237,9 +264,9 @@ function renderVizHtml(template, workflow) {
237
264
  return template.replace("__WORKFLOW_DATA__", data);
238
265
  }
239
266
  function startVizServer(workflow, port) {
240
- const template = readFileSync(findTemplate(), "utf8");
241
- const cytoscapeJs = readFileSync(require2.resolve("cytoscape/dist/cytoscape.min.js"), "utf8");
242
- const dagreJs = readFileSync(require2.resolve("cytoscape-dagre"), "utf8");
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");
243
270
  const html = renderVizHtml(template, workflow);
244
271
  const server = createServer((request, response) => {
245
272
  const path = (request.url ?? "/").split("?")[0];
@@ -270,8 +297,8 @@ function startVizServer(workflow, port) {
270
297
  }
271
298
 
272
299
  // src/commands/workflow.ts
273
- import { existsSync as existsSync2, mkdirSync, writeFileSync } from "node:fs";
274
- import { dirname as dirname2 } from "node:path";
300
+ import { existsSync as existsSync3, mkdirSync, writeFileSync } from "node:fs";
301
+ import { dirname as dirname3 } from "node:path";
275
302
  var ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
276
303
  function skeleton(id) {
277
304
  return {
@@ -289,11 +316,11 @@ function skeleton(id) {
289
316
  };
290
317
  }
291
318
  function cmdWorkflowNew(graphPath, id, json) {
292
- if (existsSync2(graphPath)) {
319
+ if (existsSync3(graphPath)) {
293
320
  throw usageError(`workflow file already exists: ${graphPath}`);
294
321
  }
295
322
  const workflowId = ID_PATTERN.test(id) ? id : "workflow";
296
- mkdirSync(dirname2(graphPath), { recursive: true });
323
+ mkdirSync(dirname3(graphPath), { recursive: true });
297
324
  writeFileSync(graphPath, `${JSON.stringify(skeleton(workflowId), null, 2)}
298
325
  `, "utf8");
299
326
  if (json)
@@ -355,6 +382,10 @@ function cmdWorkflowCheck(load, json) {
355
382
  `);
356
383
  }
357
384
 
385
+ // src/runs/hook.ts
386
+ import { spawnSync } from "node:child_process";
387
+ import { existsSync as existsSync4, statSync } from "node:fs";
388
+
358
389
  // src/workflow/validate.ts
359
390
  import { z } from "zod";
360
391
  var moveSchema = z.object({
@@ -560,6 +591,58 @@ function computeNext(wf, session) {
560
591
  };
561
592
  }
562
593
 
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;
601
+ }
602
+ }
603
+ function warn(message) {
604
+ process.stderr.write(`hook: ${message}
605
+ `);
606
+ }
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
+ }
644
+ }
645
+
563
646
  // src/runs/service.ts
564
647
  var KEY_PATTERN = /^[A-Za-z0-9._-]{1,128}$/;
565
648
 
@@ -694,7 +777,7 @@ class SessionService {
694
777
 
695
778
  // src/runs/store.ts
696
779
  import { mkdirSync as mkdirSync2, unlinkSync } from "node:fs";
697
- import { dirname as dirname3 } from "node:path";
780
+ import { dirname as dirname4 } from "node:path";
698
781
 
699
782
  // src/runs/driver.ts
700
783
  import { createRequire as createRequire2 } from "node:module";
@@ -819,7 +902,7 @@ class SqliteRunStore {
819
902
  constructor(path) {
820
903
  this.path = path;
821
904
  if (path !== ":memory:") {
822
- mkdirSync2(dirname3(path), { recursive: true });
905
+ mkdirSync2(dirname4(path), { recursive: true });
823
906
  }
824
907
  this.db = openDriver(path);
825
908
  if (path !== ":memory:") {
@@ -916,11 +999,11 @@ class AppError2 extends Error {
916
999
  }
917
1000
 
918
1001
  // src/workflow/load.ts
919
- import { readFileSync as readFileSync2 } from "node:fs";
1002
+ import { readFileSync as readFileSync3 } from "node:fs";
920
1003
  function loadWorkflowFile(path) {
921
1004
  let raw;
922
1005
  try {
923
- raw = JSON.parse(readFileSync2(path, "utf8"));
1006
+ raw = JSON.parse(readFileSync3(path, "utf8"));
924
1007
  } catch (error) {
925
1008
  const message = error instanceof Error ? error.message : String(error);
926
1009
  const missing = error.code === "ENOENT";
@@ -947,6 +1030,7 @@ usage:
947
1030
  next-steps steps --key <key> [--limit <n>] [--after <seq>] [--json]
948
1031
  next-steps keys [--json]
949
1032
  next-steps reset --key <key> [--json]
1033
+ next-steps agents [--json] (print the agent playbook)
950
1034
 
951
1035
  next-steps workflow new [--json]
952
1036
  next-steps workflow show [--json]
@@ -959,6 +1043,7 @@ usage:
959
1043
 
960
1044
  state: ./.next-steps/state.db (created on first use, gitignored)
961
1045
  graph: ./.next-steps/workflow.json (one graph per project, committed)
1046
+ hook: ./.next-steps/hook (optional, runs after each log)
962
1047
 
963
1048
  exit codes:
964
1049
  0 ok 2 usage 3 not found 4 invalid transition 5 invalid workflow`;
@@ -973,7 +1058,7 @@ function asString(value) {
973
1058
  return typeof value === "string" ? value : undefined;
974
1059
  }
975
1060
  function requireGraphFile(graphPath) {
976
- if (existsSync3(graphPath))
1061
+ if (existsSync5(graphPath))
977
1062
  return;
978
1063
  throw new AppError2("workflow_not_found", `workflow file not found: ${graphPath} (run "next-steps workflow new")`, 3);
979
1064
  }
@@ -989,8 +1074,8 @@ function requireWorkflow(graphPath) {
989
1074
  }
990
1075
  function execute(argv, options) {
991
1076
  const cwd = options.cwd ?? process.cwd();
992
- const graphPath = options.graphPath ?? join2(cwd, ".next-steps", "workflow.json");
993
- const dbPath = options.dbPath ?? join2(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");
994
1079
  const [command, ...rest] = argv;
995
1080
  if (!command || command === "help" || command === "--help" || command === "-h") {
996
1081
  return HELP;
@@ -998,6 +1083,10 @@ function execute(argv, options) {
998
1083
  if (command === "version" || command === "--version" || command === "-v") {
999
1084
  return `next-steps ${VERSION}`;
1000
1085
  }
1086
+ if (command === "agents") {
1087
+ const { values } = parseOptions(rest, { json: { type: "boolean" } });
1088
+ return cmdAgents(Boolean(values.json));
1089
+ }
1001
1090
  if (command === "workflow") {
1002
1091
  const { positionals, values } = parseOptions(rest, { json: { type: "boolean" } }, true);
1003
1092
  const [subcommand] = positionals;
@@ -1051,7 +1140,7 @@ press Ctrl+C to stop`;
1051
1140
  key: asString(values.key),
1052
1141
  node: asString(values.node),
1053
1142
  json: Boolean(values.json)
1054
- });
1143
+ }, (result) => runPostLogHook({ path: join3(dirname5(graphPath), "hook"), cwd, workflow }, result));
1055
1144
  }
1056
1145
  case "next": {
1057
1146
  const { values } = parseOptions(rest, {
@@ -0,0 +1,120 @@
1
+ <!-- Template: copy this to AGENTS.md in a repo that uses next-steps on PATH. -->
2
+
3
+ # Work the graph with next-steps
4
+
5
+ This repo defines its work as one graph in `.next-steps/workflow.json`.
6
+ `next-steps` walks that graph one node at a time: it tells you which nodes are
7
+ legal now, and records the path you take.
8
+
9
+ Pass `--json` to every command.
10
+
11
+ ## Walk
12
+
13
+ 1. **Open a session.**
14
+
15
+ ```sh
16
+ next-steps create --json
17
+ ```
18
+
19
+ The response holds `key` (your task id) and the first `next` list.
20
+
21
+ **Done when:** you have `key` in hand and a non-empty `next` list.
22
+
23
+ 2. **Choose one next node.**
24
+
25
+ Each entry has `node`, `edge`, `title`, `description`, `reviewRequired`, and
26
+ sometimes `condition`. The `condition` is prose telling you when that branch
27
+ applies; you judge it against the task, nothing checks it for you. `edge` is
28
+ derived from `from->to` and is what the log will record.
29
+
30
+ ```json
31
+ {
32
+ "current": { "node": "verify", "title": "Verify" },
33
+ "next": [
34
+ { "edge": "verify->implement", "node": "implement", "title": "Implement",
35
+ "description": "Loop back and fix the failure.",
36
+ "condition": "the build or the tests failed",
37
+ "reviewRequired": true },
38
+ { "edge": "verify->review", "node": "review", "title": "Peer review",
39
+ "description": "Hand the change to review.",
40
+ "condition": "the build and the tests pass",
41
+ "reviewRequired": false }
42
+ ],
43
+ "done": false
44
+ }
45
+ ```
46
+
47
+ **Done when:** exactly one entry is chosen.
48
+
49
+ 3. **If that entry has `reviewRequired: true`, stop and ask.**
50
+
51
+ Prepare everything the node needs — the plan, the diff, the command you are
52
+ about to run — show it to the user, and wait for an explicit yes. Then act.
53
+
54
+ **Done when:** the user said yes. The CLI only reports the flag — nothing
55
+ enforces it. A node without the flag: skip this step.
56
+
57
+ 4. **Do that node's work.**
58
+
59
+ **Done when:** the node's `description` is satisfied — not when you started
60
+ it.
61
+
62
+ 5. **Log the node.**
63
+
64
+ ```sh
65
+ next-steps log --key <key> --node <node> --json
66
+ ```
67
+
68
+ One log per visit, after the work. Log is the only call per node; the
69
+ response already carries the new `next` list, so continue straight from it.
70
+
71
+ **Done when:** exit `0` and the response contains the new `next` list.
72
+
73
+ 6. **Repeat 2–5 until the response says `done: true`.**
74
+
75
+ **Done when:** `done` is true. Stop there; a written summary is its own node
76
+ if the graph wants one.
77
+
78
+ `next-steps next --key <key> --json` returns the same list on demand. Use it when
79
+ you resume rather than during the walk.
80
+
81
+ ## Branches
82
+
83
+ - **`END` is a legal target.** Log it like any node; the session becomes
84
+ `done`. The `next` list also labels it `finish`.
85
+ - **A node whose only move is `END`** finishes the run as soon as you log it.
86
+ The response is already `done: true`; there is no extra step.
87
+ - **Exit `4`** means the move is not legal. The error's `details.legal` is the
88
+ complete list of nodes you can move to; choose from it, or correct the work and
89
+ log the node the graph expects.
90
+ - **Exit `3`** means the key is unknown or `.next-steps/workflow.json` is
91
+ missing — run `next-steps workflow new` if the graph has never been created. A
92
+ key belongs to one task: create a fresh session for a new task.
93
+
94
+ ## Resume
95
+
96
+ A new agent, or the same one after a context reset, rebuilds the task from the
97
+ tool:
98
+
99
+ ```sh
100
+ next-steps steps --key <key> --json # audit trail: seq, node, from, edge, at
101
+ next-steps next --key <key> --json # legal moves now
102
+ ```
103
+
104
+ The `steps` record is the source of truth for what already happened.
105
+
106
+ ## Housekeeping
107
+
108
+ ```sh
109
+ next-steps keys --json # sessions on this machine
110
+ next-steps reset --key <key> --json # delete a finished or abandoned session
111
+ ```
112
+
113
+ `.next-steps/state.db` is local state and is gitignored. The graph,
114
+ `.next-steps/workflow.json`, is source code: commit it with the rest of the repo.
115
+
116
+ ## Exit codes
117
+
118
+ `0` ok · `2` usage, duplicate key, or invalid key · `3` not found ·
119
+ `4` invalid move or unknown node · `5` invalid workflow file. Errors go to
120
+ stderr as `{ "error": { "code", "message", "details" } }`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wanbicoi/next-steps",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Agent-facing workflow walker: log a step, list the next ones.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -10,6 +10,7 @@
10
10
  "files": [
11
11
  "dist",
12
12
  "viz/template.html",
13
+ "docs/AGENTS.sample.md",
13
14
  "README.md",
14
15
  "LICENSE"
15
16
  ],