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 +38 -0
- package/lib/background-shells.mjs +108 -0
- package/lib/codex.mjs +5 -0
- package/lib/roadmap-format.mjs +52 -0
- package/mcp/server.mjs +62 -11
- package/package.json +1 -1
- package/runner/README.md +191 -22
- package/runner/attach.mjs +708 -69
- package/runner/cawdev.mjs +17 -2
- package/runner/configure.mjs +34 -0
- package/runner/input.mjs +26 -9
- package/runner/prefs.mjs +52 -0
- package/runner/restart.mjs +191 -0
- package/runner/runner.mjs +323 -27
- package/runner/sign-in.mjs +11 -0
- package/runner/split.mjs +109 -16
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) {
|
package/lib/roadmap-format.mjs
CHANGED
|
@@ -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
|
|
1636
|
-
`
|
|
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
|
-
*
|
|
1790
|
-
*
|
|
1791
|
-
*
|
|
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
|
-
/**
|
|
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 :
|
|
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.
|