yadflow 3.15.5 → 3.16.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/CHANGELOG.md CHANGED
@@ -1,3 +1,16 @@
1
+ # [3.16.0](https://github.com/abdelrahmannasr/yadflow/compare/v3.15.5...v3.16.0) (2026-08-12)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **pr-template:** name GitLab's 2700-character description truncation ([475e2b7](https://github.com/abdelrahmannasr/yadflow/commit/475e2b7db8f0d24909ce5c874122f18dc6508985)), closes [#164](https://github.com/abdelrahmannasr/yadflow/issues/164)
7
+ * **update:** reject an unusable provenance record instead of ignoring it ([5107381](https://github.com/abdelrahmannasr/yadflow/commit/5107381ae2c08b261fddda76433cfc2a3fe3ea46)), closes [#188](https://github.com/abdelrahmannasr/yadflow/issues/188) [#164](https://github.com/abdelrahmannasr/yadflow/issues/164)
8
+
9
+
10
+ ### Features
11
+
12
+ * **update:** never silently overwrite a locally modified managed file ([28d6ee4](https://github.com/abdelrahmannasr/yadflow/commit/28d6ee4c1af250415c3ad50d999a263ebe91cd83)), closes [#164](https://github.com/abdelrahmannasr/yadflow/issues/164)
13
+
1
14
  ## [3.15.5](https://github.com/abdelrahmannasr/yadflow/compare/v3.15.4...v3.15.5) (2026-08-12)
2
15
 
3
16
 
package/README.md CHANGED
@@ -65,6 +65,11 @@ Every step stops at a gate until a human approves. New here? **Walk it lesson-by
65
65
 
66
66
  Running `yad` tells you when a new release is out — upgrade with `npm install yadflow -g`, then
67
67
  `yad update` to re-sync this project's skills. See [staying up to date](docs/CLI.md#staying-up-to-date).
68
+ An update rewrites the files yad manages (gate scripts, CI, PR/MR templates) — but not one **you**
69
+ edited: yad records the sha of every file it writes, so an edit to one is reported as `modified` and
70
+ left alone. A file it has no record of (an install predating that record) is still replaced, but only
71
+ after saving a `.yad-orig` backup
72
+ ([managed files](docs/CLI.md#managed-files-what-yad-owns-and-what-you-edited)).
68
73
 
69
74
  ## What `npx yadflow setup` installs
70
75
 
package/bin/yad.mjs CHANGED
@@ -30,11 +30,15 @@ ${c.bold('Setup & maintenance')}
30
30
  yad setup Guided first-run setup (profile interview, install, connect & wire repos)
31
31
  profile flags: --solo | --team <n>, --greenfield | --brownfield,
32
32
  --monorepo | --separate, --tools (configure design/testing/learning now)
33
- yad check Report what is missing / drifted / stale / legacy (read-only)
33
+ yad check Report what is missing / drifted / modified / stale / legacy (read-only)
34
34
  yad check --fix Reconcile: fill what is missing, update what changed
35
35
  yad update Apply drift only (alias for: check --fix --scope=changed);
36
36
  installs newly-added skills, updates changed skills + gate scripts,
37
- and migrates pre-2.0 sdlc-* installs to the yad-* names
37
+ and migrates pre-2.0 sdlc-* installs to the yad-* names.
38
+ A managed file whose edit yad can prove (its recorded sha) is
39
+ reported 'modified' and left alone; --overwrite-local replaces
40
+ it. Anything else it cannot account for is replaced only after
41
+ a <file>.yad-orig backup
38
42
  yad update --push Also commit each repo's applied changes and push them straight to the
39
43
  default branch of the hub + every connected repo (a chore(yad-update)
40
44
  commit; no PR — the push-on-main yad-update-guard runs verified-commits
@@ -159,6 +163,8 @@ ${c.bold('Options')}
159
163
  --no-push gate ci: commit the ledger but do not push
160
164
  --push check --fix / update: commit + push applied changes to the default branch
161
165
  --allow-branch check --fix --push / update --push / repo refresh --push: allow committing on a non-default branch
166
+ --overwrite-local check --fix / update: replace managed files reported as 'modified'
167
+ (a <file>.yad-orig backup is written first)
162
168
  -h, --help Show this help
163
169
  -v, --version Print version
164
170
 
@@ -178,6 +184,7 @@ function parseArgs(argv) {
178
184
  else if (a === '--no-push') o.noPush = true;
179
185
  else if (a === '--push') o.push = true;
180
186
  else if (a === '--allow-branch') o.allowBranch = true;
187
+ else if (a === '--overwrite-local') o.overwriteLocal = true;
181
188
  else if (a === '--merged') o.merged = true;
182
189
  else if (a === '--overview') o.overview = true;
183
190
  // `--check` is a bare boolean for `docs sync --check`, but takes a value for
@@ -232,10 +239,10 @@ async function main() {
232
239
  });
233
240
  break;
234
241
  case 'check':
235
- await reconcile(o.dir, { fix: o.fix, scope: o.scope, force: o.force, push: o.push, allowBranch: o.allowBranch, today });
242
+ await reconcile(o.dir, { fix: o.fix, scope: o.scope, force: o.force, push: o.push, allowBranch: o.allowBranch, overwriteLocal: o.overwriteLocal, today });
236
243
  break;
237
244
  case 'update':
238
- await reconcile(o.dir, { fix: true, scope: 'changed', force: o.force, push: o.push, allowBranch: o.allowBranch, today });
245
+ await reconcile(o.dir, { fix: true, scope: 'changed', force: o.force, push: o.push, allowBranch: o.allowBranch, overwriteLocal: o.overwriteLocal, today });
239
246
  break;
240
247
  case 'doctor':
241
248
  await runDoctor(o.dir, { json: o.json });
package/cli/manifest.mjs CHANGED
@@ -230,6 +230,17 @@ export const REPO_WIRING = {
230
230
  ],
231
231
  };
232
232
 
233
+ // Provenance of the wired files above, per repo root. Wiring files are OURS to rewrite, but "the
234
+ // on-disk copy differs from the shipped template" cannot tell a STALE copy (overwrite it) from one a
235
+ // team deliberately CUSTOMIZED (ask first) — so every write records the sha256 of what it wrote here.
236
+ // On the next update, on-disk == recorded proves the copy is untouched since we wrote it; anything
237
+ // else is a local edit, reported as `modified` and left alone (#164). Committed, so the record
238
+ // travels with the repo instead of living in one person's clone.
239
+ export const MANAGED_LEDGER = '.sdlc/managed.json';
240
+ // Suffix for the copy written beside a managed file before its content is replaced without that
241
+ // proof — the local edit is always recoverable from the working tree, not only from git history.
242
+ export const BACKUP_SUFFIX = '.yad-orig';
243
+
233
244
  export const wiringFor = (platform) => [
234
245
  ...REPO_WIRING.common,
235
246
  ...(REPO_WIRING[platform] || []),
package/cli/plan.mjs CHANGED
@@ -3,12 +3,13 @@
3
3
  // setup (apply all), update (apply changed), and check (report; fix non-ok) share it.
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
+ import { err } from './errors.mjs';
6
7
  import {
7
- asset, exists, copyDir, copyFile, dirMatches, sameContent, readJSON,
8
+ asset, exists, copyDir, copyFile, dirMatches, sameContent, readJSON, readJSONStrict, writeJSON, fileSha,
8
9
  } from './lib.mjs';
9
10
  import {
10
- SKILLS, IDE_TARGETS, IDE_OPENCODE_DIR, MODULE_FILES, wiringFor, HUB_WIRING, PROJECT_FILES,
11
- LEGACY_SKILLS, REMOVED_SKILLS, LEGACY_MARKER, LEGACY_REPO_FILES, LEGACY_HUB_FILES,
11
+ VERSION, SKILLS, IDE_TARGETS, IDE_OPENCODE_DIR, MODULE_FILES, wiringFor, HUB_WIRING, PROJECT_FILES,
12
+ LEGACY_SKILLS, REMOVED_SKILLS, LEGACY_MARKER, LEGACY_REPO_FILES, LEGACY_HUB_FILES, MANAGED_LEDGER, BACKUP_SUFFIX,
12
13
  } from './manifest.mjs';
13
14
 
14
15
  // A git pathspec (forward slashes, relative to a repo root) for `dest` under `root`. Actions carry
@@ -36,6 +37,83 @@ const dirAction = (scope, item, src, dest, { root } = {}) => ({
36
37
  apply: () => copyDir(src, dest),
37
38
  });
38
39
 
40
+ // ---- managed-file provenance (#164) --------------------------------------------------------
41
+ // Read one repo root's ledger of "files yad wrote, and the sha it wrote". Strict, like every other
42
+ // ledger read: only an ABSENT ledger means "no record" ({}). One that exists but does not parse — or
43
+ // parses into something that is not a `files` map — must throw. Defaulting either to {} would
44
+ // silently downgrade every locally-modified file to an unrecorded one and re-open, one backup short,
45
+ // the silent clobber this record exists to prevent.
46
+ const isMap = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
47
+ export function readManagedLedger(root) {
48
+ const file = path.join(root, MANAGED_LEDGER);
49
+ const rec = readJSONStrict(file, null);
50
+ if (rec === null && !exists(file)) return {};
51
+ if (!isMap(rec) || !isMap(rec.files)) {
52
+ // Parses, but is not a record — YAD-STATE-002 (wrong shape), not -001 (does not parse).
53
+ throw err('YAD-STATE-002', `unreadable provenance record in ${file}: expected an object with a "files" map`,
54
+ 'restore it from git — or delete it to start over, which costs the record (the next update then backs up every managed file it replaces)');
55
+ }
56
+ return rec.files;
57
+ }
58
+
59
+ // Where the pre-overwrite copy of `dest` goes.
60
+ export const backupPathFor = (dest) => `${dest}${BACKUP_SUFFIX}`;
61
+
62
+ // A wired file (gate script, CI fragment, PR/MR template) — a fileAction plus provenance:
63
+ // 'ok' bytes are the shipped template
64
+ // 'missing' not installed
65
+ // 'outdated' differs, and the recorded sha proves WE wrote what is there (a stale copy) — or the
66
+ // file predates the ledger (no record at all), in which case nothing is proven and
67
+ // apply() saves a .yad-orig copy before replacing it
68
+ // 'modified' differs, and the recorded sha says someone edited our copy — never overwritten by a
69
+ // plain update; `--overwrite-local` replaces it (after a .yad-orig backup)
70
+ // apply() backs up whenever provenance is not proven, so no unproven content is ever discarded.
71
+ const wiredFileAction = (scope, item, src, dest, { root, exec = false, ledger = {} } = {}) => {
72
+ const base = fileAction(scope, item, src, dest, { root, exec });
73
+ const managed = { src, dest, root };
74
+ if (base.status !== 'outdated') return { ...base, managed };
75
+ const recorded = ledger[rel(root, dest)];
76
+ const ours = !!recorded && recorded === fileSha(dest);
77
+ const backup = ours ? null : backupPathFor(dest);
78
+ return {
79
+ ...base,
80
+ // No record at all is a pre-ledger install, not evidence of an edit: keep the routine upgrade
81
+ // working (still 'outdated'), but never discard content we cannot prove we wrote — hence backup.
82
+ status: ours || !recorded ? 'outdated' : 'modified',
83
+ managed,
84
+ backup,
85
+ apply: () => {
86
+ if (backup) fs.copyFileSync(dest, backup);
87
+ copyFile(src, dest, { exec });
88
+ },
89
+ };
90
+ };
91
+
92
+ // Persist the provenance of every managed file whose on-disk bytes ARE the shipped template — the
93
+ // ones just applied AND the ones already correct. Seeding the already-correct ones is what migrates
94
+ // an install made before this ledger existed: from then on, an edit to any of them is detectable.
95
+ // A file we skipped as `modified` is deliberately NOT recorded — it is the team's copy, not ours.
96
+ // Keys are sorted so two repos' updates produce mergeable, byte-stable ledgers.
97
+ // Returns the roots written, so the caller can stage them alongside what they describe.
98
+ export function recordManagedWrites(actions = []) {
99
+ const byRoot = new Map();
100
+ for (const a of actions) {
101
+ const m = a?.managed;
102
+ if (!m || !m.root) continue;
103
+ if (!sameContent(m.src, m.dest)) continue;
104
+ if (!byRoot.has(m.root)) byRoot.set(m.root, {});
105
+ byRoot.get(m.root)[rel(m.root, m.dest)] = fileSha(m.dest);
106
+ }
107
+ const roots = [];
108
+ for (const [root, written] of byRoot) {
109
+ const files = { ...readManagedLedger(root), ...written };
110
+ const sorted = Object.fromEntries(Object.keys(files).sort().map((k) => [k, files[k]]));
111
+ writeJSON(path.join(root, MANAGED_LEDGER), { version: VERSION, files: sorted });
112
+ roots.push(root);
113
+ }
114
+ return roots;
115
+ }
116
+
39
117
  // Persisted state gets one deliberately narrow compatibility repair. Explicit setup/planner input
40
118
  // does not: a caller typo is an error, while the known v3.11.1 `.cluade` stamp is safely migrated.
41
119
  const PERSISTED_IDE_ALIASES = new Map([['.cluade', '.claude']]);
@@ -371,8 +449,9 @@ export function legacyHubActions(root) {
371
449
  // Per-repo wiring (gate scripts, CI, PR template).
372
450
  export function repoActions(root, repo) {
373
451
  const repoRoot = path.resolve(root, repo.path);
452
+ const ledger = readManagedLedger(repoRoot);
374
453
  return wiringFor(repo.platform).map((w) =>
375
- fileAction(repo.name, w.dest, asset(w.src), path.join(repoRoot, w.dest), { root: repoRoot, exec: !!w.exec }),
454
+ wiredFileAction(repo.name, w.dest, asset(w.src), path.join(repoRoot, w.dest), { root: repoRoot, exec: !!w.exec, ledger }),
376
455
  );
377
456
  }
378
457
 
@@ -383,8 +462,9 @@ export function hubActions(root) {
383
462
  // `bridge_enabled` is the canonical flag (the documented hub-config schema); older setup versions
384
463
  // wrote `bridge` — accept an explicit true in either spelling, wire nothing otherwise.
385
464
  if (!hub?.platform || !(hub.bridge_enabled === true || hub.bridge === true)) return [];
465
+ const ledger = readManagedLedger(root);
386
466
  return [...HUB_WIRING.common, ...(HUB_WIRING[hub.platform] || [])].map((w) =>
387
- fileAction('hub', w.dest, asset(w.src), path.join(root, w.dest), { root, exec: !!w.exec }),
467
+ wiredFileAction('hub', w.dest, asset(w.src), path.join(root, w.dest), { root, exec: !!w.exec, ledger }),
388
468
  );
389
469
  }
390
470
 
package/cli/reconcile.mjs CHANGED
@@ -10,18 +10,18 @@ import {
10
10
  const readFileSafe = (p) => { try { return fs.readFileSync(p, 'utf8'); } catch { return ''; } };
11
11
 
12
12
  import { preflightGuardReadiness } from './hubcommit.mjs';
13
- import { VERSION, PROJECT_FILES } from './manifest.mjs';
13
+ import { VERSION, PROJECT_FILES, MANAGED_LEDGER, BACKUP_SUFFIX } from './manifest.mjs';
14
14
  import {
15
15
  moduleActions, repoActions, hubActions, authorsActions,
16
16
  legacyModuleActions, removedModuleActions, legacyRepoActions, legacyHubActions,
17
- ideTargetStateFor,
17
+ ideTargetStateFor, recordManagedWrites,
18
18
  } from './plan.mjs';
19
19
  import { gitHead, packRepo } from './setup.mjs';
20
- import { groupByRoot, commitUpdates } from './update-commit.mjs';
20
+ import { groupByRoot, commitUpdates, repoLabel } from './update-commit.mjs';
21
21
 
22
- const MARK = { missing: c.red('missing'), new: c.cyan('new'), outdated: c.yellow('outdated'), stale: c.yellow('stale'), legacy: c.yellow('legacy'), removed: c.yellow('removed'), ok: c.green('ok') };
22
+ const MARK = { missing: c.red('missing'), new: c.cyan('new'), outdated: c.yellow('outdated'), modified: c.cyan('modified'), stale: c.yellow('stale'), legacy: c.yellow('legacy'), removed: c.yellow('removed'), ok: c.green('ok') };
23
23
 
24
- export async function reconcile(root, { fix = false, scope = 'all', force = false, push = false, allowBranch = false } = {}) {
24
+ export async function reconcile(root, { fix = false, scope = 'all', force = false, push = false, allowBranch = false, overwriteLocal = false } = {}) {
25
25
  log(c.bold(`\nSDLC reconcile ${c.dim('v' + VERSION)}`));
26
26
  log(c.dim(`target: ${root}\n`));
27
27
 
@@ -91,7 +91,7 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
91
91
  if (!byScope.has(a.scope)) byScope.set(a.scope, []);
92
92
  byScope.get(a.scope).push(a);
93
93
  }
94
- const counts = { missing: 0, new: 0, outdated: 0, stale: 0, legacy: 0, removed: 0, ok: 0 };
94
+ const counts = { missing: 0, new: 0, outdated: 0, modified: 0, stale: 0, legacy: 0, removed: 0, ok: 0 };
95
95
  for (const [scopeName, items] of byScope) {
96
96
  const notOk = items.filter((i) => i.status !== 'ok');
97
97
  items.forEach((i) => counts[i.status]++);
@@ -125,16 +125,29 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
125
125
  warn('existing .cluade path was left untouched; review its contents and remove it manually');
126
126
  }
127
127
 
128
+ // A managed file the team edited is NEVER rewritten by a plain update — that silent clobber is what
129
+ // #164 reported. It is reported on every run (honest drift) until either the edit is dropped or
130
+ // `--overwrite-local` replaces it, which still saves the previous content beside it.
131
+ const modified = actions.filter((a) => a.status === 'modified');
132
+ for (const m of modified) {
133
+ warn(`${m.scope}/${m.item} is locally modified — it matches neither the shipped template nor the copy yad wrote`);
134
+ }
135
+ if (modified.length && !overwriteLocal) {
136
+ hand(`keep the edits (reported as \`modified\` on every check), or replace them with \`yad update --overwrite-local\` — each previous version is saved beside the file as <file>${BACKUP_SUFFIX}`);
137
+ }
138
+
128
139
  const fixable = actions.filter((a) =>
129
- a.status !== 'ok' && (scope === 'all' ? true : a.status !== 'missing'),
140
+ a.status !== 'ok'
141
+ && (a.status !== 'modified' || overwriteLocal)
142
+ && (scope === 'all' ? true : a.status !== 'missing'),
130
143
  );
131
144
  log('');
132
- log(c.dim(`summary: ${counts.missing} missing, ${counts.new} new, ${counts.outdated} outdated, ${counts.stale} stale, ${counts.legacy} legacy, ${counts.removed} removed, ${counts.ok} ok`));
145
+ log(c.dim(`summary: ${counts.missing} missing, ${counts.new} new, ${counts.outdated} outdated, ${counts.modified} modified, ${counts.stale} stale, ${counts.legacy} legacy, ${counts.removed} removed, ${counts.ok} ok`));
133
146
 
134
147
  if (!fix) {
135
148
  if (push) warn('--push has no effect without --fix (there is nothing applied to commit).');
136
149
  if (fixable.length || gaps.length) hand('run `yad check --fix` to reconcile (or `yad setup` for missing one-time setup).');
137
- return { counts, gaps, applied: 0 };
150
+ return { counts, gaps, applied: 0, modified: modified.length };
138
151
  }
139
152
 
140
153
  // --- apply --- (collect the applied actions so --push can stage each repo's exact allowlist) ---
@@ -145,16 +158,32 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
145
158
  a.apply();
146
159
  applied++;
147
160
  appliedActions.push(a);
148
- info(`${a.status} fixed: ${a.scope}/${a.item}`);
161
+ // A backup means the replaced content was not provably ours (a pre-ledger install, or an edit
162
+ // --overwrite-local was told to discard). Never report that as an ordinary template adoption.
163
+ info(`${a.status} → fixed: ${a.scope}/${a.item}${a.backup ? ` ${c.yellow(`(previous content saved to ${path.basename(a.backup)})`)}` : ''}`);
149
164
  }
150
165
  if (force) {
166
+ // --force re-copies what is already correct; it deliberately does NOT reach a `modified` file —
167
+ // only --overwrite-local discards a local edit, and only after backing it up.
151
168
  for (const a of actions.filter((a) => a.status === 'ok')) { a.apply(); appliedActions.push(a); }
152
169
  }
153
170
  // Refresh the version stamp and persist only the canonical targets used to build actions. This also
154
171
  // completes legacy/corrupt target migration even when no skill content itself needed an update.
155
172
  writeCanonicalStamp();
156
173
  appliedActions.push({ scope: 'hub', item: PROJECT_FILES.version, status: 'stamp', root, paths: [PROJECT_FILES.version] });
174
+ // Record what we wrote (and what was already correct) so the NEXT update can tell a stale managed
175
+ // file from an edited one. Seeding the already-correct files is what migrates a pre-ledger install.
176
+ // A file left as `modified` records nothing — it differs from the template by definition.
177
+ for (const ledgerRoot of recordManagedWrites(actions)) {
178
+ appliedActions.push({
179
+ scope: repoLabel(root, ledgerRoot), item: MANAGED_LEDGER, status: 'stamp',
180
+ root: ledgerRoot, paths: [MANAGED_LEDGER],
181
+ });
182
+ }
157
183
  applied ? ok(`reconciled ${applied} item(s)`) : info('nothing to fix');
184
+ if (modified.length && !overwriteLocal) {
185
+ warn(`${modified.length} locally modified file(s) left untouched — this update did not reach them`);
186
+ }
158
187
  if (gaps.length) hand('one-time setup still missing — run `yad setup`.');
159
188
 
160
189
  // --- publish: commit each repo's applied changes and push directly to its default branch ---
@@ -182,5 +211,5 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
182
211
  },
183
212
  });
184
213
  }
185
- return { counts, gaps, applied };
214
+ return { counts, gaps, applied, modified: modified.length };
186
215
  }
package/cli/setup.mjs CHANGED
@@ -10,7 +10,7 @@ import { VERSION, IDE_TARGETS, PROJECT_FILES, DESIGN_TOOLS, DESIGN_PRIMARY, TEST
10
10
  import {
11
11
  moduleActions, repoActions, hubActions, authorsActions,
12
12
  legacyModuleActions, removedModuleActions, legacyRepoActions, legacyHubActions,
13
- safeIdeTargetsFor, detectedIdeTargetStateFor,
13
+ safeIdeTargetsFor, detectedIdeTargetStateFor, recordManagedWrites,
14
14
  } from './plan.mjs';
15
15
  import { validateLogin, rolesForScope } from './platform.mjs';
16
16
 
@@ -348,10 +348,16 @@ export function registerLearning(root, { tool, kb = null, today = null } = {}) {
348
348
  function applyActions(actions, { force = false } = {}) {
349
349
  let changed = 0;
350
350
  for (const a of actions) {
351
+ // A managed file the team edited is left alone here too — setup re-runs with force:true, so
352
+ // without this the wizard would be a second silent-clobber path for the same edits (#164).
353
+ if (a.status === 'modified') {
354
+ warn(`kept locally modified ${a.scope}/${a.item} — replace it with \`yad update --overwrite-local\``);
355
+ continue;
356
+ }
351
357
  if (a.status === 'ok' && !force) continue;
352
358
  a.apply();
353
359
  changed++;
354
- info(`${a.status === 'missing' ? 'installed' : 'updated'} ${a.scope}/${a.item}`);
360
+ info(`${a.status === 'missing' ? 'installed' : 'updated'} ${a.scope}/${a.item}${a.backup ? ` (previous content saved to ${path.basename(a.backup)})` : ''}`);
355
361
  }
356
362
  if (!changed) info('already up to date');
357
363
  return changed;
@@ -708,9 +714,14 @@ export async function runSetup(root, opts = {}) {
708
714
  S('Wire connected repos + the hub (CI gates, PR template, gate-sync)');
709
715
  guide(['Installs the CI safety gates, PR/MR template, and gate-sync — automatic, no input needed.']);
710
716
  if (registry.repos.length === 0) info('no repos to wire');
717
+ // Every managed file this step writes is recorded (sha per repo root) so a LATER `yad update` can
718
+ // tell a stale copy from one the team edited, instead of silently rewriting both (#164).
719
+ const wired = [];
711
720
  for (const repo of registry.repos) {
712
721
  log(` ${c.bold(repo.name)} ${c.dim(`(${repo.platform})`)}`);
713
- applyActions(repoActions(root, repo), { force: true });
722
+ const repoWiring = repoActions(root, repo);
723
+ applyActions(repoWiring, { force: true });
724
+ wired.push(...repoWiring);
714
725
  // Migrate pre-2.0 wired CI (marker-owned sdlc-*.yml -> yad-*.yml); a user-authored
715
726
  // same-named file is never touched.
716
727
  applyActions(legacyRepoActions(root, repo), { force: true });
@@ -720,8 +731,12 @@ export async function runSetup(root, opts = {}) {
720
731
  if (hubWiring.length) {
721
732
  log(` ${c.bold('hub')} ${c.dim('(gate-sync + verified-commits CI)')}`);
722
733
  applyActions(hubWiring, { force: true });
734
+ wired.push(...hubWiring);
723
735
  }
724
736
  applyActions(legacyHubActions(root), { force: true });
737
+ // After every write to a managed path has landed (including the legacy renames), so the recorded
738
+ // sha is the file's final state.
739
+ recordManagedWrites(wired);
725
740
  // author allowlists for the verified-commits gate (hub + every repo), from the roster emails
726
741
  applyActions(authorsActions(root, registry.repos), { force: true });
727
742
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yadflow",
3
- "version": "3.15.5",
3
+ "version": "3.16.0",
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",
@@ -203,6 +203,18 @@ catches a free-form description that bypassed it:
203
203
  (`epics/**`, detected from the CI-supplied `--changed <file>` list) **FAILS** — artifact changes
204
204
  must go through a `review/EP-*` PR.
205
205
 
206
+ **GitLab truncates the description this gate reads.** `$CI_MERGE_REQUEST_DESCRIPTION` stops at **2700
207
+ characters**, so a long but perfectly valid MR can lose a required section *before the gate sees it* —
208
+ the author then reads "does not use the template" while looking at a description that visibly contains
209
+ it (#164). GitHub is unaffected (`github.event.pull_request.body` is not truncated). Two mitigations,
210
+ both shipped:
211
+
212
+ - the GitLab MR templates (`yad-pr-template` `templates/gitlab/…` and `templates/hub/gitlab/…`) carry
213
+ the constraint as a comment and keep every required section early, so a truncated body still passes;
214
+ - when a required section is missing **and** the body it read is ≥ 2700 characters, the gate prints a
215
+ `NOTE` naming the truncation and the fix — reorder the required sections above the cutoff and push
216
+ the long narrative to the end. Never delete a section: reordering is always allowed.
217
+
206
218
  ## 8. Phase 6 — feature-thread gates (`lineage-check.sh`, `epic-open.sh`, `reconcile-debt-check.sh`)
207
219
 
208
220
  After the contract locks and code ships, a change must not mutate a locked artifact — it becomes a new
@@ -26,6 +26,18 @@ touched domain). This step **never auto-advances**; it sets up the template and
26
26
  `templates/hub/gitlab/merge_request_templates/Default.md` →
27
27
  `{project-root}/.gitlab/merge_request_templates/Default.md`. The hub body carries no `Task:` trailer
28
28
  (hub PRs change artifacts, not code); its routing helper is `yad-hub-bridge`'s `hub-route.sh`.
29
+ - **GitLab reads a truncated description.** The `pr-template` gate is fed
30
+ `$CI_MERGE_REQUEST_DESCRIPTION`, which GitLab cuts at **2700 characters** — a required section below
31
+ that cutoff is invisible to the gate even though the MR shows it, and the failure reads "does not use
32
+ the template" (#164). Both GitLab templates say so in a comment and keep `## Summary` /
33
+ `## Impact & Risk` / `## Checklist` (hub: `## Artifact under review` / `## Impact & Risk (front-half)`
34
+ / `## Checklist`) early, so a truncated body still passes. Long narrative goes **after** them.
35
+ Sections may be reordered freely; deleting one fails the gate. GitHub is unaffected.
36
+ - **Installed templates are yad-managed.** `yad update` rewrites them on upgrade. An edit yad can
37
+ prove — the file's sha differs from the one it recorded when it wrote the template — is reported as
38
+ `modified` and left alone; a copy it has no record of is replaced after a `.yad-orig` backup (see
39
+ `docs/CLI.md` → *Managed files*). Either way, put knowledge that must survive an upgrade in an ADR
40
+ under `docs/`, not in the template.
29
41
  - The Impact & Risk block reuses the conventions of earlier steps: the `Task: <story>-<task>` trailer
30
42
  (`yad-implement`), the contract surface (`yad-architecture` / contract-check), and the
31
43
  domain-owner escalation (`yad-review-gate`).
@@ -45,6 +45,18 @@ if [ -z "$BODY" ] || [ ! -f "$BODY" ]; then
45
45
  exit 1
46
46
  fi
47
47
 
48
+ # GitLab TRUNCATES $CI_MERGE_REQUEST_DESCRIPTION at 2700 characters, so a long-but-valid description
49
+ # can lose a required section before this gate ever reads it — the author then sees "does not use the
50
+ # template" while looking at an MR that visibly contains it (#164). Measure the RAW body now (the
51
+ # trailer strip below shortens it) so a failure at that boundary can say so. GitHub bodies are not
52
+ # truncated, so the note is advisory and only ever printed alongside a real failure.
53
+ GITLAB_DESC_LIMIT=2700
54
+ # CHARACTERS, not bytes — GitLab counts characters, and a description full of multibyte punctuation
55
+ # (an em-dash costs 3 bytes) would hit 2700 bytes long before it could ever be truncated. `wc -m`
56
+ # would need a UTF-8 locale we cannot assume across CI images, so count UTF-8 code points the
57
+ # locale-independent way: every byte that is NOT a continuation byte (0x80-0xBF) starts one.
58
+ RAW_CHARS="$(LC_ALL=C tr -d '\200-\277' < "$BODY" | wc -c | tr -d '[:space:]')"
59
+
48
60
  # The Review Companion injects a `<!-- yad:trailer --> … <!-- /yad:trailer -->` briefing block (and
49
61
  # `<!-- yad:noblock -->` notes) into the description. Strip those before the template check so the
50
62
  # AI-generated prose can never hide a required section heading or be mistaken for the `Risk level:`
@@ -110,5 +122,10 @@ else
110
122
  check_code_body
111
123
  fi
112
124
 
125
+ if [ "$rc" != 0 ] && [ "$RAW_CHARS" -ge "$GITLAB_DESC_LIMIT" ]; then
126
+ echo "NOTE [pr-template]: the description this gate read is ${RAW_CHARS} characters. On GitLab the gate reads \$CI_MERGE_REQUEST_DESCRIPTION, which is TRUNCATED at ${GITLAB_DESC_LIMIT} — a section below that cutoff is invisible here even though the MR shows it."
127
+ echo "NOTE [pr-template]: if the missing section IS in your description, move the required sections above the cutoff (reorder, never delete) and push the long narrative to the end."
128
+ fi
129
+
113
130
  [ "$rc" = 0 ] && echo "PASS [pr-template]: body uses the ${KIND} template (required sections present)."
114
131
  exit "$rc"
@@ -1,4 +1,8 @@
1
1
  <!-- SDLC MR template (Phase 3 build plan §D). One atomic task per MR. -->
2
+ <!-- GITLAB 2700-CHARACTER LIMIT: the yad-pr-template gate reads $CI_MERGE_REQUEST_DESCRIPTION, which
3
+ GitLab truncates at 2700 characters — a heading past the cutoff reads as missing even though you
4
+ can see it here. Keep Summary, Impact & Risk (with its filled risk-level line) and Checklist
5
+ within the first 2700 characters; put long narrative below them. Reorder, never delete. -->
2
6
 
3
7
  ## Summary
4
8
  <!-- What this MR does, in one or two sentences. -->
@@ -2,6 +2,9 @@
2
2
  <!-- This MR is a REVIEW VEHICLE on the product hub, not a code merge. The file gate (yad-review-gate)
3
3
  advances the step; do NOT rely on merging this MR to advance. Reviewers approve/comment here, then a
4
4
  `yad-review-gate action: sync` pulls that into the file ledger. -->
5
+ <!-- GITLAB 2700-CHARACTER LIMIT: the yad-pr-template gate reads $CI_MERGE_REQUEST_DESCRIPTION, which
6
+ GitLab truncates at 2700 characters — a heading past the cutoff reads as missing. The required
7
+ sections come first for that reason; keep long narrative last. Reorder, never delete. -->
5
8
 
6
9
  ## Artifact under review
7
10
  - Epic: `EP-<slug>`
@@ -9,14 +12,20 @@
9
12
  - Gate step: `<epic-review | architecture-review | ui-design-review | stories-review>`
10
13
  - Owner: `<epic.md owner>`
11
14
 
12
- ## What changed
13
- <!-- One or two sentences on what this artifact says / what changed since the last review round. -->
14
-
15
15
  ## Impact & Risk (front-half)
16
16
  - **Domains / repos touched:** <epic.repos, e.g. backend, mobile>
17
17
  - **Risk tags:** <none | contract | auth | payments> <!-- contract/auth/payments => escalates to domain owners -->
18
18
  - **Contract surface:** <n/a | locked @ sha256:…> <!-- architecture only; a re-lock invalidates prior approvals -->
19
19
 
20
+ ## Checklist
21
+ - [ ] `owner` set in the artifact frontmatter (inherited from `epic.md`)
22
+ - [ ] Contract re-locked (`.sdlc/contract-lock.json`) if the surface changed (architecture only)
23
+ - [ ] Risk tags reflect the real surface touched (contract/auth/payments escalate)
24
+ - [ ] No secrets or tokens in the artifact or this description
25
+
26
+ ## What changed
27
+ <!-- One or two sentences on what this artifact says / what changed since the last review round. -->
28
+
20
29
  ## Required approvals (yad-review-gate rule)
21
30
  - Base: **owner + 1 reviewer**.
22
31
  - Escalated (risk tag set, or a stories MR): **plus one domain-owner per touched repo** — see the
@@ -28,10 +37,4 @@
28
37
  - **Comment** to record review comments (synced into `reviews/<artifact>--<date>--comments.md`).
29
38
  - **Do NOT merge to advance** — `yad-review-gate action: sync` + `action: advance` move the step.
30
39
 
31
- ## Checklist
32
- - [ ] `owner` set in the artifact frontmatter (inherited from `epic.md`)
33
- - [ ] Contract re-locked (`.sdlc/contract-lock.json`) if the surface changed (architecture only)
34
- - [ ] Risk tags reflect the real surface touched (contract/auth/payments escalate)
35
- - [ ] No secrets or tokens in the artifact or this description
36
-
37
40
  /assign me