create-agentic-workspace 0.17.4 → 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 +2 -2
- package/permission-floor.json +1 -1
- package/retired-artifacts.json +11 -1
- package/src/retiredArtifacts.mjs +113 -8
- package/src/update.mjs +1 -1
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",
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"marketplace_name": "agentic-foundry",
|
|
36
36
|
"marketplace_repo": "lukasrepublic/agentic-foundry",
|
|
37
37
|
"plugin_name": "foundry",
|
|
38
|
-
"plugin_version": "1.17.
|
|
38
|
+
"plugin_version": "1.17.5",
|
|
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.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:*)",
|
package/retired-artifacts.json
CHANGED
|
@@ -1,12 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schema_version": 1,
|
|
3
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
|
+
},
|
|
4
14
|
"entries": [
|
|
5
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" },
|
|
6
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" },
|
|
7
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" },
|
|
8
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" },
|
|
9
|
-
{ "path": ".foundry/hasher-ref", "kind": "file", "retired_in": "0.24.0", "reason": "the retired hasher reference; no current script reads it" },
|
|
19
|
+
{ "path": ".foundry/hasher-ref", "kind": "file", "retired_in": "0.24.0", "reason": "the retired hasher reference; no current foundry script reads it" },
|
|
10
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" },
|
|
11
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" },
|
|
12
22
|
{ "path": ".claude/dispatcher-mode.json", "kind": "file", "retired_in": "1.10.0", "reason": "the retired dispatcher's mode file" },
|
package/src/retiredArtifacts.mjs
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
// every present entry is reported `[stale]` on every run; removal happens only under `--cleanup`,
|
|
4
4
|
// only for catalogued paths, only when the path is exactly the catalogued kind (a regular file, or a
|
|
5
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
|
|
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
|
|
7
9
|
// outside the catalogue is ever a candidate: `.claude/skills`, `.claude/agents` and every operator
|
|
8
10
|
// file are invisible to this module by construction.
|
|
9
11
|
import fs from 'node:fs';
|
|
@@ -19,6 +21,13 @@ export function loadRetiredCatalogue(pkgDir) {
|
|
|
19
21
|
if (!doc || doc.schema_version !== 1 || !Array.isArray(doc.entries)) {
|
|
20
22
|
throw new Error('retired-artifacts.json: unrecognised schema');
|
|
21
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
|
+
}
|
|
22
31
|
for (const e of doc.entries) {
|
|
23
32
|
if (typeof e.path !== 'string' || path.isAbsolute(e.path) || e.path.split(/[\\/]+/).includes('..')) {
|
|
24
33
|
throw new Error(`retired-artifacts.json: bad path ${JSON.stringify(e.path)}`);
|
|
@@ -92,6 +101,72 @@ function hookCommandText(physicalRoot) {
|
|
|
92
101
|
return { text: parts.join('\n'), unreadable };
|
|
93
102
|
}
|
|
94
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
|
+
|
|
95
170
|
/** Plan: `{ rows: [{ relPath, kind, retired_in, reason, state, why?, entries? }], present, refused }`
|
|
96
171
|
* where `state` is `stale` (present, of the catalogued kind, and not wired) or `refused` (present
|
|
97
172
|
* but a symlink, the other kind, escaping the root, or — for a hook — still named by a hook command
|
|
@@ -101,6 +176,7 @@ function hookCommandText(physicalRoot) {
|
|
|
101
176
|
export function planRetiredArtifacts({ physicalRoot, catalogue }) {
|
|
102
177
|
const rows = [];
|
|
103
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
|
|
104
180
|
for (const entry of catalogue.entries) {
|
|
105
181
|
for (const relPath of candidates(physicalRoot, entry)) {
|
|
106
182
|
const abs = confinedJoin(physicalRoot, relPath);
|
|
@@ -127,6 +203,21 @@ export function planRetiredArtifacts({ physicalRoot, catalogue }) {
|
|
|
127
203
|
if (hooks.unreadable) { row.state = 'refused'; row.why = 'settings-unreadable'; }
|
|
128
204
|
else if (hooks.text.includes(path.posix.basename(relPath))) { row.state = 'refused'; row.why = 'referenced'; }
|
|
129
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
|
+
}
|
|
130
221
|
if (row.state === 'stale' && entry.kind === 'dir') {
|
|
131
222
|
try { row.entries = fs.readdirSync(abs).length; } catch { row.entries = null; }
|
|
132
223
|
}
|
|
@@ -161,28 +252,42 @@ export function applyRetiredArtifacts(plan, physicalRoot) {
|
|
|
161
252
|
else fs.unlinkSync(abs);
|
|
162
253
|
removed += 1;
|
|
163
254
|
row.state = 'removed';
|
|
164
|
-
} catch {
|
|
255
|
+
} catch (e) {
|
|
165
256
|
row.state = 'refused';
|
|
257
|
+
row.why = 'remove-failed';
|
|
258
|
+
row.error = e && e.code ? e.code : 'error';
|
|
166
259
|
}
|
|
167
260
|
}
|
|
168
261
|
plan.removed = removed;
|
|
169
262
|
return removed;
|
|
170
263
|
}
|
|
171
264
|
|
|
172
|
-
/** The rows every writer prints. `
|
|
173
|
-
*
|
|
174
|
-
|
|
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' }) {
|
|
175
273
|
const out = [];
|
|
176
274
|
for (const r of plan.rows) {
|
|
177
275
|
const size = r.kind === 'dir' && typeof r.entries === 'number' ? ` [${r.entries} entr${r.entries === 1 ? 'y' : 'ies'}]` : '';
|
|
178
276
|
if (r.state === 'stale') {
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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}`);
|
|
182
281
|
} else if (r.state === 'removed') {
|
|
183
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`);
|
|
184
285
|
} else if (r.why === 'referenced') {
|
|
185
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`);
|
|
186
291
|
} else if (r.why === 'settings-unreadable') {
|
|
187
292
|
out.push(` [refused] ${r.relPath} — .claude/settings*.json does not parse, so its hook wiring is unknown — left alone`);
|
|
188
293
|
} else {
|
package/src/update.mjs
CHANGED
|
@@ -257,7 +257,7 @@ export async function runUpdate(argv, { cwd, configDir, homeDir, pkgDir, output,
|
|
|
257
257
|
previewLines.push(...renderStatuslineRows(planStatuslineWiring({ physicalRoot, templatesDir })));
|
|
258
258
|
// retired-artifacts (hotfix-v1.17.4, ER #236): PREVIEW-ONLY rows; Phase 4 re-plans fresh from disk.
|
|
259
259
|
const retiredCatalogue = loadRetiredCatalogue(pkgDir);
|
|
260
|
-
previewLines.push(...renderRetiredArtifactRows(planRetiredArtifacts({ physicalRoot, catalogue: retiredCatalogue }), { cleanup: flags.cleanup }));
|
|
260
|
+
previewLines.push(...renderRetiredArtifactRows(planRetiredArtifacts({ physicalRoot, catalogue: retiredCatalogue }), { cleanup: flags.cleanup, phase: 'preview' }));
|
|
261
261
|
// settings.local.json retirement (hotfix-v1.17.4): PREVIEW-ONLY row (PR #237 review Risk 2 —
|
|
262
262
|
// every Phase 4 write is announced before the first write happens); Phase 4 re-plans fresh.
|
|
263
263
|
{
|