yadflow 3.12.1 → 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 +14 -0
- package/bin/yad.mjs +18 -3
- package/cli/checkpoint.mjs +59 -1
- package/cli/ledger.mjs +26 -0
- package/package.json +1 -1
- package/skills/yad-checks/references/check-gates.md +7 -0
- package/skills/yad-checks/templates/checks/verified-commits.sh +24 -0
- package/skills/yad-engineer-review/references/ship-and-record.md +20 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,17 @@
|
|
|
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
|
+
|
|
8
|
+
## [3.12.2](https://github.com/abdelrahmannasr/yadflow/compare/v3.12.1...v3.12.2) (2026-07-14)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Bug Fixes
|
|
12
|
+
|
|
13
|
+
* **checks:** waive verified-commits signature for content-free merge commits ([1e73837](https://github.com/abdelrahmannasr/yadflow/commit/1e738372f821e93020069b565d40315cd7be2591)), closes [#138](https://github.com/abdelrahmannasr/yadflow/issues/138)
|
|
14
|
+
|
|
1
15
|
## [3.12.1](https://github.com/abdelrahmannasr/yadflow/compare/v3.12.0...v3.12.1) (2026-07-14)
|
|
2
16
|
|
|
3
17
|
|
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
|
-
|
|
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; }
|
package/cli/checkpoint.mjs
CHANGED
|
@@ -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.
|
|
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",
|
|
@@ -93,6 +93,13 @@ repo. For each commit in `<base>..HEAD`, two independent checks:
|
|
|
93
93
|
(2+ parents) — a merge's author is whoever pressed merge (often a platform noreply), not a roster
|
|
94
94
|
human, and its content already passed the PR gate suite. This waiver matters for the push-on-default
|
|
95
95
|
`yad-update-guard` (§9), which — unlike this PR-triggered gate — sees merge commits.
|
|
96
|
+
A merge commit is **additionally signature-waived when it introduces no content of its own** (its
|
|
97
|
+
combined diff — `git diff-tree --cc` — is empty, i.e. no conflict-resolution or evil-merge hunks):
|
|
98
|
+
every change it carries already lives in an individually author+signature-checked parent, so there
|
|
99
|
+
is nothing to protect. This unblocks **self-hosted GitLab**, which does not sign UI-created merge
|
|
100
|
+
commits (the signature API returns 404) — without it every routine merge would red the branch. A
|
|
101
|
+
merge that *does* introduce content of its own still requires a verified signature (fail-closed),
|
|
102
|
+
so an evil merge pushed direct-to-default cannot smuggle in unverified changes.
|
|
96
103
|
|
|
97
104
|
Degradation is explicit, never silent: a missing allowlist SKIPs the author check with a warning
|
|
98
105
|
(configure roster emails, re-wire); no GitHub/GitLab remote SKIPs the signature check (the badge is a
|
|
@@ -11,6 +11,12 @@
|
|
|
11
11
|
# commits set the platform itself as the committer (e.g. noreply@github.com), and their integrity is
|
|
12
12
|
# covered by the signature check (the platform signs them).
|
|
13
13
|
#
|
|
14
|
+
# Merge-commit signature exemption: a merge that introduces NO content of its own (its combined diff
|
|
15
|
+
# is empty — no conflict-resolution / evil-merge hunks) is signature-waived, because every change it
|
|
16
|
+
# carries already lives in its individually author+signature-checked parents. This unblocks self-hosted
|
|
17
|
+
# GitLab, which does not sign UI-created merge commits (the signature API returns 404). A merge that
|
|
18
|
+
# DOES introduce content of its own still requires a verified signature (fail-closed).
|
|
19
|
+
#
|
|
14
20
|
# Degradation is explicit, never silent:
|
|
15
21
|
# - no allowlist file -> author check SKIPPED with a warning (configure emails, re-wire)
|
|
16
22
|
# - no GitHub/GitLab remote -> signature check SKIPPED with a warning (no platform, no badge)
|
|
@@ -88,6 +94,19 @@ signature_verified() {
|
|
|
88
94
|
esac
|
|
89
95
|
}
|
|
90
96
|
|
|
97
|
+
# 0 when a merge commit introduced NO content of its own. The combined diff (--cc) lists only hunks
|
|
98
|
+
# that differ from ALL parents, so empty output means every change lives in an individually-checked
|
|
99
|
+
# parent — there is no conflict-resolution or evil-merge content unique to the merge commit, hence
|
|
100
|
+
# nothing an unverified author could smuggle in past the per-parent author+signature checks.
|
|
101
|
+
merge_introduces_no_content() {
|
|
102
|
+
# `local` on its own line: `local out=$(...)` would mask the substitution's exit status (local
|
|
103
|
+
# always returns 0). A git error (e.g. a parent tree missing in a shallow clone) must fail closed —
|
|
104
|
+
# an empty stdout from a *failed* command is not evidence the merge is content-free.
|
|
105
|
+
local out
|
|
106
|
+
out="$(git diff-tree --cc --no-commit-id -r "$1")" || return 1
|
|
107
|
+
[ -z "$out" ]
|
|
108
|
+
}
|
|
109
|
+
|
|
91
110
|
rc=0
|
|
92
111
|
while IFS= read -r sha; do
|
|
93
112
|
[ -z "$sha" ] && continue
|
|
@@ -126,6 +145,11 @@ while IFS= read -r sha; do
|
|
|
126
145
|
if [ -n "$platform" ]; then
|
|
127
146
|
if signature_verified "$sha"; then
|
|
128
147
|
echo "PASS [verified-commits]: ${short} signature verified by ${platform}"
|
|
148
|
+
elif [ "$is_merge" = 1 ] && merge_introduces_no_content "$sha"; then
|
|
149
|
+
# Content-free merge: nothing to protect. Self-hosted GitLab does not sign UI merge commits, so
|
|
150
|
+
# requiring a signature here would block every routine merge. An evil merge (content of its own)
|
|
151
|
+
# falls through to FAIL below.
|
|
152
|
+
echo "WARN [verified-commits]: ${short} unsigned merge commit — introduces no content of its own (covered by verified parents); signature waived. (Self-hosted GitLab does not sign UI merge commits; on a platform that signs its merges an unsigned content-free merge is unusual but still harmless.)"
|
|
129
153
|
else
|
|
130
154
|
echo "FAIL [verified-commits]: ${short} signature missing/unverified — sign commits (GPG/SSH key registered on ${platform}), or the signature API was unreachable (GitLab: set GITLAB_TOKEN/SDLC_API_TOKEN with read_api)."
|
|
131
155
|
rc=1
|
|
@@ -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
|