cawdev-cli 1.0.3-beta → 1.0.4-beta
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/lib/harness-prompt.mjs +12 -1
- package/lib/stage-tools.mjs +33 -2
- package/lib/tool-line.mjs +19 -8
- package/mcp/README.md +1 -0
- package/mcp/server.mjs +147 -0
- package/package.json +2 -5
- package/runner/README.md +99 -3
- package/runner/attach.mjs +18 -1
- package/runner/cawdev.mjs +21 -2
- package/runner/configure.mjs +58 -0
- package/runner/runner.mjs +479 -29
- package/runner/macbook-laptop.json +0 -48
package/runner/runner.mjs
CHANGED
|
@@ -33,7 +33,8 @@ import {
|
|
|
33
33
|
import { AI_CONFIG, harnessPrompt, readRepoConfig } from '../lib/harness-prompt.mjs';
|
|
34
34
|
import { loadToken, storedUrls } from './token-store.mjs';
|
|
35
35
|
import {
|
|
36
|
-
CARD_WRITE, CAWDEV_READS, GIT_READS, driftedFrom, readOnlyExpert,
|
|
36
|
+
CARD_WRITE, CAWDEV_READS, GIT_READS, beforeTheWorkExists, driftedFrom, readOnlyExpert,
|
|
37
|
+
toolsForStage,
|
|
37
38
|
} from '../lib/stage-tools.mjs';
|
|
38
39
|
import { capabilityIn, describeCall } from '../lib/tool-line.mjs';
|
|
39
40
|
import { findSecret } from '../lib/secrets.mjs';
|
|
@@ -164,6 +165,13 @@ const DEFAULTS = {
|
|
|
164
165
|
// would be a question with one sensible answer.
|
|
165
166
|
'mcp__cawdev__code_map',
|
|
166
167
|
'mcp__cawdev__file_deps',
|
|
168
|
+
// R302. The SonarQube reading — a read, allowed by default like the map.
|
|
169
|
+
// Through READ_ONLY_CAWDEV this reaches REVIEW and AUDIT as the card
|
|
170
|
+
// asks, and ASK, SCOPE, MERGE, RELEASE and INTERVIEW as well: it is a
|
|
171
|
+
// read and harmless there, and subtracting it from five lists would be
|
|
172
|
+
// five lists to keep in step. PLAN is the one profile that drops it —
|
|
173
|
+
// `beforeTheWorkExists`, below — because the card says so.
|
|
174
|
+
'mcp__cawdev__quality_get',
|
|
167
175
|
'mcp__cawdev__roadmap_statuses',
|
|
168
176
|
'mcp__cawdev__roadmap_list',
|
|
169
177
|
'mcp__cawdev__roadmap_get',
|
|
@@ -463,6 +471,12 @@ async function readConfig() {
|
|
|
463
471
|
* "dycrypt": "/Users/you/code/dycrypt"
|
|
464
472
|
* "dycrypt": { "path": "…", "allowedTools": ["Bash(mvn *)", "mcp__roadmap"] }
|
|
465
473
|
* "cawdev": { "workspaces": ["/…/ws-1", "/…/ws-2", "/…/ws-3"] }
|
|
474
|
+
* "cawdev": { "workspaces": [...], "quality": { "command": "sonar-scanner", "tokenEnv": "SONAR_TOKEN" } }
|
|
475
|
+
*
|
|
476
|
+
* R300: the fourth form adds a `quality` block — see `qualityBlockOf` — and a
|
|
477
|
+
* project with one has its branches scanned after a coding run's TEST stage.
|
|
478
|
+
* Captured here at boot, not hot-swapped: adding or changing the block needs
|
|
479
|
+
* a restart, and `restartNeededFor` says so.
|
|
466
480
|
*
|
|
467
481
|
* R47: a bare path means ONE workspace, which is what every config meant
|
|
468
482
|
* before workspaces existed. Concurrency for a project is
|
|
@@ -508,11 +522,51 @@ function normaliseProjects(projects) {
|
|
|
508
522
|
// falls through to the machine's answer; `false` here refuses it for one
|
|
509
523
|
// project on a machine that otherwise allows it.
|
|
510
524
|
browser: settings.browser,
|
|
525
|
+
// R300: whether, and how, this project's branches are scanned with
|
|
526
|
+
// SonarQube after TEST. Null is the ordinary case and means never.
|
|
527
|
+
quality: qualityBlockOf(slug, settings.quality),
|
|
511
528
|
};
|
|
512
529
|
}
|
|
513
530
|
return normalised;
|
|
514
531
|
}
|
|
515
532
|
|
|
533
|
+
/**
|
|
534
|
+
* A project's `quality` block, normalised — R300.
|
|
535
|
+
*
|
|
536
|
+
* The scan is opt-in TWICE: here, on the machine, and on the project, which
|
|
537
|
+
* must have a SonarQube key (the claim carries it). A machine with no block
|
|
538
|
+
* never scans and never says so; a machine with one and no scanner on its
|
|
539
|
+
* PATH reports an `error` reading rather than nothing, so the page says the
|
|
540
|
+
* scan could not run instead of showing a branch nobody looked at.
|
|
541
|
+
*
|
|
542
|
+
* `tokenEnv` names the variable this machine holds the scanner token in. The
|
|
543
|
+
* platform never sends one: the project's SonarQube token (R304) is the
|
|
544
|
+
* platform's reader credential and stays there; the scanner's is the
|
|
545
|
+
* operator's and stays here. `url` overrides the platform's `sonarUrl` for
|
|
546
|
+
* a machine that reaches the server by another name (a LAN hostname).
|
|
547
|
+
*
|
|
548
|
+
* `whenBranchesRefused` is what to do on a server that refuses
|
|
549
|
+
* `sonar.branch.name` — Community Edition. The default, `skip`, reports an
|
|
550
|
+
* error saying so; `main` re-runs the scan as the project's MAIN analysis,
|
|
551
|
+
* which overwrites the project's dashboard with this branch's code and so
|
|
552
|
+
* makes the platform's project reading describe whichever branch scanned
|
|
553
|
+
* last. An operator opts into that; nothing opts in for them.
|
|
554
|
+
*/
|
|
555
|
+
function qualityBlockOf(slug, block) {
|
|
556
|
+
if (!block) return null;
|
|
557
|
+
const when = block.whenBranchesRefused ?? 'skip';
|
|
558
|
+
if (when !== 'skip' && when !== 'main') {
|
|
559
|
+
throw new Error(`Project "${slug}": quality.whenBranchesRefused must be "skip" or "main", not "${when}".`);
|
|
560
|
+
}
|
|
561
|
+
return {
|
|
562
|
+
command: block.command ?? 'sonar-scanner',
|
|
563
|
+
tokenEnv: block.tokenEnv ?? 'SONAR_TOKEN',
|
|
564
|
+
url: block.url ?? null,
|
|
565
|
+
whenBranchesRefused: when,
|
|
566
|
+
timeoutSeconds: block.timeoutSeconds ?? 600,
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
|
|
516
570
|
|
|
517
571
|
/**
|
|
518
572
|
* What THIS daemon knows about a skill, beyond what the platform sent — R76.
|
|
@@ -1305,10 +1359,17 @@ async function resolveSkills(config, run, cwd, baseCommit, asked) {
|
|
|
1305
1359
|
notes.push(await prepareSkillIndex(config, skill, local, run, cwd, baseCommit));
|
|
1306
1360
|
}
|
|
1307
1361
|
attached.push({ skill, local });
|
|
1362
|
+
// R303. A required server says so where the person is looking, as the
|
|
1363
|
+
// expert line does. Whether a STAGE is actually asked for it is decided at
|
|
1364
|
+
// spawn — a read-only stage cannot call it and is not nudged for it.
|
|
1365
|
+
const must = skill.mode === 'REQUIRED'
|
|
1366
|
+
? ' This project REQUIRES it: if a stage finishes without calling one of its tools, the '
|
|
1367
|
+
+ 'session will be told.'
|
|
1368
|
+
: '';
|
|
1308
1369
|
notes.push(`${skill.name ?? skill.key} is available to this session as `
|
|
1309
1370
|
+ `${skill.toolPrefix ?? `mcp__${skill.serverName}`}. The first call still asks a person — `
|
|
1310
1371
|
+ `allow ${skill.toolPrefix ?? `mcp__${skill.serverName}`} for the session to cover the `
|
|
1311
|
-
+
|
|
1372
|
+
+ `rest.${must}`);
|
|
1312
1373
|
}
|
|
1313
1374
|
return { attached, notes };
|
|
1314
1375
|
}
|
|
@@ -2239,6 +2300,189 @@ async function surveyGit(config) {
|
|
|
2239
2300
|
}
|
|
2240
2301
|
}
|
|
2241
2302
|
|
|
2303
|
+
/**
|
|
2304
|
+
* Scans the run's branch with SonarQube and tells the platform — R300.
|
|
2305
|
+
*
|
|
2306
|
+
* Runs AFTER the TEST stage settles and BEFORE the next stage begins, on the
|
|
2307
|
+
* workspace the run just wrote, and it is the DAEMON that runs it: a session
|
|
2308
|
+
* that reported its own gate could lie about it (RunnerLimit's argument), so
|
|
2309
|
+
* the scanner is a subprocess this process starts and nothing the spawned
|
|
2310
|
+
* `claude` can call. Only a staged run with a TEST stage gets here — a
|
|
2311
|
+
* lifecycle without one, an unstaged run, a run whose gate refused TEST twice
|
|
2312
|
+
* or one that reported done inside TEST is not scanned, because there was no
|
|
2313
|
+
* settled TEST stage to scan after.
|
|
2314
|
+
*
|
|
2315
|
+
* The shape is `surveyGit`'s: every failure is CAUGHT and reported as an
|
|
2316
|
+
* `error` reading, never thrown to the walk — a failed scan never fails a
|
|
2317
|
+
* run. The two silent exits are the two ways the feature is off: no `quality`
|
|
2318
|
+
* block on this machine (nothing is logged; the operator never asked), and no
|
|
2319
|
+
* key on the project (logged, because the machine did ask and the project is
|
|
2320
|
+
* what is missing). Everything after that reports SOMETHING, so that "could
|
|
2321
|
+
* not scan" and "did not scan" never reach the page as the same answer.
|
|
2322
|
+
*
|
|
2323
|
+
* What is scanned is the WORKING TREE, which after TEST may hold edits the
|
|
2324
|
+
* session had not committed; `headSha` is HEAD, and says where the tree
|
|
2325
|
+
* started rather than that the tree equals it.
|
|
2326
|
+
*
|
|
2327
|
+
* The scanner's working directory is a temp dir OUTSIDE the checkout,
|
|
2328
|
+
* passed as `-Dsonar.working.directory=`: `.scannerwork/` inside it would
|
|
2329
|
+
* show up in the working-copy watcher and in `resetWorkspace`'s `git clean`
|
|
2330
|
+
* list — R76's `.codegraph` lesson, see `excludeLocally`. The token goes in
|
|
2331
|
+
* the child's ENVIRONMENT and never on its argv, which `ps` would show.
|
|
2332
|
+
*/
|
|
2333
|
+
async function scanQuality(config, run, cwd, branch) {
|
|
2334
|
+
const slug = run.projectSlug;
|
|
2335
|
+
const quality = config.projects[slug]?.quality;
|
|
2336
|
+
if (!quality) return;
|
|
2337
|
+
if (!run.sonarProjectKey) {
|
|
2338
|
+
log(` quality: ${slug} has no SonarQube key — not scanned`);
|
|
2339
|
+
return;
|
|
2340
|
+
}
|
|
2341
|
+
const key = run.sonarProjectKey;
|
|
2342
|
+
const report = async (reading) => {
|
|
2343
|
+
await api(config, `/api/runners/${config.runnerId}/quality/${slug}`, {
|
|
2344
|
+
method: 'POST',
|
|
2345
|
+
body: { runId: run.id, branch, ...reading },
|
|
2346
|
+
}).catch((failure) => log(` could not report the quality of ${branch}: ${failure.message}`));
|
|
2347
|
+
if (reading.error) {
|
|
2348
|
+
log(` quality: could not scan ${branch}: ${reading.error}`);
|
|
2349
|
+
} else {
|
|
2350
|
+
const problems = (reading.newBugs ?? 0) + (reading.newVulnerabilities ?? 0)
|
|
2351
|
+
+ (reading.newSecurityHotspots ?? 0) + (reading.newCodeSmells ?? 0);
|
|
2352
|
+
log(` quality: ${branch} gate ${reading.gate ?? 'NONE'} · ${problems} new problem${problems === 1 ? '' : 's'}`
|
|
2353
|
+
+ (reading.warning ? ` · ${reading.warning}` : ''));
|
|
2354
|
+
}
|
|
2355
|
+
};
|
|
2356
|
+
|
|
2357
|
+
const url = quality.url ?? run.sonarUrl;
|
|
2358
|
+
if (!url) {
|
|
2359
|
+
return report({ error: 'No SonarQube server: the platform has none configured and the quality block names no url.' });
|
|
2360
|
+
}
|
|
2361
|
+
const token = process.env[quality.tokenEnv];
|
|
2362
|
+
if (!token) {
|
|
2363
|
+
return report({ error: `The scanner token is not set: export ${quality.tokenEnv} on this machine.` });
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
let headSha = null;
|
|
2367
|
+
let workDir = null;
|
|
2368
|
+
try {
|
|
2369
|
+
headSha = await git(cwd, ['rev-parse', 'HEAD']);
|
|
2370
|
+
workDir = await mkdtemp(join(tmpdir(), 'cawdev-sonar-'));
|
|
2371
|
+
const deadline = Date.now() + quality.timeoutSeconds * 1000;
|
|
2372
|
+
const remaining = () => Math.max(1000, deadline - Date.now());
|
|
2373
|
+
const scannerArgs = (withBranch) => [
|
|
2374
|
+
`-Dsonar.host.url=${url}`,
|
|
2375
|
+
`-Dsonar.projectKey=${key}`,
|
|
2376
|
+
...(withBranch ? [`-Dsonar.branch.name=${branch}`] : []),
|
|
2377
|
+
`-Dsonar.scm.revision=${headSha}`,
|
|
2378
|
+
`-Dsonar.working.directory=${workDir}`,
|
|
2379
|
+
];
|
|
2380
|
+
const env = { ...process.env, SONAR_TOKEN: token };
|
|
2381
|
+
|
|
2382
|
+
let analysedAs = 'BRANCH';
|
|
2383
|
+
let warning = null;
|
|
2384
|
+
let ran = await runSkillCommand(quality.command, scannerArgs(true),
|
|
2385
|
+
{ cwd, env, timeoutMs: remaining() });
|
|
2386
|
+
if (!ran.ok) {
|
|
2387
|
+
if (/ENOENT/.test(ran.said)) {
|
|
2388
|
+
return report({ headSha, error: `${quality.command} is not installed on this machine (spawn ENOENT).` });
|
|
2389
|
+
}
|
|
2390
|
+
if (/sonar\.branch\.name.*(Developer Edition|above is required)/is.test(ran.said)) {
|
|
2391
|
+
if (quality.whenBranchesRefused !== 'main') {
|
|
2392
|
+
return report({
|
|
2393
|
+
headSha,
|
|
2394
|
+
error: 'This SonarQube server does not analyse branches (Community Edition). '
|
|
2395
|
+
+ 'Set "whenBranchesRefused": "main" in the quality block to analyse it as the main analysis instead.',
|
|
2396
|
+
});
|
|
2397
|
+
}
|
|
2398
|
+
// The operator opted in: once more, as the project's main analysis.
|
|
2399
|
+
analysedAs = 'MAIN';
|
|
2400
|
+
warning = "This server does not analyse branches; the branch was analysed as the project's main analysis.";
|
|
2401
|
+
ran = await runSkillCommand(quality.command, scannerArgs(false),
|
|
2402
|
+
{ cwd, env, timeoutMs: remaining() });
|
|
2403
|
+
}
|
|
2404
|
+
}
|
|
2405
|
+
if (!ran.ok) {
|
|
2406
|
+
return report({ headSha, error: ran.said.split('\n')[0] || 'the scanner failed and said nothing' });
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
// What the scanner left behind: where the server queued the analysis.
|
|
2410
|
+
const task = parseProperties(await readFile(join(workDir, 'report-task.txt'), 'utf8'));
|
|
2411
|
+
const serverUrl = (task.serverUrl ?? url).replace(/\/$/, '');
|
|
2412
|
+
const headers = { authorization: `Bearer ${token}` };
|
|
2413
|
+
const ask = async (path) => {
|
|
2414
|
+
const response = await fetch(`${serverUrl}${path}`, { headers });
|
|
2415
|
+
if (!response.ok) {
|
|
2416
|
+
throw new Error(`SonarQube answered ${response.status} to ${path.split('?')[0]}`);
|
|
2417
|
+
}
|
|
2418
|
+
return response.json();
|
|
2419
|
+
};
|
|
2420
|
+
|
|
2421
|
+
// The analysis is queued on the server; wait for it to settle.
|
|
2422
|
+
let settled = null;
|
|
2423
|
+
while (Date.now() < deadline) {
|
|
2424
|
+
const { task: current } = await ask(`/api/ce/task?id=${encodeURIComponent(task.ceTaskId)}`);
|
|
2425
|
+
if (['SUCCESS', 'FAILED', 'CANCELED'].includes(current?.status)) {
|
|
2426
|
+
settled = current;
|
|
2427
|
+
break;
|
|
2428
|
+
}
|
|
2429
|
+
await new Promise((wake) => setTimeout(wake, 3000));
|
|
2430
|
+
}
|
|
2431
|
+
if (!settled) {
|
|
2432
|
+
return report({ headSha, error: `SonarQube had not finished analysing after ${quality.timeoutSeconds}s.` });
|
|
2433
|
+
}
|
|
2434
|
+
if (settled.status !== 'SUCCESS') {
|
|
2435
|
+
return report({ headSha, error: settled.errorMessage ?? `SonarQube ${settled.status.toLowerCase()} the analysis.` });
|
|
2436
|
+
}
|
|
2437
|
+
|
|
2438
|
+
const { projectStatus } = await ask(`/api/qualitygates/project_status?analysisId=${encodeURIComponent(settled.analysisId)}`);
|
|
2439
|
+
const metrics = 'new_bugs,new_vulnerabilities,new_security_hotspots,new_code_smells,new_coverage';
|
|
2440
|
+
const { component } = await ask(`/api/measures/component?component=${encodeURIComponent(key)}`
|
|
2441
|
+
+ `&metricKeys=${metrics}${analysedAs === 'BRANCH' ? `&branch=${encodeURIComponent(branch)}` : ''}`);
|
|
2442
|
+
const measure = (metric) => {
|
|
2443
|
+
const found = (component?.measures ?? []).find((each) => each.metric === metric);
|
|
2444
|
+
// New-code values sit in `period.value` — `periods[0].value` on older servers.
|
|
2445
|
+
const value = found?.period?.value ?? found?.periods?.[0]?.value ?? found?.value;
|
|
2446
|
+
return value === undefined || value === null || value === '' ? null : Number(value);
|
|
2447
|
+
};
|
|
2448
|
+
const integer = (metric) => {
|
|
2449
|
+
const value = measure(metric);
|
|
2450
|
+
return value === null ? null : Math.round(value);
|
|
2451
|
+
};
|
|
2452
|
+
|
|
2453
|
+
await report({
|
|
2454
|
+
headSha,
|
|
2455
|
+
gate: projectStatus?.status ?? 'NONE',
|
|
2456
|
+
analysedAs,
|
|
2457
|
+
newBugs: integer('new_bugs'),
|
|
2458
|
+
newVulnerabilities: integer('new_vulnerabilities'),
|
|
2459
|
+
newSecurityHotspots: integer('new_security_hotspots'),
|
|
2460
|
+
newCodeSmells: integer('new_code_smells'),
|
|
2461
|
+
newCoverage: measure('new_coverage'),
|
|
2462
|
+
dashboardUrl: task.dashboardUrl ?? null,
|
|
2463
|
+
warning,
|
|
2464
|
+
});
|
|
2465
|
+
} catch (failure) {
|
|
2466
|
+
await report({ headSha, error: failure.message.split('\n')[0] });
|
|
2467
|
+
} finally {
|
|
2468
|
+
if (workDir) {
|
|
2469
|
+
await rm(workDir, { recursive: true, force: true }).catch(() => {});
|
|
2470
|
+
}
|
|
2471
|
+
}
|
|
2472
|
+
}
|
|
2473
|
+
|
|
2474
|
+
/** `key=value` lines — the shape of the scanner's `report-task.txt`. */
|
|
2475
|
+
function parseProperties(text) {
|
|
2476
|
+
const parsed = {};
|
|
2477
|
+
for (const line of text.split('\n')) {
|
|
2478
|
+
const at = line.indexOf('=');
|
|
2479
|
+
if (at > 0) {
|
|
2480
|
+
parsed[line.slice(0, at).trim()] = line.slice(at + 1).trim();
|
|
2481
|
+
}
|
|
2482
|
+
}
|
|
2483
|
+
return parsed;
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2242
2486
|
/**
|
|
2243
2487
|
* Reads and reports the code map, unless it would say the same thing again.
|
|
2244
2488
|
*
|
|
@@ -3091,7 +3335,11 @@ async function performWorkspaceRequest(config, request, afterTheTreeMoved = null
|
|
|
3091
3335
|
if (!branch) {
|
|
3092
3336
|
result = 'A merge has to name a branch. Nothing was merged.';
|
|
3093
3337
|
} else {
|
|
3094
|
-
|
|
3338
|
+
// R306: whose idea it was. A rule's merge lands with nobody reading
|
|
3339
|
+
// the diff, and the log is where an operator sees that happen.
|
|
3340
|
+
log(` MERGING ${branch} — ${request.requestedByRule
|
|
3341
|
+
? `by ${request.requestedByRule}, nobody is reading this diff`
|
|
3342
|
+
: 'asked for from the console'}`);
|
|
3095
3343
|
// R261. A sprint's branch lands with a merge commit; every other branch
|
|
3096
3344
|
// squashes as it always did. The claim says which.
|
|
3097
3345
|
({ ok, result, failure: failureKind } = await mergePullRequest(cwd, branch, {
|
|
@@ -3361,8 +3609,10 @@ function announceRules(rules) {
|
|
|
3361
3609
|
if (!on.length) return;
|
|
3362
3610
|
log(` this project's rules: ${on.join(', ')}`);
|
|
3363
3611
|
if (rules.autoMerge) {
|
|
3364
|
-
|
|
3365
|
-
|
|
3612
|
+
// R306: not "when this run finishes" any more — the platform asks for the
|
|
3613
|
+
// merge when the branch reaches Done on the development board.
|
|
3614
|
+
log(' !! auto_merge is ON: its branch will be merged when it reaches Done on the '
|
|
3615
|
+
+ 'board, with nobody reading it');
|
|
3366
3616
|
}
|
|
3367
3617
|
}
|
|
3368
3618
|
|
|
@@ -3514,7 +3764,26 @@ async function openPullRequestFor(cwd, { branch, base, title, body }) {
|
|
|
3514
3764
|
* convention wants a merge commit for a release branch too, for the same
|
|
3515
3765
|
* reason; release branches are not worked through the development board
|
|
3516
3766
|
* today, and if that changes this is the line that has to change with it.)
|
|
3767
|
+
*
|
|
3768
|
+
* **It waits out GitHub's not-yet-computed mergeability — R306.** The host
|
|
3769
|
+
* computes whether a pull request can merge lazily, and `gh pr merge` seconds
|
|
3770
|
+
* after `gh pr create` — which is exactly when a rule's merge arrives on a
|
|
3771
|
+
* project nobody reads first — can fail for no reason a person can act on.
|
|
3772
|
+
* So before merging, `gh pr view --json mergeable` is asked, and while it says
|
|
3773
|
+
* `UNKNOWN` this waits, bounded (`MERGEABILITY_POLLS` × `mergeabilityWaitMs()`),
|
|
3774
|
+
* for the rule, the board's button and a person's press alike. Any other
|
|
3775
|
+
* answer — `MERGEABLE`, `CONFLICTING`, an error, something that is not JSON —
|
|
3776
|
+
* proceeds at once: the merge itself is what says whether it could, and
|
|
3777
|
+
* `classifyMergeFailure` is unchanged.
|
|
3517
3778
|
*/
|
|
3779
|
+
const MERGEABILITY_POLLS = 6;
|
|
3780
|
+
|
|
3781
|
+
/** Between two mergeability questions. Five seconds, unless the environment says otherwise — `gateTimeoutSeconds`'s shape. */
|
|
3782
|
+
function mergeabilityWaitMs() {
|
|
3783
|
+
const configured = Number(process.env.CAWDEV_MERGEABILITY_WAIT_MS);
|
|
3784
|
+
return Number.isFinite(configured) && configured > 0 ? configured : 5000;
|
|
3785
|
+
}
|
|
3786
|
+
|
|
3518
3787
|
async function mergePullRequest(cwd, branch, { method = null } = {}) {
|
|
3519
3788
|
const url = await findPullRequest(cwd, branch).catch(() => null);
|
|
3520
3789
|
if (!url || url.includes('/compare/')) {
|
|
@@ -3524,6 +3793,7 @@ async function mergePullRequest(cwd, branch, { method = null } = {}) {
|
|
|
3524
3793
|
result: 'There is no pull request for this branch to merge. Nothing was merged.',
|
|
3525
3794
|
};
|
|
3526
3795
|
}
|
|
3796
|
+
await waitOutUnknownMergeability(cwd, url, branch);
|
|
3527
3797
|
const mergeCommit = method === 'MERGE_COMMIT';
|
|
3528
3798
|
const merged = await gh(cwd, ['pr', 'merge', url, mergeCommit ? '--merge' : '--squash', '--delete-branch']);
|
|
3529
3799
|
if (merged.code === 0) {
|
|
@@ -3549,6 +3819,33 @@ async function mergePullRequest(cwd, branch, { method = null } = {}) {
|
|
|
3549
3819
|
};
|
|
3550
3820
|
}
|
|
3551
3821
|
|
|
3822
|
+
/**
|
|
3823
|
+
* While the host has not yet said whether the pull request can merge, wait —
|
|
3824
|
+
* bounded. See `mergePullRequest`. One log line if it waited at all, so a
|
|
3825
|
+
* merge that took thirty seconds says why.
|
|
3826
|
+
*/
|
|
3827
|
+
async function waitOutUnknownMergeability(cwd, url, branch) {
|
|
3828
|
+
let waited = 0;
|
|
3829
|
+
for (let poll = 0; poll < MERGEABILITY_POLLS; poll++) {
|
|
3830
|
+
const asked = await gh(cwd, ['pr', 'view', url, '--json', 'mergeable,mergeStateStatus']);
|
|
3831
|
+
if (asked.code !== 0) return;
|
|
3832
|
+
let view;
|
|
3833
|
+
try {
|
|
3834
|
+
view = JSON.parse(asked.out || '{}');
|
|
3835
|
+
} catch {
|
|
3836
|
+
// Not JSON — a gh that answered something else. Nothing to wait for.
|
|
3837
|
+
return;
|
|
3838
|
+
}
|
|
3839
|
+
if (view.mergeable !== 'UNKNOWN') {
|
|
3840
|
+
if (waited) log(` ${branch}: mergeability known after ${waited}s`);
|
|
3841
|
+
return;
|
|
3842
|
+
}
|
|
3843
|
+
await sleep(mergeabilityWaitMs());
|
|
3844
|
+
waited += mergeabilityWaitMs() / 1000;
|
|
3845
|
+
}
|
|
3846
|
+
log(` ${branch}: the host still has not computed mergeability after ${waited}s; merging anyway`);
|
|
3847
|
+
}
|
|
3848
|
+
|
|
3552
3849
|
/**
|
|
3553
3850
|
* Whether a tag exists ON THE REMOTE — R156.
|
|
3554
3851
|
*
|
|
@@ -4272,8 +4569,11 @@ const PROFILE_TOOLS = {
|
|
|
4272
4569
|
// R198: a plan run on an ISSUE (a confirm) is handed exactly this list. What
|
|
4273
4570
|
// differs is one paragraph of prompt, not a tool — the "ended without a plan"
|
|
4274
4571
|
// note on the issue is the platform's to write, for the reason above.
|
|
4572
|
+
// R302. Less the reads that need the work to exist — `quality_get` — the
|
|
4573
|
+
// same narrowing `toolsForStage` applies to a walk's PLAN stage, by the same
|
|
4574
|
+
// name: a plan run spawned as one process comes through here and not there.
|
|
4275
4575
|
PLAN: (run) => [
|
|
4276
|
-
...READ_ONLY_CAWDEV,
|
|
4576
|
+
...beforeTheWorkExists(READ_ONLY_CAWDEV),
|
|
4277
4577
|
...READ_FILES,
|
|
4278
4578
|
...GIT_READS,
|
|
4279
4579
|
...(run?.entryNumber ? [] : [CARD_WRITE]),
|
|
@@ -4520,7 +4820,10 @@ function stagePrompt(stage, carried) {
|
|
|
4520
4820
|
+ '`git diff` to find out what the work actually changed. A test you could not run is '
|
|
4521
4821
|
+ 'a line in the testbook, not a failure of this stage.'
|
|
4522
4822
|
: 'Run what proves the work. Report what passed and what did not, with the output. A '
|
|
4523
|
-
+ 'failing test is a result, not a failure of this stage.'
|
|
4823
|
+
+ 'failing test is a result, not a failure of this stage. The branch\'s SonarQube gate '
|
|
4824
|
+
+ 'is one call away — `quality_get`, which reads this run\'s branch by default; a red '
|
|
4825
|
+
+ 'gate with new vulnerabilities is a finding to report with the output, not a failure '
|
|
4826
|
+
+ 'to hide.',
|
|
4524
4827
|
MEMORY: 'Write what the NEXT session on this branch needs to know: what was done, what was '
|
|
4525
4828
|
+ 'tried and rejected and why, and anything that turned out to matter and would not be '
|
|
4526
4829
|
+ 'obvious from the diff. Be brief and concrete. This is saved as this branch\'s '
|
|
@@ -4947,7 +5250,9 @@ matters — and stop there. Do not decide the verdict. That is the person's.
|
|
|
4947
5250
|
The findings go on the card and the person decides Done or Not done.
|
|
4948
5251
|
|
|
4949
5252
|
**Read the code.** Start with \`code_map\` and \`file_deps\` — cawdev has already
|
|
4950
|
-
mapped this repository. Then read the work with \`git_reads\` tools.
|
|
5253
|
+
mapped this repository. Then read the work with \`git_reads\` tools. \`quality_get\`
|
|
5254
|
+
gives the branch's SonarQube gate and the project's open findings; a new
|
|
5255
|
+
vulnerability belongs in your comment.
|
|
4951
5256
|
|
|
4952
5257
|
**Check against the Build: and Done when:.** Use \`roadmap_get\` to read the
|
|
4953
5258
|
card's own words, then check the code against them. Point at what is done and
|
|
@@ -5043,8 +5348,9 @@ function nudgeText(stage, missing) {
|
|
|
5043
5348
|
const names = missing.map((each) => `\`${each}\``).join(', ');
|
|
5044
5349
|
return `cawdev: this project requires ${names}, and this ${stage.stage} stage has not `
|
|
5045
5350
|
+ 'used it.\n\n'
|
|
5046
|
-
+ 'Use it now — `Skill` with `skill: "<name>"`,
|
|
5047
|
-
+ '— and then say what it
|
|
5351
|
+
+ 'Use it now — `Skill` with `skill: "<name>"`, `Agent` with `subagent_type: "<name>"`, or '
|
|
5352
|
+
+ 'for an MCP server a call to one of its tools (`mcp__<server>__…`) — and then say what it '
|
|
5353
|
+
+ 'changed about your answer.\n\n'
|
|
5048
5354
|
+ 'If it genuinely does not apply to this work, say so in one sentence and stop. '
|
|
5049
5355
|
+ '**You will not be asked again.**';
|
|
5050
5356
|
}
|
|
@@ -5412,6 +5718,14 @@ async function startRun(config, offered, workspace) {
|
|
|
5412
5718
|
if (run.baseBranch) {
|
|
5413
5719
|
log(` cut from ${run.baseBranch} (the sprint's branch)`);
|
|
5414
5720
|
}
|
|
5721
|
+
// R300. Which SonarQube project this is and where — two FACTS the
|
|
5722
|
+
// scanner needs on its command line, learnt with the claim because
|
|
5723
|
+
// reading a project is session-only. No token comes with them; the
|
|
5724
|
+
// scanner runs with this machine's own, from the variable the `quality`
|
|
5725
|
+
// block names. Stored on the run the way `baseBranch` is, so the walk
|
|
5726
|
+
// reads them off `run` after TEST rather than through another parameter.
|
|
5727
|
+
run.sonarProjectKey = claimed.sonarProjectKey ?? null;
|
|
5728
|
+
run.sonarUrl = claimed.sonarUrl ?? null;
|
|
5415
5729
|
// R69. Whether this is the same conversation picked back up, and which one.
|
|
5416
5730
|
// Null on an ordinary claim, which is nearly all of them.
|
|
5417
5731
|
resume = claimed.resume ?? null;
|
|
@@ -5652,9 +5966,9 @@ async function skipRest(config, run, lifecycle, from, why) {
|
|
|
5652
5966
|
|
|
5653
5967
|
async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace, resume,
|
|
5654
5968
|
projectServers, expertAgents, skills, instincts, briefing, plan, lifecycle, shield) {
|
|
5655
|
-
const spawn = (stage, carried) => spawnAgent(config, run, runToken, cwd, baseCommit,
|
|
5656
|
-
resume, projectServers, expertAgents, skills, instincts, briefing, plan, lifecycle,
|
|
5657
|
-
stage, carried);
|
|
5969
|
+
const spawn = (stage, carried, usage) => spawnAgent(config, run, runToken, cwd, baseCommit,
|
|
5970
|
+
workspace, resume, projectServers, expertAgents, skills, instincts, briefing, plan, lifecycle,
|
|
5971
|
+
shield, stage, carried, usage);
|
|
5658
5972
|
|
|
5659
5973
|
// No lifecycle, or a resume: one process, exactly as before.
|
|
5660
5974
|
//
|
|
@@ -5671,8 +5985,34 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
|
5671
5985
|
const carried = {};
|
|
5672
5986
|
let last = null;
|
|
5673
5987
|
|
|
5988
|
+
// R305. What the RUN has been credited with so far. Each stage is its own
|
|
5989
|
+
// process with its own cumulative counter, so the base the next child adds
|
|
5990
|
+
// to must move — read once from the claim, it stayed at zero and the platform
|
|
5991
|
+
// ignored every stage after the first as a reading that went backwards, so a
|
|
5992
|
+
// five-stage run's stored total was its largest stage rather than the sum.
|
|
5993
|
+
const spent = { tokensIn: Number(run.tokensIn) || 0, tokensOut: Number(run.tokensOut) || 0 };
|
|
5994
|
+
// And what each STAGE was already credited with, for the same reason. A run
|
|
5995
|
+
// walked a second time in a fresh session — handed off to another machine,
|
|
5996
|
+
// or one that died before the CLI said its session id — re-walks the same
|
|
5997
|
+
// `run_stage` rows, and the claim's total already holds the first walk; a
|
|
5998
|
+
// stage counted from zero here would be kept as max(walk) by the platform
|
|
5999
|
+
// while the run kept the sum. Read once; an older platform, or a stub,
|
|
6000
|
+
// answers without the fields and the base is zero, which is today's number.
|
|
6001
|
+
const stored = await api(config, `/api/projects/${run.projectSlug}/runs/${run.id}/stages`)
|
|
6002
|
+
.catch(() => []);
|
|
6003
|
+
const creditedTo = (name) => {
|
|
6004
|
+
const row = Array.isArray(stored) ? stored.find((each) => each?.stage === name) : null;
|
|
6005
|
+
return { tokensIn: Number(row?.tokensIn) || 0, tokensOut: Number(row?.tokensOut) || 0 };
|
|
6006
|
+
};
|
|
6007
|
+
|
|
5674
6008
|
for (let at = 0; at < lifecycle.length; at++) {
|
|
5675
6009
|
const stage = lifecycle[at];
|
|
6010
|
+
// R305. What THIS stage's processes have used, across its attempts. Outside
|
|
6011
|
+
// the attempt loop on purpose: `run_stage` is one row per stage and keeps
|
|
6012
|
+
// the larger reading, so a second attempt after a refused gate must report
|
|
6013
|
+
// the first attempt's total plus its own, or the row would show
|
|
6014
|
+
// max(attempt) and the stages would no longer sum to the run.
|
|
6015
|
+
const stageSpent = creditedTo(stage.stage);
|
|
5676
6016
|
// Two attempts at most, and the second only exists because a REFUSED gate
|
|
5677
6017
|
// should cost one cheap stage rather than a whole run. A third would be a
|
|
5678
6018
|
// loop nobody asked for.
|
|
@@ -5681,7 +6021,19 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
|
5681
6021
|
log(` ${stage.stage}${stage.model ? ` on ${stage.model}` : ''}`
|
|
5682
6022
|
+ `${attempt ? ' (again, after the gate refused it)' : ''}`);
|
|
5683
6023
|
|
|
5684
|
-
|
|
6024
|
+
// Both handed BY REFERENCE and moved below once the child is over. Safe
|
|
6025
|
+
// only because stages run one at a time: the child reads them at each
|
|
6026
|
+
// `result` event, and nothing else touches them while it lives.
|
|
6027
|
+
last = await spawn(stage, carried, { run: spent, stage: stageSpent });
|
|
6028
|
+
|
|
6029
|
+
// Credited before the exit is judged, so a stage that was killed or
|
|
6030
|
+
// skipped still counts what its `result` events said it used.
|
|
6031
|
+
if (last?.usage) {
|
|
6032
|
+
spent.tokensIn += last.usage.input;
|
|
6033
|
+
spent.tokensOut += last.usage.output;
|
|
6034
|
+
stageSpent.tokensIn += last.usage.input;
|
|
6035
|
+
stageSpent.tokensOut += last.usage.output;
|
|
6036
|
+
}
|
|
5685
6037
|
|
|
5686
6038
|
// A clean exit, OR a turn that ended. The second half is the half that
|
|
5687
6039
|
// matters now: `endSession` closes a stage's input when its turn is over
|
|
@@ -5798,6 +6150,15 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
|
5798
6150
|
|
|
5799
6151
|
carried[stage.stage] = last.text;
|
|
5800
6152
|
|
|
6153
|
+
// R300. The scanner, after TEST settles and before the next stage — on
|
|
6154
|
+
// the tree the tests just ran against, by the daemon and not the session.
|
|
6155
|
+
// AWAITED: the workspace is released in `perform`'s finally, and a
|
|
6156
|
+
// scanner still reading files when the next run's reset runs is the
|
|
6157
|
+
// hazard; the block's `timeoutSeconds` bounds the wait. It never throws.
|
|
6158
|
+
if (stage.stage === 'TEST' && run.branch && writesAnythingProfile(run)) {
|
|
6159
|
+
await scanQuality(config, run, cwd, run.branch);
|
|
6160
|
+
}
|
|
6161
|
+
|
|
5801
6162
|
// R108. The MEMORY stage's output IS the briefing, saved by the DAEMON
|
|
5802
6163
|
// rather than by the session calling a tool. One less thing to trust, and
|
|
5803
6164
|
// one less tool to hand a stage that should not be writing.
|
|
@@ -5908,7 +6269,11 @@ function gateTimeoutSeconds() {
|
|
|
5908
6269
|
async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, resume,
|
|
5909
6270
|
projectServers, expertAgents, skills, instincts, briefing, plan, lifecycle, shield,
|
|
5910
6271
|
stage = null,
|
|
5911
|
-
carried = null
|
|
6272
|
+
carried = null,
|
|
6273
|
+
// R305. `{ run, stage }`: what the run and this stage had already used
|
|
6274
|
+
// before this child, as the walk counted them. Null on an unstaged run or
|
|
6275
|
+
// a resume, where the claim's own figure is the base and there is no stage.
|
|
6276
|
+
usage = null) {
|
|
5912
6277
|
// R51: what this machine will let a STORED rule cover. The project's rules
|
|
5913
6278
|
// are filtered through it before they go anywhere near a spawn, so the
|
|
5914
6279
|
// platform can narrow what runs here and never widen it.
|
|
@@ -5955,7 +6320,7 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
5955
6320
|
const agyCmd = config.agentCommands.find((each) => each.endsWith('agy')) ?? 'agy';
|
|
5956
6321
|
return spawnAntigravity(config, run, runToken, cwd, baseCommit, resume, projectServers,
|
|
5957
6322
|
skills, stageExperts, cannotDelegate, instincts, briefing, plan, lifecycle, stage, carried,
|
|
5958
|
-
agyCmd, granted);
|
|
6323
|
+
agyCmd, granted, usage);
|
|
5959
6324
|
}
|
|
5960
6325
|
|
|
5961
6326
|
const ceiling = [
|
|
@@ -6073,10 +6438,31 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
6073
6438
|
// required HERE: requiring what the stage was deliberately not given is how
|
|
6074
6439
|
// this would come to widen what a run may do, which is the one thing it must
|
|
6075
6440
|
// not do. The mode is read after the narrowing, always.
|
|
6441
|
+
//
|
|
6442
|
+
// R303. An MCP server can be required too, and the entry comes from
|
|
6443
|
+
// `attached` — what this machine actually spawned — and NEVER from the
|
|
6444
|
+
// claim's list, for exactly the reason above: a server the machine refused
|
|
6445
|
+
// cannot be required of a session that has no such tool. And only when the
|
|
6446
|
+
// stage can reach it: a read-only stage is spawned with `listOnly`, which
|
|
6447
|
+
// strips the permission prompt, and `mcp__<server>__*` is never on its list —
|
|
6448
|
+
// so a PLAN stage told it MUST call `mcp__codegraph__…` would be nudged for
|
|
6449
|
+
// something it was deliberately not given. IMPLEMENT and TEST, and a run
|
|
6450
|
+
// with no lifecycle, keep the prompt and can answer the first call.
|
|
6451
|
+
//
|
|
6452
|
+
// Keyed `<kind>:<name>` on both sides — here and where `used` is filled —
|
|
6453
|
+
// so a server named like a skill cannot satisfy the other's requirement.
|
|
6454
|
+
const serversCanBeReached = !readOnlyStage;
|
|
6076
6455
|
const mustUse = new Set([
|
|
6077
|
-
...stageExperts.filter((each) => each.mode === 'REQUIRED')
|
|
6078
|
-
|
|
6456
|
+
...stageExperts.filter((each) => each.mode === 'REQUIRED')
|
|
6457
|
+
.map((each) => `EXPERT:${qualified(each.key)}`),
|
|
6458
|
+
...skills.filter((each) => each.mode === 'REQUIRED')
|
|
6459
|
+
.map((each) => `SKILL:${qualified(each.key)}`),
|
|
6460
|
+
...(serversCanBeReached ? attached : [])
|
|
6461
|
+
.filter(({ skill }) => skill.mode === 'REQUIRED')
|
|
6462
|
+
.map(({ skill }) => `MCP_SERVER:${skill.serverName}`),
|
|
6079
6463
|
]);
|
|
6464
|
+
/** The transcript's name for a requirement — what the nudge and the log say. */
|
|
6465
|
+
const nameOf = (entry) => entry.slice(entry.indexOf(':') + 1);
|
|
6080
6466
|
|
|
6081
6467
|
// R104/R105. The experts and skills the project turned on, as one plugin in
|
|
6082
6468
|
// the daemon's own directory — see writeRunPlugin for why not the checkout.
|
|
@@ -6216,6 +6602,14 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
6216
6602
|
description: each.description ?? each.name ?? each.key,
|
|
6217
6603
|
required: each.mode === 'REQUIRED',
|
|
6218
6604
|
})),
|
|
6605
|
+
// R303. Only a REQUIRED server changes the prompt, and only where the
|
|
6606
|
+
// stage can reach it — the same gate `mustUse` applies, so the prompt and
|
|
6607
|
+
// the nudge never disagree about what a stage owes.
|
|
6608
|
+
mcpServers: attached.map(({ skill }) => ({
|
|
6609
|
+
name: skill.name ?? skill.key,
|
|
6610
|
+
prefix: skill.toolPrefix ?? `mcp__${skill.serverName}`,
|
|
6611
|
+
required: serversCanBeReached && skill.mode === 'REQUIRED',
|
|
6612
|
+
})),
|
|
6219
6613
|
cannotDelegate,
|
|
6220
6614
|
// R124. Null on everything but an implementation phase whose card has been
|
|
6221
6615
|
// planned, which the PLATFORM decides — the runner does not work out which
|
|
@@ -6345,6 +6739,9 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
6345
6739
|
?? (promptFor(run) + harness + (stage ? stagePrompt(stage, carried) : '')));
|
|
6346
6740
|
|
|
6347
6741
|
let lastText = '';
|
|
6742
|
+
// R305. This child's own last reading, handed back to the walk so the next
|
|
6743
|
+
// stage's base can move.
|
|
6744
|
+
let childTotals = null;
|
|
6348
6745
|
// Whether the CLI ever said its turn was over. This, and not the exit code,
|
|
6349
6746
|
// is what says a STAGE did its work — see the failure branch in
|
|
6350
6747
|
// walkLifecycle, and endSession above for why the two came apart.
|
|
@@ -6453,7 +6850,7 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
6453
6850
|
// R161. A required one says so where the person is looking. Only when the
|
|
6454
6851
|
// stage can actually reach it: `cannot` and this are mutually exclusive
|
|
6455
6852
|
// by construction, because `mustUse` is built from `stageExperts`.
|
|
6456
|
-
const must = mustUse.has(qualified(agent.key))
|
|
6853
|
+
const must = mustUse.has(`EXPERT:${qualified(agent.key)}`)
|
|
6457
6854
|
? ' This project REQUIRES it: if this stage finishes without using it, the session '
|
|
6458
6855
|
+ 'will be told.'
|
|
6459
6856
|
: '';
|
|
@@ -6464,7 +6861,7 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
6464
6861
|
}
|
|
6465
6862
|
|
|
6466
6863
|
for (const skill of skills) {
|
|
6467
|
-
const must = mustUse.has(qualified(skill.key))
|
|
6864
|
+
const must = mustUse.has(`SKILL:${qualified(skill.key)}`)
|
|
6468
6865
|
? ' This project REQUIRES it: if this stage finishes without using it, the session '
|
|
6469
6866
|
+ 'will be told.'
|
|
6470
6867
|
: '';
|
|
@@ -6496,8 +6893,10 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
6496
6893
|
|
|
6497
6894
|
// R76. What this run had already used before this child started. Zero on an
|
|
6498
6895
|
// ordinary run; on a resume it is what the earlier session spent, which the
|
|
6499
|
-
// claim carries on the run itself.
|
|
6500
|
-
|
|
6896
|
+
// claim carries on the run itself. R305: on a staged run the walk keeps the
|
|
6897
|
+
// count, because the claim's figure never moves and every stage after the
|
|
6898
|
+
// first would otherwise report from zero again.
|
|
6899
|
+
const usageBase = usage?.run ?? {
|
|
6501
6900
|
tokensIn: Number(run.tokensIn) || 0,
|
|
6502
6901
|
tokensOut: Number(run.tokensOut) || 0,
|
|
6503
6902
|
};
|
|
@@ -6578,7 +6977,7 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
6578
6977
|
// lifecycle never reaches here — `endSession` is deliberately not
|
|
6579
6978
|
// called for one — so REQUIRED is prompt wording and a console row
|
|
6580
6979
|
// on ASK, ROADMAP and AUDIT, and nothing more.
|
|
6581
|
-
const missing = [...mustUse].filter((
|
|
6980
|
+
const missing = [...mustUse].filter((entry) => !used.has(entry)).map(nameOf);
|
|
6582
6981
|
// Whether the input stays open for one more turn. NOT an early
|
|
6583
6982
|
// return out of this handler, which the obvious shape would be:
|
|
6584
6983
|
// this is the stdout reader's `while`, and returning from it would
|
|
@@ -6635,10 +7034,19 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
6635
7034
|
if (event?.type === 'result') {
|
|
6636
7035
|
const totals = totalsOf(event);
|
|
6637
7036
|
if (totals) {
|
|
7037
|
+
childTotals = totals;
|
|
6638
7038
|
reportUsage(config, run, {
|
|
6639
7039
|
tokensIn: usageBase.tokensIn + totals.input,
|
|
6640
7040
|
tokensOut: usageBase.tokensOut + totals.output,
|
|
6641
7041
|
});
|
|
7042
|
+
// R305. The same reading, filed on the stage: its own base plus
|
|
7043
|
+
// this child, absolute for the stage across its attempts.
|
|
7044
|
+
if (stage && usage?.stage) {
|
|
7045
|
+
reportStageUsage(config, run, stage.stage, {
|
|
7046
|
+
tokensIn: usage.stage.tokensIn + totals.input,
|
|
7047
|
+
tokensOut: usage.stage.tokensOut + totals.output,
|
|
7048
|
+
});
|
|
7049
|
+
}
|
|
6642
7050
|
}
|
|
6643
7051
|
}
|
|
6644
7052
|
// R113. Anything at all counts as alive.
|
|
@@ -6682,7 +7090,7 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
6682
7090
|
// already being walked. See ../lib/tool-line.mjs.
|
|
6683
7091
|
const reached = capabilityIn(recorded.body);
|
|
6684
7092
|
if (reached) {
|
|
6685
|
-
used.add(reached.key);
|
|
7093
|
+
used.add(`${reached.kind}:${reached.key}`);
|
|
6686
7094
|
}
|
|
6687
7095
|
}
|
|
6688
7096
|
if (stage && recorded.kind === 'TOOL') {
|
|
@@ -6815,7 +7223,9 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
6815
7223
|
// R112. A stage hands its result back so the loop can store the plan,
|
|
6816
7224
|
// gate on it, and carry it into the next stage. A run with no lifecycle
|
|
6817
7225
|
// resolves with nothing, exactly as it always did.
|
|
6818
|
-
resolvePromise(stage
|
|
7226
|
+
resolvePromise(stage
|
|
7227
|
+
? { code, signal, text: lastText, turnEnded, usage: childTotals }
|
|
7228
|
+
: undefined);
|
|
6819
7229
|
});
|
|
6820
7230
|
});
|
|
6821
7231
|
}
|
|
@@ -7111,7 +7521,7 @@ function inlinedSkillsFor(skills) {
|
|
|
7111
7521
|
*/
|
|
7112
7522
|
async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume, projectServers,
|
|
7113
7523
|
skills, stageExperts, cannotDelegate, instincts, briefing, plan, lifecycle, stage, carried,
|
|
7114
|
-
agyCmd, granted) {
|
|
7524
|
+
agyCmd, granted, usage = null) {
|
|
7115
7525
|
if (!granted.everything) {
|
|
7116
7526
|
const summary = 'This machine has not allowed everything, and Antigravity has no narrower '
|
|
7117
7527
|
+ 'permission mode — no per-tool allow-list exists on it, and even reading needs it. Turn '
|
|
@@ -7231,6 +7641,13 @@ async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume,
|
|
|
7231
7641
|
|
|
7232
7642
|
return new Promise((resolvePromise) => {
|
|
7233
7643
|
let result = null;
|
|
7644
|
+
// R76 / R305: what the run had already used before this process, and what
|
|
7645
|
+
// this process itself reported — see the same pair on the Claude Code path.
|
|
7646
|
+
const usageBase = usage?.run ?? {
|
|
7647
|
+
tokensIn: Number(run.tokensIn) || 0,
|
|
7648
|
+
tokensOut: Number(run.tokensOut) || 0,
|
|
7649
|
+
};
|
|
7650
|
+
let childTotals = null;
|
|
7234
7651
|
let err = '';
|
|
7235
7652
|
let buffer = '';
|
|
7236
7653
|
const child = spawn(agyCmd, args, {
|
|
@@ -7312,10 +7729,23 @@ async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume,
|
|
|
7312
7729
|
} else if (event.event === 'result') {
|
|
7313
7730
|
result = event.result;
|
|
7314
7731
|
if (result?.usage) {
|
|
7732
|
+
// R305. On the same base as the Claude Code path: what the run had
|
|
7733
|
+
// already used before this process. Reported with no base until
|
|
7734
|
+
// now, so a resume or a second stage here counted from zero.
|
|
7735
|
+
childTotals = {
|
|
7736
|
+
input: Number(result.usage.input_tokens) || 0,
|
|
7737
|
+
output: Number(result.usage.output_tokens) || 0,
|
|
7738
|
+
};
|
|
7315
7739
|
reportUsage(config, run, {
|
|
7316
|
-
tokensIn:
|
|
7317
|
-
tokensOut:
|
|
7740
|
+
tokensIn: usageBase.tokensIn + childTotals.input,
|
|
7741
|
+
tokensOut: usageBase.tokensOut + childTotals.output,
|
|
7318
7742
|
});
|
|
7743
|
+
if (stage && usage?.stage) {
|
|
7744
|
+
reportStageUsage(config, run, stage.stage, {
|
|
7745
|
+
tokensIn: usage.stage.tokensIn + childTotals.input,
|
|
7746
|
+
tokensOut: usage.stage.tokensOut + childTotals.output,
|
|
7747
|
+
});
|
|
7748
|
+
}
|
|
7319
7749
|
}
|
|
7320
7750
|
}
|
|
7321
7751
|
}
|
|
@@ -7365,7 +7795,9 @@ async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume,
|
|
|
7365
7795
|
if (!stage) {
|
|
7366
7796
|
await settleActions(config, run, cwd);
|
|
7367
7797
|
}
|
|
7368
|
-
resolvePromise(stage
|
|
7798
|
+
resolvePromise(stage
|
|
7799
|
+
? { code, signal: null, text, turnEnded, usage: childTotals }
|
|
7800
|
+
: undefined);
|
|
7369
7801
|
});
|
|
7370
7802
|
});
|
|
7371
7803
|
}
|
|
@@ -7399,6 +7831,19 @@ function reportUsage(config, run, totals) {
|
|
|
7399
7831
|
}).catch((failure) => log(` could not record what the session used: ${failure.message}`));
|
|
7400
7832
|
}
|
|
7401
7833
|
|
|
7834
|
+
/**
|
|
7835
|
+
* The same reading, for one STAGE — R305. Absolute for the stage across its
|
|
7836
|
+
* attempts, fire and forget for the reason `reportUsage` is, and the platform
|
|
7837
|
+
* keeps the largest reading here too.
|
|
7838
|
+
*/
|
|
7839
|
+
function reportStageUsage(config, run, stageName, totals) {
|
|
7840
|
+
api(config, `/api/projects/${run.projectSlug}/runs/${run.id}/stages/${stageName}/usage`, {
|
|
7841
|
+
method: 'POST',
|
|
7842
|
+
body: totals,
|
|
7843
|
+
}).catch((failure) =>
|
|
7844
|
+
log(` could not record what the ${stageName} stage used: ${failure.message}`));
|
|
7845
|
+
}
|
|
7846
|
+
|
|
7402
7847
|
async function finish(config, run, state, summary) {
|
|
7403
7848
|
await api(config, `/api/projects/${run.projectSlug}/runs/${run.id}/transition`, {
|
|
7404
7849
|
method: 'POST',
|
|
@@ -7617,6 +8062,11 @@ async function main() {
|
|
|
7617
8062
|
// The paths still go to the log — the banner says how many, this says
|
|
7618
8063
|
// which, and "which" is what you need when one of them is wrong.
|
|
7619
8064
|
log(`serving ${slug}: ${project.workspaces.join(', ')}`);
|
|
8065
|
+
// R300. Said before the first run, so the log says the feature is on
|
|
8066
|
+
// — and which variable the token is read from — before a scan is missed.
|
|
8067
|
+
if (project.quality) {
|
|
8068
|
+
log(`quality: ${slug} scans with ${project.quality.command} after TEST, token from $${project.quality.tokenEnv}`);
|
|
8069
|
+
}
|
|
7620
8070
|
}
|
|
7621
8071
|
await sayWhichServedProjectsTheTokenCannotReach(config);
|
|
7622
8072
|
|