dev-loops 0.6.0 → 0.7.1

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 (172) hide show
  1. package/.claude/.claude-plugin/plugin.json +1 -1
  2. package/.claude/agents/dev-loop.md +2 -1
  3. package/.claude/agents/review.md +2 -1
  4. package/.claude/commands/{auto.md → loop-auto.md} +1 -1
  5. package/.claude/commands/loop-continue.md +15 -0
  6. package/.claude/commands/loop-enqueue.md +22 -0
  7. package/.claude/commands/loop-grill.md +17 -0
  8. package/.claude/commands/{info.md → loop-info.md} +2 -2
  9. package/.claude/commands/loop-queue-status.md +24 -0
  10. package/.claude/commands/{start-spike.md → loop-start-spike.md} +1 -1
  11. package/.claude/commands/{start.md → loop-start.md} +1 -1
  12. package/.claude/commands/{status.md → loop-status.md} +1 -1
  13. package/.claude/hooks/_bash-command-classify.mjs +333 -29
  14. package/.claude/hooks/_hook-decisions.mjs +138 -15
  15. package/.claude/hooks/pre-tool-use-bash-gate.mjs +36 -15
  16. package/.claude/skills/copilot-pr-followup/SKILL.md +24 -12
  17. package/.claude/skills/dev-loop/SKILL.md +15 -11
  18. package/.claude/skills/docs/acceptance-criteria-verification.md +6 -2
  19. package/.claude/skills/docs/anti-patterns.md +2 -1
  20. package/.claude/skills/docs/artifact-authority-contract.md +22 -4
  21. package/.claude/skills/docs/copilot-loop-operations.md +1 -1
  22. package/.claude/skills/docs/cross-harness-regression-contract.md +60 -0
  23. package/.claude/skills/docs/issue-intake-procedure.md +1 -0
  24. package/.claude/skills/docs/local-planning-flow.md +1 -1
  25. package/.claude/skills/docs/local-planning-worked-example.md +1 -1
  26. package/.claude/skills/docs/merge-preconditions.md +17 -1
  27. package/.claude/skills/docs/plan-file-contract.md +1 -1
  28. package/.claude/skills/docs/public-dev-loop-contract.md +1 -1
  29. package/.claude/skills/docs/retrospective-checkpoint-contract.md +90 -76
  30. package/.claude/skills/docs/ui-e2e-scoping-step.md +32 -0
  31. package/.claude/skills/docs/workflow-handoff-contract.md +39 -0
  32. package/.claude/skills/local-implementation/SKILL.md +25 -14
  33. package/.claude/skills/loop-grill/SKILL.md +163 -0
  34. package/AGENTS.md +1 -0
  35. package/CHANGELOG.md +95 -0
  36. package/README.md +9 -9
  37. package/agents/dev-loop.agent.md +3 -2
  38. package/agents/developer.agent.md +1 -1
  39. package/agents/docs.agent.md +1 -1
  40. package/agents/fixer.agent.md +1 -1
  41. package/agents/quality.agent.md +1 -1
  42. package/agents/refiner.agent.md +1 -1
  43. package/agents/review.agent.md +3 -2
  44. package/cli/index.mjs +35 -42
  45. package/extension/README.md +4 -4
  46. package/extension/checks.ts +1 -5
  47. package/extension/harness-types.ts +1 -0
  48. package/extension/index.ts +6 -0
  49. package/extension/pi-extension-adapter.ts +2 -0
  50. package/extension/presentation.ts +7 -19
  51. package/package.json +9 -10
  52. package/scripts/github/capture-review-threads.mjs +2 -9
  53. package/scripts/github/comment-issue.mjs +2 -9
  54. package/scripts/github/create-label.mjs +133 -0
  55. package/scripts/github/detect-checkpoint-evidence.mjs +170 -14
  56. package/scripts/github/detect-linked-issue-pr.mjs +22 -6
  57. package/scripts/github/edit-pr.mjs +259 -0
  58. package/scripts/github/fetch-ci-logs.mjs +2 -9
  59. package/scripts/github/list-issues.mjs +2 -9
  60. package/scripts/github/manage-sub-issues.mjs +46 -10
  61. package/scripts/github/offer-human-handoff.mjs +8 -5
  62. package/scripts/github/post-gate-findings.mjs +14 -2
  63. package/scripts/github/probe-ci-status.mjs +8 -2
  64. package/scripts/github/probe-copilot-review.mjs +21 -14
  65. package/scripts/github/ready-for-review.mjs +26 -8
  66. package/scripts/github/reconcile-draft-gate.mjs +7 -3
  67. package/scripts/github/reply-resolve-review-thread.mjs +16 -5
  68. package/scripts/github/reply-resolve-review-threads.mjs +69 -7
  69. package/scripts/github/request-copilot-review.mjs +8 -2
  70. package/scripts/github/resolve-handoff-candidates.mjs +7 -3
  71. package/scripts/github/resolve-tracker-local-spec.mjs +9 -3
  72. package/scripts/github/stage-reviewer-draft.mjs +10 -2
  73. package/scripts/github/tick-verified-checkboxes.mjs +202 -0
  74. package/scripts/github/upsert-checkpoint-verdict.mjs +31 -10
  75. package/scripts/github/verify-fresh-review-context.mjs +150 -31
  76. package/scripts/github/view-pr.mjs +150 -0
  77. package/scripts/github/write-gate-context.mjs +248 -61
  78. package/scripts/github/write-gate-findings-log.mjs +75 -1
  79. package/scripts/lib/jq-output.mjs +18 -0
  80. package/scripts/loop/_post-convergence-change.mjs +211 -0
  81. package/scripts/loop/_pr-runner-coordination.mjs +70 -0
  82. package/scripts/loop/_repo-root-resolver.mjs +47 -0
  83. package/scripts/loop/build-handoff-envelope.mjs +14 -4
  84. package/scripts/loop/check-retro-tooling.mjs +14 -3
  85. package/scripts/loop/checkpoint-contract.mjs +7 -4
  86. package/scripts/loop/cleanup-worktree.mjs +12 -3
  87. package/scripts/loop/conductor-monitor.mjs +12 -18
  88. package/scripts/loop/copilot-pr-handoff.mjs +135 -14
  89. package/scripts/loop/debt-remediate.mjs +24 -12
  90. package/scripts/loop/detect-change-scope.mjs +36 -7
  91. package/scripts/loop/detect-copilot-loop-state.mjs +11 -5
  92. package/scripts/loop/detect-copilot-session-activity.mjs +7 -3
  93. package/scripts/loop/detect-initial-copilot-pr-state.mjs +7 -3
  94. package/scripts/loop/detect-internal-only-pr.mjs +8 -2
  95. package/scripts/loop/detect-issue-refinement-artifact.mjs +6 -3
  96. package/scripts/loop/detect-pr-gate-coordination-state.mjs +149 -66
  97. package/scripts/loop/detect-reviewer-loop-state.mjs +12 -2
  98. package/scripts/loop/detect-tracker-first-loop-state.mjs +9 -1
  99. package/scripts/loop/detect-tracker-pr-state.mjs +10 -3
  100. package/scripts/loop/ensure-worktree.mjs +8 -3
  101. package/scripts/loop/info.mjs +12 -4
  102. package/scripts/loop/inspect-run-viewer/constants.mjs +4 -18
  103. package/scripts/loop/inspect-run-viewer/vendor/mermaid.min.js +3405 -0
  104. package/scripts/loop/inspect-run-viewer-ci-changes.mjs +18 -6
  105. package/scripts/loop/inspect-run-viewer.mjs +67 -4
  106. package/scripts/loop/inspect-run.mjs +8 -2
  107. package/scripts/loop/outer-loop.mjs +8 -4
  108. package/scripts/loop/pr-runner-coordination.mjs +2 -9
  109. package/scripts/loop/pre-commit-branch-guard.mjs +16 -10
  110. package/scripts/loop/pre-flight-gate.mjs +9 -9
  111. package/scripts/loop/pre-pr-ready-gate.mjs +8 -4
  112. package/scripts/loop/pre-write-remote-freshness-guard.mjs +13 -4
  113. package/scripts/loop/provision-worktree.mjs +74 -3
  114. package/scripts/loop/resolve-dev-loop-startup.mjs +76 -7
  115. package/scripts/loop/resolve-gate-dispatch.mjs +134 -0
  116. package/scripts/loop/resolve-pr-conflicts.mjs +14 -7
  117. package/scripts/loop/run-conductor-cycle.mjs +8 -2
  118. package/scripts/loop/run-queue.mjs +18 -9
  119. package/scripts/loop/run-refinement-audit.mjs +8 -9
  120. package/scripts/loop/run-watch-cycle.mjs +2 -9
  121. package/scripts/loop/sanctioned-commands.mjs +104 -0
  122. package/scripts/loop/steer-loop.mjs +29 -16
  123. package/scripts/loop/validate-pr-body-spec.mjs +207 -0
  124. package/scripts/loop/watch-initial-copilot-pr.mjs +8 -3
  125. package/scripts/pages/build-site.mjs +59 -8
  126. package/scripts/pages/build-state-atlas.mjs +441 -0
  127. package/scripts/projects/_resolve-project.mjs +148 -0
  128. package/scripts/projects/add-queue-item.mjs +60 -54
  129. package/scripts/projects/archive-done-items.mjs +49 -84
  130. package/scripts/projects/ensure-queue-board.mjs +10 -36
  131. package/scripts/projects/list-queue-items.mjs +116 -65
  132. package/scripts/projects/move-queue-item.mjs +28 -49
  133. package/scripts/projects/reconcile-queue.mjs +253 -0
  134. package/scripts/projects/reorder-queue-item.mjs +41 -47
  135. package/scripts/projects/resolve-active-board-item.mjs +108 -46
  136. package/scripts/projects/sync-item-status.mjs +15 -10
  137. package/scripts/refine/_refine-helpers.mjs +21 -5
  138. package/scripts/refine/exit-spike.mjs +18 -8
  139. package/scripts/refine/promote-plan.mjs +22 -16
  140. package/scripts/refine/prose-linkage-detector.mjs +1 -1
  141. package/scripts/refine/refine-plan-file.mjs +13 -4
  142. package/scripts/refine/refinement-completeness-checker.mjs +1 -1
  143. package/scripts/refine/scaffold-spike-file.mjs +4 -8
  144. package/scripts/refine/scope-boundary-cross-checker.mjs +1 -1
  145. package/scripts/refine/tree-integrity-validator.mjs +1 -1
  146. package/scripts/refine/validate-plan-file.mjs +1 -1
  147. package/scripts/refine/validate-spike-file.mjs +1 -1
  148. package/scripts/refine/verify.mjs +11 -6
  149. package/scripts/release/assert-core-dependency-version.mjs +123 -0
  150. package/scripts/release/extract-changelog-section.mjs +16 -2
  151. package/skills/copilot-pr-followup/SKILL.md +24 -12
  152. package/skills/dev-loop/SKILL.md +10 -6
  153. package/skills/docs/acceptance-criteria-verification.md +6 -2
  154. package/skills/docs/anti-patterns.md +2 -1
  155. package/skills/docs/artifact-authority-contract.md +22 -4
  156. package/skills/docs/copilot-loop-operations.md +1 -1
  157. package/skills/docs/cross-harness-regression-contract.md +60 -0
  158. package/skills/docs/issue-intake-procedure.md +1 -0
  159. package/skills/docs/local-planning-flow.md +1 -1
  160. package/skills/docs/local-planning-worked-example.md +1 -1
  161. package/skills/docs/merge-preconditions.md +17 -1
  162. package/skills/docs/plan-file-contract.md +1 -1
  163. package/skills/docs/public-dev-loop-contract.md +1 -1
  164. package/skills/docs/retrospective-checkpoint-contract.md +90 -76
  165. package/skills/docs/ui-e2e-scoping-step.md +32 -0
  166. package/skills/docs/workflow-handoff-contract.md +39 -0
  167. package/skills/local-implementation/SKILL.md +25 -14
  168. package/skills/loop-grill/SKILL.md +165 -0
  169. package/.claude/commands/continue.md +0 -15
  170. package/scripts/loop/conductor.mjs +0 -233
  171. package/scripts/loop/detect-stale-runner.mjs +0 -265
  172. package/scripts/loop/pre-push-main-guard.mjs +0 -117
@@ -0,0 +1,441 @@
1
+ // Generates the "State atlas" page for the GitHub Pages site: every dev-loops
2
+ // state machine rendered as a mermaid diagram, emitted DETERMINISTICALLY from
3
+ // the code's own exported tables so the page can never drift from the code.
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).
9
+ //
10
+ // The page reuses the article design system (docs/articles/introducing-dev-loops.html):
11
+ // the same :root tokens, body gradient, and typography. It intentionally defines
12
+ // a <style> block and a <body> tag so build-site.mjs can inject the shared nav
13
+ // (NAV_CSS references --heading/--kicker/--accent-soft, all declared here).
14
+ import { STATE, TRANSITIONS } from '../../packages/core/src/loop/copilot-loop-state.mjs';
15
+ import { REVIEWER_STATE, REVIEWER_TRANSITIONS } from '../../packages/core/src/loop/reviewer-loop-state.mjs';
16
+ import { OUTER_STATE, OUTER_TRANSITIONS } from '../../packages/core/src/loop/conductor-routing.mjs';
17
+ import { PUBLIC_DEV_LOOP_GATE_CONTRACT } from '../../packages/core/src/loop/public-dev-loop-routing-contract.mjs';
18
+
19
+ // Classify a state/gate id by name into one of four visual classes. Colors are
20
+ // drawn from the site's own dark palette (accent violet, kicker blue,
21
+ // accent-soft, and the card-border slate) so diagrams sit natively in the skin.
22
+ const WAIT = /waiting|watch/;
23
+ const STOP = /blocked|unavailable|stop|needs_human|reconcile|invalidated|conflict/;
24
+ const TERM = /done|terminal|complete|merged/;
25
+ // TERM is tested before STOP so names carrying both (e.g. stop_done_terminal)
26
+ // classify as terminal, not blocked. `conflict` is a stop keyword: a conflicted
27
+ // head is a blocked/detour state, never active progress.
28
+ function classify(name) {
29
+ if (WAIT.test(name)) return 'wait';
30
+ if (TERM.test(name)) return 'term';
31
+ if (STOP.test(name)) return 'stop';
32
+ return 'act';
33
+ }
34
+
35
+ // classDef colors sourced from the article design tokens: --accent #a78bfa,
36
+ // --kicker #93c5fd, --accent-soft #ddd6fe, slate #94a3b8, ground #0f172a.
37
+ const CLASSDEFS = [
38
+ ' classDef act fill:#171532,stroke:#a78bfa,stroke-width:1px,color:#e5e7eb;',
39
+ ' classDef wait fill:#0f1d33,stroke:#93c5fd,stroke-width:1px,color:#e5e7eb;',
40
+ ' classDef term fill:#1c1a3a,stroke:#ddd6fe,stroke-width:1.5px,color:#f8fafc;',
41
+ ' classDef stop fill:#1a2233,stroke:#94a3b8,stroke-width:1px,color:#cbd5e1,stroke-dasharray:4 2;',
42
+ ];
43
+
44
+ // A state with no legal transitions in its table is terminal → edge to [*].
45
+ function edgesFromTransitions(values, transitions) {
46
+ const edges = [];
47
+ for (const s of values) {
48
+ const targets = transitions[s] ?? [];
49
+ if (targets.length === 0) edges.push([s, '[*]']);
50
+ else for (const t of targets) edges.push([s, t]);
51
+ }
52
+ return edges;
53
+ }
54
+
55
+ // Note: mermaid sources are HTML-escaped (& then <) when embedded into the page
56
+ // (see sectionMarkup), so the browser's parser hands mermaid the exact source
57
+ // back via textContent. Every id here is snake_case and every label is HTML-free,
58
+ // so the escaping is defense-in-depth against future drift, not load-bearing:
59
+ // today's sources contain `>` (in `-->` arrows) but never `<` or `&`, and the
60
+ // arrows survive verbatim in the built bytes for the atlas tests.
61
+ function renderStateDiagram(edges, states) {
62
+ const lines = ['stateDiagram-v2'];
63
+ for (const [a, b] of edges) lines.push(` ${a} --> ${b}`);
64
+ lines.push(...CLASSDEFS);
65
+ for (const s of states) lines.push(` class ${s} ${classify(s)}`);
66
+ return lines.join('\n');
67
+ }
68
+
69
+ // The public dev-loop gate contract as a router → gates flowchart hub: one edge
70
+ // per row, labelled with routeKind; each gate node shows gate id + strategy.
71
+ // Gates carry their own routeKind, so color by it (route→act, wait→wait,
72
+ // stop/needs_reconcile→stop) instead of the name heuristic — e.g.
73
+ // waiting_for_merge_authorization names "waiting" but its routeKind is stop.
74
+ // Terminal-named gates (stop_done_terminal) still classify as term.
75
+ function gateClass(row) {
76
+ if (TERM.test(row.gate)) return 'term';
77
+ if (row.routeKind === 'route') return 'act';
78
+ if (row.routeKind === 'wait') return 'wait';
79
+ return 'stop';
80
+ }
81
+
82
+ function renderGateFlowchart(contract) {
83
+ const lines = ['flowchart TD', ' router(["dev-loop router"])'];
84
+ for (const row of contract) {
85
+ const strategy = row.selectedStrategy ?? 'none';
86
+ lines.push(` router -->|${row.routeKind}| ${row.gate}["${row.gate} — ${strategy}"]`);
87
+ }
88
+ lines.push(...CLASSDEFS);
89
+ for (const row of contract) lines.push(` class ${row.gate} ${gateClass(row)}`);
90
+ return lines.join('\n');
91
+ }
92
+
93
+ // --- Code-derived diagrams (single source of truth = the exported tables) ---
94
+ const copilotDiagram = renderStateDiagram(
95
+ edgesFromTransitions(Object.values(STATE), TRANSITIONS),
96
+ Object.values(STATE),
97
+ );
98
+ const reviewerDiagram = renderStateDiagram(
99
+ edgesFromTransitions(Object.values(REVIEWER_STATE), REVIEWER_TRANSITIONS),
100
+ Object.values(REVIEWER_STATE),
101
+ );
102
+ const outerDiagram = renderStateDiagram(
103
+ edgesFromTransitions(Object.values(OUTER_STATE), OUTER_TRANSITIONS),
104
+ Object.values(OUTER_STATE),
105
+ );
106
+ const gateDiagram = renderGateFlowchart(PUBLIC_DEV_LOOP_GATE_CONTRACT);
107
+
108
+ // --- Statically-authored diagrams (documented, table-less sources) ---
109
+
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);
149
+
150
+ // Release pipeline: the fail-closed gate chain from .github/workflows/release.yml.
151
+ const releasePipelineNodes = [
152
+ 'push_tag',
153
+ 'ancestry',
154
+ 'lockstep',
155
+ 'exists',
156
+ 'changelog',
157
+ 'create',
158
+ 'done_release_published',
159
+ 'done_idempotent_skip',
160
+ 'stop_not_on_main',
161
+ 'stop_core_mismatch',
162
+ 'stop_changelog_missing',
163
+ ];
164
+ const releaseDiagram = [
165
+ 'flowchart TD',
166
+ ' push_tag(["push v* tag"])',
167
+ ' push_tag --> ancestry{"commit is ancestor of origin/main?"}',
168
+ ' ancestry -->|no| stop_not_on_main["stopped: not on main"]',
169
+ ' ancestry -->|yes| lockstep{"@dev-loops/core dep in lockstep?"}',
170
+ ' lockstep -->|no| stop_core_mismatch["stopped: core version mismatch"]',
171
+ ' lockstep -->|yes| exists{"release already exists?"}',
172
+ ' exists -->|yes| done_idempotent_skip["done: idempotent skip"]',
173
+ ' exists -->|no| changelog{"CHANGELOG section extractable?"}',
174
+ ' changelog -->|no| stop_changelog_missing["stopped: changelog missing"]',
175
+ ' changelog -->|yes| create["gh release create --verify-tag"]',
176
+ ' create --> done_release_published["done: release published, fires npm-publish"]',
177
+ ...CLASSDEFS,
178
+ ...releasePipelineNodes.map((n) => ` class ${n} ${classify(n)}`),
179
+ ].join('\n');
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // Hand-authored explanatory prose.
183
+ //
184
+ // This is the ONLY hand-written content on the page: the diagrams above are
185
+ // generated from the code tables; the words below are authored from the
186
+ // documented contracts. Keeping the two apart in this module makes the split
187
+ // explicit — if a diagram and its prose disagree, the diagram (the code) wins.
188
+ // ---------------------------------------------------------------------------
189
+
190
+ const INTRO_PROSE = [
191
+ '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.",
193
+ '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
+ ];
195
+
196
+ const SECTIONS = [
197
+ {
198
+ id: 'public-gate-hub',
199
+ title: 'Public dev-loop gate hub',
200
+ source: 'packages/core/src/loop/public-dev-loop-routing-contract.mjs — PUBLIC_DEV_LOOP_GATE_CONTRACT',
201
+ prose: [
202
+ 'The single public entrypoint, <code>dev-loop</code>, resolves the canonical current state to exactly one of eleven gates. Each gate carries a closed route-kind — <code>route</code> to an internal strategy, <code>stop</code> for a human decision or terminal work, or <code>wait</code> on an external signal — and the internal strategy it selects.',
203
+ 'This is the table the startup resolver walks on every invocation: it is not a one-time setup step but the routing decision re-made each time. Ambiguous, conflicting, or unsupported state does not get a guessed route — it fails closed to <code>fail_closed_reconcile</code>. Edge labels below are the route-kind; each node names the gate and the strategy it selects.',
204
+ ],
205
+ diagram: gateDiagram,
206
+ },
207
+ {
208
+ id: 'outer-routing',
209
+ title: 'Outer conductor routing',
210
+ source: 'packages/core/src/loop/conductor-routing.mjs — OUTER_STATE + OUTER_TRANSITIONS',
211
+ prose: [
212
+ 'Above the family-local machines, the outer conductor derives one routing outcome per tick: continue waiting, hand off to the Copilot loop, hand off to the reviewer loop, or stay with a live owner. The four active outcomes form a fully-connected core by design — routing is stateless per tick, so each tick re-derives the outcome from fresh detector state rather than following a remembered path.',
213
+ 'The three remaining outcomes — <code>stop_needs_human</code>, <code>done_terminal</code>, and <code>needs_reconcile</code> — are absorbing: once reached, the outer loop does not route onward on its own.',
214
+ ],
215
+ diagram: outerDiagram,
216
+ },
217
+ {
218
+ id: 'pr-lifecycle',
219
+ title: 'PR lifecycle contract',
220
+ source: 'skills/docs/pr-lifecycle-contract.md (documented vocabulary)',
221
+ prose: [
222
+ '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
+ '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.',
224
+ ],
225
+ diagram: prLifecycleDiagram,
226
+ },
227
+ {
228
+ id: 'copilot-loop',
229
+ title: 'Copilot review/fix loop',
230
+ source: 'packages/core/src/loop/copilot-loop-state.mjs — STATE + TRANSITIONS',
231
+ prose: [
232
+ 'The inner loop requests Copilot review, watches for it, and remediates — all scoped to one PR head. Unresolved feedback always routes into fix and reply/resolve, never into a wait: the loop does not sleep on a PR that has open threads. Only once threads are resolved and CI is settled does it re-request or converge.',
233
+ 'Re-requesting is bounded by <code>maxCopilotRounds</code>. At the cap a clean PR routes to <code>round_cap_clean_fallback</code> (proceed to the pre-approval gate) rather than dead-ending on a review that can never come, while a not-clean PR hard-stops at <code>round_cap_reached</code>; a significant head change is the escape hatch that re-opens auto re-request. A separate low-signal heuristic can end the loop early at <code>low_signal_converged</code> when extra rounds yield only minimal actionable feedback. These cap and convergence states are the ones older prose graphs omit.',
234
+ ],
235
+ diagram: copilotDiagram,
236
+ },
237
+ {
238
+ id: 'reviewer-loop',
239
+ title: 'Reviewer loop',
240
+ source: 'packages/core/src/loop/reviewer-loop-state.mjs — REVIEWER_STATE + REVIEWER_TRANSITIONS',
241
+ prose: [
242
+ 'The reviewer side plans a bounded set of review angles, runs them in parallel, merges the results into one draft review, and posts it — then submits. The external-wait boundaries are explicitly named states (<code>waiting_for_review_request</code>, <code>waiting_for_user_submit</code>) rather than implicit pauses, so a wait on a human is always a distinct, inspectable state.',
243
+ 'If the reviewed head goes stale, <code>review_invalidated</code> discards the pending draft and re-enters at <code>review_requested</code> so the review is always produced for the current head.',
244
+ ],
245
+ diagram: reviewerDiagram,
246
+ },
247
+ {
248
+ id: 'release-pipeline',
249
+ title: 'Release pipeline',
250
+ source: '.github/workflows/release.yml (documented workflow)',
251
+ prose: [
252
+ 'Cutting a release is milestone-terminal. The version-bump PR itself goes through the same full gate pipeline as any other change — there is no privileged path to main. Pushing a <code>v*</code> tag is the only manual release step.',
253
+ 'Everything after the tag fails closed: the release commit must be an ancestor of <code>origin/main</code>, the shipped <code>@dev-loops/core</code> dependency must be in major.minor lockstep with the release, an existing release short-circuits idempotently, and the CHANGELOG section must extract before <code>gh release create</code> runs. Any failed check stops the release rather than publishing a partial one.',
254
+ ],
255
+ diagram: releaseDiagram,
256
+ },
257
+ ];
258
+
259
+ function proseParagraphs(prose) {
260
+ return prose.map((p) => ` <p>${p}</p>`).join('\n');
261
+ }
262
+
263
+ // Escape mermaid source for HTML embedding: & first, then <. The HTML parser
264
+ // unescapes these before mermaid reads the element's textContent, so mermaid
265
+ // sees the exact original source (and `-->` arrows pass through untouched).
266
+ const escapeMermaid = (src) => src.replace(/&/g, '&amp;').replace(/</g, '&lt;');
267
+
268
+ function sectionMarkup(s) {
269
+ return ` <section class="atlas-section" id="${s.id}">
270
+ <h2>${s.title}</h2>
271
+ <p class="source">Diagram generated from <code>${s.source}</code></p>
272
+ ${proseParagraphs(s.prose)}
273
+ <div class="diagram"><div class="mermaid">
274
+ ${escapeMermaid(s.diagram)}
275
+ </div></div>
276
+ </section>`;
277
+ }
278
+
279
+ // The colour legend mirrors the four classDef classes so readers can map a node
280
+ // colour to its role. Swatch colours are the classDef stroke colours above.
281
+ const LEGEND = [
282
+ { cls: 'act', label: 'active / in-progress', stroke: '#a78bfa' },
283
+ { cls: 'wait', label: 'waiting on external', stroke: '#93c5fd' },
284
+ { cls: 'stop', label: 'blocked / fail-closed', stroke: '#94a3b8' },
285
+ { cls: 'term', label: 'terminal', stroke: '#ddd6fe' },
286
+ ];
287
+
288
+ function legendMarkup() {
289
+ const items = LEGEND.map(
290
+ (l) => ` <li><span class="swatch" style="border-color:${l.stroke}" aria-hidden="true"></span> ${l.label}</li>`,
291
+ ).join('\n');
292
+ return ` <ul class="legend" aria-label="diagram colour key">
293
+ ${items}
294
+ </ul>`;
295
+ }
296
+
297
+ /**
298
+ * Build the full State atlas page HTML (deterministic; no timestamps/randomness).
299
+ *
300
+ * The returned HTML has a single <style> block and a <body> tag so build-site's
301
+ * injectNav can attach the shared nav. It references the vendored mermaid via
302
+ * <script src="assets/mermaid.min.js"> (copied into site/ by build-site) rather
303
+ * than inlining ~3MB.
304
+ *
305
+ * The CSP meta mirrors the article pages' policy (default-src 'none';
306
+ * 'unsafe-inline' styles; data: images; locked base/form) and extends it with
307
+ * script-src 'self' (the copied mermaid asset) + 'unsafe-inline' (the init
308
+ * script below), matching the articles' inline approach — they use
309
+ * 'unsafe-inline', not hash sources.
310
+ *
311
+ * @returns {string}
312
+ */
313
+ export function buildStateAtlasHtml() {
314
+ const intro = INTRO_PROSE.map((p) => ` <p class="lede">${p}</p>`).join('\n');
315
+ const sections = SECTIONS.map(sectionMarkup).join('\n');
316
+ return `<!DOCTYPE html>
317
+ <html lang="en">
318
+ <head>
319
+ <meta charset="utf-8" />
320
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
321
+ <meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'" />
322
+ <title>State atlas — dev-loops</title>
323
+ <style>
324
+ :root {
325
+ --ground-1: #08101f;
326
+ --ground-2: #0b1220;
327
+ --ground-3: #0f172a;
328
+ --ink: #e5e7eb;
329
+ --heading: #f8fafc;
330
+ --copy: #cbd5e1;
331
+ --accent: #a78bfa;
332
+ --accent-soft: #ddd6fe;
333
+ --kicker: #93c5fd;
334
+ --card-border: rgba(148, 163, 184, 0.18);
335
+ --font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, "Helvetica Neue", sans-serif;
336
+ --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
337
+ }
338
+
339
+ * { box-sizing: border-box; }
340
+
341
+ body {
342
+ margin: 0;
343
+ font-family: var(--font);
344
+ color: var(--ink);
345
+ overflow-x: hidden;
346
+ -webkit-text-size-adjust: 100%;
347
+ background:
348
+ radial-gradient(circle at 85% 4%, rgba(139, 92, 246, 0.22), transparent 26%),
349
+ radial-gradient(circle at 12% 2%, rgba(59, 130, 246, 0.16), transparent 22%),
350
+ linear-gradient(180deg, var(--ground-1) 0%, var(--ground-2) 38%, var(--ground-3) 100%);
351
+ background-attachment: fixed;
352
+ }
353
+
354
+ .wrap {
355
+ max-width: 48rem;
356
+ margin: 0 auto;
357
+ padding: clamp(2rem, 6vw, 4rem) clamp(1.1rem, 5vw, 2rem) 5rem;
358
+ }
359
+ @media (min-width: 900px) {
360
+ .wrap { max-width: 64rem; }
361
+ }
362
+
363
+ .kicker {
364
+ text-transform: uppercase;
365
+ letter-spacing: 0.14em;
366
+ font-size: 0.72rem;
367
+ color: var(--kicker);
368
+ margin: 0 0 0.6rem;
369
+ font-weight: 600;
370
+ }
371
+
372
+ h1, h2, h3 { color: var(--heading); letter-spacing: -0.02em; margin-top: 0; }
373
+ h1 { font-weight: 760; font-size: clamp(1.9rem, 5vw, 2.7rem); line-height: 1.08; margin-bottom: 0.9rem; }
374
+ h2 { font-weight: 720; font-size: clamp(1.35rem, 3.4vw, 1.85rem); line-height: 1.15; margin: 0 0 0.6rem; }
375
+
376
+ p { line-height: 1.72; font-size: 1.05rem; color: var(--ink); margin: 0 0 1.15rem; }
377
+ strong { color: var(--accent-soft); }
378
+ .lede { color: var(--copy); font-size: clamp(1.02rem, 2.2vw, 1.18rem); line-height: 1.5; margin: 0 0 1.4rem; }
379
+ .source { color: var(--copy); font-size: 0.85rem; margin: 0 0 0.6rem; }
380
+
381
+ code {
382
+ font-family: var(--mono);
383
+ font-size: 0.9em;
384
+ background: rgba(148, 163, 184, 0.12);
385
+ border: 1px solid rgba(148, 163, 184, 0.16);
386
+ border-radius: 6px;
387
+ padding: 0.05em 0.4em;
388
+ color: #e2e8f0;
389
+ overflow-wrap: anywhere;
390
+ }
391
+
392
+ .legend { list-style: none; display: flex; flex-wrap: wrap; gap: 0.5rem 1.4rem; padding: 0; margin: 0 0 2.6rem; font-size: 0.9rem; color: var(--copy); }
393
+ .legend li { display: inline-flex; align-items: center; gap: 0.5rem; margin: 0; }
394
+ .legend .swatch { width: 0.85rem; height: 0.85rem; border-radius: 4px; border: 2px solid; background: rgba(15, 23, 42, 0.6); display: inline-block; }
395
+
396
+ .atlas-section { margin: 0 0 3rem; }
397
+
398
+ .diagram {
399
+ background: linear-gradient(180deg, rgba(15, 23, 42, 0.82), rgba(15, 23, 42, 0.6));
400
+ border: 1px solid var(--card-border);
401
+ border-radius: 14px;
402
+ padding: 1.15rem;
403
+ overflow-x: auto;
404
+ }
405
+ .diagram .mermaid { text-align: center; }
406
+ .diagram .mermaid svg { max-width: 100%; height: auto; }
407
+ </style>
408
+ </head>
409
+ <body>
410
+ <main class="wrap">
411
+ <p class="kicker">dev-loops</p>
412
+ <h1>State atlas</h1>
413
+ ${intro}
414
+ ${legendMarkup()}
415
+ ${sections}
416
+ </main>
417
+ <script src="assets/mermaid.min.js"></script>
418
+ <script>
419
+ mermaid.initialize({
420
+ startOnLoad: true,
421
+ securityLevel: 'strict',
422
+ theme: 'base',
423
+ themeVariables: {
424
+ darkMode: true,
425
+ background: 'transparent',
426
+ primaryColor: '#0f172a',
427
+ primaryTextColor: '#f8fafc',
428
+ primaryBorderColor: '#a78bfa',
429
+ secondaryColor: '#171532',
430
+ tertiaryColor: '#0f1d33',
431
+ lineColor: '#94a3b8',
432
+ textColor: '#e5e7eb',
433
+ fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace',
434
+ fontSize: '14px',
435
+ },
436
+ });
437
+ </script>
438
+ </body>
439
+ </html>
440
+ `;
441
+ }
@@ -0,0 +1,148 @@
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 };