yadflow 3.16.0 → 3.16.2
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 +18 -0
- package/bin/yad.mjs +2 -1
- package/cli/checkpoint.mjs +97 -10
- package/cli/errors.mjs +1 -0
- package/cli/ledger.mjs +121 -35
- package/cli/usage.mjs +6 -0
- package/package.json +1 -1
- package/skills/sdlc/config.yaml +6 -1
- package/skills/sdlc/module-help.csv +2 -2
- package/skills/yad-defects/SKILL.md +11 -3
- package/skills/yad-docs-overview/references/pipeline-model.md +6 -1
- package/skills/yad-engineer-review/references/ship-and-record.md +43 -7
- package/skills/yad-epic/references/state-schema.md +10 -4
- package/skills/yad-reconcile/SKILL.md +9 -1
- package/skills/yad-timeline/SKILL.md +7 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,21 @@
|
|
|
1
|
+
## [3.16.2](https://github.com/abdelrahmannasr/yadflow/compare/v3.16.1...v3.16.2) (2026-08-12)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* **checkpoint:** make a --retro-ship dry run honest and side-effect free ([b03704f](https://github.com/abdelrahmannasr/yadflow/commit/b03704fb0564b1510dca121a24853758431f9374)), closes [112/#142](https://github.com/abdelrahmannasr/yadflow/issues/142) [#167](https://github.com/abdelrahmannasr/yadflow/issues/167)
|
|
7
|
+
* **checkpoint:** name the shard path and the fold step after --retro-ship ([64c33b3](https://github.com/abdelrahmannasr/yadflow/commit/64c33b37171ee1cebf540b7a66c35ee56cdc61a7)), closes [#167](https://github.com/abdelrahmannasr/yadflow/issues/167) [#167](https://github.com/abdelrahmannasr/yadflow/issues/167)
|
|
8
|
+
* **skills:** read build-log as the folded + shard union ([4302de2](https://github.com/abdelrahmannasr/yadflow/commit/4302de2e5f569989e8fc51c0a165aaae5abe1625)), closes [#167](https://github.com/abdelrahmannasr/yadflow/issues/167)
|
|
9
|
+
|
|
10
|
+
## [3.16.1](https://github.com/abdelrahmannasr/yadflow/compare/v3.16.0...v3.16.1) (2026-08-12)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
### Bug Fixes
|
|
14
|
+
|
|
15
|
+
* **checkpoint:** guard --retro-ship per repo so a multi-repo story can be fully recorded ([d6d2fae](https://github.com/abdelrahmannasr/yadflow/commit/d6d2faeea91c0d59a00a532c0605f76c5b77eace)), closes [#166](https://github.com/abdelrahmannasr/yadflow/issues/166)
|
|
16
|
+
* **checkpoint:** validate the retro-ship repo instead of relying on the duplicate guard ([f1e085e](https://github.com/abdelrahmannasr/yadflow/commit/f1e085e89e4eb10fbf853647ab10c1912237daf8)), closes [#166](https://github.com/abdelrahmannasr/yadflow/issues/166) [#166](https://github.com/abdelrahmannasr/yadflow/issues/166) [#166](https://github.com/abdelrahmannasr/yadflow/issues/166)
|
|
17
|
+
* **ledger:** hold an exclusive lock across a ledger read-modify-write ([45b849a](https://github.com/abdelrahmannasr/yadflow/commit/45b849a7755a5bc58bc646e627218170a54b31bc)), closes [#166](https://github.com/abdelrahmannasr/yadflow/issues/166)
|
|
18
|
+
|
|
1
19
|
# [3.16.0](https://github.com/abdelrahmannasr/yadflow/compare/v3.15.5...v3.16.0) (2026-08-12)
|
|
2
20
|
|
|
3
21
|
|
package/bin/yad.mjs
CHANGED
|
@@ -115,7 +115,8 @@ ${c.bold('Build helpers')}
|
|
|
115
115
|
yad checkpoint --retro-ship <epic>/<story> --repo <r>
|
|
116
116
|
Record a retroactive build-log ship for a PRE-TRACKING story
|
|
117
117
|
(merged before ledger tracking), then carry its status: shipped
|
|
118
|
-
flip in the same commit (--merge-commit <sha>, --task <t> opt.)
|
|
118
|
+
flip in the same commit (--merge-commit <sha>, --task <t> opt.);
|
|
119
|
+
one repo per run — re-run per --repo for a multi-repo story
|
|
119
120
|
yad tidy up [<epic>] [--push] Fold FINISHED back-half shards (a shipped story's
|
|
120
121
|
trust-log/build-log entries) back into the single folded
|
|
121
122
|
ledger, as one chore(hub) commit — the manual "pack it up"
|
package/cli/checkpoint.mjs
CHANGED
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
// marker would strand the PR's required checks.
|
|
23
23
|
import fs from 'node:fs';
|
|
24
24
|
import path from 'node:path';
|
|
25
|
-
import { c, log, ok, info, fail, hand, exists, pushWithRebase } from './lib.mjs';
|
|
25
|
+
import { c, log, ok, info, fail, hand, exists, readJSON, pushWithRebase } from './lib.mjs';
|
|
26
26
|
import { PROJECT_FILES } from './manifest.mjs';
|
|
27
27
|
import { loadHub } from './gate.mjs';
|
|
28
28
|
import { resolveCommitterLogin } from './platform.mjs';
|
|
@@ -171,7 +171,27 @@ export function stagedStoryIsStatusOnly(git, file) {
|
|
|
171
171
|
// (with a printed reason) aborts the commit; `file` is the shard just written, so a dry run can delete it
|
|
172
172
|
// and leave no side effect. Does NOT author the story frontmatter — it only supplies the missing
|
|
173
173
|
// evidence, and the human must have ALREADY flipped `status:` to a back-half value in the working tree.
|
|
174
|
-
|
|
174
|
+
//
|
|
175
|
+
// ONE repo per run (#166). A story that shipped in several repos is backfilled by re-running with each
|
|
176
|
+
// `--repo`; the second run finds the flip already committed, so it lands only the new ship shard.
|
|
177
|
+
//
|
|
178
|
+
// The repo names a retro ship MAY carry — the story's own `repos:` frontmatter (its statement of where
|
|
179
|
+
// it was implemented), else the hub's connected-repo registry as the project-wide fallback. Used to
|
|
180
|
+
// reject a typo'd/mis-cased/invented `--repo` (#166 review): once the duplicate guard is per repo, a
|
|
181
|
+
// wrong name no longer collides with anything, so nothing else would stop it from committing a
|
|
182
|
+
// `retroactive: true` ship for a repo that never existed. Returns `{ names: [], source: 'none' }` when
|
|
183
|
+
// neither declares anything — a legacy story with no metadata is still backfillable, never blocked on
|
|
184
|
+
// a missing list.
|
|
185
|
+
export function retroShipRepos(root, storyFile) {
|
|
186
|
+
const declared = readFrontmatter(storyFile).repos;
|
|
187
|
+
const story = (Array.isArray(declared) ? declared : declared ? [declared] : []).map(String).filter(Boolean);
|
|
188
|
+
if (story.length) return { names: story, source: 'story' };
|
|
189
|
+
const reg = readJSON(path.join(root, PROJECT_FILES.reposRegistry), { repos: [] });
|
|
190
|
+
const names = (Array.isArray(reg?.repos) ? reg.repos : []).map((r) => r?.name).filter(Boolean);
|
|
191
|
+
return { names, source: names.length ? 'registry' : 'none' };
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function recordRetroShip(root, { epic, story, repo, task, mergeCommit, today, dryRun = false }) {
|
|
175
195
|
if (!epic || !story) { fail('--retro-ship needs <epic>/<story> (e.g. --retro-ship EP-foo/EP-foo-S01)'); return { ok: false }; }
|
|
176
196
|
if (!repo) { fail('--retro-ship needs --repo <name> (the repo the story shipped in)'); return { ok: false }; }
|
|
177
197
|
const epicDir = path.join(root, 'epics', epic);
|
|
@@ -183,20 +203,78 @@ export function recordRetroShip(root, { epic, story, repo, task, mergeCommit, to
|
|
|
183
203
|
// Evidence and the flip must land TOGETHER — the #112 no-drift invariant. Refuse unless the human has
|
|
184
204
|
// already flipped the story frontmatter to a back-half status in the working tree; otherwise the ship
|
|
185
205
|
// shard would commit while the artifact still says e.g. `approved` — the very drift #112 prevents.
|
|
186
|
-
|
|
206
|
+
const storyStatus = readFrontmatter(storyFile).status;
|
|
207
|
+
if (!BACK_HALF_STATUSES.has(storyStatus)) {
|
|
187
208
|
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`);
|
|
209
|
+
hand(`set \`status: in-build\` or \`status: shipped\` in ${storyRel} first, then re-run — the ship and the flip land in one commit`);
|
|
189
210
|
return { ok: false };
|
|
190
211
|
}
|
|
191
212
|
|
|
213
|
+
// A ship is permanent audit evidence, so the repo it names must be one the story could have shipped
|
|
214
|
+
// in — an unrecognized `--repo` is a typo, not a discovery. Skipped only when nothing declares any
|
|
215
|
+
// repo (`source: 'none'`), so a legacy story with no metadata is never blocked.
|
|
216
|
+
const { names, source } = retroShipRepos(root, storyFile);
|
|
217
|
+
if (names.length && !names.includes(repo)) {
|
|
218
|
+
fail(`${repo} is not a repo ${source === 'story' ? `${story} declares` : 'connected to this hub'} — a retroactive ship must name a real repo, never invent one`);
|
|
219
|
+
hand(`known: ${names.join(', ')} (names are case-sensitive)`);
|
|
220
|
+
return { ok: false };
|
|
221
|
+
}
|
|
222
|
+
// Which of the story's OWN declared repos still lack evidence — read BEFORE the write so both the
|
|
223
|
+
// refusal and the success path can report honestly how much of a multi-repo backfill is left. Only
|
|
224
|
+
// the story's own list is used: the hub registry lists every connected repo, which says nothing
|
|
225
|
+
// about where THIS story shipped.
|
|
226
|
+
const remaining = () => {
|
|
227
|
+
if (source !== 'story') return [];
|
|
228
|
+
let recorded;
|
|
229
|
+
try { recorded = new Set(readShips(epicDir).filter((s) => s.story === story).map((s) => s.repo)); }
|
|
230
|
+
catch { return []; } // corrupt build-log — the write below reports it; don't guess at progress
|
|
231
|
+
return names.filter((n) => !recorded.has(n) && n !== repo);
|
|
232
|
+
};
|
|
233
|
+
const left = remaining();
|
|
234
|
+
|
|
192
235
|
let res;
|
|
193
236
|
try { res = writeRetroShip(epicDir, { story, repo, task, mergeCommit, shippedAt: today }); }
|
|
194
237
|
catch (e) { fail(`could not record retroactive ship — ${e.message}`); return { ok: false }; }
|
|
195
238
|
if (!res.written) {
|
|
196
|
-
|
|
239
|
+
if (res.reason === 'collision') {
|
|
240
|
+
// Distinct names, ONE shard file (`buildShardName` sanitizes each component) — recording this one
|
|
241
|
+
// would overwrite the other repo's ship record, so it is refused rather than silently clobbered.
|
|
242
|
+
// `writeRetroShip` reports a clash EITHER as `repo` (a ship readShips can see) OR as `file` (a shard
|
|
243
|
+
// on disk it cannot parse) — and a malformed shard can carry a blank `repo`, satisfying neither. Name
|
|
244
|
+
// whichever it gave us; never index into the one it did not, or the refusal becomes a stack trace.
|
|
245
|
+
const clashedWith = res.repo ? `the already-recorded ${res.repo}`
|
|
246
|
+
: res.file ? `an existing shard (${path.basename(res.file)})` : 'an existing ship record';
|
|
247
|
+
fail(`${repo} cannot be recorded: it shares a build-log shard name with ${clashedWith}`);
|
|
248
|
+
hand('recording it would overwrite that ship record — rename one of the repos in the registry, or record this ship through the normal ship/checkpoint flow');
|
|
249
|
+
return { ok: false };
|
|
250
|
+
}
|
|
251
|
+
// Per REPO, not per story (#166) — so the message names the repo. The follow-up hint names the
|
|
252
|
+
// story's OWN still-unrecorded repos; with none left there is nothing to re-run, and inventing a
|
|
253
|
+
// `--repo <other>` at that point would fabricate a ship for a repo the story never shipped in.
|
|
254
|
+
fail(`${story} already has a build-log ship in ${repo} — it is not pre-tracking there; use the normal ship/checkpoint flow`);
|
|
255
|
+
if (left.length) hand(`still unrecorded for ${story}: ${left.join(', ')} — re-run with \`--repo <name>\` for each`);
|
|
197
256
|
return { ok: false };
|
|
198
257
|
}
|
|
199
|
-
|
|
258
|
+
// A dry run WROTE this shard only so the flip could be previewed; it is rolled back before the command
|
|
259
|
+
// returns. Every line below must therefore speak in the conditional — a past-tense "recorded" would tell
|
|
260
|
+
// the operator the backfill landed, they would never re-run for real, and the story would keep
|
|
261
|
+
// `status: shipped` with no ship behind it: exactly the #112/#142 drift this command exists to remove.
|
|
262
|
+
ok(`${dryRun ? 'would record' : 'recorded'} retroactive ship for ${story} (${repo})${mergeCommit ? ` @ ${mergeCommit}` : ''}`);
|
|
263
|
+
// Name WHERE the record landed. The ledger is shard-then-fold, so this ship is one loose shard and the
|
|
264
|
+
// folded `build-log.json` is untouched until `yad tidy up` runs — an operator who opens build-log.json,
|
|
265
|
+
// finds nothing, and concludes the write was lost is reading half the ledger (#167). Every reader unions
|
|
266
|
+
// the two; say so here, while they are looking. `tidy up` only folds a story whose frontmatter is
|
|
267
|
+
// `shipped`, so an `in-build` story's shard stays loose (and still readable) by design — mention the
|
|
268
|
+
// `shipped` precondition only when it is actually still outstanding.
|
|
269
|
+
const rel = path.relative(root, res.file).split(path.sep).join('/');
|
|
270
|
+
info(`${dryRun ? 'would land at' : 'landed at'} ${rel}`);
|
|
271
|
+
info(`readers union build-log/ shards with the folded build-log.json; \`yad tidy up\` folds it in${storyStatus === 'shipped' ? '' : ` once ${story} is \`shipped\``}`);
|
|
272
|
+
// A multi-repo story is only half-reconciled until every declared repo has evidence, and nothing
|
|
273
|
+
// downstream reports the gap (the story already reads `shipped`) — so say it here, while the
|
|
274
|
+
// operator is running the backfill. `left` excludes the repo just recorded; in a dry run that record is
|
|
275
|
+
// rolled back, so it is still unrecorded and belongs back in the list.
|
|
276
|
+
const stillLeft = dryRun && left.length ? [repo, ...left] : left;
|
|
277
|
+
if (stillLeft.length) hand(`${story} declares ${names.length} repos — still unrecorded: ${stillLeft.join(', ')}; re-run with \`--repo <name>\` for each`);
|
|
200
278
|
return { ok: true, file: res.file };
|
|
201
279
|
}
|
|
202
280
|
|
|
@@ -231,22 +309,30 @@ export async function runCheckpoint(root, opts = {}) {
|
|
|
231
309
|
// already wrote is then carried by the normal storyStatusPathspecs path below — no raw git needed.
|
|
232
310
|
let retroFile;
|
|
233
311
|
if (opts.retroShip) {
|
|
234
|
-
const r = recordRetroShip(root, opts.retroShip);
|
|
312
|
+
const r = recordRetroShip(root, { ...opts.retroShip, dryRun: opts.dryRun });
|
|
235
313
|
if (!r.ok) { process.exitCode = 1; return; }
|
|
236
314
|
retroFile = r.file;
|
|
237
315
|
}
|
|
238
316
|
|
|
317
|
+
// A --retro-ship DRY RUN wrote a real shard so the flip could be previewed, and it must be undone on
|
|
318
|
+
// EVERY exit path below, not just the happy one. An early return that skipped the rollback (git add
|
|
319
|
+
// failed, nothing staged) would leave an untracked retro shard behind — which then refuses the
|
|
320
|
+
// operator's next REAL backfill ("already has a build-log ship in <repo>") and rides into the next
|
|
321
|
+
// plain `yad checkpoint` as permanent `retroactive: true` audit evidence nobody chose to record.
|
|
322
|
+
// A real run deliberately keeps its shard on a failure (see the commit-failed path below).
|
|
323
|
+
const rollbackRetro = () => { if (opts.dryRun && retroFile) cleanupRetroShard(retroFile); };
|
|
324
|
+
|
|
239
325
|
// The machine ledgers PLUS any build-log-backed story `status:` flip (#112) — one commit records
|
|
240
326
|
// both, so the story artifact never drifts from build-log and no raw git-to-main push is needed.
|
|
241
327
|
const pathspecs = [...backHalfPathspecs(root), ...storyStatusPathspecs(root)];
|
|
242
|
-
if (!pathspecs.length) { info('no back-half ledgers found — nothing to checkpoint'); return; }
|
|
328
|
+
if (!pathspecs.length) { rollbackRetro(); info('no back-half ledgers found — nothing to checkpoint'); return; }
|
|
243
329
|
|
|
244
330
|
// Stage the allowlist. `git add -- <spec>` picks up new + modified files, and deletions of tracked
|
|
245
331
|
// files WITHIN a still-present spec (e.g. a removed build-state/<story>.json). A wholesale-deleted
|
|
246
332
|
// top-level ledger is intentionally NOT staged (its spec drops out on the existence check) — an
|
|
247
333
|
// append-only audit ledger vanishing is an anomaly a human should see, not something to auto-commit.
|
|
248
334
|
const add = git('add', '--', ...pathspecs);
|
|
249
|
-
if (!add.ok) { fail(`git add failed — ${add.stderr.split('\n')[0] || add.code}`); process.exitCode = 1; return; }
|
|
335
|
+
if (!add.ok) { rollbackRetro(); fail(`git add failed — ${add.stderr.split('\n')[0] || add.code}`); process.exitCode = 1; return; }
|
|
250
336
|
|
|
251
337
|
// #112 review-bypass guard: a story is only carried when its staged change is the `status:` line
|
|
252
338
|
// ALONE. Unstage any candidate whose working tree also touched prose/other frontmatter — those
|
|
@@ -261,6 +347,7 @@ export async function runCheckpoint(root, opts = {}) {
|
|
|
261
347
|
}
|
|
262
348
|
|
|
263
349
|
if (git('diff', '--cached', '--quiet', '--', ...pathspecs).ok) {
|
|
350
|
+
rollbackRetro();
|
|
264
351
|
info('back-half state unchanged — nothing to commit');
|
|
265
352
|
return;
|
|
266
353
|
}
|
|
@@ -279,7 +366,7 @@ export async function runCheckpoint(root, opts = {}) {
|
|
|
279
366
|
git('reset', '-q', '--', ...pathspecs); // restore the index — a dry run must not leave things staged
|
|
280
367
|
// A --retro-ship dry run wrote a shard so the flip could be PREVIEWED above; undo it now so the dry
|
|
281
368
|
// run leaves no side effect on disk (git reset only unstaged it, back to untracked).
|
|
282
|
-
|
|
369
|
+
rollbackRetro();
|
|
283
370
|
info('dry run — not committed');
|
|
284
371
|
return { message };
|
|
285
372
|
}
|
package/cli/errors.mjs
CHANGED
|
@@ -21,6 +21,7 @@ export const CODES = {
|
|
|
21
21
|
'YAD-STATE-003': 'a registered repo path is missing or not a git repository',
|
|
22
22
|
'YAD-STATE-004': 'an epic step cannot be skipped / un-skipped in its current state',
|
|
23
23
|
'YAD-STATE-005': 'an authoring step is stranded behind its completed review gate',
|
|
24
|
+
'YAD-STATE-006': 'a back-half ledger is locked by another yad process that is writing it',
|
|
24
25
|
'YAD-CFG-001': 'hub.json names an unknown platform (expected github, gitlab, or null)',
|
|
25
26
|
'YAD-CFG-002': 'design.json names an unknown design tool (expected one of config.yaml design.tools, or none)',
|
|
26
27
|
'YAD-CFG-003': 'testing.json names an unknown testing tool (expected one of config.yaml testing.tools, or none)',
|
package/cli/ledger.mjs
CHANGED
|
@@ -9,10 +9,69 @@
|
|
|
9
9
|
//
|
|
10
10
|
// A legacy epic that only has the folded file still reads correctly (no shards to union) → zero
|
|
11
11
|
// migration; new writes simply go to shards.
|
|
12
|
+
//
|
|
13
|
+
// Shards remove the conflict BETWEEN entries; they do not make a single read-modify-write atomic, so
|
|
14
|
+
// every writer that decides something from the ledger before writing takes an exclusive lock first
|
|
15
|
+
// (see "ledger locking" below).
|
|
12
16
|
import fs from 'node:fs';
|
|
13
17
|
import path from 'node:path';
|
|
14
18
|
import { readJSON, readJSONStrict, writeJSON } from './lib.mjs';
|
|
15
19
|
import { epicFiles } from './manifest.mjs';
|
|
20
|
+
import { err } from './errors.mjs';
|
|
21
|
+
|
|
22
|
+
// ---- ledger locking -------------------------------------------------------------------------------
|
|
23
|
+
// Shards make concurrent writers conflict-free ACROSS entries, but a writer that READS THEN WRITES is
|
|
24
|
+
// only safe if nothing slips in between. `writeRetroShip` decides "no ship for this (story, repo) yet"
|
|
25
|
+
// and then writes; `updateShip` finds a ship and then rewrites it; a fold reads shards, merges them,
|
|
26
|
+
// then deletes them. Two `--retro-ship` runs with different `--task` values both read "no ship", both
|
|
27
|
+
// write, and the duplicate guard is defeated — two shards for one (story, repo). So every
|
|
28
|
+
// read-modify-write on a ledger holds an exclusive lock for its whole span.
|
|
29
|
+
//
|
|
30
|
+
// `fs.mkdirSync` is the primitive: directory creation is atomic on POSIX and Windows, so exactly one
|
|
31
|
+
// caller wins and the rest get EEXIST. It needs no fd bookkeeping, releases with one `rmdir`, and an
|
|
32
|
+
// EMPTY DIRECTORY IS INVISIBLE TO GIT — a lock can never be committed, and `readShardDir` only reads
|
|
33
|
+
// `*.json` files, so it is never mistaken for an entry either.
|
|
34
|
+
//
|
|
35
|
+
// The lock is per LEDGER, not per (story, repo). A per-identity lock keyed on the raw identity would
|
|
36
|
+
// let the one pair that most needs serializing race: `api.v2` and `api_v2` are DIFFERENT identities
|
|
37
|
+
// that share ONE shard filename (`safe()` maps both to `api_v2`), so they would take different locks
|
|
38
|
+
// and then collide on the same file. A ledger-wide lock cannot be wrong that way, and what it
|
|
39
|
+
// serializes is a handful of local file operations.
|
|
40
|
+
const LOCK_STALE_MS = 30_000; // a lock older than this belonged to a process that died holding it
|
|
41
|
+
const LOCK_WAIT_MS = 50;
|
|
42
|
+
const LOCK_RETRIES = 100; // ≈5s — long enough for any real ledger write, short enough to report
|
|
43
|
+
|
|
44
|
+
// Block the thread without spinning. `Atomics.wait` on the main thread is allowed in Node (only
|
|
45
|
+
// browsers forbid it), and these ledger writers are synchronous by design.
|
|
46
|
+
const sleep = (ms) => { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); };
|
|
47
|
+
|
|
48
|
+
// Run `fn` while holding `lockPath` exclusively; always releases, even when `fn` throws. Throws
|
|
49
|
+
// YAD-STATE-006 when the lock is held by a live process for the whole retry window — better to report
|
|
50
|
+
// than to write over another writer's work.
|
|
51
|
+
export function withLedgerLock(lockPath, fn, { retries = LOCK_RETRIES, waitMs = LOCK_WAIT_MS } = {}) {
|
|
52
|
+
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
53
|
+
for (let i = 0; ; i++) {
|
|
54
|
+
try {
|
|
55
|
+
fs.mkdirSync(lockPath);
|
|
56
|
+
break;
|
|
57
|
+
} catch (e) {
|
|
58
|
+
if (e.code !== 'EEXIST') throw e;
|
|
59
|
+
// A holder that died leaves its lock behind forever; reclaim one that is provably too old.
|
|
60
|
+
let age;
|
|
61
|
+
try { age = Date.now() - fs.statSync(lockPath).mtimeMs; } catch { continue; } // vanished → retry
|
|
62
|
+
if (age > LOCK_STALE_MS) { try { fs.rmdirSync(lockPath); } catch { /* someone else won */ } continue; }
|
|
63
|
+
if (i >= retries) {
|
|
64
|
+
throw err('YAD-STATE-006', `another process is writing ${path.basename(lockPath, '.lock')} in ${path.basename(path.dirname(path.dirname(lockPath)))}`,
|
|
65
|
+
'wait for the other yad command to finish and re-run; if nothing else is running, delete the stale .lock directory the message names');
|
|
66
|
+
}
|
|
67
|
+
sleep(waitMs);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
try { return fn(); } finally { try { fs.rmdirSync(lockPath); } catch { /* already reclaimed */ } }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const buildLogLock = (epicDir) => `${epicFiles(epicDir).buildLog}.lock`;
|
|
74
|
+
const trustLogLock = (epicDir) => `${epicFiles(epicDir).trustLog}.lock`;
|
|
16
75
|
|
|
17
76
|
// ---- shard filenames — the ONE source of truth for the naming convention -------------------------
|
|
18
77
|
// story ids already contain hyphens; the filename is just a unique handle (the entry inside carries
|
|
@@ -86,46 +145,68 @@ export function readShips(epicDir) {
|
|
|
86
145
|
// ship: `task` defaults to the sentinel `retro`, `mergeCommit` is written only when the caller supplies
|
|
87
146
|
// it (never invented), and `shippedAt` is the backfill date (the `retroactive` flag marks it as such).
|
|
88
147
|
//
|
|
89
|
-
// Guard: refuse when the story
|
|
90
|
-
// normal ship/checkpoint flow applies; a retro record would only muddy the ledger.
|
|
91
|
-
//
|
|
92
|
-
|
|
148
|
+
// Guard: refuse when the story already has a build-log ship IN THIS REPO — then it isn't pre-tracking
|
|
149
|
+
// there and the normal ship/checkpoint flow applies; a retro record would only muddy the ledger. The
|
|
150
|
+
// key is (story, repo), NOT story alone (#166): a story tagged with several repos ships once per repo,
|
|
151
|
+
// so a story-only guard let the FIRST backfill lock out every other repo and a multi-repo pre-tracking
|
|
152
|
+
// story could never be fully recorded. Each repo is recorded by its own run — the shards are distinct
|
|
153
|
+
// by construction (`buildShardName` keys on story+task+repo). Not keyed on `task` too: that would let
|
|
154
|
+
// repeated `--task T0x` pile several retro shards into one repo, the very muddying this guard prevents.
|
|
155
|
+
//
|
|
156
|
+
// Two names can differ yet share ONE shard file: `buildShardName` sanitizes each component through
|
|
157
|
+
// `safe()`, so `api.v2` and `api_v2` both become `api_v2` while the (story, repo) key sees them as
|
|
158
|
+
// distinct. Writing the second would silently overwrite the first repo's ship — destroying evidence in
|
|
159
|
+
// an append-only ledger, and (via the --dry-run cleanup) deleting an already-committed shard. So a
|
|
160
|
+
// name that COLLIDES with an existing ship's shard name is refused as `reason: 'collision'`, and the
|
|
161
|
+
// target file is never overwritten even when `readShips` cannot see it (a corrupt shard is skipped by
|
|
162
|
+
// `readShardDir`). Returns { written: false, reason } when refused, else { written: true, file, ship }.
|
|
163
|
+
export function writeRetroShip(epicDir, { story, repo, task = 'retro', mergeCommit, shippedAt }, lockOpts = {}) {
|
|
93
164
|
if (!story) throw new Error('writeRetroShip: story is required');
|
|
94
165
|
if (!repo) throw new Error('writeRetroShip: repo is required');
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
166
|
+
// Both guards and the write are ONE critical section: read outside the lock and a concurrent run
|
|
167
|
+
// (notably a different `--task` for the same (story, repo)) can write between the check and ours.
|
|
168
|
+
return withLedgerLock(buildLogLock(epicDir), () => {
|
|
169
|
+
const ships = readShips(epicDir).filter((s) => s.story === story);
|
|
170
|
+
if (ships.some((s) => s.repo === repo)) return { written: false, reason: 'exists' };
|
|
171
|
+
const clash = ships.find((s) => safe(s.repo) === safe(repo));
|
|
172
|
+
if (clash) return { written: false, reason: 'collision', repo: clash.repo };
|
|
173
|
+
const t = task || 'retro';
|
|
174
|
+
const ship = { story, task: t, repo, retroactive: true, note: 'pre-tracking backfill' };
|
|
175
|
+
if (mergeCommit) ship.mergeCommit = mergeCommit;
|
|
176
|
+
if (shippedAt) ship.shippedAt = shippedAt;
|
|
177
|
+
const f = epicFiles(epicDir);
|
|
178
|
+
const file = path.join(f.buildLogDir, buildShardName({ story, task: t, repo }));
|
|
179
|
+
if (fs.existsSync(file)) return { written: false, reason: 'collision', file };
|
|
180
|
+
writeJSON(file, ship);
|
|
181
|
+
return { written: true, file, ship };
|
|
182
|
+
}, lockOpts);
|
|
106
183
|
}
|
|
107
184
|
|
|
108
185
|
// Find the ship matching `match(ship)` across loose shards (authoritative until folded) then the
|
|
109
186
|
// folded file, apply `update(ship)`, and write back ONLY the file that holds it. Returns
|
|
110
|
-
// { found, where, file, ship }; found:false writes nothing (the caller warns).
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
187
|
+
// { found, where, file, ship }; found:false writes nothing (the caller warns). The find and the
|
|
188
|
+
// write-back are one locked span: a fold running between them would move the ship into the folded
|
|
189
|
+
// file and delete the shard this call is about to rewrite, resurrecting the deleted shard.
|
|
190
|
+
export function updateShip(epicDir, match, update, lockOpts = {}) {
|
|
191
|
+
return withLedgerLock(buildLogLock(epicDir), () => {
|
|
192
|
+
const f = epicFiles(epicDir);
|
|
193
|
+
for (const { name, obj } of readShardDir(f.buildLogDir)) {
|
|
194
|
+
if (match(obj)) {
|
|
195
|
+
update(obj);
|
|
196
|
+
const file = path.join(f.buildLogDir, name);
|
|
197
|
+
writeJSON(file, obj);
|
|
198
|
+
return { found: true, where: 'shard', file, ship: obj };
|
|
199
|
+
}
|
|
119
200
|
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
201
|
+
const foldedObj = readJSONStrict(f.buildLog, null);
|
|
202
|
+
const ship = Array.isArray(foldedObj?.ships) ? foldedObj.ships.find(match) : null;
|
|
203
|
+
if (ship) {
|
|
204
|
+
update(ship);
|
|
205
|
+
writeJSON(f.buildLog, foldedObj);
|
|
206
|
+
return { found: true, where: 'folded', file: f.buildLog, ship };
|
|
207
|
+
}
|
|
208
|
+
return { found: false };
|
|
209
|
+
}, lockOpts);
|
|
129
210
|
}
|
|
130
211
|
|
|
131
212
|
// ---- folding (used by `yad tidy up`) -------------------------------------------------------------
|
|
@@ -170,11 +251,16 @@ function fold(epicDir, { foldedPath, dir, arr, isTrust }, pick, { dryRun = false
|
|
|
170
251
|
return { folded: toFold.length, remaining: shards.length - toFold.length, deleted };
|
|
171
252
|
}
|
|
172
253
|
|
|
254
|
+
// Both folds are read-fold-delete, so they hold their ledger's lock for the whole span — otherwise a
|
|
255
|
+
// ship written (or stamped by `updateShip`) after the read but before the delete is folded away
|
|
256
|
+
// without its change, or deleted without ever being folded.
|
|
173
257
|
export function foldTrust(epicDir, pick, opts = {}) {
|
|
174
258
|
const f = epicFiles(epicDir);
|
|
175
|
-
return
|
|
259
|
+
return withLedgerLock(trustLogLock(epicDir), () =>
|
|
260
|
+
fold(epicDir, { foldedPath: f.trustLog, dir: f.trustLogDir, arr: 'runs', isTrust: true }, pick, opts), opts);
|
|
176
261
|
}
|
|
177
262
|
export function foldBuild(epicDir, pick, opts = {}) {
|
|
178
263
|
const f = epicFiles(epicDir);
|
|
179
|
-
return
|
|
264
|
+
return withLedgerLock(buildLogLock(epicDir), () =>
|
|
265
|
+
fold(epicDir, { foldedPath: f.buildLog, dir: f.buildLogDir, arr: 'ships', isTrust: false }, pick, opts), opts);
|
|
180
266
|
}
|
package/cli/usage.mjs
CHANGED
|
@@ -243,11 +243,17 @@ function memberFlags(m) {
|
|
|
243
243
|
|
|
244
244
|
// Team-level hygiene, keyed by epic/story — a ship with no recorded engineer review is a process gap,
|
|
245
245
|
// not attributable to one person, so it lives here rather than in a member's flag list.
|
|
246
|
+
//
|
|
247
|
+
// A `retroactive: true` ship is EXCLUDED: it is a `yad checkpoint --retro-ship` reconciliation of a
|
|
248
|
+
// story that shipped before the ledger existed, so it never had a tracked PR to review — counting it
|
|
249
|
+
// as a missing review reports a gap the team could not have filled. It also scales with repo count
|
|
250
|
+
// (one backfill shard per repo since #166), which would swamp the real gaps with reconciliation noise.
|
|
246
251
|
export function shipHygiene(root, { since, until } = {}) {
|
|
247
252
|
const items = [];
|
|
248
253
|
for (const epic of listEpics(root)) {
|
|
249
254
|
for (const s of readShips(path.join(root, 'epics', epic))) {
|
|
250
255
|
if (!inWindow(s.shippedAt, since, until)) continue;
|
|
256
|
+
if (s.retroactive) continue;
|
|
251
257
|
if (!Array.isArray(s.engineer_review) || s.engineer_review.length === 0) {
|
|
252
258
|
items.push({ epic, story: s.story || null, task: s.task || null, repo: s.repo || null, shippedAt: s.shippedAt || null });
|
|
253
259
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yadflow",
|
|
3
|
-
"version": "3.16.
|
|
3
|
+
"version": "3.16.2",
|
|
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",
|
package/skills/sdlc/config.yaml
CHANGED
|
@@ -139,7 +139,12 @@ build:
|
|
|
139
139
|
risk_levels: [low, medium, high] # high (or a contract/auth/payments surface) routes to domain owners (yad-review-gate escalation)
|
|
140
140
|
# Step E (yad-engineer-review) — AI review (advisory) + engineer review (the human gate) + merge.
|
|
141
141
|
ai_review: coderabbit # advisory first pass; never the authority (.coderabbit.yaml)
|
|
142
|
-
|
|
142
|
+
# Append-only ship ledger (back-half analogue of approvals.json), stored shard-then-fold: writers add one
|
|
143
|
+
# shard per ship under build_log_dir; `yad tidy up` folds a SHIPPED story's shards into build_log.
|
|
144
|
+
# READERS MUST UNION the two (dedupe by (story, task, repo); a shard wins) — build_log alone omits every
|
|
145
|
+
# unfolded ship, e.g. a `yad checkpoint --retro-ship` backfill or any ship on an `in-build` story.
|
|
146
|
+
build_log: "epics/EP-<slug>/.sdlc/build-log.json" # folded ships
|
|
147
|
+
build_log_dir: "epics/EP-<slug>/.sdlc/build-log/" # one <story>-<task>-<repo>.json shard per ship
|
|
143
148
|
story_build_states: [in-build, shipped] # in-build = some tasks shipped; shipped = all tasks in tasks.md shipped
|
|
144
149
|
# Backfill (yad-backfill) — specs for already-built features in an existing repo.
|
|
145
150
|
backfill:
|
|
@@ -33,7 +33,7 @@ SDLC Workflow,yad-docs,Author Docs Site,DS,"Generate the per-epic interactive do
|
|
|
33
33
|
SDLC Workflow,yad-docs-overview,Docs Overview Site,DO,"Generate the project SDLC-overview interactive site (docs/sdlc-site/) — every stage from setup to ship modeled as flow paths, system components, and stakeholder roles — reusing the same vendored shell, themed with yadflow's brand palette. Reads config.yaml + module-help.csv + the overview diagram as the pipeline source. Folds the hand-maintained docs/index.html report into the site as report.html (linked from the nav). Not a gate — a project-level enrichment that regenerates whenever the skill set / pipeline changes.",,{action: generate|deploy},,,,false,docs/sdlc-site/,sdlc-site/ .docs-build.json
|
|
34
34
|
SDLC Workflow,yad-docs-sync,Docs Sync,DY,"Maintenance/CI: keep the generated doc sites fresh. Detect staleness (a content hash of the approved artifacts + the connected repos' HEAD shas + the doc-shell version vs each site's build manifest), report which sites drifted and why, regenerate + redeploy the stale ones, and wire a CI job that rebuilds on push (carrying [skip ci] + a concurrency group to prevent deploy loops). Generalizes the rule that feature work must hand-update docs/index.html + diagrams + skill counts. Refresh is always a human/CI decision; never a gate.",,{action: check|refresh|wire} {epic: EP-<slug>},,,,false,epics/EP-<slug>/.sdlc/,docs-build.json yad-docs.yml
|
|
35
35
|
SDLC Workflow,yad-change,Change/Defect Intake,CH,"Phase 6 post-lock change management: the INTAKE + TRIAGE step of a feature thread. Classifies the change DEPTH (defect-fix / behavioral-no-surface / contract-surface / new-capability), seeds a NEW EP-<slug> change-epic threaded to its parent (lineage frontmatter kind/parent/thread/inherits/supersedes + a state.json whose inherited steps are pre-marked done and only the changed steps run; a pointer-lock contract-lock.json when architecture is inherited), and records the intake in change.json (escape_stage + root_cause for defects). For hotfixes it records the ship-first exception and opens reconcile-debt.json. Never auto-advances — hands off to the normal authoring skills + yad-review-gate.",,{parent: EP-<slug>} {title: one-line} {kind: change|defect|hotfix} {origin: production|staging|qa|review} {severity: sev1..sev4} {description: text} {affected: artifacts},1-front,,yad-review-gate,false,epics/EP-<slug>/,epic.md state.json change.json reconcile-debt.json contract-lock.json
|
|
36
|
-
SDLC Workflow,yad-timeline,Feature Timeline,TL,"Render a feature THREAD (its linked epics, genesis->changes->defects) as an evolution view (the vendored React/Vite/Tailwind shell HTML + a TIMELINE.md summary) AND resolve the inheritance chain into the authoritative current artifact set (thread-resolved.md: the winning source per artifact + the resolved contract-lock hash) — the composed source-of-truth AI/humans read for the next change. Reads frontmatter lineage + each change.json + build-log.json. An OUTPUT ENRICHMENT — never a gate; never mutates state.",,{thread: EP-<genesis>} {action: generate|deploy},,,,false,epics/EP-<genesis>/,timeline-site/ thread-resolved.md TIMELINE.md
|
|
37
|
-
SDLC Workflow,yad-defects,Quality-Gap Report,DF,"Generate a per-epic AND per-thread defect/bug report (same vendored shell + DEFECTS.md). Walks the thread for every kind:defect change-epic + each change.json defect block + shipped regressions in build-log.json, aggregates by escape_stage (the SDLC gate that should have caught it) and root_cause, and visualizes WHERE quality gaps systematically come from (e.g. % of thread defects that escaped at the test-cases gate) so the team can harden the originating stage. An OUTPUT ENRICHMENT — never a gate; never mutates state.",,{epic: EP-<slug> | thread: EP-<genesis>} {action: generate|deploy},,,,false,epics/EP-<slug>/,defects-site/ DEFECTS.md
|
|
36
|
+
SDLC Workflow,yad-timeline,Feature Timeline,TL,"Render a feature THREAD (its linked epics, genesis->changes->defects) as an evolution view (the vendored React/Vite/Tailwind shell HTML + a TIMELINE.md summary) AND resolve the inheritance chain into the authoritative current artifact set (thread-resolved.md: the winning source per artifact + the resolved contract-lock hash) — the composed source-of-truth AI/humans read for the next change. Reads frontmatter lineage + each change.json + the build ledger (folded build-log.json UNIONed with every build-log/ shard). An OUTPUT ENRICHMENT — never a gate; never mutates state.",,{thread: EP-<genesis>} {action: generate|deploy},,,,false,epics/EP-<genesis>/,timeline-site/ thread-resolved.md TIMELINE.md
|
|
37
|
+
SDLC Workflow,yad-defects,Quality-Gap Report,DF,"Generate a per-epic AND per-thread defect/bug report (same vendored shell + DEFECTS.md). Walks the thread for every kind:defect change-epic + each change.json defect block + shipped regressions in the build ledger (folded build-log.json UNIONed with every build-log/ shard), aggregates by escape_stage (the SDLC gate that should have caught it) and root_cause, and visualizes WHERE quality gaps systematically come from (e.g. % of thread defects that escaped at the test-cases gate) so the team can harden the originating stage. An OUTPUT ENRICHMENT — never a gate; never mutates state.",,{epic: EP-<slug> | thread: EP-<genesis>} {action: generate|deploy},,,,false,epics/EP-<slug>/,defects-site/ DEFECTS.md
|
|
38
38
|
SDLC Workflow,yad-reconcile,Change Reconciler,RE,"Maintenance/CI (mirrors yad-docs-sync — never a gate): detect post-lock DRIFT/ORPHANS — shipped code or a repo HEAD advance (the repos.json syncedHead-vs-current-HEAD rule) with NO owning change-epic in any thread — plus open hotfix reconcile debt, and report which thread drifted and why. refresh points at yad-change to open a reconcile change-epic (or yad-stub first, to anchor orphan brownfield code that has no epic at all) — never silent; wire commits advisory CI ([skip ci] + concurrency, like yad-docs-sync). The actual merge BLOCK is the lineage-check / reconcile-debt gates; this only discovers.",,{action: check|refresh|wire} {thread: EP-<genesis>},,,,false,epics/EP-<genesis>/.sdlc/,(report) reconcile-debt.json yad-reconcile.yml
|
|
39
39
|
SDLC Workflow,yad-stub,Stub Genesis Epic,SG,"Phase 6 brownfield helper: mint a STUB genesis epic for an already-built feature that has no epic in the hub, so a defect/change can thread off it TODAY (yad-change requires a real parent and dead-ends without one). Creates the smallest real thread anchor — a tiny epic.md (kind:feature, thread:self, verified:false, stub:backfill-pending) + a seeded state.json (kind:stub / currentStep:backfill-pending) + empty ledgers — never inventing behaviour. Defects thread off it immediately (gates pass; the bug list is derived by the thread rollup); yad-backfill + its promote step later flip it into a real feature epic. Never auto-advances.",,{feature: <name>} {repos: [<repo>]} {description: one-line},1-front,,yad-change,false,epics/EP-<slug>/,epic.md state.json approvals.json comments.json
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: yad-defects
|
|
3
|
-
description: 'Phase 6 output enrichment (never a gate) — the quality-gap report. Generates a per-epic AND per-thread defect/bug report (the vendored React/Vite/Tailwind shell HTML + a DEFECTS.md) that aggregates every kind:defect change-epic + each change.json defect block + shipped regressions in build-log.json BY escape_stage (the SDLC gate that should have caught the defect) and root_cause, and visualizes WHERE quality gaps systematically come from — e.g. "% of this feature''s defects that escaped at the test-cases gate" — so the team hardens the originating stage instead of just fixing symptoms. Degrades to markdown-only when no docs target is connected. Use when the user says "show the defect report", "where are our quality gaps", "generate the bug report for this epic", or "which gate is leaking defects".'
|
|
3
|
+
description: 'Phase 6 output enrichment (never a gate) — the quality-gap report. Generates a per-epic AND per-thread defect/bug report (the vendored React/Vite/Tailwind shell HTML + a DEFECTS.md) that aggregates every kind:defect change-epic + each change.json defect block + shipped regressions in the build ledger (the folded build-log.json unioned with every build-log/ shard) BY escape_stage (the SDLC gate that should have caught the defect) and root_cause, and visualizes WHERE quality gaps systematically come from — e.g. "% of this feature''s defects that escaped at the test-cases gate" — so the team hardens the originating stage instead of just fixing symptoms. Degrades to markdown-only when no docs target is connected. Use when the user says "show the defect report", "where are our quality gaps", "generate the bug report for this epic", or "which gate is leaking defects".'
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# SDLC — Quality-Gap Report (Phase 6, output enrichment)
|
|
@@ -32,10 +32,16 @@ risk), not just the symptom. It is an **output enrichment**, exactly like `yad-d
|
|
|
32
32
|
Resolve the scope (`yad thread <id> --json` for a thread). Collect, across the scoped epic(s):
|
|
33
33
|
- every `kind: defect` (and `kind: hotfix`) change-epic + its `.sdlc/change.json` `defect` block
|
|
34
34
|
(`origin`, `severity`, `escape_stage`, `root_cause`);
|
|
35
|
-
- the shipped regression fixes from each
|
|
35
|
+
- the shipped regression fixes from each epic's build ledger (the fix that closed the defect, linking
|
|
36
36
|
the change-epic → its regression story/test);
|
|
37
37
|
- open reconcile debt (a hotfix whose front truth is not yet restored).
|
|
38
38
|
|
|
39
|
+
The build ledger is **shard-then-fold**: read it as the **union** of the folded `.sdlc/build-log.json`
|
|
40
|
+
`ships` PLUS every loose `.sdlc/build-log/` shard, deduped by `(story, task, repo)` — a shard WINS over a
|
|
41
|
+
folded ship of the same key. Reading `build-log.json` alone drops every ship not yet folded by
|
|
42
|
+
`yad tidy up` (including every `yad checkpoint --retro-ship` backfill, and every ship on a story still at
|
|
43
|
+
`in-build`, which `tidy up` never folds), which would under-count the very fixes this report attributes.
|
|
44
|
+
|
|
39
45
|
### Step 2 — Attribute each defect to the gate that should have caught it
|
|
40
46
|
A defect is attributed to its **earliest** responsible SDLC stage. Use `change.json.escape_stage`
|
|
41
47
|
(human-set at intake), cross-checked against the fix's shape: a missing negative test → `test-cases`; a
|
|
@@ -73,7 +79,9 @@ Also write a plain `epics/<scope>/DEFECTS.md` mirror. On `action: deploy`, `yad
|
|
|
73
79
|
- **Degrade gracefully.** No docs target → `DEFECTS.md` only; never fail because a tool is absent.
|
|
74
80
|
|
|
75
81
|
## Reference
|
|
76
|
-
- The defect data: `.sdlc/change.json` `defect` blocks +
|
|
82
|
+
- The defect data: `.sdlc/change.json` `defect` blocks + the build ledger, read as the folded
|
|
83
|
+
`build-log.json` unioned with every `.sdlc/build-log/` shard
|
|
84
|
+
(`../yad-epic/references/state-schema.md`, Phase 6; `../yad-engineer-review/references/ship-and-record.md`).
|
|
77
85
|
- The shell + deterministic generation it reuses: `../yad-docs/SKILL.md`, `../yad-docs/references/data-mapping.md`.
|
|
78
86
|
- The companion evolution view: `../yad-timeline/SKILL.md`.
|
|
79
87
|
- The intake that records `escape_stage` + `root_cause`: `../yad-change/SKILL.md`.
|
|
@@ -69,7 +69,7 @@ Per-story, per-repo: `spec → tasks → implement → checks → engineer-revie
|
|
|
69
69
|
| `yad-checks` | `checks/*.sh`, CI workflows — the gate set: `spec-link · contract-check · build-test-lint · verified-commits · commit-message · pr-title · pr-template · lineage-check · epic-open · reconcile-debt`, plus `yad-update-guard` (push-on-default: re-checks any direct-to-default commit with `verified-commits · commit-message`) |
|
|
70
70
|
| `yad-pr-template` | PR/MR template + routing helpers |
|
|
71
71
|
| `yad-commit` / `yad-open-pr` / `yad-ship` | one commit / one PR/MR |
|
|
72
|
-
| `yad-engineer-review` | engineer review + ship recorded
|
|
72
|
+
| `yad-engineer-review` | engineer review + ship recorded as a `build-log/` shard |
|
|
73
73
|
| `yad-backfill` | DRAFT specs for legacy features |
|
|
74
74
|
|
|
75
75
|
### Path: Automation (the second dial + observation)
|
|
@@ -110,6 +110,11 @@ and the change-thread ledgers `change.json`, `reconcile-debt.json`, `build-log.j
|
|
|
110
110
|
the **connected code repos**; the **design / testing / learning tools**; and the **platform**
|
|
111
111
|
(GitHub/GitLab + Pages). A skill's `sideEffects` link its step to the component it writes.
|
|
112
112
|
|
|
113
|
+
`trust-log.json` and `build-log.json` are the *folded* halves of two **shard-then-fold** ledgers — each
|
|
114
|
+
also has a shard dir (`.sdlc/trust-log/`, `.sdlc/build-log/`) holding the entries `yad tidy up` has not
|
|
115
|
+
folded yet. They render as one component each, but anything READING them must union the folded file with
|
|
116
|
+
its shards (`../../yad-engineer-review/references/ship-and-record.md`).
|
|
117
|
+
|
|
113
118
|
## Roles = the lenses
|
|
114
119
|
|
|
115
120
|
The eight yadflow lenses, each to its relevant phase sections + paths:
|
|
@@ -81,10 +81,45 @@ yad checkpoint --retro-ship <epic>/<story> --repo <r> [--task <t>] [--merge-comm
|
|
|
81
81
|
It writes ONE minimal ship shard marked `retroactive: true` (`task` defaults to the sentinel `retro`;
|
|
82
82
|
`mergeCommit` is written only if you pass `--merge-commit`; `shippedAt` is the backfill date), then runs
|
|
83
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
|
|
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
|
|
87
|
-
ship shard is never committed while the artifact still says `approved`.
|
|
84
|
+
refuses when the story already has a ship **in that repo** (then it isn't pre-tracking there — use the
|
|
85
|
+
normal flow). It 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 a back-half `status:` (`in-build` or `shipped`) in
|
|
87
|
+
`stories/<story>.md`, so a ship shard is never committed while the artifact still says `approved`.
|
|
88
|
+
|
|
89
|
+
**Where the record lands — it is a shard, not an append to `build-log.json`.** Like every other ship, a
|
|
90
|
+
retroactive one is written to `.sdlc/build-log/` and the folded `build-log.json` is left untouched until
|
|
91
|
+
`yad tidy up` folds it. Opening `build-log.json` and finding nothing does **not** mean the write was lost:
|
|
92
|
+
read the ledger by the union rule above and the ship is there (#167). Note `tidy up` only folds a story
|
|
93
|
+
whose frontmatter is `shipped`, so a backfill recorded against an `in-build` story stays a loose shard
|
|
94
|
+
indefinitely — which is precisely why reading the folded file alone is never sufficient.
|
|
95
|
+
|
|
96
|
+
**One repo per run (#166).** A ship is recorded per `(story, task, repo)`, so a story that shipped in
|
|
97
|
+
several repos needs one retroactive shard **per repo** — the guard is keyed on `(story, repo)`, not on
|
|
98
|
+
the story alone, so recording the first repo never locks out the rest. Re-run once per repo:
|
|
99
|
+
|
|
100
|
+
```
|
|
101
|
+
yad checkpoint --retro-ship EP-foo/EP-foo-S01 --repo web --push
|
|
102
|
+
yad checkpoint --retro-ship EP-foo/EP-foo-S01 --repo api --push
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
The `status:` flip rides the **first** commit (it only needs one ship to be carried); each later run
|
|
106
|
+
lands only its own ship shard, so the story ends up with complete per-repo evidence. After each run the
|
|
107
|
+
command names the declared repos that still have none, so a half-finished backfill is visible instead of
|
|
108
|
+
looking complete.
|
|
109
|
+
|
|
110
|
+
A ship is permanent audit evidence, so `--repo` is checked before anything is written: it must be a repo
|
|
111
|
+
the story's `repos:` frontmatter declares (or, for a legacy story that declares none, one connected in
|
|
112
|
+
`.sdlc/repos.json`). A typo'd, mis-cased or invented name is **refused** — the per-repo guard means it
|
|
113
|
+
would otherwise collide with nothing and quietly record a ship for a repo that never existed. A name
|
|
114
|
+
that would share a shard **filename** with an already-recorded repo (shard names sanitize everything
|
|
115
|
+
outside `[A-Za-z0-9_-]` to `_`, so `api.v2` and `api_v2` are one file) is refused for the same reason:
|
|
116
|
+
recording it would overwrite the other repo's ship record in an append-only ledger.
|
|
117
|
+
|
|
118
|
+
Those checks decide from the ledger and then write it, so each one runs under an exclusive **lock** on
|
|
119
|
+
the epic's `build-log` — as does `yad review reconcile`'s stamp and `yad tidy up`'s fold. Two commands
|
|
120
|
+
writing the same ledger at once would otherwise both read "no ship yet" and both write. The lock is an
|
|
121
|
+
empty directory (`build-log.json.lock`), so git never sees it; one held by a process that died is
|
|
122
|
+
reclaimed after 30s, and a live one reports `YAD-STATE-006` rather than writing over the other's work.
|
|
88
123
|
|
|
89
124
|
**Engagement (the Review Companion).** Each `engineer_review` entry carries `engagement: verified | none`
|
|
90
125
|
— `verified` when the engineer reviewed through the [companion](../../yad-review-companion/SKILL.md)
|
|
@@ -110,11 +145,12 @@ The story frontmatter `status` reflects build progress:
|
|
|
110
145
|
You write this flip into `stories/<story>.md`, but **do not hand-commit it** — the next
|
|
111
146
|
`yad checkpoint --push` carries it in the same `chore(hub)` commit as the ledgers (the story now has a
|
|
112
147
|
build-log ship, so checkpoint stages it; #112). This is what keeps the story artifact from drifting
|
|
113
|
-
from
|
|
148
|
+
from the build ledger, so there is never a reason to fall back to a raw `git push origin main`.
|
|
114
149
|
|
|
115
150
|
So the chain is traceable both ways: from the epic down (`epic.md` → `stories/<story>.md` →
|
|
116
|
-
`tasks.md` →
|
|
117
|
-
trailer → story → epic).
|
|
151
|
+
`tasks.md` → the build-ledger ship → `mergeCommit`) and from a merge commit back up (its `Task:`
|
|
152
|
+
trailer → story → epic). Resolve that ship by the union rule, not from `build-log.json` alone — until
|
|
153
|
+
`yad tidy up` folds it, the ship lives only in its `build-log/` shard and the chain looks broken.
|
|
118
154
|
|
|
119
155
|
## Preconditions for ship (all required)
|
|
120
156
|
|
|
@@ -325,11 +325,17 @@ storage layout is noted here (it mirrors `trust-log.json`):
|
|
|
325
325
|
ship object. `(story, task, repo)` is already a natural unique key, so no `uid` is needed.
|
|
326
326
|
- **Folded file:** `epics/<epic>/.sdlc/build-log.json` = `{ "epic": "<id>", "ships": [ <ship>, … ] }`
|
|
327
327
|
(also the legacy single-file layout, and the output of `yad tidy up`).
|
|
328
|
-
- **Union-read rule
|
|
329
|
-
`(story, task, repo)`** — a shard WINS over a stale
|
|
330
|
-
ship's shard is authoritative until it is folded).
|
|
328
|
+
- **Union-read rule (binding on every reader — never read the folded file alone):** union the folded
|
|
329
|
+
`ships` with every `build-log/` shard, **deduping by `(story, task, repo)`** — a shard WINS over a stale
|
|
330
|
+
folded ship (so a `yad review reconcile` edit to a ship's shard is authoritative until it is folded).
|
|
331
|
+
`build-log.json` on its own is only the *folded* half of the ledger: it omits every ship `yad tidy up`
|
|
332
|
+
has not folded, including every `yad checkpoint --retro-ship` backfill, and — because `tidy up` folds
|
|
333
|
+
only a story whose frontmatter is `shipped` — every ship on a story still at `in-build`, indefinitely.
|
|
334
|
+
A reader that skips the union silently under-reports what shipped (#167).
|
|
331
335
|
- `yad checkpoint` commits the shard dir; `yad tidy up` folds a shipped story's finished shards into the
|
|
332
|
-
folded file (loose objects + `git gc`).
|
|
336
|
+
folded file (loose objects + `git gc`). No **ship** path ever appends to the folded file — that is what
|
|
337
|
+
keeps concurrent shippers conflict-free. (`yad review reconcile` does write it, but only to stamp a ship
|
|
338
|
+
already folded there; see `updateShip`.)
|
|
333
339
|
|
|
334
340
|
---
|
|
335
341
|
|
|
@@ -34,7 +34,7 @@ Run `yad reconcile check` (optionally `--thread EP-<genesis>`). For each thread
|
|
|
34
34
|
`yad check` drift style, any of:
|
|
35
35
|
- **Broken lineage** — a change-epic whose `parent` is missing, a cycle, or a `thread` cache that
|
|
36
36
|
disagrees with the computed root (the same signal `yad doctor` reports).
|
|
37
|
-
- **Orphan / drift** — code shipped (
|
|
37
|
+
- **Orphan / drift** — code shipped (the build ledger) or a touched repo's HEAD advanced past its
|
|
38
38
|
`repos.json` `syncedHead` with **no owning change-epic** in any thread — i.e. behaviour reached
|
|
39
39
|
production that no epic in the thread describes. Name the repo (`<repo>: <old>→<new>`).
|
|
40
40
|
- **Open hotfix debt** — a `reconcile-debt.json` entry still `open`; the next normal change on that
|
|
@@ -42,6 +42,14 @@ Run `yad reconcile check` (optionally `--thread EP-<genesis>`). For each thread
|
|
|
42
42
|
|
|
43
43
|
Writes nothing. This is the read-only sweep a human (or CI) runs to see the picture.
|
|
44
44
|
|
|
45
|
+
> **Read the build ledger as a union — a missed ship reads as "no drift".** It is **shard-then-fold**:
|
|
46
|
+
> union the folded `.sdlc/build-log.json` `ships` with every loose `.sdlc/build-log/` shard, deduped by
|
|
47
|
+
> `(story, task, repo)` (a shard WINS over a folded ship of the same key). Reading `build-log.json` alone
|
|
48
|
+
> drops every ship not yet folded by `yad tidy up` — including every `yad checkpoint --retro-ship` backfill,
|
|
49
|
+
> and every ship on a story still at `in-build`, which `tidy up` never folds. Those are exactly the ships
|
|
50
|
+
> most likely to be orphaned, so missing them turns this check into a false all-clear. See
|
|
51
|
+
> `../yad-engineer-review/references/ship-and-record.md`.
|
|
52
|
+
|
|
45
53
|
### Step 2 — `refresh` (advisory, never silent)
|
|
46
54
|
For each flagged thread, **point the human at the fix** — open a reconcile change-epic with `yad-change`
|
|
47
55
|
(`kind: change`, threaded to the affected feature) to bring the front artifacts back in step with what
|
|
@@ -38,9 +38,15 @@ current-truth map, and any open reconcile debt. **STOP** and report if the linea
|
|
|
38
38
|
|
|
39
39
|
### Step 2 — Read each node's evolution facts
|
|
40
40
|
For each epic in the chain read `epic.md` (lineage + the change brief), `.sdlc/change.json` (depth,
|
|
41
|
-
defect block),
|
|
41
|
+
defect block), the build ledger (ship events), and the contract-lock (a real lock = a re-lock
|
|
42
42
|
event; a pointer-lock = inherited). Greenfield-safe: an absent input degrades its part of the view.
|
|
43
43
|
|
|
44
|
+
The build ledger is **shard-then-fold**, so read it as the **union** of the folded `.sdlc/build-log.json`
|
|
45
|
+
`ships` PLUS every loose `.sdlc/build-log/` shard, deduped by `(story, task, repo)` — a shard WINS over a
|
|
46
|
+
folded ship of the same key. Reading `build-log.json` alone silently drops every ship not yet folded by
|
|
47
|
+
`yad tidy up` (including every `yad checkpoint --retro-ship` backfill, and every ship on a story still at
|
|
48
|
+
`in-build`, which `tidy up` never folds). See `../yad-engineer-review/references/ship-and-record.md`.
|
|
49
|
+
|
|
44
50
|
### Step 3 — Render the evolution view (yad-docs shell)
|
|
45
51
|
Generate the site into `epics/<thread>/timeline-site/` (copy the shell verbatim; generate `src/data/*.ts`
|
|
46
52
|
deterministically; theme from the design system). The thread maps onto the shell primitives:
|