dev-loops 0.7.1 → 0.8.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 +7 -7
- package/.claude/agents/fixer.md +1 -0
- package/.claude/agents/refiner.md +4 -4
- package/.claude/agents/review.md +10 -13
- package/.claude/commands/loop-continue.md +2 -1
- package/.claude/commands/loop-enqueue.md +9 -2
- package/.claude/commands/loop-info.md +1 -1
- package/.claude/commands/loop-start-spike.md +1 -1
- package/.claude/hooks/_bash-command-classify.mjs +7 -6
- package/.claude/hooks/_hook-decisions.mjs +5 -4
- package/.claude/skills/copilot-pr-followup/SKILL.md +42 -28
- package/.claude/skills/dev-loop/SKILL.md +8 -8
- package/.claude/skills/docs/acceptance-criteria-verification.md +13 -4
- package/.claude/skills/docs/anti-patterns.md +6 -5
- package/.claude/skills/docs/artifact-authority-contract.md +17 -14
- package/.claude/skills/docs/confirmation-rules.md +2 -1
- package/.claude/skills/docs/contract-style-guide.md +37 -0
- package/.claude/skills/docs/copilot-loop-operations.md +21 -15
- package/.claude/skills/docs/cross-harness-regression-contract.md +2 -2
- package/.claude/skills/docs/debt-remediation-contract.md +1 -1
- package/.claude/skills/docs/epic-tree-refinement-procedure.md +25 -22
- package/.claude/skills/docs/issue-intake-procedure.md +37 -22
- package/.claude/skills/docs/merge-preconditions.md +17 -13
- package/.claude/skills/docs/pr-lifecycle-contract.md +45 -36
- package/.claude/skills/docs/public-dev-loop-contract.md +61 -44
- package/.claude/skills/docs/retrospective-checkpoint-contract.md +25 -9
- package/.claude/skills/docs/spike-mode-contract.md +14 -7
- package/.claude/skills/docs/stop-conditions.md +30 -15
- package/.claude/skills/docs/tracker-first-loop-state.md +13 -8
- package/.claude/skills/docs/validation-policy.md +4 -5
- package/.claude/skills/local-implementation/SKILL.md +49 -143
- package/.claude/skills/loop-grill/SKILL.md +34 -14
- package/AGENTS.md +1 -1
- package/CHANGELOG.md +36 -0
- package/README.md +95 -189
- package/agents/dev-loop.agent.md +8 -8
- package/agents/fixer.agent.md +1 -0
- package/agents/refiner.agent.md +4 -4
- package/agents/review.agent.md +10 -13
- package/extension/README.md +5 -4
- package/package.json +7 -5
- package/scripts/_core-helpers.mjs +2 -0
- package/scripts/claude/headless-dev-loop.mjs +1 -1
- package/scripts/docs/validate-rule-ownership.mjs +442 -0
- package/scripts/docs/validate-state-machine-conformance.mjs +1169 -0
- package/scripts/github/_gate-names.mjs +5 -0
- package/scripts/github/_review-thread-mutations.mjs +5 -2
- package/scripts/github/capture-review-threads.mjs +2 -2
- package/scripts/github/create-pr.mjs +119 -10
- package/scripts/github/detect-checkpoint-evidence.mjs +109 -24
- package/scripts/github/edit-issue.mjs +259 -0
- package/scripts/github/list-review-threads.mjs +277 -0
- package/scripts/github/post-gate-findings.mjs +6 -3
- package/scripts/github/probe-ci-status.mjs +18 -0
- package/scripts/github/probe-copilot-review.mjs +24 -3
- package/scripts/github/reconcile-draft-gate.mjs +13 -13
- package/scripts/github/request-copilot-review.mjs +109 -42
- package/scripts/github/upsert-checkpoint-verdict.mjs +98 -27
- package/scripts/github/verify-briefing-prefixes.mjs +382 -0
- package/scripts/github/verify-fresh-review-context.mjs +78 -3
- package/scripts/github/wait-pr-checks.mjs +171 -0
- package/scripts/github/write-gate-context.mjs +220 -10
- package/scripts/github/write-gate-findings-log.mjs +54 -2
- package/scripts/loop/_post-convergence-change.mjs +2 -2
- package/scripts/loop/_pr-runner-coordination.mjs +112 -13
- package/scripts/loop/check-retro-tooling.mjs +14 -9
- package/scripts/loop/copilot-pr-handoff.mjs +47 -16
- package/scripts/loop/detect-change-scope.mjs +2 -2
- package/scripts/loop/detect-copilot-loop-state.mjs +34 -14
- package/scripts/loop/detect-internal-only-pr.mjs +6 -6
- package/scripts/loop/detect-pr-gate-coordination-state.mjs +152 -18
- package/scripts/loop/detect-refinement-grill-state.mjs +136 -0
- package/scripts/loop/resolve-dev-loop-startup.mjs +145 -8
- package/scripts/loop/run-watch-cycle.mjs +42 -7
- package/scripts/loop/sanctioned-commands.mjs +3 -0
- package/scripts/loop/validate-pr-body-spec.mjs +21 -5
- package/scripts/pages/build-state-atlas.mjs +65 -48
- package/scripts/projects/_resolve-project.mjs +1 -148
- package/scripts/projects/add-queue-item.mjs +87 -4
- package/scripts/projects/list-queue-items.mjs +3 -377
- package/scripts/projects/move-queue-item.mjs +3 -410
- package/scripts/projects/reorder-queue-item.mjs +3 -22
- package/skills/copilot-pr-followup/SKILL.md +42 -28
- package/skills/dev-loop/SKILL.md +3 -3
- package/skills/docs/acceptance-criteria-verification.md +13 -4
- package/skills/docs/anti-patterns.md +6 -5
- package/skills/docs/artifact-authority-contract.md +17 -14
- package/skills/docs/confirmation-rules.md +2 -1
- package/skills/docs/contract-style-guide.md +37 -0
- package/skills/docs/copilot-loop-operations.md +21 -15
- package/skills/docs/cross-harness-regression-contract.md +2 -2
- package/skills/docs/debt-remediation-contract.md +1 -1
- package/skills/docs/epic-tree-refinement-procedure.md +25 -22
- package/skills/docs/issue-intake-procedure.md +37 -22
- package/skills/docs/merge-preconditions.md +17 -13
- package/skills/docs/pr-lifecycle-contract.md +45 -36
- package/skills/docs/public-dev-loop-contract.md +61 -44
- package/skills/docs/required-rules.json +165 -0
- package/skills/docs/retrospective-checkpoint-contract.md +25 -9
- package/skills/docs/spike-mode-contract.md +14 -7
- package/skills/docs/stop-conditions.md +30 -15
- package/skills/docs/tracker-first-loop-state.md +13 -8
- package/skills/docs/validation-policy.md +4 -5
- package/skills/local-implementation/SKILL.md +49 -143
- package/skills/loop-grill/SKILL.md +38 -17
- package/scripts/docs/validate-no-duplicate-rules.mjs +0 -250
|
@@ -10,6 +10,9 @@ import { runHandoff } from "./copilot-pr-handoff.mjs";
|
|
|
10
10
|
import { STATE } from "@dev-loops/core/loop/copilot-loop-state";
|
|
11
11
|
import { DEFAULT_POLL_INTERVAL_MS } from "@dev-loops/core/loop/policy-constants";
|
|
12
12
|
import { detectCopilotSessionActivity } from "./detect-copilot-session-activity.mjs";
|
|
13
|
+
import { ensureAsyncRunnerOwnership } from "./_pr-runner-coordination.mjs";
|
|
14
|
+
import { resolveRepoRoot } from "./_repo-root-resolver.mjs";
|
|
15
|
+
import { resolveStaleRunnerMaxAgeMs } from "./_stale-runner-detection.mjs";
|
|
13
16
|
import { parseArgs } from "node:util";
|
|
14
17
|
import {
|
|
15
18
|
EXTERNAL_HEALTHY_WAIT_TIMEOUT_POLICY,
|
|
@@ -205,6 +208,33 @@ function buildWatchCycleContractTrace({
|
|
|
205
208
|
},
|
|
206
209
|
};
|
|
207
210
|
}
|
|
211
|
+
// A blocking watch can run for the full external-healthy-wait timeout (30 min for
|
|
212
|
+
// Copilot/CI), which equals the runner-coordination stale window. Without a
|
|
213
|
+
// heartbeat the claim ages to stale across a single watch and the next step is
|
|
214
|
+
// refused. Refresh the lease on entry, on a periodic interval below
|
|
215
|
+
// the stale window, and once more on return so the resuming runner has a fresh claim.
|
|
216
|
+
async function runWatchHoldingLease(watchFactory, { repo, pr, env, cwd, ensureOwnershipImpl }) {
|
|
217
|
+
const heartbeat = async () => {
|
|
218
|
+
try {
|
|
219
|
+
// claimIfMissing:true self-heals a missing record but never stomps a live
|
|
220
|
+
// competitor (assertRunnerOwnership refuses to write on OWNERSHIP_LOST), so
|
|
221
|
+
// fail-closed competitor semantics are preserved.
|
|
222
|
+
await ensureOwnershipImpl({ repo, pr, env, cwd, claimIfMissing: true, requireExisting: false });
|
|
223
|
+
} catch {
|
|
224
|
+
// best-effort: a heartbeat failure must never affect the watch
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
const intervalMs = Math.max(1, Math.floor(resolveStaleRunnerMaxAgeMs({}, env) / 2));
|
|
228
|
+
await heartbeat();
|
|
229
|
+
const timer = setInterval(() => { void heartbeat(); }, intervalMs);
|
|
230
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
231
|
+
try {
|
|
232
|
+
return await watchFactory();
|
|
233
|
+
} finally {
|
|
234
|
+
clearInterval(timer);
|
|
235
|
+
await heartbeat();
|
|
236
|
+
}
|
|
237
|
+
}
|
|
208
238
|
export function parseWatchCycleCliArgs(argv) {
|
|
209
239
|
const options = {
|
|
210
240
|
help: false,
|
|
@@ -278,9 +308,11 @@ export async function runWatchCycle(
|
|
|
278
308
|
detectCopilotSessionActivityImpl = detectCopilotSessionActivity,
|
|
279
309
|
fetchPrHeadBranchImpl = fetchPrHeadBranch,
|
|
280
310
|
watchWorkflowRunImpl = watchWorkflowRun,
|
|
311
|
+
ensureOwnershipImpl = ensureAsyncRunnerOwnership,
|
|
281
312
|
detectSessionActivity = false,
|
|
282
313
|
} = {},
|
|
283
314
|
) {
|
|
315
|
+
const leaseCwd = resolveRepoRoot(process.cwd());
|
|
284
316
|
const handoff = await runHandoffImpl(options, { env, ghCommand });
|
|
285
317
|
const result = {
|
|
286
318
|
ok: true,
|
|
@@ -370,13 +402,16 @@ export async function runWatchCycle(
|
|
|
370
402
|
session.activity === "active"
|
|
371
403
|
&& Number.isInteger(session.runId)
|
|
372
404
|
) {
|
|
373
|
-
const workflowWatchResult = await
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
405
|
+
const workflowWatchResult = await runWatchHoldingLease(
|
|
406
|
+
() => watchWorkflowRunImpl(
|
|
407
|
+
{
|
|
408
|
+
repo: options.repo,
|
|
409
|
+
runId: session.runId,
|
|
410
|
+
timeoutMs: persistentWatchTimeoutMs,
|
|
411
|
+
},
|
|
412
|
+
{ env, ghCommand },
|
|
413
|
+
),
|
|
414
|
+
{ repo: options.repo, pr: options.pr, env, cwd: leaseCwd, ensureOwnershipImpl },
|
|
380
415
|
);
|
|
381
416
|
workflowRunWatch = {
|
|
382
417
|
attempted: true,
|
|
@@ -26,15 +26,18 @@ export const SANCTIONED_COMMANDS = Object.freeze({
|
|
|
26
26
|
reads: Object.freeze({
|
|
27
27
|
"pr-facts": "scripts/github/view-pr.mjs",
|
|
28
28
|
"ci-status": "scripts/github/probe-ci-status.mjs",
|
|
29
|
+
"ci-wait": "scripts/github/wait-pr-checks.mjs",
|
|
29
30
|
"ci-logs": "scripts/github/fetch-ci-logs.mjs",
|
|
30
31
|
"issue-list": "scripts/github/list-issues.mjs",
|
|
31
32
|
"copilot-review-state": "scripts/github/probe-copilot-review.mjs",
|
|
33
|
+
"review-threads": "scripts/github/list-review-threads.mjs",
|
|
32
34
|
"gate-coordination": "scripts/loop/detect-pr-gate-coordination-state.mjs",
|
|
33
35
|
}),
|
|
34
36
|
|
|
35
37
|
// Metadata edits.
|
|
36
38
|
edits: Object.freeze({
|
|
37
39
|
"pr-body-title-assignee-milestone": "scripts/github/edit-pr.mjs",
|
|
40
|
+
"issue-body-title-assignee-milestone": "scripts/github/edit-issue.mjs",
|
|
38
41
|
"issue-comment": "scripts/github/comment-issue.mjs",
|
|
39
42
|
}),
|
|
40
43
|
|
|
@@ -24,8 +24,8 @@ const USAGE = `Usage:
|
|
|
24
24
|
validate-pr-body-spec.mjs --input <path> [--expected-issue <n>]
|
|
25
25
|
Validate that a PR body carries the lightweight spec-of-record invariants
|
|
26
26
|
(Objective/why, In scope, Explicit non-goals, testable Acceptance criteria,
|
|
27
|
-
Definition of done, Open questions/risks, and
|
|
28
|
-
reference such as \`Closes #123\`).
|
|
27
|
+
Definition of done, Open questions/risks, and — unless --no-issue is given —
|
|
28
|
+
a GitHub closing-keyword issue reference such as \`Closes #123\`).
|
|
29
29
|
Required (exactly one):
|
|
30
30
|
--repo <owner/name> --pr <number> Fetch the PR body via gh and validate it
|
|
31
31
|
--input <path> Path to a JSON file with { "body": "..." }
|
|
@@ -34,7 +34,14 @@ Required (exactly one):
|
|
|
34
34
|
Optional:
|
|
35
35
|
--expected-issue <n> Positive integer; the referenced closing
|
|
36
36
|
issue(s) must include this number, else
|
|
37
|
-
"closes_wrong_issue".
|
|
37
|
+
"closes_wrong_issue". Mutually exclusive
|
|
38
|
+
with --no-issue.
|
|
39
|
+
--no-issue Explicit issue-less mode (#1210): the PR
|
|
40
|
+
is the sole artifact, so a closing-issue
|
|
41
|
+
reference is FORBIDDEN rather than
|
|
42
|
+
required — one present fails closed under
|
|
43
|
+
"unexpected_closing_issue_reference".
|
|
44
|
+
Mutually exclusive with --expected-issue.
|
|
38
45
|
Success output (stdout, JSON):
|
|
39
46
|
{
|
|
40
47
|
"ok": true | false,
|
|
@@ -61,6 +68,7 @@ export function parseValidatePrBodySpecCliArgs(argv) {
|
|
|
61
68
|
pr: undefined,
|
|
62
69
|
input: undefined,
|
|
63
70
|
expectedIssue: undefined,
|
|
71
|
+
noIssue: false,
|
|
64
72
|
};
|
|
65
73
|
const { tokens } = parseArgs({
|
|
66
74
|
args: [...argv],
|
|
@@ -70,6 +78,7 @@ export function parseValidatePrBodySpecCliArgs(argv) {
|
|
|
70
78
|
pr: { type: "string" },
|
|
71
79
|
input: { type: "string" },
|
|
72
80
|
"expected-issue": { type: "string" },
|
|
81
|
+
"no-issue": { type: "boolean" },
|
|
73
82
|
...JQ_OUTPUT_PARSE_OPTIONS,
|
|
74
83
|
},
|
|
75
84
|
allowPositionals: true,
|
|
@@ -111,12 +120,19 @@ export function parseValidatePrBodySpecCliArgs(argv) {
|
|
|
111
120
|
options.expectedIssue = Number(value);
|
|
112
121
|
continue;
|
|
113
122
|
}
|
|
123
|
+
if (token.name === "no-issue") {
|
|
124
|
+
options.noIssue = true;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
114
127
|
if (matchJqOutputToken(token, options, (t) => requireTokenValue(t, parseError))) continue;
|
|
115
128
|
throw parseError(`Unknown argument: ${token.rawName}`);
|
|
116
129
|
}
|
|
117
130
|
if (options.help) {
|
|
118
131
|
return options;
|
|
119
132
|
}
|
|
133
|
+
if (options.noIssue && Number.isInteger(options.expectedIssue)) {
|
|
134
|
+
throw parseError("--no-issue is mutually exclusive with --expected-issue; provide exactly one issue-linkage mode");
|
|
135
|
+
}
|
|
120
136
|
const hasInput = typeof options.input === "string" && options.input.length > 0;
|
|
121
137
|
const hasAnyRemote = (typeof options.repo === "string" && options.repo.length > 0) || Number.isInteger(options.pr);
|
|
122
138
|
const hasRemotePair = typeof options.repo === "string" && options.repo.length > 0 && Number.isInteger(options.pr);
|
|
@@ -163,12 +179,12 @@ export async function validatePrBodySpecFromOptions(options, { env = process.env
|
|
|
163
179
|
const body = typeof payload.body === "string" ? payload.body : "";
|
|
164
180
|
const repo = typeof payload.repo === "string" ? payload.repo : options.repo ?? null;
|
|
165
181
|
const pr = Number.isInteger(payload.pr) ? payload.pr : options.pr ?? null;
|
|
166
|
-
return { ...validatePrBodySpec({ body, expectedIssue: options.expectedIssue ?? null }), repo, pr };
|
|
182
|
+
return { ...validatePrBodySpec({ body, expectedIssue: options.expectedIssue ?? null, issueLess: options.noIssue }), repo, pr };
|
|
167
183
|
}
|
|
168
184
|
parseRepoSlug(options.repo);
|
|
169
185
|
const body = await fetchPrBody({ repo: options.repo, pr: options.pr }, { env, ghCommand });
|
|
170
186
|
return {
|
|
171
|
-
...validatePrBodySpec({ body, expectedIssue: options.expectedIssue ?? null }),
|
|
187
|
+
...validatePrBodySpec({ body, expectedIssue: options.expectedIssue ?? null, issueLess: options.noIssue }),
|
|
172
188
|
repo: options.repo,
|
|
173
189
|
pr: options.pr,
|
|
174
190
|
};
|
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
// state machine rendered as a mermaid diagram, emitted DETERMINISTICALLY from
|
|
3
3
|
// the code's own exported tables so the page can never drift from the code.
|
|
4
4
|
//
|
|
5
|
-
//
|
|
6
|
-
// reviewer loop, outer conductor routing,
|
|
7
|
-
//
|
|
8
|
-
//
|
|
5
|
+
// Five diagrams come straight from packages/core/src/loop tables (copilot loop,
|
|
6
|
+
// reviewer loop, outer conductor routing, the public dev-loop gate hub, and the
|
|
7
|
+
// PR lifecycle contract); one more (the release pipeline workflow) is authored
|
|
8
|
+
// from a documented, table-less source.
|
|
9
9
|
//
|
|
10
10
|
// The page reuses the article design system (docs/articles/introducing-dev-loops.html):
|
|
11
11
|
// the same :root tokens, body gradient, and typography. It intentionally defines
|
|
@@ -13,8 +13,10 @@
|
|
|
13
13
|
// (NAV_CSS references --heading/--kicker/--accent-soft, all declared here).
|
|
14
14
|
import { STATE, TRANSITIONS } from '../../packages/core/src/loop/copilot-loop-state.mjs';
|
|
15
15
|
import { REVIEWER_STATE, REVIEWER_TRANSITIONS } from '../../packages/core/src/loop/reviewer-loop-state.mjs';
|
|
16
|
+
import { GRILL_STATE, GRILL_TRANSITIONS } from '../../packages/core/src/loop/refinement-grill-state.mjs';
|
|
16
17
|
import { OUTER_STATE, OUTER_TRANSITIONS } from '../../packages/core/src/loop/conductor-routing.mjs';
|
|
17
18
|
import { PUBLIC_DEV_LOOP_GATE_CONTRACT } from '../../packages/core/src/loop/public-dev-loop-routing-contract.mjs';
|
|
19
|
+
import { PR_LIFECYCLE_STATES, PR_LIFECYCLE_TRANSITIONS } from '../../packages/core/src/loop/pr-lifecycle.mjs';
|
|
18
20
|
|
|
19
21
|
// Classify a state/gate id by name into one of four visual classes. Colors are
|
|
20
22
|
// drawn from the site's own dark palette (accent violet, kicker blue,
|
|
@@ -80,7 +82,7 @@ function gateClass(row) {
|
|
|
80
82
|
}
|
|
81
83
|
|
|
82
84
|
function renderGateFlowchart(contract) {
|
|
83
|
-
const lines = ['flowchart
|
|
85
|
+
const lines = ['flowchart LR', ' router(["dev-loop router"])'];
|
|
84
86
|
for (const row of contract) {
|
|
85
87
|
const strategy = row.selectedStrategy ?? 'none';
|
|
86
88
|
lines.push(` router -->|${row.routeKind}| ${row.gate}["${row.gate} — ${strategy}"]`);
|
|
@@ -99,53 +101,26 @@ const reviewerDiagram = renderStateDiagram(
|
|
|
99
101
|
edgesFromTransitions(Object.values(REVIEWER_STATE), REVIEWER_TRANSITIONS),
|
|
100
102
|
Object.values(REVIEWER_STATE),
|
|
101
103
|
);
|
|
104
|
+
const refinementGrillDiagram = renderStateDiagram(
|
|
105
|
+
edgesFromTransitions(Object.values(GRILL_STATE), GRILL_TRANSITIONS),
|
|
106
|
+
Object.values(GRILL_STATE),
|
|
107
|
+
);
|
|
102
108
|
const outerDiagram = renderStateDiagram(
|
|
103
109
|
edgesFromTransitions(Object.values(OUTER_STATE), OUTER_TRANSITIONS),
|
|
104
110
|
Object.values(OUTER_STATE),
|
|
105
111
|
);
|
|
106
112
|
const gateDiagram = renderGateFlowchart(PUBLIC_DEV_LOOP_GATE_CONTRACT);
|
|
107
113
|
|
|
108
|
-
//
|
|
114
|
+
// PR lifecycle: the 13-state vocabulary + required transitions exported by
|
|
115
|
+
// packages/core/src/loop/pr-lifecycle.mjs (issue #1193), re-exported here so
|
|
116
|
+
// this module's public surface is unchanged. The L2/L3 state-machine
|
|
117
|
+
// conformance harness (scripts/docs/validate-state-machine-conformance.mjs)
|
|
118
|
+
// imports the same core module directly rather than through this one, so it
|
|
119
|
+
// never pulls in this page generator's diagram-rendering work at load time.
|
|
120
|
+
export { PR_LIFECYCLE_STATES, PR_LIFECYCLE_TRANSITIONS };
|
|
121
|
+
const prLifecycleDiagram = renderStateDiagram(PR_LIFECYCLE_TRANSITIONS, PR_LIFECYCLE_STATES);
|
|
109
122
|
|
|
110
|
-
//
|
|
111
|
-
// skills/docs/pr-lifecycle-contract.md.
|
|
112
|
-
const prLifecycleStates = [
|
|
113
|
-
'draft_local_review_gate',
|
|
114
|
-
'draft_local_remediation',
|
|
115
|
-
'ready_state_needs_copilot_request',
|
|
116
|
-
'waiting_for_copilot_review',
|
|
117
|
-
'copilot_feedback_remediation',
|
|
118
|
-
'copilot_reply_resolve_pending',
|
|
119
|
-
'merge_conflict_resolution',
|
|
120
|
-
'final_local_preapproval_gate',
|
|
121
|
-
'final_gate_remediation',
|
|
122
|
-
'waiting_for_human_pr_approval',
|
|
123
|
-
'waiting_for_merge',
|
|
124
|
-
'terminal_slice_complete',
|
|
125
|
-
'stopped_needs_user_decision',
|
|
126
|
-
];
|
|
127
|
-
const prLifecycleDiagram = renderStateDiagram([
|
|
128
|
-
['draft_local_review_gate', 'draft_local_remediation'],
|
|
129
|
-
['draft_local_review_gate', 'ready_state_needs_copilot_request'],
|
|
130
|
-
['draft_local_review_gate', 'stopped_needs_user_decision'],
|
|
131
|
-
['draft_local_remediation', 'draft_local_review_gate'],
|
|
132
|
-
['ready_state_needs_copilot_request', 'waiting_for_copilot_review'],
|
|
133
|
-
['ready_state_needs_copilot_request', 'stopped_needs_user_decision'],
|
|
134
|
-
['waiting_for_copilot_review', 'copilot_feedback_remediation'],
|
|
135
|
-
['copilot_feedback_remediation', 'copilot_reply_resolve_pending'],
|
|
136
|
-
['copilot_reply_resolve_pending', 'ready_state_needs_copilot_request'],
|
|
137
|
-
['waiting_for_copilot_review', 'merge_conflict_resolution'],
|
|
138
|
-
['merge_conflict_resolution', 'waiting_for_copilot_review'],
|
|
139
|
-
['waiting_for_copilot_review', 'final_local_preapproval_gate'],
|
|
140
|
-
['final_local_preapproval_gate', 'final_gate_remediation'],
|
|
141
|
-
['final_local_preapproval_gate', 'waiting_for_human_pr_approval'],
|
|
142
|
-
['final_gate_remediation', 'final_local_preapproval_gate'],
|
|
143
|
-
['waiting_for_human_pr_approval', 'waiting_for_merge'],
|
|
144
|
-
['waiting_for_human_pr_approval', 'draft_local_review_gate'],
|
|
145
|
-
['waiting_for_merge', 'terminal_slice_complete'],
|
|
146
|
-
['terminal_slice_complete', '[*]'],
|
|
147
|
-
['stopped_needs_user_decision', '[*]'],
|
|
148
|
-
], prLifecycleStates);
|
|
123
|
+
// --- Statically-authored diagram (documented, table-less source) ---
|
|
149
124
|
|
|
150
125
|
// Release pipeline: the fail-closed gate chain from .github/workflows/release.yml.
|
|
151
126
|
const releasePipelineNodes = [
|
|
@@ -189,7 +164,7 @@ const releaseDiagram = [
|
|
|
189
164
|
|
|
190
165
|
const INTRO_PROSE = [
|
|
191
166
|
'dev-loops drives every pull request through closed, deterministic state machines. Workflow control lives in the graph; agent judgment enters only as bounded, explicit inputs, never as hidden orchestration. Exactly one state applies at a time, and each state exposes the legal transitions out of it.',
|
|
192
|
-
"Every diagram on this page is generated at site-build time from the code's own exported tables — the copilot loop's <code>STATE</code>/<code>TRANSITIONS</code>, the reviewer loop's <code>REVIEWER_STATE</code>/<code>REVIEWER_TRANSITIONS</code>, the outer loop's <code>OUTER_STATE</code>/<code>OUTER_TRANSITIONS</code>,
|
|
167
|
+
"Every diagram on this page is generated at site-build time from the code's own exported tables — the copilot loop's <code>STATE</code>/<code>TRANSITIONS</code>, the reviewer loop's <code>REVIEWER_STATE</code>/<code>REVIEWER_TRANSITIONS</code>, the outer loop's <code>OUTER_STATE</code>/<code>OUTER_TRANSITIONS</code>, the public router's <code>PUBLIC_DEV_LOOP_GATE_CONTRACT</code>, and the PR lifecycle's <code>PR_LIFECYCLE_STATES</code>/<code>PR_LIFECYCLE_TRANSITIONS</code>. They cannot drift from the code: change a transition table and this page changes with it. One diagram (the release pipeline) is authored from a documented contract that has no single code table.",
|
|
193
168
|
'The state names are not labels for a picture — they are the literal contract identifiers that appear in logs, handoff envelopes, and gate artifacts. Nodes are coloured by role: <strong>active / in-progress</strong> steps the loop advances itself, <strong>waiting-on-external</strong> states that block on something outside the loop (CI, Copilot, a human), <strong>blocked / fail-closed</strong> states that stop or reconcile rather than guess, and <strong>terminal</strong> states where the slice is complete.',
|
|
194
169
|
];
|
|
195
170
|
|
|
@@ -217,7 +192,7 @@ const SECTIONS = [
|
|
|
217
192
|
{
|
|
218
193
|
id: 'pr-lifecycle',
|
|
219
194
|
title: 'PR lifecycle contract',
|
|
220
|
-
source: '
|
|
195
|
+
source: 'packages/core/src/loop/pr-lifecycle.mjs — PR_LIFECYCLE_STATES + PR_LIFECYCLE_TRANSITIONS',
|
|
221
196
|
prose: [
|
|
222
197
|
'One PR moves through a stable thirteen-state vocabulary from draft to merge. These identifiers are part of the contract surface even as the helper implementations around them change. Two local gates guard the path — <code>draft_gate</code> (draft to ready-for-review) and <code>pre_approval_gate</code> (before final approval) — and both are fail-closed fan-out reviews that run independent angle chains and must produce clean current-head evidence to pass.',
|
|
223
198
|
'All gate evidence is per-head: a new push re-opens the gates, and ready-to-draft resets the lifecycle back into draft-stage gating. A conflicted head detours through <code>merge_conflict_resolution</code> before any further gate progression — a conflicted PR is never treated as approval- or merge-ready, even if older gate comments and CI were green. Human approval and merge are explicit external waits, not hidden remediation states.',
|
|
@@ -244,6 +219,16 @@ const SECTIONS = [
|
|
|
244
219
|
],
|
|
245
220
|
diagram: reviewerDiagram,
|
|
246
221
|
},
|
|
222
|
+
{
|
|
223
|
+
id: 'refinement-grill',
|
|
224
|
+
title: 'Refinement / grill sub-loop',
|
|
225
|
+
source: 'packages/core/src/loop/refinement-grill-state.mjs — GRILL_STATE + GRILL_TRANSITIONS',
|
|
226
|
+
prose: [
|
|
227
|
+
'Before a slice enters the loop, the refinement grill runs as a closed, deterministic sub-loop over the issue, PR body, or plan file. It loads the target, detects spec gaps, and iterates detect-gaps to answer to synthesize to re-grill until it reaches a fixed point. The iteration lives entirely in the transition graph; the LLM answer and synthesis enter only as a bounded input consumed at the await_answers state, never as hidden orchestration in a coordinator script.',
|
|
228
|
+
'Write-back synthesizes only sharpened Acceptance criteria, Definition of done, and Non-goals into the body — the raw Q&A transcript never lands in the artifact. An uncitable gap stops honestly at needs_human_handoff naming the question rather than fabricating an answer to force convergence, and any load or parse failure fails closed to blocked_needs_user_decision.',
|
|
229
|
+
],
|
|
230
|
+
diagram: refinementGrillDiagram,
|
|
231
|
+
},
|
|
247
232
|
{
|
|
248
233
|
id: 'release-pipeline',
|
|
249
234
|
title: 'Release pipeline',
|
|
@@ -270,7 +255,7 @@ function sectionMarkup(s) {
|
|
|
270
255
|
<h2>${s.title}</h2>
|
|
271
256
|
<p class="source">Diagram generated from <code>${s.source}</code></p>
|
|
272
257
|
${proseParagraphs(s.prose)}
|
|
273
|
-
<div class="diagram"><div class="mermaid">
|
|
258
|
+
<div class="diagram"><button class="expand" type="button" aria-label="View diagram fullscreen">⤢ Fullscreen</button><div class="mermaid">
|
|
274
259
|
${escapeMermaid(s.diagram)}
|
|
275
260
|
</div></div>
|
|
276
261
|
</section>`;
|
|
@@ -404,6 +389,16 @@ export function buildStateAtlasHtml() {
|
|
|
404
389
|
}
|
|
405
390
|
.diagram .mermaid { text-align: center; }
|
|
406
391
|
.diagram .mermaid svg { max-width: 100%; height: auto; }
|
|
392
|
+
.diagram { position: relative; }
|
|
393
|
+
.diagram .expand { position: sticky; float: right; top: 0.6rem; right: 0.6rem; margin: 0 0 -2rem; z-index: 2; font: 600 0.72rem var(--font); color: var(--copy); background: rgba(15, 23, 42, 0.85); border: 1px solid var(--card-border); border-radius: 8px; padding: 0.35rem 0.6rem; cursor: pointer; }
|
|
394
|
+
.diagram .expand:hover { color: var(--heading); border-color: var(--accent); }
|
|
395
|
+
.diagram .mermaid { cursor: zoom-in; }
|
|
396
|
+
.diagram .mermaid:fullscreen { cursor: zoom-out; background: #0b1220; display: flex; align-items: center; justify-content: center; padding: 2rem; }
|
|
397
|
+
.diagram .mermaid:-webkit-full-screen { cursor: zoom-out; background: #0b1220; display: flex; align-items: center; justify-content: center; padding: 2rem; }
|
|
398
|
+
.diagram .mermaid:-webkit-full-screen svg { max-width: calc(100vw - 4rem); max-height: calc(100vh - 4rem); width: auto; height: auto; }
|
|
399
|
+
.diagram .mermaid:fullscreen svg { max-width: calc(100vw - 4rem); max-height: calc(100vh - 4rem); width: auto; height: auto; }
|
|
400
|
+
.diagram .mermaid.fs-fallback { position: fixed; inset: 0; z-index: 50; background: #0b1220; display: flex; align-items: center; justify-content: center; padding: 1rem; cursor: zoom-out; overflow: auto; }
|
|
401
|
+
.diagram .mermaid.fs-fallback svg { max-width: calc(100vw - 2rem); max-height: calc(100vh - 2rem); width: auto; height: auto; }
|
|
407
402
|
</style>
|
|
408
403
|
</head>
|
|
409
404
|
<body>
|
|
@@ -416,6 +411,28 @@ ${sections}
|
|
|
416
411
|
</main>
|
|
417
412
|
<script src="assets/mermaid.min.js"></script>
|
|
418
413
|
<script>
|
|
414
|
+
let fsInFlight = false;
|
|
415
|
+
document.addEventListener('click', async (e) => {
|
|
416
|
+
if (fsInFlight) return;
|
|
417
|
+
const target = e.target instanceof Element ? e.target : e.target?.parentElement;
|
|
418
|
+
if (!target) return;
|
|
419
|
+
const btn = target.closest('.diagram .expand');
|
|
420
|
+
const box = btn ? btn.parentElement.querySelector('.mermaid') : target.closest('.diagram .mermaid');
|
|
421
|
+
if (!box) return;
|
|
422
|
+
fsInFlight = true;
|
|
423
|
+
try {
|
|
424
|
+
const fsElement = document.fullscreenElement || document.webkitFullscreenElement;
|
|
425
|
+
if (fsElement === box) {
|
|
426
|
+
await (document.exitFullscreen || document.webkitExitFullscreen)?.call(document);
|
|
427
|
+
return;
|
|
428
|
+
}
|
|
429
|
+
if (box.classList.contains('fs-fallback')) { box.classList.remove('fs-fallback'); return; }
|
|
430
|
+
const request = box.requestFullscreen || box.webkitRequestFullscreen;
|
|
431
|
+
if (request) await request.call(box);
|
|
432
|
+
else box.classList.add('fs-fallback'); // iOS Safari: CSS overlay fallback
|
|
433
|
+
} catch { /* fullscreen denied: fall back to the CSS overlay */ box.classList.add('fs-fallback'); }
|
|
434
|
+
finally { fsInFlight = false; }
|
|
435
|
+
});
|
|
419
436
|
mermaid.initialize({
|
|
420
437
|
startOnLoad: true,
|
|
421
438
|
securityLevel: 'strict',
|
|
@@ -1,148 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import path from "node:path";
|
|
3
|
-
import { parse as parseYaml } from "yaml";
|
|
4
|
-
|
|
5
|
-
// Read .devloops (and extension variants) queue settings, mirroring the
|
|
6
|
-
// resolution used by ensure-queue-board.mjs. Returns { project }, { title },
|
|
7
|
-
// and/or { olderThanDays } when configured; never throws on a missing/bad file.
|
|
8
|
-
function resolveSettings(cwd) {
|
|
9
|
-
const basePath = path.join(cwd, ".devloops");
|
|
10
|
-
const extensions = ["", ".yaml", ".yml", ".json"];
|
|
11
|
-
for (const ext of extensions) {
|
|
12
|
-
try {
|
|
13
|
-
const raw = readFileSync(basePath + ext, "utf-8");
|
|
14
|
-
const settings = ext === ".json" ? JSON.parse(raw) : parseYaml(raw);
|
|
15
|
-
const queue = settings?.queue;
|
|
16
|
-
if (!queue) return null;
|
|
17
|
-
const out = {};
|
|
18
|
-
if (typeof queue.projectNumber === "number" && Number.isInteger(queue.projectNumber) && queue.projectNumber > 0) {
|
|
19
|
-
out.project = queue.projectNumber;
|
|
20
|
-
} else if (typeof queue.boardTitle === "string" && queue.boardTitle.trim().length > 0) {
|
|
21
|
-
out.title = queue.boardTitle.trim();
|
|
22
|
-
}
|
|
23
|
-
if (typeof queue.archiveOlderThanDays === "number" && Number.isInteger(queue.archiveOlderThanDays) && queue.archiveOlderThanDays > 0) {
|
|
24
|
-
out.olderThanDays = queue.archiveOlderThanDays;
|
|
25
|
-
}
|
|
26
|
-
return out;
|
|
27
|
-
} catch {
|
|
28
|
-
// extension not present or unparseable — try next
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
return null;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
// Parse a --project value into { kind:"id"|"number"|"uri", ... }. Throws
|
|
35
|
-
// INVALID_PROJECT on empty/malformed input (bare "0" is rejected too).
|
|
36
|
-
//
|
|
37
|
-
// Supported forms:
|
|
38
|
-
// <n> positive integer → { kind:"number", value:<n> }
|
|
39
|
-
// <NODE_ID> alphanumeric/_ ID → { kind:"id", value:<NODE_ID> }
|
|
40
|
-
// https://github.com/users/<login>/projects/<n>
|
|
41
|
-
// https://github.com/orgs/<login>/projects/<n>
|
|
42
|
-
// board URI → { kind:"uri", number:<n>, owner:<login>, ownerKind:"user"|"org" }
|
|
43
|
-
const GLOBAL_NODE_ID_RE = /^[A-Za-z0-9_]+$/;
|
|
44
|
-
|
|
45
|
-
// GitHub Projects V2 board URI pattern (user- or org-scoped boards).
|
|
46
|
-
const BOARD_URI_RE = /^https:\/\/github\.com\/(users|orgs)\/([A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)\/projects\/(\d+)$/;
|
|
47
|
-
|
|
48
|
-
function parseProjectRef(raw) {
|
|
49
|
-
if (!raw || typeof raw !== "string" || raw.trim().length === 0) {
|
|
50
|
-
throw Object.assign(new Error("--project is required"), { code: "INVALID_PROJECT" });
|
|
51
|
-
}
|
|
52
|
-
const trimmed = raw.trim();
|
|
53
|
-
|
|
54
|
-
// Board URI: https://github.com/users/<login>/projects/<n>
|
|
55
|
-
// https://github.com/orgs/<login>/projects/<n>
|
|
56
|
-
const uriMatch = BOARD_URI_RE.exec(trimmed);
|
|
57
|
-
if (uriMatch) {
|
|
58
|
-
const ownerKind = uriMatch[1] === "users" ? "user" : "org";
|
|
59
|
-
const owner = uriMatch[2];
|
|
60
|
-
const number = Number(uriMatch[3]);
|
|
61
|
-
if (number < 1) {
|
|
62
|
-
throw Object.assign(
|
|
63
|
-
new Error(`--project board URI must reference a positive project number, got "${raw}"`),
|
|
64
|
-
{ code: "INVALID_PROJECT" },
|
|
65
|
-
);
|
|
66
|
-
}
|
|
67
|
-
return { kind: "uri", number, owner, ownerKind };
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const asNum = Number(trimmed);
|
|
71
|
-
if (Number.isInteger(asNum) && asNum > 0 && String(asNum) === trimmed) {
|
|
72
|
-
return { kind: "number", value: asNum };
|
|
73
|
-
}
|
|
74
|
-
// Reject bare "0" — valid node ID character but not a meaningful project reference
|
|
75
|
-
if (trimmed === "0") {
|
|
76
|
-
throw Object.assign(
|
|
77
|
-
new Error(`--project must be a positive integer, a node ID, or a board URI, got "${raw}"`),
|
|
78
|
-
{ code: "INVALID_PROJECT" },
|
|
79
|
-
);
|
|
80
|
-
}
|
|
81
|
-
if (GLOBAL_NODE_ID_RE.test(trimmed)) {
|
|
82
|
-
return { kind: "id", value: trimmed };
|
|
83
|
-
}
|
|
84
|
-
throw Object.assign(
|
|
85
|
-
new Error(`--project must be a positive integer, a node ID, or a board URI, got "${raw}"`),
|
|
86
|
-
{ code: "INVALID_PROJECT" },
|
|
87
|
-
);
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// Selector precedence: explicit --project ref wins; else resolve by board title
|
|
91
|
-
// from .devloops (passed as args.projectTitle by runCli). Fail closed if neither.
|
|
92
|
-
function resolveProjectSelector(args) {
|
|
93
|
-
const hasProjectRef = typeof args.project === "string" && args.project.trim().length > 0;
|
|
94
|
-
const projectRef = hasProjectRef ? parseProjectRef(args.project) : null;
|
|
95
|
-
const projectTitle = !hasProjectRef && typeof args.projectTitle === "string" && args.projectTitle.trim().length > 0
|
|
96
|
-
? args.projectTitle.trim()
|
|
97
|
-
: null;
|
|
98
|
-
if (!projectRef && !projectTitle) {
|
|
99
|
-
throw Object.assign(
|
|
100
|
-
new Error("--project is required (or set queue.projectNumber / queue.boardTitle in .devloops)"),
|
|
101
|
-
{ code: "INVALID_PROJECT" },
|
|
102
|
-
);
|
|
103
|
-
}
|
|
104
|
-
return { projectRef, projectTitle };
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// Find the project in `projects` matching the resolved selector; throws
|
|
108
|
-
// PROJECT_NOT_FOUND (desc: "<id>" / number N / title "T" / URI number N under "<owner>").
|
|
109
|
-
function findProject(projects, { projectRef, projectTitle }, owner) {
|
|
110
|
-
let project;
|
|
111
|
-
if (projectRef) {
|
|
112
|
-
if (projectRef.kind === "id") {
|
|
113
|
-
project = projects.find((p) => p.id === projectRef.value);
|
|
114
|
-
} else if (projectRef.kind === "uri") {
|
|
115
|
-
project = projects.find((p) => p.number === projectRef.number);
|
|
116
|
-
} else {
|
|
117
|
-
project = projects.find((p) => p.number === projectRef.value);
|
|
118
|
-
}
|
|
119
|
-
} else {
|
|
120
|
-
project = projects.find((p) => p.title === projectTitle);
|
|
121
|
-
}
|
|
122
|
-
if (!project) {
|
|
123
|
-
const desc = projectRef
|
|
124
|
-
? (projectRef.kind === "id"
|
|
125
|
-
? `"${projectRef.value}"`
|
|
126
|
-
: projectRef.kind === "uri"
|
|
127
|
-
? `URI number ${projectRef.number} under "${projectRef.owner}"`
|
|
128
|
-
: `number ${projectRef.value}`)
|
|
129
|
-
: `title "${projectTitle}"`;
|
|
130
|
-
throw Object.assign(
|
|
131
|
-
new Error(`Project ${desc} not found under owner "${owner}"`),
|
|
132
|
-
{ code: "PROJECT_NOT_FOUND" },
|
|
133
|
-
);
|
|
134
|
-
}
|
|
135
|
-
return project;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// Apply .devloops board settings when --project was not passed. Precedence:
|
|
139
|
-
// explicit --project flag > queue.projectNumber/boardTitle. Mutates args.
|
|
140
|
-
function applyDevloopsBoard(args, cwd) {
|
|
141
|
-
if (args.project === undefined) {
|
|
142
|
-
const settings = resolveSettings(cwd);
|
|
143
|
-
if (settings?.project) args.project = String(settings.project);
|
|
144
|
-
else if (settings?.title) args.projectTitle = settings.title;
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
export { resolveSettings, parseProjectRef, resolveProjectSelector, findProject, applyDevloopsBoard };
|
|
1
|
+
export { resolveSettings, parseProjectRef, parseItemRef, resolveProjectSelector, findProject, applyDevloopsBoard } from "@dev-loops/core/projects/resolve-project";
|