@assuredloop/cli 0.1.0 → 0.2.0

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.
@@ -0,0 +1,119 @@
1
+ // al conclude <name> [--yes] (T11; design.md 5 The close rule, 6, 8): the
2
+ // only command that refuses. It reads the working tree, the closing PR's
3
+ // state, and refuses while a promise change has no signed requirement, a
4
+ // spike's current question is not signed, or a change paragraph with a
5
+ // baseline effect has no valid disposition for its current version. With
6
+ // --yes it writes the Outcome, sets the status and archives the request.
7
+ import { mkdirSync, renameSync } from 'node:fs';
8
+ import { dirname, join } from 'node:path';
9
+ import { Fail, day, exists, guard, isName, now, write } from './base.js';
10
+ import { judgeRequest } from './dispositions.js';
11
+ import { git } from './git.js';
12
+ import { openRecord, recordText } from './records.js';
13
+ import { isPromise, loadState, qualify, servesSigned } from './state.js';
14
+ import { signState } from './views.js';
15
+
16
+ const NOT_KNOWN = ['whether the code, tests and documents do what the paragraphs say',
17
+ 'whether the work has ended: the tool does not take a merged task or a reconciled spec as evidence of that'];
18
+
19
+ // The reasons the close rule refuses, each naming its IDs; [] when it holds.
20
+ function reasons(state, r) {
21
+ const out = [];
22
+ if (r.spike) {
23
+ if (!r.reqs.length) out.push(`the spike has no organized question to sign in ${r.dir}/request.md`);
24
+ const unsigned = r.reqs.filter((q) => !signState(state, r, q).startsWith('signed'));
25
+ if (unsigned.length) out.push(`the spike's current question is not signed: ${unsigned.map((q) => `${r.name}/${q.id} (${signState(state, r, q)})`).join(', ')}`);
26
+ return { out, judged: [] };
27
+ }
28
+ const judged = judgeRequest(state, r);
29
+ // A paragraph abandoned with no kept effect needs no sign-off (design.md 6);
30
+ // one whose effect an owner decision keeps is judged with its sign-off in judge().
31
+ const dropped = new Set(judged.filter((j) => j.status === 'abandoned' && j.valid && j.kept === false).map((j) => j.p.id));
32
+ const unsigned = [
33
+ ...[...r.paras.values()].filter((p) => isPromise(state, p.kind) && !dropped.has(p.id) && !servesSigned(state, p, r.name)).map((p) => `${r.name}/${p.id}`),
34
+ ...[...state.spec.values()].filter((p) => isPromise(state, p.kind) && p.links.serves.some((s) => qualify(s, null).req === r.name)
35
+ && !servesSigned(state, p, null)).map((p) => p.id),
36
+ ];
37
+ if (unsigned.length) out.push(`a promise change with no signed requirement that covers it: ${unsigned.join(', ')}`);
38
+ const open = judged.filter((j) => j.status === 'pending' || !j.valid);
39
+ if (open.length) {
40
+ out.push(`no valid disposition for the current version of ${open.map((j) => `${j.id} (${j.status === 'pending' ? 'none' : `${j.status}, not valid: ${j.reason}`})`).join(', ')}`);
41
+ }
42
+ return { out, judged };
43
+ }
44
+
45
+ // The Outcome block: what became of each requirement, with the evidence and
46
+ // what is unknown.
47
+ function outcome(state, r, judged, top) {
48
+ const head = git(top, ['rev-parse', '--short=7', 'HEAD'], { allowFail: true });
49
+ const lines = ['## Outcome', '', `Concluded ${day(now())} with al conclude, on the working tree${head ? ` at ${head}` : ''}.`, ''];
50
+ const shown = (j) => ` - ${j.p.id} ${j.p.kind}: ${j.status}${j.d?.spec ? `${j.status === 'incorporated' ? ' as' : ''} ${j.d.spec}` : ''}${j.d?.by ? ` by ${j.d.by}` : ''}${j.reason ? ` (${j.reason})` : ''}`;
51
+ const placed = new Set();
52
+ for (const q of r.reqs) {
53
+ const version = (Array.isArray(r.data?.requirements) ? r.data.requirements : []).filter((v) => v?.id === q.id && v.sha256 === q.sha256).at(-1)?.version;
54
+ const v = version ? `version ${version}` : 'version not indexed';
55
+ if (r.spike) {
56
+ lines.push(`- ${q.id} ${q.title}, ${v}, ${signState(state, r, q)}: the answer answers ${q.id} ${v}.`);
57
+ continue;
58
+ }
59
+ lines.push(`- ${q.id} ${q.title}, ${v}, ${signState(state, r, q)}:`);
60
+ const mine = judged.filter((j) => j.p.links.serves.some((s) => qualify(s, r.name).key === `${r.name}/${q.id}`));
61
+ for (const j of mine) { lines.push(shown(j)); placed.add(j.id); }
62
+ // A path-1 promise is served by the spec paragraph itself (design.md 5).
63
+ const inSpec = [...state.spec.values()].filter((p) => p.links.serves.some((s) => qualify(s, null).key === `${r.name}/${q.id}`));
64
+ for (const p of inSpec) lines.push(` - ${p.id} ${p.kind ?? '-'}: in ${p.file}, serves it`);
65
+ if (!mine.length && !inSpec.length) lines.push(' - no change paragraph and no spec paragraph serves it');
66
+ }
67
+ const rest = judged.filter((j) => !placed.has(j.id));
68
+ if (rest.length) lines.push('- Other paragraphs with a baseline effect:', ...rest.map(shown));
69
+ lines.push(`- Not known: ${NOT_KNOWN.join('; ')}.`, '');
70
+ return lines;
71
+ }
72
+
73
+ const withStatus = (md) => (/\bStatus:\s*\S+/.test(md) ? md.replace(/\bStatus:\s*\S+/, 'Status: concluded')
74
+ : md.replace(/^(.*\n)/, '$1Status: concluded\n'));
75
+
76
+ function withOutcome(md, block) {
77
+ const lines = md.replace(/\r\n?/g, '\n').replace(/\n*$/, '\n').split('\n');
78
+ const at = lines.findIndex((l) => /^##\s+Outcome\s*$/.test(l));
79
+ if (at < 0) return `${lines.join('\n').replace(/\n*$/, '\n')}\n${block.join('\n')}`;
80
+ let end = lines.findIndex((l, i) => i > at && /^#{1,2}\s/.test(l));
81
+ if (end < 0) end = lines.length;
82
+ return [...lines.slice(0, at), ...block, ...lines.slice(end)].join('\n').replace(/\n*$/, '\n');
83
+ }
84
+
85
+ export function conclude({ top, args, opts }) {
86
+ const [name] = args;
87
+ if (!isName(name)) throw new Fail(`${JSON.stringify(name ?? '')} is not a request name`, 'al conclude <name> [--yes]');
88
+ const state = loadState(top);
89
+ const r = state.requests.get(name);
90
+ if (!r) throw new Fail(`no request named ${name}`, 'al context lists the open requests');
91
+ if (!r.open) throw new Fail(`${name} is already archived (requests/archive/${name}); an archived request is not concluded again`, `al context ${name}`);
92
+ const { out, judged } = reasons(state, r);
93
+ if (out.length) {
94
+ return {
95
+ body: out.map((x) => `refused: ${x}`),
96
+ read: 'working tree',
97
+ next: `al context ${name}; sign, record the dispositions, or align what is named, then al index`,
98
+ notKnown: NOT_KNOWN,
99
+ exit: 1,
100
+ };
101
+ }
102
+ const block = outcome(state, r, judged, top);
103
+ const target = `requests/archive/${name}`;
104
+ if (exists(top, target)) throw new Fail(`${target} already exists; a request name is never reused`, `al context ${name}`);
105
+ const what = `Status: concluded, the Outcome in ${target}/request.md, status: concluded in the record, and ${r.dir}/ moved to ${target}/`;
106
+ if (!opts.yes) {
107
+ return { body: [`Would conclude ${name}: ${what}`, ...block.slice(2).map((l) => ` ${l}`)], read: 'working tree', next: 'run the same command with --yes to do it', notKnown: NOT_KNOWN };
108
+ }
109
+ const rec = openRecord(top, name);
110
+ if (!rec) throw new Fail(`${name} has no record .assuredloop/records/requests/${name}.yaml`, 'a v4 request is made with al new');
111
+ guard(top, [`${r.dir}/request.md`, `${target}/request.md`, rec.path]);
112
+ const md = withOutcome(withStatus(r.md), block);
113
+ rec.doc.set('status', 'concluded');
114
+ mkdirSync(dirname(join(top, target)), { recursive: true });
115
+ renameSync(join(top, r.dir), join(top, target));
116
+ write(top, `${target}/request.md`, md);
117
+ write(top, rec.path, recordText(rec));
118
+ return { body: [`Concluded ${name}: ${what}`], read: 'working tree', next: 'commit the archive move with the closing PR', notKnown: NOT_KNOWN };
119
+ }
@@ -0,0 +1,75 @@
1
+ // v4 settings (design.md 2, schema.md 7): `.assuredloop/config.yaml` (the spec
2
+ // root, the docs and their ID prefixes) and `.assuredloop/schema.yaml` (the
3
+ // kinds, with a version). Both are optional: an absent file gives the defaults.
4
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { isSeq, parse, parseDocument } from 'yaml';
7
+ import { KINDS, LINK_WORDS } from './markers.js';
8
+ import { symlinkOn } from './scope.js';
9
+
10
+ export const SCHEMA_VERSION = 'assuredloop/1';
11
+ const DIR = '.assuredloop';
12
+
13
+ function read(top, name) {
14
+ const link = symlinkOn(top, `${DIR}/${name}`);
15
+ if (link) throw new Error(`${DIR}/${name} is reached through the symlink ${link}; nothing was read`);
16
+ const path = join(top, DIR, name);
17
+ if (!existsSync(path)) return null;
18
+ try {
19
+ return parse(readFileSync(path, 'utf8')) ?? {};
20
+ } catch (e) {
21
+ throw new Error(`${DIR}/${name}: not valid YAML: ${e.message.split('\n')[0]}`);
22
+ }
23
+ }
24
+
25
+ export function loadConfig(top) {
26
+ const c = read(top, 'config.yaml') ?? {};
27
+ if (typeof c !== 'object' || Array.isArray(c)) throw new Error(`${DIR}/config.yaml: give a mapping of settings`);
28
+ return { ...c, root: c.root ?? 'specs', docs: Array.isArray(c.docs) ? c.docs : [] };
29
+ }
30
+
31
+ export function loadSchema(top) {
32
+ const s = read(top, 'schema.yaml') ?? {};
33
+ if (typeof s !== 'object' || Array.isArray(s)) throw new Error(`${DIR}/schema.yaml: give a mapping`);
34
+ return { ...s, schema: s.schema ?? SCHEMA_VERSION, kinds: s.kinds ?? KINDS };
35
+ }
36
+
37
+ const SCHEMA_TEXT = `# The AssuredLoop model (design.md 3, schema.md 4 and 6), with a version.
38
+ schema: ${SCHEMA_VERSION}
39
+ kinds:
40
+ ${Object.entries(KINDS).map(([g, ks]) => ` ${g}: [${ks.join(', ')}]`).join('\n')}
41
+ # The link words a marker may hold.
42
+ links: [${Object.keys(LINK_WORDS).join(', ')}]
43
+ `;
44
+
45
+ // Writes schema.yaml and config.yaml when absent, and lists `file` with its
46
+ // prefix in config's docs when `prefix` is given and the file is not listed.
47
+ // Comments in an existing config.yaml stay. Returns the paths written.
48
+ export function writeSetup(top, file, prefix) {
49
+ const written = [];
50
+ mkdirSync(join(top, DIR), { recursive: true });
51
+ if (!existsSync(join(top, DIR, 'schema.yaml'))) {
52
+ writeFileSync(join(top, DIR, 'schema.yaml'), SCHEMA_TEXT);
53
+ written.push(`${DIR}/schema.yaml`);
54
+ }
55
+ const path = join(top, DIR, 'config.yaml');
56
+ const had = existsSync(path);
57
+ const doc = parseDocument(had ? readFileSync(path, 'utf8') : `# AssuredLoop settings (design.md 2, schema.md 7).\nschema: ${SCHEMA_VERSION}\nroot: specs\ndocs: []\n`);
58
+ const docs = doc.get('docs');
59
+ const listed = docs?.items?.some((d) => d.get?.('file') === file);
60
+ if (prefix && !listed) {
61
+ const entry = doc.createNode({ file, prefix });
62
+ entry.flow = true;
63
+ if (isSeq(docs)) {
64
+ docs.flow = false;
65
+ docs.add(entry);
66
+ } else {
67
+ doc.set('docs', doc.createNode([entry]));
68
+ }
69
+ }
70
+ if (!had || (prefix && !listed)) {
71
+ writeFileSync(path, doc.toString());
72
+ written.push(`${DIR}/config.yaml`);
73
+ }
74
+ return written;
75
+ }
@@ -0,0 +1,191 @@
1
+ // Dispositions and the close rule (design.md 5; the architect's answers for
2
+ // #179, decision D15): for each change paragraph with a baseline effect, the
3
+ // disposition of its current version, and whether that disposition is valid
4
+ // in the given state. Read by al check, al context and al conclude.
5
+ import { git } from './git.js';
6
+ import { parseMarkdown } from './markers.js';
7
+ import { SPEC, docsInScope } from './scope.js';
8
+ import { closeness, NEAR } from './words.js';
9
+ import { hasBaselineEffect, isPromise, qualify, servesSigned, settingsAt } from './state.js';
10
+
11
+ const list = (x) => (Array.isArray(x) ? x : []);
12
+
13
+ // The disposition of `source` (<request>/SP-n) for the version with hash
14
+ // `sha`: the last entry for that version; an entry whose hash is not filled
15
+ // yet (not indexed) stands for the current version.
16
+ function dispositionOf(data, source, sha) {
17
+ const mine = list(data?.dispositions).filter((d) => d?.source === source);
18
+ return mine.filter((d) => d.source_sha256 === sha).at(-1) ?? mine.filter((d) => !d.source_sha256).at(-1) ?? null;
19
+ }
20
+
21
+ // The binding of `holder` `link` `target` in a record's data, or null.
22
+ const bindingOf = (data, holder, link, target) => list(data?.bindings)
23
+ .find((b) => b?.holder === holder && b.link === link && b.target === target) ?? null;
24
+
25
+ // Whether disposition `d` of source paragraph `p` (request `r`) is valid.
26
+ // Returns { valid, reason, notes }. `seen` guards a supersession chain.
27
+ export function judge(state, r, p, d, opts = {}, seen = new Set()) {
28
+ const source = `${r.name}/${p.id}`;
29
+ const notes = [];
30
+ const no = (reason) => ({ valid: false, reason, notes });
31
+ const yes = (reason = null) => ({ valid: true, reason, notes });
32
+ const decision = (id) => list(r.data?.decisions).find((x) => x?.id === id) ?? null;
33
+ const spec = (id) => state.spec.get(id) ?? null;
34
+
35
+ switch (d.disposition) {
36
+ case 'incorporated': {
37
+ const s = spec(d.spec);
38
+ if (!d.spec) return no('it names no spec paragraph');
39
+ if (!s) return no(`${d.spec} is not in specs/`);
40
+ if (d.spec_sha256 && s.sha256 !== d.spec_sha256) return no(`${d.spec} changed after it was incorporated`);
41
+ if (s.sha256 === p.sha256) return yes();
42
+ if (d.decision) {
43
+ const w = decision(d.decision)?.wording;
44
+ if (!w) return no(`${d.decision} is not a wording decision in the record`);
45
+ if (w.source_sha256 !== p.sha256 || w.target_sha256 !== s.sha256) return no(`${d.decision} names other versions of ${p.id} and ${d.spec}`);
46
+ return yes(`worded by ${d.decision}`);
47
+ }
48
+ if (opts.typo?.has(d.spec) && opts.baseSpec?.get(d.spec)?.sha256 === d.spec_sha256) {
49
+ notes.push(`a typo claim on this branch changes ${d.spec}; a claim, not proven`);
50
+ return yes('a typo claim');
51
+ }
52
+ return no(`${d.spec} does not equal ${p.id} (no wording decision, no typo claim)`);
53
+ }
54
+ case 'removed': {
55
+ const id = d.spec ?? p.links.removes[0];
56
+ if (!id) return no('it names no spec paragraph');
57
+ if (!p.links.removes.includes(id)) return no(`${p.id} does not declare removes:${id}`);
58
+ if (spec(id)) return no(`${id} is still in specs/`);
59
+ const wasKind = opts.baseSpec?.get(id)?.kind ?? pastKind(state, id);
60
+ if ((isPromise(state, p.kind) || isPromise(state, wasKind)) && !servesSigned(state, p, r.name)) {
61
+ return no(`${id} is a promise, and no signed requirement covers its removal`);
62
+ }
63
+ return yes();
64
+ }
65
+ case 'superseded': {
66
+ if (!d.by) return no('it names no later change (by:)');
67
+ const by = qualify(String(d.by), r.name);
68
+ const rb = state.requests.get(by.req);
69
+ if (!rb) return no(`${d.by}: no such request`);
70
+ // The incorporated version: on this entry, or on the earlier incorporated entry it replaced.
71
+ const was = d.spec_sha256 ?? list(r.data?.dispositions).filter((x) => x?.source === source && x.disposition === 'incorporated'
72
+ && x.spec === d.spec && x.source_sha256 === p.sha256).at(-1)?.spec_sha256;
73
+ const b = bindingOf(rb.data, d.by, 'changes', d.spec) ?? bindingOf(rb.data, d.by, 'removes', d.spec);
74
+ if (!b || !was || b.target_sha256 !== was) return no(`${d.by} has no changes or removes binding to the incorporated version of ${d.spec}`);
75
+ if (seen.has(source)) return no('the supersession chain goes round in a circle');
76
+ seen.add(source);
77
+ const pb = rb.paras.get(by.id);
78
+ if (!rb.open) {
79
+ // An archived change is never checked again: its record stands.
80
+ const ok = list(rb.data?.dispositions).some((x) => x?.source === d.by && ['incorporated', 'removed', 'superseded'].includes(x.disposition));
81
+ return ok ? yes(`by ${d.by}, archived`) : no(`${d.by} has no disposition in its archived record`);
82
+ }
83
+ if (!pb) return no(`${d.by} is not in its change spec`);
84
+ const db = dispositionOf(rb.data, d.by, pb.sha256);
85
+ if (!db || !['incorporated', 'removed', 'superseded'].includes(db.disposition)) return no(`${d.by} has no incorporated, removed or superseded disposition`);
86
+ const v = judge(state, rb, pb, db, opts, seen);
87
+ return v.valid ? yes(`by ${d.by}`) : no(`${d.by}: ${v.reason}`);
88
+ }
89
+ case 'abandoned': {
90
+ // First the facts of no kept effect (D15 rules a-c): then no sign-off is
91
+ // needed, whatever decision names the drop (design.md 6).
92
+ const effect = keptEffect(state, r, p, source);
93
+ if (!effect) {
94
+ if (d.decision && !decision(d.decision)) return no(`${d.decision} is not in the record`);
95
+ notes.push(...nearNotes(state, p));
96
+ return { valid: true, reason: d.decision ? `never applied, or reverted; dropped by ${d.decision}` : null, notes, kept: false };
97
+ }
98
+ // Its effect is still in specs/: only an owner decision can keep it, and
99
+ // a kept promise needs a signed requirement (D15 rule d, design.md 5).
100
+ if (!d.decision) return no(effect);
101
+ const x = decision(d.decision);
102
+ if (!x) return no(`${d.decision} is not in the record`);
103
+ if (!/^owner\b/i.test(String(x.source ?? ''))) return no(`${d.decision}'s source is not the owner, and ${effect}`);
104
+ if (isPromise(state, p.kind) && !servesSigned(state, p, r.name)) return no(`${d.decision} keeps a promise, and no signed requirement covers it`);
105
+ return { valid: true, reason: `its effect is kept by ${d.decision}`, notes, kept: true };
106
+ }
107
+ default:
108
+ return no(`"${d.disposition}" is not a disposition`);
109
+ }
110
+ }
111
+
112
+ // The kind that spec paragraph `id` had in the newest commit that held it,
113
+ // from git history, or null. First the selected commit itself; then, newest
114
+ // first, each commit whose diff adds or removes the marker (in any spacing
115
+ // the parser allows, in any path) or changes .assuredloop/config.yaml (which
116
+ // can bring a marked file into scope or take it out). Each commit, then its
117
+ // parent, is read with that commit's own config and schema.
118
+ export function pastKind(state, id) {
119
+ const top = state.top;
120
+ const rev = state.rev ?? 'HEAD';
121
+ const marker = `<!--[[:space:]][[:space:]]*${id}[[:space:]]`;
122
+ const list = (args) => (git(top, ['log', '--format=%H', ...args], { allowFail: true }) ?? '').split('\n').filter(Boolean);
123
+ const candidates = new Set([...list(['-G', marker, rev]), ...list([rev, '--', '.assuredloop/config.yaml'])]);
124
+ const ordered = (git(top, ['rev-list', rev], { allowFail: true }) ?? '').split('\n').filter((sha) => candidates.has(sha));
125
+ const kindAt = (at) => {
126
+ const { config, kinds } = settingsAt(top, at);
127
+ for (const d of docsInScope(top, config, at).filter((x) => x.scope === SPEC)) {
128
+ const p = parseMarkdown(d.text, d.path, { kinds }).paragraphs.find((x) => x.id === id);
129
+ if (p) return p.kind;
130
+ }
131
+ return undefined;
132
+ };
133
+ for (const at of [rev, ...ordered.flatMap((sha) => [sha, `${sha}^`])]) {
134
+ const kind = kindAt(at);
135
+ if (kind !== undefined) return kind;
136
+ }
137
+ return null;
138
+ }
139
+
140
+ // Why paragraph `p` (<request>/SP-n `source`) still has an effect in specs/,
141
+ // or null when it has none: its changes or removes targets keep the text they
142
+ // were bound to, and a paragraph with neither link has no copy in specs/.
143
+ function keptEffect(state, r, p, source) {
144
+ const spec = (id) => state.spec.get(id) ?? null;
145
+ for (const id of p.links.changes) {
146
+ const b = bindingOf(r.data, source, 'changes', id);
147
+ if (!b) return `no changes binding to ${id}, so whether it was applied is unknown`;
148
+ if (spec(id)?.sha256 !== b.target_sha256) return `${id} is not the version that ${p.id} bound: applied, or changed since`;
149
+ }
150
+ for (const id of p.links.removes) {
151
+ const b = bindingOf(r.data, source, 'removes', id);
152
+ if (!b) return `no removes binding to ${id}, so whether it was applied is unknown`;
153
+ if (spec(id)?.sha256 !== b.target_sha256) return `${id} is gone or changed: the removal was applied`;
154
+ }
155
+ if (!p.links.changes.length && !p.links.removes.length) {
156
+ const same = [...state.spec.values()].find((s) => s.sha256 === p.sha256);
157
+ if (same) return `its text is in specs/ as ${same.id}`;
158
+ }
159
+ return null;
160
+ }
161
+
162
+ // A near match in specs/ for a paragraph with no changes or removes link.
163
+ const nearNotes = (state, p) => {
164
+ if (p.links.changes.length || p.links.removes.length) return [];
165
+ const near = [...state.spec.values()].find((s) => s.kind !== 'note' && closeness(s.text, p.text) >= NEAR);
166
+ return near ? [`near-match ${near.id}: applied in another form?`] : [];
167
+ };
168
+
169
+ // Every change paragraph of request `r` with a baseline effect, with the
170
+ // disposition of its current version and its validity. A spike has none.
171
+ export function judgeRequest(state, r, opts = {}) {
172
+ if (r.spike) return [];
173
+ const out = [];
174
+ for (const p of r.paras.values()) {
175
+ if (!hasBaselineEffect(state, p.kind) && !p.links.removes.length) continue;
176
+ const id = `${r.name}/${p.id}`;
177
+ const d = dispositionOf(r.data, id, p.sha256);
178
+ if (!d) {
179
+ out.push({ id, p, d: null, status: 'pending', valid: false, reason: 'no disposition for its current version', notes: [] });
180
+ continue;
181
+ }
182
+ // An archived request is never checked again against today's spec (design.md 5).
183
+ if (!r.open) {
184
+ out.push({ id, p, d, status: String(d.disposition), valid: true, reason: 'as recorded at the close; not checked again', notes: [] });
185
+ continue;
186
+ }
187
+ const v = judge(state, r, p, d, opts);
188
+ out.push({ id, p, d, status: String(d.disposition), ...v });
189
+ }
190
+ return out;
191
+ }