@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.
- package/.agents/skills/gantry/SKILL.md +166 -0
- package/.agents/skills/gantry/capabilities/claude-code.json +15 -0
- package/.agents/skills/gantry/capabilities/codex.json +14 -0
- package/.agents/skills/gantry/capabilities/opencode.json +15 -0
- package/.agents/skills/gantry/dashboard/static/app.js +100 -0
- package/.agents/skills/gantry/dashboard/static/index.html +16 -0
- package/.agents/skills/gantry/dashboard/static/style.css +74 -0
- package/.agents/skills/gantry/hooks/claude-code.settings.json +56 -0
- package/.agents/skills/gantry/hooks/codex.hooks.json +4 -0
- package/.agents/skills/gantry/hooks/git/pre-commit +77 -0
- package/.agents/skills/gantry/hooks/git/pre-push +123 -0
- package/.agents/skills/gantry/hooks/git/skipscan.py +88 -0
- package/.agents/skills/gantry/hooks/opencode.plugin.js +44 -0
- package/.agents/skills/gantry/reference/plan-workflow.md +383 -0
- package/.agents/skills/gantry/reference/round-workflow.md +755 -0
- package/.agents/skills/gantry/schemas/critic.json +93 -0
- package/.agents/skills/gantry/schemas/implementer.json +52 -0
- package/.agents/skills/gantry/schemas/learner.json +35 -0
- package/.agents/skills/gantry/schemas/plan-critic.json +39 -0
- package/.agents/skills/gantry/schemas/planner.json +64 -0
- package/.agents/skills/gantry/schemas/requirement-critic.json +48 -0
- package/.agents/skills/gantry/schemas/reviewer.json +52 -0
- package/.agents/skills/gantry/scripts/acceptance.py +66 -0
- package/.agents/skills/gantry/scripts/budget.py +162 -0
- package/.agents/skills/gantry/scripts/cleanup.py +186 -0
- package/.agents/skills/gantry/scripts/common.py +361 -0
- package/.agents/skills/gantry/scripts/dashboard.py +233 -0
- package/.agents/skills/gantry/scripts/frontier.py +192 -0
- package/.agents/skills/gantry/scripts/gates.py +401 -0
- package/.agents/skills/gantry/scripts/guard.py +568 -0
- package/.agents/skills/gantry/scripts/learner.py +99 -0
- package/.agents/skills/gantry/scripts/result.py +104 -0
- package/.agents/skills/gantry/scripts/roadmap.py +212 -0
- package/.agents/skills/gantry/scripts/runlog.py +491 -0
- package/.agents/skills/gantry/scripts/setup.py +139 -0
- package/.agents/skills/gantry/scripts/spec.py +252 -0
- package/.agents/skills/gantry/templates/issue.md +32 -0
- package/.agents/skills/gantry/templates/prd.md +26 -0
- package/.agents/skills/gantry/templates/spec.md +48 -0
- package/.agents/skills/gantry-dashboard/SKILL.md +55 -0
- package/.agents/skills/gantry-setup/SKILL.md +30 -0
- package/LICENSE +201 -0
- package/README.md +437 -0
- package/bin/gantry.mjs +45 -0
- package/package.json +36 -0
- 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.
|