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 +1 -1
- package/package.json +2 -2
- package/permission-floor.json +1 -11
- package/src/amendmentsBackfill.mjs +15 -3
- package/src/floorReconcile.mjs +51 -45
- package/src/permissionFloor.mjs +11 -7
- package/src/run.mjs +6 -25
- package/src/update.mjs +59 -28
- package/src/upgradeReport.mjs +15 -4
- package/templates/foundry-statusline.sh +3 -1
- package/templates/foundry-subagent-statusline.sh +3 -1
- package/templates/permissions.yaml.tmpl +10 -9
- package/src/selfGuardDeny.mjs +0 -75
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
|
|
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.
|
|
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.
|
|
38
|
+
"plugin_version": "1.18.0",
|
|
39
39
|
"pins_researched": "2026-08-02"
|
|
40
40
|
}
|
|
41
41
|
}
|
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.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
|
-
|
|
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
|
}
|
package/src/floorReconcile.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
313
|
-
if (
|
|
314
|
-
|
|
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
|
|
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(` [${
|
|
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(` [
|
|
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
|
|
511
|
-
summary +=
|
|
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') {
|
package/src/permissionFloor.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 = [
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
package/src/upgradeReport.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
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
|
|
11
|
-
#
|
|
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
|
|
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
|
#
|
package/src/selfGuardDeny.mjs
DELETED
|
@@ -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
|
-
}
|