dev-loops 0.4.0 → 0.6.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/.claude/.claude-plugin/plugin.json +1 -1
- package/.claude/agents/dev-loop.md +1 -1
- package/.claude/agents/refiner.md +1 -0
- package/.claude/commands/auto.md +7 -0
- package/.claude/commands/continue.md +15 -0
- package/.claude/commands/info.md +7 -0
- package/.claude/commands/start-spike.md +16 -0
- package/.claude/commands/start.md +7 -0
- package/.claude/commands/status.md +6 -0
- package/.claude/hooks/_run-context.mjs +11 -4
- package/.claude/skills/dev-loop/SKILL.md +21 -6
- package/.claude/skills/dev-loop/templates/slides-story-review.md +54 -0
- package/.claude/skills/docs/artifact-authority-contract.md +86 -31
- package/.claude/skills/docs/local-planning-flow.md +63 -0
- package/.claude/skills/docs/local-planning-worked-example.md +139 -0
- package/.claude/skills/docs/merge-preconditions.md +35 -0
- package/.claude/skills/docs/plan-file-contract.md +37 -0
- package/.claude/skills/docs/release-runbook.md +45 -0
- package/.claude/skills/docs/retrospective-checkpoint-contract.md +55 -7
- package/.claude/skills/docs/spike-mode-contract.md +237 -0
- package/.claude/skills/docs/ui-e2e-scoping-step.md +102 -0
- package/.claude/skills/local-implementation/SKILL.md +1 -1
- package/CHANGELOG.md +73 -0
- package/README.md +21 -1
- package/agents/dev-loop.agent.md +8 -1
- package/agents/refiner.agent.md +1 -0
- package/cli/index.mjs +2 -0
- package/extension/index.ts +10 -1
- package/extension/presentation.ts +15 -0
- package/lib/dev-loops-core.mjs +141 -0
- package/package.json +8 -3
- package/scripts/claude/generate-claude-assets.mjs +15 -1
- package/scripts/github/capture-review-threads.mjs +20 -2
- package/scripts/github/comment-issue.mjs +181 -0
- package/scripts/github/fetch-ci-logs.mjs +215 -0
- package/scripts/github/list-issues.mjs +191 -0
- package/scripts/github/probe-copilot-review.mjs +69 -3
- package/scripts/github/upsert-checkpoint-verdict.mjs +18 -3
- package/scripts/lib/jq-output.mjs +297 -0
- package/scripts/loop/_handoff-contract.mjs +1 -0
- package/scripts/loop/check-retro-tooling.mjs +246 -0
- package/scripts/loop/copilot-pr-handoff.mjs +21 -3
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +65 -2
- package/scripts/loop/docs-grill-contract.mjs +70 -0
- package/scripts/loop/info.mjs +21 -2
- package/scripts/loop/pr-runner-coordination.mjs +20 -4
- package/scripts/loop/resolve-dev-loop-startup.mjs +176 -5
- package/scripts/loop/resolve-pr-conflicts.mjs +357 -0
- package/scripts/loop/run-conductor-cycle.mjs +5 -0
- package/scripts/loop/run-watch-cycle.mjs +77 -3
- package/scripts/loop/slides-story-review-contract.mjs +123 -0
- package/scripts/pages/build-site.mjs +136 -0
- package/scripts/projects/add-queue-item.mjs +12 -2
- package/scripts/projects/list-queue-items.mjs +12 -2
- package/scripts/projects/move-queue-item.mjs +12 -2
- package/scripts/projects/resolve-active-board-item.mjs +193 -0
- package/scripts/refine/_refine-helpers.mjs +20 -0
- package/scripts/refine/exit-spike.mjs +186 -0
- package/scripts/refine/promote-plan.mjs +387 -0
- package/scripts/refine/refine-plan-file.mjs +165 -0
- package/scripts/refine/scaffold-spike-file.mjs +183 -0
- package/scripts/refine/validate-plan-file.mjs +64 -0
- package/scripts/refine/validate-spike-file.mjs +87 -0
- package/scripts/release/extract-changelog-section.mjs +111 -0
- package/skills/dev-loop/SKILL.md +24 -2
- package/skills/dev-loop/templates/slides-story-review.md +54 -0
- package/skills/docs/artifact-authority-contract.md +86 -31
- package/skills/docs/local-planning-flow.md +63 -0
- package/skills/docs/local-planning-worked-example.md +139 -0
- package/skills/docs/merge-preconditions.md +35 -0
- package/skills/docs/plan-file-contract.md +37 -0
- package/skills/docs/release-runbook.md +45 -0
- package/skills/docs/retrospective-checkpoint-contract.md +55 -7
- package/skills/docs/spike-mode-contract.md +237 -0
- package/skills/docs/ui-e2e-scoping-step.md +102 -0
- package/skills/local-implementation/SKILL.md +1 -1
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Assembles the GitHub Pages publishable dir deterministically. The landing
|
|
3
|
+
// page (index.html) is the "Introducing dev-loops" article; the deep-dive
|
|
4
|
+
// article and the deep-dive deck are published alongside it and reached through
|
|
5
|
+
// a shared navigation bar injected into the article pages. The source HTML files
|
|
6
|
+
// under docs/ are the source of truth; site/ is assembled, never hand-maintained.
|
|
7
|
+
// Usage: node scripts/pages/build-site.mjs [--out <dir>] [--repo-root <dir>]
|
|
8
|
+
import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
9
|
+
import { dirname, join, resolve, parse as parsePath } from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
|
|
12
|
+
const REPO_ROOT_DEFAULT = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
13
|
+
|
|
14
|
+
// The landing page: the intro article, published as index.html. file is
|
|
15
|
+
// relative to docs/articles/.
|
|
16
|
+
export const LANDING = { file: 'introducing-dev-loops.html' };
|
|
17
|
+
|
|
18
|
+
// The deep-dive article published alongside the landing page. file is relative
|
|
19
|
+
// to docs/articles/; navLabel is how the nav refers to it.
|
|
20
|
+
export const ARTICLES = [
|
|
21
|
+
{ file: 'dev-loops-deep-dive.html', navLabel: 'Deep dive' },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
// The decks to publish. file is relative to docs/presentations/; outFile is the
|
|
25
|
+
// published name (defaults to file). The deep-dive article and deck share the
|
|
26
|
+
// source basename dev-loops-deep-dive.html under different docs/ dirs, so the
|
|
27
|
+
// deck publishes under a distinct name to avoid clobbering the article in site/.
|
|
28
|
+
export const DECKS = [
|
|
29
|
+
{
|
|
30
|
+
file: 'introducing-dev-loops.html',
|
|
31
|
+
title: 'Introducing dev-loops',
|
|
32
|
+
subtitle: 'A coordination runtime for AI-assisted development',
|
|
33
|
+
description: 'The concept, the data behind it, and how to run the loop on your own project.',
|
|
34
|
+
navLabel: 'Intro (deck)',
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
file: 'dev-loops-deep-dive.html',
|
|
38
|
+
outFile: 'dev-loops-deep-dive-deck.html',
|
|
39
|
+
title: 'dev-loops: A Deep Dive',
|
|
40
|
+
subtitle: 'Coordination delay and the waiting between actions',
|
|
41
|
+
description: 'How explicit handoffs on a state graph and measuring the wait between actions cut delivery delay.',
|
|
42
|
+
navLabel: 'Deep dive (deck)',
|
|
43
|
+
},
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
// Resolve a deck's published filename: distinct outFile when set, else file.
|
|
47
|
+
const deckOut = (deck) => deck.outFile ?? deck.file;
|
|
48
|
+
|
|
49
|
+
// The other resources linked from the navigation, in order.
|
|
50
|
+
export const NAV_LINKS = [
|
|
51
|
+
...ARTICLES.map((a) => ({ file: a.file, label: a.navLabel })),
|
|
52
|
+
...DECKS.map((d) => ({ file: deckOut(d), label: d.navLabel })),
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
// Nav styling, appended to each article page's own <style> block so it reuses
|
|
56
|
+
// the article design-system variables (--heading/--kicker/--accent-soft).
|
|
57
|
+
const NAV_CSS = `
|
|
58
|
+
.site-nav { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.5rem 1.1rem; max-width: 64rem; margin: 0 auto; padding: 0.9rem clamp(1.1rem, 5vw, 2rem); border-bottom: 1px solid rgba(148, 163, 184, 0.16); }
|
|
59
|
+
.site-nav-brand { font-weight: 700; letter-spacing: -0.01em; color: var(--heading); text-decoration: none; border: 0; margin-right: auto; }
|
|
60
|
+
.site-nav-links { display: flex; flex-wrap: wrap; gap: 0.5rem 1.1rem; }
|
|
61
|
+
.site-nav a { color: var(--kicker); text-decoration: none; font-size: 0.9rem; border: 0; }
|
|
62
|
+
.site-nav a:hover { color: var(--accent-soft); }`;
|
|
63
|
+
|
|
64
|
+
function navMarkup() {
|
|
65
|
+
const links = NAV_LINKS.map((l) => ` <a href="${l.file}">${l.label}</a>`).join('\n');
|
|
66
|
+
return `<nav class="site-nav" aria-label="dev-loops resources">
|
|
67
|
+
<a class="site-nav-brand" href="index.html">dev-loops</a>
|
|
68
|
+
<div class="site-nav-links">
|
|
69
|
+
${links}
|
|
70
|
+
</div>
|
|
71
|
+
</nav>`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Inject the shared nav into an article page: nav CSS before </style>, nav
|
|
75
|
+
// markup right after <body>. Idempotent enough for assembly (each source file
|
|
76
|
+
// is read once). Throws if the page lacks the expected anchors so a structural
|
|
77
|
+
// drift fails the build rather than publishing an un-navigable page.
|
|
78
|
+
export function injectNav(html) {
|
|
79
|
+
if (!html.includes('</style>') || !/<body[^>]*>/.test(html)) {
|
|
80
|
+
throw new Error('cannot inject nav: page is missing a <style> block or <body> tag');
|
|
81
|
+
}
|
|
82
|
+
return html
|
|
83
|
+
.replace('</style>', `${NAV_CSS}\n</style>`)
|
|
84
|
+
.replace(/<body([^>]*)>/, `<body$1>\n ${navMarkup()}`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function buildSite({ repoRoot = REPO_ROOT_DEFAULT, outDir } = {}) {
|
|
88
|
+
const out = outDir ? resolve(outDir) : join(repoRoot, 'site');
|
|
89
|
+
const articlesDir = join(repoRoot, 'docs', 'articles');
|
|
90
|
+
const decksDir = join(repoRoot, 'docs', 'presentations');
|
|
91
|
+
|
|
92
|
+
// Guard: out is wiped before assembly. Refuse paths that would nuke the
|
|
93
|
+
// filesystem root, the repo itself, or an ancestor of the repo.
|
|
94
|
+
const root = resolve(repoRoot);
|
|
95
|
+
const isAncestorOf = (a, b) => b === a || b.startsWith(a + '/');
|
|
96
|
+
if (out === parsePath(out).root || out === root || isAncestorOf(out, root)) {
|
|
97
|
+
throw new Error(`refusing to wipe unsafe output dir ${out}`);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
await rm(out, { recursive: true, force: true });
|
|
101
|
+
await mkdir(out, { recursive: true });
|
|
102
|
+
|
|
103
|
+
// Landing page: the intro article, navigable, published as index.html.
|
|
104
|
+
const landingHtml = await readFile(join(articlesDir, LANDING.file), 'utf8');
|
|
105
|
+
await writeFile(join(out, 'index.html'), injectNav(landingHtml), 'utf8');
|
|
106
|
+
|
|
107
|
+
// Deep-dive article: published with the same nav so the set is navigable.
|
|
108
|
+
for (const article of ARTICLES) {
|
|
109
|
+
const html = await readFile(join(articlesDir, article.file), 'utf8');
|
|
110
|
+
await writeFile(join(out, article.file), injectNav(html), 'utf8');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Decks: self-contained slide renders, copied as-is (no nav injection).
|
|
114
|
+
for (const deck of DECKS) {
|
|
115
|
+
await cp(join(decksDir, deck.file), join(out, deckOut(deck)));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
out,
|
|
120
|
+
files: ['index.html', ...ARTICLES.map((a) => a.file), ...DECKS.map((d) => deckOut(d))],
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const invokedDirectly = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
125
|
+
if (invokedDirectly) {
|
|
126
|
+
const args = process.argv.slice(2);
|
|
127
|
+
const getArg = (name) => {
|
|
128
|
+
const i = args.indexOf(name);
|
|
129
|
+
return i >= 0 ? args[i + 1] : undefined;
|
|
130
|
+
};
|
|
131
|
+
const result = await buildSite({
|
|
132
|
+
repoRoot: getArg('--repo-root') ? resolve(getArg('--repo-root')) : undefined,
|
|
133
|
+
outDir: getArg('--out'),
|
|
134
|
+
});
|
|
135
|
+
console.log(`Built site at ${result.out}: ${result.files.join(', ')}`);
|
|
136
|
+
}
|
|
@@ -2,6 +2,7 @@
|
|
|
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
7
|
const USAGE = `Usage: dev-loops queue add --repo <owner/name> --project <number|id> --item <number>
|
|
7
8
|
dev-loops project add … (back-compat alias for "queue add")
|
|
@@ -19,10 +20,12 @@ Options:
|
|
|
19
20
|
Output (stdout):
|
|
20
21
|
JSON: { ok: true, item: { itemId, issueNumber, prNumber, status, alreadyPresent } }
|
|
21
22
|
|
|
23
|
+
${JQ_OUTPUT_USAGE}
|
|
24
|
+
|
|
22
25
|
Exit codes:
|
|
23
26
|
0 — success (or no-op when already present)
|
|
24
27
|
1 — usage or argument error
|
|
25
|
-
2 — GitHub API error
|
|
28
|
+
2 — GitHub API error / invalid --jq filter
|
|
26
29
|
3 — project, field, column, or issue/PR not found
|
|
27
30
|
`.trim();
|
|
28
31
|
|
|
@@ -46,6 +49,7 @@ function parseCliArgs(argv) {
|
|
|
46
49
|
column: { type: "string" },
|
|
47
50
|
status: { type: "string" },
|
|
48
51
|
help: { type: "boolean", short: "h" },
|
|
52
|
+
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
49
53
|
},
|
|
50
54
|
allowPositionals: true,
|
|
51
55
|
strict: false,
|
|
@@ -93,6 +97,12 @@ function parseCliArgs(argv) {
|
|
|
93
97
|
// resolved by argv order.
|
|
94
98
|
args.status = requireValue(token, "--status requires a value");
|
|
95
99
|
break;
|
|
100
|
+
case "jq":
|
|
101
|
+
args.jq = requireValue(token, "--jq requires a filter");
|
|
102
|
+
break;
|
|
103
|
+
case "silent":
|
|
104
|
+
args.silent = true;
|
|
105
|
+
break;
|
|
96
106
|
default:
|
|
97
107
|
throw Object.assign(new Error(`Unknown flag: ${token.rawName}`), { code: "INVALID_ARGS", usage: USAGE });
|
|
98
108
|
}
|
|
@@ -543,7 +553,7 @@ async function runCli(argv, { stdout = process.stdout, stderr = process.stderr,
|
|
|
543
553
|
}
|
|
544
554
|
try {
|
|
545
555
|
const result = await main(args, { env });
|
|
546
|
-
|
|
556
|
+
process.exitCode = emitResult(result, { jq: args.jq, silent: args.silent, stdout, stderr });
|
|
547
557
|
} catch (err) {
|
|
548
558
|
stderr.write(JSON.stringify({ ok: false, error: err.message, code: err.code ?? "UNKNOWN" }) + "\n");
|
|
549
559
|
process.exitCode = classifyExitCode(err);
|
|
@@ -2,6 +2,7 @@
|
|
|
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
7
|
const USAGE = `Usage: dev-loops queue list --repo <owner/name> --project <number|id> [--column <name>] [--limit <n>]
|
|
7
8
|
(dev-loops project list … is a back-compat alias)
|
|
@@ -19,10 +20,12 @@ Options:
|
|
|
19
20
|
Output (stdout):
|
|
20
21
|
JSON: { ok: true, items: [{ issueNumber, prNumber, title, url, itemId, contentId, status }, ...] }
|
|
21
22
|
|
|
23
|
+
${JQ_OUTPUT_USAGE}
|
|
24
|
+
|
|
22
25
|
Exit codes:
|
|
23
26
|
0 — success
|
|
24
27
|
1 — usage or argument error
|
|
25
|
-
2 — GitHub API error
|
|
28
|
+
2 — GitHub API error / invalid --jq filter
|
|
26
29
|
3 — project, field, or column not found
|
|
27
30
|
`.trim();
|
|
28
31
|
|
|
@@ -45,6 +48,7 @@ function parseCliArgs(argv) {
|
|
|
45
48
|
column: { type: "string" },
|
|
46
49
|
limit: { type: "string" },
|
|
47
50
|
help: { type: "boolean", short: "h" },
|
|
51
|
+
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
48
52
|
},
|
|
49
53
|
allowPositionals: true,
|
|
50
54
|
strict: false,
|
|
@@ -83,6 +87,12 @@ function parseCliArgs(argv) {
|
|
|
83
87
|
args.limit = val;
|
|
84
88
|
break;
|
|
85
89
|
}
|
|
90
|
+
case "jq":
|
|
91
|
+
args.jq = requireValue(token, "--jq requires a filter");
|
|
92
|
+
break;
|
|
93
|
+
case "silent":
|
|
94
|
+
args.silent = true;
|
|
95
|
+
break;
|
|
86
96
|
default:
|
|
87
97
|
throw parseError(`Unknown flag: ${token.rawName}`);
|
|
88
98
|
}
|
|
@@ -474,7 +484,7 @@ async function runCli(argv, { stdout = process.stdout, stderr = process.stderr,
|
|
|
474
484
|
}
|
|
475
485
|
try {
|
|
476
486
|
const result = await main(args, { env });
|
|
477
|
-
|
|
487
|
+
process.exitCode = emitResult(result, { jq: args.jq, silent: args.silent, stdout, stderr });
|
|
478
488
|
} catch (err) {
|
|
479
489
|
stderr.write(JSON.stringify({ ok: false, error: err.message, code: err.code ?? "UNKNOWN" }) + "\n");
|
|
480
490
|
process.exitCode = classifyExitCode(err);
|
|
@@ -2,6 +2,7 @@
|
|
|
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
7
|
const USAGE = `Usage: dev-loops queue move --repo <owner/name> --project <number|id> --item <number|node-id> --to-column <name>
|
|
7
8
|
(dev-loops project move … is a back-compat alias)
|
|
@@ -18,10 +19,12 @@ Options:
|
|
|
18
19
|
Output (stdout):
|
|
19
20
|
JSON: { ok: true, item: { itemId, issueNumber, prNumber, previousColumn, newColumn } }
|
|
20
21
|
|
|
22
|
+
${JQ_OUTPUT_USAGE}
|
|
23
|
+
|
|
21
24
|
Exit codes:
|
|
22
25
|
0 — success
|
|
23
26
|
1 — usage or argument error
|
|
24
|
-
2 — GitHub API error
|
|
27
|
+
2 — GitHub API error / invalid --jq filter
|
|
25
28
|
3 — project, field, column, or item not found
|
|
26
29
|
`.trim();
|
|
27
30
|
|
|
@@ -44,6 +47,7 @@ function parseCliArgs(argv) {
|
|
|
44
47
|
item: { type: "string" },
|
|
45
48
|
"to-column": { type: "string" },
|
|
46
49
|
help: { type: "boolean", short: "h" },
|
|
50
|
+
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
47
51
|
},
|
|
48
52
|
allowPositionals: true,
|
|
49
53
|
strict: false,
|
|
@@ -76,6 +80,12 @@ function parseCliArgs(argv) {
|
|
|
76
80
|
case "to-column":
|
|
77
81
|
args.toColumn = requireValue(token, "--to-column requires a value");
|
|
78
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;
|
|
79
89
|
default:
|
|
80
90
|
throw parseError(`Unknown flag: ${token.rawName}`);
|
|
81
91
|
}
|
|
@@ -532,7 +542,7 @@ async function runCli(argv, { stdout = process.stdout, stderr = process.stderr,
|
|
|
532
542
|
}
|
|
533
543
|
try {
|
|
534
544
|
const result = await main(args, { env });
|
|
535
|
-
|
|
545
|
+
process.exitCode = emitResult(result, { jq: args.jq, silent: args.silent, stdout, stderr });
|
|
536
546
|
} catch (err) {
|
|
537
547
|
stderr.write(JSON.stringify({ ok: false, error: err.message, code: err.code ?? "UNKNOWN" }) + "\n");
|
|
538
548
|
process.exitCode = classifyExitCode(err);
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Collapse the board's "In Progress" column to a SINGLE continue target (#988 P1).
|
|
3
|
+
//
|
|
4
|
+
// Pure list->single-target collapse with NO routing opinions: it lists the
|
|
5
|
+
// In-Progress items via list-queue-items.mjs and either returns the lone target
|
|
6
|
+
// or FAILS CLOSED. It never guesses among multiple active items.
|
|
7
|
+
//
|
|
8
|
+
// exactly one -> { ok: true, target: { kind: "issue"|"pr", number } }
|
|
9
|
+
// zero -> { ok: false, reason: "..." } (no in-progress item)
|
|
10
|
+
// multiple -> { ok: false, reason: "..." } (names the items)
|
|
11
|
+
//
|
|
12
|
+
// Downstream (the dev-loop skill) resolves authoritative state from this number;
|
|
13
|
+
// this helper deliberately makes no further decisions.
|
|
14
|
+
import { formatCliError, isDirectCliRun } from "../_core-helpers.mjs";
|
|
15
|
+
import { parseArgs } from "node:util";
|
|
16
|
+
import { JQ_OUTPUT_PARSE_OPTIONS, JQ_OUTPUT_USAGE, emitResult } from "../lib/jq-output.mjs";
|
|
17
|
+
import { main as listQueueItems } from "./list-queue-items.mjs";
|
|
18
|
+
|
|
19
|
+
const IN_PROGRESS_COLUMN = "In Progress";
|
|
20
|
+
|
|
21
|
+
const USAGE = `Usage: dev-loops queue resolve-active --repo <owner/name> --project <number|id>
|
|
22
|
+
|
|
23
|
+
Collapse the board's "${IN_PROGRESS_COLUMN}" column to a single continue target.
|
|
24
|
+
Used by bare \`/continue\` to pick up the one in-progress item. Fails closed
|
|
25
|
+
(no guessing) when the board has zero or more than one in-progress item.
|
|
26
|
+
|
|
27
|
+
Options:
|
|
28
|
+
--repo <owner/name> Required. Repository to scope the project search.
|
|
29
|
+
--project <number|id> Required. Project number (integer) or node ID.
|
|
30
|
+
--help, -h Show this help.
|
|
31
|
+
|
|
32
|
+
Output (stdout):
|
|
33
|
+
JSON, exactly one in-progress item:
|
|
34
|
+
{ ok: true, target: { kind: "issue"|"pr", number } }
|
|
35
|
+
JSON, zero or multiple (fail closed):
|
|
36
|
+
{ ok: false, reason: "..." }
|
|
37
|
+
|
|
38
|
+
${JQ_OUTPUT_USAGE}
|
|
39
|
+
|
|
40
|
+
Exit codes (default / unfiltered output):
|
|
41
|
+
0 — exactly one in-progress item resolved
|
|
42
|
+
1 — usage or argument error
|
|
43
|
+
2 — GitHub API error / invalid --jq filter
|
|
44
|
+
3 — fail closed (pass an explicit issue/PR): zero or multiple in-progress
|
|
45
|
+
items, or the board/project could not be resolved (project, status
|
|
46
|
+
field, or "${IN_PROGRESS_COLUMN}" column not found)
|
|
47
|
+
|
|
48
|
+
With --jq/--silent the result is filtered to a value/predicate, so the exit code
|
|
49
|
+
follows the shared jq-output contract (0 = truthy/ok, 1 = falsy/non-ok, 2 =
|
|
50
|
+
invalid filter) — fail closed surfaces as a falsy \`.ok\`, i.e. exit 1, not 3.
|
|
51
|
+
`.trim();
|
|
52
|
+
|
|
53
|
+
function parseCliArgs(argv) {
|
|
54
|
+
const parseError = (message) => Object.assign(new Error(message), { usage: USAGE, code: "INVALID_ARGS" });
|
|
55
|
+
const requireValue = (token, message) => {
|
|
56
|
+
const v = token.value;
|
|
57
|
+
if (typeof v !== "string" || v.length === 0 || v.startsWith("-")) {
|
|
58
|
+
throw parseError(message);
|
|
59
|
+
}
|
|
60
|
+
return v;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const args = {};
|
|
64
|
+
const { tokens } = parseArgs({
|
|
65
|
+
args: [...argv],
|
|
66
|
+
options: {
|
|
67
|
+
repo: { type: "string" },
|
|
68
|
+
project: { type: "string" },
|
|
69
|
+
help: { type: "boolean", short: "h" },
|
|
70
|
+
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
71
|
+
},
|
|
72
|
+
allowPositionals: true,
|
|
73
|
+
strict: false,
|
|
74
|
+
tokens: true,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
for (const token of tokens) {
|
|
78
|
+
if (token.kind === "positional") {
|
|
79
|
+
throw parseError(`Unexpected argument: ${token.value}`);
|
|
80
|
+
}
|
|
81
|
+
if (token.kind !== "option") {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
switch (token.name) {
|
|
85
|
+
case "help":
|
|
86
|
+
if (token.value !== undefined) {
|
|
87
|
+
throw parseError(`Unknown flag: ${token.rawName}=${token.value}`);
|
|
88
|
+
}
|
|
89
|
+
args.help = true;
|
|
90
|
+
break;
|
|
91
|
+
case "repo":
|
|
92
|
+
args.repo = requireValue(token, "--repo requires a value (owner/name)");
|
|
93
|
+
break;
|
|
94
|
+
case "project":
|
|
95
|
+
args.project = requireValue(token, "--project requires a value (number or node ID)");
|
|
96
|
+
break;
|
|
97
|
+
case "jq":
|
|
98
|
+
args.jq = requireValue(token, "--jq requires a filter");
|
|
99
|
+
break;
|
|
100
|
+
case "silent":
|
|
101
|
+
args.silent = true;
|
|
102
|
+
break;
|
|
103
|
+
default:
|
|
104
|
+
throw parseError(`Unknown flag: ${token.rawName}`);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return args;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function describeItem(item) {
|
|
111
|
+
const ref = item.prNumber != null ? `PR #${item.prNumber}` : `issue #${item.issueNumber}`;
|
|
112
|
+
return item.title ? `${ref} (${item.title})` : ref;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Collapse a list of board items to a single continue target. Prefer the linked
|
|
116
|
+
// PR number when present (the canonical artifact once work is in flight), else
|
|
117
|
+
// the issue. No routing opinion beyond that single pick.
|
|
118
|
+
function collapseToTarget(items) {
|
|
119
|
+
if (items.length === 0) {
|
|
120
|
+
return {
|
|
121
|
+
ok: false,
|
|
122
|
+
reason: `No in-progress board item to continue. Pass an explicit issue/PR, e.g. \`/continue #N\`.`,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
if (items.length > 1) {
|
|
126
|
+
const listed = items.map(describeItem).join(", ");
|
|
127
|
+
return {
|
|
128
|
+
ok: false,
|
|
129
|
+
reason: `${items.length} in-progress board items: ${listed}. Pass an explicit issue/PR to disambiguate, e.g. \`/continue #N\`.`,
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
const item = items[0];
|
|
133
|
+
const target = item.prNumber != null
|
|
134
|
+
? { kind: "pr", number: item.prNumber }
|
|
135
|
+
: { kind: "issue", number: item.issueNumber };
|
|
136
|
+
return { ok: true, target };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
async function main(args, { env = process.env, runChild } = {}) {
|
|
140
|
+
const listed = await listQueueItems(
|
|
141
|
+
{ repo: args.repo, project: args.project, column: IN_PROGRESS_COLUMN },
|
|
142
|
+
{ env, runChild },
|
|
143
|
+
);
|
|
144
|
+
return collapseToTarget(listed.items ?? []);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function classifyExitCode(err) {
|
|
148
|
+
if (err.code === "INVALID_ARGS" || err.code === "INVALID_REPO" || err.code === "INVALID_PROJECT") return 1;
|
|
149
|
+
if (err.code === "PROJECT_NOT_FOUND" || err.code === "FIELD_NOT_FOUND" || err.code === "COLUMN_NOT_FOUND") return 3;
|
|
150
|
+
return 2;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function runCli(argv, { stdout = process.stdout, stderr = process.stderr, env = process.env, runChild } = {}) {
|
|
154
|
+
let args;
|
|
155
|
+
try {
|
|
156
|
+
args = parseCliArgs(argv);
|
|
157
|
+
} catch (err) {
|
|
158
|
+
stderr.write(`${formatCliError(err)}\n`);
|
|
159
|
+
process.exitCode = 1;
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
if (args.help) {
|
|
163
|
+
stdout.write(USAGE);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
try {
|
|
167
|
+
const result = await main(args, { env, runChild });
|
|
168
|
+
// Fail closed (zero/multiple) is a clean, expected outcome — distinct exit code 3,
|
|
169
|
+
// not a crash; --jq/--silent still apply so callers can probe `.ok`.
|
|
170
|
+
process.exitCode = emitResult(result, {
|
|
171
|
+
jq: args.jq,
|
|
172
|
+
silent: args.silent,
|
|
173
|
+
stdout,
|
|
174
|
+
stderr,
|
|
175
|
+
ok: result.ok,
|
|
176
|
+
});
|
|
177
|
+
if (result.ok !== true && args.jq === undefined && !args.silent) {
|
|
178
|
+
process.exitCode = 3;
|
|
179
|
+
}
|
|
180
|
+
} catch (err) {
|
|
181
|
+
stderr.write(JSON.stringify({ ok: false, error: err.message, code: err.code ?? "UNKNOWN" }) + "\n");
|
|
182
|
+
process.exitCode = classifyExitCode(err);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (isDirectCliRun(import.meta.url)) {
|
|
187
|
+
runCli(process.argv.slice(2)).catch((error) => {
|
|
188
|
+
process.stderr.write(JSON.stringify({ ok: false, error: error.message, code: error.code ?? "UNKNOWN" }) + "\n");
|
|
189
|
+
process.exitCode = 2;
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export { main, collapseToTarget, runCli };
|
|
@@ -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()
|