create-agentic-workspace 0.12.1 → 0.14.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 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.12.1",
3
+ "version": "0.14.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.12.1",
37
+ "plugin_version": "1.14.0",
38
38
  "pins_researched": "2026-08-02"
39
39
  }
40
40
  }
@@ -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.12.1",
4
+ "generated_for_plugin_version": "1.14.0",
5
5
  "entries": [
6
6
  {
7
7
  "rule": "Bash(~/.claude/plugins/cache/*/foundry/*/scripts/foundry-acceptance-contract-validate.py:*)",
@@ -98,6 +98,11 @@
98
98
  "tier": "allow",
99
99
  "rationale": "read-only machinery index derived on demand from skills/agents frontmatter"
100
100
  },
101
+ {
102
+ "rule": "Bash(~/.claude/plugins/cache/*/foundry/*/scripts/foundry-manifest-to-tasklist.py:*)",
103
+ "tier": "allow",
104
+ "rationale": "read-only projection of a release manifest into a TaskCreate plan; prints JSON, executes nothing"
105
+ },
101
106
  {
102
107
  "rule": "Bash(~/.claude/plugins/cache/*/foundry/*/scripts/foundry-native-todo-discipline.py:*)",
103
108
  "tier": "allow",
@@ -113,6 +118,11 @@
113
118
  "tier": "ask",
114
119
  "rationale": "reconciles .claude/settings.json permissions.allow/ask from the operator-owned .foundry/permissions.yaml; a ceremony, not a self-grant"
115
120
  },
121
+ {
122
+ "rule": "Bash(~/.claude/plugins/cache/*/foundry/*/scripts/foundry-capability-preflight.py:*)",
123
+ "tier": "allow",
124
+ "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"
125
+ },
116
126
  {
117
127
  "rule": "Bash(~/.claude/plugins/cache/*/foundry/*/scripts/foundry-prepublication-leak-scan.py:*)",
118
128
  "tier": "allow",
@@ -203,6 +213,11 @@
203
213
  "tier": "allow",
204
214
  "rationale": "read-only intake schema-defect / grounding checker over a project dir"
205
215
  },
216
+ {
217
+ "rule": "Bash(~/.claude/plugins/cache/*/foundry/*/scripts/foundry_message_kind.py:*)",
218
+ "tier": "allow",
219
+ "rationale": "deterministic outgoing cross-session message lint (fleet-is-listagents, AC-FIL-3): reads its --in argument, checks the first line against the closed FINDING|NEEDS-INTERFACE|CHALLENGE|HANDOFF vocabulary and HANDOFF's json shape against schema/blocker.schema.json, prints a JSON verdict; writes nothing"
220
+ },
206
221
  {
207
222
  "rule": "Bash(~/.claude/plugins/cache/*/foundry/*/scripts/foundry_repo_fleet.py status:*)",
208
223
  "tier": "allow",
@@ -223,6 +238,11 @@
223
238
  "tier": "allow",
224
239
  "rationale": "session-scoped mode/fork-policy set-and-resolve state-writer; not a release/repo ceremony"
225
240
  },
241
+ {
242
+ "rule": "Bash(~/.claude/plugins/cache/*/foundry/*/scripts/foundry-routine-wake-prompt.py:*)",
243
+ "tier": "allow",
244
+ "rationale": "routine-wake (autonomy-continuation R3, AC-RWK-1): validates <programme>/--deck-name as [a-z0-9-]+ slugs and prints the self-contained Routine prompt plus the /schedule recipe and prerequisites; reads no corpus state and writes nothing"
245
+ },
226
246
  {
227
247
  "rule": "Bash(claude plugin tag:*)",
228
248
  "tier": "ask",
@@ -263,6 +283,11 @@
263
283
  "tier": "ask",
264
284
  "rationale": "pinned ceremony (AC-PFM-3): documented no-op today, but the flag is the shape a future auto-heal would reuse"
265
285
  },
286
+ {
287
+ "rule": "Bash(~/.claude/plugins/cache/*/foundry/*/scripts/foundry-merge-when-green.py:*)",
288
+ "tier": "ask",
289
+ "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"
290
+ },
266
291
  {
267
292
  "rule": "Bash(~/.claude/plugins/cache/*/foundry/*/scripts/foundry-project-sync.py:*)",
268
293
  "tier": "ask",
@@ -340,6 +365,10 @@
340
365
  }
341
366
  ],
342
367
  "not_invoked": [
368
+ {
369
+ "script": "foundry_shell_scan.py",
370
+ "rationale": "library: no argparse/__main__; the stdlib shell tokenizer imported by the two PreToolUse(Bash) guards (guard-structured-observations)"
371
+ },
343
372
  {
344
373
  "script": "foundry_audit_ledger.py",
345
374
  "rationale": "library: no argparse/__main__; imported by foundry-audit-record.py for the ledger schema/writer"
@@ -368,6 +397,10 @@
368
397
  "script": "foundry_contract.py",
369
398
  "rationale": "library: no argparse/__main__; acceptance-contract schema/hash helpers imported by other scripts"
370
399
  },
400
+ {
401
+ "script": "foundry_floor_hooks.py",
402
+ "rationale": "library: no argparse/__main__; shared subject-parsing/authorization/evidence helpers imported by hooks/foundry-task-created.py and hooks/foundry-task-completed.py"
403
+ },
371
404
  {
372
405
  "script": "foundry_graph.py",
373
406
  "rationale": "library: no argparse/__main__; citation-graph primitives imported by the graph CLIs"
@@ -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
- reconciledExisting: Boolean(floorPlan && floorPlan.total > 0),
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
- return { exitCode: exitCodeForPlan(plan), output: lines.join('\n') };
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
- phases.push({ name: 'reinitialization', verdict: anyCreated || anyFloorAdded ? 'changed' : 'already current' });
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
- return { exitCode: anyDrifted ? 2 : 0, output: lines.join('\n') };
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}`);