yadflow 3.6.0 → 3.7.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 +6 -3
- package/bin/yad.mjs +21 -0
- package/cli/checkpoint.mjs +159 -0
- package/cli/gate.mjs +13 -10
- package/cli/hubcommit.mjs +33 -0
- package/cli/ledger.mjs +147 -0
- package/cli/lib.mjs +16 -0
- package/cli/manifest.mjs +7 -1
- package/cli/review.mjs +12 -10
- package/cli/thread.mjs +4 -1
- package/cli/tidy.mjs +116 -0
- package/cli/usage.mjs +3 -4
- package/package.json +1 -1
- package/skills/yad-engineer-review/SKILL.md +19 -5
- package/skills/yad-engineer-review/references/ship-and-record.md +24 -4
- package/skills/yad-epic/references/state-schema.md +52 -5
- package/skills/yad-implement/SKILL.md +3 -2
- package/skills/yad-run/SKILL.md +23 -6
- package/skills/yad-run/references/run-loop.md +23 -6
- package/skills/yad-spec/SKILL.md +4 -3
- package/skills/yad-status/SKILL.md +9 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
# [3.
|
|
1
|
+
# [3.7.0](https://github.com/abdelrahmannasr/yadflow/compare/v3.6.1...v3.7.0) (2026-07-04)
|
|
2
2
|
|
|
3
3
|
|
|
4
4
|
### Features
|
|
5
5
|
|
|
6
|
-
*
|
|
7
|
-
* **
|
|
6
|
+
* **cli:** add shared hub-commit default-branch guard helpers ([44290f6](https://github.com/abdelrahmannasr/yadflow/commit/44290f6109c806f077609fec352904efe801f830))
|
|
7
|
+
* **cli:** add yad checkpoint to commit machine-written back-half state ([c09089c](https://github.com/abdelrahmannasr/yadflow/commit/c09089c698884ad39fc8cf66a7e28d8d44321668))
|
|
8
|
+
* **cli:** add yad tidy up to fold finished ledger shards ([59727f3](https://github.com/abdelrahmannasr/yadflow/commit/59727f32a7b5e3274c8fd96b8b39134b3acf6705))
|
|
9
|
+
* **cli:** shard-then-fold storage for the back-half ledgers ([6182598](https://github.com/abdelrahmannasr/yadflow/commit/618259847e3dbdee5f00bce4fa2a5d64df0364a3))
|
|
10
|
+
* **cli:** wire yad checkpoint and yad tidy up into the CLI ([fe4770d](https://github.com/abdelrahmannasr/yadflow/commit/fe4770de87ebec394c3a29c78077a0baf7a924cc))
|
|
8
11
|
|
|
9
12
|
# [2.2.0](https://github.com/abdelrahmannasr/yadflow/compare/v2.1.0...v2.2.0) (2026-06-14)
|
|
10
13
|
|
package/bin/yad.mjs
CHANGED
|
@@ -10,6 +10,8 @@ import { runCommit } from '../cli/commit.mjs';
|
|
|
10
10
|
import { runOpenPr } from '../cli/openpr.mjs';
|
|
11
11
|
import { reviewTrailer, reviewContext, reviewNudge, reviewReconcile, reviewWalkthrough } from '../cli/review.mjs';
|
|
12
12
|
import { runShip } from '../cli/ship.mjs';
|
|
13
|
+
import { runCheckpoint } from '../cli/checkpoint.mjs';
|
|
14
|
+
import { runTidy } from '../cli/tidy.mjs';
|
|
13
15
|
import { runRepo } from '../cli/repo.mjs';
|
|
14
16
|
import { runRoster } from '../cli/roster.mjs';
|
|
15
17
|
import { runDocs } from '../cli/docs.mjs';
|
|
@@ -81,6 +83,14 @@ ${c.bold('Build helpers')}
|
|
|
81
83
|
branch opens the front-half artifact-review PR (delegates to
|
|
82
84
|
gate open), any other hub branch uses the code-task template
|
|
83
85
|
yad ship --type <t> -m <subject> Commit AND open the task PR/MR in one step (stage-aware)
|
|
86
|
+
yad checkpoint [--push] Commit the machine-written back-half hub state
|
|
87
|
+
(trust-log/build-log/build-state) as one audit-trail
|
|
88
|
+
chore(hub) commit — default branch only (--allow-branch
|
|
89
|
+
to override); a no-op when nothing changed
|
|
90
|
+
yad tidy up [<epic>] [--push] Fold FINISHED back-half shards (a shipped story's
|
|
91
|
+
trust-log/build-log entries) back into the single folded
|
|
92
|
+
ledger, as one chore(hub) commit — the manual "pack it up"
|
|
93
|
+
for the shard files; a no-op when nothing is foldable
|
|
84
94
|
yad review trailer --repo <r> --pr <n> --body <text> Post the companion's 60-sec briefing to a code PR/MR
|
|
85
95
|
yad review context --repo <r> --pr <n> Print the grounding bundle for cards/chat
|
|
86
96
|
yad review walkthrough --repo <r> --pr <n> Bundle + ordered risk-tagged stops for the
|
|
@@ -133,6 +143,8 @@ function parseArgs(argv) {
|
|
|
133
143
|
else if (a === '--force') o.force = true;
|
|
134
144
|
else if (a === '--contract-change') o.contractChange = true;
|
|
135
145
|
else if (a === '--no-push') o.noPush = true;
|
|
146
|
+
else if (a === '--push') o.push = true;
|
|
147
|
+
else if (a === '--allow-branch') o.allowBranch = true;
|
|
136
148
|
else if (a === '--merged') o.merged = true;
|
|
137
149
|
else if (a === '--overview') o.overview = true;
|
|
138
150
|
// `--check` is a bare boolean for `docs sync --check`, but takes a value for
|
|
@@ -259,6 +271,15 @@ async function main() {
|
|
|
259
271
|
case 'ship':
|
|
260
272
|
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 });
|
|
261
273
|
break;
|
|
274
|
+
case 'checkpoint':
|
|
275
|
+
await runCheckpoint(o.dir, { push: o.push, allowBranch: o.allowBranch, dryRun: o.dryRun });
|
|
276
|
+
break;
|
|
277
|
+
case 'tidy': {
|
|
278
|
+
const [, action, epic] = o._;
|
|
279
|
+
if (action !== 'up') { log(`usage: yad tidy up [<epic>] [--push] [--dry-run]`); process.exitCode = action ? 1 : 0; break; }
|
|
280
|
+
await runTidy(o.dir, { epic: epic || o.epic, push: o.push, allowBranch: o.allowBranch, dryRun: o.dryRun });
|
|
281
|
+
break;
|
|
282
|
+
}
|
|
262
283
|
case 'repo': {
|
|
263
284
|
const [, action, name] = o._;
|
|
264
285
|
await runRepo(o.dir, { action: action || 'list', name, today });
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// `yad checkpoint` — commit the machine-written back-half hub state (trust-log / build-log /
|
|
2
|
+
// build-state) as one audit-trail commit. This is the back-half analogue of the front-half gate sync
|
|
3
|
+
// (cli/gate.mjs): the SDLC back half (yad-run, yad-engineer-review) WRITES these ledgers into the
|
|
4
|
+
// working tree but never commits them, so teammates/CI/`yad status` on other machines see stale trust
|
|
5
|
+
// evidence. checkpoint lands them with a `chore(hub): ...` message.
|
|
6
|
+
//
|
|
7
|
+
// Two invariants keep it out of the gates' way:
|
|
8
|
+
// 1. It stages ONLY the three back-half ledgers by an explicit allowlist — never `git add -A`,
|
|
9
|
+
// which would sweep the CI-owned front-half ledger (state/approvals/comments/hub-prs.json,
|
|
10
|
+
// reviews/*.md) and trip the ledger-guard gate.
|
|
11
|
+
// 2. It commits ONLY on the default branch (mirroring gate.mjs), so the commit never enters a PR's
|
|
12
|
+
// base..HEAD range — where verified-commits would fail an unsigned commit and its [skip ci]
|
|
13
|
+
// marker would strand the PR's required checks.
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { c, log, ok, info, fail, hand, exists, pushWithRebase } from './lib.mjs';
|
|
17
|
+
import { PROJECT_FILES } from './manifest.mjs';
|
|
18
|
+
import { loadHub } from './gate.mjs';
|
|
19
|
+
import { resolveCommitterLogin } from './platform.mjs';
|
|
20
|
+
import { hubGit, resolveDefaultBranch, guardDefaultBranch } from './hubcommit.mjs';
|
|
21
|
+
|
|
22
|
+
// The machine-written back-half ledgers, relative to an epic's dir. The two append-only logs are
|
|
23
|
+
// shard-then-fold (cli/ledger.mjs): each is a folded file PLUS a shard dir of loose per-entry files —
|
|
24
|
+
// both are allowlisted so a checkpoint commits new shards and any `yad tidy up` fold. `build-state` is
|
|
25
|
+
// the whole dir (one JSON per story). Keep in sync with cli/manifest.mjs epicFiles.
|
|
26
|
+
const BACK_HALF = [
|
|
27
|
+
'.sdlc/trust-log.json', '.sdlc/trust-log',
|
|
28
|
+
'.sdlc/build-log.json', '.sdlc/build-log',
|
|
29
|
+
'.sdlc/build-state',
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
// PURE — the repo-relative pathspecs to stage: every back-half ledger that exists under any epic.
|
|
33
|
+
// Explicit allowlist by design (see invariant 1 above).
|
|
34
|
+
export function backHalfPathspecs(root) {
|
|
35
|
+
const epicsDir = path.join(root, 'epics');
|
|
36
|
+
if (!fs.existsSync(epicsDir)) return [];
|
|
37
|
+
const out = [];
|
|
38
|
+
for (const e of fs.readdirSync(epicsDir, { withFileTypes: true })) {
|
|
39
|
+
if (!e.isDirectory()) continue;
|
|
40
|
+
for (const rel of BACK_HALF) {
|
|
41
|
+
if (fs.existsSync(path.join(epicsDir, e.name, rel))) out.push(path.posix.join('epics', e.name, rel));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// PURE — turn the staged pathspecs into the subject label + the body's file list. A story id is pulled
|
|
48
|
+
// from any file that carries one (`build-state/<story>.json`, a `trust-log/`/`build-log/` shard whose
|
|
49
|
+
// name starts with the story id); the folded logs are epic-scoped. Prefer the most specific label.
|
|
50
|
+
export function summarizeStaged(files = []) {
|
|
51
|
+
const stories = new Set();
|
|
52
|
+
const epics = new Set();
|
|
53
|
+
const basenames = [];
|
|
54
|
+
for (const f of files) {
|
|
55
|
+
const m = f.match(/^epics\/([^/]+)\/\.sdlc\/(.+)$/);
|
|
56
|
+
if (!m) continue;
|
|
57
|
+
const [, epic, rest] = m;
|
|
58
|
+
epics.add(epic);
|
|
59
|
+
// the first `…-S0N` token in the filename is the story id (ids contain hyphens; `[\w-]` stops at `/`)
|
|
60
|
+
const s = rest.match(/([A-Za-z][\w-]*?-S\d+)/);
|
|
61
|
+
if (s) stories.add(`${epic}/${s[1]}`);
|
|
62
|
+
basenames.push(rest);
|
|
63
|
+
}
|
|
64
|
+
let label;
|
|
65
|
+
if (stories.size === 1) label = [...stories][0];
|
|
66
|
+
else if (stories.size > 1) label = `${stories.size} stories`;
|
|
67
|
+
else if (epics.size === 1) label = [...epics][0];
|
|
68
|
+
else label = `${epics.size} epics`;
|
|
69
|
+
return { label, basenames };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Collapse any whitespace/newline runs to a single space — keeps a hostile `git user.name` or a stray
|
|
73
|
+
// path from breaking the one-line subject or injecting a fake trailer line.
|
|
74
|
+
const oneLine = (s = '') => String(s).replace(/\s+/g, ' ').trim();
|
|
75
|
+
|
|
76
|
+
// `@login` from the roster (the auditable handle the user asked for), else the raw git user.name,
|
|
77
|
+
// else a stable placeholder so the subject is never empty.
|
|
78
|
+
export function checkpointAuthor(login, name) {
|
|
79
|
+
if (login) return `@${oneLine(login)}`;
|
|
80
|
+
const n = oneLine(name);
|
|
81
|
+
return n || 'unknown';
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// PURE — the audit-trail commit message. The subject passes the hub commit-message gate (valid type
|
|
85
|
+
// `chore`, optional scope `hub`, non-empty description, no trailing period). No Task trailer and no
|
|
86
|
+
// Co-Authored-By footer: this is human-owned machine state, not an authored code change. `label` and
|
|
87
|
+
// `author` are collapsed to one line so nothing can split the subject or forge a trailer.
|
|
88
|
+
export function buildCheckpointMessage({ label, author, basenames = [] }) {
|
|
89
|
+
const subject = `chore(hub): sync back-half state — ${oneLine(label)} by ${oneLine(author)} [skip ci]`;
|
|
90
|
+
const body = basenames.length ? `Updated: ${basenames.join(', ')}` : '';
|
|
91
|
+
return body ? `${subject}\n\n${body}` : subject;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function runCheckpoint(root, opts = {}) {
|
|
95
|
+
log(c.bold('\nyad checkpoint'));
|
|
96
|
+
if (!exists(path.join(root, '.git'))) { fail('not a git repo'); process.exitCode = 1; return; }
|
|
97
|
+
if (!exists(path.join(root, PROJECT_FILES.hubConfig))) {
|
|
98
|
+
fail('no .sdlc/hub.json — checkpoint commits the hub back-half ledger; run it from the product hub');
|
|
99
|
+
process.exitCode = 1;
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const { hub } = loadHub(root);
|
|
104
|
+
const git = hubGit(root);
|
|
105
|
+
const branch = git('rev-parse', '--abbrev-ref', 'HEAD').stdout;
|
|
106
|
+
const defaultBranch = resolveDefaultBranch(git, hub);
|
|
107
|
+
|
|
108
|
+
// Default-branch guard (invariant 2) — shared with `yad tidy up`.
|
|
109
|
+
if (!guardDefaultBranch(branch, defaultBranch, { allowBranch: opts.allowBranch, cmd: 'yad checkpoint' })) return;
|
|
110
|
+
|
|
111
|
+
const pathspecs = backHalfPathspecs(root);
|
|
112
|
+
if (!pathspecs.length) { info('no back-half ledgers found — nothing to checkpoint'); return; }
|
|
113
|
+
|
|
114
|
+
// Stage the allowlist. `git add -- <spec>` picks up new + modified files, and deletions of tracked
|
|
115
|
+
// files WITHIN a still-present spec (e.g. a removed build-state/<story>.json). A wholesale-deleted
|
|
116
|
+
// top-level ledger is intentionally NOT staged (its spec drops out on the existence check) — an
|
|
117
|
+
// append-only audit ledger vanishing is an anomaly a human should see, not something to auto-commit.
|
|
118
|
+
const add = git('add', '--', ...pathspecs);
|
|
119
|
+
if (!add.ok) { fail(`git add failed — ${add.stderr.split('\n')[0] || add.code}`); process.exitCode = 1; return; }
|
|
120
|
+
if (git('diff', '--cached', '--quiet', '--', ...pathspecs).ok) {
|
|
121
|
+
info('back-half state unchanged — nothing to commit');
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
// The exact files staged from the allowlist — all known to git by construction, so they are the
|
|
125
|
+
// pathspec for the commit (a directory spec like build-state/ would make `git commit -- <dir>` fail
|
|
126
|
+
// when the dir is empty, e.g. created before its first story JSON). This also scopes the commit to
|
|
127
|
+
// ONLY the allowlist, so any unrelated pre-staged file never rides along.
|
|
128
|
+
const staged = git('diff', '--cached', '--name-only', '--', ...pathspecs).stdout.split('\n').filter(Boolean);
|
|
129
|
+
|
|
130
|
+
const { label, basenames } = summarizeStaged(staged);
|
|
131
|
+
const author = checkpointAuthor(resolveCommitterLogin(root, hub?.roster || []), git('config', 'user.name').stdout);
|
|
132
|
+
const message = buildCheckpointMessage({ label, author, basenames });
|
|
133
|
+
|
|
134
|
+
if (opts.dryRun) {
|
|
135
|
+
log('\n' + c.dim(message) + '\n');
|
|
136
|
+
git('reset', '-q', '--', ...pathspecs); // restore the index — a dry run must not leave things staged
|
|
137
|
+
info('dry run — not committed');
|
|
138
|
+
return { message };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const cm = git('commit', '-m', message, '--', ...staged);
|
|
142
|
+
if (!cm.ok) {
|
|
143
|
+
git('reset', '-q', '--', ...pathspecs); // don't leave the ledgers staged for an unrelated commit to sweep up
|
|
144
|
+
fail(`git commit failed — ${cm.stderr.split('\n')[0] || cm.code}`);
|
|
145
|
+
process.exitCode = 1;
|
|
146
|
+
return { message };
|
|
147
|
+
}
|
|
148
|
+
ok(`checkpointed ${staged.length} file(s): ${c.dim(label)}`);
|
|
149
|
+
|
|
150
|
+
if (!opts.push) return { message };
|
|
151
|
+
// Push HEAD to its OWN branch — never to `defaultBranch` blindly. On the default branch these are the
|
|
152
|
+
// same; with --allow-branch on a WIP branch, pushing HEAD:defaultBranch would publish the whole WIP
|
|
153
|
+
// branch to the default branch (bypassing review), so the target is always the branch we are on.
|
|
154
|
+
if (pushWithRebase(root, branch).ok) { ok(`pushed to origin/${branch}`); return { message }; }
|
|
155
|
+
fail(`could not push to origin/${branch} — a protected branch, or the append-only ledgers hit an unresolvable rebase conflict`);
|
|
156
|
+
hand(`run \`git pull --rebase\` and re-run \`yad checkpoint --push\``);
|
|
157
|
+
process.exitCode = 1;
|
|
158
|
+
return { message };
|
|
159
|
+
}
|
package/cli/gate.mjs
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import fs from 'node:fs';
|
|
6
6
|
import path from 'node:path';
|
|
7
7
|
import {
|
|
8
|
-
c, log, ok, info, warn, hand, fail, note, readJSONStrict, writeJSON, run,
|
|
8
|
+
c, log, ok, info, warn, hand, fail, note, readJSONStrict, writeJSON, run, pushWithRebase,
|
|
9
9
|
} from './lib.mjs';
|
|
10
10
|
import { PROJECT_FILES } from './manifest.mjs';
|
|
11
11
|
import {
|
|
@@ -83,7 +83,7 @@ function warnIncompleteDiscovery(epicDir, artifact) {
|
|
|
83
83
|
|
|
84
84
|
// Fail fast on a corrupt or wrong-shape hub config: a silently-defaulted hub.json would degrade
|
|
85
85
|
// every gate to file-only without anyone noticing, and a typo'd platform would read as "no bridge".
|
|
86
|
-
function loadHub(root) {
|
|
86
|
+
export function loadHub(root) {
|
|
87
87
|
const hubFile = path.join(root, PROJECT_FILES.hubConfig);
|
|
88
88
|
const regFile = path.join(root, PROJECT_FILES.reposRegistry);
|
|
89
89
|
// Distinguish an ABSENT hub.json (null default → fine, file-only gate) from one that exists but
|
|
@@ -441,13 +441,7 @@ export async function gateCi(root, { branch, pr, merged = false, today, push = t
|
|
|
441
441
|
ok(`committed gate update: ${c.dim(subject)}`);
|
|
442
442
|
if (!push) return { synced };
|
|
443
443
|
|
|
444
|
-
|
|
445
|
-
if (git('push', 'origin', `HEAD:${target}`).ok) { ok(`pushed to origin/${target}`); return { synced }; }
|
|
446
|
-
if (attempt < 3) {
|
|
447
|
-
info(`push rejected — rebasing onto origin/${target} and retrying (${attempt}/3)`);
|
|
448
|
-
if (!git('pull', '--rebase', 'origin', target).ok) git('rebase', '--abort'); // never leave a wedged rebase
|
|
449
|
-
}
|
|
450
|
-
}
|
|
444
|
+
if (pushWithRebase(root, target).ok) { ok(`pushed to origin/${target}`); return { synced }; }
|
|
451
445
|
fail(`could not push to origin/${target}${merged ? ' — protected default branch? allow the gate bot to push the merge advance (see yad-hub-bridge references/bridge.md)' : ''} — or run \`yad gate sync\` locally`);
|
|
452
446
|
process.exitCode = 1;
|
|
453
447
|
return { synced };
|
|
@@ -645,7 +639,7 @@ export async function gateTrailer(root, { epic, artifact, body, number, getBody
|
|
|
645
639
|
// ---- helpers ------------------------------------------------------------------------------------
|
|
646
640
|
const base = (artifact) => artifactBase(artifact);
|
|
647
641
|
|
|
648
|
-
function fillHubTemplate({ epic, artifact, step, owner, domains }) {
|
|
642
|
+
export function fillHubTemplate({ epic, artifact, step, owner, domains }) {
|
|
649
643
|
return [
|
|
650
644
|
'## Artifact under review',
|
|
651
645
|
`- Epic: \`${epic}\``,
|
|
@@ -660,6 +654,15 @@ function fillHubTemplate({ epic, artifact, step, owner, domains }) {
|
|
|
660
654
|
'## How to review (this drives the gate)',
|
|
661
655
|
'- **Approve** to record your approval; **comment / request changes** to hold the gate.',
|
|
662
656
|
'- This step advances when approvals are satisfied, all threads are resolved, and this PR is merged.',
|
|
657
|
+
'',
|
|
658
|
+
// Required by the hub `pr-template` gate (check_hub_body). Mirrors the Checklist block of the
|
|
659
|
+
// committed static template (yad-pr-template/templates/hub/<platform>/) so the generated body
|
|
660
|
+
// passes on the first CI run.
|
|
661
|
+
'## Checklist',
|
|
662
|
+
'- [ ] `owner` set in the artifact frontmatter (inherited from `epic.md`)',
|
|
663
|
+
'- [ ] Contract re-locked (`.sdlc/contract-lock.json`) if the surface changed (architecture only)',
|
|
664
|
+
'- [ ] Risk tags reflect the real surface touched (contract/auth/payments escalate)',
|
|
665
|
+
'- [ ] No secrets or tokens in the artifact or this description',
|
|
663
666
|
].join('\n');
|
|
664
667
|
}
|
|
665
668
|
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Shared machinery for committing the machine-written back-half ledgers to the hub — used by both
|
|
2
|
+
// `yad checkpoint` (sync new state) and `yad tidy up` (fold finished shards). Both must commit ONLY on
|
|
3
|
+
// the default branch, so their `[skip ci]` commit never enters a PR's base..HEAD range (where it would
|
|
4
|
+
// strand required checks and fail verified-commits). This module is the single home of that guard.
|
|
5
|
+
import { warn, fail, hand, run } from './lib.mjs';
|
|
6
|
+
|
|
7
|
+
export const hubGit = (root) => (...args) => run('git', args, { cwd: root });
|
|
8
|
+
|
|
9
|
+
// The default branch: hub config, else the remote's published default (origin/HEAD), else 'main'.
|
|
10
|
+
// NEVER the current branch — falling back to it (as gate.mjs does, safe there because CI checks out the
|
|
11
|
+
// default branch) would make the guard below a no-op on a WIP branch and let an unsigned commit land in
|
|
12
|
+
// a future PR's range.
|
|
13
|
+
export function resolveDefaultBranch(git, hub) {
|
|
14
|
+
const originHead = git('symbolic-ref', '--short', 'refs/remotes/origin/HEAD'); // e.g. "origin/main"
|
|
15
|
+
return hub?.default_branch
|
|
16
|
+
|| (originHead.ok && originHead.stdout ? originHead.stdout.replace(/^origin\//, '') : '')
|
|
17
|
+
|| 'main';
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Guard: only commit on the default branch. Returns true when OK; on a non-default branch it prints the
|
|
21
|
+
// refusal + sets a non-zero exit code, unless `allowBranch` overrides (with a warning). `--allow-branch`
|
|
22
|
+
// is the SINGLE documented override — never `--force` (which elsewhere only waives the atomic guard).
|
|
23
|
+
export function guardDefaultBranch(branch, defaultBranch, { allowBranch = false, cmd = 'yad checkpoint' } = {}) {
|
|
24
|
+
if (branch === defaultBranch) return true;
|
|
25
|
+
if (allowBranch) {
|
|
26
|
+
warn(`--allow-branch: committing on '${branch}' — pushes go to origin/${branch}, and this commit needs a verified signature to pass the gate in a PR`);
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
fail(`on '${branch}', not the default branch '${defaultBranch}' — ${cmd} commits go to the default branch to stay out of PR-checked ranges`);
|
|
30
|
+
hand(`switch to '${defaultBranch}' and re-run, or pass --allow-branch to override (set default_branch in .sdlc/hub.json if '${defaultBranch}' is wrong)`);
|
|
31
|
+
process.exitCode = 1;
|
|
32
|
+
return false;
|
|
33
|
+
}
|
package/cli/ledger.mjs
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// Shard-then-fold storage for the two append-only back-half ledgers (trust-log, build-log).
|
|
2
|
+
//
|
|
3
|
+
// The problem: both ledgers were ONE file per epic, so two people driving different stories of the
|
|
4
|
+
// SAME epic both appended to the same file → git merge conflict on push. The fix ("loose objects +
|
|
5
|
+
// `git gc`"): each writer writes ONE small file per entry under a shard dir, so concurrent writers
|
|
6
|
+
// touch different files → zero conflict, by construction. Readers UNION the folded file (the legacy
|
|
7
|
+
// single file, and the output of `yad tidy up`) with every loose shard. `yad tidy up` folds finished
|
|
8
|
+
// shards back into the folded file on demand — a single, serialized, human-run act.
|
|
9
|
+
//
|
|
10
|
+
// A legacy epic that only has the folded file still reads correctly (no shards to union) → zero
|
|
11
|
+
// migration; new writes simply go to shards.
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { readJSON, readJSONStrict, writeJSON } from './lib.mjs';
|
|
15
|
+
import { epicFiles } from './manifest.mjs';
|
|
16
|
+
|
|
17
|
+
// ---- shard filenames — the ONE source of truth for the naming convention -------------------------
|
|
18
|
+
// story ids already contain hyphens; the filename is just a unique handle (the entry inside carries
|
|
19
|
+
// the fields), so no parsing-back is needed. A trust entry needs `uid` to stay unique across re-runs
|
|
20
|
+
// of the same (story, repo, step); a ship is unique by (story, task, repo) already.
|
|
21
|
+
export const trustShardName = (e) => `${e.story}-${e.repo}-${e.step}-${e.uid}.json`;
|
|
22
|
+
export const buildShardName = (e) => `${e.story}-${e.task}-${e.repo}.json`;
|
|
23
|
+
|
|
24
|
+
// Read every shard object under `dir` (each file = ONE entry object). Sorted for determinism; a
|
|
25
|
+
// corrupt/non-object shard is skipped (these ledgers are advisory evidence, never fatal).
|
|
26
|
+
function readShardDir(dir) {
|
|
27
|
+
if (!fs.existsSync(dir)) return [];
|
|
28
|
+
const out = [];
|
|
29
|
+
for (const name of fs.readdirSync(dir).filter((n) => n.endsWith('.json')).sort()) {
|
|
30
|
+
const obj = readJSON(path.join(dir, name), null);
|
|
31
|
+
if (obj && typeof obj === 'object' && !Array.isArray(obj)) out.push({ name, obj });
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The half-applied-tidy guard key: a shard is a genuine duplicate of a folded entry ONLY when its FULL
|
|
37
|
+
// identity matches — NOT `uid` alone. `uid` is minted by an LLM skill, so two runs of DIFFERENT
|
|
38
|
+
// (story,repo,step) could share a short token; keying on uid alone would drop the second as a false
|
|
39
|
+
// "duplicate" and corrupt the trust count (in the unsafe direction). Legacy folded entries lack `uid`,
|
|
40
|
+
// so their key never collides with a real shard's key — they are never skipped.
|
|
41
|
+
const trustKey = (e) => `${e.story}|${e.repo}|${e.step}|${e.uid}`;
|
|
42
|
+
|
|
43
|
+
// trust-log = the evidence base. EVERY entry is a distinct step run (re-runs of a step share
|
|
44
|
+
// story/repo/step), so we CONCATENATE folded runs + shards and never dedup by (story,repo,step) —
|
|
45
|
+
// that would drop re-run history the trust threshold counts. The only guard is a shard whose full
|
|
46
|
+
// identity (`trustKey`) already appears folded (a half-applied tidy). A corrupt folded file must throw
|
|
47
|
+
// (readJSONStrict), never silently read as empty — under-reporting the evidence base is a safety bug.
|
|
48
|
+
export function readTrustRuns(epicDir) {
|
|
49
|
+
const f = epicFiles(epicDir);
|
|
50
|
+
const foldedObj = readJSONStrict(f.trustLog, null);
|
|
51
|
+
const folded = Array.isArray(foldedObj?.runs) ? foldedObj.runs : [];
|
|
52
|
+
const seen = new Set(folded.filter((e) => e && e.uid).map(trustKey));
|
|
53
|
+
const out = [...folded];
|
|
54
|
+
for (const { obj } of readShardDir(f.trustLogDir)) {
|
|
55
|
+
if (obj.uid && seen.has(trustKey(obj))) continue; // a true full-identity twin already folded
|
|
56
|
+
out.push(obj);
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// build-log = one ship per (story, task, repo) — a natural unique key. `yad review reconcile` mutates
|
|
62
|
+
// a ship's engineer_review in its shard, so a shard is authoritative and WINS over a stale folded
|
|
63
|
+
// ship of the same key.
|
|
64
|
+
const shipKey = (s) => `${s.story}|${s.task}|${s.repo}`;
|
|
65
|
+
export function readShips(epicDir) {
|
|
66
|
+
const f = epicFiles(epicDir);
|
|
67
|
+
const foldedObj = readJSONStrict(f.buildLog, null);
|
|
68
|
+
const folded = Array.isArray(foldedObj?.ships) ? foldedObj.ships : [];
|
|
69
|
+
const byKey = new Map();
|
|
70
|
+
for (const s of folded) byKey.set(shipKey(s), s);
|
|
71
|
+
for (const { obj } of readShardDir(f.buildLogDir)) byKey.set(shipKey(obj), obj);
|
|
72
|
+
return [...byKey.values()];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Find the ship matching `match(ship)` across loose shards (authoritative until folded) then the
|
|
76
|
+
// folded file, apply `update(ship)`, and write back ONLY the file that holds it. Returns
|
|
77
|
+
// { found, where, file, ship }; found:false writes nothing (the caller warns).
|
|
78
|
+
export function updateShip(epicDir, match, update) {
|
|
79
|
+
const f = epicFiles(epicDir);
|
|
80
|
+
for (const { name, obj } of readShardDir(f.buildLogDir)) {
|
|
81
|
+
if (match(obj)) {
|
|
82
|
+
update(obj);
|
|
83
|
+
const file = path.join(f.buildLogDir, name);
|
|
84
|
+
writeJSON(file, obj);
|
|
85
|
+
return { found: true, where: 'shard', file, ship: obj };
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
const foldedObj = readJSONStrict(f.buildLog, null);
|
|
89
|
+
const ship = Array.isArray(foldedObj?.ships) ? foldedObj.ships.find(match) : null;
|
|
90
|
+
if (ship) {
|
|
91
|
+
update(ship);
|
|
92
|
+
writeJSON(f.buildLog, foldedObj);
|
|
93
|
+
return { found: true, where: 'folded', file: f.buildLog, ship };
|
|
94
|
+
}
|
|
95
|
+
return { found: false };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---- folding (used by `yad tidy up`) -------------------------------------------------------------
|
|
99
|
+
const trustSort = (a, b) =>
|
|
100
|
+
`${a.date || ''}|${a.story || ''}|${a.repo || ''}|${a.step || ''}|${a.uid || ''}`
|
|
101
|
+
.localeCompare(`${b.date || ''}|${b.story || ''}|${b.repo || ''}|${b.step || ''}|${b.uid || ''}`);
|
|
102
|
+
const buildSort = (a, b) =>
|
|
103
|
+
`${a.shippedAt || ''}|${a.story || ''}|${a.task || ''}|${a.repo || ''}`
|
|
104
|
+
.localeCompare(`${b.shippedAt || ''}|${b.story || ''}|${b.task || ''}|${b.repo || ''}`);
|
|
105
|
+
|
|
106
|
+
// Fold the shards that `pick(entry)` selects into the folded file, then delete them. Returns
|
|
107
|
+
// { folded, remaining, deleted } — `deleted` are the removed shard paths so the caller can stage them.
|
|
108
|
+
// Idempotent: nothing picked → no write, no delete. Deterministic order so the folded output is stable.
|
|
109
|
+
function fold(epicDir, { foldedPath, dir, arr, isTrust }, pick, { dryRun = false } = {}) {
|
|
110
|
+
const shards = readShardDir(dir);
|
|
111
|
+
const toFold = shards.filter((s) => pick(s.obj));
|
|
112
|
+
if (!toFold.length) return { folded: 0, remaining: shards.length, deleted: [] };
|
|
113
|
+
if (dryRun) return { folded: toFold.length, remaining: shards.length - toFold.length, deleted: [] };
|
|
114
|
+
|
|
115
|
+
// A corrupt folded file must ABORT the fold (readJSONStrict throws), never be silently rebuilt from
|
|
116
|
+
// scratch — rebuilding would erase all previously-folded history.
|
|
117
|
+
const foldedObj = readJSONStrict(foldedPath, null) || { epic: path.basename(epicDir), [arr]: [] };
|
|
118
|
+
if (!Array.isArray(foldedObj[arr])) foldedObj[arr] = [];
|
|
119
|
+
if (isTrust) {
|
|
120
|
+
// Guard on FULL identity (trustKey), not uid alone — a shard skipped here is a genuine twin of an
|
|
121
|
+
// already-folded run, so deleting it below is safe; two runs that merely share a uid keep both.
|
|
122
|
+
const seen = new Set(foldedObj.runs.filter((e) => e && e.uid).map(trustKey));
|
|
123
|
+
for (const s of toFold) if (!(s.obj.uid && seen.has(trustKey(s.obj)))) foldedObj.runs.push(s.obj);
|
|
124
|
+
foldedObj.runs.sort(trustSort);
|
|
125
|
+
} else {
|
|
126
|
+
const byKey = new Map(foldedObj.ships.map((s) => [shipKey(s), s]));
|
|
127
|
+
for (const s of toFold) byKey.set(shipKey(s.obj), s.obj);
|
|
128
|
+
foldedObj.ships = [...byKey.values()].sort(buildSort);
|
|
129
|
+
}
|
|
130
|
+
writeJSON(foldedPath, foldedObj);
|
|
131
|
+
const deleted = [];
|
|
132
|
+
for (const s of toFold) {
|
|
133
|
+
const p = path.join(dir, s.name);
|
|
134
|
+
fs.rmSync(p, { force: true });
|
|
135
|
+
deleted.push(p);
|
|
136
|
+
}
|
|
137
|
+
return { folded: toFold.length, remaining: shards.length - toFold.length, deleted };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function foldTrust(epicDir, pick, opts = {}) {
|
|
141
|
+
const f = epicFiles(epicDir);
|
|
142
|
+
return fold(epicDir, { foldedPath: f.trustLog, dir: f.trustLogDir, arr: 'runs', isTrust: true }, pick, opts);
|
|
143
|
+
}
|
|
144
|
+
export function foldBuild(epicDir, pick, opts = {}) {
|
|
145
|
+
const f = epicFiles(epicDir);
|
|
146
|
+
return fold(epicDir, { foldedPath: f.buildLog, dir: f.buildLogDir, arr: 'ships', isTrust: false }, pick, opts);
|
|
147
|
+
}
|
package/cli/lib.mjs
CHANGED
|
@@ -147,3 +147,19 @@ export function run(cmd, args = [], opts = {}) {
|
|
|
147
147
|
};
|
|
148
148
|
}
|
|
149
149
|
export const has = (cmd) => run(process.platform === 'win32' ? 'where' : 'which', [cmd]).ok;
|
|
150
|
+
|
|
151
|
+
// Push HEAD to origin/<target>, rebasing onto it and retrying on rejection — both the front-half gate
|
|
152
|
+
// sync and the back-half checkpoint push append-only ledgers to the default branch, so a concurrent
|
|
153
|
+
// push is a normal race, not an error. Returns { ok } after up to `attempts` tries; logs each retry.
|
|
154
|
+
// A failed `pull --rebase` is aborted so we never leave a wedged rebase for the next command.
|
|
155
|
+
export function pushWithRebase(cwd, target, { attempts = 3 } = {}) {
|
|
156
|
+
const git = (...args) => run('git', args, { cwd });
|
|
157
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
158
|
+
if (git('push', 'origin', `HEAD:${target}`).ok) return { ok: true };
|
|
159
|
+
if (attempt < attempts) {
|
|
160
|
+
info(`push rejected — rebasing onto origin/${target} and retrying (${attempt}/${attempts})`);
|
|
161
|
+
if (!git('pull', '--rebase', 'origin', target).ok) git('rebase', '--abort');
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return { ok: false };
|
|
165
|
+
}
|
package/cli/manifest.mjs
CHANGED
|
@@ -178,7 +178,13 @@ export const epicFiles = (epicRoot) => ({
|
|
|
178
178
|
comments: `${epicRoot}/.sdlc/comments.json`,
|
|
179
179
|
hubPrs: `${epicRoot}/.sdlc/hub-prs.json`,
|
|
180
180
|
contractLock: `${epicRoot}/.sdlc/contract-lock.json`,
|
|
181
|
-
|
|
181
|
+
// The two append-only back-half ledgers use shard-then-fold storage (cli/ledger.mjs): writers add
|
|
182
|
+
// one loose shard per entry under the *Dir path (conflict-free concurrent writes); `yad tidy up`
|
|
183
|
+
// folds finished shards back into the single *Log file. Readers union the folded file + loose shards.
|
|
184
|
+
buildLog: `${epicRoot}/.sdlc/build-log.json`, // folded ships (also the legacy single file)
|
|
185
|
+
buildLogDir: `${epicRoot}/.sdlc/build-log`, // one <story>-<task>-<repo>.json per ship
|
|
186
|
+
trustLog: `${epicRoot}/.sdlc/trust-log.json`, // folded runs (also the legacy single file)
|
|
187
|
+
trustLogDir: `${epicRoot}/.sdlc/trust-log`, // one <story>-<repo>-<step>-<uid>.json per run
|
|
182
188
|
buildStateDir: `${epicRoot}/.sdlc/build-state`, // Phase 4 — per-story, per-repo back-half state
|
|
183
189
|
|
|
184
190
|
change: `${epicRoot}/.sdlc/change.json`, // Phase 6 — change/defect intake + triage
|
package/cli/review.mjs
CHANGED
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
// The CLI never calls an LLM: the skill (yad-review-companion / yad-engineer-review) generates the
|
|
7
7
|
// trailer/cards/chat text and posts it via these primitives, all to the PLATFORM (never a ledger file).
|
|
8
8
|
import path from 'node:path';
|
|
9
|
-
import { log, ok, info, warn, fail, note, run, readJSON
|
|
10
|
-
import { PROJECT_FILES
|
|
9
|
+
import { log, ok, info, warn, fail, note, run, readJSON } from './lib.mjs';
|
|
10
|
+
import { PROJECT_FILES } from './manifest.mjs';
|
|
11
|
+
import { updateShip } from './ledger.mjs';
|
|
11
12
|
import { epicRoot } from './epic-state.mjs';
|
|
12
13
|
import {
|
|
13
14
|
detectPlatform, readPr, mapApprovers, getPrBody, editPrBody, postComment, prNumberFromUrl,
|
|
@@ -161,18 +162,19 @@ export async function reviewReconcile(root, { epic, repo, dir, pr, reader = read
|
|
|
161
162
|
engineerReview.push({ approver: r.name, role: r.role, ...(r.domain ? { domain: r.domain } : {}), engagement: r.engagement === 'verified' ? 'verified' : 'none' });
|
|
162
163
|
}
|
|
163
164
|
|
|
164
|
-
const file = epicFiles(epicRoot(root, epic)).buildLog;
|
|
165
|
-
const ledger = readJSON(file, null);
|
|
166
165
|
// Match by exact PR number — never substring (`--pr 5` must not match a ship recorded against #15).
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
166
|
+
// build-log is shard-then-fold now: updateShip mutates the ship's own loose shard (authoritative
|
|
167
|
+
// until `yad tidy up` folds it), or the folded file if already folded — never both.
|
|
168
|
+
const res = updateShip(
|
|
169
|
+
epicRoot(root, epic),
|
|
170
|
+
(s) => s.pr != null && (String(s.pr) === String(pr) || prNumberFromUrl(s.pr) === String(pr)),
|
|
171
|
+
(s) => { s.engineer_review = engineerReview; },
|
|
172
|
+
);
|
|
173
|
+
if (!res.found) {
|
|
170
174
|
warn(`no build-log ship record matches PR #${pr} in ${epic} — attach this at ship time:`);
|
|
171
175
|
log(JSON.stringify({ engineer_review: engineerReview }, null, 2));
|
|
172
176
|
return { engineerReview, written: false };
|
|
173
177
|
}
|
|
174
|
-
ship.
|
|
175
|
-
writeJSON(file, ledger);
|
|
176
|
-
ok(`stamped engagement onto ${epic} ship ${ship.story || ''}${ship.task ? '/' + ship.task : ''} (PR #${pr})`);
|
|
178
|
+
ok(`stamped engagement onto ${epic} ship ${res.ship.story || ''}${res.ship.task ? '/' + res.ship.task : ''} (PR #${pr})`);
|
|
177
179
|
return { engineerReview, written: true };
|
|
178
180
|
}
|
package/cli/thread.mjs
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import fs from 'node:fs';
|
|
7
7
|
import { c, log, ok, info, warn, hand, readJSON, exists } from './lib.mjs';
|
|
8
|
+
import { readShips } from './ledger.mjs';
|
|
8
9
|
import {
|
|
9
10
|
epicRoot, isValidEpicId, epicLineage, readFrontmatter, isStubEpic,
|
|
10
11
|
resolveThread, threadEpics, resolveCurrentArtifacts, resolveCurrentStories, THREAD_ARTIFACT_BASES,
|
|
@@ -17,7 +18,9 @@ export const loadDebt = (root, epic) => {
|
|
|
17
18
|
const v = readJSON(path.join(epicRoot(root, epic), '.sdlc', 'reconcile-debt.json'), []);
|
|
18
19
|
return Array.isArray(v) ? v : [];
|
|
19
20
|
};
|
|
20
|
-
|
|
21
|
+
// The build ledger is shard-then-fold now (cli/ledger.mjs): union the folded file + loose ship shards
|
|
22
|
+
// so a caller sees every ship whether or not `yad tidy up` has folded them yet.
|
|
23
|
+
export const loadBuildLog = (root, epic) => ({ epic, ships: readShips(epicRoot(root, epic)) });
|
|
21
24
|
|
|
22
25
|
// An epic is SEALED once every authored story is `shipped` (config.yaml change.seal_on). A sealed epic
|
|
23
26
|
// refuses new behaviour (epic-open.sh) — a further change must open a new threaded change-epic, which is
|
package/cli/tidy.mjs
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// `yad tidy up` — fold FINISHED back-half shards back into their single folded ledger, on demand.
|
|
2
|
+
//
|
|
3
|
+
// The two logs are shard-then-fold (cli/ledger.mjs): writers drop one loose file per entry so
|
|
4
|
+
// concurrent writers never conflict. Left alone they accumulate; `yad tidy up` is the "pack it up"
|
|
5
|
+
// step (git's loose-objects → `git gc`). It is a MANUAL, human-run act, so only one person folds at a
|
|
6
|
+
// time — the fold is a rewrite (worse to conflict on than an append), and doing it deliberately keeps
|
|
7
|
+
// it serialized. It folds ONLY shards of a SHIPPED story (terminal, no more writes coming), never an
|
|
8
|
+
// in-progress one, so it can't race an active writer. Idempotent: nothing finished → no-op.
|
|
9
|
+
import fs from 'node:fs';
|
|
10
|
+
import path from 'node:path';
|
|
11
|
+
import { c, log, ok, info, fail, hand, exists, pushWithRebase } from './lib.mjs';
|
|
12
|
+
import { PROJECT_FILES } from './manifest.mjs';
|
|
13
|
+
import { loadHub } from './gate.mjs';
|
|
14
|
+
import { resolveCommitterLogin } from './platform.mjs';
|
|
15
|
+
import { checkpointAuthor } from './checkpoint.mjs';
|
|
16
|
+
import { hubGit, resolveDefaultBranch, guardDefaultBranch } from './hubcommit.mjs';
|
|
17
|
+
import { foldTrust, foldBuild } from './ledger.mjs';
|
|
18
|
+
import { epicRoot, isValidEpicId, readFrontmatter } from './epic-state.mjs';
|
|
19
|
+
|
|
20
|
+
// Story ids in an epic whose frontmatter `status` is `shipped` — the terminal, safe-to-fold signal
|
|
21
|
+
// (`yad-engineer-review` sets it once every task in the story has a ship record).
|
|
22
|
+
export function shippedStories(root, epic) {
|
|
23
|
+
const dir = path.join(epicRoot(root, epic), 'stories');
|
|
24
|
+
const set = new Set();
|
|
25
|
+
if (!exists(dir)) return set;
|
|
26
|
+
for (const name of fs.readdirSync(dir).filter((n) => n.endsWith('.md'))) {
|
|
27
|
+
if (readFrontmatter(path.join(dir, name)).status === 'shipped') set.add(name.replace(/\.md$/, ''));
|
|
28
|
+
}
|
|
29
|
+
return set;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function epicsWithLedgers(root) {
|
|
33
|
+
const dir = path.join(root, 'epics');
|
|
34
|
+
if (!exists(dir)) return [];
|
|
35
|
+
return fs.readdirSync(dir, { withFileTypes: true })
|
|
36
|
+
.filter((e) => e.isDirectory() && isValidEpicId(e.name)).map((e) => e.name).sort();
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function runTidy(root, opts = {}) {
|
|
40
|
+
log(c.bold('\nyad tidy up'));
|
|
41
|
+
if (!exists(path.join(root, '.git'))) { fail('not a git repo'); process.exitCode = 1; return; }
|
|
42
|
+
if (!exists(path.join(root, PROJECT_FILES.hubConfig))) {
|
|
43
|
+
fail('no .sdlc/hub.json — run `yad tidy up` from the product hub');
|
|
44
|
+
process.exitCode = 1;
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const { hub } = loadHub(root);
|
|
49
|
+
const git = hubGit(root);
|
|
50
|
+
const branch = git('rev-parse', '--abbrev-ref', 'HEAD').stdout;
|
|
51
|
+
const defaultBranch = resolveDefaultBranch(git, hub);
|
|
52
|
+
if (!guardDefaultBranch(branch, defaultBranch, { allowBranch: opts.allowBranch, cmd: 'yad tidy up' })) return;
|
|
53
|
+
|
|
54
|
+
if (opts.epic && !isValidEpicId(opts.epic)) { fail(`invalid epic id '${opts.epic}'`); process.exitCode = 1; return; }
|
|
55
|
+
if (opts.epic && !exists(epicRoot(root, opts.epic))) { fail(`no such epic '${opts.epic}'`); process.exitCode = 1; return; }
|
|
56
|
+
const epics = opts.epic ? [opts.epic] : epicsWithLedgers(root);
|
|
57
|
+
const touched = [];
|
|
58
|
+
let folded = 0;
|
|
59
|
+
for (const epic of epics) {
|
|
60
|
+
const shipped = shippedStories(root, epic);
|
|
61
|
+
const pick = (e) => shipped.has(e.story); // only a shipped story's shards are safe to fold
|
|
62
|
+
const epicDir = epicRoot(root, epic);
|
|
63
|
+
const t = foldTrust(epicDir, pick, { dryRun: opts.dryRun });
|
|
64
|
+
const b = foldBuild(epicDir, pick, { dryRun: opts.dryRun });
|
|
65
|
+
if (t.folded + b.folded > 0) {
|
|
66
|
+
folded += t.folded + b.folded;
|
|
67
|
+
touched.push(epic);
|
|
68
|
+
info(`${epic}: ${opts.dryRun ? 'would fold' : 'folded'} ${t.folded} trust + ${b.folded} build shard(s)`);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (!folded) { info('nothing to tidy — no finished shards to fold'); return { folded: 0 }; }
|
|
72
|
+
|
|
73
|
+
const author = checkpointAuthor(resolveCommitterLogin(root, hub?.roster || []), git('config', 'user.name').stdout);
|
|
74
|
+
const label = touched.length === 1 ? touched[0] : `${touched.length} epics`;
|
|
75
|
+
const message = `chore(hub): tidy back-half ledgers — ${label} by ${author} [skip ci]`;
|
|
76
|
+
|
|
77
|
+
if (opts.dryRun) {
|
|
78
|
+
log('\n' + c.dim(message) + '\n');
|
|
79
|
+
info('dry run — nothing folded or committed');
|
|
80
|
+
return { message, folded };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Stage the fold (modified folded files + deleted shards) under each touched epic's ledger paths.
|
|
84
|
+
// A pathspec that matches NOTHING (neither on disk nor tracked) makes `git add` fatal (exit 128), so
|
|
85
|
+
// keep only the paths that exist OR are tracked — `git add -A` on those stages new/modified files AND
|
|
86
|
+
// the shard deletions (even when the shard dir is now empty or removed).
|
|
87
|
+
const candidates = touched.flatMap((e) => {
|
|
88
|
+
const rel = `epics/${e}/.sdlc`;
|
|
89
|
+
return [`${rel}/trust-log.json`, `${rel}/trust-log`, `${rel}/build-log.json`, `${rel}/build-log`];
|
|
90
|
+
});
|
|
91
|
+
const pathspecs = candidates.filter((spec) =>
|
|
92
|
+
exists(path.join(root, spec)) || git('ls-files', '--error-unmatch', '--', spec).ok);
|
|
93
|
+
if (!pathspecs.length) { info('fold produced no stageable change'); return { folded }; }
|
|
94
|
+
const add = git('add', '-A', '--', ...pathspecs);
|
|
95
|
+
if (!add.ok) { fail(`git add failed — ${add.stderr.split('\n')[0] || add.code}`); process.exitCode = 1; return { folded }; }
|
|
96
|
+
const staged = git('diff', '--cached', '--name-only', '--', ...pathspecs).stdout.split('\n').filter(Boolean);
|
|
97
|
+
if (!staged.length) { info('fold produced no net change — nothing to commit'); return { folded }; }
|
|
98
|
+
|
|
99
|
+
const cm = git('commit', '-m', message, '--', ...staged);
|
|
100
|
+
if (!cm.ok) {
|
|
101
|
+
// Leave the fold STAGED (don't reset) — the on-disk fold already happened, so a re-run would see
|
|
102
|
+
// "nothing to tidy"; keeping it staged lets a retry or `yad checkpoint` land it, never lose it.
|
|
103
|
+
fail(`git commit failed — ${cm.stderr.split('\n')[0] || cm.code}`);
|
|
104
|
+
hand('the fold is staged — re-run `yad tidy up`, or `yad checkpoint` to land it');
|
|
105
|
+
process.exitCode = 1;
|
|
106
|
+
return { message, folded };
|
|
107
|
+
}
|
|
108
|
+
ok(`tidied ${folded} shard(s) into ${touched.length} epic ledger(s): ${c.dim(label)}`);
|
|
109
|
+
|
|
110
|
+
if (!opts.push) return { message, folded };
|
|
111
|
+
if (pushWithRebase(root, branch).ok) { ok(`pushed to origin/${branch}`); return { message, folded }; }
|
|
112
|
+
fail(`could not push to origin/${branch} — a protected branch, or an unresolvable rebase conflict`);
|
|
113
|
+
hand(`run \`git pull --rebase\` and re-run \`yad tidy up --push\``);
|
|
114
|
+
process.exitCode = 1;
|
|
115
|
+
return { message, folded };
|
|
116
|
+
}
|
package/cli/usage.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import fs from 'node:fs';
|
|
|
15
15
|
import path from 'node:path';
|
|
16
16
|
import { c, log, ok, note, readJSON, run } from './lib.mjs';
|
|
17
17
|
import { PROJECT_FILES, epicFiles } from './manifest.mjs';
|
|
18
|
+
import { readShips } from './ledger.mjs';
|
|
18
19
|
import { rolesForScope } from './platform.mjs';
|
|
19
20
|
|
|
20
21
|
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
@@ -105,8 +106,7 @@ function ledgerEvents(root, epic, resolver) {
|
|
|
105
106
|
};
|
|
106
107
|
for (const a of readLedger(f.approvals, []) || []) emit(a.approver, 'approved', a.date, { artifact: a.artifact, role: a.role });
|
|
107
108
|
for (const cm of readLedger(f.comments, []) || []) emit(cm.commenter, 'commented', cm.date, { artifact: cm.artifact, role: cm.role });
|
|
108
|
-
const
|
|
109
|
-
for (const s of bl?.ships || []) {
|
|
109
|
+
for (const s of readShips(path.join(root, 'epics', epic))) {
|
|
110
110
|
for (const er of s.engineer_review || []) emit(er.approver, 'shipped', s.shippedAt, { story: s.story, task: s.task, repo: s.repo, risk: s.risk });
|
|
111
111
|
}
|
|
112
112
|
return events;
|
|
@@ -246,8 +246,7 @@ function memberFlags(m) {
|
|
|
246
246
|
export function shipHygiene(root, { since, until } = {}) {
|
|
247
247
|
const items = [];
|
|
248
248
|
for (const epic of listEpics(root)) {
|
|
249
|
-
const
|
|
250
|
-
for (const s of bl?.ships || []) {
|
|
249
|
+
for (const s of readShips(path.join(root, 'epics', epic))) {
|
|
251
250
|
if (!inWindow(s.shippedAt, since, until)) continue;
|
|
252
251
|
if (!Array.isArray(s.engineer_review) || s.engineer_review.length === 0) {
|
|
253
252
|
items.push({ epic, story: s.story || null, task: s.task || null, repo: s.repo || null, shippedAt: s.shippedAt || null });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yadflow",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.7.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",
|
|
@@ -16,7 +16,11 @@ then **ship** — merge, record the ship, and update the story state. This is th
|
|
|
16
16
|
- `{project-root}` resolves from the project working directory — the **product** repo (the source of
|
|
17
17
|
truth: it holds the story and the build ledger).
|
|
18
18
|
- Code repos are separate git repos under `{project-root}/demo-repos/<repo>/`.
|
|
19
|
-
- The build ledger
|
|
19
|
+
- The build ledger uses **shard-then-fold** storage: each ship is its own shard file
|
|
20
|
+
`{project-root}/epics/<epic>/.sdlc/build-log/<story>-<task>-<repo>.json`, so concurrent shippers never
|
|
21
|
+
conflict; readers UNION the folded `build-log.json` with every loose shard, and `yad tidy up` folds
|
|
22
|
+
finished shards back into `build-log.json`. It — like the trust log and build-state — is committed by
|
|
23
|
+
`yad checkpoint` (see Step 3), not by hand.
|
|
20
24
|
- The engineer-review rule reuses `yad-review-gate`: base = at least one `owner` AND one distinct
|
|
21
25
|
`reviewer`; **escalated** (the PR's Impact & Risk is `high`, or it touches contract/auth/payments) =
|
|
22
26
|
base PLUS one `domain-owner` per touched domain — the same routing `yad-pr-template`'s
|
|
@@ -59,7 +63,7 @@ and records an approval. Determine the rule from the PR's Impact & Risk block (r
|
|
|
59
63
|
domain-owner per touched domain. Record each approval; re-evaluate whether the rule is satisfied.
|
|
60
64
|
Record `engagement: verified` when the engineer reviewed through the companion (else `none` for a bare
|
|
61
65
|
approve); `yad review reconcile --epic <id> --repo <r> --pr <n>` stamps it onto the ship record from the
|
|
62
|
-
platform. Soft by default (both count; a bare approve draws `yad review nudge`); only gates when
|
|
66
|
+
platform (mutating the ship's shard where it lives, or its folded entry if already tidied). Soft by default (both count; a bare approve draws `yad review nudge`); only gates when
|
|
63
67
|
`hub.review.requireEngagement: true`. The signal is gameable by design and sits **beside** the CI gates,
|
|
64
68
|
never above them.
|
|
65
69
|
Recording an approval does **not** ship — shipping is a separate, explicit step. Front-half discipline:
|
|
@@ -69,7 +73,9 @@ the gate talks only through files; refuse to treat AI review as a human approval
|
|
|
69
73
|
Ship **iff ALL hold**: the check gates pass (Step C), the AI review has run (advisory), and the
|
|
70
74
|
engineer-review rule is satisfied (Step 2). Then:
|
|
71
75
|
- **Merge** the task branch into the repo's default branch (the human performs/authorises the merge).
|
|
72
|
-
- **Record the ship** —
|
|
76
|
+
- **Record the ship** — write the ship to its own shard
|
|
77
|
+
`epics/<epic>/.sdlc/build-log/<story>-<task>-<repo>.json` (readers union the folded `build-log.json` +
|
|
78
|
+
the loose shards, deduping by (story, task, repo) so a shard wins over a stale folded ship):
|
|
73
79
|
```json
|
|
74
80
|
{ "story": "<story>", "task": "<task>", "repo": "<repo>", "branch": "feat/<story>-<task>-…",
|
|
75
81
|
"pr": "<url|#>", "mergeCommit": "<sha>", "gates": ["spec-link","contract-check","build-test-lint"],
|
|
@@ -81,11 +87,19 @@ engineer-review rule is satisfied (Step 2). Then:
|
|
|
81
87
|
**epic → story → task → PR → mergeCommit** is now traceable end to end.
|
|
82
88
|
- **Finalize the trust verdict (Phase 4).** If this story has a `build-state/<story>.json` (it ran
|
|
83
89
|
through `yad-run`), the engineer **confirms or overrides** the provisional trust verdict that the
|
|
84
|
-
orchestrator derived for this run, and the final verdict is written
|
|
85
|
-
`epics/<epic>/.sdlc/trust-log
|
|
90
|
+
orchestrator derived for this run, and the final verdict is written back into that run's trust shard
|
|
91
|
+
`epics/<epic>/.sdlc/trust-log/<story>-<repo>-implement-<uid>.json` (or, if the run was already folded
|
|
92
|
+
by `yad tidy up`, into its entry in the folded `trust-log.json`). The human has the last word on the trust signal: a diff merged
|
|
86
93
|
as authored is `approved-unchanged`; one the engineer edited before merge is `approved-with-edits`;
|
|
87
94
|
a rejected one is `rejected`. This is the evidence that later earns a step its `machine_advance`
|
|
88
95
|
(it never weakens the merge gate — the engineer still owns the merge).
|
|
96
|
+
- **Commit the machine-written ledgers.** Run `yad checkpoint --push` from `{project-root}` to commit
|
|
97
|
+
the back-half ledgers just written (the `build-log/` shard, and the `trust-log/` shard /
|
|
98
|
+
`build-state/<story>.json` if the story ran through `yad-run`) as one `chore(hub): …` audit-trail
|
|
99
|
+
commit — default branch only, staging the shard dirs (`yad tidy up` folds finished shards later),
|
|
100
|
+
never a front-half gate file. It is the back-half analogue of the front-half `yad gate` sync; the
|
|
101
|
+
reviewable state (the story frontmatter `status`, the code-repo `tasks.md`) is committed as usual
|
|
102
|
+
alongside — checkpoint only lands the machine audit ledgers.
|
|
89
103
|
|
|
90
104
|
### Step 4 — Stop
|
|
91
105
|
Report what shipped and the story's state. Do not advance anything else; the front-half `state.json`
|
|
@@ -15,9 +15,24 @@ ship. Shipping records the merge and updates the story state so the whole chain
|
|
|
15
15
|
- **escalated:** when the PR's Impact & Risk is `high`, or it touches contract/auth/payments — base
|
|
16
16
|
PLUS one `domain-owner` per touched domain (exactly what `risk-route.sh` prints).
|
|
17
17
|
|
|
18
|
-
## The build ledger — `epics/<epic>/.sdlc/build-log.json`
|
|
18
|
+
## The build ledger — shard-then-fold (`epics/<epic>/.sdlc/build-log/` → `build-log.json`)
|
|
19
19
|
|
|
20
|
-
Append-only
|
|
20
|
+
Append-only, one record per shipped task. **Storage mirrors the trust log's "loose objects + `git gc`"
|
|
21
|
+
model** so two people shipping different tasks of the same epic never collide on one file:
|
|
22
|
+
- **Per-ship shard:** each ship is its own file `epics/<epic>/.sdlc/build-log/<story>-<task>-<repo>.json`
|
|
23
|
+
= ONE ship object (the record below). `(story, task, repo)` is already unique, so no `uid` is needed;
|
|
24
|
+
concurrent shippers write different files → zero merge conflict.
|
|
25
|
+
- **Folded file:** `epics/<epic>/.sdlc/build-log.json` = `{ "epic": "<id>", "ships": [ … ] }` — the
|
|
26
|
+
legacy single-file layout, and where `yad tidy up` folds finished shards.
|
|
27
|
+
- **Union-read rule:** to read the ledger, union the folded `ships` with every `build-log/` shard,
|
|
28
|
+
**deduping by `(story, task, repo)`** — a loose shard WINS over a stale folded ship of the same key.
|
|
29
|
+
- `yad review reconcile` stamps the engagement signal by **mutating the ship's shard** where it lives
|
|
30
|
+
(or the folded entry if the story was already tidied) — see below.
|
|
31
|
+
- `yad checkpoint` commits the shard dir; `yad tidy up` (manual, one person) folds a shipped story's
|
|
32
|
+
finished shards into `build-log.json`.
|
|
33
|
+
|
|
34
|
+
The folded file is shown below; a **shard** file is just one element of `ships` (the bare ship object,
|
|
35
|
+
without the `{ epic, ships }` wrapper):
|
|
21
36
|
|
|
22
37
|
```json
|
|
23
38
|
{
|
|
@@ -45,7 +60,11 @@ Append-only. One record per shipped task:
|
|
|
45
60
|
```
|
|
46
61
|
|
|
47
62
|
This is the back-half analogue of the front half's `approvals.json` — files only, no hidden state, so a
|
|
48
|
-
future service can drive ship by writing the same records.
|
|
63
|
+
future service can drive ship by writing the same records. Like the trust log and build-state, it is a
|
|
64
|
+
machine-written ledger committed by **`yad checkpoint`** (the back-half analogue of `yad gate` sync),
|
|
65
|
+
not by hand: after recording the ship, `yad checkpoint --push` lands the new `build-log/` shard as a
|
|
66
|
+
`chore(hub): …` audit-trail commit on the default branch (allowlist-scoped to the back-half ledgers,
|
|
67
|
+
never a front-half gate file); `yad tidy up` folds finished shards into `build-log.json` later.
|
|
49
68
|
|
|
50
69
|
**Engagement (the Review Companion).** Each `engineer_review` entry carries `engagement: verified | none`
|
|
51
70
|
— `verified` when the engineer reviewed through the [companion](../../yad-review-companion/SKILL.md)
|
|
@@ -53,7 +72,8 @@ future service can drive ship by writing the same records.
|
|
|
53
72
|
approve. The optional `companion` block records which faces ran. It is **soft by default** (both count;
|
|
54
73
|
a bare approve draws a friendly `yad review nudge`); it only gates ship when
|
|
55
74
|
`hub.review.requireEngagement: true`. `yad review reconcile --epic <id> --repo <r> --pr <n>` reads the
|
|
56
|
-
code PR's approvals (with the engagement signal) and stamps them onto the matching ship record —
|
|
75
|
+
code PR's approvals (with the engagement signal) and stamps them onto the matching ship record — writing
|
|
76
|
+
back into the ship's shard where it lives (or its folded entry if the story was already tidied) — the
|
|
57
77
|
back-half **bridge**, the analogue of `yad gate sync`. The signal is gameable by design ("visible, not
|
|
58
78
|
impossible"): it makes engineer-review quality visible, it does not prove a human read the diff. It sits
|
|
59
79
|
**beside** the CI gates (build/test/lint/contract/verified-commits) — never above them; CI still
|
|
@@ -149,6 +149,15 @@ Phase 3 recorded build progress only *after the fact* in `build-log.json`. Phase
|
|
|
149
149
|
steps to carry their own `automation` dial so the orchestrator (`yad-run`) can read it and decide
|
|
150
150
|
whether to advance on its own. Two new files under `.sdlc/` do this.
|
|
151
151
|
|
|
152
|
+
> **Who commits these.** `build-state/<story-id>.json`, `trust-log.json`, and `build-log.json` are
|
|
153
|
+
> **machine-written** by the back half (`yad-run`, `yad-engineer-review`) and committed by
|
|
154
|
+
> **`yad checkpoint`** — the back-half analogue of the front-half `yad gate ci` sync. It lands them as
|
|
155
|
+
> one `chore(hub): sync back-half state — <epic>/<story> by @<login>` audit-trail commit, on the
|
|
156
|
+
> default branch, staging **only** these three ledgers by an explicit allowlist (never a front-half
|
|
157
|
+
> gate file — `state/approvals/comments/hub-prs.json`, `reviews/*.md` — so `ledger-guard` never trips).
|
|
158
|
+
> Teammates don't review these machine writes; the commit exists so CI, `yad status`, and other
|
|
159
|
+
> machines always see current trust evidence.
|
|
160
|
+
|
|
152
161
|
## `build-state/<story-id>.json`
|
|
153
162
|
One file per story that has entered the build half. The build half is **per-story, per-repo**, so the
|
|
154
163
|
steps live under each repo (mirrors the per-repo shape of `build-log.json`).
|
|
@@ -189,15 +198,37 @@ story/repo's `currentStep` into the next build sub-step (`spec`/`tasks` → `yad
|
|
|
189
198
|
`yad-implement`, `checks` → `yad-checks`, `engineer-review` → `yad-engineer-review`) and prints it with
|
|
190
199
|
the remaining chain and the step's automation dial — so the build half is guided, not just hinted at.
|
|
191
200
|
|
|
192
|
-
## `trust-log.json`
|
|
193
|
-
Append-only ledger
|
|
194
|
-
|
|
201
|
+
## `trust-log.json` (shard-then-fold)
|
|
202
|
+
Append-only ledger, the back-half analogue of `approvals.json`. **This is the evidence base** that
|
|
203
|
+
decides when a step is safe to automate (build plan Step A). One entry per step run.
|
|
204
|
+
|
|
205
|
+
**Storage — loose shards + a folded file (the "loose objects + `git gc`" model).** Two people driving
|
|
206
|
+
different stories of the same epic used to both append to one `trust-log.json` → a git merge conflict on
|
|
207
|
+
push. So each writer now writes ONE small shard file per entry under a shard dir, and readers union it
|
|
208
|
+
back:
|
|
209
|
+
- **Shard dir & name:** `epics/<epic>/.sdlc/trust-log/<story>-<repo>-<step>-<uid>.json` — each file is
|
|
210
|
+
ONE trust entry object. `uid` is a short unique token the writer generates fresh per run (never
|
|
211
|
+
reused), so re-runs of the same `(story, repo, step)` stay distinct files → concurrent writers touch
|
|
212
|
+
different files → zero conflict by construction.
|
|
213
|
+
- **Folded file:** `epics/<epic>/.sdlc/trust-log.json` = `{ "epic": "<id>", "runs": [ <entry>, … ] }`
|
|
214
|
+
(also the legacy single-file layout, and the output of `yad tidy up`).
|
|
215
|
+
- **Union-read rule:** to read the ledger, take the folded file's `runs` array PLUS every file in the
|
|
216
|
+
`trust-log/` shard dir, and **concatenate** — every entry is a distinct run and the trust threshold
|
|
217
|
+
counts re-runs, so **never dedup by `(story, repo, step)`**. (The only guard: a shard whose FULL
|
|
218
|
+
identity `(story, repo, step, uid)` already appears in the folded `runs` is a half-applied tidy and is
|
|
219
|
+
skipped — keying on `uid` alone would wrongly drop a different run that happened to reuse a token.) A legacy epic with only
|
|
220
|
+
the folded file and no shard dir still reads correctly — nothing to union.
|
|
221
|
+
- **`yad tidy up`** (manual, one person) folds a SHIPPED story's finished shards into the folded file's
|
|
222
|
+
`runs` and deletes them. Writers never fold — they only add shards; `yad checkpoint` commits the shard
|
|
223
|
+
dir, and `yad tidy up` is the back-half analogue of `git gc` folding loose objects.
|
|
224
|
+
- The **threshold slice** (below) reads this same union, filtered to the step (and repo).
|
|
195
225
|
|
|
196
226
|
```json
|
|
197
227
|
{
|
|
198
228
|
"story": "EP-<slug>-S0N",
|
|
199
229
|
"repo": "backend",
|
|
200
230
|
"step": "checks",
|
|
231
|
+
"uid": "<short-unique-token>",
|
|
201
232
|
"automation": "human_approve",
|
|
202
233
|
"verdict": "approved-unchanged",
|
|
203
234
|
"signals": { "checks": "pass", "human_edited_diff": false, "scope_overrun": false, "contract_touch": false },
|
|
@@ -210,6 +241,7 @@ base** that decides when a step is safe to automate (build plan Step A). One ent
|
|
|
210
241
|
| Field | Values | Meaning |
|
|
211
242
|
|-------|--------|---------|
|
|
212
243
|
| `step` | a `back_steps` id | Which step this run is recorded against. |
|
|
244
|
+
| `uid` | short unique token | Generated fresh per run (never reused) — makes each shard file and each re-run distinct; also the folded/loose de-dup guard. Legacy folded entries may lack it. |
|
|
213
245
|
| `automation` | dial in force at run time | So the log shows whether the run was a manual or an automated advance. |
|
|
214
246
|
| `verdict` | `approved-unchanged` \| `approved-with-edits` \| `rejected` | The trust signal. **Provisional verdict is derived** (below); the human gate for that step confirms or overrides it and finalizes the entry. |
|
|
215
247
|
| `signals` | object | The raw inputs the provisional verdict was derived from. The fields present depend on the step (table below). |
|
|
@@ -231,10 +263,25 @@ same three-way shape, anchored to each step's human gate, never self-graded):
|
|
|
231
263
|
- accepted as produced → `approved-unchanged`.
|
|
232
264
|
|
|
233
265
|
**Trust threshold** (from `config.yaml` `automation.trust_threshold`): a step is a candidate for
|
|
234
|
-
`machine_advance` only when its slice of
|
|
235
|
-
|
|
266
|
+
`machine_advance` only when its slice of the trust ledger — the **union** of the folded `trust-log.json`
|
|
267
|
+
`runs` plus every `trust-log/` shard, filtered to the same `step` (this story's repo or the project) —
|
|
268
|
+
has `>= min_runs` entries AND the fraction with `verdict == "approved-unchanged"` is
|
|
236
269
|
`>= min_approved_unchanged`. The dial-setter in `yad-run` enforces this; `yad-status` surfaces it.
|
|
237
270
|
|
|
271
|
+
## `build-log.json` (shard-then-fold)
|
|
272
|
+
The build ledger records one ship per merged task. Its schema and the ship record's fields are
|
|
273
|
+
documented authoritatively in `../../yad-engineer-review/references/ship-and-record.md`; only its
|
|
274
|
+
storage layout is noted here (it mirrors `trust-log.json`):
|
|
275
|
+
- **Shard dir & name:** `epics/<epic>/.sdlc/build-log/<story>-<task>-<repo>.json` — each file is ONE
|
|
276
|
+
ship object. `(story, task, repo)` is already a natural unique key, so no `uid` is needed.
|
|
277
|
+
- **Folded file:** `epics/<epic>/.sdlc/build-log.json` = `{ "epic": "<id>", "ships": [ <ship>, … ] }`
|
|
278
|
+
(also the legacy single-file layout, and the output of `yad tidy up`).
|
|
279
|
+
- **Union-read rule:** union the folded `ships` with every `build-log/` shard, **deduping by
|
|
280
|
+
`(story, task, repo)`** — a shard WINS over a stale folded ship (so a `yad review reconcile` edit to a
|
|
281
|
+
ship's shard is authoritative until it is folded).
|
|
282
|
+
- `yad checkpoint` commits the shard dir; `yad tidy up` folds a shipped story's finished shards into the
|
|
283
|
+
folded file (loose objects + `git gc`).
|
|
284
|
+
|
|
238
285
|
---
|
|
239
286
|
|
|
240
287
|
# Phase 6 — feature threads (post-lock change management)
|
|
@@ -121,8 +121,9 @@ finalize a `tasks` trust entry, anchored to what the human/dev actually did with
|
|
|
121
121
|
- the task is **re-scoped** first (its `Files:`/boundary edited) → `approved-with-edits`
|
|
122
122
|
(signal `task_rescoped: true`);
|
|
123
123
|
- the task list is discarded / regenerated → `rejected`.
|
|
124
|
-
|
|
125
|
-
|
|
124
|
+
Write the entry to its own shard `epics/<epic>/.sdlc/trust-log/<story>-<repo>-tasks-<uid>.json` (a fresh
|
|
125
|
+
`uid` per run, so concurrent writers never conflict; readers union the folded `trust-log.json` + the
|
|
126
|
+
loose shards). Schema: `../yad-epic/references/state-schema.md`. `tasks` stays `human_approve` until its slice clears
|
|
126
127
|
the threshold — this only *gathers* evidence. (The `implement` step's own verdict is finalized later,
|
|
127
128
|
at the engineer review in `yad-engineer-review`: merged as authored → `approved-unchanged`; edited first →
|
|
128
129
|
`approved-with-edits`; scope/contract/checks halt → `rejected`.)
|
package/skills/yad-run/SKILL.md
CHANGED
|
@@ -31,8 +31,15 @@ signal to seed them from, so they are earned only on real runs.
|
|
|
31
31
|
- Automation config is `skills/sdlc/config.yaml` → `automation:` (`back_steps`, `default`,
|
|
32
32
|
`trust_threshold`, `locked_steps`, `kill_switch`).
|
|
33
33
|
- Per-story build-half state: `epics/<epic>/.sdlc/build-state/<story-id>.json` (per repo).
|
|
34
|
-
- Trust ledger:
|
|
34
|
+
- Trust ledger: **shard-then-fold** — each run is its own shard file
|
|
35
|
+
`epics/<epic>/.sdlc/trust-log/<story>-<repo>-<step>-<uid>.json` (a fresh `uid` per run, so concurrent
|
|
36
|
+
writers never conflict); readers UNION the folded `trust-log.json` with every loose shard, and
|
|
37
|
+
`yad tidy up` folds finished shards back into `trust-log.json`. Schemas:
|
|
35
38
|
`../yad-epic/references/state-schema.md`.
|
|
39
|
+
- These machine-written back-half files (`build-state/<story>.json`, the `trust-log/` shards) are
|
|
40
|
+
committed by **`yad checkpoint`** — the back-half analogue of the front-half `yad gate` sync; the loop
|
|
41
|
+
calls it each iteration so the state is durable and shared without a human commit. (`yad checkpoint`
|
|
42
|
+
stages the shard dirs; `yad tidy up` later folds finished shards — loose objects + `git gc`.)
|
|
36
43
|
- The orchestrator **calls the existing step skills unchanged** — `yad-spec` (A), `yad-implement`
|
|
37
44
|
(B), `yad-checks` (C). It owns only the *advance decision*, never what a step does.
|
|
38
45
|
- To see (read-only, without driving the loop) the next build sub-step per story/repo, use
|
|
@@ -59,9 +66,10 @@ Walk the steps for `repo` starting at `from`/`currentStep`. For each step:
|
|
|
59
66
|
|
|
60
67
|
1. **Run the step's skill** — `spec`→`yad-spec`, `tasks`→ the tasks leg of `yad-spec`,
|
|
61
68
|
`implement`→`yad-implement`, `checks`→`yad-checks (action: run)`. Capture its result.
|
|
62
|
-
2. **Derive trust signals &
|
|
63
|
-
|
|
64
|
-
|
|
69
|
+
2. **Derive trust signals & write a trust-log shard** — write the entry to its own file
|
|
70
|
+
`trust-log/<story>-<repo>-<step>-<uid>.json` (a fresh `uid` per run; never append to a shared file),
|
|
71
|
+
with `ranBy: machine` if this advance was automated, else `human` — see `references/run-loop.md` for
|
|
72
|
+
the derivation. Do this for *every* step run, pass or fail; the log is the evidence base.
|
|
65
73
|
3. **Compute the effective dial.** Start from the step's `automation` in build-state, then **force it
|
|
66
74
|
to `human_approve`** if `automation.kill_switch` is true OR the step is `locked` OR the step id is
|
|
67
75
|
in `automation.locked_steps`. (So a kill switch or a lock always wins.)
|
|
@@ -76,12 +84,21 @@ Walk the steps for `repo` starting at `from`/`currentStep`. For each step:
|
|
|
76
84
|
5. **Always stop at `engineer-review`** (it is `locked`): hand off to `yad-engineer-review` for the human merge
|
|
77
85
|
gate, which finalizes the trust verdict (confirm/override the provisional one).
|
|
78
86
|
|
|
87
|
+
**Commit the machine-written state.** After each iteration's writes (the trust-log shard in 2 and the
|
|
88
|
+
build-state change in 4), run `yad checkpoint --push` from `{project-root}`. It commits *only* the
|
|
89
|
+
`trust-log/` shards + `build-state/<story>.json` (never a front-half gate file) as one `chore(hub): …`
|
|
90
|
+
audit-trail commit, and only ever on the default branch. It is a safe no-op when nothing changed, so
|
|
91
|
+
call it every iteration — teammates don't review these machine writes, but CI and `yad status` on
|
|
92
|
+
other machines must see current trust evidence. Never run it off the default branch (it will refuse):
|
|
93
|
+
an unpushed or branch-stranded trust log quietly undermines the "earned automation" premise.
|
|
94
|
+
|
|
79
95
|
### `action: set-dial` — earn (or revert) a step's automation
|
|
80
96
|
Flip `step`'s `automation` to `to` in build-state. Enforce, in order:
|
|
81
97
|
- **Refuse** if `step` is in `automation.locked_steps` or is a front state or `engineer-review` —
|
|
82
98
|
these can never be `machine_advance` (front-state lock, build plan §E). Report the refusal reason.
|
|
83
|
-
- For `to: machine_advance`, **refuse unless the trust threshold is met**: the step's slice of
|
|
84
|
-
`trust-log.json`
|
|
99
|
+
- For `to: machine_advance`, **refuse unless the trust threshold is met**: the step's slice of the trust
|
|
100
|
+
ledger — the **union** of the folded `trust-log.json` `runs` PLUS every `trust-log/` shard, filtered to
|
|
101
|
+
this step (and repo) — has `>= trust_threshold.min_runs` entries AND the fraction with
|
|
85
102
|
`verdict == "approved-unchanged"` is `>= trust_threshold.min_approved_unchanged`. If it is not met,
|
|
86
103
|
report the current evidence (runs, % unchanged) and how far short it is — "it seems fine" is not
|
|
87
104
|
evidence.
|
|
@@ -28,17 +28,19 @@ while step is a back step (not engineer-review):
|
|
|
28
28
|
|
|
29
29
|
signals = derive_signals(step, result) # see "Deriving signals"
|
|
30
30
|
verdict = derive_verdict(signals) # rejected | approved-with-edits | approved-unchanged
|
|
31
|
-
|
|
32
|
-
|
|
31
|
+
uid = fresh_short_token() # e.g. `openssl rand -hex 4` — unique per run,
|
|
32
|
+
# never reuse; a collision would fuse two runs
|
|
33
|
+
write trust-log/<story>-<repo>-<step>-<uid>.json # ONE shard file = ONE entry (never append to a shared file)
|
|
34
|
+
= { story, repo, step, uid, automation: bs.step.automation, verdict, signals, ranBy, date }
|
|
33
35
|
|
|
34
36
|
eff = effective_dial(step, bs, cfg) # see "Effective dial"
|
|
35
37
|
|
|
36
38
|
if result is a HALT (failed / scope overrun / contract touch / ambiguous):
|
|
37
|
-
bs.step.status = "blocked"; persist; STOP and report the human action needed
|
|
39
|
+
bs.step.status = "blocked"; persist; checkpoint; STOP and report the human action needed
|
|
38
40
|
elif eff == "machine_advance":
|
|
39
|
-
bs.step.status = "done"; advance bs.currentStep to next; persist; continue
|
|
41
|
+
bs.step.status = "done"; advance bs.currentStep to next; persist; checkpoint; continue # Step B advance
|
|
40
42
|
else: # human_approve
|
|
41
|
-
bs.step.status = "done"; persist; STOP and report "waiting for human at <next>"
|
|
43
|
+
bs.step.status = "done"; persist; checkpoint; STOP and report "waiting for human at <next>"
|
|
42
44
|
|
|
43
45
|
# reached engineer-review: always stop, hand to yad-engineer-review (human gate, finalizes the verdict)
|
|
44
46
|
```
|
|
@@ -47,6 +49,18 @@ while step is a back step (not engineer-review):
|
|
|
47
49
|
nudge; otherwise `human`. Persist build-state after every transition so a halt leaves an accurate,
|
|
48
50
|
resumable record.
|
|
49
51
|
|
|
52
|
+
`checkpoint` = run `yad checkpoint --push` from `{project-root}`. It commits the machine-written
|
|
53
|
+
back-half ledgers (in this loop, the loose `trust-log/` shards this run wrote + `build-state/<story>.json`;
|
|
54
|
+
also the `build-log/` shards at engineer-review) — the shard dirs are what checkpoint stages, the same
|
|
55
|
+
way `git gc` folds loose objects later (`yad tidy up` folds finished shards into the folded
|
|
56
|
+
`trust-log.json` / `build-log.json`) — as one `chore(hub): sync back-half state — <epic>/<story> by @<login> [skip ci]`
|
|
57
|
+
audit-trail commit, on the default branch only,
|
|
58
|
+
staging *only* those files by an explicit allowlist (never a front-half gate file — so `ledger-guard`
|
|
59
|
+
never trips). It is idempotent (a no-op when nothing changed), so calling it after every transition —
|
|
60
|
+
including a halt — is safe and keeps the shared trust evidence current for CI, teammates, and
|
|
61
|
+
`yad status` on other machines. It refuses to run off the default branch (an unsigned `[skip ci]`
|
|
62
|
+
commit inside a future PR range would fail `verified-commits` and strand the PR).
|
|
63
|
+
|
|
50
64
|
## Effective dial (kill switch & locks always win)
|
|
51
65
|
|
|
52
66
|
```
|
|
@@ -100,7 +114,10 @@ A step is a **candidate** for `machine_advance` only when its trust evidence cle
|
|
|
100
114
|
to `machine_advance`:
|
|
101
115
|
|
|
102
116
|
```
|
|
103
|
-
|
|
117
|
+
# read the ledger by UNION: the folded trust-log.json `runs` array PLUS every loose trust-log/ shard
|
|
118
|
+
# (concatenate — every shard is a distinct run; never dedup by story/repo/step, the threshold counts re-runs)
|
|
119
|
+
all = trust-log.json.runs + [read each file in trust-log/]
|
|
120
|
+
slice = entries in `all` for this step (this story's repo; widen to the project if you track it there)
|
|
104
121
|
runs = len(slice)
|
|
105
122
|
unchanged = count(e.verdict == "approved-unchanged" in slice)
|
|
106
123
|
earned = runs >= trust_threshold.min_runs
|
package/skills/yad-spec/SKILL.md
CHANGED
|
@@ -101,9 +101,10 @@ verdict is **anchored to the human who accepts the spec**, never self-graded:
|
|
|
101
101
|
`human_edited_spec: true`);
|
|
102
102
|
- the spec is rejected or the ceremony re-run → `rejected`.
|
|
103
103
|
`yad-run` records a provisional entry when the spec is generated; this acceptance finalizes it (same
|
|
104
|
-
pattern as the engineer review finalizing `implement` at `yad-engineer-review`).
|
|
105
|
-
`epics/<epic>/.sdlc/trust-log
|
|
106
|
-
|
|
104
|
+
pattern as the engineer review finalizing `implement` at `yad-engineer-review`). Write/finalize the
|
|
105
|
+
entry in its own shard `epics/<epic>/.sdlc/trust-log/<story>-<repo>-spec-<uid>.json` (a fresh `uid` per
|
|
106
|
+
run; readers union the folded `trust-log.json` + the loose shards). Schema:
|
|
107
|
+
`../yad-epic/references/state-schema.md`. **Run standalone, no trust entry is written** — the
|
|
107
108
|
log measures orchestrated runs. `spec` stays `human_approve` until its slice clears the threshold;
|
|
108
109
|
this step only *gathers* the evidence, it does not flip the dial.
|
|
109
110
|
|
|
@@ -21,7 +21,12 @@ report all if the user asked for an overview).
|
|
|
21
21
|
### Step 2 — Read state
|
|
22
22
|
Read `.sdlc/state.json`, `.sdlc/approvals.json`, `epic.md` frontmatter (for `repos`), and — if present
|
|
23
23
|
— `.sdlc/contract-lock.json`. For the build half (Phase 4), also read — if present — every
|
|
24
|
-
`.sdlc/build-state/<story-id>.json`,
|
|
24
|
+
`.sdlc/build-state/<story-id>.json`, and the trust ledger read as the **union** of the folded
|
|
25
|
+
`.sdlc/trust-log.json` `runs` PLUS every loose `.sdlc/trust-log/` shard (concatenate — every shard is a
|
|
26
|
+
distinct run; never dedup by story/repo/step — but DO skip a shard whose full identity
|
|
27
|
+
`(story,repo,step,uid)` already appears in the folded `runs`, i.e. a half-applied `yad tidy up`). All are committed by `yad checkpoint` (so a fresh clone
|
|
28
|
+
or another machine sees current evidence; `yad tidy up` folds finished shards into `trust-log.json`).
|
|
29
|
+
Also read the `automation` block of
|
|
25
30
|
`skills/sdlc/config.yaml` (`back_steps`, `trust_threshold`, `locked_steps`, `kill_switch`). For the
|
|
26
31
|
cross-cutting learning layer, also read — if present — the **local-only** `.sdlc/learning-records.json`
|
|
27
32
|
(the per-epic learning ledger, gitignored) and the project-wide `{project-root}/.sdlc/learning-records.json`.
|
|
@@ -72,8 +77,9 @@ Print, in this order:
|
|
|
72
77
|
reads the same `build-state` files.)
|
|
73
78
|
8. **Automation & trust** — print the system-wide **kill switch** state from `config.yaml`
|
|
74
79
|
`automation.kill_switch` (when `on`, note that every step is forced to `human_approve`). Then, for
|
|
75
|
-
each back-half step that has entries in
|
|
76
|
-
|
|
80
|
+
each back-half step that has entries in the trust ledger — the **union** of the folded
|
|
81
|
+
`.sdlc/trust-log.json` `runs` plus every loose `.sdlc/trust-log/` shard — print its **trust record**:
|
|
82
|
+
number of runs, the fraction with `verdict == "approved-unchanged"`, and whether that clears
|
|
77
83
|
`automation.trust_threshold` (`min_runs`, `min_approved_unchanged`) — i.e. whether the step is
|
|
78
84
|
**earned** (eligible to be flipped to `machine_advance`) or still **gathering evidence**. Restate
|
|
79
85
|
the predicate (self-contained): `earned = runs >= min_runs AND unchanged/runs >= min_approved_unchanged`.
|