yadflow 3.12.2 → 3.13.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 +30 -0
- package/bin/yad.mjs +26 -6
- package/cli/checkpoint.mjs +59 -1
- package/cli/doctor.mjs +113 -1
- package/cli/epic-state.mjs +14 -6
- package/cli/gate.mjs +191 -25
- package/cli/ledger.mjs +26 -0
- package/cli/platform.mjs +80 -0
- package/package.json +3 -3
- package/skills/yad-architecture/SKILL.md +15 -3
- package/skills/yad-architecture/references/contract-format.md +4 -1
- package/skills/yad-checks/references/check-gates.md +18 -1
- package/skills/yad-checks/templates/checks/contract-check.sh +46 -3
- package/skills/yad-checks/templates/checks/epic-open.sh +41 -9
- package/skills/yad-checks/templates/checks/lineage-check.sh +38 -9
- package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +39 -7
- package/skills/yad-checks/templates/checks/spec-link.sh +17 -5
- package/skills/yad-docs/templates/app/package-lock.json +7 -519
- package/skills/yad-engineer-review/references/ship-and-record.md +20 -0
- package/skills/yad-epic/references/state-schema.md +13 -0
- package/skills/yad-hub-bridge/references/bridge.md +50 -5
- package/skills/yad-hub-bridge/templates/github/yad-gate-sync.yml +6 -3
- package/skills/yad-hub-bridge/templates/gitlab/yad-gate-sync.gitlab-ci.yml +13 -2
- package/skills/yad-review-gate/SKILL.md +13 -3
- package/skills/yad-review-gate/references/gating.md +7 -3
- package/skills/yad-spec/references/spec-handoff.md +12 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,33 @@
|
|
|
1
|
+
## [3.13.1](https://github.com/abdelrahmannasr/yadflow/compare/v3.13.0...v3.13.1) (2026-07-29)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* **checks:** apply the Task-trailer rule to the thread gates too ([18aed8d](https://github.com/abdelrahmannasr/yadflow/commit/18aed8df958718483fe929cea84275ca86ae67be)), closes [#157](https://github.com/abdelrahmannasr/yadflow/issues/157)
|
|
7
|
+
* **checks:** do not build a /-rooted lock path when product-repo is absent ([0d2a214](https://github.com/abdelrahmannasr/yadflow/commit/0d2a214a30c7fc31ad087fa127277b50637a00de))
|
|
8
|
+
* **checks:** keep product-repo resolution backward-compatible and loud ([4c00c92](https://github.com/abdelrahmannasr/yadflow/commit/4c00c928642fbab4517805be14a1c6926f41b84c)), closes [#149](https://github.com/abdelrahmannasr/yadflow/issues/149) [#149](https://github.com/abdelrahmannasr/yadflow/issues/149)
|
|
9
|
+
* **checks:** resolve link.md product-repo the same way in every gate ([47c9b30](https://github.com/abdelrahmannasr/yadflow/commit/47c9b30548153ef5be607cf760c3e9ec9e73699f)), closes [#149](https://github.com/abdelrahmannasr/yadflow/issues/149)
|
|
10
|
+
* **checks:** spec-link resolves a Task trailer even on a maintenance commit ([d24dd83](https://github.com/abdelrahmannasr/yadflow/commit/d24dd83abadc63bb00e8c8d6d7c6008e32344db8)), closes [#157](https://github.com/abdelrahmannasr/yadflow/issues/157)
|
|
11
|
+
* **doctor:** refuse a decorative contract lock, and report a gate that went stale ([1ee3296](https://github.com/abdelrahmannasr/yadflow/commit/1ee3296dbddfdd308949b8013b3bf678f638fc80)), closes [#156](https://github.com/abdelrahmannasr/yadflow/issues/156)
|
|
12
|
+
* **gate:** bound the review-branch probe so it can never hang gate open ([afa5416](https://github.com/abdelrahmannasr/yadflow/commit/afa54167efd5879537c4bc19ab29531fe08ed31b))
|
|
13
|
+
* **gate:** hash the contract surface exactly as the documented recipe does ([f29e781](https://github.com/abdelrahmannasr/yadflow/commit/f29e78183b92c7baf33b46dbdfa7a4776aa06e4e)), closes [#156](https://github.com/abdelrahmannasr/yadflow/issues/156)
|
|
14
|
+
* **gate:** keep the recorded PR entry when --pr names that same PR ([b1febe1](https://github.com/abdelrahmannasr/yadflow/commit/b1febe184278109f0bafc2968648da362d68483a))
|
|
15
|
+
* **gate:** make a merged review PR reachable by hand ([4ccbd17](https://github.com/abdelrahmannasr/yadflow/commit/4ccbd1736640935175d63bac15a01a94e381b44e)), closes [#158](https://github.com/abdelrahmannasr/yadflow/issues/158)
|
|
16
|
+
* **gate:** never drop a done step's approval record on re-sync ([1a5e434](https://github.com/abdelrahmannasr/yadflow/commit/1a5e4348d00edd4e775a735a49fc0a86a3f0e391)), closes [#156](https://github.com/abdelrahmannasr/yadflow/issues/156)
|
|
17
|
+
* **gate:** re-bind approvals recorded before PR provenance existed ([6b7e816](https://github.com/abdelrahmannasr/yadflow/commit/6b7e816638a329d9365c6e727b2c12debdbd24aa)), closes [#156](https://github.com/abdelrahmannasr/yadflow/issues/156) [#156](https://github.com/abdelrahmannasr/yadflow/issues/156)
|
|
18
|
+
* **gate:** re-sync a re-opened review so its approvals re-bind ([28eabd2](https://github.com/abdelrahmannasr/yadflow/commit/28eabd222214ad2af7d18ef20c56795c9eb171d1)), closes [#156](https://github.com/abdelrahmannasr/yadflow/issues/156)
|
|
19
|
+
* **gate:** require the review branch on origin, and check before writing state ([27f0d92](https://github.com/abdelrahmannasr/yadflow/commit/27f0d92ce680c26df6ea7ff6510879a11f05d00f)), closes [#158](https://github.com/abdelrahmannasr/yadflow/issues/158)
|
|
20
|
+
* **gate:** stop a done-step re-sync from churning the ledger ([68462a6](https://github.com/abdelrahmannasr/yadflow/commit/68462a622753c0d30b458d22d9e1ad890ad8e61f)), closes [#156](https://github.com/abdelrahmannasr/yadflow/issues/156)
|
|
21
|
+
* **gate:** validate --pr and confirm it names this artifact's review ([f5d7773](https://github.com/abdelrahmannasr/yadflow/commit/f5d7773da474784a43cc3e24298da94c8f38bbdb)), closes [#7](https://github.com/abdelrahmannasr/yadflow/issues/7) [#158](https://github.com/abdelrahmannasr/yadflow/issues/158)
|
|
22
|
+
* **hub-bridge:** serialize the GitLab gate-sync job ([4294979](https://github.com/abdelrahmannasr/yadflow/commit/4294979f80cb4491d074fe27e74f9af454a2f62a)), closes [#156](https://github.com/abdelrahmannasr/yadflow/issues/156)
|
|
23
|
+
|
|
24
|
+
# [3.13.0](https://github.com/abdelrahmannasr/yadflow/compare/v3.12.2...v3.13.0) (2026-07-14)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
### Features
|
|
28
|
+
|
|
29
|
+
* **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)
|
|
30
|
+
|
|
1
31
|
## [3.12.2](https://github.com/abdelrahmannasr/yadflow/compare/v3.12.1...v3.12.2) (2026-07-14)
|
|
2
32
|
|
|
3
33
|
|
package/bin/yad.mjs
CHANGED
|
@@ -74,8 +74,13 @@ ${c.bold('Where am I / what next')}
|
|
|
74
74
|
--undo reverses it until the stories review opens
|
|
75
75
|
|
|
76
76
|
${c.bold('Review gate (front half)')}
|
|
77
|
-
yad gate open <epic> <artifact> Open the review PR/MR; mark the step in_review
|
|
78
|
-
|
|
77
|
+
yad gate open <epic> <artifact> Open the review PR/MR; mark the step in_review. The review
|
|
78
|
+
branch must already be on origin (it is never created here)
|
|
79
|
+
yad gate sync <epic> [artifact] [--pr <n>]
|
|
80
|
+
Pull PR state -> ledger; advance on approved+resolved+merged.
|
|
81
|
+
With no recorded PR, resolves it from the review branch; --pr
|
|
82
|
+
names one (and overrides a stale recorded pointer). Advisory
|
|
83
|
+
in bridge mode — there, recover with 'yad gate ci' below
|
|
79
84
|
yad gate comments <epic> [artifact] Fetch unresolved review comments to address
|
|
80
85
|
yad gate status <epic> Show each review step + approvals
|
|
81
86
|
yad gate repair <epic> [--push] Close an author step stranded behind a passed review gate
|
|
@@ -101,6 +106,10 @@ ${c.bold('Build helpers')}
|
|
|
101
106
|
status: flip (→ in-build/shipped) backed by a build-log
|
|
102
107
|
ship — as one audit-trail chore(hub) commit; default
|
|
103
108
|
branch only (--allow-branch to override); no-op when clean
|
|
109
|
+
yad checkpoint --retro-ship <epic>/<story> --repo <r>
|
|
110
|
+
Record a retroactive build-log ship for a PRE-TRACKING story
|
|
111
|
+
(merged before ledger tracking), then carry its status: shipped
|
|
112
|
+
flip in the same commit (--merge-commit <sha>, --task <t> opt.)
|
|
104
113
|
yad tidy up [<epic>] [--push] Fold FINISHED back-half shards (a shipped story's
|
|
105
114
|
trust-log/build-log entries) back into the single folded
|
|
106
115
|
ledger, as one chore(hub) commit — the manual "pack it up"
|
|
@@ -155,7 +164,7 @@ ${c.bold('Environment')}
|
|
|
155
164
|
YAD_NO_UPDATE_NOTIFIER=1 Silence the "update available" notice (also off in CI)
|
|
156
165
|
YAD_NO_REPORT=1 Never offer to file a bug report after a failure`;
|
|
157
166
|
|
|
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']);
|
|
167
|
+
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
168
|
|
|
160
169
|
function parseArgs(argv) {
|
|
161
170
|
const o = { _: [], dir: process.cwd(), fix: false, force: false, scope: 'all' };
|
|
@@ -268,7 +277,7 @@ async function main() {
|
|
|
268
277
|
// advisory (reads the platform, prints status, writes nothing). The artifact status flip is
|
|
269
278
|
// CI's job at merge — never wired into the local gate. File-only mode keeps local writes.
|
|
270
279
|
if (action === 'open') await gateOpen(o.dir, { epic, artifact });
|
|
271
|
-
else if (action === 'sync') await gateSync(o.dir, { epic, artifact, today, local: true });
|
|
280
|
+
else if (action === 'sync') await gateSync(o.dir, { epic, artifact, today, number: o.pr, local: true });
|
|
272
281
|
else if (action === 'comments') await gateComments(o.dir, { epic, artifact, today });
|
|
273
282
|
else if (action === 'status') await gateStatus(o.dir, { epic });
|
|
274
283
|
else if (action === 'repair') await gateRepair(o.dir, { epic, push: o.push, allowBranch: o.allowBranch, dryRun: o.dryRun });
|
|
@@ -301,9 +310,20 @@ async function main() {
|
|
|
301
310
|
case 'ship':
|
|
302
311
|
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
312
|
break;
|
|
304
|
-
case 'checkpoint':
|
|
305
|
-
|
|
313
|
+
case 'checkpoint': {
|
|
314
|
+
let retroShip;
|
|
315
|
+
if (o['retro-ship']) {
|
|
316
|
+
const [epic, story] = String(o['retro-ship']).split('/');
|
|
317
|
+
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; }
|
|
318
|
+
// The story id becomes a path element (stories/<story>.md) — pin it to the id shape (and to its
|
|
319
|
+
// own epic) so a `..` or a slash can never traverse out of the epic's stories dir, and a typo'd
|
|
320
|
+
// cross-epic id is caught here rather than failing obscurely later.
|
|
321
|
+
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; }
|
|
322
|
+
retroShip = { epic, story, repo: o.repo, task: o.task, mergeCommit: o['merge-commit'], today };
|
|
323
|
+
}
|
|
324
|
+
await runCheckpoint(o.dir, { push: o.push, allowBranch: o.allowBranch, dryRun: o.dryRun, retroShip });
|
|
306
325
|
break;
|
|
326
|
+
}
|
|
307
327
|
case 'tidy': {
|
|
308
328
|
const [, action, epic] = o._;
|
|
309
329
|
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/doctor.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import path from 'node:path';
|
|
|
6
6
|
import fs from 'node:fs';
|
|
7
7
|
import { c, log, ok, info, warn, fail, hand, run, has, exists, readJSON, readJSONStrict } from './lib.mjs';
|
|
8
8
|
import { VERSION, PROJECT_FILES, DESIGN_TOOLS, TESTING_TOOLS, LEARNING_TOOLS } from './manifest.mjs';
|
|
9
|
-
import { loadLedger, epicRoot, isValidEpicId, epicLineage, resolveThread, stateInvariants } from './epic-state.mjs';
|
|
9
|
+
import { loadLedger, epicRoot, isValidEpicId, epicLineage, resolveThread, stateInvariants, contractSurfaceHash, artifactHash } from './epic-state.mjs';
|
|
10
10
|
import { loadDebt } from './thread.mjs';
|
|
11
11
|
import { gitHead, insideWorkspace } from './setup.mjs';
|
|
12
12
|
import { cliFor, validateLogin, hostFromGitUrl } from './platform.mjs';
|
|
@@ -280,6 +280,116 @@ export function ciTagsChecks(checks, root, hub, registry) {
|
|
|
280
280
|
}
|
|
281
281
|
}
|
|
282
282
|
|
|
283
|
+
// Is `.sdlc/contract-lock.json` still the hash of the surface it claims to lock? The lock is what the
|
|
284
|
+
// spec pins and what contract-check compares a code repo's slice against, but nothing ever verified it
|
|
285
|
+
// against the live contract.md — so a surface edited without a re-lock (or locked with a different
|
|
286
|
+
// recipe) read as "locked" while binding nothing. FAIL on a mismatch: a decorative lock is worse than
|
|
287
|
+
// none, because everyone downstream treats it as proof.
|
|
288
|
+
//
|
|
289
|
+
// Two shapes (yad-change references/triage.md): a SURFACE lock, verified against this epic's own
|
|
290
|
+
// contract.md, and a POINTER lock — a change-epic that inherited architecture, which carries no
|
|
291
|
+
// contract.md at all and instead copies the parent's hash verbatim. Its integrity property is that the
|
|
292
|
+
// copy still equals what the referenced lock holds, so verify it there.
|
|
293
|
+
function contractLockCheck(checks, root, epic, ledger) {
|
|
294
|
+
const id = `epic:${epic}:contract-lock`;
|
|
295
|
+
const lock = ledger.contractLock;
|
|
296
|
+
const epicDir = epicRoot(root, epic);
|
|
297
|
+
// An epic that has not reached the lock yet simply has no lock file — that is the normal pre-lock
|
|
298
|
+
// state and stays silent. A lock file that EXISTS but carries no usable hash is the opposite: it is
|
|
299
|
+
// the decorative lock this check was added to catch, so it must never read as "not locked yet".
|
|
300
|
+
// `readJSONStrict` yields null both for an absent file and for one holding literal `null`, so ask the
|
|
301
|
+
// filesystem — the second is a malformed lock, not a missing one.
|
|
302
|
+
if (lock === null && !exists(ledger.files.contractLock)) return;
|
|
303
|
+
const stored = typeof lock?.hash === 'string' && /^sha256:[0-9a-f]{64}$/.test(lock.hash) ? lock.hash : null;
|
|
304
|
+
if (!stored) {
|
|
305
|
+
check(checks, id, 'epics', 'fail',
|
|
306
|
+
`${epic}: contract-lock.json exists but carries no usable sha256 hash`,
|
|
307
|
+
're-lock the surface (yad-architecture Step 5) or delete the file — a lock nobody can verify is worse than none');
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
const short = (h) => `${h.slice(0, 19)}…`;
|
|
311
|
+
|
|
312
|
+
if (lock.inheritedFrom || lock.ref) {
|
|
313
|
+
// The ref is repo-controlled text, so keep it inside this hub's epics/ — a lock file must not be
|
|
314
|
+
// able to point the check at arbitrary JSON elsewhere on disk.
|
|
315
|
+
const epicsDir = path.join(root, 'epics');
|
|
316
|
+
const refPath = path.resolve(path.join(epicDir, '.sdlc'), lock.ref || `../../${lock.inheritedFrom}/.sdlc/contract-lock.json`);
|
|
317
|
+
if (refPath !== epicsDir && !refPath.startsWith(epicsDir + path.sep)) {
|
|
318
|
+
check(checks, id, 'epics', 'fail',
|
|
319
|
+
`${epic}: pointer-lock ref '${lock.ref}' resolves outside epics/`,
|
|
320
|
+
'a pointer-lock must reference another epic in this hub — fix `ref` (yad-change writes ../../EP-<parent>/.sdlc/contract-lock.json)');
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
const parent = readJSON(refPath, null);
|
|
324
|
+
if (!parent || typeof parent.hash !== 'string') {
|
|
325
|
+
check(checks, id, 'epics', 'fail',
|
|
326
|
+
`${epic}: pointer-lock references ${lock.inheritedFrom || lock.ref}, whose contract-lock.json is missing or has no hash`,
|
|
327
|
+
're-thread the change-epic (yad-change) so it points at a real parent lock');
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (parent.hash !== stored) {
|
|
331
|
+
check(checks, id, 'epics', 'fail',
|
|
332
|
+
`${epic}: pointer-lock pins ${short(stored)} but ${lock.inheritedFrom || 'its parent'} now locks ${short(parent.hash)}`,
|
|
333
|
+
'the inherited surface was re-locked upstream — re-copy the parent hash, or re-author architecture in this epic');
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
// A pointer-lock epic has no contract.md by construction (the surface physically cannot drift).
|
|
337
|
+
// One that DOES have a contract.md is a change-epic that re-authored architecture but left the
|
|
338
|
+
// inherited fields behind, so verify the live surface as well rather than trusting the pointer.
|
|
339
|
+
if (!exists(path.join(epicDir, 'contract.md'))) {
|
|
340
|
+
check(checks, id, 'epics', 'ok', `${epic}: pointer-lock matches ${lock.inheritedFrom || 'its parent'} (${short(stored)})`);
|
|
341
|
+
return;
|
|
342
|
+
}
|
|
343
|
+
check(checks, `${id}:inherited`, 'epics', 'warn',
|
|
344
|
+
`${epic}: lock is marked inherited from ${lock.inheritedFrom || lock.ref} but this epic has its own contract.md`,
|
|
345
|
+
're-authored architecture? drop `inheritedFrom`/`ref` and re-lock against this epic\'s surface');
|
|
346
|
+
// and fall through to verify the live surface too
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
if (!exists(path.join(epicDir, 'contract.md'))) {
|
|
350
|
+
check(checks, id, 'epics', 'fail',
|
|
351
|
+
`${epic}: contract-lock.json pins ${short(stored)} but there is no contract.md to lock`,
|
|
352
|
+
'restore contract.md, or record the lock as inherited (`inheritedFrom` + `ref`) if this epic threads off a parent');
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
const current = contractSurfaceHash(epicDir);
|
|
356
|
+
if (current === null) {
|
|
357
|
+
check(checks, id, 'epics', 'fail',
|
|
358
|
+
`${epic}: contract-lock.json pins ${short(stored)} but contract.md has no readable CONTRACT-SURFACE block`,
|
|
359
|
+
'restore the BEGIN/END markers around the surface, then re-lock (see yad-architecture Step 5)');
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
if (current !== stored) {
|
|
363
|
+
check(checks, id, 'epics', 'fail',
|
|
364
|
+
`${epic}: contract surface drifted from its lock — contract.md hashes ${short(current)}, contract-lock.json pins ${short(stored)}`,
|
|
365
|
+
'the surface changed without a re-lock: re-run the yad-architecture Step 5 recipe and re-open the architecture gate');
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
check(checks, id, 'epics', 'ok', `${epic}: contract surface matches its lock (${short(stored)})`);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// A review step that is `done` but whose approvals no longer bind to the artifact as it stands today.
|
|
372
|
+
// The gate is deliberately one-way — nothing pulls a chain backward once work is built on it — so the
|
|
373
|
+
// only way this surfaces is if something reports it. `gate sync` records the gap on the step it is
|
|
374
|
+
// syncing; this reports it for the whole epic, so a re-locked surface that was never re-approved is
|
|
375
|
+
// visible in the one command people run when something looks wrong. WARN, not FAIL: the state is a
|
|
376
|
+
// fact about history, and the fix (re-open the review) is a human decision.
|
|
377
|
+
function staleGateCheck(checks, root, epic, ledger) {
|
|
378
|
+
const epicDir = epicRoot(root, epic);
|
|
379
|
+
for (const s of ledger.state.steps) {
|
|
380
|
+
if (s.type !== 'review+approve' || s.status !== 'done' || s.inherited || s.skipped) continue;
|
|
381
|
+
const cur = artifactHash(epicDir, s.artifact);
|
|
382
|
+
if (!cur) continue; // nothing to bind to (no locked surface / incomplete set) — not a staleness claim
|
|
383
|
+
const forStep = ledger.approvals.filter((a) => a.step === s.id && a.status === 'approved');
|
|
384
|
+
if (!forStep.length) continue; // solo mode waives approvals entirely; absence is not staleness
|
|
385
|
+
const live = forStep.filter((a) => !a.artifactHash || a.artifactHash === cur);
|
|
386
|
+
if (live.length) continue;
|
|
387
|
+
check(checks, `epic:${epic}:${s.id}:stale`, 'epics', 'warn',
|
|
388
|
+
`${epic}: ${s.id} is done, but all ${forStep.length} approval(s) are bound to an older ${s.artifact}`,
|
|
389
|
+
'the artifact changed after it was approved — re-open the review (a fresh PR/MR) so the record matches what shipped');
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
283
393
|
export function epicChecks(checks, root) {
|
|
284
394
|
const epicsDir = path.join(root, 'epics');
|
|
285
395
|
if (!exists(epicsDir)) return;
|
|
@@ -308,6 +418,8 @@ export function epicChecks(checks, root) {
|
|
|
308
418
|
if (openPr) check(checks, `epic:${e}:migration`, 'epics', 'warn',
|
|
309
419
|
`${e}: an open review PR (${openPr.artifact}${openPr.number ? ` #${openPr.number}` : ''}) is recorded on the default branch`,
|
|
310
420
|
'opened under a pre-3.0 yadflow? merge/close it before continuing — CI now records the gate ledger on the default branch only at merge');
|
|
421
|
+
contractLockCheck(checks, root, e, ledger);
|
|
422
|
+
staleGateCheck(checks, root, e, ledger);
|
|
311
423
|
}
|
|
312
424
|
} catch (err) {
|
|
313
425
|
check(checks, `epic:${e}`, 'epics', 'fail', `${e}: ${err.message} [${err.code || 'YAD-STATE-001'}]`, err.hint || 'fix the file or restore it from git');
|
package/cli/epic-state.mjs
CHANGED
|
@@ -57,11 +57,19 @@ export function upsertHubPr(hubPrs = [], rec) {
|
|
|
57
57
|
return [...hubPrs.filter((p) => p.artifact !== rec.artifact), rec];
|
|
58
58
|
}
|
|
59
59
|
|
|
60
|
-
// SHA-256 of the contract surface block (architecture only).
|
|
61
|
-
// yad-architecture/references/contract-format.md
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
60
|
+
// SHA-256 of the contract surface block (architecture only). Byte-for-byte identical to the recipe
|
|
61
|
+
// yad-architecture/references/contract-format.md publishes — the one the architect runs to write
|
|
62
|
+
// contract-lock.json:
|
|
63
|
+
//
|
|
64
|
+
// awk '/CONTRACT-SURFACE:BEGIN/{f=1;next} /CONTRACT-SURFACE:END/{f=0} f' contract.md \
|
|
65
|
+
// | tr -d '\r' | shasum -a 256
|
|
66
|
+
//
|
|
67
|
+
// Canonicalization, in the order awk applies it: every line strictly between the markers, CRLF
|
|
68
|
+
// normalized to LF (so a CRLF re-save never revokes approvals), joined by LF, and TERMINATED by a
|
|
69
|
+
// trailing LF — awk emits a newline after every line it prints, so that last newline is part of the
|
|
70
|
+
// hashed bytes. Omitting it made the CLI digest and the documented recipe disagree by exactly one
|
|
71
|
+
// byte on every surface, so contract-lock.json could never equal what approvals bind to (issue #156).
|
|
72
|
+
// A BEGIN without an END is malformed and yields null — never a silent hash of everything to EOF.
|
|
65
73
|
export function contractSurfaceHash(epicDir) {
|
|
66
74
|
const file = path.join(epicDir, 'contract.md');
|
|
67
75
|
if (!fs.existsSync(file)) return null;
|
|
@@ -75,7 +83,7 @@ export function contractSurfaceHash(epicDir) {
|
|
|
75
83
|
if (inside) body.push(ln);
|
|
76
84
|
}
|
|
77
85
|
if (!terminated || !body.length) return null;
|
|
78
|
-
return 'sha256:' + createHash('sha256').update(body.join('\n')).digest('hex');
|
|
86
|
+
return 'sha256:' + createHash('sha256').update(body.join('\n') + '\n').digest('hex');
|
|
79
87
|
}
|
|
80
88
|
|
|
81
89
|
// Deterministic fingerprint of the whole stories/ set: hash each story file, sort, combine. Lets an
|