yadflow 3.12.2 → 3.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ # [3.13.0](https://github.com/abdelrahmannasr/yadflow/compare/v3.12.2...v3.13.0) (2026-07-14)
2
+
3
+
4
+ ### Features
5
+
6
+ * **checkpoint:** add --retro-ship to reconcile pre-tracking shipped stories ([#142](https://github.com/abdelrahmannasr/yadflow/issues/142)) ([d7988a3](https://github.com/abdelrahmannasr/yadflow/commit/d7988a335da7d675dd7b53456c12610c3f442d97)), closes [#112](https://github.com/abdelrahmannasr/yadflow/issues/112) [#112](https://github.com/abdelrahmannasr/yadflow/issues/112)
7
+
1
8
  ## [3.12.2](https://github.com/abdelrahmannasr/yadflow/compare/v3.12.1...v3.12.2) (2026-07-14)
2
9
 
3
10
 
package/bin/yad.mjs CHANGED
@@ -101,6 +101,10 @@ ${c.bold('Build helpers')}
101
101
  status: flip (→ in-build/shipped) backed by a build-log
102
102
  ship — as one audit-trail chore(hub) commit; default
103
103
  branch only (--allow-branch to override); no-op when clean
104
+ yad checkpoint --retro-ship <epic>/<story> --repo <r>
105
+ Record a retroactive build-log ship for a PRE-TRACKING story
106
+ (merged before ledger tracking), then carry its status: shipped
107
+ flip in the same commit (--merge-commit <sha>, --task <t> opt.)
104
108
  yad tidy up [<epic>] [--push] Fold FINISHED back-half shards (a shipped story's
105
109
  trust-log/build-log entries) back into the single folded
106
110
  ledger, as one chore(hub) commit — the manual "pack it up"
@@ -155,7 +159,7 @@ ${c.bold('Environment')}
155
159
  YAD_NO_UPDATE_NOTIFIER=1 Silence the "update available" notice (also off in CI)
156
160
  YAD_NO_REPORT=1 Never offer to file a bug report after a failure`;
157
161
 
158
- const VALUE_FLAGS = new Set(['--dir', '--type', '--message', '--task', '--ai', '--risk', '--repo', '--platform', '--base', '--title', '--scope', '--branch', '--pr', '--epic', '--name', '--email', '--roles', '--team', '--body', '--out', '--since', '--until', '--member', '--format', '--reason']);
162
+ const VALUE_FLAGS = new Set(['--dir', '--type', '--message', '--task', '--ai', '--risk', '--repo', '--platform', '--base', '--title', '--scope', '--branch', '--pr', '--epic', '--name', '--email', '--roles', '--team', '--body', '--out', '--since', '--until', '--member', '--format', '--reason', '--retro-ship', '--merge-commit']);
159
163
 
160
164
  function parseArgs(argv) {
161
165
  const o = { _: [], dir: process.cwd(), fix: false, force: false, scope: 'all' };
@@ -301,9 +305,20 @@ async function main() {
301
305
  case 'ship':
302
306
  await runShip(o.dir, { type: o.type, message: o.message, task: o.task, ai: o.ai, contractChange: o.contractChange, dryRun: o.dryRun, force: o.force, repo: o.repo, platform: o.platform, base: o.base, title: o.title, risk: o.risk });
303
307
  break;
304
- case 'checkpoint':
305
- await runCheckpoint(o.dir, { push: o.push, allowBranch: o.allowBranch, dryRun: o.dryRun });
308
+ case 'checkpoint': {
309
+ let retroShip;
310
+ if (o['retro-ship']) {
311
+ const [epic, story] = String(o['retro-ship']).split('/');
312
+ if (!epic || !isValidEpicId(epic)) { log(c.red(`invalid --retro-ship: expected <epic>/<story> with epic EP-<slug> (got ${o['retro-ship']})`)); process.exitCode = 1; break; }
313
+ // The story id becomes a path element (stories/<story>.md) — pin it to the id shape (and to its
314
+ // own epic) so a `..` or a slash can never traverse out of the epic's stories dir, and a typo'd
315
+ // cross-epic id is caught here rather than failing obscurely later.
316
+ if (!story || !/^EP-[a-z0-9-]+-S\d+$/.test(story) || !story.startsWith(`${epic}-S`)) { log(c.red(`invalid --retro-ship: expected <epic>/<story> with story <epic>-S<NN> (got ${o['retro-ship']})`)); process.exitCode = 1; break; }
317
+ retroShip = { epic, story, repo: o.repo, task: o.task, mergeCommit: o['merge-commit'], today };
318
+ }
319
+ await runCheckpoint(o.dir, { push: o.push, allowBranch: o.allowBranch, dryRun: o.dryRun, retroShip });
306
320
  break;
321
+ }
307
322
  case 'tidy': {
308
323
  const [, action, epic] = o._;
309
324
  if (action !== 'up') { log(`usage: yad tidy up [<epic>] [--push] [--dry-run]`); process.exitCode = action ? 1 : 0; break; }
@@ -27,7 +27,7 @@ import { PROJECT_FILES } from './manifest.mjs';
27
27
  import { loadHub } from './gate.mjs';
28
28
  import { resolveCommitterLogin } from './platform.mjs';
29
29
  import { hubGit, resolveDefaultBranch, guardDefaultBranch } from './hubcommit.mjs';
30
- import { readShips } from './ledger.mjs';
30
+ import { readShips, writeRetroShip } from './ledger.mjs';
31
31
  import { readFrontmatter } from './epic-state.mjs';
32
32
 
33
33
  // The machine-written back-half ledgers, relative to an epic's dir. The two append-only logs are
@@ -166,6 +166,48 @@ export function stagedStoryIsStatusOnly(git, file) {
166
166
  return changes > 0;
167
167
  }
168
168
 
169
+ // --retro-ship orchestration (#142): validate the target, then write the retroactive ship shard so the
170
+ // normal checkpoint path carries the story's already-made `status:` flip. Returns { ok, file } — ok:false
171
+ // (with a printed reason) aborts the commit; `file` is the shard just written, so a dry run can delete it
172
+ // and leave no side effect. Does NOT author the story frontmatter — it only supplies the missing
173
+ // evidence, and the human must have ALREADY flipped `status:` to a back-half value in the working tree.
174
+ export function recordRetroShip(root, { epic, story, repo, task, mergeCommit, today }) {
175
+ if (!epic || !story) { fail('--retro-ship needs <epic>/<story> (e.g. --retro-ship EP-foo/EP-foo-S01)'); return { ok: false }; }
176
+ if (!repo) { fail('--retro-ship needs --repo <name> (the repo the story shipped in)'); return { ok: false }; }
177
+ const epicDir = path.join(root, 'epics', epic);
178
+ const storyRel = path.posix.join('epics', epic, 'stories', `${story}.md`);
179
+ const storyFile = path.join(epicDir, 'stories', `${story}.md`);
180
+ if (!exists(epicDir)) { fail(`no epic ${epic} under epics/`); return { ok: false }; }
181
+ if (!exists(storyFile)) { fail(`no story ${story} under epics/${epic}/stories/`); return { ok: false }; }
182
+
183
+ // Evidence and the flip must land TOGETHER — the #112 no-drift invariant. Refuse unless the human has
184
+ // already flipped the story frontmatter to a back-half status in the working tree; otherwise the ship
185
+ // shard would commit while the artifact still says e.g. `approved` — the very drift #112 prevents.
186
+ if (!BACK_HALF_STATUSES.has(readFrontmatter(storyFile).status)) {
187
+ fail(`${story} frontmatter is not at in-build|shipped`);
188
+ hand(`set \`status: shipped\` in ${storyRel} first, then re-run — the ship and the flip land in one commit`);
189
+ return { ok: false };
190
+ }
191
+
192
+ let res;
193
+ try { res = writeRetroShip(epicDir, { story, repo, task, mergeCommit, shippedAt: today }); }
194
+ catch (e) { fail(`could not record retroactive ship — ${e.message}`); return { ok: false }; }
195
+ if (!res.written) {
196
+ fail(`${story} already has a build-log ship — it is not pre-tracking; use the normal ship/checkpoint flow`);
197
+ return { ok: false };
198
+ }
199
+ ok(`recorded retroactive ship for ${story} (${repo})${mergeCommit ? ` @ ${mergeCommit}` : ''}`);
200
+ return { ok: true, file: res.file };
201
+ }
202
+
203
+ // Undo a retro shard a dry run wrote so the preview leaves NO side effect: delete the shard, and if its
204
+ // `.sdlc/build-log/` dir was created just for it (now empty), drop that too. Best-effort — a non-empty
205
+ // dir (pre-existing shards) or a benign rmdir race is ignored.
206
+ function cleanupRetroShard(file) {
207
+ fs.rmSync(file, { force: true });
208
+ try { fs.rmdirSync(path.dirname(file)); } catch { /* not empty / already gone — leave it */ }
209
+ }
210
+
169
211
  export async function runCheckpoint(root, opts = {}) {
170
212
  log(c.bold('\nyad checkpoint'));
171
213
  if (!exists(path.join(root, '.git'))) { fail('not a git repo'); process.exitCode = 1; return; }
@@ -183,6 +225,17 @@ export async function runCheckpoint(root, opts = {}) {
183
225
  // Default-branch guard (invariant 2) — shared with `yad tidy up`.
184
226
  if (!guardDefaultBranch(branch, defaultBranch, { allowBranch: opts.allowBranch, cmd: 'yad checkpoint' })) return;
185
227
 
228
+ // --retro-ship (#142): record a retroactive build-log ship for a PRE-TRACKING story (merged before
229
+ // the back-half ledger existed, so it has no ship and its `status:` flip can't be carried). Done
230
+ // AFTER the branch guard so we never leave a dangling shard on the wrong branch; the flip the human
231
+ // already wrote is then carried by the normal storyStatusPathspecs path below — no raw git needed.
232
+ let retroFile;
233
+ if (opts.retroShip) {
234
+ const r = recordRetroShip(root, opts.retroShip);
235
+ if (!r.ok) { process.exitCode = 1; return; }
236
+ retroFile = r.file;
237
+ }
238
+
186
239
  // The machine ledgers PLUS any build-log-backed story `status:` flip (#112) — one commit records
187
240
  // both, so the story artifact never drifts from build-log and no raw git-to-main push is needed.
188
241
  const pathspecs = [...backHalfPathspecs(root), ...storyStatusPathspecs(root)];
@@ -224,6 +277,9 @@ export async function runCheckpoint(root, opts = {}) {
224
277
  if (opts.dryRun) {
225
278
  log('\n' + c.dim(message) + '\n');
226
279
  git('reset', '-q', '--', ...pathspecs); // restore the index — a dry run must not leave things staged
280
+ // A --retro-ship dry run wrote a shard so the flip could be PREVIEWED above; undo it now so the dry
281
+ // run leaves no side effect on disk (git reset only unstaged it, back to untracked).
282
+ if (retroFile) cleanupRetroShard(retroFile);
227
283
  info('dry run — not committed');
228
284
  return { message };
229
285
  }
@@ -231,6 +287,8 @@ export async function runCheckpoint(root, opts = {}) {
231
287
  const cm = git('commit', '-m', message, '--', ...staged);
232
288
  if (!cm.ok) {
233
289
  git('reset', '-q', '--', ...pathspecs); // don't leave the ledgers staged for an unrelated commit to sweep up
290
+ // The retro shard stays on disk (untracked) — a follow-up `yad checkpoint` picks it up and carries
291
+ // the flip; re-running `--retro-ship` would refuse ("already has a build-log ship").
234
292
  fail(`git commit failed — ${cm.stderr.split('\n')[0] || cm.code}`);
235
293
  process.exitCode = 1;
236
294
  return { message };
package/cli/ledger.mjs CHANGED
@@ -79,6 +79,32 @@ export function readShips(epicDir) {
79
79
  return [...byKey.values()];
80
80
  }
81
81
 
82
+ // Record a RETROACTIVE ship for a pre-tracking story — one merged & shipped before the back-half
83
+ // ledger existed, so it has no build-log ship and `yad checkpoint` can't carry its `status:` flip
84
+ // (issue #142). Writes ONE minimal ship shard, marked `retroactive: true`, so `readShips` now proves
85
+ // the story shipped and checkpoint carries the human's already-made flip. It is NOT a fabricated real
86
+ // ship: `task` defaults to the sentinel `retro`, `mergeCommit` is written only when the caller supplies
87
+ // it (never invented), and `shippedAt` is the backfill date (the `retroactive` flag marks it as such).
88
+ //
89
+ // Guard: refuse when the story ALREADY has ANY build-log ship — then it isn't pre-tracking and the
90
+ // normal ship/checkpoint flow applies; a retro record would only muddy the ledger. Returns
91
+ // { written: false, reason } in that case, else { written: true, file, ship }.
92
+ export function writeRetroShip(epicDir, { story, repo, task = 'retro', mergeCommit, shippedAt }) {
93
+ if (!story) throw new Error('writeRetroShip: story is required');
94
+ if (!repo) throw new Error('writeRetroShip: repo is required');
95
+ if (readShips(epicDir).some((s) => s.story === story)) {
96
+ return { written: false, reason: 'exists' };
97
+ }
98
+ const t = task || 'retro';
99
+ const ship = { story, task: t, repo, retroactive: true, note: 'pre-tracking backfill' };
100
+ if (mergeCommit) ship.mergeCommit = mergeCommit;
101
+ if (shippedAt) ship.shippedAt = shippedAt;
102
+ const f = epicFiles(epicDir);
103
+ const file = path.join(f.buildLogDir, buildShardName({ story, task: t, repo }));
104
+ writeJSON(file, ship);
105
+ return { written: true, file, ship };
106
+ }
107
+
82
108
  // Find the ship matching `match(ship)` across loose shards (authoritative until folded) then the
83
109
  // folded file, apply `update(ship)`, and write back ONLY the file that holds it. Returns
84
110
  // { found, where, file, ship }; found:false writes nothing (the caller warns).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yadflow",
3
- "version": "3.12.2",
3
+ "version": "3.13.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",
@@ -66,6 +66,26 @@ not by hand: after recording the ship, `yad checkpoint --push` lands the new `bu
66
66
  `chore(hub): …` audit-trail commit on the default branch (allowlist-scoped to the back-half ledgers,
67
67
  never a front-half gate file); `yad tidy up` folds finished shards into `build-log.json` later.
68
68
 
69
+ ### Retroactive ship — a pre-tracking story (#142)
70
+
71
+ A story that was merged and shipped **before** the back-half ledger existed has no build-log ship, so
72
+ `yad checkpoint` can't carry its `status: shipped` flip (the flip is only carried when a ship backs it,
73
+ #112) — leaving a raw `git push origin main` as the only way to land it, against the never-raw-git
74
+ convention. To reconcile it through yad, record a **retroactive** ship, then checkpoint carries the flip
75
+ in the same commit:
76
+
77
+ ```
78
+ yad checkpoint --retro-ship <epic>/<story> --repo <r> [--task <t>] [--merge-commit <sha>] [--push]
79
+ ```
80
+
81
+ It writes ONE minimal ship shard marked `retroactive: true` (`task` defaults to the sentinel `retro`;
82
+ `mergeCommit` is written only if you pass `--merge-commit`; `shippedAt` is the backfill date), then runs
83
+ the normal checkpoint so the story's already-made `status:` flip rides along in the **same** commit. It
84
+ refuses when the story already has a real ship (then it isn't pre-tracking — use the normal flow). It
85
+ does **not** author the story frontmatter — and to keep evidence and the flip atomic (the no-drift
86
+ invariant), it **refuses** unless you have already set `status: shipped` in `stories/<story>.md`, so a
87
+ ship shard is never committed while the artifact still says `approved`.
88
+
69
89
  **Engagement (the Review Companion).** Each `engineer_review` entry carries `engagement: verified | none`
70
90
  — `verified` when the engineer reviewed through the [companion](../../yad-review-companion/SKILL.md)
71
91
  (`yad review trailer/context/nudge`, a real trailer/cards/chat session over the diff), `none` for a bare