@sabaiway/agent-workflow-kit 1.41.0 → 1.43.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.
@@ -77,6 +77,10 @@ const renderProject = (vm, { color }) => {
77
77
  }
78
78
  for (const s of p.deployStamps) lines.push(` ${pad(s.display, STAMP_COL)}${s.version ?? '—'}`);
79
79
  lines.push(` ${pad('docs/ai present', STAMP_COL)}${p.docsAi ? 'yes' : 'no'}`);
80
+ // Only the actionable 'old' layout renders a line — a migrated/none store needs no note (AD-051).
81
+ if (p.adrLayout === 'old') {
82
+ lines.push(` ${pad('ADR store', STAMP_COL)}old layout — run /agent-workflow-kit migrate-adr-store`);
83
+ }
80
84
  if (p.visibility) {
81
85
  const v = p.visibility.error ? `error: ${p.visibility.error}` : p.visibility.phrase;
82
86
  lines.push(` ${pad('visibility', STAMP_COL)}${v}`);
@@ -6,14 +6,28 @@
6
6
  // EMPTY (AD-021/AD-038): a populated declaration is per-entry maintainer consent recorded through
7
7
  // this preview, never auto-seeding.
8
8
  //
9
- // What it offers (the derivation invariants, test-pinned):
9
+ // What it offers (the derivation invariants, test-pinned) — CLOSED-WORLD since AD-052: an entry
10
+ // is offered only when every axis is proven safe by MEMBERSHIP in a finite, test-guarded set,
11
+ // never by absence from a blocklist (Issue-011 killed the blocklist model — one more gap per
12
+ // review round, unprovable):
10
13
  // • sources: discoverGateCandidates over package.json scripts (velocity-profile.mjs stays
11
14
  // read-only; THIS module owns the offer mapping) + the conditional review-state candidate;
12
- // • warn-flagged candidates (release/publish/deploy/push/version/commit/tag, pre*/post*) NEVER
13
- // enter the offer excluded, not offered-with-a-warning;
14
- // only TERMINATING verification classes are offered (test / lint / type-check / build)
15
- // never dev/start/watch/serve/preview, never a formatter write-mode;
16
- // • commands are package-manager-aware (packageManager field, else lockfile probe, else npm);
15
+ // • NAME screens shape WHICH entries are offered: warn-flagged candidates (release/publish/
16
+ // deploy/push/version/commit/tag, pre*/post*) never enter; only terminating verification
17
+ // class NAMES (test / lint / type-check / build) pass, never dev/start/watch/serve/preview,
18
+ // never a mutating-variant name; a shell-unsafe name is screened first;
19
+ // • the BODY must be a string member of the literal BODY_ALLOWLIST after a pinned ASCII-only
20
+ // normalization — anything else is screened out with a LOUD note (never silently absent);
21
+ // • the cmd is the per-PM HOOK-FREE form `COREPACK_ENABLE_NETWORK=0 <pm> exec -- <allowlisted-body>`
22
+ // (packageManager field, else lockfile probe, else npm): `exec` runs a command, not a named
23
+ // script, so the pre/post lifecycle-hook class dies structurally on npm/pnpm/yarn alike, and
24
+ // the Corepack env prefix blocks a hostile packageManager pin from fetching the PM binary; a
25
+ // family with no verified fail-closed exec contract is WITHHELD with a loud note. NEVER
26
+ // `<pm> run <name>` — that re-exposes hooks and lets a later package.json edit change a
27
+ // byte-exact-approved gate;
28
+ // • the safety claim is scoped to the OFFER DERIVATION, not a runtime sandbox: a gate still
29
+ // executes project-controlled tooling (a node_modules/.bin PATH shim intercepts under every
30
+ // form) — the documented residual, disclosed in the preview, bounded by the two consents;
17
31
  // • ids derive kebab-case from script names (build:prod → build-prod) and every offered entry
18
32
  // passes the runner's validateDeclaration (the seeder imports the validator — NEVER the
19
33
  // reverse: run-gates.mjs stays a runner that writes nothing);
@@ -62,33 +76,35 @@ const usageFail = (message) =>
62
76
  export const TRUST_CHAIN_DISCLOSURE =
63
77
  'Disclosure: once the optional approval hook is wired (/agent-workflow-kit hook), it ' +
64
78
  'auto-approves byte-exact declared gate commands from the project root — seeding (this consent) ' +
65
- 'and hook wiring (its own consent) are two separate yeses.';
79
+ 'and hook wiring (its own consent) are two separate yeses. A script gate runs the project\'s ' +
80
+ 'own tooling, which executes project-controlled code (the seeder does not sandbox it; the two ' +
81
+ 'consents bound it).';
66
82
 
67
83
  const USAGE = `usage: seed-gates [--dry-run | --apply] [--only <id>]... [--cwd <dir>] [--help]
68
84
 
69
85
  Consent-gated seeder for the project's own docs/ai/gates.json. Default is --dry-run: prints the
70
86
  derived { id, title, cmd } entries and writes NOTHING. --apply APPENDS exactly the consented
71
87
  entries (--only <id> selects a subset; append-only — existing entries are never modified or
72
- removed, an id collision is refused). Only terminating verification commands are offered
73
- (test / lint / type-check / build); release/publish/deploy scripts and watch/serve modes never
74
- enter the offer. ${TRUST_CHAIN_DISCLOSURE}`;
88
+ removed, an id collision is refused). The offer is CLOSED-WORLD: only a terminating-class script
89
+ name (test / lint / type-check / build — never release/publish/deploy, never watch/serve, never a
90
+ write-mode variant) whose BODY is a member of the literal runner allowlist is offered, as the
91
+ hook-free \`COREPACK_ENABLE_NETWORK=0 <pm> exec -- <body>\` form for the detected package manager. A gate-class script whose
92
+ body is NOT in the allowlist is screened out with a note naming it (a command you trust can still
93
+ be declared by hand); non-gate-class names are excluded silently, as always.
94
+ ${TRUST_CHAIN_DISCLOSURE}`;
75
95
 
76
- // ── candidate classification (the LOCKED derivation invariants) ────────────────────────
77
- // The derived cmd (`<pm> run <name>`) is bash-interpolated by the gate runner and can become
96
+ // ── candidate classification: the NAME screens (the LOCKED derivation invariants) ──────
97
+ // NAME screens shape WHICH safe entries are offered; the danger axis (the BODY) is closed-world
98
+ // below (AD-052). The derived cmd is bash-interpolated by the gate runner and can become
78
99
  // hook-auto-approvable, so only shell-SAFE script names ever enter the offer: a name carrying
79
100
  // whitespace or any shell metacharacter (`test:ci && echo pwn`) is screened out entirely.
80
101
  const SAFE_SCRIPT_NAME_PATTERN = /^[A-Za-z0-9:_.-]+$/;
81
102
  const TERMINATING_CLASS_PATTERN = /^(test|lint|type-?check|types|tsc|build)([:._-]|$)/i;
82
- // Non-terminating screening is TOKEN-set based, position-independent (never an anchored regex —
83
- // the anchored form missed `build:preview` and a bare `vite preview` body): a non-terminating
84
- // token in ANY name segment, or as ANY bare/dashed body word, disqualifies; `watch` disqualifies
85
- // as a substring anywhere (watchAll, --watchAll). Conservative on purpose — a screened-out script
86
- // can still be declared by hand; a wrongly-included one would become hook-auto-approvable.
103
+ // Non-terminating NAME screening is TOKEN-set based, position-independent (never an anchored
104
+ // regex — the anchored form missed `build:preview`): a non-terminating token in ANY name segment
105
+ // disqualifies; `watch` disqualifies as a substring anywhere (watchAll). Conservative on purpose
106
+ // a screened-out script can still be declared by hand.
87
107
  const NON_TERMINATING_TOKENS = new Set(['dev', 'start', 'serve', 'watch', 'preview']);
88
- // A terminating-looking NAME can still hide release work in its BODY (`"test": "npm publish"`):
89
- // the same token classes the warn-name screen rejects are rejected as bare body words too — an
90
- // offered cmd is hook-auto-approvable, so a dangerous body must never ride a clean name.
91
- const DANGEROUS_BODY_TOKENS = new Set(['release', 'publish', 'deploy', 'push', 'version', 'commit', 'tag']);
92
108
  const WATCH_ANYWHERE_PATTERN = /watch/i;
93
109
  const wordOf = (raw) => raw.toLowerCase().replace(/^-+/, '').split('=')[0];
94
110
  const hasTokenIn = (text, splitter, tokens) =>
@@ -97,16 +113,82 @@ const hasTokenIn = (text, splitter, tokens) =>
97
113
  .some((part) => tokens.has(wordOf(part)));
98
114
  const isNonTerminatingName = (name) =>
99
115
  hasTokenIn(name, /[:._-]/, NON_TERMINATING_TOKENS) || WATCH_ANYWHERE_PATTERN.test(name);
100
- const isNonTerminatingBody = (body) =>
101
- hasTokenIn(body, /\s+/, NON_TERMINATING_TOKENS) || WATCH_ANYWHERE_PATTERN.test(body);
102
- const isDangerousBody = (body) => hasTokenIn(body, /\s+/, DANGEROUS_BODY_TOKENS);
103
- // A MUTATING VARIANT of a terminating class never enters the offer — a hook-auto-approvable gate
104
- // must never be a writer. Screened on BOTH axes: the script NAME (lint:fix, test:update,
105
- // build:write, test:snapshot) and the script BODY's write-mode flags (eslint --fix,
106
- // prettier --write / -w, jest -u / --updateSnapshot, tsc -w). Conservative by design: an excluded
107
- // candidate can still be declared by hand — a wrongly-included one would be silently auto-approved.
116
+ // A MUTATING VARIANT NAME of a terminating class never enters the offer (lint:fix, test:update,
117
+ // build:write, test:snapshot) a hook-auto-approvable gate must never be a writer. Write-mode
118
+ // BODIES need no screen: the closed-world allowlist simply has no write-mode member.
108
119
  const MUTATING_VARIANT_NAME_PATTERN = /(^|[:._-])(fix|write|update|snapshot)([:._-]|$)/i;
109
- const MUTATING_BODY_FLAG_PATTERN = /(^|\s)(--fix|--write|--update(?:-snapshot)?|--updateSnapshot|-w|-u)(=|\s|$)/;
120
+
121
+ // ── the CLOSED-WORLD body contract (AD-052 — the Issue-011 structural fix) ─────────────
122
+ // The body axis is ALLOWLIST MEMBERSHIP, never blocklist screening. The failure direction flips
123
+ // by construction: worst case = a legit command not offered (mild, add-by-hand) — never a
124
+ // dangerous one offered. Extension contract (edit-safety): adding an entry = adding the literal
125
+ // here + classifying its stem in the partition below + its own test case; the self-safety test
126
+ // pins every entry against shell metacharacters, write-mode flags, and unrecognized runner stems.
127
+ // Deliberately EXCLUDED: `tsc -p .` without --noEmit — it EMITS compiled .js into the tree by
128
+ // default, a write side-effect a verification gate must not have.
129
+ export const BODY_ALLOWLIST = Object.freeze([
130
+ 'node --test',
131
+ 'vitest run',
132
+ 'jest',
133
+ 'jest --ci',
134
+ 'eslint .',
135
+ 'prettier --check .',
136
+ 'tsc --noEmit',
137
+ 'tsc -p . --noEmit',
138
+ 'vite build',
139
+ ]);
140
+ // The FIXED executable-source partition of the allowlist stems (test-pinned, never a runtime
141
+ // probe): a host-runtime stem (`node`) is always on PATH and never a node_modules package — no
142
+ // local-bin resolution applies to it; a package-runner stem resolves from the project's own
143
+ // node_modules (or Berry .pnp.cjs) via `<pm> exec`, under the per-PM no-network-fetch rule below.
144
+ export const HOST_RUNTIME_STEMS = Object.freeze(['node']);
145
+ export const PACKAGE_RUNNER_STEMS = Object.freeze(['vitest', 'jest', 'eslint', 'prettier', 'tsc', 'vite']);
146
+
147
+ // Decision-3 order, test-pinned: (1) string-typed, else not offered; (2) reject ANY char outside
148
+ // printable ASCII + space/tab BEFORE trimming — String.trim() strips \n/NBSP/BOM at the EDGES and
149
+ // would mask a leading/trailing forbidden char; (3) trim + collapse ASCII space/tab runs to one
150
+ // space; (4) literal membership. No case folding, no arg reordering, no separate env/path reject
151
+ // axis — `FOO=bar node --test` and `./scripts/x.sh` are already rejected by NON-MEMBERSHIP.
152
+ const PRINTABLE_ASCII_BODY_PATTERN = /^[\x20-\x7E\t]*$/;
153
+ const allowlistedBodyOf = (body) => {
154
+ if (typeof body !== 'string') return null;
155
+ if (!PRINTABLE_ASCII_BODY_PATTERN.test(body)) return null;
156
+ const normalized = body.replace(/^[ \t]+|[ \t]+$/g, '').replace(/[ \t]+/g, ' ');
157
+ return BODY_ALLOWLIST.includes(normalized) ? normalized : null;
158
+ };
159
+
160
+ // The per-PM HOOK-FREE exec form (Decision 1, host-proven npm 12 / pnpm 10 / yarn 1 + berry 4):
161
+ // `exec` runs a COMMAND, not a named script — no pre/post lifecycle exists to fire, uniformly.
162
+ // The `--` keeps a body flag (`jest --ci`) from being absorbed by the PM. Per-PM hardening — the
163
+ // pinned invariant is NO NETWORK FETCH of an absent runner, not "no user-machine fallback":
164
+ // npm — `--script-shell /bin/sh` beats a hostile per-project `.npmrc script-shell` (proven the
165
+ // flag wins) and `--offline` refuses an absent runner as a cache miss, never a registry
166
+ // fetch; a hit on a previously-installed real package (the npm cache OR the host's
167
+ // global tree — both user machine state, not project content) is the documented runtime
168
+ // residual (Decision 2 ii / 6), not a separate guard.
169
+ // pnpm — no network fetch; an absent runner fails closed when no project-local or
170
+ // PATH-resolvable runner exists (a user-installed one executing is the same
171
+ // user-machine-state residual). Reads no npm `.npmrc script-shell`.
172
+ // yarn — same contract, uniform for classic + berry (berry resolves via .pnp.cjs; proven).
173
+ // The `COREPACK_ENABLE_NETWORK=0` prefix closes ONE more attacker-reachable fetch axis: when the
174
+ // PM binary is a Corepack shim, a hostile `packageManager: "<pm>@<uncached-version>"` field makes
175
+ // Corepack DOWNLOAD that PM release from the registry BEFORE exec even resolves the runner. The env
176
+ // var disables that provisioning (proven: it fails closed "Network access disabled" on an uncached
177
+ // pin, and is inert when the PM is a real install or already provisioned) — so the no-network
178
+ // contract holds for the PM-provision step too, not just the runner. Applies to all three (Corepack
179
+ // can shim npm/pnpm/yarn alike). An UNKNOWN family has no verified fail-closed exec contract → cmd
180
+ // null (withheld, loud note) — the mild worst case, never a hole. detectPackageManager can only
181
+ // yield npm/pnpm/yarn today (bun collapses to npm — characterized), so this is the builder floor.
182
+ const COREPACK_NO_NETWORK = 'COREPACK_ENABLE_NETWORK=0';
183
+ export const execCmdFor = (pm, body) => {
184
+ if (pm === 'npm') return { cmd: `${COREPACK_NO_NETWORK} npm exec --offline --script-shell /bin/sh -- ${body}`, note: null };
185
+ if (pm === 'pnpm') return { cmd: `${COREPACK_NO_NETWORK} pnpm exec -- ${body}`, note: null };
186
+ if (pm === 'yarn') return { cmd: `${COREPACK_NO_NETWORK} yarn exec -- ${body}`, note: null };
187
+ return {
188
+ cmd: null,
189
+ note: `no fail-closed exec form is verified for this package manager (${pm}) — add the gate by hand`,
190
+ };
191
+ };
110
192
 
111
193
  export const kebabIdOf = (name) =>
112
194
  String(name)
@@ -133,8 +215,11 @@ export const detectPackageManager = (cwd, deps = {}) => {
133
215
  return 'npm';
134
216
  };
135
217
 
136
- // The script-derived offer entries. Order = package.json scripts order (the offer the user reads).
137
- export const deriveScriptEntries = (cwd, deps = {}) => {
218
+ // The script-derived offer: entries + loud honesty notes (a gate-class script screened out on its
219
+ // body, or a whole family withheld, is COUNTED and NAMED — never silently absent). Order =
220
+ // package.json scripts order (the offer the user reads). `deps.packageManager` injects a family
221
+ // token past the detector for the builder-boundary fail-closed proof (T4b).
222
+ const deriveScripts = (cwd, deps = {}) => {
138
223
  const read = deps.readFile ?? readFileSync;
139
224
  const pkg = (() => {
140
225
  try {
@@ -143,29 +228,48 @@ export const deriveScriptEntries = (cwd, deps = {}) => {
143
228
  return null; // no/unreadable package.json → no script candidates (an honest empty offer)
144
229
  }
145
230
  })();
146
- const pm = detectPackageManager(cwd, deps);
147
- const seen = new Set();
148
- const bodyOf = (name) => String(pkg?.scripts?.[name] ?? '');
149
- return discoverGateCandidates(pkg ?? {})
231
+ const pm = deps.packageManager ?? detectPackageManager(cwd, deps);
232
+ const named = discoverGateCandidates(pkg ?? {})
150
233
  .filter((c) => !c.warn) // warn-flagged NEVER enter the offer
151
234
  .filter((c) => SAFE_SCRIPT_NAME_PATTERN.test(c.scriptName)) // shell-safe names only, FIRST
152
235
  .filter((c) => TERMINATING_CLASS_PATTERN.test(c.scriptName))
153
236
  .filter((c) => !isNonTerminatingName(c.scriptName))
154
- .filter((c) => !MUTATING_VARIANT_NAME_PATTERN.test(c.scriptName) && !MUTATING_BODY_FLAG_PATTERN.test(bodyOf(c.scriptName)))
155
- .filter((c) => !isNonTerminatingBody(bodyOf(c.scriptName)))
156
- .filter((c) => !isDangerousBody(bodyOf(c.scriptName)))
157
- .map((c) => ({
158
- id: kebabIdOf(c.scriptName),
159
- title: `Project script: ${pm} run ${c.scriptName}`,
160
- cmd: `${pm} run ${c.scriptName}`,
161
- }))
162
- .filter((e) => {
163
- if (!e.id || seen.has(e.id)) return false; // an empty or duplicate derived id never enters the offer
164
- seen.add(e.id);
165
- return true;
166
- });
237
+ .filter((c) => !MUTATING_VARIANT_NAME_PATTERN.test(c.scriptName));
238
+ const seen = new Set(); // first occurrence of a derived id wins, whatever its outcome (conservative)
239
+ const entries = [];
240
+ const screenedIds = [];
241
+ const withheld = [];
242
+ for (const c of named) {
243
+ const id = kebabIdOf(c.scriptName);
244
+ if (!id || seen.has(id)) continue; // an empty or duplicate derived id never enters the offer
245
+ seen.add(id);
246
+ const body = allowlistedBodyOf(pkg?.scripts?.[c.scriptName]);
247
+ if (body === null) {
248
+ screenedIds.push(id);
249
+ continue;
250
+ }
251
+ const { cmd, note } = execCmdFor(pm, body);
252
+ if (cmd === null) {
253
+ withheld.push({ id, note });
254
+ continue;
255
+ }
256
+ entries.push({ id, title: `Project script ${c.scriptName}: ${body}`, cmd });
257
+ }
258
+ const notes = [];
259
+ if (screenedIds.length) {
260
+ notes.push(
261
+ `${screenedIds.length} gate-class script(s) screened out — body not in the closed-world ` +
262
+ `allowlist: ${screenedIds.join(', ')} — a command you trust can still be declared by hand in ${GATES_REL}`,
263
+ );
264
+ }
265
+ if (withheld.length) {
266
+ notes.push(`${withheld.length} script gate(s) withheld: ${withheld.map((w) => w.id).join(', ')} — ${withheld[0].note}`);
267
+ }
268
+ return { entries, notes };
167
269
  };
168
270
 
271
+ export const deriveScriptEntries = (cwd, deps = {}) => deriveScripts(cwd, deps).entries;
272
+
169
273
  // The conditional review-state candidate — keyed on the SLOT the checker enforces
170
274
  // (plan-execution.review, tools/review-state.mjs), read via the shared config reader. Offered only
171
275
  // when the config DECLARES reviewed/council there; solo configs and a council-on-plan-authoring-only
@@ -256,14 +360,13 @@ const assertOnlyIdsOffered = (offer, onlyIds = []) => {
256
360
  // plus loud notes. Both review candidates key on the same slot (plan-execution.review reviewed/council)
257
361
  // but gate distinct axes (receipt presence vs review-round convergence) — offered together.
258
362
  export const buildOffer = (cwd, deps = {}) => {
259
- const entries = deriveScriptEntries(cwd, deps);
363
+ const scripts = deriveScripts(cwd, deps);
260
364
  const rs = reviewStateCandidate(cwd, deps);
261
365
  const rl = reviewLedgerCandidate(cwd, deps);
262
366
  const candidates = [rs.candidate, rl.candidate].filter(Boolean);
263
- const notes = [rs.note, rl.note].filter(Boolean);
264
367
  return {
265
- entries: [...entries, ...candidates],
266
- notes,
368
+ entries: [...scripts.entries, ...candidates],
369
+ notes: [...scripts.notes, rs.note, rl.note].filter(Boolean),
267
370
  };
268
371
  };
269
372
 
@@ -361,7 +464,7 @@ export const applySeed = ({ cwd, onlyIds = [] }, deps = {}) => {
361
464
  }
362
465
  const offer = buildOffer(cwd, deps);
363
466
  assertOnlyIdsOffered(offer, onlyIds); // BEFORE the empty-offer return — a typo is never masked
364
- if (!offer.entries.length) return { outcome: 'nothing' };
467
+ if (!offer.entries.length) return { outcome: 'nothing', notes: offer.notes };
365
468
  const selected = onlyIds.length ? offer.entries.filter((e) => onlyIds.includes(e.id)) : offer.entries;
366
469
 
367
470
  const existing = loadExistingDeclaration(cwd, deps);
@@ -382,7 +485,7 @@ export const applySeed = ({ cwd, onlyIds = [] }, deps = {}) => {
382
485
  validateDeclaration(merged); // every written declaration passes the runner's validator, always
383
486
  const body = `${JSON.stringify(merged, null, 2)}\n`;
384
487
  const { writtenPath } = writeDocsAiFileAtomic(cwd, GATES_REL, body, deps, { stop, noun: 'a gate declaration' });
385
- return { outcome: 'written', writtenPath, appended: selected.map((e) => e.id) };
488
+ return { outcome: 'written', writtenPath, appended: selected.map((e) => e.id), notes: offer.notes };
386
489
  };
387
490
 
388
491
  // ── CLI ────────────────────────────────────────────────────────────────────────────────
@@ -439,9 +542,11 @@ export const main = (argv = process.argv.slice(2), deps = {}) => {
439
542
  const result = applySeed({ cwd, onlyIds: args.only }, deps);
440
543
  if (result.outcome === 'nothing') {
441
544
  log('[agent-workflow-kit] nothing to offer — no seedable terminating verification scripts were found; wrote nothing.');
545
+ for (const note of result.notes) log(` note: ${note}`); // the user learns WHY (same note as the preview)
442
546
  return EXIT_OK;
443
547
  }
444
548
  log(`[agent-workflow-kit] appended ${result.appended.length} consented gate(s) to ${GATES_REL}: ${result.appended.join(', ')}`);
549
+ for (const note of result.notes) log(` note: ${note}`); // a mixed offer never silently omits what was screened
445
550
  log(`[agent-workflow-kit] ${TRUST_CHAIN_DISCLOSURE}`);
446
551
  return EXIT_OK;
447
552
  } catch (err) {
@@ -15,7 +15,7 @@ import { AUTONOMY_REL, loadAutonomy, resolveAutonomy, COMMAND_REDLINES } from '.
15
15
 
16
16
  // Deployment-lineage head this velocity build targets; bump together with agent-workflow-memory
17
17
  // LINEAGE_HEAD when the deployed docs/ai structure changes.
18
- export const EXPECTED_WORKFLOW_VERSION = '1.3.0';
18
+ export const EXPECTED_WORKFLOW_VERSION = '2.0.0';
19
19
  export const SETTINGS_FILE = '.claude/settings.json';
20
20
  export const SETTINGS_LOCAL_FILE = '.claude/settings.local.json';
21
21
  export const CLAUDE_DIR = '.claude';
@@ -111,6 +111,7 @@ const projectVm = (p) =>
111
111
  dir: p.dir,
112
112
  deployed: p.deployed,
113
113
  docsAi: p.docsAi,
114
+ adrLayout: p.adrLayout ?? null,
114
115
  deployStamps: (p.deployStamps ?? []).map((st) => ({ display: st.display, version: st.version ?? null })),
115
116
  visibility: visibilityVm(p.visibility),
116
117
  settings: settingsVm(p.settings),