cawdev-cli 1.0.5-beta → 1.0.7-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/agents.mjs CHANGED
@@ -25,3 +25,41 @@ export function agentOfCommand(command) {
25
25
  export function agentLabel(command) {
26
26
  return LABELS[agentOfCommand(command)];
27
27
  }
28
+
29
+ /**
30
+ * Why an agent that needs the whole machine was refused, and what to do about
31
+ * it — in steps, for THIS machine. Codex and Antigravity have no per-command
32
+ * allow-list, so a session that writes can only run with everything allowed;
33
+ * the refusal used to say "turn on allow everything" and nothing about where,
34
+ * or that the machine must first agree to take rules from the console.
35
+ *
36
+ * Pure, so the wording is read in a test rather than found in a failed run.
37
+ *
38
+ * @param agent `codex` or `agy`
39
+ * @param machine `{ name, acceptsConsoleRules, configPath, consoleUrl }`
40
+ * @param run `{ entryRef, profile }` — what to start again, and how
41
+ * @param readsWithout whether a read-only session of this agent needs nothing
42
+ */
43
+ export function needsEverything(agent, machine = {}, run = {}, { readsWithout = true } = {}) {
44
+ const who = LABELS[agent] ?? agent;
45
+ const short = agent === 'agy' ? 'agy' : agent;
46
+ const again = run.entryRef ? `/new ${run.profile === 'PLAN' ? 'plan' : 'dev'} ${run.entryRef}` : '/new …';
47
+ const lines = [
48
+ `${who} cannot ${readsWithout ? 'write code' : 'run'} on this machine yet: it has no per-command `
49
+ + 'permissions, so a session that commits and pushes must be allowed to run anything — and '
50
+ + 'this machine has not allowed everything.',
51
+ '',
52
+ 'To allow it:',
53
+ machine.acceptsConsoleRules
54
+ ? ' 1. ✓ This machine already accepts permission rules from the console.'
55
+ : ' 1. On this machine: in the cawdev terminal press c → "Accept permission rules from the '
56
+ + `console"${machine.configPath ? ` (or "acceptsRulesFromConsole": true in ${machine.configPath})` : ''}.`,
57
+ ` 2. On cawdev: ${machine.consoleUrl ? `${machine.consoleUrl.replace(/\/+$/, '')}/settings/runners` : 'Settings → Runners'}`
58
+ + ` → ${machine.name ?? 'this machine'} → tick "Allow everything".`,
59
+ ` 3. Start it again: ${again} --${short}`,
60
+ '',
61
+ 'Allowing everything lets every unattended session on this machine run any command in its checkouts.',
62
+ `Or, without it: ${again} --claude${readsWithout && agent === 'codex' && run.entryRef ? ` — or keep ${who} for plans (/new plan ${run.entryRef} --codex)` : ''}.`,
63
+ ];
64
+ return lines.join('\n');
65
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The shell commands a Claude Code session started in the BACKGROUND and never
3
+ * saw the end of — read off its stream-json events.
4
+ *
5
+ * A lifecycle stage is one turn (R112): the daemon closes the session the
6
+ * moment the turn ends. A stage that ran `npm test` with `run_in_background`
7
+ * and ended its turn to wait for it loses the run — the result never comes
8
+ * back, nothing is recorded, and the walk marched on to MEMORY and FINISHED
9
+ * with the work unproven and uncommitted (R15, saasng). So the walk asks this,
10
+ * at the stage's end, whether anything it started is unfinished or unread;
11
+ * `walkLifecycle` then fails the stage instead of calling it done, which is
12
+ * what lets `/continue` pick the run back up at that stage.
13
+ *
14
+ * Pure: events in, a list out. Three tool shapes, by name:
15
+ * - `Bash` with `run_in_background: true` starts one; its result names the id
16
+ * ("… running in background with ID: <id>");
17
+ * - `BashOutput` (or `TaskOutput`) reads one by `bash_id`/`shell_id`/`task_id`;
18
+ * the command is over only when a read says a status other than running;
19
+ * - `KillShell`/`KillBash`/`TaskStop` ends one.
20
+ * A command whose id cannot be read off its result stays unfinished: failing
21
+ * loudly and re-running a stage costs a turn, calling unproven work proven
22
+ * costs somebody's afternoon.
23
+ *
24
+ * @author Claude Opus 5.5 (Claude Code)
25
+ * @author Moez Ben MBarka
26
+ */
27
+
28
+ const READS = new Set(['BashOutput', 'TaskOutput']);
29
+ const KILLS = new Set(['KillShell', 'KillBash', 'TaskStop']);
30
+
31
+ /** The text of a tool_result's content, whichever of its two shapes it came in. */
32
+ function textOf(content) {
33
+ if (typeof content === 'string') return content;
34
+ if (Array.isArray(content)) {
35
+ return content.map((part) => (typeof part?.text === 'string' ? part.text : '')).join('\n');
36
+ }
37
+ return '';
38
+ }
39
+
40
+ function shellIdOf(input) {
41
+ const id = input?.bash_id ?? input?.shell_id ?? input?.task_id ?? input?.id;
42
+ return typeof id === 'string' && id ? id : null;
43
+ }
44
+
45
+ export class BackgroundShells {
46
+ constructor() {
47
+ /** tool_use id of each background `Bash` → { command, shellId, done }. */
48
+ this.started = new Map();
49
+ /** tool_use id of each read → the shell id it read. */
50
+ this.reads = new Map();
51
+ }
52
+
53
+ /** One stream-json event. Anything that is not about a shell is ignored. */
54
+ observe(event) {
55
+ const parts = event?.message?.content;
56
+ if (!Array.isArray(parts)) return;
57
+ if (event.type === 'assistant') {
58
+ for (const part of parts) {
59
+ if (part?.type !== 'tool_use') continue;
60
+ if (part.name === 'Bash' && part.input?.run_in_background === true) {
61
+ this.started.set(part.id, {
62
+ command: String(part.input.command ?? '').trim(),
63
+ shellId: null,
64
+ done: false,
65
+ });
66
+ } else if (READS.has(part.name)) {
67
+ const id = shellIdOf(part.input);
68
+ if (id) this.reads.set(part.id, id);
69
+ } else if (KILLS.has(part.name)) {
70
+ this.finish(shellIdOf(part.input));
71
+ }
72
+ }
73
+ return;
74
+ }
75
+ if (event.type === 'user') {
76
+ for (const part of parts) {
77
+ if (part?.type !== 'tool_result') continue;
78
+ const text = textOf(part.content);
79
+ const launched = this.started.get(part.tool_use_id);
80
+ if (launched && !launched.shellId) {
81
+ const id = /\bID:?\s*([A-Za-z0-9_-]+)/.exec(text);
82
+ if (id) launched.shellId = id[1];
83
+ continue;
84
+ }
85
+ const read = this.reads.get(part.tool_use_id);
86
+ if (read) {
87
+ // Over only when the read says so; a read of a command still going
88
+ // is the command still going.
89
+ if (!/<status>\s*running\s*<\/status>|\bstatus:\s*running\b/i.test(text)) {
90
+ this.finish(read);
91
+ }
92
+ }
93
+ }
94
+ }
95
+ }
96
+
97
+ finish(shellId) {
98
+ if (!shellId) return;
99
+ for (const each of this.started.values()) {
100
+ if (each.shellId === shellId) each.done = true;
101
+ }
102
+ }
103
+
104
+ /** The commands started in the background whose end this session never saw. */
105
+ unfinished() {
106
+ return [...this.started.values()].filter((each) => !each.done).map((each) => each.command);
107
+ }
108
+ }
package/lib/codex.mjs CHANGED
@@ -120,6 +120,11 @@ export function codexArgs({
120
120
  if (server.disabledTools?.length) {
121
121
  args.push('-c', `${key}.disabled_tools=${tomlValue(server.disabledTools)}`);
122
122
  }
123
+ // How long one call may take before Codex gives up on it — 60 s unless
124
+ // set, which is shorter than a person answering a question.
125
+ if (server.toolTimeoutSec) {
126
+ args.push('-c', `${key}.tool_timeout_sec=${tomlValue(server.toolTimeoutSec)}`);
127
+ }
123
128
  args.push('-c', `${key}.default_tools_approval_mode="approve"`);
124
129
  }
125
130
  if (resumeId) {
@@ -255,6 +255,23 @@ export function parseRoadmap(text) {
255
255
  entry.afterRefs = tokens.map(({ ref }) => ref);
256
256
  continue;
257
257
  }
258
+ const tags = /^Tags:\s*(.+)$/.exec(line.trim());
259
+ if (tags) {
260
+ // R315. Already normalised by the server (lowercase, no `#`) by the
261
+ // time it reaches the file, so a plain split is the whole rule.
262
+ entry.tags = tags[1].split(/[,\s]+/).map((t) => t.trim()).filter(Boolean);
263
+ continue;
264
+ }
265
+ // R315. Computed, never stored, and never sent by import.mjs — the same
266
+ // arrangement as `Development:` above. Kept as the exact text so a file
267
+ // parsed and re-rendered without asking the API again is still a fixed
268
+ // point; `renderEntry` recomputes the same text from a live `origin`
269
+ // object when there is one.
270
+ const origin = /^Origin:\s*(.+)$/.exec(line.trim());
271
+ if (origin) {
272
+ entry.origin = origin[1].trim();
273
+ continue;
274
+ }
258
275
  const sprint = /^Sprint:\s*S(\d+)\s*(.*)$/.exec(line.trim());
259
276
  if (sprint) {
260
277
  // R257: `Sprint: S1 Notifications` — the sprint's number and its name
@@ -360,6 +377,33 @@ export function renderRoadmap(entries, { preamble, sectionOrder = [] }) {
360
377
  return parts.join('\n').replace(/\n+$/, '') + '\n';
361
378
  }
362
379
 
380
+ /**
381
+ * The word `Origin:` writes — R315. `entry.origin` is a plain string once it
382
+ * has round-tripped through the file (see the parser above); freshly off the
383
+ * API it is `{kind, label, url}`, and this is the one place that turns the
384
+ * second into the first, so the two paths render identically.
385
+ */
386
+ function originWord(origin) {
387
+ if (typeof origin === 'string') {
388
+ return origin;
389
+ }
390
+ if (!origin || origin.kind === 'NONE') {
391
+ return null;
392
+ }
393
+ switch (origin.kind) {
394
+ case 'SONARQUBE':
395
+ return 'SonarQube';
396
+ case 'FEEDBACK':
397
+ return 'Feedback';
398
+ case 'APPLICATION':
399
+ return origin.label ? `An application (${origin.label})` : 'An application';
400
+ case 'SESSION':
401
+ return origin.label ? `A session (${origin.label})` : 'A session';
402
+ default:
403
+ return null;
404
+ }
405
+ }
406
+
363
407
  function renderEntry(entry) {
364
408
  const lines = [`### ${entryRef(entry)} — ${entry.title}`, ''];
365
409
 
@@ -425,6 +469,14 @@ function renderEntry(entry) {
425
469
  .map(({ ref }) => ref);
426
470
  lines.push(`After: ${written.join(', ')}`);
427
471
  }
472
+ if (entry.tags?.length) {
473
+ // R315. Sorted, so the export does not churn on the set's own order.
474
+ lines.push(`Tags: ${[...entry.tags].sort().join(', ')}`);
475
+ }
476
+ const origin = originWord(entry.origin);
477
+ if (origin) {
478
+ lines.push(`Origin: ${origin}`);
479
+ }
428
480
  if (entry.sprint) {
429
481
  // R257. A line on the card and never a heading: the section is where the
430
482
  // file groups, and a sprint is a set a person opened across sections. The
package/mcp/server.mjs CHANGED
@@ -665,6 +665,13 @@ const TOOLS = [
665
665
  'Cards this one starts coding after — it waits in the queue until every one of ' +
666
666
  'them is MERGED or SHIPPED. Refs, as `related`. Same project, must exist, never itself.',
667
667
  },
668
+ tags: {
669
+ type: 'array',
670
+ items: { type: 'string' },
671
+ description:
672
+ 'Free words — lowercase, letters/digits/./_/-, up to 32 characters, at least one ' +
673
+ 'letter, at most 12. The leading # is optional and stripped.',
674
+ },
668
675
  },
669
676
  required: ['title'],
670
677
  },
@@ -674,7 +681,7 @@ const TOOLS = [
674
681
  method: 'POST',
675
682
  body: pick(args, [
676
683
  'title', 'body', 'status', 'branch', 'merge', 'version', 'reason', 'section', 'related',
677
- 'after', 'sprint',
684
+ 'after', 'sprint', 'tags',
678
685
  ]),
679
686
  });
680
687
  return `Created ${refOf(entry)} in ${slug}.\n\n${formatEntry(entry, {})}`;
@@ -734,6 +741,13 @@ const TOOLS = [
734
741
  items: { type: ['string', 'integer'] },
735
742
  description: 'Refs — R12, i15; a bare number is a roadmap card (R221).',
736
743
  },
744
+ tags: {
745
+ type: 'array',
746
+ items: { type: 'string' },
747
+ description:
748
+ 'Free words — lowercase, letters/digits/./_/-, up to 32 characters, at least one ' +
749
+ 'letter, at most 12. The leading # is optional and stripped.',
750
+ },
737
751
  },
738
752
  required: ['title', 'severity'],
739
753
  },
@@ -741,7 +755,7 @@ const TOOLS = [
741
755
  const slug = await resolveProject(config, args.project);
742
756
  const issue = await api(config, `/api/projects/${slug}/issues`, {
743
757
  method: 'POST',
744
- body: pick(args, ['title', 'body', 'severity', 'status', 'related']),
758
+ body: pick(args, ['title', 'body', 'severity', 'status', 'related', 'tags']),
745
759
  });
746
760
  return `Filed ${refOf(issue)} in ${slug}.\n\n${formatEntry(issue, {})}`;
747
761
  },
@@ -779,7 +793,7 @@ const TOOLS = [
779
793
  {
780
794
  name: 'roadmap_update',
781
795
  description:
782
- 'Edit an entry\'s title, body, section, sprint, related refs or the cards it starts ' +
796
+ 'Edit an entry\'s title, body, section, sprint, tags, related refs or the cards it starts ' +
783
797
  'after. Use roadmap_set_status to move it.',
784
798
  inputSchema: {
785
799
  type: 'object',
@@ -813,6 +827,12 @@ const TOOLS = [
813
827
  'them is MERGED or SHIPPED. Refs, as `related`. Same project, must exist, never ' +
814
828
  'itself. [] clears it.',
815
829
  },
830
+ tags: {
831
+ type: 'array',
832
+ items: { type: 'string' },
833
+ description:
834
+ 'Free words — same rule as roadmap_create. Leave out to leave them alone; [] clears them.',
835
+ },
816
836
  },
817
837
  required: ['number'],
818
838
  },
@@ -821,7 +841,7 @@ const TOOLS = [
821
841
  const entry = await api(config, `/api/projects/${slug}/roadmap/${args.number}`, {
822
842
  method: 'PATCH',
823
843
  // pick keeps a present 0, which is how a card leaves its sprint.
824
- body: pick(args, ['title', 'body', 'section', 'related', 'after', 'sprint']),
844
+ body: pick(args, ['title', 'body', 'section', 'related', 'after', 'sprint', 'tags']),
825
845
  });
826
846
  return `Updated ${refOf(entry)}.\n\n${formatEntry(entry, {})}`;
827
847
  },
@@ -1631,9 +1651,13 @@ async function decide(config, args) {
1631
1651
  }
1632
1652
  // EXPIRED, or the poll gave up before the platform expired it. Same answer
1633
1653
  // either way, and the difference is on the run for whoever reads it later.
1654
+ // R339: a person may still allow it late, and if they do the session is
1655
+ // sent a message saying so — so this says to carry on, not to stop.
1634
1656
  return deny(
1635
- `Nobody answered this permission request in time. Do not retry it in a loop: report ` +
1636
- `blocked, say exactly what you needed to run and why, and let a person allow it.`,
1657
+ `Nobody answered this permission request in time. Do not retry it in a loop. Carry on ` +
1658
+ `with whatever does not need it; if a person allows it later you will be sent a ` +
1659
+ `message saying so, and may run it then. If nothing else can be done without it, ` +
1660
+ `report blocked and say exactly what you needed to run and why.`,
1637
1661
  );
1638
1662
  } catch (failure) {
1639
1663
  return deny(
@@ -1786,9 +1810,10 @@ function skillServers() {
1786
1810
  /**
1787
1811
  * Waits for somebody to decide, for as long as the platform will hold it open.
1788
1812
  *
1789
- * Bounded a little beyond the platform's own expiry so the two cannot both be
1790
- * waiting on each other: if the sweep is late, this gives up and denies, which
1791
- * is the same answer the sweep would have produced.
1813
+ * R339: the platform's wait is the project's setting now, and may be never,
1814
+ * so this has no deadline of its own: it returns when the request stops being
1815
+ * PENDING — allowed, refused, or expired by the platform's sweep. A test may
1816
+ * still bound it.
1792
1817
  */
1793
1818
  async function pollForDecision(config, project, runId, approvalId) {
1794
1819
  const deadline = Date.now() + approvalTimeoutSeconds() * 1000;
@@ -1811,10 +1836,10 @@ async function pollForDecision(config, project, runId, approvalId) {
1811
1836
  return null;
1812
1837
  }
1813
1838
 
1814
- /** A little past the platform's own fifteen-minute expiry. Overridable for tests. */
1839
+ /** For ever: the platform decides when nobody is coming (R339). Overridable for tests. */
1815
1840
  function approvalTimeoutSeconds() {
1816
1841
  const configured = Number(process.env.CAWDEV_APPROVAL_TIMEOUT_SECONDS);
1817
- return Number.isFinite(configured) && configured > 0 ? configured : 960;
1842
+ return Number.isFinite(configured) && configured > 0 ? configured : Infinity;
1818
1843
  }
1819
1844
 
1820
1845
  // There is no roadmap_delete or changelog_delete, and there will not be. The
@@ -2065,6 +2090,23 @@ function refOf(entry) {
2065
2090
  return entry.ref ?? ((entry.kind === 'ISSUE' ? 'i' : 'R') + entry.number);
2066
2091
  }
2067
2092
 
2093
+ /**
2094
+ * The word `formatEntry` prints for a card's origin — R315. Never stored,
2095
+ * always the object shape `{kind, label, url}` off the API — unlike
2096
+ * `roadmap-format.mjs`'s own copy, which also has to read one back out of a
2097
+ * file.
2098
+ */
2099
+ function originWord(origin) {
2100
+ if (!origin || origin.kind === 'NONE') return null;
2101
+ switch (origin.kind) {
2102
+ case 'SONARQUBE': return 'SonarQube';
2103
+ case 'FEEDBACK': return 'Feedback';
2104
+ case 'APPLICATION': return origin.label ? `an application (${origin.label})` : 'an application';
2105
+ case 'SESSION': return origin.label ? `a session (${origin.label})` : 'a session';
2106
+ default: return null;
2107
+ }
2108
+ }
2109
+
2068
2110
  function formatEntry(entry, { brief, comments, plan }) {
2069
2111
  const lines = [`${refOf(entry)} — ${entry.title}`, ` status: ${entry.statusDisplay}`];
2070
2112
  if (entry.branch) lines.push(` branch: ${entry.branch}`);
@@ -2072,6 +2114,9 @@ function formatEntry(entry, { brief, comments, plan }) {
2072
2114
  if (entry.version) lines.push(` version: ${entry.version}`);
2073
2115
  if (entry.declinedReason) lines.push(` declined because: ${entry.declinedReason}`);
2074
2116
  if (entry.section) lines.push(` section: ${entry.section}`);
2117
+ // R315. Sorted for the same reason the export is: a reader's shape must not
2118
+ // depend on the set's own insertion order.
2119
+ if (entry.tags?.length) lines.push(` tags: ${[...entry.tags].sort().map((t) => `#${t}`).join(' ')}`);
2075
2120
  // R257. Always printed when the card has one, so a reader's shape does not
2076
2121
  // depend on which tool printed the card; a closed sprint says so.
2077
2122
  if (entry.sprint) {
@@ -2108,6 +2153,12 @@ function formatEntry(entry, { brief, comments, plan }) {
2108
2153
  lines.push(` written by: a ${entry.createdBy.profile} session, `
2109
2154
  + `under ${entry.createdBy.startedByEmail}`);
2110
2155
  }
2156
+ // R315. Never null on the wire — NONE is a person at the console — so only
2157
+ // ever printed when it says something. Beside `written by` rather than
2158
+ // instead of it: origin says the DOOR, and the session line above already
2159
+ // says who and what kind, when the door was a session.
2160
+ const origin = originWord(entry.origin);
2161
+ if (origin) lines.push(` origin: ${origin}`);
2111
2162
  if (!brief && entry.body) lines.push('', entry.body);
2112
2163
  // R124, between the body and the discussion — the order they are read in:
2113
2164
  // what we want, how we mean to get it, and the argument about both.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cawdev-cli",
3
- "version": "1.0.5-beta",
3
+ "version": "1.0.7-beta",
4
4
  "description": "cawdev's terminal client and runner daemon — zero dependencies, on purpose",
5
5
  "type": "module",
6
6
  "bin": {