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.
Files changed (3) hide show
  1. package/README.md +16 -1
  2. package/dist/cli.js +178 -9
  3. 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
- Once Slopflow is published to npm, install the CLI globally:
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 === "test" ? "capturing test evidence" : command === "review" ? "preparing review" : "completing work"}.`, 2);
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 { issue: workStatus.issue };
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 activeWorkCount = await countWorkDirs(workRoot);
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": activeWorkCount,
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: "started",
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 countWorkDirs(workRoot) {
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
- return entries.filter((entry) => entry.isDirectory()).length;
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 0;
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.1.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": "./dist/cli.js"
24
+ "slopflow": "dist/cli.js"
25
25
  },
26
26
  "files": [
27
27
  "dist",