create-agentic-workspace 0.18.1 → 0.18.2

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.18.1",
3
+ "version": "0.18.2",
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",
@@ -25,6 +25,7 @@
25
25
  "templates",
26
26
  "permission-floor.json",
27
27
  "retired-artifacts.json",
28
+ "python-requirements.json",
28
29
  "package.json",
29
30
  "README.md"
30
31
  ],
@@ -35,7 +36,7 @@
35
36
  "marketplace_name": "agentic-foundry",
36
37
  "marketplace_repo": "lukasrepublic/agentic-foundry",
37
38
  "plugin_name": "foundry",
38
- "plugin_version": "1.18.1",
39
+ "plugin_version": "1.18.2",
39
40
  "pins_researched": "2026-08-02"
40
41
  }
41
42
  }
@@ -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.18.1",
4
+ "generated_for_plugin_version": "1.18.2",
5
5
  "entries": [
6
6
  {
7
7
  "rule": "Bash(~/.claude/plugins/cache/*/foundry/*/scripts/foundry-acceptance-contract-validate.py:*)",
@@ -0,0 +1,21 @@
1
+ {
2
+ "//": "Third-party Python modules the plugin's scripts import, with the exact versions CI tests (requirements-dev.txt). Kept equal to the plugin's requirements.txt and to the scripts' actual imports by tests/test_python_runtime_deps.py. The updater installs a missing one into the user site; it never upgrades an existing install.",
3
+ "requirements": [
4
+ {
5
+ "module": "yaml",
6
+ "requirement": "PyYAML==6.0.3"
7
+ },
8
+ {
9
+ "module": "jsonschema",
10
+ "requirement": "jsonschema==4.25.0"
11
+ }
12
+ ],
13
+ "//constraints": "Exact pins for jsonschema's transitive dependencies (pip -c), so the updater installs exactly what CI tests; equal to requirements-dev.txt by test.",
14
+ "constraints": [
15
+ "attrs==26.1.0",
16
+ "jsonschema-specifications==2025.9.1",
17
+ "referencing==0.37.0",
18
+ "rpds-py==0.30.0",
19
+ "typing-extensions==4.16.0"
20
+ ]
21
+ }
@@ -27,6 +27,8 @@
27
27
  { "path": ".claude/resume", "kind": "dir", "retired_in": "0.25.0", "reason": "the bespoke resume store; native --resume replaced it" },
28
28
  { "path": ".claude/logs", "kind": "dir", "retired_in": "1.10.0", "reason": "the retired dispatcher's logs" },
29
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" }
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
+ { "path": ".foundry/admission-ledger.jsonl", "kind": "file", "retired_in": "0.24.0", "reason": "the retired ADL admission gate's ledger; nothing reads it" },
32
+ { "path": ".claude/hooks/foundry-subagent-statusline.sh", "kind": "file", "retired_in": "1.18.2", "reason": "the subagent status line is retired (the status line runs for the root session only); removed only once no settings file names it" }
31
33
  ]
32
34
  }
package/src/cleanup.mjs CHANGED
@@ -1,9 +1,9 @@
1
- // cleanup.mjs — Phase 3 (opt-in, `--cleanup`) of `npx update-agentic-workspace`: prune superseded
2
- // plugin-cache versions and remove a stale/duplicate marketplace registration. THE FIRST recursive
3
- // delete of an adopter path in cli/src/ — every other `rmSync` in this package removes a temp file
4
- // the process itself just wrote. Fail-closed throughout: AC-UWC-4 skips (removes nothing) on any
5
- // indeterminate input, and AC-UWC-9 refuses (removes nothing AT ALL, superseded entries included)
6
- // on any cache entry that is not a plain immediate-child directory of the pinned root.
1
+ // cleanup.mjs — Phase 3 (opt-in, `--cleanup`) of `npx update-agentic-workspace`: remove a stale or
2
+ // duplicate marketplace registration, and REPORT superseded plugin-cache versions. Since v1.18.2 a
3
+ // cache version is never deleted (a running session may still use it — see the note above
4
+ // runCleanupPhase's report), so this module carries no recursive delete. Fail-closed throughout:
5
+ // AC-UWC-4 skips on any indeterminate input, and AC-UWC-9 refuses on any cache entry that is not a
6
+ // plain immediate-child directory of the pinned root.
7
7
  import fs from 'node:fs';
8
8
  import path from 'node:path';
9
9
  import { RefusalError } from './util.mjs';
@@ -118,14 +118,13 @@ export function planCachePrune({ pluginCacheDir, liveVersions }) {
118
118
  return candidates;
119
119
  }
120
120
 
121
- /** Remove exactly the candidates already validated by planCachePrune — never re-validates, never
122
- * enumerates further. Called only under `--cleanup` (AC-UWC-6), and only once planCachePrune has
123
- * returned without throwing for the WHOLE directory (AC-UWC-9's "removes nothing at all"). */
124
- export function applyCachePrune(pluginCacheDir, candidates) {
125
- for (const name of candidates) {
126
- fs.rmSync(path.join(pluginCacheDir, name), { recursive: true, force: false });
127
- }
128
- }
121
+ // v1.18.2: superseded plugin-cache versions are NEVER removed. A running Claude Code session keeps
122
+ // the version it started with — its hooks resolve through ${CLAUDE_PLUGIN_ROOT} and the plugin's
123
+ // bin/ is on its PATH — so deleting a version the registry no longer names broke every live session
124
+ // still on it ("Plugin directory does not exist … run /plugin to reinstall" on every hook, and a
125
+ // dead PATH entry). Measured on the operator's own session and reported by an adopter ("keeps
126
+ // crashing since update"). The registry cannot tell which versions running sessions hold, and an
127
+ // old version costs only disk, so the prune is retired; planCachePrune still lists them (report).
129
128
 
130
129
  // ── AC-UWC-7 — a stale or duplicate registration that no scope enables ──────────────────────────
131
130
 
@@ -272,14 +271,14 @@ export function runCleanupPhase({
272
271
  // AC-UWC-5 — previewed before the first removal, in EVERY mode (report-only included, so the
273
272
  // adopter sees the same list --cleanup would act on).
274
273
  if (candidates.length > 0 || removableRegs.length > 0) {
275
- print('cleanup: the following would be removed:');
276
- for (const name of candidates) print(` [cache] ${path.join(pluginCacheDir, name)}`);
274
+ print(removableRegs.length > 0 ? 'cleanup: the following would be removed:' : 'cleanup: nothing to remove.');
275
+ for (const name of candidates) print(` [cache] ${path.join(pluginCacheDir, name)} — superseded; KEPT (a running session may still use it)`);
277
276
  for (const name of removableRegs) print(` [marketplace registration] ${name}`);
278
277
  } else {
279
278
  print('cleanup: nothing to remove.');
280
279
  }
281
280
 
282
- const anything = candidates.length > 0 || removableRegs.length > 0;
281
+ const anything = removableRegs.length > 0;
283
282
 
284
283
  if (!cleanupFlag) {
285
284
  // AC-UWC-6/-7 — report-only: zero filesystem-removal calls AND zero `claude` invocations.
@@ -291,13 +290,12 @@ export function runCleanupPhase({
291
290
  };
292
291
  }
293
292
 
294
- applyCachePrune(pluginCacheDir, candidates);
295
293
  for (const name of removableRegs) {
296
294
  runClaude(['plugin', 'marketplace', 'remove', name], { env, cwd, claudeBin });
297
295
  }
298
296
 
299
297
  return {
300
298
  verdict: anything ? 'changed' : 'already current',
301
- candidateVersions: candidates, prunedVersions: candidates, removedRegistrations: removableRegs,
299
+ candidateVersions: candidates, prunedVersions: [], removedRegistrations: removableRegs,
302
300
  };
303
301
  }
@@ -0,0 +1,93 @@
1
+ // v1.18.2 — the upgrade TRUES UP release manifests to the current contract (operator directive,
2
+ // 2026-09-26: "whatever artifacts are no longer compatible with a new version must be fixed by the
3
+ // updater … not dumped on a user as a surprise"). 41 of 84 real `.foundry/releases/*/release.yaml`
4
+ // failed the loader. The loader now reads every harmless legacy shape (bookkeeping fields, dotted
5
+ // ids, legacy state names, a missing description); this module rewrites the two that carry MEANING on
6
+ // disk, so the file says what the machinery reads:
7
+ // * a legacy `state:` value -> the current vocabulary (in_progress/partially-* -> active, released/
8
+ // done -> completed);
9
+ // * a missing `description:` -> one line naming the release id, inserted after `id:`.
10
+ // Edits are LINE-LEVEL on top-level keys only (column 0), so every comment and every other byte is
11
+ // preserved (some manifests carry over 1,000 comment lines; a YAML round-trip would drop them all).
12
+ // Idempotent; symlinks are never followed; anything it cannot rewrite safely is left alone and listed.
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { confinedJoin } from './util.mjs';
16
+
17
+ export const STATE_TRUE_UP = Object.freeze({
18
+ in_progress: 'active', 'in-progress': 'active', 'partially-released': 'active', 'partially-merged': 'active',
19
+ released: 'completed', done: 'completed',
20
+ });
21
+
22
+ const STATE_LINE = /^state:[ \t]*(["']?)([A-Za-z_-]+)\1([ \t]*(?:#.*)?)$/m;
23
+ const ID_LINE = /^id:[ \t]*(["']?)([a-z0-9.-]+)\1[ \t]*(?:#.*)?$/m;
24
+ const DESCRIPTION_LINE = /^description:/m;
25
+
26
+ /** The new text for one manifest, and what changed; `changes` empty when nothing applies. */
27
+ export function trueUpText(text) {
28
+ let out = text;
29
+ const changes = [];
30
+ const sm = STATE_LINE.exec(out);
31
+ if (sm && Object.prototype.hasOwnProperty.call(STATE_TRUE_UP, sm[2])) {
32
+ const to = STATE_TRUE_UP[sm[2]];
33
+ out = out.replace(STATE_LINE, `state: ${to}${sm[3]}`);
34
+ changes.push(`state ${sm[2]} -> ${to}`);
35
+ }
36
+ const im = ID_LINE.exec(out);
37
+ if (im && !DESCRIPTION_LINE.test(out)) {
38
+ const at = im.index + im[0].length;
39
+ out = `${out.slice(0, at)}\ndescription: "${im[2]}"${out.slice(at)}`;
40
+ changes.push('description added');
41
+ }
42
+ return { text: out, changes };
43
+ }
44
+
45
+ /** Plan over every `.foundry/releases/<id>/release.yaml` of the workspace. */
46
+ export function planManifestTrueUp({ physicalRoot }) {
47
+ const rows = [];
48
+ const dir = confinedJoin(physicalRoot, '.foundry/releases');
49
+ const st = dir ? fs.lstatSync(dir, { throwIfNoEntry: false }) : null;
50
+ if (!st || !st.isDirectory() || st.isSymbolicLink()) return { rows, applied: false };
51
+ for (const name of fs.readdirSync(dir).sort()) {
52
+ const rel = path.posix.join('.foundry/releases', name, 'release.yaml');
53
+ const abs = confinedJoin(physicalRoot, rel);
54
+ if (!abs) continue;
55
+ const fst = fs.lstatSync(abs, { throwIfNoEntry: false });
56
+ if (!fst || !fst.isFile()) continue; // a symlink or a missing manifest is never touched
57
+ let text;
58
+ try { text = fs.readFileSync(abs, 'utf-8'); } catch { continue; }
59
+ const { text: next, changes } = trueUpText(text);
60
+ if (changes.length > 0) rows.push({ rel, abs, changes, next, before: text });
61
+ }
62
+ return { rows, applied: false };
63
+ }
64
+
65
+ /** Atomic per-file write (temp + rename), re-checking the bytes did not change since the plan. */
66
+ export function applyManifestTrueUp(plan) {
67
+ const written = [];
68
+ for (const r of plan.rows) {
69
+ let cur;
70
+ try { cur = fs.readFileSync(r.abs, 'utf-8'); } catch { continue; }
71
+ if (cur !== r.before) continue; // changed since the plan: left alone
72
+ // re-check at apply time (security review): still a regular, non-symlinked file inside the root
73
+ const fst = fs.lstatSync(r.abs, { throwIfNoEntry: false });
74
+ if (!fst || !fst.isFile()) continue;
75
+ const tmp = path.join(path.dirname(r.abs), `.release.yaml.${process.pid}.tmp`);
76
+ let fd;
77
+ try { fd = fs.openSync(tmp, 'wx', 0o644); } catch { continue; } // a stale temp from a crashed run: skip this row
78
+ try { fs.writeFileSync(fd, r.next); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
79
+ try {
80
+ fs.renameSync(tmp, r.abs);
81
+ written.push(r.rel);
82
+ } catch {
83
+ try { fs.unlinkSync(tmp); } catch { /* best effort */ }
84
+ }
85
+ }
86
+ plan.applied = true;
87
+ plan.written = written;
88
+ return plan;
89
+ }
90
+
91
+ export function renderManifestTrueUpRows(plan) {
92
+ return plan.rows.map((r) => ` [${plan.applied ? 'trued-up' : 'would true up'}] ${r.rel} — ${r.changes.join('; ')}`);
93
+ }
@@ -0,0 +1,130 @@
1
+ // v1.18.2: the plugin's scripts import two third-party Python packages (jsonschema, PyYAML), and
2
+ // nothing ever put them on the machine: the plugin declared no runtime dependency, the updater
3
+ // never checked, and a fresh agent container (whose image did not carry them) failed its doctor and
4
+ // every contract script. This phase makes the interpreter the scripts run under (`python3` on PATH)
5
+ // able to import every declared module, installing ONLY what is missing — an existing install is
6
+ // never upgraded or replaced — at the exact versions CI tests (`python-requirements.json`, kept equal
7
+ // to the plugin's `requirements.txt` by test).
8
+ import fs from 'node:fs';
9
+ import os from 'node:os';
10
+ import path from 'node:path';
11
+ import { execFileSync } from 'node:child_process';
12
+
13
+ /** A spawnSync-shaped result from the CLI's closed spawn surface (the named import above); never throws. */
14
+ function runPython(bin, args, opts) {
15
+ try {
16
+ return { status: 0, stdout: execFileSync(bin, args, { ...opts, stdio: ['ignore', 'pipe', 'pipe'] }), stderr: '' };
17
+ } catch (e) {
18
+ if (typeof e.status === 'number') return { status: e.status, stdout: String(e.stdout || ''), stderr: String(e.stderr || '') };
19
+ return { error: e, status: null, stdout: '', stderr: '' };
20
+ }
21
+ }
22
+
23
+ /** The declared runtime requirements shipped with this package: [{ module, requirement }], with the
24
+ * exact transitive pins attached as `.constraints` (pip -c) — the set CI tests (requirements-dev.txt). */
25
+ export function loadPythonRequirements(pkgDir) {
26
+ const doc = JSON.parse(fs.readFileSync(path.join(pkgDir, 'python-requirements.json'), 'utf-8'));
27
+ const reqs = doc.requirements;
28
+ Object.defineProperty(reqs, 'constraints', { value: doc.constraints || [], enumerable: false });
29
+ return reqs;
30
+ }
31
+
32
+ // v1.18.2 security review (Risk 1): never run python in the workspace — `-c`/`-m` put the cwd first
33
+ // on sys.path, so a stray json.py/pip/ there would run (and a `yaml/` dir would fake an install).
34
+ // A neutral cwd plus PYTHONSAFEPATH (3.11+). Not `-I`: that also drops the user site we install into.
35
+ function childOpts(env, timeout) {
36
+ return { env: { ...env, PYTHONSAFEPATH: '1' }, cwd: os.tmpdir(), encoding: 'utf-8', timeout };
37
+ }
38
+
39
+ // Prints JSON: the interpreter, whether it is a venv, and which declared modules cannot be found.
40
+ // find_spec (no import) so a broken package's import-time side effects never run here.
41
+ const PROBE = [
42
+ 'import importlib.util, json, sys',
43
+ 'mods = sys.argv[1:]',
44
+ 'print(json.dumps({"python": sys.executable, "version": "%d.%d" % sys.version_info[:2],',
45
+ ' "venv": sys.prefix != sys.base_prefix,',
46
+ ' "missing": [m for m in mods if importlib.util.find_spec(m) is None]}))',
47
+ ].join('\n');
48
+
49
+ /** Probe `python3` for the declared modules. `{ ok:false, reason }` when python3 cannot run. */
50
+ export function probePythonDeps(requirements, { env = process.env, python = 'python3', spawn = runPython } = {}) {
51
+ const r = spawn(python, ['-c', PROBE, ...requirements.map((q) => q.module)], childOpts(env, 30000));
52
+ if (r.error || r.status !== 0) {
53
+ return { ok: false, reason: r.error ? `${python} not runnable (${r.error.code || r.error.message})` : `${python} probe exited ${r.status}` };
54
+ }
55
+ try {
56
+ const out = JSON.parse(String(r.stdout).trim().split('\n').pop());
57
+ const missing = requirements.filter((q) => out.missing.includes(q.module));
58
+ return { ok: true, python: out.python, version: out.version, venv: out.venv, missing };
59
+ } catch {
60
+ return { ok: false, reason: `${python} probe output unreadable` };
61
+ }
62
+ }
63
+
64
+ /** The pip argv for installing `missing`: `--user` outside a venv (a venv has no user site). */
65
+ export function pipArgs(missing, { venv, breakSystem = false, constraintsFile = null }) {
66
+ // --only-binary: no sdist build backend ever runs on the operator's machine (review Risk 2).
67
+ const args = ['-m', 'pip', 'install', '--disable-pip-version-check', '--quiet', '--no-input', '--only-binary=:all:'];
68
+ if (!venv) args.push('--user');
69
+ if (breakSystem) args.push('--break-system-packages');
70
+ if (constraintsFile) args.push('-c', constraintsFile);
71
+ return [...args, ...missing.map((q) => q.requirement)];
72
+ }
73
+
74
+ function writeConstraints(requirements) {
75
+ const lines = requirements.constraints || [];
76
+ if (lines.length === 0) return null;
77
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'foundry-pydeps-'));
78
+ const file = path.join(dir, 'constraints.txt');
79
+ fs.writeFileSync(file, lines.join('\n') + '\n', { mode: 0o600 });
80
+ return file;
81
+ }
82
+
83
+ /**
84
+ * Install what is missing, then re-probe. A PEP 668 "externally-managed-environment" refusal is
85
+ * retried once with --break-system-packages: with --user that writes only the user site (~/.local
86
+ * or ~/Library/Python), never the system interpreter's own packages. Never throws.
87
+ * Returns { verdict: 'already current'|'changed'|'failed'|'skipped', installed, missing, reason }.
88
+ */
89
+ export function ensurePythonDeps(requirements, { env = process.env, python = 'python3', spawn = runPython, dryRun = false } = {}) {
90
+ const before = probePythonDeps(requirements, { env, python, spawn });
91
+ if (!before.ok) return { verdict: 'skipped', installed: [], missing: requirements.map((q) => q.module), reason: before.reason };
92
+ if (before.missing.length === 0) return { verdict: 'already current', installed: [], missing: [], python: before.python };
93
+ if (dryRun) {
94
+ return { verdict: 'would change', installed: [], missing: before.missing.map((q) => q.module), python: before.python };
95
+ }
96
+ let constraintsFile = null;
97
+ try { constraintsFile = writeConstraints(requirements); } catch { constraintsFile = null; }
98
+ let overrode = false;
99
+ let r = spawn(python, pipArgs(before.missing, { venv: before.venv, constraintsFile }), childOpts(env, 600000));
100
+ const out = `${r.stdout || ''}${r.stderr || ''}`;
101
+ if (r.status !== 0 && /externally-managed-environment/.test(out)) {
102
+ overrode = true;
103
+ r = spawn(python, pipArgs(before.missing, { venv: before.venv, breakSystem: true, constraintsFile }), childOpts(env, 600000));
104
+ }
105
+ if (constraintsFile) { try { fs.unlinkSync(constraintsFile); fs.rmdirSync(path.dirname(constraintsFile)); } catch { /* best effort */ } }
106
+ const after = probePythonDeps(requirements, { env, python, spawn });
107
+ const still = after.ok ? after.missing.map((q) => q.module) : before.missing.map((q) => q.module);
108
+ const installed = before.missing.map((q) => q.module).filter((m) => !still.includes(m));
109
+ if (still.length === 0) return { verdict: 'changed', installed, missing: [], python: before.python, pep668Overridden: overrode };
110
+ const tail = `${r.stdout || ''}${r.stderr || ''}`.trim().split('\n').slice(-1)[0] || `exit ${r.status}`;
111
+ const noPip = /No module named pip/.test(`${r.stdout || ''}${r.stderr || ''}`);
112
+ return {
113
+ verdict: 'failed', installed, missing: still, python: before.python,
114
+ reason: noPip
115
+ ? `pip is not available for ${before.python} — install it (Debian/Ubuntu: apt-get install python3-pip, or python3-${still.includes('yaml') ? 'yaml' : 'jsonschema'} directly), then re-run`
116
+ : `pip install failed: ${tail}`,
117
+ };
118
+ }
119
+
120
+ /** One output row per outcome, in the updater's `[verdict] subject — detail` shape. */
121
+ export function renderPythonDepsRow(result, requirements) {
122
+ const want = requirements.map((q) => q.requirement).join(', ');
123
+ switch (result.verdict) {
124
+ case 'already current': return ` [ok] python deps: ${requirements.map((q) => q.module).join(', ')} importable (${result.python})`;
125
+ case 'would change': return ` [would install] python deps: ${result.missing.join(', ')} for ${result.python} (${want}; user site, missing only)`;
126
+ case 'changed': return ` [installed] python deps: ${result.installed.join(', ')} for ${result.python} (user site${result.pep668Overridden ? ', PEP 668 overridden' : ''})`;
127
+ case 'skipped': return ` [skipped] python deps: ${result.reason}`;
128
+ default: return ` [failed] python deps: still missing ${result.missing.join(', ')} — ${result.reason}`;
129
+ }
130
+ }
@@ -27,7 +27,15 @@ import { resolveTarget, readTarget, writeTargetAtomically } from './floorReconci
27
27
  export const MARKER = 'feat-foundry-init-statusline-wrapper';
28
28
  export const WRAPPERS = Object.freeze([
29
29
  { template: 'foundry-statusline.sh', rel: '.claude/hooks/foundry-statusline.sh', key: 'statusLine' },
30
- { template: 'foundry-subagent-statusline.sh', rel: '.claude/hooks/foundry-subagent-statusline.sh', key: 'subagentStatusLine' },
30
+ ]);
31
+
32
+ // v1.18.2 (operator directive, 2026-09-26): the status line is for the ROOT session in a visual
33
+ // terminal only — never subagents. v1.17.0–v1.18.1 also wired `subagentStatusLine`, which Claude Code
34
+ // runs once per running subagent on every refresh; with the renderer's git/python work that fed the
35
+ // process storm that crashed adopters' sessions. The key is RETIRED: removed wherever its command is
36
+ // exactly the one this framework wrote (an operator's own value is never touched), and never added.
37
+ export const RETIRED_KEYS = Object.freeze([
38
+ { key: 'subagentStatusLine', rel: '.claude/hooks/foundry-subagent-statusline.sh' },
31
39
  ]);
32
40
 
33
41
  export function desiredSettingsValue(rel) {
@@ -68,6 +76,12 @@ export function planStatuslineWiring({ physicalRoot, templatesDir }) {
68
76
  const unverifiable = fileRow && (fileRow.action === 'kept' || fileRow.action === 'refused');
69
77
  plan.keys.push({ key: w.key, action: present ? 'already-wired' : (unverifiable ? 'not-wired' : 'wired') });
70
78
  }
79
+ for (const r of RETIRED_KEYS) {
80
+ const v = settings[r.key];
81
+ if (v && typeof v === 'object' && v.command === desiredSettingsValue(r.rel).command) {
82
+ plan.keys.push({ key: r.key, action: 'unwired' });
83
+ }
84
+ }
71
85
  return plan;
72
86
  }
73
87
 
@@ -99,13 +113,19 @@ export function applyStatuslineWiring(plan) {
99
113
  }
100
114
  }
101
115
  const toAdd = plan.keys.filter((k) => k.action === 'wired');
102
- if (toAdd.length > 0) {
116
+ const toRemove = plan.keys.filter((k) => k.action === 'unwired');
117
+ if (toAdd.length > 0 || toRemove.length > 0) {
103
118
  const settings = readTarget(plan.settingsPath);
104
119
  for (const k of toAdd) {
105
120
  if (Object.prototype.hasOwnProperty.call(settings, k.key)) continue; // raced in since plan
106
121
  const w = WRAPPERS.find((x) => x.key === k.key);
107
122
  settings[k.key] = desiredSettingsValue(w.rel);
108
123
  }
124
+ for (const k of toRemove) {
125
+ const r = RETIRED_KEYS.find((x) => x.key === k.key);
126
+ const v = settings[k.key];
127
+ if (v && typeof v === 'object' && v.command === desiredSettingsValue(r.rel).command) delete settings[k.key];
128
+ }
109
129
  writeTargetAtomically(plan.settingsPath, settings);
110
130
  }
111
131
  plan.applied = true;
@@ -126,6 +146,8 @@ export function renderStatuslineRows(plan) {
126
146
  const notWired = plan.keys.filter((k) => k.action === 'not-wired').map((k) => k.key);
127
147
  if (wired.length) rows.push(` [statusline] wired ${wired.join(', ')} in .claude/settings.json`);
128
148
  if (already.length) rows.push(` [statusline] already wired: ${already.join(', ')} (existing value kept)`);
149
+ const unwired = plan.keys.filter((k) => k.action === 'unwired').map((k) => k.key);
150
+ if (unwired.length) rows.push(` [statusline] ${plan.applied ? 'removed' : 'would remove'} ${unwired.join(', ')} from .claude/settings.json (the status line is for the root session only)`);
129
151
  if (notWired.length) rows.push(` [statusline] NOT wired: ${notWired.join(', ')} — the wrapper at that path is not one this framework wrote (kept or refused); verify it, then wire by hand`);
130
152
  return rows;
131
153
  }
@@ -133,6 +155,6 @@ export function renderStatuslineRows(plan) {
133
155
  export function statuslineChanged(plan) {
134
156
  return Boolean(plan && plan.applied && (
135
157
  plan.files.some((f) => f.action === 'create' || f.action === 'converged')
136
- || plan.keys.some((k) => k.action === 'wired')
158
+ || plan.keys.some((k) => k.action === 'wired' || k.action === 'unwired')
137
159
  ));
138
160
  }
package/src/update.mjs CHANGED
@@ -17,6 +17,7 @@ import {
17
17
  } from './floorReconcile.mjs';
18
18
  import { reconcileGitignorePlan, applyGitignorePlan, renderGitignoreRow } from './gitignoreReconcile.mjs';
19
19
  import { planAmendmentsBackfill, applyAmendmentsBackfill, renderAmendmentsRow } from './amendmentsBackfill.mjs';
20
+ import { planManifestTrueUp, applyManifestTrueUp, renderManifestTrueUpRows } from './manifestTrueUp.mjs';
20
21
  import { buildUpgradeReport, writeUpgradeReport, installedVersionBefore, versionOrNull, NEXT_LINE } from './upgradeReport.mjs';
21
22
  import { planStatuslineWiring, applyStatuslineWiring, renderStatuslineRows, statuslineChanged } from './statuslineWiring.mjs';
22
23
  import { loadRetiredCatalogue, planRetiredArtifacts, applyRetiredArtifacts, renderRetiredArtifactRows } from './retiredArtifacts.mjs';
@@ -29,6 +30,7 @@ import {
29
30
  readInstalledPluginsRegistry, scopeRecordsFor,
30
31
  } from './pluginRefresh.mjs';
31
32
  import { runCleanupPhase } from './cleanup.mjs';
33
+ import { loadPythonRequirements, ensurePythonDeps, renderPythonDepsRow } from './pythonDeps.mjs';
32
34
 
33
35
  export { ALLOWED_CLAUDE_SUBCOMMANDS };
34
36
 
@@ -266,6 +268,8 @@ export async function runUpdate(argv, { cwd, configDir, homeDir, pkgDir, output,
266
268
  // re-plans fresh from disk before it writes.
267
269
  const previewAmendmentsRow = renderAmendmentsRow(planAmendmentsBackfill({ physicalRoot }));
268
270
  if (previewAmendmentsRow) previewLines.push(previewAmendmentsRow);
271
+ // v1.18.2: release manifests trued up to the current contract (preview only here)
272
+ previewLines.push(...renderManifestTrueUpRows(planManifestTrueUp({ physicalRoot })));
269
273
  // statusline-wiring (AC-SLW-1/-2): PREVIEW-ONLY rows; Phase 4 re-plans fresh from disk.
270
274
  previewLines.push(...renderStatuslineRows(planStatuslineWiring({ physicalRoot, templatesDir })));
271
275
  // retired-artifacts (hotfix-v1.17.4, ER #236): PREVIEW-ONLY rows; Phase 4 re-plans fresh from disk.
@@ -280,6 +284,9 @@ export async function runUpdate(argv, { cwd, configDir, homeDir, pkgDir, output,
280
284
  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)`);
281
285
  else if (lp.error) previewLines.push(` [permission-floor] ${lp.rel}: would not be reconciled (${lp.error})`);
282
286
  }
287
+ // v1.18.2: python deps — the preview only probes (nothing installed before the first write).
288
+ const pythonRequirements = loadPythonRequirements(pkgDir);
289
+ previewLines.push(renderPythonDepsRow(ensurePythonDeps(pythonRequirements, { env: spawnEnv, python: spawnEnv.FOUNDRY_PYTHON || 'python3', dryRun: true }), pythonRequirements));
283
290
  print(previewLines.join('\n'));
284
291
  if (flags.dryRun) {
285
292
  print('');
@@ -337,6 +344,12 @@ export async function runUpdate(argv, { cwd, configDir, homeDir, pkgDir, output,
337
344
  ...(installedAfter ? {} : { reason: 'installed version unreadable from installed_plugins.json' }),
338
345
  });
339
346
 
347
+ // ── Phase 2b (v1.18.2): the plugin's Python runtime deps — install only what is missing ──────
348
+ // FOUNDRY_PYTHON names another interpreter (a venv's), and lets tests keep pip off the machine.
349
+ const pythonDeps = ensurePythonDeps(pythonRequirements, { env: spawnEnv, python: spawnEnv.FOUNDRY_PYTHON || 'python3' });
350
+ print(renderPythonDepsRow(pythonDeps, pythonRequirements));
351
+ phases.push({ name: 'python-deps', verdict: pythonDeps.verdict, ...(pythonDeps.reason ? { reason: pythonDeps.reason } : {}) });
352
+
340
353
  // ── Phase 3: cleanup (sibling atom; always previewed, only acts under --cleanup) ────────────
341
354
  const cleanupScopeDescriptors = scopes; // same {name, settingsPath} pairs, unresolved-required
342
355
  const cleanupResult = runCleanupPhase({
@@ -397,6 +410,10 @@ export async function runUpdate(argv, { cwd, configDir, homeDir, pkgDir, output,
397
410
  for (const rel of amendmentsPlan.writtenPaths || []) wrote(rel, 'amendments-backfill');
398
411
  const amendmentsRow = renderAmendmentsRow(amendmentsPlan);
399
412
  if (amendmentsRow) print(amendmentsRow);
413
+ // v1.18.2: release manifests trued up to the current contract — re-planned fresh from disk
414
+ const trueUpPlan = applyManifestTrueUp(planManifestTrueUp({ physicalRoot }));
415
+ for (const rel of trueUpPlan.written || []) wrote(rel, 'manifest-true-up');
416
+ for (const row of renderManifestTrueUpRows(trueUpPlan)) print(row);
400
417
 
401
418
  const anyCreated = filePlan.some((f) => f.action === 'create');
402
419
  const anyFloorAdded = Boolean(floorPlan && floorPlan.total > 0)
@@ -447,7 +464,7 @@ export async function runUpdate(argv, { cwd, configDir, homeDir, pkgDir, output,
447
464
 
448
465
  phases.push({
449
466
  name: 'reinitialization',
450
- verdict: anyCreated || anyFloorAdded || anyGitignoreChanged || anyAmendmentsBackfilled
467
+ verdict: anyCreated || anyFloorAdded || anyGitignoreChanged || anyAmendmentsBackfilled || (trueUpPlan.written || []).length > 0
451
468
  || statuslineChanged(statuslinePlan) || retiredRemoved > 0 || localRetired > 0 ? 'changed' : 'already current',
452
469
  });
453
470
 
@@ -459,7 +476,7 @@ export async function runUpdate(argv, { cwd, configDir, homeDir, pkgDir, output,
459
476
  // named in the LAST line so the operator's next step is never a guess.
460
477
  const report = buildUpgradeReport({
461
478
  installedBefore, installedAfter, afterEntry, toPluginVersion: pins.plugin_version, phases, filePlan, amendmentsPlan,
462
- written, removed, configDir, hostname: os.hostname(),
479
+ written, removed, configDir, hostname: os.hostname(), pythonDeps,
463
480
  updaterVersion, coreVersion: corePkg.version, updaterPluginVersion: pins.plugin_version,
464
481
  retiredArtifacts: { present: retiredPlan.rows.filter((r) => r.state === 'stale').map((r) => r.relPath), removed: retiredRemoved, refused: retiredPlan.refused },
465
482
  localRetired,
@@ -481,7 +498,9 @@ export async function runUpdate(argv, { cwd, configDir, homeDir, pkgDir, output,
481
498
  // into "the update failed" for the whole run (run.mjs makes the identical choice; see its own
482
499
  // comment on `gitignoreRefused`).
483
500
  const gitignoreRefused = Boolean(freshGitignorePlan && freshGitignorePlan.action === 'refused');
484
- return { exitCode: anyDrifted || gitignoreRefused ? 2 : 0, output: lines.join('\n') };
501
+ // v1.18.2: a python dep that could not be installed leaves the plugin's scripts broken — exit 2
502
+ // (attention needed; the row above names what is missing and why), like a refused gitignore.
503
+ return { exitCode: anyDrifted || gitignoreRefused || pythonDeps.verdict === 'failed' ? 2 : 0, output: lines.join('\n') };
485
504
  } catch (e) {
486
505
  if (e instanceof RefusalError) {
487
506
  print(`refused: ${e.message}`);
@@ -33,6 +33,7 @@ export function buildUpgradeReport({
33
33
  installedBefore = null, installedAfter = null, afterEntry, toPluginVersion, phases, filePlan, amendmentsPlan, now = new Date(),
34
34
  updaterVersion = null, coreVersion = null, updaterPluginVersion = null,
35
35
  retiredArtifacts = null, localRetired = 0, written = [], removed = [], configDir = null, hostname = null,
36
+ pythonDeps = null,
36
37
  }) {
37
38
  const seedRow = (filePlan || []).find((f) => f.seed);
38
39
  return {
@@ -59,6 +60,12 @@ export function buildUpgradeReport({
59
60
  failed: amendmentsPlan.failed ?? 0,
60
61
  total: amendmentsPlan.total ?? ((amendmentsPlan.written ?? 0) + amendmentsPlan.present + amendmentsPlan.skipped) }
61
62
  : { backfilled: 0, present: 0, skipped: 0, failed: 0, total: 0 },
63
+ // v1.18.2: whether python3 can import every module the plugin's scripts need, and what this run
64
+ // installed. null when the phase did not run (a dry run records nothing).
65
+ python_deps: pythonDeps
66
+ ? { verdict: pythonDeps.verdict, installed: pythonDeps.installed || [], missing: pythonDeps.missing || [],
67
+ ...(pythonDeps.python ? { python: pythonDeps.python } : {}), ...(pythonDeps.reason ? { reason: pythonDeps.reason } : {}) }
68
+ : null,
62
69
  permissions_policy: seedRow ? (seedRow.action === 'create' ? 'created' : 'kept') : 'absent',
63
70
  drifted: (filePlan || []).filter((f) => f.action === 'drifted').map((f) => f.relPath),
64
71
  // hotfix-v1.17.4: what the sweep found (paths are workspace-relative catalogue entries, never free text)
@@ -24,6 +24,55 @@
24
24
  # FAIL-OPEN is the only invariant: any error → print what could be built (possibly nothing) and `exit 0`.
25
25
  set +e
26
26
 
27
+ # v1.18.2 — THROTTLE + SINGLE-FLIGHT. Claude Code re-runs the status line on every update; the
28
+ # renderer shells out to git (a full `git status`) and python, which on WSL took seconds. Runs then
29
+ # overlapped, each refresh spawning another, until the machine ran out of memory and Claude Code
30
+ # itself crashed (an adopter's WSL session: 44,616 spawns in 21 minutes, SIGBUS). So: one render per
31
+ # project at a time (an mkdir lock), its output cached for FOUNDRY_STATUSLINE_TTL seconds (default 5),
32
+ # and a refresh that finds a render in flight prints the last line and exits at once. The cache-hit
33
+ # path spawns only `cat` (and `date` where bash has no EPOCHSECONDS). Cache: ~/.cache/foundry-statusline.
34
+ # Security review (v1.18.2, Block): the cache lives in the user's OWN cache dir, never a shared /tmp,
35
+ # and is used only when it is a real directory owned by this user (not a symlink) — otherwise the
36
+ # wrapper renders directly, uncached (fail-open), so a planted dir/link is never read or written.
37
+ if [ -z "${FOUNDRY_STATUSLINE_INNER:-}" ]; then
38
+ PAYLOAD="$(cat 2>/dev/null || true)"
39
+ _d="${XDG_CACHE_HOME:-${HOME:-/nonexistent}/.cache}/foundry-statusline"
40
+ mkdir -p -m 700 "$_d" 2>/dev/null
41
+ if [ ! -d "$_d" ] || [ -L "$_d" ] || [ ! -O "$_d" ]; then
42
+ printf '%s' "$PAYLOAD" | FOUNDRY_STATUSLINE_INNER=1 bash "$0" "$@" 2>/dev/null
43
+ exit 0
44
+ fi
45
+ _k="${CLAUDE_PROJECT_DIR:-$PWD}"; _k="${_k//[^A-Za-z0-9]/_}"
46
+ _cache="$_d/${_k}.out"; _lock="$_d/${_k}.lock"
47
+ _now="${EPOCHSECONDS:-$(date +%s)}"; _ttl="${FOUNDRY_STATUSLINE_TTL:-5}"
48
+ _ts=0; [ -r "$_cache.ts" ] && read -r _ts < "$_cache.ts" 2>/dev/null
49
+ case "$_ts" in ''|*[!0-9]*) _ts=0 ;; esac
50
+ if [ -r "$_cache" ] && [ "$_ts" -le "$_now" ] && [ $(( _now - _ts )) -lt "$_ttl" ]; then
51
+ printf '%s' "$(< "$_cache")"; exit 0
52
+ fi
53
+ if ! mkdir "$_lock" 2>/dev/null; then
54
+ # a lock whose ts is not written yet is FRESH (a render just started), never stale
55
+ _lt="$_now"; [ -r "$_lock/ts" ] && read -r _lt < "$_lock/ts" 2>/dev/null
56
+ case "$_lt" in ''|*[!0-9]*) _lt="$_now" ;; esac
57
+ if [ $(( _now - _lt )) -gt 30 ]; then rm -f "$_lock/ts" 2>/dev/null; rmdir "$_lock" 2>/dev/null; fi
58
+ [ -r "$_cache" ] && printf '%s' "$(< "$_cache")"
59
+ exit 0
60
+ fi
61
+ printf '%s\n' "$_now" > "$_lock/ts" 2>/dev/null
62
+ # bounded: a hung render (git on a stalled filesystem) is killed at 20 s where `timeout` exists,
63
+ # so it can never outlive the 30 s stale-lock window and overlap the next one
64
+ if command -v timeout >/dev/null 2>&1; then
65
+ _out="$(printf '%s' "$PAYLOAD" | FOUNDRY_STATUSLINE_INNER=1 timeout 20 bash "$0" "$@" 2>/dev/null)"
66
+ else
67
+ _out="$(printf '%s' "$PAYLOAD" | FOUNDRY_STATUSLINE_INNER=1 bash "$0" "$@" 2>/dev/null)"
68
+ fi
69
+ printf '%s' "$_out" > "$_cache.tmp" 2>/dev/null && mv -f "$_cache.tmp" "$_cache" 2>/dev/null
70
+ printf '%s\n' "${EPOCHSECONDS:-$(date +%s)}" > "$_cache.ts" 2>/dev/null
71
+ rm -f "$_lock/ts" 2>/dev/null; rmdir "$_lock" 2>/dev/null
72
+ printf '%s' "$_out"
73
+ exit 0
74
+ fi
75
+
27
76
  PAYLOAD="$(cat 2>/dev/null || true)"
28
77
  CFG="${CLAUDE_CONFIG_DIR:-${HOME}/.claude}"
29
78
  RENDERER="foundry-statusline.sh"