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 +3 -2
- package/permission-floor.json +1 -1
- package/python-requirements.json +21 -0
- package/retired-artifacts.json +3 -1
- package/src/cleanup.mjs +17 -19
- package/src/manifestTrueUp.mjs +93 -0
- package/src/pythonDeps.mjs +130 -0
- package/src/statuslineWiring.mjs +25 -3
- package/src/update.mjs +22 -3
- package/src/upgradeReport.mjs +7 -0
- package/templates/foundry-statusline.sh +49 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-agentic-workspace",
|
|
3
|
-
"version": "0.18.
|
|
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.
|
|
39
|
+
"plugin_version": "1.18.2",
|
|
39
40
|
"pins_researched": "2026-08-02"
|
|
40
41
|
}
|
|
41
42
|
}
|
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.18.
|
|
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
|
+
}
|
package/retired-artifacts.json
CHANGED
|
@@ -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`:
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
// indeterminate input, and AC-UWC-9 refuses
|
|
6
|
-
//
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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 =
|
|
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:
|
|
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
|
+
}
|
package/src/statuslineWiring.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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}`);
|
package/src/upgradeReport.mjs
CHANGED
|
@@ -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"
|