@cat-factory/executor-harness 1.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +143 -0
- package/dist/agent-runner.js +389 -0
- package/dist/agent.js +810 -0
- package/dist/blueprint.js +367 -0
- package/dist/bootstrap.js +99 -0
- package/dist/ci-fixer.js +46 -0
- package/dist/coding-agent.js +285 -0
- package/dist/conflict-resolver.js +138 -0
- package/dist/embed.js +8 -0
- package/dist/explore.js +74 -0
- package/dist/failure.js +47 -0
- package/dist/fixer.js +44 -0
- package/dist/follow-ups.js +103 -0
- package/dist/frontend-infra.js +283 -0
- package/dist/fs-utils.js +11 -0
- package/dist/git.js +778 -0
- package/dist/job.js +409 -0
- package/dist/logger.js +27 -0
- package/dist/merger.js +135 -0
- package/dist/on-call.js +126 -0
- package/dist/pi-workspace.js +237 -0
- package/dist/pi.js +971 -0
- package/dist/process.js +25 -0
- package/dist/redact.js +109 -0
- package/dist/runner.js +228 -0
- package/dist/server.js +135 -0
- package/dist/spec.js +754 -0
- package/dist/structured-output.js +431 -0
- package/dist/tester.js +191 -0
- package/package.json +35 -0
- package/src/agent-runner.ts +484 -0
- package/src/agent.ts +948 -0
- package/src/coding-agent.ts +393 -0
- package/src/embed.ts +32 -0
- package/src/failure.ts +73 -0
- package/src/follow-ups.ts +106 -0
- package/src/frontend-infra.ts +340 -0
- package/src/fs-utils.ts +11 -0
- package/src/git.ts +955 -0
- package/src/job.ts +766 -0
- package/src/logger.ts +45 -0
- package/src/pi-workspace.ts +348 -0
- package/src/pi.ts +1236 -0
- package/src/process.ts +33 -0
- package/src/redact.ts +109 -0
- package/src/runner.ts +384 -0
- package/src/server.ts +153 -0
- package/src/structured-output.ts +524 -0
package/dist/spec.js
ADDED
|
@@ -0,0 +1,754 @@
|
|
|
1
|
+
import { access, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join, sep } from 'node:path';
|
|
3
|
+
import { cloneExistingBranch, cloneRepo, commitAll, createBranch, pushBranch, remoteBranchExists, } from './git.js';
|
|
4
|
+
import { agentNeverActed, agentOutputTail, NEVER_ACTED_CAUSE, runAgentInWorkspace, unusableFinalAnswerCause, withWorkspace, } from './pi-workspace.js';
|
|
5
|
+
import { diagnosticsSuffix, resolveStructuredOutput, } from './structured-output.js';
|
|
6
|
+
import { log } from './logger.js';
|
|
7
|
+
/** Compact description of the requirements-document shape, fed to the JSON repair call. */
|
|
8
|
+
const SPEC_SHAPE_HINT = 'Expected a requirements document with a two-level taxonomy — module (domain) → ' +
|
|
9
|
+
'group (feature) — where each group carries BOTH its requirements and the domain ' +
|
|
10
|
+
'rules scoped to it: {"service": string, "summary": string, "modules": [{"name": ' +
|
|
11
|
+
'string, "summary": string, "groups": [{"name": string, "summary": string, ' +
|
|
12
|
+
'"requirements": [{"id": string, "title": string, "statement": string, "kind": ' +
|
|
13
|
+
'string, "priority": string, "sourceBlockIds": string[], "acceptance": [{"given": ' +
|
|
14
|
+
'string, "when": string, "outcome": string}]}], "rules": [{"id": string, "rule": ' +
|
|
15
|
+
'string, "rationale": string, "sourceBlockIds": string[]}]}]}]}.';
|
|
16
|
+
// Runs one "spec" job end to end. The spec-writer agent gets the implementation
|
|
17
|
+
// branch (created from base when it does not exist yet — this step runs BEFORE the
|
|
18
|
+
// coder, seeding the branch the coder then resumes), reads any existing spec, and
|
|
19
|
+
// (re)generates the unified PRESCRIPTIVE specification document for the service from
|
|
20
|
+
// the combined task context. The harness deterministically SHARDS that document into
|
|
21
|
+
// the in-repo `spec/` folder — a tiny `service.json`, an `overview.md` index, and one
|
|
22
|
+
// canonical `modules/<module>/<group>.json` (+ `<group>.md`) per feature group, plus
|
|
23
|
+
// the Gherkin `features/<module>/<group>.feature` files — then commits the result onto
|
|
24
|
+
// the branch. Sharding is the whole point: a single monolithic `spec.json` made every
|
|
25
|
+
// concurrent task branch conflict on a whole-file rewrite. The document is also
|
|
26
|
+
// returned to the Worker so it can persist + surface it.
|
|
27
|
+
//
|
|
28
|
+
// Mirrors handleBlueprint's secret handling and watchdog wiring: the per-job
|
|
29
|
+
// GitHub + proxy tokens arrive in the request body and live only for the job's
|
|
30
|
+
// duration in an ephemeral workspace; `opts` carry the watchdog signal and the
|
|
31
|
+
// progress callback.
|
|
32
|
+
// The folder + file layout, kept in lockstep with @cat-factory/contracts
|
|
33
|
+
// (SPEC_DIR / SPEC_SERVICE_PATH / SPEC_MODULES_DIR / …). Duplicated here because the
|
|
34
|
+
// harness image is deliberately self-contained (no @cat-factory/contracts dep).
|
|
35
|
+
const SPEC_DIR = 'spec';
|
|
36
|
+
const SPEC_SERVICE_PATH = `${SPEC_DIR}/service.json`;
|
|
37
|
+
const SPEC_OVERVIEW_PATH = `${SPEC_DIR}/overview.md`;
|
|
38
|
+
const SPEC_MODULES_DIR = `${SPEC_DIR}/modules`;
|
|
39
|
+
const SPEC_FEATURES_DIR = `${SPEC_DIR}/features`;
|
|
40
|
+
// Monolithic-layout files from before the spec was sharded. They are never written any
|
|
41
|
+
// more, so a migrated repo would otherwise carry a stale, never-updated spec.json /
|
|
42
|
+
// rules.md / version.json forever. Deleted on every write (pre-1.0 no-compat policy:
|
|
43
|
+
// break old shapes, don't migrate them). The old FLAT `features/*.feature` files are
|
|
44
|
+
// pruned separately — see listLegacyFeatureFiles.
|
|
45
|
+
const LEGACY_SPEC_FILES = [
|
|
46
|
+
`${SPEC_DIR}/spec.json`,
|
|
47
|
+
`${SPEC_DIR}/rules.md`,
|
|
48
|
+
`${SPEC_DIR}/version.json`,
|
|
49
|
+
];
|
|
50
|
+
// Coercion limits so a committed doc can never balloon past what the schema accepts.
|
|
51
|
+
const MAX_MODULES = 40;
|
|
52
|
+
const MAX_GROUPS_PER_MODULE = 40;
|
|
53
|
+
const MAX_REQUIREMENTS_PER_GROUP = 60;
|
|
54
|
+
const MAX_ACCEPTANCE = 20;
|
|
55
|
+
const MAX_RULES_PER_GROUP = 100;
|
|
56
|
+
const PRIORITIES = ['must', 'should', 'could'];
|
|
57
|
+
const KINDS = ['functional', 'nonfunctional', 'constraint'];
|
|
58
|
+
function asString(value) {
|
|
59
|
+
return typeof value === 'string' && value.trim() !== '' ? value.trim() : undefined;
|
|
60
|
+
}
|
|
61
|
+
function coerceStringList(value, max) {
|
|
62
|
+
if (!Array.isArray(value))
|
|
63
|
+
return [];
|
|
64
|
+
const out = [];
|
|
65
|
+
for (const raw of value) {
|
|
66
|
+
const s = asString(raw);
|
|
67
|
+
if (s)
|
|
68
|
+
out.push(s);
|
|
69
|
+
if (out.length >= max)
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
function slugify(name, fallback) {
|
|
75
|
+
const slug = name
|
|
76
|
+
.toLowerCase()
|
|
77
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
78
|
+
.replace(/^-+|-+$/g, '');
|
|
79
|
+
return slug || fallback;
|
|
80
|
+
}
|
|
81
|
+
// A fallback acceptance id is derived deterministically from its owning
|
|
82
|
+
// requirement id + position (`<reqId>-ac-<n>`), so the SAME doc always renders the
|
|
83
|
+
// SAME bytes — no module-global counter that leaks state across jobs (which would
|
|
84
|
+
// make an unchanged doc hash differently on a long-lived harness process and force a
|
|
85
|
+
// spurious version bump + commit). A model-supplied id still wins.
|
|
86
|
+
function coerceAcceptance(value, reqId, index) {
|
|
87
|
+
if (typeof value !== 'object' || value === null)
|
|
88
|
+
return null;
|
|
89
|
+
const o = value;
|
|
90
|
+
const given = typeof o.given === 'string' ? o.given.trim() : '';
|
|
91
|
+
const when = typeof o.when === 'string' ? o.when.trim() : '';
|
|
92
|
+
// Accept either `outcome` (canonical) or a model-emitted `then` as the Then clause.
|
|
93
|
+
const outcome = typeof o.outcome === 'string'
|
|
94
|
+
? o.outcome.trim()
|
|
95
|
+
: typeof o.then === 'string'
|
|
96
|
+
? o.then.trim()
|
|
97
|
+
: '';
|
|
98
|
+
// A criterion with no Then clause is not testable; drop it.
|
|
99
|
+
if (outcome === '')
|
|
100
|
+
return null;
|
|
101
|
+
return {
|
|
102
|
+
id: asString(o.id) ?? `${reqId}-ac-${index + 1}`,
|
|
103
|
+
given,
|
|
104
|
+
when,
|
|
105
|
+
outcome,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
function coerceRequirement(value, index) {
|
|
109
|
+
if (typeof value !== 'object' || value === null)
|
|
110
|
+
return null;
|
|
111
|
+
const o = value;
|
|
112
|
+
const title = asString(o.title);
|
|
113
|
+
const statement = asString(o.statement) ?? asString(o.title);
|
|
114
|
+
if (!statement)
|
|
115
|
+
return null;
|
|
116
|
+
const priority = PRIORITIES.includes(o.priority)
|
|
117
|
+
? o.priority
|
|
118
|
+
: 'should';
|
|
119
|
+
const kind = KINDS.includes(o.kind)
|
|
120
|
+
? o.kind
|
|
121
|
+
: 'functional';
|
|
122
|
+
const id = asString(o.id) ?? `req-${slugify(title ?? statement.slice(0, 40), `${index + 1}`)}`;
|
|
123
|
+
const acceptance = (Array.isArray(o.acceptance) ? o.acceptance : [])
|
|
124
|
+
.map((a, i) => coerceAcceptance(a, id, i))
|
|
125
|
+
.filter((a) => a !== null)
|
|
126
|
+
.slice(0, MAX_ACCEPTANCE);
|
|
127
|
+
return {
|
|
128
|
+
id,
|
|
129
|
+
title: title ?? statement.slice(0, 120),
|
|
130
|
+
statement,
|
|
131
|
+
kind,
|
|
132
|
+
priority,
|
|
133
|
+
sourceBlockIds: coerceStringList(o.sourceBlockIds, 40),
|
|
134
|
+
acceptance,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
// A fallback rule id is derived from the rule text (`rule-<slug>`), NOT its position,
|
|
138
|
+
// so reordering a group's rules never changes their ids and the group file stays
|
|
139
|
+
// byte-stable. A model-supplied id still wins.
|
|
140
|
+
function coerceRule(value, index) {
|
|
141
|
+
if (typeof value !== 'object' || value === null)
|
|
142
|
+
return null;
|
|
143
|
+
const o = value;
|
|
144
|
+
const rule = asString(o.rule);
|
|
145
|
+
if (!rule)
|
|
146
|
+
return null;
|
|
147
|
+
return {
|
|
148
|
+
id: asString(o.id) ?? `rule-${slugify(rule.slice(0, 60), `${index + 1}`)}`,
|
|
149
|
+
rule,
|
|
150
|
+
rationale: asString(o.rationale) ?? '',
|
|
151
|
+
sourceBlockIds: coerceStringList(o.sourceBlockIds, 40),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
function coerceGroup(value) {
|
|
155
|
+
if (typeof value !== 'object' || value === null)
|
|
156
|
+
return null;
|
|
157
|
+
const o = value;
|
|
158
|
+
const name = asString(o.name);
|
|
159
|
+
if (!name)
|
|
160
|
+
return null;
|
|
161
|
+
const requirements = (Array.isArray(o.requirements) ? o.requirements : [])
|
|
162
|
+
.map((r, i) => coerceRequirement(r, i))
|
|
163
|
+
.filter((r) => r !== null)
|
|
164
|
+
.slice(0, MAX_REQUIREMENTS_PER_GROUP);
|
|
165
|
+
const rules = (Array.isArray(o.rules) ? o.rules : [])
|
|
166
|
+
.map((r, i) => coerceRule(r, i))
|
|
167
|
+
.filter((r) => r !== null)
|
|
168
|
+
.slice(0, MAX_RULES_PER_GROUP);
|
|
169
|
+
return { name, summary: asString(o.summary) ?? '', requirements, rules };
|
|
170
|
+
}
|
|
171
|
+
function coerceModule(value) {
|
|
172
|
+
if (typeof value !== 'object' || value === null)
|
|
173
|
+
return null;
|
|
174
|
+
const o = value;
|
|
175
|
+
const name = asString(o.name);
|
|
176
|
+
if (!name)
|
|
177
|
+
return null;
|
|
178
|
+
const groups = (Array.isArray(o.groups) ? o.groups : [])
|
|
179
|
+
.map(coerceGroup)
|
|
180
|
+
.filter((g) => g !== null)
|
|
181
|
+
.slice(0, MAX_GROUPS_PER_MODULE);
|
|
182
|
+
return { name, summary: asString(o.summary) ?? '', groups };
|
|
183
|
+
}
|
|
184
|
+
/** Return `id` if unseen, else the first `id-2` / `id-3` … not already in `used`. */
|
|
185
|
+
function uniqueId(id, used) {
|
|
186
|
+
if (!used.has(id)) {
|
|
187
|
+
used.add(id);
|
|
188
|
+
return id;
|
|
189
|
+
}
|
|
190
|
+
let n = 2;
|
|
191
|
+
while (used.has(`${id}-${n}`))
|
|
192
|
+
n++;
|
|
193
|
+
const unique = `${id}-${n}`;
|
|
194
|
+
used.add(unique);
|
|
195
|
+
return unique;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Force every requirement / acceptance / rule id in the doc to be globally unique
|
|
199
|
+
* (in place), suffixing `-2`, `-3` … on collision — the same scheme the feature-file
|
|
200
|
+
* slugs already use. Ids double as Gherkin scenario / test names and provenance
|
|
201
|
+
* anchors, so duplicates (two requirements sharing a title, a model echoing an id)
|
|
202
|
+
* would otherwise silently alias. Deterministic: same tree → same ids.
|
|
203
|
+
*/
|
|
204
|
+
function dedupeIds(doc) {
|
|
205
|
+
const used = new Set();
|
|
206
|
+
// Traverse in the SAME name-sorted order the renderer shards in, so a cross-group id
|
|
207
|
+
// collision's `-N` suffix lands on a deterministic group regardless of the order the
|
|
208
|
+
// agent happened to emit modules/groups in. Iterating the raw arrays would let a
|
|
209
|
+
// reordered-but-identical doc bake a different suffix into the affected group shards,
|
|
210
|
+
// reintroducing exactly the merge churn sharding exists to kill.
|
|
211
|
+
const modules = [...doc.modules].sort((a, b) => a.name.localeCompare(b.name));
|
|
212
|
+
for (const m of modules) {
|
|
213
|
+
const groups = [...m.groups].sort((a, b) => a.name.localeCompare(b.name));
|
|
214
|
+
for (const g of groups) {
|
|
215
|
+
for (const r of g.requirements) {
|
|
216
|
+
r.id = uniqueId(r.id, used);
|
|
217
|
+
for (const a of r.acceptance)
|
|
218
|
+
a.id = uniqueId(a.id, used);
|
|
219
|
+
}
|
|
220
|
+
for (const rule of g.rules)
|
|
221
|
+
rule.id = uniqueId(rule.id, used);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* Coerce an agent's parsed JSON into a well-formed {@link SpecDocTree},
|
|
227
|
+
* dropping anything malformed. Returns null when no usable service name remains.
|
|
228
|
+
* Tolerates either a bare doc object or `{ requirements: {...} }`. The Worker
|
|
229
|
+
* re-validates the returned doc against the strict Valibot schema before use.
|
|
230
|
+
*/
|
|
231
|
+
export function coerceSpecDoc(parsed, fallbackName) {
|
|
232
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
233
|
+
return null;
|
|
234
|
+
const root = parsed;
|
|
235
|
+
const obj = typeof root.requirements === 'object' &&
|
|
236
|
+
root.requirements !== null &&
|
|
237
|
+
!Array.isArray(root.requirements)
|
|
238
|
+
? root.requirements
|
|
239
|
+
: root;
|
|
240
|
+
const service = asString(obj.service) ?? asString(fallbackName);
|
|
241
|
+
if (!service)
|
|
242
|
+
return null;
|
|
243
|
+
const modules = (Array.isArray(obj.modules) ? obj.modules : [])
|
|
244
|
+
.map(coerceModule)
|
|
245
|
+
.filter((m) => m !== null)
|
|
246
|
+
.slice(0, MAX_MODULES);
|
|
247
|
+
// Lenient safety net: a model that ignored the taxonomy and returned flat top-level
|
|
248
|
+
// `groups` (or whose `modules` were all malformed, so nothing survived coercion) gets
|
|
249
|
+
// those groups wrapped into one module named after the service, so its work is not
|
|
250
|
+
// dropped. Keyed on the COERCED result, not the raw array length, so a non-empty but
|
|
251
|
+
// junk `modules` alongside real `groups` still rescues the groups. The strict Valibot
|
|
252
|
+
// schema (modules-only) and the steering prompt make this rare; it is NOT a compat path
|
|
253
|
+
// for old on-disk specs.
|
|
254
|
+
if (modules.length === 0 && Array.isArray(obj.groups) && obj.groups.length > 0) {
|
|
255
|
+
const wrapped = coerceModule({ name: service, summary: '', groups: obj.groups });
|
|
256
|
+
if (wrapped)
|
|
257
|
+
modules.push(wrapped);
|
|
258
|
+
}
|
|
259
|
+
const doc = { service, summary: asString(obj.summary) ?? '', modules };
|
|
260
|
+
dedupeIds(doc);
|
|
261
|
+
return doc;
|
|
262
|
+
}
|
|
263
|
+
/** The exact canonical JSON bytes written to a per-group shard. */
|
|
264
|
+
function canonicalJson(value) {
|
|
265
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Assign each item a stable, collision-free, filesystem-safe slug. Items are processed
|
|
269
|
+
* name-sorted so collision suffixes (`-2`, `-3`, …) are deterministic regardless of the
|
|
270
|
+
* order the agent emitted them — the same set of names always yields the same slugs.
|
|
271
|
+
*/
|
|
272
|
+
function assignSlugs(items, nameOf) {
|
|
273
|
+
const used = new Set();
|
|
274
|
+
const out = new Map();
|
|
275
|
+
const sorted = [...items].sort((a, b) => nameOf(a).localeCompare(nameOf(b)));
|
|
276
|
+
for (const item of sorted) {
|
|
277
|
+
const base = slugify(nameOf(item), 'item');
|
|
278
|
+
let slug = base;
|
|
279
|
+
let n = 2;
|
|
280
|
+
while (used.has(slug))
|
|
281
|
+
slug = `${base}-${n++}`;
|
|
282
|
+
used.add(slug);
|
|
283
|
+
out.set(item, slug);
|
|
284
|
+
}
|
|
285
|
+
return out;
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* Walk the doc into name-sorted modules with their resolved slugs and their name-sorted
|
|
289
|
+
* groups. The single source of slug/sort truth for every renderer — computed once here
|
|
290
|
+
* rather than re-derived (and re-filtered per module) at each call site.
|
|
291
|
+
*/
|
|
292
|
+
function walkModules(doc) {
|
|
293
|
+
const moduleSlugs = assignSlugs(doc.modules, (m) => m.name);
|
|
294
|
+
const modules = [...doc.modules].sort((a, b) => a.name.localeCompare(b.name));
|
|
295
|
+
return modules.map((module) => {
|
|
296
|
+
const groupSlugs = assignSlugs(module.groups, (g) => g.name);
|
|
297
|
+
const groups = [...module.groups]
|
|
298
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
299
|
+
.map((group) => ({ group, groupSlug: groupSlugs.get(group) }));
|
|
300
|
+
return { module, moduleSlug: moduleSlugs.get(module), groups };
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
/** Flatten {@link walkModules} into one ref per group (for the feature-file render). */
|
|
304
|
+
function walkGroups(doc) {
|
|
305
|
+
const out = [];
|
|
306
|
+
for (const { module, moduleSlug, groups } of walkModules(doc)) {
|
|
307
|
+
for (const { group, groupSlug } of groups) {
|
|
308
|
+
out.push({ module, group, moduleSlug, groupSlug });
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return out;
|
|
312
|
+
}
|
|
313
|
+
/** The human-readable render of one feature group (its requirements + scoped rules). */
|
|
314
|
+
function renderGroupMarkdown(module, group) {
|
|
315
|
+
const lines = [`# ${module.name} — ${group.name}`, ''];
|
|
316
|
+
if (group.summary)
|
|
317
|
+
lines.push(group.summary, '');
|
|
318
|
+
if (group.requirements.length === 0) {
|
|
319
|
+
lines.push('_No requirements captured yet._', '');
|
|
320
|
+
}
|
|
321
|
+
else {
|
|
322
|
+
lines.push('## Requirements', '');
|
|
323
|
+
for (const r of group.requirements) {
|
|
324
|
+
lines.push(`- **${r.title}** _(${r.priority}, ${r.kind})_ — ${r.statement}`);
|
|
325
|
+
for (const a of r.acceptance) {
|
|
326
|
+
lines.push(` - _Given_ ${a.given} _When_ ${a.when} _Then_ ${a.outcome}`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
lines.push('');
|
|
330
|
+
}
|
|
331
|
+
if (group.rules.length > 0) {
|
|
332
|
+
lines.push('## Domain rules', '');
|
|
333
|
+
for (const r of group.rules) {
|
|
334
|
+
lines.push(`- **${r.rule}**`);
|
|
335
|
+
if (r.rationale)
|
|
336
|
+
lines.push(` - _Why:_ ${r.rationale}`);
|
|
337
|
+
}
|
|
338
|
+
lines.push('');
|
|
339
|
+
}
|
|
340
|
+
return `${lines.join('\n').trimEnd()}\n`;
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Deterministically SHARD a spec doc into the in-repo artifact files: a tiny
|
|
344
|
+
* `service.json`, an `overview.md` index (modules → features with links), and per
|
|
345
|
+
* feature group a canonical `modules/<module>/<group>.json` + a human `<group>.md`
|
|
346
|
+
* (plus a `_module.json` per module). Pure: same doc → same bytes, and a group file's
|
|
347
|
+
* bytes depend only on that group — so two task branches editing different features
|
|
348
|
+
* never touch the same file.
|
|
349
|
+
*/
|
|
350
|
+
export function renderSpecFiles(doc) {
|
|
351
|
+
const files = [];
|
|
352
|
+
files.push({
|
|
353
|
+
path: SPEC_SERVICE_PATH,
|
|
354
|
+
content: canonicalJson({ service: doc.service, summary: doc.summary }),
|
|
355
|
+
});
|
|
356
|
+
const moduleRefs = walkModules(doc);
|
|
357
|
+
// overview.md — the index agents read first (names + links only, never the bodies).
|
|
358
|
+
const overview = [`# ${doc.service} — Specification`, ''];
|
|
359
|
+
overview.push('> Prescriptive spec for this service (what MUST be true). This index lists the');
|
|
360
|
+
overview.push('> modules and their features; open `modules/<module>/<feature>.md` for detail');
|
|
361
|
+
overview.push('> and `features/<module>/*.feature` for the acceptance scenarios to satisfy.');
|
|
362
|
+
overview.push('');
|
|
363
|
+
if (doc.summary)
|
|
364
|
+
overview.push(doc.summary, '');
|
|
365
|
+
if (moduleRefs.length === 0) {
|
|
366
|
+
overview.push('_No requirements captured yet._');
|
|
367
|
+
}
|
|
368
|
+
else {
|
|
369
|
+
for (const { module, moduleSlug, groups } of moduleRefs) {
|
|
370
|
+
overview.push(`## ${module.name}`);
|
|
371
|
+
if (module.summary)
|
|
372
|
+
overview.push('', module.summary);
|
|
373
|
+
overview.push('');
|
|
374
|
+
if (groups.length === 0) {
|
|
375
|
+
overview.push('_No features captured yet._', '');
|
|
376
|
+
continue;
|
|
377
|
+
}
|
|
378
|
+
for (const { group, groupSlug } of groups) {
|
|
379
|
+
const detail = group.summary ? ` — ${group.summary}` : '';
|
|
380
|
+
overview.push(`- [${group.name}](modules/${moduleSlug}/${groupSlug}.md)${detail}`);
|
|
381
|
+
}
|
|
382
|
+
overview.push('');
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
files.push({ path: SPEC_OVERVIEW_PATH, content: `${overview.join('\n').trimEnd()}\n` });
|
|
386
|
+
for (const { module, moduleSlug } of moduleRefs) {
|
|
387
|
+
files.push({
|
|
388
|
+
path: `${SPEC_MODULES_DIR}/${moduleSlug}/_module.json`,
|
|
389
|
+
content: canonicalJson({ name: module.name, summary: module.summary }),
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
for (const { module, moduleSlug, groups } of moduleRefs) {
|
|
393
|
+
for (const { group, groupSlug } of groups) {
|
|
394
|
+
files.push({
|
|
395
|
+
path: `${SPEC_MODULES_DIR}/${moduleSlug}/${groupSlug}.json`,
|
|
396
|
+
content: canonicalJson(group),
|
|
397
|
+
});
|
|
398
|
+
files.push({
|
|
399
|
+
path: `${SPEC_MODULES_DIR}/${moduleSlug}/${groupSlug}.md`,
|
|
400
|
+
content: renderGroupMarkdown(module, group),
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return files;
|
|
405
|
+
}
|
|
406
|
+
/**
|
|
407
|
+
* Pass-1 (mechanical) Gherkin render: one `features/<module>/<group>.feature` file per
|
|
408
|
+
* feature group, one `Scenario` per acceptance criterion. Deterministic (same doc →
|
|
409
|
+
* same bytes); a `must` requirement's scenarios are tagged `@must`. The `acceptance`
|
|
410
|
+
* agent later polishes these (pass 2). Groups with no acceptance criteria produce no
|
|
411
|
+
* feature file.
|
|
412
|
+
*/
|
|
413
|
+
export function renderFeatureFiles(doc) {
|
|
414
|
+
const files = [];
|
|
415
|
+
for (const { module, group, moduleSlug, groupSlug } of walkGroups(doc)) {
|
|
416
|
+
const scenarios = [];
|
|
417
|
+
for (const r of group.requirements) {
|
|
418
|
+
for (let i = 0; i < r.acceptance.length; i++) {
|
|
419
|
+
const a = r.acceptance[i];
|
|
420
|
+
const name = r.acceptance.length > 1 ? `${r.title} (#${i + 1})` : r.title;
|
|
421
|
+
if (r.priority === 'must')
|
|
422
|
+
scenarios.push(' @must');
|
|
423
|
+
scenarios.push(` Scenario: ${name}`);
|
|
424
|
+
if (a.given)
|
|
425
|
+
scenarios.push(` Given ${a.given}`);
|
|
426
|
+
if (a.when)
|
|
427
|
+
scenarios.push(` When ${a.when}`);
|
|
428
|
+
scenarios.push(` Then ${a.outcome}`);
|
|
429
|
+
scenarios.push('');
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
if (scenarios.length === 0)
|
|
433
|
+
continue;
|
|
434
|
+
const lines = [`Feature: ${module.name} — ${group.name}`];
|
|
435
|
+
if (group.summary)
|
|
436
|
+
lines.push(` ${group.summary}`);
|
|
437
|
+
lines.push('');
|
|
438
|
+
lines.push(...scenarios);
|
|
439
|
+
files.push({
|
|
440
|
+
path: `${SPEC_FEATURES_DIR}/${moduleSlug}/${groupSlug}.feature`,
|
|
441
|
+
content: `${lines.join('\n').trimEnd()}\n`,
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
return files;
|
|
445
|
+
}
|
|
446
|
+
/**
|
|
447
|
+
* Reassemble the existing sharded spec from disk (so the agent refines in place). Reads
|
|
448
|
+
* `service.json` for the service name/summary and every `modules/<m>/<g>.json` shard
|
|
449
|
+
* (skipping `_module.json`), grouping them back into the module → group tree, then runs
|
|
450
|
+
* the lenient coercion to normalise. Returns null when no shards are present (fresh repo).
|
|
451
|
+
*/
|
|
452
|
+
export async function readExistingSpec(dir, fallbackName) {
|
|
453
|
+
let service = fallbackName;
|
|
454
|
+
let summary = '';
|
|
455
|
+
try {
|
|
456
|
+
const raw = JSON.parse(await readFile(join(dir, SPEC_SERVICE_PATH), 'utf8'));
|
|
457
|
+
if (typeof raw.service === 'string' && raw.service.trim())
|
|
458
|
+
service = raw.service;
|
|
459
|
+
if (typeof raw.summary === 'string')
|
|
460
|
+
summary = raw.summary;
|
|
461
|
+
}
|
|
462
|
+
catch {
|
|
463
|
+
// No service.json — fall through; modules may still exist (or this is a fresh repo).
|
|
464
|
+
}
|
|
465
|
+
const modulesDir = join(dir, SPEC_MODULES_DIR);
|
|
466
|
+
let moduleNames;
|
|
467
|
+
try {
|
|
468
|
+
const entries = await readdir(modulesDir, { withFileTypes: true });
|
|
469
|
+
moduleNames = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
|
470
|
+
}
|
|
471
|
+
catch {
|
|
472
|
+
return null;
|
|
473
|
+
}
|
|
474
|
+
if (moduleNames.length === 0)
|
|
475
|
+
return null;
|
|
476
|
+
const modules = [];
|
|
477
|
+
for (const moduleSlug of moduleNames.sort()) {
|
|
478
|
+
const modulePath = join(modulesDir, moduleSlug);
|
|
479
|
+
let moduleName = moduleSlug;
|
|
480
|
+
let moduleSummary = '';
|
|
481
|
+
try {
|
|
482
|
+
const meta = JSON.parse(await readFile(join(modulePath, '_module.json'), 'utf8'));
|
|
483
|
+
if (typeof meta.name === 'string' && meta.name.trim())
|
|
484
|
+
moduleName = meta.name;
|
|
485
|
+
if (typeof meta.summary === 'string')
|
|
486
|
+
moduleSummary = meta.summary;
|
|
487
|
+
}
|
|
488
|
+
catch {
|
|
489
|
+
// No `_module.json`; fall back to the slug as the module name.
|
|
490
|
+
}
|
|
491
|
+
const groupFiles = (await readdir(modulePath))
|
|
492
|
+
.filter((f) => f.endsWith('.json') && f !== '_module.json')
|
|
493
|
+
.sort();
|
|
494
|
+
const groups = [];
|
|
495
|
+
for (const file of groupFiles) {
|
|
496
|
+
try {
|
|
497
|
+
groups.push(JSON.parse(await readFile(join(modulePath, file), 'utf8')));
|
|
498
|
+
}
|
|
499
|
+
catch {
|
|
500
|
+
// Skip an unreadable / malformed shard rather than failing the whole reassembly.
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
modules.push({ name: moduleName, summary: moduleSummary, groups });
|
|
504
|
+
}
|
|
505
|
+
return coerceSpecDoc({ service, summary, modules }, fallbackName);
|
|
506
|
+
}
|
|
507
|
+
/** Extract the first JSON object from an agent's final message (tolerating fences/prose). */
|
|
508
|
+
export function extractJsonObject(text) {
|
|
509
|
+
const trimmed = text.trim();
|
|
510
|
+
const fenced = /^```(?:json)?\s*([\s\S]*?)\s*```$/i.exec(trimmed);
|
|
511
|
+
const body = fenced ? (fenced[1] ?? '') : trimmed;
|
|
512
|
+
try {
|
|
513
|
+
return JSON.parse(body);
|
|
514
|
+
}
|
|
515
|
+
catch {
|
|
516
|
+
const start = body.indexOf('{');
|
|
517
|
+
const end = body.lastIndexOf('}');
|
|
518
|
+
if (start === -1 || end === -1 || end <= start) {
|
|
519
|
+
throw new Error('agent did not return a JSON object');
|
|
520
|
+
}
|
|
521
|
+
return JSON.parse(body.slice(start, end + 1));
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
/** Render this task's requirements that the agent applies onto the baseline spec. */
|
|
525
|
+
function renderTask(task) {
|
|
526
|
+
const header = `### ${task.title || '(untitled task)'}${task.id ? ` (block ${task.id})` : ''}`;
|
|
527
|
+
return `${header}\n\n${task.description || '(no description)'}`;
|
|
528
|
+
}
|
|
529
|
+
/** Render the existing module → feature taxonomy so the agent reuses slots, not duplicates them. */
|
|
530
|
+
function renderTaxonomyInventory(existing) {
|
|
531
|
+
const lines = [
|
|
532
|
+
'',
|
|
533
|
+
'EXISTING taxonomy (modules → features). Map each new requirement/rule into the',
|
|
534
|
+
'closest-fitting EXISTING module and feature below, reusing its EXACT name. Create a',
|
|
535
|
+
'new module or feature ONLY when nothing here fits — never a near-duplicate of an',
|
|
536
|
+
'existing one (e.g. do not add "Authentication" when "Auth" exists, or "User Login"',
|
|
537
|
+
'when "Login" exists). A cross-cutting concern belongs in a `common`/`infrastructure`',
|
|
538
|
+
'module, itself split into specific features — never a catch-all bucket.',
|
|
539
|
+
'',
|
|
540
|
+
];
|
|
541
|
+
if (existing.modules.length === 0) {
|
|
542
|
+
lines.push('_(none yet — you are starting the taxonomy)_');
|
|
543
|
+
return lines;
|
|
544
|
+
}
|
|
545
|
+
for (const module of existing.modules) {
|
|
546
|
+
lines.push(`- ${module.name}`);
|
|
547
|
+
for (const group of module.groups)
|
|
548
|
+
lines.push(` - ${group.name}`);
|
|
549
|
+
}
|
|
550
|
+
return lines;
|
|
551
|
+
}
|
|
552
|
+
/** Compose the task prompt: the worker's guidance, the baseline spec, and this task. */
|
|
553
|
+
function buildUserPrompt(job, existing) {
|
|
554
|
+
const lines = [job.instructions.trim()];
|
|
555
|
+
if (existing) {
|
|
556
|
+
lines.push('', 'The specification ALREADY committed to the repository is the baseline (the spec', 'as merged before this task). Keep every part of it that this task does not touch', 'exactly as-is, preserving its `sourceBlockIds`. Adjust an existing requirement', 'only where this task changes its expected behaviour. Return the COMPLETE updated', 'document (baseline plus this task’s increment), not a diff.');
|
|
557
|
+
lines.push(...renderTaxonomyInventory(existing));
|
|
558
|
+
lines.push('', 'Baseline specification:', '```json', JSON.stringify(existing, null, 2), '```');
|
|
559
|
+
}
|
|
560
|
+
else {
|
|
561
|
+
lines.push('', 'No specification exists in the repository yet, so this task starts a new one.', 'Organise it as a module (domain) → feature (group) taxonomy: place each requirement', 'and rule in a specific feature under a specific module; keep cross-cutting concerns', 'in a `common`/`infrastructure` module split into specific features (no catch-all).');
|
|
562
|
+
}
|
|
563
|
+
lines.push('', 'Requirements for the ONE task to apply as an increment (its clarified description).', 'Translate ONLY what these state into prescriptive requirements with complete', 'acceptance-scenario coverage — do NOT invent requirements or fill gaps they leave:', '', renderTask(job.task));
|
|
564
|
+
lines.push('', 'Respond with ONLY the JSON object for the requirements document — no prose, no', 'code fences.');
|
|
565
|
+
return lines.join('\n');
|
|
566
|
+
}
|
|
567
|
+
async function fileExists(abs) {
|
|
568
|
+
try {
|
|
569
|
+
await access(abs);
|
|
570
|
+
return true;
|
|
571
|
+
}
|
|
572
|
+
catch {
|
|
573
|
+
return false;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
/** List every canonical shard file currently under `spec/modules/` (repo-relative, `/`-joined). */
|
|
577
|
+
async function listExistingModuleFiles(dir) {
|
|
578
|
+
const base = join(dir, SPEC_MODULES_DIR);
|
|
579
|
+
try {
|
|
580
|
+
const rels = await readdir(base, { recursive: true });
|
|
581
|
+
return rels
|
|
582
|
+
.map((r) => `${SPEC_MODULES_DIR}/${r.split(sep).join('/')}`)
|
|
583
|
+
.filter((p) => p.endsWith('.json') || p.endsWith('.md'));
|
|
584
|
+
}
|
|
585
|
+
catch {
|
|
586
|
+
return [];
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
/**
|
|
590
|
+
* Old FLAT-layout Gherkin files written directly under `spec/features/` before features
|
|
591
|
+
* were nested under `features/<module>/`. The sharded renderer never targets a top-level
|
|
592
|
+
* `.feature`, so any such file is a stale orphan that would otherwise feed spec-aware
|
|
593
|
+
* agents acceptance scenarios with no live requirements behind them — prune them.
|
|
594
|
+
*/
|
|
595
|
+
async function listLegacyFeatureFiles(dir) {
|
|
596
|
+
try {
|
|
597
|
+
const entries = await readdir(join(dir, SPEC_FEATURES_DIR), { withFileTypes: true });
|
|
598
|
+
return entries
|
|
599
|
+
.filter((e) => e.isFile() && e.name.endsWith('.feature'))
|
|
600
|
+
.map((e) => `${SPEC_FEATURES_DIR}/${e.name}`);
|
|
601
|
+
}
|
|
602
|
+
catch {
|
|
603
|
+
return [];
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* Write the rendered files under `dir` and reconcile the canonical shards.
|
|
608
|
+
*
|
|
609
|
+
* The spec-writer OWNS the canonical artifact (`service.json`, `overview.md`, the
|
|
610
|
+
* per-group `modules/<m>/<g>.{json,md}`) and always rewrites it. Because these are
|
|
611
|
+
* canonical (not seed-once), a module/group that the new doc no longer contains is an
|
|
612
|
+
* ORPHAN that must be DELETED — otherwise the next reassembly would resurrect it. So
|
|
613
|
+
* after writing we remove any `modules/**` `.json`/`.md` file the render did not emit
|
|
614
|
+
* (`git add -A` in `commitAll` then stages the deletion).
|
|
615
|
+
*
|
|
616
|
+
* The Gherkin `features/<m>/<g>.feature` files are the exception: they are SEEDED
|
|
617
|
+
* (written only when absent, never overwritten OR deleted) so a later manual refinement
|
|
618
|
+
* of a scenario survives a re-run. A removed group's seed feature file may linger; that
|
|
619
|
+
* is harmless and far cheaper than destroying hand-edited scenarios.
|
|
620
|
+
*
|
|
621
|
+
* Finally, the pre-sharding monolithic artifacts (`spec.json` / `rules.md` /
|
|
622
|
+
* `version.json` and the old FLAT `features/*.feature` files) are deleted on sight so a
|
|
623
|
+
* migrated repo never carries a stale, never-updated spec alongside the shards.
|
|
624
|
+
*/
|
|
625
|
+
export async function writeRequirementsFiles(dir, files) {
|
|
626
|
+
const desired = new Set(files.map((f) => f.path));
|
|
627
|
+
for (const file of files) {
|
|
628
|
+
const abs = join(dir, file.path);
|
|
629
|
+
const isFeature = file.path.startsWith(`${SPEC_FEATURES_DIR}/`);
|
|
630
|
+
if (isFeature && (await fileExists(abs)))
|
|
631
|
+
continue; // seed-once: don't clobber pass-2 polish.
|
|
632
|
+
await mkdir(dirname(abs), { recursive: true });
|
|
633
|
+
await writeFile(abs, file.content, 'utf8');
|
|
634
|
+
}
|
|
635
|
+
// Prune orphaned canonical shards (a removed/renamed module or group).
|
|
636
|
+
for (const existing of await listExistingModuleFiles(dir)) {
|
|
637
|
+
if (!desired.has(existing))
|
|
638
|
+
await rm(join(dir, existing), { force: true });
|
|
639
|
+
}
|
|
640
|
+
// Drop stale monolithic-layout artifacts left by a pre-sharding repo.
|
|
641
|
+
for (const legacy of [...LEGACY_SPEC_FILES, ...(await listLegacyFeatureFiles(dir))]) {
|
|
642
|
+
await rm(join(dir, legacy), { force: true });
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* Clone the implementation `branch`, creating it from `repo.baseBranch` when it does
|
|
647
|
+
* not exist yet. This step runs BEFORE the coder, so on the first task run the branch
|
|
648
|
+
* is absent and we seed it; on a re-run (or after the coder has pushed) it already
|
|
649
|
+
* exists and we resume on it, so the requirements commit lands on the same branch the
|
|
650
|
+
* coder uses. Returns once the checkout in `dir` is on `branch` with commit identity set.
|
|
651
|
+
*/
|
|
652
|
+
async function checkoutOrCreateBranch(job, dir, signal) {
|
|
653
|
+
const exists = await remoteBranchExists(job.repo.cloneUrl, job.branch, job.ghToken, signal);
|
|
654
|
+
if (exists) {
|
|
655
|
+
await cloneExistingBranch({
|
|
656
|
+
cloneUrl: job.repo.cloneUrl,
|
|
657
|
+
branch: job.branch,
|
|
658
|
+
ghToken: job.ghToken,
|
|
659
|
+
dir,
|
|
660
|
+
signal,
|
|
661
|
+
});
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
await cloneRepo({ repo: job.repo, ghToken: job.ghToken, dir, signal });
|
|
665
|
+
await createBranch(dir, job.branch, signal);
|
|
666
|
+
}
|
|
667
|
+
/** Run one requirements job end to end. */
|
|
668
|
+
export async function handleSpec(job, opts = {}) {
|
|
669
|
+
const { signal } = opts;
|
|
670
|
+
const trace = { jobId: job.jobId, repo: `${job.repo.owner}/${job.repo.name}`, branch: job.branch };
|
|
671
|
+
return withWorkspace('requirements', async (dir) => {
|
|
672
|
+
log.info('requirements: checking out implementation branch', trace);
|
|
673
|
+
await checkoutOrCreateBranch(job, dir, signal);
|
|
674
|
+
const existing = await readExistingSpec(dir, job.repo.name);
|
|
675
|
+
log.info('requirements: running agent', { ...trace, task: job.task.id });
|
|
676
|
+
const { summary, stats, stderrTail, usage, diagnostics: runDiag, } = await runAgentInWorkspace({
|
|
677
|
+
dir,
|
|
678
|
+
systemPrompt: job.systemPrompt,
|
|
679
|
+
userPrompt: buildUserPrompt(job, existing),
|
|
680
|
+
model: job.model,
|
|
681
|
+
harness: job.harness,
|
|
682
|
+
subscriptionToken: job.subscriptionToken,
|
|
683
|
+
subscriptionBaseUrl: job.subscriptionBaseUrl,
|
|
684
|
+
proxyBaseUrl: job.proxyBaseUrl,
|
|
685
|
+
sessionToken: job.sessionToken,
|
|
686
|
+
// The agent RETURNS the requirements document as JSON — the harness renders
|
|
687
|
+
// + commits the files (below); the agent never calls an edit/write tool. So
|
|
688
|
+
// the no-edit guard must be off (like the blueprinter / merger).
|
|
689
|
+
expectsEdits: false,
|
|
690
|
+
}, opts);
|
|
691
|
+
// The spec is HANDED OFF to the spec-companion to review, so an unusable final
|
|
692
|
+
// answer (cut off at the output ceiling, or an empty completion) must fail LOUDLY
|
|
693
|
+
// here — not get laundered into a half-baked doc by the structured repair below,
|
|
694
|
+
// which is how the companion ended up looping on an "unreviewable" artifact. Opt-in
|
|
695
|
+
// per agent (see `unusableFinalAnswerCause`): only document producers gate on it.
|
|
696
|
+
const unusable = unusableFinalAnswerCause(runDiag);
|
|
697
|
+
if (unusable) {
|
|
698
|
+
log.warn('requirements: unusable final answer', { ...trace, ...stats, ...runDiag });
|
|
699
|
+
return {
|
|
700
|
+
summary,
|
|
701
|
+
stats,
|
|
702
|
+
error: `the requirements agent did not return a usable specification: ${unusable}.${agentOutputTail(stderrTail, summary)}`,
|
|
703
|
+
...(usage ? { usage } : {}),
|
|
704
|
+
};
|
|
705
|
+
}
|
|
706
|
+
// Parse the agent's document; on a malformed reply, make ONE structured repair
|
|
707
|
+
// call (see json-repair) before giving up. Both the failure and the repair
|
|
708
|
+
// outcome are logged + folded into the failure reason for observability.
|
|
709
|
+
const { value: doc, diagnostics } = await resolveStructuredOutput({
|
|
710
|
+
label: 'requirements',
|
|
711
|
+
shapeHint: SPEC_SHAPE_HINT,
|
|
712
|
+
parse: (text) => coerceSpecDoc(extractJsonObject(text), job.repo.name),
|
|
713
|
+
}, summary, {
|
|
714
|
+
harness: job.harness,
|
|
715
|
+
subscriptionToken: job.subscriptionToken,
|
|
716
|
+
subscriptionBaseUrl: job.subscriptionBaseUrl,
|
|
717
|
+
proxyBaseUrl: job.proxyBaseUrl,
|
|
718
|
+
sessionToken: job.sessionToken,
|
|
719
|
+
model: job.model,
|
|
720
|
+
jobId: job.jobId,
|
|
721
|
+
signal,
|
|
722
|
+
});
|
|
723
|
+
if (!doc) {
|
|
724
|
+
return {
|
|
725
|
+
summary,
|
|
726
|
+
stats,
|
|
727
|
+
error: noRequirementsReason(stats, summary, stderrTail, diagnostics),
|
|
728
|
+
...(usage ? { usage } : {}),
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
await writeRequirementsFiles(dir, [...renderSpecFiles(doc), ...renderFeatureFiles(doc)]);
|
|
732
|
+
// Add one commit onto the branch (no history reset, no force). Sharded, deterministic
|
|
733
|
+
// rendering means an unchanged group's bytes are identical, so `commitAll` finds
|
|
734
|
+
// nothing staged and makes no commit (no version.json counter to bump) — we still
|
|
735
|
+
// return the doc so the ingest is idempotent.
|
|
736
|
+
const committed = await commitAll(dir, 'Update service requirements', signal);
|
|
737
|
+
if (committed) {
|
|
738
|
+
log.info('requirements: pushing regenerated requirements', { ...trace, ...stats });
|
|
739
|
+
await pushBranch(dir, job.branch, job.ghToken, signal);
|
|
740
|
+
}
|
|
741
|
+
else {
|
|
742
|
+
log.info('requirements: no changes to push (requirements unchanged)', trace);
|
|
743
|
+
}
|
|
744
|
+
return { spec: doc, summary, stats, ...(usage ? { usage } : {}) };
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
/** Human-readable reason a requirements run produced no usable document. */
|
|
748
|
+
function noRequirementsReason(stats, summary, stderrTail, diagnostics) {
|
|
749
|
+
const cause = agentNeverActed(stats) ? NEVER_ACTED_CAUSE : '';
|
|
750
|
+
return (`the requirements agent produced no usable document ` +
|
|
751
|
+
`(tool calls: ${stats.toolCalls}, assistant output: ${stats.assistantChars} chars).${cause}` +
|
|
752
|
+
(diagnostics ? diagnosticsSuffix(diagnostics) : '') +
|
|
753
|
+
agentOutputTail(stderrTail, summary));
|
|
754
|
+
}
|