create-agentic-workspace 0.11.0 → 0.13.0
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 +5 -0
- package/package.json +2 -2
- package/permission-floor.json +30 -1
- package/src/gitignoreReconcile.mjs +278 -0
- package/src/run.mjs +40 -3
- package/src/update.mjs +37 -2
- package/templates/gitignore.tmpl +5 -0
package/README.md
CHANGED
|
@@ -33,6 +33,11 @@ it does invoke `claude` (bounded by a closed allowlist — see its own README).
|
|
|
33
33
|
- Scaffolds a seven-file, schema-valid workspace seed.
|
|
34
34
|
- Re-running is a **reconcile with a drift report** — an edited managed file is reported
|
|
35
35
|
`drifted` and left byte-identical, never overwritten. Never-clobber is unconditional.
|
|
36
|
+
- `.gitignore` gets its own narrower reconcile on top of that: the file also carries a
|
|
37
|
+
`FOUNDRY-RUNTIME-GITIGNORE-BEGIN`/`-END` managed block, converged independently of the
|
|
38
|
+
whole-file compare above (so an adopter's own surrounding lines never block it from catching
|
|
39
|
+
up) — reported `[converged]`, `[unchanged]`, or, for a malformed sentinel state or a symlinked
|
|
40
|
+
`.gitignore`, `[refused]` and left untouched.
|
|
36
41
|
|
|
37
42
|
## Flags
|
|
38
43
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-agentic-workspace",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
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.13.0",
|
|
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.13.0",
|
|
5
5
|
"entries": [
|
|
6
6
|
{
|
|
7
7
|
"rule": "Bash(~/.claude/plugins/cache/*/foundry/*/scripts/foundry-acceptance-contract-validate.py:*)",
|
|
@@ -103,6 +103,21 @@
|
|
|
103
103
|
"tier": "allow",
|
|
104
104
|
"rationale": "SessionStart injection directive over the native Tasks list; no external writes"
|
|
105
105
|
},
|
|
106
|
+
{
|
|
107
|
+
"rule": "Bash(~/.claude/plugins/cache/*/foundry/*/scripts/foundry-permissions-compile.py --check:*)",
|
|
108
|
+
"tier": "allow",
|
|
109
|
+
"rationale": "read-only drift check over .foundry/permissions.yaml vs .claude/settings.json; never writes"
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
"rule": "Bash(~/.claude/plugins/cache/*/foundry/*/scripts/foundry-permissions-compile.py --write:*)",
|
|
113
|
+
"tier": "ask",
|
|
114
|
+
"rationale": "reconciles .claude/settings.json permissions.allow/ask from the operator-owned .foundry/permissions.yaml; a ceremony, not a self-grant"
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
"rule": "Bash(~/.claude/plugins/cache/*/foundry/*/scripts/foundry-capability-preflight.py:*)",
|
|
118
|
+
"tier": "allow",
|
|
119
|
+
"rationale": "feat-foundry-authorization-capability-preflight-at-dispatch (AC-CPD-1): reads a contract/charter's requires_capabilities plus the effective settings.json/settings.local.json/user-scope settings and .foundry/permissions.yaml, prints one JSON verdict; never edits settings or the policy"
|
|
120
|
+
},
|
|
106
121
|
{
|
|
107
122
|
"rule": "Bash(~/.claude/plugins/cache/*/foundry/*/scripts/foundry-prepublication-leak-scan.py:*)",
|
|
108
123
|
"tier": "allow",
|
|
@@ -158,6 +173,11 @@
|
|
|
158
173
|
"tier": "allow",
|
|
159
174
|
"rationale": "worktree wrapper, workspace-root-confined by construction; consumed by WorktreeCreate + dispatch"
|
|
160
175
|
},
|
|
176
|
+
{
|
|
177
|
+
"rule": "Bash(~/.claude/plugins/cache/*/foundry/*/scripts/foundry_blocker_check.py:*)",
|
|
178
|
+
"tier": "allow",
|
|
179
|
+
"rationale": "deterministic blocker-shape lint (feat-foundry-blocker-requires-evidence): reads its --in argument, partitions candidate blockers against schema/blocker.schema.json, prints a JSON verdict; writes nothing"
|
|
180
|
+
},
|
|
161
181
|
{
|
|
162
182
|
"rule": "Bash(~/.claude/plugins/cache/*/foundry/*/scripts/foundry_ceremony_tier.py:*)",
|
|
163
183
|
"tier": "allow",
|
|
@@ -248,6 +268,11 @@
|
|
|
248
268
|
"tier": "ask",
|
|
249
269
|
"rationale": "pinned ceremony (AC-PFM-3): documented no-op today, but the flag is the shape a future auto-heal would reuse"
|
|
250
270
|
},
|
|
271
|
+
{
|
|
272
|
+
"rule": "Bash(~/.claude/plugins/cache/*/foundry/*/scripts/foundry-merge-when-green.py:*)",
|
|
273
|
+
"tier": "ask",
|
|
274
|
+
"rationale": "issues gh pr merge <pr> --squash once every check is green — a merge is a repo-state mutation, ask like foundry-authorize.py/foundry-amend.py, never a self-grant"
|
|
275
|
+
},
|
|
251
276
|
{
|
|
252
277
|
"rule": "Bash(~/.claude/plugins/cache/*/foundry/*/scripts/foundry-project-sync.py:*)",
|
|
253
278
|
"tier": "ask",
|
|
@@ -325,6 +350,10 @@
|
|
|
325
350
|
}
|
|
326
351
|
],
|
|
327
352
|
"not_invoked": [
|
|
353
|
+
{
|
|
354
|
+
"script": "foundry_shell_scan.py",
|
|
355
|
+
"rationale": "library: no argparse/__main__; the stdlib shell tokenizer imported by the two PreToolUse(Bash) guards (guard-structured-observations)"
|
|
356
|
+
},
|
|
328
357
|
{
|
|
329
358
|
"script": "foundry_audit_ledger.py",
|
|
330
359
|
"rationale": "library: no argparse/__main__; imported by foundry-audit-record.py for the ledger schema/writer"
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
// gitignoreReconcile.mjs — feat-gitignore-block-reconcile (ER #177, AC-GBR-1..5).
|
|
2
|
+
//
|
|
3
|
+
// `.gitignore` is one of scaffold.mjs's six template-derived managed files, so it already goes
|
|
4
|
+
// through the whole-file never-clobber plan in reconcile.mjs: absent -> `create`, byte-identical ->
|
|
5
|
+
// `unchanged`, anything else -> `drifted` and NEVER WRITTEN. That is correct for a file an adopter
|
|
6
|
+
// never touches, but `.gitignore` is NOT one of those — the template ships adopter-visible content
|
|
7
|
+
// (Risk #5's local-settings comment) around a FOUNDRY-RUNTIME-GITIGNORE-BEGIN/END block that is
|
|
8
|
+
// framework-owned. Once an adopter's `.gitignore` carries its own lines too, the whole-file compare
|
|
9
|
+
// reports `drifted` forever and the sentinel block — the one piece ER #169 needed every existing
|
|
10
|
+
// workspace to receive — never converges on an `--existing` reconcile or an upgrade run.
|
|
11
|
+
//
|
|
12
|
+
// This module is scoped to EXACTLY that block's INTERIOR, the same way floorReconcile.mjs is
|
|
13
|
+
// scoped to the `permissions` key inside settings.json: converge the interior lines between the
|
|
14
|
+
// sentinels, leave every other line byte-identical — INCLUDING the target's own BEGIN/END sentinel
|
|
15
|
+
// lines, whatever their exact text — and never touch the file at all when the interior already
|
|
16
|
+
// matches (AC-GBR-4). The sentinel lines themselves are deliberately never rewritten once a block
|
|
17
|
+
// exists: `cli/templates/gitignore.tmpl` ships them BARE (no annotation) while the shipped bash
|
|
18
|
+
// applier writes them ANNOTATED, and every bootstrapped workspace in practice carries the
|
|
19
|
+
// annotated form (PR #179 review) — replacing whole lines including the sentinels made this
|
|
20
|
+
// module and the bash applier flip a workspace's sentinel text back and forth on alternating
|
|
21
|
+
// runs, never settling. See loadDesiredInterior's own comment.
|
|
22
|
+
//
|
|
23
|
+
// It deliberately does NOT port scripts/foundry-apply-runtime-gitignore.sh's Risk #9/#10
|
|
24
|
+
// deviation-line relocation — that machinery answers "does an adopter rule silently defeat a
|
|
25
|
+
// re-include", which is out of scope here (see the charter's Out of scope): this module only
|
|
26
|
+
// answers "does the managed block's interior match the template", and reports converged either way.
|
|
27
|
+
import fs from 'node:fs';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
import { confinedJoin, RefusalError } from './util.mjs';
|
|
30
|
+
|
|
31
|
+
export const BEGIN_TOKEN = 'FOUNDRY-RUNTIME-GITIGNORE-BEGIN';
|
|
32
|
+
export const END_TOKEN = 'FOUNDRY-RUNTIME-GITIGNORE-END';
|
|
33
|
+
|
|
34
|
+
// The shipped bash applier's OWN sentinel line text (scripts/foundry-apply-runtime-gitignore.sh's
|
|
35
|
+
// `BEGIN_LINE`/`END_LINE`, copied verbatim) — used ONLY when APPENDING a brand-new block (no
|
|
36
|
+
// sentinel found at all). `cli/templates/gitignore.tmpl` ships the BARE form (no annotation) for
|
|
37
|
+
// the scaffold's own fresh-create path, so the two tools' sentinel LINE TEXT already disagrees by
|
|
38
|
+
// design; `planGitignoreBlock` below never rewrites an EXISTING sentinel line for exactly that
|
|
39
|
+
// reason (see its own comment) — but a freshly-appended block has no prior sentinel to preserve,
|
|
40
|
+
// and writing the bash applier's own annotated form here is what lets both tools settle on the
|
|
41
|
+
// SAME text from that point on, rather than each converting the other's form back to its own on
|
|
42
|
+
// alternating runs.
|
|
43
|
+
export const ANNOTATED_BEGIN_LINE = `# ${BEGIN_TOKEN} (managed by scripts/foundry-apply-runtime-gitignore.sh -- do not edit by hand)`;
|
|
44
|
+
export const ANNOTATED_END_LINE = `# ${END_TOKEN} (re-run the applier to converge; do not edit by hand)`;
|
|
45
|
+
|
|
46
|
+
/** Split raw file text into a line array with NO trailing empty element for a final newline (a
|
|
47
|
+
* file ending "a\nb\n" and one ending "a\nb" both split to ['a','b'] — the asymmetry is resolved on
|
|
48
|
+
* WRITE, not on read: see writeGitignoreAtomically, which always terminates every line, mirroring
|
|
49
|
+
* the shipped bash applier's own `write_output` (`printf '%s\n'` per line, unconditionally). A file
|
|
50
|
+
* this module never writes (the `unchanged` action) is never re-serialized at all, so an original
|
|
51
|
+
* file missing its final newline is left completely alone — this normalization only ever applies to
|
|
52
|
+
* bytes this module itself is already rewriting. */
|
|
53
|
+
function splitLines(raw) {
|
|
54
|
+
if (raw === '') return [];
|
|
55
|
+
const body = raw.endsWith('\n') ? raw.slice(0, -1) : raw;
|
|
56
|
+
return body.split('\n');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Scan a line array for the sentinel pair, exactly mirroring the shipped applier's
|
|
60
|
+
* `scan_sentinels` malformed-state checks (AC-GBR-3), plus a stricter "at most one block" rule this
|
|
61
|
+
* atom's own charter adds (the bash applier tolerates several closed blocks; the JS reconcile
|
|
62
|
+
* refuses on the second one rather than silently acting on only the first). Returns
|
|
63
|
+
* `{ ok: true, blocks: [{ start, end }] }` (0 or 1 entries) or `{ ok: false, reason }`; never
|
|
64
|
+
* throws — callers decide whether a scan failure is a refusal or a bug (see loadDesiredBlock). */
|
|
65
|
+
export function scanSentinels(lines) {
|
|
66
|
+
const blocks = [];
|
|
67
|
+
let open = -1;
|
|
68
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
69
|
+
const ln = lines[i];
|
|
70
|
+
const isBegin = ln.includes(BEGIN_TOKEN);
|
|
71
|
+
const isEnd = ln.includes(END_TOKEN);
|
|
72
|
+
if (isBegin && isEnd) {
|
|
73
|
+
return { ok: false, reason: `line ${i + 1} carries both the BEGIN and END tokens: ${ln}` };
|
|
74
|
+
}
|
|
75
|
+
if (isBegin) {
|
|
76
|
+
if (open >= 0) {
|
|
77
|
+
return {
|
|
78
|
+
ok: false,
|
|
79
|
+
reason: `BEGIN sentinel at line ${open + 1} has no matching END sentinel (another BEGIN found at line ${i + 1} first)`,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
open = i;
|
|
83
|
+
} else if (isEnd) {
|
|
84
|
+
if (open < 0) {
|
|
85
|
+
return { ok: false, reason: `END sentinel at line ${i + 1} precedes the first BEGIN sentinel` };
|
|
86
|
+
}
|
|
87
|
+
blocks.push({ start: open, end: i });
|
|
88
|
+
open = -1;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (open >= 0) {
|
|
92
|
+
return { ok: false, reason: `BEGIN sentinel at line ${open + 1} has no matching END sentinel` };
|
|
93
|
+
}
|
|
94
|
+
if (blocks.length > 1) {
|
|
95
|
+
return { ok: false, reason: `${blocks.length} managed blocks found; expected at most one` };
|
|
96
|
+
}
|
|
97
|
+
return { ok: true, blocks };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The desired block, read fresh from the shipped template every call — never a copy of its
|
|
101
|
+
* content vendored into this module, which is exactly the drift `cli/templates/gitignore.tmpl`
|
|
102
|
+
* being a denied path (this atom may read it, never edit it) is designed to prevent. Returns the
|
|
103
|
+
* BEGIN line through the END line, inclusive, exactly as `scaffold.mjs` writes them into a fresh
|
|
104
|
+
* `.gitignore` — no re-templating, no substitution (gitignore.tmpl carries none). A malformed
|
|
105
|
+
* shipped template is a packaging bug, not an adopter's data problem, so it throws a plain Error
|
|
106
|
+
* rather than a RefusalError (nothing here has offered a refusal-shaped report for the template
|
|
107
|
+
* itself to the CLI's caller). */
|
|
108
|
+
export function loadDesiredBlock(templatesDir) {
|
|
109
|
+
const raw = fs.readFileSync(path.join(templatesDir, 'gitignore.tmpl'), 'utf-8');
|
|
110
|
+
const lines = splitLines(raw);
|
|
111
|
+
const scan = scanSentinels(lines);
|
|
112
|
+
if (!scan.ok || scan.blocks.length !== 1) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
`cli/templates/gitignore.tmpl carries no single well-formed managed block: ${scan.ok ? 'no block found' : scan.reason}`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
const { start, end } = scan.blocks[0];
|
|
118
|
+
return Object.freeze(lines.slice(start, end + 1));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The desired INTERIOR only — the template's block with its own BEGIN/END lines stripped off.
|
|
122
|
+
* This, not `loadDesiredBlock`'s full block, is what `planGitignoreBlock` converges a target's
|
|
123
|
+
* block onto: PR #179 review — `cli/templates/gitignore.tmpl` ships BARE sentinel lines (no
|
|
124
|
+
* annotation) while `scripts/foundry-apply-runtime-gitignore.sh` writes ANNOTATED ones, and every
|
|
125
|
+
* bootstrapped workspace in practice carries the annotated form (the applier is what operators
|
|
126
|
+
* actually ran before this atom existed). Comparing/replacing WHOLE lines including the sentinels
|
|
127
|
+
* made the first `--existing`/upgrade run rewrite an annotated sentinel to bare, and a later bash
|
|
128
|
+
* applier run flip it back — the two tools never settled. Scoping this module to the interior only,
|
|
129
|
+
* and preserving whichever sentinel text the target already has verbatim, closes that oscillation:
|
|
130
|
+
* neither tool has an opinion on the other's sentinel line text once a block exists. */
|
|
131
|
+
export function loadDesiredInterior(templatesDir) {
|
|
132
|
+
const block = loadDesiredBlock(templatesDir);
|
|
133
|
+
return Object.freeze(block.slice(1, -1));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Resolve `.gitignore` inside the physically-resolved target root, refusing (RefusalError) any
|
|
137
|
+
* resolution that escapes it — same confinement discipline as every other managed path. lstat, not
|
|
138
|
+
* stat: a symlinked `.gitignore` must be seen as such (AC-GBR-3's "a symlinked .gitignore is
|
|
139
|
+
* refused the same way"), not silently followed. */
|
|
140
|
+
export function resolveGitignoreTarget(physicalRoot) {
|
|
141
|
+
const joined = confinedJoin(physicalRoot, '.gitignore');
|
|
142
|
+
if (joined === null) {
|
|
143
|
+
throw new RefusalError('refusing .gitignore: path escapes the target root', '.gitignore');
|
|
144
|
+
}
|
|
145
|
+
const st = fs.lstatSync(joined, { throwIfNoEntry: false });
|
|
146
|
+
if (!st) return { path: joined, present: false };
|
|
147
|
+
if (!st.isFile()) return { path: joined, present: true, notRegular: true };
|
|
148
|
+
return { path: joined, present: true };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Compute the reconcile action for an EXISTING, regular-file `.gitignore` WITHOUT touching the
|
|
152
|
+
* filesystem beyond the one read already implied by `targetPath` — this is what lets --dry-run
|
|
153
|
+
* report the same action a real run would take (AC-GBR-4). Returns one of:
|
|
154
|
+
* { action: 'unchanged' }
|
|
155
|
+
* { action: 'converged', nextLines } -- a single block found, its INTERIOR differs
|
|
156
|
+
* { action: 'appended', nextLines } -- no block found; a new block is appended
|
|
157
|
+
* { action: 'refused', reason } -- a malformed sentinel state (AC-GBR-3)
|
|
158
|
+
* `desiredInterior` is the array `loadDesiredInterior` returns (the template's block with its own
|
|
159
|
+
* sentinel lines stripped).
|
|
160
|
+
*
|
|
161
|
+
* PR #179 review: this compares and replaces only the INTERIOR lines against the template's
|
|
162
|
+
* interior. When a block already exists, its own BEGIN/END lines are copied through byte-for-byte,
|
|
163
|
+
* whatever their text — this module never has an opinion on a sentinel line it FOUND, only on one
|
|
164
|
+
* it is WRITING for the first time (the append case, which uses the bash applier's own annotated
|
|
165
|
+
* form so the two tools agree on the text from that point on; see ANNOTATED_BEGIN_LINE/END_LINE). */
|
|
166
|
+
export function planGitignoreBlock({ currentLines, desiredInterior }) {
|
|
167
|
+
const scan = scanSentinels(currentLines);
|
|
168
|
+
if (!scan.ok) {
|
|
169
|
+
return { action: 'refused', reason: scan.reason };
|
|
170
|
+
}
|
|
171
|
+
if (scan.blocks.length === 0) {
|
|
172
|
+
// AC-GBR-2: append, preceded by exactly one blank line. An empty (0-line) file has nothing to
|
|
173
|
+
// precede, so the block is written on its own rather than opening the file with a blank line.
|
|
174
|
+
const blockLines = [ANNOTATED_BEGIN_LINE, ...desiredInterior, ANNOTATED_END_LINE];
|
|
175
|
+
const nextLines = currentLines.length > 0
|
|
176
|
+
? [...currentLines, '', ...blockLines]
|
|
177
|
+
: [...blockLines];
|
|
178
|
+
return { action: 'appended', nextLines };
|
|
179
|
+
}
|
|
180
|
+
const { start, end } = scan.blocks[0];
|
|
181
|
+
const currentInterior = currentLines.slice(start + 1, end);
|
|
182
|
+
const equal = currentInterior.length === desiredInterior.length
|
|
183
|
+
&& currentInterior.every((ln, i) => ln === desiredInterior[i]);
|
|
184
|
+
if (equal) return { action: 'unchanged' };
|
|
185
|
+
// AC-GBR-1: replace EXACTLY the interior lines; the target's own BEGIN line (index `start`) and
|
|
186
|
+
// END line (index `end`) are carried through UNCHANGED — `slice(0, start + 1)` includes the
|
|
187
|
+
// BEGIN line itself, `slice(end)` includes the END line itself — and every line outside the
|
|
188
|
+
// block, on either side, is copied through untouched and in order.
|
|
189
|
+
const nextLines = [
|
|
190
|
+
...currentLines.slice(0, start + 1),
|
|
191
|
+
...desiredInterior,
|
|
192
|
+
...currentLines.slice(end),
|
|
193
|
+
];
|
|
194
|
+
return { action: 'converged', nextLines };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** The whole read-plan step for a target known to be PRESENT: resolve, refuse a non-regular file
|
|
198
|
+
* the same way a malformed sentinel state is refused, read, scan, plan. Returns `null` only when
|
|
199
|
+
* the caller should not have invoked this at all (kept as a defensive assertion, not a normal
|
|
200
|
+
* path — `reconcileGitignore` below is what decides absence is the create path's business). */
|
|
201
|
+
function planFromTarget(target, desiredInterior) {
|
|
202
|
+
if (target.notRegular) {
|
|
203
|
+
return { action: 'refused', reason: 'not a regular file (symlink or special file)', path: target.path };
|
|
204
|
+
}
|
|
205
|
+
const raw = fs.readFileSync(target.path, 'utf-8');
|
|
206
|
+
const currentLines = splitLines(raw);
|
|
207
|
+
const plan = planGitignoreBlock({ currentLines, desiredInterior });
|
|
208
|
+
return { ...plan, path: target.path };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The orchestration entry point run.mjs / update.mjs call: resolve + plan, WITHOUT writing
|
|
212
|
+
* anything (that is `applyGitignorePlan`'s job, called only after the same confirm/dry-run gate
|
|
213
|
+
* every other write in this CLI goes through). Returns `null` when `.gitignore` is absent — AC-GBR-2
|
|
214
|
+
* leaves that case to the existing managed-file create path untouched, so there is nothing for this
|
|
215
|
+
* module to plan or report; the caller must not print a row for a `null` result. */
|
|
216
|
+
export function reconcileGitignorePlan({ physicalRoot, templatesDir }) {
|
|
217
|
+
const target = resolveGitignoreTarget(physicalRoot);
|
|
218
|
+
if (!target.present) return null;
|
|
219
|
+
const desiredInterior = loadDesiredInterior(templatesDir);
|
|
220
|
+
return planFromTarget(target, desiredInterior);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Install new `.gitignore` bytes by rename, same-directory temp opened O_EXCL then fsync then
|
|
224
|
+
* rename — the identical pattern floorReconcile.mjs's `writeTargetAtomically` uses for settings.json
|
|
225
|
+
* and for the identical reason: this is a rewrite of bytes belonging to an EXISTING regular file
|
|
226
|
+
* already lstat-verified not to be a symlink, so plain reconcile.mjs's create-only O_EXCL write
|
|
227
|
+
* (which requires the path NOT exist) cannot apply here — a same-dir rename is what makes the
|
|
228
|
+
* replace atomic (same filesystem) and closes the same interrupted-write / classify-to-write-race
|
|
229
|
+
* loss floorReconcile.mjs's own comment documents, without ever truncating the file in place. */
|
|
230
|
+
export function writeGitignoreAtomically(targetPath, nextLines) {
|
|
231
|
+
const dir = path.dirname(targetPath);
|
|
232
|
+
const tmp = path.join(dir, `.gitignore.${process.pid}.tmp`);
|
|
233
|
+
const bytes = Buffer.from(nextLines.map((ln) => `${ln}\n`).join(''), 'utf-8');
|
|
234
|
+
const fd = fs.openSync(tmp, 'wx');
|
|
235
|
+
try {
|
|
236
|
+
fs.writeFileSync(fd, bytes);
|
|
237
|
+
fs.fsyncSync(fd);
|
|
238
|
+
} finally {
|
|
239
|
+
fs.closeSync(fd);
|
|
240
|
+
}
|
|
241
|
+
try {
|
|
242
|
+
fs.renameSync(tmp, targetPath);
|
|
243
|
+
} catch (e) {
|
|
244
|
+
fs.rmSync(tmp, { force: true });
|
|
245
|
+
throw e;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** Apply a plan computed by `reconcileGitignorePlan`. A no-op for every action except `converged`
|
|
250
|
+
* and `appended` — `unchanged` and `refused` both leave the file byte-for-byte as it was, which is
|
|
251
|
+
* what makes AC-GBR-4's no-write-when-already-converged guarantee hold: there is no code path from
|
|
252
|
+
* `unchanged` to a write syscall at all, not merely one that happens to produce identical bytes. */
|
|
253
|
+
export function applyGitignorePlan(plan) {
|
|
254
|
+
if (!plan) return;
|
|
255
|
+
if (plan.action === 'converged' || plan.action === 'appended') {
|
|
256
|
+
writeGitignoreAtomically(plan.path, plan.nextLines);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Render the single report row for a plan, in the SAME ` [action] path (detail)` shape
|
|
261
|
+
* preview.mjs's own rows use. Returns `null` for a `null` plan (absent `.gitignore` — nothing to
|
|
262
|
+
* print; the generic managed-file row already covers that case). The four non-null strings are
|
|
263
|
+
* exactly AC-GBR-1/-2/-3's own report text. */
|
|
264
|
+
export function renderGitignoreRow(plan) {
|
|
265
|
+
if (!plan) return null;
|
|
266
|
+
switch (plan.action) {
|
|
267
|
+
case 'converged':
|
|
268
|
+
return ' [converged] .gitignore (managed block)';
|
|
269
|
+
case 'appended':
|
|
270
|
+
return ' [converged] .gitignore (managed block appended)';
|
|
271
|
+
case 'unchanged':
|
|
272
|
+
return ' [unchanged] .gitignore (managed block)';
|
|
273
|
+
case 'refused':
|
|
274
|
+
return ` [refused] .gitignore (malformed managed block: ${plan.reason})`;
|
|
275
|
+
default:
|
|
276
|
+
throw new Error(`unknown gitignore reconcile action: ${plan.action}`);
|
|
277
|
+
}
|
|
278
|
+
}
|
package/src/run.mjs
CHANGED
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
resolveTarget, readTarget, readTrackedRules, planAdditions, applyAdditions,
|
|
19
19
|
writeTargetAtomically, renderPlan,
|
|
20
20
|
} from './floorReconcile.mjs';
|
|
21
|
+
import { reconcileGitignorePlan, applyGitignorePlan, renderGitignoreRow } from './gitignoreReconcile.mjs';
|
|
21
22
|
|
|
22
23
|
export { DECLARED_PATH_SET };
|
|
23
24
|
|
|
@@ -168,6 +169,23 @@ export async function runCli(argv, { cwd, isTTY, input, output, homeDir, pkgDir
|
|
|
168
169
|
|
|
169
170
|
print(renderPreview({ plan, machineScopeWrites, map }));
|
|
170
171
|
|
|
172
|
+
// gitignore-block-reconcile (ER #177, AC-GBR-1/-2/-3): the generic managed-file row above
|
|
173
|
+
// already covers `.gitignore` for the CREATE case (absent -> the full template lands verbatim,
|
|
174
|
+
// already converged) and for the byte-identical case, but once an adopter's `.gitignore` also
|
|
175
|
+
// carries its own lines it will never again match the template byte-for-byte, so that row reads
|
|
176
|
+
// `drifted` forever and the never-clobber plan never writes it — which is exactly why the
|
|
177
|
+
// sentinel-delimited block needs its OWN, narrower reconcile. Computed here (before the
|
|
178
|
+
// dry-run return) so --dry-run reports the same action a real run would take; `null` for an
|
|
179
|
+
// absent `.gitignore`, which is the CREATE path's business and prints nothing extra.
|
|
180
|
+
const gitignorePlan = reconcileGitignorePlan({
|
|
181
|
+
physicalRoot, templatesDir: path.join(pkgDir, 'templates'),
|
|
182
|
+
});
|
|
183
|
+
const gitignoreRow = renderGitignoreRow(gitignorePlan);
|
|
184
|
+
if (gitignoreRow) {
|
|
185
|
+
print('');
|
|
186
|
+
print(gitignoreRow);
|
|
187
|
+
}
|
|
188
|
+
|
|
171
189
|
// Resolved HERE — before the write phase and before the dry-run return — because the reconcile
|
|
172
190
|
// below must know what it would add in order to decide whether to write at all, and --dry-run
|
|
173
191
|
// must be able to report those rules. That requirement is carried by the TRACKED classification
|
|
@@ -227,6 +245,12 @@ export async function runCli(argv, { cwd, isTTY, input, output, homeDir, pkgDir
|
|
|
227
245
|
}
|
|
228
246
|
|
|
229
247
|
applyPlan(plan);
|
|
248
|
+
// A `refused` gitignorePlan is a no-op here — applyGitignorePlan only writes on `converged` /
|
|
249
|
+
// `appended` — so a malformed managed block never blocks the rest of this run's writes; it is
|
|
250
|
+
// reported (the row above, and the exit code below) rather than escalated to a hard refusal,
|
|
251
|
+
// since it is a data-integrity issue local to one file, not the security-shaped case
|
|
252
|
+
// --reconcile-floor's pre-write refusal exists for.
|
|
253
|
+
applyGitignorePlan(gitignorePlan);
|
|
230
254
|
|
|
231
255
|
if (floorPlan && floorPlan.total > 0) {
|
|
232
256
|
writeTargetAtomically(floorTarget.path, applyAdditions(floorPlan.settingsObj, floorPlan, { map, pins }));
|
|
@@ -278,14 +302,27 @@ export async function runCli(argv, { cwd, isTTY, input, output, homeDir, pkgDir
|
|
|
278
302
|
// on the path is the check that covers both — a directory-only test would tell a worktree user
|
|
279
303
|
// to re-init a repository they already have.
|
|
280
304
|
const isGitRepo = fs.existsSync(path.join(targetRoot, '.git'));
|
|
305
|
+
const gitignoreWrote = Boolean(
|
|
306
|
+
gitignorePlan && (gitignorePlan.action === 'converged' || gitignorePlan.action === 'appended'),
|
|
307
|
+
);
|
|
281
308
|
print(TRUST_HANDOFF_TEXT(targetRoot, {
|
|
282
309
|
isGitRepo,
|
|
283
310
|
// only when a reconcile actually wrote — a dry run, a no-op second run, or a plain scaffold
|
|
284
|
-
// all keep the standard hand-off
|
|
285
|
-
|
|
311
|
+
// all keep the standard hand-off. The gitignore-block-reconcile counts too: it is the SAME
|
|
312
|
+
// kind of write to an already-trusted workspace floorPlan's own comment describes, just to a
|
|
313
|
+
// different file.
|
|
314
|
+
reconciledExisting: Boolean(floorPlan && floorPlan.total > 0) || gitignoreWrote,
|
|
286
315
|
}));
|
|
287
316
|
|
|
288
|
-
|
|
317
|
+
// A refused gitignore block joins the SAME non-zero bucket `drifted` files use (exit 2, "needs
|
|
318
|
+
// the operator's attention") rather than exit 1's hard-refusal bucket — the rest of the run's
|
|
319
|
+
// writes already landed, so "refused" here must not read as "nothing happened". exitCodeForPlan
|
|
320
|
+
// only ever returns 0 or 2, so a refusal simply forces 2 rather than deferring to it.
|
|
321
|
+
const gitignoreRefused = Boolean(gitignorePlan && gitignorePlan.action === 'refused');
|
|
322
|
+
return {
|
|
323
|
+
exitCode: gitignoreRefused ? 2 : exitCodeForPlan(plan),
|
|
324
|
+
output: lines.join('\n'),
|
|
325
|
+
};
|
|
289
326
|
} catch (e) {
|
|
290
327
|
if (e instanceof RefusalError) {
|
|
291
328
|
print(`refused: ${e.message}`);
|
package/src/update.mjs
CHANGED
|
@@ -14,6 +14,7 @@ import { planManagedFiles, applyPlan } from './reconcile.mjs';
|
|
|
14
14
|
import {
|
|
15
15
|
resolveTarget, readTarget, readTrackedRules, planAdditions, applyAdditions, writeTargetAtomically,
|
|
16
16
|
} from './floorReconcile.mjs';
|
|
17
|
+
import { reconcileGitignorePlan, applyGitignorePlan, renderGitignoreRow } from './gitignoreReconcile.mjs';
|
|
17
18
|
import {
|
|
18
19
|
ALLOWED_CLAUDE_SUBCOMMANDS, resolveClaudeOnPath, runClaude,
|
|
19
20
|
defaultScopes, snapshotScopes, classifyMigration, migrationActions, migrateScope,
|
|
@@ -161,6 +162,12 @@ export async function runUpdate(argv, { cwd, configDir, homeDir, pkgDir, output,
|
|
|
161
162
|
})
|
|
162
163
|
: null;
|
|
163
164
|
|
|
165
|
+
// gitignore-block-reconcile (ER #177, AC-GBR-1): PREVIEW-ONLY, same caveat as previewFloorPlan
|
|
166
|
+
// above — `.gitignore` is not a migration target, but Phase 4 recomputes fresh from disk anyway,
|
|
167
|
+
// for the same "never apply a stale pre-migration plan" reason.
|
|
168
|
+
const templatesDir = path.join(pkgDir, 'templates');
|
|
169
|
+
const previewGitignorePlan = reconcileGitignorePlan({ physicalRoot, templatesDir });
|
|
170
|
+
|
|
164
171
|
// ── AC-UAW-7: the preview, before the first `claude` invocation and the first write ─────────
|
|
165
172
|
const previewLines = ['The following claude invocations will be made:'];
|
|
166
173
|
for (const { scopeSnap, trigger } of migrations) {
|
|
@@ -191,6 +198,8 @@ export async function runUpdate(argv, { cwd, configDir, homeDir, pkgDir, output,
|
|
|
191
198
|
} else {
|
|
192
199
|
previewLines.push(' [permission-floor] .claude/settings.json absent — left to the create path');
|
|
193
200
|
}
|
|
201
|
+
const previewGitignoreRow = renderGitignoreRow(previewGitignorePlan);
|
|
202
|
+
if (previewGitignoreRow) previewLines.push(previewGitignoreRow);
|
|
194
203
|
print(previewLines.join('\n'));
|
|
195
204
|
|
|
196
205
|
const env = { ...spawnEnv, CLAUDE_CONFIG_DIR: configDir };
|
|
@@ -255,15 +264,41 @@ export async function runUpdate(argv, { cwd, configDir, homeDir, pkgDir, output,
|
|
|
255
264
|
writeTargetAtomically(freshFloorTarget.path, applyAdditions(settingsObj, floorPlan, { map, pins }));
|
|
256
265
|
}
|
|
257
266
|
}
|
|
267
|
+
// Recomputed FRESH from disk, same reasoning as floorPlan just above: never apply a plan
|
|
268
|
+
// captured before Phases 1-3 ran, even though `.gitignore` is not itself a migration target.
|
|
269
|
+
// Skipped entirely when THIS run's own filePlan just CREATED `.gitignore` — AC-GBR-2 leaves the
|
|
270
|
+
// absent case to the create path, and re-planning immediately after applyPlan would otherwise
|
|
271
|
+
// find the just-written file already converged and print a redundant `[unchanged]` row for a
|
|
272
|
+
// file that never existed before this run.
|
|
273
|
+
const gitignoreFileAction = filePlan.find((f) => f.relPath === '.gitignore')?.action;
|
|
274
|
+
let freshGitignorePlan = null;
|
|
275
|
+
if (gitignoreFileAction !== 'create') {
|
|
276
|
+
freshGitignorePlan = reconcileGitignorePlan({ physicalRoot, templatesDir });
|
|
277
|
+
applyGitignorePlan(freshGitignorePlan);
|
|
278
|
+
const gitignoreRow = renderGitignoreRow(freshGitignorePlan);
|
|
279
|
+
if (gitignoreRow) print(gitignoreRow);
|
|
280
|
+
}
|
|
281
|
+
|
|
258
282
|
const anyCreated = filePlan.some((f) => f.action === 'create');
|
|
259
283
|
const anyFloorAdded = Boolean(floorPlan && floorPlan.total > 0);
|
|
260
|
-
|
|
284
|
+
const anyGitignoreChanged = Boolean(
|
|
285
|
+
freshGitignorePlan && (freshGitignorePlan.action === 'converged' || freshGitignorePlan.action === 'appended'),
|
|
286
|
+
);
|
|
287
|
+
phases.push({
|
|
288
|
+
name: 'reinitialization',
|
|
289
|
+
verdict: anyCreated || anyFloorAdded || anyGitignoreChanged ? 'changed' : 'already current',
|
|
290
|
+
});
|
|
261
291
|
|
|
262
292
|
print('');
|
|
263
293
|
print(renderSummary(phases));
|
|
264
294
|
|
|
265
295
|
const anyDrifted = filePlan.some((f) => f.action === 'drifted');
|
|
266
|
-
|
|
296
|
+
// Same bucket a `drifted` managed file uses (exit 2), not the hard-refusal exit 1 — Phases 1-4
|
|
297
|
+
// already ran and wrote what they could; a malformed gitignore block is reported, not escalated
|
|
298
|
+
// into "the update failed" for the whole run (run.mjs makes the identical choice; see its own
|
|
299
|
+
// comment on `gitignoreRefused`).
|
|
300
|
+
const gitignoreRefused = Boolean(freshGitignorePlan && freshGitignorePlan.action === 'refused');
|
|
301
|
+
return { exitCode: anyDrifted || gitignoreRefused ? 2 : 0, output: lines.join('\n') };
|
|
267
302
|
} catch (e) {
|
|
268
303
|
if (e instanceof RefusalError) {
|
|
269
304
|
print(`refused: ${e.message}`);
|
package/templates/gitignore.tmpl
CHANGED
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
!/.foundry/README.md
|
|
7
7
|
!/.foundry/build-provenance.yaml
|
|
8
8
|
!/.foundry/stack-profile.lock
|
|
9
|
+
# The workspace's RECORD lives here too: programme manifests + charters + wave state, decision
|
|
10
|
+
# records, and the operator's standing-grants policy (ER #169) -- committed, never runtime.
|
|
11
|
+
!/.foundry/releases/
|
|
12
|
+
!/.foundry/decisions/
|
|
13
|
+
!/.foundry/permissions.yaml
|
|
9
14
|
# FOUNDRY-RUNTIME-GITIGNORE-END
|
|
10
15
|
|
|
11
16
|
# Local, UNGATED permission overrides — never commit them (PR #61 security review Risk 5).
|