@assuredloop/cli 0.1.0 → 0.2.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/src/commands.js DELETED
@@ -1,456 +0,0 @@
1
- // The commands new, record and context, and the readers of request.md they share.
2
- import { readFileSync, writeFileSync, mkdirSync, existsSync, lstatSync } from 'node:fs';
3
- import { isAbsolute, join, resolve } from 'node:path';
4
- import { Fail, HISTORY, gapFail, git, hasCommits, historyGap, isShallow, mainCommit, mainName, now, ownCommits, resolveCommit, STAMP, stamp, day, unavailable } from './git.js';
5
- import { openTree, findRequest, isName, noSymlinkOn } from './tree.js';
6
- import { formatSnapshot, notSnapshot, parseSnapshot, sha256, slug, snapshots } from './snapshot.js';
7
- import { ID_TEXT, PREFIX_TEXT, TITLES, headingAt, prose } from './sections.js';
8
- import { SIGNED_OFF, organized, parts, signoffState, signoffStep, statusField, tierIs, tierOne } from './signoff.js';
9
- import { changeStates, stateText } from './states.js';
10
- import { recordSection } from './record-section.js';
11
- import { archivedLines, diffView, sectionView } from './views.js';
12
- import { hintLines, hintsOf, ranked, readBranch } from './hints.js';
13
- import { decisions as decisionIds } from './record-section.js';
14
- import { rootOf, baseline } from './spec.js';
15
- import { requestsIn } from './links.js';
16
- import { audit } from './audit.js';
17
- import { noChangeMd } from './conclude.js';
18
-
19
- // The entries of a `## <title>` section: its bullet or numbered items, each with its continuation lines.
20
- export function entriesOf(text, title) {
21
- const lines = text.split('\n');
22
- const at = headingAt(lines, title);
23
- if (at < 0) return [];
24
- const out = [];
25
- for (const l of lines.slice(at + 1)) {
26
- if (/^#{1,2}\s/.test(l)) break;
27
- if (/^(-|\d+\.)\s/.test(l)) out.push(l.trim());
28
- else if (l.trim() && out.length) out[out.length - 1] += ` ${l.trim()}`;
29
- }
30
- return out;
31
- }
32
-
33
- // [REC-8]: the child request a part names, in its one form: the part is
34
- // `request <name>`, or ends with `: request <name>`. Null for any other part.
35
- // Parts are read out of fenced code.
36
- export const childOf = (entry) => entry.match(/^(?:-|\d+\.)\s+(?:.*:\s+)?request ([a-z0-9][a-z0-9-]*)$/)?.[1] ?? null;
37
- export const partsOf = (md) => entriesOf(prose(md), TITLES.parts);
38
- export const childrenOf = (md) => partsOf(md).map(childOf).filter(Boolean);
39
-
40
- // The entries of `## Decisions`: each one's ID, date and Source clause (up to
41
- // its first ". "). A clause that names the agent makes it an agent ruling.
42
- export function decisionList(md) {
43
- return entriesOf(md, TITLES.decisions).filter((e) => /^- D\d+\b/.test(e)).map((e) => {
44
- const source = e.match(/Source:\s*([\s\S]*?)(?:\.\s|\.$|$)/)?.[1] ?? '';
45
- return { id: e.match(/^- (D\d+)\b/)[1], date: e.match(/\d{4}-\d\d-\d\d/)?.[0] ?? '', source, agent: /\bagent\b/.test(source) };
46
- });
47
- }
48
-
49
- // [STA-8]: whether a request is concluded on main, derived from main's history:
50
- // the commit on main's first-parent line (the --at commit's, under --at)
51
- // where its archived request.md arrived (for a merge, the merge commit), as
52
- // { sha, when }; else { none } saying why. Nothing records it.
53
- export function concluding(top, name, at) {
54
- const path = `requests/archive/${name}/request.md`;
55
- const main = at ? resolveCommit(top, at) : mainCommit(top);
56
- // With no main, a clone that lacks history cannot tell whether main concluded it ([VW-9]).
57
- const gap = !main && historyGap(top);
58
- if (gap) return { none: unavailable(gap.kind, ' without main'), gap: unavailable(gap.kind) };
59
- if (!main || git(top, ['cat-file', '-e', `${main}:${path}`], { allowFail: true }) === null) return { none: 'not on main yet' };
60
- const unknown = `on main; which commit added ${path} is not known`;
61
- if (isShallow(top)) return { none: `${unknown}: ${HISTORY}`, gap: HISTORY };
62
- const found = git(top, ['log', '--first-parent', '--no-renames', '--diff-filter=A', '--format=%H %ct', main, '--', path]);
63
- if (!found) return { none: unknown };
64
- const [sha, when] = found.split('\n')[0].split(' ');
65
- return { sha, when: new Date(Number(when) * 1000) };
66
- }
67
-
68
- export function concludedOnMain(top, name, at) {
69
- const c = concluding(top, name, at);
70
- return c.sha ? `${at ? `in the history of ${at.slice(0, 7)}` : 'on main'} at ${c.sha.slice(0, 7)} (${stamp(c.when)}), where requests/archive/${name}/request.md arrived` : c.none;
71
- }
72
-
73
- const ID_ARG = new RegExp(`^\\[?(${ID_TEXT})\\]?$`);
74
-
75
- export const BAD_NAME = (name) => `${JSON.stringify(name ?? '')} is not a request name: a name is lowercase letters, digits and hyphens, never a path ([REC-1])`;
76
- const TIERS = ['0', '1', '2', '3', 'S'];
77
- const TIER0 = 'tier 0 has no record ([REC-10]): if this is a fix, say why in the commit and drop the record; otherwise its tier is 1 or higher';
78
-
79
- // A labelled line, as in `Next al context x`.
80
- export const line = (label, text) => `${label.padEnd(10)}${text}`;
81
-
82
- // [VW-2], [VW-6]: items grouped by state, `<count> <state>: <ids>`, none left
83
- // out. IDs the same up to a last number that runs on read as a range, REC-1–3,
84
- // so it expands back to the exact IDs: the text before it the same, and no
85
- // leading zero. An item marked `alone` (an ID@n, a waiting block) never is.
86
- const ID_RUN = new RegExp(`^(${PREFIX_TEXT}-(?:\\d+\\.)*)(0|[1-9]\\d*)$`);
87
- export function grouped(label, items) {
88
- const groups = [...new Set(items.map((x) => x.state))].map((s) => {
89
- const xs = items.filter((x) => x.state === s);
90
- const names = [];
91
- let run = null;
92
- const flush = () => { if (run) names.push(run.first === run.last ? run.first : `${run.first}–${run.last.slice(run.last.indexOf('-') + 1)}`); run = null; };
93
- for (const x of xs) {
94
- const m = !x.alone && x.text.match(ID_RUN);
95
- if (run && m && run.stem === m[1] && Number(m[2]) === run.n + 1) { run.last = x.text; run.n += 1; continue; }
96
- flush();
97
- if (m) run = { stem: m[1], first: x.text, last: x.text, n: Number(m[2]) };
98
- else names.push(x.text);
99
- }
100
- flush();
101
- return `${xs.length} ${s}: ${names.join(', ')}`;
102
- });
103
- return line(label, groups.join(' · '));
104
- }
105
-
106
- // A labelled block of lines: the label on the first, the rest under it.
107
- export const lines = (label, texts) => texts.map((t, i) => line(i ? '' : label, t));
108
-
109
- function readInput(from, cwd) {
110
- if (from === undefined) throw new Fail('--from <file|-> is missing', 'pass the text with --from <file>, or --from - on standard input');
111
- try {
112
- return from === '-' ? readFileSync(0) : readFileSync(resolve(cwd, from));
113
- } catch (e) {
114
- throw new Fail(`cannot read ${from}: ${e.code || e.message}`);
115
- }
116
- }
117
-
118
- function historyNote(top) {
119
- return !hasCommits(top) ? 'no commits yet, so no history' : isShallow(top) ? HISTORY : null;
120
- }
121
-
122
- // al new <name> --from <file|-> [--title <t>] [--tier <t>]
123
- export function newRequest({ top, cwd, args, opts }) {
124
- const [name] = args;
125
- if (!isName(name)) {
126
- throw new Fail(`bad request name ${JSON.stringify(name ?? '')}: use lowercase letters, digits and hyphens`, 'al new <name> --from <file|->');
127
- }
128
- if (opts.tier !== undefined && !TIERS.includes(opts.tier)) throw new Fail(`bad tier ${opts.tier}: one of ${TIERS.join(', ')}`);
129
- if (opts.tier === '0') throw new Fail(TIER0, 'al new <name> --tier 1 (or higher) when the work changes a promise');
130
- const words = readInput(opts.from, cwd);
131
- if (words.length === 0) throw new Fail('no owner\'s words given', 'pass the words with --from <file> or on standard input');
132
-
133
- // [REC-1]: a name is never reused, whether in the tree or ever in history.
134
- const paths = [`requests/${name}`, `requests/archive/${name}`];
135
- const used = paths.find((p) => existsSync(join(top, p))) ||
136
- (git(top, ['log', '--all', '-1', '--format=%h', '--', ...paths], { allowFail: true }) ? 'git history' : null);
137
- if (used) throw new Fail(`the name ${name} is already used (${used}); a request name is never reused`, 'al new <another name> --from <file|->');
138
-
139
- // [REC-1]: records are written only inside requests/ in this repo, never through a symlink.
140
- if (!noSymlinkOn(top, `requests/${name}/origin`)) {
141
- throw new Fail('requests/ is a symlink; records are written only through real folders, and nothing was written', 'make requests/ a real folder in this repo');
142
- }
143
-
144
- const when = now();
145
- const date = stamp(when);
146
- const snapName = `${day(when)}-owner-words.md`;
147
- const dir = join(top, 'requests', name);
148
- mkdirSync(join(dir, 'origin'), { recursive: true });
149
- writeFileSync(join(dir, 'origin', snapName), formatSnapshot({
150
- source: opts.from === '-' ? 'standard input' : opts.from, fetched: date, text: words,
151
- }));
152
- writeFileSync(join(dir, 'request.md'), [
153
- `# ${opts.title ?? name}`,
154
- opts.tier === undefined ? 'Status: open' : `Tier: ${opts.tier} · Status: open`,
155
- '',
156
- `## ${TITLES.words}`,
157
- '',
158
- `- ${day(when)} the owner's words, snapshot origin/${snapName}`,
159
- '',
160
- ].join('\n'));
161
- return {
162
- body: [`Wrote requests/${name}/request.md and requests/${name}/origin/${snapName}`],
163
- next: `write the organized requirement in requests/${name}/request.md, then al context ${name}`,
164
- notKnown: [historyNote(top) ?? 'whether the name is used on a branch not fetched here'],
165
- };
166
- }
167
-
168
- // al record <name> origin --url <src> | --verify <snapshot>, --from <file|-> [--yes]
169
- export function recordOrigin(ctx) {
170
- const { top, cwd, args, opts } = ctx;
171
- const [name, kind] = args;
172
- if (kind === 'signoff') return recordSignoff(ctx);
173
- if (kind === 'section') return recordSection(ctx);
174
- if (kind === 'decision' || kind === 'part') return recordEntry(ctx, kind);
175
- if (kind !== 'origin') throw new Fail(`record ${kind ?? ''}: al record <name> origin|signoff|section|decision|part`, 'al record <name> origin --url <source> --from -');
176
- const tree = openTree(top);
177
- const dir = requestToWrite(top, tree, name);
178
- const origin = `${dir}/origin`;
179
- if (!noSymlinkOn(top, origin)) {
180
- throw new Fail(`${origin} is reached through a symlink; records are written only through real folders, and nothing was written`, `make ${origin} a real folder in this repo`);
181
- }
182
- if ((opts.url === undefined) === (opts.verify === undefined)) throw new Fail('give one of --url <source> or --verify <snapshot>');
183
- if (opts.fetched !== undefined && !STAMP.test(opts.fetched)) throw new Fail(`--fetched ${opts.fetched}: write it as YYYY-MM-DDTHH:MMZ`);
184
- const text = readInput(opts.from, cwd);
185
- if (text.length === 0) throw new Fail('the text is empty', 'pass the fetched text with --from <file> or on standard input');
186
- const fetched = opts.fetched ?? stamp(now());
187
- const body = [];
188
- let source = opts.url;
189
-
190
- if (opts.verify !== undefined) {
191
- // An absolute path names exactly one file; a bare name is looked up in origin/ first.
192
- const inOrigin = `${dir}/origin/${opts.verify}`;
193
- const path = !isAbsolute(opts.verify) && tree.read(inOrigin) !== null ? inOrigin : opts.verify;
194
- const bytes = (isAbsolute(path) ? null : tree.read(path)) ?? (existsSync(resolve(cwd, path)) ? readFileSync(resolve(cwd, path)) : null);
195
- if (bytes === null) throw new Fail(`no snapshot ${opts.verify}`, `ls ${dir}/origin`);
196
- const old = parseSnapshot(bytes);
197
- if (!old) throw new Fail(notSnapshot(opts.verify));
198
- if (!old.intact) body.push(`not ok: ${opts.verify} text no longer matches its SHA-256`);
199
- if (sha256(text) === old.recorded) {
200
- body.push(`unchanged since ${old.fields.Fetched}`);
201
- return { body, next: `al context ${name}`, notKnown: ['whether the source changes after this fetch'] };
202
- }
203
- body.push(`changed since ${old.fields.Fetched}`);
204
- source = old.fields.Source;
205
- }
206
-
207
- const snapshot = formatSnapshot({ source, fetched, updated: opts.updated, text });
208
- const base = `${fetched.slice(0, 10)}-${slug(source)}`;
209
- let file = `${base}.md`;
210
- // A name is taken by anything there, a symlink included, so no write goes through a link.
211
- for (let n = 2; taken(join(top, dir, 'origin', file)); n++) file = `${base}-${n}.md`;
212
- const target = `${dir}/origin/${file}`;
213
- const shown = snapshot.toString('utf8').replace(/\n$/, '').split('\n');
214
- if (opts.yes) {
215
- mkdirSync(join(top, dir, 'origin'), { recursive: true });
216
- writeFileSync(join(top, target), snapshot);
217
- body.push(`Wrote ${target}:`, ...shown);
218
- return { body, next: `cite origin/${file} in an entry of ${dir}/request.md`, notKnown: ['whether the source changes after this fetch'] };
219
- }
220
- body.push(`Would write ${target}:`, ...shown);
221
- return { body, next: 'run the same command with --yes to write it', notKnown: ['whether the source changes after this fetch'] };
222
- }
223
-
224
- // The folder of a request the tool may write: it exists, and is not archived on
225
- // main ([REC-1]; one archived on this branch still may be, [REC-12]).
226
- export function requestToWrite(top, tree, name) {
227
- if (!isName(name)) throw new Fail(BAD_NAME(name), 'al context for the requests there are');
228
- const dir = findRequest(tree, name);
229
- if (!dir) throw new Fail(`no request named ${name ?? ''}`, 'al new <name> --from <file|->');
230
- const main = mainCommit(top);
231
- const gap = dir.startsWith('requests/archive/') && !main && historyGap(top);
232
- if (gap) throw gapFail(gap, ` with no main, so whether ${name} is archived on main is not known; nothing was written`);
233
- if (dir.startsWith('requests/archive/') && main && git(top, ['cat-file', '-e', `${main}:${dir}/request.md`], { allowFail: true }) !== null) {
234
- throw new Fail(`${name} is archived on ${main.replace('refs/remotes/', '').replace('refs/heads/', '')}, and an archived request is not edited`,
235
- 'start a new request that follows it: al new <name> --from <file|->');
236
- }
237
- return dir;
238
- }
239
-
240
- // al record <name> signoff --source <where> [--words <quote>] [--yes] ([REC-5]):
241
- // shows only what changed since the last sign-off, and writes on --yes.
242
- function recordSignoff({ top, args, opts }) {
243
- const [name] = args;
244
- if (!opts.source) throw new Fail('--source <where the owner signed> is missing', `al record ${name} signoff --source <where> --words <quote>`);
245
- const tree = openTree(top);
246
- const dir = requestToWrite(top, tree, name);
247
- if (!noSymlinkOn(top, `${dir}/request.md`) || !noSymlinkOn(top, `${dir}/origin`)) {
248
- throw new Fail(`${dir} is reached through a symlink; records are written only through real folders, and nothing was written`, `make ${dir} a real folder in this repo`);
249
- }
250
- const md = tree.read(`${dir}/request.md`).toString('utf8');
251
- const org = organized(md);
252
- if (!org) throw new Fail(`${dir}/request.md has no "## Organized requirement" (or question) to sign`, `write the organized requirement in ${dir}/request.md`);
253
- // What changed is what context reads: since its own sign-off, or, for a child, its parent's ([REC-5]).
254
- const state = signoffState(tree, dir, name);
255
- const last = state.signoff;
256
- const since = last ? last.file : state.parentSignoff && `${state.parent}'s origin/${state.parentSignoff.file}`;
257
- const notKnown = ['whether the owner read what was signed (the file records only what they said)'];
258
- if (!state.blocked) {
259
- return { body: [`${last ? `unchanged since ${last.file}` : `signed off through ${state.parent}; unchanged since`}; nothing to sign`], next: `al context ${name}`, notKnown };
260
- }
261
- const changed = since ? state.changed : null;
262
- const shown = changed
263
- ? parts(org.text, org.oneLine).filter((p) => changed.includes(p.key)).flatMap((p) => p.text.replace(/\n+$/, '').split('\n'))
264
- : org.text.replace(/\n+$/, '').split('\n');
265
- const removed = changed ? changed.filter((k) => !parts(org.text, org.oneLine).some((p) => p.key === k)) : [];
266
- const when = now();
267
- const fetched = stamp(when);
268
- let file = `${day(when)}-signoff.md`;
269
- for (let n = 2; taken(join(top, dir, 'origin', file)); n++) file = `${day(when)}-signoff-${n}.md`;
270
- const body = [since ? `Changed since ${since}:` : 'To be signed (first sign-off):', ...shown];
271
- if (removed.length) body.push(`Removed: ${removed.join(', ')}`);
272
- if (!opts.yes) {
273
- body.push(`Would write ${dir}/origin/${file} and set the Signed off line`);
274
- return { body, next: 'show this to the owner; on their OK, run the same command with --yes', notKnown };
275
- }
276
- mkdirSync(join(top, dir, 'origin'), { recursive: true });
277
- writeFileSync(join(top, dir, 'origin', file), formatSnapshot({ source: opts.source, words: opts.words, fetched, text: org.text, signed: true }));
278
- const signedOff = `Signed off: ${day(when)} owner, origin/${file}\n`;
279
- const lines = org.raw.split(/(?<=\n)/);
280
- const at = lines.findIndex((l) => SIGNED_OFF.test(l));
281
- if (at >= 0) lines.splice(at, 1, signedOff);
282
- else {
283
- const end = lines.findLastIndex((l) => l.trim());
284
- // A last line with no newline (the end of the file) gets one, so the Signed off line stands alone.
285
- if (end >= 0 && !lines[end].endsWith('\n')) lines[end] += '\n';
286
- lines.splice(end + 1, 0, signedOff);
287
- }
288
- writeFileSync(join(top, dir, 'request.md'), md.slice(0, org.start) + lines.join('') + md.slice(org.end));
289
- body.push(`Wrote ${dir}/origin/${file} and the Signed off line in ${dir}/request.md`);
290
- return { body, next: `al context ${name}`, notKnown };
291
- }
292
-
293
- // al record <name> decision --source <who> --text <decision> [--yes] ([REC-7]),
294
- // and al record <name> part --text <part> [--yes] ([REC-8]): one new entry at
295
- // the end of ## Decisions (the next literal Dn, dated) or ## Parts (the next
296
- // number), the section created at the end when missing. Nothing else changes.
297
- function recordEntry({ top, args, opts }, kind) {
298
- const [name] = args;
299
- const usage = kind === 'decision' ? `al record ${name} decision --source <who> --text <decision> --yes` : `al record ${name} part --text <part> --yes`;
300
- if (opts.text === undefined || (kind === 'decision' && opts.source === undefined)) throw new Fail(`record ${kind} needs ${kind === 'decision' ? '--source and ' : ''}--text`, usage);
301
- const tree = openTree(top);
302
- const dir = requestToWrite(top, tree, name);
303
- if (!noSymlinkOn(top, `${dir}/request.md`)) throw new Fail(`${dir} is reached through a symlink; records are written only through real folders, and nothing was written`, `make ${dir} a real folder in this repo`);
304
- const md = tree.read(`${dir}/request.md`).toString('utf8');
305
- const title = kind === 'decision' ? TITLES.decisions : TITLES.parts;
306
- const lines = md.split('\n');
307
- const at = headingAt(lines, title);
308
- let end = at < 0 ? lines.length : lines.findIndex((l, i) => i > at && /^#{1,2}\s/.test(l));
309
- if (end < 0) end = lines.length;
310
- let last = end;
311
- while (at >= 0 && last > at + 1 && !lines[last - 1].trim()) last--;
312
- const n = kind === 'decision' ? Math.max(0, ...decisionIds(md).ids) + 1 : partsOf(md).length + 1;
313
- const bullets = partsOf(md).at(-1)?.startsWith('-');
314
- const entry = kind === 'decision' ? `- D${n}, ${day(now())}. Source: ${opts.source}. ${opts.text}` : bullets ? `- ${opts.text}` : `${n}. ${opts.text}`;
315
- const next = at < 0 ? `${md.replace(/\n*$/, '\n')}\n## ${title}\n\n${entry}\n` : [...lines.slice(0, last), entry, ...lines.slice(last)].join('\n');
316
- if (!opts.yes) return { body: [`Would add to ${dir}/request.md, ## ${title}:`, entry], next: 'run the same command with --yes to write it', notKnown: [] };
317
- writeFileSync(join(top, dir, 'request.md'), next);
318
- return { body: [`Added to ${dir}/request.md, ## ${title}:`, entry], next: `al context ${name}`, notKnown: [] };
319
- }
320
-
321
- const taken = (path) => {
322
- try { lstatSync(path); return true; } catch { return false; }
323
- };
324
-
325
- // al context [<name> | <ID> | --diff <range>] [--audit] [--at <commit>]: where
326
- // the project, a request or a section stands ([VW-1] to [VW-4], [VW-7]).
327
- export function context({ top, args, opts }) {
328
- const [name] = args;
329
- if (opts.for !== undefined && (opts.for !== 'review' || opts.diff === undefined)) throw new Fail('--for review goes with --diff <range>', 'al context --diff main...HEAD --for review');
330
- if (opts.audit) {
331
- if (name === undefined || opts.diff !== undefined) throw new Fail('--audit takes a request, a section ID or path:line', 'al context <name | ID | path:line> --audit');
332
- return audit(top, ID_ARG.test(name) ? name.match(ID_ARG)[1] : name, opts.at);
333
- }
334
- if (/:\d+$/.test(name ?? '')) throw new Fail(`${name}: path:line goes with --audit`, `al context ${name} --audit`);
335
- if (name !== undefined && !ID_ARG.test(name) && !isName(name)) throw new Fail(BAD_NAME(name), 'al context for the requests there are');
336
- if (opts.diff !== undefined) return diffView(top, opts.diff, opts.for === 'review', opts.all, opts.at);
337
- if (ID_ARG.test(name ?? '')) return sectionView(top, name.match(ID_ARG)[1], opts.at);
338
- if (name === undefined) return projectView(top, opts.at);
339
- const tree = openTree(top, opts.at);
340
- const dir = findRequest(tree, name);
341
- if (!dir) throw new Fail(`no request named ${name} in the ${tree.label}`, 'al context <name> for a request under requests/');
342
-
343
- const md = tree.read(`${dir}/request.md`).toString('utf8').split('\n');
344
- const title = (md.find((l) => l.startsWith('# ')) ?? `# ${name}`).slice(2).trim();
345
- const field = (key) => statusField(md.join('\n'), key);
346
- const head = [field('Type'), field('Tier') && `tier ${field('Tier')}`, field('Status') ?? 'status unknown'].filter(Boolean);
347
-
348
- const read = snapshots(tree, dir).reverse();
349
- const files = read.map((x) => x.file);
350
- const bad = [];
351
- for (const { file: f, s } of read) {
352
- if (!s) bad.push([f, notSnapshot(f)]);
353
- else if (!s.intact) bad.push([f, 'no longer matches its SHA-256']);
354
- }
355
- const state = signoffState(tree, dir, name);
356
- const changed = state.changed?.length ? `: ${state.changed.join(', ')}` : '';
357
- const signedBy = state.signoff ? `origin/${state.signoff.file} (${state.signoff.fetched})` : '';
358
- const through = state.parent ? `${signedBy ? ' and ' : ''}through ${state.parent}` : '';
359
- const body = [
360
- ...(state.blocked ? [`BLOCKED: ${state.reason}${changed}`] : []),
361
- `${name} ${title} ${head.join(' · ')}`,
362
- ...(dir.startsWith('requests/archive/') ? [line('Concluded', concludedOnMain(top, name, opts.at)), ...archivedLines(top, name, opts.at, opts.all)] : []),
363
- line('Require', state.blocked ? `${state.reason}${changed}${changed && state.signoff ? `; al record ${name} signoff --source <where> shows what changed` : ''}` : `signed off ${signedBy}${through}; unchanged since`),
364
- line('Words', files.length ? `${files.join(' · ')} (SHA-256 checked: ${files.length - bad.length} of ${files.length} match)` : 'no snapshots in origin/'),
365
- ];
366
- // [VW-2]: the decisions, the held sections, the parts, and who else holds sections in the same files.
367
- const text = md.join('\n');
368
- const decisions = decisionList(text).reverse().map((d) => `${d.id} ${d.date}${d.agent ? ' (agent ruling)' : ''}`.trim());
369
- // --all, the verbose mode ([VW-2]): each decision in full, newest first.
370
- if (opts.all) body.push(...lines('Decided', entriesOf(text, TITLES.decisions).filter((e) => /^- D\d+/.test(e)).reverse().map((e) => e.slice(2))));
371
- else if (decisions.length) body.push(line('Decided', decisions.join(' · ')));
372
- const all = changeStates(top, { at: opts.at });
373
- const held = all.filter((e) => e.request === name);
374
- const count = (id) => held.filter((e) => e.id === id).length;
375
- const label = (e) => (count(e.id) > 1 ? `${e.id}@${e.n}` : e.id);
376
- // [VW-2]: a block marked Dropped or Kept shows by its marker, not its content state.
377
- const shown = held.map((e) => (e.mark ? { ...e, state: e.mark, by: null } : e));
378
- const own = (key) => (key.startsWith(`${name}/`) ? key.slice(name.length + 1) : key);
379
- const full = (e) => `${label(e)} ${stateText(e, e.by && own(e.by))}${e.forR.length ? ` (${e.forR.join(', ')})` : ''}`;
380
- // [VW-2]: one section per line with --all; else the list while it fits in
381
- // 100 characters; else every section grouped by state, none left out.
382
- const list = line('Spec', shown.map(full).join(' · '));
383
- if (held.length) {
384
- body.push(...(opts.all ? lines('Spec', shown.map(full)) : list.length <= 100 ? [list]
385
- : [grouped('Spec', shown.map((e) => (e.state === 'waiting' ? { state: e.state, text: `${e.id}@${e.n} on ${own(e.by)}`, alone: true }
386
- : { state: e.state, text: label(e), alone: count(e.id) > 1 })))]));
387
- }
388
- const parts = partsOf(text);
389
- if (parts.length) body.push(line('Parts', parts.map((p) => p.replace(/\s+/g, ' ')).join(' · ')));
390
- const mine = new Set(held.map((e) => e.file).filter(Boolean));
391
- const others = all.filter((e) => e.request !== name && mine.has(e.file));
392
- if (others.length) body.push(line('Same file', others.map((e) => `${e.request} holds ${e.id} in ${e.file} (${e.state})`).join(' · ')));
393
-
394
- // [HNT-1]: the hints this request owns, from the list check makes; then each
395
- // named child's state ([REC-8]). Three at most, in twelve lines, or --all.
396
- const main = opts.at ? null : mainCommit(top);
397
- const base = main && git(top, ['merge-base', main, 'HEAD'], { allowFail: true });
398
- const commits = base ? ownCommits(top, main, 'HEAD') : [];
399
- const b = readBranch(top, { base, commits, tree, at: opts.at, range: `${mainName(top)}..HEAD` });
400
- // conclude's rules re-run on an archived request are check's ([STA-8]); a view never re-checks one.
401
- const hints = ranked(hintsOf(top, b, { main }), b).filter((h) => h.owners.includes(name) && h.rank !== 7);
402
- if (tierIs(field('Tier'), '0')) hints.push({ kind: 'note', text: TIER0, command: `set the Tier line of ${dir}/request.md to 1 or higher, then al context ${name}; for a fix, drop the record` });
403
- partsOf(text).forEach((p, k) => {
404
- const c = childOf(p);
405
- const d = c && findRequest(tree, c);
406
- const status = d && statusField(tree.read(`${d}/request.md`).toString('utf8'), 'Status');
407
- const now = !d ? null : d.startsWith('requests/archive/') ? status : signoffState(tree, d, c).blocked ? 'blocked' : 'open';
408
- if (now) hints.push({ kind: 'note', text: `part ${k + 1} names request ${c}: ${now}`, command: `al context ${c}` });
409
- });
410
- const cap = opts.all ? hints.length : Math.max(0, Math.min(3, 12 - body.length - 3));
411
- body.push(...hintLines(hints, cap, { self: name }));
412
- const hidden = cap === 0 && hints.length ? `; hints: ${hints.length} more hidden, --all` : '';
413
- return {
414
- tree,
415
- body,
416
- // [REC-1]: an archived request is not edited; its Next is a read command.
417
- next: (dir.startsWith('requests/archive/') ? `al context ${name} --audit for the whole trace`
418
- : state.blocked ? signoffStep(name, dir, state)
419
- : bad.length
420
- ? `re-fetch the source of origin/${bad[0][0]}, then al record ${name} origin ${bad[0][1].startsWith('no longer') ? `--verify ${bad[0][0]}` : '--url <source>'} --from -`
421
- : nextStep(name, dir, text, held, tree)) + hidden,
422
- notKnown: [historyNote(top), 'whether the sources changed since they were fetched', opts.at && 'the hints that compare with main (not read under --at)'].filter(Boolean),
423
- };
424
- }
425
-
426
- // [VW-2]: the step a signed request is at, from its held sections: align what
427
- // needs it, consolidate what is pending, write a Now still unchanged, else
428
- // conclude. The first that holds wins.
429
- const ALIGN = ['differs', 'broken link', 'base revised', 'base dropped', 'not found', 'waiting'];
430
- function nextStep(name, dir, md, held, tree) {
431
- const live = held.filter((e) => e.mark !== 'dropped');
432
- if (held.some((e) => ALIGN.includes(e.state) && !(e.mark === 'dropped' && e.retainsNothing))) return `al context ${name} --all`;
433
- if (live.some((e) => e.state === 'pending')) return `al consolidate ${name}`;
434
- const unchanged = live.find((e) => e.state === 'no change yet');
435
- if (unchanged) return `edit the Now of ${unchanged.block} in ${dir}/change.md, then al context ${name}`;
436
- if (noChangeMd(tree, dir) && tierOne(md)) return `edit the sections it amends in the baseline, then al conclude ${name}`;
437
- return `al conclude ${name}`;
438
- }
439
-
440
- // al context: the open requests, one line each, the blocked ones first ([VW-1]).
441
- function projectView(top, at) {
442
- const tree = openTree(top, at);
443
- const rows = requestsIn(tree).filter((r) => r.open).map((r) => {
444
- const s = signoffState(tree, r.dir, r.name);
445
- const title = (r.md.match(/^# (.*)$/m)?.[1] ?? r.name).trim();
446
- return { blocked: s.blocked, text: `${r.name} ${title} ${s.blocked ? `BLOCKED: ${s.reason}` : statusField(r.md, 'Status') ?? 'status unknown'}` };
447
- }).sort((x, y) => y.blocked - x.blocked);
448
- const body = rows.length ? rows.map((r) => r.text) : ['no open requests'];
449
- if (!baseline(tree, rootOf(top, tree, at)).length) body.push('no baseline yet; requests add sections as they go');
450
- return {
451
- tree,
452
- body,
453
- next: rows[0] ? `al context ${rows[0].text.split(' ')[0]}` : 'al new <name> --from <file|->',
454
- notKnown: [historyNote(top), 'whether the sources changed since they were fetched'].filter(Boolean),
455
- };
456
- }