create-agentic-workspace 0.15.0 → 0.16.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/README.md CHANGED
@@ -33,6 +33,17 @@ it does invoke `claude` (bounded by a closed allowlist — see its own README).
33
33
  - Scaffolds a seven-file, schema-valid workspace seed.
34
34
  - Re-running is a **reconcile with a drift report** — an edited managed file is reported
35
35
  `drifted` and left byte-identical, never overwritten. Never-clobber is unconditional.
36
+ - The permission-floor reconcile is additive with ONE narrow exception: a row shaped exactly like
37
+ the floor's own root-glob rows (`Bash(<plugin-root-glob>/scripts/<name>[ <sub>]:*)`), whose
38
+ `(name, sub)` PAIR — not the script name alone — the shipped floor no longer declares, is retired
39
+ from `allow`/`ask` and printed `[retired] <row>`. The pair, not just the name, matters: retiring
40
+ one subcommand of a script that still ships other subcommands (say `<name> --a:*` is dropped
41
+ while `<name> --b:*` remains) removes only the stale `(name, sub)` row, never the whole family —
42
+ and a script deleted outright does not leave a standing grant behind for a future script with the
43
+ same name to inherit unreviewed. Any row of another shape (an adopter-authored rule naming a
44
+ plugin script through a different prefix, or a `deny` row) is never touched. The summary line
45
+ reports all three outcomes together:
46
+ `permission-floor reconcile: … — N added, M retired, K unchanged`.
36
47
  - `.gitignore` gets its own narrower reconcile on top of that: the file also carries a
37
48
  `FOUNDRY-RUNTIME-GITIGNORE-BEGIN`/`-END` managed block, converged independently of the
38
49
  whole-file compare above (so an adopter's own surrounding lines never block it from catching
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-agentic-workspace",
3
- "version": "0.15.0",
3
+ "version": "0.16.0",
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.15.0",
37
+ "plugin_version": "1.16.0",
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.15.0",
4
+ "generated_for_plugin_version": "1.16.0",
5
5
  "entries": [
6
6
  {
7
7
  "rule": "Bash(~/.claude/plugins/cache/*/foundry/*/scripts/foundry-acceptance-contract-validate.py:*)",
@@ -298,6 +298,16 @@
298
298
  "tier": "ask",
299
299
  "rationale": "ceremony: --create arrives after other args, not prefix-keyable, so the whole script is ask"
300
300
  },
301
+ {
302
+ "rule": "Bash(~/.claude/plugins/cache/*/foundry/*/scripts/foundry-worktree-gc.py --dry-run:*)",
303
+ "tier": "allow",
304
+ "rationale": "read-only classification of every worktree/branch matching the discipline's naming glob; --apply below is the deletion tier (branch-and-worktree-discipline, AC-BWD-2)"
305
+ },
306
+ {
307
+ "rule": "Bash(~/.claude/plugins/cache/*/foundry/*/scripts/foundry-worktree-gc.py --apply:*)",
308
+ "tier": "ask",
309
+ "rationale": "deletes the merged class's worktrees + local (git branch -d, falling back to -D only on that refusal, narrated) AND remote branches (git worktree remove, git push origin --delete) -- a repo-state mutation, ask like foundry-merge-when-green.py's own merge call, never a self-grant"
310
+ },
301
311
  {
302
312
  "rule": "Bash(~/.claude/plugins/cache/*/foundry/*/scripts/foundry_command_deck.py:*)",
303
313
  "tier": "allow",
@@ -7,9 +7,15 @@
7
7
  // handbooks are missing the entire floor as a result.
8
8
  //
9
9
  // This module converges the target's `permissions` block by ADDING the rules the classifier named.
10
- // Nothing is removed, nothing is reordered. The desired state is the shipped constant, the current
11
- // state is what the classifier reads, and the write is the delta — recomputed every run, which is
12
- // why no ledger is needed and why a second run is silent.
10
+ // Nothing is reordered. The desired state is the shipped constant, the current state is what the
11
+ // classifier reads, and the write is the delta — recomputed every run, which is why no ledger is
12
+ // needed and why a second run is silent.
13
+ //
14
+ // AC-FRR-1 (ER #199, floor-retires-rows) adds the ONE narrow exception: a row shaped exactly like
15
+ // the floor's own root-glob rows, whose script name(+sub) the shipped map no longer declares, is
16
+ // removed from `allow`/`ask` — see `planRetirements`/`applyRetirements` below. Nothing else about
17
+ // the additive design above changes: an adopter-authored row of any other shape still survives
18
+ // forever, and `deny` is never touched by either side.
13
19
  //
14
20
  // THE WRITE IS THIS CLI'S FIRST TO A PATH THAT ALREADY EXISTS, and every anti-clobber control in
15
21
  // the codebase is structurally unavailable to it. `applyPlan` opens O_EXCL, create-only, refusing
@@ -23,9 +29,10 @@
23
29
  // rather than its letter.
24
30
  // One mechanism answers all three: confinement join + LINK-LEVEL stat + temp-in-.claude + rename.
25
31
  import fs from 'node:fs';
32
+ import os from 'node:os';
26
33
  import path from 'node:path';
27
34
  import { confinedJoin, RefusalError } from './util.mjs';
28
- import { buildSettings } from './permissionFloor.mjs';
35
+ import { buildSettings, classifyDrift } from './permissionFloor.mjs';
29
36
 
30
37
  /** The drift classes whose findings name a rule this module may ADD. Everything else the
31
38
  * classifier can emit is report-only: blanket-allow, ask-shadowed, ask-shadowed-ceremony and
@@ -157,6 +164,115 @@ export function planAdditions({ findings, map, settingsObj, pins }) {
157
164
  return { additions, total, pin, withheldAllow, blanket, pinsVersion: pins.plugin_version };
158
165
  }
159
166
 
167
+ // ── AC-FRR-1/-2 (floor-retires-rows, ER #199). ───────────────────────────────────────────────────
168
+ //
169
+ // The reconcile above only ever ADDS: every pre-existing rule survives forever, even the ones the
170
+ // shipped floor itself no longer declares. Concretely: v1.15.0 deleted four fleet scripts (#196)
171
+ // and dropped their four `allow` rows from cli/permission-floor.json, and every workspace scaffolded
172
+ // or reconciled at an earlier version keeps those four `allow` rules with nothing to remove them —
173
+ // a script with the same name landing later would silently inherit a grant nobody re-authorized.
174
+ //
175
+ // Retirement is deliberately NARROWER than the addition side's classifyDrift/covers() fold: it
176
+ // compares a row's LITERAL text against the floor's OWN `plugin_root_glob` text (never resolved
177
+ // against a real filesystem path, never folded across `~`/`$HOME`/an interpreter word), because the
178
+ // question here is "did the FLOOR ITSELF write this exact shape", not "does some broader rule cover
179
+ // the reach this map entry names". A row of any other shape — a different prefix, a hand-authored
180
+ // rule naming the same script through a bare path, a `deny` row — is never a retirement candidate
181
+ // (AC-FRR-2): only a row shaped exactly like the ones `buildSettings`/the reconcile itself would
182
+ // write is the floor's own to take back.
183
+
184
+ const ROOT_SHAPE_NAME_RE = '[A-Za-z0-9_.-]+';
185
+
186
+ /** Escape every regex metacharacter in `glob`, INCLUDING `*` — the floor's own glob text is matched
187
+ * LITERALLY here (an actual `*` character in the permission rule's text), never expanded against a
188
+ * filesystem. Sibling of foldRegexFromGlob in permissionFloor.mjs, which instead treats `*` as a
189
+ * wildcard for the addition-side coverage fold; the two escape functions look alike and answer two
190
+ * different questions on purpose. */
191
+ function escapeLiteral(s) {
192
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
193
+ }
194
+
195
+ /** Build the regex that recognizes the floor's own row shape over ITS OWN `plugin_root_glob`:
196
+ * `Bash(<plugin_root_glob>/scripts/<name>[ <sub>]:*)`. Derived from the map's own glob text, never
197
+ * a second hardcoded copy of it — the same one-source-of-truth reasoning foldRegexFromGlob already
198
+ * documents for the addition side. */
199
+ function floorRootShapeRe(pluginRootGlob) {
200
+ return new RegExp(`^Bash\\(${escapeLiteral(pluginRootGlob)}/scripts/(${ROOT_SHAPE_NAME_RE})(?: (.+))?:\\*\\)$`);
201
+ }
202
+
203
+ /** Parse `rule` against the floor's own root-glob shape (the same shape `buildSettings` writes).
204
+ * Returns `{ name, sub }` (`sub` is `null` for a bare `<name>:*` row) when `rule` is of exactly this
205
+ * shape, `null` for any other shape at all — including the map's own bare, non-`:*` exception
206
+ * (`foundry-doctor.py)` with no trailing marker) and every adopter-authored rule that merely NAMES a
207
+ * plugin script through a different prefix or with no plugin-root glob (AC-FRR-2). Exported for the
208
+ * differential/fixture tests that need to assert the shape gate directly. */
209
+ export function parseFloorRootShape(rule, pluginRootGlob) {
210
+ if (typeof pluginRootGlob !== 'string' || pluginRootGlob === '') return null;
211
+ const m = floorRootShapeRe(pluginRootGlob).exec(rule);
212
+ if (!m) return null;
213
+ return { name: m[1], sub: m[2] ?? null };
214
+ }
215
+
216
+ /** A collision-free key for the `(name, sub)` pair — `JSON.stringify` of a 2-tuple rather than a
217
+ * string concatenation with a hand-picked separator, which a `sub` containing that exact separator
218
+ * (an unlikely but not-impossible flag value) could otherwise fold into a DIFFERENT pair's key. */
219
+ function rootNameKey(parsed) {
220
+ return JSON.stringify([parsed.name, parsed.sub]);
221
+ }
222
+
223
+ /** The (name, sub) pairs the SHIPPED map itself declares, each parsed through the exact same shape
224
+ * matcher used to recognize a target row — never a second, hand-written enumeration of script
225
+ * names, which is precisely how the two could drift apart. A map entry of another shape (there is
226
+ * none today, but the parser must not silently assume one) contributes nothing here. */
227
+ function shippedRootNames(map) {
228
+ const set = new Set();
229
+ for (const e of map.entries) {
230
+ const parsed = parseFloorRootShape(e.rule, map.plugin_root_glob);
231
+ if (parsed) set.add(rootNameKey(parsed));
232
+ }
233
+ return set;
234
+ }
235
+
236
+ /** Compute the rows the reconcile SHALL remove (AC-FRR-1), WITHOUT touching the filesystem. Every
237
+ * `allow`/`ask` row shaped exactly like the floor's own root-glob rows, whose `(name, sub)` pair the
238
+ * shipped map no longer declares, is queued for removal. `deny` is never a candidate (AC-FRR-2) —
239
+ * retirement only narrows a grant or a prompt, and a deny row read back as "extra" is, if anything,
240
+ * a reason to leave it exactly where the operator (or an earlier release) put it. Returns
241
+ * `{ retirements: { allow: [...], ask: [...] }, total }`. */
242
+ export function planRetirements({ settingsObj, map }) {
243
+ const shipped = shippedRootNames(map);
244
+ const retirements = { allow: [], ask: [] };
245
+ const perms = (settingsObj && settingsObj.permissions) || {};
246
+ for (const tier of ['allow', 'ask']) {
247
+ for (const rule of perms[tier] || []) {
248
+ 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);
251
+ }
252
+ }
253
+ const total = retirements.allow.length + retirements.ask.length;
254
+ return { retirements, total };
255
+ }
256
+
257
+ /** Apply a retirement plan to a parsed settings object, returning a NEW object. Pure — no I/O.
258
+ * Removes exactly the named rows from `allow`/`ask`, preserving every surviving rule's text and
259
+ * relative order; `deny` and every other top-level key pass through untouched. Composed with
260
+ * `applyAdditions` at the call site — the two touch disjoint rule sets by construction (a row
261
+ * cannot simultaneously be absent-from-target-and-in-the-map, which is what `applyAdditions` adds,
262
+ * and present-in-target-and-absent-from-the-map, which is what this removes). */
263
+ export function applyRetirements(settingsObj, retirementPlan) {
264
+ const next = { ...settingsObj };
265
+ const perms = { ...(settingsObj.permissions || {}) };
266
+ for (const tier of ['allow', 'ask']) {
267
+ const toRemove = new Set(retirementPlan.retirements[tier]);
268
+ if (toRemove.size === 0) continue;
269
+ const existing = Array.isArray(perms[tier]) ? perms[tier] : [];
270
+ perms[tier] = existing.filter((rule) => !toRemove.has(rule));
271
+ }
272
+ next.permissions = perms;
273
+ return next;
274
+ }
275
+
160
276
  /** Apply a plan to a parsed settings object, returning a NEW object. Pure — no I/O.
161
277
  *
162
278
  * Additive by construction: every pre-existing rule keeps its text, its tier and its position
@@ -186,6 +302,39 @@ export function applyAdditions(settingsObj, plan, { map, pins }) {
186
302
  return next;
187
303
  }
188
304
 
305
+ // ── AC-FRR-1 review round 1: additions MUST be planned against the POST-retirement rule set ────
306
+ //
307
+ // Computing `planAdditions` over the raw (pre-retirement) tracked rules is a real, one-cycle
308
+ // defect, not a hypothetical: the addition side's `covers()` is a PREFIX fold — a `:*`-suffixed
309
+ // effective rule covers every narrower reach beneath it — so a bare `Bash(<glob>/scripts/foo:*)`
310
+ // row a workspace still carries covers BOTH `Bash(<glob>/scripts/foo --a:*)` and
311
+ // `Bash(<glob>/scripts/foo --b:*)` map entries a map restructure might split it into. Planned in
312
+ // that order, a SINGLE reconcile pass would retire the bare row (its exact `(foo, null)` pair is
313
+ // no longer in the shipped map) while adding NEITHER split row (the pre-retirement classification
314
+ // still sees the bare row "covering" them) — the grant for that script is gone until a SECOND run
315
+ // notices the split rows are now genuinely absent. `planReconcile` closes the gap by re-deriving
316
+ // the tracked rules from the ALREADY-RETIRED settings object before classifying what to add, so
317
+ // the two sides compose into one correct delta in one pass. This is the ONLY entry point either
318
+ // call site (run.mjs's `--existing` path, update.mjs's Phase 4) should use from here on — never
319
+ // `planAdditions`/`planRetirements` called separately against the same raw settingsObj.
320
+
321
+ /** Compute both plans, correctly composed: retirement first, additions against the resulting
322
+ * (post-retirement) rule set. Returns `{ additionsPlan, retirementPlan }`; `additionsPlan.settingsObj`
323
+ * is the POST-retirement object — the one `applyAdditions` must be called against, so the caller
324
+ * never needs to call `applyRetirements` a second time on top of it. */
325
+ export function planReconcile({
326
+ settingsObj, map, pins, pluginRootExpansion = [], unreadableOrigins = [], home = os.homedir(),
327
+ }) {
328
+ const retirementPlan = planRetirements({ settingsObj, map });
329
+ const postRetirementSettingsObj = applyRetirements(settingsObj, retirementPlan);
330
+ const findings = classifyDrift(map, readTrackedRules(postRetirementSettingsObj), {
331
+ pluginRootExpansion, unreadableOrigins, home,
332
+ });
333
+ const additionsPlan = planAdditions({ findings, map, settingsObj: postRetirementSettingsObj, pins });
334
+ additionsPlan.settingsObj = postRetirementSettingsObj;
335
+ return { additionsPlan, retirementPlan };
336
+ }
337
+
189
338
  /** Resolve the target settings path, refusing anything that is not a regular file inside the root.
190
339
  * lstat, NOT stat: statSync().isFile() follows a symlink, so an in-root link would pass. */
191
340
  export function resolveTarget(physicalRoot) {
@@ -246,17 +395,32 @@ export function writeTargetAtomically(targetPath, obj) {
246
395
 
247
396
  /** Render the plan for the operator: every rule that would be added, with its tier, plus the
248
397
  * per-tier counts and any qualifier. Used for both the dry-run report and the post-write one, so
249
- * the two cannot describe the same plan differently. */
250
- export function renderPlan(plan, { applied }) {
398
+ * the two cannot describe the same plan differently.
399
+ *
400
+ * AC-FRR-1: when `retirementPlan` is supplied (both call sites in this codebase always supply
401
+ * one), every row it names prints as `[retired] <row>` — same tag whether this is a dry-run
402
+ * preview or a just-applied report, because the row itself does not become "more retired" for
403
+ * having actually been removed — and the summary line gains `N added, M retired, K unchanged` in
404
+ * the SAME line as the existing per-tier addition counts, never a second, separately-findable
405
+ * report. `K unchanged` is the shipped floor's own row count minus what this run added — the rows
406
+ * that needed no action at all, additions and retirements both being actions. */
407
+ export function renderPlan(plan, { applied, retirementPlan = null, mapEntryCount = null }) {
251
408
  const lines = [];
252
409
  const verb = applied ? 'added' : 'would add';
253
410
  for (const tier of ['allow', 'ask', 'deny']) {
254
411
  for (const rule of plan.additions[tier]) lines.push(` [${tier}] ${rule}`);
255
412
  }
256
- lines.push(
257
- `permission-floor reconcile: ${verb} ` +
258
- ['allow', 'ask', 'deny'].map((t) => `${t}=${plan.additions[t].length}`).join(', '),
259
- );
413
+ if (retirementPlan) {
414
+ for (const tier of ['allow', 'ask']) {
415
+ for (const rule of retirementPlan.retirements[tier]) lines.push(` [retired] ${rule}`);
416
+ }
417
+ }
418
+ let summary = `permission-floor reconcile: ${verb} ` +
419
+ ['allow', 'ask', 'deny'].map((t) => `${t}=${plan.additions[t].length}`).join(', ');
420
+ if (retirementPlan && typeof mapEntryCount === 'number') {
421
+ summary += ` — ${plan.total} added, ${retirementPlan.total} retired, ${mapEntryCount - plan.total} unchanged`;
422
+ }
423
+ lines.push(summary);
260
424
  if (plan.pin.state === 'absent') {
261
425
  lines.push(` + marketplace pin added — the bundled allow rules are wildcarded across the plugin cache and are bounded only by it`);
262
426
  } else if (plan.pin.state === 'pinned') {
package/src/run.mjs CHANGED
@@ -15,8 +15,7 @@ import { planManagedFiles, applyPlan, exitCodeForPlan } from './reconcile.mjs';
15
15
  import { renderPreview, TRUST_HANDOFF_TEXT } from './preview.mjs';
16
16
  import { validateSlug, resolveIdentity, wireIdentity, plannedMachineScopeWrites } from './identity.mjs';
17
17
  import {
18
- resolveTarget, readTarget, readTrackedRules, planAdditions, applyAdditions,
19
- writeTargetAtomically, renderPlan,
18
+ resolveTarget, readTarget, applyAdditions, planReconcile, writeTargetAtomically, renderPlan,
20
19
  } from './floorReconcile.mjs';
21
20
  import { reconcileGitignorePlan, applyGitignorePlan, renderGitignoreRow } from './gitignoreReconcile.mjs';
22
21
 
@@ -198,18 +197,25 @@ export async function runCli(argv, { cwd, isTTY, input, output, homeDir, pkgDir
198
197
  // would stay incomplete while the report said converged — and the repo would then ship to every
199
198
  // other clone and to CI without it.
200
199
  let floorPlan = null;
200
+ let floorRetirementPlan = null;
201
201
  let floorTarget = null;
202
202
  if (answers.reconcileFloor) {
203
203
  floorTarget = resolveTarget(physicalRoot);
204
204
  if (floorTarget.present) {
205
205
  const settingsObj = readTarget(floorTarget.path);
206
- const trackedFindings = classifyDrift(map, readTrackedRules(settingsObj), {
207
- pluginRootExpansion, unreadableOrigins: [], home: homeDir,
206
+ // AC-FRR-1 (ER #199, review round 1): retirement first, additions planned against the
207
+ // POST-retirement rule set — never against the raw settingsObj directly. See
208
+ // floorReconcile.mjs's own comment on planReconcile for why the other order loses a grant
209
+ // for one cycle across a map restructure.
210
+ const { additionsPlan, retirementPlan } = planReconcile({
211
+ settingsObj, map, pins, pluginRootExpansion, unreadableOrigins: [], home: homeDir,
208
212
  });
209
- floorPlan = planAdditions({ findings: trackedFindings, map, settingsObj, pins });
210
- floorPlan.settingsObj = settingsObj;
213
+ floorPlan = additionsPlan;
214
+ floorRetirementPlan = retirementPlan;
211
215
  print('');
212
- for (const line of renderPlan(floorPlan, { applied: false })) print(line);
216
+ for (const line of renderPlan(floorPlan, {
217
+ applied: false, retirementPlan: floorRetirementPlan, mapEntryCount: map.entries.length,
218
+ })) print(line);
213
219
  } else {
214
220
  // absent settings.json is the CREATE path's business, not this one's — the managed-file
215
221
  // plan above already writes the full floor for it, and racing that would duplicate it
@@ -223,7 +229,9 @@ export async function runCli(argv, { cwd, isTTY, input, output, homeDir, pkgDir
223
229
  // permission floor unattended; --yes must be given EXPLICITLY. This sits above applyPlan
224
230
  // deliberately — a "refused" verdict printed after the scaffold write had already landed reads
225
231
  // as "nothing happened", which is the one thing it must not mean.
226
- if (floorPlan && floorPlan.total > 0 && !isTTY && answers.yes !== true) {
232
+ const floorHasWork = Boolean(floorPlan)
233
+ && (floorPlan.total > 0 || (floorRetirementPlan && floorRetirementPlan.total > 0));
234
+ if (floorHasWork && !isTTY && answers.yes !== true) {
227
235
  throw new RefusalError(
228
236
  'refusing --reconcile-floor without a terminal: pass --yes explicitly to confirm the write',
229
237
  'reconcile-floor',
@@ -252,10 +260,15 @@ export async function runCli(argv, { cwd, isTTY, input, output, homeDir, pkgDir
252
260
  // --reconcile-floor's pre-write refusal exists for.
253
261
  applyGitignorePlan(gitignorePlan);
254
262
 
255
- if (floorPlan && floorPlan.total > 0) {
263
+ if (floorHasWork) {
264
+ // floorPlan.settingsObj is ALREADY post-retirement (planReconcile derived it that way) —
265
+ // applyAdditions composes onto it directly; a second applyRetirements call here would be
266
+ // retiring an object that was never given the rows back in the first place.
256
267
  writeTargetAtomically(floorTarget.path, applyAdditions(floorPlan.settingsObj, floorPlan, { map, pins }));
257
268
  print('');
258
- for (const line of renderPlan(floorPlan, { applied: true })) print(line);
269
+ for (const line of renderPlan(floorPlan, {
270
+ applied: true, retirementPlan: floorRetirementPlan, mapEntryCount: map.entries.length,
271
+ })) print(line);
259
272
  }
260
273
 
261
274
  if (slug) {
package/src/update.mjs CHANGED
@@ -8,11 +8,11 @@
8
8
  import fs from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import { RefusalError, physicalResolve } from './util.mjs';
11
- import { loadMap, buildSettings, classifyDrift } from './permissionFloor.mjs';
11
+ import { loadMap, buildSettings } from './permissionFloor.mjs';
12
12
  import { buildManagedFiles } from './scaffold.mjs';
13
13
  import { planManagedFiles, applyPlan } from './reconcile.mjs';
14
14
  import {
15
- resolveTarget, readTarget, readTrackedRules, planAdditions, applyAdditions, writeTargetAtomically,
15
+ resolveTarget, readTarget, applyAdditions, planReconcile, writeTargetAtomically, renderPlan,
16
16
  } from './floorReconcile.mjs';
17
17
  import { reconcileGitignorePlan, applyGitignorePlan, renderGitignoreRow } from './gitignoreReconcile.mjs';
18
18
  import {
@@ -153,14 +153,18 @@ export async function runUpdate(argv, { cwd, configDir, homeDir, pkgDir, output,
153
153
  // captured plan verbatim in Phase 4 would silently clobber whatever Phase 1 just wrote —
154
154
  // Phase 4 therefore re-reads and recomputes the floor plan fresh, right before it writes.
155
155
  const floorTarget = resolveTarget(physicalRoot);
156
- const previewFloorPlan = floorTarget.present
157
- ? planAdditions({
158
- findings: classifyDrift(map, readTrackedRules(readTarget(floorTarget.path)), {
159
- pluginRootExpansion: [], unreadableOrigins: [], home: homeDir,
160
- }),
161
- map, settingsObj: readTarget(floorTarget.path), pins,
156
+ // AC-FRR-1 (ER #199, review round 1): retirement first, additions planned against the
157
+ // POST-retirement rule set — planReconcile is the ONLY entry point either the preview here or
158
+ // Phase 4 below should use; see its own comment in floorReconcile.mjs. PREVIEW-ONLY: Phase 4
159
+ // re-reads and recomputes fresh right before it writes, for the migration-clobber reason above.
160
+ const previewReconcile = floorTarget.present
161
+ ? planReconcile({
162
+ settingsObj: readTarget(floorTarget.path), map, pins,
163
+ pluginRootExpansion: [], unreadableOrigins: [], home: homeDir,
162
164
  })
163
165
  : null;
166
+ const previewFloorPlan = previewReconcile ? previewReconcile.additionsPlan : null;
167
+ const previewRetirementPlan = previewReconcile ? previewReconcile.retirementPlan : null;
164
168
 
165
169
  // gitignore-block-reconcile (ER #177, AC-GBR-1): PREVIEW-ONLY, same caveat as previewFloorPlan
166
170
  // above — `.gitignore` is not a migration target, but Phase 4 recomputes fresh from disk anyway,
@@ -195,6 +199,9 @@ export async function runUpdate(argv, { cwd, configDir, homeDir, pkgDir, output,
195
199
  for (const f of filePlan) previewLines.push(` [${f.action}] ${f.relPath}`);
196
200
  if (previewFloorPlan) {
197
201
  previewLines.push(` [permission-floor] would add allow=${previewFloorPlan.additions.allow.length}, ask=${previewFloorPlan.additions.ask.length}, deny=${previewFloorPlan.additions.deny.length}`);
202
+ if (previewRetirementPlan && previewRetirementPlan.total > 0) {
203
+ previewLines.push(` [permission-floor] would retire allow=${previewRetirementPlan.retirements.allow.length}, ask=${previewRetirementPlan.retirements.ask.length}`);
204
+ }
198
205
  } else {
199
206
  previewLines.push(' [permission-floor] .claude/settings.json absent — left to the create path');
200
207
  }
@@ -252,16 +259,24 @@ export async function runUpdate(argv, { cwd, configDir, homeDir, pkgDir, output,
252
259
  // migration may have just rewritten this exact file (project scope's settings.json IS the
253
260
  // floor-reconcile target). Applying a stale pre-migration plan here would silently clobber it.
254
261
  let floorPlan = null;
262
+ let floorRetirementPlan = null;
255
263
  const freshFloorTarget = resolveTarget(physicalRoot);
256
264
  if (freshFloorTarget.present) {
257
265
  const settingsObj = readTarget(freshFloorTarget.path);
258
- const findings = classifyDrift(map, readTrackedRules(settingsObj), {
259
- pluginRootExpansion: [], unreadableOrigins: [], home: homeDir,
266
+ // AC-FRR-1 (ER #199, review round 1): the upgrader's own reconcile path — retirement first,
267
+ // additions planned against the POST-retirement rule set (planReconcile; see its comment in
268
+ // floorReconcile.mjs). `floorPlan.settingsObj` comes back already post-retirement, so the
269
+ // write below composes onto it directly — never a second `applyRetirements` call.
270
+ const { additionsPlan, retirementPlan } = planReconcile({
271
+ settingsObj, map, pins, pluginRootExpansion: [], unreadableOrigins: [], home: homeDir,
260
272
  });
261
- floorPlan = planAdditions({ findings, map, settingsObj, pins });
262
- floorPlan.settingsObj = settingsObj;
263
- if (floorPlan.total > 0) {
264
- writeTargetAtomically(freshFloorTarget.path, applyAdditions(settingsObj, floorPlan, { map, pins }));
273
+ floorPlan = additionsPlan;
274
+ floorRetirementPlan = retirementPlan;
275
+ if (floorPlan.total > 0 || floorRetirementPlan.total > 0) {
276
+ writeTargetAtomically(freshFloorTarget.path, applyAdditions(floorPlan.settingsObj, floorPlan, { map, pins }));
277
+ for (const line of renderPlan(floorPlan, {
278
+ applied: true, retirementPlan: floorRetirementPlan, mapEntryCount: map.entries.length,
279
+ })) print(line);
265
280
  }
266
281
  }
267
282
  // Recomputed FRESH from disk, same reasoning as floorPlan just above: never apply a plan
@@ -280,7 +295,8 @@ export async function runUpdate(argv, { cwd, configDir, homeDir, pkgDir, output,
280
295
  }
281
296
 
282
297
  const anyCreated = filePlan.some((f) => f.action === 'create');
283
- const anyFloorAdded = Boolean(floorPlan && floorPlan.total > 0);
298
+ const anyFloorAdded = Boolean(floorPlan && floorPlan.total > 0)
299
+ || Boolean(floorRetirementPlan && floorRetirementPlan.total > 0);
284
300
  const anyGitignoreChanged = Boolean(
285
301
  freshGitignorePlan && (freshGitignorePlan.action === 'converged' || freshGitignorePlan.action === 'appended'),
286
302
  );