slopflow 0.1.0 → 0.2.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/README.md +16 -1
- package/dist/cli.js +178 -9
- 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>
|
|
@@ -49,6 +52,16 @@ next-steps.md
|
|
|
49
52
|
|
|
50
53
|
It does not create placeholder evidence, review, or completion files.
|
|
51
54
|
|
|
55
|
+
Pause, resume, or cancel local issue work without running gates or mutating Jujutsu history:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
slopflow pause 2 --reason "waiting for external review"
|
|
59
|
+
slopflow resume 2
|
|
60
|
+
slopflow cancel 2 --reason "superseded by another issue"
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Lifecycle commands preserve the work directory and record local status only. They do not push, close issues, publish, delete evidence, or abandon Jujutsu changes.
|
|
64
|
+
|
|
52
65
|
Capture command-based quality evidence for started issue work:
|
|
53
66
|
|
|
54
67
|
```bash
|
|
@@ -94,7 +107,9 @@ Agent skills are installed separately through Vercel Skills. The Slopflow npm pa
|
|
|
94
107
|
|
|
95
108
|
## Install
|
|
96
109
|
|
|
97
|
-
|
|
110
|
+
Slopflow is published on npm: https://www.npmjs.com/package/slopflow
|
|
111
|
+
|
|
112
|
+
Install the CLI globally:
|
|
98
113
|
|
|
99
114
|
```bash
|
|
100
115
|
npm install -g slopflow
|
package/dist/cli.js
CHANGED
|
@@ -32,6 +32,15 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
32
32
|
if (command === "test") {
|
|
33
33
|
return testCommand(args);
|
|
34
34
|
}
|
|
35
|
+
if (command === "pause") {
|
|
36
|
+
return lifecycleCommand("pause", args);
|
|
37
|
+
}
|
|
38
|
+
if (command === "resume") {
|
|
39
|
+
return lifecycleCommand("resume", args);
|
|
40
|
+
}
|
|
41
|
+
if (command === "cancel") {
|
|
42
|
+
return lifecycleCommand("cancel", args);
|
|
43
|
+
}
|
|
35
44
|
if (command === "review") {
|
|
36
45
|
return reviewCommand(args[0]);
|
|
37
46
|
}
|
|
@@ -70,6 +79,9 @@ function completeCommand(issueId) {
|
|
|
70
79
|
const workStatus = readWorkStatus(workDir, issueId, "complete");
|
|
71
80
|
const issue = workStatus.issue;
|
|
72
81
|
const issueText = `${issue.provider}:${issue.repo}#${issue.number}`;
|
|
82
|
+
if (workStatus.status === "cancelled") {
|
|
83
|
+
return completeBlocked(issueText, "issue work is cancelled", `inspect ${relativeToCwd(join(workDir, "cancel-note.md"))} or start new work`, workDir);
|
|
84
|
+
}
|
|
73
85
|
const contractPath = join(workDir, "contract.md");
|
|
74
86
|
if (!existsSync(contractPath)) {
|
|
75
87
|
return completeBlocked(issueText, "missing contract.md", "restore contract.md or rerun slopflow start", workDir);
|
|
@@ -294,16 +306,151 @@ function testCommand(args) {
|
|
|
294
306
|
});
|
|
295
307
|
return exitCode;
|
|
296
308
|
}
|
|
309
|
+
function lifecycleCommand(action, args) {
|
|
310
|
+
const issueId = args[0];
|
|
311
|
+
const reason = action === "resume" ? undefined : parseReasonArg(args.slice(1), action);
|
|
312
|
+
const { root, workDir, workStatus, statusPath } = readLifecycleContext(issueId, action);
|
|
313
|
+
const issue = workStatus.issue;
|
|
314
|
+
const issueText = `${issue.provider}:${issue.repo}#${issue.number}`;
|
|
315
|
+
const now = new Date().toISOString();
|
|
316
|
+
if (action === "pause") {
|
|
317
|
+
if (workStatus.status === "cancelled") {
|
|
318
|
+
throw new SlopflowError("Cancelled issue work cannot be paused.", `Inspect ${relativeToCwd(join(workDir, "cancel-note.md"))}.`, 2);
|
|
319
|
+
}
|
|
320
|
+
if (workStatus.status === "complete") {
|
|
321
|
+
throw new SlopflowError("Complete issue work cannot be paused.", `Inspect ${relativeToCwd(join(workDir, "completion-note.md"))}.`, 2);
|
|
322
|
+
}
|
|
323
|
+
const pauseNotePath = join(workDir, "pause-note.md");
|
|
324
|
+
writeFileSync(pauseNotePath, buildLifecycleNote("Pause", issueText, reason, now), "utf8");
|
|
325
|
+
writeJson(statusPath, { ...workStatus, status: "paused", paused_at: now, pause_reason: reason });
|
|
326
|
+
printBlock("pause", {
|
|
327
|
+
status: "paused",
|
|
328
|
+
issue: issueText,
|
|
329
|
+
"pause-note": relativeToCwd(pauseNotePath),
|
|
330
|
+
"next-step": `slopflow resume ${issueId}`,
|
|
331
|
+
});
|
|
332
|
+
return 0;
|
|
333
|
+
}
|
|
334
|
+
if (action === "cancel") {
|
|
335
|
+
if (workStatus.status === "complete") {
|
|
336
|
+
throw new SlopflowError("Complete issue work cannot be cancelled.", `Inspect ${relativeToCwd(join(workDir, "completion-note.md"))}.`, 2);
|
|
337
|
+
}
|
|
338
|
+
const cancelNotePath = join(workDir, "cancel-note.md");
|
|
339
|
+
writeFileSync(cancelNotePath, buildLifecycleNote("Cancel", issueText, reason, now), "utf8");
|
|
340
|
+
writeJson(statusPath, { ...workStatus, status: "cancelled", cancelled_at: now, cancel_reason: reason });
|
|
341
|
+
printBlock("cancel", {
|
|
342
|
+
status: "cancelled",
|
|
343
|
+
issue: issueText,
|
|
344
|
+
"cancel-note": relativeToCwd(cancelNotePath),
|
|
345
|
+
artifacts: "preserved",
|
|
346
|
+
"next-step": "inspect artifacts or manually abandon related VCS work if desired",
|
|
347
|
+
});
|
|
348
|
+
return 0;
|
|
349
|
+
}
|
|
350
|
+
if (workStatus.status === "cancelled") {
|
|
351
|
+
throw new SlopflowError("Cancelled issue work cannot be resumed.", `Inspect ${relativeToCwd(join(workDir, "cancel-note.md"))}.`, 2);
|
|
352
|
+
}
|
|
353
|
+
const wasPaused = workStatus.status === "paused";
|
|
354
|
+
if (wasPaused) {
|
|
355
|
+
writeJson(statusPath, { ...workStatus, status: "active", resumed_at: now });
|
|
356
|
+
}
|
|
357
|
+
const testsSummary = summarizeLatestTests(workDir);
|
|
358
|
+
const reviewStatus = summarizeReviewVerdict(workDir);
|
|
359
|
+
const completionStatus = existsSync(join(workDir, "completion-note.md")) || workStatus.status === "complete" ? "complete" : "incomplete";
|
|
360
|
+
printBlock("resume", {
|
|
361
|
+
status: wasPaused ? "active" : String(workStatus.status ?? "active"),
|
|
362
|
+
issue: issueText,
|
|
363
|
+
contract: relativeToCwd(join(workDir, "contract.md")),
|
|
364
|
+
tests: testsSummary,
|
|
365
|
+
review: reviewStatus,
|
|
366
|
+
completion: completionStatus,
|
|
367
|
+
"current-jj-change": readCurrentJjChange(root),
|
|
368
|
+
"next-step": nextStepForWork(issueId, workDir, reviewStatus, completionStatus),
|
|
369
|
+
});
|
|
370
|
+
return 0;
|
|
371
|
+
}
|
|
372
|
+
function readLifecycleContext(issueId, command) {
|
|
373
|
+
if (!issueId) {
|
|
374
|
+
throw new SlopflowError("Missing issue id.", `Run \`slopflow ${command} <issue-id>${command === "resume" ? "" : " --reason <text>"}\`.`, 2);
|
|
375
|
+
}
|
|
376
|
+
if (!/^\d+$/.test(issueId)) {
|
|
377
|
+
throw new SlopflowError("Issue id must be a plain number for the configured repository.", undefined, 2);
|
|
378
|
+
}
|
|
379
|
+
const root = findRepoRoot(process.cwd());
|
|
380
|
+
if (!root) {
|
|
381
|
+
throw new SlopflowError("Could not find a repository root.", "Run Slopflow inside an initialized repository.", 2);
|
|
382
|
+
}
|
|
383
|
+
const config = readMachineConfig(root);
|
|
384
|
+
const workDir = join(root, config.artifact_root, issueId);
|
|
385
|
+
const statusPath = join(workDir, "status.json");
|
|
386
|
+
return { root, workDir, statusPath, workStatus: readWorkStatus(workDir, issueId, command) };
|
|
387
|
+
}
|
|
388
|
+
function parseReasonArg(args, command) {
|
|
389
|
+
const reasonIndex = args.indexOf("--reason");
|
|
390
|
+
const reason = reasonIndex >= 0 ? args[reasonIndex + 1] : undefined;
|
|
391
|
+
if (!reason || reason.trim().length === 0) {
|
|
392
|
+
throw new SlopflowError("Missing required `--reason <text>`.", `Run \`slopflow ${command} <issue-id> --reason <text>\`.`, 2);
|
|
393
|
+
}
|
|
394
|
+
return reason.trim();
|
|
395
|
+
}
|
|
396
|
+
function buildLifecycleNote(kind, issue, reason, timestamp) {
|
|
397
|
+
const verb = kind === "Pause" ? "Paused" : "Cancelled";
|
|
398
|
+
return `# ${kind} Note\n\n` +
|
|
399
|
+
`Issue: ${issue}\n\n` +
|
|
400
|
+
`${verb} at: ${timestamp}\n\n` +
|
|
401
|
+
`## Reason\n\n${reason}\n`;
|
|
402
|
+
}
|
|
403
|
+
function summarizeLatestTests(workDir) {
|
|
404
|
+
const testsPath = join(workDir, "evidence", "tests.json");
|
|
405
|
+
if (!existsSync(testsPath))
|
|
406
|
+
return "missing";
|
|
407
|
+
const evidence = readTestEvidence(testsPath);
|
|
408
|
+
const latest = Object.entries(evidence.latest);
|
|
409
|
+
if (latest.length === 0)
|
|
410
|
+
return "missing";
|
|
411
|
+
return latest.map(([name, gate]) => `${name}:${gate.status}`).join(",");
|
|
412
|
+
}
|
|
413
|
+
function summarizeReviewVerdict(workDir) {
|
|
414
|
+
const reviewPath = join(workDir, "review.json");
|
|
415
|
+
if (!existsSync(reviewPath))
|
|
416
|
+
return "missing";
|
|
417
|
+
const validation = readAndValidateReviewVerdict(reviewPath);
|
|
418
|
+
return validation.ok ? validation.verdict.verdict : "invalid";
|
|
419
|
+
}
|
|
420
|
+
function nextStepForWork(issueId, workDir, reviewStatus, completionStatus) {
|
|
421
|
+
if (completionStatus === "complete")
|
|
422
|
+
return "no local action required";
|
|
423
|
+
if (summarizeLatestTests(workDir) === "missing")
|
|
424
|
+
return `slopflow test ${issueId} --name <gate> -- <command>`;
|
|
425
|
+
if (reviewStatus === "missing" || reviewStatus === "invalid")
|
|
426
|
+
return `slopflow review ${issueId}`;
|
|
427
|
+
if (reviewStatus === "changes-requested")
|
|
428
|
+
return "address required changes";
|
|
429
|
+
return `slopflow complete ${issueId}`;
|
|
430
|
+
}
|
|
297
431
|
function readWorkStatus(workDir, issueId, command) {
|
|
298
432
|
const workStatusPath = join(workDir, "status.json");
|
|
299
433
|
if (!existsSync(workStatusPath)) {
|
|
300
|
-
throw new SlopflowError(`Issue work status not found for #${issueId}.`, `Run \`slopflow start ${issueId}\` before ${command
|
|
434
|
+
throw new SlopflowError(`Issue work status not found for #${issueId}.`, `Run \`slopflow start ${issueId}\` before ${workStatusCommandPhrase(command)}.`, 2);
|
|
301
435
|
}
|
|
302
436
|
const workStatus = readJson(workStatusPath);
|
|
303
437
|
if (!workStatus.issue) {
|
|
304
438
|
throw new SlopflowError(`Issue work status is missing issue metadata for #${issueId}.`, "Inspect the work directory before retrying.", 2);
|
|
305
439
|
}
|
|
306
|
-
return
|
|
440
|
+
return workStatus;
|
|
441
|
+
}
|
|
442
|
+
function workStatusCommandPhrase(command) {
|
|
443
|
+
if (command === "test")
|
|
444
|
+
return "capturing test evidence";
|
|
445
|
+
if (command === "review")
|
|
446
|
+
return "preparing review";
|
|
447
|
+
if (command === "complete")
|
|
448
|
+
return "completing work";
|
|
449
|
+
if (command === "pause")
|
|
450
|
+
return "pausing work";
|
|
451
|
+
if (command === "resume")
|
|
452
|
+
return "resuming work";
|
|
453
|
+
return "cancelling work";
|
|
307
454
|
}
|
|
308
455
|
function parseTestArgs(args) {
|
|
309
456
|
const issueId = args[0];
|
|
@@ -427,7 +574,7 @@ async function statusCommand() {
|
|
|
427
574
|
const config = readMachineConfig(root);
|
|
428
575
|
const artifactRoot = String(config.artifact_root ?? DEFAULT_ARTIFACT_ROOT);
|
|
429
576
|
const workRoot = join(root, artifactRoot);
|
|
430
|
-
const
|
|
577
|
+
const workCounts = await countWorkDirsByStatus(workRoot);
|
|
431
578
|
const currentJjChange = readCurrentJjChange(root);
|
|
432
579
|
printBlock("status", {
|
|
433
580
|
state: "initialized",
|
|
@@ -436,7 +583,10 @@ async function statusCommand() {
|
|
|
436
583
|
vcs: config.vcs.type,
|
|
437
584
|
"artifact-root": artifactRoot,
|
|
438
585
|
"current-jj-change": currentJjChange,
|
|
439
|
-
"active-work-count":
|
|
586
|
+
"active-work-count": workCounts.active,
|
|
587
|
+
"paused-work-count": workCounts.paused,
|
|
588
|
+
"cancelled-work-count": workCounts.cancelled,
|
|
589
|
+
"complete-work-count": workCounts.complete,
|
|
440
590
|
"next-step": "slopflow start <issue-id>",
|
|
441
591
|
});
|
|
442
592
|
return 0;
|
|
@@ -493,7 +643,7 @@ function buildStartArtifacts({ issue, issueReference, workDir, root, }) {
|
|
|
493
643
|
const contract = buildContract(issue, issueReference);
|
|
494
644
|
const status = {
|
|
495
645
|
schema_version: 1,
|
|
496
|
-
status: "
|
|
646
|
+
status: "active",
|
|
497
647
|
issue: issueReference,
|
|
498
648
|
work_directory: relative(root, workDir),
|
|
499
649
|
artifacts: {
|
|
@@ -798,13 +948,32 @@ function stableStringify(value) {
|
|
|
798
948
|
}
|
|
799
949
|
return JSON.stringify(value);
|
|
800
950
|
}
|
|
801
|
-
async function
|
|
951
|
+
async function countWorkDirsByStatus(workRoot) {
|
|
952
|
+
const counts = { active: 0, paused: 0, cancelled: 0, complete: 0 };
|
|
802
953
|
try {
|
|
803
954
|
const entries = await readdir(workRoot, { withFileTypes: true });
|
|
804
|
-
|
|
955
|
+
for (const entry of entries) {
|
|
956
|
+
if (!entry.isDirectory())
|
|
957
|
+
continue;
|
|
958
|
+
const statusPath = join(workRoot, entry.name, "status.json");
|
|
959
|
+
let status = "active";
|
|
960
|
+
if (existsSync(statusPath)) {
|
|
961
|
+
const value = readJson(statusPath);
|
|
962
|
+
status = value.status ?? "active";
|
|
963
|
+
}
|
|
964
|
+
if (status === "paused")
|
|
965
|
+
counts.paused += 1;
|
|
966
|
+
else if (status === "cancelled")
|
|
967
|
+
counts.cancelled += 1;
|
|
968
|
+
else if (status === "complete")
|
|
969
|
+
counts.complete += 1;
|
|
970
|
+
else
|
|
971
|
+
counts.active += 1;
|
|
972
|
+
}
|
|
973
|
+
return counts;
|
|
805
974
|
}
|
|
806
975
|
catch {
|
|
807
|
-
return
|
|
976
|
+
return counts;
|
|
808
977
|
}
|
|
809
978
|
}
|
|
810
979
|
function readCurrentJjChange(root) {
|
|
@@ -845,7 +1014,7 @@ function printBlock(name, values, stream = process.stdout) {
|
|
|
845
1014
|
}
|
|
846
1015
|
}
|
|
847
1016
|
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`);
|
|
1017
|
+
process.stdout.write(`Usage: slopflow <command>\n\nCommands:\n init [--force]\n status\n start <issue-id>\n pause <issue-id> --reason <text>\n resume <issue-id>\n cancel <issue-id> --reason <text>\n test <issue-id> --name <gate> -- <command...>\n review <issue-id>\n complete <issue-id>\n`);
|
|
849
1018
|
}
|
|
850
1019
|
if (process.argv[1] && realpathSync(fileURLToPath(import.meta.url)) === realpathSync(process.argv[1])) {
|
|
851
1020
|
process.exitCode = await main();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "slopflow",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
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",
|