slopflow 0.2.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 +124 -1
  2. package/dist/cli.js +501 -25
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -22,12 +22,121 @@ slopflow complete <issue-id>
22
22
 
23
23
  Slopflow requires Node.js 24 or newer.
24
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
+
25
98
  Initialize Slopflow machine config in a Jujutsu-backed GitHub repo:
26
99
 
27
100
  ```bash
28
101
  slopflow init
29
102
  ```
30
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
+
31
140
  Inspect current Slopflow state:
32
141
 
33
142
  ```bash
@@ -89,6 +198,20 @@ slopflow complete 2
89
198
 
90
199
  ## Agent skills
91
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
+
92
215
  Install the portable Slopflow skill:
93
216
 
94
217
  ```bash
@@ -101,7 +224,7 @@ Install the live-context Slopflow skill for Claude Code or Pi with `pi-skill-int
101
224
  npx skills add aivv73/slopflow --skill slopflow-live
102
225
  ```
103
226
 
104
- 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.
105
228
 
106
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.
107
230
 
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]);
@@ -47,21 +66,391 @@ export async function main(argv = process.argv.slice(2)) {
47
66
  if (command === "complete") {
48
67
  return completeCommand(args[0]);
49
68
  }
50
- printHelp();
51
- return 0;
69
+ throw new SlopflowError(`Unknown command: ${command}`, "Run `slopflow --help`.", 2);
52
70
  }
53
71
  catch (error) {
54
72
  if (error instanceof SlopflowError) {
55
- printBlock("error", {
73
+ const payload = {
56
74
  status: "blocked",
57
75
  message: error.message,
76
+ ...error.details,
58
77
  ...(error.hint ? { hint: error.hint } : {}),
59
- }, process.stderr);
78
+ };
79
+ if (wantsJson)
80
+ printJson({ error: payload });
81
+ else
82
+ printBlock("error", payload);
60
83
  return error.code;
61
84
  }
62
85
  throw error;
63
86
  }
64
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
+ }
65
454
  function completeCommand(issueId) {
66
455
  if (!issueId) {
67
456
  throw new SlopflowError("Missing issue id.", "Run `slopflow complete <issue-id>`.", 2);
@@ -566,7 +955,7 @@ function initCommand({ force }) {
566
955
  });
567
956
  return 0;
568
957
  }
569
- async function statusCommand() {
958
+ async function statusCommand({ json = false } = {}) {
570
959
  const root = findRepoRoot(process.cwd());
571
960
  if (!root) {
572
961
  throw new SlopflowError("Could not find a repository root.", "Run Slopflow inside a Jujutsu repository.", 2);
@@ -576,7 +965,7 @@ async function statusCommand() {
576
965
  const workRoot = join(root, artifactRoot);
577
966
  const workCounts = await countWorkDirsByStatus(workRoot);
578
967
  const currentJjChange = readCurrentJjChange(root);
579
- printBlock("status", {
968
+ const status = {
580
969
  state: "initialized",
581
970
  repo: config.issue_tracker.repo,
582
971
  issue_tracker: config.issue_tracker.type,
@@ -588,7 +977,11 @@ async function statusCommand() {
588
977
  "cancelled-work-count": workCounts.cancelled,
589
978
  "complete-work-count": workCounts.complete,
590
979
  "next-step": "slopflow start <issue-id>",
591
- });
980
+ };
981
+ if (json)
982
+ printJson({ status });
983
+ else
984
+ printBlock("status", status);
592
985
  return 0;
593
986
  }
594
987
  function readMachineConfig(root) {
@@ -603,28 +996,108 @@ function readMachineConfig(root) {
603
996
  return config;
604
997
  }
605
998
  function fetchGitHubItem(repo, number) {
606
- const issue = runGhJson(["issue", "view", String(number), "--repo", repo, "--json", "number,title,body,url,state"]);
607
- if (issue) {
608
- return normalizeGitHubItem(issue, "issue");
609
- }
610
- const pr = runGhJson(["pr", "view", String(number), "--repo", repo, "--json", "number,title,body,url,state"]);
611
- if (pr) {
612
- return normalizeGitHubItem(pr, "pull_request");
613
- }
614
- 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
+ });
615
1021
  }
616
1022
  function runGhJson(args) {
617
1023
  const result = spawnSync("gh", args, { encoding: "utf8" });
618
- if (result.error || result.status !== 0) {
619
- 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
+ };
620
1048
  }
621
1049
  try {
622
- return JSON.parse(result.stdout);
1050
+ return { ok: true, value: JSON.parse(result.stdout) };
623
1051
  }
624
- catch {
625
- 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
+ };
626
1062
  }
627
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";
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";
1100
+ }
628
1101
  function normalizeGitHubItem(value, kind) {
629
1102
  const item = value;
630
1103
  if (typeof item.number !== "number" || typeof item.title !== "string") {
@@ -1013,8 +1486,11 @@ function printBlock(name, values, stream = process.stdout) {
1013
1486
  stream.write(` ${key}: ${String(value)}\n`);
1014
1487
  }
1015
1488
  }
1489
+ function printJson(value, stream = process.stdout) {
1490
+ stream.write(`${JSON.stringify(value, null, 2)}\n`);
1491
+ }
1016
1492
  function printHelp() {
1017
- process.stdout.write(`Usage: slopflow <command>\n\nCommands:\n init [--force]\n status\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`);
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`);
1018
1494
  }
1019
1495
  if (process.argv[1] && realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1])) {
1020
1496
  process.exitCode = await main();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "slopflow",
3
- "version": "0.2.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",