create-agentic-workspace 0.17.1 → 0.17.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-agentic-workspace",
3
- "version": "0.17.1",
3
+ "version": "0.17.3",
4
4
  "description": "The pre-session bootstrap wizard for an Agentic Foundry workspace: declares (never grants) the permission floor, absorbs foundry-bootstrap.sh's out-of-session identity wiring, and scaffolds a seven-file schema-valid workspace. Zero dependencies, no lifecycle scripts, no telemetry.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -34,7 +34,7 @@
34
34
  "marketplace_name": "agentic-foundry",
35
35
  "marketplace_repo": "lukasrepublic/agentic-foundry",
36
36
  "plugin_name": "foundry",
37
- "plugin_version": "1.17.1",
37
+ "plugin_version": "1.17.3",
38
38
  "pins_researched": "2026-08-02"
39
39
  }
40
40
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schema_version": 1,
3
3
  "plugin_root_glob": "~/.claude/plugins/cache/*/foundry/*",
4
- "generated_for_plugin_version": "1.17.1",
4
+ "generated_for_plugin_version": "1.17.3",
5
5
  "entries": [
6
6
  {
7
7
  "rule": "Bash(~/.claude/plugins/cache/*/foundry/*/scripts/foundry-acceptance-contract-validate.py:*)",
@@ -213,6 +213,45 @@ export function parseFloorRootShape(rule, pluginRootGlob) {
213
213
  return { name: m[1], sub: m[2] ?? null };
214
214
  }
215
215
 
216
+ /** The floor's root-glob shape with every `*` segment of the glob allowed to be a CONCRETE
217
+ * segment instead (`cache/agentic-foundry/foundry/1.9.1/scripts/<name>[ <sub>]:*`) — the shape an
218
+ * init before installer-unpinning (v1.7.0) wrote, with the marketplace directory and the plugin
219
+ * version spelled out. hotfix-v1.17.3: such rows are stale by construction (the floor has written
220
+ * only version-wildcarded rows since; the wildcard row the reconcile adds in the same pass covers
221
+ * the script), and because they never matched the exact-shape regex above, nothing ever retired
222
+ * them — an adopter carried `1.9.1` allow rows across eight releases. `*` in the glob becomes
223
+ * `([^/]+)`; a row whose captured segments are ALL literal `*` is the exact shape (handled above),
224
+ * so this parser reports `pinned: true` only when at least one segment is concrete. */
225
+ function floorPinnedShapeRe(pluginRootGlob) {
226
+ // PR #233 security review Risk 2: every `*` but the last (the marketplace directory) may be a
227
+ // plain name (no dots — so `..` and an operator's partial glob like `1.*` never qualify) or the
228
+ // literal `*`; the LAST `*` (the plugin version) may be a semver-shaped segment or `*`. A rule
229
+ // from a foreign marketplace still matches by shape (the name is not the floor's to know here),
230
+ // but only for the tiers and conditions planRetirements allows.
231
+ const stars = (pluginRootGlob.match(/\*/g) || []).length;
232
+ let seen = 0;
233
+ const src = escapeLiteral(pluginRootGlob).replace(/\\\*/g, () => {
234
+ seen += 1;
235
+ return seen === stars ? '(\\*|\\d+\\.\\d+\\.\\d+[A-Za-z0-9.+-]*)' : '(\\*|[A-Za-z0-9_-]+)';
236
+ });
237
+ return new RegExp(`^Bash\\(${src}/scripts/(${ROOT_SHAPE_NAME_RE})(?: (.+))?:\\*\\)$`);
238
+ }
239
+
240
+ /** Parse `rule` as a version-/marketplace-PINNED variant of the floor's own row shape. Returns
241
+ * `{ name, sub, pinned: true }` when at least one glob segment is concrete in the row, `null` for
242
+ * the exact wildcard shape (parseFloorRootShape's business) and for every other shape. Exported for
243
+ * the fixture tests. */
244
+ export function parseFloorPinnedShape(rule, pluginRootGlob) {
245
+ if (typeof pluginRootGlob !== 'string' || pluginRootGlob === '') return null;
246
+ const stars = (pluginRootGlob.match(/\*/g) || []).length;
247
+ if (stars === 0) return null;
248
+ const m = floorPinnedShapeRe(pluginRootGlob).exec(rule);
249
+ if (!m) return null;
250
+ const segs = m.slice(1, 1 + stars);
251
+ if (segs.every((x) => x === '*')) return null;
252
+ return { name: m[1 + stars], sub: m[2 + stars] ?? null, pinned: true };
253
+ }
254
+
216
255
  /** A collision-free key for the `(name, sub)` pair — `JSON.stringify` of a 2-tuple rather than a
217
256
  * string concatenation with a hand-picked separator, which a `sub` containing that exact separator
218
257
  * (an unlikely but not-impossible flag value) could otherwise fold into a DIFFERENT pair's key. */
@@ -241,13 +280,34 @@ function shippedRootNames(map) {
241
280
  * `{ retirements: { allow: [...], ask: [...] }, total }`. */
242
281
  export function planRetirements({ settingsObj, map }) {
243
282
  const shipped = shippedRootNames(map);
283
+ const shippedAsk = new Set();
284
+ for (const e of map.entries) {
285
+ if (e.tier !== 'ask') continue;
286
+ const parsed = parseFloorRootShape(e.rule, map.plugin_root_glob);
287
+ if (parsed) shippedAsk.add(rootNameKey(parsed));
288
+ }
244
289
  const retirements = { allow: [], ask: [] };
245
290
  const perms = (settingsObj && settingsObj.permissions) || {};
246
291
  for (const tier of ['allow', 'ask']) {
247
292
  for (const rule of perms[tier] || []) {
248
293
  const parsed = parseFloorRootShape(rule, map.plugin_root_glob);
249
- if (!parsed) continue; // not the floor's own shape at all -> never touched (AC-FRR-2)
250
- if (!shipped.has(rootNameKey(parsed))) retirements[tier].push(rule);
294
+ if (parsed) {
295
+ if (!shipped.has(rootNameKey(parsed))) retirements[tier].push(rule);
296
+ continue;
297
+ }
298
+ // hotfix-v1.17.3: a version-/marketplace-pinned variant of the floor's own shape. An `allow`
299
+ // row is retired whether or not the script still ships — the wildcard row covers a shipped
300
+ // script (added in this same pass when absent), and a pinned row for a gone script is exactly
301
+ // the ER #199 class. An `ask` row is retired ONLY when the shipped map declares the same
302
+ // (name, sub) at `ask`, so the wildcard `ask` row replaces it (PR #233 security review Risk 1:
303
+ // `ask` beats `allow`, so dropping an ask row under a broader allow would turn a prompt into a
304
+ // silent grant — a widening this pass must never perform).
305
+ const pinned = parseFloorPinnedShape(rule, map.plugin_root_glob);
306
+ if (pinned) {
307
+ if (tier === 'allow') retirements[tier].push(rule);
308
+ else if (shippedAsk.has(rootNameKey(pinned))) retirements[tier].push(rule);
309
+ }
310
+ // any other shape -> never touched (AC-FRR-2)
251
311
  }
252
312
  }
253
313
  const total = retirements.allow.length + retirements.ask.length;
@@ -4,6 +4,7 @@
4
4
  // (AC-BCL-8). `covers()` agrees with tests/test_permission_floor_map.py::_subsumes on the shared
5
5
  // 8-row table by construction (same prefix-subsumption rule).
6
6
  import fs from 'node:fs';
7
+ import { SELF_GUARD_DENY } from './selfGuardDeny.mjs';
7
8
  import os from 'node:os';
8
9
 
9
10
  /** The one map schema_version this build understands. A map declaring anything else is refused
@@ -214,6 +215,10 @@ export function buildSettings(map, pins) {
214
215
  for (const e of map.entries) {
215
216
  byTier[e.tier].push(e.rule);
216
217
  }
218
+ // hotfix-v1.17.3: the policy file's own self-guard pair rides with the floor on the create path
219
+ // (the scaffold seeds .foundry/permissions.yaml in the same run), so a fresh workspace is in-sync
220
+ // instead of `policy drift (2)` until someone runs the compiler.
221
+ for (const r of SELF_GUARD_DENY) if (!byTier.deny.includes(r)) byTier.deny.push(r);
217
222
  // THE PINNED LITERAL — SUPERSEDED (feat-foundry-installer-unpinning, AC-IUP-3). This block used
218
223
  // to read (AC-BCL-4(b), contract v1.2 — PR #61 security review Block 1), verbatim:
219
224
  //
package/src/run.mjs CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  import { reconcileGitignorePlan, applyGitignorePlan, renderGitignoreRow } from './gitignoreReconcile.mjs';
21
21
  import { planAmendmentsBackfill, applyAmendmentsBackfill, renderAmendmentsRow } from './amendmentsBackfill.mjs';
22
22
  import { planStatuslineWiring, applyStatuslineWiring, renderStatuslineRows } from './statuslineWiring.mjs';
23
+ import { policyPresent, missingSelfGuardDeny, applySelfGuardDeny, renderSelfGuardRow, selfGuardShapeOk } from './selfGuardDeny.mjs';
23
24
 
24
25
  export { DECLARED_PATH_SET };
25
26
 
@@ -253,8 +254,18 @@ export async function runCli(argv, { cwd, isTTY, input, output, homeDir, pkgDir
253
254
  // permission floor unattended; --yes must be given EXPLICITLY. This sits above applyPlan
254
255
  // deliberately — a "refused" verdict printed after the scaffold write had already landed reads
255
256
  // as "nothing happened", which is the one thing it must not mean.
257
+ // hotfix-v1.17.3 (PR #233 review Risk 4): the self-guard pair is previewed here and counts as
258
+ // floor work, so a piped run cannot write it without an explicit --yes and --dry-run names it.
259
+ let selfGuardPreview = { missing: [], shapeOk: true };
260
+ if (answers.reconcileFloor && floorTarget && floorTarget.present && policyPresent(physicalRoot)) {
261
+ const cur0 = readTarget(floorTarget.path);
262
+ selfGuardPreview = { missing: missingSelfGuardDeny(cur0), shapeOk: selfGuardShapeOk(cur0) };
263
+ const prow = renderSelfGuardRow(physicalRoot, selfGuardPreview.missing.length, { shapeOk: selfGuardPreview.shapeOk, applied: false });
264
+ if (prow) print(prow);
265
+ }
256
266
  const floorHasWork = Boolean(floorPlan)
257
- && (floorPlan.total > 0 || (floorRetirementPlan && floorRetirementPlan.total > 0));
267
+ && (floorPlan.total > 0 || (floorRetirementPlan && floorRetirementPlan.total > 0)
268
+ || selfGuardPreview.missing.length > 0);
258
269
  if (floorHasWork && !isTTY && answers.yes !== true) {
259
270
  throw new RefusalError(
260
271
  'refusing --reconcile-floor without a terminal: pass --yes explicitly to confirm the write',
@@ -297,6 +308,17 @@ export async function runCli(argv, { cwd, isTTY, input, output, homeDir, pkgDir
297
308
  applied: true, retirementPlan: floorRetirementPlan, mapEntryCount: map.entries.length,
298
309
  })) print(line);
299
310
  }
311
+ // hotfix-v1.17.3 (ER #232): under --reconcile-floor (the one opt-in that permits a narrow
312
+ // settings write on --existing), converge the policy file's self-guard deny pair, fresh from
313
+ // disk after the floor write, whenever a policy file exists.
314
+ if (answers.reconcileFloor && floorTarget && floorTarget.present && policyPresent(physicalRoot)) {
315
+ const cur = readTarget(floorTarget.path);
316
+ const shapeOk = selfGuardShapeOk(cur);
317
+ const missing = missingSelfGuardDeny(cur);
318
+ if (shapeOk && missing.length > 0) writeTargetAtomically(floorTarget.path, applySelfGuardDeny(cur));
319
+ const row = renderSelfGuardRow(physicalRoot, missing.length, { shapeOk });
320
+ if (row) print(row);
321
+ }
300
322
  // AFTER the floor write above: that write serialises a settings object read before this
301
323
  // point, so wiring the statusLine keys first would have been overwritten by it. The wiring
302
324
  // re-reads settings.json itself and adds only the absent keys (AC-SLW-2).
@@ -0,0 +1,75 @@
1
+ // selfGuardDeny.mjs — the two framework-owned deny rules that protect the operator's policy file
2
+ // (hotfix-v1.17.3, ER #232): `Edit(.foundry/permissions.yaml)` and `Write(.foundry/permissions.yaml)`.
3
+ //
4
+ // They are what `scripts/foundry-permissions-compile.py --write` derives for a policy with ZERO grants
5
+ // (its SELF_GUARD pair) — no operator judgement is in them, so every writer that seeds or keeps the
6
+ // policy file converges them too, and a fresh seed is in-sync instead of `policy drift (2)` until
7
+ // someone remembers the compiler. Grants stay the compiler's (operator-run) business.
8
+ //
9
+ // Discipline: add-if-absent onto `permissions.deny`, never reorder or remove anything, never touch
10
+ // another key; a settings object without a `permissions` block gains one with only `deny`.
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+
14
+ export const POLICY_REL = '.foundry/permissions.yaml';
15
+ export const SELF_GUARD_DENY = Object.freeze([
16
+ `Edit(${POLICY_REL})`,
17
+ `Write(${POLICY_REL})`,
18
+ ]);
19
+
20
+ const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
21
+
22
+ /** `true` when `settingsObj.permissions` is absent or a plain object whose `deny`, if present, is an
23
+ * array — the only shapes this module will write into. Anything else is refused rather than
24
+ * guessed (PR #233 security review Risk 3: a string `deny` would otherwise be spread into
25
+ * characters and written back). */
26
+ export function selfGuardShapeOk(settingsObj) {
27
+ if (!isPlainObject(settingsObj)) return false;
28
+ const perms = settingsObj.permissions;
29
+ if (perms === undefined) return true;
30
+ if (!isPlainObject(perms)) return false;
31
+ return perms.deny === undefined || Array.isArray(perms.deny);
32
+ }
33
+
34
+ /** Which of the two rules `settingsObj` still lacks. Pure. Returns [] for a shape this module will
35
+ * not write into (see selfGuardShapeOk) — the caller reports that separately. */
36
+ export function missingSelfGuardDeny(settingsObj) {
37
+ if (!selfGuardShapeOk(settingsObj)) return [];
38
+ const deny = (settingsObj.permissions && settingsObj.permissions.deny) || [];
39
+ return SELF_GUARD_DENY.filter((r) => !deny.includes(r));
40
+ }
41
+
42
+ /** A NEW settings object with the missing rules appended to `permissions.deny`. Pure; returns the
43
+ * input unchanged for a shape it will not write into. */
44
+ export function applySelfGuardDeny(settingsObj) {
45
+ if (!selfGuardShapeOk(settingsObj)) return settingsObj;
46
+ const missing = missingSelfGuardDeny(settingsObj);
47
+ if (missing.length === 0) return settingsObj;
48
+ const next = { ...settingsObj };
49
+ next.permissions = { ...(next.permissions || {}) };
50
+ next.permissions.deny = [...(next.permissions.deny || []), ...missing];
51
+ return next;
52
+ }
53
+
54
+ /** `true` when the policy file exists as a regular file under `physicalRoot` (seeded or kept) — the
55
+ * rules guard a file, so they are written only when there is one. Never follows a symlink. */
56
+ export function policyPresent(physicalRoot) {
57
+ try {
58
+ return fs.lstatSync(path.join(physicalRoot, POLICY_REL)).isFile();
59
+ } catch {
60
+ return false;
61
+ }
62
+ }
63
+
64
+ /** The one row every writer prints: `[permissions] self-guard deny rules added (N)` or
65
+ * `already present`. `null` when there is no policy file to guard (nothing to say). */
66
+ export function renderSelfGuardRow(physicalRoot, addedCount, { shapeOk = true, applied = true } = {}) {
67
+ if (!policyPresent(physicalRoot)) return null;
68
+ if (!shapeOk) return ' [permissions] settings.permissions is not the shape expected (object with an array deny) — self-guard deny rules NOT written';
69
+ if (addedCount > 0) {
70
+ return applied
71
+ ? ` [permissions] self-guard deny rules added (${addedCount}): Edit/Write on ${POLICY_REL}`
72
+ : ` [permissions] would add self-guard deny rules (${addedCount}): Edit/Write on ${POLICY_REL}`;
73
+ }
74
+ return ' [permissions] self-guard deny rules already present';
75
+ }
package/src/update.mjs CHANGED
@@ -16,8 +16,9 @@ import {
16
16
  } from './floorReconcile.mjs';
17
17
  import { reconcileGitignorePlan, applyGitignorePlan, renderGitignoreRow } from './gitignoreReconcile.mjs';
18
18
  import { planAmendmentsBackfill, applyAmendmentsBackfill, renderAmendmentsRow } from './amendmentsBackfill.mjs';
19
- import { buildUpgradeReport, writeUpgradeReport, installedVersionBefore, NEXT_LINE } from './upgradeReport.mjs';
19
+ import { buildUpgradeReport, writeUpgradeReport, installedVersionBefore, versionOrNull, NEXT_LINE } from './upgradeReport.mjs';
20
20
  import { planStatuslineWiring, applyStatuslineWiring, renderStatuslineRows, statuslineChanged } from './statuslineWiring.mjs';
21
+ import { policyPresent, missingSelfGuardDeny, applySelfGuardDeny, renderSelfGuardRow, selfGuardShapeOk } from './selfGuardDeny.mjs';
21
22
  import {
22
23
  ALLOWED_CLAUDE_SUBCOMMANDS, resolveClaudeOnPath, runClaude,
23
24
  defaultScopes, snapshotScopes, classifyMigration, migrationActions, migrateScope,
@@ -72,7 +73,7 @@ function isInstalledInScopeFactory(registry, pluginKey, cwd) {
72
73
  /** Run the update command end to end. Never throws — every failure path is caught and turned into
73
74
  * a refusal-shaped exit 1 (or, for a bug, exit 1 with the error message), matching run.mjs's own
74
75
  * contract. */
75
- export async function runUpdate(argv, { cwd, configDir, homeDir, pkgDir, output, spawnEnv = process.env }) {
76
+ export async function runUpdate(argv, { cwd, configDir, homeDir, pkgDir, output, spawnEnv = process.env, updaterVersion = null }) {
76
77
  const lines = [];
77
78
  const print = (s) => {
78
79
  lines.push(s);
@@ -103,7 +104,11 @@ export async function runUpdate(argv, { cwd, configDir, homeDir, pkgDir, output,
103
104
  return { exitCode: 0, output: lines.join('\n') };
104
105
  }
105
106
 
106
- const pins = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf-8')).foundry;
107
+ const corePkg = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf-8'));
108
+ const pins = corePkg.foundry;
109
+ // ER #228: say WHICH updater this is, first, every run — a stale npx-cached updater looked
110
+ // exactly like a broken backfill until the report and the log carried the version.
111
+ print(`update-agentic-workspace ${versionOrNull(updaterVersion) || 'unknown'} (core create-agentic-workspace ${versionOrNull(corePkg.version) || 'unknown'}, built for plugin ${versionOrNull(pins.plugin_version) || 'unknown'})`);
107
112
  const marketplaceName = pins.marketplace_name;
108
113
  const marketplaceRepo = pins.marketplace_repo;
109
114
  const pluginKey = `${pins.plugin_name}@${pins.marketplace_name}`;
@@ -207,6 +212,13 @@ export async function runUpdate(argv, { cwd, configDir, homeDir, pkgDir, output,
207
212
  if (previewRetirementPlan && previewRetirementPlan.total > 0) {
208
213
  previewLines.push(` [permission-floor] would retire allow=${previewRetirementPlan.retirements.allow.length}, ask=${previewRetirementPlan.retirements.ask.length}`);
209
214
  }
215
+ // hotfix-v1.17.3 (PR #233 review Risk 4): the self-guard pair is previewed like the floor.
216
+ if (policyPresent(physicalRoot) || filePlan.some((f) => f.seed && f.action === 'create')) {
217
+ const cur0 = readTarget(floorTarget.path);
218
+ const prow = renderSelfGuardRow(physicalRoot, missingSelfGuardDeny(cur0).length, { shapeOk: selfGuardShapeOk(cur0), applied: false });
219
+ if (prow) previewLines.push(prow);
220
+ else previewLines.push(' [permissions] would add self-guard deny rules (2): Edit/Write on .foundry/permissions.yaml (with the seed)');
221
+ }
210
222
  } else {
211
223
  previewLines.push(' [permission-floor] .claude/settings.json absent — left to the create path');
212
224
  }
@@ -289,6 +301,17 @@ export async function runUpdate(argv, { cwd, configDir, homeDir, pkgDir, output,
289
301
  applied: true, retirementPlan: floorRetirementPlan, mapEntryCount: map.entries.length,
290
302
  })) print(line);
291
303
  }
304
+ // hotfix-v1.17.3 (ER #232): the policy file's self-guard deny pair is framework-owned — converge
305
+ // it here, fresh from disk after the floor write, whenever a policy file exists (seeded above or
306
+ // kept). Grants stay the compiler's (operator-run) business.
307
+ if (policyPresent(physicalRoot)) {
308
+ const cur = readTarget(freshFloorTarget.path);
309
+ const shapeOk = selfGuardShapeOk(cur);
310
+ const missing = missingSelfGuardDeny(cur);
311
+ if (shapeOk && missing.length > 0) writeTargetAtomically(freshFloorTarget.path, applySelfGuardDeny(cur));
312
+ const row = renderSelfGuardRow(physicalRoot, missing.length, { shapeOk });
313
+ if (row) print(row);
314
+ }
292
315
  }
293
316
  // Recomputed FRESH from disk, same reasoning as floorPlan just above: never apply a plan
294
317
  // captured before Phases 1-3 ran, even though `.gitignore` is not itself a migration target.
@@ -339,6 +362,7 @@ export async function runUpdate(argv, { cwd, configDir, homeDir, pkgDir, output,
339
362
  // named in the LAST line so the operator's next step is never a guess.
340
363
  const report = buildUpgradeReport({
341
364
  installedBefore, afterEntry, toPluginVersion: pins.plugin_version, phases, filePlan, amendmentsPlan,
365
+ updaterVersion, coreVersion: corePkg.version, updaterPluginVersion: pins.plugin_version,
342
366
  });
343
367
  const reportPath = writeUpgradeReport(physicalRoot, report);
344
368
  print('');
@@ -31,6 +31,7 @@ export function versionOrNull(v) {
31
31
  * `amendmentsPlan` is the applied backfill plan (or null); `phases` is what renderSummary got. */
32
32
  export function buildUpgradeReport({
33
33
  installedBefore = null, afterEntry, toPluginVersion, phases, filePlan, amendmentsPlan, now = new Date(),
34
+ updaterVersion = null, coreVersion = null, updaterPluginVersion = null,
34
35
  }) {
35
36
  const seedRow = (filePlan || []).find((f) => f.seed);
36
37
  return {
@@ -42,10 +43,18 @@ export function buildUpgradeReport({
42
43
  // first install) makes the skill list only the current version's CHANGELOG section.
43
44
  from_plugin_version: versionOrNull(installedBefore),
44
45
  to_plugin_version: (afterEntry && versionOrNull(afterEntry.version)) || versionOrNull(toPluginVersion),
46
+ // ER #228 (v1.17.2): WHICH updater ran. A stale npx-cached updater was indistinguishable from a
47
+ // broken walk; the skill refuses a report whose updater was built for another plugin version.
48
+ updater_version: versionOrNull(updaterVersion),
49
+ core_version: versionOrNull(coreVersion),
50
+ updater_plugin_version: versionOrNull(updaterPluginVersion),
45
51
  phases: (phases || []).map((p) => ({ name: p.name, verdict: p.verdict, ...(p.reason ? { reason: p.reason } : {}) })),
52
+ // `total` is the walk's denominator (ER #228): backfilled + present + skipped == total, so a
53
+ // walk that never opened a file cannot read as a clean pass.
46
54
  amendments: amendmentsPlan
47
- ? { backfilled: amendmentsPlan.written ?? 0, present: amendmentsPlan.present, skipped: amendmentsPlan.skipped }
48
- : { backfilled: 0, present: 0, skipped: 0 },
55
+ ? { backfilled: amendmentsPlan.written ?? 0, present: amendmentsPlan.present, skipped: amendmentsPlan.skipped,
56
+ total: amendmentsPlan.total ?? ((amendmentsPlan.written ?? 0) + amendmentsPlan.present + amendmentsPlan.skipped) }
57
+ : { backfilled: 0, present: 0, skipped: 0, total: 0 },
49
58
  permissions_policy: seedRow ? (seedRow.action === 'create' ? 'created' : 'kept') : 'absent',
50
59
  drifted: (filePlan || []).filter((f) => f.action === 'drifted').map((f) => f.relPath),
51
60
  };