slopflow 0.1.0
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/LICENSE +21 -0
- package/README.md +173 -0
- package/dist/cli.js +852 -0
- package/package.json +47 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Danila Martsymov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# Slopflow
|
|
2
|
+
|
|
3
|
+
Turn AI slop into scoped, tested, reviewed, reversible changes.
|
|
4
|
+
|
|
5
|
+
Slopflow is a local CLI-runbook for controlled issue execution by AI coding agents.
|
|
6
|
+
|
|
7
|
+
The first vertical slice provides:
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
slopflow init
|
|
11
|
+
slopflow status
|
|
12
|
+
slopflow start <issue-id>
|
|
13
|
+
slopflow test <issue-id> --name <gate> -- <command...>
|
|
14
|
+
slopflow review <issue-id>
|
|
15
|
+
slopflow complete <issue-id>
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
Slopflow requires Node.js 24 or newer.
|
|
21
|
+
|
|
22
|
+
Initialize Slopflow machine config in a Jujutsu-backed GitHub repo:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
slopflow init
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Inspect current Slopflow state:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
slopflow status
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Bootstrap controlled work for an existing issue:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
slopflow start 2
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
`start` creates real bootstrap artifacts under `.slopflow/work/<issue-id>/`:
|
|
41
|
+
|
|
42
|
+
```text
|
|
43
|
+
issue.md
|
|
44
|
+
contract.md
|
|
45
|
+
status.json
|
|
46
|
+
goal-prompt.md
|
|
47
|
+
next-steps.md
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
It does not create placeholder evidence, review, or completion files.
|
|
51
|
+
|
|
52
|
+
Capture command-based quality evidence for started issue work:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
slopflow test 2 --name unit -- npm test
|
|
56
|
+
slopflow test 2 --name typecheck -- npm run build
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`test` writes structured evidence and raw logs under `.slopflow/work/<issue-id>/evidence/`, then returns the wrapped command's exit code.
|
|
60
|
+
|
|
61
|
+
Prepare a review packet and validate reviewer verdict state:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
slopflow review 2
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`review` writes `.slopflow/work/<issue-id>/review-packet.md` but never creates `review.json`. A separate human or agent reviewer must write the verdict.
|
|
68
|
+
|
|
69
|
+
Mark issue work locally complete after evidence and reviewer gates pass:
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
slopflow complete 2
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`complete` generates `completion-note.md` when missing, preserves an existing note, updates local `status.json`, and never publishes, pushes, merges, opens PRs, or closes issues.
|
|
76
|
+
|
|
77
|
+
## Agent skills
|
|
78
|
+
|
|
79
|
+
Install the portable Slopflow skill:
|
|
80
|
+
|
|
81
|
+
```bash
|
|
82
|
+
npx skills add aivv73/slopflow --skill slopflow
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Install the live-context Slopflow skill for Claude Code or Pi with `pi-skill-interpolation`:
|
|
86
|
+
|
|
87
|
+
```bash
|
|
88
|
+
npx skills add aivv73/slopflow --skill slopflow-live
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
The portable skill does not execute shell commands during rendering. The live skill uses Claude-compatible read-only shell interpolation to inject current Slopflow and Jujutsu context.
|
|
92
|
+
|
|
93
|
+
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
|
+
|
|
95
|
+
## Install
|
|
96
|
+
|
|
97
|
+
Once Slopflow is published to npm, install the CLI globally:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
npm install -g slopflow
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Then run:
|
|
104
|
+
|
|
105
|
+
```bash
|
|
106
|
+
slopflow --help
|
|
107
|
+
slopflow status
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
For local development from a clone:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
npm install
|
|
114
|
+
npm run build
|
|
115
|
+
npm link
|
|
116
|
+
slopflow status
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
To remove the local link later:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
npm unlink -g slopflow
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
## Development
|
|
126
|
+
|
|
127
|
+
Slopflow uses TypeScript and npm for the CLI implementation. Use Node.js 24 or newer.
|
|
128
|
+
|
|
129
|
+
Install dependencies:
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
npm install
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Run the test suite:
|
|
136
|
+
|
|
137
|
+
```bash
|
|
138
|
+
npm test
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Build the CLI:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
npm run build
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
Check the npm package contents:
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
npm run pack:check
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Run the install smoke test:
|
|
154
|
+
|
|
155
|
+
```bash
|
|
156
|
+
npm run pack:smoke
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
Run full local CI, matching the GitHub Actions workflow:
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
npm run ci
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
GitHub Actions runs the same `npm run ci` package checks on pushes to `main` and pull requests.
|
|
166
|
+
|
|
167
|
+
Check release readiness without publishing:
|
|
168
|
+
|
|
169
|
+
```bash
|
|
170
|
+
npm run release:check
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
See [docs/release.md](docs/release.md) for the manual npm release checklist.
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,852 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { readdir } from "node:fs/promises";
|
|
5
|
+
import { dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
const SCHEMA_VERSION = 1;
|
|
8
|
+
const DEFAULT_ARTIFACT_ROOT = ".slopflow/work";
|
|
9
|
+
const DEFAULT_PRS_AS_REQUEST_SURFACE = true;
|
|
10
|
+
const REVIEW_DIFF_LIMIT = 50_000;
|
|
11
|
+
class SlopflowError extends Error {
|
|
12
|
+
hint;
|
|
13
|
+
code;
|
|
14
|
+
constructor(message, hint, code = 1) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.hint = hint;
|
|
17
|
+
this.code = code;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
21
|
+
try {
|
|
22
|
+
const [command, ...args] = argv;
|
|
23
|
+
if (command === "init") {
|
|
24
|
+
return initCommand({ force: args.includes("--force") });
|
|
25
|
+
}
|
|
26
|
+
if (command === "status") {
|
|
27
|
+
return await statusCommand();
|
|
28
|
+
}
|
|
29
|
+
if (command === "start") {
|
|
30
|
+
return startCommand(args[0]);
|
|
31
|
+
}
|
|
32
|
+
if (command === "test") {
|
|
33
|
+
return testCommand(args);
|
|
34
|
+
}
|
|
35
|
+
if (command === "review") {
|
|
36
|
+
return reviewCommand(args[0]);
|
|
37
|
+
}
|
|
38
|
+
if (command === "complete") {
|
|
39
|
+
return completeCommand(args[0]);
|
|
40
|
+
}
|
|
41
|
+
printHelp();
|
|
42
|
+
return 0;
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
if (error instanceof SlopflowError) {
|
|
46
|
+
printBlock("error", {
|
|
47
|
+
status: "blocked",
|
|
48
|
+
message: error.message,
|
|
49
|
+
...(error.hint ? { hint: error.hint } : {}),
|
|
50
|
+
}, process.stderr);
|
|
51
|
+
return error.code;
|
|
52
|
+
}
|
|
53
|
+
throw error;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function completeCommand(issueId) {
|
|
57
|
+
if (!issueId) {
|
|
58
|
+
throw new SlopflowError("Missing issue id.", "Run `slopflow complete <issue-id>`.", 2);
|
|
59
|
+
}
|
|
60
|
+
if (!/^\d+$/.test(issueId)) {
|
|
61
|
+
throw new SlopflowError("Issue id must be a plain number for the configured repository.", undefined, 2);
|
|
62
|
+
}
|
|
63
|
+
const root = findRepoRoot(process.cwd());
|
|
64
|
+
if (!root) {
|
|
65
|
+
throw new SlopflowError("Could not find a repository root.", "Run Slopflow inside an initialized repository.", 2);
|
|
66
|
+
}
|
|
67
|
+
const config = readMachineConfig(root);
|
|
68
|
+
const workDir = join(root, config.artifact_root, issueId);
|
|
69
|
+
const workStatusPath = join(workDir, "status.json");
|
|
70
|
+
const workStatus = readWorkStatus(workDir, issueId, "complete");
|
|
71
|
+
const issue = workStatus.issue;
|
|
72
|
+
const issueText = `${issue.provider}:${issue.repo}#${issue.number}`;
|
|
73
|
+
const contractPath = join(workDir, "contract.md");
|
|
74
|
+
if (!existsSync(contractPath)) {
|
|
75
|
+
return completeBlocked(issueText, "missing contract.md", "restore contract.md or rerun slopflow start", workDir);
|
|
76
|
+
}
|
|
77
|
+
if (!isJjStatusReadable(root)) {
|
|
78
|
+
return completeBlocked(issueText, "jj status is not readable", "fix Jujutsu repository state", workDir);
|
|
79
|
+
}
|
|
80
|
+
const reviewPath = join(workDir, "review.json");
|
|
81
|
+
if (!existsSync(reviewPath)) {
|
|
82
|
+
return completeBlocked(issueText, "missing review verdict", `slopflow review ${issueId}`, workDir);
|
|
83
|
+
}
|
|
84
|
+
const reviewValidation = readAndValidateReviewVerdict(reviewPath);
|
|
85
|
+
if (!reviewValidation.ok) {
|
|
86
|
+
return completeBlocked(issueText, "invalid review verdict", "fix review.json", workDir);
|
|
87
|
+
}
|
|
88
|
+
if (reviewValidation.verdict.verdict !== "complete") {
|
|
89
|
+
return completeBlocked(issueText, "review verdict is changes-requested", "address required changes", workDir);
|
|
90
|
+
}
|
|
91
|
+
const evidenceGate = evaluateCompletionEvidence(workDir);
|
|
92
|
+
if (!evidenceGate.ok) {
|
|
93
|
+
return completeBlocked(issueText, evidenceGate.reason, evidenceGate.nextStep, workDir);
|
|
94
|
+
}
|
|
95
|
+
const completionNotePath = join(workDir, "completion-note.md");
|
|
96
|
+
if (!existsSync(completionNotePath)) {
|
|
97
|
+
writeFileSync(completionNotePath, buildCompletionNote({ issue: issueText, testsStatus: evidenceGate.testsStatus, review: reviewValidation.verdict, workDir }), "utf8");
|
|
98
|
+
}
|
|
99
|
+
const updatedStatus = { ...readJson(workStatusPath), status: "complete", completed_at: new Date().toISOString() };
|
|
100
|
+
writeJson(workStatusPath, updatedStatus);
|
|
101
|
+
printBlock("complete", {
|
|
102
|
+
status: "complete",
|
|
103
|
+
issue: issueText,
|
|
104
|
+
tests: evidenceGate.testsStatus,
|
|
105
|
+
review: "complete",
|
|
106
|
+
"completion-note": relativeToCwd(completionNotePath),
|
|
107
|
+
"next-step": "export/publish when ready",
|
|
108
|
+
});
|
|
109
|
+
return 0;
|
|
110
|
+
}
|
|
111
|
+
function completeBlocked(issue, reason, nextStep, workDir) {
|
|
112
|
+
printBlock("complete", {
|
|
113
|
+
status: "blocked",
|
|
114
|
+
issue,
|
|
115
|
+
reason,
|
|
116
|
+
"completion-note": relativeToCwd(join(workDir, "completion-note.md")),
|
|
117
|
+
"next-step": nextStep,
|
|
118
|
+
});
|
|
119
|
+
return 2;
|
|
120
|
+
}
|
|
121
|
+
function evaluateCompletionEvidence(workDir) {
|
|
122
|
+
const testsPath = join(workDir, "evidence", "tests.json");
|
|
123
|
+
if (!existsSync(testsPath)) {
|
|
124
|
+
const exceptionPath = join(workDir, "evidence", "test-exception.md");
|
|
125
|
+
if (existsSync(exceptionPath)) {
|
|
126
|
+
return { ok: true, testsStatus: "exception-accepted" };
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
ok: false,
|
|
130
|
+
reason: "missing test evidence",
|
|
131
|
+
nextStep: "slopflow test <issue-id> --name <gate> -- <command>",
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
const evidence = readTestEvidence(testsPath);
|
|
135
|
+
const latest = Object.entries(evidence.latest);
|
|
136
|
+
if (latest.length === 0) {
|
|
137
|
+
return { ok: false, reason: "missing latest test gate", nextStep: "slopflow test <issue-id> --name <gate> -- <command>" };
|
|
138
|
+
}
|
|
139
|
+
const failed = latest.filter(([, gate]) => gate.status === "failed").map(([name]) => name);
|
|
140
|
+
if (failed.length > 0) {
|
|
141
|
+
return { ok: false, reason: `failed latest test gate: ${failed.join(", ")}`, nextStep: "fix failing gates and rerun slopflow test" };
|
|
142
|
+
}
|
|
143
|
+
const passed = latest.filter(([, gate]) => gate.status === "passed");
|
|
144
|
+
if (passed.length === 0) {
|
|
145
|
+
return { ok: false, reason: "no latest test gate passed", nextStep: "rerun a required quality gate with slopflow test" };
|
|
146
|
+
}
|
|
147
|
+
return { ok: true, testsStatus: "passed" };
|
|
148
|
+
}
|
|
149
|
+
function buildCompletionNote({ issue, testsStatus, review, workDir }) {
|
|
150
|
+
const testsSummary = buildTestEvidenceSummary(join(workDir, "evidence", "tests.json"));
|
|
151
|
+
const exceptionPath = join(workDir, "evidence", "test-exception.md");
|
|
152
|
+
const exception = existsSync(exceptionPath) ? readFileSync(exceptionPath, "utf8") : "";
|
|
153
|
+
return `# Completion Note\n\n` +
|
|
154
|
+
`Issue: ${issue}\n\n` +
|
|
155
|
+
`## Summary\n\nLocal issue work passed Slopflow completion gates.\n\n` +
|
|
156
|
+
`## Quality Gates\n\n` +
|
|
157
|
+
`Tests: ${testsStatus}\n\n` +
|
|
158
|
+
`${testsStatus === "exception-accepted" ? `Test exception accepted by reviewer:\n\n${indentBlock(exception)}\n\n` : `${testsSummary}\n\n`}` +
|
|
159
|
+
`## Review\n\n` +
|
|
160
|
+
`Verdict: ${review.verdict}\n\n` +
|
|
161
|
+
`Reviewer: ${review.reviewer}\n\n` +
|
|
162
|
+
`Reviewed at: ${review.reviewed_at}\n\n` +
|
|
163
|
+
`${review.summary}\n\n` +
|
|
164
|
+
`## Known Limitations / Follow-ups\n\nNone recorded.\n`;
|
|
165
|
+
}
|
|
166
|
+
function reviewCommand(issueId) {
|
|
167
|
+
if (!issueId) {
|
|
168
|
+
throw new SlopflowError("Missing issue id.", "Run `slopflow review <issue-id>`.", 2);
|
|
169
|
+
}
|
|
170
|
+
if (!/^\d+$/.test(issueId)) {
|
|
171
|
+
throw new SlopflowError("Issue id must be a plain number for the configured repository.", undefined, 2);
|
|
172
|
+
}
|
|
173
|
+
const root = findRepoRoot(process.cwd());
|
|
174
|
+
if (!root) {
|
|
175
|
+
throw new SlopflowError("Could not find a repository root.", "Run Slopflow inside an initialized repository.", 2);
|
|
176
|
+
}
|
|
177
|
+
const config = readMachineConfig(root);
|
|
178
|
+
const workDir = join(root, config.artifact_root, issueId);
|
|
179
|
+
const workStatus = readWorkStatus(workDir, issueId, "review");
|
|
180
|
+
const issue = workStatus.issue;
|
|
181
|
+
const testsPath = join(workDir, "evidence", "tests.json");
|
|
182
|
+
const testEvidenceStatus = existsSync(testsPath) ? "present" : "missing";
|
|
183
|
+
const reviewPath = join(workDir, "review.json");
|
|
184
|
+
const packetPath = join(workDir, "review-packet.md");
|
|
185
|
+
writeFileSync(packetPath, buildReviewPacket({ root, workDir, issue, testsPath }), "utf8");
|
|
186
|
+
if (!existsSync(reviewPath)) {
|
|
187
|
+
printBlock("review", {
|
|
188
|
+
status: "pending",
|
|
189
|
+
issue: `${issue.provider}:${issue.repo}#${issue.number}`,
|
|
190
|
+
packet: relativeToCwd(packetPath),
|
|
191
|
+
verdict: "missing",
|
|
192
|
+
"test-evidence": testEvidenceStatus,
|
|
193
|
+
"next-step": "ask reviewer to write review.json",
|
|
194
|
+
});
|
|
195
|
+
return 0;
|
|
196
|
+
}
|
|
197
|
+
const validation = readAndValidateReviewVerdict(reviewPath);
|
|
198
|
+
if (!validation.ok) {
|
|
199
|
+
printBlock("review", {
|
|
200
|
+
status: "blocked",
|
|
201
|
+
issue: `${issue.provider}:${issue.repo}#${issue.number}`,
|
|
202
|
+
packet: relativeToCwd(packetPath),
|
|
203
|
+
verdict: "invalid",
|
|
204
|
+
"test-evidence": testEvidenceStatus,
|
|
205
|
+
error: validation.error,
|
|
206
|
+
"next-step": "fix review.json",
|
|
207
|
+
});
|
|
208
|
+
return 2;
|
|
209
|
+
}
|
|
210
|
+
const verdict = validation.verdict.verdict;
|
|
211
|
+
printBlock("review", {
|
|
212
|
+
status: verdict === "complete" ? "complete" : "changes-requested",
|
|
213
|
+
issue: `${issue.provider}:${issue.repo}#${issue.number}`,
|
|
214
|
+
packet: relativeToCwd(packetPath),
|
|
215
|
+
verdict,
|
|
216
|
+
"test-evidence": testEvidenceStatus,
|
|
217
|
+
"next-step": verdict === "complete" ? `slopflow complete ${issueId}` : "address required changes",
|
|
218
|
+
});
|
|
219
|
+
return 0;
|
|
220
|
+
}
|
|
221
|
+
function readAndValidateReviewVerdict(path) {
|
|
222
|
+
try {
|
|
223
|
+
return validateReviewVerdict(JSON.parse(readFileSync(path, "utf8")));
|
|
224
|
+
}
|
|
225
|
+
catch (error) {
|
|
226
|
+
return { ok: false, error: error instanceof Error ? error.message : "review.json is invalid" };
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
function testCommand(args) {
|
|
230
|
+
const { issueId, gateName, command } = parseTestArgs(args);
|
|
231
|
+
const root = findRepoRoot(process.cwd());
|
|
232
|
+
if (!root) {
|
|
233
|
+
throw new SlopflowError("Could not find a repository root.", "Run Slopflow inside an initialized repository.", 2);
|
|
234
|
+
}
|
|
235
|
+
const config = readMachineConfig(root);
|
|
236
|
+
const workDir = join(root, config.artifact_root, issueId);
|
|
237
|
+
const workStatus = readWorkStatus(workDir, issueId, "test");
|
|
238
|
+
const evidenceDir = join(workDir, "evidence");
|
|
239
|
+
const logsDir = join(evidenceDir, "logs");
|
|
240
|
+
mkdirSync(logsDir, { recursive: true });
|
|
241
|
+
const startedAt = new Date().toISOString();
|
|
242
|
+
const attemptId = `${gateName}-${formatTimestampForId(startedAt)}`;
|
|
243
|
+
const relativeLogPath = `evidence/logs/${attemptId}.txt`;
|
|
244
|
+
const logPath = join(workDir, relativeLogPath);
|
|
245
|
+
const result = spawnSync(command[0], command.slice(1), {
|
|
246
|
+
cwd: root,
|
|
247
|
+
encoding: "utf8",
|
|
248
|
+
env: process.env,
|
|
249
|
+
});
|
|
250
|
+
const finishedAt = new Date().toISOString();
|
|
251
|
+
const exitCode = typeof result.status === "number" ? result.status : 1;
|
|
252
|
+
const status = exitCode === 0 ? "passed" : "failed";
|
|
253
|
+
const commandText = command.join(" ");
|
|
254
|
+
writeFileSync(logPath, buildTestLog({
|
|
255
|
+
attemptId,
|
|
256
|
+
gateName,
|
|
257
|
+
commandText,
|
|
258
|
+
cwd: root,
|
|
259
|
+
startedAt,
|
|
260
|
+
finishedAt,
|
|
261
|
+
exitCode,
|
|
262
|
+
stdout: result.stdout ?? "",
|
|
263
|
+
stderr: result.stderr || result.error?.message || "",
|
|
264
|
+
}), "utf8");
|
|
265
|
+
const attempt = {
|
|
266
|
+
attempt_id: attemptId,
|
|
267
|
+
name: gateName,
|
|
268
|
+
command: commandText,
|
|
269
|
+
status,
|
|
270
|
+
exit_code: exitCode,
|
|
271
|
+
log: relativeLogPath,
|
|
272
|
+
started_at: startedAt,
|
|
273
|
+
finished_at: finishedAt,
|
|
274
|
+
};
|
|
275
|
+
const evidencePath = join(evidenceDir, "tests.json");
|
|
276
|
+
const evidence = readTestEvidence(evidencePath);
|
|
277
|
+
evidence.attempts.push(attempt);
|
|
278
|
+
evidence.latest[gateName] = {
|
|
279
|
+
attempt_id: attempt.attempt_id,
|
|
280
|
+
status: attempt.status,
|
|
281
|
+
exit_code: attempt.exit_code,
|
|
282
|
+
log: attempt.log,
|
|
283
|
+
};
|
|
284
|
+
writeJson(evidencePath, evidence);
|
|
285
|
+
printBlock("test", {
|
|
286
|
+
status,
|
|
287
|
+
issue: `${workStatus.issue.provider}:${workStatus.issue.repo}#${workStatus.issue.number}`,
|
|
288
|
+
gate: gateName,
|
|
289
|
+
command: commandText,
|
|
290
|
+
"exit-code": exitCode,
|
|
291
|
+
log: relativeToCwd(logPath),
|
|
292
|
+
evidence: relativeToCwd(evidencePath),
|
|
293
|
+
"next-step": status === "passed" ? `slopflow review ${issueId}` : "fix implementation or create reviewed test exception",
|
|
294
|
+
});
|
|
295
|
+
return exitCode;
|
|
296
|
+
}
|
|
297
|
+
function readWorkStatus(workDir, issueId, command) {
|
|
298
|
+
const workStatusPath = join(workDir, "status.json");
|
|
299
|
+
if (!existsSync(workStatusPath)) {
|
|
300
|
+
throw new SlopflowError(`Issue work status not found for #${issueId}.`, `Run \`slopflow start ${issueId}\` before ${command === "test" ? "capturing test evidence" : command === "review" ? "preparing review" : "completing work"}.`, 2);
|
|
301
|
+
}
|
|
302
|
+
const workStatus = readJson(workStatusPath);
|
|
303
|
+
if (!workStatus.issue) {
|
|
304
|
+
throw new SlopflowError(`Issue work status is missing issue metadata for #${issueId}.`, "Inspect the work directory before retrying.", 2);
|
|
305
|
+
}
|
|
306
|
+
return { issue: workStatus.issue };
|
|
307
|
+
}
|
|
308
|
+
function parseTestArgs(args) {
|
|
309
|
+
const issueId = args[0];
|
|
310
|
+
if (!issueId) {
|
|
311
|
+
throw new SlopflowError("Missing issue id.", "Run `slopflow test <issue-id> --name <gate> -- <command...>`.", 2);
|
|
312
|
+
}
|
|
313
|
+
if (!/^\d+$/.test(issueId)) {
|
|
314
|
+
throw new SlopflowError("Issue id must be a plain number for the configured repository.", undefined, 2);
|
|
315
|
+
}
|
|
316
|
+
const separatorIndex = args.indexOf("--");
|
|
317
|
+
if (separatorIndex === -1) {
|
|
318
|
+
throw new SlopflowError("Missing `--` before wrapped command.", "Run `slopflow test <issue-id> --name <gate> -- <command...>`.", 2);
|
|
319
|
+
}
|
|
320
|
+
const optionArgs = args.slice(1, separatorIndex);
|
|
321
|
+
const command = args.slice(separatorIndex + 1);
|
|
322
|
+
if (command.length === 0) {
|
|
323
|
+
throw new SlopflowError("Missing wrapped command.", "Pass the command after `--`.", 2);
|
|
324
|
+
}
|
|
325
|
+
const nameIndex = optionArgs.indexOf("--name");
|
|
326
|
+
const gateName = nameIndex >= 0 ? optionArgs[nameIndex + 1] : undefined;
|
|
327
|
+
if (!gateName) {
|
|
328
|
+
throw new SlopflowError("Missing required `--name <gate>`.", undefined, 2);
|
|
329
|
+
}
|
|
330
|
+
if (!/^[a-z0-9][a-z0-9_-]*$/.test(gateName)) {
|
|
331
|
+
throw new SlopflowError("Invalid gate name.", "Use lowercase letters, numbers, underscores, or hyphens; start with a letter or number.", 2);
|
|
332
|
+
}
|
|
333
|
+
return { issueId, gateName, command };
|
|
334
|
+
}
|
|
335
|
+
function startCommand(issueId) {
|
|
336
|
+
if (!issueId) {
|
|
337
|
+
throw new SlopflowError("Missing issue id.", "Run `slopflow start <issue-id>`.", 2);
|
|
338
|
+
}
|
|
339
|
+
if (!/^\d+$/.test(issueId)) {
|
|
340
|
+
throw new SlopflowError("Issue id must be a plain number for the configured repository.", undefined, 2);
|
|
341
|
+
}
|
|
342
|
+
const root = findRepoRoot(process.cwd());
|
|
343
|
+
if (!root) {
|
|
344
|
+
throw new SlopflowError("Could not find a repository root.", "Run Slopflow inside an initialized repository.", 2);
|
|
345
|
+
}
|
|
346
|
+
const config = readMachineConfig(root);
|
|
347
|
+
if (config.issue_tracker.type !== "github") {
|
|
348
|
+
throw new SlopflowError("Unsupported issue tracker.", "Slopflow v0 start only supports GitHub.", 2);
|
|
349
|
+
}
|
|
350
|
+
const issueNumber = Number(issueId);
|
|
351
|
+
const item = fetchGitHubItem(config.issue_tracker.repo, issueNumber);
|
|
352
|
+
const issueReference = {
|
|
353
|
+
provider: "github",
|
|
354
|
+
repo: config.issue_tracker.repo,
|
|
355
|
+
number: item.number,
|
|
356
|
+
kind: item.kind,
|
|
357
|
+
};
|
|
358
|
+
const workDir = join(root, config.artifact_root, String(issueNumber));
|
|
359
|
+
const statusPath = join(workDir, "status.json");
|
|
360
|
+
let action = "created";
|
|
361
|
+
if (existsSync(workDir)) {
|
|
362
|
+
if (!existsSync(statusPath)) {
|
|
363
|
+
throw new SlopflowError(`Work directory already exists without status metadata: ${relativeToCwd(workDir)}`, "Move it aside or inspect it before retrying.", 2);
|
|
364
|
+
}
|
|
365
|
+
const existing = readJson(statusPath);
|
|
366
|
+
if (stableStringify(existing.issue) !== stableStringify(issueReference)) {
|
|
367
|
+
throw new SlopflowError(`Work directory already exists for a different issue reference: ${relativeToCwd(workDir)}`, "Slopflow will not overwrite issue work automatically.", 2);
|
|
368
|
+
}
|
|
369
|
+
action = "unchanged";
|
|
370
|
+
}
|
|
371
|
+
else {
|
|
372
|
+
mkdirSync(workDir, { recursive: true });
|
|
373
|
+
const artifacts = buildStartArtifacts({ issue: item, issueReference, workDir, root });
|
|
374
|
+
for (const [filename, content] of Object.entries(artifacts)) {
|
|
375
|
+
writeFileSync(join(workDir, filename), content, "utf8");
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
printBlock("start", {
|
|
379
|
+
status: action,
|
|
380
|
+
issue: `github:${issueReference.repo}#${issueReference.number}`,
|
|
381
|
+
kind: issueReference.kind,
|
|
382
|
+
"work-directory": relativeToCwd(workDir),
|
|
383
|
+
contract: relativeToCwd(join(workDir, "contract.md")),
|
|
384
|
+
"goal-prompt": relativeToCwd(join(workDir, "goal-prompt.md")),
|
|
385
|
+
"next-step": `create goal mirror from ${relativeToCwd(join(workDir, "goal-prompt.md"))}`,
|
|
386
|
+
});
|
|
387
|
+
return 0;
|
|
388
|
+
}
|
|
389
|
+
function initCommand({ force }) {
|
|
390
|
+
const repo = discoverRepoContext(process.cwd());
|
|
391
|
+
const configPath = join(repo.root, ".slopflow", "config.json");
|
|
392
|
+
const workPath = join(repo.root, DEFAULT_ARTIFACT_ROOT);
|
|
393
|
+
const desired = desiredConfig(repo.githubRepo);
|
|
394
|
+
let action = "created";
|
|
395
|
+
if (existsSync(configPath)) {
|
|
396
|
+
const existing = readJson(configPath);
|
|
397
|
+
if (stableStringify(existing) === stableStringify(desired)) {
|
|
398
|
+
action = "unchanged";
|
|
399
|
+
}
|
|
400
|
+
else if (!force) {
|
|
401
|
+
throw new SlopflowError("Existing .slopflow/config.json differs from detected config.", "Re-run with `slopflow init --force` to intentionally refresh machine config.", 2);
|
|
402
|
+
}
|
|
403
|
+
else {
|
|
404
|
+
action = "reinitialized";
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
if (action !== "unchanged") {
|
|
408
|
+
mkdirSync(dirname(configPath), { recursive: true });
|
|
409
|
+
writeJson(configPath, desired);
|
|
410
|
+
}
|
|
411
|
+
mkdirSync(workPath, { recursive: true });
|
|
412
|
+
printBlock("init", {
|
|
413
|
+
status: action,
|
|
414
|
+
repo: repo.githubRepo,
|
|
415
|
+
vcs: "jj",
|
|
416
|
+
config: relativeToCwd(configPath),
|
|
417
|
+
"artifact-root": desired.artifact_root,
|
|
418
|
+
"next-step": "slopflow status",
|
|
419
|
+
});
|
|
420
|
+
return 0;
|
|
421
|
+
}
|
|
422
|
+
async function statusCommand() {
|
|
423
|
+
const root = findRepoRoot(process.cwd());
|
|
424
|
+
if (!root) {
|
|
425
|
+
throw new SlopflowError("Could not find a repository root.", "Run Slopflow inside a Jujutsu repository.", 2);
|
|
426
|
+
}
|
|
427
|
+
const config = readMachineConfig(root);
|
|
428
|
+
const artifactRoot = String(config.artifact_root ?? DEFAULT_ARTIFACT_ROOT);
|
|
429
|
+
const workRoot = join(root, artifactRoot);
|
|
430
|
+
const activeWorkCount = await countWorkDirs(workRoot);
|
|
431
|
+
const currentJjChange = readCurrentJjChange(root);
|
|
432
|
+
printBlock("status", {
|
|
433
|
+
state: "initialized",
|
|
434
|
+
repo: config.issue_tracker.repo,
|
|
435
|
+
issue_tracker: config.issue_tracker.type,
|
|
436
|
+
vcs: config.vcs.type,
|
|
437
|
+
"artifact-root": artifactRoot,
|
|
438
|
+
"current-jj-change": currentJjChange,
|
|
439
|
+
"active-work-count": activeWorkCount,
|
|
440
|
+
"next-step": "slopflow start <issue-id>",
|
|
441
|
+
});
|
|
442
|
+
return 0;
|
|
443
|
+
}
|
|
444
|
+
function readMachineConfig(root) {
|
|
445
|
+
const configPath = join(root, ".slopflow", "config.json");
|
|
446
|
+
if (!existsSync(configPath)) {
|
|
447
|
+
throw new SlopflowError("Slopflow machine config is missing.", "Run `slopflow init` first.", 2);
|
|
448
|
+
}
|
|
449
|
+
const config = readJson(configPath);
|
|
450
|
+
if (!config.artifact_root || !config.issue_tracker?.type || !config.issue_tracker.repo || !config.vcs?.type) {
|
|
451
|
+
throw new SlopflowError("Slopflow machine config is incomplete.", "Run `slopflow init --force` to refresh it.", 2);
|
|
452
|
+
}
|
|
453
|
+
return config;
|
|
454
|
+
}
|
|
455
|
+
function fetchGitHubItem(repo, number) {
|
|
456
|
+
const issue = runGhJson(["issue", "view", String(number), "--repo", repo, "--json", "number,title,body,url,state"]);
|
|
457
|
+
if (issue) {
|
|
458
|
+
return normalizeGitHubItem(issue, "issue");
|
|
459
|
+
}
|
|
460
|
+
const pr = runGhJson(["pr", "view", String(number), "--repo", repo, "--json", "number,title,body,url,state"]);
|
|
461
|
+
if (pr) {
|
|
462
|
+
return normalizeGitHubItem(pr, "pull_request");
|
|
463
|
+
}
|
|
464
|
+
throw new SlopflowError(`Could not read GitHub issue or PR #${number} from ${repo}.`, "Ensure `gh` is installed, authenticated, and the item exists.", 2);
|
|
465
|
+
}
|
|
466
|
+
function runGhJson(args) {
|
|
467
|
+
const result = spawnSync("gh", args, { encoding: "utf8" });
|
|
468
|
+
if (result.error || result.status !== 0) {
|
|
469
|
+
return null;
|
|
470
|
+
}
|
|
471
|
+
try {
|
|
472
|
+
return JSON.parse(result.stdout);
|
|
473
|
+
}
|
|
474
|
+
catch {
|
|
475
|
+
return null;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
function normalizeGitHubItem(value, kind) {
|
|
479
|
+
const item = value;
|
|
480
|
+
if (typeof item.number !== "number" || typeof item.title !== "string") {
|
|
481
|
+
throw new SlopflowError("GitHub returned an unexpected issue shape.", undefined, 2);
|
|
482
|
+
}
|
|
483
|
+
return {
|
|
484
|
+
number: item.number,
|
|
485
|
+
title: item.title,
|
|
486
|
+
body: typeof item.body === "string" ? item.body : "",
|
|
487
|
+
url: typeof item.url === "string" ? item.url : "",
|
|
488
|
+
state: typeof item.state === "string" ? item.state : "unknown",
|
|
489
|
+
kind,
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
function buildStartArtifacts({ issue, issueReference, workDir, root, }) {
|
|
493
|
+
const contract = buildContract(issue, issueReference);
|
|
494
|
+
const status = {
|
|
495
|
+
schema_version: 1,
|
|
496
|
+
status: "started",
|
|
497
|
+
issue: issueReference,
|
|
498
|
+
work_directory: relative(root, workDir),
|
|
499
|
+
artifacts: {
|
|
500
|
+
issue: "issue.md",
|
|
501
|
+
contract: "contract.md",
|
|
502
|
+
goal_prompt: "goal-prompt.md",
|
|
503
|
+
next_steps: "next-steps.md",
|
|
504
|
+
},
|
|
505
|
+
created_by: "slopflow start",
|
|
506
|
+
};
|
|
507
|
+
return {
|
|
508
|
+
"issue.md": buildIssueMarkdown(issue, issueReference),
|
|
509
|
+
"contract.md": contract,
|
|
510
|
+
"status.json": `${JSON.stringify(status, null, 2)}\n`,
|
|
511
|
+
"goal-prompt.md": buildGoalPrompt(contract),
|
|
512
|
+
"next-steps.md": buildNextSteps(issueReference),
|
|
513
|
+
};
|
|
514
|
+
}
|
|
515
|
+
function buildIssueMarkdown(issue, issueReference) {
|
|
516
|
+
return `# ${escapeMarkdown(issue.title)}\n\n` +
|
|
517
|
+
`Issue: github:${issueReference.repo}#${issueReference.number}\n\n` +
|
|
518
|
+
`Kind: ${issueReference.kind}\n\n` +
|
|
519
|
+
`State: ${issue.state}\n\n` +
|
|
520
|
+
`URL: ${issue.url}\n\n` +
|
|
521
|
+
`## Body\n\n${issue.body || "_No issue body provided._"}\n`;
|
|
522
|
+
}
|
|
523
|
+
function buildContract(issue, issueReference) {
|
|
524
|
+
return `# Issue Execution Contract\n\n` +
|
|
525
|
+
`Issue: github:${issueReference.repo}#${issueReference.number}\n\n` +
|
|
526
|
+
`## Issue Summary\n\n${issue.title}\n\n` +
|
|
527
|
+
`## Acceptance Criteria\n\nExtract from the source issue before implementing. Source issue body:\n\n${indentBlock(issue.body || "No issue body provided.")}\n\n` +
|
|
528
|
+
`## Constraints\n\n- Stay within the scope of github:${issueReference.repo}#${issueReference.number}.\n- Preserve Slopflow's CLI-runbook model; do not introduce autonomous orchestration unless explicitly approved.\n- Do not add dependencies without justification and review.\n\n` +
|
|
529
|
+
`## Out of Scope\n\n- Work not requested by the source issue.\n- Publishing, pushing, merging, creating PRs, or closing issues unless separately requested.\n\n` +
|
|
530
|
+
`## Required Quality Gates\n\n- Test evidence is required unless an explicit test exception is written and accepted by review.\n- Reviewer verdict is required before completion.\n- Browser or design evidence is required only if this contract is updated to require it.\n\n` +
|
|
531
|
+
`## Blocked-Stop Conditions\n\n- Acceptance criteria cannot be extracted from the issue.\n- Required external tools or credentials are unavailable.\n- The implementation would expand beyond the source issue.\n- Quality gates cannot run and no reviewed test exception exists.\n\n` +
|
|
532
|
+
`## Completion Criteria\n\n- Implementation matches the issue execution contract.\n- Required quality gates have evidence.\n- Reviewer verdict is complete.\n- Completion note summarizes changes, tests, review result, and limitations.\n`;
|
|
533
|
+
}
|
|
534
|
+
function buildGoalPrompt(contract) {
|
|
535
|
+
return `Create a Pi goal mirror from this Slopflow issue execution contract. The contract is canonical; the Pi goal is only a runtime mirror.\n\n${contract}`;
|
|
536
|
+
}
|
|
537
|
+
function buildNextSteps(issueReference) {
|
|
538
|
+
return `# Next Steps\n\n` +
|
|
539
|
+
`1. Read \`contract.md\` and confirm scope for github:${issueReference.repo}#${issueReference.number}.\n` +
|
|
540
|
+
`2. Create a Pi goal mirror from \`goal-prompt.md\` if working inside Pi.\n` +
|
|
541
|
+
`3. Plan the smallest implementation that satisfies the contract.\n` +
|
|
542
|
+
`4. Implement only the contract scope.\n` +
|
|
543
|
+
`5. Capture test evidence with Slopflow when the test command exists.\n` +
|
|
544
|
+
`6. Do not mark complete until reviewer verdict and required evidence exist.\n`;
|
|
545
|
+
}
|
|
546
|
+
function buildReviewPacket({ root, workDir, issue, testsPath }) {
|
|
547
|
+
const contractPath = join(workDir, "contract.md");
|
|
548
|
+
const contract = existsSync(contractPath) ? readFileSync(contractPath, "utf8") : "_Missing contract.md_";
|
|
549
|
+
const testsSummary = buildTestEvidenceSummary(testsPath);
|
|
550
|
+
const jjStatus = runTextCommand("jj", ["--no-pager", "status"], root);
|
|
551
|
+
const diff = runTextCommand("jj", ["--no-pager", "diff", "--git"], root);
|
|
552
|
+
const boundedDiff = boundText(diff, REVIEW_DIFF_LIMIT);
|
|
553
|
+
const changedFiles = changedFilesFromDiff(diff);
|
|
554
|
+
return `# Review Packet\n\n` +
|
|
555
|
+
`## Issue Reference\n\n` +
|
|
556
|
+
`Issue: ${issue.provider}:${issue.repo}#${issue.number}\n\n` +
|
|
557
|
+
`Kind: ${issue.kind}\n\n` +
|
|
558
|
+
`## Reviewer Instructions\n\n` +
|
|
559
|
+
`Review the diff against the issue execution contract. Slopflow does not create \`review.json\`; write it only if you are the reviewer.\n\n` +
|
|
560
|
+
`Valid \`review.json\` schema:\n\n` +
|
|
561
|
+
"```json\n" +
|
|
562
|
+
JSON.stringify({
|
|
563
|
+
schema_version: 1,
|
|
564
|
+
verdict: "complete | changes-requested",
|
|
565
|
+
reviewer: "reviewer-name",
|
|
566
|
+
reviewed_at: new Date(0).toISOString(),
|
|
567
|
+
summary: "Review summary",
|
|
568
|
+
required_changes: [],
|
|
569
|
+
}, null, 2) +
|
|
570
|
+
"\n```\n\n" +
|
|
571
|
+
`- Use \`verdict: "complete"\` only when no required changes remain.\n` +
|
|
572
|
+
`- Use \`verdict: "changes-requested"\` with actionable \`required_changes\`.\n\n` +
|
|
573
|
+
`## Contract\n\n` +
|
|
574
|
+
"```markdown\n" + contract + "\n```\n\n" +
|
|
575
|
+
`## Test Evidence Summary\n\n${testsSummary}\n\n` +
|
|
576
|
+
`## Jujutsu Status\n\n` +
|
|
577
|
+
"```text\n" + jjStatus + "\n```\n\n" +
|
|
578
|
+
`## Changed Files\n\n${changedFiles.length > 0 ? changedFiles.map((file) => `- ${file}`).join("\n") : "_No changed files detected._"}\n\n` +
|
|
579
|
+
`## Diff Excerpt\n\n` +
|
|
580
|
+
`Inline diff limit: ${REVIEW_DIFF_LIMIT} characters. Run \`jj --no-pager diff --git\` for the full diff.\n\n` +
|
|
581
|
+
"```diff\n" + boundedDiff.text + "\n```\n" +
|
|
582
|
+
(boundedDiff.truncated ? "\n_Diff excerpt truncated._\n" : "");
|
|
583
|
+
}
|
|
584
|
+
function buildTestEvidenceSummary(testsPath) {
|
|
585
|
+
if (!existsSync(testsPath)) {
|
|
586
|
+
return "Status: missing\n\nNo `evidence/tests.json` exists yet.";
|
|
587
|
+
}
|
|
588
|
+
const evidence = readTestEvidence(testsPath);
|
|
589
|
+
const latestEntries = Object.entries(evidence.latest);
|
|
590
|
+
if (latestEntries.length === 0) {
|
|
591
|
+
return "Status: missing\n\n`evidence/tests.json` exists but has no latest gate results.";
|
|
592
|
+
}
|
|
593
|
+
const lines = ["Status: present", "", `Attempts: ${evidence.attempts.length}`, "", "Latest gates:"];
|
|
594
|
+
for (const [name, latest] of latestEntries) {
|
|
595
|
+
lines.push(`- ${name}: ${latest.status} (exit ${latest.exit_code}, log ${latest.log})`);
|
|
596
|
+
}
|
|
597
|
+
return lines.join("\n");
|
|
598
|
+
}
|
|
599
|
+
function runTextCommand(command, args, cwd) {
|
|
600
|
+
const result = spawnSync(command, args, { cwd, encoding: "utf8" });
|
|
601
|
+
if (result.error) {
|
|
602
|
+
return `unavailable: ${result.error.message}`;
|
|
603
|
+
}
|
|
604
|
+
return `${result.stdout ?? ""}${result.stderr ?? ""}`.trimEnd();
|
|
605
|
+
}
|
|
606
|
+
function boundText(text, limit) {
|
|
607
|
+
if (text.length <= limit) {
|
|
608
|
+
return { text, truncated: false };
|
|
609
|
+
}
|
|
610
|
+
return { text: text.slice(0, limit), truncated: true };
|
|
611
|
+
}
|
|
612
|
+
function changedFilesFromDiff(diff) {
|
|
613
|
+
const files = new Set();
|
|
614
|
+
for (const line of diff.split("\n")) {
|
|
615
|
+
const match = line.match(/^diff --git a\/(.+?) b\/(.+)$/);
|
|
616
|
+
if (match?.[2]) {
|
|
617
|
+
files.add(match[2]);
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
return [...files].sort();
|
|
621
|
+
}
|
|
622
|
+
function validateReviewVerdict(value) {
|
|
623
|
+
const verdict = value;
|
|
624
|
+
if (!verdict || typeof verdict !== "object") {
|
|
625
|
+
return { ok: false, error: "review.json must be an object" };
|
|
626
|
+
}
|
|
627
|
+
if (verdict.schema_version !== 1) {
|
|
628
|
+
return { ok: false, error: "schema_version must be 1" };
|
|
629
|
+
}
|
|
630
|
+
if (verdict.verdict !== "complete" && verdict.verdict !== "changes-requested") {
|
|
631
|
+
return { ok: false, error: "verdict must be complete or changes-requested" };
|
|
632
|
+
}
|
|
633
|
+
if (!nonEmptyString(verdict.reviewer)) {
|
|
634
|
+
return { ok: false, error: "reviewer must be a non-empty string" };
|
|
635
|
+
}
|
|
636
|
+
if (!nonEmptyString(verdict.reviewed_at) || !isIsoTimestamp(verdict.reviewed_at)) {
|
|
637
|
+
return { ok: false, error: "reviewed_at must be an ISO timestamp" };
|
|
638
|
+
}
|
|
639
|
+
if (!nonEmptyString(verdict.summary)) {
|
|
640
|
+
return { ok: false, error: "summary must be a non-empty string" };
|
|
641
|
+
}
|
|
642
|
+
if (!Array.isArray(verdict.required_changes) || !verdict.required_changes.every(nonEmptyString)) {
|
|
643
|
+
return { ok: false, error: "required_changes must be an array of non-empty strings" };
|
|
644
|
+
}
|
|
645
|
+
if (verdict.verdict === "complete" && verdict.required_changes.length !== 0) {
|
|
646
|
+
return { ok: false, error: "complete verdict requires empty required_changes" };
|
|
647
|
+
}
|
|
648
|
+
if (verdict.verdict === "changes-requested" && verdict.required_changes.length === 0) {
|
|
649
|
+
return { ok: false, error: "changes-requested verdict requires required_changes" };
|
|
650
|
+
}
|
|
651
|
+
return { ok: true, verdict: verdict };
|
|
652
|
+
}
|
|
653
|
+
function nonEmptyString(value) {
|
|
654
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
655
|
+
}
|
|
656
|
+
function isIsoTimestamp(value) {
|
|
657
|
+
return /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(value) && !Number.isNaN(Date.parse(value));
|
|
658
|
+
}
|
|
659
|
+
function indentBlock(value) {
|
|
660
|
+
return value
|
|
661
|
+
.split("\n")
|
|
662
|
+
.map((line) => `> ${line}`)
|
|
663
|
+
.join("\n");
|
|
664
|
+
}
|
|
665
|
+
function escapeMarkdown(value) {
|
|
666
|
+
return value.replace(/^#/gm, "\\#");
|
|
667
|
+
}
|
|
668
|
+
function discoverRepoContext(start) {
|
|
669
|
+
const root = findRepoRoot(start);
|
|
670
|
+
if (!root) {
|
|
671
|
+
throw new SlopflowError("Could not find a repository root.", "Run `slopflow init` inside a Jujutsu repository with a GitHub origin remote.", 2);
|
|
672
|
+
}
|
|
673
|
+
if (!existsSync(join(root, ".jj"))) {
|
|
674
|
+
throw new SlopflowError("Jujutsu repository not detected.", "Initialize Jujutsu first; Slopflow v0 only supports jj-backed work.", 2);
|
|
675
|
+
}
|
|
676
|
+
if (!commandExists("jj")) {
|
|
677
|
+
throw new SlopflowError("Jujutsu executable not found.", "Install `jj` before running `slopflow init`.", 2);
|
|
678
|
+
}
|
|
679
|
+
return { root, githubRepo: readGithubRepo(root) };
|
|
680
|
+
}
|
|
681
|
+
function findRepoRoot(start) {
|
|
682
|
+
let current = resolve(start);
|
|
683
|
+
while (true) {
|
|
684
|
+
if (existsSync(join(current, ".jj")) || existsSync(join(current, ".git"))) {
|
|
685
|
+
return current;
|
|
686
|
+
}
|
|
687
|
+
const parent = dirname(current);
|
|
688
|
+
if (parent === current) {
|
|
689
|
+
return null;
|
|
690
|
+
}
|
|
691
|
+
current = parent;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
function readGithubRepo(root) {
|
|
695
|
+
const configPath = gitConfigPath(root);
|
|
696
|
+
if (!existsSync(configPath)) {
|
|
697
|
+
throw new SlopflowError("Git config not found for GitHub remote detection.", "Use a colocated Jujutsu/Git repository with an origin remote.", 2);
|
|
698
|
+
}
|
|
699
|
+
const config = readFileSync(configPath, "utf8");
|
|
700
|
+
const match = config.match(/\[remote "origin"\][\s\S]*?\n\s*url\s*=\s*(.+)\n?/);
|
|
701
|
+
if (!match?.[1]) {
|
|
702
|
+
throw new SlopflowError("GitHub origin remote not found.", "Set origin to a GitHub repository before running `slopflow init`.", 2);
|
|
703
|
+
}
|
|
704
|
+
const repo = parseGithubRemote(match[1].trim());
|
|
705
|
+
if (!repo) {
|
|
706
|
+
throw new SlopflowError(`Origin remote is not a supported GitHub URL: ${match[1].trim()}`, "Use https://github.com/<owner>/<repo>.git or git@github.com:<owner>/<repo>.git.", 2);
|
|
707
|
+
}
|
|
708
|
+
return repo;
|
|
709
|
+
}
|
|
710
|
+
function gitConfigPath(root) {
|
|
711
|
+
const dotGit = join(root, ".git");
|
|
712
|
+
try {
|
|
713
|
+
const content = readFileSync(dotGit, "utf8").trim();
|
|
714
|
+
const prefix = "gitdir:";
|
|
715
|
+
if (content.toLowerCase().startsWith(prefix)) {
|
|
716
|
+
const gitdir = content.slice(prefix.length).trim();
|
|
717
|
+
return join(isAbsolute(gitdir) ? gitdir : join(root, gitdir), "config");
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
catch {
|
|
721
|
+
// `.git` is usually a directory. Fall through to the colocated config path.
|
|
722
|
+
}
|
|
723
|
+
return join(dotGit, "config");
|
|
724
|
+
}
|
|
725
|
+
function parseGithubRemote(url) {
|
|
726
|
+
const patterns = [
|
|
727
|
+
/^https:\/\/github\.com\/(?<owner>[^/]+)\/(?<repo>[^/]+?)(?:\.git)?\/?$/,
|
|
728
|
+
/^git@github\.com:(?<owner>[^/]+)\/(?<repo>[^/]+?)(?:\.git)?$/,
|
|
729
|
+
/^ssh:\/\/git@github\.com\/(?<owner>[^/]+)\/(?<repo>[^/]+?)(?:\.git)?\/?$/,
|
|
730
|
+
];
|
|
731
|
+
for (const pattern of patterns) {
|
|
732
|
+
const match = url.match(pattern);
|
|
733
|
+
if (match?.groups) {
|
|
734
|
+
return `${match.groups.owner}/${match.groups.repo}`;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
return null;
|
|
738
|
+
}
|
|
739
|
+
function desiredConfig(githubRepo) {
|
|
740
|
+
return {
|
|
741
|
+
schema_version: SCHEMA_VERSION,
|
|
742
|
+
artifact_root: DEFAULT_ARTIFACT_ROOT,
|
|
743
|
+
issue_tracker: {
|
|
744
|
+
type: "github",
|
|
745
|
+
repo: githubRepo,
|
|
746
|
+
prs_as_request_surface: DEFAULT_PRS_AS_REQUEST_SURFACE,
|
|
747
|
+
},
|
|
748
|
+
vcs: { type: "jj" },
|
|
749
|
+
};
|
|
750
|
+
}
|
|
751
|
+
function readJson(path) {
|
|
752
|
+
try {
|
|
753
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
754
|
+
}
|
|
755
|
+
catch (error) {
|
|
756
|
+
if (error instanceof SyntaxError) {
|
|
757
|
+
throw new SlopflowError(`Invalid JSON in ${path}.`, error.message, 2);
|
|
758
|
+
}
|
|
759
|
+
throw error;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
function writeJson(path, data) {
|
|
763
|
+
writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, "utf8");
|
|
764
|
+
}
|
|
765
|
+
function readTestEvidence(path) {
|
|
766
|
+
if (!existsSync(path)) {
|
|
767
|
+
return { schema_version: 1, latest: {}, attempts: [] };
|
|
768
|
+
}
|
|
769
|
+
const existing = readJson(path);
|
|
770
|
+
return {
|
|
771
|
+
schema_version: 1,
|
|
772
|
+
latest: existing.latest ?? {},
|
|
773
|
+
attempts: existing.attempts ?? [],
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
function formatTimestampForId(value) {
|
|
777
|
+
return value.replace(/[:.]/g, "-");
|
|
778
|
+
}
|
|
779
|
+
function buildTestLog({ attemptId, gateName, commandText, cwd, startedAt, finishedAt, exitCode, stdout, stderr, }) {
|
|
780
|
+
return `slopflow test log\n` +
|
|
781
|
+
`attempt: ${attemptId}\n` +
|
|
782
|
+
`gate: ${gateName}\n` +
|
|
783
|
+
`command: ${commandText}\n` +
|
|
784
|
+
`cwd: ${cwd}\n` +
|
|
785
|
+
`started_at: ${startedAt}\n` +
|
|
786
|
+
`finished_at: ${finishedAt}\n` +
|
|
787
|
+
`exit_code: ${exitCode}\n\n` +
|
|
788
|
+
`--- stdout ---\n${stdout}\n` +
|
|
789
|
+
`--- stderr ---\n${stderr}\n`;
|
|
790
|
+
}
|
|
791
|
+
function stableStringify(value) {
|
|
792
|
+
if (Array.isArray(value)) {
|
|
793
|
+
return `[${value.map((item) => stableStringify(item)).join(",")}]`;
|
|
794
|
+
}
|
|
795
|
+
if (value && typeof value === "object") {
|
|
796
|
+
const entries = Object.entries(value).sort(([left], [right]) => left.localeCompare(right));
|
|
797
|
+
return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`).join(",")}}`;
|
|
798
|
+
}
|
|
799
|
+
return JSON.stringify(value);
|
|
800
|
+
}
|
|
801
|
+
async function countWorkDirs(workRoot) {
|
|
802
|
+
try {
|
|
803
|
+
const entries = await readdir(workRoot, { withFileTypes: true });
|
|
804
|
+
return entries.filter((entry) => entry.isDirectory()).length;
|
|
805
|
+
}
|
|
806
|
+
catch {
|
|
807
|
+
return 0;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
function readCurrentJjChange(root) {
|
|
811
|
+
const result = spawnSync("jj", ["--no-pager", "status"], {
|
|
812
|
+
cwd: root,
|
|
813
|
+
encoding: "utf8",
|
|
814
|
+
});
|
|
815
|
+
if (result.error) {
|
|
816
|
+
return `unavailable: ${result.error.message}`;
|
|
817
|
+
}
|
|
818
|
+
if (result.status !== 0) {
|
|
819
|
+
const detail = result.stderr.trim() || result.stdout.trim() || "jj status failed";
|
|
820
|
+
return `unavailable: ${detail}`;
|
|
821
|
+
}
|
|
822
|
+
for (const line of result.stdout.split("\n")) {
|
|
823
|
+
if (line.includes("Working copy") && line.includes("(@)")) {
|
|
824
|
+
return line.split(":").slice(1).join(":").trim();
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
return "unknown";
|
|
828
|
+
}
|
|
829
|
+
function isJjStatusReadable(root) {
|
|
830
|
+
const result = spawnSync("jj", ["--no-pager", "status"], { cwd: root, encoding: "utf8" });
|
|
831
|
+
return !result.error && result.status === 0;
|
|
832
|
+
}
|
|
833
|
+
function commandExists(command) {
|
|
834
|
+
const result = spawnSync(command, ["--version"], { encoding: "utf8" });
|
|
835
|
+
return !result.error && result.status === 0;
|
|
836
|
+
}
|
|
837
|
+
function relativeToCwd(path) {
|
|
838
|
+
const relativePath = relative(process.cwd(), path);
|
|
839
|
+
return relativePath.startsWith("..") ? path : relativePath || ".";
|
|
840
|
+
}
|
|
841
|
+
function printBlock(name, values, stream = process.stdout) {
|
|
842
|
+
stream.write(`${name}:\n`);
|
|
843
|
+
for (const [key, value] of Object.entries(values)) {
|
|
844
|
+
stream.write(` ${key}: ${String(value)}\n`);
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
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`);
|
|
849
|
+
}
|
|
850
|
+
if (process.argv[1] && realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1])) {
|
|
851
|
+
process.exitCode = await main();
|
|
852
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "slopflow",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Controlled issue execution workflow for AI coding agents",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"keywords": [
|
|
8
|
+
"ai",
|
|
9
|
+
"agents",
|
|
10
|
+
"cli",
|
|
11
|
+
"workflow",
|
|
12
|
+
"jujutsu",
|
|
13
|
+
"slopflow"
|
|
14
|
+
],
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/aivv73/slopflow.git"
|
|
18
|
+
},
|
|
19
|
+
"homepage": "https://github.com/aivv73/slopflow#readme",
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/aivv73/slopflow/issues"
|
|
22
|
+
},
|
|
23
|
+
"bin": {
|
|
24
|
+
"slopflow": "./dist/cli.js"
|
|
25
|
+
},
|
|
26
|
+
"files": [
|
|
27
|
+
"dist",
|
|
28
|
+
"README.md",
|
|
29
|
+
"LICENSE"
|
|
30
|
+
],
|
|
31
|
+
"scripts": {
|
|
32
|
+
"build": "npx -y -p typescript@5.9.3 -p @types/node@24.13.2 tsc -p tsconfig.json",
|
|
33
|
+
"ci": "npm test && npm run pack:smoke",
|
|
34
|
+
"pack:check": "npm run build && npm pack --dry-run",
|
|
35
|
+
"pack:smoke": "npm run build && node scripts/pack-smoke.mjs",
|
|
36
|
+
"prepare": "npm run build",
|
|
37
|
+
"test": "npm run build && node --test",
|
|
38
|
+
"release:check": "npm run ci && npm pack --dry-run"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": ">=24"
|
|
42
|
+
},
|
|
43
|
+
"devDependencies": {
|
|
44
|
+
"@types/node": "^24.0.0",
|
|
45
|
+
"typescript": "^5.9.0"
|
|
46
|
+
}
|
|
47
|
+
}
|