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.
- package/README.md +124 -1
- package/dist/cli.js +501 -25
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
73
|
+
const payload = {
|
|
56
74
|
status: "blocked",
|
|
57
75
|
message: error.message,
|
|
76
|
+
...error.details,
|
|
58
77
|
...(error.hint ? { hint: error.hint } : {}),
|
|
59
|
-
}
|
|
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
|
-
|
|
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
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
if (
|
|
612
|
-
|
|
613
|
-
}
|
|
614
|
-
|
|
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
|
|
619
|
-
return
|
|
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
|
|
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();
|