slopflow 0.1.0 → 0.2.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.
Files changed (3) hide show
  1. package/README.md +140 -2
  2. package/dist/cli.js +678 -33
  3. package/package.json +2 -2
package/README.md CHANGED
@@ -10,6 +10,9 @@ The first vertical slice provides:
10
10
  slopflow init
11
11
  slopflow status
12
12
  slopflow start <issue-id>
13
+ slopflow pause <issue-id> --reason <text>
14
+ slopflow resume <issue-id>
15
+ slopflow cancel <issue-id> --reason <text>
13
16
  slopflow test <issue-id> --name <gate> -- <command...>
14
17
  slopflow review <issue-id>
15
18
  slopflow complete <issue-id>
@@ -19,12 +22,121 @@ slopflow complete <issue-id>
19
22
 
20
23
  Slopflow requires Node.js 24 or newer.
21
24
 
25
+ ## CLI output contract
26
+
27
+ Slopflow's default command output is an agent-facing, TOON-like key-block format: a named block followed by compact `key: value` fields and a concrete `next-step` or `help[...]` hint when useful.
28
+
29
+ Example success output:
30
+
31
+ ```text
32
+ status:
33
+ state: initialized
34
+ repo: owner/name
35
+ issue_tracker: github
36
+ vcs: jj
37
+ artifact-root: .slopflow/work
38
+ next-step: slopflow start <issue-id>
39
+ ```
40
+
41
+ Example structured error output:
42
+
43
+ ```text
44
+ error:
45
+ status: blocked
46
+ message: <short reason>
47
+ hint: <optional next action>
48
+ ```
49
+
50
+ Canonical Slopflow status, gate, and error output is written to stdout so agents can parse a single structured stream. Stderr is reserved for debug output and wrapped-command logs; `slopflow test` captures wrapped command stdout/stderr in evidence logs under `.slopflow/work/<issue-id>/evidence/logs/`.
51
+
52
+ Use `--json` only when scripts or integrations need machine JSON output. The default remains compact key-block output for agents. Initial JSON modes are:
53
+
54
+ ```bash
55
+ slopflow status --json
56
+ slopflow doctor --json
57
+ ```
58
+
59
+ Errors with `--json` return structured JSON error objects:
60
+
61
+ ```json
62
+ {
63
+ "error": {
64
+ "status": "blocked",
65
+ "message": "Slopflow machine config is missing.",
66
+ "hint": "Run `slopflow init` first."
67
+ }
68
+ }
69
+ ```
70
+
71
+ ### Doctor output and severity
72
+
73
+ `slopflow doctor` is a read-only setup diagnostic. It uses the same compact key-block format and reports a top-level status plus grouped severities:
74
+
75
+ ```text
76
+ doctor:
77
+ status: warn
78
+ core: passed
79
+ project-docs: passed
80
+ recommended: warn
81
+ failed-count: 0
82
+ warning-count: 1
83
+ next-step: run npx -y gh-axi --help when GitHub AXI operations are needed
84
+ checks[...]:
85
+ core.node: passed node v26.1.0 satisfies >=24
86
+ core.jj: passed jj executable found
87
+ recommended.gh-axi: warn unchecked; run npx -y gh-axi --help when GitHub AXI operations are needed
88
+ ```
89
+
90
+ Severity rules:
91
+
92
+ - `passed` — all core, project-doc, and recommended checks passed.
93
+ - `warn` — core checks passed, but at least one project-doc or recommended check is missing, optional, or unchecked.
94
+ - `failed` — at least one core readiness check failed; the command exits with code `2`.
95
+
96
+ Doctor detail strings are intentionally bounded and summarized so setup diagnostics stay agent-readable instead of dumping full command output.
97
+
22
98
  Initialize Slopflow machine config in a Jujutsu-backed GitHub repo:
23
99
 
24
100
  ```bash
25
101
  slopflow init
26
102
  ```
27
103
 
104
+ Preview minimal project-local setup without writing files:
105
+
106
+ ```bash
107
+ slopflow install minimal
108
+ ```
109
+
110
+ Apply minimal project-local setup after reviewing the plan:
111
+
112
+ ```bash
113
+ slopflow install minimal --yes
114
+ ```
115
+
116
+ `install minimal` only writes project-local Slopflow files such as `.slopflow/config.json` and `.slopflow/work/`. It does not mutate global agent configuration, install packages globally, push, publish, create PRs, close issues, or merge changes. Existing incompatible config blocks unless rerun with explicit `--force`.
117
+
118
+ Preview recommended project-local setup without writing files:
119
+
120
+ ```bash
121
+ slopflow install recommended
122
+ ```
123
+
124
+ Apply recommended project-local setup after reviewing the plan:
125
+
126
+ ```bash
127
+ slopflow install recommended --yes
128
+ ```
129
+
130
+ `install recommended` applies the minimal Slopflow setup and writes a project-local `.pi/slopflow-packages.json` manifest with suggested skill installation commands. It does not run those commands automatically and does not mutate global Claude, Pi, Cursor, or other agent harness configuration.
131
+
132
+ Validate repository-distributed Slopflow skills without modifying files:
133
+
134
+ ```bash
135
+ slopflow skill lint
136
+ ```
137
+
138
+ `skill lint` checks that portable skills avoid shell interpolation, live skills use read-only interpolation, Slopflow safety rules are present, and setup templates include OKF frontmatter where applicable.
139
+
28
140
  Inspect current Slopflow state:
29
141
 
30
142
  ```bash
@@ -49,6 +161,16 @@ next-steps.md
49
161
 
50
162
  It does not create placeholder evidence, review, or completion files.
51
163
 
164
+ Pause, resume, or cancel local issue work without running gates or mutating Jujutsu history:
165
+
166
+ ```bash
167
+ slopflow pause 2 --reason "waiting for external review"
168
+ slopflow resume 2
169
+ slopflow cancel 2 --reason "superseded by another issue"
170
+ ```
171
+
172
+ Lifecycle commands preserve the work directory and record local status only. They do not push, close issues, publish, delete evidence, or abandon Jujutsu changes.
173
+
52
174
  Capture command-based quality evidence for started issue work:
53
175
 
54
176
  ```bash
@@ -76,6 +198,20 @@ slopflow complete 2
76
198
 
77
199
  ## Agent skills
78
200
 
201
+ For a new project, install and run the setup skill before the Slopflow execution skill. It records the repository's issue tracker, triage labels, and domain-documentation layout so later engineering skills have the local context they expect:
202
+
203
+ ```bash
204
+ npx skills add aivv73/slopflow --skill setup-slopflow-skills
205
+ ```
206
+
207
+ If your agent runtime supports Claude-compatible skill interpolation, use the live setup variant instead:
208
+
209
+ ```bash
210
+ npx skills add aivv73/slopflow --skill setup-slopflow-skills-live
211
+ ```
212
+
213
+ The setup skills create OKF-compatible `docs/agents/*.md` concept documents and update the repo's `AGENTS.md` or `CLAUDE.md` instructions. They are adapted from Matt Pocock's engineering skills (https://github.com/mattpocock/skills/) for Slopflow onboarding. Run one of them first in a newly onboarded project, then initialize Slopflow and use the execution skill.
214
+
79
215
  Install the portable Slopflow skill:
80
216
 
81
217
  ```bash
@@ -88,13 +224,15 @@ Install the live-context Slopflow skill for Claude Code or Pi with `pi-skill-int
88
224
  npx skills add aivv73/slopflow --skill slopflow-live
89
225
  ```
90
226
 
91
- The portable skill does not execute shell commands during rendering. The live skill uses Claude-compatible read-only shell interpolation to inject current Slopflow and Jujutsu context.
227
+ The portable skills do not execute shell commands during rendering. The live skills use Claude-compatible read-only shell interpolation to inject setup or Slopflow context.
92
228
 
93
229
  Agent skills are installed separately through Vercel Skills. The Slopflow npm package distributes the CLI and does not install skills into Claude, Pi, Cursor, or other agent harness directories.
94
230
 
95
231
  ## Install
96
232
 
97
- Once Slopflow is published to npm, install the CLI globally:
233
+ Slopflow is published on npm: https://www.npmjs.com/package/slopflow
234
+
235
+ Install the CLI globally:
98
236
 
99
237
  ```bash
100
238
  npm install -g slopflow
package/dist/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { spawnSync } from "node:child_process";
3
- import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
3
+ import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
4
4
  import { readdir } from "node:fs/promises";
5
5
  import { dirname, isAbsolute, join, relative, resolve } from "node:path";
6
6
  import { fileURLToPath } from "node:url";
@@ -11,20 +11,39 @@ const REVIEW_DIFF_LIMIT = 50_000;
11
11
  class SlopflowError extends Error {
12
12
  hint;
13
13
  code;
14
- constructor(message, hint, code = 1) {
14
+ details;
15
+ constructor(message, hint, code = 1, details = {}) {
15
16
  super(message);
16
17
  this.hint = hint;
17
18
  this.code = code;
19
+ this.details = details;
18
20
  }
19
21
  }
20
22
  export async function main(argv = process.argv.slice(2)) {
23
+ const wantsJson = argv.includes("--json");
21
24
  try {
22
25
  const [command, ...args] = argv;
26
+ if (!command) {
27
+ return await homeCommand();
28
+ }
29
+ if (command === "--help" || command === "-h") {
30
+ printHelp();
31
+ return 0;
32
+ }
23
33
  if (command === "init") {
24
34
  return initCommand({ force: args.includes("--force") });
25
35
  }
26
36
  if (command === "status") {
27
- return await statusCommand();
37
+ return await statusCommand({ json: args.includes("--json") });
38
+ }
39
+ if (command === "doctor") {
40
+ return doctorCommand({ json: args.includes("--json") });
41
+ }
42
+ if (command === "install") {
43
+ return installCommand(args);
44
+ }
45
+ if (command === "skill") {
46
+ return skillCommand(args);
28
47
  }
29
48
  if (command === "start") {
30
49
  return startCommand(args[0]);
@@ -32,27 +51,406 @@ export async function main(argv = process.argv.slice(2)) {
32
51
  if (command === "test") {
33
52
  return testCommand(args);
34
53
  }
54
+ if (command === "pause") {
55
+ return lifecycleCommand("pause", args);
56
+ }
57
+ if (command === "resume") {
58
+ return lifecycleCommand("resume", args);
59
+ }
60
+ if (command === "cancel") {
61
+ return lifecycleCommand("cancel", args);
62
+ }
35
63
  if (command === "review") {
36
64
  return reviewCommand(args[0]);
37
65
  }
38
66
  if (command === "complete") {
39
67
  return completeCommand(args[0]);
40
68
  }
41
- printHelp();
42
- return 0;
69
+ throw new SlopflowError(`Unknown command: ${command}`, "Run `slopflow --help`.", 2);
43
70
  }
44
71
  catch (error) {
45
72
  if (error instanceof SlopflowError) {
46
- printBlock("error", {
73
+ const payload = {
47
74
  status: "blocked",
48
75
  message: error.message,
76
+ ...error.details,
49
77
  ...(error.hint ? { hint: error.hint } : {}),
50
- }, process.stderr);
78
+ };
79
+ if (wantsJson)
80
+ printJson({ error: payload });
81
+ else
82
+ printBlock("error", payload);
51
83
  return error.code;
52
84
  }
53
85
  throw error;
54
86
  }
55
87
  }
88
+ function doctorCommand({ json = false } = {}) {
89
+ const checks = [];
90
+ checks.push({ name: "core.slopflow", status: "passed", detail: "cli running" });
91
+ const root = findRepoRoot(process.cwd());
92
+ const nodeEngine = root ? readPackageNodeEngine(root) : null;
93
+ const nodeSatisfies = nodeEngine ? nodeVersionSatisfies(process.versions.node, nodeEngine) : true;
94
+ checks.push({
95
+ name: "core.node",
96
+ status: nodeSatisfies ? "passed" : "failed",
97
+ detail: nodeEngine ? `node v${process.versions.node} satisfies ${nodeEngine}` : `node v${process.versions.node}; package.json engines.node not found`,
98
+ });
99
+ const jjPresent = commandExists("jj");
100
+ checks.push({
101
+ name: "core.jj",
102
+ status: jjPresent ? "passed" : "failed",
103
+ detail: jjPresent ? "jj executable found" : "jj executable missing",
104
+ });
105
+ checks.push({
106
+ name: "core.repository",
107
+ status: root ? "passed" : "failed",
108
+ detail: root ? `root ${relativeToCwd(root)}` : "no .jj or .git repository root found",
109
+ });
110
+ const jjRepo = root ? existsSync(join(root, ".jj")) : false;
111
+ checks.push({
112
+ name: "core.jj-repo",
113
+ status: jjRepo ? "passed" : "failed",
114
+ detail: jjRepo ? ".jj present" : "Jujutsu repository not detected",
115
+ });
116
+ const configPath = root ? join(root, ".slopflow", "config.json") : "";
117
+ const configExists = Boolean(root && existsSync(configPath));
118
+ checks.push({
119
+ name: "core.config",
120
+ status: configExists ? "passed" : "failed",
121
+ detail: configExists ? relativeToCwd(configPath) : ".slopflow/config.json missing",
122
+ });
123
+ let artifactRoot = DEFAULT_ARTIFACT_ROOT;
124
+ if (root && configExists) {
125
+ try {
126
+ artifactRoot = readMachineConfig(root).artifact_root;
127
+ }
128
+ catch {
129
+ artifactRoot = DEFAULT_ARTIFACT_ROOT;
130
+ }
131
+ }
132
+ const workRoot = root ? join(root, artifactRoot) : "";
133
+ const workRootExists = Boolean(root && existsSync(workRoot));
134
+ checks.push({
135
+ name: "core.work-root",
136
+ status: workRootExists ? "passed" : "failed",
137
+ detail: workRootExists ? relativeToCwd(workRoot) : `${artifactRoot} missing`,
138
+ });
139
+ for (const [name, path] of [
140
+ ["project-docs.issue-tracker", "docs/agents/issue-tracker.md"],
141
+ ["project-docs.triage-labels", "docs/agents/triage-labels.md"],
142
+ ["project-docs.domain", "docs/agents/domain.md"],
143
+ ["project-docs.context", "CONTEXT.md"],
144
+ ["project-docs.adr", "docs/adr"],
145
+ ]) {
146
+ const present = Boolean(root && existsSync(join(root, path)));
147
+ checks.push({ name, status: present ? "passed" : "warn", detail: present ? path : `${path} missing` });
148
+ }
149
+ const ghPresent = commandExists("gh");
150
+ checks.push({
151
+ name: "recommended.gh",
152
+ status: ghPresent ? "passed" : "warn",
153
+ detail: doctorDetail(ghPresent ? "gh executable found" : "gh executable missing; GitHub issue start may fail"),
154
+ });
155
+ const ghAxiPresent = commandExists("gh-axi");
156
+ checks.push({
157
+ name: "recommended.gh-axi",
158
+ status: ghAxiPresent ? "passed" : "warn",
159
+ detail: doctorDetail(ghAxiPresent ? "gh-axi executable found" : "unchecked; run npx -y gh-axi --help when GitHub AXI operations are needed"),
160
+ });
161
+ const failedCount = checks.filter((check) => check.status === "failed").length;
162
+ const warningCount = checks.filter((check) => check.status === "warn").length;
163
+ const status = failedCount > 0 ? "failed" : warningCount > 0 ? "warn" : "passed";
164
+ const coreStatus = checks.some((check) => check.name.startsWith("core.") && check.status === "failed") ? "failed" : "passed";
165
+ const projectDocsStatus = groupStatus(checks, "project-docs.");
166
+ const recommendedStatus = groupStatus(checks, "recommended.");
167
+ const doctor = {
168
+ status,
169
+ core: coreStatus,
170
+ "project-docs": projectDocsStatus,
171
+ recommended: recommendedStatus,
172
+ "failed-count": failedCount,
173
+ "warning-count": warningCount,
174
+ "next-step": nextStepForDoctor(status, checks),
175
+ };
176
+ const checksOutput = Object.fromEntries(checks.map((check) => [check.name, `${check.status} ${check.detail}`]));
177
+ if (json) {
178
+ printJson({ doctor, checks: checksOutput });
179
+ }
180
+ else {
181
+ printBlock("doctor", doctor);
182
+ printBlock(`checks[${checks.length}]`, checksOutput);
183
+ }
184
+ return failedCount > 0 ? 2 : 0;
185
+ }
186
+ function groupStatus(checks, prefix) {
187
+ const group = checks.filter((check) => check.name.startsWith(prefix));
188
+ if (group.some((check) => check.status === "failed"))
189
+ return "failed";
190
+ if (group.some((check) => check.status === "warn"))
191
+ return "warn";
192
+ return "passed";
193
+ }
194
+ function nextStepForDoctor(status, checks) {
195
+ if (status === "passed")
196
+ return "slopflow start <issue-id>";
197
+ const firstFailed = checks.find((check) => check.status === "failed");
198
+ if (firstFailed?.name === "core.config")
199
+ return "slopflow init";
200
+ if (firstFailed?.name === "core.work-root")
201
+ return "slopflow init";
202
+ if (firstFailed?.name === "core.jj")
203
+ return "install jj";
204
+ if (firstFailed?.name === "core.repository" || firstFailed?.name === "core.jj-repo")
205
+ return "run inside a Jujutsu repository";
206
+ const firstWarn = checks.find((check) => check.status === "warn");
207
+ if (firstWarn?.name === "recommended.gh")
208
+ return "install gh or continue if GitHub start is not needed";
209
+ if (firstWarn?.name === "recommended.gh-axi")
210
+ return "run npx -y gh-axi --help when GitHub AXI operations are needed";
211
+ return "inspect doctor checks";
212
+ }
213
+ function doctorDetail(value, limit = 160) {
214
+ const normalized = value.replace(/\s+/g, " ").trim();
215
+ return normalized.length > limit ? `${normalized.slice(0, limit)}...` : normalized;
216
+ }
217
+ function installCommand(args) {
218
+ const [profile, ...flags] = args;
219
+ if (profile !== "minimal" && profile !== "recommended") {
220
+ throw new SlopflowError("Unsupported install profile.", "Run `slopflow install minimal` or `slopflow install recommended`.", 2);
221
+ }
222
+ const yes = flags.includes("--yes");
223
+ const force = flags.includes("--force");
224
+ const repo = discoverRepoContext(process.cwd());
225
+ const configPath = join(repo.root, ".slopflow", "config.json");
226
+ const desired = desiredConfig(repo.githubRepo);
227
+ const workPath = join(repo.root, desired.artifact_root);
228
+ const configExists = existsSync(configPath);
229
+ const workExists = existsSync(workPath);
230
+ const manifestPath = join(repo.root, ".pi", "slopflow-packages.json");
231
+ const manifestExists = existsSync(manifestPath);
232
+ let configAction = configExists ? "preserve" : "create";
233
+ if (configExists) {
234
+ const existing = readJson(configPath);
235
+ if (stableStringify(existing) !== stableStringify(desired)) {
236
+ if (!force) {
237
+ throw new SlopflowError("Existing .slopflow/config.json differs from detected config.", "Inspect it or rerun with `slopflow install minimal --yes --force` to refresh project-local config.", 2);
238
+ }
239
+ configAction = "refresh";
240
+ }
241
+ }
242
+ const workAction = workExists ? "preserve" : "create";
243
+ const manifestAction = manifestExists ? "preserve" : "create";
244
+ if (!yes) {
245
+ printBlock("install", {
246
+ status: "planned",
247
+ profile,
248
+ mode: "dry-run",
249
+ repo: repo.githubRepo,
250
+ config: relativeToCwd(configPath),
251
+ "config-action": configAction,
252
+ "work-root": desired.artifact_root,
253
+ "work-root-action": workAction,
254
+ ...(profile === "recommended" ? {
255
+ manifest: relativeToCwd(manifestPath),
256
+ "manifest-action": manifestAction,
257
+ "suggested-command": "npx skills add aivv73/slopflow --skill slopflow-live",
258
+ } : {}),
259
+ writes: "none",
260
+ "next-step": `slopflow install ${profile} --yes`,
261
+ });
262
+ return 0;
263
+ }
264
+ if (configAction !== "preserve") {
265
+ mkdirSync(dirname(configPath), { recursive: true });
266
+ writeJson(configPath, desired);
267
+ }
268
+ mkdirSync(workPath, { recursive: true });
269
+ if (profile === "recommended" && manifestAction !== "preserve") {
270
+ mkdirSync(dirname(manifestPath), { recursive: true });
271
+ writeJson(manifestPath, buildRecommendedInstallManifest());
272
+ }
273
+ printBlock("install", {
274
+ status: configAction === "preserve" && workAction === "preserve" && (profile === "minimal" || manifestAction === "preserve") ? "unchanged" : "applied",
275
+ profile,
276
+ mode: "apply",
277
+ repo: repo.githubRepo,
278
+ config: relativeToCwd(configPath),
279
+ "config-action": configAction,
280
+ "work-root": desired.artifact_root,
281
+ "work-root-action": workAction,
282
+ ...(profile === "recommended" ? {
283
+ manifest: relativeToCwd(manifestPath),
284
+ "manifest-action": manifestAction,
285
+ "suggested-command": "npx skills add aivv73/slopflow --skill slopflow-live",
286
+ } : {}),
287
+ writes: "project-local",
288
+ "next-step": "slopflow doctor",
289
+ });
290
+ return 0;
291
+ }
292
+ function buildRecommendedInstallManifest() {
293
+ return {
294
+ schema_version: 1,
295
+ profile: "recommended",
296
+ generated_by: "slopflow install recommended",
297
+ project_local_only: true,
298
+ suggested_commands: [
299
+ "npx skills add aivv73/slopflow --skill setup-slopflow-skills-live",
300
+ "npx skills add aivv73/slopflow --skill slopflow-live",
301
+ "npx skills add aivv73/slopflow --skill slopflow",
302
+ ],
303
+ notes: [
304
+ "Slopflow does not run these commands automatically.",
305
+ "Review agent harness documentation before installing global or user-level integrations.",
306
+ ],
307
+ };
308
+ }
309
+ function skillCommand(args) {
310
+ const [subcommand] = args;
311
+ if (subcommand !== "lint") {
312
+ throw new SlopflowError("Unsupported skill command.", "Run `slopflow skill lint`.", 2);
313
+ }
314
+ const root = findRepoRoot(process.cwd()) ?? process.cwd();
315
+ const skillsDir = join(root, "skills");
316
+ const checks = lintSkills(skillsDir);
317
+ const failedCount = checks.filter((check) => check.status === "failed").length;
318
+ printBlock("skill-lint", {
319
+ status: failedCount > 0 ? "failed" : "passed",
320
+ "skills-dir": relativeToCwd(skillsDir),
321
+ "failed-count": failedCount,
322
+ "check-count": checks.length,
323
+ "next-step": failedCount > 0 ? "fix failing skill checks" : "no skill lint action required",
324
+ });
325
+ printBlock(`checks[${checks.length}]`, Object.fromEntries(checks.map((check) => [check.name, `${check.status} ${check.detail}`])));
326
+ return failedCount > 0 ? 2 : 0;
327
+ }
328
+ function lintSkills(skillsDir) {
329
+ const checks = [];
330
+ const portablePath = join(skillsDir, "slopflow", "SKILL.md");
331
+ const livePath = join(skillsDir, "slopflow-live", "SKILL.md");
332
+ const portable = readOptionalText(portablePath);
333
+ const live = readOptionalText(livePath);
334
+ checks.push({ name: "slopflow.exists", status: portable ? "passed" : "failed", detail: portable ? "skills/slopflow/SKILL.md" : "missing skills/slopflow/SKILL.md" });
335
+ if (portable) {
336
+ checks.push({ name: "slopflow.no-interpolation", status: /!`/.test(portable) ? "failed" : "passed", detail: /!`/.test(portable) ? "portable skill contains shell interpolation" : "no shell interpolation" });
337
+ checks.push(skillTextCheck("slopflow.canonical", portable, /artifacts are canonical/i, "states CLI/artifacts are canonical"));
338
+ checks.push(skillTextCheck("slopflow.no-fabrication", portable, /Do not manually fabricate/i, "forbids fabricated artifacts"));
339
+ checks.push(skillTextCheck("slopflow.no-push-without-request", portable, /Do not push, merge, publish, create a pull request, or close an issue unless/i, "forbids push/merge/publish/PR/close unless requested"));
340
+ }
341
+ checks.push({ name: "slopflow-live.exists", status: live ? "passed" : "failed", detail: live ? "skills/slopflow-live/SKILL.md" : "missing skills/slopflow-live/SKILL.md" });
342
+ if (live) {
343
+ checks.push({ name: "slopflow-live.read-only-interpolation", status: liveInterpolationIsReadOnly(live) ? "passed" : "failed", detail: liveInterpolationIsReadOnly(live) ? "interpolation commands look read-only" : "interpolation includes mutating command" });
344
+ checks.push(skillTextCheck("slopflow-live.canonical", live, /artifacts are canonical/i, "states CLI/artifacts are canonical"));
345
+ checks.push(skillTextCheck("slopflow-live.no-fabrication", live, /Do not manually fabricate/i, "forbids fabricated artifacts"));
346
+ checks.push(skillTextCheck("slopflow-live.no-push-without-request", live, /Do not push, merge, publish, create a pull request, or close an issue unless/i, "forbids push/merge/publish/PR/close unless requested"));
347
+ }
348
+ for (const template of setupTemplateFiles(skillsDir)) {
349
+ const relativePath = relative(skillsDir, template);
350
+ const content = readOptionalText(template) ?? "";
351
+ const ok = /^---\n[\s\S]+?\n---\n/.test(content) && /^type:\s*\S.+$/m.test(content.match(/^---\n([\s\S]+?)\n---\n/)?.[1] ?? "");
352
+ checks.push({ name: `setup-template.${relativePath}`, status: ok ? "passed" : "failed", detail: ok ? "OKF frontmatter with type" : "missing OKF frontmatter type" });
353
+ }
354
+ return checks;
355
+ }
356
+ function skillTextCheck(name, content, pattern, passedDetail) {
357
+ return { name, status: pattern.test(content) ? "passed" : "failed", detail: pattern.test(content) ? passedDetail : `missing ${passedDetail}` };
358
+ }
359
+ function liveInterpolationIsReadOnly(content) {
360
+ const commands = [...content.matchAll(/!`([^`]+)`/g)].map((match) => match[1] ?? "");
361
+ return commands.every((command) => !/\b(slopflow\s+(init|start|test|review|complete|pause|resume|cancel)|jj\s+(new|desc|rebase|git\s+push)|gh\s+(issue|pr)\s+(create|edit|close|comment)|rm\s+-|write|curl\s+-X\s*(POST|PUT|PATCH|DELETE))\b/i.test(command));
362
+ }
363
+ function setupTemplateFiles(skillsDir) {
364
+ const result = [];
365
+ for (const setupName of ["setup-slopflow-skills", "setup-slopflow-skills-live"]) {
366
+ const dir = join(skillsDir, setupName);
367
+ if (!existsSync(dir))
368
+ continue;
369
+ for (const entry of readdirSyncSafe(dir)) {
370
+ const path = join(dir, entry);
371
+ if (entry === "SKILL.md" || !entry.endsWith(".md") || !statSync(path).isFile())
372
+ continue;
373
+ result.push(path);
374
+ }
375
+ }
376
+ return result.sort();
377
+ }
378
+ function readOptionalText(path) {
379
+ return existsSync(path) ? readFileSync(path, "utf8") : null;
380
+ }
381
+ function readdirSyncSafe(path) {
382
+ try {
383
+ return existsSync(path) ? readdirSync(path) : [];
384
+ }
385
+ catch {
386
+ return [];
387
+ }
388
+ }
389
+ function readPackageNodeEngine(root) {
390
+ const packagePath = join(root, "package.json");
391
+ if (!existsSync(packagePath))
392
+ return null;
393
+ try {
394
+ const packageJson = readJson(packagePath);
395
+ return typeof packageJson.engines?.node === "string" ? packageJson.engines.node : null;
396
+ }
397
+ catch {
398
+ return null;
399
+ }
400
+ }
401
+ function nodeVersionSatisfies(version, engine) {
402
+ const major = Number(version.split(".")[0] ?? "0");
403
+ const minimumMajor = engine.match(/>=\s*(\d+)/)?.[1];
404
+ if (minimumMajor)
405
+ return major >= Number(minimumMajor);
406
+ return true;
407
+ }
408
+ async function homeCommand() {
409
+ const bin = process.argv[1] ? relativeToCwd(realpathSync(process.argv[1])) : "slopflow";
410
+ const description = "Controlled issue execution for AI coding agents.";
411
+ const root = findRepoRoot(process.cwd());
412
+ if (!root) {
413
+ printBlock("slopflow", {
414
+ bin,
415
+ description,
416
+ state: "no-repository",
417
+ "next-step": "cd to a Slopflow repository or run `slopflow --help`",
418
+ });
419
+ return 0;
420
+ }
421
+ const configPath = join(root, ".slopflow", "config.json");
422
+ if (!existsSync(configPath)) {
423
+ printBlock("slopflow", {
424
+ bin,
425
+ description,
426
+ state: "uninitialized",
427
+ "repo-root": relativeToCwd(root),
428
+ vcs: existsSync(join(root, ".jj")) ? "jj" : "unknown",
429
+ "next-step": "slopflow init",
430
+ });
431
+ return 0;
432
+ }
433
+ const config = readMachineConfig(root);
434
+ const artifactRoot = String(config.artifact_root ?? DEFAULT_ARTIFACT_ROOT);
435
+ const workRoot = join(root, artifactRoot);
436
+ const workCounts = await countWorkDirsByStatus(workRoot);
437
+ printBlock("slopflow", {
438
+ bin,
439
+ description,
440
+ state: "initialized",
441
+ repo: config.issue_tracker.repo,
442
+ issue_tracker: config.issue_tracker.type,
443
+ vcs: config.vcs.type,
444
+ "artifact-root": artifactRoot,
445
+ "current-jj-change": readCurrentJjChange(root),
446
+ "active-work-count": workCounts.active,
447
+ "paused-work-count": workCounts.paused,
448
+ "cancelled-work-count": workCounts.cancelled,
449
+ "complete-work-count": workCounts.complete,
450
+ "next-step": "slopflow start <issue-id>",
451
+ });
452
+ return 0;
453
+ }
56
454
  function completeCommand(issueId) {
57
455
  if (!issueId) {
58
456
  throw new SlopflowError("Missing issue id.", "Run `slopflow complete <issue-id>`.", 2);
@@ -70,6 +468,9 @@ function completeCommand(issueId) {
70
468
  const workStatus = readWorkStatus(workDir, issueId, "complete");
71
469
  const issue = workStatus.issue;
72
470
  const issueText = `${issue.provider}:${issue.repo}#${issue.number}`;
471
+ if (workStatus.status === "cancelled") {
472
+ return completeBlocked(issueText, "issue work is cancelled", `inspect ${relativeToCwd(join(workDir, "cancel-note.md"))} or start new work`, workDir);
473
+ }
73
474
  const contractPath = join(workDir, "contract.md");
74
475
  if (!existsSync(contractPath)) {
75
476
  return completeBlocked(issueText, "missing contract.md", "restore contract.md or rerun slopflow start", workDir);
@@ -294,16 +695,151 @@ function testCommand(args) {
294
695
  });
295
696
  return exitCode;
296
697
  }
698
+ function lifecycleCommand(action, args) {
699
+ const issueId = args[0];
700
+ const reason = action === "resume" ? undefined : parseReasonArg(args.slice(1), action);
701
+ const { root, workDir, workStatus, statusPath } = readLifecycleContext(issueId, action);
702
+ const issue = workStatus.issue;
703
+ const issueText = `${issue.provider}:${issue.repo}#${issue.number}`;
704
+ const now = new Date().toISOString();
705
+ if (action === "pause") {
706
+ if (workStatus.status === "cancelled") {
707
+ throw new SlopflowError("Cancelled issue work cannot be paused.", `Inspect ${relativeToCwd(join(workDir, "cancel-note.md"))}.`, 2);
708
+ }
709
+ if (workStatus.status === "complete") {
710
+ throw new SlopflowError("Complete issue work cannot be paused.", `Inspect ${relativeToCwd(join(workDir, "completion-note.md"))}.`, 2);
711
+ }
712
+ const pauseNotePath = join(workDir, "pause-note.md");
713
+ writeFileSync(pauseNotePath, buildLifecycleNote("Pause", issueText, reason, now), "utf8");
714
+ writeJson(statusPath, { ...workStatus, status: "paused", paused_at: now, pause_reason: reason });
715
+ printBlock("pause", {
716
+ status: "paused",
717
+ issue: issueText,
718
+ "pause-note": relativeToCwd(pauseNotePath),
719
+ "next-step": `slopflow resume ${issueId}`,
720
+ });
721
+ return 0;
722
+ }
723
+ if (action === "cancel") {
724
+ if (workStatus.status === "complete") {
725
+ throw new SlopflowError("Complete issue work cannot be cancelled.", `Inspect ${relativeToCwd(join(workDir, "completion-note.md"))}.`, 2);
726
+ }
727
+ const cancelNotePath = join(workDir, "cancel-note.md");
728
+ writeFileSync(cancelNotePath, buildLifecycleNote("Cancel", issueText, reason, now), "utf8");
729
+ writeJson(statusPath, { ...workStatus, status: "cancelled", cancelled_at: now, cancel_reason: reason });
730
+ printBlock("cancel", {
731
+ status: "cancelled",
732
+ issue: issueText,
733
+ "cancel-note": relativeToCwd(cancelNotePath),
734
+ artifacts: "preserved",
735
+ "next-step": "inspect artifacts or manually abandon related VCS work if desired",
736
+ });
737
+ return 0;
738
+ }
739
+ if (workStatus.status === "cancelled") {
740
+ throw new SlopflowError("Cancelled issue work cannot be resumed.", `Inspect ${relativeToCwd(join(workDir, "cancel-note.md"))}.`, 2);
741
+ }
742
+ const wasPaused = workStatus.status === "paused";
743
+ if (wasPaused) {
744
+ writeJson(statusPath, { ...workStatus, status: "active", resumed_at: now });
745
+ }
746
+ const testsSummary = summarizeLatestTests(workDir);
747
+ const reviewStatus = summarizeReviewVerdict(workDir);
748
+ const completionStatus = existsSync(join(workDir, "completion-note.md")) || workStatus.status === "complete" ? "complete" : "incomplete";
749
+ printBlock("resume", {
750
+ status: wasPaused ? "active" : String(workStatus.status ?? "active"),
751
+ issue: issueText,
752
+ contract: relativeToCwd(join(workDir, "contract.md")),
753
+ tests: testsSummary,
754
+ review: reviewStatus,
755
+ completion: completionStatus,
756
+ "current-jj-change": readCurrentJjChange(root),
757
+ "next-step": nextStepForWork(issueId, workDir, reviewStatus, completionStatus),
758
+ });
759
+ return 0;
760
+ }
761
+ function readLifecycleContext(issueId, command) {
762
+ if (!issueId) {
763
+ throw new SlopflowError("Missing issue id.", `Run \`slopflow ${command} <issue-id>${command === "resume" ? "" : " --reason <text>"}\`.`, 2);
764
+ }
765
+ if (!/^\d+$/.test(issueId)) {
766
+ throw new SlopflowError("Issue id must be a plain number for the configured repository.", undefined, 2);
767
+ }
768
+ const root = findRepoRoot(process.cwd());
769
+ if (!root) {
770
+ throw new SlopflowError("Could not find a repository root.", "Run Slopflow inside an initialized repository.", 2);
771
+ }
772
+ const config = readMachineConfig(root);
773
+ const workDir = join(root, config.artifact_root, issueId);
774
+ const statusPath = join(workDir, "status.json");
775
+ return { root, workDir, statusPath, workStatus: readWorkStatus(workDir, issueId, command) };
776
+ }
777
+ function parseReasonArg(args, command) {
778
+ const reasonIndex = args.indexOf("--reason");
779
+ const reason = reasonIndex >= 0 ? args[reasonIndex + 1] : undefined;
780
+ if (!reason || reason.trim().length === 0) {
781
+ throw new SlopflowError("Missing required `--reason <text>`.", `Run \`slopflow ${command} <issue-id> --reason <text>\`.`, 2);
782
+ }
783
+ return reason.trim();
784
+ }
785
+ function buildLifecycleNote(kind, issue, reason, timestamp) {
786
+ const verb = kind === "Pause" ? "Paused" : "Cancelled";
787
+ return `# ${kind} Note\n\n` +
788
+ `Issue: ${issue}\n\n` +
789
+ `${verb} at: ${timestamp}\n\n` +
790
+ `## Reason\n\n${reason}\n`;
791
+ }
792
+ function summarizeLatestTests(workDir) {
793
+ const testsPath = join(workDir, "evidence", "tests.json");
794
+ if (!existsSync(testsPath))
795
+ return "missing";
796
+ const evidence = readTestEvidence(testsPath);
797
+ const latest = Object.entries(evidence.latest);
798
+ if (latest.length === 0)
799
+ return "missing";
800
+ return latest.map(([name, gate]) => `${name}:${gate.status}`).join(",");
801
+ }
802
+ function summarizeReviewVerdict(workDir) {
803
+ const reviewPath = join(workDir, "review.json");
804
+ if (!existsSync(reviewPath))
805
+ return "missing";
806
+ const validation = readAndValidateReviewVerdict(reviewPath);
807
+ return validation.ok ? validation.verdict.verdict : "invalid";
808
+ }
809
+ function nextStepForWork(issueId, workDir, reviewStatus, completionStatus) {
810
+ if (completionStatus === "complete")
811
+ return "no local action required";
812
+ if (summarizeLatestTests(workDir) === "missing")
813
+ return `slopflow test ${issueId} --name <gate> -- <command>`;
814
+ if (reviewStatus === "missing" || reviewStatus === "invalid")
815
+ return `slopflow review ${issueId}`;
816
+ if (reviewStatus === "changes-requested")
817
+ return "address required changes";
818
+ return `slopflow complete ${issueId}`;
819
+ }
297
820
  function readWorkStatus(workDir, issueId, command) {
298
821
  const workStatusPath = join(workDir, "status.json");
299
822
  if (!existsSync(workStatusPath)) {
300
- throw new SlopflowError(`Issue work status not found for #${issueId}.`, `Run \`slopflow start ${issueId}\` before ${command === "test" ? "capturing test evidence" : command === "review" ? "preparing review" : "completing work"}.`, 2);
823
+ throw new SlopflowError(`Issue work status not found for #${issueId}.`, `Run \`slopflow start ${issueId}\` before ${workStatusCommandPhrase(command)}.`, 2);
301
824
  }
302
825
  const workStatus = readJson(workStatusPath);
303
826
  if (!workStatus.issue) {
304
827
  throw new SlopflowError(`Issue work status is missing issue metadata for #${issueId}.`, "Inspect the work directory before retrying.", 2);
305
828
  }
306
- return { issue: workStatus.issue };
829
+ return workStatus;
830
+ }
831
+ function workStatusCommandPhrase(command) {
832
+ if (command === "test")
833
+ return "capturing test evidence";
834
+ if (command === "review")
835
+ return "preparing review";
836
+ if (command === "complete")
837
+ return "completing work";
838
+ if (command === "pause")
839
+ return "pausing work";
840
+ if (command === "resume")
841
+ return "resuming work";
842
+ return "cancelling work";
307
843
  }
308
844
  function parseTestArgs(args) {
309
845
  const issueId = args[0];
@@ -419,7 +955,7 @@ function initCommand({ force }) {
419
955
  });
420
956
  return 0;
421
957
  }
422
- async function statusCommand() {
958
+ async function statusCommand({ json = false } = {}) {
423
959
  const root = findRepoRoot(process.cwd());
424
960
  if (!root) {
425
961
  throw new SlopflowError("Could not find a repository root.", "Run Slopflow inside a Jujutsu repository.", 2);
@@ -427,18 +963,25 @@ async function statusCommand() {
427
963
  const config = readMachineConfig(root);
428
964
  const artifactRoot = String(config.artifact_root ?? DEFAULT_ARTIFACT_ROOT);
429
965
  const workRoot = join(root, artifactRoot);
430
- const activeWorkCount = await countWorkDirs(workRoot);
966
+ const workCounts = await countWorkDirsByStatus(workRoot);
431
967
  const currentJjChange = readCurrentJjChange(root);
432
- printBlock("status", {
968
+ const status = {
433
969
  state: "initialized",
434
970
  repo: config.issue_tracker.repo,
435
971
  issue_tracker: config.issue_tracker.type,
436
972
  vcs: config.vcs.type,
437
973
  "artifact-root": artifactRoot,
438
974
  "current-jj-change": currentJjChange,
439
- "active-work-count": activeWorkCount,
975
+ "active-work-count": workCounts.active,
976
+ "paused-work-count": workCounts.paused,
977
+ "cancelled-work-count": workCounts.cancelled,
978
+ "complete-work-count": workCounts.complete,
440
979
  "next-step": "slopflow start <issue-id>",
441
- });
980
+ };
981
+ if (json)
982
+ printJson({ status });
983
+ else
984
+ printBlock("status", status);
442
985
  return 0;
443
986
  }
444
987
  function readMachineConfig(root) {
@@ -453,27 +996,107 @@ function readMachineConfig(root) {
453
996
  return config;
454
997
  }
455
998
  function fetchGitHubItem(repo, number) {
456
- const issue = runGhJson(["issue", "view", String(number), "--repo", repo, "--json", "number,title,body,url,state"]);
457
- if (issue) {
458
- return normalizeGitHubItem(issue, "issue");
459
- }
460
- const pr = runGhJson(["pr", "view", String(number), "--repo", repo, "--json", "number,title,body,url,state"]);
461
- if (pr) {
462
- return normalizeGitHubItem(pr, "pull_request");
463
- }
464
- throw new SlopflowError(`Could not read GitHub issue or PR #${number} from ${repo}.`, "Ensure `gh` is installed, authenticated, and the item exists.", 2);
999
+ const issueArgs = ["issue", "view", String(number), "--repo", repo, "--json", "number,title,body,url,state"];
1000
+ const issue = runGhJson(issueArgs);
1001
+ if (issue.ok) {
1002
+ return normalizeGitHubItem(issue.value, "issue");
1003
+ }
1004
+ if (!issue.notFound) {
1005
+ throw githubCommandError(issue);
1006
+ }
1007
+ const prArgs = ["pr", "view", String(number), "--repo", repo, "--json", "number,title,body,url,state"];
1008
+ const pr = runGhJson(prArgs);
1009
+ if (pr.ok) {
1010
+ return normalizeGitHubItem(pr.value, "pull_request");
1011
+ }
1012
+ if (!pr.notFound) {
1013
+ throw githubCommandError(pr);
1014
+ }
1015
+ throw new SlopflowError(`Could not read GitHub issue or PR #${number} from ${repo}.`, "Ensure the issue or PR exists in the configured repository.", 2, {
1016
+ command: `gh ${issueArgs.join(" ")} && gh ${prArgs.join(" ")}`,
1017
+ "exit-code": pr.exitCode,
1018
+ detail: summarizeCommandFailure(pr),
1019
+ "next-step": `verify #${number} exists in ${repo}`,
1020
+ });
465
1021
  }
466
1022
  function runGhJson(args) {
467
1023
  const result = spawnSync("gh", args, { encoding: "utf8" });
468
- if (result.error || result.status !== 0) {
469
- return null;
1024
+ if (result.error) {
1025
+ return {
1026
+ ok: false,
1027
+ args,
1028
+ exitCode: "spawn-error",
1029
+ stdout: result.stdout ?? "",
1030
+ stderr: result.stderr ?? "",
1031
+ message: result.error.message,
1032
+ notFound: false,
1033
+ spawnError: result.error.message,
1034
+ };
1035
+ }
1036
+ if (result.status !== 0) {
1037
+ const stdout = result.stdout ?? "";
1038
+ const stderr = result.stderr ?? "";
1039
+ return {
1040
+ ok: false,
1041
+ args,
1042
+ exitCode: typeof result.status === "number" ? result.status : 1,
1043
+ stdout,
1044
+ stderr,
1045
+ message: summarizeText(stderr || stdout || "gh command failed"),
1046
+ notFound: isLikelyNotFound(stdout, stderr),
1047
+ };
470
1048
  }
471
1049
  try {
472
- return JSON.parse(result.stdout);
1050
+ return { ok: true, value: JSON.parse(result.stdout) };
473
1051
  }
474
- catch {
475
- return null;
1052
+ catch (error) {
1053
+ return {
1054
+ ok: false,
1055
+ args,
1056
+ exitCode: 0,
1057
+ stdout: result.stdout ?? "",
1058
+ stderr: result.stderr ?? "",
1059
+ message: error instanceof Error ? error.message : "GitHub JSON parse failed",
1060
+ notFound: false,
1061
+ };
1062
+ }
1063
+ }
1064
+ function githubCommandError(failure) {
1065
+ return new SlopflowError("GitHub command failed while reading issue work.", nextStepForGithubFailure(failure), 2, {
1066
+ command: `gh ${failure.args.join(" ")}`,
1067
+ "exit-code": failure.exitCode,
1068
+ detail: summarizeCommandFailure(failure),
1069
+ "next-step": nextStepForGithubFailure(failure),
1070
+ });
1071
+ }
1072
+ function summarizeCommandFailure(failure) {
1073
+ const parts = [failure.message, summarizeText(failure.stderr), summarizeText(failure.stdout)].filter(Boolean);
1074
+ return parts.length > 0 ? parts.join(" | ") : "gh command failed";
1075
+ }
1076
+ function summarizeText(value, limit = 300) {
1077
+ const normalized = value.replace(/\s+/g, " ").trim();
1078
+ if (!normalized)
1079
+ return "";
1080
+ return normalized.length > limit ? `${normalized.slice(0, limit)}...` : normalized;
1081
+ }
1082
+ function isLikelyNotFound(stdout, stderr) {
1083
+ const text = `${stdout}\n${stderr}`.toLowerCase();
1084
+ return /not found|could not resolve|no .* found|404/.test(text) && !isLikelyAuthFailure(stdout, stderr);
1085
+ }
1086
+ function isLikelyAuthFailure(stdout, stderr) {
1087
+ return /auth|authentication|authorize|login|401|403|forbidden|permission/i.test(`${stdout}\n${stderr}`);
1088
+ }
1089
+ function nextStepForGithubFailure(failure) {
1090
+ if (failure.spawnError) {
1091
+ return "install GitHub CLI `gh` and ensure it is on PATH";
476
1092
  }
1093
+ if (isLikelyAuthFailure(failure.stdout, failure.stderr)) {
1094
+ return "gh auth login";
1095
+ }
1096
+ if (failure.exitCode === 0) {
1097
+ return "inspect gh JSON output or update GitHub response parsing";
1098
+ }
1099
+ return "inspect gh output and retry";
477
1100
  }
478
1101
  function normalizeGitHubItem(value, kind) {
479
1102
  const item = value;
@@ -493,7 +1116,7 @@ function buildStartArtifacts({ issue, issueReference, workDir, root, }) {
493
1116
  const contract = buildContract(issue, issueReference);
494
1117
  const status = {
495
1118
  schema_version: 1,
496
- status: "started",
1119
+ status: "active",
497
1120
  issue: issueReference,
498
1121
  work_directory: relative(root, workDir),
499
1122
  artifacts: {
@@ -798,13 +1421,32 @@ function stableStringify(value) {
798
1421
  }
799
1422
  return JSON.stringify(value);
800
1423
  }
801
- async function countWorkDirs(workRoot) {
1424
+ async function countWorkDirsByStatus(workRoot) {
1425
+ const counts = { active: 0, paused: 0, cancelled: 0, complete: 0 };
802
1426
  try {
803
1427
  const entries = await readdir(workRoot, { withFileTypes: true });
804
- return entries.filter((entry) => entry.isDirectory()).length;
1428
+ for (const entry of entries) {
1429
+ if (!entry.isDirectory())
1430
+ continue;
1431
+ const statusPath = join(workRoot, entry.name, "status.json");
1432
+ let status = "active";
1433
+ if (existsSync(statusPath)) {
1434
+ const value = readJson(statusPath);
1435
+ status = value.status ?? "active";
1436
+ }
1437
+ if (status === "paused")
1438
+ counts.paused += 1;
1439
+ else if (status === "cancelled")
1440
+ counts.cancelled += 1;
1441
+ else if (status === "complete")
1442
+ counts.complete += 1;
1443
+ else
1444
+ counts.active += 1;
1445
+ }
1446
+ return counts;
805
1447
  }
806
1448
  catch {
807
- return 0;
1449
+ return counts;
808
1450
  }
809
1451
  }
810
1452
  function readCurrentJjChange(root) {
@@ -844,8 +1486,11 @@ function printBlock(name, values, stream = process.stdout) {
844
1486
  stream.write(` ${key}: ${String(value)}\n`);
845
1487
  }
846
1488
  }
1489
+ function printJson(value, stream = process.stdout) {
1490
+ stream.write(`${JSON.stringify(value, null, 2)}\n`);
1491
+ }
847
1492
  function printHelp() {
848
- process.stdout.write(`Usage: slopflow <command>\n\nCommands:\n init [--force]\n status\n start <issue-id>\n test <issue-id> --name <gate> -- <command...>\n review <issue-id>\n complete <issue-id>\n`);
1493
+ process.stdout.write(`Usage: slopflow <command>\n\nCommands:\n init [--force]\n status\n doctor\n install minimal [--yes] [--force]\n install recommended [--yes] [--force]\n start <issue-id>\n pause <issue-id> --reason <text>\n resume <issue-id>\n cancel <issue-id> --reason <text>\n test <issue-id> --name <gate> -- <command...>\n review <issue-id>\n complete <issue-id>\n`);
849
1494
  }
850
1495
  if (process.argv[1] && realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1])) {
851
1496
  process.exitCode = await main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slopflow",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "Controlled issue execution workflow for AI coding agents",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -21,7 +21,7 @@
21
21
  "url": "https://github.com/aivv73/slopflow/issues"
22
22
  },
23
23
  "bin": {
24
- "slopflow": "./dist/cli.js"
24
+ "slopflow": "dist/cli.js"
25
25
  },
26
26
  "files": [
27
27
  "dist",