cawdev-cli 1.0.2-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/runner-config.mjs +81 -0
- 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 +124 -12
- package/runner/attach.mjs +87 -31
- package/runner/cawdev.mjs +36 -25
- package/runner/configure.mjs +144 -21
- package/runner/runner.mjs +560 -53
- package/runner/macbook-laptop.json +0 -48
package/runner/runner.mjs
CHANGED
|
@@ -33,11 +33,13 @@ 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';
|
|
40
41
|
import { cliVersion, isBelow } from '../lib/version.mjs';
|
|
42
|
+
import { reloadableFor, restartNeededFor } from '../lib/runner-config.mjs';
|
|
41
43
|
|
|
42
44
|
// --- configuration -----------------------------------------------------------
|
|
43
45
|
|
|
@@ -163,6 +165,13 @@ const DEFAULTS = {
|
|
|
163
165
|
// would be a question with one sensible answer.
|
|
164
166
|
'mcp__cawdev__code_map',
|
|
165
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',
|
|
166
175
|
'mcp__cawdev__roadmap_statuses',
|
|
167
176
|
'mcp__cawdev__roadmap_list',
|
|
168
177
|
'mcp__cawdev__roadmap_get',
|
|
@@ -409,6 +418,14 @@ async function readConfig() {
|
|
|
409
418
|
* daemon's cwd is not the terminal's: `cawdev` starts it detached.
|
|
410
419
|
*/
|
|
411
420
|
configPath: fromFile ? resolve(path) : null,
|
|
421
|
+
/**
|
|
422
|
+
* The file as it was when this daemon booted — R294. What every later
|
|
423
|
+
* reading is compared against, so the daemon can say which of its
|
|
424
|
+
* settings the file has moved on from and only a restart would apply.
|
|
425
|
+
* The RAW file, not `config`: the question is what the operator changed,
|
|
426
|
+
* and defaults filled in here are not something they wrote.
|
|
427
|
+
*/
|
|
428
|
+
bootedFile: fromFile ? file : null,
|
|
412
429
|
};
|
|
413
430
|
|
|
414
431
|
if (!config.token) {
|
|
@@ -454,6 +471,12 @@ async function readConfig() {
|
|
|
454
471
|
* "dycrypt": "/Users/you/code/dycrypt"
|
|
455
472
|
* "dycrypt": { "path": "…", "allowedTools": ["Bash(mvn *)", "mcp__roadmap"] }
|
|
456
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.
|
|
457
480
|
*
|
|
458
481
|
* R47: a bare path means ONE workspace, which is what every config meant
|
|
459
482
|
* before workspaces existed. Concurrency for a project is
|
|
@@ -499,11 +522,51 @@ function normaliseProjects(projects) {
|
|
|
499
522
|
// falls through to the machine's answer; `false` here refuses it for one
|
|
500
523
|
// project on a machine that otherwise allows it.
|
|
501
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),
|
|
502
528
|
};
|
|
503
529
|
}
|
|
504
530
|
return normalised;
|
|
505
531
|
}
|
|
506
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
|
+
|
|
507
570
|
|
|
508
571
|
/**
|
|
509
572
|
* What THIS daemon knows about a skill, beyond what the platform sent — R76.
|
|
@@ -1296,10 +1359,17 @@ async function resolveSkills(config, run, cwd, baseCommit, asked) {
|
|
|
1296
1359
|
notes.push(await prepareSkillIndex(config, skill, local, run, cwd, baseCommit));
|
|
1297
1360
|
}
|
|
1298
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
|
+
: '';
|
|
1299
1369
|
notes.push(`${skill.name ?? skill.key} is available to this session as `
|
|
1300
1370
|
+ `${skill.toolPrefix ?? `mcp__${skill.serverName}`}. The first call still asks a person — `
|
|
1301
1371
|
+ `allow ${skill.toolPrefix ?? `mcp__${skill.serverName}`} for the session to cover the `
|
|
1302
|
-
+
|
|
1372
|
+
+ `rest.${must}`);
|
|
1303
1373
|
}
|
|
1304
1374
|
return { attached, notes };
|
|
1305
1375
|
}
|
|
@@ -2230,6 +2300,189 @@ async function surveyGit(config) {
|
|
|
2230
2300
|
}
|
|
2231
2301
|
}
|
|
2232
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
|
+
|
|
2233
2486
|
/**
|
|
2234
2487
|
* Reads and reports the code map, unless it would say the same thing again.
|
|
2235
2488
|
*
|
|
@@ -3082,7 +3335,11 @@ async function performWorkspaceRequest(config, request, afterTheTreeMoved = null
|
|
|
3082
3335
|
if (!branch) {
|
|
3083
3336
|
result = 'A merge has to name a branch. Nothing was merged.';
|
|
3084
3337
|
} else {
|
|
3085
|
-
|
|
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'}`);
|
|
3086
3343
|
// R261. A sprint's branch lands with a merge commit; every other branch
|
|
3087
3344
|
// squashes as it always did. The claim says which.
|
|
3088
3345
|
({ ok, result, failure: failureKind } = await mergePullRequest(cwd, branch, {
|
|
@@ -3352,8 +3609,10 @@ function announceRules(rules) {
|
|
|
3352
3609
|
if (!on.length) return;
|
|
3353
3610
|
log(` this project's rules: ${on.join(', ')}`);
|
|
3354
3611
|
if (rules.autoMerge) {
|
|
3355
|
-
|
|
3356
|
-
|
|
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');
|
|
3357
3616
|
}
|
|
3358
3617
|
}
|
|
3359
3618
|
|
|
@@ -3505,7 +3764,26 @@ async function openPullRequestFor(cwd, { branch, base, title, body }) {
|
|
|
3505
3764
|
* convention wants a merge commit for a release branch too, for the same
|
|
3506
3765
|
* reason; release branches are not worked through the development board
|
|
3507
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.
|
|
3508
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
|
+
|
|
3509
3787
|
async function mergePullRequest(cwd, branch, { method = null } = {}) {
|
|
3510
3788
|
const url = await findPullRequest(cwd, branch).catch(() => null);
|
|
3511
3789
|
if (!url || url.includes('/compare/')) {
|
|
@@ -3515,6 +3793,7 @@ async function mergePullRequest(cwd, branch, { method = null } = {}) {
|
|
|
3515
3793
|
result: 'There is no pull request for this branch to merge. Nothing was merged.',
|
|
3516
3794
|
};
|
|
3517
3795
|
}
|
|
3796
|
+
await waitOutUnknownMergeability(cwd, url, branch);
|
|
3518
3797
|
const mergeCommit = method === 'MERGE_COMMIT';
|
|
3519
3798
|
const merged = await gh(cwd, ['pr', 'merge', url, mergeCommit ? '--merge' : '--squash', '--delete-branch']);
|
|
3520
3799
|
if (merged.code === 0) {
|
|
@@ -3540,6 +3819,33 @@ async function mergePullRequest(cwd, branch, { method = null } = {}) {
|
|
|
3540
3819
|
};
|
|
3541
3820
|
}
|
|
3542
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
|
+
|
|
3543
3849
|
/**
|
|
3544
3850
|
* Whether a tag exists ON THE REMOTE — R156.
|
|
3545
3851
|
*
|
|
@@ -4263,8 +4569,11 @@ const PROFILE_TOOLS = {
|
|
|
4263
4569
|
// R198: a plan run on an ISSUE (a confirm) is handed exactly this list. What
|
|
4264
4570
|
// differs is one paragraph of prompt, not a tool — the "ended without a plan"
|
|
4265
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.
|
|
4266
4575
|
PLAN: (run) => [
|
|
4267
|
-
...READ_ONLY_CAWDEV,
|
|
4576
|
+
...beforeTheWorkExists(READ_ONLY_CAWDEV),
|
|
4268
4577
|
...READ_FILES,
|
|
4269
4578
|
...GIT_READS,
|
|
4270
4579
|
...(run?.entryNumber ? [] : [CARD_WRITE]),
|
|
@@ -4511,7 +4820,10 @@ function stagePrompt(stage, carried) {
|
|
|
4511
4820
|
+ '`git diff` to find out what the work actually changed. A test you could not run is '
|
|
4512
4821
|
+ 'a line in the testbook, not a failure of this stage.'
|
|
4513
4822
|
: 'Run what proves the work. Report what passed and what did not, with the output. A '
|
|
4514
|
-
+ '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.',
|
|
4515
4827
|
MEMORY: 'Write what the NEXT session on this branch needs to know: what was done, what was '
|
|
4516
4828
|
+ 'tried and rejected and why, and anything that turned out to matter and would not be '
|
|
4517
4829
|
+ 'obvious from the diff. Be brief and concrete. This is saved as this branch\'s '
|
|
@@ -4938,7 +5250,9 @@ matters — and stop there. Do not decide the verdict. That is the person's.
|
|
|
4938
5250
|
The findings go on the card and the person decides Done or Not done.
|
|
4939
5251
|
|
|
4940
5252
|
**Read the code.** Start with \`code_map\` and \`file_deps\` — cawdev has already
|
|
4941
|
-
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.
|
|
4942
5256
|
|
|
4943
5257
|
**Check against the Build: and Done when:.** Use \`roadmap_get\` to read the
|
|
4944
5258
|
card's own words, then check the code against them. Point at what is done and
|
|
@@ -5034,8 +5348,9 @@ function nudgeText(stage, missing) {
|
|
|
5034
5348
|
const names = missing.map((each) => `\`${each}\``).join(', ');
|
|
5035
5349
|
return `cawdev: this project requires ${names}, and this ${stage.stage} stage has not `
|
|
5036
5350
|
+ 'used it.\n\n'
|
|
5037
|
-
+ 'Use it now — `Skill` with `skill: "<name>"`,
|
|
5038
|
-
+ '— 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'
|
|
5039
5354
|
+ 'If it genuinely does not apply to this work, say so in one sentence and stop. '
|
|
5040
5355
|
+ '**You will not be asked again.**';
|
|
5041
5356
|
}
|
|
@@ -5403,6 +5718,14 @@ async function startRun(config, offered, workspace) {
|
|
|
5403
5718
|
if (run.baseBranch) {
|
|
5404
5719
|
log(` cut from ${run.baseBranch} (the sprint's branch)`);
|
|
5405
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;
|
|
5406
5729
|
// R69. Whether this is the same conversation picked back up, and which one.
|
|
5407
5730
|
// Null on an ordinary claim, which is nearly all of them.
|
|
5408
5731
|
resume = claimed.resume ?? null;
|
|
@@ -5643,9 +5966,9 @@ async function skipRest(config, run, lifecycle, from, why) {
|
|
|
5643
5966
|
|
|
5644
5967
|
async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace, resume,
|
|
5645
5968
|
projectServers, expertAgents, skills, instincts, briefing, plan, lifecycle, shield) {
|
|
5646
|
-
const spawn = (stage, carried) => spawnAgent(config, run, runToken, cwd, baseCommit,
|
|
5647
|
-
resume, projectServers, expertAgents, skills, instincts, briefing, plan, lifecycle,
|
|
5648
|
-
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);
|
|
5649
5972
|
|
|
5650
5973
|
// No lifecycle, or a resume: one process, exactly as before.
|
|
5651
5974
|
//
|
|
@@ -5662,8 +5985,34 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
|
5662
5985
|
const carried = {};
|
|
5663
5986
|
let last = null;
|
|
5664
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
|
+
|
|
5665
6008
|
for (let at = 0; at < lifecycle.length; at++) {
|
|
5666
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);
|
|
5667
6016
|
// Two attempts at most, and the second only exists because a REFUSED gate
|
|
5668
6017
|
// should cost one cheap stage rather than a whole run. A third would be a
|
|
5669
6018
|
// loop nobody asked for.
|
|
@@ -5672,7 +6021,19 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
|
5672
6021
|
log(` ${stage.stage}${stage.model ? ` on ${stage.model}` : ''}`
|
|
5673
6022
|
+ `${attempt ? ' (again, after the gate refused it)' : ''}`);
|
|
5674
6023
|
|
|
5675
|
-
|
|
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
|
+
}
|
|
5676
6037
|
|
|
5677
6038
|
// A clean exit, OR a turn that ended. The second half is the half that
|
|
5678
6039
|
// matters now: `endSession` closes a stage's input when its turn is over
|
|
@@ -5789,6 +6150,15 @@ async function walkLifecycle(config, run, runToken, cwd, baseCommit, workspace,
|
|
|
5789
6150
|
|
|
5790
6151
|
carried[stage.stage] = last.text;
|
|
5791
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
|
+
|
|
5792
6162
|
// R108. The MEMORY stage's output IS the briefing, saved by the DAEMON
|
|
5793
6163
|
// rather than by the session calling a tool. One less thing to trust, and
|
|
5794
6164
|
// one less tool to hand a stage that should not be writing.
|
|
@@ -5899,7 +6269,11 @@ function gateTimeoutSeconds() {
|
|
|
5899
6269
|
async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, resume,
|
|
5900
6270
|
projectServers, expertAgents, skills, instincts, briefing, plan, lifecycle, shield,
|
|
5901
6271
|
stage = null,
|
|
5902
|
-
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) {
|
|
5903
6277
|
// R51: what this machine will let a STORED rule cover. The project's rules
|
|
5904
6278
|
// are filtered through it before they go anywhere near a spawn, so the
|
|
5905
6279
|
// platform can narrow what runs here and never widen it.
|
|
@@ -5946,7 +6320,7 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
5946
6320
|
const agyCmd = config.agentCommands.find((each) => each.endsWith('agy')) ?? 'agy';
|
|
5947
6321
|
return spawnAntigravity(config, run, runToken, cwd, baseCommit, resume, projectServers,
|
|
5948
6322
|
skills, stageExperts, cannotDelegate, instincts, briefing, plan, lifecycle, stage, carried,
|
|
5949
|
-
agyCmd, granted);
|
|
6323
|
+
agyCmd, granted, usage);
|
|
5950
6324
|
}
|
|
5951
6325
|
|
|
5952
6326
|
const ceiling = [
|
|
@@ -6064,10 +6438,31 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
6064
6438
|
// required HERE: requiring what the stage was deliberately not given is how
|
|
6065
6439
|
// this would come to widen what a run may do, which is the one thing it must
|
|
6066
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;
|
|
6067
6455
|
const mustUse = new Set([
|
|
6068
|
-
...stageExperts.filter((each) => each.mode === 'REQUIRED')
|
|
6069
|
-
|
|
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}`),
|
|
6070
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);
|
|
6071
6466
|
|
|
6072
6467
|
// R104/R105. The experts and skills the project turned on, as one plugin in
|
|
6073
6468
|
// the daemon's own directory — see writeRunPlugin for why not the checkout.
|
|
@@ -6207,6 +6602,14 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
6207
6602
|
description: each.description ?? each.name ?? each.key,
|
|
6208
6603
|
required: each.mode === 'REQUIRED',
|
|
6209
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
|
+
})),
|
|
6210
6613
|
cannotDelegate,
|
|
6211
6614
|
// R124. Null on everything but an implementation phase whose card has been
|
|
6212
6615
|
// planned, which the PLATFORM decides — the runner does not work out which
|
|
@@ -6336,6 +6739,9 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
6336
6739
|
?? (promptFor(run) + harness + (stage ? stagePrompt(stage, carried) : '')));
|
|
6337
6740
|
|
|
6338
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;
|
|
6339
6745
|
// Whether the CLI ever said its turn was over. This, and not the exit code,
|
|
6340
6746
|
// is what says a STAGE did its work — see the failure branch in
|
|
6341
6747
|
// walkLifecycle, and endSession above for why the two came apart.
|
|
@@ -6444,7 +6850,7 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
6444
6850
|
// R161. A required one says so where the person is looking. Only when the
|
|
6445
6851
|
// stage can actually reach it: `cannot` and this are mutually exclusive
|
|
6446
6852
|
// by construction, because `mustUse` is built from `stageExperts`.
|
|
6447
|
-
const must = mustUse.has(qualified(agent.key))
|
|
6853
|
+
const must = mustUse.has(`EXPERT:${qualified(agent.key)}`)
|
|
6448
6854
|
? ' This project REQUIRES it: if this stage finishes without using it, the session '
|
|
6449
6855
|
+ 'will be told.'
|
|
6450
6856
|
: '';
|
|
@@ -6455,7 +6861,7 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
6455
6861
|
}
|
|
6456
6862
|
|
|
6457
6863
|
for (const skill of skills) {
|
|
6458
|
-
const must = mustUse.has(qualified(skill.key))
|
|
6864
|
+
const must = mustUse.has(`SKILL:${qualified(skill.key)}`)
|
|
6459
6865
|
? ' This project REQUIRES it: if this stage finishes without using it, the session '
|
|
6460
6866
|
+ 'will be told.'
|
|
6461
6867
|
: '';
|
|
@@ -6487,8 +6893,10 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
6487
6893
|
|
|
6488
6894
|
// R76. What this run had already used before this child started. Zero on an
|
|
6489
6895
|
// ordinary run; on a resume it is what the earlier session spent, which the
|
|
6490
|
-
// claim carries on the run itself.
|
|
6491
|
-
|
|
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 ?? {
|
|
6492
6900
|
tokensIn: Number(run.tokensIn) || 0,
|
|
6493
6901
|
tokensOut: Number(run.tokensOut) || 0,
|
|
6494
6902
|
};
|
|
@@ -6569,7 +6977,7 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
6569
6977
|
// lifecycle never reaches here — `endSession` is deliberately not
|
|
6570
6978
|
// called for one — so REQUIRED is prompt wording and a console row
|
|
6571
6979
|
// on ASK, ROADMAP and AUDIT, and nothing more.
|
|
6572
|
-
const missing = [...mustUse].filter((
|
|
6980
|
+
const missing = [...mustUse].filter((entry) => !used.has(entry)).map(nameOf);
|
|
6573
6981
|
// Whether the input stays open for one more turn. NOT an early
|
|
6574
6982
|
// return out of this handler, which the obvious shape would be:
|
|
6575
6983
|
// this is the stdout reader's `while`, and returning from it would
|
|
@@ -6626,10 +7034,19 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
6626
7034
|
if (event?.type === 'result') {
|
|
6627
7035
|
const totals = totalsOf(event);
|
|
6628
7036
|
if (totals) {
|
|
7037
|
+
childTotals = totals;
|
|
6629
7038
|
reportUsage(config, run, {
|
|
6630
7039
|
tokensIn: usageBase.tokensIn + totals.input,
|
|
6631
7040
|
tokensOut: usageBase.tokensOut + totals.output,
|
|
6632
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
|
+
}
|
|
6633
7050
|
}
|
|
6634
7051
|
}
|
|
6635
7052
|
// R113. Anything at all counts as alive.
|
|
@@ -6673,7 +7090,7 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
6673
7090
|
// already being walked. See ../lib/tool-line.mjs.
|
|
6674
7091
|
const reached = capabilityIn(recorded.body);
|
|
6675
7092
|
if (reached) {
|
|
6676
|
-
used.add(reached.key);
|
|
7093
|
+
used.add(`${reached.kind}:${reached.key}`);
|
|
6677
7094
|
}
|
|
6678
7095
|
}
|
|
6679
7096
|
if (stage && recorded.kind === 'TOOL') {
|
|
@@ -6806,7 +7223,9 @@ async function spawnAgent(config, run, runToken, cwd, baseCommit, workspace, res
|
|
|
6806
7223
|
// R112. A stage hands its result back so the loop can store the plan,
|
|
6807
7224
|
// gate on it, and carry it into the next stage. A run with no lifecycle
|
|
6808
7225
|
// resolves with nothing, exactly as it always did.
|
|
6809
|
-
resolvePromise(stage
|
|
7226
|
+
resolvePromise(stage
|
|
7227
|
+
? { code, signal, text: lastText, turnEnded, usage: childTotals }
|
|
7228
|
+
: undefined);
|
|
6810
7229
|
});
|
|
6811
7230
|
});
|
|
6812
7231
|
}
|
|
@@ -7102,7 +7521,7 @@ function inlinedSkillsFor(skills) {
|
|
|
7102
7521
|
*/
|
|
7103
7522
|
async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume, projectServers,
|
|
7104
7523
|
skills, stageExperts, cannotDelegate, instincts, briefing, plan, lifecycle, stage, carried,
|
|
7105
|
-
agyCmd, granted) {
|
|
7524
|
+
agyCmd, granted, usage = null) {
|
|
7106
7525
|
if (!granted.everything) {
|
|
7107
7526
|
const summary = 'This machine has not allowed everything, and Antigravity has no narrower '
|
|
7108
7527
|
+ 'permission mode — no per-tool allow-list exists on it, and even reading needs it. Turn '
|
|
@@ -7222,6 +7641,13 @@ async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume,
|
|
|
7222
7641
|
|
|
7223
7642
|
return new Promise((resolvePromise) => {
|
|
7224
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;
|
|
7225
7651
|
let err = '';
|
|
7226
7652
|
let buffer = '';
|
|
7227
7653
|
const child = spawn(agyCmd, args, {
|
|
@@ -7303,10 +7729,23 @@ async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume,
|
|
|
7303
7729
|
} else if (event.event === 'result') {
|
|
7304
7730
|
result = event.result;
|
|
7305
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
|
+
};
|
|
7306
7739
|
reportUsage(config, run, {
|
|
7307
|
-
tokensIn:
|
|
7308
|
-
tokensOut:
|
|
7740
|
+
tokensIn: usageBase.tokensIn + childTotals.input,
|
|
7741
|
+
tokensOut: usageBase.tokensOut + childTotals.output,
|
|
7309
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
|
+
}
|
|
7310
7749
|
}
|
|
7311
7750
|
}
|
|
7312
7751
|
}
|
|
@@ -7356,7 +7795,9 @@ async function spawnAntigravity(config, run, runToken, cwd, baseCommit, resume,
|
|
|
7356
7795
|
if (!stage) {
|
|
7357
7796
|
await settleActions(config, run, cwd);
|
|
7358
7797
|
}
|
|
7359
|
-
resolvePromise(stage
|
|
7798
|
+
resolvePromise(stage
|
|
7799
|
+
? { code, signal: null, text, turnEnded, usage: childTotals }
|
|
7800
|
+
: undefined);
|
|
7360
7801
|
});
|
|
7361
7802
|
});
|
|
7362
7803
|
}
|
|
@@ -7390,6 +7831,19 @@ function reportUsage(config, run, totals) {
|
|
|
7390
7831
|
}).catch((failure) => log(` could not record what the session used: ${failure.message}`));
|
|
7391
7832
|
}
|
|
7392
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
|
+
|
|
7393
7847
|
async function finish(config, run, state, summary) {
|
|
7394
7848
|
await api(config, `/api/projects/${run.projectSlug}/runs/${run.id}/transition`, {
|
|
7395
7849
|
method: 'POST',
|
|
@@ -7608,6 +8062,11 @@ async function main() {
|
|
|
7608
8062
|
// The paths still go to the log — the banner says how many, this says
|
|
7609
8063
|
// which, and "which" is what you need when one of them is wrong.
|
|
7610
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
|
+
}
|
|
7611
8070
|
}
|
|
7612
8071
|
await sayWhichServedProjectsTheTokenCannotReach(config);
|
|
7613
8072
|
|
|
@@ -7659,6 +8118,11 @@ async function main() {
|
|
|
7659
8118
|
// inventing a file.
|
|
7660
8119
|
agents: [...config.agentCommands],
|
|
7661
8120
|
configPath: config.configPath,
|
|
8121
|
+
// R294: the keys the file has moved on from since boot that only a restart
|
|
8122
|
+
// would apply — nothing, at boot, by definition — and when the file was
|
|
8123
|
+
// last read, so a door that just signalled can wait for a fresh answer.
|
|
8124
|
+
restartNeeded: [],
|
|
8125
|
+
configReadAt: new Date().toISOString(),
|
|
7662
8126
|
// R81. `cawdev` starts a daemon for you when it finds none, and
|
|
7663
8127
|
// quitting the UI leaves it running — it is driving sessions. A
|
|
7664
8128
|
// background process you did not know you started is the cost of that
|
|
@@ -7668,45 +8132,88 @@ async function main() {
|
|
|
7668
8132
|
};
|
|
7669
8133
|
|
|
7670
8134
|
// R283. `cawdev config agent enable/disable` and `cawdev config rules
|
|
7671
|
-
// on/off` write the file and, if a daemon is running on it, send
|
|
8135
|
+
// on/off` write the file and, if a daemon is running on it, send SIGHUP — NOT
|
|
7672
8136
|
// a control-socket command. `control.mjs` is deliberately read-only ("no
|
|
7673
8137
|
// command can" change anything, so a person acting through it never gets
|
|
7674
8138
|
// more than their own HTTPS session already grants); SIGHUP is a unix
|
|
7675
8139
|
// convention for "re-read your config", asked of a process this operator
|
|
7676
8140
|
// already owns, so it does not open that door. Only the settings that are
|
|
7677
|
-
// genuinely safe to hot-swap
|
|
7678
|
-
//
|
|
7679
|
-
//
|
|
8141
|
+
// genuinely safe to hot-swap — `HOT_SWAPPABLE`, in lib/runner-config.mjs,
|
|
8142
|
+
// the ONE spelling of that list — are applied: `agentCommands`, `grantable`
|
|
8143
|
+
// and `acceptsRulesFromConsole` are read fresh from `config` on every use
|
|
8144
|
+
// (spawn, or the next heartbeat), never captured once at boot, so mutating
|
|
7680
8145
|
// them here is enough, with no further plumbing.
|
|
7681
|
-
|
|
7682
|
-
|
|
8146
|
+
//
|
|
8147
|
+
// R294: and the daemon SAYS what it could not apply. Everything else in the
|
|
8148
|
+
// file is read once at boot, and until this a project added to the file was
|
|
8149
|
+
// logged as "nothing changed" — the handler diffed only the three keys it
|
|
8150
|
+
// could swap. `rereadConfig` compares the whole file against the one this
|
|
8151
|
+
// process BOOTED with (not the last one read, so an edit reverted by hand
|
|
8152
|
+
// clears the notice by itself) and publishes the keys only a restart would
|
|
8153
|
+
// apply as `restartNeeded` on the hello, with `configReadAt` so a door that
|
|
8154
|
+
// just signalled can tell a fresh answer from the old one. A notice, never a
|
|
8155
|
+
// restart — R113's rule: the daemon says so, and a person decides. Two
|
|
8156
|
+
// triggers, one function: the signal, and the heartbeat noticing the file's
|
|
8157
|
+
// mtime moved, which is what covers a hand edit with no door at all.
|
|
8158
|
+
let configSeenAt = null;
|
|
8159
|
+
const rereadConfig = async (why) => {
|
|
8160
|
+
const path = config.configPath ?? configPathFromArgv();
|
|
7683
8161
|
try {
|
|
8162
|
+
// The mtime first, so a re-read on a signal is not followed by a second
|
|
8163
|
+
// one on the next beat for the same write.
|
|
8164
|
+
configSeenAt = (await stat(path).catch(() => null))?.mtimeMs ?? configSeenAt;
|
|
7684
8165
|
const file = JSON.parse(await readFile(path, 'utf8'));
|
|
7685
|
-
|
|
7686
|
-
|
|
7687
|
-
|
|
7688
|
-
|
|
8166
|
+
// What the three keys RESOLVE to, defaults filled in as `readConfig`
|
|
8167
|
+
// fills them, so a key the file omits is not reported as a change.
|
|
8168
|
+
const resolved = {
|
|
8169
|
+
acceptsRulesFromConsole: file.acceptsRulesFromConsole ?? DEFAULTS.acceptsRulesFromConsole,
|
|
8170
|
+
grantable: file.grantable ?? DEFAULTS.grantable,
|
|
8171
|
+
agentCommands: file.agentCommands
|
|
8172
|
+
?? (file.agentCommand ? [file.agentCommand] : null)
|
|
8173
|
+
?? DEFAULTS.agentCommands,
|
|
7689
8174
|
};
|
|
7690
|
-
|
|
7691
|
-
|
|
7692
|
-
|
|
7693
|
-
|
|
7694
|
-
|
|
7695
|
-
|
|
8175
|
+
const applied = reloadableFor({
|
|
8176
|
+
agentCommands: config.agentCommands,
|
|
8177
|
+
grantable: config.grantable,
|
|
8178
|
+
acceptsRulesFromConsole: config.acceptsRulesFromConsole,
|
|
8179
|
+
}, resolved);
|
|
8180
|
+
Object.assign(config, resolved);
|
|
7696
8181
|
// R288: the hello describes the current state, not the booted one — an
|
|
7697
8182
|
// attach opened after a toggle would otherwise offer to turn on what is
|
|
7698
8183
|
// already on. The same object `serveControl` sends, mutated in place.
|
|
7699
8184
|
advertised.acceptsConsoleRules = config.acceptsRulesFromConsole === true;
|
|
7700
8185
|
advertised.agents = [...config.agentCommands];
|
|
7701
|
-
|
|
7702
|
-
|
|
7703
|
-
|
|
7704
|
-
? `
|
|
7705
|
-
|
|
8186
|
+
advertised.restartNeeded = config.bootedFile ? restartNeededFor(config.bootedFile, file) : [];
|
|
8187
|
+
advertised.configReadAt = new Date().toISOString();
|
|
8188
|
+
const said = [
|
|
8189
|
+
applied.length ? `applied: ${applied.join(', ')}` : null,
|
|
8190
|
+
advertised.restartNeeded.length ? `needs restart: ${advertised.restartNeeded.join(', ')}` : null,
|
|
8191
|
+
].filter(Boolean);
|
|
8192
|
+
log(`re-read ${path} on ${why} — ${said.length ? said.join('; ') : 'nothing changed'}`);
|
|
7706
8193
|
} catch (failure) {
|
|
7707
|
-
log(
|
|
8194
|
+
log(`${why} re-read of ${path} failed: ${failure.message}`);
|
|
7708
8195
|
}
|
|
7709
|
-
|
|
8196
|
+
// A terminal already attached got its hello before this; the daemon tells
|
|
8197
|
+
// it rather than waiting for the next attach. An older client ignores an
|
|
8198
|
+
// event type it does not know.
|
|
8199
|
+
control?.publish({ type: 'runner', runner: advertised });
|
|
8200
|
+
};
|
|
8201
|
+
process.on('SIGHUP', () => rereadConfig('SIGHUP'));
|
|
8202
|
+
|
|
8203
|
+
// The heartbeat's half of R294: the file's mtime, read once per beat, and a
|
|
8204
|
+
// re-read when it moved. `stat` and nothing more when it did not, which is
|
|
8205
|
+
// what makes this affordable every thirty seconds. Skipped for a daemon
|
|
8206
|
+
// booted from the environment alone — there is no file to have changed.
|
|
8207
|
+
configSeenAt = config.configPath ? (await stat(config.configPath).catch(() => null))?.mtimeMs ?? null : null;
|
|
8208
|
+
const noticeConfigEdits = async () => {
|
|
8209
|
+
if (!config.configPath) return;
|
|
8210
|
+
const seen = (await stat(config.configPath).catch(() => null))?.mtimeMs ?? null;
|
|
8211
|
+
if (seen === null || seen === configSeenAt) return;
|
|
8212
|
+
// `rereadConfig` records the mtime before it reads: a file caught
|
|
8213
|
+
// mid-write fails to parse, and the write that finishes it moves the
|
|
8214
|
+
// mtime again.
|
|
8215
|
+
await rereadConfig('edit');
|
|
8216
|
+
};
|
|
7710
8217
|
|
|
7711
8218
|
// R52. Nothing here is load-bearing for running an agent, so a daemon that
|
|
7712
8219
|
// cannot open a socket says so and carries on: trading the ability to run
|
|
@@ -7814,7 +8321,7 @@ async function main() {
|
|
|
7814
8321
|
: [];
|
|
7815
8322
|
}
|
|
7816
8323
|
}).catch((failure) => log(`heartbeat failed: ${failure.message}`));
|
|
7817
|
-
await Promise.all([workspacesReported, heartbeatAnswered]);
|
|
8324
|
+
await Promise.all([workspacesReported, heartbeatAnswered, noticeConfigEdits()]);
|
|
7818
8325
|
};
|
|
7819
8326
|
|
|
7820
8327
|
await beat();
|