@sabaiway/agent-workflow-kit 10.2.0 → 10.4.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.
- package/CHANGELOG.md +72 -0
- package/README.md +7 -7
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/agents/executor.md +40 -0
- package/references/modes/agents.md +9 -4
- package/references/modes/procedures.md +17 -8
- package/references/modes/recipes.md +7 -4
- package/references/modes/recommendations.md +4 -1
- package/references/modes/set-recipe.md +22 -5
- package/references/modes/status.md +3 -3
- package/references/modes/upgrade.md +7 -5
- package/references/shared/composition-handoff.md +1 -1
- package/references/shared/deploy-tail.md +2 -2
- package/references/templates/agent_rules.md +3 -2
- package/references/templates/orchestration.json +1 -1
- package/tools/ack-store.mjs +57 -0
- package/tools/ack-write.mjs +1 -1
- package/tools/autonomy-config.mjs +1 -1
- package/tools/carriers.mjs +140 -0
- package/tools/cheap-agents-read.mjs +172 -0
- package/tools/cheap-agents.mjs +57 -105
- package/tools/commands.mjs +3 -3
- package/tools/direct-run.mjs +6 -0
- package/tools/doc-parity.mjs +8 -0
- package/tools/ensure-ops.mjs +18 -9
- package/tools/ensure-specs.mjs +3 -4
- package/tools/ensure-vocabulary.mjs +5 -2
- package/tools/family-registry.mjs +70 -21
- package/tools/flow-check.mjs +2 -7
- package/tools/inject-methodology.mjs +4 -0
- package/tools/lens-region.mjs +4 -1
- package/tools/node-evidence.mjs +77 -0
- package/tools/orchestration-config.mjs +34 -13
- package/tools/procedures.mjs +65 -52
- package/tools/recipes.mjs +156 -184
- package/tools/recommendations.mjs +145 -78
- package/tools/renderers.mjs +36 -7
- package/tools/review-state.mjs +10 -11
- package/tools/set-recipe.mjs +63 -24
- package/tools/spec-adoption.mjs +71 -0
- package/tools/spec-check.mjs +2 -2
- package/tools/upgrade-runlist.mjs +1 -1
- package/tools/view-model.mjs +19 -3
package/tools/set-recipe.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// set-recipe.mjs — the WRITER for docs/ai/orchestration.json. The division of labor (AD-025): the AGENT
|
|
3
|
-
// turns plain language into explicit `--set <activity>.<slot>=<
|
|
3
|
+
// turns plain language into explicit `--set <activity>.<slot>=<value>` / `--unset <activity>.<slot>`
|
|
4
4
|
// ops; the KIT does the deterministic validate → merge → preview → write. The kit ships NO NL parser
|
|
5
5
|
// (stays dependency-free + deterministic) and performs no `all`-magic — the agent expands "both review"
|
|
6
6
|
// into explicit per-activity ops (asking if scope is unclear).
|
|
@@ -17,15 +17,25 @@
|
|
|
17
17
|
// language when narrating. Exit codes: 0 success (an explicit recipe that gracefully degrades is still
|
|
18
18
|
// 0); 2 usage (bad/duplicate op, --write with zero ops); 1 config error (malformed/unreadable config)
|
|
19
19
|
// or a write STOP (no deployment / symlinked leaf). main(argv, ctx) → { code, stdout, stderr }; cwd /
|
|
20
|
-
// env / home / detect / fs are injectable for host-independent tests.
|
|
20
|
+
// env / home / detect / surveyVehicle / fs are injectable for host-independent tests.
|
|
21
|
+
//
|
|
22
|
+
// It writes every slot of every activity in the registry (carriers.mjs, via recipes.mjs) — the
|
|
23
|
+
// carrier slots and the `routine.parallel` switch included — and resolves each preview against the
|
|
24
|
+
// SAME readiness the recipes advisor composes: detected backends plus the executor-vehicle survey.
|
|
21
25
|
//
|
|
22
26
|
// Dependency-free, Node >= 22. No side effects on import (the isDirectRun idiom).
|
|
23
27
|
|
|
24
28
|
import { readFileSync, lstatSync } from 'node:fs';
|
|
25
29
|
import { homedir } from 'node:os';
|
|
26
|
-
import { detectBackends } from './detect-backends.mjs';
|
|
27
30
|
import { isDirectRun } from './direct-run.mjs';
|
|
28
|
-
import {
|
|
31
|
+
import {
|
|
32
|
+
ACTIVITIES,
|
|
33
|
+
SLOT_RECIPES,
|
|
34
|
+
EXECUTOR_APPLY,
|
|
35
|
+
composeReadiness,
|
|
36
|
+
resolveActivityRecipe,
|
|
37
|
+
composeActiveRecipeLine,
|
|
38
|
+
} from './recipes.mjs';
|
|
29
39
|
import { loadAutonomy, resolveAutonomy } from './autonomy-config.mjs';
|
|
30
40
|
import {
|
|
31
41
|
CONFIG_REL,
|
|
@@ -35,6 +45,7 @@ import {
|
|
|
35
45
|
parseOp,
|
|
36
46
|
applySetOps,
|
|
37
47
|
serializeConfig,
|
|
48
|
+
refreshReadme,
|
|
38
49
|
CANON_README,
|
|
39
50
|
} from './orchestration-config.mjs';
|
|
40
51
|
import { writeConfig as writeConfigFs } from './orchestration-write.mjs';
|
|
@@ -50,7 +61,7 @@ const parseArgs = (argv) => {
|
|
|
50
61
|
let write = false;
|
|
51
62
|
let json = false;
|
|
52
63
|
const takeOp = (kind, tok) => {
|
|
53
|
-
if (tok === undefined || tok.startsWith('--')) throw fail(2, `--${kind} requires <activity>.<slot>${kind === 'set' ? '=<
|
|
64
|
+
if (tok === undefined || tok.startsWith('--')) throw fail(2, `--${kind} requires <activity>.<slot>${kind === 'set' ? '=<value>' : ''}`);
|
|
54
65
|
const op = parseOp(kind, tok);
|
|
55
66
|
const key = `${op.activity}.${op.slot}`;
|
|
56
67
|
if (seen.has(key)) throw fail(2, `duplicate op for "${key}" — name each activity.slot at most once`);
|
|
@@ -74,12 +85,24 @@ const parseArgs = (argv) => {
|
|
|
74
85
|
|
|
75
86
|
// ── effective-recipe resolution per op (degradation honesty) ────────────────────────
|
|
76
87
|
|
|
88
|
+
// The readiness EVERY resolution here runs against: the detected bridges plus the executor-vehicle
|
|
89
|
+
// survey, composed by the one helper the recipes CLI uses. Detection is a SECONDARY input — a bridge
|
|
90
|
+
// detector throw must NOT block the write (the config write is readiness-independent) and must not
|
|
91
|
+
// cost the CARRIER either: the hook warns, the bridge half floors at not-ready, the vehicle survives.
|
|
92
|
+
const composeReadinessOrWarn = (cwd, deps, warnings) =>
|
|
93
|
+
composeReadiness(cwd, {
|
|
94
|
+
...deps,
|
|
95
|
+
onDetectError: (err) => warnings.push(
|
|
96
|
+
`backend detection failed (${(err && err.message) || err}) — treating every bridge as not ready; recipes needing a bridge degrade to solo (the executor vehicle is unaffected).`,
|
|
97
|
+
),
|
|
98
|
+
});
|
|
99
|
+
|
|
77
100
|
// A single op's before/after value + the effective recipe it resolves to here (vs live readiness).
|
|
78
101
|
// `to` is null for an unset (falls to the computed default). degradedFrom/reason carry the honesty.
|
|
79
|
-
const resolveOp = (op, current, after,
|
|
102
|
+
const resolveOp = (op, current, after, readiness) => {
|
|
80
103
|
const from = current?.[op.activity]?.[op.slot] ?? null;
|
|
81
104
|
const to = after?.[op.activity]?.[op.slot] ?? null;
|
|
82
|
-
const r = resolveActivityRecipe({ config: after ?? {}, readiness
|
|
105
|
+
const r = resolveActivityRecipe({ config: after ?? {}, readiness, activity: op.activity, slot: op.slot });
|
|
83
106
|
return { activity: op.activity, slot: op.slot, from, to, effective: r.recipe, degradedFrom: r.degradedFrom, reason: r.reason };
|
|
84
107
|
};
|
|
85
108
|
|
|
@@ -127,19 +150,40 @@ const buildJson = ({ changed, unchanged, warnings, writtenPath, noop, activeLine
|
|
|
127
150
|
activeLine: activeLine ?? null,
|
|
128
151
|
});
|
|
129
152
|
|
|
153
|
+
// The writable surface, rendered FROM the registry — never re-typed as literals, so an activity, a
|
|
154
|
+
// slot or an accepted value added to the table shows up in the help (and in the doc that quotes it).
|
|
155
|
+
const ACTIVITY_LINES = Object.entries(ACTIVITIES)
|
|
156
|
+
.map(([activity, def]) => ` ${activity} → ${Object.keys(def.slots).join(', ')}`)
|
|
157
|
+
.join('\n');
|
|
158
|
+
|
|
159
|
+
const VALUE_LINES = Object.entries(SLOT_RECIPES)
|
|
160
|
+
.map(([slotType, values]) => ` ${slotType} slots accept ${values.join(' | ')}`)
|
|
161
|
+
.join('\n');
|
|
162
|
+
|
|
163
|
+
const QUALIFIED_SLOTS = Object.entries(ACTIVITIES)
|
|
164
|
+
.flatMap(([activity, def]) => Object.keys(def.slots).map((slot) => `${activity}.${slot}`))
|
|
165
|
+
.join(', ');
|
|
166
|
+
|
|
130
167
|
const HELP = `set-recipe — write the per-project orchestration config (docs/ai/orchestration.json).
|
|
131
168
|
|
|
132
169
|
Usage:
|
|
133
|
-
node set-recipe.mjs [--set <activity>.<slot>=<
|
|
170
|
+
node set-recipe.mjs [--set <activity>.<slot>=<value>]... [--unset <activity>.<slot>]... [--write] [--json]
|
|
134
171
|
|
|
135
|
-
--set <activity>.<slot>=<
|
|
172
|
+
--set <activity>.<slot>=<value> pin a value (fully-qualified; e.g. plan-authoring.review=council)
|
|
136
173
|
--unset <activity>.<slot> return a slot to its computed default
|
|
137
174
|
--write apply the change (default: preview only — writes nothing)
|
|
138
175
|
--json machine-readable output
|
|
139
176
|
--help, -h this help
|
|
140
177
|
|
|
141
|
-
Activities
|
|
142
|
-
|
|
178
|
+
Activities and their slots:
|
|
179
|
+
${ACTIVITY_LINES}
|
|
180
|
+
|
|
181
|
+
Accepted values per slot type:
|
|
182
|
+
${VALUE_LINES}
|
|
183
|
+
|
|
184
|
+
A carrier slot set to subagent needs the executor vehicle placed in this project — ${EXECUTOR_APPLY};
|
|
185
|
+
without it the slot resolves to solo with the reason stated. routine.parallel is a flag, not a
|
|
186
|
+
recipe: it never degrades.
|
|
143
187
|
|
|
144
188
|
Previews by default; --write applies via an atomic, symlink/TOCTOU-safe write behind a deployment gate.
|
|
145
189
|
Config writer only: it NEVER runs a backend and NEVER commits. Hand-editing the file stays fully supported.
|
|
@@ -152,7 +196,7 @@ Exit codes: 0 success (an explicit recipe that gracefully degrades is still 0);
|
|
|
152
196
|
|
|
153
197
|
export const main = (argv, ctx = {}) => {
|
|
154
198
|
const cwd = ctx.cwd ?? process.cwd();
|
|
155
|
-
const
|
|
199
|
+
const readinessDeps = { detect: ctx.detect, surveyVehicle: ctx.surveyVehicle };
|
|
156
200
|
const readFile = ctx.readFileSync ?? readFileSync;
|
|
157
201
|
const lstat = ctx.lstatSync ?? lstatSync;
|
|
158
202
|
const writeConfig = ctx.writeConfig ?? writeConfigFs;
|
|
@@ -170,23 +214,18 @@ export const main = (argv, ctx = {}) => {
|
|
|
170
214
|
return { code: 0, stdout: JSON.stringify(buildJson({ changed: [], unchanged: [], warnings: [], writtenPath: null, noop: true }), null, 2), stderr: '' };
|
|
171
215
|
}
|
|
172
216
|
const shown = current == null ? `(no ${CONFIG_REL} yet — computed defaults apply)` : serializeConfig(current).replace(/\n$/, '');
|
|
173
|
-
const hint = `\nPass --set <activity>.<slot>=<
|
|
217
|
+
const hint = `\nPass --set <activity>.<slot>=<value> (preview) then --write to apply. Activities/slots: ${QUALIFIED_SLOTS}.`;
|
|
174
218
|
return { code: 0, stdout: `${source === 'none' ? '' : `${CONFIG_REL}:\n`}${shown}${hint}`, stderr: '' };
|
|
175
219
|
}
|
|
176
220
|
|
|
177
|
-
|
|
221
|
+
// The merged config, then the _README refresh: a note that normalize-matches a KNOWN PRIOR canonical
|
|
222
|
+
// is replaced by the current one on a touched write, while a customized note stays untouched.
|
|
223
|
+
const after = refreshReadme(applySetOps(current, ops, { seedReadme: CANON_README })).config;
|
|
178
224
|
|
|
179
|
-
// Detection is a SECONDARY input — it only refines the EFFECTIVE recipe note. A throw must NOT block
|
|
180
|
-
// the write (the config write is readiness-independent): treat all backends as not-ready, warn, exit 0.
|
|
181
225
|
const warnings = [];
|
|
182
|
-
|
|
183
|
-
try {
|
|
184
|
-
detection = detect();
|
|
185
|
-
} catch (err) {
|
|
186
|
-
warnings.push(`backend detection failed (${(err && err.message) || err}) — treating all backends as not ready; recipes needing a backend degrade to solo.`);
|
|
187
|
-
}
|
|
226
|
+
const readiness = composeReadinessOrWarn(cwd, readinessDeps, warnings);
|
|
188
227
|
|
|
189
|
-
const resolved = ops.map((op) => resolveOp(op, current, after,
|
|
228
|
+
const resolved = ops.map((op) => resolveOp(op, current, after, readiness));
|
|
190
229
|
const changed = resolved.filter((e) => e.from !== e.to);
|
|
191
230
|
const unchanged = resolved.filter((e) => e.from === e.to);
|
|
192
231
|
const noop = changed.length === 0;
|
|
@@ -220,7 +259,7 @@ export const main = (argv, ctx = {}) => {
|
|
|
220
259
|
return { error: (err && err.message) || String(err) };
|
|
221
260
|
}
|
|
222
261
|
})();
|
|
223
|
-
const activeLine = composeActiveRecipeLine({ config: after, source: CONFIG_REL },
|
|
262
|
+
const activeLine = composeActiveRecipeLine({ config: after, source: CONFIG_REL }, readiness, autonomyFacts);
|
|
224
263
|
const stdout = json
|
|
225
264
|
? JSON.stringify(buildJson({ changed, unchanged, warnings, writtenPath, noop: false, activeLine }), null, 2)
|
|
226
265
|
: formatHuman({ changed, unchanged, warnings, wrote: true, fileBody, activeLine });
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// spec-adoption.mjs — which of the four adoption states the feature-spec store is in, and whether the layer
|
|
2
|
+
// was declined. Contract: docs/ai/specs/kit/spec-adoption.md. The census and the per-document read are
|
|
3
|
+
// spec-check's own exported seam; this module walks nothing of its own. Read-only, Node >= 22.
|
|
4
|
+
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { SPEC_SCHEMA } from '../references/scripts/spec-schema.mjs';
|
|
7
|
+
import { readRegularFileNoFollow } from './fs-read-nofollow.mjs';
|
|
8
|
+
import { probe as probePath, realpath as realpathOf, list as listDir } from './spec-check-cli.mjs';
|
|
9
|
+
import { walkStore, readClosure } from './spec-check.mjs';
|
|
10
|
+
import { ACKS_SPEC_ADOPTION_KEY, factFingerprint, readAckValue } from './ack-store.mjs';
|
|
11
|
+
|
|
12
|
+
export const ADOPTION = Object.freeze({
|
|
13
|
+
NOT_ADOPTED: 'not-adopted',
|
|
14
|
+
ADOPTING: 'adopting',
|
|
15
|
+
ADOPTED: 'adopted',
|
|
16
|
+
UNREADABLE: 'unreadable',
|
|
17
|
+
});
|
|
18
|
+
export const ADOPTION_STATES = Object.freeze(Object.values(ADOPTION));
|
|
19
|
+
|
|
20
|
+
export const STORE_DIR_REL = SPEC_SCHEMA.storePrefix.slice(0, -1);
|
|
21
|
+
export const SPEC_ADOPTION_LANE = 'spec-adoption';
|
|
22
|
+
export const DECLINE_FACT = `spec-adoption:declined:${SPEC_SCHEMA.storePrefix}`;
|
|
23
|
+
|
|
24
|
+
const CONTRACT_KIND = 'spec';
|
|
25
|
+
const LIVE = 'live';
|
|
26
|
+
const DRAFT = 'draft';
|
|
27
|
+
const RETIRED = 'retired';
|
|
28
|
+
|
|
29
|
+
const DEFAULT_IO = Object.freeze({ read: readRegularFileNoFollow, probe: probePath, realpath: realpathOf, list: listDir });
|
|
30
|
+
|
|
31
|
+
const verdict = (state, counts = { live: 0, draft: 0, retired: 0 }, reason = null) => Object.freeze({ state, ...counts, reason });
|
|
32
|
+
|
|
33
|
+
// surveySpecAdoption(root, deps) -> { state, live, draft, retired, reason }. `deps.io` overrides the four
|
|
34
|
+
// IO primitives (tests); every other answer comes from the store bytes through the one reader.
|
|
35
|
+
export const surveySpecAdoption = (root, deps = {}) => {
|
|
36
|
+
const io = { ...DEFAULT_IO, ...(deps.io ?? {}) };
|
|
37
|
+
const dirState = io.probe(join(root, STORE_DIR_REL));
|
|
38
|
+
if (dirState === 'absent') return verdict(ADOPTION.NOT_ADOPTED);
|
|
39
|
+
if (dirState !== 'dir') return verdict(ADOPTION.UNREADABLE, undefined, `${STORE_DIR_REL} is ${dirState === 'file' ? 'a file' : dirState}, not a directory`);
|
|
40
|
+
const rootReal = io.realpath(root);
|
|
41
|
+
if (rootReal === null) return verdict(ADOPTION.UNREADABLE, undefined, 'the project root does not resolve');
|
|
42
|
+
const findings = [];
|
|
43
|
+
const ctx = { io, at: (rel) => (rel === '' ? root : `${root}/${rel}`), rootReal, add: (rule, path, message) => findings.push({ rule, path, message }) };
|
|
44
|
+
const closure = walkStore(ctx).map((path) => ({ path, roles: ['present'] }));
|
|
45
|
+
const docs = findings.length === 0 ? readClosure(closure, ctx) : new Map();
|
|
46
|
+
if (findings.length > 0) return verdict(ADOPTION.UNREADABLE, undefined, `${findings[0].path}: ${findings[0].message}`);
|
|
47
|
+
const counts = { live: 0, draft: 0, retired: 0 };
|
|
48
|
+
for (const doc of docs.values()) {
|
|
49
|
+
if (doc.verdict?.kind !== CONTRACT_KIND) continue;
|
|
50
|
+
if (doc.verdict.status === LIVE) counts.live += 1;
|
|
51
|
+
else if (doc.verdict.status === DRAFT) counts.draft += 1;
|
|
52
|
+
else if (doc.verdict.status === RETIRED) counts.retired += 1;
|
|
53
|
+
}
|
|
54
|
+
return verdict(counts.live > 0 ? ADOPTION.ADOPTED : ADOPTION.ADOPTING, counts);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const declineFingerprint = () => factFingerprint(DECLINE_FACT);
|
|
58
|
+
|
|
59
|
+
// True when the store's decline is recorded; the guarded reader's refusals propagate to the caller.
|
|
60
|
+
export const readDeclineAck = (root, deps = {}) => readAckValue(root, deps, ACKS_SPEC_ADOPTION_KEY) === declineFingerprint();
|
|
61
|
+
|
|
62
|
+
const plural = (n, noun) => `${n} ${noun}`;
|
|
63
|
+
|
|
64
|
+
// The one status line body, per state (the caller prefixes its own label).
|
|
65
|
+
export const describeAdoption = ({ state, live, draft, reason }, { declined = false } = {}) => {
|
|
66
|
+
const suffix = declined && state !== ADOPTION.ADOPTED ? ' — declined' : '';
|
|
67
|
+
if (state === ADOPTION.NOT_ADOPTED) return `not adopted${suffix}`;
|
|
68
|
+
if (state === ADOPTION.ADOPTING) return `adopting (${plural(draft, 'draft')})${suffix}`;
|
|
69
|
+
if (state === ADOPTION.ADOPTED) return `adopted (${plural(live, 'live')}, ${plural(draft, 'draft')})`;
|
|
70
|
+
return `could not be read — ${reason}`;
|
|
71
|
+
};
|
package/tools/spec-check.mjs
CHANGED
|
@@ -87,7 +87,7 @@ const refusal = (message) => ({ verdict: 'REFUSE', exit: 2, findings: [], docume
|
|
|
87
87
|
// Every document of the closure, read ONCE: probe, then (only for a regular file) the descriptor-
|
|
88
88
|
// bound read and the reader verdict. Containment of the containing directory is decided BEFORE the
|
|
89
89
|
// read, so a directory that resolves outside the root is never opened through.
|
|
90
|
-
const readClosure = (closure, ctx) => {
|
|
90
|
+
export const readClosure = (closure, ctx) => {
|
|
91
91
|
const { io, at, rootReal, add } = ctx;
|
|
92
92
|
const docs = new Map();
|
|
93
93
|
for (const { path, roles } of closure) {
|
|
@@ -247,7 +247,7 @@ const judgeListing = (doc, docs, add) => {
|
|
|
247
247
|
// FINDING, never an empty directory quietly walked past: an incomplete census that reported a clean
|
|
248
248
|
// store would be the one answer this lane must never give. A directory is contained BEFORE it is
|
|
249
249
|
// listed, and a non-regular `.md` sitting in the store is stated rather than skipped.
|
|
250
|
-
const walkStore = (ctx) => {
|
|
250
|
+
export const walkStore = (ctx) => {
|
|
251
251
|
const { io, at, rootReal, add } = ctx;
|
|
252
252
|
const found = [];
|
|
253
253
|
const stack = [STORE_DIR];
|
package/tools/view-model.mjs
CHANGED
|
@@ -54,7 +54,11 @@ const recipesVm = (r) => {
|
|
|
54
54
|
if (r.error) return { error: r.error };
|
|
55
55
|
const pairs = [];
|
|
56
56
|
for (const [activity, slots] of Object.entries(r.activities ?? {})) {
|
|
57
|
-
|
|
57
|
+
// source + degradedFrom ride along: an effective recipe alone cannot tell a chosen value from a
|
|
58
|
+
// computed default, nor a degrade from a configuration that really names the resolved recipe.
|
|
59
|
+
for (const [slot, v] of Object.entries(slots)) {
|
|
60
|
+
pairs.push({ key: `${activity}.${slot}`, recipe: v.recipe, source: v.source ?? null, degradedFrom: v.degradedFrom ?? null });
|
|
61
|
+
}
|
|
58
62
|
}
|
|
59
63
|
return { pairs, detectError: r.detectError ?? null };
|
|
60
64
|
};
|
|
@@ -76,8 +80,18 @@ const velocityVm = (v) => {
|
|
|
76
80
|
|
|
77
81
|
const agentsVm = (a) => {
|
|
78
82
|
if (!a) return null;
|
|
79
|
-
if (a.error) return { error: a.error };
|
|
80
|
-
|
|
83
|
+
if (a.error) return { error: a.error, executor: a.executor ?? null, executorReason: a.executorReason ?? null };
|
|
84
|
+
const bundled = a.bundled ?? 0;
|
|
85
|
+
return {
|
|
86
|
+
bundled,
|
|
87
|
+
placed: a.placed ?? 0,
|
|
88
|
+
// The envelope counts the read-only vehicles; an envelope predating the executor field bundled
|
|
89
|
+
// only read-only ones.
|
|
90
|
+
readOnly: a.readOnly ?? (a.executor == null ? bundled : Math.max(bundled - 1, 0)),
|
|
91
|
+
// null = an envelope predating the field (unknown), never a state.
|
|
92
|
+
executor: a.executor ?? null,
|
|
93
|
+
executorReason: a.executorReason ?? null,
|
|
94
|
+
};
|
|
81
95
|
};
|
|
82
96
|
|
|
83
97
|
const hookVm = (h) => {
|
|
@@ -114,6 +128,8 @@ const projectVm = (p) =>
|
|
|
114
128
|
deployed: p.deployed,
|
|
115
129
|
docsAi: p.docsAi,
|
|
116
130
|
adrLayout: p.adrLayout ?? null,
|
|
131
|
+
// null = an envelope predating the field (unknown), never a state.
|
|
132
|
+
specs: p.specs ?? null,
|
|
117
133
|
deployStamps: (p.deployStamps ?? []).map((st) => ({ display: st.display, version: st.version ?? null })),
|
|
118
134
|
visibility: visibilityVm(p.visibility),
|
|
119
135
|
settings: settingsVm(p.settings),
|