cawdev-cli 1.1.0 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/finish-check.mjs +34 -1
- package/lib/harness-prompt.mjs +6 -0
- package/lib/order.mjs +7 -0
- package/lib/protected.mjs +225 -0
- package/lib/roadmap-format.mjs +4 -2
- package/lib/runner-config.mjs +9 -0
- package/lib/secrets.mjs +2 -2
- package/lib/sonar-analysis.mjs +52 -0
- package/lib/sonarqube-mcp.mjs +50 -0
- package/lib/stage-tools.mjs +52 -1
- package/lib/version-files.mjs +99 -0
- package/mcp/README.md +5 -6
- package/mcp/server.mjs +183 -25
- package/package.json +1 -1
- package/runner/README.md +79 -0
- package/runner/attach.mjs +46 -1
- package/runner/cawdev.mjs +20 -2
- package/runner/configure.mjs +62 -0
- package/runner/git-host.mjs +298 -5
- package/runner/pause-note.mjs +104 -0
- package/runner/push-guard.mjs +333 -0
- package/runner/runner.mjs +681 -180
package/mcp/README.md
CHANGED
|
@@ -67,7 +67,8 @@ The sequence is fixed, and the second step is the one people skip:
|
|
|
67
67
|
"what is being worked on right now" without asking anyone.
|
|
68
68
|
3. Build to the card's "Done when" list.
|
|
69
69
|
4. Finish with a PR. Never push to `main`. As your session ends the platform
|
|
70
|
-
moves the *branch* on the development board, not the card: to `
|
|
70
|
+
moves the *branch* on the development board, not the card: to `TEST` (the board
|
|
71
|
+
column, not the TEST stage of a run) where
|
|
71
72
|
the project runs `require_review` and somebody is going to read it, and to
|
|
72
73
|
`DONE` where it does not — a finish nobody was asked about (R132), because
|
|
73
74
|
there is no reviewer coming.
|
|
@@ -79,9 +80,7 @@ The sequence is fixed, and the second step is the one people skip:
|
|
|
79
80
|
`roadmap_statuses` will tell you, and it is worth reading rather than guessing:
|
|
80
81
|
`IN_DEVELOPMENT` needs a branch, `MERGED` needs the merge — a pull request, a merge
|
|
81
82
|
commit, or a sha — `SHIPPED` needs a version that is a real git tag, and
|
|
82
|
-
`DECLINED` needs a reason.
|
|
83
|
-
request it would otherwise carry belongs to the run, and a project that reviews
|
|
84
|
-
without opening pull requests has none to give it.
|
|
83
|
+
`DECLINED` needs a reason.
|
|
85
84
|
|
|
86
85
|
**A card you file may land in the `BACKLOG`** (R371). Where a new card goes
|
|
87
86
|
when you name no status is the project's rule, per door, and the default for a
|
|
@@ -190,12 +189,12 @@ what you are allowed to do.
|
|
|
190
189
|
| `done_when_tick` | Tick (or untick) one numbered item of the card's Done-when checklist as built, with one line of evidence (R368). `task_current` lists the items. Defaults to the run's own card and refuses another; the reviewer's column is a person's and no token can set it. |
|
|
191
190
|
| `roadmap_set_status` | Move a card, carrying whatever the status requires. |
|
|
192
191
|
| `roadmap_decline` | Decline with a reason. The only exit. |
|
|
193
|
-
| `backlog_file` | File **feedback**, not a card — something a person should decide about. A WRITER accepts it as a card or an issue, or refuses it (R199). Tagged `agent` as a source, plus any `tags` you pass — `customer` when you relay what a user said (R372). |
|
|
192
|
+
| `backlog_file` | File **feedback**, not a card — something a person should decide about. A WRITER accepts it as a card or an issue, or refuses it (R199). Tagged `agent` as a source, plus any `tags` you pass — `customer` when you relay what a user said (R372). Inside a run, what you find that is **not this card's** goes here, one item per suggestion: the platform stamps the item with the run, so the feedback page links the run and its card, and accepting it relates the new card to yours (R572). Every stage holds it, read-only ones included. |
|
|
194
193
|
| `changelog_list` | The changelog, grouped by release, newest first. |
|
|
195
194
|
| `changelog_get` | One entry. |
|
|
196
195
|
| `changelog_add` | Add an entry; no version means `Unreleased`. |
|
|
197
196
|
| `changelog_update` | Edit one, including moving it to a release when it ships. |
|
|
198
|
-
| `quality_get` | The SonarQube reading — the default branch's gate, a named branch's gate and new problems (inside a run, this run's branch by default), and the open findings with links. Read-only: nothing here refreshes, scans or files (R302). |
|
|
197
|
+
| `quality_get` | The SonarQube reading — the default branch's gate, a named branch's gate and new problems (inside a run, this run's branch by default), and the open findings with links (not those a person dismissed, R467). Read-only: nothing here refreshes, scans or files (R302). |
|
|
199
198
|
|
|
200
199
|
### Inside a run
|
|
201
200
|
|
package/mcp/server.mjs
CHANGED
|
@@ -21,7 +21,11 @@ import {
|
|
|
21
21
|
summaryOf,
|
|
22
22
|
withinCeiling,
|
|
23
23
|
} from '../lib/tool-rules.mjs';
|
|
24
|
+
import {
|
|
25
|
+
isProtected, notAllowed, protectedChanges, protectedPolicy, protectedRefusal, relativeTo,
|
|
26
|
+
} from '../lib/protected.mjs';
|
|
24
27
|
import { findDestructive, withinScope } from '../lib/secrets.mjs';
|
|
28
|
+
import { byCodeUnit } from '../lib/order.mjs';
|
|
25
29
|
import { COMMITS_ITS_WORK, isUnfinished, leftOver, notDoneYet } from '../lib/finish-check.mjs';
|
|
26
30
|
|
|
27
31
|
const NAME = 'cawdev';
|
|
@@ -929,7 +933,9 @@ const TOOLS = [
|
|
|
929
933
|
'person should decide about: `kind` is your opinion, ISSUE (broken) or FEATURE (should ' +
|
|
930
934
|
'also do / do better). A WRITER later accepts it as a roadmap card, accepts it as an ' +
|
|
931
935
|
'issue, or refuses it with a reason. Use this when you are not sure it deserves a card; ' +
|
|
932
|
-
'use roadmap_create or issue_file when you are.'
|
|
936
|
+
'use roadmap_create or issue_file when you are. Inside a run, this is where something you ' +
|
|
937
|
+
'found that is NOT this card\'s goes — one item per suggestion: cawdev links it to this run ' +
|
|
938
|
+
'and its card, so you need not write either into the body.',
|
|
933
939
|
inputSchema: {
|
|
934
940
|
type: 'object',
|
|
935
941
|
properties: {
|
|
@@ -1444,6 +1450,14 @@ const TOOLS = [
|
|
|
1444
1450
|
return notDoneYet(left);
|
|
1445
1451
|
}
|
|
1446
1452
|
}
|
|
1453
|
+
// R562. The diff gate: the branch against the commit it was cut from,
|
|
1454
|
+
// so a shell `git checkout main -- pom.xml` is caught as an Edit is.
|
|
1455
|
+
if (run && COMMITS_ITS_WORK.has(run.profile)) {
|
|
1456
|
+
const refusal = await protectedGate(config, project, runId);
|
|
1457
|
+
if (refusal) {
|
|
1458
|
+
return refusal;
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1447
1461
|
}
|
|
1448
1462
|
|
|
1449
1463
|
const message = await api(config, `/api/projects/${project}/runs/${runId}/messages`, {
|
|
@@ -1820,7 +1834,49 @@ async function decide(config, args) {
|
|
|
1820
1834
|
// R110. The shield FIRST, and the ordering is the point — see shieldSays.
|
|
1821
1835
|
// A hit does not deny; it skips the rules and goes straight to a person,
|
|
1822
1836
|
// and it is recorded so somebody can see what was stopped.
|
|
1823
|
-
const
|
|
1837
|
+
const policy = shieldPolicy();
|
|
1838
|
+
|
|
1839
|
+
// R562. A protected file, BEFORE the shield and the rules: no stored rule
|
|
1840
|
+
// may cover it, and no session grant either. A path already allowed on this
|
|
1841
|
+
// branch goes straight through; otherwise REFUSE denies and ASK raises a
|
|
1842
|
+
// request that says plainly it is about a protected file.
|
|
1843
|
+
const target = input.file_path ?? input.path ?? input.notebook_path;
|
|
1844
|
+
if (target && policy.protected.globs.length && WRITE_TOOLS.has(toolName)
|
|
1845
|
+
&& isProtected(String(target), policy.root, policy.protected.globs)) {
|
|
1846
|
+
const relative = relativeTo(String(target), policy.root) ?? String(target);
|
|
1847
|
+
const allowed = await protectedAllowedNow(config, project, runId, policy.protected);
|
|
1848
|
+
if (allowed.has(relative)) {
|
|
1849
|
+
return allow(input, 'a person allowed this protected file');
|
|
1850
|
+
}
|
|
1851
|
+
await api(config, `/api/projects/${project}/runs/${runId}/blocks`, {
|
|
1852
|
+
method: 'POST',
|
|
1853
|
+
body: { kind: 'PROTECTED', detail: `${toolName} ${relative}` },
|
|
1854
|
+
}).catch(() => {
|
|
1855
|
+
// Bookkeeping, as for the shield below.
|
|
1856
|
+
});
|
|
1857
|
+
if (policy.protected.mode !== 'ASK') {
|
|
1858
|
+
return deny(
|
|
1859
|
+
`${relative} is protected in this project: an agent never changes it. Do not work ` +
|
|
1860
|
+
`around it (no shell, no git checkout). Report blocked if the work needs it.`,
|
|
1861
|
+
);
|
|
1862
|
+
}
|
|
1863
|
+
const asked = await api(config, `/api/projects/${project}/runs/${runId}/approvals`, {
|
|
1864
|
+
method: 'POST',
|
|
1865
|
+
body: {
|
|
1866
|
+
toolName,
|
|
1867
|
+
toolInput: JSON.stringify(input),
|
|
1868
|
+
summary: `PROTECTED FILE — ${summaryOf(toolName, input)}`,
|
|
1869
|
+
suggestion: null,
|
|
1870
|
+
exactPattern: null,
|
|
1871
|
+
exactWithinCeiling: false,
|
|
1872
|
+
toolUseId: args.tool_use_id,
|
|
1873
|
+
protectedPaths: [relative],
|
|
1874
|
+
},
|
|
1875
|
+
});
|
|
1876
|
+
return await answerOf(config, project, runId, asked.id, input);
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
const stopped = shieldSays(policy, toolName, input);
|
|
1824
1880
|
if (stopped) {
|
|
1825
1881
|
await api(config, `/api/projects/${project}/runs/${runId}/blocks`, {
|
|
1826
1882
|
method: 'POST',
|
|
@@ -1893,26 +1949,7 @@ async function decide(config, args) {
|
|
|
1893
1949
|
},
|
|
1894
1950
|
});
|
|
1895
1951
|
|
|
1896
|
-
|
|
1897
|
-
if (decided?.state === 'ALLOWED') {
|
|
1898
|
-
return allow(input, decided.reason ?? 'allowed by a person');
|
|
1899
|
-
}
|
|
1900
|
-
if (decided?.state === 'DENIED') {
|
|
1901
|
-
return deny(
|
|
1902
|
-
`${decided.reason ?? 'A person refused this.'} Do not try to work around it — report ` +
|
|
1903
|
-
`blocked and say what you needed, or ask for a different approach.`,
|
|
1904
|
-
);
|
|
1905
|
-
}
|
|
1906
|
-
// EXPIRED, or the poll gave up before the platform expired it. Same answer
|
|
1907
|
-
// either way, and the difference is on the run for whoever reads it later.
|
|
1908
|
-
// R339: a person may still allow it late, and if they do the session is
|
|
1909
|
-
// sent a message saying so — so this says to carry on, not to stop.
|
|
1910
|
-
return deny(
|
|
1911
|
-
`Nobody answered this permission request in time. Do not retry it in a loop. Carry on ` +
|
|
1912
|
-
`with whatever does not need it; if a person allows it later you will be sent a ` +
|
|
1913
|
-
`message saying so, and may run it then. If nothing else can be done without it, ` +
|
|
1914
|
-
`report blocked and say exactly what you needed to run and why.`,
|
|
1915
|
-
);
|
|
1952
|
+
return await answerOf(config, project, runId, asked.id, input);
|
|
1916
1953
|
} catch (failure) {
|
|
1917
1954
|
return deny(
|
|
1918
1955
|
`cawdev could not be asked whether this is allowed (${failure.message}). Treating that as ` +
|
|
@@ -1921,6 +1958,104 @@ async function decide(config, args) {
|
|
|
1921
1958
|
}
|
|
1922
1959
|
}
|
|
1923
1960
|
|
|
1961
|
+
/** The tools whose path is a file they change; Read, Grep and Glob carry a path too. */
|
|
1962
|
+
const WRITE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
|
|
1963
|
+
|
|
1964
|
+
/** Waits for the person and turns the decision into the answer the CLI reads. */
|
|
1965
|
+
async function answerOf(config, project, runId, approvalId, input) {
|
|
1966
|
+
const decided = await pollForDecision(config, project, runId, approvalId);
|
|
1967
|
+
if (decided?.state === 'ALLOWED') {
|
|
1968
|
+
return allow(input, decided.reason ?? 'allowed by a person');
|
|
1969
|
+
}
|
|
1970
|
+
if (decided?.state === 'DENIED') {
|
|
1971
|
+
return deny(
|
|
1972
|
+
`${decided.reason ?? 'A person refused this.'} Do not try to work around it — report ` +
|
|
1973
|
+
`blocked and say what you needed, or ask for a different approach.`,
|
|
1974
|
+
);
|
|
1975
|
+
}
|
|
1976
|
+
// EXPIRED, or the poll gave up before the platform expired it. Same answer
|
|
1977
|
+
// either way, and the difference is on the run for whoever reads it later.
|
|
1978
|
+
// R339: a person may still allow it late, and if they do the session is
|
|
1979
|
+
// sent a message saying so — so this says to carry on, not to stop.
|
|
1980
|
+
return deny(
|
|
1981
|
+
`Nobody answered this permission request in time. Do not retry it in a loop. Carry on ` +
|
|
1982
|
+
`with whatever does not need it; if a person allows it later you will be sent a ` +
|
|
1983
|
+
`message saying so, and may run it then. If nothing else can be done without it, ` +
|
|
1984
|
+
`report blocked and say exactly what you needed to run and why.`,
|
|
1985
|
+
);
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
/**
|
|
1989
|
+
* The protected paths a person has allowed on this branch — the claim's list
|
|
1990
|
+
* plus this run's own ALLOWED protected requests, read fresh (R562). Never
|
|
1991
|
+
* throws: not knowing what was allowed means nothing was.
|
|
1992
|
+
*/
|
|
1993
|
+
async function protectedAllowedNow(config, project, runId, policy) {
|
|
1994
|
+
const allowed = new Set(policy.allowed ?? []);
|
|
1995
|
+
try {
|
|
1996
|
+
const asked = await api(config, `/api/projects/${project}/runs/${runId}/approvals`);
|
|
1997
|
+
for (const approval of asked ?? []) {
|
|
1998
|
+
if (approval?.state === 'ALLOWED' && Array.isArray(approval.protectedPaths)) {
|
|
1999
|
+
approval.protectedPaths.forEach((path) => allowed.add(path));
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
} catch {
|
|
2003
|
+
// The claim's list stands.
|
|
2004
|
+
}
|
|
2005
|
+
return allowed;
|
|
2006
|
+
}
|
|
2007
|
+
|
|
2008
|
+
/**
|
|
2009
|
+
* The protected-file gate on `report done` — R562. Null when the branch is
|
|
2010
|
+
* clean of unallowed protected changes (or nothing can be judged), else the
|
|
2011
|
+
* refusal the session reads. In ASK mode a person is asked once, for all of
|
|
2012
|
+
* them, and ALLOWED lets the run finish.
|
|
2013
|
+
*/
|
|
2014
|
+
async function protectedGate(config, project, runId) {
|
|
2015
|
+
const policy = shieldPolicy().protected;
|
|
2016
|
+
const base = process.env.CAWDEV_PROTECTED_BASE || null;
|
|
2017
|
+
if (!policy.globs.length || !base) {
|
|
2018
|
+
return null;
|
|
2019
|
+
}
|
|
2020
|
+
const root = process.env.CAWDEV_WORKSPACE ?? process.cwd();
|
|
2021
|
+
const changed = await protectedChanges(root, base, policy.globs);
|
|
2022
|
+
const allowed = await protectedAllowedNow(config, project, runId, policy);
|
|
2023
|
+
const held = notAllowed(changed, [...allowed]);
|
|
2024
|
+
if (!held.length) {
|
|
2025
|
+
return null;
|
|
2026
|
+
}
|
|
2027
|
+
const list = held.join(', ');
|
|
2028
|
+
await api(config, `/api/projects/${project}/runs/${runId}/blocks`, {
|
|
2029
|
+
method: 'POST',
|
|
2030
|
+
body: { kind: 'PROTECTED', detail: `branch changes ${list}` },
|
|
2031
|
+
}).catch(() => {
|
|
2032
|
+
// Bookkeeping.
|
|
2033
|
+
});
|
|
2034
|
+
const refusal = protectedRefusal(held, base);
|
|
2035
|
+
if (policy.mode !== 'ASK') {
|
|
2036
|
+
return refusal;
|
|
2037
|
+
}
|
|
2038
|
+
const asked = await api(config, `/api/projects/${project}/runs/${runId}/approvals`, {
|
|
2039
|
+
method: 'POST',
|
|
2040
|
+
body: {
|
|
2041
|
+
toolName: 'ProtectedChange',
|
|
2042
|
+
toolInput: JSON.stringify({ paths: held }),
|
|
2043
|
+
summary: `Changes protected file(s): ${list}`,
|
|
2044
|
+
suggestion: null,
|
|
2045
|
+
exactPattern: null,
|
|
2046
|
+
exactWithinCeiling: false,
|
|
2047
|
+
protectedPaths: held,
|
|
2048
|
+
},
|
|
2049
|
+
});
|
|
2050
|
+
const decided = await pollForDecision(config, project, runId, asked.id);
|
|
2051
|
+
if (decided?.state === 'ALLOWED') {
|
|
2052
|
+
return null;
|
|
2053
|
+
}
|
|
2054
|
+
return decided?.state === 'DENIED' && decided.reason
|
|
2055
|
+
? `${refusal} A person refused: ${decided.reason}`
|
|
2056
|
+
: refusal;
|
|
2057
|
+
}
|
|
2058
|
+
|
|
1924
2059
|
function allow(updatedInput, reason) {
|
|
1925
2060
|
// `updatedInput` echoed back unchanged. The hook exists to say yes or no,
|
|
1926
2061
|
// not to rewrite what the agent was about to do behind its back.
|
|
@@ -2013,10 +2148,18 @@ function shieldPolicy() {
|
|
|
2013
2148
|
blockSecrets: parsed.blockSecrets !== false,
|
|
2014
2149
|
blockDestructive: parsed.blockDestructive !== false,
|
|
2015
2150
|
pathScope: Array.isArray(parsed.pathScope) ? parsed.pathScope : null,
|
|
2151
|
+
// R562. Unreadable is REFUSE with no globs — never a way of allowing.
|
|
2152
|
+
protected: protectedPolicy(parsed),
|
|
2016
2153
|
root: process.env.CAWDEV_WORKSPACE ?? process.cwd(),
|
|
2017
2154
|
};
|
|
2018
2155
|
} catch {
|
|
2019
|
-
return {
|
|
2156
|
+
return {
|
|
2157
|
+
blockSecrets: true,
|
|
2158
|
+
blockDestructive: true,
|
|
2159
|
+
pathScope: null,
|
|
2160
|
+
protected: protectedPolicy(null),
|
|
2161
|
+
root: process.cwd(),
|
|
2162
|
+
};
|
|
2020
2163
|
}
|
|
2021
2164
|
}
|
|
2022
2165
|
|
|
@@ -2247,6 +2390,14 @@ function formatFileDeps(map, path) {
|
|
|
2247
2390
|
/** How many finding lines a reading prints before saying "and N more" — R302. */
|
|
2248
2391
|
const FINDING_LINES = 50;
|
|
2249
2392
|
|
|
2393
|
+
/** One failed gate condition, as SonarQube words it: `new_violations 1287 > 0`. A rating's step is its letter. */
|
|
2394
|
+
function describeCondition({ metric, comparator, threshold, actual }) {
|
|
2395
|
+
const letter = (step) => ('12345'.includes(step) && step.length === 1 ? 'ABCDE'['12345'.indexOf(step)] : step);
|
|
2396
|
+
if (metric.endsWith('_rating')) return `${metric} ${letter(actual)} worse than ${letter(threshold)}`;
|
|
2397
|
+
const symbol = { GT: '>', LT: '<', EQ: '=', NE: '!=' }[comparator] ?? comparator;
|
|
2398
|
+
return `${metric} ${actual} ${symbol} ${threshold}`;
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2250
2401
|
/**
|
|
2251
2402
|
* The reading as a session should read it — R302: one line for the default
|
|
2252
2403
|
* branch, one for the branch asked about, then the open findings.
|
|
@@ -2272,6 +2423,12 @@ function formatQualityReading(reading, branch) {
|
|
|
2272
2423
|
const parts = project.error
|
|
2273
2424
|
? [`the last read failed: ${project.error}`, `before that: gate ${project.gate ?? 'NONE'}`]
|
|
2274
2425
|
: [`gate ${project.gate ?? 'NONE'}`];
|
|
2426
|
+
// R587: which version was analysed, right after the verdict it belongs to.
|
|
2427
|
+
if (project.analysedVersion) parts.push(`v${project.analysedVersion.replace(/^v/i, '')}`);
|
|
2428
|
+
// R587: the conditions the gate failed on, the cause a session would otherwise guess at.
|
|
2429
|
+
if (project.gateConditions?.length) {
|
|
2430
|
+
parts.push(`failing: ${project.gateConditions.map(describeCondition).join(', ')}`);
|
|
2431
|
+
}
|
|
2275
2432
|
parts.push(
|
|
2276
2433
|
`${count(project.bugs)} bugs`,
|
|
2277
2434
|
`${count(project.vulnerabilities)} vulnerabilities`,
|
|
@@ -2280,6 +2437,7 @@ function formatQualityReading(reading, branch) {
|
|
|
2280
2437
|
`coverage ${percent(project.coverage)}`,
|
|
2281
2438
|
);
|
|
2282
2439
|
if (project.analysedAt) parts.push(`analysed ${project.analysedAt}`);
|
|
2440
|
+
if (project.newCodeSince) parts.push(`new code since ${project.newCodeSince}`);
|
|
2283
2441
|
if (project.dashboardUrl) parts.push(project.dashboardUrl);
|
|
2284
2442
|
lines.push(`Default branch: ${parts.join(' · ')}`);
|
|
2285
2443
|
}
|
|
@@ -2366,7 +2524,7 @@ function originWord(origin) {
|
|
|
2366
2524
|
if (!origin || origin.kind === 'NONE') return null;
|
|
2367
2525
|
switch (origin.kind) {
|
|
2368
2526
|
case 'SONARQUBE': return 'SonarQube';
|
|
2369
|
-
case 'FEEDBACK': return 'Feedback';
|
|
2527
|
+
case 'FEEDBACK': return origin.label ? `Feedback (from ${origin.label})` : 'Feedback';
|
|
2370
2528
|
case 'APPLICATION': return origin.label ? `an application (${origin.label})` : 'an application';
|
|
2371
2529
|
case 'SESSION': return origin.label ? `a session (${origin.label})` : 'a session';
|
|
2372
2530
|
default: return null;
|
|
@@ -2391,7 +2549,7 @@ function formatEntry(entry, { brief, comments, plan }) {
|
|
|
2391
2549
|
if (entry.section) lines.push(` section: ${entry.section}`);
|
|
2392
2550
|
// R315. Sorted for the same reason the export is: a reader's shape must not
|
|
2393
2551
|
// depend on the set's own insertion order.
|
|
2394
|
-
if (entry.tags?.length) lines.push(` tags: ${[...entry.tags].sort().map((t) => `#${t}`).join(' ')}`);
|
|
2552
|
+
if (entry.tags?.length) lines.push(` tags: ${[...entry.tags].sort(byCodeUnit).map((t) => `#${t}`).join(' ')}`);
|
|
2395
2553
|
// R331. A username, never the email.
|
|
2396
2554
|
if (entry.assignee) lines.push(` assignee: ${entry.assignee.username}`);
|
|
2397
2555
|
// R335. Not printed at MEDIUM, as the export does not write it.
|
package/package.json
CHANGED
package/runner/README.md
CHANGED
|
@@ -970,6 +970,15 @@ files at all. R47 removes the need for this by giving each run a workspace.
|
|
|
970
970
|
— the branch rewrote the same lines — the run fails **naming the stash and
|
|
971
971
|
the command to recover it**, because work parked somewhere nobody was told
|
|
972
972
|
about is work lost.
|
|
973
|
+
|
|
974
|
+
**A refused move to RUNNING is never the run's failure** (i267). The move
|
|
975
|
+
can be refused only when the platform no longer holds this claim: the run
|
|
976
|
+
was cancelled while the checkout was prepared, or put back in the queue. The
|
|
977
|
+
daemon then logs `the platform did not let this run start`, posts nothing,
|
|
978
|
+
and leaves the run exactly as the platform has it; if it is QUEUED, the next
|
|
979
|
+
poll offers it again and it is claimed properly. Only 400, 404, 409 and 410
|
|
980
|
+
count as a refusal; any other status (401, 403, 408, 429), a network error or
|
|
981
|
+
a 5xx still fails the run.
|
|
973
982
|
3. **Spawns the agent** in that directory, in its own process group, with the
|
|
974
983
|
run token in its environment and an `.mcp.json` pointing at cawdev's MCP
|
|
975
984
|
server. **Your own token never reaches the child.**
|
|
@@ -1167,6 +1176,63 @@ Before this, R325's run finished with a file modified and nothing pushed, and
|
|
|
1167
1176
|
the next run in that checkout refused to start on top of it. A checkout with no
|
|
1168
1177
|
remote commits what is left and asks nothing of a push.
|
|
1169
1178
|
|
|
1179
|
+
## Git hosts: GitHub, GitLab and Bitbucket — and what the agent may push (R565)
|
|
1180
|
+
|
|
1181
|
+
Branches, pull requests and merges are opened **from cawdev** and performed by
|
|
1182
|
+
this daemon, with credentials that live on this machine. The platform never
|
|
1183
|
+
holds one. A project says which host it is on (`/projects/*/settings`, "Git
|
|
1184
|
+
host"), or the daemon works it out from `origin`.
|
|
1185
|
+
|
|
1186
|
+
| Host | How the daemon talks to it |
|
|
1187
|
+
|---|---|
|
|
1188
|
+
| GitHub | `gh`, signed in (`gh auth login`) |
|
|
1189
|
+
| GitLab | `glab`, signed in (`glab auth login --hostname <host>`) |
|
|
1190
|
+
| Bitbucket Cloud (bitbucket.org) | its REST API, with a token this machine keeps |
|
|
1191
|
+
|
|
1192
|
+
Bitbucket Server / Data Center is not supported.
|
|
1193
|
+
|
|
1194
|
+
**Bitbucket's token** goes in the environment, and the config names the
|
|
1195
|
+
variable — never the token itself:
|
|
1196
|
+
|
|
1197
|
+
```json
|
|
1198
|
+
{
|
|
1199
|
+
"bitbucket": { "email": "you@example.com", "tokenEnv": "BITBUCKET_TOKEN" }
|
|
1200
|
+
}
|
|
1201
|
+
```
|
|
1202
|
+
|
|
1203
|
+
With `email`, the token is an Atlassian API token and is sent as Basic
|
|
1204
|
+
`email:token`. Without it, it is a workspace or repository access token, sent
|
|
1205
|
+
as a Bearer token. `tokenEnv` defaults to `BITBUCKET_TOKEN`. The daemon logs,
|
|
1206
|
+
once at boot, where it reads the token from (or that it is missing). That
|
|
1207
|
+
variable is **removed from the environment of every agent session** it starts;
|
|
1208
|
+
`GH_TOKEN` and `GITLAB_TOKEN` are not, because `git push` may authenticate
|
|
1209
|
+
through them.
|
|
1210
|
+
|
|
1211
|
+
**After updating, restart the daemon.** A daemon that predates this does not
|
|
1212
|
+
advertise `bitbucket` in its `gitHosts`, and cawdev will not address a
|
|
1213
|
+
Bitbucket project's pull request or merge to it: the button says "No machine of
|
|
1214
|
+
yours runs a cawdev runner that speaks Bitbucket. Restart the daemon to update
|
|
1215
|
+
it."
|
|
1216
|
+
|
|
1217
|
+
### What the agent may push
|
|
1218
|
+
|
|
1219
|
+
A coding session may commit on its card's branch and push **that branch and
|
|
1220
|
+
nothing else**. For Claude Code the daemon passes a `PreToolUse` hook
|
|
1221
|
+
(`push-guard.mjs`) and disallows `gh` and `glab`: a host CLI, a force, a delete,
|
|
1222
|
+
`--mirror`, `--all`, a `+` refspec, or a push to any other branch is refused
|
|
1223
|
+
with a reason the session reads. A release session may also push its own tag.
|
|
1224
|
+
|
|
1225
|
+
After **any** writing session, for Claude, Codex and Antigravity alike, the
|
|
1226
|
+
daemon compares the remote with what it held before and, if the session pushed a
|
|
1227
|
+
ref other than its branch, says so on the run: *"This session pushed `<ref>`,
|
|
1228
|
+
which is outside its branch …"*. Deletions cannot be attributed to a session and
|
|
1229
|
+
are not flagged.
|
|
1230
|
+
|
|
1231
|
+
**This is a guard against mistakes, not a security boundary.** An agent with a
|
|
1232
|
+
shell and your credentials can get round a pattern match (`git -c alias.x='!…'`,
|
|
1233
|
+
a host CLI called through a script). **The real wall is branch protection on the
|
|
1234
|
+
host: protect the default branch and require a pull request.** Do that.
|
|
1235
|
+
|
|
1170
1236
|
## What a project's rules can ask of it (R40)
|
|
1171
1237
|
|
|
1172
1238
|
A project may decide that a finished run **pushes its branch**, **opens a pull
|
|
@@ -1340,6 +1406,19 @@ bound to it state the numbers first. There is nothing to configure here and
|
|
|
1340
1406
|
nothing to upgrade: the platform sets the same `paused` flag a person's click
|
|
1341
1407
|
sets, and this daemon already obeys it on the next beat.
|
|
1342
1408
|
|
|
1409
|
+
**A queued run says why a paused machine leaves it queued (R571).** The daemon
|
|
1410
|
+
logs and shows one of two lines, from `pause-note.mjs` (pure, tested):
|
|
1411
|
+
`this machine is paused from the console (resume it on Settings → Machines, or
|
|
1412
|
+
press c)` for a person's pause, and `cawdev paused this machine for usage: its
|
|
1413
|
+
five-hour window is at 92%, back in 1h 20m — it resumes by itself` for the
|
|
1414
|
+
platform's. The attached terminal's `c` menu has a **Pause this machine** /
|
|
1415
|
+
**Resume this machine** row, which makes the first line's "press c" true: it
|
|
1416
|
+
writes the platform through `POST /api/runners/{id}/pause` (`setPaused` in
|
|
1417
|
+
`configure.mjs`), never the config file, and the daemon reads it off its next
|
|
1418
|
+
heartbeat. A running daemon needs a **restart** to show these lines and to tell
|
|
1419
|
+
attached terminals about a pause; an older one keeps saying *paused from the
|
|
1420
|
+
console* and has no pause row in `c`.
|
|
1421
|
+
|
|
1343
1422
|
## Trying it without spending Claude usage
|
|
1344
1423
|
|
|
1345
1424
|
`stub-agent.mjs` stands in for `claude`. It is spawned the same way, talks to
|
package/runner/attach.mjs
CHANGED
|
@@ -52,7 +52,7 @@ import { basename, dirname } from 'node:path';
|
|
|
52
52
|
import { listSockets, socketPathFor } from './control.mjs';
|
|
53
53
|
import {
|
|
54
54
|
RESTART_FROM_ATTACH, addProject, addWorkspace, agentsOf, pathsOf, readConfigFile, reloadIfRunning,
|
|
55
|
-
removeProject, setAcceptsRulesFromConsole, setAgentEnabled, setQuality, slugsOf,
|
|
55
|
+
removeProject, setAcceptsRulesFromConsole, setAgentEnabled, setMaxPlanningRuns, setPaused, setQuality, slugsOf,
|
|
56
56
|
} from './configure.mjs';
|
|
57
57
|
import {
|
|
58
58
|
TYPE_A_PATH, absolute, completePath, describe, inspectPath, listDirectories,
|
|
@@ -779,6 +779,25 @@ export function configMenu(file, runner = {}, gone = new Set()) {
|
|
|
779
779
|
rulesOn
|
|
780
780
|
? { id: 'rules', label: 'Stop accepting permission rules from the console', hint: 'on now' }
|
|
781
781
|
: { id: 'rules', label: 'Accept permission rules from the console', hint: 'off now' },
|
|
782
|
+
// R577: a number on the platform, not a switch in the file — the hello
|
|
783
|
+
// carries what the daemon was last told, so the row reads the live value.
|
|
784
|
+
// R571: the row that makes a queued line's "or press c" true. R288: the
|
|
785
|
+
// label is the change, the hint the current value.
|
|
786
|
+
{
|
|
787
|
+
id: 'pause',
|
|
788
|
+
// A usage pause has no toggle: the sweep would pause it again within a minute.
|
|
789
|
+
label: runner.pausedByUsage
|
|
790
|
+
? 'Paused by cawdev for usage'
|
|
791
|
+
: runner.paused ? 'Resume this machine — claim new runs again' : 'Pause this machine — claim nothing new',
|
|
792
|
+
hint: runner.pausedByUsage
|
|
793
|
+
? 'resumes by itself when the window resets; change the threshold on Settings → Machines'
|
|
794
|
+
: runner.paused ? 'paused now' : 'claiming now',
|
|
795
|
+
},
|
|
796
|
+
{
|
|
797
|
+
id: 'planning-limit',
|
|
798
|
+
label: 'Limit planning runs at once',
|
|
799
|
+
hint: runner.maxPlanningRuns ? `${runner.maxPlanningRuns} at once now` : 'no limit now',
|
|
800
|
+
},
|
|
782
801
|
// R300: one row per served project, saying whether its branches are
|
|
783
802
|
// scanned here now — the state the row would change, R288's rule.
|
|
784
803
|
...slugs.map((slug) => (qualityOf(file.projects[slug])
|
|
@@ -2573,6 +2592,32 @@ export class Attached {
|
|
|
2573
2592
|
this.planned = this.planned.filter((card) => card.slug !== slug);
|
|
2574
2593
|
return this.reloadDaemon(file, next);
|
|
2575
2594
|
}
|
|
2595
|
+
if (chosen.id === 'pause') {
|
|
2596
|
+
if (this.runner.pausedByUsage) {
|
|
2597
|
+
this.say(` ${ink.muted('cawdev paused this machine for usage; it resumes by itself. Change the threshold on Settings → Machines.')}`);
|
|
2598
|
+
return this.note('');
|
|
2599
|
+
}
|
|
2600
|
+
// Writes the platform, which only a person may do, and not the file.
|
|
2601
|
+
if (!this.requireSignIn('pause or resume this machine')) return undefined;
|
|
2602
|
+
const runner = await setPaused({
|
|
2603
|
+
session: this.session, runnerId: this.runner.id, paused: this.runner.paused !== true, say, ink,
|
|
2604
|
+
});
|
|
2605
|
+
this.runner.paused = runner?.paused === true;
|
|
2606
|
+
this.runner.pausedByUsage = runner?.pausedByUsage === true;
|
|
2607
|
+
return this.note('');
|
|
2608
|
+
}
|
|
2609
|
+
if (chosen.id === 'planning-limit') {
|
|
2610
|
+
// Writes the platform, which only a person may do, and not the file — so
|
|
2611
|
+
// no reload: the daemon reads the number off its next heartbeat.
|
|
2612
|
+
if (!this.requireSignIn('limit planning runs at once')) return undefined;
|
|
2613
|
+
const answer = (await ask.line(' How many at once (0 for no limit)? ')).trim();
|
|
2614
|
+
const limit = answer === '' ? NaN : Number(answer);
|
|
2615
|
+
const runner = await setMaxPlanningRuns({
|
|
2616
|
+
session: this.session, runnerId: this.runner.id, limit, say, ink,
|
|
2617
|
+
});
|
|
2618
|
+
this.runner.maxPlanningRuns = runner?.maxPlanningRuns ?? null;
|
|
2619
|
+
return this.note('');
|
|
2620
|
+
}
|
|
2576
2621
|
if (chosen.id === 'rules') {
|
|
2577
2622
|
const next = await setAcceptsRulesFromConsole({
|
|
2578
2623
|
file, configPath, ask, say, ink, enabled: file.acceptsRulesFromConsole !== true,
|
package/runner/cawdev.mjs
CHANGED
|
@@ -30,9 +30,9 @@ import { asker, askOne, mintForThisMachine, setUpThisMachine } from './bootstrap
|
|
|
30
30
|
import { liveSockets, probeSocket, socketPathFor } from './control.mjs';
|
|
31
31
|
import {
|
|
32
32
|
addProject, addWorkspace, readConfigFile, reloadIfRunning, removeProject, setAcceptsRulesFromConsole,
|
|
33
|
-
setQuality, setAgentEnabled,
|
|
33
|
+
setMaxPlanningRuns, setQuality, setAgentEnabled, thisRunnerId,
|
|
34
34
|
} from './configure.mjs';
|
|
35
|
-
import { restartNeededFor } from '../lib/runner-config.mjs';
|
|
35
|
+
import { restartNeededFor, runnerNameOf } from '../lib/runner-config.mjs';
|
|
36
36
|
import { Select } from './select.mjs';
|
|
37
37
|
import { storedSession } from './sign-in.mjs';
|
|
38
38
|
import { loadToken } from './token-store.mjs';
|
|
@@ -69,6 +69,7 @@ const USAGE = `
|
|
|
69
69
|
cawdev config rules on|off accept permission rules from the console
|
|
70
70
|
cawdev config quality on|off <slug> scan that project's branches with
|
|
71
71
|
SonarQube after a run's TEST stage
|
|
72
|
+
cawdev config planning-limit <n> how many planning runs (PLAN, SCOPE, AUDIT, REVIEW) run here at once; 0 is no limit
|
|
72
73
|
cawdev config show this machine's config, token redacted
|
|
73
74
|
|
|
74
75
|
On a machine with no config, cawdev sets one up: it signs you in through the
|
|
@@ -473,6 +474,7 @@ export async function pickAction(ask, say, pick = askOne) {
|
|
|
473
474
|
{ id: 'rules-off', label: 'Stop accepting rules from the console' },
|
|
474
475
|
{ id: 'quality-on', label: 'Scan a project\'s branches with SonarQube after TEST' },
|
|
475
476
|
{ id: 'quality-off', label: 'Stop scanning a project\'s branches' },
|
|
477
|
+
{ id: 'planning-limit', label: 'Limit planning runs at once' },
|
|
476
478
|
{ id: 'show', label: 'Show this machine\'s config' },
|
|
477
479
|
],
|
|
478
480
|
}));
|
|
@@ -484,6 +486,9 @@ export async function pickAction(ask, say, pick = askOne) {
|
|
|
484
486
|
if (id === 'remove-project') {
|
|
485
487
|
return ['remove-project', (await ask.line(' Which project (slug)? ')).trim()];
|
|
486
488
|
}
|
|
489
|
+
if (id === 'planning-limit') {
|
|
490
|
+
return ['planning-limit', (await ask.line(' How many at once (0 for no limit)? ')).trim()];
|
|
491
|
+
}
|
|
487
492
|
if (id === 'rules-on') return ['rules', 'on'];
|
|
488
493
|
if (id === 'rules-off') return ['rules', 'off'];
|
|
489
494
|
if (id === 'quality-on' || id === 'quality-off') {
|
|
@@ -547,6 +552,19 @@ export async function runAction([verb, ...rest], { configPath, file, url, sessio
|
|
|
547
552
|
await reload(await setQuality({ file, configPath, ask, say, ink, slug, enabled: sub === 'on' }));
|
|
548
553
|
return;
|
|
549
554
|
}
|
|
555
|
+
if (verb === 'planning-limit') {
|
|
556
|
+
// R577. Written to the PLATFORM, not the file, so no `reload`: there is no
|
|
557
|
+
// key for a daemon to re-read, and it learns the number from its heartbeat.
|
|
558
|
+
const [raw] = rest;
|
|
559
|
+
const limit = raw === undefined || String(raw).trim() === '' ? NaN : Number(raw);
|
|
560
|
+
if (!Number.isInteger(limit) || limit < 0 || limit > 50) {
|
|
561
|
+
throw new Error('Name a number: cawdev config planning-limit <0-50>');
|
|
562
|
+
}
|
|
563
|
+
await setMaxPlanningRuns({
|
|
564
|
+
session, runnerId: await thisRunnerId(session, runnerNameOf(file)), limit, say, ink,
|
|
565
|
+
});
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
550
568
|
throw new Error(`Not a cawdev config command: ${verb}. See cawdev --help.`);
|
|
551
569
|
}
|
|
552
570
|
|
package/runner/configure.mjs
CHANGED
|
@@ -18,6 +18,11 @@
|
|
|
18
18
|
// the hello — applied, or these keys need a restart — never this file's guess
|
|
19
19
|
// about which keys the daemon can take.
|
|
20
20
|
//
|
|
21
|
+
// R577: `setMaxPlanningRuns` is the one setting here that writes the PLATFORM
|
|
22
|
+
// and not the file — a limit on planning runs is a per-machine value the
|
|
23
|
+
// console also edits, so it lives where both can reach it. It therefore never
|
|
24
|
+
// goes through `reloadIfRunning`: the daemon reads it off its next heartbeat.
|
|
25
|
+
//
|
|
21
26
|
// R288: the same functions have a SECOND door — the attached terminal's `c`
|
|
22
27
|
// key — and that is why every question here comes in through a parameter.
|
|
23
28
|
// `ask.line` and `pick` are the two shapes a question takes (a line, or one
|
|
@@ -457,6 +462,63 @@ export async function removeProject({
|
|
|
457
462
|
return next;
|
|
458
463
|
}
|
|
459
464
|
|
|
465
|
+
/**
|
|
466
|
+
* The id the platform knows this machine by — R577.
|
|
467
|
+
*
|
|
468
|
+
* Looked up by the name the daemon registers under (`runnerNameOf`), among the
|
|
469
|
+
* machines that are yours: the config file does not hold the id, and two
|
|
470
|
+
* people's laptops may both be called `laptop`.
|
|
471
|
+
*/
|
|
472
|
+
export async function thisRunnerId(session, name) {
|
|
473
|
+
const runners = await session.request('/api/runners');
|
|
474
|
+
const mine = (Array.isArray(runners) ? runners : [])
|
|
475
|
+
.filter((each) => each.yours && each.name === name);
|
|
476
|
+
if (mine.length === 0) {
|
|
477
|
+
throw new Error(`cawdev has no machine of yours called "${name}" — has its daemon run once?`);
|
|
478
|
+
}
|
|
479
|
+
if (mine.length > 1) {
|
|
480
|
+
throw new Error(`More than one machine of yours is called "${name}" (${mine.map((each) => each.id).join(', ')}) — set it on the Runners page.`);
|
|
481
|
+
}
|
|
482
|
+
return mine[0].id;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* How many planning runs this machine runs at once — R577. 0 is no limit.
|
|
487
|
+
*
|
|
488
|
+
* Posted to the owner-only pause endpoint, whose "absent leaves it alone"
|
|
489
|
+
* means nothing else about the machine moves. Refused before any request when
|
|
490
|
+
* the number is not one, so a typo costs no round trip.
|
|
491
|
+
*/
|
|
492
|
+
export async function setMaxPlanningRuns({ session, runnerId, limit, say, ink = painter(3) }) {
|
|
493
|
+
if (!Number.isInteger(limit) || limit < 0 || limit > 50) {
|
|
494
|
+
throw new Error('A planning limit is a whole number from 0 (no limit) to 50.');
|
|
495
|
+
}
|
|
496
|
+
const runner = await session.request(`/api/runners/${runnerId}/pause`, {
|
|
497
|
+
method: 'POST',
|
|
498
|
+
body: { maxPlanningRuns: limit },
|
|
499
|
+
});
|
|
500
|
+
say(` ${ink.success('✓')} ${ink.muted('planning runs at once:')} ${ink.text(limit ? String(limit) : 'no limit')}`
|
|
501
|
+
+ ` ${ink.muted('— the daemon applies it on its next heartbeat')}`);
|
|
502
|
+
return runner;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Pause this machine or resume it — R73, offered in the attached terminal's `c`
|
|
507
|
+
* menu by R571 so the queued line's "press c" is true. Writes the platform,
|
|
508
|
+
* like `setMaxPlanningRuns`, through the call Settings → Machines makes; the
|
|
509
|
+
* daemon reads it off its next heartbeat and no file is touched.
|
|
510
|
+
*/
|
|
511
|
+
export async function setPaused({ session, runnerId, paused, say, ink = painter(3) }) {
|
|
512
|
+
const runner = await session.request(`/api/runners/${runnerId}/pause`, {
|
|
513
|
+
method: 'POST',
|
|
514
|
+
body: { paused: paused === true },
|
|
515
|
+
});
|
|
516
|
+
say(` ${ink.success('✓')} ${ink.muted(paused ? 'paused:' : 'resumed:')} `
|
|
517
|
+
+ `${ink.text(paused ? 'claiming nothing new' : 'claiming again')}`
|
|
518
|
+
+ ` ${ink.muted('— the daemon applies it on its next heartbeat')}`);
|
|
519
|
+
return runner;
|
|
520
|
+
}
|
|
521
|
+
|
|
460
522
|
/** Turn `acceptsRulesFromConsole` on or off — R126. */
|
|
461
523
|
export async function setAcceptsRulesFromConsole({
|
|
462
524
|
file, configPath, enabled,
|