create-agentic-workspace 0.16.1 → 0.17.1
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/README.md +8 -0
- package/package.json +2 -2
- package/permission-floor.json +1 -1
- package/src/amendmentsBackfill.mjs +162 -0
- package/src/reconcile.mjs +5 -0
- package/src/run.mjs +36 -1
- package/src/scaffold.mjs +10 -1
- package/src/statuslineWiring.mjs +138 -0
- package/src/update.mjs +44 -1
- package/src/upgradeReport.mjs +82 -0
- package/templates/foundry-statusline.sh +88 -0
- package/templates/foundry-subagent-statusline.sh +43 -0
- package/templates/permissions.yaml.tmpl +37 -0
package/README.md
CHANGED
|
@@ -49,6 +49,14 @@ it does invoke `claude` (bounded by a closed allowlist — see its own README).
|
|
|
49
49
|
whole-file compare above (so an adopter's own surrounding lines never block it from catching
|
|
50
50
|
up) — reported `[converged]`, `[unchanged]`, or, for a malformed sentinel state or a symlinked
|
|
51
51
|
`.gitignore`, `[refused]` and left untouched.
|
|
52
|
+
- `.foundry/permissions.yaml` is a SEED, not a managed file: written once when absent (an empty,
|
|
53
|
+
commented starter for standing grants — see the plugin's `docs/how-to/standing-grants.md`),
|
|
54
|
+
then operator-owned — reported `[kept]`, never compared, never drifted, never written again.
|
|
55
|
+
- The Amendments backfill: every `specs/**/feat-*.md` with a normative region and no
|
|
56
|
+
`## Amendments` section after it gets the empty section `/foundry:amend` requires, reported as
|
|
57
|
+
one row (`[amendments] backfilled N of M specs (K already present, J skipped: no normative
|
|
58
|
+
region)`). Outside the hashed region, so no `spec_sha256` moves; already-present, marker-less and
|
|
59
|
+
symlinked specs are never written; `--dry-run` prints the same row.
|
|
52
60
|
|
|
53
61
|
## Flags
|
|
54
62
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-agentic-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.1",
|
|
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",
|
|
@@ -34,7 +34,7 @@
|
|
|
34
34
|
"marketplace_name": "agentic-foundry",
|
|
35
35
|
"marketplace_repo": "lukasrepublic/agentic-foundry",
|
|
36
36
|
"plugin_name": "foundry",
|
|
37
|
-
"plugin_version": "1.
|
|
37
|
+
"plugin_version": "1.17.1",
|
|
38
38
|
"pins_researched": "2026-08-02"
|
|
39
39
|
}
|
|
40
40
|
}
|
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.
|
|
4
|
+
"generated_for_plugin_version": "1.17.1",
|
|
5
5
|
"entries": [
|
|
6
6
|
{
|
|
7
7
|
"rule": "Bash(~/.claude/plugins/cache/*/foundry/*/scripts/foundry-acceptance-contract-validate.py:*)",
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
// amendmentsBackfill.mjs — amendments-backfill (ER #214, AC-AMB-1..5).
|
|
2
|
+
//
|
|
3
|
+
// `/foundry:amend` (v1.11.0) refuses, by design and by test, when a spec has no `## Amendments`
|
|
4
|
+
// heading after its LAST `<!-- /normative -->` marker (outside fenced code) — see
|
|
5
|
+
// scripts/foundry-amend.py `amendments_section_ok`, whose rule this module mirrors byte for byte
|
|
6
|
+
// (tests/test_amendments_backfill.py cross-checks the two over one fixture set). Nothing added that
|
|
7
|
+
// section to a spec written before the verb existed, so on an upgraded corpus the verb never fires:
|
|
8
|
+
// 0 of 204 specs on one adopter, 7 of 360 on the self-hosting workspace (2026-09-22/23).
|
|
9
|
+
//
|
|
10
|
+
// The section sits OUTSIDE the hashed normative region, so appending it moves no `spec_sha256`
|
|
11
|
+
// and no authorization (AC-AMB-4 pins that with the real hashing function). This module therefore
|
|
12
|
+
// runs on the `--existing` reconcile and the upgrader's Phase 4 like the gitignore block does:
|
|
13
|
+
// idempotent, never-clobber in the only sense that matters (a spec that already has the section is
|
|
14
|
+
// never written), atomic writes, and a plan/apply split so `--dry-run` prints the same row.
|
|
15
|
+
import fs from 'node:fs';
|
|
16
|
+
import path from 'node:path';
|
|
17
|
+
import { confinedJoin } from './util.mjs';
|
|
18
|
+
|
|
19
|
+
export const NORMATIVE_CLOSE = '<!-- /normative -->';
|
|
20
|
+
export const AMENDMENTS_HEADING = '## Amendments';
|
|
21
|
+
/** Exactly what is appended (AC-AMB-1); the leading blank line is added only when the file does
|
|
22
|
+
* not already end with one, so a spec ending "...\n" gets "\n## Amendments..." and one ending
|
|
23
|
+
* "...\n\n" gets the heading directly. Header columns are `foundry-amend.py`'s own row shape. */
|
|
24
|
+
export const AMENDMENTS_BLOCK = `${AMENDMENTS_HEADING}\n\n| date | what changed | why reality required it | auth_seq |\n|---|---|---|---|\n`;
|
|
25
|
+
|
|
26
|
+
// ER #223 (v1.17.1): EVERY `*.md` under specs/, not only `feat-*.md` — foundry-amend.py has no
|
|
27
|
+
// filename rule (it classifies by the normative region), and an adopter's delivery atoms are named
|
|
28
|
+
// `spec-*.md`. A README or template without a normative region lands in `skipped`, never written.
|
|
29
|
+
const SPEC_BASENAME_RE = /\.md$/;
|
|
30
|
+
const CODE_FENCE_RE = /```[\s\S]*?```/g;
|
|
31
|
+
|
|
32
|
+
/** The verdict `foundry-amend.py`'s amendments_section_ok gives: `present` when a `## Amendments`
|
|
33
|
+
* heading appears after the LAST normative close marker and outside any fenced code block;
|
|
34
|
+
* `absent` when it does not; `no-marker` when the spec has no close marker at all (amend's own
|
|
35
|
+
* whole-body fallback applies there, and this module never writes such a file). Fenced blocks are
|
|
36
|
+
* masked with same-length filler so the heading's offset stays comparable to the marker's, exactly
|
|
37
|
+
* as the Python does; CRLF is tolerated because neither search depends on line structure. */
|
|
38
|
+
export function classifySpec(text) {
|
|
39
|
+
const closeIdx = text.lastIndexOf(NORMATIVE_CLOSE);
|
|
40
|
+
if (closeIdx === -1) return 'no-marker';
|
|
41
|
+
const masked = text.replace(CODE_FENCE_RE, (m) => '\0'.repeat(m.length));
|
|
42
|
+
const idx = masked.indexOf(AMENDMENTS_HEADING);
|
|
43
|
+
if (idx !== -1 && idx > closeIdx) return 'present';
|
|
44
|
+
// A heading BEFORE the marker does not count for amend either — the section must follow the
|
|
45
|
+
// normative region — so it is `absent` and the block is appended at the end of the file.
|
|
46
|
+
return 'absent';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Every regular `*.md` under `<root>/specs` (any basename — ER #223), depth-first, with symlinked FILES reported
|
|
50
|
+
* separately (never followed, never written — AC-AMB-2) and symlinked DIRECTORIES not descended
|
|
51
|
+
* (the same confinement instinct as the scaffold's confinedJoin: nothing outside the workspace
|
|
52
|
+
* root is ever touched). Absent `specs/` yields an empty walk, not an error. */
|
|
53
|
+
export function walkSpecs(physicalRoot) {
|
|
54
|
+
const files = [];
|
|
55
|
+
const symlinks = [];
|
|
56
|
+
// v1.17.1 security review Risk 1: the ROOT is confined like every entry under it — a `specs`
|
|
57
|
+
// that is itself a symlink (or resolves outside the workspace) is never descended, never written.
|
|
58
|
+
const specsRoot = confinedJoin(physicalRoot, 'specs');
|
|
59
|
+
if (!specsRoot) return { files, symlinks };
|
|
60
|
+
const rootStat = fs.lstatSync(specsRoot, { throwIfNoEntry: false });
|
|
61
|
+
if (!rootStat || rootStat.isSymbolicLink() || !rootStat.isDirectory()) return { files, symlinks };
|
|
62
|
+
const stack = [specsRoot];
|
|
63
|
+
while (stack.length > 0) {
|
|
64
|
+
const dir = stack.pop();
|
|
65
|
+
let entries;
|
|
66
|
+
try {
|
|
67
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
68
|
+
} catch {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
for (const ent of entries) {
|
|
72
|
+
const abs = path.join(dir, ent.name);
|
|
73
|
+
if (ent.isSymbolicLink()) {
|
|
74
|
+
if (SPEC_BASENAME_RE.test(ent.name)) symlinks.push(abs);
|
|
75
|
+
continue; // never descend a symlinked directory, never write a symlinked file
|
|
76
|
+
}
|
|
77
|
+
if (ent.isDirectory()) stack.push(abs);
|
|
78
|
+
else if (ent.isFile() && SPEC_BASENAME_RE.test(ent.name)) files.push(abs);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
files.sort();
|
|
82
|
+
symlinks.sort();
|
|
83
|
+
return { files, symlinks };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Plan only — no write. `{ toAppend: [abs...], present, skipped, symlinks, total }` where `total`
|
|
87
|
+
* counts every regular spec seen (the "of <m>" in the row) and `skipped` is the no-marker count. */
|
|
88
|
+
export function planAmendmentsBackfill({ physicalRoot }) {
|
|
89
|
+
const { files, symlinks } = walkSpecs(physicalRoot);
|
|
90
|
+
const toAppend = [];
|
|
91
|
+
let present = 0;
|
|
92
|
+
let skipped = 0;
|
|
93
|
+
for (const abs of files) {
|
|
94
|
+
let text;
|
|
95
|
+
try {
|
|
96
|
+
text = fs.readFileSync(abs, 'utf-8');
|
|
97
|
+
} catch {
|
|
98
|
+
skipped += 1;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
const verdict = classifySpec(text);
|
|
102
|
+
if (verdict === 'present') present += 1;
|
|
103
|
+
else if (verdict === 'no-marker') skipped += 1;
|
|
104
|
+
else toAppend.push(abs);
|
|
105
|
+
}
|
|
106
|
+
return { toAppend, present, skipped, symlinks, total: files.length, applied: false };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** The bytes to append for a given current text: the block, preceded by one newline when the text
|
|
110
|
+
* does not already end with a blank line, and by TWO when it does not end with a newline at all. */
|
|
111
|
+
export function appendBytesFor(text) {
|
|
112
|
+
if (text === '' || text.endsWith('\n\n') || text.endsWith('\r\n\r\n')) return AMENDMENTS_BLOCK;
|
|
113
|
+
if (text.endsWith('\n')) return `\n${AMENDMENTS_BLOCK}`;
|
|
114
|
+
return `\n\n${AMENDMENTS_BLOCK}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Atomic append: read, compose, write a sibling temp file, rename over the original (the same
|
|
118
|
+
* discipline as gitignoreReconcile's writer). Re-classifies right before writing so a file that
|
|
119
|
+
* gained the section between plan and apply is left alone. Returns the count actually written. */
|
|
120
|
+
export function applyAmendmentsBackfill(plan) {
|
|
121
|
+
let written = 0;
|
|
122
|
+
for (const abs of plan.toAppend) {
|
|
123
|
+
let text;
|
|
124
|
+
try {
|
|
125
|
+
text = fs.readFileSync(abs, 'utf-8');
|
|
126
|
+
} catch {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
if (classifySpec(text) !== 'absent') continue;
|
|
130
|
+
const tmp = `${abs}.amendments-backfill.tmp`;
|
|
131
|
+
// v1.17.1 security review Risk 2: `wx` refuses to write through a planted sibling — a symlink
|
|
132
|
+
// or a leftover file at the temp path means this spec is skipped, never written elsewhere.
|
|
133
|
+
try {
|
|
134
|
+
fs.writeFileSync(tmp, text + appendBytesFor(text), { encoding: 'utf-8', flag: 'wx' });
|
|
135
|
+
} catch {
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
fs.renameSync(tmp, abs);
|
|
140
|
+
} catch {
|
|
141
|
+
fs.rmSync(tmp, { force: true });
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
written += 1;
|
|
145
|
+
}
|
|
146
|
+
plan.applied = true;
|
|
147
|
+
plan.written = written;
|
|
148
|
+
return written;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The one row (AC-AMB-1). `null` when the workspace has no specs at all, so a fresh scaffold's
|
|
152
|
+
* output is unchanged. Under dry-run the count is what WOULD be backfilled; after apply it is what
|
|
153
|
+
* was. Symlinked spec files are named so the operator sees what was deliberately not touched. */
|
|
154
|
+
export function renderAmendmentsRow(plan) {
|
|
155
|
+
if (!plan || (plan.total === 0 && plan.symlinks.length === 0)) return null;
|
|
156
|
+
const n = plan.applied ? plan.written : plan.toAppend.length;
|
|
157
|
+
let row = ` [amendments] backfilled ${n} of ${plan.total} specs (${plan.present} already present, ${plan.skipped} skipped: no normative region)`;
|
|
158
|
+
if (plan.symlinks.length > 0) {
|
|
159
|
+
row += `; ${plan.symlinks.length} symlinked spec file(s) not touched: ${plan.symlinks.map((p) => path.basename(p)).join(', ')}`;
|
|
160
|
+
}
|
|
161
|
+
return row;
|
|
162
|
+
}
|
package/src/reconcile.mjs
CHANGED
|
@@ -12,6 +12,11 @@ export function planManagedFiles(managedFiles) {
|
|
|
12
12
|
const { present, equal, notRegular } = fileBytesEqual(f.absPath, f.bytes);
|
|
13
13
|
let action;
|
|
14
14
|
if (!present) action = 'create';
|
|
15
|
+
// permissions-scaffold (ER #215, AC-PSC-2): a SEED entry is written once when absent and is
|
|
16
|
+
// operator-owned from then on — present means `kept`, whatever its bytes: never compared,
|
|
17
|
+
// never `drifted`, never written. Only `drifted` feeds exitCodeForPlan, so a seed never
|
|
18
|
+
// turns a converged run into exit 2.
|
|
19
|
+
else if (f.seed) action = 'kept';
|
|
15
20
|
else if (notRegular) action = 'drifted';
|
|
16
21
|
else action = equal ? 'unchanged' : 'drifted';
|
|
17
22
|
return { ...f, action };
|
package/src/run.mjs
CHANGED
|
@@ -18,6 +18,8 @@ import {
|
|
|
18
18
|
resolveTarget, readTarget, applyAdditions, planReconcile, writeTargetAtomically, renderPlan,
|
|
19
19
|
} from './floorReconcile.mjs';
|
|
20
20
|
import { reconcileGitignorePlan, applyGitignorePlan, renderGitignoreRow } from './gitignoreReconcile.mjs';
|
|
21
|
+
import { planAmendmentsBackfill, applyAmendmentsBackfill, renderAmendmentsRow } from './amendmentsBackfill.mjs';
|
|
22
|
+
import { planStatuslineWiring, applyStatuslineWiring, renderStatuslineRows } from './statuslineWiring.mjs';
|
|
21
23
|
|
|
22
24
|
export { DECLARED_PATH_SET };
|
|
23
25
|
|
|
@@ -184,6 +186,28 @@ export async function runCli(argv, { cwd, isTTY, input, output, homeDir, pkgDir
|
|
|
184
186
|
print('');
|
|
185
187
|
print(gitignoreRow);
|
|
186
188
|
}
|
|
189
|
+
// amendments-backfill (ER #214, AC-AMB-1): every `specs/**/feat-*.md` with a normative region
|
|
190
|
+
// and no `## Amendments` section after it gets the empty section `/foundry:amend` requires.
|
|
191
|
+
// Planned here for the same reason as the gitignore block — --dry-run reports the same row a
|
|
192
|
+
// real run would act on — and `null` when the workspace has no specs at all (a fresh scaffold).
|
|
193
|
+
const amendmentsPlan = planAmendmentsBackfill({ physicalRoot });
|
|
194
|
+
const amendmentsRow = renderAmendmentsRow(amendmentsPlan);
|
|
195
|
+
if (amendmentsRow) {
|
|
196
|
+
if (!gitignoreRow) print('');
|
|
197
|
+
print(amendmentsRow);
|
|
198
|
+
}
|
|
199
|
+
// statusline-wiring (v1.17.0, AC-SLW-1/-2): ONLY on --existing --reconcile-floor. A plain
|
|
200
|
+
// --existing run never touches .claude/settings.json (AC-BCL-9: an existing settings file is
|
|
201
|
+
// reported drifted, left byte-identical, never merged); --reconcile-floor is the one opt-in
|
|
202
|
+
// that already permits a narrow-key write to it, and this wiring is the same class of write.
|
|
203
|
+
// The greenfield create path never wires it — feat-foundry-bootstrap-cli AC-BCL-4(c) closes
|
|
204
|
+
// the pre-session key set, deliberately. planStatuslineWiring returns an empty plan when
|
|
205
|
+
// settings.json is absent. The upgrader (update.mjs) always reconciles the floor, so it
|
|
206
|
+
// always wires.
|
|
207
|
+
const statuslinePlan = answers.existing && answers.reconcileFloor
|
|
208
|
+
? planStatuslineWiring({ physicalRoot, templatesDir: path.join(pkgDir, 'templates') })
|
|
209
|
+
: null;
|
|
210
|
+
for (const row of renderStatuslineRows(statuslinePlan)) print(row);
|
|
187
211
|
|
|
188
212
|
// Resolved HERE — before the write phase and before the dry-run return — because the reconcile
|
|
189
213
|
// below must know what it would add in order to decide whether to write at all, and --dry-run
|
|
@@ -259,6 +283,9 @@ export async function runCli(argv, { cwd, isTTY, input, output, homeDir, pkgDir
|
|
|
259
283
|
// since it is a data-integrity issue local to one file, not the security-shaped case
|
|
260
284
|
// --reconcile-floor's pre-write refusal exists for.
|
|
261
285
|
applyGitignorePlan(gitignorePlan);
|
|
286
|
+
// Same never-clobber posture as the gitignore block: only a spec classified `absent` is ever
|
|
287
|
+
// written, and it is re-classified immediately before the atomic append (AC-AMB-2).
|
|
288
|
+
applyAmendmentsBackfill(amendmentsPlan);
|
|
262
289
|
|
|
263
290
|
if (floorHasWork) {
|
|
264
291
|
// floorPlan.settingsObj is ALREADY post-retirement (planReconcile derived it that way) —
|
|
@@ -270,6 +297,13 @@ export async function runCli(argv, { cwd, isTTY, input, output, homeDir, pkgDir
|
|
|
270
297
|
applied: true, retirementPlan: floorRetirementPlan, mapEntryCount: map.entries.length,
|
|
271
298
|
})) print(line);
|
|
272
299
|
}
|
|
300
|
+
// AFTER the floor write above: that write serialises a settings object read before this
|
|
301
|
+
// point, so wiring the statusLine keys first would have been overwritten by it. The wiring
|
|
302
|
+
// re-reads settings.json itself and adds only the absent keys (AC-SLW-2).
|
|
303
|
+
// Re-planned FRESH here (review round 2): the plan above was computed before the interactive
|
|
304
|
+
// confirmation, and a wrapper that appeared during that window must classify as `kept`, not be
|
|
305
|
+
// renamed over — the same re-plan-before-write discipline update.mjs's Phase 4 uses.
|
|
306
|
+
if (statuslinePlan) applyStatuslineWiring(planStatuslineWiring({ physicalRoot, templatesDir: path.join(pkgDir, 'templates') }));
|
|
273
307
|
|
|
274
308
|
if (slug) {
|
|
275
309
|
ensureGitRepo(physicalRoot);
|
|
@@ -324,7 +358,8 @@ export async function runCli(argv, { cwd, isTTY, input, output, homeDir, pkgDir
|
|
|
324
358
|
// all keep the standard hand-off. The gitignore-block-reconcile counts too: it is the SAME
|
|
325
359
|
// kind of write to an already-trusted workspace floorPlan's own comment describes, just to a
|
|
326
360
|
// different file.
|
|
327
|
-
reconciledExisting: Boolean(floorPlan && floorPlan.total > 0) || gitignoreWrote
|
|
361
|
+
reconciledExisting: Boolean(floorPlan && floorPlan.total > 0) || gitignoreWrote
|
|
362
|
+
|| Boolean(amendmentsPlan && amendmentsPlan.applied && amendmentsPlan.written > 0),
|
|
328
363
|
}));
|
|
329
364
|
|
|
330
365
|
// A refused gitignore block joins the SAME non-zero bucket `drifted` files use (exit 2, "needs
|
package/src/scaffold.mjs
CHANGED
|
@@ -13,6 +13,11 @@ export const TEMPLATE_ENTRIES = Object.freeze([
|
|
|
13
13
|
{ template: 'specs-features-README.md', target: 'specs/features/README.md' },
|
|
14
14
|
{ template: 'specs-lifecycle-README.md', target: 'specs/lifecycle/README.md' },
|
|
15
15
|
{ template: 'foundry-README.md', target: '.foundry/README.md' },
|
|
16
|
+
// permissions-scaffold (ER #215, AC-PSC-2): a SEED, not a managed file. Written once when
|
|
17
|
+
// absent; once present it is operator-owned — never read for comparison, never reported
|
|
18
|
+
// drifted, never written again (reconcile.mjs reports it `kept`). The other entries are
|
|
19
|
+
// framework-owned text an adopter is not expected to edit; this one is the opposite.
|
|
20
|
+
{ template: 'permissions.yaml.tmpl', target: '.foundry/permissions.yaml', seed: true },
|
|
16
21
|
]);
|
|
17
22
|
|
|
18
23
|
/** Render a template's raw text with the CLAUDE.md substitutions (the only templated file). */
|
|
@@ -46,7 +51,10 @@ export function buildManagedFiles({
|
|
|
46
51
|
}
|
|
47
52
|
const raw = fs.readFileSync(path.join(templatesDir, entry.template), 'utf-8');
|
|
48
53
|
const rendered = renderTemplate(entry.target, raw, { projectName, stageMode });
|
|
49
|
-
files.push({
|
|
54
|
+
files.push({
|
|
55
|
+
relPath: entry.target, absPath: joined, bytes: Buffer.from(rendered, 'utf-8'),
|
|
56
|
+
...(entry.seed ? { seed: true } : {}),
|
|
57
|
+
});
|
|
50
58
|
}
|
|
51
59
|
|
|
52
60
|
const settingsJoined = confinedJoin(physicalRoot, '.claude/settings.json');
|
|
@@ -68,4 +76,5 @@ export const DECLARED_PATH_SET = Object.freeze([
|
|
|
68
76
|
'specs/features/README.md',
|
|
69
77
|
'specs/lifecycle/README.md',
|
|
70
78
|
'.foundry/README.md',
|
|
79
|
+
'.foundry/permissions.yaml',
|
|
71
80
|
]);
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// statuslineWiring.mjs — statusline-wiring (v1.17.0, AC-SLW-1/-2).
|
|
2
|
+
//
|
|
3
|
+
// The token-budget status line (`⌂ <repo>:<branch> · tok ██████░░░░ 69% · ⚙️ factory`) is rendered
|
|
4
|
+
// by the plugin's shipped `scripts/foundry-statusline.sh`, reached through a thin wrapper at
|
|
5
|
+
// `.claude/hooks/foundry-statusline.sh` and a `statusLine` key in `.claude/settings.json`. Until
|
|
6
|
+
// v1.17.0 nothing shipped that wiring: /foundry:init only verified it, and the wrapper printed
|
|
7
|
+
// nothing when its one cache glob missed — on the operator's second machine the bar was simply
|
|
8
|
+
// absent, with no line saying which piece was missing.
|
|
9
|
+
//
|
|
10
|
+
// This module is the WRITER, and it runs only post-trust — from `update-agentic-workspace` and from
|
|
11
|
+
// `create-agentic-workspace --existing` on a workspace whose `.claude/settings.json` already exists.
|
|
12
|
+
// The greenfield create path never calls it: feat-foundry-bootstrap-cli AC-BCL-4(c) (a frozen
|
|
13
|
+
// security Block) closes the pre-session settings key set, and that reasoning stands.
|
|
14
|
+
//
|
|
15
|
+
// Two artifacts, two disciplines:
|
|
16
|
+
// * the wrappers are FRAMEWORK-OWNED: absent -> create; present with the framework marker ->
|
|
17
|
+
// converged onto the shipped bytes; present WITHOUT the marker -> kept (the operator wrote
|
|
18
|
+
// their own; it is never touched). Mode 0755.
|
|
19
|
+
// * the settings keys are ADDED ONLY WHEN ABSENT: an existing `statusLine`/`subagentStatusLine`
|
|
20
|
+
// value, whatever it points at, is never overwritten. Same narrow-key discipline as the floor.
|
|
21
|
+
// Neither ever contributes to the exit-2 drift verdict.
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { confinedJoin } from './util.mjs';
|
|
25
|
+
import { resolveTarget, readTarget, writeTargetAtomically } from './floorReconcile.mjs';
|
|
26
|
+
|
|
27
|
+
export const MARKER = 'feat-foundry-init-statusline-wrapper';
|
|
28
|
+
export const WRAPPERS = Object.freeze([
|
|
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' },
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
export function desiredSettingsValue(rel) {
|
|
34
|
+
return { type: 'command', command: `$CLAUDE_PROJECT_DIR/${rel}`, padding: 0 };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Plan only. `files[]` rows carry `{ rel, abs, action: create|converged|unchanged|kept|refused, bytes }`;
|
|
38
|
+
* `keys[]` rows carry `{ key, action: wired|already-wired }`; `settingsPresent` says whether the
|
|
39
|
+
* post-trust precondition held (when it did not, the plan is empty and renders nothing). */
|
|
40
|
+
export function planStatuslineWiring({ physicalRoot, templatesDir }) {
|
|
41
|
+
const target = resolveTarget(physicalRoot);
|
|
42
|
+
const plan = { settingsPresent: target.present, settingsPath: target.path, files: [], keys: [], applied: false };
|
|
43
|
+
if (!target.present) return plan;
|
|
44
|
+
for (const w of WRAPPERS) {
|
|
45
|
+
const abs = confinedJoin(physicalRoot, w.rel);
|
|
46
|
+
const bytes = fs.readFileSync(path.join(templatesDir, w.template));
|
|
47
|
+
if (abs === null) { plan.files.push({ rel: w.rel, abs: null, action: 'refused', reason: 'path escapes the target root', bytes }); continue; }
|
|
48
|
+
const st = fs.lstatSync(abs, { throwIfNoEntry: false });
|
|
49
|
+
let action;
|
|
50
|
+
if (!st) action = 'create';
|
|
51
|
+
else if (!st.isFile()) action = 'refused';
|
|
52
|
+
else {
|
|
53
|
+
const cur = fs.readFileSync(abs);
|
|
54
|
+
if (cur.equals(bytes)) action = 'unchanged';
|
|
55
|
+
else if (cur.toString('utf-8').includes(MARKER)) action = 'converged';
|
|
56
|
+
else action = 'kept';
|
|
57
|
+
}
|
|
58
|
+
plan.files.push({ rel: w.rel, abs, action, bytes, ...(action === 'refused' && st ? { reason: 'not a regular file' } : {}) });
|
|
59
|
+
}
|
|
60
|
+
const settings = readTarget(target.path);
|
|
61
|
+
for (const w of WRAPPERS) {
|
|
62
|
+
const present = Object.prototype.hasOwnProperty.call(settings, w.key);
|
|
63
|
+
// Security review (Risk 2): never wire a key at a file this framework did not write or could
|
|
64
|
+
// not verify — a `kept` (no marker) or `refused` (not a regular file) wrapper leaves its key
|
|
65
|
+
// `not-wired`, the row says so, and the operator decides. Decided at PLAN time so the preview
|
|
66
|
+
// and a dry-run say exactly what apply will do.
|
|
67
|
+
const fileRow = plan.files.find((f) => f.rel === w.rel);
|
|
68
|
+
const unverifiable = fileRow && (fileRow.action === 'kept' || fileRow.action === 'refused');
|
|
69
|
+
plan.keys.push({ key: w.key, action: present ? 'already-wired' : (unverifiable ? 'not-wired' : 'wired') });
|
|
70
|
+
}
|
|
71
|
+
return plan;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Apply: write create/converged wrappers atomically with mode 0755; add absent keys in one
|
|
75
|
+
* atomic settings write, leaving every other key byte-for-byte as it was. */
|
|
76
|
+
export function applyStatuslineWiring(plan) {
|
|
77
|
+
if (!plan.settingsPresent) return plan;
|
|
78
|
+
for (const f of plan.files) {
|
|
79
|
+
if (f.action !== 'create' && f.action !== 'converged') continue;
|
|
80
|
+
fs.mkdirSync(path.dirname(f.abs), { recursive: true });
|
|
81
|
+
// Security review (statusline-wiring, Block 1): the temp path is opened with 'wx' — O_EXCL,
|
|
82
|
+
// never following a planted symlink at that name — pid-suffixed against a concurrent run,
|
|
83
|
+
// fchmod'ed on the fd (no follow-up chmod that would follow a link), and removed if the
|
|
84
|
+
// rename fails. The same primitive floorReconcile.writeTargetAtomically uses (PR #61 Block 2).
|
|
85
|
+
const tmp = path.join(path.dirname(f.abs), `.${path.basename(f.abs)}.${process.pid}.tmp`);
|
|
86
|
+
const fd = fs.openSync(tmp, 'wx', 0o755);
|
|
87
|
+
try {
|
|
88
|
+
fs.writeFileSync(fd, f.bytes);
|
|
89
|
+
fs.fchmodSync(fd, 0o755);
|
|
90
|
+
fs.fsyncSync(fd);
|
|
91
|
+
} finally {
|
|
92
|
+
fs.closeSync(fd);
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
fs.renameSync(tmp, f.abs);
|
|
96
|
+
} catch (e) {
|
|
97
|
+
fs.rmSync(tmp, { force: true });
|
|
98
|
+
throw e;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const toAdd = plan.keys.filter((k) => k.action === 'wired');
|
|
102
|
+
if (toAdd.length > 0) {
|
|
103
|
+
const settings = readTarget(plan.settingsPath);
|
|
104
|
+
for (const k of toAdd) {
|
|
105
|
+
if (Object.prototype.hasOwnProperty.call(settings, k.key)) continue; // raced in since plan
|
|
106
|
+
const w = WRAPPERS.find((x) => x.key === k.key);
|
|
107
|
+
settings[k.key] = desiredSettingsValue(w.rel);
|
|
108
|
+
}
|
|
109
|
+
writeTargetAtomically(plan.settingsPath, settings);
|
|
110
|
+
}
|
|
111
|
+
plan.applied = true;
|
|
112
|
+
return plan;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Rows for the preview / Phase 4 output. Empty when the post-trust precondition did not hold. */
|
|
116
|
+
export function renderStatuslineRows(plan) {
|
|
117
|
+
if (!plan || !plan.settingsPresent) return [];
|
|
118
|
+
const rows = [];
|
|
119
|
+
for (const f of plan.files) {
|
|
120
|
+
const suffix = f.action === 'kept' ? ' (operator-owned, no framework marker — never reconciled)'
|
|
121
|
+
: f.action === 'refused' ? ` (refused: ${f.reason})` : '';
|
|
122
|
+
rows.push(` [${f.action}] ${f.rel}${suffix}`);
|
|
123
|
+
}
|
|
124
|
+
const wired = plan.keys.filter((k) => k.action === 'wired').map((k) => k.key);
|
|
125
|
+
const already = plan.keys.filter((k) => k.action === 'already-wired').map((k) => k.key);
|
|
126
|
+
const notWired = plan.keys.filter((k) => k.action === 'not-wired').map((k) => k.key);
|
|
127
|
+
if (wired.length) rows.push(` [statusline] wired ${wired.join(', ')} in .claude/settings.json`);
|
|
128
|
+
if (already.length) rows.push(` [statusline] already wired: ${already.join(', ')} (existing value kept)`);
|
|
129
|
+
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
|
+
return rows;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function statuslineChanged(plan) {
|
|
134
|
+
return Boolean(plan && plan.applied && (
|
|
135
|
+
plan.files.some((f) => f.action === 'create' || f.action === 'converged')
|
|
136
|
+
|| plan.keys.some((k) => k.action === 'wired')
|
|
137
|
+
));
|
|
138
|
+
}
|
package/src/update.mjs
CHANGED
|
@@ -15,6 +15,9 @@ import {
|
|
|
15
15
|
resolveTarget, readTarget, applyAdditions, planReconcile, writeTargetAtomically, renderPlan,
|
|
16
16
|
} from './floorReconcile.mjs';
|
|
17
17
|
import { reconcileGitignorePlan, applyGitignorePlan, renderGitignoreRow } from './gitignoreReconcile.mjs';
|
|
18
|
+
import { planAmendmentsBackfill, applyAmendmentsBackfill, renderAmendmentsRow } from './amendmentsBackfill.mjs';
|
|
19
|
+
import { buildUpgradeReport, writeUpgradeReport, installedVersionBefore, NEXT_LINE } from './upgradeReport.mjs';
|
|
20
|
+
import { planStatuslineWiring, applyStatuslineWiring, renderStatuslineRows, statuslineChanged } from './statuslineWiring.mjs';
|
|
18
21
|
import {
|
|
19
22
|
ALLOWED_CLAUDE_SUBCOMMANDS, resolveClaudeOnPath, runClaude,
|
|
20
23
|
defaultScopes, snapshotScopes, classifyMigration, migrationActions, migrateScope,
|
|
@@ -121,6 +124,8 @@ export async function runUpdate(argv, { cwd, configDir, homeDir, pkgDir, output,
|
|
|
121
124
|
|
|
122
125
|
const registry = readInstalledPluginsRegistry(configDir);
|
|
123
126
|
const isInstalled = isInstalledInScopeFactory(registry, pluginKey, cwd);
|
|
127
|
+
// ER #222: the report's `from` is what THIS workspace had installed, read before any mutation.
|
|
128
|
+
const installedBefore = installedVersionBefore(registry, pluginKey, cwd);
|
|
124
129
|
|
|
125
130
|
const migrations = [];
|
|
126
131
|
const indeterminateInstalledness = [];
|
|
@@ -207,6 +212,12 @@ export async function runUpdate(argv, { cwd, configDir, homeDir, pkgDir, output,
|
|
|
207
212
|
}
|
|
208
213
|
const previewGitignoreRow = renderGitignoreRow(previewGitignorePlan);
|
|
209
214
|
if (previewGitignoreRow) previewLines.push(previewGitignoreRow);
|
|
215
|
+
// amendments-backfill (ER #214, AC-AMB-1): PREVIEW-ONLY like the two rows above; Phase 4
|
|
216
|
+
// re-plans fresh from disk before it writes.
|
|
217
|
+
const previewAmendmentsRow = renderAmendmentsRow(planAmendmentsBackfill({ physicalRoot }));
|
|
218
|
+
if (previewAmendmentsRow) previewLines.push(previewAmendmentsRow);
|
|
219
|
+
// statusline-wiring (AC-SLW-1/-2): PREVIEW-ONLY rows; Phase 4 re-plans fresh from disk.
|
|
220
|
+
previewLines.push(...renderStatuslineRows(planStatuslineWiring({ physicalRoot, templatesDir })));
|
|
210
221
|
print(previewLines.join('\n'));
|
|
211
222
|
|
|
212
223
|
const env = { ...spawnEnv, CLAUDE_CONFIG_DIR: configDir };
|
|
@@ -294,20 +305,52 @@ export async function runUpdate(argv, { cwd, configDir, homeDir, pkgDir, output,
|
|
|
294
305
|
if (gitignoreRow) print(gitignoreRow);
|
|
295
306
|
}
|
|
296
307
|
|
|
308
|
+
// amendments-backfill (ER #214, AC-AMB-1/-2): re-planned FRESH from disk like the two blocks
|
|
309
|
+
// above, applied with the module's own re-classify-before-append guard.
|
|
310
|
+
const amendmentsPlan = planAmendmentsBackfill({ physicalRoot });
|
|
311
|
+
applyAmendmentsBackfill(amendmentsPlan);
|
|
312
|
+
const amendmentsRow = renderAmendmentsRow(amendmentsPlan);
|
|
313
|
+
if (amendmentsRow) print(amendmentsRow);
|
|
314
|
+
|
|
297
315
|
const anyCreated = filePlan.some((f) => f.action === 'create');
|
|
298
316
|
const anyFloorAdded = Boolean(floorPlan && floorPlan.total > 0)
|
|
299
317
|
|| Boolean(floorRetirementPlan && floorRetirementPlan.total > 0);
|
|
300
318
|
const anyGitignoreChanged = Boolean(
|
|
301
319
|
freshGitignorePlan && (freshGitignorePlan.action === 'converged' || freshGitignorePlan.action === 'appended'),
|
|
302
320
|
);
|
|
321
|
+
const anyAmendmentsBackfilled = amendmentsPlan.written > 0;
|
|
322
|
+
// statusline-wiring (v1.17.0, AC-SLW-1/-2): the updater is the post-trust writer of the
|
|
323
|
+
// wrapper files and the two settings keys (added only when absent). Planned fresh from disk
|
|
324
|
+
// here, after the floor write above, so the settings read is the current one.
|
|
325
|
+
const statuslinePlan = planStatuslineWiring({ physicalRoot, templatesDir });
|
|
326
|
+
applyStatuslineWiring(statuslinePlan);
|
|
327
|
+
for (const row of renderStatuslineRows(statuslinePlan)) print(row);
|
|
303
328
|
phases.push({
|
|
304
329
|
name: 'reinitialization',
|
|
305
|
-
verdict: anyCreated || anyFloorAdded || anyGitignoreChanged
|
|
330
|
+
verdict: anyCreated || anyFloorAdded || anyGitignoreChanged || anyAmendmentsBackfilled
|
|
331
|
+
|| statuslineChanged(statuslinePlan) ? 'changed' : 'already current',
|
|
306
332
|
});
|
|
307
333
|
|
|
308
334
|
print('');
|
|
309
335
|
print(renderSummary(phases));
|
|
310
336
|
|
|
337
|
+
// post-upgrade-skill (AC-PUS-1): the hand-off to the judgement half. Written on every
|
|
338
|
+
// completed run (overwritten — a report, not a managed file; `.foundry/*` is gitignored), and
|
|
339
|
+
// named in the LAST line so the operator's next step is never a guess.
|
|
340
|
+
const report = buildUpgradeReport({
|
|
341
|
+
installedBefore, afterEntry, toPluginVersion: pins.plugin_version, phases, filePlan, amendmentsPlan,
|
|
342
|
+
});
|
|
343
|
+
const reportPath = writeUpgradeReport(physicalRoot, report);
|
|
344
|
+
print('');
|
|
345
|
+
if (reportPath === null) {
|
|
346
|
+
// PR #218 review round 2: never hand off to a report that was not written — a planted link
|
|
347
|
+
// at that path would otherwise be what the skill reads.
|
|
348
|
+
print(' [refused] .foundry/upgrade-report.json (.foundry is not a directory, or the report path is not a regular file — NOT written)');
|
|
349
|
+
print('next: make .foundry/upgrade-report.json a regular path and re-run — do not run /foundry:post-upgrade until this run writes its report');
|
|
350
|
+
} else {
|
|
351
|
+
print(NEXT_LINE);
|
|
352
|
+
}
|
|
353
|
+
|
|
311
354
|
const anyDrifted = filePlan.some((f) => f.action === 'drifted');
|
|
312
355
|
// Same bucket a `drifted` managed file uses (exit 2), not the hard-refusal exit 1 — Phases 1-4
|
|
313
356
|
// already ran and wrote what they could; a malformed gitignore block is reported, not escalated
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// upgradeReport.mjs — post-upgrade-skill (AC-PUS-1).
|
|
2
|
+
//
|
|
3
|
+
// An upgrade has two halves. The deterministic half is this package: file reconcile, the
|
|
4
|
+
// Amendments backfill, the permissions seed. The judgement half — standing grants into policy,
|
|
5
|
+
// `requires_capabilities` on unfrozen contracts, a truth pass over the adopter's own prose,
|
|
6
|
+
// branch garbage collection — cannot honestly be a script and lives in the plugin's
|
|
7
|
+
// `/foundry:post-upgrade` skill. The hand-off between the two is this report: written on every
|
|
8
|
+
// completed run (always overwritten — it is a report, not a managed file), read by the skill, and
|
|
9
|
+
// named in the updater's last output line. `.foundry/*` is gitignored by the runtime block, so the
|
|
10
|
+
// report never lands in the adopter's repo.
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { confinedJoin } from './util.mjs';
|
|
14
|
+
import { scopeRecordsFor } from './pluginRefresh.mjs';
|
|
15
|
+
|
|
16
|
+
export const REPORT_REL = '.foundry/upgrade-report.json';
|
|
17
|
+
export const REPORT_SCHEMA_VERSION = 1;
|
|
18
|
+
export const NEXT_LINE = `next: run /foundry:post-upgrade in your next session (report: ${REPORT_REL})`;
|
|
19
|
+
|
|
20
|
+
/** The plugin version strings come from the marketplace manifest — remote content refreshed by
|
|
21
|
+
* `claude plugin marketplace update` — and land in a file an agent later reads and acts on. Only a
|
|
22
|
+
* version-shaped string is copied (PR #218 security review, Risk 3); anything else is `null`. */
|
|
23
|
+
const VERSION_RE = /^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]{1,40})?$/;
|
|
24
|
+
export function versionOrNull(v) {
|
|
25
|
+
return typeof v === 'string' && v.length <= 64 && VERSION_RE.test(v) ? v : null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Pure: assemble the report object. `installedBefore` is this workspace's installed version from
|
|
29
|
+
* `installedVersionBefore` (or null); `afterEntry` is the marketplace manifest's plugin entry after
|
|
30
|
+
* the refresh (or null); `filePlan` is the managed-file plan;
|
|
31
|
+
* `amendmentsPlan` is the applied backfill plan (or null); `phases` is what renderSummary got. */
|
|
32
|
+
export function buildUpgradeReport({
|
|
33
|
+
installedBefore = null, afterEntry, toPluginVersion, phases, filePlan, amendmentsPlan, now = new Date(),
|
|
34
|
+
}) {
|
|
35
|
+
const seedRow = (filePlan || []).find((f) => f.seed);
|
|
36
|
+
return {
|
|
37
|
+
schema_version: REPORT_SCHEMA_VERSION,
|
|
38
|
+
ran_at: now.toISOString(),
|
|
39
|
+
// ER #222 (v1.17.1): THIS workspace's installed version from installed_plugins.json, captured
|
|
40
|
+
// before any mutation — never the marketplace clone's advertised version, which is per machine
|
|
41
|
+
// and already moved by the time Phase 1 reads it. null (no record, unreadable registry, a
|
|
42
|
+
// first install) makes the skill list only the current version's CHANGELOG section.
|
|
43
|
+
from_plugin_version: versionOrNull(installedBefore),
|
|
44
|
+
to_plugin_version: (afterEntry && versionOrNull(afterEntry.version)) || versionOrNull(toPluginVersion),
|
|
45
|
+
phases: (phases || []).map((p) => ({ name: p.name, verdict: p.verdict, ...(p.reason ? { reason: p.reason } : {}) })),
|
|
46
|
+
amendments: amendmentsPlan
|
|
47
|
+
? { backfilled: amendmentsPlan.written ?? 0, present: amendmentsPlan.present, skipped: amendmentsPlan.skipped }
|
|
48
|
+
: { backfilled: 0, present: 0, skipped: 0 },
|
|
49
|
+
permissions_policy: seedRow ? (seedRow.action === 'create' ? 'created' : 'kept') : 'absent',
|
|
50
|
+
drifted: (filePlan || []).filter((f) => f.action === 'drifted').map((f) => f.relPath),
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** The version THIS workspace had installed before the run (ER #222): the project-scope record
|
|
55
|
+
* whose `projectPath` is `cwd`, else the user-scope record (no `projectPath`), else null. Reads the
|
|
56
|
+
* registry object `readInstalledPluginsRegistry` returns; `ok: false` is null, never a guess. */
|
|
57
|
+
export function installedVersionBefore(registry, pluginKey, cwd) {
|
|
58
|
+
if (!registry || !registry.ok) return null;
|
|
59
|
+
const records = scopeRecordsFor(registry.doc, pluginKey).filter((r) => r && typeof r === 'object');
|
|
60
|
+
const project = records.find((r) => r.projectPath && path.resolve(String(r.projectPath)) === path.resolve(cwd));
|
|
61
|
+
const user = records.find((r) => !r.projectPath);
|
|
62
|
+
const hit = project || user;
|
|
63
|
+
return hit ? versionOrNull(hit.version) : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Write the report under the workspace root, creating `.foundry/` if needed. Overwrites.
|
|
67
|
+
* Confined the way every other Phase-4 writer is (PR #218 security review, Risk 4): the target is
|
|
68
|
+
* joined through `confinedJoin`, a `.foundry` that is a symlink or a leaf that is not a regular
|
|
69
|
+
* file is REFUSED (returns null, nothing written) — the write can never land outside the
|
|
70
|
+
* physically-resolved root through a planted link. */
|
|
71
|
+
export function writeUpgradeReport(physicalRoot, report) {
|
|
72
|
+
const abs = confinedJoin(physicalRoot, REPORT_REL);
|
|
73
|
+
if (abs === null) return null;
|
|
74
|
+
const dir = path.dirname(abs);
|
|
75
|
+
const dst = fs.lstatSync(dir, { throwIfNoEntry: false });
|
|
76
|
+
if (dst && !dst.isDirectory()) return null; // `.foundry` is a symlink or a file
|
|
77
|
+
const lst = fs.lstatSync(abs, { throwIfNoEntry: false });
|
|
78
|
+
if (lst && !lst.isFile()) return null; // the leaf is a symlink or special
|
|
79
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
80
|
+
fs.writeFileSync(abs, `${JSON.stringify(report, null, 2)}\n`, 'utf-8');
|
|
81
|
+
return abs;
|
|
82
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# foundry-statusline.sh — version-agnostic, fail-open self-resolving statusLine wrapper
|
|
3
|
+
# (feat-foundry-init-statusline-wrapper, AC-SLW-1; statusline-wiring v1.17.0, AC-SLW-3).
|
|
4
|
+
#
|
|
5
|
+
# Installed into an adopter repo at .claude/hooks/foundry-statusline.sh by `npx update-agentic-workspace`
|
|
6
|
+
# (and `create-agentic-workspace --existing` on a trusted workspace), which also sets
|
|
7
|
+
# statusLine.command to "$CLAUDE_PROJECT_DIR/.claude/hooks/foundry-statusline.sh" — the EXPANDABLE
|
|
8
|
+
# placeholder. (The plugin-root hook path-placeholder is HOOK-scoped and does NOT expand in a statusLine
|
|
9
|
+
# command, so this wrapper references it NOWHERE.) The line above carrying the feature id is the
|
|
10
|
+
# FRAMEWORK MARKER: the updater converges a wrapper that carries it and keeps one that does not.
|
|
11
|
+
#
|
|
12
|
+
# RESOLUTION ORDER (v1.17.0): the renderer the plugin ships is looked for
|
|
13
|
+
# 1. via installed_plugins.json under ${CLAUDE_CONFIG_DIR:-$HOME/.claude} — the installPath Claude Code
|
|
14
|
+
# itself records for the plugin, so a non-default config root or cache layout still resolves;
|
|
15
|
+
# 2. via the plugin cache under the same root, newest by the <version> PATH-SEGMENT (the dir between
|
|
16
|
+
# /foundry/ and /scripts/), never a whole-path `sort -V` — a whole-path sort ranks by the marketplace
|
|
17
|
+
# name first, so with foundry installed under >1 marketplace an OLDER version under a lexically-
|
|
18
|
+
# greater marketplace could win (the §8 fix this wrapper has always carried);
|
|
19
|
+
# 3. via the self-hosting source checkout, ${CLAUDE_PROJECT_DIR:-$PWD}/agentic-foundry/scripts/.
|
|
20
|
+
# When none resolves, the token bar is rendered HERE from the payload — `⌂ <dir>:<branch> · tok <bar> NN%`
|
|
21
|
+
# — so a missing renderer is visible as a plainer line, never as an absent one. `/foundry:doctor`'s
|
|
22
|
+
# `statusline:` advisory says which of the four pieces is missing.
|
|
23
|
+
#
|
|
24
|
+
# FAIL-OPEN is the only invariant: any error → print what could be built (possibly nothing) and `exit 0`.
|
|
25
|
+
set +e
|
|
26
|
+
|
|
27
|
+
PAYLOAD="$(cat 2>/dev/null || true)"
|
|
28
|
+
CFG="${CLAUDE_CONFIG_DIR:-${HOME}/.claude}"
|
|
29
|
+
RENDERER="foundry-statusline.sh"
|
|
30
|
+
|
|
31
|
+
selected=""
|
|
32
|
+
# 1. installed_plugins.json (jq when available; the file is small and the key shape is fixed)
|
|
33
|
+
if [ -r "${CFG}/plugins/installed_plugins.json" ] && command -v jq >/dev/null 2>&1; then
|
|
34
|
+
ip="$(jq -r '(.plugins."foundry@agentic-foundry" // ."foundry@agentic-foundry" // []) | (if type=="array" then .[0] else . end) | (.installPath // empty)' "${CFG}/plugins/installed_plugins.json" 2>/dev/null)"
|
|
35
|
+
[ -n "$ip" ] && [ -r "${ip}/scripts/${RENDERER}" ] && selected="${ip}/scripts/${RENDERER}"
|
|
36
|
+
fi
|
|
37
|
+
# 2. the plugin cache, newest by version segment
|
|
38
|
+
if [ -z "$selected" ]; then
|
|
39
|
+
selected="$(
|
|
40
|
+
for cand in "${CFG}/plugins/cache/"*/foundry/*/scripts/${RENDERER}; do
|
|
41
|
+
[ -f "$cand" ] || continue
|
|
42
|
+
ver="$(basename "$(dirname "$(dirname "$cand")")")"
|
|
43
|
+
printf '%s\t%s\n' "$ver" "$cand"
|
|
44
|
+
done | sort -V -k1,1 | tail -1 | cut -f2-
|
|
45
|
+
)"
|
|
46
|
+
fi
|
|
47
|
+
# 3. the self-hosting source checkout
|
|
48
|
+
if [ -z "$selected" ] || [ ! -r "$selected" ]; then
|
|
49
|
+
src="${CLAUDE_PROJECT_DIR:-$PWD}/agentic-foundry/scripts/${RENDERER}"
|
|
50
|
+
[ -r "$src" ] && selected="$src"
|
|
51
|
+
fi
|
|
52
|
+
|
|
53
|
+
# The resolved file must be the shipped renderer, not merely a file at a plausible path (security
|
|
54
|
+
# review, Risk 3): its own header line is required before it is run. A miss falls through to the
|
|
55
|
+
# inline bar, and so does a renderer that exits non-zero — `exec` on the right of a pipe only
|
|
56
|
+
# replaces the subshell, so the bar below is what "never silently absent" rests on.
|
|
57
|
+
if [ -n "$selected" ] && [ -r "$selected" ] && grep -q '^# foundry-statusline.sh' "$selected" 2>/dev/null; then
|
|
58
|
+
if printf '%s' "$PAYLOAD" | bash "$selected" "$@"; then
|
|
59
|
+
exit 0
|
|
60
|
+
fi
|
|
61
|
+
fi
|
|
62
|
+
|
|
63
|
+
# 4. inline fallback — the bar itself, from the payload, so it is never silently absent
|
|
64
|
+
jqr() { printf '%s' "$PAYLOAD" | jq -r "$1" 2>/dev/null || true; }
|
|
65
|
+
DIR=""; REM=""
|
|
66
|
+
if command -v jq >/dev/null 2>&1; then
|
|
67
|
+
DIR="$(jqr '(.workspace.current_dir // .workspace.project_dir // .cwd // empty)')"
|
|
68
|
+
REM="$(jqr '(.context_window.remaining_percentage // empty)')"
|
|
69
|
+
fi
|
|
70
|
+
[ -n "$DIR" ] || DIR="${CLAUDE_PROJECT_DIR:-$PWD}"
|
|
71
|
+
LABEL="$(basename "$DIR" 2>/dev/null)"
|
|
72
|
+
BRANCH="$(git -C "$DIR" symbolic-ref --short HEAD 2>/dev/null || true)"
|
|
73
|
+
[ -n "$BRANCH" ] && LABEL="${LABEL}:${BRANCH}"
|
|
74
|
+
OUT="⌂ ${LABEL}"
|
|
75
|
+
case "$REM" in
|
|
76
|
+
''|*[!0-9.]*) ;;
|
|
77
|
+
*)
|
|
78
|
+
USED="$(printf '%.0f' "$(printf '100 - %s\n' "$REM" | bc -l 2>/dev/null || echo 0)" 2>/dev/null)"
|
|
79
|
+
[ -n "$USED" ] || USED=0
|
|
80
|
+
[ "$USED" -lt 0 ] 2>/dev/null && USED=0
|
|
81
|
+
[ "$USED" -gt 100 ] 2>/dev/null && USED=100
|
|
82
|
+
FILLED=$(( USED / 10 )); BAR=""
|
|
83
|
+
i=0; while [ $i -lt 10 ]; do if [ $i -lt $FILLED ]; then BAR="${BAR}█"; else BAR="${BAR}░"; fi; i=$((i+1)); done
|
|
84
|
+
OUT="${OUT} · tok ${BAR} ${USED}%"
|
|
85
|
+
;;
|
|
86
|
+
esac
|
|
87
|
+
printf '%s\n' "$OUT"
|
|
88
|
+
exit 0
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# foundry-subagent-statusline.sh — version-agnostic, fail-open self-resolving subagentStatusLine wrapper
|
|
3
|
+
# (feat-foundry-init-statusline-wrapper, AC-SLW-1; statusline-wiring v1.17.0, AC-SLW-3).
|
|
4
|
+
#
|
|
5
|
+
# Installed at .claude/hooks/foundry-subagent-statusline.sh by `npx update-agentic-workspace`, which also
|
|
6
|
+
# sets subagentStatusLine.command to "$CLAUDE_PROJECT_DIR/.claude/hooks/foundry-subagent-statusline.sh".
|
|
7
|
+
# The line above carrying the feature id is the FRAMEWORK MARKER the updater converges on.
|
|
8
|
+
#
|
|
9
|
+
# Same three-step resolution as the main wrapper (installed_plugins.json under
|
|
10
|
+
# ${CLAUDE_CONFIG_DIR:-$HOME/.claude}, then the cache newest by version segment, then the self-hosting
|
|
11
|
+
# source). A sub-agent row has no inline fallback: with no renderer it prints nothing and exits 0.
|
|
12
|
+
set +e
|
|
13
|
+
|
|
14
|
+
PAYLOAD="$(cat 2>/dev/null || true)"
|
|
15
|
+
CFG="${CLAUDE_CONFIG_DIR:-${HOME}/.claude}"
|
|
16
|
+
RENDERER="foundry-subagent-statusline.sh"
|
|
17
|
+
|
|
18
|
+
selected=""
|
|
19
|
+
if [ -r "${CFG}/plugins/installed_plugins.json" ] && command -v jq >/dev/null 2>&1; then
|
|
20
|
+
ip="$(jq -r '(.plugins."foundry@agentic-foundry" // ."foundry@agentic-foundry" // []) | (if type=="array" then .[0] else . end) | (.installPath // empty)' "${CFG}/plugins/installed_plugins.json" 2>/dev/null)"
|
|
21
|
+
[ -n "$ip" ] && [ -r "${ip}/scripts/${RENDERER}" ] && selected="${ip}/scripts/${RENDERER}"
|
|
22
|
+
fi
|
|
23
|
+
if [ -z "$selected" ]; then
|
|
24
|
+
selected="$(
|
|
25
|
+
for cand in "${CFG}/plugins/cache/"*/foundry/*/scripts/${RENDERER}; do
|
|
26
|
+
[ -f "$cand" ] || continue
|
|
27
|
+
ver="$(basename "$(dirname "$(dirname "$cand")")")"
|
|
28
|
+
printf '%s\t%s\n' "$ver" "$cand"
|
|
29
|
+
done | sort -V -k1,1 | tail -1 | cut -f2-
|
|
30
|
+
)"
|
|
31
|
+
fi
|
|
32
|
+
if [ -z "$selected" ] || [ ! -r "$selected" ]; then
|
|
33
|
+
src="${CLAUDE_PROJECT_DIR:-$PWD}/agentic-foundry/scripts/${RENDERER}"
|
|
34
|
+
[ -r "$src" ] && selected="$src"
|
|
35
|
+
fi
|
|
36
|
+
|
|
37
|
+
[ -n "$selected" ] || exit 0
|
|
38
|
+
[ -r "$selected" ] || exit 0
|
|
39
|
+
# The resolved file must be the shipped renderer (its own header line), not merely a file at a
|
|
40
|
+
# plausible path — security review, Risk 3.
|
|
41
|
+
grep -q '^# foundry-subagent-statusline.sh' "$selected" 2>/dev/null || exit 0
|
|
42
|
+
printf '%s' "$PAYLOAD" | bash "$selected" "$@"
|
|
43
|
+
exit 0
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# .foundry/permissions.yaml — this workspace's STANDING GRANTS as policy.
|
|
2
|
+
#
|
|
3
|
+
# The operator edits this file; the agent never does (`Edit`/`Write` on it are refused by the
|
|
4
|
+
# compiled floor). `foundry-permissions-compile.py --write` turns every grant into one native
|
|
5
|
+
# Claude Code rule `<tool>(<pattern>)` in .claude/settings.json; `--check` reports drift; the
|
|
6
|
+
# doctor's `permissions-policy` line says `policy in-sync (<n>)` when the two agree. A contract's
|
|
7
|
+
# `requires_capabilities` is checked against these grants by the capability preflight before an
|
|
8
|
+
# atom is dispatched, so a denial surfaces before the work starts, not in the middle of it.
|
|
9
|
+
#
|
|
10
|
+
# Seeded EMPTY by `npx update-agentic-workspace` / `create-agentic-workspace --existing` and never
|
|
11
|
+
# reconciled again (operator-owned). Schema: <plugin>/schema/permissions.schema.json.
|
|
12
|
+
#
|
|
13
|
+
# Each grant:
|
|
14
|
+
# id a slug; surfaced in report and blocker lines
|
|
15
|
+
# tool Bash | Edit | Write | Read | WebFetch | Agent
|
|
16
|
+
# pattern the native rule's parenthesised body — the compiled rule is exactly <tool>(<pattern>)
|
|
17
|
+
# mode automatic — proceed once every listed precondition has been verified by command
|
|
18
|
+
# approval_required — one blocker line naming this grant's id; the operator decides
|
|
19
|
+
# preconditions closed set: ci-green, security-reviewed-label, spec-authorized, charter-committed,
|
|
20
|
+
# worktree-clean, branch-up-to-date
|
|
21
|
+
#
|
|
22
|
+
# Two worked examples (commented out — copy one under `grants:` and edit it):
|
|
23
|
+
#
|
|
24
|
+
# # An atom PR may be merged by the agent once its checks are green and the branch is current.
|
|
25
|
+
# - id: merge-atom-pr-when-green
|
|
26
|
+
# tool: Bash
|
|
27
|
+
# pattern: "gh pr merge:*"
|
|
28
|
+
# mode: automatic
|
|
29
|
+
# preconditions: [ci-green, branch-up-to-date]
|
|
30
|
+
#
|
|
31
|
+
# # Pushing a tag is always the operator's call.
|
|
32
|
+
# - id: push-release-tag
|
|
33
|
+
# tool: Bash
|
|
34
|
+
# pattern: "git push origin v*"
|
|
35
|
+
# mode: approval_required
|
|
36
|
+
schema_version: 1
|
|
37
|
+
grants: []
|