yadflow 3.10.0 → 3.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,11 @@
1
+ ## [3.10.1](https://github.com/abdelrahmannasr/yadflow/compare/v3.10.0...v3.10.1) (2026-07-08)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * re-run pr-template gate on an edited PR body ([17ad94a](https://github.com/abdelrahmannasr/yadflow/commit/17ad94a4881610b4b653700be50a4eddd7036c5d))
7
+ * stop yad repo refresh --push stranding the regenerated pack.md ([f0b5f4c](https://github.com/abdelrahmannasr/yadflow/commit/f0b5f4ce9f22afcd6078aae3ea1dd5a64885be35))
8
+
1
9
  # [3.10.0](https://github.com/abdelrahmannasr/yadflow/compare/v3.9.4...v3.10.0) (2026-07-08)
2
10
 
3
11
 
@@ -5,8 +5,13 @@
5
5
  // analogue of `yad checkpoint` (cli/checkpoint.mjs) and reuses its default-branch commit machinery.
6
6
  //
7
7
  // Invariants (shared with checkpoint):
8
- // 1. Stage an EXPLICIT allowlist — exactly the tracked code-maps + the registry (never `git add -A`,
9
- // which would sweep unrelated work in the hub). The gitignored repomix pack.md is never staged.
8
+ // 1. Commit an EXPLICIT allowlist via `git commit -- <paths>` (--only) — the tracked code-maps, the
9
+ // registry, and (only when its change is the managed pack-ignore block alone) the hub `.gitignore`.
10
+ // NEVER `git add -A` and NEVER a whole-index `git reset`: both would mutate unrelated staged work.
11
+ // The repomix pack.md is gitignored (setup + publish scaffold the ignore via ensurePackIgnored) and
12
+ // never committed as content. A pack committed BEFORE that ignore existed is self-healed here: its
13
+ // on-disk file is held aside across the --only commit so the deletion is recorded (the regenerable
14
+ // cache is restored right after), clearing the stranded working tree — see the commit block below.
10
15
  // 2. Commit ONLY on the hub's default branch (unless --allow-branch), so the `[skip ci]` audit
11
16
  // commit never enters a PR's base..HEAD range where it would strand required checks.
12
17
  import fs from 'node:fs';
@@ -16,6 +21,7 @@ import { PROJECT_FILES } from './manifest.mjs';
16
21
  import { loadHub } from './gate.mjs';
17
22
  import { resolveCommitterLogin } from './platform.mjs';
18
23
  import { hubGit, resolveDefaultBranch, guardDefaultBranch, preflightGuardReadiness } from './hubcommit.mjs';
24
+ import { ensurePackIgnored, PACK_IGNORE_BLOCK } from './setup.mjs';
19
25
  import { checkpointAuthor } from './checkpoint.mjs';
20
26
 
21
27
  // Collapse any whitespace/newline runs to a single space — keeps a hostile `git user.name` or a stray
@@ -25,6 +31,9 @@ const oneLine = (s = '') => String(s).replace(/\s+/g, ' ').trim();
25
31
  // The tracked code-map for a repo: the registered path, else the conventional location.
26
32
  const codeMapOf = (repo) => repo.codeMap || path.posix.join('.sdlc/code-context', repo.name, 'code-map.md');
27
33
 
34
+ // The repomix pack for a repo: the registered path, else the conventional location.
35
+ const packOf = (repo) => repo.contextPack || path.posix.join('.sdlc/code-context', repo.name, 'pack.md');
36
+
28
37
  // PURE — the repo-relative pathspecs to stage: the registry plus each registered repo's code-map that
29
38
  // exists on disk. When `name` is given (a scoped `yad repo refresh <name> --push`), only that repo's
30
39
  // code-map is staged, so an unrelated repo's uncommitted code-map never rides along in a named refresh's
@@ -41,6 +50,57 @@ export function codeMapPathspecs(root, registry = { repos: [] }, name = null) {
41
50
  return out;
42
51
  }
43
52
 
53
+ // PURE — each registered repo's on-disk pack path (scoped by `name` like codeMapPathspecs). These are
54
+ // UNTRACK candidates, not content to stage: the pack is gitignored, but a hub that committed it before
55
+ // the ignore existed would otherwise strand a dirty pack on every refresh. publishCodeContext keeps only
56
+ // the still-tracked ones and records their removal in the audit commit (see the self-heal block there).
57
+ export function packPathspecs(root, registry = { repos: [] }, name = null) {
58
+ const out = [];
59
+ for (const repo of registry.repos || []) {
60
+ if (name && repo.name !== name) continue;
61
+ const rel = packOf(repo);
62
+ if (fs.existsSync(path.join(root, rel))) out.push(rel);
63
+ }
64
+ return out;
65
+ }
66
+
67
+ // True iff committing `.gitignore` would carry ONLY the managed pack-ignore block (comments + glob) and
68
+ // nothing else. Guards invariant 1: a hub whose `.gitignore` also has unrelated uncommitted edits must
69
+ // keep them OUT of the `[skip ci]` audit commit. The publish commit is `git commit -- <paths>` (--only,
70
+ // reads the WORKING TREE), so this compares the working tree — an untracked `.gitignore` must be wholly
71
+ // managed; a tracked one must differ from HEAD by the managed block alone (added, nothing removed).
72
+ // `git` is a hubGit-style accessor; `root` is the hub root. Mirrors checkpoint's stagedStoryIsStatusOnly.
73
+ export function ignoreChangeIsManagedOnly(git, root) {
74
+ const gi = path.join(root, '.gitignore');
75
+ if (!fs.existsSync(gi)) return false;
76
+ const managed = new Set(PACK_IGNORE_BLOCK.map((l) => l.trim()));
77
+ if (!git('ls-files', '--error-unmatch', '--', '.gitignore').ok) {
78
+ // untracked: every non-blank line of the whole file must be a managed line
79
+ let seen = 0;
80
+ for (const l of fs.readFileSync(gi, 'utf8').split('\n')) {
81
+ const body = l.trim();
82
+ if (body === '') continue;
83
+ if (!managed.has(body)) return false;
84
+ seen++;
85
+ }
86
+ return seen > 0;
87
+ }
88
+ // tracked: the working-tree-vs-HEAD diff must ADD only managed lines and remove nothing
89
+ const d = git('diff', '-U0', 'HEAD', '--', '.gitignore');
90
+ if (!d.ok) return false;
91
+ let added = 0;
92
+ for (const ln of d.stdout.split('\n')) {
93
+ if (ln.startsWith('+++') || ln.startsWith('---') || ln.startsWith('@@')) continue; // headers/hunks
94
+ if (ln.startsWith('-')) return false; // we only ever append — any removal ⇒ not ours
95
+ if (ln.startsWith('+')) {
96
+ const body = ln.slice(1).trim();
97
+ if (body !== '' && !managed.has(body)) return false; // a non-managed added line ⇒ a user edit
98
+ added++;
99
+ }
100
+ }
101
+ return added > 0;
102
+ }
103
+
44
104
  // PURE — turn the staged pathspecs into the subject `label` + the body's file list. A `<name>` is
45
105
  // pulled from any `.sdlc/code-context/<name>/code-map.md` path; a commit that only touched the
46
106
  // registry is labelled `registry`.
@@ -49,7 +109,9 @@ export function summarizeCodeContext(files = []) {
49
109
  const basenames = [];
50
110
  for (const f of files) {
51
111
  basenames.push(f);
52
- const m = f.match(/\.sdlc\/code-context\/([^/]+)\/code-map\.md$/);
112
+ // A repo name comes from either its code-map (content) or a pack removal (self-heal), so the
113
+ // subject label stays accurate even when untracking a stranded pack is the only change.
114
+ const m = f.match(/\.sdlc\/code-context\/([^/]+)\/(?:code-map|pack)\.md$/);
53
115
  if (m) repos.add(m[1]);
54
116
  }
55
117
  let label;
@@ -88,11 +150,26 @@ export async function publishCodeContext(root, { push = false, allowBranch = fal
88
150
  const defaultBranch = resolveDefaultBranch(git, hub);
89
151
  if (!guardDefaultBranch(branch, defaultBranch, { allowBranch, cmd: 'yad repo refresh --push' })) return;
90
152
 
153
+ // Stage the EXPLICIT allowlist (code-maps + registry), scoped so an unrelated pre-staged file is never
154
+ // swept in and the user's index is left untouched — mirrors `runCheckpoint`. NEVER `git add -A` and
155
+ // NEVER a whole-index `git reset` (both would mutate unrelated staged work). The commit below is
156
+ // `git commit -- <paths>` (--only), which reads the WORKING TREE for the named paths only.
91
157
  const pathspecs = codeMapPathspecs(root, registry, name);
92
- if (!pathspecs.length) { info('no code-context to publish — nothing to commit'); return; }
158
+ if (pathspecs.length) {
159
+ const add = git('add', '--', ...pathspecs);
160
+ if (!add.ok) { fail(`git add failed — ${add.stderr.split('\n')[0] || add.code}`); process.exitCode = 1; return; }
161
+ }
93
162
 
94
- const add = git('add', '--', ...pathspecs);
95
- if (!add.ok) { fail(`git add failed ${add.stderr.split('\n')[0] || add.code}`); process.exitCode = 1; return; }
163
+ // Make the "pack is gitignored" assumption true (idempotent; packRepo also does this on refresh) so a
164
+ // hub whose pack was tracked before the ignore existed stops stranding a dirty tree. Publish `.gitignore`
165
+ // ONLY when the change is the managed pack-ignore block alone (invariant 1) — a hub whose `.gitignore`
166
+ // also carries unrelated uncommitted edits keeps them OUT of this audit commit; the pack is still ignored
167
+ // on disk and the human commits their own `.gitignore` edits through their own change. When we do carry
168
+ // it, `git add` makes an untracked `.gitignore` known so the --only commit can include it.
169
+ const ignoreChanged = ensurePackIgnored(root);
170
+ const commitIgnore = ignoreChangeIsManagedOnly(git, root);
171
+ if (commitIgnore) git('add', '--', '.gitignore');
172
+ else if (ignoreChanged) hand('.gitignore has unrelated uncommitted edits — commit them yourself so the pack ignore is published (it is already ignored locally)');
96
173
 
97
174
  // Push HEAD to its OWN branch — on the default branch this is the same; with --allow-branch it keeps a
98
175
  // WIP branch from being force-published onto the default branch. Shared by the fresh-commit path and
@@ -105,7 +182,20 @@ export async function publishCodeContext(root, { push = false, allowBranch = fal
105
182
  return false;
106
183
  };
107
184
 
108
- if (git('diff', '--cached', '--quiet', '--', ...pathspecs).ok) {
185
+ // Self-heal (invariant 1): a pack committed before it was gitignored strands the working tree on every
186
+ // refresh. Any STILL-TRACKED pack must be recorded as removed in this commit — but `git rm --cached`
187
+ // can't be committed under --only (which reads the working tree, where the regenerated pack still
188
+ // exists). So we untrack it below by momentarily removing the on-disk file across the commit (restored
189
+ // right after), letting --only record a clean deletion while the regenerable cache is preserved.
190
+ const trackedPacks = packPathspecs(root, registry, name)
191
+ .filter((p) => git('ls-files', '--error-unmatch', '--', p).ok);
192
+
193
+ // The exact files this audit commit will touch: the staged allowlist (+ managed `.gitignore`) plus any
194
+ // pack removal. Scopes the --only commit and, being all known to git, is a safe commit pathspec.
195
+ const ignoreSpec = commitIgnore ? ['.gitignore'] : [];
196
+ const staged = git('diff', '--cached', '--name-only', '--', ...pathspecs, ...ignoreSpec).stdout.split('\n').filter(Boolean);
197
+ const fileset = [...staged, ...trackedPacks];
198
+ if (!fileset.length) {
109
199
  // Nothing new to commit. A non-push run is simply done. But on a push run a PRIOR run may have
110
200
  // committed and then FAILED to push (the commit sits ahead of origin) — a plain re-run must land
111
201
  // that commit, not silently no-op and exit 0 while it stays stranded. Push any unpushed commit(s).
@@ -117,27 +207,37 @@ export async function publishCodeContext(root, { push = false, allowBranch = fal
117
207
  pushHead();
118
208
  return;
119
209
  }
120
- // The exact files staged from the allowlist — scopes the commit to ONLY the allowlist, so any
121
- // unrelated pre-staged file never rides along.
122
- const staged = git('diff', '--cached', '--name-only', '--', ...pathspecs).stdout.split('\n').filter(Boolean);
123
210
 
124
211
  // Only relevant when we are about to push a commit straight to the default branch: warn (never block)
125
212
  // if signing/allowlisting would make the yad-update-guard reject it. Gated on `push` and deferred to
126
213
  // here so it isn't noise on a guard-refused branch or a nothing-to-commit run.
127
214
  if (push) preflightGuardReadiness(root);
128
215
 
129
- const { label, basenames } = summarizeCodeContext(staged);
216
+ const { label, basenames } = summarizeCodeContext(fileset);
130
217
  const author = checkpointAuthor(resolveCommitterLogin(root, hub?.roster || []), git('config', 'user.name').stdout);
131
218
  const message = buildCodeMapMessage({ label, author, basenames });
132
219
 
133
- const cm = git('commit', '-m', message, '--', ...staged);
134
- if (!cm.ok) {
135
- git('reset', '-q', '--', ...pathspecs); // don't leave the allowlist staged for an unrelated commit to sweep up
136
- fail(`git commit failed — ${cm.stderr.split('\n')[0] || cm.code}`);
137
- process.exitCode = 1;
138
- return { message };
220
+ // Untrack the packs by holding their bytes and removing the files across the --only commit, then
221
+ // restoring them (now gitignored ⇒ a clean tree). try/finally so the regenerable cache is always put
222
+ // back, even on a commit failure.
223
+ const held = [];
224
+ try {
225
+ for (const p of trackedPacks) {
226
+ const abs = path.join(root, p);
227
+ held.push({ abs, buf: fs.readFileSync(abs) });
228
+ fs.rmSync(abs);
229
+ }
230
+ const cm = git('commit', '-m', message, '--', ...fileset); // --only: adds from the tree, packs as deletions
231
+ if (!cm.ok) {
232
+ git('reset', '-q', '--', ...pathspecs, ...ignoreSpec); // unstage only OUR allowlist for a clean retry
233
+ fail(`git commit failed — ${cm.stderr.split('\n')[0] || cm.code}`);
234
+ process.exitCode = 1;
235
+ return { message };
236
+ }
237
+ } finally {
238
+ for (const h of held) if (!fs.existsSync(h.abs)) fs.writeFileSync(h.abs, h.buf);
139
239
  }
140
- ok(`published ${staged.length} file(s): ${c.dim(label)}`);
240
+ ok(`published ${fileset.length} file(s): ${c.dim(label)}`);
141
241
 
142
242
  if (!push) return { message };
143
243
  pushHead();
package/cli/repo.mjs CHANGED
@@ -73,8 +73,9 @@ export async function runRepo(root, { action = 'list', name, today, push = false
73
73
  if (push) {
74
74
  // Publish whatever tracked code-context now differs (the AI-regenerated code-maps + the stamped
75
75
  // registry) straight to the hub's default branch as one audit-trail commit. The pack itself is
76
- // gitignored; the code-map is regenerated by the AI (yad-connect-repos) run that first if a
77
- // repo's map is stale, then `yad repo refresh --push` lands it.
76
+ // gitignored (packRepo scaffolds the ignore; publish self-heals a pre-ignore tracked pack); the
77
+ // code-map is regenerated by the AI (yad-connect-repos) — run that first if a repo's map is stale,
78
+ // then `yad repo refresh --push` lands it.
78
79
  await publishCodeContext(root, { push: true, allowBranch, name });
79
80
  } else {
80
81
  hand('regenerate the code-map in Claude Code (yad-connect-repos) — the pack is cached, the map is the AI step');
package/cli/setup.mjs CHANGED
@@ -740,12 +740,40 @@ export async function runSetup(root, opts = {}) {
740
740
  log(c.dim('Re-run anytime: `yad check` (report) / `yad check --fix` (reconcile).'));
741
741
  }
742
742
 
743
+ // The repomix pack is a large, regenerable artifact — the hub tracks the AI-authored code-map, not the
744
+ // pack. `yad repo refresh --push` relies on the pack being gitignored (repo-publish.mjs never stages it);
745
+ // this makes that assumption true in every hub, so a regenerated pack never strands as a dirty tree.
746
+ export const PACK_IGNORE_GLOB = '.sdlc/code-context/*/pack.md';
747
+
748
+ // The exact lines ensurePackIgnored appends — a comment pair + the glob. Kept as data (not inline
749
+ // strings) so the publish gate can verify a staged `.gitignore` change is ONLY this managed block and
750
+ // never sweep an unrelated user edit into the audit commit (repo-publish.mjs, invariant 1).
751
+ export const PACK_IGNORE_BLOCK = [
752
+ '# Repomix code-context packs are large, regenerable artifacts (yad repo refresh) — the',
753
+ '# tracked code-map.md is the reviewed AI output; the pack itself is never committed.',
754
+ PACK_IGNORE_GLOB,
755
+ ];
756
+
757
+ // Idempotently ensure the hub `.gitignore` ignores the repomix pack. No-op (returns false) if the line
758
+ // is already present (as its own entry); otherwise appends the managed block to a fresh or existing file
759
+ // and returns true.
760
+ export function ensurePackIgnored(root) {
761
+ const gi = path.join(root, '.gitignore');
762
+ const lines = exists(gi) ? fs.readFileSync(gi, 'utf8').split('\n') : [];
763
+ if (lines.some((l) => l.trim() === PACK_IGNORE_GLOB)) return false;
764
+ const body = lines.join('\n').replace(/\n*$/, '');
765
+ const prefix = body ? `${body}\n\n` : '';
766
+ fs.writeFileSync(gi, `${prefix}${PACK_IGNORE_BLOCK.join('\n')}\n`);
767
+ return true;
768
+ }
769
+
743
770
  // Deterministic repomix pack (code-map generation itself is an AI step, handed off).
744
771
  export function packRepo(root, repo) {
745
772
  const repoRoot = path.resolve(root, repo.path);
746
773
  const out = path.join(root, repo.contextPack);
747
774
  if (!has('npx')) { warn(`${repo.name}: npx missing — skipped repomix pack`); return false; }
748
775
  fs.mkdirSync(path.dirname(out), { recursive: true });
776
+ ensurePackIgnored(root); // keep the pack out of git before it is (re)written — see repo-publish.mjs invariant 1
749
777
  info(`${repo.name}: packing with repomix …`);
750
778
  const r = run('npx', ['repomix@latest', '--compress', '--include-logs', '--style', 'markdown', '-o', out], { cwd: repoRoot });
751
779
  if (r.ok) { ok(`${repo.name}: cached ${repo.contextPack}`); hand(`${repo.name}: generate the code-map in Claude Code (yad-connect-repos)`); return true; }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yadflow",
3
- "version": "3.10.0",
3
+ "version": "3.10.1",
4
4
  "description": "Yadflow — the gated, team, multi-repo SDLC: author → review → build with a PR-driven review gate and a zero-dependency `yad` CLI (setup, gate, commit, open-pr, ship, repo, thread, reconcile). A BMAD module + 38 yad-* skills.",
5
5
  "type": "module",
6
6
  "author": "AbdelRahman Nasr",
@@ -215,7 +215,11 @@ The gates run identically under either CI; the config just invokes the scripts w
215
215
 
216
216
  - **GitHub Actions** — `templates/github/yad-checks.yml` → `.github/workflows/yad-checks.yml`. The
217
217
  jobs run on `pull_request` with `fetch-depth: 0`, passing `origin/${{ github.base_ref }}` as base
218
- (verified-commits also gets a read-only `GH_TOKEN` for the Verified-badge lookup). The pattern jobs
218
+ (verified-commits also gets a read-only `GH_TOKEN` for the Verified-badge lookup). The trigger sets
219
+ `types: [opened, synchronize, reopened, edited]` — the extra `edited` so a title/body correction
220
+ re-runs the pattern gates without a close/reopen (a plain re-run replays the frozen original payload).
221
+ The commit-range jobs carry `if: github.event.action != 'edited'` so a bare body/title edit only
222
+ re-runs `pr-title`/`pr-template`, not the whole suite. The pattern jobs
219
223
  read the title/body from the event payload: `pr-title` takes `${{ github.event.pull_request.title }}`
220
224
  and `pr-template` writes `${{ github.event.pull_request.body }}` to a temp file. All `--profile code`.
221
225
  The Phase 6 thread gates (`lineage-check`, `epic-open`, `reconcile-debt`) run as their own jobs with
@@ -6,11 +6,16 @@
6
6
  name: yad-checks
7
7
  on:
8
8
  pull_request:
9
+ # `edited` (beyond the opened/synchronize/reopened defaults) so a PR title/body correction
10
+ # re-runs the pattern gates without a close/reopen — e.g. fixing a body the pr-template gate held.
11
+ # The commit-range jobs below skip a bare `edited` (only pr-title/pr-template need to re-check).
12
+ types: [opened, synchronize, reopened, edited]
9
13
  branches: ["**"]
10
14
 
11
15
  jobs:
12
16
  spec-link:
13
17
  runs-on: ubuntu-latest
18
+ if: github.event.action != 'edited'
14
19
  steps:
15
20
  - uses: actions/checkout@v4
16
21
  with: { fetch-depth: 0 }
@@ -18,6 +23,7 @@ jobs:
18
23
 
19
24
  contract-check:
20
25
  runs-on: ubuntu-latest
26
+ if: github.event.action != 'edited'
21
27
  steps:
22
28
  - uses: actions/checkout@v4
23
29
  with: { fetch-depth: 0 }
@@ -25,6 +31,7 @@ jobs:
25
31
 
26
32
  build-test-lint:
27
33
  runs-on: ubuntu-latest
34
+ if: github.event.action != 'edited'
28
35
  env:
29
36
  YAD_TEST_MAX_WORKERS: "2" # cap jest/vitest test workers in CI; ignored by other runners
30
37
  steps:
@@ -41,6 +48,7 @@ jobs:
41
48
  # story->epic resolution and degrade to a note when the product repo is not reachable from CI.
42
49
  lineage-check:
43
50
  runs-on: ubuntu-latest
51
+ if: github.event.action != 'edited'
44
52
  steps:
45
53
  - uses: actions/checkout@v4
46
54
  with: { fetch-depth: 0 }
@@ -48,6 +56,7 @@ jobs:
48
56
 
49
57
  epic-open:
50
58
  runs-on: ubuntu-latest
59
+ if: github.event.action != 'edited'
51
60
  steps:
52
61
  - uses: actions/checkout@v4
53
62
  with: { fetch-depth: 0 }
@@ -55,14 +64,17 @@ jobs:
55
64
 
56
65
  reconcile-debt:
57
66
  runs-on: ubuntu-latest
67
+ if: github.event.action != 'edited'
58
68
  steps:
59
69
  - uses: actions/checkout@v4
60
70
  with: { fetch-depth: 0 }
61
71
  - run: bash checks/reconcile-debt-check.sh "origin/${{ github.base_ref }}"
62
72
 
63
73
  # Pattern gates: commit subject + PR title + PR body all follow the convention (profile: code).
74
+ # commit-message reads the commit range, not the title/body — skip it on a bare `edited` event.
64
75
  commit-message:
65
76
  runs-on: ubuntu-latest
77
+ if: github.event.action != 'edited'
66
78
  steps:
67
79
  - uses: actions/checkout@v4
68
80
  with: { fetch-depth: 0 }
@@ -88,8 +100,10 @@ jobs:
88
100
  bash checks/pr-template.sh --profile code "$body"
89
101
 
90
102
  # No unverified commits from unverified users: platform-Verified signature + allowlisted author.
103
+ # Reads the commit range, not the title/body — skip it on a bare `edited` event.
91
104
  verified-commits:
92
105
  runs-on: ubuntu-latest
106
+ if: github.event.action != 'edited'
93
107
  permissions:
94
108
  contents: read
95
109
  env:
@@ -82,7 +82,13 @@ side-effect:** when a repo is stale (HEAD ≠ `syncedHead`), the phase **flags i
82
82
  silently re-packing the whole repo. A phase never refreshes the registry on its own; the human runs
83
83
  `yad repo refresh` (or `yad check --fix`). After the AI regenerates the code-map, `yad repo refresh
84
84
  --push` publishes the refreshed code-maps + registry to the hub's default branch as a `chore(hub): sync
85
- code-context … [skip ci]` audit commit (never `pack.md`; `--allow-branch` overrides the branch guard).
85
+ code-context … [skip ci]` audit commit (never the pack's content; `--allow-branch` overrides the branch
86
+ guard). The `pack.md` is gitignored — `yad repo refresh`/`yad setup` scaffold
87
+ `.sdlc/code-context/*/pack.md` into the hub `.gitignore` (so a regenerated pack never dirties the tree),
88
+ and a hub that tracked the pack *before* that ignore existed is self-healed: `--push` untracks it and
89
+ lands the removal + the managed `.gitignore` line in the same audit commit. The commit is a scoped
90
+ `git commit -- <paths>` — it never sweeps unrelated staged work, and an unrelated hand-edit to
91
+ `.gitignore` is left for the human to commit rather than riding the `[skip ci]` audit commit.
86
92
 
87
93
  ## Why this stays DRY with backfill
88
94