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 +11 -0
- package/package.json +2 -2
- package/permission-floor.json +11 -1
- package/src/floorReconcile.mjs +174 -10
- package/src/run.mjs +23 -10
- package/src/update.mjs +31 -15
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.
|
|
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.
|
|
37
|
+
"plugin_version": "1.16.0",
|
|
38
38
|
"pins_researched": "2026-08-02"
|
|
39
39
|
}
|
|
40
40
|
}
|
package/permission-floor.json
CHANGED
|
@@ -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.
|
|
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",
|
package/src/floorReconcile.mjs
CHANGED
|
@@ -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
|
|
11
|
-
//
|
|
12
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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,
|
|
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
|
-
|
|
207
|
-
|
|
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 =
|
|
210
|
-
|
|
213
|
+
floorPlan = additionsPlan;
|
|
214
|
+
floorRetirementPlan = retirementPlan;
|
|
211
215
|
print('');
|
|
212
|
-
for (const line of renderPlan(floorPlan, {
|
|
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
|
-
|
|
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 (
|
|
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, {
|
|
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
|
|
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,
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
259
|
-
|
|
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 =
|
|
262
|
-
|
|
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
|
);
|