unitbob 0.3.6 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -20,15 +20,31 @@ Add the Unitbob plugin marketplace: sergeygershun/unitbob-connector
20
20
  Install the unitbob plugin
21
21
  ```
22
22
 
23
- **With commands (in the terminal):**
23
+ **Claude Code (in the terminal):**
24
24
  ```
25
25
  claude plugin marketplace add sergeygershun/unitbob-connector
26
26
  claude plugin install unitbob@unitbob
27
27
  ```
28
28
 
29
- Restart the session so the commands load.
29
+ **Codex (in the terminal):**
30
+ ```
31
+ codex plugin marketplace add sergeygershun/unitbob-connector
32
+ codex plugin add unitbob@unitbob
33
+ npx -y unitbob@0.4.1 codex-install
34
+ ```
30
35
 
31
- In Codex it is the same the same install, and the same phrasings below.
36
+ Start a new Claude Code or Codex thread so the installed skill and named agents
37
+ load. After setup, the phrasings and Unitbob flow below are the same on both
38
+ hosts.
39
+
40
+ Codex compatibility: version 0.145.0 accepts the Unitbob custom-agent TOML files,
41
+ but its experimental rollout budget is shared by the root and subagents rather
42
+ than enforced separately for each named agent. No Codex version is currently
43
+ qualified by Unitbob for a native per-agent ceiling. Before the first bounded
44
+ role, Unitbob therefore asks whether to continue this invocation without that
45
+ mechanical ceiling; approval is never persisted. The definitions keep the native
46
+ budget values so a future Codex release can be qualified without introducing a
47
+ Unitbob supervisor.
32
48
 
33
49
  ---
34
50
 
package/dist/cli.js CHANGED
@@ -25,6 +25,9 @@ import { validateBuild } from "./verbs/validateBuild.js";
25
25
  import { fixPrepare } from "./verbs/fixPrepare.js";
26
26
  import { contractPrompt } from "./verbs/contractPrompt.js";
27
27
  import { suiteReviewPrepare } from "./verbs/suiteReviewPrepare.js";
28
+ import { validateWorkerPlan } from "./verbs/validateWorkerPlan.js";
29
+ import { validateWorkerCheckpoints } from "./verbs/validateWorkerCheckpoints.js";
30
+ import { installCodexAgents } from "./verbs/codexInstall.js";
28
31
  const USAGE = `unitbob — thin local hands for the Unitbob server.
29
32
 
30
33
  Usage: unitbob [--project-root <dir>] <verb> [args]
@@ -36,6 +39,7 @@ Options:
36
39
 
37
40
  Verbs:
38
41
  init Link this project to Unitbob (also happens automatically).
42
+ codex-install Install the bounded Unitbob worker definitions for Codex.
39
43
  recipe <name> Fetch and print a recipe from the server.
40
44
  show Print the link to this project's map.
41
45
  map-prepare Internal: keylessly update the graph (no API key) and write the host map-build request.
@@ -47,6 +51,9 @@ Verbs:
47
51
  suite-review-prepare Internal: bind an independent BDD quality review to the built behavioral candidate.
48
52
  validate-build Internal: check the host's suite answer against the request, locally, before
49
53
  uploading. Reports every problem at once; put-suite-build runs it too.
54
+ validate-worker-plan Internal: validate the exact request-bound worker plan before fan-out.
55
+ validate-worker-checkpoints
56
+ Internal: validate every worker checkpoint before assembly or repair.
50
57
  put-suite-build Internal: upload the host-built guardrail suite (whole spec file + test_metadata),
51
58
  then run every branch it published and report the server's results.
52
59
  run-local [branch] Internal: run the suite you just wrote, before publishing it, with the same runner
@@ -81,6 +88,9 @@ export async function main(argv, deps = { ensureLinked }) {
81
88
  const linked = () => deps.ensureLinked(parsed.root);
82
89
  try {
83
90
  switch (verb) {
91
+ case 'codex-install':
92
+ installCodexAgents(args);
93
+ return 0;
84
94
  case 'init':
85
95
  await init(args);
86
96
  return 0;
@@ -108,6 +118,12 @@ export async function main(argv, deps = { ensureLinked }) {
108
118
  case 'validate-build':
109
119
  await validateBuild(await linked(), args);
110
120
  return 0;
121
+ case 'validate-worker-plan':
122
+ await validateWorkerPlan(await linked(), args);
123
+ return 0;
124
+ case 'validate-worker-checkpoints':
125
+ await validateWorkerCheckpoints(await linked(), args);
126
+ return 0;
111
127
  case 'put-suite-build':
112
128
  return await publishAndRun(await linked(), args);
113
129
  case 'fix-prepare':
@@ -9,6 +9,87 @@ import { BDD_RUN_ARTIFACTS } from "../runner/bdd.js";
9
9
  // runs the suite to green before upload; the connector materializes the stored
10
10
  // blob only so `check` can execute it locally.
11
11
  export const BEHAVIORAL_DIR = '.unitbob/behavioral';
12
+ export const BEHAVIORAL_WORLD_PATH = `${BEHAVIORAL_DIR}/step_definitions/00_unitbob_world.rb`;
13
+ // The Ruby/Cucumber harness is connector-owned. Host agents own business steps;
14
+ // this file owns only the stable Rails integration seam they build on.
15
+ export const BEHAVIORAL_WORLD = `# Generated by Unitbob. DO NOT EDIT: suite materialization restores this file.
16
+ repo_root = ENV.fetch('UNITBOB_REPO_ROOT')
17
+ ENV['RAILS_ENV'] ||= 'test'
18
+ require File.join(repo_root, 'config', 'environment')
19
+ require 'action_dispatch/testing/integration'
20
+ require 'rspec/expectations'
21
+ require 'rspec/mocks'
22
+
23
+ module UnitbobWorld
24
+ include RSpec::Matchers
25
+ include RSpec::Mocks::ExampleMethods
26
+
27
+ attr_reader :unitbob_session
28
+
29
+ def unitbob_get(path, **options)
30
+ unitbob_session.get(path, **options)
31
+ end
32
+
33
+ def unitbob_post(path, **options)
34
+ unitbob_session.post(path, **options)
35
+ end
36
+
37
+ def unitbob_patch(path, **options)
38
+ unitbob_session.patch(path, **options)
39
+ end
40
+
41
+ def unitbob_put(path, **options)
42
+ unitbob_session.put(path, **options)
43
+ end
44
+
45
+ def unitbob_delete(path, **options)
46
+ unitbob_session.delete(path, **options)
47
+ end
48
+
49
+ def unitbob_expect_status(status)
50
+ unitbob_session.assert_response(status)
51
+ end
52
+
53
+ def unitbob_expect_redirect_to(location)
54
+ unitbob_session.assert_redirected_to(location)
55
+ end
56
+ end
57
+
58
+ World(UnitbobWorld)
59
+
60
+ Before do
61
+ @unitbob_time_zone = Time.zone
62
+ @unitbob_locale = I18n.locale
63
+ @unitbob_connection = ActiveRecord::Base.connection
64
+ @unitbob_connection.begin_transaction(joinable: false)
65
+ RSpec::Mocks.setup
66
+
67
+ @unitbob_session = ActionDispatch::Integration::Session.new(Rails.application)
68
+ @unitbob_session.singleton_class.class_eval { attr_accessor :assertions }
69
+ @unitbob_session.assertions = 0
70
+ end
71
+
72
+ After do
73
+ mock_error = nil
74
+ begin
75
+ RSpec::Mocks.verify
76
+ rescue Exception => error # rubocop:disable Lint/RescueException
77
+ mock_error = error
78
+ ensure
79
+ RSpec::Mocks.teardown
80
+ @unitbob_connection.rollback_transaction if @unitbob_connection&.transaction_open?
81
+ Time.zone = @unitbob_time_zone
82
+ I18n.locale = @unitbob_locale
83
+ end
84
+ raise mock_error if mock_error
85
+ end
86
+ `;
87
+ export function materializeBehavioralWorld(projectRoot) {
88
+ const worldPath = join(projectRoot, BEHAVIORAL_WORLD_PATH);
89
+ mkdirSync(dirname(worldPath), { recursive: true });
90
+ writeFileSync(worldPath, BEHAVIORAL_WORLD);
91
+ return worldPath;
92
+ }
12
93
  // Write a behavioral artifact envelope (main file + support files) under the
13
94
  // behavioral root, after checking every path is safe. Stale suite artifacts are
14
95
  // removed while the separately provisioned runner environment is preserved.
@@ -17,6 +98,9 @@ export function materializeBehavioral(projectRoot, artifact, runner) {
17
98
  const files = [artifact, ...(artifact.support_files ?? [])];
18
99
  for (const file of files)
19
100
  assertUnitbobPath(file.path, BEHAVIORAL_DIR);
101
+ if (files.some((file) => file.path === BEHAVIORAL_WORLD_PATH)) {
102
+ throw new Error(`${BEHAVIORAL_WORLD_PATH} is the connector-owned World and cannot be supplied by the host artifact.`);
103
+ }
20
104
  const behavioralRoot = join(projectRoot, BEHAVIORAL_DIR);
21
105
  const runnerEntries = RUNNER_ENVIRONMENT_ENTRIES[runner] ?? EMPTY_ENTRIES;
22
106
  mkdirSync(behavioralRoot, { recursive: true });
@@ -33,6 +117,9 @@ export function materializeBehavioral(projectRoot, artifact, runner) {
33
117
  if (file === artifact)
34
118
  mainPath = dest;
35
119
  }
120
+ if (runner === 'cucumber') {
121
+ materializeBehavioralWorld(projectRoot);
122
+ }
36
123
  return { mainPath };
37
124
  }
38
125
  // Everything under the behavioral root that the next materialization will
@@ -49,7 +136,11 @@ export function filesLostOnMaterialize(projectRoot, artifact, runner) {
49
136
  const behavioralRoot = join(projectRoot, BEHAVIORAL_DIR);
50
137
  if (!existsSync(behavioralRoot))
51
138
  return [];
52
- const listed = new Set([artifact.path, ...(artifact.support_files ?? []).map((file) => file.path)]);
139
+ const listed = new Set([
140
+ artifact.path,
141
+ ...(artifact.support_files ?? []).map((file) => file.path),
142
+ ...(runner === 'cucumber' ? [BEHAVIORAL_WORLD_PATH] : []),
143
+ ]);
53
144
  const runnerEntries = RUNNER_ENVIRONMENT_ENTRIES[runner] ?? EMPTY_ENTRIES;
54
145
  return readdirSync(behavioralRoot)
55
146
  .filter((entry) => !runnerEntries.has(entry) && !CONNECTOR_RUN_ARTIFACTS.has(entry))
@@ -3,6 +3,7 @@ import { createHash } from 'node:crypto';
3
3
  import { dirname, join, sep } from 'node:path';
4
4
  import { assertUnitbobPath } from "./artifactPath.js";
5
5
  import { readBudget, RUN_BUDGET } from "./budget.js";
6
+ import { readWorkerPlan, validateWorkerPlanFiles, workerPlanDigest, workerPlanPath } from "./workerPlan.js";
6
7
  export function requestPath(projectRoot) {
7
8
  return join(projectRoot, '.unitbob', 'suite-build', 'request.json');
8
9
  }
@@ -42,6 +43,28 @@ export function writeBehavioralReviewRequest(projectRoot, output, candidateRun,
42
43
  ...(evidence.fixed_candidate_run ? { fixed_candidate_run: evidence.fixed_candidate_run } : {}),
43
44
  } : {}),
44
45
  };
46
+ const workerPlanDigestInMetadata = metadata?.worker_plan_digest;
47
+ if (workerPlanDigestInMetadata === undefined && existsSync(workerPlanPath(projectRoot))) {
48
+ throw new Error('behavioral test_metadata.worker_plan_digest is required when a local worker-plan.json exists.');
49
+ }
50
+ if (workerPlanDigestInMetadata !== undefined) {
51
+ if (typeof workerPlanDigestInMetadata !== 'string' || !/^[a-f0-9]{64}$/.test(workerPlanDigestInMetadata)) {
52
+ throw new Error('behavioral test_metadata.worker_plan_digest must be a SHA-256 string.');
53
+ }
54
+ const planErrors = validateWorkerPlanFiles(projectRoot);
55
+ if (planErrors.length > 0)
56
+ throw new Error(`Cannot review an invalid worker plan:\n- ${planErrors.join('\n- ')}`);
57
+ const digest = workerPlanDigest(projectRoot);
58
+ if (workerPlanDigestInMetadata !== digest) {
59
+ throw new Error('behavioral test_metadata.worker_plan_digest does not match the exact local worker-plan.json bytes.');
60
+ }
61
+ const buildRequest = readSuiteBuildRequest(projectRoot);
62
+ const assignment = buildRequest.branches.find((branch) => branch.suite_kind === 'behavioral')?.assignment;
63
+ const items = readWorkerPlan(projectRoot).workers.filter((item) => item.branch === 'behavioral');
64
+ request.behavioral_assignment = assignment;
65
+ request.worker_plan = items;
66
+ request.plan_digest = digest;
67
+ }
45
68
  writeArtifact(reviewRequestPath(projectRoot), request);
46
69
  return request;
47
70
  }
@@ -80,6 +103,7 @@ const POST_CANDIDATE_METADATA_KEYS = [
80
103
  'known_defect_context',
81
104
  'candidate_run',
82
105
  'fixed_candidate_run',
106
+ 'selection_review',
83
107
  ];
84
108
  export function readBehavioralReview(projectRoot, output) {
85
109
  const metadata = output.test_metadata;
@@ -98,6 +122,7 @@ export function readBehavioralReview(projectRoot, output) {
98
122
  if (!('bdd_quality_review' in review) || !('known_defect_probe' in review)) {
99
123
  throw new Error(`${path} must contain bdd_quality_review and known_defect_probe.`);
100
124
  }
125
+ validateSelectionReview(review, metadata);
101
126
  const evidence = readCandidateRunEvidence(projectRoot, output);
102
127
  return {
103
128
  ...review,
@@ -105,6 +130,40 @@ export function readBehavioralReview(projectRoot, output) {
105
130
  ...(evidence.fixed_candidate_run ? { fixed_candidate_run: evidence.fixed_candidate_run } : {}),
106
131
  };
107
132
  }
133
+ function validateSelectionReview(review, metadata) {
134
+ const digest = metadata?.worker_plan_digest;
135
+ if (digest === undefined)
136
+ return;
137
+ const selection = review.selection_review;
138
+ if (!selection || selection.plan_digest !== digest || !Array.isArray(selection.capability_reviews)) {
139
+ throw new Error('The behavioral review must contain selection_review bound to worker_plan_digest.');
140
+ }
141
+ const expected = Array.isArray(metadata?.capabilities)
142
+ ? metadata.capabilities.flatMap((entry) => {
143
+ const id = entry?.capability_id;
144
+ return typeof id === 'string' && id ? [id] : [];
145
+ })
146
+ : [];
147
+ const entries = selection.capability_reviews;
148
+ const actual = entries.map((entry) => entry?.capability_id);
149
+ for (const id of expected) {
150
+ if (actual.filter((candidate) => candidate === id).length !== 1) {
151
+ throw new Error(`selection_review must contain exactly one verdict for capability ${id}.`);
152
+ }
153
+ }
154
+ for (const [index, entry] of entries.entries()) {
155
+ if (!expected.includes(entry?.capability_id)) {
156
+ throw new Error(`selection_review.capability_reviews[${index}] names an unassigned capability.`);
157
+ }
158
+ if (!['pass', 'does_not_pass'].includes(entry?.verdict)) {
159
+ throw new Error(`selection_review.capability_reviews[${index}].verdict must be pass or does_not_pass.`);
160
+ }
161
+ if (entry.verdict === 'does_not_pass' &&
162
+ (typeof entry.reviewer_objection_text !== 'string' || !entry.reviewer_objection_text.trim())) {
163
+ throw new Error(`selection_review.capability_reviews[${index}] must include reviewer_objection_text.`);
164
+ }
165
+ }
166
+ }
108
167
  // Why a review does not bind — two different mistakes with one symptom.
109
168
  //
110
169
  // The likelier one, now that a branch may answer with a bare `{ path }`: the
@@ -0,0 +1,170 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { assertUnitbobPath } from "./artifactPath.js";
5
+ export function workerPlanPath(projectRoot) {
6
+ return join(projectRoot, '.unitbob', 'suite-build', 'worker-plan.json');
7
+ }
8
+ function requestPath(projectRoot) {
9
+ return join(projectRoot, '.unitbob', 'suite-build', 'request.json');
10
+ }
11
+ export function checkpointPath(projectRoot, item) {
12
+ return join(projectRoot, '.unitbob', 'suite-build', 'checkpoints', `${item.branch}-${item.worker_id}.json`);
13
+ }
14
+ export function exactFileDigest(path) {
15
+ return createHash('sha256').update(readFileSync(path)).digest('hex');
16
+ }
17
+ export function requestDigest(projectRoot) {
18
+ return exactFileDigest(requestPath(projectRoot));
19
+ }
20
+ export function workerPlanDigest(projectRoot) {
21
+ return exactFileDigest(workerPlanPath(projectRoot));
22
+ }
23
+ export function readWorkerPlan(projectRoot) {
24
+ const path = workerPlanPath(projectRoot);
25
+ if (!existsSync(path))
26
+ throw new Error(`${path} not found — write the complete worker plan before fan-out.`);
27
+ let parsed;
28
+ try {
29
+ parsed = JSON.parse(readFileSync(path, 'utf8'));
30
+ }
31
+ catch (error) {
32
+ throw new Error(`${path} is not valid JSON: ${error.message}`);
33
+ }
34
+ return parsed;
35
+ }
36
+ export function validateWorkerPlanFiles(projectRoot) {
37
+ const errors = [];
38
+ const plan = readWorkerPlan(projectRoot);
39
+ let request;
40
+ try {
41
+ request = JSON.parse(readFileSync(requestPath(projectRoot), 'utf8'));
42
+ }
43
+ catch (error) {
44
+ throw new Error(`${requestPath(projectRoot)} is not valid JSON: ${error.message}`);
45
+ }
46
+ if (!plan || typeof plan !== 'object')
47
+ return ['worker plan must be an object'];
48
+ if (plan.request_digest !== requestDigest(projectRoot))
49
+ errors.push('request_digest does not match the exact request.json bytes');
50
+ if (!Array.isArray(plan.workers) || plan.workers.length === 0)
51
+ return [...errors, 'workers must be a non-empty array'];
52
+ const branches = Array.isArray(request.branches) ? request.branches : [];
53
+ const expectedByBranch = new Map(branches.map((branch) => [
54
+ branch.suite_kind,
55
+ assignmentIds(branch.assignment),
56
+ ]));
57
+ const budget = request.budget;
58
+ const workerCeiling = typeof budget?.workers === 'number' ? budget.workers : Number.POSITIVE_INFINITY;
59
+ const seenWorkers = new Set();
60
+ const seenPaths = new Map();
61
+ for (const [index, item] of plan.workers.entries()) {
62
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
63
+ errors.push(`workers[${index}]: worker plan item must be an object`);
64
+ continue;
65
+ }
66
+ const label = workerLabel(item, index);
67
+ if (!isNonEmptyString(item?.branch) || !expectedByBranch.has(item.branch))
68
+ errors.push(`${label}: branch is not in the request`);
69
+ if (!isNonEmptyString(item?.worker_id) || !/^[a-zA-Z0-9_-]+$/.test(item.worker_id))
70
+ errors.push(`${label}: worker_id must be a stable filename-safe id`);
71
+ else if (seenWorkers.has(item.worker_id))
72
+ errors.push(`${label}: worker_id ${item.worker_id} appears more than once`);
73
+ else
74
+ seenWorkers.add(item.worker_id);
75
+ for (const field of ['capability_ids', 'promises', 'planned_cases', 'source_paths', 'owned_paths']) {
76
+ if (!Array.isArray(item?.[field]) || item[field].length === 0)
77
+ errors.push(`${label}: ${field} must be non-empty`);
78
+ }
79
+ for (const field of ['capability_ids', 'promises', 'source_paths', 'owned_paths']) {
80
+ if (Array.isArray(item?.[field]) && item[field].some((value) => !isNonEmptyString(value))) {
81
+ errors.push(`${label}: ${field} must contain non-empty strings`);
82
+ }
83
+ }
84
+ if (Array.isArray(item?.planned_cases) && item.planned_cases.some((value) => !(isNonEmptyString(value) || (value !== null && typeof value === 'object')))) {
85
+ errors.push(`${label}: planned_cases must contain non-empty intents`);
86
+ }
87
+ if (!isNonEmptyString(item?.done_when))
88
+ errors.push(`${label}: done_when must be non-empty`);
89
+ if (!isNonEmptyString(item?.harness_path))
90
+ errors.push(`${label}: harness_path must be non-empty`);
91
+ const expectedHarness = item?.branch === 'behavioral'
92
+ ? '.unitbob/behavioral/step_definitions/00_unitbob_world.rb'
93
+ : item?.branch === 'structural' ? '.unitbob/structural/unitbob_helper.rb' : null;
94
+ if (expectedHarness && item.harness_path !== expectedHarness) {
95
+ errors.push(`${label}: harness_path must name the connector-owned ${expectedHarness}`);
96
+ }
97
+ if (!item?.limits || item.limits.planned_cases !== item.planned_cases?.length) {
98
+ errors.push(`${label}: limits.planned_cases must equal planned_cases.length`);
99
+ }
100
+ if (!Number.isInteger(item?.limits?.fact_finder_lookups) || item.limits.fact_finder_lookups < 0 || item.limits.fact_finder_lookups > 8) {
101
+ errors.push(`${label}: limits.fact_finder_lookups must be an integer from 0 to 8`);
102
+ }
103
+ for (const ownedPath of Array.isArray(item?.owned_paths) ? item.owned_paths : []) {
104
+ if (!isNonEmptyString(ownedPath)) {
105
+ errors.push(`${label}: owned path must be a non-empty string`);
106
+ continue;
107
+ }
108
+ try {
109
+ assertUnitbobPath(ownedPath, `.unitbob/${item.branch}`);
110
+ }
111
+ catch (error) {
112
+ errors.push(`${label}: ${error.message}`);
113
+ }
114
+ const owner = seenPaths.get(ownedPath);
115
+ if (owner)
116
+ errors.push(`${label}: owned path ${ownedPath} is also owned by ${owner}`);
117
+ else
118
+ seenPaths.set(ownedPath, label);
119
+ }
120
+ }
121
+ for (const [branch, expected] of expectedByBranch) {
122
+ const items = plan.workers.filter((item) => item && typeof item === 'object' && !Array.isArray(item) && item.branch === branch);
123
+ if (expected.length > 0 && items.length === 0)
124
+ errors.push(`${branch}: no worker slice was planned`);
125
+ if (items.length > Math.min(workerCeiling, expected.length)) {
126
+ errors.push(`${branch}: worker count ${items.length} exceeds min(budget.workers, capability count)`);
127
+ }
128
+ const assigned = items.flatMap((item) => Array.isArray(item.capability_ids) ? item.capability_ids : []);
129
+ for (const id of expected) {
130
+ const count = assigned.filter((candidate) => candidate === id).length;
131
+ if (count === 0)
132
+ errors.push(`${branch}: assigned capability ${id} is missing from the plan`);
133
+ if (count > 1)
134
+ errors.push(`${branch}: assigned capability ${id} appears more than once`);
135
+ }
136
+ for (const id of assigned.filter((id) => !expected.includes(id)))
137
+ errors.push(`${branch}: capability ${id} was not assigned by the request`);
138
+ const sizes = items.map((item) => Array.isArray(item.planned_cases) ? item.planned_cases.length : 0).filter((size) => size > 0);
139
+ if (sizes.length > 1 && Math.max(...sizes) / Math.min(...sizes) > 1.5) {
140
+ errors.push(`${branch}: planned case slice ratio exceeds 1.5`);
141
+ }
142
+ }
143
+ return errors;
144
+ }
145
+ function assignmentIds(value) {
146
+ const assignment = value;
147
+ if (Array.isArray(assignment?.capabilities)) {
148
+ return assignment.capabilities.flatMap((entry) => idFrom(entry, 'capability_id'));
149
+ }
150
+ if (!Array.isArray(assignment?.blocks))
151
+ return [];
152
+ return assignment.blocks.flatMap((block) => {
153
+ const record = block;
154
+ return Array.isArray(record.interfaces)
155
+ ? record.interfaces.flatMap((entry) => idFrom(entry, 'interface_id'))
156
+ : [];
157
+ });
158
+ }
159
+ function idFrom(value, field) {
160
+ const id = value?.[field];
161
+ return isNonEmptyString(id) ? [id] : [];
162
+ }
163
+ function workerLabel(item, index) {
164
+ return item && isNonEmptyString(item.branch) && isNonEmptyString(item.worker_id)
165
+ ? `${item.branch}:${item.worker_id}`
166
+ : `workers[${index}]`;
167
+ }
168
+ function isNonEmptyString(value) {
169
+ return typeof value === 'string' && value.trim().length > 0;
170
+ }
@@ -0,0 +1,89 @@
1
+ import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { runProcess } from "../proc.js";
4
+ import { BEHAVIORAL_WORLD_PATH } from "../files/behavioral.js";
5
+ import { PROVISION_TIMEOUT_MS } from "./provision.js";
6
+ const PROBE_ROOT = '.unitbob/suite-build/world-probe';
7
+ export async function probeBehavioralWorld(projectRoot, deps = {
8
+ runCmd: (command, args, options) => runProcess(command, args, {
9
+ cwd: options.cwd,
10
+ env: options.env,
11
+ timeoutMs: PROVISION_TIMEOUT_MS,
12
+ }),
13
+ }) {
14
+ const probeRoot = join(projectRoot, PROBE_ROOT);
15
+ const feature = join(probeRoot, 'world.feature');
16
+ const steps = join(probeRoot, 'world_steps.rb');
17
+ mkdirSync(probeRoot, { recursive: true });
18
+ writeFileSync(feature, PROBE_FEATURE);
19
+ writeFileSync(steps, PROBE_STEPS);
20
+ try {
21
+ const result = await deps.runCmd('bundle', [
22
+ 'exec', 'cucumber', feature,
23
+ '--require', join(projectRoot, BEHAVIORAL_WORLD_PATH),
24
+ '--require', steps,
25
+ '--format', 'progress',
26
+ ], {
27
+ cwd: projectRoot,
28
+ env: {
29
+ ...process.env,
30
+ RAILS_ENV: 'test',
31
+ UNITBOB_REPO_ROOT: projectRoot,
32
+ BUNDLE_GEMFILE: join(projectRoot, '.unitbob', 'behavioral', 'Gemfile'),
33
+ },
34
+ });
35
+ if (result.code === 0)
36
+ return { status: 'ok' };
37
+ const detail = [result.stderr, result.stdout].find((text) => text.trim())?.trim() ?? `exit ${result.code}`;
38
+ return { status: 'fixable', message: `The connector-owned Ruby/Cucumber World probe failed: ${detail}` };
39
+ }
40
+ catch (error) {
41
+ return { status: 'fixable', message: `The connector-owned Ruby/Cucumber World probe could not run: ${String(error)}` };
42
+ }
43
+ finally {
44
+ rmSync(probeRoot, { recursive: true, force: true });
45
+ }
46
+ }
47
+ const PROBE_FEATURE = `Feature: Unitbob World profile
48
+ Scenario: request, assertion counter, mocks, and state mutation
49
+ Given the first World probe scenario mutates supported state
50
+ Then its integration assertion counter advances
51
+
52
+ Scenario: supported state is clean at the next scenario boundary
53
+ Then the second World probe scenario sees clean state and fresh mocks
54
+ `;
55
+ const PROBE_STEPS = `PROBE_VERSION = "unitbob-world-probe-#{Process.pid}"
56
+ PROBE_TIME_ZONE = Time.zone
57
+ PROBE_OTHER_TIME_ZONE = PROBE_TIME_ZONE&.name == 'UTC' ? 'Hawaii' : 'UTC'
58
+ PROBE_LOCALE = I18n.locale
59
+ PROBE_RECEIVER = Object.new
60
+
61
+ Given('the first World probe scenario mutates supported state') do
62
+ @unitbob_connection.execute("INSERT INTO schema_migrations (version) VALUES ('#{PROBE_VERSION}')")
63
+ Time.zone = PROBE_OTHER_TIME_ZONE
64
+ alternate_locale = (I18n.available_locales - [PROBE_LOCALE]).first
65
+ I18n.locale = alternate_locale if alternate_locale
66
+ expect(PROBE_RECEIVER).to receive(:unitbob_probe).once.and_return(:mocked)
67
+ expect(PROBE_RECEIVER.unitbob_probe).to eq(:mocked)
68
+ unitbob_get('/__unitbob_world_probe__')
69
+ unitbob_expect_status(:success)
70
+ unitbob_get('/__unitbob_world_probe_redirect__')
71
+ unitbob_expect_redirect_to('/__unitbob_world_probe_target__')
72
+ end
73
+
74
+ Then('its integration assertion counter advances') do
75
+ expect(unitbob_session.assertions).to be > 0
76
+ end
77
+
78
+ Then('the second World probe scenario sees clean state and fresh mocks') do
79
+ quoted = @unitbob_connection.quote(PROBE_VERSION)
80
+ count = @unitbob_connection.select_value("SELECT COUNT(*) FROM schema_migrations WHERE version = #{quoted}").to_i
81
+ expect(count).to eq(0)
82
+ expect(Time.zone).to eq(PROBE_TIME_ZONE)
83
+ expect(I18n.locale).to eq(PROBE_LOCALE)
84
+ expect(PROBE_RECEIVER).not_to respond_to(:unitbob_probe)
85
+ probe = double('fresh-world-probe')
86
+ expect(probe).to receive(:call).once
87
+ probe.call
88
+ end
89
+ `;
@@ -0,0 +1,28 @@
1
+ import { copyFileSync, existsSync, mkdirSync, readFileSync } from 'node:fs';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+ const AGENT_NAMES = ['suite-worker', 'suite-repair-worker', 'fact-finder'];
6
+ const bundledAgentsDir = fileURLToPath(new URL('../../plugin/codex/agents/', import.meta.url));
7
+ export function installCodexAgents(args, deps = { home: homedir(), stdout: process.stdout }) {
8
+ if (args.length > 0)
9
+ throw new Error('codex-install accepts no arguments.');
10
+ const targetDir = join(deps.home, '.codex', 'agents');
11
+ const files = AGENT_NAMES.map((name) => ({
12
+ source: join(bundledAgentsDir, `${name}.toml`),
13
+ target: join(targetDir, `${name}.toml`),
14
+ }));
15
+ for (const file of files) {
16
+ if (!existsSync(file.target))
17
+ continue;
18
+ if (readFileSync(file.target, 'utf8') === readFileSync(file.source, 'utf8'))
19
+ continue;
20
+ throw new Error(`Refusing to overwrite existing Codex agent definition: ${file.target}`);
21
+ }
22
+ mkdirSync(targetDir, { recursive: true });
23
+ for (const file of files) {
24
+ if (!existsSync(file.target))
25
+ copyFileSync(file.source, file.target);
26
+ }
27
+ deps.stdout.write(`Installed 3 Unitbob Codex agent definitions in ${targetDir}. Start a new Codex thread before running Unitbob.\n`);
28
+ }
@@ -129,6 +129,7 @@ function withReview(config, request, output) {
129
129
  ...qualityReview,
130
130
  candidate_digest: review.candidate_digest,
131
131
  },
132
+ ...(review.selection_review ? { selection_review: review.selection_review } : {}),
132
133
  known_defect_probe: review.known_defect_probe,
133
134
  known_defect_context: request.known_defect_context,
134
135
  candidate_run: review.candidate_run,
@@ -1,10 +1,12 @@
1
1
  import { clearSpending } from "../files/budget.js";
2
2
  import { materializeHelper } from "../files/guardrails.js";
3
+ import { materializeBehavioralWorld } from "../files/behavioral.js";
3
4
  import { recipeNameFor, writeSuiteBuildRequest, } from "../files/suiteBuild.js";
4
5
  import { bootCheck, SIGNAL_STRENGTH } from "../runner/bootcheck.js";
5
6
  import { anyStackPrecheck, detectBddRunner, detectStructuralRunner } from "../runner/precheck.js";
6
7
  import { selectRunnerEnvelope, withInstalledRunnerVersion } from "../runner/manifest.js";
7
8
  import { ensureRunner } from "../runner/provision.js";
9
+ import { probeBehavioralWorld } from "../runner/worldProbe.js";
8
10
  import { Wire } from "../wire.js";
9
11
  // The complete envelope for one branch, or null when this machine cannot
10
12
  // produce one: the server offered no combination this stack matches, or the
@@ -60,6 +62,7 @@ export async function suitePrepare(config, args = [], deps) {
60
62
  precheck: anyStackPrecheck,
61
63
  bootCheck: (projectRoot, runner) => bootCheck(projectRoot, runner),
62
64
  ensureRunner: deps?.ensureRunner ?? ensureRunner,
65
+ worldProbe: deps?.worldProbe ?? probeBehavioralWorld,
63
66
  runnerEnvelope: runnerEnvelopeFor,
64
67
  stdout: process.stdout,
65
68
  ...deps,
@@ -104,6 +107,14 @@ export async function suitePrepare(config, args = [], deps) {
104
107
  fixableNotices.push(` Behavioral runner "${runner}" not installed: ${prov.message ?? ''}${steps}`);
105
108
  continue;
106
109
  }
110
+ if (runner === 'cucumber') {
111
+ materializeBehavioralWorld(config.projectRoot);
112
+ const probe = await actual.worldProbe(config.projectRoot);
113
+ if (probe.status === 'fixable') {
114
+ fixableNotices.push(` Behavioral World profile is not ready (fixable): ${probe.message ?? 'probe failed'}`);
115
+ continue;
116
+ }
117
+ }
107
118
  }
108
119
  buildable.push({ packet, runner });
109
120
  }
@@ -18,10 +18,34 @@ export function collectBuildProblems(request, outputs, unreadable = []) {
18
18
  const add = (message) => { problems.push({ branch: output.suite_kind, message }); };
19
19
  checkRunnerManifest(branch, output, add);
20
20
  checkAssignment(branch, output, add);
21
+ if (output.suite_kind === 'behavioral')
22
+ checkDuplicateStepExpressions(output, add);
21
23
  }
22
24
  problems.push(...unansweredBranches(request, outputs, unreadable));
23
25
  return problems;
24
26
  }
27
+ function checkDuplicateStepExpressions(output, add) {
28
+ const suite = output.suite_file;
29
+ const definitions = new Map();
30
+ for (const file of Array.isArray(suite?.support_files) ? suite.support_files : []) {
31
+ if (typeof file.path !== 'string' || typeof file.content !== 'string')
32
+ continue;
33
+ for (const line of file.content.split('\n')) {
34
+ const call = line.match(/^\s*(?:Given|When|Then|And|But)\s*\(\s*(['"`])(.*?)\1/);
35
+ const decorator = line.match(/^\s*@(given|when|then)\s*\(\s*(['"])(.*?)\2/i);
36
+ const expression = call?.[2] ?? decorator?.[3];
37
+ if (!expression)
38
+ continue;
39
+ definitions.set(expression, [...(definitions.get(expression) ?? []), file.path]);
40
+ }
41
+ }
42
+ for (const [expression, paths] of definitions) {
43
+ const uniquePaths = [...new Set(paths)];
44
+ if (paths.length > 1) {
45
+ add(`duplicate step expression "${expression}" appears in ${uniquePaths.join(', ')} — step expressions share one branch-global namespace.`);
46
+ }
47
+ }
48
+ }
25
49
  // The branch that is not there at all. Every check above reads the answer and
26
50
  // asks whether it is well-formed; none of them can see a branch the answer never
27
51
  // mentions, because there is no entry to walk. So this one walks the request
@@ -0,0 +1,87 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import { checkpointPath, readWorkerPlan, requestDigest, validateWorkerPlanFiles, workerPlanDigest, } from "../files/workerPlan.js";
3
+ export async function validateWorkerCheckpoints(config, _args = [], deps = { stdout: process.stdout }) {
4
+ const planErrors = validateWorkerPlanFiles(config.projectRoot);
5
+ if (planErrors.length > 0)
6
+ throw new Error(`Cannot validate checkpoints for an invalid worker plan:\n- ${planErrors.join('\n- ')}`);
7
+ const plan = readWorkerPlan(config.projectRoot);
8
+ const expectedRequestDigest = requestDigest(config.projectRoot);
9
+ const expectedPlanDigest = workerPlanDigest(config.projectRoot);
10
+ const errors = [];
11
+ for (const item of plan.workers) {
12
+ const label = `${item.branch}:${item.worker_id}`;
13
+ const path = checkpointPath(config.projectRoot, item);
14
+ if (!existsSync(path)) {
15
+ errors.push(`${label}: checkpoint is missing at ${path}`);
16
+ continue;
17
+ }
18
+ let checkpoint;
19
+ try {
20
+ const parsed = JSON.parse(readFileSync(path, 'utf8'));
21
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
22
+ errors.push(`${label}: checkpoint must be an object`);
23
+ continue;
24
+ }
25
+ checkpoint = parsed;
26
+ }
27
+ catch (error) {
28
+ errors.push(`${label}: checkpoint is not valid JSON: ${error.message}`);
29
+ continue;
30
+ }
31
+ if (checkpoint.request_digest !== expectedRequestDigest)
32
+ errors.push(`${label}: request_digest is stale`);
33
+ if (checkpoint.plan_digest !== expectedPlanDigest)
34
+ errors.push(`${label}: plan_digest is stale`);
35
+ if (checkpoint.branch !== item.branch)
36
+ errors.push(`${label}: branch does not match its plan item`);
37
+ if (checkpoint.worker_id !== item.worker_id)
38
+ errors.push(`${label}: worker_id does not match its plan item`);
39
+ const completed = stringArray(checkpoint.completed_promises, `${label}: completed_promises`, errors);
40
+ const unresolved = stringArray(checkpoint.unresolved_promises, `${label}: unresolved_promises`, errors);
41
+ const accounted = [...completed, ...unresolved];
42
+ for (const promise of item.promises) {
43
+ const count = accounted.filter((candidate) => candidate === promise).length;
44
+ if (count !== 1)
45
+ errors.push(`${label}: promise ${promise} must appear exactly once across completed/unresolved promises`);
46
+ }
47
+ for (const promise of accounted.filter((candidate) => !item.promises.includes(candidate))) {
48
+ errors.push(`${label}: checkpoint names promise ${promise} outside its plan item`);
49
+ }
50
+ const writtenPaths = stringArray(checkpoint.written_paths, `${label}: written_paths`, errors);
51
+ for (const pathValue of writtenPaths.filter((candidate) => !item.owned_paths.includes(candidate))) {
52
+ errors.push(`${label}: written path ${pathValue} is not an owned path`);
53
+ }
54
+ validateCompactFacts(checkpoint.facts, label, errors);
55
+ stringArray(checkpoint.decisions, `${label}: decisions`, errors);
56
+ stringArray(checkpoint.known_problems, `${label}: known_problems`, errors);
57
+ }
58
+ if (errors.length > 0)
59
+ throw new Error(`Worker checkpoints are invalid:\n- ${errors.join('\n- ')}`);
60
+ const validWorkers = plan.workers.map((item) => `${item.branch}:${item.worker_id}`);
61
+ deps.stdout.write(`Worker checkpoints valid for ${validWorkers.join(', ')}.\n`);
62
+ return { valid_workers: validWorkers };
63
+ }
64
+ function stringArray(value, label, errors) {
65
+ if (!Array.isArray(value) || value.some((entry) => typeof entry !== 'string' || !entry.trim())) {
66
+ errors.push(`${label} must be an array of non-empty strings`);
67
+ return [];
68
+ }
69
+ return value;
70
+ }
71
+ function validateCompactFacts(value, label, errors) {
72
+ if (!Array.isArray(value)) {
73
+ errors.push(`${label}: facts must be an array`);
74
+ return;
75
+ }
76
+ for (const [index, entry] of value.entries()) {
77
+ const fact = entry;
78
+ if (!fact || typeof fact.fact !== 'string' || !fact.fact.trim())
79
+ errors.push(`${label}: facts[${index}].fact must be non-empty`);
80
+ if (!Array.isArray(fact?.source_refs) || fact.source_refs.some((ref) => typeof ref !== 'string' || !ref.trim())) {
81
+ errors.push(`${label}: facts[${index}].source_refs must be compact source references`);
82
+ }
83
+ if ('source' in (fact ?? {}) || 'transcript' in (fact ?? {}) || 'suite' in (fact ?? {})) {
84
+ errors.push(`${label}: facts[${index}] may not embed source, transcript, or suite copies`);
85
+ }
86
+ }
87
+ }
@@ -0,0 +1,9 @@
1
+ import { validateWorkerPlanFiles, workerPlanDigest } from "../files/workerPlan.js";
2
+ export async function validateWorkerPlan(config, _args = [], deps = { stdout: process.stdout }) {
3
+ const errors = validateWorkerPlanFiles(config.projectRoot);
4
+ if (errors.length > 0)
5
+ throw new Error(`Worker plan is invalid:\n- ${errors.join('\n- ')}`);
6
+ const planDigest = workerPlanDigest(config.projectRoot);
7
+ deps.stdout.write(`Worker plan valid (${planDigest}). Fan-out may start.\n`);
8
+ return { plan_digest: planDigest };
9
+ }
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "unitbob",
3
- "version": "0.3.6",
3
+ "version": "0.4.1",
4
4
  "description": "Unitbob connector — thin local hands for the Unitbob Rails brain. Owns no domain logic: it runs tools, relays bytes over the wire, and prints what the server returns.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "unitbob": "dist/bin.js"
8
8
  },
9
9
  "files": [
10
- "dist"
10
+ "dist",
11
+ "plugin/codex/agents"
11
12
  ],
12
13
  "engines": {
13
14
  "node": ">=18"
@@ -16,6 +17,7 @@
16
17
  "build": "rm -rf dist && tsc -p tsconfig.json && chmod +x dist/bin.js",
17
18
  "prepublishOnly": "npm run build",
18
19
  "test": "node --test test/*.test.ts",
20
+ "test:world-profiles": "node scripts/test-world-profiles.mjs",
19
21
  "check:release": "node scripts/check-release.mjs"
20
22
  },
21
23
  "publishConfig": {
@@ -0,0 +1,77 @@
1
+ name = "fact-finder"
2
+ description = "Answers one closed question about the analyzed project's source with exact, copyable facts. It does not decide what to test, write tests, or run anything."
3
+ model = "gpt-5.6-luna"
4
+ model_reasoning_effort = "low"
5
+ sandbox_mode = "read-only"
6
+ developer_instructions = '''
7
+ You look things up in the source of the project being analyzed, and you report
8
+ what you found. That is the whole job.
9
+
10
+ A suite worker writes tests it is not allowed to run — the test database is
11
+ shared and the coordinator owns every run — so it has to get the factory name,
12
+ the required fields and the shape of the answer right on the first try. A fact
13
+ it guesses becomes an assertion about something that does not exist, and the
14
+ repair round that follows costs more than every lookup you will ever do. You
15
+ are the alternative to that guess.
16
+
17
+ ## What a good answer looks like
18
+
19
+ **Quote, don't summarise.** A signature, the exact keyword arguments a factory
20
+ takes, the literal strings in an enum, the status a controller returns — copy the
21
+ lines and name the file and line they came from. "The factory accepts a status"
22
+ is not an answer; `factory :invoice do status { "draft" } end` at
23
+ `spec/factories/invoices.rb:4` is.
24
+
25
+ **Excerpts, never whole files.** Paste the lines that answer the question and the
26
+ few around them that make them readable. Nothing else. Your answer lands in the
27
+ worker's context, and the worker is the most expensive participant in the run —
28
+ one 78,000-character reply on a measured run cost about 20,000 tokens of the
29
+ context it was helping to fill. **This holds even when you are asked for a whole
30
+ file.** Send the relevant part and say what you left out; if a worker really
31
+ needs to read a file end to end, it can open it itself.
32
+
33
+ **Say what is not there.** "There is no factory for `Report`; the specs build it
34
+ with `Report.create!(project:, title:)` — see `spec/models/report_spec.rb:8`" is
35
+ a complete answer, and a far more useful one than a plausible factory name. Never
36
+ fill a gap with what a project of this shape usually has.
37
+
38
+ **Answer the question you were asked.** If it is unclear or turns out to rest on
39
+ a false premise, say so in a line and report what you did find. Do not widen it
40
+ into a survey of the area.
41
+
42
+ ## What is not yours
43
+
44
+ **Do not reason about how to write the test.** Which Scenario to write, whether a
45
+ surface is worth covering, whether a failure is a product defect or a broken
46
+ fixture — none of that is your call, and an opinion on it in your answer is worse
47
+ than silence, because the worker holds the context you do not. Report the facts;
48
+ the worker decides.
49
+
50
+ **Do not run the suite and do not boot the application.** There is one test
51
+ database and the coordinator alone runs against it. A run started from here lands
52
+ on top of whatever a worker was doing, and cleaning up after yourself does not
53
+ undo it — on a measured run one lookup agent ran the suite and reported that it
54
+ had tidied the files away afterwards. `Bash` stays open because reading and
55
+ searching need it, so this rule is yours to keep rather than something the tools
56
+ enforce: `grep`, `find`, `cat`, `git log`, reading a schema — yes; `rspec`,
57
+ `cucumber`, `pytest`, `rails console`, `rails server`, a migration, a seed task,
58
+ anything that installs — no.
59
+
60
+ **Do not write to the project.** You have no `Write`, `Edit`, or `NotebookEdit`,
61
+ and there is nothing you need them for.
62
+
63
+ ## Your budget is thirty turns
64
+
65
+ Reading is fast and cheap; deciding what to read is neither. Open the files you
66
+ were pointed at, `grep` for what you actually need, and answer.
67
+
68
+ If you hit the ceiling anyway, what the worker gets is what you have said so far
69
+ — so report each fact as you confirm it rather than saving everything for a
70
+ summary at the end. A partial answer that names the factory and admits it never
71
+ found the enum is usable. Thirty turns of searching followed by nothing is not.
72
+ '''
73
+
74
+ [features.rollout_budget]
75
+ enabled = true
76
+ limit_tokens = 20000
77
+ reminder_at_remaining_tokens = [4000]
@@ -0,0 +1,26 @@
1
+ name = "suite-repair-worker"
2
+ description = "Completes one bounded Unitbob failure packet using the prior checkpoint and owned files, without widening scope or running the suite."
3
+ model = "gpt-5.6-terra"
4
+ model_reasoning_effort = "medium"
5
+ developer_instructions = '''
6
+ You receive one failure packet: one validated plan item, its checkpoint, owned
7
+ paths, and only related failures or stack traces. This is your complete scope.
8
+
9
+ Complete `unresolved_promises` first while preserving every completed file and
10
+ decision. Then repair only harness problems whose stack points into this slice's
11
+ owned files. Do not expand capabilities, promises, planned cases, or paths. Do
12
+ not edit host-owned shared files, the connector-owned harness, application
13
+ production code, or another slice.
14
+
15
+ Update the same checkpoint as promises complete. Keep facts compact and
16
+ source-referenced. Never run the suite or boot the application; the coordinator
17
+ owns the single final run. Do not delegate a second repair, continue another
18
+ agent, or request another repair round. If work remains at the turn ceiling,
19
+ record it in `unresolved_promises` so the coordinator can produce an honest
20
+ branch `build_error`.
21
+ '''
22
+
23
+ [features.rollout_budget]
24
+ enabled = true
25
+ limit_tokens = 15000
26
+ reminder_at_remaining_tokens = [3000]
@@ -0,0 +1,43 @@
1
+ name = "suite-worker"
2
+ description = "Implements exactly one validated Unitbob worker-plan item into owned suite files and a compact checkpoint. It never runs or globally validates the suite."
3
+ model = "gpt-5.6-terra"
4
+ model_reasoning_effort = "medium"
5
+ developer_instructions = '''
6
+ You receive exactly one worker-plan item and the request paths it references.
7
+ That item is your complete scope. Do not add capabilities, promises, examples,
8
+ or scenarios after fan-out.
9
+
10
+ On the initial incarnation, create the checkpoint before reading application
11
+ source, at the path prescribed by the workflow. Copy the exact request and plan
12
+ digests, your branch and worker id, put every assigned promise in
13
+ `unresolved_promises`, and start with empty `completed_promises`, `written_paths`,
14
+ `facts`, and `decisions`. On an explicitly approved fresh incarnation after a
15
+ native budget stop, preserve the supplied checkpoint and completed files and
16
+ continue only its `unresolved_promises`; never initialize that checkpoint again.
17
+ Update the checkpoint after every completed promise. Also keep `known_problems` as a compact
18
+ array of precise unresolved harness problems (empty when none are known). Facts are short statements with
19
+ source references; never embed source files, suite copies, or transcript.
20
+
21
+ Read only the initial `source_paths` and dependencies needed for the finite
22
+ planned cases. Ask closed questions with the files to look in. For a closed
23
+ missing fact, use the named `fact-finder`
24
+ agent and respect the plan's lookup limit. A lookup may confirm implementation
25
+ facts but may not expand the plan.
26
+
27
+ Write only the plan item's `owned_paths` and its checkpoint. Never edit the
28
+ connector-owned harness, another worker's file, the user's own tests, manifests,
29
+ or lockfiles. Use the connector-owned helper or World as an interface; do not
30
+ copy it into an owned file.
31
+
32
+ Never run the suite, boot the application, or perform branch-global duplicate,
33
+ marker, metadata, or surface validation. You may make one final read of your
34
+ owned files before handoff. Do not create temporary self-validation scripts or
35
+ loop over repeated rereads. If the turn ceiling arrives, leave partial files and
36
+ an accurate checkpoint; the coordinator will rotate unresolved work into one
37
+ fresh repair task.
38
+ '''
39
+
40
+ [features.rollout_budget]
41
+ enabled = true
42
+ limit_tokens = 40000
43
+ reminder_at_remaining_tokens = [8000, 4000]