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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-agentic-workspace",
3
- "version": "0.17.4",
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.4",
38
+ "plugin_version": "1.17.5",
39
39
  "pins_researched": "2026-08-02"
40
40
  }
41
41
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schema_version": 1,
3
3
  "plugin_root_glob": "~/.claude/plugins/cache/*/foundry/*",
4
- "generated_for_plugin_version": "1.17.4",
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:*)",
@@ -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" },
@@ -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. Nothing
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. `cleanup` false → `[stale]` (nothing removed); true → `[removed]`
173
- * for what went, `[refused]` for what did not. Empty when nothing catalogued is present. */
174
- export function renderRetiredArtifactRows(plan, { cleanup }) {
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
- out.push(cleanup
180
- ? ` [stale] ${r.relPath}${size} — retired in v${r.retired_in} (${r.reason}) — NOT removed`
181
- : ` [stale] ${r.relPath}${size} — retired in v${r.retired_in} (${r.reason}); remove with --cleanup`);
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
  {