create-agentic-workspace 0.17.5 → 0.18.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
@@ -52,7 +52,7 @@ it does invoke `claude` (bounded by a closed allowlist — see its own README).
52
52
  - `.foundry/permissions.yaml` is a SEED, not a managed file: written once when absent (an empty,
53
53
  commented starter for standing grants — see the plugin's `docs/how-to/standing-grants.md`),
54
54
  then operator-owned — reported `[kept]`, never compared, never drifted, never written again.
55
- - The Amendments backfill: every `specs/**/feat-*.md` with a normative region and no
55
+ - The Amendments backfill: every `specs/**/*.md` (any basename, since v1.17.1) with a normative region and no
56
56
  `## Amendments` section after it gets the empty section `/foundry:amend` requires, reported as
57
57
  one row (`[amendments] backfilled N of M specs (K already present, J skipped: no normative
58
58
  region)`). Outside the hashed region, so no `spec_sha256` moves; already-present, marker-less and
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-agentic-workspace",
3
- "version": "0.17.5",
3
+ "version": "0.18.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",
@@ -35,7 +35,7 @@
35
35
  "marketplace_name": "agentic-foundry",
36
36
  "marketplace_repo": "lukasrepublic/agentic-foundry",
37
37
  "plugin_name": "foundry",
38
- "plugin_version": "1.17.5",
38
+ "plugin_version": "1.18.0",
39
39
  "pins_researched": "2026-08-02"
40
40
  }
41
41
  }
@@ -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.5",
4
+ "generated_for_plugin_version": "1.18.0",
5
5
  "entries": [
6
6
  {
7
7
  "rule": "Bash(~/.claude/plugins/cache/*/foundry/*/scripts/foundry-acceptance-contract-validate.py:*)",
@@ -233,11 +233,6 @@
233
233
  "tier": "allow",
234
234
  "rationale": "routine-wake (autonomy-continuation R3, AC-RWK-1): validates <programme>/--deck-name as [a-z0-9-]+ slugs and prints the self-contained Routine prompt plus the /schedule recipe and prerequisites; reads no corpus state and writes nothing"
235
235
  },
236
- {
237
- "rule": "Bash(claude plugin tag:*)",
238
- "tier": "ask",
239
- "rationale": "a tree path arrives between the verb and --push, not prefix-keyable at finer grain; a dry-run also prompts"
240
- },
241
236
  {
242
237
  "rule": "Bash(~/.claude/plugins/cache/*/foundry/*/scripts/foundry-authorize.py:*)",
243
238
  "tier": "ask",
@@ -358,11 +353,6 @@
358
353
  "tier": "deny",
359
354
  "rationale": "absolute anti-pattern: bypasses required reviews/checks on the merge floor"
360
355
  },
361
- {
362
- "rule": "Bash(git push --force:*)",
363
- "tier": "deny",
364
- "rationale": "absolute anti-pattern: force-push; belt-and-braces behind the hook-enforced floor (R3)"
365
- },
366
356
  {
367
357
  "rule": "Bash(tofu destroy -auto-approve:*)",
368
358
  "tier": "deny",
@@ -103,7 +103,7 @@ export function planAmendmentsBackfill({ physicalRoot }) {
103
103
  else if (verdict === 'no-marker') skipped += 1;
104
104
  else toAppend.push(abs);
105
105
  }
106
- return { toAppend, present, skipped, symlinks, total: files.length, applied: false };
106
+ return { physicalRoot, toAppend, present, skipped, symlinks, total: files.length, applied: false };
107
107
  }
108
108
 
109
109
  /** The bytes to append for a given current text: the block, preceded by one newline when the text
@@ -119,32 +119,43 @@ export function appendBytesFor(text) {
119
119
  * gained the section between plan and apply is left alone. Returns the count actually written. */
120
120
  export function applyAmendmentsBackfill(plan) {
121
121
  let written = 0;
122
+ // v1.18.0 (AC-V118C-8): every planned spec lands in exactly one bucket — written, gained the
123
+ // section since the plan (present), or failed — so backfilled + present + skipped + failed ==
124
+ // total and the post-upgrade arithmetic check can never refuse on an uncounted file.
125
+ let failed = 0;
126
+ const writtenPaths = [];
122
127
  for (const abs of plan.toAppend) {
123
128
  let text;
124
129
  try {
125
130
  text = fs.readFileSync(abs, 'utf-8');
126
131
  } catch {
132
+ failed += 1;
127
133
  continue;
128
134
  }
129
- if (classifySpec(text) !== 'absent') continue;
135
+ if (classifySpec(text) !== 'absent') { plan.present += 1; continue; }
130
136
  const tmp = `${abs}.amendments-backfill.tmp`;
131
137
  // v1.17.1 security review Risk 2: `wx` refuses to write through a planted sibling — a symlink
132
138
  // or a leftover file at the temp path means this spec is skipped, never written elsewhere.
133
139
  try {
134
140
  fs.writeFileSync(tmp, text + appendBytesFor(text), { encoding: 'utf-8', flag: 'wx' });
135
141
  } catch {
142
+ failed += 1;
136
143
  continue;
137
144
  }
138
145
  try {
139
146
  fs.renameSync(tmp, abs);
140
147
  } catch {
141
148
  fs.rmSync(tmp, { force: true });
149
+ failed += 1;
142
150
  continue;
143
151
  }
144
152
  written += 1;
153
+ if (plan.physicalRoot) writtenPaths.push(path.relative(plan.physicalRoot, abs).split(path.sep).join('/'));
145
154
  }
146
155
  plan.applied = true;
147
156
  plan.written = written;
157
+ plan.failed = failed;
158
+ plan.writtenPaths = writtenPaths;
148
159
  return written;
149
160
  }
150
161
 
@@ -154,7 +165,8 @@ export function applyAmendmentsBackfill(plan) {
154
165
  export function renderAmendmentsRow(plan) {
155
166
  if (!plan || (plan.total === 0 && plan.symlinks.length === 0)) return null;
156
167
  const n = plan.applied ? plan.written : plan.toAppend.length;
157
- let row = ` [amendments] backfilled ${n} of ${plan.total} specs (${plan.present} already present, ${plan.skipped} skipped: no normative region)`;
168
+ const verb = plan.applied ? 'backfilled' : 'would backfill';
169
+ let row = ` [amendments] ${verb} ${n} of ${plan.total} specs (${plan.present} already present, ${plan.skipped} skipped: no normative region${plan.failed ? `, ${plan.failed} FAILED to write` : ''})`;
158
170
  if (plan.symlinks.length > 0) {
159
171
  row += `; ${plan.symlinks.length} symlinked spec file(s) not touched: ${plan.symlinks.map((p) => path.basename(p)).join(', ')}`;
160
172
  }
@@ -32,7 +32,7 @@ import fs from 'node:fs';
32
32
  import os from 'node:os';
33
33
  import path from 'node:path';
34
34
  import { confinedJoin, RefusalError } from './util.mjs';
35
- import { buildSettings, classifyDrift } from './permissionFloor.mjs';
35
+ import { PROJECTED_TIERS, buildSettings, classifyDrift } from './permissionFloor.mjs';
36
36
 
37
37
  /** The drift classes whose findings name a rule this module may ADD. Everything else the
38
38
  * classifier can emit is report-only: blanket-allow, ask-shadowed, ask-shadowed-ceremony and
@@ -147,6 +147,8 @@ export function planAdditions({ findings, map, settingsObj, pins }) {
147
147
  for (const f of findings) {
148
148
  if (!ADDITIVE_CLASSES.includes(f.class)) continue;
149
149
  const tier = tierOfRule.get(f.rule);
150
+ // v1.18.0: only the projected tier (deny) is ever written; script rows are a registry
151
+ if (!PROJECTED_TIERS.includes(tier)) continue;
150
152
  // the tier comes from the map; a finding naming a rule the map does not declare is not ours
151
153
  if (tier === undefined || tier !== TIER_OF_CLASS[f.class]) continue;
152
154
  if (tier === 'allow' && withheldAllow) continue;
@@ -197,7 +199,9 @@ function escapeLiteral(s) {
197
199
  * a second hardcoded copy of it — the same one-source-of-truth reasoning foldRegexFromGlob already
198
200
  * documents for the addition side. */
199
201
  function floorRootShapeRe(pluginRootGlob) {
200
- return new RegExp(`^Bash\\(${escapeLiteral(pluginRootGlob)}/scripts/(${ROOT_SHAPE_NAME_RE})(?: (.+))?:\\*\\)$`);
202
+ // v1.18.0: the `:*` suffix is optional — earlier releases also wrote a bare row (the doctor's
203
+ // `Bash(<glob>/scripts/foundry-doctor.py)`), and retirement must take that back too.
204
+ return new RegExp(`^Bash\\(${escapeLiteral(pluginRootGlob)}/scripts/(${ROOT_SHAPE_NAME_RE})(?: (.+?))?(?::\\*)?\\)$`);
201
205
  }
202
206
 
203
207
  /** Parse `rule` against the floor's own root-glob shape (the same shape `buildSettings` writes).
@@ -234,7 +238,7 @@ function floorPinnedShapeRe(pluginRootGlob) {
234
238
  seen += 1;
235
239
  return seen === stars ? '(\\*|\\d+\\.\\d+\\.\\d+[A-Za-z0-9.+-]*)' : '(\\*|[A-Za-z0-9_-]+)';
236
240
  });
237
- return new RegExp(`^Bash\\(${src}/scripts/(${ROOT_SHAPE_NAME_RE})(?: (.+))?:\\*\\)$`);
241
+ return new RegExp(`^Bash\\(${src}/scripts/(${ROOT_SHAPE_NAME_RE})(?: (.+?))?(?::\\*)?\\)$`);
238
242
  }
239
243
 
240
244
  /** Parse `rule` as a version-/marketplace-PINNED variant of the floor's own row shape. Returns
@@ -290,46 +294,45 @@ export function askRootKeys(settingsObj, map) {
290
294
  * retirement only narrows a grant or a prompt, and a deny row read back as "extra" is, if anything,
291
295
  * a reason to leave it exactly where the operator (or an earlier release) put it. Returns
292
296
  * `{ retirements: { allow: [...], ask: [...] }, total }`. */
293
- export function planRetirements({ settingsObj, map, askCoveredBy = null }) {
294
- const shipped = shippedRootNames(map);
295
- const shippedAsk = new Set();
296
- for (const e of map.entries) {
297
- if (e.tier !== 'ask') continue;
298
- const parsed = parseFloorRootShape(e.rule, map.plugin_root_glob);
299
- if (parsed) shippedAsk.add(rootNameKey(parsed));
300
- }
301
- // hotfix-v1.17.4 (PR #237 security review Risk 3): for a file this pass does NOT also reconcile
302
- // (`.claude/settings.local.json`), "the wildcard ask row replaces it" is only true when the
303
- // TRACKED file actually carries that wildcard row — so the caller passes the tracked file's ask
304
- // keys (`askRootKeys`) and a pinned `ask` row is retired only when both sets hold the pair.
305
- // `null` (the tracked-file call sites, where the same pass adds the wildcard row) keeps the
306
- // v1.17.3 rule unchanged.
307
- const askReplaced = (key) => shippedAsk.has(key) && (askCoveredBy === null || askCoveredBy.has(key));
308
- const retirements = { allow: [], ask: [] };
297
+ /** Rows an earlier release of the floor wrote verbatim and v1.18.0 takes back (AC-V118A-2/-4): the
298
+ * broad force-push deny (the git-discipline hook is the floor — it refuses protected targets and
299
+ * allows feature branches, which this row wrongly refused), the release-ceremony ask row, and the
300
+ * policy file's self-guard deny pair (operator decision 2026-09-25). Literal text only. */
301
+ export const RETIRED_FLOOR_LITERALS = Object.freeze({
302
+ allow: Object.freeze([]),
303
+ ask: Object.freeze(['Bash(claude plugin tag:*)']),
304
+ deny: Object.freeze([
305
+ 'Bash(git push --force:*)',
306
+ 'Edit(.foundry/permissions.yaml)',
307
+ 'Write(.foundry/permissions.yaml)',
308
+ ]),
309
+ });
310
+
311
+ /** v1.18.0 (AC-V118A-2): every `allow`/`ask` row shaped exactly like the floor's own script rows —
312
+ * wildcard (`<plugin_root_glob>/scripts/<x>`) or version-/marketplace-pinned — is retired, whether
313
+ * or not the script still ships: those rows never matched a real invocation (measured), and the
314
+ * plugin's scripts are allowed by the PreToolUse hook instead. Plus the RETIRED_FLOOR_LITERALS.
315
+ * Any other shape — an operator's own rule, a different prefix, a hand-written bare path — is never
316
+ * touched (AC-FRR-2). Retiring an `ask` row cannot turn a prompt into a grant the operator did not
317
+ * choose: the scripts it named are allowed by design (operator decision: no foundry script prompts).
318
+ * `askCoveredBy` is accepted for call-site compatibility and ignored. */
319
+ export function planRetirements({ settingsObj, map, askCoveredBy = null }) { // eslint-disable-line no-unused-vars
320
+ const retirements = { allow: [], ask: [], deny: [] };
309
321
  const perms = (settingsObj && settingsObj.permissions) || {};
310
322
  for (const tier of ['allow', 'ask']) {
311
323
  for (const rule of perms[tier] || []) {
312
- const parsed = parseFloorRootShape(rule, map.plugin_root_glob);
313
- if (parsed) {
314
- if (!shipped.has(rootNameKey(parsed))) retirements[tier].push(rule);
315
- continue;
316
- }
317
- // hotfix-v1.17.3: a version-/marketplace-pinned variant of the floor's own shape. An `allow`
318
- // row is retired whether or not the script still ships — the wildcard row covers a shipped
319
- // script (added in this same pass when absent), and a pinned row for a gone script is exactly
320
- // the ER #199 class. An `ask` row is retired ONLY when the shipped map declares the same
321
- // (name, sub) at `ask`, so the wildcard `ask` row replaces it (PR #233 security review Risk 1:
322
- // `ask` beats `allow`, so dropping an ask row under a broader allow would turn a prompt into a
323
- // silent grant — a widening this pass must never perform).
324
- const pinned = parseFloorPinnedShape(rule, map.plugin_root_glob);
325
- if (pinned) {
326
- if (tier === 'allow') retirements[tier].push(rule);
327
- else if (askReplaced(rootNameKey(pinned))) retirements[tier].push(rule);
324
+ if (typeof rule !== 'string') continue;
325
+ if (parseFloorRootShape(rule, map.plugin_root_glob) || parseFloorPinnedShape(rule, map.plugin_root_glob)) {
326
+ retirements[tier].push(rule);
328
327
  }
329
- // any other shape -> never touched (AC-FRR-2)
330
328
  }
331
329
  }
332
- const total = retirements.allow.length + retirements.ask.length;
330
+ for (const tier of ['allow', 'ask', 'deny']) {
331
+ for (const rule of perms[tier] || []) {
332
+ if (RETIRED_FLOOR_LITERALS[tier].includes(rule) && !retirements[tier].includes(rule)) retirements[tier].push(rule);
333
+ }
334
+ }
335
+ const total = retirements.allow.length + retirements.ask.length + retirements.deny.length;
333
336
  return { retirements, total };
334
337
  }
335
338
 
@@ -342,8 +345,8 @@ export function planRetirements({ settingsObj, map, askCoveredBy = null }) {
342
345
  export function applyRetirements(settingsObj, retirementPlan) {
343
346
  const next = { ...settingsObj };
344
347
  const perms = { ...(settingsObj.permissions || {}) };
345
- for (const tier of ['allow', 'ask']) {
346
- const toRemove = new Set(retirementPlan.retirements[tier]);
348
+ for (const tier of ['allow', 'ask', 'deny']) {
349
+ const toRemove = new Set(retirementPlan.retirements[tier] || []);
347
350
  if (toRemove.size === 0) continue;
348
351
  const existing = Array.isArray(perms[tier]) ? perms[tier] : [];
349
352
  perms[tier] = existing.filter((rule) => !toRemove.has(rule));
@@ -497,18 +500,21 @@ export function writeTargetAtomically(targetPath, obj) {
497
500
  export function renderPlan(plan, { applied, retirementPlan = null, mapEntryCount = null }) {
498
501
  const lines = [];
499
502
  const verb = applied ? 'added' : 'would add';
503
+ const rverb = applied ? 'retired' : 'would retire';
504
+ // v1.18.0 (AC-V118A-7): every row names its file and its tier, so no reader can mistake which
505
+ // file holds which tier (the 2026-09-25 misread: an ask row relayed as a deny).
500
506
  for (const tier of ['allow', 'ask', 'deny']) {
501
- for (const rule of plan.additions[tier]) lines.push(` [${tier}] ${rule}`);
507
+ for (const rule of plan.additions[tier]) lines.push(` [${applied ? 'added' : 'would add'}] .claude/settings.json ${tier}: ${rule}`);
502
508
  }
503
509
  if (retirementPlan) {
504
- for (const tier of ['allow', 'ask']) {
505
- for (const rule of retirementPlan.retirements[tier]) lines.push(` [retired] ${rule}`);
510
+ for (const tier of ['allow', 'ask', 'deny']) {
511
+ for (const rule of retirementPlan.retirements[tier] || []) lines.push(` [${rverb}] .claude/settings.json ${tier}: ${rule}`);
506
512
  }
507
513
  }
508
- let summary = `permission-floor reconcile: ${verb} ` +
514
+ let summary = `permission-floor reconcile (.claude/settings.json): ${verb} ` +
509
515
  ['allow', 'ask', 'deny'].map((t) => `${t}=${plan.additions[t].length}`).join(', ');
510
- if (retirementPlan && typeof mapEntryCount === 'number') {
511
- summary += ` — ${plan.total} added, ${retirementPlan.total} retired, ${mapEntryCount - plan.total} unchanged`;
516
+ if (retirementPlan) {
517
+ summary += `; ${rverb} ` + ['allow', 'ask', 'deny'].map((t) => `${t}=${(retirementPlan.retirements[t] || []).length}`).join(', ');
512
518
  }
513
519
  lines.push(summary);
514
520
  if (plan.pin.state === 'absent') {
@@ -4,7 +4,6 @@
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';
8
7
  import os from 'node:os';
9
8
 
10
9
  /** The one map schema_version this build understands. A map declaring anything else is refused
@@ -210,15 +209,19 @@ export const DRIFT_CLASSES = Object.freeze([
210
209
 
211
210
  /** Build the settings.json object the CLI writes, verbatim from the bundled map plus the
212
211
  * marketplace/plugin pins (AC-BCL-4). */
212
+ /** v1.18.0 (AC-V118A-2): the ONLY tier the floor writes into settings. Measured 2026-09-25 with live
213
+ * `claude -p` runs: no Bash rule naming a script path matches (not the floor's
214
+ * plugin-cache glob shape, not even an exact absolute path), so the
215
+ * allow rows granted nothing and the ask rows gated nothing. The plugin's own scripts are allowed by
216
+ * the `foundry-plugin-scripts-allow.py` PreToolUse hook instead, and the map's script rows stay as the
217
+ * closed-world REGISTRY of every script (AC-PFM-2), never projected. */
218
+ export const PROJECTED_TIERS = Object.freeze(['deny']);
219
+
213
220
  export function buildSettings(map, pins) {
214
221
  const byTier = { allow: [], ask: [], deny: [] };
215
222
  for (const e of map.entries) {
216
- byTier[e.tier].push(e.rule);
223
+ if (PROJECTED_TIERS.includes(e.tier)) byTier[e.tier].push(e.rule);
217
224
  }
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);
222
225
  // THE PINNED LITERAL — SUPERSEDED (feat-foundry-installer-unpinning, AC-IUP-3). This block used
223
226
  // to read (AC-BCL-4(b), contract v1.2 — PR #61 security review Block 1), verbatim:
224
227
  //
@@ -278,7 +281,8 @@ export function buildSettings(map, pins) {
278
281
  * rationale (AC-BCL-3). */
279
282
  export function renderCapabilityLines(map) {
280
283
  const lines = [];
281
- for (const tier of ['allow', 'ask', 'deny']) {
284
+ lines.push(" [allow] the plugin's own scripts — by the plugin's PreToolUse hook, not by settings rules");
285
+ for (const tier of PROJECTED_TIERS) {
282
286
  const entries = map.entries.filter((e) => e.tier === tier);
283
287
  if (entries.length === 0) continue;
284
288
  lines.push(` [${tier}] (${entries.length} rules)`);
package/src/run.mjs CHANGED
@@ -9,7 +9,7 @@ import { QUESTION_TABLE } from './questions.mjs';
9
9
  import { parseArgv, renderHelp } from './argv.mjs';
10
10
  import { resolveAnswers, isYesMode } from './answers.mjs';
11
11
  import { RefusalError, physicalResolve, isNonEmptyDir } from './util.mjs';
12
- import { loadMap, buildSettings, classifyDrift } from './permissionFloor.mjs';
12
+ import { PROJECTED_TIERS, loadMap, buildSettings, classifyDrift } from './permissionFloor.mjs';
13
13
  import { buildManagedFiles, DECLARED_PATH_SET } from './scaffold.mjs';
14
14
  import { planManagedFiles, applyPlan, exitCodeForPlan } from './reconcile.mjs';
15
15
  import { renderPreview, TRUST_HANDOFF_TEXT } from './preview.mjs';
@@ -20,7 +20,6 @@ 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';
24
23
 
25
24
  export { DECLARED_PATH_SET };
26
25
 
@@ -254,18 +253,8 @@ export async function runCli(argv, { cwd, isTTY, input, output, homeDir, pkgDir
254
253
  // permission floor unattended; --yes must be given EXPLICITLY. This sits above applyPlan
255
254
  // deliberately — a "refused" verdict printed after the scaffold write had already landed reads
256
255
  // 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
- }
266
256
  const floorHasWork = Boolean(floorPlan)
267
- && (floorPlan.total > 0 || (floorRetirementPlan && floorRetirementPlan.total > 0)
268
- || selfGuardPreview.missing.length > 0);
257
+ && (floorPlan.total > 0 || (floorRetirementPlan && floorRetirementPlan.total > 0));
269
258
  if (floorHasWork && !isTTY && answers.yes !== true) {
270
259
  throw new RefusalError(
271
260
  'refusing --reconcile-floor without a terminal: pass --yes explicitly to confirm the write',
@@ -308,17 +297,6 @@ export async function runCli(argv, { cwd, isTTY, input, output, homeDir, pkgDir
308
297
  applied: true, retirementPlan: floorRetirementPlan, mapEntryCount: map.entries.length,
309
298
  })) print(line);
310
299
  }
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
- }
322
300
  // AFTER the floor write above: that write serialises a settings object read before this
323
301
  // point, so wiring the statusLine keys first would have been overwritten by it. The wiring
324
302
  // re-reads settings.json itself and adds only the absent keys (AC-SLW-2).
@@ -357,7 +335,10 @@ export async function runCli(argv, { cwd, isTTY, input, output, homeDir, pkgDir
357
335
  // classification stays above the write and over the tracked file alone — consent has to be
358
336
  // informed by what WILL be written, which is a different question from what remains after.
359
337
  const { effective, unreadable } = readEffectiveRules(physicalRoot);
360
- const findings = classifyDrift(map, effective, {
338
+ // v1.18.0: classify only the projected tier (deny) — the map's script rows are a registry the
339
+ // floor never writes, so reporting them absent would read as a failed write.
340
+ const projectedMap = { ...map, entries: map.entries.filter((e) => PROJECTED_TIERS.includes(e.tier)) };
341
+ const findings = classifyDrift(projectedMap, effective, {
361
342
  pluginRootExpansion, unreadableOrigins: unreadable, home: homeDir,
362
343
  });
363
344
  if (findings.length > 0) {
package/src/update.mjs CHANGED
@@ -7,6 +7,7 @@
7
7
  // machinery run.mjs's create path already uses (cli/src/reconcile.mjs, cli/src/floorReconcile.mjs).
8
8
  import fs from 'node:fs';
9
9
  import path from 'node:path';
10
+ import os from 'node:os';
10
11
  import { RefusalError, physicalResolve, confinedJoin } from './util.mjs';
11
12
  import { loadMap, buildSettings } from './permissionFloor.mjs';
12
13
  import { buildManagedFiles } from './scaffold.mjs';
@@ -18,7 +19,6 @@ import { reconcileGitignorePlan, applyGitignorePlan, renderGitignoreRow } from '
18
19
  import { planAmendmentsBackfill, applyAmendmentsBackfill, renderAmendmentsRow } from './amendmentsBackfill.mjs';
19
20
  import { buildUpgradeReport, writeUpgradeReport, installedVersionBefore, versionOrNull, NEXT_LINE } from './upgradeReport.mjs';
20
21
  import { planStatuslineWiring, applyStatuslineWiring, renderStatuslineRows, statuslineChanged } from './statuslineWiring.mjs';
21
- import { policyPresent, missingSelfGuardDeny, applySelfGuardDeny, renderSelfGuardRow, selfGuardShapeOk } from './selfGuardDeny.mjs';
22
22
  import { loadRetiredCatalogue, planRetiredArtifacts, applyRetiredArtifacts, renderRetiredArtifactRows } from './retiredArtifacts.mjs';
23
23
  import { planRetirements, applyRetirements, askRootKeys } from './floorReconcile.mjs';
24
24
  import {
@@ -32,14 +32,18 @@ import { runCleanupPhase } from './cleanup.mjs';
32
32
 
33
33
  export { ALLOWED_CLAUDE_SUBCOMMANDS };
34
34
 
35
+ /** v1.18.0 (AC-V118C-3): managed paths with their own reconciler on the update path. */
36
+ const SEPARATELY_RECONCILED = new Set(['.claude/settings.json', '.gitignore']);
37
+
35
38
  /** The update entry point's OWN small flag table (Clarifications: "the update entry point carries
36
39
  * its own small flag table, disjoint from the wizard's") — deliberately NOT cli/src/argv.mjs +
37
40
  * QUESTION_TABLE, which is denied to the sibling cleanup atom and whose flag set is derived from
38
41
  * the wizard's prompts, not this command's. `--cleanup` is the cleanup atom's own opt-in. */
39
42
  export function parseUpdateArgv(argv) {
40
- const values = { cleanup: false, help: false };
43
+ const values = { cleanup: false, help: false, dryRun: false };
41
44
  for (const tok of argv) {
42
45
  if (tok === '--cleanup') values.cleanup = true;
46
+ else if (tok === '--dry-run') values.dryRun = true;
43
47
  else if (tok === '--help') values.help = true;
44
48
  else throw new RefusalError(`unknown flag: ${tok}`, tok);
45
49
  }
@@ -119,11 +123,13 @@ export async function runUpdate(argv, { cwd, configDir, homeDir, pkgDir, output,
119
123
 
120
124
  if (flags.help) {
121
125
  print([
122
- 'Usage: update-agentic-workspace [--cleanup] [--help]',
126
+ 'Usage: update-agentic-workspace [--dry-run] [--cleanup] [--help]',
123
127
  '',
124
128
  ' --cleanup Also prune superseded plugin-cache versions and remove a stale or',
125
129
  ' duplicate marketplace registration (previewed either way; only',
126
130
  ' removed under this flag). Off by default.',
131
+ ' --dry-run Plan and print everything; write nothing and run no claude command.',
132
+ ' Exits 0 or 2 exactly as the real run would.',
127
133
  ' --help Show this help and exit.',
128
134
  ].join('\n'));
129
135
  return { exitCode: 0, output: lines.join('\n') };
@@ -182,6 +188,15 @@ export async function runUpdate(argv, { cwd, configDir, homeDir, pkgDir, output,
182
188
  settingsBytes,
183
189
  });
184
190
  const filePlan = planManagedFiles(managedFiles);
191
+ // v1.18.0 (AC-V118C-3): on the UPDATE path, a present file is never "drifted" just because it
192
+ // is not byte-identical to a fresh scaffold. `.claude/settings.json` and `.gitignore` each have
193
+ // their own reconciler below (the whole-file compare could never pass: it has no statusLine key
194
+ // and no operator rows, so every run exited 2); CLAUDE.md and foundry-project.json are
195
+ // operator-owned after creation; the framework READMEs are create-only and reported `kept`.
196
+ for (const f of filePlan) {
197
+ if (f.action !== 'drifted') continue;
198
+ f.action = SEPARATELY_RECONCILED.has(f.relPath) ? 'reconciled' : 'kept';
199
+ }
185
200
 
186
201
  // This is a PREVIEW-ONLY computation: `.claude/settings.json` is also `project` scope's
187
202
  // settings file, and Phase 1's migration (below) may write to that SAME path. Applying THIS
@@ -208,7 +223,11 @@ export async function runUpdate(argv, { cwd, configDir, homeDir, pkgDir, output,
208
223
  const previewGitignorePlan = reconcileGitignorePlan({ physicalRoot, templatesDir });
209
224
 
210
225
  // ── AC-UAW-7: the preview, before the first `claude` invocation and the first write ─────────
211
- const previewLines = ['The following claude invocations will be made:'];
226
+ const previewLines = [
227
+ flags.dryRun ? 'DRY RUN — the plan below is printed; nothing will be written and no claude command will run.'
228
+ : 'PLAN — printed before anything is written (rows below describe what the run will do):',
229
+ 'The following claude invocations will be made:',
230
+ ];
212
231
  for (const { scopeSnap, trigger } of migrations) {
213
232
  for (const args of migrationActions(trigger, {
214
233
  scope: scopeSnap.name, marketplaceName, marketplaceRepo, pluginKey,
@@ -233,16 +252,10 @@ export async function runUpdate(argv, { cwd, configDir, homeDir, pkgDir, output,
233
252
  previewLines.push('The following workspace paths will be reconciled (never-clobber):');
234
253
  for (const f of filePlan) previewLines.push(` [${f.action}] ${f.relPath}`);
235
254
  if (previewFloorPlan) {
236
- previewLines.push(` [permission-floor] would add allow=${previewFloorPlan.additions.allow.length}, ask=${previewFloorPlan.additions.ask.length}, deny=${previewFloorPlan.additions.deny.length}`);
255
+ previewLines.push(` [permission-floor] .claude/settings.json: would add deny=${previewFloorPlan.additions.deny.length} (the floor writes only deny rows; foundry scripts are allowed by the plugin's PreToolUse hook)`);
237
256
  if (previewRetirementPlan && previewRetirementPlan.total > 0) {
238
- previewLines.push(` [permission-floor] would retire allow=${previewRetirementPlan.retirements.allow.length}, ask=${previewRetirementPlan.retirements.ask.length}`);
239
- }
240
- // hotfix-v1.17.3 (PR #233 review Risk 4): the self-guard pair is previewed like the floor.
241
- if (policyPresent(physicalRoot) || filePlan.some((f) => f.seed && f.action === 'create')) {
242
- const cur0 = readTarget(floorTarget.path);
243
- const prow = renderSelfGuardRow(physicalRoot, missingSelfGuardDeny(cur0).length, { shapeOk: selfGuardShapeOk(cur0), applied: false });
244
- if (prow) previewLines.push(prow);
245
- else previewLines.push(' [permissions] would add self-guard deny rules (2): Edit/Write on .foundry/permissions.yaml (with the seed)');
257
+ const r = previewRetirementPlan.retirements;
258
+ previewLines.push(` [permission-floor] .claude/settings.json: would retire allow=${r.allow.length}, ask=${r.ask.length}, deny=${(r.deny || []).length}`);
246
259
  }
247
260
  } else {
248
261
  previewLines.push(' [permission-floor] .claude/settings.json absent — left to the create path');
@@ -264,12 +277,23 @@ export async function runUpdate(argv, { cwd, configDir, homeDir, pkgDir, output,
264
277
  const lp = planLocalRetirement({
265
278
  physicalRoot, map, trackedSettingsObj: floorTarget.present ? readTarget(floorTarget.path) : null,
266
279
  });
267
- if (lp.plan && lp.plan.total > 0) previewLines.push(` [permission-floor] ${lp.rel}: would retire allow=${lp.plan.retirements.allow.length}, ask=${lp.plan.retirements.ask.length} (never adds)`);
280
+ if (lp.plan && lp.plan.total > 0) previewLines.push(` [permission-floor] ${lp.rel}: would retire allow=${lp.plan.retirements.allow.length}, ask=${lp.plan.retirements.ask.length}, deny=${(lp.plan.retirements.deny || []).length} (never adds)`);
268
281
  else if (lp.error) previewLines.push(` [permission-floor] ${lp.rel}: would not be reconciled (${lp.error})`);
269
282
  }
270
283
  print(previewLines.join('\n'));
284
+ if (flags.dryRun) {
285
+ print('');
286
+ print('dry run: nothing was written and no claude command was run.');
287
+ const gitignoreRefusedPreview = previewGitignorePlan && previewGitignorePlan.action === 'refused';
288
+ return { exitCode: filePlan.some((f) => f.action === 'drifted') || gitignoreRefusedPreview ? 2 : 0, output: lines.join('\n') };
289
+ }
271
290
 
272
291
  const env = { ...spawnEnv, CLAUDE_CONFIG_DIR: configDir };
292
+ // v1.18.0 (AC-V118C-1): every tracked path this run writes or removes, for the report — the
293
+ // post-upgrade skill commits exactly these (nothing the updater writes may stay uncommitted).
294
+ const written = [];
295
+ const removed = [];
296
+ const wrote = (relPath, kind) => { if (!written.some((w) => w.path === relPath)) written.push({ path: relPath, kind }); };
273
297
  const phases = [];
274
298
 
275
299
  // ── Phase 1: marketplace refresh ─────────────────────────────────────────────────────────────
@@ -277,6 +301,7 @@ export async function runUpdate(argv, { cwd, configDir, homeDir, pkgDir, output,
277
301
  for (const { scopeSnap, trigger } of migrations) {
278
302
  migrateScope({ scopeSnap, trigger, marketplaceName, marketplaceRepo, pluginKey, env, cwd, claudeBin });
279
303
  anyMigrated = true;
304
+ if (scopeSnap.name === 'project') wrote('.claude/settings.json', 'marketplace-migration');
280
305
  }
281
306
  const manifestBefore = readMarketplaceManifest(configDir, marketplaceName);
282
307
  const beforeEntry = manifestBefore.present ? pluginEntryOf(manifestBefore.doc, pins.plugin_name) : null;
@@ -302,7 +327,15 @@ export async function runUpdate(argv, { cwd, configDir, homeDir, pkgDir, output,
302
327
  for (const scopeName of enabledScopes) {
303
328
  runPluginUpdate({ scope: scopeName, pluginKey, env, cwd, claudeBin });
304
329
  }
305
- phases.push({ name: 'plugin-update', verdict: refreshed ? 'changed' : 'already current' });
330
+ // v1.18.0 (AC-V118C-5): the verdict is what THIS workspace has installed, read back from the
331
+ // platform's own registry — never whether the marketplace clone moved in this run (a machine's
332
+ // second workspace read "already current" whatever it installed).
333
+ const installedAfter = installedVersionBefore(readInstalledPluginsRegistry(configDir), pluginKey, cwd);
334
+ phases.push({
335
+ name: 'plugin-update',
336
+ verdict: installedAfter && installedAfter !== installedBefore ? 'changed' : 'already current',
337
+ ...(installedAfter ? {} : { reason: 'installed version unreadable from installed_plugins.json' }),
338
+ });
306
339
 
307
340
  // ── Phase 3: cleanup (sibling atom; always previewed, only acts under --cleanup) ────────────
308
341
  const cleanupScopeDescriptors = scopes; // same {name, settingsPath} pairs, unresolved-required
@@ -315,6 +348,7 @@ export async function runUpdate(argv, { cwd, configDir, homeDir, pkgDir, output,
315
348
 
316
349
  // ── Phase 4: reinitialization — managed files, then the additive floor reconcile ───────────
317
350
  applyPlan(filePlan);
351
+ for (const f of filePlan) if (f.action === 'create') wrote(f.relPath, f.seed ? 'seed' : 'managed');
318
352
  // Recomputed FRESH from disk — never the preview-time `previewFloorPlan` — because Phase 1's
319
353
  // migration may have just rewritten this exact file (project scope's settings.json IS the
320
354
  // floor-reconcile target). Applying a stale pre-migration plan here would silently clobber it.
@@ -334,21 +368,11 @@ export async function runUpdate(argv, { cwd, configDir, homeDir, pkgDir, output,
334
368
  floorRetirementPlan = retirementPlan;
335
369
  if (floorPlan.total > 0 || floorRetirementPlan.total > 0) {
336
370
  writeTargetAtomically(freshFloorTarget.path, applyAdditions(floorPlan.settingsObj, floorPlan, { map, pins }));
371
+ wrote('.claude/settings.json', 'permission-floor');
337
372
  for (const line of renderPlan(floorPlan, {
338
373
  applied: true, retirementPlan: floorRetirementPlan, mapEntryCount: map.entries.length,
339
374
  })) print(line);
340
375
  }
341
- // hotfix-v1.17.3 (ER #232): the policy file's self-guard deny pair is framework-owned — converge
342
- // it here, fresh from disk after the floor write, whenever a policy file exists (seeded above or
343
- // kept). Grants stay the compiler's (operator-run) business.
344
- if (policyPresent(physicalRoot)) {
345
- const cur = readTarget(freshFloorTarget.path);
346
- const shapeOk = selfGuardShapeOk(cur);
347
- const missing = missingSelfGuardDeny(cur);
348
- if (shapeOk && missing.length > 0) writeTargetAtomically(freshFloorTarget.path, applySelfGuardDeny(cur));
349
- const row = renderSelfGuardRow(physicalRoot, missing.length, { shapeOk });
350
- if (row) print(row);
351
- }
352
376
  }
353
377
  // Recomputed FRESH from disk, same reasoning as floorPlan just above: never apply a plan
354
378
  // captured before Phases 1-3 ran, even though `.gitignore` is not itself a migration target.
@@ -361,6 +385,7 @@ export async function runUpdate(argv, { cwd, configDir, homeDir, pkgDir, output,
361
385
  if (gitignoreFileAction !== 'create') {
362
386
  freshGitignorePlan = reconcileGitignorePlan({ physicalRoot, templatesDir });
363
387
  applyGitignorePlan(freshGitignorePlan);
388
+ if (freshGitignorePlan.action === 'converged' || freshGitignorePlan.action === 'appended') wrote('.gitignore', 'managed-block');
364
389
  const gitignoreRow = renderGitignoreRow(freshGitignorePlan);
365
390
  if (gitignoreRow) print(gitignoreRow);
366
391
  }
@@ -369,6 +394,7 @@ export async function runUpdate(argv, { cwd, configDir, homeDir, pkgDir, output,
369
394
  // above, applied with the module's own re-classify-before-append guard.
370
395
  const amendmentsPlan = planAmendmentsBackfill({ physicalRoot });
371
396
  applyAmendmentsBackfill(amendmentsPlan);
397
+ for (const rel of amendmentsPlan.writtenPaths || []) wrote(rel, 'amendments-backfill');
372
398
  const amendmentsRow = renderAmendmentsRow(amendmentsPlan);
373
399
  if (amendmentsRow) print(amendmentsRow);
374
400
 
@@ -384,6 +410,8 @@ export async function runUpdate(argv, { cwd, configDir, homeDir, pkgDir, output,
384
410
  // here, after the floor write above, so the settings read is the current one.
385
411
  const statuslinePlan = planStatuslineWiring({ physicalRoot, templatesDir });
386
412
  applyStatuslineWiring(statuslinePlan);
413
+ for (const f of statuslinePlan.files || []) if (f.action === 'create' || f.action === 'converged') wrote(f.rel, 'statusline');
414
+ if ((statuslinePlan.keys || []).some((k) => k.action === 'wired')) wrote('.claude/settings.json', 'statusline');
387
415
  for (const row of renderStatuslineRows(statuslinePlan)) print(row);
388
416
  // retired-artifacts (hotfix-v1.17.4, ER #236): re-planned FRESH from disk; reported every run,
389
417
  // removed only under --cleanup, only catalogued paths of the catalogued kind, never a hook a
@@ -391,6 +419,7 @@ export async function runUpdate(argv, { cwd, configDir, homeDir, pkgDir, output,
391
419
  const retiredPlan = planRetiredArtifacts({ physicalRoot, catalogue: retiredCatalogue });
392
420
  let retiredRemoved = 0;
393
421
  if (flags.cleanup && retiredPlan.present > 0) retiredRemoved = applyRetiredArtifacts(retiredPlan, physicalRoot);
422
+ for (const r of retiredPlan.rows) if (r.state === 'removed') removed.push(r.relPath);
394
423
  for (const row of renderRetiredArtifactRows(retiredPlan, { cleanup: flags.cleanup })) print(row);
395
424
 
396
425
  // settings.local.json (hotfix-v1.17.4): the tracked-file reconcile never reads it, so a
@@ -407,8 +436,9 @@ export async function runUpdate(argv, { cwd, configDir, homeDir, pkgDir, output,
407
436
  });
408
437
  if (lp.plan && lp.plan.total > 0) {
409
438
  writeTargetAtomically(lp.abs, applyRetirements(lp.settingsObj, lp.plan));
439
+ wrote(lp.rel, 'local-retirement');
410
440
  localRetired = lp.plan.total;
411
- for (const tier of ['allow', 'ask']) for (const r of lp.plan.retirements[tier]) print(` [retired] ${lp.rel}: ${r}`);
441
+ for (const tier of ['allow', 'ask', 'deny']) for (const r of lp.plan.retirements[tier] || []) print(` [retired] ${lp.rel} ${tier}: ${r}`);
412
442
  print(` [permission-floor] ${lp.rel}: retired ${localRetired} version-pinned/gone row(s)`);
413
443
  } else if (lp.error) {
414
444
  print(` [permission-floor] ${lp.rel}: not reconciled (${lp.error})`);
@@ -428,7 +458,8 @@ export async function runUpdate(argv, { cwd, configDir, homeDir, pkgDir, output,
428
458
  // completed run (overwritten — a report, not a managed file; `.foundry/*` is gitignored), and
429
459
  // named in the LAST line so the operator's next step is never a guess.
430
460
  const report = buildUpgradeReport({
431
- installedBefore, afterEntry, toPluginVersion: pins.plugin_version, phases, filePlan, amendmentsPlan,
461
+ installedBefore, installedAfter, afterEntry, toPluginVersion: pins.plugin_version, phases, filePlan, amendmentsPlan,
462
+ written, removed, configDir, hostname: os.hostname(),
432
463
  updaterVersion, coreVersion: corePkg.version, updaterPluginVersion: pins.plugin_version,
433
464
  retiredArtifacts: { present: retiredPlan.rows.filter((r) => r.state === 'stale').map((r) => r.relPath), removed: retiredRemoved, refused: retiredPlan.refused },
434
465
  localRetired,
@@ -30,9 +30,9 @@ export function versionOrNull(v) {
30
30
  * the refresh (or null); `filePlan` is the managed-file plan;
31
31
  * `amendmentsPlan` is the applied backfill plan (or null); `phases` is what renderSummary got. */
32
32
  export function buildUpgradeReport({
33
- installedBefore = null, afterEntry, toPluginVersion, phases, filePlan, amendmentsPlan, now = new Date(),
33
+ installedBefore = null, installedAfter = null, afterEntry, toPluginVersion, phases, filePlan, amendmentsPlan, now = new Date(),
34
34
  updaterVersion = null, coreVersion = null, updaterPluginVersion = null,
35
- retiredArtifacts = null, localRetired = 0,
35
+ retiredArtifacts = null, localRetired = 0, written = [], removed = [], configDir = null, hostname = null,
36
36
  }) {
37
37
  const seedRow = (filePlan || []).find((f) => f.seed);
38
38
  return {
@@ -43,7 +43,9 @@ export function buildUpgradeReport({
43
43
  // and already moved by the time Phase 1 reads it. null (no record, unreadable registry, a
44
44
  // first install) makes the skill list only the current version's CHANGELOG section.
45
45
  from_plugin_version: versionOrNull(installedBefore),
46
- to_plugin_version: (afterEntry && versionOrNull(afterEntry.version)) || versionOrNull(toPluginVersion),
46
+ // v1.18.0 (AC-V118C-5): what this workspace has INSTALLED after the run, from the platform's own
47
+ // registry; the marketplace's advertised version only when the registry could not be read.
48
+ to_plugin_version: versionOrNull(installedAfter) || (afterEntry && versionOrNull(afterEntry.version)) || versionOrNull(toPluginVersion),
47
49
  // ER #228 (v1.17.2): WHICH updater ran. A stale npx-cached updater was indistinguishable from a
48
50
  // broken walk; the skill refuses a report whose updater was built for another plugin version.
49
51
  updater_version: versionOrNull(updaterVersion),
@@ -54,8 +56,9 @@ export function buildUpgradeReport({
54
56
  // walk that never opened a file cannot read as a clean pass.
55
57
  amendments: amendmentsPlan
56
58
  ? { backfilled: amendmentsPlan.written ?? 0, present: amendmentsPlan.present, skipped: amendmentsPlan.skipped,
59
+ failed: amendmentsPlan.failed ?? 0,
57
60
  total: amendmentsPlan.total ?? ((amendmentsPlan.written ?? 0) + amendmentsPlan.present + amendmentsPlan.skipped) }
58
- : { backfilled: 0, present: 0, skipped: 0, total: 0 },
61
+ : { backfilled: 0, present: 0, skipped: 0, failed: 0, total: 0 },
59
62
  permissions_policy: seedRow ? (seedRow.action === 'create' ? 'created' : 'kept') : 'absent',
60
63
  drifted: (filePlan || []).filter((f) => f.action === 'drifted').map((f) => f.relPath),
61
64
  // hotfix-v1.17.4: what the sweep found (paths are workspace-relative catalogue entries, never free text)
@@ -63,6 +66,14 @@ export function buildUpgradeReport({
63
66
  ? { present: retiredArtifacts.present, removed: retiredArtifacts.removed, refused: retiredArtifacts.refused }
64
67
  : { present: [], removed: 0, refused: 0 },
65
68
  settings_local_retired: localRetired,
69
+ // v1.18.0 (AC-V118C-1/-2): every path this run wrote or removed — `/foundry:post-upgrade`
70
+ // commits exactly the tracked ones as the first commit of its PR, so nothing the updater writes
71
+ // stays uncommitted; `config_dir`/`hostname` say WHICH environment the versions describe (a
72
+ // container's ~/.claude is its own volume), so a report from another environment is refused.
73
+ written: (written || []).map((w) => ({ path: String(w.path), kind: String(w.kind) })),
74
+ removed: (removed || []).map(String),
75
+ config_dir: typeof configDir === 'string' ? configDir : null,
76
+ hostname: typeof hostname === 'string' ? hostname : null,
66
77
  };
67
78
  }
68
79
 
@@ -31,7 +31,9 @@ RENDERER="foundry-statusline.sh"
31
31
  selected=""
32
32
  # 1. installed_plugins.json (jq when available; the file is small and the key shape is fixed)
33
33
  if [ -r "${CFG}/plugins/installed_plugins.json" ] && command -v jq >/dev/null 2>&1; then
34
- ip="$(jq -r '(.plugins."foundry@agentic-foundry" // ."foundry@agentic-foundry" // []) | (if type=="array" then .[0] else . end) | (.installPath // empty)' "${CFG}/plugins/installed_plugins.json" 2>/dev/null)"
34
+ # v1.18.0: THIS project's record first, then the user-scope record, then any — never simply the
35
+ # first record, which may be another project's install at another version.
36
+ ip="$(jq -r --arg p "${CLAUDE_PROJECT_DIR:-$PWD}" '(.plugins."foundry@agentic-foundry" // ."foundry@agentic-foundry" // []) | (if type=="array" then . else [.] end) | ((map(select(.projectPath == $p)) + map(select(.projectPath == null)) + .)[0] // {}) | (.installPath // empty)' "${CFG}/plugins/installed_plugins.json" 2>/dev/null)"
35
37
  [ -n "$ip" ] && [ -r "${ip}/scripts/${RENDERER}" ] && selected="${ip}/scripts/${RENDERER}"
36
38
  fi
37
39
  # 2. the plugin cache, newest by version segment
@@ -17,7 +17,9 @@ RENDERER="foundry-subagent-statusline.sh"
17
17
 
18
18
  selected=""
19
19
  if [ -r "${CFG}/plugins/installed_plugins.json" ] && command -v jq >/dev/null 2>&1; then
20
- ip="$(jq -r '(.plugins."foundry@agentic-foundry" // ."foundry@agentic-foundry" // []) | (if type=="array" then .[0] else . end) | (.installPath // empty)' "${CFG}/plugins/installed_plugins.json" 2>/dev/null)"
20
+ # v1.18.0: THIS project's record first, then the user-scope record, then any — never simply the
21
+ # first record, which may be another project's install at another version.
22
+ ip="$(jq -r --arg p "${CLAUDE_PROJECT_DIR:-$PWD}" '(.plugins."foundry@agentic-foundry" // ."foundry@agentic-foundry" // []) | (if type=="array" then . else [.] end) | ((map(select(.projectPath == $p)) + map(select(.projectPath == null)) + .)[0] // {}) | (.installPath // empty)' "${CFG}/plugins/installed_plugins.json" 2>/dev/null)"
21
23
  [ -n "$ip" ] && [ -r "${ip}/scripts/${RENDERER}" ] && selected="${ip}/scripts/${RENDERER}"
22
24
  fi
23
25
  if [ -z "$selected" ]; then
@@ -1,21 +1,22 @@
1
1
  # .foundry/permissions.yaml — this workspace's STANDING GRANTS as policy.
2
2
  #
3
- # The operator edits this file; the agent never does (`Edit`/`Write` on it are refused by the
4
- # compiled floor). `foundry-permissions-compile.py --write` turns every grant into one native
5
- # Claude Code rule `<tool>(<pattern>)` in .claude/settings.json; `--check` reports drift; the
6
- # doctor's `permissions-policy` line says `policy in-sync (<n>)` when the two agree. A contract's
7
- # `requires_capabilities` is checked against these grants by the capability preflight before an
8
- # atom is dispatched, so a denial surfaces before the work starts, not in the middle of it.
3
+ # Operator-owned (the agent writes it only when you ask). Grants only ever WIDEN: an `automatic`
4
+ # grant compiles (`foundry-permissions-compile.py --write`) to one native allow rule
5
+ # `<tool>(<pattern>)` in .claude/settings.json; an `approval_required` grant compiles to NOTHING — the
6
+ # command keeps the session's normal permission mode, and the agent's own loop stops to ask you.
7
+ # Nothing here can add a prompt or a refusal. `--check` reports drift; the doctor's
8
+ # `permissions-policy` line says `policy in-sync` when the two agree. The plugin's own scripts need
9
+ # no grant (a PreToolUse hook allows them).
9
10
  #
10
- # Seeded EMPTY by `npx update-agentic-workspace` / `create-agentic-workspace --existing` and never
11
- # reconciled again (operator-owned). Schema: <plugin>/schema/permissions.schema.json.
11
+ # Seeded EMPTY by the updater / `create-agentic-workspace --existing` and never reconciled again.
12
+ # Schema: <plugin>/schema/permissions.schema.json.
12
13
  #
13
14
  # Each grant:
14
15
  # id a slug; surfaced in report and blocker lines
15
16
  # tool Bash | Edit | Write | Read | WebFetch | Agent
16
17
  # pattern the native rule's parenthesised body — the compiled rule is exactly <tool>(<pattern>)
17
18
  # mode automatic — proceed once every listed precondition has been verified by command
18
- # approval_required — one blocker line naming this grant's id; the operator decides
19
+ # approval_required — writes no rule; the agent stops with one line naming this id, you decide
19
20
  # preconditions closed set: ci-green, security-reviewed-label, spec-authorized, charter-committed,
20
21
  # worktree-clean, branch-up-to-date
21
22
  #
@@ -1,75 +0,0 @@
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
- }