@julioborges/gantry 0.1.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 (46) hide show
  1. package/.agents/skills/gantry/SKILL.md +166 -0
  2. package/.agents/skills/gantry/capabilities/claude-code.json +15 -0
  3. package/.agents/skills/gantry/capabilities/codex.json +14 -0
  4. package/.agents/skills/gantry/capabilities/opencode.json +15 -0
  5. package/.agents/skills/gantry/dashboard/static/app.js +100 -0
  6. package/.agents/skills/gantry/dashboard/static/index.html +16 -0
  7. package/.agents/skills/gantry/dashboard/static/style.css +74 -0
  8. package/.agents/skills/gantry/hooks/claude-code.settings.json +56 -0
  9. package/.agents/skills/gantry/hooks/codex.hooks.json +4 -0
  10. package/.agents/skills/gantry/hooks/git/pre-commit +77 -0
  11. package/.agents/skills/gantry/hooks/git/pre-push +123 -0
  12. package/.agents/skills/gantry/hooks/git/skipscan.py +88 -0
  13. package/.agents/skills/gantry/hooks/opencode.plugin.js +44 -0
  14. package/.agents/skills/gantry/reference/plan-workflow.md +383 -0
  15. package/.agents/skills/gantry/reference/round-workflow.md +755 -0
  16. package/.agents/skills/gantry/schemas/critic.json +93 -0
  17. package/.agents/skills/gantry/schemas/implementer.json +52 -0
  18. package/.agents/skills/gantry/schemas/learner.json +35 -0
  19. package/.agents/skills/gantry/schemas/plan-critic.json +39 -0
  20. package/.agents/skills/gantry/schemas/planner.json +64 -0
  21. package/.agents/skills/gantry/schemas/requirement-critic.json +48 -0
  22. package/.agents/skills/gantry/schemas/reviewer.json +52 -0
  23. package/.agents/skills/gantry/scripts/acceptance.py +66 -0
  24. package/.agents/skills/gantry/scripts/budget.py +162 -0
  25. package/.agents/skills/gantry/scripts/cleanup.py +186 -0
  26. package/.agents/skills/gantry/scripts/common.py +361 -0
  27. package/.agents/skills/gantry/scripts/dashboard.py +233 -0
  28. package/.agents/skills/gantry/scripts/frontier.py +192 -0
  29. package/.agents/skills/gantry/scripts/gates.py +401 -0
  30. package/.agents/skills/gantry/scripts/guard.py +568 -0
  31. package/.agents/skills/gantry/scripts/learner.py +99 -0
  32. package/.agents/skills/gantry/scripts/result.py +104 -0
  33. package/.agents/skills/gantry/scripts/roadmap.py +212 -0
  34. package/.agents/skills/gantry/scripts/runlog.py +491 -0
  35. package/.agents/skills/gantry/scripts/setup.py +139 -0
  36. package/.agents/skills/gantry/scripts/spec.py +252 -0
  37. package/.agents/skills/gantry/templates/issue.md +32 -0
  38. package/.agents/skills/gantry/templates/prd.md +26 -0
  39. package/.agents/skills/gantry/templates/spec.md +48 -0
  40. package/.agents/skills/gantry-dashboard/SKILL.md +55 -0
  41. package/.agents/skills/gantry-setup/SKILL.md +30 -0
  42. package/LICENSE +201 -0
  43. package/README.md +437 -0
  44. package/bin/gantry.mjs +45 -0
  45. package/package.json +36 -0
  46. package/scripts/ensure-npm-author.mjs +29 -0
@@ -0,0 +1,755 @@
1
+ # Canonical round workflow
2
+
3
+ One round contains only Issues selected by `frontier.py` whose dependency readiness is satisfied. The
4
+ caller supplies `args.round`, `args.issues`, `args.models`, `args.branch`, `args.baseRef`,
5
+ `args.isolate`, `args.correctionBudget`, `args.skillDir`, `args.repoRoot`, effective `args.policy` and
6
+ rendered `args.paths`. A Run spans one or more rounds, each a separate invocation of this workflow
7
+ sharing the same `args.runId` and `args.unitId`: the caller supplies `args.isFirstRound = false` for
8
+ every round after the first (it defaults to `true`, so a single-round Run or a harness that never sets
9
+ it needs no change), and, for the last round of a Run, `args.isLastRound` (boolean, true only for that
10
+ final frontier round) and `args.learnerRunLogs` (an array of Run-log JSONL paths, normally just the
11
+ current Run's `~/.gantry/state/<unit-id>/runs/<run-id>.jsonl`), so the optional Learner phase below can
12
+ run; `args.models.learn` is optional and falls back to `args.models.critic` when absent.
13
+ The host supplies its command runner as `runCommand(command, { cwd, input })`; integration cannot
14
+ proceed without it. `input`, when provided, is written to the invoked command's stdin — every recorded
15
+ Run event (`runlog.py append <unitId> <runId> [--state-root <path>]`) is called this way, with the JSON
16
+ event payload passed as `input` rather than as a command-line argument, so a harness that only forwards
17
+ `cwd` and drops `input` silently breaks every recorded round.
18
+
19
+ ## Recorded Run lifecycle
20
+
21
+ When the caller also supplies `args.runId` and `args.unitId` (the value of
22
+ `runlog.py unit-id --cwd <repoRoot>`), this workflow appends every lifecycle event through
23
+ `runlog.py append <unitId> <runId>`: on the first round of a Run (`args.isFirstRound` not explicitly
24
+ `false`), `run.started` always opens the recorded Run first (this Run log's required first event, per
25
+ `runlog.py`'s own rule), and `run.resumed` follows immediately after when `args.priorRun` names the Run
26
+ and worktree being continued; a later round of the same Run passes `args.isFirstRound = false` so
27
+ `run.started`, `run.resumed` and `policy.changed` are never appended again — a Run log accepts only one
28
+ `run.started` and rejects a duplicate. Every round, first or not, appends `round.started`, one `phase.started` /
29
+ `phase.finished` pair per phase that actually runs — Implement, Review and Critic for each Issue, plus the
30
+ optional Learner phase (below) on the last round when it finds recurring evidence — one `subagent.started` /
31
+ `subagent.stopped` pair per fresh agent carrying the role result, `review.finding` after the Reviewer returns,
32
+ `refutation` on every non-accepted Critic verdict, `issue.blocked` when the correction ceiling is spent
33
+ without acceptance, `issue.done` on successful integration, `policy.changed` when `args.priorRun.policyHash`
34
+ (the policy hash recorded on the prior Run) differs from the current effective policy hash,
35
+ `run.cancelled` on the first red post-merge gate and `round.finished` / `run.finished` at the end. When the
36
+ Learner phase runs, it is recorded before `run.finished`: `run.finished` remains the Run log's last event
37
+ on a completed Run, never followed by a subagent. The
38
+ Critic's `subagent.stopped` never carries its verdict unchanged: `runlog.py append` rejects any
39
+ `command`, `output` (and similarly-tokenized) field at any nesting level per its own rule, and the
40
+ Critic's `gateResult` is a real `gates.py --json` payload whose `gates[]` entries carry exactly those
41
+ fields (`command`, `output_tail`) alongside a real command's execution details. The workflow instead
42
+ records `projectCriticResult(verdict)`: `complete`, `criteria`, `gatesVerdict`, `gateFailures`,
43
+ `refutations`, `requiredFixes` and `decisionsForOperator` unchanged, plus `gateResult` narrowed to only
44
+ its `verdict` and `requirements` (both free of command/output data) — never `gateResult.gates`. This
45
+ keeps the Run log a record of the Critic's role result and reasoning, never of command output, while the
46
+ full verdict (including the untouched `gateResult`) still drives `criticAccepted` and the workflow's own
47
+ structured output. Every other role's result reaches `subagent.stopped` unprojected, and `runlog.py`
48
+ still fails loudly if any of them carries prohibited data.
49
+ Before any agent works in a worktree, the workflow marks that worktree with the Run
50
+ (`runlog.py mark <runId> --cwd <worktree>`, written into the worktree's own git directory): the
51
+ repository root at the start of every round, and each Issue worktree as it is assigned. A git hook
52
+ inherits whatever environment shelled out to `git`, so `GANTRY_RUN_ID` cannot be relied on to reach
53
+ `pre-commit`/`pre-push`; the marker is how they resolve the Run instead, which is what makes their
54
+ `hook.denied` recording a guarantee rather than a best effort. The hooks and `guard.py` share one
55
+ resolution order: `GANTRY_RUN_ID` when the caller exports it, then the marker, and a harness session
56
+ ID only when nothing else names a Run and its Run log already exists. Marks are cleared
57
+ (`runlog.py unmark`) only when the Run itself ends — the last round, or a cancelled one — never
58
+ between rounds of the same Run, and never across worktrees: git keeps one git directory per worktree,
59
+ so two worktrees of the same execution unit running different Runs cannot attribute a denial to each
60
+ other. Because each round is a separate invocation that only remembers what it marked itself, the
61
+ Run's end enumerates `git worktree list --porcelain` and clears every marker naming that Run, so a
62
+ worktree marked by an earlier round is never left behind.
63
+ Preflight resolves `args.runId` and `args.unitId` once per Run and never rereads the log to decide
64
+ readiness or completion — only `frontier.py`, Issue `Status:` lines and `roadmap.py` decide that. When
65
+ `args.priorRun` names the Issue being continued (`args.priorRun.issue`), its preserved worktree, branch
66
+ and `correctionsSpent` are reused instead of
67
+ creating a new worktree or resetting the correction count, and the Issue keeps its authoritative Status
68
+ until the Critic accepts it. `args.priorRun.correctionsSpent` is scoped to `args.priorRun.issue` alone —
69
+ `runlog.py corrections` (the sole source of this value; see `SKILL.md`) withholds the `run.resumed`
70
+ base from any other Issue that happens to share the same Run log, so callers must never reuse one
71
+ Issue's derived `correctionsSpent` for a different Issue. Omitting `args.runId` or `args.unitId` disables
72
+ all of the above and leaves the round behaviorally identical, so a harness without a resolved Run log
73
+ keeps working.
74
+
75
+ ```
76
+ implement (TDD) → review (standards + Spec) → one review fix pass
77
+ → adversarial Critic → correction implementer ⇄ Critic (at most two correction attempts)
78
+ ```
79
+
80
+ ## Per-Issue roles
81
+
82
+ **Implementer:** a fresh agent reads the complete Issue, parent Spec, settled decisions, context and ADRs.
83
+ It follows red → green TDD at the approved CLI, script-interface and template-file seams, makes small
84
+ commits, runs `gates.py --run --diff-base <baseRef> --cwd "$(pwd)"`, and leaves a clean tree. It does not
85
+ edit `ROADMAP.md`, Issue statuses or criteria checkboxes.
86
+
87
+ **Reviewer:** a fresh agent reviews the delivery against two independent axes:
88
+
89
+ - standards: documented repository rules, context and ADRs;
90
+ - Spec: every Issue acceptance criterion and its `What to build` contract.
91
+
92
+ It reports blocking and non-blocking findings. Exactly one fresh Implementer pass addresses blocking review
93
+ findings in the same worktree.
94
+
95
+ **Critic:** a fresh adversarial Critic defaults to refutation. It runs `acceptance.py <issue> --json` and
96
+ `gates.py --run --diff-base <baseRef> --cwd "$(pwd)" --json`, verifies evidence per criterion, checks the
97
+ tree and diff for weakened tests, placeholders and scope creep, and returns `complete` only with proof.
98
+ Refutations generate ordered required fixes and consume one correction attempt; the correction-budget
99
+ ceiling is two and exhaustion leaves the Issue refuted with its worktree retained.
100
+
101
+ ## Learner phase (optional)
102
+
103
+ After the last round of a Run, an optional Learner reads only the `refutation` and `review.finding`
104
+ events already recorded in the Run log — never source files, `AGENTS.md`, `CONTEXT.md`, a template or
105
+ the repository policy. `learner.py <runlog...> --json` groups identical evidence text across different
106
+ Issues or attempts and drafts one lesson candidate per recurring group, each carrying the recurring
107
+ evidence and a proposed target (the Gantry section of `AGENTS.md`, `CONTEXT.md`, or an effective
108
+ template); a problem that occurred on only one Issue or attempt produces no candidate. When the
109
+ extraction finds nothing recurring, or no Run-log path is available, the phase is skipped — no
110
+ `phase.started`/`subagent.started`/`subagent.stopped`/`phase.finished` events are recorded for a
111
+ skipped Learner phase. When the Learner does run, it is recorded like every other phase: `phase.started`,
112
+ `subagent.started`, `subagent.stopped` (carrying the candidates as its result) and `phase.finished`, all
113
+ appended after `round.finished` and before `run.finished`, so `run.finished` stays the Run log's last
114
+ event and no subagent runs after it. `runlog.py` requires an `issue` on every `phase.started`/
115
+ `phase.finished` event; because the Learner is not scoped to one Issue, these events use the reserved
116
+ non-Issue reference `learn#00` rather than a real Issue ref. The Learner
117
+ never writes anything: a lesson candidate is a draft for the operator to accept or discard, surfaced by
118
+ the final report, never auto-injected into `AGENTS.md`, `CONTEXT.md`, a template or policy.
119
+
120
+ ## Isolation and integration
121
+
122
+ When `args.isolate` is true, each Implementer uses its own worktree and branch from `args.baseRef`; agents
123
+ in the round may run concurrently. The host harness waits for accepted deliveries and integrates branches
124
+ one at a time with `git merge --no-ff`. Run the declared gates after every merge. A failing merge gate stops
125
+ the Run rather than fixing forward.
126
+
127
+ Only a Critic-complete, gate-green and clean delivery may be integrated. Completion requires exactly one
128
+ passing, non-empty evidence entry for every criterion returned by `acceptance.py`; the workflow does not
129
+ trust Issue fields carried in its input. Then, and only then, the orchestrator calls `roadmap.py done <ref>`
130
+ and commits the resulting authoritative projection. Refuted, failed, parked and externally blocked Issues
131
+ remain unticked.
132
+
133
+ When the correction ceiling is spent without acceptance, the workflow records `issue.blocked` in the Run
134
+ log and preserves the worktree, but it never writes to the Issue file: the Issue keeps its authoritative
135
+ `Status: ready-for-agent` so `frontier.py` still reports it as workable and a fresh Run (with or without
136
+ continuation) can pick it up. "Blocked" here names a Run-log fact about this attempt, not an Issue-file
137
+ projection — status authority stays in Issue files, per this Issue's contract, and only `roadmap.py done`
138
+ (after Critic acceptance) ever changes an Issue's `Status:` line.
139
+
140
+ ## Executable Claude Code Workflow
141
+
142
+ This Workflow script is the executable chain. It creates one Implementer chain per Issue; `pipeline`
143
+ may run those independent chains in parallel, but each chain keeps TDD → review → Critic ordering.
144
+
145
+ ```js
146
+ export const meta = {
147
+ name: 'gantry-round',
148
+ description: 'Gantry round: TDD, two-axis review and adversarial Critic per Issue',
149
+ phases: [
150
+ { title: 'Implement', detail: 'one TDD Implementer per Issue' },
151
+ { title: 'Review', detail: 'standards and Spec, with one fix pass' },
152
+ { title: 'Critic', detail: 'adversarial verification and bounded corrections' },
153
+ { title: 'Learn', detail: 'optional recurring lesson candidates for the operator' },
154
+ ],
155
+ }
156
+
157
+ const A = args
158
+ const scripts = `${A.skillDir}/scripts`
159
+ const paths = A.paths
160
+ const policy = A.policy
161
+ const requestedBudget = A.correctionBudget ?? policy.budget.corrections
162
+ const budget = Number.isInteger(requestedBudget) && requestedBudget >= 0
163
+ ? Math.min(requestedBudget, 2)
164
+ : 2
165
+
166
+ const runLogEnabled = Boolean(A.runId && A.unitId)
167
+ const stateRootFlag = A.stateRoot ? ` --state-root '${String(A.stateRoot).replaceAll("'", "'\\''")}'` : ''
168
+
169
+ function canonical(value) {
170
+ if (Array.isArray(value)) return value.map(canonical)
171
+ if (value && typeof value === 'object') {
172
+ return Object.keys(value).sort().reduce((acc, key) => {
173
+ acc[key] = canonical(value[key])
174
+ return acc
175
+ }, {})
176
+ }
177
+ return value
178
+ }
179
+
180
+ function stableHash(value) {
181
+ const json = JSON.stringify(canonical(value))
182
+ let hash = 5381
183
+ for (let index = 0; index < json.length; index += 1) {
184
+ hash = ((hash * 33) ^ json.charCodeAt(index)) >>> 0
185
+ }
186
+ return hash.toString(16).padStart(8, '0')
187
+ }
188
+
189
+ function projectCriticResult(verdict) {
190
+ if (!verdict || typeof verdict !== 'object') return verdict
191
+ const projected = {
192
+ complete: verdict.complete,
193
+ criteria: verdict.criteria,
194
+ gatesVerdict: verdict.gatesVerdict,
195
+ gateFailures: verdict.gateFailures,
196
+ refutations: verdict.refutations,
197
+ requiredFixes: verdict.requiredFixes,
198
+ decisionsForOperator: verdict.decisionsForOperator,
199
+ }
200
+ if (verdict.gateResult && typeof verdict.gateResult === 'object') {
201
+ projected.gateResult = { verdict: verdict.gateResult.verdict, requirements: verdict.gateResult.requirements }
202
+ }
203
+ return projected
204
+ }
205
+
206
+ async function appendRunEvent(event, issueRef, phaseName, data) {
207
+ if (!runLogEnabled) return
208
+ const payload = {
209
+ ts: new Date().toISOString(),
210
+ run: A.runId,
211
+ event,
212
+ ...(issueRef ? { issue: issueRef } : {}),
213
+ ...(phaseName ? { phase: phaseName } : {}),
214
+ ...(data !== undefined ? { data } : {}),
215
+ }
216
+ await runWorkflowCommand(
217
+ `python3 "${scripts}/runlog.py" append '${A.unitId}' '${A.runId}'${stateRootFlag}`,
218
+ { cwd: A.repoRoot, input: JSON.stringify(payload) },
219
+ )
220
+ }
221
+
222
+ // A git hook inherits the environment of whatever shelled out to `git`, which no harness
223
+ // controls, so `GANTRY_RUN_ID` cannot be relied on to reach `pre-commit`/`pre-push`. Every
224
+ // worktree this Run works in is therefore marked with the Run before any agent runs there
225
+ // (`runlog.py mark`, which writes into that worktree's own git directory); the hooks read it
226
+ // back through `runlog.resolve_hook_run`, which is what makes their `hook.denied` recording a
227
+ // guarantee. The marks are cleared when the Run itself ends, never between rounds.
228
+ const markedWorktrees = new Set()
229
+
230
+ async function markRunIn(worktree) {
231
+ if (!runLogEnabled || !worktree || markedWorktrees.has(worktree)) return
232
+ await runWorkflowCommand(
233
+ `python3 "${scripts}/runlog.py" mark '${A.runId}' --cwd ${shellQuote(worktree)}${stateRootFlag}`,
234
+ { cwd: worktree },
235
+ )
236
+ markedWorktrees.add(worktree)
237
+ }
238
+
239
+ // Each round is a separate invocation with its own memory, so `markedWorktrees` only ever holds
240
+ // what *this* invocation marked -- an Issue finished in an earlier round is absent from the last
241
+ // one and its worktree would keep a marker naming a Run that is already over. The Run's end
242
+ // therefore enumerates the clone's worktrees and clears every marker that names this Run.
243
+ async function unmarkRun() {
244
+ if (!runLogEnabled) return
245
+ const worktrees = new Set(markedWorktrees)
246
+ const listed = await runCommand('git worktree list --porcelain', { cwd: A.repoRoot })
247
+ if (listed && listed.exitCode === 0) {
248
+ for (const line of String(listed.stdout || '').split('\n')) {
249
+ if (line.startsWith('worktree ')) worktrees.add(line.slice('worktree '.length).trim())
250
+ }
251
+ }
252
+ for (const worktree of worktrees) {
253
+ if (!worktree) continue
254
+ const current = await runCommand(
255
+ `python3 "${scripts}/runlog.py" current --cwd ${shellQuote(worktree)}`, { cwd: A.repoRoot },
256
+ )
257
+ if (!current || current.exitCode !== 0 || String(current.stdout || '').trim() !== A.runId) continue
258
+ await runCommand(`python3 "${scripts}/runlog.py" unmark --cwd ${shellQuote(worktree)}`, { cwd: A.repoRoot })
259
+ }
260
+ markedWorktrees.clear()
261
+ }
262
+
263
+ function priorAssignment(issue) {
264
+ return A.priorRun && A.priorRun.issue === issue.ref ? A.priorRun : null
265
+ }
266
+
267
+ // `runlog.py` requires `issue` on every `phase.started`/`phase.finished` event; the Learn phase is not
268
+ // tied to any single Issue, so it records its phase/subagent events against this reserved, non-Issue
269
+ // reference rather than omitting `issue` (which `runlog.py append` would reject).
270
+ const LEARN_PHASE_ISSUE = 'learn#00'
271
+
272
+ const isFirstRound = A.isFirstRound !== false
273
+ if (runLogEnabled && isFirstRound) {
274
+ const runStartedData = {
275
+ repositoryRoot: A.repoRoot,
276
+ policyHash: stableHash(policy),
277
+ tier: A.tier || 'unknown',
278
+ staleAfterSeconds: (policy.dashboard && policy.dashboard.staleAfterSeconds) || 900,
279
+ }
280
+ await appendRunEvent('run.started', undefined, undefined, runStartedData)
281
+ if (A.priorRun && A.priorRun.run) {
282
+ await appendRunEvent('run.resumed', undefined, undefined, {
283
+ priorRun: A.priorRun.run,
284
+ worktree: A.priorRun.worktree,
285
+ issue: A.priorRun.issue,
286
+ correctionsSpent: A.priorRun.correctionsSpent,
287
+ })
288
+ }
289
+ if (A.priorRun && A.priorRun.policyHash && A.priorRun.policyHash !== runStartedData.policyHash) {
290
+ await appendRunEvent('policy.changed', undefined, undefined, { policyHash: runStartedData.policyHash })
291
+ }
292
+ }
293
+ if (runLogEnabled) {
294
+ await markRunIn(A.repoRoot)
295
+ await appendRunEvent('round.started', undefined, undefined, { round: A.round })
296
+ }
297
+
298
+ async function roleSchema(role) {
299
+ const result = await runWorkflowCommand(
300
+ `python3 "${scripts}/result.py" --role "${role}" --schema`,
301
+ )
302
+ return JSON.parse(result.stdout)
303
+ }
304
+
305
+ async function validRoleResult(role, result) {
306
+ if (!result) return false
307
+ if (A.structuredOutput === true) return true
308
+ const validation = await runCommand(
309
+ `python3 "${scripts}/result.py" --role "${role}" --json`,
310
+ { cwd: A.repoRoot, input: JSON.stringify(result) },
311
+ )
312
+ return Boolean(validation && validation.exitCode === 0)
313
+ }
314
+ async function requestRole(role, prompt, options) {
315
+ const native = A.structuredOutput === true
316
+ const schema = native ? await roleSchema(role) : null
317
+ const result = await agent(prompt, {
318
+ ...options,
319
+ ...(schema ? { schema } : {}),
320
+ })
321
+ if (await validRoleResult(role, result)) return result
322
+ const retry = await agent(`${prompt}\nYour prior result was invalid. Return the complete ${role} result contract.`, {
323
+ ...options,
324
+ label: `${options.label}:retry`,
325
+ ...(schema ? { schema } : {}),
326
+ })
327
+ return (await validRoleResult(role, retry)) ? retry : null
328
+ }
329
+
330
+ function location(impl) {
331
+ return impl && impl.worktree ? impl.worktree : A.repoRoot
332
+ }
333
+
334
+ function shellQuote(value) {
335
+ return `'${String(value).replaceAll("'", "'\\''")}'`
336
+ }
337
+
338
+ function issueWorktreePath(issue) {
339
+ const [spec, number] = issue.ref.split('#')
340
+ return `${A.repoRoot}.gantry-${spec}-${String(Number(number)).padStart(2, '0')}`
341
+ }
342
+
343
+ async function configuredIssueBranch(issue) {
344
+ let result
345
+ try {
346
+ result = await runWorkflowCommand(
347
+ `python3 "${scripts}/common.py" --cwd ${shellQuote(A.repoRoot)} --issue ${shellQuote(issue.path)} --json`,
348
+ )
349
+ const payload = JSON.parse(result.stdout)
350
+ if (typeof payload.issueBranch !== 'string' || !payload.issueBranch) throw new Error('missing issueBranch')
351
+ return payload.issueBranch
352
+ } catch {
353
+ return null
354
+ }
355
+ }
356
+
357
+ async function implementationLocation(issue, previous) {
358
+ if (!A.isolate) return { worktree: A.repoRoot, branch: A.branch }
359
+ const prior = priorAssignment(issue)
360
+ if (!previous && prior && prior.worktree) {
361
+ const branch = prior.branch || await configuredIssueBranch(issue)
362
+ if (!branch) return null
363
+ const current = await runCommand('git branch --show-current', { cwd: prior.worktree })
364
+ return current && current.exitCode === 0 && current.stdout.trim() === branch
365
+ ? { worktree: prior.worktree, branch }
366
+ : null
367
+ }
368
+ const branch = await configuredIssueBranch(issue)
369
+ if (!branch) return null
370
+ if (previous) {
371
+ if (previous.branch !== branch) return null
372
+ const current = await runCommand('git branch --show-current', { cwd: previous.worktree })
373
+ return current && current.exitCode === 0 && current.stdout.trim() === branch
374
+ ? { worktree: previous.worktree, branch }
375
+ : null
376
+ }
377
+ const worktree = issueWorktreePath(issue)
378
+ try {
379
+ await runWorkflowCommand(
380
+ `git worktree add --quiet -b ${shellQuote(branch)} ${shellQuote(worktree)} ${shellQuote(A.baseRef)}`,
381
+ )
382
+ const current = await runCommand('git branch --show-current', { cwd: worktree })
383
+ return current && current.exitCode === 0 && current.stdout.trim() === branch
384
+ ? { worktree, branch }
385
+ : null
386
+ } catch {
387
+ return null
388
+ }
389
+ }
390
+
391
+ async function authoritativeCriterionIndexes(issue) {
392
+ let result
393
+ try {
394
+ result = await runWorkflowCommand(
395
+ `python3 "${scripts}/acceptance.py" "${A.repoRoot}/${issue.path}" --json`,
396
+ )
397
+ } catch {
398
+ return null
399
+ }
400
+ try {
401
+ const acceptance = JSON.parse(result.stdout)
402
+ if (!Array.isArray(acceptance.criteria) || acceptance.criteria.length === 0) return null
403
+ const indexes = acceptance.criteria.map(item => item && item.index)
404
+ if (!indexes.every(index => Number.isInteger(index) && index > 0)) return null
405
+ if (new Set(indexes).size !== indexes.length) return null
406
+ return indexes
407
+ } catch {
408
+ return null
409
+ }
410
+ }
411
+
412
+ async function criticAccepted(verdict, issue) {
413
+ if (!verdict || verdict.complete !== true || verdict.gatesVerdict !== 'pass' ||
414
+ !verdict.gateResult || verdict.gateResult.verdict !== verdict.gatesVerdict) return false
415
+ const expectedIndexes = await authoritativeCriterionIndexes(issue)
416
+ if (!expectedIndexes || !Array.isArray(verdict.criteria) || verdict.criteria.length !== expectedIndexes.length) return false
417
+ const observedIndexes = new Set()
418
+ for (const criterion of verdict.criteria) {
419
+ if (!criterion || typeof criterion !== 'object' || !Number.isInteger(criterion.index) ||
420
+ !expectedIndexes.includes(criterion.index) || observedIndexes.has(criterion.index) ||
421
+ criterion.met !== true || typeof criterion.evidence !== 'string' || !criterion.evidence.trim()) {
422
+ return false
423
+ }
424
+ observedIndexes.add(criterion.index)
425
+ }
426
+ return observedIndexes.size === expectedIndexes.length
427
+ }
428
+
429
+ async function runWorkflowCommand(command, options) {
430
+ const result = await runCommand(command, { cwd: A.repoRoot, ...(options || {}) })
431
+ if (!result || result.exitCode !== 0) {
432
+ throw new Error(`workflow command failed: ${command}`)
433
+ }
434
+ return result
435
+ }
436
+
437
+ async function integrationGatePasses() {
438
+ let result
439
+ try {
440
+ result = await runWorkflowCommand(
441
+ `python3 "${scripts}/gates.py" --run --diff-base ${A.baseRef} --cwd "${A.repoRoot}" --json`,
442
+ )
443
+ } catch {
444
+ return false
445
+ }
446
+ try {
447
+ const payload = JSON.parse(result.stdout)
448
+ return payload.verdict === 'pass' && Array.isArray(payload.requirements) && payload.requirements.length === 0
449
+ } catch {
450
+ return false
451
+ }
452
+ }
453
+
454
+ function implementPrompt(issue, feedback, assigned) {
455
+ const work = `Work in ${assigned.worktree} on ${assigned.branch}; verify the current branch and never switch it.`
456
+ return `You are the fresh TDD Implementer for ${issue.ref} — "${issue.title}".
457
+ ${work}
458
+ Read ${issue.path}, ${issue.specPath}, ${paths.decisions}, ${paths.context}, and ${paths.adrs} first.
459
+ Implement only this Issue. Invoke the \`tdd\` skill and work behavior by behavior: failing test → minimal
460
+ code → refactor. Keep tests real where the criterion requires a real process, file, repository or command.
461
+ Run \`python3 ${scripts}/gates.py --run --diff-base ${A.baseRef} --cwd "$(pwd)"\` before returning.
462
+ Never edit ROADMAP.md, Status, or criteria checkboxes. Never force-push, and never skip, disable
463
+ or weaken a test. Never bypass the repository git hooks: no \`--no-verify\` in any abbreviation
464
+ (e.g. \`--no-veri\`) or \`-n\`, no \`core.hooksPath\` override in any spelling, no
465
+ \`--git-dir\`/\`GIT_DIR=\`, no \`GIT_CONFIG_*\`.
466
+ Commit small changes and leave a clean tree.
467
+ Effective Git policy: target ${policy.git.target}, prefix ${policy.git.prefix}.
468
+ ${feedback ? `Fix every item first:\n${feedback.items.map((item, index) => `${index + 1}. ${typeof item === 'string' ? item : `${item.finding} → ${item.fix}`}`).join('\n')}` : ''}
469
+ Return worktree, branch, commits, summary, testsAdded, gatesResult, decisions and blockers as structured output.`
470
+ }
471
+
472
+ function reviewPrompt(issue, impl) {
473
+ return `You are the fresh Reviewer for ${issue.ref}, in ${location(impl)}, against ${A.baseRef}.
474
+ Invoke the \`code-review\` skill; if unavailable, perform its two axes yourself.
475
+ Standards: AGENTS.md, ${paths.context}, ${paths.adrs}, repository standards, and no weakened verification.
476
+ Spec: every criterion and every paragraph of ${issue.path}'s What to build; identify missing, wrong or
477
+ scope-creeping behavior. Do not edit. Return blocking, nonBlocking and summary as structured output.`
478
+ }
479
+
480
+ function criticPrompt(issue, impl, review, attempt) {
481
+ return `You are the fresh adversarial Critic for ${issue.ref}, attempt ${attempt}, in ${location(impl)}
482
+ against ${A.baseRef}. Default to complete=false when uncertain; never trust the Implementer. The correction
483
+ budget ceiling is two attempts and must never be raised.
484
+ Run \`python3 ${scripts}/acceptance.py ${A.repoRoot}/${issue.path} --json\` and prove every criterion with
485
+ code plus a real test or required command output. Run
486
+ \`python3 ${scripts}/gates.py --run --diff-base ${A.baseRef} --cwd "$(pwd)" --json\`; parse its JSON verdict
487
+ and requirements. Return that parsed result unchanged in \`gateResult\`; derive \`gatesVerdict\` and
488
+ \`gateFailures\` from it, never a prose paraphrase. \`no_gates\` and \`not_run\` are not passing results.
489
+ Require a clean tree and commits after ${A.baseRef}. Inspect the diff for skipped,
490
+ disabled or mock-replaced tests, TODO/FIXME/not implemented text, Status/checkbox/ROADMAP edits, and scope
491
+ creep. Check \`git reflog\` and the branch history for a forced rewrite; a force-push is a refutation on
492
+ its own. So is any commit or push made with \`--no-verify\` in any abbreviation (e.g.
493
+ \`--no-veri\`) or \`-n\`, or under a \`core.hooksPath\`/\`--git-dir\`/\`GIT_DIR=\`/\`GIT_CONFIG_*\`
494
+ override, and any test-skip pattern that reached HEAD despite the hooks. Verify the Review
495
+ findings were actually fixed. Do not edit.
496
+ Return complete only for gate-green, clean, fully evidenced work; otherwise ordered requiredFixes,
497
+ refutations, gateResult, gateFailures and decisionsForOperator as structured output.`
498
+ }
499
+
500
+ async function implement(issue, feedback, previous) {
501
+ const assigned = await implementationLocation(issue, previous)
502
+ if (!assigned) return null
503
+ const options = {
504
+ label: `implement:${issue.ref}`, phase: 'Implement', model: A.models.implement, cwd: assigned.worktree,
505
+ }
506
+ await markRunIn(assigned.worktree)
507
+ await appendRunEvent('phase.started', issue.ref, 'Implement', { worktree: assigned.worktree })
508
+ await appendRunEvent('subagent.started', issue.ref, 'Implement', { role: 'implementer' })
509
+ const result = await requestRole('implementer', implementPrompt(issue, feedback, assigned), options)
510
+ await appendRunEvent('subagent.stopped', issue.ref, 'Implement', { role: 'implementer', result })
511
+ await appendRunEvent('phase.finished', issue.ref, 'Implement', { worktree: assigned.worktree })
512
+ return result && result.worktree === assigned.worktree && result.branch === assigned.branch ? result : null
513
+ }
514
+
515
+ const results = await pipeline(
516
+ A.issues,
517
+ issue => implement(issue, null, null),
518
+ async (impl, issue) => {
519
+ if (!impl) return null
520
+ await appendRunEvent('phase.started', issue.ref, 'Review', { worktree: location(impl) })
521
+ await appendRunEvent('subagent.started', issue.ref, 'Review', { role: 'reviewer' })
522
+ const review = await requestRole('reviewer', reviewPrompt(issue, impl), {
523
+ label: `review:${issue.ref}`, phase: 'Review', model: A.models.review, cwd: location(impl),
524
+ })
525
+ await appendRunEvent('subagent.stopped', issue.ref, 'Review', { role: 'reviewer', result: review })
526
+ await appendRunEvent('phase.finished', issue.ref, 'Review', {})
527
+ if (review) {
528
+ await appendRunEvent('review.finding', issue.ref, 'Review', {
529
+ blocking: review.blocking.length, nonBlocking: review.nonBlocking.length,
530
+ })
531
+ }
532
+ if (!review) return { impl, reviewerFailed: true }
533
+ const reviewed = review && review.blocking.length
534
+ ? await implement(issue, { kind: 'review', items: review.blocking }, impl)
535
+ : impl
536
+ if (!reviewed) {
537
+ return { impl: null, implementerFailed: true, failedImpl: impl, review, reviewFix: true }
538
+ }
539
+ return { impl: reviewed, review, reviewFix: Boolean(review && review.blocking.length) }
540
+ },
541
+ async (state, issue) => {
542
+ if (!state) return { ref: issue.ref, outcome: 'implementer_failed' }
543
+ if (state.implementerFailed) {
544
+ return {
545
+ ref: issue.ref, outcome: 'implementer_failed',
546
+ worktree: state.failedImpl.worktree, branch: state.failedImpl.branch,
547
+ commits: state.failedImpl.commits, corrections: 0, reviewFix: state.reviewFix,
548
+ review: state.review, verdict: null,
549
+ decisions: state.failedImpl.decisions || [], blockers: state.failedImpl.blockers || [],
550
+ }
551
+ }
552
+ if (!state.impl) return { ref: issue.ref, outcome: 'implementer_failed' }
553
+ if (state.reviewerFailed) return { ref: issue.ref, outcome: 'reviewer_failed', worktree: state.impl.worktree, branch: state.impl.branch }
554
+ let impl = state.impl
555
+ let verdict = null
556
+ const prior = priorAssignment(issue)
557
+ let corrections = prior && Number.isInteger(prior.correctionsSpent) ? prior.correctionsSpent : 0
558
+ let accepted = false
559
+ for (let attempt = 1; ; attempt += 1) {
560
+ await appendRunEvent('phase.started', issue.ref, 'Critic', { attempt, worktree: location(impl) })
561
+ await appendRunEvent('subagent.started', issue.ref, 'Critic', { role: 'critic', attempt })
562
+ verdict = await requestRole('critic', criticPrompt(issue, impl, state.review, attempt), {
563
+ label: `critic:${issue.ref}#${attempt}`, phase: 'Critic', model: A.models.critic, cwd: location(impl),
564
+ })
565
+ await appendRunEvent('subagent.stopped', issue.ref, 'Critic', { role: 'critic', attempt, result: projectCriticResult(verdict) })
566
+ await appendRunEvent('phase.finished', issue.ref, 'Critic', { attempt })
567
+ if (!verdict) {
568
+ return {
569
+ ref: issue.ref, issuePath: issue.path, outcome: 'critic_failed',
570
+ worktree: impl.worktree, branch: impl.branch, commits: impl.commits,
571
+ corrections, reviewFix: state.reviewFix, review: state.review, verdict: null,
572
+ decisions: (impl.decisions) || [], blockers: impl.blockers || [],
573
+ }
574
+ }
575
+ accepted = await criticAccepted(verdict, issue)
576
+ if (!accepted) {
577
+ await appendRunEvent('refutation', issue.ref, 'Critic', {
578
+ attempt, refutations: verdict.refutations || [],
579
+ })
580
+ }
581
+ if (accepted || corrections >= budget) break
582
+ const corrected = await implement(issue, { kind: 'critic', items: verdict.requiredFixes }, impl)
583
+ if (!corrected) {
584
+ return {
585
+ ref: issue.ref, issuePath: issue.path, outcome: 'implementer_failed',
586
+ worktree: impl.worktree, branch: impl.branch, commits: impl.commits,
587
+ corrections, reviewFix: state.reviewFix, review: state.review, verdict,
588
+ decisions: [...(impl.decisions || []), ...(verdict.decisionsForOperator || [])],
589
+ blockers: impl.blockers || [],
590
+ }
591
+ }
592
+ corrections += 1
593
+ impl = corrected
594
+ }
595
+ if (!accepted) {
596
+ await appendRunEvent('issue.blocked', issue.ref, 'Critic', {
597
+ worktree: impl && impl.worktree, corrections,
598
+ })
599
+ }
600
+ return {
601
+ ref: issue.ref,
602
+ issuePath: issue.path,
603
+ outcome: accepted ? 'accepted' : 'refuted',
604
+ worktree: impl && impl.worktree, branch: impl && impl.branch, commits: impl && impl.commits,
605
+ corrections, reviewFix: state.reviewFix, review: state.review, verdict,
606
+ decisions: [...((impl && impl.decisions) || []), ...((verdict && verdict.decisionsForOperator) || [])],
607
+ blockers: (impl && impl.blockers) || [],
608
+ }
609
+ },
610
+ )
611
+
612
+ function learnerPrompt(extracted) {
613
+ return `You are the fresh optional Learner for this Run.
614
+ Read only the recurring refutation and review-finding evidence already extracted below from the Run
615
+ log; never open AGENTS.md, CONTEXT.md, a template, the repository policy or any source file.
616
+ ${JSON.stringify(extracted.candidates)}
617
+ Draft one English lesson candidate per recurring group above, unchanged in its evidence, and name its
618
+ proposed target (the Gantry section of AGENTS.md, CONTEXT.md, or an effective template). Never edit
619
+ AGENTS.md, CONTEXT.md, a template or policy; a candidate is a draft for the operator.
620
+ Return candidates as structured output.`
621
+ }
622
+
623
+ async function learn() {
624
+ const logs = Array.isArray(A.learnerRunLogs) ? A.learnerRunLogs.filter(Boolean) : []
625
+ if (!logs.length) return []
626
+ let extracted
627
+ try {
628
+ const result = await runWorkflowCommand(
629
+ `python3 "${scripts}/learner.py" ${logs.map(shellQuote).join(' ')} --json`,
630
+ )
631
+ extracted = JSON.parse(result.stdout)
632
+ } catch {
633
+ return []
634
+ }
635
+ if (!Array.isArray(extracted.candidates) || !extracted.candidates.length) return []
636
+ await appendRunEvent('phase.started', LEARN_PHASE_ISSUE, 'Learn', {})
637
+ await appendRunEvent('subagent.started', LEARN_PHASE_ISSUE, 'Learn', { role: 'learner' })
638
+ const learned = await requestRole('learner', learnerPrompt(extracted), {
639
+ label: 'learn', phase: 'Learn', model: A.models.learn ?? A.models.critic, cwd: A.repoRoot,
640
+ })
641
+ const candidates = learned && Array.isArray(learned.candidates) ? learned.candidates : extracted.candidates
642
+ await appendRunEvent('subagent.stopped', LEARN_PHASE_ISSUE, 'Learn', { role: 'learner', result: { candidates } })
643
+ await appendRunEvent('phase.finished', LEARN_PHASE_ISSUE, 'Learn', {})
644
+ return candidates
645
+ }
646
+
647
+ const deliveries = results.filter(Boolean)
648
+ let integrationStopped = false
649
+ let cancelReason = null
650
+ for (const delivery of deliveries) {
651
+ if (delivery.outcome !== 'accepted') continue
652
+ if (integrationStopped) {
653
+ delivery.outcome = 'integration_pending'
654
+ continue
655
+ }
656
+ if (A.isolate) {
657
+ if (!delivery.branch) {
658
+ delivery.outcome = 'integration_failed'
659
+ integrationStopped = true
660
+ cancelReason = { issue: delivery.ref, reason: 'integration_branch_missing' }
661
+ continue
662
+ }
663
+ try {
664
+ await runWorkflowCommand(`git merge --no-ff "${delivery.branch}" -m "gantry: integrate ${delivery.ref}"`)
665
+ } catch {
666
+ delivery.outcome = 'integration_failed'
667
+ integrationStopped = true
668
+ cancelReason = { issue: delivery.ref, reason: 'integration_merge_failed' }
669
+ continue
670
+ }
671
+ }
672
+ if (!await integrationGatePasses()) {
673
+ delivery.outcome = 'integration_failed'
674
+ integrationStopped = true
675
+ cancelReason = { issue: delivery.ref, reason: 'integration_gate_failed' }
676
+ continue
677
+ }
678
+ try {
679
+ await runWorkflowCommand(`python3 "${scripts}/roadmap.py" done ${delivery.ref}`)
680
+ await runWorkflowCommand(`git add -- ROADMAP.md "${delivery.issuePath}"`)
681
+ await runWorkflowCommand(`git commit -m "gantry: complete ${delivery.ref}"`)
682
+ delivery.outcome = 'done'
683
+ await appendRunEvent('issue.done', delivery.ref, undefined, { worktree: delivery.worktree })
684
+ } catch {
685
+ delivery.outcome = 'integration_failed'
686
+ integrationStopped = true
687
+ cancelReason = { issue: delivery.ref, reason: 'integration_commit_failed' }
688
+ }
689
+ }
690
+ await appendRunEvent('round.finished', undefined, undefined, { round: A.round })
691
+ if (integrationStopped) {
692
+ await appendRunEvent('run.cancelled', cancelReason && cancelReason.issue, undefined, {
693
+ round: A.round, reason: cancelReason && cancelReason.reason,
694
+ })
695
+ }
696
+ const candidates = A.isLastRound ? await learn() : []
697
+ if (!integrationStopped && A.isLastRound) {
698
+ await appendRunEvent('run.finished', undefined, undefined, { round: A.round })
699
+ }
700
+ if (integrationStopped || A.isLastRound) {
701
+ await unmarkRun()
702
+ }
703
+ let prOffer = null
704
+ if (!integrationStopped && A.isLastRound) {
705
+ const completed = deliveries.filter(d => d.outcome === 'done')
706
+ if (completed.length > 0) {
707
+ const ghCheck = await runCommand('gh --version', { cwd: A.repoRoot })
708
+ if (ghCheck && ghCheck.exitCode === 0 && typeof prompt === 'function') {
709
+ const checkRoadmap = await runCommand(`python3 "${scripts}/roadmap.py" check`, { cwd: A.repoRoot })
710
+ const checkFrontier = await runCommand(`python3 "${scripts}/frontier.py" --scope all --json`, { cwd: A.repoRoot })
711
+ if ((!checkRoadmap || checkRoadmap.exitCode === 0) && (!checkFrontier || checkFrontier.exitCode === 0)) {
712
+ const bodyLines = []
713
+ for (const d of completed) {
714
+ const accCheck = await runCommand(`python3 "${scripts}/acceptance.py" "${A.repoRoot}/${d.issuePath}" --json`)
715
+ let texts = {}
716
+ if (accCheck && accCheck.exitCode === 0) {
717
+ try {
718
+ const accPayload = JSON.parse(accCheck.stdout)
719
+ if (Array.isArray(accPayload.criteria)) {
720
+ for (const c of accPayload.criteria) {
721
+ texts[c.index] = c.text
722
+ }
723
+ }
724
+ } catch (e) {}
725
+ }
726
+ const criteriaText = (d.verdict && d.verdict.criteria ? d.verdict.criteria : []).map(c => `- [x] ${texts[c.index] || 'Criterion'}: ${c.evidence}`).join('\\n')
727
+ bodyLines.push(`## ${d.ref}\\n\\n${criteriaText}`)
728
+ }
729
+ const body = bodyLines.join('\\n\\n')
730
+ const target = policy.git && policy.git.target ? policy.git.target : 'main'
731
+ const answer = await prompt(`Open a draft pull request from ${A.branch} to ${target}?\\n\\nBody preview:\\n${body}\\n\\n(yes/no)`)
732
+ if (answer && answer.toLowerCase().trim() === 'yes') {
733
+ const pr = await runCommand(`gh pr create --draft --base ${shellQuote(target)} --head ${shellQuote(A.branch)} --title "Run delivery" --body ${shellQuote(body)}`, { cwd: A.repoRoot })
734
+ if (pr && pr.exitCode === 0) {
735
+ prOffer = { status: 'opened', target }
736
+ } else {
737
+ prOffer = { status: 'failed', target }
738
+ }
739
+ } else {
740
+ prOffer = { status: 'declined', target }
741
+ }
742
+ } else {
743
+ prOffer = { status: 'failed_checks', target: policy.git && policy.git.target ? policy.git.target : 'main' }
744
+ }
745
+ } else {
746
+ prOffer = { status: 'unavailable', target: policy.git && policy.git.target ? policy.git.target : 'main' }
747
+ }
748
+ }
749
+ }
750
+ return { round: A.round, date: A.date, results: deliveries, ...(A.isLastRound ? { candidates, prOffer } : {}) }
751
+ ```
752
+
753
+ The Workflow returns `done` only after serial integration (when isolated), a passing post-integration
754
+ gate and `roadmap.py done`. `no_gates` is not a generic acceptance path: a future bootstrap contract must
755
+ explicitly supply and prove its exceptional checks before it can be modeled here.