create-agentic-workspace 0.17.3 → 0.17.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +3 -2
- package/permission-floor.json +1 -1
- package/retired-artifacts.json +32 -0
- package/src/floorReconcile.mjs +32 -2
- package/src/retiredArtifacts.mjs +298 -0
- package/src/update.mjs +71 -2
- package/src/upgradeReport.mjs +6 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-agentic-workspace",
|
|
3
|
-
"version": "0.17.
|
|
3
|
+
"version": "0.17.5",
|
|
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",
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
"src",
|
|
25
25
|
"templates",
|
|
26
26
|
"permission-floor.json",
|
|
27
|
+
"retired-artifacts.json",
|
|
27
28
|
"package.json",
|
|
28
29
|
"README.md"
|
|
29
30
|
],
|
|
@@ -34,7 +35,7 @@
|
|
|
34
35
|
"marketplace_name": "agentic-foundry",
|
|
35
36
|
"marketplace_repo": "lukasrepublic/agentic-foundry",
|
|
36
37
|
"plugin_name": "foundry",
|
|
37
|
-
"plugin_version": "1.17.
|
|
38
|
+
"plugin_version": "1.17.5",
|
|
38
39
|
"pins_researched": "2026-08-02"
|
|
39
40
|
}
|
|
40
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.17.
|
|
4
|
+
"generated_for_plugin_version": "1.17.5",
|
|
5
5
|
"entries": [
|
|
6
6
|
{
|
|
7
7
|
"rule": "Bash(~/.claude/plugins/cache/*/foundry/*/scripts/foundry-acceptance-contract-validate.py:*)",
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": 1,
|
|
3
|
+
"_comment": "Workspace-side artifacts an earlier release wrote and no current release reads (hotfix-v1.17.4, ER #236). The updater reports every present entry as [stale] on every run and removes it only under --cleanup. `path` is relative to the workspace root; a single `*` is allowed in the LAST segment only (a basename glob). `kind` is `file` or `dir`; a path of the other kind, or a symlink, is reported and never removed. Nothing outside this list is ever touched; `.claude/skills`, `.claude/agents` and operator files are never candidates.",
|
|
4
|
+
"reference_scan": {
|
|
5
|
+
"_comment": "ER #241: a catalogued path is `stale` only when NONE of these workspace files names its full relative path (and, for a hook, when no other hook script names its basename) — an adopter may re-adopt a retired path for their own CI or tooling, in their own namespace. The updater and the doctor both read this list. A heuristic, deliberately fail-closed: a symlink, an unreadable entry, or a budget exceeded refuses every row. `.foundry/` itself is not scanned (the upgrade report lists retired paths by design).",
|
|
6
|
+
"files": [".claude/settings.json", ".claude/settings.local.json", ".mcp.json", ".gitlab-ci.yml", "Jenkinsfile", "Dockerfile", "Makefile", "justfile", "Taskfile.yml", "package.json", "pyproject.toml", "tox.ini", "noxfile.py", ".envrc", ".pre-commit-config.yaml", ".vscode/tasks.json"],
|
|
7
|
+
"dirs": [".github", ".gitlab", ".circleci", ".buildkite", ".gitea", ".forgejo", ".claude/hooks", ".claude/commands", ".claude/skills", ".claude/agents", ".githooks", ".husky", "scripts", "bin", "tools"],
|
|
8
|
+
"root_suffixes": [".md", ".sh", ".yml", ".yaml"],
|
|
9
|
+
"max_files": 5000,
|
|
10
|
+
"max_dirs": 20000,
|
|
11
|
+
"max_bytes": 4194304,
|
|
12
|
+
"max_total_bytes": 67108864
|
|
13
|
+
},
|
|
14
|
+
"entries": [
|
|
15
|
+
{ "path": ".foundry/wiring-hash.pin", "kind": "file", "retired_in": "0.24.0", "reason": "the wiring-hash auto-heal and its pin were removed with the live-seam merge gate" },
|
|
16
|
+
{ "path": ".foundry/active-workflow.json", "kind": "file", "retired_in": "0.5.0", "reason": "the macro-workflow engine's active-workflow pointer; the engine was retired" },
|
|
17
|
+
{ "path": ".foundry/context-snapshots", "kind": "dir", "retired_in": "0.25.0", "reason": "the bespoke context-lifecycle snapshots; /foundry:context wraps native /compact and --resume now" },
|
|
18
|
+
{ "path": ".foundry/branch-protection-unenforced.json", "kind": "file", "retired_in": "0.24.0", "reason": "branch-protection-as-code's marker; Tier A is applied by hand and observed, never written from here" },
|
|
19
|
+
{ "path": ".foundry/hasher-ref", "kind": "file", "retired_in": "0.24.0", "reason": "the retired hasher reference; no current foundry script reads it" },
|
|
20
|
+
{ "path": ".claude/settings.json.bak", "kind": "file", "retired_in": "1.7.0", "reason": "a backup an early settings migration left behind; the updater writes atomically and keeps no .bak" },
|
|
21
|
+
{ "path": ".claude/dispatch-queue", "kind": "dir", "retired_in": "1.10.0", "reason": "the retired dispatch-queue's old location; the multi-repo worktree manifest lives under .foundry/dispatch-queue" },
|
|
22
|
+
{ "path": ".claude/dispatcher-mode.json", "kind": "file", "retired_in": "1.10.0", "reason": "the retired dispatcher's mode file" },
|
|
23
|
+
{ "path": ".claude/dispatcher-mode.json.example", "kind": "file", "retired_in": "1.10.0", "reason": "the retired dispatcher's example" },
|
|
24
|
+
{ "path": ".claude/dispatcher-mode.json.lock", "kind": "file", "retired_in": "1.10.0", "reason": "the retired dispatcher's lock" },
|
|
25
|
+
{ "path": ".claude/dispatch-log.jsonl.example", "kind": "file", "retired_in": "1.10.0", "reason": "the retired dispatcher's example log" },
|
|
26
|
+
{ "path": ".claude/context-state", "kind": "dir", "retired_in": "0.25.0", "reason": "the bespoke context-state store; native session state replaced it" },
|
|
27
|
+
{ "path": ".claude/resume", "kind": "dir", "retired_in": "0.25.0", "reason": "the bespoke resume store; native --resume replaced it" },
|
|
28
|
+
{ "path": ".claude/logs", "kind": "dir", "retired_in": "1.10.0", "reason": "the retired dispatcher's logs" },
|
|
29
|
+
{ "path": ".claude/session-learnings", "kind": "dir", "retired_in": "1.0.0", "reason": "the old learnings location; the partition is .foundry/session-learnings" },
|
|
30
|
+
{ "path": ".claude/hooks/*-exec-guard.sh", "kind": "file", "exclude_prefix": "foundry-", "retired_in": "1.8.0", "reason": "the retired session-context framework's cloud-cli exec guard; a hook still named by a command in .claude/settings*.json is refused, never removed" }
|
|
31
|
+
]
|
|
32
|
+
}
|
package/src/floorReconcile.mjs
CHANGED
|
@@ -272,13 +272,25 @@ function shippedRootNames(map) {
|
|
|
272
272
|
return set;
|
|
273
273
|
}
|
|
274
274
|
|
|
275
|
+
/** The `(name, sub)` keys of every wildcard-shaped `ask` row a settings object carries — what
|
|
276
|
+
* `planRetirements({ askCoveredBy })` takes for the local file (hotfix-v1.17.4). */
|
|
277
|
+
export function askRootKeys(settingsObj, map) {
|
|
278
|
+
const keys = new Set();
|
|
279
|
+
const perms = (settingsObj && settingsObj.permissions) || {};
|
|
280
|
+
for (const rule of perms.ask || []) {
|
|
281
|
+
const parsed = typeof rule === 'string' ? parseFloorRootShape(rule, map.plugin_root_glob) : null;
|
|
282
|
+
if (parsed) keys.add(rootNameKey(parsed));
|
|
283
|
+
}
|
|
284
|
+
return keys;
|
|
285
|
+
}
|
|
286
|
+
|
|
275
287
|
/** Compute the rows the reconcile SHALL remove (AC-FRR-1), WITHOUT touching the filesystem. Every
|
|
276
288
|
* `allow`/`ask` row shaped exactly like the floor's own root-glob rows, whose `(name, sub)` pair the
|
|
277
289
|
* shipped map no longer declares, is queued for removal. `deny` is never a candidate (AC-FRR-2) —
|
|
278
290
|
* retirement only narrows a grant or a prompt, and a deny row read back as "extra" is, if anything,
|
|
279
291
|
* a reason to leave it exactly where the operator (or an earlier release) put it. Returns
|
|
280
292
|
* `{ retirements: { allow: [...], ask: [...] }, total }`. */
|
|
281
|
-
export function planRetirements({ settingsObj, map }) {
|
|
293
|
+
export function planRetirements({ settingsObj, map, askCoveredBy = null }) {
|
|
282
294
|
const shipped = shippedRootNames(map);
|
|
283
295
|
const shippedAsk = new Set();
|
|
284
296
|
for (const e of map.entries) {
|
|
@@ -286,6 +298,13 @@ export function planRetirements({ settingsObj, map }) {
|
|
|
286
298
|
const parsed = parseFloorRootShape(e.rule, map.plugin_root_glob);
|
|
287
299
|
if (parsed) shippedAsk.add(rootNameKey(parsed));
|
|
288
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));
|
|
289
308
|
const retirements = { allow: [], ask: [] };
|
|
290
309
|
const perms = (settingsObj && settingsObj.permissions) || {};
|
|
291
310
|
for (const tier of ['allow', 'ask']) {
|
|
@@ -305,7 +324,7 @@ export function planRetirements({ settingsObj, map }) {
|
|
|
305
324
|
const pinned = parseFloorPinnedShape(rule, map.plugin_root_glob);
|
|
306
325
|
if (pinned) {
|
|
307
326
|
if (tier === 'allow') retirements[tier].push(rule);
|
|
308
|
-
else if (
|
|
327
|
+
else if (askReplaced(rootNameKey(pinned))) retirements[tier].push(rule);
|
|
309
328
|
}
|
|
310
329
|
// any other shape -> never touched (AC-FRR-2)
|
|
311
330
|
}
|
|
@@ -438,8 +457,19 @@ export function writeTargetAtomically(targetPath, obj) {
|
|
|
438
457
|
const dir = path.dirname(targetPath);
|
|
439
458
|
const tmp = path.join(dir, `.settings.json.${process.pid}.tmp`);
|
|
440
459
|
const bytes = Buffer.from(`${JSON.stringify(obj, null, 2)}\n`, 'utf-8');
|
|
460
|
+
// hotfix-v1.17.4 (PR #237 security review Risk 5): a rename-install would otherwise reset the
|
|
461
|
+
// target's mode to the umask default — an operator's 0600 `settings.local.json` (it often holds
|
|
462
|
+
// `env`) must come back 0600. The original's permission bits are copied onto the temp file
|
|
463
|
+
// before the rename; a target that does not exist yet keeps the default.
|
|
464
|
+
let mode = null;
|
|
465
|
+
try {
|
|
466
|
+
mode = fs.statSync(targetPath).mode & 0o777;
|
|
467
|
+
} catch {
|
|
468
|
+
mode = null;
|
|
469
|
+
}
|
|
441
470
|
const fd = fs.openSync(tmp, 'wx');
|
|
442
471
|
try {
|
|
472
|
+
if (mode !== null) fs.fchmodSync(fd, mode);
|
|
443
473
|
fs.writeFileSync(fd, bytes);
|
|
444
474
|
fs.fsyncSync(fd);
|
|
445
475
|
} finally {
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
// retiredArtifacts.mjs — the sweep for workspace-side files an earlier release wrote and no current
|
|
2
|
+
// release reads (hotfix-v1.17.4, ER #236). Driven by the shipped catalogue `cli/retired-artifacts.json`:
|
|
3
|
+
// every present entry is reported `[stale]` on every run; removal happens only under `--cleanup`,
|
|
4
|
+
// only for catalogued paths, only when the path is exactly the catalogued kind (a regular file, or a
|
|
5
|
+
// real directory) — a symlink or the other kind is reported `[refused]` and left alone, and so is a
|
|
6
|
+
// hook under `.claude/hooks/` that a hook command in `.claude/settings*.json` still names, and so is
|
|
7
|
+
// any path a workspace wiring file (CI, scripts, settings, root docs — the catalogue's reference_scan)
|
|
8
|
+
// still names by its full relative path (ER #241). Nothing
|
|
9
|
+
// outside the catalogue is ever a candidate: `.claude/skills`, `.claude/agents` and every operator
|
|
10
|
+
// file are invisible to this module by construction.
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { confinedJoin } from './util.mjs';
|
|
14
|
+
|
|
15
|
+
export const CATALOGUE_REL = 'retired-artifacts.json';
|
|
16
|
+
|
|
17
|
+
/** Load and validate the catalogue shipped beside `src/` (the package root). Refuses a malformed
|
|
18
|
+
* entry loudly rather than sweeping with a half-read list. */
|
|
19
|
+
export function loadRetiredCatalogue(pkgDir) {
|
|
20
|
+
const doc = JSON.parse(fs.readFileSync(path.join(pkgDir, CATALOGUE_REL), 'utf-8'));
|
|
21
|
+
if (!doc || doc.schema_version !== 1 || !Array.isArray(doc.entries)) {
|
|
22
|
+
throw new Error('retired-artifacts.json: unrecognised schema');
|
|
23
|
+
}
|
|
24
|
+
const rs = doc.reference_scan;
|
|
25
|
+
const relOk = (x) => typeof x === 'string' && x.length > 0 && !path.isAbsolute(x) && !x.split(/[\\/]+/).includes('..');
|
|
26
|
+
if (!rs || !Array.isArray(rs.files) || !Array.isArray(rs.dirs) || !Array.isArray(rs.root_suffixes)
|
|
27
|
+
|| !rs.files.every(relOk) || !rs.dirs.every(relOk) || !rs.root_suffixes.every((x) => typeof x === 'string' && x.startsWith('.'))
|
|
28
|
+
|| !['max_files', 'max_dirs', 'max_bytes', 'max_total_bytes'].every((k) => Number.isInteger(rs[k]) && rs[k] >= 1)) {
|
|
29
|
+
throw new Error('retired-artifacts.json: reference_scan missing or malformed');
|
|
30
|
+
}
|
|
31
|
+
for (const e of doc.entries) {
|
|
32
|
+
if (typeof e.path !== 'string' || path.isAbsolute(e.path) || e.path.split(/[\\/]+/).includes('..')) {
|
|
33
|
+
throw new Error(`retired-artifacts.json: bad path ${JSON.stringify(e.path)}`);
|
|
34
|
+
}
|
|
35
|
+
if (e.kind !== 'file' && e.kind !== 'dir') throw new Error(`retired-artifacts.json: bad kind for ${e.path}`);
|
|
36
|
+
const star = e.path.indexOf('*');
|
|
37
|
+
if (star !== -1 && (e.path.lastIndexOf('*') !== star || e.path.slice(star).includes('/'))) {
|
|
38
|
+
throw new Error(`retired-artifacts.json: only one '*' in the last segment is allowed (${e.path})`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return doc;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Expand an entry to concrete relative paths present on disk: the path itself, or, for a
|
|
45
|
+
* basename glob, every directory entry that matches (names starting with `exclude_prefix` skipped). */
|
|
46
|
+
function candidates(physicalRoot, entry) {
|
|
47
|
+
if (!entry.path.includes('*')) return [entry.path];
|
|
48
|
+
const dirRel = path.posix.dirname(entry.path);
|
|
49
|
+
const pattern = path.posix.basename(entry.path);
|
|
50
|
+
const [pre, post] = pattern.split('*');
|
|
51
|
+
const dirAbs = confinedJoin(physicalRoot, dirRel);
|
|
52
|
+
if (!dirAbs) return [];
|
|
53
|
+
let names;
|
|
54
|
+
try {
|
|
55
|
+
names = fs.readdirSync(dirAbs);
|
|
56
|
+
} catch {
|
|
57
|
+
return [];
|
|
58
|
+
}
|
|
59
|
+
return names
|
|
60
|
+
.filter((n) => n.startsWith(pre) && n.endsWith(post) && n.length >= pre.length + post.length)
|
|
61
|
+
.filter((n) => !(entry.exclude_prefix && n.startsWith(entry.exclude_prefix)))
|
|
62
|
+
.map((n) => path.posix.join(dirRel, n));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const HOOKS_DIR_REL = '.claude/hooks/';
|
|
66
|
+
const SETTINGS_FILES = ['.claude/settings.json', '.claude/settings.local.json'];
|
|
67
|
+
|
|
68
|
+
/** Every `command` string under `hooks` in the two workspace settings files, joined — the text a
|
|
69
|
+
* hook candidate's basename is checked against (PR #237 security review Risk 1: a basename glob
|
|
70
|
+
* must never take a hook the operator still wires). `unreadable` is true when a settings file
|
|
71
|
+
* exists but does not parse: then nothing under `.claude/hooks/` is planned removable (fail-closed). */
|
|
72
|
+
function hookCommandText(physicalRoot) {
|
|
73
|
+
const parts = [];
|
|
74
|
+
let unreadable = false;
|
|
75
|
+
for (const rel of SETTINGS_FILES) {
|
|
76
|
+
const abs = confinedJoin(physicalRoot, rel);
|
|
77
|
+
if (!abs) continue;
|
|
78
|
+
let raw;
|
|
79
|
+
try {
|
|
80
|
+
if (!fs.lstatSync(abs).isFile()) continue;
|
|
81
|
+
raw = fs.readFileSync(abs, 'utf-8');
|
|
82
|
+
} catch {
|
|
83
|
+
continue; // absent: nothing wired there
|
|
84
|
+
}
|
|
85
|
+
let obj;
|
|
86
|
+
try {
|
|
87
|
+
obj = JSON.parse(raw);
|
|
88
|
+
} catch {
|
|
89
|
+
unreadable = true;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const walk = (v) => {
|
|
93
|
+
if (Array.isArray(v)) { for (const x of v) walk(x); return; }
|
|
94
|
+
if (v && typeof v === 'object') {
|
|
95
|
+
if (typeof v.command === 'string') parts.push(v.command);
|
|
96
|
+
for (const x of Object.values(v)) walk(x);
|
|
97
|
+
}
|
|
98
|
+
};
|
|
99
|
+
if (obj && typeof obj === 'object' && obj.hooks) walk(obj.hooks);
|
|
100
|
+
}
|
|
101
|
+
return { text: parts.join('\n'), unreadable };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Skipped at ANY depth: version-control internals and installed dependencies only (PR #242 review
|
|
105
|
+
// Risk 1 — `build`/`dist`/`venv` inside a wiring dir may hold wiring, so they are read).
|
|
106
|
+
const SKIP_DIRS = new Set(['.git', 'node_modules']);
|
|
107
|
+
const ABSENT = new Set(['ENOENT', 'ENOTDIR']);
|
|
108
|
+
|
|
109
|
+
/** ER #241: read every file the catalogue's `reference_scan` names (explicit files, the tree under each
|
|
110
|
+
* listed dir, and workspace-root files with a listed suffix). Returns `{ texts: [[relPath, text]],
|
|
111
|
+
* incomplete: null | '<why>' }`. Links are never followed and never skipped silently: a symlink, a
|
|
112
|
+
* path escaping the root, a non-ENOENT stat error, an unreadable entry, or any budget exceeded
|
|
113
|
+
* (`max_files`, `max_dirs`, `max_bytes` per file, `max_total_bytes`) makes the scan `incomplete`, and
|
|
114
|
+
* the caller then refuses every row (PR #242 review Risks 2 and 5). Iterative, so depth cannot throw. */
|
|
115
|
+
export function scanReferenceTexts(physicalRoot, rs) {
|
|
116
|
+
const texts = [];
|
|
117
|
+
let files = 0;
|
|
118
|
+
let dirs = 0;
|
|
119
|
+
let bytes = 0;
|
|
120
|
+
let incomplete = null;
|
|
121
|
+
const fail = (why) => { if (!incomplete) incomplete = why; };
|
|
122
|
+
const statOf = (rel) => {
|
|
123
|
+
const abs = confinedJoin(physicalRoot, rel);
|
|
124
|
+
if (!abs) { fail(`${rel} resolves outside the workspace`); return null; }
|
|
125
|
+
try {
|
|
126
|
+
return { abs, st: fs.lstatSync(abs) };
|
|
127
|
+
} catch (e) {
|
|
128
|
+
if (!ABSENT.has(e.code)) fail(`${rel} unreadable (${e.code || 'error'})`);
|
|
129
|
+
return null; // absent: nothing wired there
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
const readFile = (rel, abs, st) => {
|
|
133
|
+
files += 1;
|
|
134
|
+
if (files > rs.max_files) return fail(`more than ${rs.max_files} files to scan`);
|
|
135
|
+
if (st.size > rs.max_bytes) return fail(`${rel} is larger than ${rs.max_bytes} bytes`);
|
|
136
|
+
bytes += st.size;
|
|
137
|
+
if (bytes > rs.max_total_bytes) return fail(`more than ${rs.max_total_bytes} bytes to scan`);
|
|
138
|
+
try { texts.push([rel, fs.readFileSync(abs, 'utf-8')]); } catch (e) { fail(`${rel} unreadable (${e.code || 'error'})`); }
|
|
139
|
+
};
|
|
140
|
+
const visit = (rel, { treeRoot }) => {
|
|
141
|
+
const hit = statOf(rel);
|
|
142
|
+
if (!hit) return [];
|
|
143
|
+
const { abs, st } = hit;
|
|
144
|
+
if (st.isSymbolicLink()) { fail(`${rel} is a symlink (not followed)`); return []; }
|
|
145
|
+
if (st.isFile()) { readFile(rel, abs, st); return []; }
|
|
146
|
+
if (!st.isDirectory() || !treeRoot) return [];
|
|
147
|
+
dirs += 1;
|
|
148
|
+
if (dirs > rs.max_dirs) { fail(`more than ${rs.max_dirs} directories to scan`); return []; }
|
|
149
|
+
let names;
|
|
150
|
+
try { names = fs.readdirSync(abs).sort(); } catch (e) { fail(`${rel} unreadable (${e.code || 'error'})`); return []; }
|
|
151
|
+
return names.filter((n) => !SKIP_DIRS.has(n)).map((n) => path.posix.join(rel, n));
|
|
152
|
+
};
|
|
153
|
+
for (const f of rs.files) { visit(f, { treeRoot: false }); if (incomplete) return { texts, incomplete }; }
|
|
154
|
+
for (const d of rs.dirs) {
|
|
155
|
+
const stack = [d];
|
|
156
|
+
while (stack.length > 0 && !incomplete) stack.push(...visit(stack.pop(), { treeRoot: true }).reverse());
|
|
157
|
+
if (incomplete) return { texts, incomplete };
|
|
158
|
+
}
|
|
159
|
+
let rootNames = [];
|
|
160
|
+
try { rootNames = fs.readdirSync(physicalRoot).sort(); } catch (e) { fail(`workspace root unreadable (${e.code || 'error'})`); }
|
|
161
|
+
const explicit = new Set(rs.files);
|
|
162
|
+
for (const n of rootNames) {
|
|
163
|
+
if (incomplete) break;
|
|
164
|
+
if (explicit.has(n) || !rs.root_suffixes.some((suf) => n.endsWith(suf))) continue;
|
|
165
|
+
visit(n, { treeRoot: false });
|
|
166
|
+
}
|
|
167
|
+
return { texts, incomplete };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Plan: `{ rows: [{ relPath, kind, retired_in, reason, state, why?, entries? }], present, refused }`
|
|
171
|
+
* where `state` is `stale` (present, of the catalogued kind, and not wired) or `refused` (present
|
|
172
|
+
* but a symlink, the other kind, escaping the root, or — for a hook — still named by a hook command
|
|
173
|
+
* in `.claude/settings*.json`, or those files unreadable); absent paths are omitted. A `dir` row
|
|
174
|
+
* carries `entries` (its direct entry count) so the operator sees what a removal takes. Pure over
|
|
175
|
+
* the filesystem — nothing is written. */
|
|
176
|
+
export function planRetiredArtifacts({ physicalRoot, catalogue }) {
|
|
177
|
+
const rows = [];
|
|
178
|
+
let hooks = null; // read lazily, once, only when a hook candidate is present
|
|
179
|
+
let refs = null; // ER #241: the reference scan, read lazily, once, only when a stale candidate exists
|
|
180
|
+
for (const entry of catalogue.entries) {
|
|
181
|
+
for (const relPath of candidates(physicalRoot, entry)) {
|
|
182
|
+
const abs = confinedJoin(physicalRoot, relPath);
|
|
183
|
+
let st = null;
|
|
184
|
+
if (abs) {
|
|
185
|
+
try {
|
|
186
|
+
st = fs.lstatSync(abs);
|
|
187
|
+
} catch {
|
|
188
|
+
continue; // absent: nothing to say
|
|
189
|
+
}
|
|
190
|
+
} else {
|
|
191
|
+
// the path resolves OUTSIDE the workspace (a symlink escaping the root): present, reported,
|
|
192
|
+
// never touched
|
|
193
|
+
try {
|
|
194
|
+
st = fs.lstatSync(path.join(physicalRoot, relPath));
|
|
195
|
+
} catch {
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
const kindOk = abs && !st.isSymbolicLink() && (entry.kind === 'dir' ? st.isDirectory() : st.isFile());
|
|
200
|
+
const row = { relPath, kind: entry.kind, retired_in: entry.retired_in, reason: entry.reason, state: kindOk ? 'stale' : 'refused', why: kindOk ? null : 'kind' };
|
|
201
|
+
if (row.state === 'stale' && relPath.startsWith(HOOKS_DIR_REL)) {
|
|
202
|
+
if (hooks === null) hooks = hookCommandText(physicalRoot);
|
|
203
|
+
if (hooks.unreadable) { row.state = 'refused'; row.why = 'settings-unreadable'; }
|
|
204
|
+
else if (hooks.text.includes(path.posix.basename(relPath))) { row.state = 'refused'; row.why = 'referenced'; }
|
|
205
|
+
}
|
|
206
|
+
if (row.state === 'stale') {
|
|
207
|
+
// ER #241: an adopter may re-adopt a retired path for their own CI or tooling. A path named
|
|
208
|
+
// (full relative path) by any file the catalogue's reference_scan lists is refused, never
|
|
209
|
+
// removed; a scan that could not finish refuses every row (fail-closed).
|
|
210
|
+
if (refs === null) refs = scanReferenceTexts(physicalRoot, catalogue.reference_scan);
|
|
211
|
+
if (refs.incomplete) { row.state = 'refused'; row.why = 'scan-incomplete'; row.detail = refs.incomplete; }
|
|
212
|
+
else {
|
|
213
|
+
// full relative path anywhere in the scan set; for a hook, also its basename in any OTHER
|
|
214
|
+
// hook script (a live hook that sources `$(dirname "$0")/<name>` — PR #242 review Risk 3)
|
|
215
|
+
const base = relPath.startsWith(HOOKS_DIR_REL) ? path.posix.basename(relPath) : null;
|
|
216
|
+
const by = refs.texts.filter(([rel, text]) => rel !== relPath
|
|
217
|
+
&& (text.includes(relPath) || (base && rel.startsWith(HOOKS_DIR_REL) && text.includes(base)))).map(([rel]) => rel);
|
|
218
|
+
if (by.length > 0) { row.state = 'refused'; row.why = 'referenced'; row.refs = by.slice(0, 3); }
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (row.state === 'stale' && entry.kind === 'dir') {
|
|
222
|
+
try { row.entries = fs.readdirSync(abs).length; } catch { row.entries = null; }
|
|
223
|
+
}
|
|
224
|
+
rows.push(row);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
rows,
|
|
229
|
+
present: rows.filter((r) => r.state === 'stale').length,
|
|
230
|
+
refused: rows.filter((r) => r.state === 'refused').length,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Remove every `stale` row (re-checked with lstat immediately before each removal). Returns the
|
|
235
|
+
* count removed. A row whose state changed between plan and apply is skipped, never forced. */
|
|
236
|
+
export function applyRetiredArtifacts(plan, physicalRoot) {
|
|
237
|
+
let removed = 0;
|
|
238
|
+
for (const row of plan.rows) {
|
|
239
|
+
if (row.state !== 'stale') continue;
|
|
240
|
+
const abs = confinedJoin(physicalRoot, row.relPath);
|
|
241
|
+
if (!abs) continue;
|
|
242
|
+
let st;
|
|
243
|
+
try {
|
|
244
|
+
st = fs.lstatSync(abs);
|
|
245
|
+
} catch {
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (st.isSymbolicLink()) continue;
|
|
249
|
+
if (row.kind === 'dir' ? !st.isDirectory() : !st.isFile()) continue;
|
|
250
|
+
try {
|
|
251
|
+
if (row.kind === 'dir') fs.rmSync(abs, { recursive: true, force: false });
|
|
252
|
+
else fs.unlinkSync(abs);
|
|
253
|
+
removed += 1;
|
|
254
|
+
row.state = 'removed';
|
|
255
|
+
} catch (e) {
|
|
256
|
+
row.state = 'refused';
|
|
257
|
+
row.why = 'remove-failed';
|
|
258
|
+
row.error = e && e.code ? e.code : 'error';
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
plan.removed = removed;
|
|
262
|
+
return removed;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** The rows every writer prints. `phase` is `'preview'` (before the first write) or `'result'`
|
|
266
|
+
* (after apply; the default). `cleanup` false → `[stale] …; remove with --cleanup` in both phases.
|
|
267
|
+
* `cleanup` true → the preview says `; will be removed`; after apply, `[removed]` for what went,
|
|
268
|
+
* `[refused]` for what was never removable, and `— NOT removed (changed since the plan)` only for a
|
|
269
|
+
* row apply skipped because it changed between plan and apply (hotfix-v1.17.5: the preview used to
|
|
270
|
+
* print that last wording, contradicting the `[removed]` row the same run printed next). Empty when
|
|
271
|
+
* nothing catalogued is present. */
|
|
272
|
+
export function renderRetiredArtifactRows(plan, { cleanup, phase = 'result' }) {
|
|
273
|
+
const out = [];
|
|
274
|
+
for (const r of plan.rows) {
|
|
275
|
+
const size = r.kind === 'dir' && typeof r.entries === 'number' ? ` [${r.entries} entr${r.entries === 1 ? 'y' : 'ies'}]` : '';
|
|
276
|
+
if (r.state === 'stale') {
|
|
277
|
+
const tail = !cleanup ? '; remove with --cleanup'
|
|
278
|
+
: phase === 'preview' ? '; will be removed'
|
|
279
|
+
: ' — NOT removed (changed since the plan)';
|
|
280
|
+
out.push(` [stale] ${r.relPath}${size} — retired in v${r.retired_in} (${r.reason})${tail}`);
|
|
281
|
+
} else if (r.state === 'removed') {
|
|
282
|
+
out.push(` [removed] ${r.relPath}${size} — retired in v${r.retired_in}`);
|
|
283
|
+
} else if (r.why === 'referenced' && r.refs) {
|
|
284
|
+
out.push(` [refused] ${r.relPath} — still referenced by ${r.refs.join(', ')} — left alone`);
|
|
285
|
+
} else if (r.why === 'referenced') {
|
|
286
|
+
out.push(` [refused] ${r.relPath} — still named by a hook command in .claude/settings*.json — left alone`);
|
|
287
|
+
} else if (r.why === 'scan-incomplete') {
|
|
288
|
+
out.push(` [refused] ${r.relPath} — the reference scan could not finish (${r.detail}) — left alone`);
|
|
289
|
+
} else if (r.why === 'remove-failed') {
|
|
290
|
+
out.push(` [refused] ${r.relPath} — removal failed (${r.error}) — left in place`);
|
|
291
|
+
} else if (r.why === 'settings-unreadable') {
|
|
292
|
+
out.push(` [refused] ${r.relPath} — .claude/settings*.json does not parse, so its hook wiring is unknown — left alone`);
|
|
293
|
+
} else {
|
|
294
|
+
out.push(` [refused] ${r.relPath} — present but not a regular ${r.kind} (a link, or the other kind) — left alone`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return out;
|
|
298
|
+
}
|
package/src/update.mjs
CHANGED
|
@@ -7,7 +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 { RefusalError, physicalResolve } from './util.mjs';
|
|
10
|
+
import { RefusalError, physicalResolve, confinedJoin } from './util.mjs';
|
|
11
11
|
import { loadMap, buildSettings } from './permissionFloor.mjs';
|
|
12
12
|
import { buildManagedFiles } from './scaffold.mjs';
|
|
13
13
|
import { planManagedFiles, applyPlan } from './reconcile.mjs';
|
|
@@ -19,6 +19,8 @@ import { planAmendmentsBackfill, applyAmendmentsBackfill, renderAmendmentsRow }
|
|
|
19
19
|
import { buildUpgradeReport, writeUpgradeReport, installedVersionBefore, versionOrNull, NEXT_LINE } from './upgradeReport.mjs';
|
|
20
20
|
import { planStatuslineWiring, applyStatuslineWiring, renderStatuslineRows, statuslineChanged } from './statuslineWiring.mjs';
|
|
21
21
|
import { policyPresent, missingSelfGuardDeny, applySelfGuardDeny, renderSelfGuardRow, selfGuardShapeOk } from './selfGuardDeny.mjs';
|
|
22
|
+
import { loadRetiredCatalogue, planRetiredArtifacts, applyRetiredArtifacts, renderRetiredArtifactRows } from './retiredArtifacts.mjs';
|
|
23
|
+
import { planRetirements, applyRetirements, askRootKeys } from './floorReconcile.mjs';
|
|
22
24
|
import {
|
|
23
25
|
ALLOWED_CLAUDE_SUBCOMMANDS, resolveClaudeOnPath, runClaude,
|
|
24
26
|
defaultScopes, snapshotScopes, classifyMigration, migrationActions, migrateScope,
|
|
@@ -48,6 +50,29 @@ export function parseUpdateArgv(argv) {
|
|
|
48
50
|
* itself defines (marketplace-refresh, plugin-update, reinitialization); the full orchestrator
|
|
49
51
|
* below may print a fourth `cleanup` row once the sibling atom's phase is wired in, which is a
|
|
50
52
|
* property of the RUNTIME composition, not of this formatter's own contract. */
|
|
53
|
+
/** hotfix-v1.17.4 (ER #236): the retirement-only plan over `.claude/settings.local.json`, which the
|
|
54
|
+
* tracked-file reconcile never reads. Read-only; `{ rel, abs, plan, error }` — `plan` null when the
|
|
55
|
+
* file is absent, not a regular file, or resolves outside the root; `error` set when it exists but
|
|
56
|
+
* does not parse (reported, never guessed). A pinned `ask` row is retired only when the TRACKED file
|
|
57
|
+
* carries the replacing wildcard row (`askCoveredBy`; PR #237 security review Risk 3). */
|
|
58
|
+
function planLocalRetirement({ physicalRoot, map, trackedSettingsObj }) {
|
|
59
|
+
const rel = '.claude/settings.local.json';
|
|
60
|
+
const abs = confinedJoin(physicalRoot, rel);
|
|
61
|
+
if (!abs) return { rel, abs: null, plan: null, error: 'resolves outside the workspace' };
|
|
62
|
+
try {
|
|
63
|
+
if (!fs.lstatSync(abs).isFile()) return { rel, abs, plan: null, error: 'not a regular file' };
|
|
64
|
+
} catch (e) {
|
|
65
|
+
return { rel, abs, plan: null, error: e && e.code === 'ENOENT' ? null : e.message };
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
const settingsObj = readTarget(abs);
|
|
69
|
+
const askCoveredBy = trackedSettingsObj ? askRootKeys(trackedSettingsObj, map) : new Set();
|
|
70
|
+
return { rel, abs, settingsObj, plan: planRetirements({ settingsObj, map, askCoveredBy }), error: null };
|
|
71
|
+
} catch (e) {
|
|
72
|
+
return { rel, abs, plan: null, error: e.message };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
51
76
|
export function renderSummary(phases) {
|
|
52
77
|
const lines = ['Summary:'];
|
|
53
78
|
for (const p of phases) {
|
|
@@ -230,6 +255,18 @@ export async function runUpdate(argv, { cwd, configDir, homeDir, pkgDir, output,
|
|
|
230
255
|
if (previewAmendmentsRow) previewLines.push(previewAmendmentsRow);
|
|
231
256
|
// statusline-wiring (AC-SLW-1/-2): PREVIEW-ONLY rows; Phase 4 re-plans fresh from disk.
|
|
232
257
|
previewLines.push(...renderStatuslineRows(planStatuslineWiring({ physicalRoot, templatesDir })));
|
|
258
|
+
// retired-artifacts (hotfix-v1.17.4, ER #236): PREVIEW-ONLY rows; Phase 4 re-plans fresh from disk.
|
|
259
|
+
const retiredCatalogue = loadRetiredCatalogue(pkgDir);
|
|
260
|
+
previewLines.push(...renderRetiredArtifactRows(planRetiredArtifacts({ physicalRoot, catalogue: retiredCatalogue }), { cleanup: flags.cleanup, phase: 'preview' }));
|
|
261
|
+
// settings.local.json retirement (hotfix-v1.17.4): PREVIEW-ONLY row (PR #237 review Risk 2 —
|
|
262
|
+
// every Phase 4 write is announced before the first write happens); Phase 4 re-plans fresh.
|
|
263
|
+
{
|
|
264
|
+
const lp = planLocalRetirement({
|
|
265
|
+
physicalRoot, map, trackedSettingsObj: floorTarget.present ? readTarget(floorTarget.path) : null,
|
|
266
|
+
});
|
|
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)`);
|
|
268
|
+
else if (lp.error) previewLines.push(` [permission-floor] ${lp.rel}: would not be reconciled (${lp.error})`);
|
|
269
|
+
}
|
|
233
270
|
print(previewLines.join('\n'));
|
|
234
271
|
|
|
235
272
|
const env = { ...spawnEnv, CLAUDE_CONFIG_DIR: configDir };
|
|
@@ -348,10 +385,40 @@ export async function runUpdate(argv, { cwd, configDir, homeDir, pkgDir, output,
|
|
|
348
385
|
const statuslinePlan = planStatuslineWiring({ physicalRoot, templatesDir });
|
|
349
386
|
applyStatuslineWiring(statuslinePlan);
|
|
350
387
|
for (const row of renderStatuslineRows(statuslinePlan)) print(row);
|
|
388
|
+
// retired-artifacts (hotfix-v1.17.4, ER #236): re-planned FRESH from disk; reported every run,
|
|
389
|
+
// removed only under --cleanup, only catalogued paths of the catalogued kind, never a hook a
|
|
390
|
+
// settings hook command still names.
|
|
391
|
+
const retiredPlan = planRetiredArtifacts({ physicalRoot, catalogue: retiredCatalogue });
|
|
392
|
+
let retiredRemoved = 0;
|
|
393
|
+
if (flags.cleanup && retiredPlan.present > 0) retiredRemoved = applyRetiredArtifacts(retiredPlan, physicalRoot);
|
|
394
|
+
for (const row of renderRetiredArtifactRows(retiredPlan, { cleanup: flags.cleanup })) print(row);
|
|
395
|
+
|
|
396
|
+
// settings.local.json (hotfix-v1.17.4): the tracked-file reconcile never reads it, so a
|
|
397
|
+
// version-pinned floor row an old init left there survived every upgrade. Only RETIREMENT is
|
|
398
|
+
// applied here (never additions — the local file is the operator's), with the same planner and
|
|
399
|
+
// the same never-clobber, mode-preserving write as the tracked file. The tracked file is re-read
|
|
400
|
+
// AFTER its own write above, so an `ask` row is retired only when the wildcard row that replaces
|
|
401
|
+
// it is really there (Risk 3); confined like every other path (Risk 4).
|
|
402
|
+
let localRetired = 0;
|
|
403
|
+
{
|
|
404
|
+
const lp = planLocalRetirement({
|
|
405
|
+
physicalRoot, map,
|
|
406
|
+
trackedSettingsObj: freshFloorTarget.present ? readTarget(freshFloorTarget.path) : null,
|
|
407
|
+
});
|
|
408
|
+
if (lp.plan && lp.plan.total > 0) {
|
|
409
|
+
writeTargetAtomically(lp.abs, applyRetirements(lp.settingsObj, lp.plan));
|
|
410
|
+
localRetired = lp.plan.total;
|
|
411
|
+
for (const tier of ['allow', 'ask']) for (const r of lp.plan.retirements[tier]) print(` [retired] ${lp.rel}: ${r}`);
|
|
412
|
+
print(` [permission-floor] ${lp.rel}: retired ${localRetired} version-pinned/gone row(s)`);
|
|
413
|
+
} else if (lp.error) {
|
|
414
|
+
print(` [permission-floor] ${lp.rel}: not reconciled (${lp.error})`);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
351
418
|
phases.push({
|
|
352
419
|
name: 'reinitialization',
|
|
353
420
|
verdict: anyCreated || anyFloorAdded || anyGitignoreChanged || anyAmendmentsBackfilled
|
|
354
|
-
|| statuslineChanged(statuslinePlan) ? 'changed' : 'already current',
|
|
421
|
+
|| statuslineChanged(statuslinePlan) || retiredRemoved > 0 || localRetired > 0 ? 'changed' : 'already current',
|
|
355
422
|
});
|
|
356
423
|
|
|
357
424
|
print('');
|
|
@@ -363,6 +430,8 @@ export async function runUpdate(argv, { cwd, configDir, homeDir, pkgDir, output,
|
|
|
363
430
|
const report = buildUpgradeReport({
|
|
364
431
|
installedBefore, afterEntry, toPluginVersion: pins.plugin_version, phases, filePlan, amendmentsPlan,
|
|
365
432
|
updaterVersion, coreVersion: corePkg.version, updaterPluginVersion: pins.plugin_version,
|
|
433
|
+
retiredArtifacts: { present: retiredPlan.rows.filter((r) => r.state === 'stale').map((r) => r.relPath), removed: retiredRemoved, refused: retiredPlan.refused },
|
|
434
|
+
localRetired,
|
|
366
435
|
});
|
|
367
436
|
const reportPath = writeUpgradeReport(physicalRoot, report);
|
|
368
437
|
print('');
|
package/src/upgradeReport.mjs
CHANGED
|
@@ -32,6 +32,7 @@ export function versionOrNull(v) {
|
|
|
32
32
|
export function buildUpgradeReport({
|
|
33
33
|
installedBefore = null, afterEntry, toPluginVersion, phases, filePlan, amendmentsPlan, now = new Date(),
|
|
34
34
|
updaterVersion = null, coreVersion = null, updaterPluginVersion = null,
|
|
35
|
+
retiredArtifacts = null, localRetired = 0,
|
|
35
36
|
}) {
|
|
36
37
|
const seedRow = (filePlan || []).find((f) => f.seed);
|
|
37
38
|
return {
|
|
@@ -57,6 +58,11 @@ export function buildUpgradeReport({
|
|
|
57
58
|
: { backfilled: 0, present: 0, skipped: 0, total: 0 },
|
|
58
59
|
permissions_policy: seedRow ? (seedRow.action === 'create' ? 'created' : 'kept') : 'absent',
|
|
59
60
|
drifted: (filePlan || []).filter((f) => f.action === 'drifted').map((f) => f.relPath),
|
|
61
|
+
// hotfix-v1.17.4: what the sweep found (paths are workspace-relative catalogue entries, never free text)
|
|
62
|
+
retired_artifacts: retiredArtifacts
|
|
63
|
+
? { present: retiredArtifacts.present, removed: retiredArtifacts.removed, refused: retiredArtifacts.refused }
|
|
64
|
+
: { present: [], removed: 0, refused: 0 },
|
|
65
|
+
settings_local_retired: localRetired,
|
|
60
66
|
};
|
|
61
67
|
}
|
|
62
68
|
|