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.
Files changed (107) hide show
  1. package/.claude/.claude-plugin/plugin.json +1 -1
  2. package/.claude/agents/dev-loop.md +7 -7
  3. package/.claude/agents/fixer.md +1 -0
  4. package/.claude/agents/refiner.md +4 -4
  5. package/.claude/agents/review.md +10 -13
  6. package/.claude/commands/loop-continue.md +2 -1
  7. package/.claude/commands/loop-enqueue.md +9 -2
  8. package/.claude/commands/loop-info.md +1 -1
  9. package/.claude/commands/loop-start-spike.md +1 -1
  10. package/.claude/hooks/_bash-command-classify.mjs +7 -6
  11. package/.claude/hooks/_hook-decisions.mjs +5 -4
  12. package/.claude/skills/copilot-pr-followup/SKILL.md +42 -28
  13. package/.claude/skills/dev-loop/SKILL.md +8 -8
  14. package/.claude/skills/docs/acceptance-criteria-verification.md +13 -4
  15. package/.claude/skills/docs/anti-patterns.md +6 -5
  16. package/.claude/skills/docs/artifact-authority-contract.md +17 -14
  17. package/.claude/skills/docs/confirmation-rules.md +2 -1
  18. package/.claude/skills/docs/contract-style-guide.md +37 -0
  19. package/.claude/skills/docs/copilot-loop-operations.md +21 -15
  20. package/.claude/skills/docs/cross-harness-regression-contract.md +2 -2
  21. package/.claude/skills/docs/debt-remediation-contract.md +1 -1
  22. package/.claude/skills/docs/epic-tree-refinement-procedure.md +25 -22
  23. package/.claude/skills/docs/issue-intake-procedure.md +37 -22
  24. package/.claude/skills/docs/merge-preconditions.md +17 -13
  25. package/.claude/skills/docs/pr-lifecycle-contract.md +45 -36
  26. package/.claude/skills/docs/public-dev-loop-contract.md +61 -44
  27. package/.claude/skills/docs/retrospective-checkpoint-contract.md +25 -9
  28. package/.claude/skills/docs/spike-mode-contract.md +14 -7
  29. package/.claude/skills/docs/stop-conditions.md +30 -15
  30. package/.claude/skills/docs/tracker-first-loop-state.md +13 -8
  31. package/.claude/skills/docs/validation-policy.md +4 -5
  32. package/.claude/skills/local-implementation/SKILL.md +49 -143
  33. package/.claude/skills/loop-grill/SKILL.md +34 -14
  34. package/AGENTS.md +1 -1
  35. package/CHANGELOG.md +36 -0
  36. package/README.md +95 -189
  37. package/agents/dev-loop.agent.md +8 -8
  38. package/agents/fixer.agent.md +1 -0
  39. package/agents/refiner.agent.md +4 -4
  40. package/agents/review.agent.md +10 -13
  41. package/extension/README.md +5 -4
  42. package/package.json +7 -5
  43. package/scripts/_core-helpers.mjs +2 -0
  44. package/scripts/claude/headless-dev-loop.mjs +1 -1
  45. package/scripts/docs/validate-rule-ownership.mjs +442 -0
  46. package/scripts/docs/validate-state-machine-conformance.mjs +1169 -0
  47. package/scripts/github/_gate-names.mjs +5 -0
  48. package/scripts/github/_review-thread-mutations.mjs +5 -2
  49. package/scripts/github/capture-review-threads.mjs +2 -2
  50. package/scripts/github/create-pr.mjs +119 -10
  51. package/scripts/github/detect-checkpoint-evidence.mjs +109 -24
  52. package/scripts/github/edit-issue.mjs +259 -0
  53. package/scripts/github/list-review-threads.mjs +277 -0
  54. package/scripts/github/post-gate-findings.mjs +6 -3
  55. package/scripts/github/probe-ci-status.mjs +18 -0
  56. package/scripts/github/probe-copilot-review.mjs +24 -3
  57. package/scripts/github/reconcile-draft-gate.mjs +13 -13
  58. package/scripts/github/request-copilot-review.mjs +109 -42
  59. package/scripts/github/upsert-checkpoint-verdict.mjs +98 -27
  60. package/scripts/github/verify-briefing-prefixes.mjs +382 -0
  61. package/scripts/github/verify-fresh-review-context.mjs +78 -3
  62. package/scripts/github/wait-pr-checks.mjs +171 -0
  63. package/scripts/github/write-gate-context.mjs +220 -10
  64. package/scripts/github/write-gate-findings-log.mjs +54 -2
  65. package/scripts/loop/_post-convergence-change.mjs +2 -2
  66. package/scripts/loop/_pr-runner-coordination.mjs +112 -13
  67. package/scripts/loop/check-retro-tooling.mjs +14 -9
  68. package/scripts/loop/copilot-pr-handoff.mjs +47 -16
  69. package/scripts/loop/detect-change-scope.mjs +2 -2
  70. package/scripts/loop/detect-copilot-loop-state.mjs +34 -14
  71. package/scripts/loop/detect-internal-only-pr.mjs +6 -6
  72. package/scripts/loop/detect-pr-gate-coordination-state.mjs +152 -18
  73. package/scripts/loop/detect-refinement-grill-state.mjs +136 -0
  74. package/scripts/loop/resolve-dev-loop-startup.mjs +145 -8
  75. package/scripts/loop/run-watch-cycle.mjs +42 -7
  76. package/scripts/loop/sanctioned-commands.mjs +3 -0
  77. package/scripts/loop/validate-pr-body-spec.mjs +21 -5
  78. package/scripts/pages/build-state-atlas.mjs +65 -48
  79. package/scripts/projects/_resolve-project.mjs +1 -148
  80. package/scripts/projects/add-queue-item.mjs +87 -4
  81. package/scripts/projects/list-queue-items.mjs +3 -377
  82. package/scripts/projects/move-queue-item.mjs +3 -410
  83. package/scripts/projects/reorder-queue-item.mjs +3 -22
  84. package/skills/copilot-pr-followup/SKILL.md +42 -28
  85. package/skills/dev-loop/SKILL.md +3 -3
  86. package/skills/docs/acceptance-criteria-verification.md +13 -4
  87. package/skills/docs/anti-patterns.md +6 -5
  88. package/skills/docs/artifact-authority-contract.md +17 -14
  89. package/skills/docs/confirmation-rules.md +2 -1
  90. package/skills/docs/contract-style-guide.md +37 -0
  91. package/skills/docs/copilot-loop-operations.md +21 -15
  92. package/skills/docs/cross-harness-regression-contract.md +2 -2
  93. package/skills/docs/debt-remediation-contract.md +1 -1
  94. package/skills/docs/epic-tree-refinement-procedure.md +25 -22
  95. package/skills/docs/issue-intake-procedure.md +37 -22
  96. package/skills/docs/merge-preconditions.md +17 -13
  97. package/skills/docs/pr-lifecycle-contract.md +45 -36
  98. package/skills/docs/public-dev-loop-contract.md +61 -44
  99. package/skills/docs/required-rules.json +165 -0
  100. package/skills/docs/retrospective-checkpoint-contract.md +25 -9
  101. package/skills/docs/spike-mode-contract.md +14 -7
  102. package/skills/docs/stop-conditions.md +30 -15
  103. package/skills/docs/tracker-first-loop-state.md +13 -8
  104. package/skills/docs/validation-policy.md +4 -5
  105. package/skills/local-implementation/SKILL.md +49 -143
  106. package/skills/loop-grill/SKILL.md +38 -17
  107. 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 watchWorkflowRunImpl(
374
- {
375
- repo: options.repo,
376
- runId: session.runId,
377
- timeoutMs: persistentWatchTimeoutMs,
378
- },
379
- { env, ghCommand },
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 a GitHub closing-keyword issue
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
- // Four diagrams come straight from packages/core/src/loop tables (copilot loop,
6
- // reviewer loop, outer conductor routing, and the public dev-loop gate hub); two
7
- // more are authored from documented, table-less sources (the PR lifecycle
8
- // contract and the release pipeline workflow).
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 TD', ' router(["dev-loop router"])'];
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
- // --- Statically-authored diagrams (documented, table-less sources) ---
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
- // PR lifecycle: the 13-state vocabulary + required transitions from
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>, and the public router's <code>PUBLIC_DEV_LOOP_GATE_CONTRACT</code>. They cannot drift from the code: change a transition table and this page changes with it. Two diagrams (the PR lifecycle and the release pipeline) are authored from documented contracts that have no single code table.",
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: 'skills/docs/pr-lifecycle-contract.md (documented vocabulary)',
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
- import { readFileSync } from "node:fs";
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";