dev-loops 0.3.0 → 0.5.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 (85) hide show
  1. package/.claude/.claude-plugin/plugin.json +1 -1
  2. package/.claude/agents/dev-loop.md +1 -1
  3. package/.claude/agents/refiner.md +1 -0
  4. package/.claude/hooks/_run-context.mjs +9 -16
  5. package/.claude/skills/copilot-pr-followup/SKILL.md +9 -7
  6. package/.claude/skills/dev-loop/SKILL.md +17 -7
  7. package/.claude/skills/dev-loop/templates/slides-story-review.md +54 -0
  8. package/.claude/skills/docs/anti-patterns.md +1 -1
  9. package/.claude/skills/docs/artifact-authority-contract.md +86 -31
  10. package/.claude/skills/docs/copilot-loop-operations.md +1 -1
  11. package/.claude/skills/docs/issue-intake-procedure.md +4 -0
  12. package/.claude/skills/docs/local-planning-flow.md +63 -0
  13. package/.claude/skills/docs/local-planning-worked-example.md +139 -0
  14. package/.claude/skills/docs/merge-preconditions.md +100 -1
  15. package/.claude/skills/docs/plan-file-contract.md +37 -0
  16. package/.claude/skills/docs/retrospective-checkpoint-contract.md +55 -7
  17. package/.claude/skills/docs/spike-mode-contract.md +237 -0
  18. package/.claude/skills/docs/stop-conditions.md +1 -0
  19. package/.claude/skills/docs/tracker-first-loop-state.md +6 -6
  20. package/.claude/skills/local-implementation/SKILL.md +8 -2
  21. package/CHANGELOG.md +69 -0
  22. package/README.md +9 -2
  23. package/agents/refiner.agent.md +1 -0
  24. package/cli/index.mjs +21 -2
  25. package/extension/README.md +1 -1
  26. package/package.json +6 -4
  27. package/scripts/README.md +2 -2
  28. package/scripts/github/capture-review-threads.mjs +20 -2
  29. package/scripts/github/offer-human-handoff.mjs +147 -0
  30. package/scripts/github/probe-ci-status.mjs +468 -0
  31. package/scripts/github/probe-copilot-review.mjs +69 -3
  32. package/scripts/github/request-copilot-review.mjs +3 -3
  33. package/scripts/github/resolve-handoff-candidates.mjs +412 -0
  34. package/scripts/github/upsert-checkpoint-verdict.mjs +20 -5
  35. package/scripts/lib/jq-output.mjs +297 -0
  36. package/scripts/loop/_stale-runner-detection.mjs +1 -1
  37. package/scripts/loop/_worktree-path.mjs +27 -0
  38. package/scripts/loop/check-retro-tooling.mjs +246 -0
  39. package/scripts/loop/cleanup-worktree.mjs +175 -0
  40. package/scripts/loop/copilot-pr-handoff.mjs +22 -4
  41. package/scripts/loop/detect-pr-gate-coordination-state.mjs +35 -2
  42. package/scripts/loop/detect-stale-runner.mjs +3 -4
  43. package/scripts/loop/docs-grill-contract.mjs +70 -0
  44. package/scripts/loop/ensure-worktree.mjs +219 -0
  45. package/scripts/loop/info.mjs +21 -2
  46. package/scripts/loop/outer-loop.mjs +1 -1
  47. package/scripts/loop/pr-runner-coordination.mjs +21 -5
  48. package/scripts/loop/pre-flight-gate.mjs +10 -7
  49. package/scripts/loop/pre-push-main-guard.mjs +4 -4
  50. package/scripts/loop/provision-worktree.mjs +243 -0
  51. package/scripts/loop/resolve-dev-loop-startup.mjs +181 -10
  52. package/scripts/loop/resolve-pr-conflicts.mjs +357 -0
  53. package/scripts/loop/run-queue.mjs +25 -1
  54. package/scripts/loop/run-watch-cycle.mjs +152 -25
  55. package/scripts/loop/slides-story-review-contract.mjs +123 -0
  56. package/scripts/pages/build-site.mjs +136 -0
  57. package/scripts/projects/add-queue-item.mjs +32 -7
  58. package/scripts/projects/archive-done-items.mjs +2 -1
  59. package/scripts/projects/ensure-queue-board.mjs +2 -1
  60. package/scripts/projects/list-queue-items.mjs +14 -3
  61. package/scripts/projects/move-queue-item.mjs +14 -3
  62. package/scripts/projects/reorder-queue-item.mjs +5 -4
  63. package/scripts/projects/sync-item-status.mjs +2 -1
  64. package/scripts/refine/_refine-helpers.mjs +20 -0
  65. package/scripts/refine/exit-spike.mjs +186 -0
  66. package/scripts/refine/promote-plan.mjs +387 -0
  67. package/scripts/refine/refine-plan-file.mjs +165 -0
  68. package/scripts/refine/validate-plan-file.mjs +64 -0
  69. package/scripts/refine/validate-spike-file.mjs +87 -0
  70. package/skills/copilot-pr-followup/SKILL.md +9 -7
  71. package/skills/dev-loop/SKILL.md +13 -3
  72. package/skills/dev-loop/templates/slides-story-review.md +54 -0
  73. package/skills/docs/anti-patterns.md +1 -1
  74. package/skills/docs/artifact-authority-contract.md +86 -31
  75. package/skills/docs/copilot-loop-operations.md +1 -1
  76. package/skills/docs/issue-intake-procedure.md +4 -0
  77. package/skills/docs/local-planning-flow.md +63 -0
  78. package/skills/docs/local-planning-worked-example.md +139 -0
  79. package/skills/docs/merge-preconditions.md +100 -1
  80. package/skills/docs/plan-file-contract.md +37 -0
  81. package/skills/docs/retrospective-checkpoint-contract.md +55 -7
  82. package/skills/docs/spike-mode-contract.md +237 -0
  83. package/skills/docs/stop-conditions.md +1 -0
  84. package/skills/docs/tracker-first-loop-state.md +6 -6
  85. package/skills/local-implementation/SKILL.md +8 -2
@@ -2,8 +2,10 @@
2
2
  import { formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
3
3
  import { runChild as _runChild } from "../_cli-primitives.mjs";
4
4
  import { parseArgs } from "node:util";
5
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
5
6
 
6
- const USAGE = `Usage: dev-loops project list --repo <owner/name> --project <number|id> [--column <name>] [--limit <n>]
7
+ const USAGE = `Usage: dev-loops queue list --repo <owner/name> --project <number|id> [--column <name>] [--limit <n>]
8
+ (dev-loops project list … is a back-compat alias)
7
9
 
8
10
  List GitHub Projects V2 items filtered by Status column, ordered by position
9
11
  ascending. Returns machine-readable JSON.
@@ -18,10 +20,12 @@ Options:
18
20
  Output (stdout):
19
21
  JSON: { ok: true, items: [{ issueNumber, prNumber, title, url, itemId, contentId, status }, ...] }
20
22
 
23
+ ${JQ_OUTPUT_USAGE}
24
+
21
25
  Exit codes:
22
26
  0 — success
23
27
  1 — usage or argument error
24
- 2 — GitHub API error
28
+ 2 — GitHub API error / invalid --jq filter
25
29
  3 — project, field, or column not found
26
30
  `.trim();
27
31
 
@@ -44,6 +48,7 @@ function parseCliArgs(argv) {
44
48
  column: { type: "string" },
45
49
  limit: { type: "string" },
46
50
  help: { type: "boolean", short: "h" },
51
+ ...JQ_OUTPUT_PARSE_OPTIONS,
47
52
  },
48
53
  allowPositionals: true,
49
54
  strict: false,
@@ -82,6 +87,12 @@ function parseCliArgs(argv) {
82
87
  args.limit = val;
83
88
  break;
84
89
  }
90
+ case "jq":
91
+ args.jq = requireValue(token, "--jq requires a filter");
92
+ break;
93
+ case "silent":
94
+ args.silent = true;
95
+ break;
85
96
  default:
86
97
  throw parseError(`Unknown flag: ${token.rawName}`);
87
98
  }
@@ -473,7 +484,7 @@ async function runCli(argv, { stdout = process.stdout, stderr = process.stderr,
473
484
  }
474
485
  try {
475
486
  const result = await main(args, { env });
476
- stdout.write(JSON.stringify(result) + "\n");
487
+ process.exitCode = emitResult(result, { jq: args.jq, silent: args.silent, stdout, stderr });
477
488
  } catch (err) {
478
489
  stderr.write(JSON.stringify({ ok: false, error: err.message, code: err.code ?? "UNKNOWN" }) + "\n");
479
490
  process.exitCode = classifyExitCode(err);
@@ -2,8 +2,10 @@
2
2
  import { formatCliError, isDirectCliRun, parseJsonText } from "../_core-helpers.mjs";
3
3
  import { runChild as _runChild } from "../_cli-primitives.mjs";
4
4
  import { parseArgs } from "node:util";
5
+ import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
5
6
 
6
- const USAGE = `Usage: dev-loops project move --repo <owner/name> --project <number|id> --item <number|node-id> --to-column <name>
7
+ const USAGE = `Usage: dev-loops queue move --repo <owner/name> --project <number|id> --item <number|node-id> --to-column <name>
8
+ (dev-loops project move … is a back-compat alias)
7
9
 
8
10
  Move a GitHub Projects V2 item between Status columns.
9
11
 
@@ -17,10 +19,12 @@ Options:
17
19
  Output (stdout):
18
20
  JSON: { ok: true, item: { itemId, issueNumber, prNumber, previousColumn, newColumn } }
19
21
 
22
+ ${JQ_OUTPUT_USAGE}
23
+
20
24
  Exit codes:
21
25
  0 — success
22
26
  1 — usage or argument error
23
- 2 — GitHub API error
27
+ 2 — GitHub API error / invalid --jq filter
24
28
  3 — project, field, column, or item not found
25
29
  `.trim();
26
30
 
@@ -43,6 +47,7 @@ function parseCliArgs(argv) {
43
47
  item: { type: "string" },
44
48
  "to-column": { type: "string" },
45
49
  help: { type: "boolean", short: "h" },
50
+ ...JQ_OUTPUT_PARSE_OPTIONS,
46
51
  },
47
52
  allowPositionals: true,
48
53
  strict: false,
@@ -75,6 +80,12 @@ function parseCliArgs(argv) {
75
80
  case "to-column":
76
81
  args.toColumn = requireValue(token, "--to-column requires a value");
77
82
  break;
83
+ case "jq":
84
+ args.jq = requireValue(token, "--jq requires a filter");
85
+ break;
86
+ case "silent":
87
+ args.silent = true;
88
+ break;
78
89
  default:
79
90
  throw parseError(`Unknown flag: ${token.rawName}`);
80
91
  }
@@ -531,7 +542,7 @@ async function runCli(argv, { stdout = process.stdout, stderr = process.stderr,
531
542
  }
532
543
  try {
533
544
  const result = await main(args, { env });
534
- stdout.write(JSON.stringify(result) + "\n");
545
+ process.exitCode = emitResult(result, { jq: args.jq, silent: args.silent, stdout, stderr });
535
546
  } catch (err) {
536
547
  stderr.write(JSON.stringify({ ok: false, error: err.message, code: err.code ?? "UNKNOWN" }) + "\n");
537
548
  process.exitCode = classifyExitCode(err);
@@ -4,10 +4,11 @@ import { runChild as _runChild } from "../_cli-primitives.mjs";
4
4
  import { parseArgs } from "node:util";
5
5
 
6
6
  const USAGE = `Usage:
7
- dev-loops project reorder --repo <owner/name> --project <number|id> --item <number|node-id> [--after <number|node-id>]
8
- dev-loops project reorder move-to-top <ref> --repo <owner/name> --project <number|id>
9
- dev-loops project reorder move-after <ref> <after-ref> --repo <owner/name> --project <number|id>
10
- dev-loops project reorder order <ref1> <ref2> ... --repo <owner/name> --project <number|id>
7
+ dev-loops queue reorder --repo <owner/name> --project <number|id> --item <number|node-id> [--after <number|node-id>]
8
+ dev-loops queue reorder move-to-top <ref> --repo <owner/name> --project <number|id>
9
+ dev-loops queue reorder move-after <ref> <after-ref> --repo <owner/name> --project <number|id>
10
+ dev-loops queue reorder order <ref1> <ref2> ... --repo <owner/name> --project <number|id>
11
+ (dev-loops project reorder … is a back-compat alias)
11
12
 
12
13
  Reorder GitHub Projects V2 items by board position via updateProjectV2ItemPosition.
13
14
 
@@ -4,7 +4,8 @@ import { runChild as _runChild } from "../_cli-primitives.mjs";
4
4
  import { syncBoardStatus } from "@dev-loops/core/loop/queue-board-sync";
5
5
  import { parseArgs } from "node:util";
6
6
 
7
- const USAGE = `Usage: dev-loops project sync-status --repo <owner/name> --item <number> --to-column <name>
7
+ const USAGE = `Usage: dev-loops queue sync-status --repo <owner/name> --item <number> --to-column <name>
8
+ (dev-loops project sync-status … is a back-compat alias)
8
9
 
9
10
  Sync a queued issue/PR's board Status column on a dev-loop transition (e.g.
10
11
  PR opened → "In Progress", merged → "Done"). Resolves the board from .devloops
@@ -233,6 +233,26 @@ export function extractSection(body, headingText) {
233
233
  return body.slice(start, end).trim();
234
234
  }
235
235
 
236
+ /**
237
+ * Shared base-section checker for the phase-doc-format validators (plan + spike).
238
+ * For each heading, reports its distinct missing_* code when the section is
239
+ * absent or has an empty body. Pure; no side effects.
240
+ *
241
+ * @param {string} markdownText
242
+ * @param {string} checker checker name echoed back in the result
243
+ * @param {Record<string,string>} sectionCodes heading → missing_* code (key order = section order)
244
+ * @returns {{ checker: string, ok: boolean, errors: { code: string, message: string }[] }}
245
+ */
246
+ export function checkBaseSections(markdownText, checker, sectionCodes) {
247
+ const errors = [];
248
+ for (const [heading, code] of Object.entries(sectionCodes)) {
249
+ if (!extractSection(markdownText, heading)) {
250
+ errors.push({ code, message: `Missing or empty ## ${heading} section.` });
251
+ }
252
+ }
253
+ return { checker, ok: errors.length === 0, errors };
254
+ }
255
+
236
256
  export function normalizeScopeToken(value) {
237
257
  return String(value ?? "")
238
258
  .trim()
@@ -0,0 +1,186 @@
1
+ #!/usr/bin/env node
2
+ import { readFile, writeFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { parseArgs } from "node:util";
5
+
6
+ import { buildParseError, formatCliError } from "../_core-helpers.mjs";
7
+ import { requireTokenValue } from "../_cli-primitives.mjs";
8
+ import { extractSection, isDirectCliRun } from "./_refine-helpers.mjs";
9
+ import {
10
+ validateSpikeExplorationSections,
11
+ SPIKE_FILE_EXIT_MARKER_SECTION,
12
+ } from "./validate-spike-file.mjs";
13
+ import { evaluateSpikeIntakeState } from "@dev-loops/core/loop/spike-intake-contract";
14
+ import {
15
+ evaluateSpikeExit,
16
+ buildGraduatedPlanBody,
17
+ SPIKE_EXIT_ACTION,
18
+ SPIKE_EXIT_DISPOSITION,
19
+ } from "@dev-loops/core/loop/spike-exit-contract";
20
+
21
+ const USAGE = `Usage:
22
+ exit-spike.mjs --spike-file <path> --disposition <discard|graduate> [--plan-file <path>] [--json]
23
+ Conclude a spike that has reached a Recommendation (spike_ready_for_exit).
24
+ discard The recommendation is "don't pursue": drop the spike with ZERO
25
+ tracker artifacts. The findings doc is the whole record.
26
+ graduate Emit a #947-consumable local-first plan file (Status/Objective/
27
+ In scope/Explicit non-goals) built from the spike's
28
+ Question/Approach/Findings/Recommendation. The plan then enters the
29
+ existing plan->PR promotion path (scripts/refine/promote-plan.mjs).
30
+ --plan-file is required and graduation is idempotent (re-running
31
+ reproduces the same plan file).
32
+
33
+ Fail-closed: a spike that is not ready for exit (no Recommendation) or an
34
+ unknown disposition makes ZERO tracker mutation and exits 1. No GitHub artifact
35
+ is created on any path; graduation only writes a local plan file.
36
+
37
+ Required:
38
+ --spike-file <path> Path to the spike findings artifact
39
+ --disposition <which> discard | graduate
40
+ Optional:
41
+ --plan-file <path> Output plan-file path (required for graduate)
42
+ --json Machine-readable JSON output
43
+ --help Show this help
44
+ Exit codes:
45
+ 0 Exit succeeded (discard recorded; or plan file written for graduate)
46
+ 1 Argument error, or fail-closed (not ready / unknown disposition)`.trim();
47
+
48
+ const parseError = buildParseError(USAGE);
49
+
50
+ export function parseExitSpikeCliArgs(argv) {
51
+ const { tokens } = parseArgs({
52
+ args: [...argv],
53
+ options: {
54
+ help: { type: "boolean", short: "h" },
55
+ "spike-file": { type: "string" },
56
+ disposition: { type: "string" },
57
+ "plan-file": { type: "string" },
58
+ json: { type: "boolean" },
59
+ },
60
+ allowPositionals: true,
61
+ strict: false,
62
+ tokens: true,
63
+ });
64
+ const options = { help: false, spikeFile: undefined, disposition: undefined, planFile: undefined, json: false };
65
+ for (const token of tokens) {
66
+ if (token.kind === "positional") {
67
+ throw parseError(`Unknown argument: ${token.value}`);
68
+ }
69
+ if (token.kind !== "option") continue;
70
+ if (token.name === "help") {
71
+ options.help = true;
72
+ return options;
73
+ }
74
+ if (token.name === "spike-file") {
75
+ options.spikeFile = requireTokenValue(token, parseError, { flagPattern: /^-/u });
76
+ continue;
77
+ }
78
+ if (token.name === "disposition") {
79
+ options.disposition = requireTokenValue(token, parseError, { flagPattern: /^-/u });
80
+ continue;
81
+ }
82
+ if (token.name === "plan-file") {
83
+ options.planFile = requireTokenValue(token, parseError, { flagPattern: /^-/u });
84
+ continue;
85
+ }
86
+ if (token.name === "json") {
87
+ options.json = true;
88
+ continue;
89
+ }
90
+ throw parseError(`Unknown argument: ${token.rawName}`);
91
+ }
92
+ if (typeof options.spikeFile !== "string" || options.spikeFile.trim().length === 0) {
93
+ throw parseError("exit-spike requires --spike-file <path>");
94
+ }
95
+ if (typeof options.disposition !== "string" || options.disposition.trim().length === 0) {
96
+ throw parseError("exit-spike requires --disposition <discard|graduate>");
97
+ }
98
+ // graduate emits a plan file; the destination must be explicit so we never
99
+ // guess a path. discard writes nothing, so --plan-file is irrelevant there.
100
+ if (options.disposition === SPIKE_EXIT_DISPOSITION.GRADUATE
101
+ && (typeof options.planFile !== "string" || options.planFile.trim().length === 0)) {
102
+ throw parseError("exit-spike --disposition graduate requires --plan-file <path>");
103
+ }
104
+ return options;
105
+ }
106
+
107
+ function emit(stdout, json, summary, humanLines) {
108
+ if (json) {
109
+ stdout.write(`${JSON.stringify(summary)}\n`);
110
+ } else {
111
+ stdout.write(`${humanLines.join("\n")}\n`);
112
+ }
113
+ }
114
+
115
+ export async function runCli(argv = process.argv.slice(2), { stdout = process.stdout } = {}) {
116
+ const options = parseExitSpikeCliArgs(argv);
117
+ if (options.help) {
118
+ stdout.write(`${USAGE}\n`);
119
+ return { ok: true, help: true };
120
+ }
121
+
122
+ const spikePath = path.resolve(options.spikeFile);
123
+ const markdownText = await readFile(spikePath, "utf8");
124
+
125
+ // Compute the intake-state facts with the P1 surfaces, then hand them to the
126
+ // pure exit decision. Per spike-intake-contract, `baseSectionsValid` is the
127
+ // exploration scaffold (Question/Approach/Findings); the Recommendation is the
128
+ // separate exit-marker that flips in-progress -> ready-for-exit. So a
129
+ // scaffold-valid spike without a Recommendation classifies as in-progress
130
+ // (fail-closed reason `not_ready_for_exit`), not ambiguous.
131
+ const baseSectionsValid = validateSpikeExplorationSections(markdownText).ok;
132
+ const hasRecommendation = Boolean(extractSection(markdownText, SPIKE_FILE_EXIT_MARKER_SECTION));
133
+ const { state: spikeIntakeState } = evaluateSpikeIntakeState({ baseSectionsValid, hasRecommendation });
134
+ const decision = evaluateSpikeExit({ spikeIntakeState, disposition: options.disposition });
135
+
136
+ if (!decision.ok) {
137
+ // Fail closed: no tracker artifact, no plan file — surface the reason.
138
+ const summary = { ok: false, reason: decision.reason, spikeIntakeState: decision.spikeIntakeState ?? spikeIntakeState };
139
+ emit(stdout, options.json, summary, [`exit-spike: FAIL (${decision.reason})`]);
140
+ process.exitCode = 1;
141
+ return summary;
142
+ }
143
+
144
+ // DISCARD: zero artifacts. The findings doc on disk is the whole record.
145
+ if (decision.action === SPIKE_EXIT_ACTION.DISCARD) {
146
+ const summary = { ok: true, action: SPIKE_EXIT_ACTION.DISCARD, spikeFile: spikePath };
147
+ emit(stdout, options.json, summary, [
148
+ "exit-spike: PASS (discard)",
149
+ ` spike: ${spikePath}`,
150
+ " no tracker artifact created (findings doc is the record)",
151
+ ]);
152
+ return summary;
153
+ }
154
+
155
+ // GRADUATE: build a base-valid plan body from the spike sections and write it.
156
+ // Pure builder owns the section mapping + fail-closed on empty sections; this
157
+ // script owns the file I/O only. Overwriting the same path is idempotent.
158
+ const planPath = path.resolve(options.planFile);
159
+ const planBody = buildGraduatedPlanBody({
160
+ question: extractSection(markdownText, "Question"),
161
+ approach: extractSection(markdownText, "Approach"),
162
+ findings: extractSection(markdownText, "Findings"),
163
+ recommendation: extractSection(markdownText, SPIKE_FILE_EXIT_MARKER_SECTION),
164
+ });
165
+ await writeFile(planPath, planBody, "utf8");
166
+
167
+ const summary = {
168
+ ok: true,
169
+ action: SPIKE_EXIT_ACTION.GRADUATE,
170
+ spikeFile: spikePath,
171
+ planFile: planPath,
172
+ };
173
+ emit(stdout, options.json, summary, [
174
+ "exit-spike: PASS (graduate)",
175
+ ` spike: ${spikePath}`,
176
+ ` plan: ${planPath} (local-first; promote with scripts/refine/promote-plan.mjs)`,
177
+ ]);
178
+ return summary;
179
+ }
180
+
181
+ if (isDirectCliRun(import.meta.url)) {
182
+ runCli().catch((error) => {
183
+ process.stderr.write(`${formatCliError(error)}\n`);
184
+ process.exitCode = 1;
185
+ });
186
+ }