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 +19 -3
- package/dist/cli.js +16 -0
- package/dist/files/behavioral.js +92 -1
- package/dist/files/suiteBuild.js +59 -0
- package/dist/files/workerPlan.js +170 -0
- package/dist/runner/worldProbe.js +89 -0
- package/dist/verbs/codexInstall.js +28 -0
- package/dist/verbs/putSuiteBuild.js +1 -0
- package/dist/verbs/suitePrepare.js +11 -0
- package/dist/verbs/validateBuild.js +24 -0
- package/dist/verbs/validateWorkerCheckpoints.js +87 -0
- package/dist/verbs/validateWorkerPlan.js +9 -0
- package/package.json +4 -2
- package/plugin/codex/agents/fact-finder.toml +77 -0
- package/plugin/codex/agents/suite-repair-worker.toml +26 -0
- package/plugin/codex/agents/suite-worker.toml +43 -0
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
|
-
**
|
|
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
|
-
|
|
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
|
-
|
|
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':
|
package/dist/files/behavioral.js
CHANGED
|
@@ -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([
|
|
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))
|
package/dist/files/suiteBuild.js
CHANGED
|
@@ -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
|
+
"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]
|