cawdev-cli 1.0.11-beta → 1.1.1

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/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';
@@ -375,6 +379,41 @@ const TOOLS = [
375
379
  },
376
380
  },
377
381
 
382
+ {
383
+ name: 'sprint_list',
384
+ description:
385
+ 'The sprints a project\'s cards can be filed into, with how far each has got — R489. Sprints ' +
386
+ 'are a team\'s: written `S7` inside the team and `Core team · S7` outside it. Read this before ' +
387
+ 'filing a card into one with roadmap_create / roadmap_update `sprint`. State narrows it ' +
388
+ '(ACTIVE, UPCOMING, COMPLETED; comma-separated); the default is ACTIVE and UPCOMING.',
389
+ inputSchema: {
390
+ type: 'object',
391
+ properties: {
392
+ ...PROJECT_ARGUMENT,
393
+ state: {
394
+ type: 'string',
395
+ description: 'ACTIVE, UPCOMING, COMPLETED — one or several, comma-separated. Default ACTIVE,UPCOMING.',
396
+ },
397
+ },
398
+ },
399
+ handler: async (config, args) => {
400
+ const slug = await resolveProject(config, args.project);
401
+ const state = args.state || 'ACTIVE,UPCOMING';
402
+ const sprints = await api(config, `/api/projects/${slug}/sprints?state=${encodeURIComponent(state)}`);
403
+ if (!sprints.length) return `${slug} has no sprints in ${state}.`;
404
+ return sprints
405
+ .map((sprint) => {
406
+ const counts = sprint.counts ?? {};
407
+ return (
408
+ `${sprint.label ?? sprint.ref}${sprint.name ? ` ${sprint.name}` : ''} — ` +
409
+ `${String(sprint.state).toLowerCase()} — ${counts.landed ?? 0} of ${counts.cards ?? 0} landed` +
410
+ (sprint.goal ? `\n goal: ${sprint.goal}` : '')
411
+ );
412
+ })
413
+ .join('\n');
414
+ },
415
+ },
416
+
378
417
  {
379
418
  name: 'roadmap_statuses',
380
419
  description:
@@ -396,18 +435,17 @@ const TOOLS = [
396
435
  {
397
436
  name: 'roadmap_list',
398
437
  description:
399
- 'A project\'s roadmap entries, optionally filtered by status or by sprint. Use brief=true ' +
400
- 'to survey without pulling every entry\'s body into context.',
438
+ 'A project\'s roadmap cards, optionally filtered by status or by sprint. Use brief=true ' +
439
+ 'to survey without pulling every card\'s body into context.',
401
440
  inputSchema: {
402
441
  type: 'object',
403
442
  properties: {
404
443
  ...PROJECT_ARGUMENT,
405
444
  sprint: {
406
- type: 'integer',
407
- minimum: 1,
445
+ type: ['integer', 'string'],
408
446
  description:
409
- 'Only cards in this sprint, by number — 1 for S1. A number the project has no ' +
410
- 'sprint for is refused naming the ones it has.',
447
+ 'Only cards in this sprint: `S7` (or 7), "Core team · S7" when two teams have an S7, or a ' +
448
+ 'sprint id. A sprint the project has none for is refused naming the ones it has.',
411
449
  },
412
450
  status: {
413
451
  type: 'string',
@@ -439,7 +477,9 @@ const TOOLS = [
439
477
  if (args.status) query.set('status', args.status);
440
478
  // R257. The API refuses a number with no sprint behind it, naming the
441
479
  // sprints there are; that sentence is passed through as the tool error.
442
- if (args.sprint) query.set('sprint', String(args.sprint));
480
+ if (args.sprint !== undefined && args.sprint !== '' && args.sprint !== 0) {
481
+ query.set('sprint', String(args.sprint));
482
+ }
443
483
  if (args.assignee) query.set('assignee', args.assignee);
444
484
  query.set('brief', String(brief));
445
485
 
@@ -454,7 +494,7 @@ const TOOLS = [
454
494
  {
455
495
  name: 'roadmap_get',
456
496
  description:
457
- 'One roadmap entry in full: its body, THE PLAN somebody agreed for it, and the ' +
497
+ 'One roadmap card in full: its body, THE PLAN somebody agreed for it, and the ' +
458
498
  'discussion on it. Read the plan before doing any of the work — it is what was decided, ' +
459
499
  'and it names the files. Read the comments before proposing anything: they are where an ' +
460
500
  'objection was answered, and re-proposing what was talked out three months ago is the ' +
@@ -594,7 +634,7 @@ const TOOLS = [
594
634
  {
595
635
  name: 'roadmap_comment',
596
636
  description:
597
- 'Say something about an entry, beside the entry rather than inside it. Use it for the ' +
637
+ 'Say something about a card, beside the card rather than inside it. Use it for the ' +
598
638
  'argument: an objection, a measurement, why an obvious approach was not taken. The body ' +
599
639
  'is where a settled conclusion is written down — edit that when the discussion reaches ' +
600
640
  'one. Comments cannot be deleted, by you or by anyone.',
@@ -680,12 +720,12 @@ const TOOLS = [
680
720
  {
681
721
  name: 'roadmap_create',
682
722
  description:
683
- 'Create a roadmap entry. The platform allocates its permanent number — written R91 on ' +
684
- 'a roadmap card and i91 on an issue, one sequence across both — and the ' +
723
+ 'Create a roadmap card. The platform allocates its permanent number — written R91 on ' +
724
+ 'a roadmap card and i91 on an issue, two sequences, one per kind — and the ' +
685
725
  'project decides where a card you file starts — the BACKLOG by default for a card an ' +
686
726
  'agent files, waiting until a person or a sprint brings it in — unless you say ' +
687
727
  'otherwise; a status that requires something must be given it. A card in the BACKLOG ' +
688
- 'holds no sprint: giving it one moves it to CONSIDERING. A plan session\'s card lands ' +
728
+ 'holds no sprint: giving it one moves it to CONSIDERING. A plan run\'s card lands ' +
689
729
  'where the console\'s do (R380).',
690
730
  inputSchema: {
691
731
  type: 'object',
@@ -714,12 +754,12 @@ const TOOLS = [
714
754
  reason: { type: 'string' },
715
755
  section: { type: 'string', description: 'Which part of the roadmap, e.g. "Phase 2 — …".' },
716
756
  sprint: {
717
- type: 'integer',
718
- minimum: 0,
757
+ type: ['integer', 'string'],
719
758
  description:
720
- 'The sprint to file this card into, by number — 1 for S1. 0 takes it out of its ' +
721
- 'sprint; leave it out to leave it alone. A CLOSED sprint refuses a card that is ' +
722
- 'not already in it, and only a person can reopen one — do not retry.',
759
+ 'The sprint to file this card into: `S7` (or 7) when the project\'s teams have one S7, ' +
760
+ '"Core team · S7" when two teams do, a sprint id, or 0 / "" to take it out of its sprint; ' +
761
+ 'leave it out to leave it alone. A COMPLETED sprint refuses a card that is not already in it, ' +
762
+ 'and only a person can reopen one — do not retry. Sprint_list shows what there is.',
723
763
  },
724
764
  related: {
725
765
  type: 'array',
@@ -893,7 +933,9 @@ const TOOLS = [
893
933
  'person should decide about: `kind` is your opinion, ISSUE (broken) or FEATURE (should ' +
894
934
  'also do / do better). A WRITER later accepts it as a roadmap card, accepts it as an ' +
895
935
  'issue, or refuses it with a reason. Use this when you are not sure it deserves a card; ' +
896
- '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.',
897
939
  inputSchema: {
898
940
  type: 'object',
899
941
  properties: {
@@ -929,7 +971,7 @@ const TOOLS = [
929
971
  {
930
972
  name: 'roadmap_update',
931
973
  description:
932
- 'Edit an entry\'s title, body, section, sprint, tags, parent, related refs or the cards it ' +
974
+ 'Edit a card\'s title, body, section, sprint, tags, parent, related refs or the cards it ' +
933
975
  'starts after. Use roadmap_set_status to move it.',
934
976
  inputSchema: {
935
977
  type: 'object',
@@ -943,12 +985,12 @@ const TOOLS = [
943
985
  body: { type: 'string' },
944
986
  section: { type: 'string' },
945
987
  sprint: {
946
- type: 'integer',
947
- minimum: 0,
988
+ type: ['integer', 'string'],
948
989
  description:
949
- 'The sprint to file this card into, by number — 1 for S1. 0 takes it out of its ' +
950
- 'sprint; leave it out to leave it alone. A CLOSED sprint refuses a card that is ' +
951
- 'not already in it, and only a person can reopen one — do not retry.',
990
+ 'The sprint to file this card into: `S7` (or 7) when the project\'s teams have one S7, ' +
991
+ '"Core team · S7" when two teams do, a sprint id, or 0 / "" to take it out of its sprint; ' +
992
+ 'leave it out to leave it alone. A COMPLETED sprint refuses a card that is not already in it, ' +
993
+ 'and only a person can reopen one — do not retry. Sprint_list shows what there is.',
952
994
  },
953
995
  related: {
954
996
  type: 'array',
@@ -1027,7 +1069,7 @@ const TOOLS = [
1027
1069
  {
1028
1070
  name: 'roadmap_set_status',
1029
1071
  description:
1030
- 'Move an entry to a status. Any status may move to any other — the rules are about what ' +
1072
+ 'Move a card to a status. Any status may move to any other — the rules are about what ' +
1031
1073
  'a status must carry, not a permitted path. IN_DEVELOPMENT needs a branch, MERGED the ' +
1032
1074
  'merge — the PR, the merge commit or the sha — and SHIPPED a version. Move a card to ' +
1033
1075
  'MERGED when its pull request lands; the branch may then be deleted. ' +
@@ -1076,7 +1118,7 @@ const TOOLS = [
1076
1118
  {
1077
1119
  name: 'roadmap_decline',
1078
1120
  description:
1079
- 'Decline an entry, with a reason. This is the only exit an entry has — there is no delete. ' +
1121
+ 'Decline a card, with a reason. This is the only exit a card has — there is no delete. ' +
1080
1122
  'The reason is the point: it stops the idea being proposed again.',
1081
1123
  inputSchema: {
1082
1124
  type: 'object',
@@ -1153,10 +1195,10 @@ const TOOLS = [
1153
1195
  entryNumber: {
1154
1196
  type: ['string', 'integer'],
1155
1197
  description:
1156
- 'The roadmap card or issue this entry describes — R156 — by ref: R91, i91, or a '
1198
+ 'The roadmap card or issue this changelog entry describes — R156 — by ref: R91, i91, or a '
1157
1199
  + 'bare number for a roadmap card (R221). Set it. Until R156 the only link was the '
1158
1200
  + 'ref you type into the prose, which cannot be queried, so a release could not '
1159
- + 'tell whether a card already had an entry and would write a second one beside '
1201
+ + 'tell whether a card already had a changelog entry and would write a second one beside '
1160
1202
  + 'the first. Put the ref in the text as well; that is what a reader sees.',
1161
1203
  },
1162
1204
  },
@@ -1189,8 +1231,8 @@ const TOOLS = [
1189
1231
  entryNumber: {
1190
1232
  type: ['string', 'integer'],
1191
1233
  description:
1192
- 'The roadmap card or issue this entry describes — R156 — by ref (R91, i91; a bare '
1193
- + 'number is a roadmap card). Set it on an older entry that has never been linked; '
1234
+ 'The roadmap card or issue this changelog entry describes — R156 — by ref (R91, i91; a bare '
1235
+ + 'number is a roadmap card). Set it on an older changelog entry that has never been linked; '
1194
1236
  + 'omitting it leaves whatever link is already there.',
1195
1237
  },
1196
1238
  },
@@ -1211,9 +1253,9 @@ const TOOLS = [
1211
1253
  {
1212
1254
  name: 'task_current',
1213
1255
  description:
1214
- 'What you are working on: the roadmap entry, THE PLAN agreed for it, its branch, and ' +
1256
+ 'What you are working on: the roadmap card, THE PLAN agreed for it, its branch, and ' +
1215
1257
  'everything already said and asked on this run. Call it first, and again whenever you ' +
1216
- 'are unsure where you are — a resumed or confused session re-orients from this alone.',
1258
+ 'are unsure where you are — a resumed or confused agent session re-orients from this alone.',
1217
1259
  inputSchema: { type: 'object', properties: {} },
1218
1260
  handler: async (config) => {
1219
1261
  const { runId, project } = await requireRun(config);
@@ -1408,6 +1450,14 @@ const TOOLS = [
1408
1450
  return notDoneYet(left);
1409
1451
  }
1410
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
+ }
1411
1461
  }
1412
1462
 
1413
1463
  const message = await api(config, `/api/projects/${project}/runs/${runId}/messages`, {
@@ -1435,20 +1485,20 @@ const TOOLS = [
1435
1485
  {
1436
1486
  name: 'propose_entry',
1437
1487
  description:
1438
- 'Record something an audit found, or a card a scoping session cut, as a proposed entry. ' +
1488
+ 'Record something an audit found, or a card a scoping run cut, as a proposed card. ' +
1439
1489
  '`kind` is your opinion: "issue" ' +
1440
1490
  '(broken, unsafe, or loses data — with a severity) or "roadmap" (it should also do this, ' +
1441
1491
  'or do it better — no severity). A PERSON decides, and may file it the other way — you ' +
1442
- 'are not creating an entry, you are suggesting it. Default "issue", so an audit written ' +
1492
+ 'are not creating a card, you are suggesting it. Default "issue", so an audit written ' +
1443
1493
  'before there was a choice files what it always did. Severity is "critical" (broken, ' +
1444
1494
  'unsafe, or loses data), "medium" (it will hurt, but not today) or "minor" (worth doing, ' +
1445
- 'nobody is bleeding). `section` is where you think it belongs; a scoping session names ' +
1495
+ 'nobody is bleeding). `section` is where you think it belongs; a scoping run names ' +
1446
1496
  'ONE for every card of the idea. A roadmap card MUST say what it starts after — `after`: ' +
1447
1497
  '`[]` for a card that starts after none, `"#2"` for the second card you proposed in this ' +
1448
1498
  'run (the number this tool answered with), `"R12"` for a card already on the roadmap. ' +
1449
1499
  'You can only name what has already been proposed, so file in build order. Write each ' +
1450
- 'one as an entry would be written, and say where in the code you saw it. Only an audit ' +
1451
- 'or a scoping session may use this.',
1500
+ 'one as a card would be written, and say where in the code you saw it. Only an audit ' +
1501
+ 'or a scoping run may use this.',
1452
1502
  inputSchema: {
1453
1503
  type: 'object',
1454
1504
  properties: {
@@ -1473,7 +1523,7 @@ const TOOLS = [
1473
1523
  type: 'string',
1474
1524
  description:
1475
1525
  'The roadmap section this belongs under, in your opinion. Optional. The person ' +
1476
- 'accepting sees it as the default and may file it elsewhere. A scoping session ' +
1526
+ 'accepting sees it as the default and may file it elsewhere. A scoping run ' +
1477
1527
  'uses the same section on every card it cuts from one idea.',
1478
1528
  },
1479
1529
  after: {
@@ -1485,7 +1535,7 @@ const TOOLS = [
1485
1535
  'card you proposed in THIS run, by the number this tool answered with; it must ' +
1486
1536
  'already have been proposed, so propose what a card depends on before the card. ' +
1487
1537
  '`"R12"` or `"i7"` names a card already on the roadmap. A person adds the cards ' +
1488
- 'in this order, and a coding session on one waits until what it starts after has ' +
1538
+ 'in this order, and a coding run on one waits until what it starts after has ' +
1489
1539
  'landed.',
1490
1540
  },
1491
1541
  },
@@ -1565,7 +1615,7 @@ const TOOLS = [
1565
1615
  name: 'ask_group',
1566
1616
  description:
1567
1617
  'Ask a ROUND of questions at once, and wait for all of them. Use it when you have several ' +
1568
- 'things to settle that belong together — they arrive in the person\'s inbox under one ' +
1618
+ 'things to settle that belong together — they arrive in the person\'s Waiting on you page under one ' +
1569
1619
  'title, on one form, instead of interrupting them once per question. At most 12 in a ' +
1570
1620
  'round; ask the rest in the next one. Blocks for up to ten minutes; if the round is ' +
1571
1621
  'unfinished by then it returns a group_id for await_group.',
@@ -1575,7 +1625,7 @@ const TOOLS = [
1575
1625
  title: {
1576
1626
  type: 'string',
1577
1627
  description:
1578
- 'What this round is called in their inbox. A CTO Interview uses "CTO Interview".',
1628
+ 'What this round is called in Waiting on you. A CTO Interview uses "CTO Interview".',
1579
1629
  },
1580
1630
  intro: {
1581
1631
  type: 'string',
@@ -1705,7 +1755,7 @@ const TOOLS = [
1705
1755
  name: 'await_answer',
1706
1756
  description:
1707
1757
  'Resume waiting for a question ask_user handed back. Between the two you experience one ' +
1708
- 'natural blocking ask; the person experiences an inbox item.',
1758
+ 'natural blocking ask; the person experiences a Waiting on you item.',
1709
1759
  inputSchema: {
1710
1760
  type: 'object',
1711
1761
  properties: { question_id: { type: 'string' } },
@@ -1784,7 +1834,49 @@ async function decide(config, args) {
1784
1834
  // R110. The shield FIRST, and the ordering is the point — see shieldSays.
1785
1835
  // A hit does not deny; it skips the rules and goes straight to a person,
1786
1836
  // and it is recorded so somebody can see what was stopped.
1787
- 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);
1788
1880
  if (stopped) {
1789
1881
  await api(config, `/api/projects/${project}/runs/${runId}/blocks`, {
1790
1882
  method: 'POST',
@@ -1857,26 +1949,7 @@ async function decide(config, args) {
1857
1949
  },
1858
1950
  });
1859
1951
 
1860
- const decided = await pollForDecision(config, project, runId, asked.id);
1861
- if (decided?.state === 'ALLOWED') {
1862
- return allow(input, decided.reason ?? 'allowed by a person');
1863
- }
1864
- if (decided?.state === 'DENIED') {
1865
- return deny(
1866
- `${decided.reason ?? 'A person refused this.'} Do not try to work around it — report ` +
1867
- `blocked and say what you needed, or ask for a different approach.`,
1868
- );
1869
- }
1870
- // EXPIRED, or the poll gave up before the platform expired it. Same answer
1871
- // either way, and the difference is on the run for whoever reads it later.
1872
- // R339: a person may still allow it late, and if they do the session is
1873
- // sent a message saying so — so this says to carry on, not to stop.
1874
- return deny(
1875
- `Nobody answered this permission request in time. Do not retry it in a loop. Carry on ` +
1876
- `with whatever does not need it; if a person allows it later you will be sent a ` +
1877
- `message saying so, and may run it then. If nothing else can be done without it, ` +
1878
- `report blocked and say exactly what you needed to run and why.`,
1879
- );
1952
+ return await answerOf(config, project, runId, asked.id, input);
1880
1953
  } catch (failure) {
1881
1954
  return deny(
1882
1955
  `cawdev could not be asked whether this is allowed (${failure.message}). Treating that as ` +
@@ -1885,6 +1958,104 @@ async function decide(config, args) {
1885
1958
  }
1886
1959
  }
1887
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
+
1888
2059
  function allow(updatedInput, reason) {
1889
2060
  // `updatedInput` echoed back unchanged. The hook exists to say yes or no,
1890
2061
  // not to rewrite what the agent was about to do behind its back.
@@ -1977,10 +2148,18 @@ function shieldPolicy() {
1977
2148
  blockSecrets: parsed.blockSecrets !== false,
1978
2149
  blockDestructive: parsed.blockDestructive !== false,
1979
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),
1980
2153
  root: process.env.CAWDEV_WORKSPACE ?? process.cwd(),
1981
2154
  };
1982
2155
  } catch {
1983
- 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
+ };
1984
2163
  }
1985
2164
  }
1986
2165
 
@@ -2065,11 +2244,23 @@ function approvalTimeoutSeconds() {
2065
2244
  // The same goes for comments — nothing here removes one, and nothing there
2066
2245
  // does either.
2067
2246
  //
2068
- // Nor is there a sprint_open, sprint_close or sprint_rename — R257. Opening,
2069
- // closing and renaming a sprint are a person's acts on the page, and the API
2070
- // says so structurally (POST/PATCH /sprints take a session, not a token). An
2071
- // agent's whole reach here is reading a card's sprint and filing a card into
2072
- // one with roadmap_create / roadmap_update `sprint`.
2247
+ // Nor is there a sprint_open, sprint_start, sprint_complete or sprint_rename —
2248
+ // R257, and a team's since R474. Opening, starting, completing and renaming a
2249
+ // sprint are a person's acts on the page, and the API says so structurally
2250
+ // (the /api/teams/{id}/sprints writes take a session, not a token). An
2251
+ // agent's whole reach here is reading the sprints (sprint_list) and a card's
2252
+ // sprint, and filing a card into one with roadmap_create / roadmap_update
2253
+ // `sprint`.
2254
+
2255
+ /**
2256
+ * R489. A card's sprint as a line: `Core team · S7 Name (active)`. The label
2257
+ * is the server's one spelling; `S7` alone means nothing outside its team.
2258
+ */
2259
+ function sprintLine(sprint) {
2260
+ const label = sprint.label ?? sprint.ref ?? `S${sprint.number}`;
2261
+ const state = String(sprint.state ?? '').toLowerCase();
2262
+ return `${label}${sprint.name ? ` ${sprint.name}` : ''}${state ? ` (${state})` : ''}`;
2263
+ }
2073
2264
 
2074
2265
  function pick(source, keys) {
2075
2266
  const out = {};
@@ -2318,7 +2509,7 @@ function originWord(origin) {
2318
2509
  if (!origin || origin.kind === 'NONE') return null;
2319
2510
  switch (origin.kind) {
2320
2511
  case 'SONARQUBE': return 'SonarQube';
2321
- case 'FEEDBACK': return 'Feedback';
2512
+ case 'FEEDBACK': return origin.label ? `Feedback (from ${origin.label})` : 'Feedback';
2322
2513
  case 'APPLICATION': return origin.label ? `an application (${origin.label})` : 'an application';
2323
2514
  case 'SESSION': return origin.label ? `a session (${origin.label})` : 'a session';
2324
2515
  default: return null;
@@ -2343,7 +2534,7 @@ function formatEntry(entry, { brief, comments, plan }) {
2343
2534
  if (entry.section) lines.push(` section: ${entry.section}`);
2344
2535
  // R315. Sorted for the same reason the export is: a reader's shape must not
2345
2536
  // depend on the set's own insertion order.
2346
- if (entry.tags?.length) lines.push(` tags: ${[...entry.tags].sort().map((t) => `#${t}`).join(' ')}`);
2537
+ if (entry.tags?.length) lines.push(` tags: ${[...entry.tags].sort(byCodeUnit).map((t) => `#${t}`).join(' ')}`);
2347
2538
  // R331. A username, never the email.
2348
2539
  if (entry.assignee) lines.push(` assignee: ${entry.assignee.username}`);
2349
2540
  // R335. Not printed at MEDIUM, as the export does not write it.
@@ -2361,7 +2552,7 @@ function formatEntry(entry, { brief, comments, plan }) {
2361
2552
  // depend on which tool printed the card; a closed sprint says so.
2362
2553
  if (entry.sprint) {
2363
2554
  lines.push(
2364
- ` sprint: ${entry.sprint.ref} ${entry.sprint.name}${entry.sprint.state === 'CLOSED' ? ' (closed)' : ''}`,
2555
+ ` sprint: ${sprintLine(entry.sprint)}`,
2365
2556
  );
2366
2557
  }
2367
2558
  if (entry.related?.length) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cawdev-cli",
3
- "version": "1.0.11-beta",
3
+ "version": "1.1.1",
4
4
  "description": "cawdev's terminal client and runner daemon — zero dependencies, on purpose",
5
5
  "type": "module",
6
6
  "bin": {