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/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 `REVIEW` where
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. `REVIEW` needs nothing, deliberately: the pull
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 stopped = shieldSays(shieldPolicy(), toolName, input);
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
- const decided = await pollForDecision(config, project, runId, asked.id);
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 { blockSecrets: true, blockDestructive: true, pathScope: null, root: process.cwd() };
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cawdev-cli",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "cawdev's terminal client and runner daemon — zero dependencies, on purpose",
5
5
  "type": "module",
6
6
  "bin": {
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
 
@@ -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,