yadflow 3.10.1 → 3.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/README.md +3 -0
- package/bin/yad.mjs +24 -5
- package/cli/doctor.mjs +30 -3
- package/cli/epic-state.mjs +67 -0
- package/cli/errors.mjs +1 -0
- package/cli/gate.mjs +66 -1
- package/cli/manifest.mjs +4 -0
- package/cli/setup.mjs +25 -10
- package/cli/update-notice.mjs +179 -0
- package/package.json +1 -1
- package/skills/yad-connect-repos/SKILL.md +18 -0
- package/skills/yad-epic/SKILL.md +5 -0
- package/skills/yad-hub-bridge/references/bridge.md +7 -0
- package/skills/yad-review-gate/references/gating.md +3 -1
- package/skills/yad-stories/SKILL.md +4 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
## [3.11.1](https://github.com/abdelrahmannasr/yadflow/compare/v3.11.0...v3.11.1) (2026-07-11)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* **gate:** close the authoring step when its review gate advances ([8baaed9](https://github.com/abdelrahmannasr/yadflow/commit/8baaed9416a063013b5e6acf1ae36c5a1b3c920b))
|
|
7
|
+
* **setup:** contain repo paths to the workspace so sibling repos connect ([265a7ae](https://github.com/abdelrahmannasr/yadflow/commit/265a7ae543fccf8697ff89726ecb1a6aadde62ce))
|
|
8
|
+
|
|
9
|
+
# [3.11.0](https://github.com/abdelrahmannasr/yadflow/compare/v3.10.1...v3.11.0) (2026-07-09)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
### Features
|
|
13
|
+
|
|
14
|
+
* notify when a newer yadflow is published ([9b7a5bf](https://github.com/abdelrahmannasr/yadflow/commit/9b7a5bfca4f27ab08ce4e3e48f2ba331c3e8bfd5))
|
|
15
|
+
|
|
1
16
|
## [3.10.1](https://github.com/abdelrahmannasr/yadflow/compare/v3.10.0...v3.10.1) (2026-07-08)
|
|
2
17
|
|
|
3
18
|
|
package/README.md
CHANGED
|
@@ -63,6 +63,9 @@ Every step stops at a gate until a human approves. New here? **Walk it lesson-by
|
|
|
63
63
|
[guided tutorial](https://abdelrahmannasr.github.io/yadflow/tutorial/)**, or read the
|
|
64
64
|
[team guide](TEAM-GUIDE.md).
|
|
65
65
|
|
|
66
|
+
Running `yad` tells you when a new release is out — upgrade with `npm install yadflow -g`, then
|
|
67
|
+
`yad update` to re-sync this project's skills. See [staying up to date](docs/CLI.md#staying-up-to-date).
|
|
68
|
+
|
|
66
69
|
## What `npx yadflow setup` installs
|
|
67
70
|
|
|
68
71
|

|
package/bin/yad.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { VERSION } from '../cli/manifest.mjs';
|
|
|
4
4
|
import { c, log, closePrompts, askYesNo } from '../cli/lib.mjs';
|
|
5
5
|
import { runSetup } from '../cli/setup.mjs';
|
|
6
6
|
import { reconcile } from '../cli/reconcile.mjs';
|
|
7
|
-
import { gateOpen, gateSync, gateComments, gateStatus, gateCi, gateReview, gateTrailer, gateWalkthrough } from '../cli/gate.mjs';
|
|
7
|
+
import { gateOpen, gateSync, gateComments, gateStatus, gateCi, gateReview, gateTrailer, gateWalkthrough, gateRepair } from '../cli/gate.mjs';
|
|
8
8
|
import { isValidEpicId } from '../cli/epic-state.mjs';
|
|
9
9
|
import { runCommit } from '../cli/commit.mjs';
|
|
10
10
|
import { runOpenPr } from '../cli/openpr.mjs';
|
|
@@ -22,6 +22,7 @@ import { syncStatuses } from '../cli/artifact-status.mjs';
|
|
|
22
22
|
import { runThread, runReconcile } from '../cli/thread.mjs';
|
|
23
23
|
import { runReport } from '../cli/report.mjs';
|
|
24
24
|
import { runUsage } from '../cli/usage.mjs';
|
|
25
|
+
import { maybeNotifyUpdate } from '../cli/update-notice.mjs';
|
|
25
26
|
|
|
26
27
|
const HELP = `${c.bold('yad')} — setup, review-gate & build helpers for the SDLC Workflow module ${c.dim('v' + VERSION)}
|
|
27
28
|
|
|
@@ -77,6 +78,8 @@ ${c.bold('Review gate (front half)')}
|
|
|
77
78
|
yad gate sync <epic> [artifact] Pull PR state -> ledger; advance on approved+resolved+merged
|
|
78
79
|
yad gate comments <epic> [artifact] Fetch unresolved review comments to address
|
|
79
80
|
yad gate status <epic> Show each review step + approvals
|
|
81
|
+
yad gate repair <epic> [--push] Close an author step stranded behind a passed review gate
|
|
82
|
+
(YAD-STATE-005); --push commits state.json to the default branch
|
|
80
83
|
yad gate review <epic> [artifact] Print the grounding bundle for the review companion
|
|
81
84
|
(artifact + risk + contract + PR + code-maps) — fun, easy review
|
|
82
85
|
yad gate walkthrough <epic> [artifact] Grounding bundle + ordered risk-tagged stops for the
|
|
@@ -146,7 +149,11 @@ ${c.bold('Options')}
|
|
|
146
149
|
--push check --fix / update: commit + push applied changes to the default branch
|
|
147
150
|
--allow-branch check --fix --push / update --push / repo refresh --push: allow committing on a non-default branch
|
|
148
151
|
-h, --help Show this help
|
|
149
|
-
-v, --version Print version
|
|
152
|
+
-v, --version Print version
|
|
153
|
+
|
|
154
|
+
${c.bold('Environment')}
|
|
155
|
+
YAD_NO_UPDATE_NOTIFIER=1 Silence the "update available" notice (also off in CI)
|
|
156
|
+
YAD_NO_REPORT=1 Never offer to file a bug report after a failure`;
|
|
150
157
|
|
|
151
158
|
const VALUE_FLAGS = new Set(['--dir', '--type', '--message', '--task', '--ai', '--risk', '--repo', '--platform', '--base', '--title', '--scope', '--branch', '--pr', '--epic', '--name', '--email', '--roles', '--team', '--body', '--out', '--since', '--until', '--member', '--format', '--reason']);
|
|
152
159
|
|
|
@@ -254,7 +261,7 @@ async function main() {
|
|
|
254
261
|
const [, action, epic, artifact] = o._;
|
|
255
262
|
// `gate ci` takes no positionals — epic/artifact come from --branch (or a sweep of all PRs).
|
|
256
263
|
if (action === 'ci') { await gateCi(o.dir, { branch: o.branch, pr: o.pr, merged: o.merged, push: !o.noPush, today }); break; }
|
|
257
|
-
if (!epic) { log(c.red('usage: yad gate <open|sync|comments|status|review|walkthrough|trailer|ci> <epic> [artifact]')); process.exitCode = 1; break; }
|
|
264
|
+
if (!epic) { log(c.red('usage: yad gate <open|sync|comments|status|repair|review|walkthrough|trailer|ci> <epic> [artifact]')); process.exitCode = 1; break; }
|
|
258
265
|
// The epic id becomes a path segment under epics/ — reject anything but EP-<slug> outright.
|
|
259
266
|
if (!isValidEpicId(epic)) { log(c.red(`invalid epic id: ${epic} (expected EP-<slug>, [a-z0-9-] only)`)); process.exitCode = 1; break; }
|
|
260
267
|
// In bridge mode CI is the sole ledger writer: `open` only opens the PR, and local `sync` is
|
|
@@ -264,10 +271,11 @@ async function main() {
|
|
|
264
271
|
else if (action === 'sync') await gateSync(o.dir, { epic, artifact, today, local: true });
|
|
265
272
|
else if (action === 'comments') await gateComments(o.dir, { epic, artifact, today });
|
|
266
273
|
else if (action === 'status') await gateStatus(o.dir, { epic });
|
|
274
|
+
else if (action === 'repair') await gateRepair(o.dir, { epic, push: o.push, allowBranch: o.allowBranch, dryRun: o.dryRun });
|
|
267
275
|
else if (action === 'review') await gateReview(o.dir, { epic, artifact });
|
|
268
276
|
else if (action === 'walkthrough') await gateWalkthrough(o.dir, { epic, artifact });
|
|
269
277
|
else if (action === 'trailer') await gateTrailer(o.dir, { epic, artifact, body: o.body || o.message, number: o.pr });
|
|
270
|
-
else { log(c.red(`unknown gate action: ${action} (open|sync|comments|status|review|walkthrough|trailer|ci)`)); process.exitCode = 1; }
|
|
278
|
+
else { log(c.red(`unknown gate action: ${action} (open|sync|comments|status|repair|review|walkthrough|trailer|ci)`)); process.exitCode = 1; }
|
|
271
279
|
break;
|
|
272
280
|
}
|
|
273
281
|
case 'review': {
|
|
@@ -364,4 +372,15 @@ main()
|
|
|
364
372
|
} catch { /* reporting is best-effort — never mask the original failure */ }
|
|
365
373
|
}
|
|
366
374
|
})
|
|
367
|
-
.
|
|
375
|
+
// Runs for every command, success or failure, after any report prompt. Prints to stderr and never
|
|
376
|
+
// touches process.exitCode, so a command's stdout contract and exit status are unaffected.
|
|
377
|
+
// The try/finally is load-bearing, not defensive noise: a rejection here would escape as an
|
|
378
|
+
// unhandled rejection (exit 1 on an otherwise successful command) AND skip closePrompts(), leaving
|
|
379
|
+
// the readline handle open so the process never exits.
|
|
380
|
+
.finally(async () => {
|
|
381
|
+
try {
|
|
382
|
+
await maybeNotifyUpdate();
|
|
383
|
+
} catch { /* the notice is never worth failing or hanging a command over */ } finally {
|
|
384
|
+
closePrompts();
|
|
385
|
+
}
|
|
386
|
+
});
|
package/cli/doctor.mjs
CHANGED
|
@@ -6,9 +6,9 @@ import path from 'node:path';
|
|
|
6
6
|
import fs from 'node:fs';
|
|
7
7
|
import { c, log, ok, info, warn, fail, hand, run, has, exists, readJSON, readJSONStrict } from './lib.mjs';
|
|
8
8
|
import { VERSION, PROJECT_FILES, DESIGN_TOOLS, TESTING_TOOLS, LEARNING_TOOLS } from './manifest.mjs';
|
|
9
|
-
import { loadLedger, epicRoot, isValidEpicId, epicLineage, resolveThread } from './epic-state.mjs';
|
|
9
|
+
import { loadLedger, epicRoot, isValidEpicId, epicLineage, resolveThread, stateInvariants } from './epic-state.mjs';
|
|
10
10
|
import { loadDebt } from './thread.mjs';
|
|
11
|
-
import { gitHead } from './setup.mjs';
|
|
11
|
+
import { gitHead, insideWorkspace } from './setup.mjs';
|
|
12
12
|
import { cliFor, validateLogin, hostFromGitUrl } from './platform.mjs';
|
|
13
13
|
|
|
14
14
|
const MIN_NODE = 18;
|
|
@@ -18,6 +18,18 @@ const MIN_NODE = 18;
|
|
|
18
18
|
const isSolo = (hub) => !!(hub && (hub.solo === true || hub.review_gate?.solo === true));
|
|
19
19
|
// owner/repo slug from a git url (https or ssh), for the branch-protection probe.
|
|
20
20
|
const repoSlug = (url) => ((url || '').match(/[:/]([^/:]+\/[^/]+?)(?:\.git)?$/) || [])[1] || null;
|
|
21
|
+
// Is an already-resolved path nested under the project root? Repo paths are contained to the WORKSPACE
|
|
22
|
+
// (the root's parent, see setup.insideWorkspace), so a registered sibling resolves outside the root —
|
|
23
|
+
// which is what distinguishes "absent because it lives elsewhere" from "absent because it is broken".
|
|
24
|
+
// The path.sep suffix keeps /proj-evil from reading as inside /proj.
|
|
25
|
+
const underProjectRoot = (root, p) => {
|
|
26
|
+
const projectRoot = path.resolve(root);
|
|
27
|
+
return p === projectRoot || p.startsWith(projectRoot + path.sep);
|
|
28
|
+
};
|
|
29
|
+
// An absent path is only excused as "a sibling that lives elsewhere" when it is one the connect step
|
|
30
|
+
// would actually accept. A hand-edited registry pointing outside the workspace entirely (../../x) is
|
|
31
|
+
// corruption, and must not be reassured away as an expected sibling.
|
|
32
|
+
const isRegistrableSibling = (root, rpath) => insideWorkspace(root, rpath);
|
|
21
33
|
|
|
22
34
|
// Each check: { id, section, status: 'ok'|'warn'|'fail', message, hint? }
|
|
23
35
|
function check(checks, id, section, status, message, hint = '') {
|
|
@@ -213,7 +225,15 @@ export function projectChecks(checks, root) {
|
|
|
213
225
|
// would read as "healthy") — an entry with no path is malformed.
|
|
214
226
|
if (!repo.path) { check(checks, `repo:${repo.name || '(unnamed)'}`, 'project', 'fail', `${repo.name || '(unnamed)'}: no \`path\` in repos.json [YAD-STATE-003]`, 're-connect the repo (`yad setup`)'); continue; }
|
|
215
227
|
const repoRoot = path.resolve(root, repo.path);
|
|
216
|
-
|
|
228
|
+
// A registered repo may be a SIBLING of the hub (`../backend`, the standard multi-repo layout).
|
|
229
|
+
// Such a checkout is legitimately absent wherever only the hub is checked out — hub CI, a fresh
|
|
230
|
+
// clone — so its absence is a warn, not corruption. A missing path INSIDE the project root is
|
|
231
|
+
// still a hard fail: nothing but damage explains it.
|
|
232
|
+
if (!exists(repoRoot)) {
|
|
233
|
+
if (underProjectRoot(root, repoRoot) || !isRegistrableSibling(root, repo.path)) check(checks, `repo:${repo.name}`, 'project', 'fail', `${repo.name}: path ${repo.path} does not exist [YAD-STATE-003]`, 'fix the path in repos.json or re-connect the repo');
|
|
234
|
+
else check(checks, `repo:${repo.name}`, 'project', 'warn', `${repo.name}: ${repo.path} is not present in this checkout (sibling repo, outside the hub)`, 'expected when only the hub is checked out; clone it alongside the hub to work on it here');
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
217
237
|
const head = gitHead(repoRoot);
|
|
218
238
|
if (!head) { check(checks, `repo:${repo.name}`, 'project', 'fail', `${repo.name}: ${repo.path} is not a git repository (or has no commits) [YAD-STATE-003]`, 'init/clone the repo, then re-connect it'); continue; }
|
|
219
239
|
if (!repo.syncedHead) check(checks, `repo:${repo.name}`, 'project', 'warn', `${repo.name}: registered without a code-context pack (greenfield)`, 'run `yad repo refresh ' + repo.name + '` once it has code');
|
|
@@ -270,6 +290,13 @@ export function epicChecks(checks, root) {
|
|
|
270
290
|
if (!ledger.state) check(checks, `epic:${e}`, 'epics', 'warn', `${e}: no state.json — epic not seeded`, 'author it via yad-epic, or remove the directory');
|
|
271
291
|
else {
|
|
272
292
|
check(checks, `epic:${e}`, 'epics', 'ok', `${e}: currentStep ${ledger.state.currentStep}`);
|
|
293
|
+
// Chain consistency: a passed review gate whose author step was never closed. currentStep alone
|
|
294
|
+
// cannot see this, yet it blocks every later step (including the parallel test-cases track).
|
|
295
|
+
for (const v of stateInvariants(ledger.state)) {
|
|
296
|
+
check(checks, `epic:${e}:${v.authorStep}`, 'epics', 'fail',
|
|
297
|
+
`${e}: ${v.message} [${v.code}]`,
|
|
298
|
+
`run \`yad gate repair ${e}\` to close it`);
|
|
299
|
+
}
|
|
273
300
|
// Migration guard (pre-3.0 model): under the current model CI records the ledger on the
|
|
274
301
|
// default branch only at merge (when the step is already done), and writes nothing during
|
|
275
302
|
// review — so an OPEN (non-done) review PR recorded here means it was opened under an older
|
package/cli/epic-state.mjs
CHANGED
|
@@ -184,6 +184,29 @@ export function findReviewStep(state, artifact) {
|
|
|
184
184
|
export const isEscalated = (step) =>
|
|
185
185
|
(step?.risk_tags || []).some((t) => RISK_ESCALATORS.includes(t)) || step?.id === 'stories-review';
|
|
186
186
|
|
|
187
|
+
// The authoring step paired with a review gate: `stories-review` -> `stories`. Resolved BY ID (the
|
|
188
|
+
// same `-review` suffix convention `isSkippableStep` uses), never positionally — a chain may legally
|
|
189
|
+
// omit the author step (a change-epic that carries only the gate), and `steps[i-1]` would then point
|
|
190
|
+
// at an unrelated step. Returns null when the chain has no such step.
|
|
191
|
+
export function authorStepFor(state, reviewStep) {
|
|
192
|
+
const id = String(reviewStep?.id || '');
|
|
193
|
+
if (!id.endsWith('-review')) return null;
|
|
194
|
+
return state?.steps?.find((s) => s.id === id.replace(/-review$/, '')) || null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Closing a review gate implies its artifact was authored — so the CLI, not the authoring skill, is
|
|
198
|
+
// what makes `<step>.status = done` true. Without this an author step left at `in_progress` strands
|
|
199
|
+
// forever: `preconditionsMet` requires every PRIOR step done, so the parallel `test-cases` track (and
|
|
200
|
+
// every later step) stays blocked behind a review that already passed. Idempotent; a no-op on an
|
|
201
|
+
// absent step and on a `skipped` one (already `done`, carrying its skip provenance).
|
|
202
|
+
// Returns the id it closed, or null.
|
|
203
|
+
function closeAuthorStep(state, reviewStep) {
|
|
204
|
+
const author = authorStepFor(state, reviewStep);
|
|
205
|
+
if (!author || author.status === 'done') return null;
|
|
206
|
+
author.status = 'done';
|
|
207
|
+
return author.id;
|
|
208
|
+
}
|
|
209
|
+
|
|
187
210
|
const uniqueBy = (arr, key) => {
|
|
188
211
|
const seen = new Set();
|
|
189
212
|
return arr.filter((x) => (seen.has(x[key]) ? false : seen.add(x[key])));
|
|
@@ -293,6 +316,10 @@ export function gatePredicate({
|
|
|
293
316
|
export function advanceState(state, step) {
|
|
294
317
|
const i = state.steps.findIndex((s) => s.id === step.id);
|
|
295
318
|
state.steps[i] = { ...state.steps[i], status: 'done' };
|
|
319
|
+
// Defensive: `markInReview` normally closed the author step when the gate opened, but the CI bridge
|
|
320
|
+
// advances on a merge event without ever running it locally. Close it here too, so a passed gate can
|
|
321
|
+
// never leave its author step behind (issue #131).
|
|
322
|
+
closeAuthorStep(state, step);
|
|
296
323
|
if (step.id === 'stories-review') {
|
|
297
324
|
const tc = state.steps.find((s) => s.id === 'test-cases');
|
|
298
325
|
if (tc && tc.status === 'blocked') tc.status = 'in_progress';
|
|
@@ -442,6 +469,9 @@ export function unskipStep(state, stepId) {
|
|
|
442
469
|
export function markInReview(state, step) {
|
|
443
470
|
const i = state.steps.findIndex((s) => s.id === step.id);
|
|
444
471
|
if (state.steps[i].status !== 'done') state.steps[i].status = 'in_review';
|
|
472
|
+
// Opening a review gate means the artifact was authored — close the paired author step rather than
|
|
473
|
+
// trusting the authoring skill to have hand-edited state.json (issue #131).
|
|
474
|
+
closeAuthorStep(state, step);
|
|
445
475
|
if (state.currentStep !== 'ready-for-build') state.currentStep = step.id;
|
|
446
476
|
return state;
|
|
447
477
|
}
|
|
@@ -567,6 +597,43 @@ export function preconditionsMet(state, stepId) {
|
|
|
567
597
|
return { ok: true, blockedBy: null, reason: 'ready' };
|
|
568
598
|
}
|
|
569
599
|
|
|
600
|
+
// PURE. Consistency invariants over a chain, for `doctor` (report) and `gate repair` (heal). Today one
|
|
601
|
+
// rule: a `review+approve` step that is `done` must have its paired author step `done` too — a gate
|
|
602
|
+
// cannot have passed on an unauthored artifact. Violations are epics damaged by a pre-fix `gate sync`
|
|
603
|
+
// (issue #131); they read as healthy to a `currentStep`-only check while silently blocking every later
|
|
604
|
+
// step through `preconditionsMet`.
|
|
605
|
+
//
|
|
606
|
+
// Deliberately NOT the broader "no non-done step precedes a done one": the parallel `test-cases` track
|
|
607
|
+
// legitimately sits `in_progress` after `stories-review` advanced the epic to ready-for-build.
|
|
608
|
+
// No FS / network. Returns [] on a missing or malformed chain (loadLedger already reports that).
|
|
609
|
+
export function stateInvariants(state) {
|
|
610
|
+
if (!state || !Array.isArray(state.steps)) return [];
|
|
611
|
+
const violations = [];
|
|
612
|
+
for (const step of state.steps) {
|
|
613
|
+
if (step.type !== 'review+approve' || step.status !== 'done') continue;
|
|
614
|
+
const author = authorStepFor(state, step);
|
|
615
|
+
if (!author || author.status === 'done') continue;
|
|
616
|
+
violations.push({
|
|
617
|
+
code: 'YAD-STATE-005',
|
|
618
|
+
reviewStep: step.id,
|
|
619
|
+
authorStep: author.id,
|
|
620
|
+
message: `${author.id} is '${author.status}' behind a completed ${step.id}`,
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
return violations;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// Apply the repair `stateInvariants` describes: close every author step stranded behind a done review
|
|
627
|
+
// gate. Mutates `state` and returns the ids it closed (empty when already consistent — idempotent).
|
|
628
|
+
export function repairState(state) {
|
|
629
|
+
const closed = [];
|
|
630
|
+
for (const v of stateInvariants(state)) {
|
|
631
|
+
const author = state.steps.find((s) => s.id === v.authorStep);
|
|
632
|
+
if (author && author.status !== 'done') { author.status = 'done'; closed.push(author.id); }
|
|
633
|
+
}
|
|
634
|
+
return closed;
|
|
635
|
+
}
|
|
636
|
+
|
|
570
637
|
// PURE next-action resolver for ONE epic's ledger — what `yad next <epic>` prints. Reads state + the
|
|
571
638
|
// recorded review PRs only. kind:
|
|
572
639
|
// 'new' — no epic state yet (seed one with yad-epic)
|
package/cli/errors.mjs
CHANGED
|
@@ -20,6 +20,7 @@ export const CODES = {
|
|
|
20
20
|
'YAD-STATE-002': 'a ledger/config JSON file parses but has the wrong shape',
|
|
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
|
+
'YAD-STATE-005': 'an authoring step is stranded behind its completed review gate',
|
|
23
24
|
'YAD-CFG-001': 'hub.json names an unknown platform (expected github, gitlab, or null)',
|
|
24
25
|
'YAD-CFG-002': 'design.json names an unknown design tool (expected one of config.yaml design.tools, or none)',
|
|
25
26
|
'YAD-CFG-003': 'testing.json names an unknown testing tool (expected one of config.yaml testing.tools, or none)',
|
package/cli/gate.mjs
CHANGED
|
@@ -11,8 +11,9 @@ import { PROJECT_FILES } from './manifest.mjs';
|
|
|
11
11
|
import {
|
|
12
12
|
epicRoot, loadLedger, findReviewStep, artifactBase, artifactHash, gatePredicate,
|
|
13
13
|
advanceState, markInReview, isEscalated, parseReviewBranch, artifactFromBase,
|
|
14
|
-
upsertHubPr, DISCOVERY_FILES,
|
|
14
|
+
upsertHubPr, stateInvariants, repairState, DISCOVERY_FILES,
|
|
15
15
|
} from './epic-state.mjs';
|
|
16
|
+
import { hubGit, preflightGuardReadiness, resolveDefaultBranch, guardDefaultBranch } from './hubcommit.mjs';
|
|
16
17
|
import {
|
|
17
18
|
readPr, mapApprovers, createPr, reviewersForScopes, resolveCommitterLogin,
|
|
18
19
|
getPrBody, editPrBody, postComment,
|
|
@@ -486,6 +487,70 @@ export async function gateStatus(root, { epic } = {}) {
|
|
|
486
487
|
}
|
|
487
488
|
}
|
|
488
489
|
|
|
490
|
+
// PURE — the audit-trail commit message for a state repair (mirrors buildCheckpointMessage). The
|
|
491
|
+
// subject passes the hub commit-message gate (valid type `chore`, scope `gate`, no trailing period)
|
|
492
|
+
// and carries [skip ci]: the repair lands on the default branch, where a re-triggered gate workflow
|
|
493
|
+
// would have nothing to do. No Task trailer and no Co-Authored-By footer — this is machine state a
|
|
494
|
+
// human corrected, not an authored code change.
|
|
495
|
+
export function buildRepairMessage({ epic, steps }) {
|
|
496
|
+
const subject = 'chore(gate): repair epic state — close stranded author step(s) [skip ci]';
|
|
497
|
+
return `${subject}\n\nEpic: ${epic}\nClosed: ${steps.join(', ')}\nReason: YAD-STATE-005 — author step(s) left behind a completed review gate`;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// `yad gate repair <epic>` — heal the chain inconsistency `doctor` reports as YAD-STATE-005: an author
|
|
501
|
+
// step stranded at in_progress behind a review gate that already advanced (issue #131). A pre-fix
|
|
502
|
+
// `gate sync` could leave this, and it never self-heals — sync skips a step that is already `done` — so
|
|
503
|
+
// the damage needs an explicit, auditable correction.
|
|
504
|
+
//
|
|
505
|
+
// Only state.json is touched, and `--push` commits ONLY that file: a `[skip ci]` chore commit must never
|
|
506
|
+
// sweep up an unrelated edit. It lands on the DEFAULT branch, where `ledger-guard` (which polices the
|
|
507
|
+
// machine-written ledger on review PRs) does not apply — so this stays compatible with "CI is the sole
|
|
508
|
+
// writer of the ledger" during review.
|
|
509
|
+
export async function gateRepair(root, { epic, push = false, allowBranch = false, dryRun = false } = {}) {
|
|
510
|
+
const epicDir = epicRoot(root, epic);
|
|
511
|
+
const ledger = loadLedger(epicDir);
|
|
512
|
+
if (!ledger.state) { fail(`no epic state at ${epicDir}/.sdlc/state.json`); process.exitCode = 1; return { closed: [] }; }
|
|
513
|
+
|
|
514
|
+
log(c.bold(`\nyad gate repair ${c.dim(epic)}`));
|
|
515
|
+
const violations = stateInvariants(ledger.state);
|
|
516
|
+
if (!violations.length) { ok('epic state is consistent — nothing to repair'); return { closed: [] }; }
|
|
517
|
+
for (const v of violations) warn(`${v.message} [${v.code}]`);
|
|
518
|
+
|
|
519
|
+
const closed = repairState(ledger.state);
|
|
520
|
+
if (dryRun) { info('dry run — nothing written'); return { closed }; }
|
|
521
|
+
writeJSON(ledger.files.state, ledger.state);
|
|
522
|
+
ok(`closed ${closed.length} stranded author step(s): ${c.dim(closed.join(', '))}`);
|
|
523
|
+
if (!push) { hand('re-run `yad doctor` to confirm, then commit epics/*/.sdlc/state.json (or re-run with --push)'); return { closed }; }
|
|
524
|
+
|
|
525
|
+
// --- publish: narrow, default-branch-only commit of the one repaired file ---
|
|
526
|
+
preflightGuardReadiness(root);
|
|
527
|
+
const git = hubGit(root);
|
|
528
|
+
const branch = git('rev-parse', '--abbrev-ref', 'HEAD').stdout;
|
|
529
|
+
const defaultBranch = resolveDefaultBranch(git, loadHub(root).hub);
|
|
530
|
+
if (!guardDefaultBranch(branch, defaultBranch, { allowBranch, cmd: 'yad gate repair' })) return { closed };
|
|
531
|
+
|
|
532
|
+
const spec = path.relative(root, ledger.files.state);
|
|
533
|
+
if (!git('add', '--', spec).ok) { fail(`git add failed for ${spec}`); process.exitCode = 1; return { closed }; }
|
|
534
|
+
if (git('diff', '--cached', '--quiet', '--', spec).ok) { info('state.json unchanged on disk — nothing to commit'); return { closed }; }
|
|
535
|
+
|
|
536
|
+
const message = buildRepairMessage({ epic, steps: closed });
|
|
537
|
+
const cm = git('commit', '-m', message, '--', spec);
|
|
538
|
+
if (!cm.ok) {
|
|
539
|
+
git('reset', '-q', '--', spec); // never leave it staged for an unrelated commit to sweep up
|
|
540
|
+
fail(`git commit failed — ${cm.stderr.split('\n')[0] || cm.code}`);
|
|
541
|
+
process.exitCode = 1;
|
|
542
|
+
return { closed };
|
|
543
|
+
}
|
|
544
|
+
ok(`committed the repair: ${c.dim(message.split('\n')[0])}`);
|
|
545
|
+
// Push HEAD to its OWN branch — with --allow-branch we are not on the default branch, and pushing
|
|
546
|
+
// HEAD:defaultBranch would publish a WIP branch straight to it.
|
|
547
|
+
if (pushWithRebase(root, branch).ok) { ok(`pushed to origin/${branch}`); return { closed }; }
|
|
548
|
+
fail(`could not push to origin/${branch} — a protected branch, or an unresolvable rebase conflict`);
|
|
549
|
+
hand('run `git pull --rebase` and re-run `yad gate repair <epic> --push`');
|
|
550
|
+
process.exitCode = 1;
|
|
551
|
+
return { closed };
|
|
552
|
+
}
|
|
553
|
+
|
|
489
554
|
// `head` overrides the review branch the PR is opened against — `open-pr` delegates here after pushing
|
|
490
555
|
// the user's checked-out branch, which for a per-story review (review/EP-*/stories-S01) does NOT equal
|
|
491
556
|
// the branch this would otherwise recompute (artifactFromBase collapses stories-S01 → stories/). Pass
|
package/cli/manifest.mjs
CHANGED
|
@@ -10,6 +10,10 @@ import { readFileSync } from 'node:fs';
|
|
|
10
10
|
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
11
11
|
export const VERSION = pkg.version;
|
|
12
12
|
|
|
13
|
+
// The published npm package name — the registry path the update check queries, and the name in the
|
|
14
|
+
// `npm install <name> -g` line it prints. Read from package.json so a rename can never desync them.
|
|
15
|
+
export const PKG_NAME = pkg.name;
|
|
16
|
+
|
|
13
17
|
// The upstream yadflow repo, as `owner/name` — where `yad report` files issues. Derived from
|
|
14
18
|
// package.json `bugs.url` (the single source of truth) so it tracks a fork/rename automatically;
|
|
15
19
|
// falls back to the canonical slug if the field is ever malformed.
|
package/cli/setup.mjs
CHANGED
|
@@ -43,14 +43,28 @@ export function detectPlatform(remoteUrl = '') {
|
|
|
43
43
|
}
|
|
44
44
|
export const gitHead = (cwd) => run('git', ['rev-parse', 'HEAD'], { cwd }).stdout || null;
|
|
45
45
|
|
|
46
|
-
// Containment:
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
|
|
46
|
+
// Containment: a repo path must live inside the WORKSPACE — the hub root's parent. The standard
|
|
47
|
+
// multi-repo layout puts the code repos BESIDE the hub, not under it (project/{product,backend,frontend}),
|
|
48
|
+
// so `../backend` has to register; containing to the hub root instead forced separate git repos to nest
|
|
49
|
+
// inside the hub's own repo (issue #129). A nested path (demo-repos/api) still works.
|
|
50
|
+
//
|
|
51
|
+
// The bound stays real: the registry path is later joined and executed against (repomix cwd,
|
|
52
|
+
// .coderabbit.yaml + CI wiring), and even the read-only remote probe must not run against an arbitrary
|
|
53
|
+
// outside path — so `../../elsewhere` and absolute-outside paths are still rejected. The path.sep-suffixed
|
|
54
|
+
// compare avoids the /project vs /project-evil prefix trap, now one level up at the workspace.
|
|
55
|
+
// A sibling of the hub (../product-evil) is, correctly, indistinguishable from ../backend: both are
|
|
56
|
+
// ordinary workspace members. The workspace DIRECTORY ITSELF (`..`) is not: it contains the hub, so
|
|
57
|
+
// registering it as a code repo would point repomix and the CI writes at the whole tree. Only the hub
|
|
58
|
+
// root itself (a monorepo, `.`) and strict descendants of the workspace pass.
|
|
59
|
+
//
|
|
60
|
+
// Place the hub one level below the workspace root (project/product), not directly in $HOME — the
|
|
61
|
+
// workspace is the trust boundary, and a shallow hub makes every sibling of it registerable.
|
|
62
|
+
export function insideWorkspace(root, rpath) {
|
|
51
63
|
const projectRoot = path.resolve(root);
|
|
64
|
+
const parent = path.dirname(projectRoot);
|
|
65
|
+
const workspace = parent === projectRoot ? projectRoot : parent; // degenerate: root is the fs root
|
|
52
66
|
const resolved = path.resolve(projectRoot, rpath);
|
|
53
|
-
return resolved === projectRoot || resolved.startsWith(
|
|
67
|
+
return resolved === projectRoot || resolved.startsWith(workspace + path.sep);
|
|
54
68
|
}
|
|
55
69
|
|
|
56
70
|
// Validate + record one code repo into the registry (the testable half of the connect loop).
|
|
@@ -192,8 +206,8 @@ export function reconcileRepoRoles(root, name, repo, current = [], want = []) {
|
|
|
192
206
|
}
|
|
193
207
|
|
|
194
208
|
export function registerRepo(root, registry, { name, rpath, platform, domain_owner = '', domain_owners = null, default_branch = 'main', today = null, pack = true }) {
|
|
195
|
-
if (!
|
|
196
|
-
warn(`${rpath} resolves outside the project root — skipped`);
|
|
209
|
+
if (!insideWorkspace(root, rpath)) {
|
|
210
|
+
warn(`${rpath} resolves outside the workspace (the project root's parent) — skipped`);
|
|
197
211
|
return null;
|
|
198
212
|
}
|
|
199
213
|
const repoRoot = path.resolve(root, rpath);
|
|
@@ -633,8 +647,9 @@ export async function runSetup(root, opts = {}) {
|
|
|
633
647
|
const name = await ask(' repo name (blank to finish)', '');
|
|
634
648
|
if (!name) break;
|
|
635
649
|
if (known.has(name)) { warn(`${name} already registered — skipping`); continue; }
|
|
636
|
-
|
|
637
|
-
|
|
650
|
+
// Siblings of the hub are the common layout (project/{product,backend}) — `../backend` is valid.
|
|
651
|
+
const rpath = await ask(' path (relative to project root, e.g. ../backend)', `demo-repos/${name}`);
|
|
652
|
+
if (!insideWorkspace(root, rpath)) { warn(`${rpath} resolves outside the workspace (the project root's parent) — skipped`); continue; }
|
|
638
653
|
const detected = run('git', ['remote', 'get-url', 'origin'], { cwd: path.resolve(root, rpath) });
|
|
639
654
|
const platform = (await ask(' platform (github/gitlab)', detectPlatform(detected.ok ? detected.stdout : '') || 'github')).toLowerCase();
|
|
640
655
|
// Domain owners route the per-repo review. Solo (no roster) and monorepo (one repo = one owner)
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
// "A new yadflow is out" — the update disclaimer printed after every `yad` command.
|
|
2
|
+
//
|
|
3
|
+
// Three rules make this safe to run on every invocation:
|
|
4
|
+
// 1. It never throws and never touches process.exitCode. A dead registry, an unwritable home, or a
|
|
5
|
+
// malformed cache degrades to silence, never to a failed command.
|
|
6
|
+
// 2. It prints to STDERR (the `note()` convention in lib.mjs), so `--json` commands, the grounding
|
|
7
|
+
// bundles, and `yad -v` keep a machine-readable STDOUT.
|
|
8
|
+
// 3. It is cache-first: the network is touched at most once per TTL. Every other run is pure disk.
|
|
9
|
+
//
|
|
10
|
+
// Deliberately NOT suppressed on a non-TTY. Skills invoke `yad` through an agent's Bash tool, where
|
|
11
|
+
// stdout/stderr are piped — the usual "only notify on a TTY" guard would hide the notice from exactly
|
|
12
|
+
// the case we most want it in. `CI` is the suppression signal instead.
|
|
13
|
+
import os from 'node:os';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { VERSION, PKG_NAME, UPSTREAM_REPO } from './manifest.mjs';
|
|
16
|
+
import { c, exists, readJSON, writeJSON, PKG_ROOT } from './lib.mjs';
|
|
17
|
+
|
|
18
|
+
export const DAY_MS = 24 * 60 * 60 * 1000;
|
|
19
|
+
const FETCH_TIMEOUT_MS = 1500;
|
|
20
|
+
const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
|
|
21
|
+
|
|
22
|
+
// An env var counts as "set" only when it carries a meaningful value — `CI=false` and `CI=0` are
|
|
23
|
+
// common in shells that always export the name.
|
|
24
|
+
const truthy = (v) => !!v && v !== '0' && v !== 'false';
|
|
25
|
+
|
|
26
|
+
// ---- semver -------------------------------------------------------------
|
|
27
|
+
// A deliberately small parser: we only ever compare a released `x.y.z` against another. Anything the
|
|
28
|
+
// registry hands us that is not a clean triple (garbage, a range, undefined) yields null → no notice.
|
|
29
|
+
export function parseVersion(v) {
|
|
30
|
+
if (typeof v !== 'string') return null;
|
|
31
|
+
const m = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/.exec(v.trim());
|
|
32
|
+
if (!m) return null;
|
|
33
|
+
return { major: +m[1], minor: +m[2], patch: +m[3], pre: m[4] ?? null };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// The canonical `x.y.z` form. `parseVersion` tolerates a leading `v`, so anything interpolated into
|
|
37
|
+
// the banner or the release-tag URL must be normalized first — otherwise a `v`-prefixed `latest`
|
|
38
|
+
// (from a mirror registry or a hand-edited cache) yields a dead `.../releases/tag/vv3.11.0` link.
|
|
39
|
+
export function normalizeVersion(v) {
|
|
40
|
+
const p = parseVersion(v);
|
|
41
|
+
return p ? `${p.major}.${p.minor}.${p.patch}${p.pre ? `-${p.pre}` : ''}` : null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// True when `latest` is a release strictly newer than `current`. A prerelease `latest` never nags a
|
|
45
|
+
// user on a stable version — dist-tags.latest should never be one, but a mis-tagged publish would
|
|
46
|
+
// otherwise pester every user until it was fixed. Prereleases are not ordered against each other
|
|
47
|
+
// (rc.2 does not "beat" rc.1); the only prerelease transition we announce is rc → its stable.
|
|
48
|
+
export function isNewer(latest, current) {
|
|
49
|
+
const l = parseVersion(latest);
|
|
50
|
+
const cur = parseVersion(current);
|
|
51
|
+
if (!l || !cur) return false;
|
|
52
|
+
if (l.pre && !cur.pre) return false;
|
|
53
|
+
if (l.major !== cur.major) return l.major > cur.major;
|
|
54
|
+
if (l.minor !== cur.minor) return l.minor > cur.minor;
|
|
55
|
+
if (l.patch !== cur.patch) return l.patch > cur.patch;
|
|
56
|
+
// Same x.y.z: the stable release supersedes the prerelease of that same version, so a user sitting
|
|
57
|
+
// on 4.0.0-rc.1 is told when 4.0.0 final ships.
|
|
58
|
+
return !l.pre && !!cur.pre;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ---- registry -----------------------------------------------------------
|
|
62
|
+
export function registryBase({ env = process.env } = {}) {
|
|
63
|
+
const base = env.YAD_REGISTRY_URL || env.npm_config_registry || DEFAULT_REGISTRY;
|
|
64
|
+
return base.replace(/\/+$/, '');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The `dist-tags` endpoint returns a few dozen bytes (`{"latest":"3.10.1"}`); the packument at
|
|
68
|
+
// /<pkg> or /<pkg>/latest is orders of magnitude larger for the same one field.
|
|
69
|
+
// `fetchImpl` must NOT default to a bare `fetch` in the parameter list: default parameters are
|
|
70
|
+
// evaluated before the function body's try/catch is entered, so on a runtime without a global fetch
|
|
71
|
+
// (Node 18 started with --no-experimental-fetch) that would throw a ReferenceError straight past
|
|
72
|
+
// every guard here and out through bin/yad.mjs's .finally. Resolve it inside the try instead.
|
|
73
|
+
export async function fetchLatest({ env = process.env, timeoutMs = FETCH_TIMEOUT_MS, fetchImpl } = {}) {
|
|
74
|
+
try {
|
|
75
|
+
const doFetch = fetchImpl ?? globalThis.fetch;
|
|
76
|
+
if (typeof doFetch !== 'function') return null; // no fetch on this runtime — stay quiet
|
|
77
|
+
const url = `${registryBase({ env })}/-/package/${encodeURIComponent(PKG_NAME)}/dist-tags`;
|
|
78
|
+
const res = await doFetch(url, {
|
|
79
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
80
|
+
headers: { accept: 'application/json' },
|
|
81
|
+
});
|
|
82
|
+
if (!res.ok) return null;
|
|
83
|
+
const tags = await res.json();
|
|
84
|
+
return typeof tags?.latest === 'string' ? tags.latest : null;
|
|
85
|
+
} catch {
|
|
86
|
+
return null; // offline, DNS failure, timeout, non-JSON body — all mean "we don't know", not "fail"
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---- cache --------------------------------------------------------------
|
|
91
|
+
// The CLI's only per-user state. Everything else it writes is project-scoped under .sdlc/.
|
|
92
|
+
export function cacheFile({ env = process.env, platform = process.platform, home = os.homedir() } = {}) {
|
|
93
|
+
if (env.YAD_CACHE_DIR) return path.join(env.YAD_CACHE_DIR, 'update-check.json');
|
|
94
|
+
if (env.XDG_CACHE_HOME) return path.join(env.XDG_CACHE_HOME, 'yadflow', 'update-check.json');
|
|
95
|
+
if (platform === 'win32' && env.LOCALAPPDATA) return path.join(env.LOCALAPPDATA, 'yadflow', 'update-check.json');
|
|
96
|
+
return path.join(home, '.cache', 'yadflow', 'update-check.json');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export const readCache = (file) => readJSON(file, null);
|
|
100
|
+
|
|
101
|
+
// A read-only home (CI images, locked-down laptops, a root-owned ~/.cache) must not break `yad`.
|
|
102
|
+
// Losing the cache only costs one registry round-trip per command.
|
|
103
|
+
export function writeCache(file, data) {
|
|
104
|
+
try {
|
|
105
|
+
writeJSON(file, data);
|
|
106
|
+
return true;
|
|
107
|
+
} catch {
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---- suppression --------------------------------------------------------
|
|
113
|
+
// `pkgRoot` carrying a .git means yad is running from a source checkout (`npm run yad`, the test
|
|
114
|
+
// suite's execFileSync calls), not from a global npm install. Nagging a maintainer about the version
|
|
115
|
+
// they are editing is noise.
|
|
116
|
+
export function shouldSuppress({ env = process.env, pkgRoot = PKG_ROOT } = {}) {
|
|
117
|
+
if (truthy(env.YAD_NO_UPDATE_NOTIFIER)) return true;
|
|
118
|
+
if (truthy(env.CI)) return true;
|
|
119
|
+
if (truthy(env.SDLC_NONINTERACTIVE)) return true;
|
|
120
|
+
if (exists(path.join(pkgRoot, '.git'))) return true;
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ---- banner -------------------------------------------------------------
|
|
125
|
+
// `yad update` is the necessary second half: upgrading the global CLI leaves this project's installed
|
|
126
|
+
// yad-* skills stamped at the old version in .sdlc/cli-version.json, which `yad doctor` then flags.
|
|
127
|
+
export function formatBanner(current, latest) {
|
|
128
|
+
// Normalize so a `v`-prefixed input can never produce `.../releases/tag/vv3.11.0`. Callers only
|
|
129
|
+
// reach here after isNewer(), so parseVersion has already accepted both — the ?? is belt and braces.
|
|
130
|
+
const v = normalizeVersion(latest) ?? latest;
|
|
131
|
+
const url = `https://github.com/${UPSTREAM_REPO}/releases/tag/v${v}`;
|
|
132
|
+
return [
|
|
133
|
+
'',
|
|
134
|
+
` ${c.yellow('!')} ${c.bold(`${PKG_NAME} update available`)} — ${c.dim(current)} → ${c.green(v)}`,
|
|
135
|
+
` ${c.dim('Changelog:')} ${url}`,
|
|
136
|
+
` ${c.dim('Update:')} ${c.cyan(`npm install ${PKG_NAME} -g`)}`,
|
|
137
|
+
` ${c.dim('Then:')} ${c.cyan('yad update')} ${c.dim("(re-sync this project's yad-* skills)")}`,
|
|
138
|
+
].join('\n');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---- orchestrator -------------------------------------------------------
|
|
142
|
+
// Returns true when a banner was printed (tests assert on this; callers ignore it).
|
|
143
|
+
export async function maybeNotifyUpdate({
|
|
144
|
+
env = process.env,
|
|
145
|
+
now = Date.now(),
|
|
146
|
+
pkgRoot = PKG_ROOT,
|
|
147
|
+
ttlMs = DAY_MS,
|
|
148
|
+
current = VERSION,
|
|
149
|
+
out = (s) => console.error(s),
|
|
150
|
+
fetchImpl, // resolved to globalThis.fetch inside fetchLatest — see the note there
|
|
151
|
+
} = {}) {
|
|
152
|
+
try {
|
|
153
|
+
if (shouldSuppress({ env, pkgRoot })) return false;
|
|
154
|
+
|
|
155
|
+
const file = cacheFile({ env });
|
|
156
|
+
const cache = readCache(file);
|
|
157
|
+
// `age >= 0` matters: a lastCheck stamped in the future (a clock that jumped forward, an NTP
|
|
158
|
+
// correction, a cache synced from another machine) yields a negative age, which would read as
|
|
159
|
+
// "fresh" and pin a stale `latest` until real time caught up. Treat it as expired instead.
|
|
160
|
+
const age = now - cache?.lastCheck;
|
|
161
|
+
const fresh = Number.isFinite(cache?.lastCheck) && age >= 0 && age < ttlMs;
|
|
162
|
+
|
|
163
|
+
let latest = typeof cache?.latest === 'string' ? cache.latest : null;
|
|
164
|
+
if (!fresh) {
|
|
165
|
+
const fetched = await fetchLatest({ env, fetchImpl });
|
|
166
|
+
if (fetched) latest = fetched;
|
|
167
|
+
// Stamp lastCheck even when the fetch failed: an offline user would otherwise pay the full
|
|
168
|
+
// timeout on every single command. We keep any previously-known `latest` so the banner survives
|
|
169
|
+
// a temporary outage.
|
|
170
|
+
writeCache(file, { lastCheck: now, latest });
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (!isNewer(latest, current)) return false;
|
|
174
|
+
out(formatBanner(current, latest));
|
|
175
|
+
return true;
|
|
176
|
+
} catch {
|
|
177
|
+
return false; // never let the notifier turn a successful command into a failed one
|
|
178
|
+
}
|
|
179
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yadflow",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.11.1",
|
|
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",
|
|
@@ -34,6 +34,24 @@ epic's approvals. It only writes the project-wide registry and the per-repo cont
|
|
|
34
34
|
`roles` map, e.g. `roles: hub=owner,reviewer backend=domain-owner`). Validate the login against the
|
|
35
35
|
hub (`gh api users/<login>` / `glab api users?username=`); a miss is flagged `unverified` (warn-only).
|
|
36
36
|
- `path` — local path to the code repo (relative to `{project-root}` or absolute). For local repos.
|
|
37
|
+
It must resolve inside the **workspace** — the project root's parent — so the standard layout, where
|
|
38
|
+
the code repos sit **beside** the hub rather than under it, registers as `../backend`:
|
|
39
|
+
|
|
40
|
+
```text
|
|
41
|
+
project/ <- the workspace (containment boundary)
|
|
42
|
+
product/ <- the hub repo; `yad setup` runs here
|
|
43
|
+
backend/ <- ../backend
|
|
44
|
+
frontend/ <- ../frontend
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
A nested path (`demo-repos/api`, the demo layout) also works. A path that escapes the workspace
|
|
48
|
+
(`../../elsewhere`, or an absolute path outside it) is rejected and skipped — the registry path is
|
|
49
|
+
later used as a working directory (repomix) and written into (`.coderabbit.yaml`, CI wiring).
|
|
50
|
+
It is stored **exactly as typed**; every consumer re-resolves it against the project root.
|
|
51
|
+
|
|
52
|
+
The workspace is the trust boundary, so **put the hub one level below it** (`project/product`), not
|
|
53
|
+
directly in `$HOME` — with the hub at `~/product` the workspace becomes `~` and every home-dir
|
|
54
|
+
sibling turns into a registrable repo. The workspace directory itself (`..`) is never registrable.
|
|
37
55
|
- `git_url` — optional remote (SSH or HTTPS; GitHub or GitLab). Used when the repo is not yet on disk.
|
|
38
56
|
- `domain_owners` — the engineer(s) who own this repo's domain (a repo may have several; drives per-repo
|
|
39
57
|
review routing). Each name is also written into that person's `roles[<repo>]` map in `hub.json`.
|
package/skills/yad-epic/SKILL.md
CHANGED
|
@@ -169,6 +169,11 @@ In `state.json`: set `epic.status: "done"`, set `epic-review.status: "in_review"
|
|
|
169
169
|
`currentStep: "epic-review"`. Write `state.json`. Do **not** re-seed and do **not** touch
|
|
170
170
|
`approvals.json` — only real reviewers approve, through the gate.
|
|
171
171
|
|
|
172
|
+
> Since 3.11 the CLI closes the authoring step itself whenever its review gate opens or advances
|
|
173
|
+
> (`yad gate open` / `sync`), so this edit is a no-op when the gate has already run. Keep making it —
|
|
174
|
+
> it keeps `state.json` truthful before the gate opens — but it is no longer load-bearing: an epic
|
|
175
|
+
> whose author step is left `in_progress` used to strand forever (`YAD-STATE-005`).
|
|
176
|
+
|
|
172
177
|
### Step 6 — Stop at the gate (do NOT advance)
|
|
173
178
|
Report: epic ID, the path to `epic.md`, and that the next action is **review** via
|
|
174
179
|
`yad-review-gate`. **Never mark the epic-review step approved here** — only real reviewers do that
|
|
@@ -169,6 +169,13 @@ opens the PR only; local `yad gate sync` is advisory in bridge mode (writes noth
|
|
|
169
169
|
everyone `git checkout <default> && git pull`. (Without the bridge, humans own the ledger locally and
|
|
170
170
|
these guards are no-ops.)
|
|
171
171
|
|
|
172
|
+
**The one sanctioned human ledger write: `yad gate repair`.** It heals a `YAD-STATE-005` chain (an
|
|
173
|
+
authoring step stranded behind a review gate that already advanced) by writing `state.json` alone. This
|
|
174
|
+
is not a `ledger-guard` gap: the repair commits to the **default branch**, where `ledger-guard` — which
|
|
175
|
+
only inspects review PRs — never runs, and where the `yad-update-guard` (platform-Verified signature +
|
|
176
|
+
roster-allowlisted author) vets it instead, exactly as it does for `yad checkpoint` and `yad update`.
|
|
177
|
+
The command refuses to commit off the default branch unless `--allow-branch` is passed.
|
|
178
|
+
|
|
172
179
|
**Loop prevention & races.** The only ledger commit lands on the **default branch** at merge, which
|
|
173
180
|
fires no PR trigger; it carries `[skip ci]` to guard sibling workflows. Because CI never pushes the
|
|
174
181
|
review branch, there is no `synchronize` / MR-pipeline loop to prevent — and it is now **safe to enable**
|
|
@@ -55,7 +55,9 @@ re-approve. (Hash recipe: `yad-architecture/references/contract-format.md`.)
|
|
|
55
55
|
4. `action: approve` approver *bob* role *reviewer* → ledger entry added. Predicate:
|
|
56
56
|
`|owners|=1, |reviewers|=1` → **base pass**.
|
|
57
57
|
5. `action: advance` → `epic-review.status=done`, `architecture.status=in_progress`,
|
|
58
|
-
`currentStep=architecture`. Gate reports the advance.
|
|
58
|
+
`currentStep=architecture`. Gate reports the advance. The paired authoring step (`epic`) is closed
|
|
59
|
+
too, if it was not already — a gate cannot have passed on an unauthored artifact. `doctor` reports
|
|
60
|
+
any surviving violation as `YAD-STATE-005`; `yad gate repair <epic>` heals it.
|
|
59
61
|
|
|
60
62
|
## Participation record (comments.json)
|
|
61
63
|
`approvals.json` answers "who approved"; `.sdlc/comments.json` answers "who reviewed/commented". The
|
|
@@ -104,6 +104,10 @@ As a <role>, I want <capability>, so that <outcome>.
|
|
|
104
104
|
In `state.json`: set `stories.status: "done"`, set `stories-review.status: "in_review"`, and set
|
|
105
105
|
`currentStep: "stories-review"`. Write `state.json`. Do **not** touch `approvals.json`.
|
|
106
106
|
|
|
107
|
+
> Since 3.11 the CLI also closes the authoring step when its review gate opens or advances, so this
|
|
108
|
+
> edit is a no-op once `yad gate open` has run. A `stories` step left `in_progress` behind a passed
|
|
109
|
+
> `stories-review` used to block the parallel `test-cases` track (`YAD-STATE-005`).
|
|
110
|
+
|
|
107
111
|
### Step 7 — Stop at the gate (do NOT advance)
|
|
108
112
|
Report: the story IDs created, the repos each touches, and that the next action is **review** via
|
|
109
113
|
`yad-review-gate`. Note that this review routes **per-repo reviewers**: owner + 1 reviewer **plus**, for
|