create-agentic-workspace 0.16.0 → 0.17.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
@@ -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.16.0",
3
+ "version": "0.17.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.16.0",
37
+ "plugin_version": "1.17.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.16.0",
4
+ "generated_for_plugin_version": "1.17.0",
5
5
  "entries": [
6
6
  {
7
7
  "rule": "Bash(~/.claude/plugins/cache/*/foundry/*/scripts/foundry-acceptance-contract-validate.py:*)",
@@ -0,0 +1,143 @@
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
+
18
+ export const NORMATIVE_CLOSE = '<!-- /normative -->';
19
+ export const AMENDMENTS_HEADING = '## Amendments';
20
+ /** Exactly what is appended (AC-AMB-1); the leading blank line is added only when the file does
21
+ * not already end with one, so a spec ending "...\n" gets "\n## Amendments..." and one ending
22
+ * "...\n\n" gets the heading directly. Header columns are `foundry-amend.py`'s own row shape. */
23
+ export const AMENDMENTS_BLOCK = `${AMENDMENTS_HEADING}\n\n| date | what changed | why reality required it | auth_seq |\n|---|---|---|---|\n`;
24
+
25
+ const SPEC_BASENAME_RE = /^feat-.*\.md$/;
26
+ const CODE_FENCE_RE = /```[\s\S]*?```/g;
27
+
28
+ /** The verdict `foundry-amend.py`'s amendments_section_ok gives: `present` when a `## Amendments`
29
+ * heading appears after the LAST normative close marker and outside any fenced code block;
30
+ * `absent` when it does not; `no-marker` when the spec has no close marker at all (amend's own
31
+ * whole-body fallback applies there, and this module never writes such a file). Fenced blocks are
32
+ * masked with same-length filler so the heading's offset stays comparable to the marker's, exactly
33
+ * as the Python does; CRLF is tolerated because neither search depends on line structure. */
34
+ export function classifySpec(text) {
35
+ const closeIdx = text.lastIndexOf(NORMATIVE_CLOSE);
36
+ if (closeIdx === -1) return 'no-marker';
37
+ const masked = text.replace(CODE_FENCE_RE, (m) => '\0'.repeat(m.length));
38
+ const idx = masked.indexOf(AMENDMENTS_HEADING);
39
+ if (idx !== -1 && idx > closeIdx) return 'present';
40
+ // A heading BEFORE the marker does not count for amend either — the section must follow the
41
+ // normative region — so it is `absent` and the block is appended at the end of the file.
42
+ return 'absent';
43
+ }
44
+
45
+ /** Every regular `feat-*.md` under `<root>/specs`, depth-first, with symlinked FILES reported
46
+ * separately (never followed, never written — AC-AMB-2) and symlinked DIRECTORIES not descended
47
+ * (the same confinement instinct as the scaffold's confinedJoin: nothing outside the workspace
48
+ * root is ever touched). Absent `specs/` yields an empty walk, not an error. */
49
+ export function walkSpecs(physicalRoot) {
50
+ const specsRoot = path.join(physicalRoot, 'specs');
51
+ const files = [];
52
+ const symlinks = [];
53
+ if (!fs.existsSync(specsRoot)) return { files, symlinks };
54
+ const stack = [specsRoot];
55
+ while (stack.length > 0) {
56
+ const dir = stack.pop();
57
+ let entries;
58
+ try {
59
+ entries = fs.readdirSync(dir, { withFileTypes: true });
60
+ } catch {
61
+ continue;
62
+ }
63
+ for (const ent of entries) {
64
+ const abs = path.join(dir, ent.name);
65
+ if (ent.isSymbolicLink()) {
66
+ if (SPEC_BASENAME_RE.test(ent.name)) symlinks.push(abs);
67
+ continue; // never descend a symlinked directory, never write a symlinked file
68
+ }
69
+ if (ent.isDirectory()) stack.push(abs);
70
+ else if (ent.isFile() && SPEC_BASENAME_RE.test(ent.name)) files.push(abs);
71
+ }
72
+ }
73
+ files.sort();
74
+ symlinks.sort();
75
+ return { files, symlinks };
76
+ }
77
+
78
+ /** Plan only — no write. `{ toAppend: [abs...], present, skipped, symlinks, total }` where `total`
79
+ * counts every regular spec seen (the "of <m>" in the row) and `skipped` is the no-marker count. */
80
+ export function planAmendmentsBackfill({ physicalRoot }) {
81
+ const { files, symlinks } = walkSpecs(physicalRoot);
82
+ const toAppend = [];
83
+ let present = 0;
84
+ let skipped = 0;
85
+ for (const abs of files) {
86
+ let text;
87
+ try {
88
+ text = fs.readFileSync(abs, 'utf-8');
89
+ } catch {
90
+ skipped += 1;
91
+ continue;
92
+ }
93
+ const verdict = classifySpec(text);
94
+ if (verdict === 'present') present += 1;
95
+ else if (verdict === 'no-marker') skipped += 1;
96
+ else toAppend.push(abs);
97
+ }
98
+ return { toAppend, present, skipped, symlinks, total: files.length, applied: false };
99
+ }
100
+
101
+ /** The bytes to append for a given current text: the block, preceded by one newline when the text
102
+ * does not already end with a blank line, and by TWO when it does not end with a newline at all. */
103
+ export function appendBytesFor(text) {
104
+ if (text === '' || text.endsWith('\n\n') || text.endsWith('\r\n\r\n')) return AMENDMENTS_BLOCK;
105
+ if (text.endsWith('\n')) return `\n${AMENDMENTS_BLOCK}`;
106
+ return `\n\n${AMENDMENTS_BLOCK}`;
107
+ }
108
+
109
+ /** Atomic append: read, compose, write a sibling temp file, rename over the original (the same
110
+ * discipline as gitignoreReconcile's writer). Re-classifies right before writing so a file that
111
+ * gained the section between plan and apply is left alone. Returns the count actually written. */
112
+ export function applyAmendmentsBackfill(plan) {
113
+ let written = 0;
114
+ for (const abs of plan.toAppend) {
115
+ let text;
116
+ try {
117
+ text = fs.readFileSync(abs, 'utf-8');
118
+ } catch {
119
+ continue;
120
+ }
121
+ if (classifySpec(text) !== 'absent') continue;
122
+ const tmp = `${abs}.amendments-backfill.tmp`;
123
+ fs.writeFileSync(tmp, text + appendBytesFor(text), 'utf-8');
124
+ fs.renameSync(tmp, abs);
125
+ written += 1;
126
+ }
127
+ plan.applied = true;
128
+ plan.written = written;
129
+ return written;
130
+ }
131
+
132
+ /** The one row (AC-AMB-1). `null` when the workspace has no specs at all, so a fresh scaffold's
133
+ * output is unchanged. Under dry-run the count is what WOULD be backfilled; after apply it is what
134
+ * was. Symlinked spec files are named so the operator sees what was deliberately not touched. */
135
+ export function renderAmendmentsRow(plan) {
136
+ if (!plan || (plan.total === 0 && plan.symlinks.length === 0)) return null;
137
+ const n = plan.applied ? plan.written : plan.toAppend.length;
138
+ let row = ` [amendments] backfilled ${n} of ${plan.total} specs (${plan.present} already present, ${plan.skipped} skipped: no normative region)`;
139
+ if (plan.symlinks.length > 0) {
140
+ row += `; ${plan.symlinks.length} symlinked spec file(s) not touched: ${plan.symlinks.map((p) => path.basename(p)).join(', ')}`;
141
+ }
142
+ return row;
143
+ }
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({ relPath: entry.target, absPath: joined, bytes: Buffer.from(rendered, 'utf-8') });
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, 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,
@@ -207,6 +210,12 @@ export async function runUpdate(argv, { cwd, configDir, homeDir, pkgDir, output,
207
210
  }
208
211
  const previewGitignoreRow = renderGitignoreRow(previewGitignorePlan);
209
212
  if (previewGitignoreRow) previewLines.push(previewGitignoreRow);
213
+ // amendments-backfill (ER #214, AC-AMB-1): PREVIEW-ONLY like the two rows above; Phase 4
214
+ // re-plans fresh from disk before it writes.
215
+ const previewAmendmentsRow = renderAmendmentsRow(planAmendmentsBackfill({ physicalRoot }));
216
+ if (previewAmendmentsRow) previewLines.push(previewAmendmentsRow);
217
+ // statusline-wiring (AC-SLW-1/-2): PREVIEW-ONLY rows; Phase 4 re-plans fresh from disk.
218
+ previewLines.push(...renderStatuslineRows(planStatuslineWiring({ physicalRoot, templatesDir })));
210
219
  print(previewLines.join('\n'));
211
220
 
212
221
  const env = { ...spawnEnv, CLAUDE_CONFIG_DIR: configDir };
@@ -294,20 +303,52 @@ export async function runUpdate(argv, { cwd, configDir, homeDir, pkgDir, output,
294
303
  if (gitignoreRow) print(gitignoreRow);
295
304
  }
296
305
 
306
+ // amendments-backfill (ER #214, AC-AMB-1/-2): re-planned FRESH from disk like the two blocks
307
+ // above, applied with the module's own re-classify-before-append guard.
308
+ const amendmentsPlan = planAmendmentsBackfill({ physicalRoot });
309
+ applyAmendmentsBackfill(amendmentsPlan);
310
+ const amendmentsRow = renderAmendmentsRow(amendmentsPlan);
311
+ if (amendmentsRow) print(amendmentsRow);
312
+
297
313
  const anyCreated = filePlan.some((f) => f.action === 'create');
298
314
  const anyFloorAdded = Boolean(floorPlan && floorPlan.total > 0)
299
315
  || Boolean(floorRetirementPlan && floorRetirementPlan.total > 0);
300
316
  const anyGitignoreChanged = Boolean(
301
317
  freshGitignorePlan && (freshGitignorePlan.action === 'converged' || freshGitignorePlan.action === 'appended'),
302
318
  );
319
+ const anyAmendmentsBackfilled = amendmentsPlan.written > 0;
320
+ // statusline-wiring (v1.17.0, AC-SLW-1/-2): the updater is the post-trust writer of the
321
+ // wrapper files and the two settings keys (added only when absent). Planned fresh from disk
322
+ // here, after the floor write above, so the settings read is the current one.
323
+ const statuslinePlan = planStatuslineWiring({ physicalRoot, templatesDir });
324
+ applyStatuslineWiring(statuslinePlan);
325
+ for (const row of renderStatuslineRows(statuslinePlan)) print(row);
303
326
  phases.push({
304
327
  name: 'reinitialization',
305
- verdict: anyCreated || anyFloorAdded || anyGitignoreChanged ? 'changed' : 'already current',
328
+ verdict: anyCreated || anyFloorAdded || anyGitignoreChanged || anyAmendmentsBackfilled
329
+ || statuslineChanged(statuslinePlan) ? 'changed' : 'already current',
306
330
  });
307
331
 
308
332
  print('');
309
333
  print(renderSummary(phases));
310
334
 
335
+ // post-upgrade-skill (AC-PUS-1): the hand-off to the judgement half. Written on every
336
+ // completed run (overwritten — a report, not a managed file; `.foundry/*` is gitignored), and
337
+ // named in the LAST line so the operator's next step is never a guess.
338
+ const report = buildUpgradeReport({
339
+ beforeEntry, afterEntry, toPluginVersion: pins.plugin_version, phases, filePlan, amendmentsPlan,
340
+ });
341
+ const reportPath = writeUpgradeReport(physicalRoot, report);
342
+ print('');
343
+ if (reportPath === null) {
344
+ // PR #218 review round 2: never hand off to a report that was not written — a planted link
345
+ // at that path would otherwise be what the skill reads.
346
+ print(' [refused] .foundry/upgrade-report.json (.foundry is not a directory, or the report path is not a regular file — NOT written)');
347
+ 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');
348
+ } else {
349
+ print(NEXT_LINE);
350
+ }
351
+
311
352
  const anyDrifted = filePlan.some((f) => f.action === 'drifted');
312
353
  // Same bucket a `drifted` managed file uses (exit 2), not the hard-refusal exit 1 — Phases 1-4
313
354
  // already ran and wrote what they could; a malformed gitignore block is reported, not escalated
@@ -0,0 +1,66 @@
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
+
15
+ export const REPORT_REL = '.foundry/upgrade-report.json';
16
+ export const REPORT_SCHEMA_VERSION = 1;
17
+ export const NEXT_LINE = `next: run /foundry:post-upgrade in your next session (report: ${REPORT_REL})`;
18
+
19
+ /** The plugin version strings come from the marketplace manifest — remote content refreshed by
20
+ * `claude plugin marketplace update` — and land in a file an agent later reads and acts on. Only a
21
+ * version-shaped string is copied (PR #218 security review, Risk 3); anything else is `null`. */
22
+ const VERSION_RE = /^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][0-9A-Za-z.-]{1,40})?$/;
23
+ export function versionOrNull(v) {
24
+ return typeof v === 'string' && v.length <= 64 && VERSION_RE.test(v) ? v : null;
25
+ }
26
+
27
+ /** Pure: assemble the report object. `beforeEntry`/`afterEntry` are the marketplace manifest's
28
+ * plugin entries around the refresh (either may be null); `filePlan` is the managed-file plan;
29
+ * `amendmentsPlan` is the applied backfill plan (or null); `phases` is what renderSummary got. */
30
+ export function buildUpgradeReport({
31
+ beforeEntry, afterEntry, toPluginVersion, phases, filePlan, amendmentsPlan, now = new Date(),
32
+ }) {
33
+ const seedRow = (filePlan || []).find((f) => f.seed);
34
+ return {
35
+ schema_version: REPORT_SCHEMA_VERSION,
36
+ ran_at: now.toISOString(),
37
+ // null when no manifest was readable before the refresh (a first install) — the skill then
38
+ // lists only the current version's CHANGELOG section (AC-PUS-1, Out of scope).
39
+ from_plugin_version: beforeEntry ? versionOrNull(beforeEntry.version) : null,
40
+ to_plugin_version: (afterEntry && versionOrNull(afterEntry.version)) || versionOrNull(toPluginVersion),
41
+ phases: (phases || []).map((p) => ({ name: p.name, verdict: p.verdict, ...(p.reason ? { reason: p.reason } : {}) })),
42
+ amendments: amendmentsPlan
43
+ ? { backfilled: amendmentsPlan.written ?? 0, present: amendmentsPlan.present, skipped: amendmentsPlan.skipped }
44
+ : { backfilled: 0, present: 0, skipped: 0 },
45
+ permissions_policy: seedRow ? (seedRow.action === 'create' ? 'created' : 'kept') : 'absent',
46
+ drifted: (filePlan || []).filter((f) => f.action === 'drifted').map((f) => f.relPath),
47
+ };
48
+ }
49
+
50
+ /** Write the report under the workspace root, creating `.foundry/` if needed. Overwrites.
51
+ * Confined the way every other Phase-4 writer is (PR #218 security review, Risk 4): the target is
52
+ * joined through `confinedJoin`, a `.foundry` that is a symlink or a leaf that is not a regular
53
+ * file is REFUSED (returns null, nothing written) — the write can never land outside the
54
+ * physically-resolved root through a planted link. */
55
+ export function writeUpgradeReport(physicalRoot, report) {
56
+ const abs = confinedJoin(physicalRoot, REPORT_REL);
57
+ if (abs === null) return null;
58
+ const dir = path.dirname(abs);
59
+ const dst = fs.lstatSync(dir, { throwIfNoEntry: false });
60
+ if (dst && !dst.isDirectory()) return null; // `.foundry` is a symlink or a file
61
+ const lst = fs.lstatSync(abs, { throwIfNoEntry: false });
62
+ if (lst && !lst.isFile()) return null; // the leaf is a symlink or special
63
+ fs.mkdirSync(dir, { recursive: true });
64
+ fs.writeFileSync(abs, `${JSON.stringify(report, null, 2)}\n`, 'utf-8');
65
+ return abs;
66
+ }
@@ -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: []