@julioborges/gantry 0.1.0 → 1.0.3

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.
@@ -28,6 +28,34 @@ Before a Run, resolve these values once and pass them as `args` to every referen
28
28
  `<repoRoot>/.gantry/config.json` when present. A missing repository policy is valid.
29
29
  4. `paths` comes from `common.resolve_workflow_paths(repoRoot, scopeSlug)`. Prompts receive paths, not
30
30
  repository-specific literals.
31
+ 5. `caveman` comes from resolving repository preference `policy["caveman"]` and host availability
32
+ via `caveman.resolve_activation(policy, harness=hostHarness, root=repoRoot, warned=args.cavemanWarned)`.
33
+ When active, the coordinating agent, planning roles (Requirement Critic, research, Planner, Plan Critic)
34
+ and round roles (Implementer, Reviewer, Critic, optional Learner) across all initial calls, retries,
35
+ review fixes and correction passes receive concise phrasing instructions for conversational messages
36
+ and summaries along with supported external-skill access, while all Specs, draft Issues, code,
37
+ documentation, lesson candidates, PR descriptions, Result Contracts, exact commands, exact errors,
38
+ acceptance criteria and verification evidence retain full detail. If enabled in policy but unavailable
39
+ in the host environment, emit at most one actionable warning per Run with installation guidance and
40
+ continue with normal behavior without claiming live token savings; a disabled preference does not load
41
+ the skill. The manual harness-neutral path carries the same instructions.
42
+ 6. `roles` comes from resolving repository defaults under `policy["execution"]["roles"]` overlaid by
43
+ optional Run and Issue overrides via `execution.resolve_roles()`. Preflight validates that every selected
44
+ combination is supported, executable, meets minimum installed harness version requirements, and authenticated
45
+ (via `execution.preflight_validate()`), refusing to start implementation when validation fails. Derived roles
46
+ (`requirement-critic`, `plan-critic`, `learner` inheriting from `critic`, and `research` from `plan`) adopt
47
+ parent defaults unless explicitly set. Bounded native harness invocations execute roles across Codex CLI,
48
+ Claude Code, OpenCode, and Antigravity (`agy --print`) while the Host Harness retains coordination. External
49
+ results pass `result.py` contract validation, missing or invalid results trigger Protocol Failure handling,
50
+ unsupported installed versions are rejected, Critic verification fails visibly on permission or tool limitations,
51
+ selection identity is honestly recorded in the Run log, and native or configured automatic model fallback
52
+ is detected and rejected. Runtime execution failures preserve work and pause the affected Issue separately
53
+ from Critic refutations and protocol failures; independent Issues integrate serially, while the next round
54
+ waits for explicit recovery without automatic fallback or retry. Explicit Issue-role replacements are
55
+ validated and recorded via `role.changed`, leave saved repository defaults and running agents unchanged, and
56
+ preserve spent correction budgets. Run log selection (`role.selected`) and change (`role.changed`) events
57
+ record requested and effective selection evidence without command outputs or credentials. Model strength
58
+ guidance between roles is advisory and does not block valid cross-family selections.
31
59
 
32
60
  Run the standard-library workflow scripts as `python3 <skillDir>/scripts/<script>.py`. `common.py` provides
33
61
  the shared Markdown parser and policy resolution; its `--json` path prints the resolved portable runtime.
@@ -123,7 +151,9 @@ Every script provides `--help`, and data-producing paths support `--json`.
123
151
  and Spec axes, one review fix pass, then a fresh adversarial Critic. The Critic alone can establish a
124
152
  complete delivery. Its refutation consumes at most the correction budget.
125
153
  - A multi-Issue round uses one isolated worktree and branch per implementer. Integrate accepted branches
126
- serially, run gates after every merge, and stop on a failed integration gate. Create and identify each
154
+ serially, run gates after every merge, and stop on a failed integration gate. When an Issue execution fails,
155
+ pause that Issue, preserve its worktree and branch, allow independent accepted Issues to integrate, and block
156
+ the next round until explicit validated recovery. Create and identify each
127
157
  Issue branch through the `git.issueBranch` policy template (default
128
158
  `{prefix}{spec}-{number:02d}`), rendered by `common.issue_branch(policy, issue)`.
129
159
  - Only after Critic acceptance, green gates and a clean worktree may the orchestrator run
@@ -163,4 +193,5 @@ command's stdin, not appended to the command line. Every recorded Run event goes
163
193
  critic and mandatory operator stop.
164
194
  - `reference/round-workflow.md` — TDD implementation, two-axis review, adversarial Critic and serial
165
195
  integration contract.
196
+ - `docs/role-execution.md` — cross-harness execution, model discovery, role overrides, and failure recovery.
166
197
  - `templates/` — default Spec, PRD and Issue structures.
@@ -0,0 +1,15 @@
1
+ {
2
+ "tier": "compatible",
3
+ "hooks": true,
4
+ "structured_output": true,
5
+ "worktree_isolation": true,
6
+ "per_role_model": true,
7
+ "parallel_round": true,
8
+ "skills_path": ".agents/skills",
9
+ "hook_events": ["PreToolUse", "PostToolUse"],
10
+ "payload_fields": ["tool_name", "tool_input", "session_id"],
11
+ "models": {
12
+ "gemini-3.8-flash-medium": {"contextWindow": 1000000},
13
+ "gemini-3.1-pro-high": {"contextWindow": 1000000}
14
+ }
15
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "hooks": {
3
+ "PreToolUse": [
4
+ {
5
+ "matcher": "*",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "python3 \".agents/skills/gantry/scripts/guard.py\" PreToolUse --json"
10
+ }
11
+ ]
12
+ }
13
+ ],
14
+ "PostToolUse": [
15
+ {
16
+ "matcher": "*",
17
+ "hooks": [
18
+ {
19
+ "type": "command",
20
+ "command": "python3 \".agents/skills/gantry/scripts/guard.py\" PostToolUse --json"
21
+ }
22
+ ]
23
+ }
24
+ ]
25
+ }
26
+ }
@@ -82,6 +82,16 @@ const targetText = t.kind === 'goal' ? `the goal "${t.goal}" (new slug: ${t.slug
82
82
  : t.kind === 'issue' ? `the Issue at ${t.issuePath} (Spec ${t.specPath})`
83
83
  : `the Spec at ${t.specPath} (slug ${t.slug})`
84
84
 
85
+ const cavemanState = A.caveman || { active: false, preference: false, scope: 'none' }
86
+ const cavemanActive = Boolean(cavemanState && cavemanState.active)
87
+ if (cavemanState && cavemanState.warning) {
88
+ if (typeof log === 'function') log(cavemanState.warning)
89
+ cavemanState.warned = true
90
+ }
91
+ const cavemanInstruction = cavemanActive
92
+ ? 'Caveman lite is active: use concise phrasing for conversational messages and summaries. Specs, draft Issues, persisted role results, exact errors, commands, and acceptance criteria retain full detail.'
93
+ : ''
94
+
85
95
  async function validateSpecBeforePlanning() {
86
96
  if (t.kind === 'goal') return null
87
97
  const specPath = t.specPath || paths.specPath
@@ -138,17 +148,38 @@ async function validRoleResult(role, result) {
138
148
  }
139
149
 
140
150
  async function requestRole(role, prompt, options) {
151
+ const selection = (options && options.selection) || (A.roles && A.roles[role])
152
+ const hostHarness = A.hostHarness || 'claude-code'
153
+ if (selection && selection.harness && selection.harness !== hostHarness) {
154
+ const cwd = (options && options.cwd) || A.repoRoot
155
+ const selectionJson = JSON.stringify(selection)
156
+ const agentPrompt = cavemanActive ? `${prompt}\n\n${cavemanInstruction}` : prompt
157
+ const cmd = `python3 "${scripts}/execution.py" dispatch --role "${role}" --cwd ${shellQuote(cwd)} --selection '${selectionJson.replaceAll("'", "'\\''")}'`
158
+ const res = await runCommand(cmd, { cwd: A.repoRoot, input: agentPrompt })
159
+ if (res && res.exitCode === 0) {
160
+ try {
161
+ return JSON.parse(res.stdout)
162
+ } catch (e) {
163
+ return null
164
+ }
165
+ }
166
+ return null
167
+ }
141
168
  const native = A.structuredOutput === true
142
- const result = await agent(prompt, {
169
+ const agentPrompt = cavemanActive ? `${prompt}\n\n${cavemanInstruction}` : prompt
170
+ const agentOptions = {
143
171
  ...options,
172
+ ...(cavemanActive ? { skills: (options.skills || []).concat(cavemanState.skill_path || 'caveman'), caveman: true } : {}),
144
173
  ...(native ? { schema: await roleSchema(role) } : {}),
145
- })
174
+ }
175
+ const result = await agent(agentPrompt, agentOptions)
146
176
  if (await validRoleResult(role, result)) return result
147
- const retry = await agent(`${prompt}\nYour prior result was invalid. Return the complete ${role} result contract.`, {
148
- ...options,
177
+ const retryPrompt = `${agentPrompt}\nYour prior result was invalid. Return the complete ${role} result contract.`
178
+ const retryOptions = {
179
+ ...agentOptions,
149
180
  label: `${options.label}:retry`,
150
- ...(native ? { schema: await roleSchema(role) } : {}),
151
- })
181
+ }
182
+ const retry = await agent(retryPrompt, retryOptions)
152
183
  return (await validRoleResult(role, retry)) ? retry : null
153
184
  }
154
185
 
@@ -282,14 +313,21 @@ and budgets as structured output.`
282
313
  }
283
314
 
284
315
  phase('Research')
316
+ const researchPrompt = (text) => (cavemanActive ? `${text}\n\n${cavemanInstruction}` : text)
317
+ const researchOptions = (opts) => ({
318
+ ...opts,
319
+ phase: 'Research',
320
+ model: A.models.plan,
321
+ ...(cavemanActive ? { skills: (opts.skills || []).concat(cavemanState.skill_path || 'caveman'), caveman: true } : {}),
322
+ })
285
323
  const research = await parallel([
286
- () => agent(`Survey ${A.repoRoot}: conventions, current code/tests, ${paths.context}, and ${paths.adrs}.
287
- Return facts and paths for the Planner.`, { label: 'research:codebase', phase: 'Research', model: A.models.plan }),
288
- () => agent(`Read ${t.specPath || paths.specPath}, ${paths.decisions}, and the relevant repository documents.
289
- Return owned and consumed contracts, settled decisions, and the testing seam.`, { label: 'research:spec', phase: 'Research', model: A.models.plan }),
290
- () => agent(`Read ${paths.exemplarIssue} and ${paths.issueTracker}. Run
291
- \`python3 ${scripts}/frontier.py --scope frontier --json\`. Return the exact Issue format and frontier facts.`,
292
- { label: 'research:format', phase: 'Research', model: A.models.plan }),
324
+ () => agent(researchPrompt(`Survey ${A.repoRoot}: conventions, current code/tests, ${paths.context}, and ${paths.adrs}.
325
+ Return facts and paths for the Planner.`), researchOptions({ label: 'research:codebase' })),
326
+ () => agent(researchPrompt(`Read ${t.specPath || paths.specPath}, ${paths.decisions}, and the relevant repository documents.
327
+ Return owned and consumed contracts, settled decisions, and the testing seam.`), researchOptions({ label: 'research:spec' })),
328
+ () => agent(researchPrompt(`Read ${paths.exemplarIssue} and ${paths.issueTracker}. Run
329
+ \`python3 ${scripts}/frontier.py --scope frontier --json\`. Return the exact Issue format and frontier facts.`),
330
+ researchOptions({ label: 'research:format' })),
293
331
  ])
294
332
 
295
333
  phase('Plan')
@@ -379,5 +417,5 @@ async function approvePlan() {
379
417
  }
380
418
 
381
419
  const approved = await approvePlan()
382
- return { target: t, structuralValidation, requirementReview, plan, critique, awaitingOperatorApproval: !approved, approved }
420
+ return { target: t, structuralValidation, requirementReview, plan, critique, caveman: cavemanState, awaitingOperatorApproval: !approved, approved }
383
421
  ```
@@ -166,6 +166,16 @@ const budget = Number.isInteger(requestedBudget) && requestedBudget >= 0
166
166
  const runLogEnabled = Boolean(A.runId && A.unitId)
167
167
  const stateRootFlag = A.stateRoot ? ` --state-root '${String(A.stateRoot).replaceAll("'", "'\\''")}'` : ''
168
168
 
169
+ const cavemanState = A.caveman || { active: false, preference: false, scope: 'none' }
170
+ const cavemanActive = Boolean(cavemanState && cavemanState.active)
171
+ if (cavemanState && cavemanState.warning) {
172
+ if (typeof log === 'function') log(cavemanState.warning)
173
+ cavemanState.warned = true
174
+ }
175
+ const cavemanInstruction = cavemanActive
176
+ ? 'Caveman lite is active: use concise phrasing for conversational messages and summaries. Code, documentation, lesson candidates, PR descriptions, Result Contracts, exact commands, exact errors, acceptance criteria, and verification evidence retain full detail.'
177
+ : ''
178
+
169
179
  function canonical(value) {
170
180
  if (Array.isArray(value)) return value.map(canonical)
171
181
  if (value && typeof value === 'object') {
@@ -302,6 +312,104 @@ async function roleSchema(role) {
302
312
  return JSON.parse(result.stdout)
303
313
  }
304
314
 
315
+ function resolveRoleForIssue(issueRef, role, options) {
316
+ if (options && options.selection) return options.selection
317
+ if (issueRef && A.issueRoleReplacements && A.issueRoleReplacements[issueRef] && A.issueRoleReplacements[issueRef][role]) {
318
+ return A.issueRoleReplacements[issueRef][role]
319
+ }
320
+ if (issueRef && A.issueRoles && A.issueRoles[issueRef] && A.issueRoles[issueRef][role]) {
321
+ return A.issueRoles[issueRef][role]
322
+ }
323
+ const roleKey = role === 'implementer' ? 'implement' : role === 'reviewer' ? 'review' : role
324
+ if (A.roles && (A.roles[role] || A.roles[roleKey])) {
325
+ return A.roles[role] || A.roles[roleKey]
326
+ }
327
+ if (A.models && (A.models[role] || A.models[roleKey])) {
328
+ return { harness: A.hostHarness || 'claude-code', model: A.models[role] || A.models[roleKey] }
329
+ }
330
+ return null
331
+ }
332
+
333
+ async function logRoleSelected(issueRef, role, selection) {
334
+ const isSelectionConfigured = Boolean(A.roles || A.issueRoles || A.issueRoleReplacements || A.recordRoleSelection)
335
+ if (!runLogEnabled || !selection || !issueRef || !isSelectionConfigured) return
336
+ await appendRunEvent('role.selected', issueRef, undefined, {
337
+ role,
338
+ issue: issueRef,
339
+ requested: selection,
340
+ effective: selection,
341
+ evidence: { source: 'role_selection' },
342
+ })
343
+ }
344
+
345
+ // Check for unresolved execution failures
346
+ let unresolvedFailures = Array.isArray(A.unresolvedFailures) ? [...A.unresolvedFailures] : []
347
+ if (unresolvedFailures.length === 0 && runLogEnabled && !isFirstRound) {
348
+ try {
349
+ const unresCheck = await runCommand(
350
+ `python3 "${scripts}/execution.py" unresolved-failures '${A.unitId}' '${A.runId}'${stateRootFlag} --json`,
351
+ { cwd: A.repoRoot },
352
+ )
353
+ if (unresCheck && unresCheck.exitCode === 0) {
354
+ unresolvedFailures = JSON.parse(unresCheck.stdout)
355
+ }
356
+ } catch (e) {}
357
+ }
358
+
359
+ if (unresolvedFailures.length > 0) {
360
+ const remainingUnresolved = []
361
+ for (const failure of unresolvedFailures) {
362
+ const replacement = resolveRoleForIssue(failure.issue, failure.role, null)
363
+ const hasReplacement = Boolean(
364
+ replacement &&
365
+ ((A.issueRoles && A.issueRoles[failure.issue] && A.issueRoles[failure.issue][failure.role]) ||
366
+ (A.issueRoleReplacements && A.issueRoleReplacements[failure.issue] && A.issueRoleReplacements[failure.issue][failure.role]))
367
+ )
368
+ if (hasReplacement) {
369
+ const repJson = JSON.stringify(replacement)
370
+ const valCmd = `python3 "${scripts}/execution.py" validate-replacement --issue "${failure.issue}" --role "${failure.role}" --selection '${repJson.replaceAll("'", "'\\''")}' --cwd ${shellQuote(A.repoRoot)} --json`
371
+ const valRes = await runCommand(valCmd, { cwd: A.repoRoot })
372
+ let valid = false
373
+ if (valRes && valRes.exitCode === 0) {
374
+ try {
375
+ const parsed = JSON.parse(valRes.stdout)
376
+ valid = parsed.valid === true
377
+ } catch (e) {}
378
+ }
379
+ if (!valid) {
380
+ return {
381
+ round: A.round,
382
+ blocked: true,
383
+ nextRoundBlocked: true,
384
+ reason: 'invalid_role_replacement',
385
+ error: `Invalid replacement selection for ${failure.issue} role ${failure.role}`,
386
+ results: [],
387
+ }
388
+ }
389
+ await appendRunEvent('role.changed', failure.issue, undefined, {
390
+ role: failure.role,
391
+ issue: failure.issue,
392
+ previous: failure.selection || { harness: 'unknown' },
393
+ requested: replacement,
394
+ effective: replacement,
395
+ evidence: { source: 'explicit_recovery' },
396
+ })
397
+ } else {
398
+ remainingUnresolved.push(failure)
399
+ }
400
+ }
401
+ if (remainingUnresolved.length > 0) {
402
+ return {
403
+ round: A.round,
404
+ blocked: true,
405
+ nextRoundBlocked: true,
406
+ reason: 'unresolved_execution_failure',
407
+ unresolvedFailures: remainingUnresolved,
408
+ results: [],
409
+ }
410
+ }
411
+ }
412
+
305
413
  async function validRoleResult(role, result) {
306
414
  if (!result) return false
307
415
  if (A.structuredOutput === true) return true
@@ -312,18 +420,62 @@ async function validRoleResult(role, result) {
312
420
  return Boolean(validation && validation.exitCode === 0)
313
421
  }
314
422
  async function requestRole(role, prompt, options) {
423
+ const issueRef = options && options.issueRef
424
+ const selection = resolveRoleForIssue(issueRef, role, options)
425
+ const hostHarness = A.hostHarness || 'claude-code'
426
+ if (options && options.executionUnavailable) {
427
+ return { executionUnavailable: true, error: options.error || 'Role execution unavailable' }
428
+ }
429
+ if (selection && selection.harness && selection.harness !== hostHarness) {
430
+ const cwd = (options && options.cwd) || A.repoRoot
431
+ const selectionJson = JSON.stringify(selection)
432
+ const agentPrompt = cavemanActive ? `${prompt}\n\n${cavemanInstruction}` : prompt
433
+ const cmd = `python3 "${scripts}/execution.py" dispatch --role "${role}" --cwd ${shellQuote(cwd)} --selection '${selectionJson.replaceAll("'", "'\\''")}'`
434
+ const res = await runCommand(cmd, { cwd: A.repoRoot, input: agentPrompt })
435
+ if (res && res.exitCode === 0) {
436
+ try {
437
+ return JSON.parse(res.stdout)
438
+ } catch (e) {
439
+ return null
440
+ }
441
+ }
442
+ if (res && res.exitCode === 2) {
443
+ return null
444
+ }
445
+ return { executionUnavailable: true, error: (res && res.stderr) || 'Harness execution failed' }
446
+ }
315
447
  const native = A.structuredOutput === true
316
448
  const schema = native ? await roleSchema(role) : null
317
- const result = await agent(prompt, {
449
+ const agentPrompt = cavemanActive ? `${prompt}\n\n${cavemanInstruction}` : prompt
450
+ const agentOptions = {
318
451
  ...options,
452
+ ...(cavemanActive ? { skills: (options.skills || []).concat(cavemanState.skill_path || 'caveman'), caveman: true } : {}),
319
453
  ...(schema ? { schema } : {}),
320
- })
454
+ }
455
+ let result
456
+ try {
457
+ result = await agent(agentPrompt, agentOptions)
458
+ } catch (err) {
459
+ return { executionUnavailable: true, error: String(err && err.message ? err.message : err) }
460
+ }
461
+ if (result && result.executionUnavailable) {
462
+ return { executionUnavailable: true, error: result.error || 'Execution unavailable' }
463
+ }
321
464
  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,
465
+ const retryPrompt = `${agentPrompt}\nYour prior result was invalid. Return the complete ${role} result contract.`
466
+ const retryOptions = {
467
+ ...agentOptions,
324
468
  label: `${options.label}:retry`,
325
- ...(schema ? { schema } : {}),
326
- })
469
+ }
470
+ let retry
471
+ try {
472
+ retry = await agent(retryPrompt, retryOptions)
473
+ } catch (err) {
474
+ return { executionUnavailable: true, error: String(err && err.message ? err.message : err) }
475
+ }
476
+ if (retry && retry.executionUnavailable) {
477
+ return { executionUnavailable: true, error: retry.error || 'Execution unavailable' }
478
+ }
327
479
  return (await validRoleResult(role, retry)) ? retry : null
328
480
  }
329
481
 
@@ -500,13 +652,20 @@ refutations, gateResult, gateFailures and decisionsForOperator as structured out
500
652
  async function implement(issue, feedback, previous) {
501
653
  const assigned = await implementationLocation(issue, previous)
502
654
  if (!assigned) return null
655
+ const selection = resolveRoleForIssue(issue.ref, 'implementer', null)
656
+ await logRoleSelected(issue.ref, 'implementer', selection)
503
657
  const options = {
504
658
  label: `implement:${issue.ref}`, phase: 'Implement', model: A.models.implement, cwd: assigned.worktree,
659
+ issueRef: issue.ref, selection,
660
+ ...(A.issueExecutionUnavailable && A.issueExecutionUnavailable[issue.ref] === 'implementer' ? { executionUnavailable: true } : {}),
505
661
  }
506
662
  await markRunIn(assigned.worktree)
507
663
  await appendRunEvent('phase.started', issue.ref, 'Implement', { worktree: assigned.worktree })
508
664
  await appendRunEvent('subagent.started', issue.ref, 'Implement', { role: 'implementer' })
509
665
  const result = await requestRole('implementer', implementPrompt(issue, feedback, assigned), options)
666
+ if (result && result.executionUnavailable) {
667
+ return { executionUnavailable: true, role: 'implementer', error: result.error, worktree: assigned.worktree, branch: assigned.branch }
668
+ }
510
669
  await appendRunEvent('subagent.stopped', issue.ref, 'Implement', { role: 'implementer', result })
511
670
  await appendRunEvent('phase.finished', issue.ref, 'Implement', { worktree: assigned.worktree })
512
671
  return result && result.worktree === assigned.worktree && result.branch === assigned.branch ? result : null
@@ -517,11 +676,19 @@ const results = await pipeline(
517
676
  issue => implement(issue, null, null),
518
677
  async (impl, issue) => {
519
678
  if (!impl) return null
679
+ if (impl.executionUnavailable) return impl
680
+ const selection = resolveRoleForIssue(issue.ref, 'reviewer', null)
681
+ await logRoleSelected(issue.ref, 'reviewer', selection)
520
682
  await appendRunEvent('phase.started', issue.ref, 'Review', { worktree: location(impl) })
521
683
  await appendRunEvent('subagent.started', issue.ref, 'Review', { role: 'reviewer' })
522
684
  const review = await requestRole('reviewer', reviewPrompt(issue, impl), {
523
685
  label: `review:${issue.ref}`, phase: 'Review', model: A.models.review, cwd: location(impl),
686
+ issueRef: issue.ref, selection,
687
+ ...(A.issueExecutionUnavailable && A.issueExecutionUnavailable[issue.ref] === 'reviewer' ? { executionUnavailable: true } : {}),
524
688
  })
689
+ if (review && review.executionUnavailable) {
690
+ return { executionUnavailable: true, role: 'reviewer', error: review.error, impl }
691
+ }
525
692
  await appendRunEvent('subagent.stopped', issue.ref, 'Review', { role: 'reviewer', result: review })
526
693
  await appendRunEvent('phase.finished', issue.ref, 'Review', {})
527
694
  if (review) {
@@ -536,10 +703,42 @@ const results = await pipeline(
536
703
  if (!reviewed) {
537
704
  return { impl: null, implementerFailed: true, failedImpl: impl, review, reviewFix: true }
538
705
  }
706
+ if (reviewed.executionUnavailable) return reviewed
539
707
  return { impl: reviewed, review, reviewFix: Boolean(review && review.blocking.length) }
540
708
  },
541
709
  async (state, issue) => {
542
710
  if (!state) return { ref: issue.ref, outcome: 'implementer_failed' }
711
+ if (state.executionUnavailable) {
712
+ const impl = state.impl
713
+ const worktree = (impl && impl.worktree) || state.worktree || issueWorktreePath(issue)
714
+ const branch = (impl && impl.branch) || state.branch || await configuredIssueBranch(issue)
715
+ const corrections = state.corrections || 0
716
+ await appendRunEvent('issue.paused', issue.ref, state.role === 'reviewer' ? 'Review' : state.role === 'implementer' ? 'Implement' : 'Critic', {
717
+ role: state.role || 'critic',
718
+ reason: 'execution_unavailable',
719
+ worktree,
720
+ error: state.error || 'Role execution unavailable',
721
+ })
722
+ return {
723
+ ref: issue.ref,
724
+ issuePath: issue.path,
725
+ outcome: 'paused',
726
+ status: 'paused',
727
+ reason: 'execution_unavailable',
728
+ executionUnavailable: true,
729
+ role: state.role || 'critic',
730
+ error: state.error || 'Role execution unavailable',
731
+ worktree,
732
+ branch,
733
+ commits: (impl && impl.commits) || [],
734
+ corrections,
735
+ reviewFix: state.reviewFix,
736
+ review: state.review,
737
+ verdict: null,
738
+ decisions: (impl && impl.decisions) || [],
739
+ blockers: (impl && impl.blockers) || [],
740
+ }
741
+ }
543
742
  if (state.implementerFailed) {
544
743
  return {
545
744
  ref: issue.ref, outcome: 'implementer_failed',
@@ -557,11 +756,42 @@ const results = await pipeline(
557
756
  let corrections = prior && Number.isInteger(prior.correctionsSpent) ? prior.correctionsSpent : 0
558
757
  let accepted = false
559
758
  for (let attempt = 1; ; attempt += 1) {
759
+ const selection = resolveRoleForIssue(issue.ref, 'critic', null)
760
+ await logRoleSelected(issue.ref, 'critic', selection)
560
761
  await appendRunEvent('phase.started', issue.ref, 'Critic', { attempt, worktree: location(impl) })
561
762
  await appendRunEvent('subagent.started', issue.ref, 'Critic', { role: 'critic', attempt })
562
763
  verdict = await requestRole('critic', criticPrompt(issue, impl, state.review, attempt), {
563
764
  label: `critic:${issue.ref}#${attempt}`, phase: 'Critic', model: A.models.critic, cwd: location(impl),
765
+ issueRef: issue.ref, selection,
766
+ ...(A.issueExecutionUnavailable && A.issueExecutionUnavailable[issue.ref] === 'critic' ? { executionUnavailable: true } : {}),
564
767
  })
768
+ if (verdict && verdict.executionUnavailable) {
769
+ await appendRunEvent('issue.paused', issue.ref, 'Critic', {
770
+ role: 'critic',
771
+ reason: 'execution_unavailable',
772
+ worktree: location(impl),
773
+ error: verdict.error || 'Critic execution unavailable',
774
+ })
775
+ return {
776
+ ref: issue.ref,
777
+ issuePath: issue.path,
778
+ outcome: 'paused',
779
+ status: 'paused',
780
+ reason: 'execution_unavailable',
781
+ executionUnavailable: true,
782
+ role: 'critic',
783
+ error: verdict.error || 'Critic execution unavailable',
784
+ worktree: impl.worktree,
785
+ branch: impl.branch,
786
+ commits: impl.commits,
787
+ corrections,
788
+ reviewFix: state.reviewFix,
789
+ review: state.review,
790
+ verdict: null,
791
+ decisions: (impl.decisions) || [],
792
+ blockers: (impl.blockers) || [],
793
+ }
794
+ }
565
795
  await appendRunEvent('subagent.stopped', issue.ref, 'Critic', { role: 'critic', attempt, result: projectCriticResult(verdict) })
566
796
  await appendRunEvent('phase.finished', issue.ref, 'Critic', { attempt })
567
797
  if (!verdict) {
@@ -589,6 +819,33 @@ const results = await pipeline(
589
819
  blockers: impl.blockers || [],
590
820
  }
591
821
  }
822
+ if (corrected.executionUnavailable) {
823
+ await appendRunEvent('issue.paused', issue.ref, 'Implement', {
824
+ role: 'implementer',
825
+ reason: 'execution_unavailable',
826
+ worktree: location(corrected),
827
+ error: corrected.error || 'Implementer execution unavailable',
828
+ })
829
+ return {
830
+ ref: issue.ref,
831
+ issuePath: issue.path,
832
+ outcome: 'paused',
833
+ status: 'paused',
834
+ reason: 'execution_unavailable',
835
+ executionUnavailable: true,
836
+ role: 'implementer',
837
+ error: corrected.error || 'Implementer execution unavailable',
838
+ worktree: corrected.worktree || impl.worktree,
839
+ branch: corrected.branch || impl.branch,
840
+ commits: impl.commits,
841
+ corrections,
842
+ reviewFix: state.reviewFix,
843
+ review: state.review,
844
+ verdict,
845
+ decisions: [...(impl.decisions || []), ...(verdict.decisionsForOperator || [])],
846
+ blockers: impl.blockers || [],
847
+ }
848
+ }
592
849
  corrections += 1
593
850
  impl = corrected
594
851
  }
@@ -693,15 +950,16 @@ if (integrationStopped) {
693
950
  round: A.round, reason: cancelReason && cancelReason.reason,
694
951
  })
695
952
  }
696
- const candidates = A.isLastRound ? await learn() : []
697
- if (!integrationStopped && A.isLastRound) {
953
+ const hasPaused = deliveries.some(d => d.outcome === 'paused')
954
+ const candidates = (A.isLastRound && !hasPaused) ? await learn() : []
955
+ if (!integrationStopped && A.isLastRound && !hasPaused) {
698
956
  await appendRunEvent('run.finished', undefined, undefined, { round: A.round })
699
957
  }
700
- if (integrationStopped || A.isLastRound) {
958
+ if (integrationStopped || (A.isLastRound && !hasPaused)) {
701
959
  await unmarkRun()
702
960
  }
703
961
  let prOffer = null
704
- if (!integrationStopped && A.isLastRound) {
962
+ if (!integrationStopped && A.isLastRound && !hasPaused) {
705
963
  const completed = deliveries.filter(d => d.outcome === 'done')
706
964
  if (completed.length > 0) {
707
965
  const ghCheck = await runCommand('gh --version', { cwd: A.repoRoot })
@@ -747,7 +1005,15 @@ if (!integrationStopped && A.isLastRound) {
747
1005
  }
748
1006
  }
749
1007
  }
750
- return { round: A.round, date: A.date, results: deliveries, ...(A.isLastRound ? { candidates, prOffer } : {}) }
1008
+ return {
1009
+ round: A.round,
1010
+ date: A.date,
1011
+ results: deliveries,
1012
+ caveman: cavemanState,
1013
+ nextRoundBlocked: hasPaused || integrationStopped,
1014
+ ...(hasPaused ? { pausedIssues: deliveries.filter(d => d.outcome === 'paused') } : {}),
1015
+ ...(A.isLastRound && !hasPaused ? { candidates, prOffer } : {}),
1016
+ }
751
1017
  ```
752
1018
 
753
1019
  The Workflow returns `done` only after serial integration (when isolated), a passing post-integration
@@ -76,15 +76,56 @@ def declared_models(capabilities_dir: Path) -> dict[str, tuple[str, int]]:
76
76
  return models
77
77
 
78
78
 
79
- def estimate(issue_path: Path, model: str, root: Path, capabilities_dir: Path) -> dict:
79
+ def resolve_model_metadata(
80
+ model: str,
81
+ capabilities_dir: Path,
82
+ catalog_path: Path | None = None,
83
+ ) -> tuple[str, int]:
84
+ """Resolve model harness and context window from capabilities or discovered catalog."""
85
+ models = declared_models(capabilities_dir)
86
+ if model in models:
87
+ return models[model]
88
+
89
+ if catalog_path and catalog_path.is_file():
90
+ try:
91
+ catalog = json.loads(catalog_path.read_text(encoding="utf-8"))
92
+ except json.JSONDecodeError as exc:
93
+ raise ConfigurationError(f"invalid catalog JSON in {catalog_path}: {exc}") from exc
94
+
95
+ raw_models = catalog.get("models", {})
96
+ details = None
97
+ harness = catalog.get("harness", "discovered")
98
+ if isinstance(raw_models, dict) and model in raw_models:
99
+ details = raw_models[model]
100
+ elif isinstance(raw_models, list):
101
+ for m in raw_models:
102
+ if isinstance(m, dict) and m.get("id") == model:
103
+ details = m
104
+ break
105
+
106
+ if details is not None:
107
+ window = details.get("contextWindow")
108
+ if not isinstance(window, int) or isinstance(window, bool) or window <= 0:
109
+ raise ConfigurationError(f"missing verified context metadata for model {model!r}")
110
+ harness = details.get("harness", harness)
111
+ return (harness, window)
112
+
113
+ raise ConfigurationError(f"unknown model ID: {model!r}")
114
+
115
+
116
+ def estimate(
117
+ issue_path: Path,
118
+ model: str,
119
+ root: Path,
120
+ capabilities_dir: Path,
121
+ catalog_path: Path | None = None,
122
+ ) -> dict:
80
123
  """Build the deterministic initial-package estimate for one Issue."""
81
124
  issue = parse_issue(issue_path)
82
125
  spec_path = artifact_path(root, "specs", issue.spec).resolve()
83
126
  if not spec_path.is_file():
84
127
  raise ConfigurationError(f"parent Spec does not exist: {spec_path.relative_to(root)}")
85
- models = declared_models(capabilities_dir)
86
- if model not in models:
87
- raise ConfigurationError(f"unknown model ID: {model!r}")
128
+ harness, window = resolve_model_metadata(model, capabilities_dir, catalog_path)
88
129
  policy = resolve_policy(root)
89
130
  share = policy.get("budget", {}).get("contextShare")
90
131
  if not isinstance(share, (int, float)) or isinstance(share, bool) or not 0 < share <= 1:
@@ -101,7 +142,6 @@ def estimate(issue_path: Path, model: str, root: Path, capabilities_dir: Path) -
101
142
  for path in ordered
102
143
  ]
103
144
  byte_count = sum(entry["bytes"] for entry in files)
104
- harness, window = models[model]
105
145
  estimated_tokens = byte_count / 4
106
146
  budget_tokens = window * share
107
147
  over_budget = estimated_tokens > budget_tokens
@@ -138,7 +178,8 @@ def emit(payload: dict, as_json: bool) -> None:
138
178
  def main() -> int:
139
179
  parser = argparse.ArgumentParser(description=__doc__)
140
180
  parser.add_argument("issue", help="Issue path")
141
- parser.add_argument("--model", required=True, help="exact model ID declared by a capability")
181
+ parser.add_argument("--model", required=True, help="exact model ID declared by a capability or catalog")
182
+ parser.add_argument("--catalog", help="path to discovered model catalog JSON")
142
183
  parser.add_argument("--cwd", default=".", help="repository or worktree to inspect")
143
184
  parser.add_argument("--json", action="store_true", help="emit machine-readable output")
144
185
  args = parser.parse_args()
@@ -149,8 +190,9 @@ def main() -> int:
149
190
  emit({"error": f"configuration error: Issue does not exist: {issue_path}"}, args.json)
150
191
  return 1
151
192
  capabilities = Path(__file__).resolve().parents[1] / "capabilities"
193
+ catalog_path = Path(args.catalog).resolve() if args.catalog else None
152
194
  try:
153
- payload = estimate(issue_path, args.model, root, capabilities)
195
+ payload = estimate(issue_path, args.model, root, capabilities, catalog_path=catalog_path)
154
196
  except (ConfigurationError, ValueError) as exc:
155
197
  emit({"error": f"configuration error: {exc}"}, args.json)
156
198
  return 1