@assuredloop/cli 0.2.2 → 0.2.4

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/README.md CHANGED
@@ -66,7 +66,9 @@ Search at level 2 needs the library `@huggingface/transformers` 4.3.1 (see
66
66
 
67
67
  GitHub does not show the marker. The ID is one flat number per file, and
68
68
  it is never used again. `al` computes a display number, for example
69
- `INV-41 (2.3:4, in Export links)`, which can change when text moves.
69
+ `INV-41 (2.3:4, in Export links)`, which can change when text moves. So
70
+ a heading needs no number of its own: write `## The proxy`, not
71
+ `## 7. The proxy`.
70
72
  - **Kinds.** Promise kinds (purpose, scope, rule, limit, definition): a
71
73
  change needs a signed requirement that covers it. Design kinds (component,
72
74
  interface, data, flow, choice) and `approach` (how one change is built):
@@ -76,7 +78,15 @@ Search at level 2 needs the library `@huggingface/transformers` 4.3.1 (see
76
78
  - **A request** is the folder `requests/<name>/`: `origin/` (the owner's words
77
79
  and the sign-offs, as snapshots with SHA-256), `request.md` (the dialog, the
78
80
  organized requirement R1…, the decisions D1…) and, from path 2, `spec.md`,
79
- the change spec, with IDs SP-1, SP-2…
81
+ the change spec, with IDs SP-1, SP-2… The order of its paragraphs is
82
+ free. The recommended order is one section per topic. Each section has
83
+ the promise paragraph first, then the design that keeps it, with its
84
+ rationale and examples, then its open points. Cross-cutting design and
85
+ decisions follow. While the request is open, a paragraph of its change
86
+ spec may be deleted: its ID is retired and never used again, and
87
+ `al check` gives a hint. In `specs/`, a removal needs `removes:`. Plan,
88
+ step and migration paragraphs may live in the tracker issue that a task's
89
+ `ref` names, instead of in the change spec.
80
90
  - **Records** in `.assuredloop/records/`, committed to git: one per request
81
91
  (its sources, requirement versions, sign-offs, decisions, tasks, the
82
92
  bindings of its links and the dispositions of its paragraphs) and one per
@@ -171,7 +181,10 @@ central: {path: ../invoicer} # in an output repo: the central repo
171
181
  ADRs are `specs/adr/NNNN-<slug>.md`, with `Status: proposed`, `accepted` or
172
182
  `superseded` on the first line. The heading's marker holds `ADR-<n>` and its
173
183
  `decides:`, `source:` and `supersedes:` links. The text of an accepted ADR
174
- does not change; a new ADR supersedes it.
184
+ does not change; a new ADR supersedes it. You may add links to the marker of
185
+ an accepted ADR when nothing else changes: keep its ID, its kind and each old
186
+ link. `al check` gives a hint for a proposed or accepted ADR with no
187
+ `source:` or no `decides:`; under `--strict`, it is `not ok`.
175
188
 
176
189
  ## Search
177
190
 
@@ -266,7 +279,7 @@ those commits. To move:
266
279
  | The tool checks | The skill asks | The owner decides |
267
280
  |---|---|---|
268
281
  | `al conclude` refuses on its three conditions (see "The close rule") | write the owner's words and the organized requirement, and ask for the sign-off | what is wanted, by signing the organized requirement |
269
- | IDs: missing, used twice, lost, used again; kinds and links; links that do not resolve; sign-off coverage of promise changes; path and typo claims; dispositions and exact equality; stale bindings; overlapping changes; ADR text changes; result applicability, as hints | choose the path, and state it in a commit message and the PR | whether a changed requirement is signed again |
282
+ | IDs: missing, used twice, lost, used again; kinds and links; links that do not resolve; sign-off coverage of promise changes; path and typo claims; dispositions and exact equality; stale bindings; overlapping changes; ADR text changes; a proposed or accepted ADR with no `source:` or no `decides:`; result applicability; a design paragraph that links to none of its requirement's promise paragraphs in its change spec; a `for:` that names no task, as hints | choose the path, and state it in a commit message and the PR | whether a changed requirement is signed again |
270
283
  | `check --strict` exits 1 on a `not ok`, when a project opts in | record each later decision, with its source | decisions whose source is the owner, such as dropping or keeping work |
271
284
 
272
285
  `al` checks that declarations are well formed and that references resolve.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@assuredloop/cli",
3
- "version": "0.2.2",
3
+ "version": "0.2.4",
4
4
  "description": "AssuredLoop keeps an AI focused on one change: the al command gathers and checks a change's requirement, spec, decisions, code and tests.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -57,8 +57,13 @@ The export link MUST expire 30 minutes after the email is sent.
57
57
  ```
58
58
 
59
59
  - When you edit a marked paragraph, keep its marker line and the blank lines
60
- around it. Keep the ID when you move the paragraph. Never renumber or
61
- delete an ID: a lost ID is `not ok`.
60
+ around it. Keep the ID when you move the paragraph. Never renumber an ID,
61
+ and never use one again.
62
+ - In an open request's change spec, you may delete a paragraph with its
63
+ marker. Its ID is retired and never used again. `al check` gives a hint
64
+ that names the deletion; when it is on purpose, nothing more is needed. In
65
+ `specs/`, a removal needs `removes:<ID>` in a change spec: a lost ID there
66
+ is `not ok`.
62
67
  - For a new paragraph, write the text with no marker. Then run
63
68
  `al spec --add-ids <file> --yes`: it gives each unmarked paragraph the next
64
69
  free ID. Then write the kind and the links into each new marker.
@@ -72,6 +77,11 @@ The export link MUST expire 30 minutes after the email is sent.
72
77
  `governed-by:<ADR>`; `for:<task>`. A promise, design or approach paragraph
73
78
  needs at least one link. In `specs/`, the ID of a change spec paragraph
74
79
  names its request: `invoice-exports/SP-12`.
80
+ - An ADR in `specs/adr/` has one marker on its heading: `ADR-<n>`, its kind,
81
+ and its `decides:`, `source:` and `supersedes:` links. Under `--strict`,
82
+ `al check` asks each proposed or accepted ADR for `source:` and
83
+ `decides:`. You may add links to the marker of an accepted ADR. Change
84
+ nothing else in it: its ID, its kind, its old links and its text stay.
75
85
  - `al` writes every hash and every derived field. Do not write them by hand.
76
86
 
77
87
  ## Path 0: a fix
@@ -131,11 +141,19 @@ The export link MUST expire 30 minutes after the email is sent.
131
141
  deliver with the owner's sign-off or after it. A change that touches no
132
142
  promise kind needs no sign-off.
133
143
  2. Write the design in `requests/<name>/spec.md`, the change spec: the
134
- promises this change adds or changes, its design, its plan. Run
144
+ promises this change adds or changes, its design, its plan. The order
145
+ of the paragraphs is free. The recommended order is one section per
146
+ topic. Each section has the promise paragraph first, then the design
147
+ that keeps it, with its rationale and examples, then its open points.
148
+ Cross-cutting design and decisions follow. Run
135
149
  `al spec --add-ids requests/<name>/spec.md --yes` for the IDs (SP-1,
136
150
  SP-2…), then write the kinds and links. A paragraph that relies on a spec
137
151
  paragraph says `builds-on:<ID>`. One that replaces it says `changes:<ID>`.
138
- One that removes it says `removes:<ID>`. Never copy the old text.
152
+ One that removes it says `removes:<ID>`. Never copy the old text. A
153
+ design or approach paragraph that keeps a promise paragraph of this
154
+ change spec says `builds-on:SP-n`. `al check` gives a hint when it serves
155
+ a requirement and links to none of that requirement's promise
156
+ paragraphs here.
139
157
  3. Snapshot each later word of the owner that you rely on:
140
158
  `al record <name> origin --url <where> --from - --yes`. Record each later
141
159
  decision: `al record <name> decision --source <who> --text "<it>" --yes`.
@@ -178,8 +196,9 @@ As path 2, in parts. Each part consolidates the paragraphs it delivers,
178
196
  with their dispositions, in its own PR. Tasks are optional: where the
179
197
  project uses them, list them in the record as
180
198
  `tasks: [{id: T2, ref: "issue #34", delivers: [R3]}]`, and name the task in
181
- the commit messages of its PRs. Run `al conclude <name> --yes` with the last
182
- part.
199
+ the commit messages of its PRs. Plan, step and migration paragraphs may live
200
+ in the tracker issue that the task's `ref` names, instead of in the change
201
+ spec. Run `al conclude <name> --yes` with the last part.
183
202
 
184
203
  ## Path S: a spike
185
204
 
package/src/v4/check.js CHANGED
@@ -4,7 +4,10 @@
4
4
  // checks that read records (T10, checks.js). The base is the merge-base of
5
5
  // HEAD with main; the head is the working tree. It exits 0; with --strict, 1
6
6
  // when it prints a `not ok`. --strict raises every marker lint and an invalid
7
- // disposition to `not ok`; the hints that need judgment stay hints.
7
+ // disposition to `not ok`; the hints that need judgment stay hints. A lost ID
8
+ // of an open request's change spec is a hint, also under --strict (#230).
9
+ import { parse as parseYaml } from 'yaml';
10
+ import { read, recordPath } from './base.js';
8
11
  import { loadConfig, loadSchema } from './config.js';
9
12
  import { mergeBase } from './git.js';
10
13
  import { diffParagraphs, idLints } from './ids.js';
@@ -34,7 +37,7 @@ export function check({ top, opts }) {
34
37
  lints.push(...idLints(head, head));
35
38
  continue;
36
39
  }
37
- lints.push(...idLints(before, head, { everUsed: used.get(scope) ?? [], removes: scope === SPEC ? removes : [] }));
40
+ lints.push(...idLints(before, head, { everUsed: used.get(scope) ?? [], removes: scope === SPEC ? removes : [] }).map((l) => deleted(top, l)));
38
41
  changes.push(...diffParagraphs(before, head).filter((c) => c.changes.length));
39
42
  }
40
43
 
@@ -43,7 +46,7 @@ export function check({ top, opts }) {
43
46
  const shown = lints.filter((l) => !(l.code === 'no-link' && t10.adopted.has(l.id) && !l.file.startsWith('requests/')));
44
47
  lints.length = 0;
45
48
  lints.push(...shown);
46
- const severity = (l) => (opts.strict ? 'not ok' : l.severity);
49
+ const severity = (l) => (opts.strict && !l.staysHint ? 'not ok' : l.severity);
47
50
  const body = notRead(top, config);
48
51
  // A root that config sets but that is not read is a not ok, as in al index (#218).
49
52
  const rootLine = rootNotOk(top, config);
@@ -53,7 +56,7 @@ export function check({ top, opts }) {
53
56
  if (!lints.length) body.push('ok: no marker lint');
54
57
  body.push(...t10.findings.map((f) => `${f.severity} ${f.code} ${f.where} ${f.id} ${f.message}`));
55
58
  // Under --strict every marker lint is a not ok.
56
- const notOk = Boolean(rootLine) || lints.length > 0 || t10.findings.some((f) => f.severity === 'not ok');
59
+ const notOk = Boolean(rootLine) || lints.some((l) => severity(l) === 'not ok') || t10.findings.some((f) => f.severity === 'not ok');
57
60
  // In an output repo: the central IDs that the branch's changed files cite (T12).
58
61
  const central = centralLookup(top);
59
62
  body.push(...central.body);
@@ -71,6 +74,31 @@ export function check({ top, opts }) {
71
74
  };
72
75
  }
73
76
 
77
+ // A lost ID of an open request's change spec (its file at the base is
78
+ // requests/<name>/spec.md): deleting a paragraph there is ordinary editing,
79
+ // so it is a hint that names the deletion and each disposition or wording
80
+ // decision of the request's record that names the ID (#230, D1-D4).
81
+ const OPEN_SPEC = /^requests\/(?!archive\/)([^/]+)\/spec\.md$/;
82
+ function deleted(top, l) {
83
+ const name = l.code === 'lost-id' && OPEN_SPEC.exec(l.file)?.[1];
84
+ if (!name) return l;
85
+ const source = `${name}/${l.id}`;
86
+ let rec;
87
+ try {
88
+ rec = parseYaml(read(top, recordPath(name)) ?? 'null');
89
+ } catch {
90
+ rec = null;
91
+ }
92
+ const list = (x) => (Array.isArray(x) ? x : []);
93
+ const says = { incorporated: (d) => `incorporated as ${d.spec}`, removed: (d) => `removed ${d.spec}`, superseded: (d) => `superseded by ${d.by}` };
94
+ const facts = [
95
+ ...list(rec?.dispositions).filter((d) => d?.source === source && says[d.disposition]).map((d) => `its disposition says ${says[d.disposition](d)}`),
96
+ ...list(rec?.decisions).filter((d) => d?.wording?.source === source).map((d) => `decision ${d.id} names its wording`),
97
+ ];
98
+ const message = `${source} is deleted${facts.map((f) => `; ${f}`).join('')}. If that is on purpose, nothing more is needed: the ID is retired and never used again.`;
99
+ return { ...l, severity: 'hint', staysHint: true, message };
100
+ }
101
+
74
102
  // The Next line names what to do for what printed: a not ok, a hint, both, or
75
103
  // neither (#198).
76
104
  function nextStep(severities, noId) {
package/src/v4/checks.js CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  } from './state.js';
17
17
  import { NEAR, closeness, diffWords, marks, requirementWords, sentencesOf, showHunk } from './words.js';
18
18
  import { groupOf, sameBlocks } from './markers.js';
19
+ import { isCentral } from './indexer.js';
19
20
 
20
21
  const DESIGN = ['component', 'interface', 'data', 'flow', 'choice'];
21
22
  const TIER = /^[ \t]*Tier:[ \t]*(0|1d|1|2|3|S)\b[ \t]*(?:—|–|--?)?[ \t]*(.*)$/m;
@@ -256,6 +257,50 @@ export function checks(top, { base, strict = false }) {
256
257
  }
257
258
  }
258
259
 
260
+ // Design links (design.md 3): a design or approach paragraph of a change
261
+ // spec that serves a requirement, where promise paragraphs of the same
262
+ // change spec serve it, and that builds on or changes none of them. In the
263
+ // central repo, `central:X` is its own X (design.md 12).
264
+ const self = (ref) => (isCentral(now.config) && ref.startsWith('central:') ? ref.slice('central:'.length) : ref);
265
+ for (const r of open) {
266
+ const own = new Set(r.paras.keys());
267
+ const key = (ref) => qualify(self(ref), r.name, own).key;
268
+ const promises = new Map();
269
+ for (const p of r.paras.values()) {
270
+ if (!promise(p.kind)) continue;
271
+ for (const k of new Set(p.links.serves.map(key))) {
272
+ if (!promises.has(k)) promises.set(k, []);
273
+ promises.get(k).push(p.id);
274
+ }
275
+ }
276
+ if (!promises.size) continue;
277
+ for (const p of r.paras.values()) {
278
+ if (groupOf(p.kind, now.kinds) !== 'design') continue;
279
+ const linked = new Set([...p.links.buildsOn, ...p.links.changes].map(key));
280
+ for (const k of new Set(p.links.serves.map(key))) {
281
+ const ids = promises.get(k);
282
+ if (!ids || ids.some((id) => linked.has(`${r.name}/${id}`))) continue;
283
+ add('hint', 'design-link', p, `${r.name}/${p.id}`, `serves ${k}, but links to none of its promise paragraphs in this change spec (${ids.join(', ')}): which promise does it keep?`);
284
+ }
285
+ }
286
+ }
287
+
288
+ // Task references (design.md 8): where the project uses tasks, a `for:`
289
+ // that names no task in the record. A bare task is the paragraph's own
290
+ // request's. The records are read only when an open change spec has a `for:`.
291
+ const tasksOf = (r) => (Array.isArray(r?.data?.tasks) ? r.data.tasks : []);
292
+ const fors = open.flatMap((r) => [...r.paras.values()].flatMap((p) => p.links.for.map((ref) => ({ r, p, ref }))));
293
+ if (fors.length && [...now.requests.values()].some((r) => tasksOf(r).length)) {
294
+ for (const { r, p, ref } of fors) {
295
+ const t = self(ref);
296
+ if (t.includes(':')) continue;
297
+ const slash = t.indexOf('/');
298
+ const [name, id] = slash > 0 ? [t.slice(0, slash), t.slice(slash + 1)] : [r.name, t];
299
+ if (tasksOf(now.requests.get(name)).some((x) => String(x?.id) === id)) continue;
300
+ add('hint', 'unknown-task', p, `${r.name}/${p.id}`, `for:${ref}: no task ${id} in the record of ${name}`);
301
+ }
302
+ }
303
+
259
304
  // Bindings whose target changed since they were bound (design.md 4, 6).
260
305
  for (const r of open) {
261
306
  for (const b of Array.isArray(r.data?.bindings) ? r.data.bindings : []) {
@@ -308,12 +353,20 @@ export function checks(top, { base, strict = false }) {
308
353
  if (pending.length) add('info', 'disposition-pending', r.specFile, r.name, `${pending.length} of ${judged.length} paragraphs with a baseline effect have no disposition for their current version: ${pending.join(', ')}`);
309
354
  }
310
355
 
311
- // ADRs (schema.md 5).
356
+ // ADRs (schema.md 5). Links may be added to the heading marker of an
357
+ // accepted ADR: the same kind, each old link kept, the rest unchanged.
358
+ const linksAdded = (a, h) => a.status === 'accepted' && a.head && h.head && a.rest === h.rest && a.head.kind === h.head.kind
359
+ && Object.entries(a.head.links).every(([k, refs]) => refs.every((x) => h.head.links[k].includes(x)));
312
360
  for (const [id, a] of was?.adrs ?? []) {
313
361
  if (!['accepted', 'superseded'].includes(a.status)) continue;
314
362
  const h = now.adrs.get(id);
315
363
  if (!h) add('not ok', 'adr-changed', a.file, id, `${id} was ${a.status} and its file is gone; an ADR is superseded, never edited or deleted`);
316
- else if (h.body !== a.body) add('not ok', 'adr-changed', h.head ?? h.file, id, `the text of ${id} changed after it was accepted; write a new ADR that supersedes it`);
364
+ else if (h.body !== a.body && !linksAdded(a, h)) add('not ok', 'adr-changed', h.head ?? h.file, id, `the text of ${id} changed after it was accepted; write a new ADR that supersedes it`);
365
+ }
366
+ for (const a of now.adrs.values()) {
367
+ if (!['proposed', 'accepted'].includes(a.status)) continue;
368
+ const missing = ['source', 'decides'].filter((w) => !a.head?.links[w].length);
369
+ if (missing.length) add(strict ? 'not ok' : 'hint', 'adr-links', a.head ?? a.file, a.id, `${a.id} has ${missing.map((w) => `no ${w}:`).join(' and ')}; add the links to its marker`);
317
370
  }
318
371
  for (const a of now.adrs.values()) {
319
372
  for (const old of a.head?.links.supersedes ?? []) {
package/src/v4/state.js CHANGED
@@ -33,6 +33,10 @@ function names(top, rev, dir, folders) {
33
33
  // The text after an ADR's Status line: what must not change once it is accepted.
34
34
  const adrBody = (text) => text.replace(/\r\n?/g, '\n').replace(/^\s*Status:[^\n]*\n?/, '').replace(/^\n+/, '');
35
35
 
36
+ // The same with the line of the heading marker left out: what must not change
37
+ // when links are added to that marker (schema.md 5).
38
+ const adrRest = (text, head) => adrBody(text.replace(/\r\n?/g, '\n').split('\n').map((l, i) => (i === head.line - 1 ? '<!-- -->' : l)).join('\n'));
39
+
36
40
  // The settings of a commit: its own config.yaml and schema.yaml, with the
37
41
  // defaults of config.js when a file is absent or not a mapping.
38
42
  export function settingsAt(top, rev) {
@@ -143,7 +147,8 @@ export function loadState(top, rev = null) {
143
147
  const paragraphs = parseMarkdown(text, file, { kinds }).paragraphs;
144
148
  const id = `ADR-${Number(m[1])}`;
145
149
  const status = /^\s*Status:\s*([a-z]+)/i.exec(text)?.[1].toLowerCase() ?? null;
146
- adrs.set(id, { id, file, status, body: adrBody(text), paragraphs, head: paragraphs.find((p) => p.id === id) ?? null });
150
+ const head = paragraphs.find((p) => p.id === id) ?? null;
151
+ adrs.set(id, { id, file, status, body: adrBody(text), rest: head && adrRest(text, head), paragraphs, head });
147
152
  }
148
153
  return { top, rev, config, kinds, root, get, scopes, lints, spec, requests, adrs };
149
154
  }
package/src/v4/views.js CHANGED
@@ -9,6 +9,7 @@ import { crossRepo, findCites } from './repos.js';
9
9
  import { diffParagraphs } from './ids.js';
10
10
  import { loadResults } from './results.js';
11
11
  import { latestVersion, loadState, named, qualify, requestOf, resolve, signedBy } from './state.js';
12
+ import { isCentral } from './indexer.js';
12
13
 
13
14
  export const line = (label, text) => `${label.length < 10 ? label.padEnd(10) : `${label} `}${text}`;
14
15
  const list = (x) => (Array.isArray(x) ? x : []);
@@ -339,6 +340,25 @@ function checkLines(state, id, local) {
339
340
  return out;
340
341
  }
341
342
 
343
+ // The paragraphs of specs/ and of the open change specs that build on,
344
+ // explain or illustrate the paragraph named `who`. In the central repo,
345
+ // `central:X` is its own X (design.md 12).
346
+ function linkedTo(state, who) {
347
+ const self = (ref) => (isCentral(state.config) && ref.startsWith('central:') ? ref.slice('central:'.length) : ref);
348
+ const holders = [
349
+ ...[...state.spec.values()].map((p) => ({ p, r: null })),
350
+ ...[...state.requests.values()].filter((r) => r.open).flatMap((r) => [...r.paras.values()].map((p) => ({ p, r }))),
351
+ ];
352
+ const out = [];
353
+ for (const { p, r } of holders) {
354
+ const own = r ? new Set(r.paras.keys()) : new Set();
355
+ for (const [key, verb] of [['buildsOn', 'builds on it'], ['explains', 'explains it'], ['illustrates', 'illustrates it']]) {
356
+ if (p.links[key].some((ref) => qualify(self(ref), r?.name ?? null, own).key === who)) out.push(`${named(r?.name ?? null, p.id)} ${verb}`);
357
+ }
358
+ }
359
+ return out.length ? out.join(' · ') : 'no paragraph builds on, explains or illustrates it';
360
+ }
361
+
342
362
  function idView(state, id) {
343
363
  const t = qualify(id, null);
344
364
  const r = t.req ? state.requests.get(t.req) : null;
@@ -356,6 +376,7 @@ function idView(state, id) {
356
376
  if (word === 'serves' || !refs.length) continue;
357
377
  body.push(line('Links', `${word} ${refs.join(', ')}`));
358
378
  }
379
+ body.push(line('Linked', linkedTo(state, who)));
359
380
  if (!t.req) {
360
381
  const govern = [...state.adrs.values()].filter((a) => (a.head?.links.decides ?? []).includes(p.id) || p.links.governedBy.includes(a.id));
361
382
  body.push(line('Governed', govern.length ? `by ${govern.map((a) => `${a.id} (${a.status ?? 'no status'}${a.status === 'superseded' ? ': history' : ''})`).join(' · ')}` : 'by no ADR'));
@@ -398,6 +419,9 @@ function diffView(top, range, review) {
398
419
  const now = loadState(top, headSha);
399
420
  const was = loadState(top, base);
400
421
  const findings = review ? checks(top, { base }).findings : [];
422
+ // The ADRs with no source link, which the view marks where it names them (schema.md 5).
423
+ const unsourced = new Set([...now.adrs.values()].filter((a) => !a.head?.links.source.length).map((a) => a.id));
424
+ const marks = (message) => [...new Set(message.match(/\bADR-\d+\b/g) ?? [])].filter((id) => unsourced.has(id)).map((id) => `; ${id} has no source:`).join('');
401
425
  const body = [];
402
426
  for (const scope of new Set([...now.scopes.keys(), ...was.scopes.keys()])) {
403
427
  const request = requestOf(scope);
@@ -430,10 +454,10 @@ function diffView(top, range, review) {
430
454
  }
431
455
  if (!request) {
432
456
  for (const a of now.adrs.values()) {
433
- if ((a.head?.links.decides ?? []).includes(c.id) || p.links.governedBy.includes(a.id)) body.push(` governed by ${a.id} (${a.status ?? 'no status'})`);
457
+ if ((a.head?.links.decides ?? []).includes(c.id) || p.links.governedBy.includes(a.id)) body.push(` governed by ${a.id} (${a.status ?? 'no status'}${unsourced.has(a.id) ? '; no source:' : ''})`);
434
458
  }
435
459
  }
436
- for (const f of findings.filter((x) => x.id === who && x.severity !== 'info')) body.push(` ${findingLine(f)}`);
460
+ for (const f of findings.filter((x) => x.id === who && x.severity !== 'info')) body.push(` ${findingLine(f)}${marks(f.message)}`);
437
461
  }
438
462
  }
439
463
  if (!body.length) body.push('no paragraph changed in the range');