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.
- package/README.md +140 -2
- package/dist/cli.js +678 -33
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
73
|
+
const payload = {
|
|
47
74
|
status: "blocked",
|
|
48
75
|
message: error.message,
|
|
76
|
+
...error.details,
|
|
49
77
|
...(error.hint ? { hint: error.hint } : {}),
|
|
50
|
-
}
|
|
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
|
|
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
|
|
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
|
|
966
|
+
const workCounts = await countWorkDirsByStatus(workRoot);
|
|
431
967
|
const currentJjChange = readCurrentJjChange(root);
|
|
432
|
-
|
|
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":
|
|
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
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
if (
|
|
462
|
-
|
|
463
|
-
}
|
|
464
|
-
|
|
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
|
|
469
|
-
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
|
+
};
|
|
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
|
|
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: "
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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": "
|
|
24
|
+
"slopflow": "dist/cli.js"
|
|
25
25
|
},
|
|
26
26
|
"files": [
|
|
27
27
|
"dist",
|