yadflow 3.9.4 → 3.10.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 +20 -0
- package/bin/yad.mjs +13 -1
- package/cli/epic-state.mjs +131 -1
- package/cli/errors.mjs +1 -0
- package/cli/repo-publish.mjs +118 -18
- package/cli/repo.mjs +3 -2
- package/cli/setup.mjs +28 -0
- package/cli/skip.mjs +45 -0
- package/package.json +1 -1
- package/skills/sdlc/config.yaml +2 -1
- package/skills/yad-checks/references/check-gates.md +5 -1
- package/skills/yad-checks/templates/github/yad-checks.yml +14 -0
- package/skills/yad-connect-repos/references/code-context.md +7 -1
- package/skills/yad-epic/SKILL.md +6 -0
- package/skills/yad-epic/references/state-schema.md +26 -0
- package/skills/yad-status/SKILL.md +7 -0
- package/skills/yad-stories/SKILL.md +5 -0
- package/skills/yad-test-cases/SKILL.md +4 -0
- package/skills/yad-ui/SKILL.md +13 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,23 @@
|
|
|
1
|
+
## [3.10.1](https://github.com/abdelrahmannasr/yadflow/compare/v3.10.0...v3.10.1) (2026-07-08)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* re-run pr-template gate on an edited PR body ([17ad94a](https://github.com/abdelrahmannasr/yadflow/commit/17ad94a4881610b4b653700be50a4eddd7036c5d))
|
|
7
|
+
* stop yad repo refresh --push stranding the regenerated pack.md ([f0b5f4c](https://github.com/abdelrahmannasr/yadflow/commit/f0b5f4ce9f22afcd6078aae3ea1dd5a64885be35))
|
|
8
|
+
|
|
9
|
+
# [3.10.0](https://github.com/abdelrahmannasr/yadflow/compare/v3.9.4...v3.10.0) (2026-07-08)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
### Bug Fixes
|
|
13
|
+
|
|
14
|
+
* **sdlc:** satisfy lint gate and cover the yad skip CLI ([838eabc](https://github.com/abdelrahmannasr/yadflow/commit/838eabc0ba597eaeac3e9249c514e3d479de4830))
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Features
|
|
18
|
+
|
|
19
|
+
* **sdlc:** make the ui-design step optional (skippable N/A) ([2bc5583](https://github.com/abdelrahmannasr/yadflow/commit/2bc5583b58626ee0cf8f8cc993a8566e4b206221))
|
|
20
|
+
|
|
1
21
|
## [3.9.4](https://github.com/abdelrahmannasr/yadflow/compare/v3.9.3...v3.9.4) (2026-07-07)
|
|
2
22
|
|
|
3
23
|
## [3.9.3](https://github.com/abdelrahmannasr/yadflow/compare/v3.9.2...v3.9.3) (2026-07-07)
|
package/bin/yad.mjs
CHANGED
|
@@ -17,6 +17,7 @@ import { runRoster } from '../cli/roster.mjs';
|
|
|
17
17
|
import { runDocs } from '../cli/docs.mjs';
|
|
18
18
|
import { runDoctor } from '../cli/doctor.mjs';
|
|
19
19
|
import { runNext } from '../cli/next.mjs';
|
|
20
|
+
import { runSkip } from '../cli/skip.mjs';
|
|
20
21
|
import { syncStatuses } from '../cli/artifact-status.mjs';
|
|
21
22
|
import { runThread, runReconcile } from '../cli/thread.mjs';
|
|
22
23
|
import { runReport } from '../cli/report.mjs';
|
|
@@ -66,6 +67,10 @@ ${c.bold('Where am I / what next')}
|
|
|
66
67
|
yad next <epic> The single next action for one epic (skill or yad command)
|
|
67
68
|
yad next <epic> --check <step> Exit 0 if <step> is runnable now, else 1 (precondition guard)
|
|
68
69
|
yad next --all Every active epic's next action at once
|
|
70
|
+
yad skip <epic> ui-design --reason <text> Mark an optional step N/A for this epic (only
|
|
71
|
+
ui-design today) — a backend/API/data epic with no UI.
|
|
72
|
+
Stays visible & auditable (pre-done, gate short-circuited);
|
|
73
|
+
--undo reverses it until the stories review opens
|
|
69
74
|
|
|
70
75
|
${c.bold('Review gate (front half)')}
|
|
71
76
|
yad gate open <epic> <artifact> Open the review PR/MR; mark the step in_review
|
|
@@ -143,7 +148,7 @@ ${c.bold('Options')}
|
|
|
143
148
|
-h, --help Show this help
|
|
144
149
|
-v, --version Print version`;
|
|
145
150
|
|
|
146
|
-
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']);
|
|
151
|
+
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']);
|
|
147
152
|
|
|
148
153
|
function parseArgs(argv) {
|
|
149
154
|
const o = { _: [], dir: process.cwd(), fix: false, force: false, scope: 'all' };
|
|
@@ -163,6 +168,7 @@ function parseArgs(argv) {
|
|
|
163
168
|
// positional. `o._[0]` is the command, already pushed by the time `--check` is seen in normal use.
|
|
164
169
|
else if (a === '--check') { const v = argv[i + 1]; o.check = (o._[0] === 'next' && v !== undefined && !v.startsWith('-')) ? argv[++i] : true; }
|
|
165
170
|
else if (a === '--all') o.all = true;
|
|
171
|
+
else if (a === '--undo') o.undo = true;
|
|
166
172
|
// setup profile flags (pre-answer the Step 0 interview, for CI/scripts)
|
|
167
173
|
else if (a === '--solo') o.solo = true;
|
|
168
174
|
else if (a === '--greenfield') o.greenfield = true;
|
|
@@ -238,6 +244,12 @@ async function main() {
|
|
|
238
244
|
await runNext(o.dir, { epic, check: typeof o.check === 'string' ? o.check : undefined, all: o.all });
|
|
239
245
|
break;
|
|
240
246
|
}
|
|
247
|
+
case 'skip': {
|
|
248
|
+
const [, epic, step] = o._;
|
|
249
|
+
if (!epic || !isValidEpicId(epic)) { log(c.red(`invalid or missing epic id: ${epic ?? '(none)'} (expected EP-<slug>, [a-z0-9-] only)`)); process.exitCode = 1; break; }
|
|
250
|
+
await runSkip(o.dir, { epic, step, reason: o.reason, undo: o.undo, today });
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
241
253
|
case 'gate': {
|
|
242
254
|
const [, action, epic, artifact] = o._;
|
|
243
255
|
// `gate ci` takes no positionals — epic/artifact come from --branch (or a sweep of all PRs).
|
package/cli/epic-state.mjs
CHANGED
|
@@ -219,6 +219,20 @@ export function gatePredicate({
|
|
|
219
219
|
};
|
|
220
220
|
}
|
|
221
221
|
|
|
222
|
+
// A SKIPPED step (an optional step the team marked N/A for this epic — e.g. `ui-design` on a
|
|
223
|
+
// backend-only epic) is satisfied without review. Like `inherited`, it is pre-marked `done` in
|
|
224
|
+
// state.json so the gate is normally never invoked on it; this short-circuit makes a direct call
|
|
225
|
+
// safe and keeps the skip a first-class, auditable outcome (the reason lives on the step).
|
|
226
|
+
// GUARD: only honour the flag on a genuinely skippable step (the author step or its `-review` gate).
|
|
227
|
+
// A corrupted/hand-edited `skipped: true` on a non-optional step (e.g. `stories-review`) must NOT
|
|
228
|
+
// bypass approvals — it falls through to the real predicate below and fails for lack of approvals.
|
|
229
|
+
if (step?.skipped && isSkippableStep(step.id)) {
|
|
230
|
+
return {
|
|
231
|
+
approvalsSatisfied: true, threadsResolved: true, merged: true, staleDropped: 0,
|
|
232
|
+
passed: true, missing: [], rule: 'skipped',
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
222
236
|
const forStep = approvals.filter((a) => a.step === step.id && a.status === 'approved');
|
|
223
237
|
// Revoke-on-change: an approval bound to a stale content hash no longer counts.
|
|
224
238
|
const stale = forStep.filter((a) => a.artifactHash && currentHash && a.artifactHash !== currentHash);
|
|
@@ -296,7 +310,12 @@ export function advanceState(state, step) {
|
|
|
296
310
|
state.currentStep = 'discovery-done';
|
|
297
311
|
return state;
|
|
298
312
|
}
|
|
299
|
-
|
|
313
|
+
// Step over any SKIPPED steps (an optional step marked N/A for this epic — e.g. a skipped
|
|
314
|
+
// `ui-design`/`ui-design-review` pair). They are pre-marked `done`, so the next runnable step is the
|
|
315
|
+
// first later step that is not skipped. When the whole tail is skipped, fall through to ready-for-build.
|
|
316
|
+
let j = i + 1;
|
|
317
|
+
while (state.steps[j]?.skipped) j++;
|
|
318
|
+
const next = state.steps[j];
|
|
300
319
|
if (next) {
|
|
301
320
|
next.status = next.type === 'review+approve' ? 'in_review' : 'in_progress';
|
|
302
321
|
state.currentStep = next.id;
|
|
@@ -306,6 +325,117 @@ export function advanceState(state, step) {
|
|
|
306
325
|
return state;
|
|
307
326
|
}
|
|
308
327
|
|
|
328
|
+
// The front steps that may be marked N/A ("skipped") for an epic that does not need them. Only the
|
|
329
|
+
// UI-design step is optional today: an epic with no user-facing surface (backend/API, data, infra)
|
|
330
|
+
// can skip it. A skip carries a recorded reason and stays VISIBLE in the chain (both the author step
|
|
331
|
+
// and its review gate pre-marked `done`, short-circuited by `gatePredicate`) — the auditable,
|
|
332
|
+
// reversible counterpart to omitting `analysis` from the chain entirely.
|
|
333
|
+
export const SKIPPABLE_STEPS = new Set(['ui-design']);
|
|
334
|
+
|
|
335
|
+
// True for a genuinely skippable step id — the author step (`ui-design`) OR its paired review gate
|
|
336
|
+
// (`ui-design-review`). Used to gate the `gatePredicate` skip short-circuit so a corrupted/hand-edited
|
|
337
|
+
// `skipped: true` on a non-optional step cannot bypass its real approvals.
|
|
338
|
+
export function isSkippableStep(id) {
|
|
339
|
+
return SKIPPABLE_STEPS.has(String(id || '').replace(/-review$/, ''));
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Strip the skip-provenance fields off a step — the inverse of the stamp `skipStep` applies.
|
|
343
|
+
function withoutSkip(step) {
|
|
344
|
+
const rest = { ...step };
|
|
345
|
+
delete rest.skipped;
|
|
346
|
+
delete rest.skipReason;
|
|
347
|
+
delete rest.skippedBy;
|
|
348
|
+
delete rest.skippedAt;
|
|
349
|
+
return rest;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// PURE. Mark a skippable step (its author step + paired `<id>-review` gate) N/A for this epic: pre-mark
|
|
353
|
+
// both `done` with a recorded reason, and — if currentStep is sitting on the pair — advance currentStep
|
|
354
|
+
// past them to the next non-skipped step. Idempotent on an already-skipped step. Refuses once the step
|
|
355
|
+
// was authored, once its review gate has opened, or once its downstream `stories` has started — the
|
|
356
|
+
// step is optional only up to authoring it. Throws on a non-skippable id or a malformed (unpaired) chain.
|
|
357
|
+
export function skipStep(state, stepId, { reason, by = null, at = null } = {}) {
|
|
358
|
+
if (!SKIPPABLE_STEPS.has(stepId)) {
|
|
359
|
+
throw err('YAD-STATE-004', `step '${stepId}' is not optional`, `only these steps may be skipped: ${[...SKIPPABLE_STEPS].join(', ')}`);
|
|
360
|
+
}
|
|
361
|
+
const ai = state.steps.findIndex((s) => s.id === stepId);
|
|
362
|
+
if (ai === -1) throw err('YAD-STATE-004', `step '${stepId}' is not in this epic's chain`, 'nothing to skip');
|
|
363
|
+
const author = state.steps[ai];
|
|
364
|
+
// Idempotent BEFORE the reason check: a repeat skip on an already-N/A step is a no-op that keeps the
|
|
365
|
+
// original reason/actor, so it must not fail merely for lacking a fresh --reason.
|
|
366
|
+
if (author.skipped) return state;
|
|
367
|
+
if (!reason || !String(reason).trim()) {
|
|
368
|
+
throw err('YAD-STATE-004', 'a skip needs a reason', 'pass a reason, e.g. "backend-only epic, no UI"');
|
|
369
|
+
}
|
|
370
|
+
// A skippable step must carry its paired `-review` gate — the change keeps BOTH in the chain. A
|
|
371
|
+
// missing gate is a malformed chain; refuse rather than half-stamp only the author step.
|
|
372
|
+
const ri = state.steps.findIndex((s) => s.id === `${stepId}-review`);
|
|
373
|
+
if (ri === -1) throw err('YAD-STATE-004', `malformed chain: ${stepId} has no ${stepId}-review gate`, 'restore state.json from git');
|
|
374
|
+
const review = state.steps[ri];
|
|
375
|
+
if (author.status === 'done') {
|
|
376
|
+
throw err('YAD-STATE-004', `${stepId} is already authored`, 'cannot skip a step whose artifact was already written');
|
|
377
|
+
}
|
|
378
|
+
// Once the review gate has opened (in_review / done), the UI work is effectively committed — skipping
|
|
379
|
+
// then would orphan a live review PR. Refuse; the step is optional only up to authoring it.
|
|
380
|
+
if (review.status !== 'blocked') {
|
|
381
|
+
throw err('YAD-STATE-004', `cannot skip ${stepId} — its review has already opened`, 'skip the UI step before its review begins');
|
|
382
|
+
}
|
|
383
|
+
const stories = state.steps.find((s) => s.id === 'stories');
|
|
384
|
+
if (stories && stories.status !== 'blocked') {
|
|
385
|
+
throw err('YAD-STATE-004', `cannot skip ${stepId} — stories have already started`, 'skip the UI step before stories begin');
|
|
386
|
+
}
|
|
387
|
+
const stamp = { skipped: true, skipReason: String(reason).trim(), skippedBy: by, skippedAt: at, status: 'done' };
|
|
388
|
+
state.steps[ai] = { ...author, ...stamp };
|
|
389
|
+
state.steps[ri] = { ...review, ...stamp };
|
|
390
|
+
// If currentStep was on the pair we just skipped, move it to the next non-skipped step.
|
|
391
|
+
if (state.currentStep === stepId || state.currentStep === `${stepId}-review`) {
|
|
392
|
+
let j = ri + 1;
|
|
393
|
+
while (state.steps[j]?.skipped) j++;
|
|
394
|
+
const next = state.steps[j];
|
|
395
|
+
if (next) {
|
|
396
|
+
if (next.status === 'blocked') next.status = next.type === 'review+approve' ? 'in_review' : 'in_progress';
|
|
397
|
+
state.currentStep = next.id;
|
|
398
|
+
} else {
|
|
399
|
+
state.currentStep = 'ready-for-build';
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return state;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// PURE. Reverse a skip: clear the N/A stamp on the pair and restore the chain. Allowed only while the
|
|
406
|
+
// downstream `stories-review` has not opened (state-only signal for "stories authoring is under way").
|
|
407
|
+
// If every earlier step is done, the restored author step becomes the active step again (and a
|
|
408
|
+
// downstream that the skip auto-opened is pushed back to `blocked` behind it); otherwise it just
|
|
409
|
+
// returns to `blocked`. Throws if the step is not skipped or it is too late.
|
|
410
|
+
export function unskipStep(state, stepId) {
|
|
411
|
+
if (!SKIPPABLE_STEPS.has(stepId)) {
|
|
412
|
+
throw err('YAD-STATE-004', `step '${stepId}' is not optional`, `only these steps may be skipped: ${[...SKIPPABLE_STEPS].join(', ')}`);
|
|
413
|
+
}
|
|
414
|
+
const ai = state.steps.findIndex((s) => s.id === stepId);
|
|
415
|
+
if (ai === -1) throw err('YAD-STATE-004', `step '${stepId}' is not in this epic's chain`, 'nothing to un-skip');
|
|
416
|
+
if (!state.steps[ai].skipped) throw err('YAD-STATE-004', `${stepId} is not skipped`, 'nothing to un-skip');
|
|
417
|
+
const storiesReview = state.steps.find((s) => s.id === 'stories-review');
|
|
418
|
+
if (storiesReview && storiesReview.status !== 'blocked') {
|
|
419
|
+
throw err('YAD-STATE-004', `cannot un-skip ${stepId} — the stories review has already opened`, 'un-skip before the stories review begins');
|
|
420
|
+
}
|
|
421
|
+
const ri = state.steps.findIndex((s) => s.id === `${stepId}-review`);
|
|
422
|
+
const priorAllDone = state.steps.slice(0, ai).every((s) => s.status === 'done');
|
|
423
|
+
state.steps[ai] = { ...withoutSkip(state.steps[ai]), status: priorAllDone ? 'in_progress' : 'blocked' };
|
|
424
|
+
if (ri !== -1) state.steps[ri] = { ...withoutSkip(state.steps[ri]), status: 'blocked' };
|
|
425
|
+
if (priorAllDone) {
|
|
426
|
+
// The restored author step is the active step again. Push the downstream the skip auto-opened
|
|
427
|
+
// back to `blocked` (it must wait behind the now-live step), and re-point currentStep here. Scan
|
|
428
|
+
// past any still-skipped steps (mirrors skipStep's step-over) and reset whether it was opened as
|
|
429
|
+
// an author step (`in_progress`) or a review gate (`in_review`).
|
|
430
|
+
let j = (ri !== -1 ? ri : ai) + 1;
|
|
431
|
+
while (state.steps[j]?.skipped) j++;
|
|
432
|
+
const after = state.steps[j];
|
|
433
|
+
if (after && (after.status === 'in_progress' || after.status === 'in_review')) after.status = 'blocked';
|
|
434
|
+
state.currentStep = stepId;
|
|
435
|
+
}
|
|
436
|
+
return state;
|
|
437
|
+
}
|
|
438
|
+
|
|
309
439
|
// Mark a step in-review (idempotent) and point currentStep at it — EXCEPT once the epic is
|
|
310
440
|
// `ready-for-build`: the parallel `test-cases` track must not pull currentStep back (the build half
|
|
311
441
|
// runs alongside the tester, and only the test-cases review is in flight at that point).
|
package/cli/errors.mjs
CHANGED
|
@@ -19,6 +19,7 @@ export const CODES = {
|
|
|
19
19
|
'YAD-STATE-001': 'a ledger/config JSON file exists but does not parse',
|
|
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
|
+
'YAD-STATE-004': 'an epic step cannot be skipped / un-skipped in its current state',
|
|
22
23
|
'YAD-CFG-001': 'hub.json names an unknown platform (expected github, gitlab, or null)',
|
|
23
24
|
'YAD-CFG-002': 'design.json names an unknown design tool (expected one of config.yaml design.tools, or none)',
|
|
24
25
|
'YAD-CFG-003': 'testing.json names an unknown testing tool (expected one of config.yaml testing.tools, or none)',
|
package/cli/repo-publish.mjs
CHANGED
|
@@ -5,8 +5,13 @@
|
|
|
5
5
|
// analogue of `yad checkpoint` (cli/checkpoint.mjs) and reuses its default-branch commit machinery.
|
|
6
6
|
//
|
|
7
7
|
// Invariants (shared with checkpoint):
|
|
8
|
-
// 1.
|
|
9
|
-
//
|
|
8
|
+
// 1. Commit an EXPLICIT allowlist via `git commit -- <paths>` (--only) — the tracked code-maps, the
|
|
9
|
+
// registry, and (only when its change is the managed pack-ignore block alone) the hub `.gitignore`.
|
|
10
|
+
// NEVER `git add -A` and NEVER a whole-index `git reset`: both would mutate unrelated staged work.
|
|
11
|
+
// The repomix pack.md is gitignored (setup + publish scaffold the ignore via ensurePackIgnored) and
|
|
12
|
+
// never committed as content. A pack committed BEFORE that ignore existed is self-healed here: its
|
|
13
|
+
// on-disk file is held aside across the --only commit so the deletion is recorded (the regenerable
|
|
14
|
+
// cache is restored right after), clearing the stranded working tree — see the commit block below.
|
|
10
15
|
// 2. Commit ONLY on the hub's default branch (unless --allow-branch), so the `[skip ci]` audit
|
|
11
16
|
// commit never enters a PR's base..HEAD range where it would strand required checks.
|
|
12
17
|
import fs from 'node:fs';
|
|
@@ -16,6 +21,7 @@ import { PROJECT_FILES } from './manifest.mjs';
|
|
|
16
21
|
import { loadHub } from './gate.mjs';
|
|
17
22
|
import { resolveCommitterLogin } from './platform.mjs';
|
|
18
23
|
import { hubGit, resolveDefaultBranch, guardDefaultBranch, preflightGuardReadiness } from './hubcommit.mjs';
|
|
24
|
+
import { ensurePackIgnored, PACK_IGNORE_BLOCK } from './setup.mjs';
|
|
19
25
|
import { checkpointAuthor } from './checkpoint.mjs';
|
|
20
26
|
|
|
21
27
|
// Collapse any whitespace/newline runs to a single space — keeps a hostile `git user.name` or a stray
|
|
@@ -25,6 +31,9 @@ const oneLine = (s = '') => String(s).replace(/\s+/g, ' ').trim();
|
|
|
25
31
|
// The tracked code-map for a repo: the registered path, else the conventional location.
|
|
26
32
|
const codeMapOf = (repo) => repo.codeMap || path.posix.join('.sdlc/code-context', repo.name, 'code-map.md');
|
|
27
33
|
|
|
34
|
+
// The repomix pack for a repo: the registered path, else the conventional location.
|
|
35
|
+
const packOf = (repo) => repo.contextPack || path.posix.join('.sdlc/code-context', repo.name, 'pack.md');
|
|
36
|
+
|
|
28
37
|
// PURE — the repo-relative pathspecs to stage: the registry plus each registered repo's code-map that
|
|
29
38
|
// exists on disk. When `name` is given (a scoped `yad repo refresh <name> --push`), only that repo's
|
|
30
39
|
// code-map is staged, so an unrelated repo's uncommitted code-map never rides along in a named refresh's
|
|
@@ -41,6 +50,57 @@ export function codeMapPathspecs(root, registry = { repos: [] }, name = null) {
|
|
|
41
50
|
return out;
|
|
42
51
|
}
|
|
43
52
|
|
|
53
|
+
// PURE — each registered repo's on-disk pack path (scoped by `name` like codeMapPathspecs). These are
|
|
54
|
+
// UNTRACK candidates, not content to stage: the pack is gitignored, but a hub that committed it before
|
|
55
|
+
// the ignore existed would otherwise strand a dirty pack on every refresh. publishCodeContext keeps only
|
|
56
|
+
// the still-tracked ones and records their removal in the audit commit (see the self-heal block there).
|
|
57
|
+
export function packPathspecs(root, registry = { repos: [] }, name = null) {
|
|
58
|
+
const out = [];
|
|
59
|
+
for (const repo of registry.repos || []) {
|
|
60
|
+
if (name && repo.name !== name) continue;
|
|
61
|
+
const rel = packOf(repo);
|
|
62
|
+
if (fs.existsSync(path.join(root, rel))) out.push(rel);
|
|
63
|
+
}
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// True iff committing `.gitignore` would carry ONLY the managed pack-ignore block (comments + glob) and
|
|
68
|
+
// nothing else. Guards invariant 1: a hub whose `.gitignore` also has unrelated uncommitted edits must
|
|
69
|
+
// keep them OUT of the `[skip ci]` audit commit. The publish commit is `git commit -- <paths>` (--only,
|
|
70
|
+
// reads the WORKING TREE), so this compares the working tree — an untracked `.gitignore` must be wholly
|
|
71
|
+
// managed; a tracked one must differ from HEAD by the managed block alone (added, nothing removed).
|
|
72
|
+
// `git` is a hubGit-style accessor; `root` is the hub root. Mirrors checkpoint's stagedStoryIsStatusOnly.
|
|
73
|
+
export function ignoreChangeIsManagedOnly(git, root) {
|
|
74
|
+
const gi = path.join(root, '.gitignore');
|
|
75
|
+
if (!fs.existsSync(gi)) return false;
|
|
76
|
+
const managed = new Set(PACK_IGNORE_BLOCK.map((l) => l.trim()));
|
|
77
|
+
if (!git('ls-files', '--error-unmatch', '--', '.gitignore').ok) {
|
|
78
|
+
// untracked: every non-blank line of the whole file must be a managed line
|
|
79
|
+
let seen = 0;
|
|
80
|
+
for (const l of fs.readFileSync(gi, 'utf8').split('\n')) {
|
|
81
|
+
const body = l.trim();
|
|
82
|
+
if (body === '') continue;
|
|
83
|
+
if (!managed.has(body)) return false;
|
|
84
|
+
seen++;
|
|
85
|
+
}
|
|
86
|
+
return seen > 0;
|
|
87
|
+
}
|
|
88
|
+
// tracked: the working-tree-vs-HEAD diff must ADD only managed lines and remove nothing
|
|
89
|
+
const d = git('diff', '-U0', 'HEAD', '--', '.gitignore');
|
|
90
|
+
if (!d.ok) return false;
|
|
91
|
+
let added = 0;
|
|
92
|
+
for (const ln of d.stdout.split('\n')) {
|
|
93
|
+
if (ln.startsWith('+++') || ln.startsWith('---') || ln.startsWith('@@')) continue; // headers/hunks
|
|
94
|
+
if (ln.startsWith('-')) return false; // we only ever append — any removal ⇒ not ours
|
|
95
|
+
if (ln.startsWith('+')) {
|
|
96
|
+
const body = ln.slice(1).trim();
|
|
97
|
+
if (body !== '' && !managed.has(body)) return false; // a non-managed added line ⇒ a user edit
|
|
98
|
+
added++;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return added > 0;
|
|
102
|
+
}
|
|
103
|
+
|
|
44
104
|
// PURE — turn the staged pathspecs into the subject `label` + the body's file list. A `<name>` is
|
|
45
105
|
// pulled from any `.sdlc/code-context/<name>/code-map.md` path; a commit that only touched the
|
|
46
106
|
// registry is labelled `registry`.
|
|
@@ -49,7 +109,9 @@ export function summarizeCodeContext(files = []) {
|
|
|
49
109
|
const basenames = [];
|
|
50
110
|
for (const f of files) {
|
|
51
111
|
basenames.push(f);
|
|
52
|
-
|
|
112
|
+
// A repo name comes from either its code-map (content) or a pack removal (self-heal), so the
|
|
113
|
+
// subject label stays accurate even when untracking a stranded pack is the only change.
|
|
114
|
+
const m = f.match(/\.sdlc\/code-context\/([^/]+)\/(?:code-map|pack)\.md$/);
|
|
53
115
|
if (m) repos.add(m[1]);
|
|
54
116
|
}
|
|
55
117
|
let label;
|
|
@@ -88,11 +150,26 @@ export async function publishCodeContext(root, { push = false, allowBranch = fal
|
|
|
88
150
|
const defaultBranch = resolveDefaultBranch(git, hub);
|
|
89
151
|
if (!guardDefaultBranch(branch, defaultBranch, { allowBranch, cmd: 'yad repo refresh --push' })) return;
|
|
90
152
|
|
|
153
|
+
// Stage the EXPLICIT allowlist (code-maps + registry), scoped so an unrelated pre-staged file is never
|
|
154
|
+
// swept in and the user's index is left untouched — mirrors `runCheckpoint`. NEVER `git add -A` and
|
|
155
|
+
// NEVER a whole-index `git reset` (both would mutate unrelated staged work). The commit below is
|
|
156
|
+
// `git commit -- <paths>` (--only), which reads the WORKING TREE for the named paths only.
|
|
91
157
|
const pathspecs = codeMapPathspecs(root, registry, name);
|
|
92
|
-
if (
|
|
158
|
+
if (pathspecs.length) {
|
|
159
|
+
const add = git('add', '--', ...pathspecs);
|
|
160
|
+
if (!add.ok) { fail(`git add failed — ${add.stderr.split('\n')[0] || add.code}`); process.exitCode = 1; return; }
|
|
161
|
+
}
|
|
93
162
|
|
|
94
|
-
|
|
95
|
-
|
|
163
|
+
// Make the "pack is gitignored" assumption true (idempotent; packRepo also does this on refresh) so a
|
|
164
|
+
// hub whose pack was tracked before the ignore existed stops stranding a dirty tree. Publish `.gitignore`
|
|
165
|
+
// ONLY when the change is the managed pack-ignore block alone (invariant 1) — a hub whose `.gitignore`
|
|
166
|
+
// also carries unrelated uncommitted edits keeps them OUT of this audit commit; the pack is still ignored
|
|
167
|
+
// on disk and the human commits their own `.gitignore` edits through their own change. When we do carry
|
|
168
|
+
// it, `git add` makes an untracked `.gitignore` known so the --only commit can include it.
|
|
169
|
+
const ignoreChanged = ensurePackIgnored(root);
|
|
170
|
+
const commitIgnore = ignoreChangeIsManagedOnly(git, root);
|
|
171
|
+
if (commitIgnore) git('add', '--', '.gitignore');
|
|
172
|
+
else if (ignoreChanged) hand('.gitignore has unrelated uncommitted edits — commit them yourself so the pack ignore is published (it is already ignored locally)');
|
|
96
173
|
|
|
97
174
|
// Push HEAD to its OWN branch — on the default branch this is the same; with --allow-branch it keeps a
|
|
98
175
|
// WIP branch from being force-published onto the default branch. Shared by the fresh-commit path and
|
|
@@ -105,7 +182,20 @@ export async function publishCodeContext(root, { push = false, allowBranch = fal
|
|
|
105
182
|
return false;
|
|
106
183
|
};
|
|
107
184
|
|
|
108
|
-
|
|
185
|
+
// Self-heal (invariant 1): a pack committed before it was gitignored strands the working tree on every
|
|
186
|
+
// refresh. Any STILL-TRACKED pack must be recorded as removed in this commit — but `git rm --cached`
|
|
187
|
+
// can't be committed under --only (which reads the working tree, where the regenerated pack still
|
|
188
|
+
// exists). So we untrack it below by momentarily removing the on-disk file across the commit (restored
|
|
189
|
+
// right after), letting --only record a clean deletion while the regenerable cache is preserved.
|
|
190
|
+
const trackedPacks = packPathspecs(root, registry, name)
|
|
191
|
+
.filter((p) => git('ls-files', '--error-unmatch', '--', p).ok);
|
|
192
|
+
|
|
193
|
+
// The exact files this audit commit will touch: the staged allowlist (+ managed `.gitignore`) plus any
|
|
194
|
+
// pack removal. Scopes the --only commit and, being all known to git, is a safe commit pathspec.
|
|
195
|
+
const ignoreSpec = commitIgnore ? ['.gitignore'] : [];
|
|
196
|
+
const staged = git('diff', '--cached', '--name-only', '--', ...pathspecs, ...ignoreSpec).stdout.split('\n').filter(Boolean);
|
|
197
|
+
const fileset = [...staged, ...trackedPacks];
|
|
198
|
+
if (!fileset.length) {
|
|
109
199
|
// Nothing new to commit. A non-push run is simply done. But on a push run a PRIOR run may have
|
|
110
200
|
// committed and then FAILED to push (the commit sits ahead of origin) — a plain re-run must land
|
|
111
201
|
// that commit, not silently no-op and exit 0 while it stays stranded. Push any unpushed commit(s).
|
|
@@ -117,27 +207,37 @@ export async function publishCodeContext(root, { push = false, allowBranch = fal
|
|
|
117
207
|
pushHead();
|
|
118
208
|
return;
|
|
119
209
|
}
|
|
120
|
-
// The exact files staged from the allowlist — scopes the commit to ONLY the allowlist, so any
|
|
121
|
-
// unrelated pre-staged file never rides along.
|
|
122
|
-
const staged = git('diff', '--cached', '--name-only', '--', ...pathspecs).stdout.split('\n').filter(Boolean);
|
|
123
210
|
|
|
124
211
|
// Only relevant when we are about to push a commit straight to the default branch: warn (never block)
|
|
125
212
|
// if signing/allowlisting would make the yad-update-guard reject it. Gated on `push` and deferred to
|
|
126
213
|
// here so it isn't noise on a guard-refused branch or a nothing-to-commit run.
|
|
127
214
|
if (push) preflightGuardReadiness(root);
|
|
128
215
|
|
|
129
|
-
const { label, basenames } = summarizeCodeContext(
|
|
216
|
+
const { label, basenames } = summarizeCodeContext(fileset);
|
|
130
217
|
const author = checkpointAuthor(resolveCommitterLogin(root, hub?.roster || []), git('config', 'user.name').stdout);
|
|
131
218
|
const message = buildCodeMapMessage({ label, author, basenames });
|
|
132
219
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
220
|
+
// Untrack the packs by holding their bytes and removing the files across the --only commit, then
|
|
221
|
+
// restoring them (now gitignored ⇒ a clean tree). try/finally so the regenerable cache is always put
|
|
222
|
+
// back, even on a commit failure.
|
|
223
|
+
const held = [];
|
|
224
|
+
try {
|
|
225
|
+
for (const p of trackedPacks) {
|
|
226
|
+
const abs = path.join(root, p);
|
|
227
|
+
held.push({ abs, buf: fs.readFileSync(abs) });
|
|
228
|
+
fs.rmSync(abs);
|
|
229
|
+
}
|
|
230
|
+
const cm = git('commit', '-m', message, '--', ...fileset); // --only: adds from the tree, packs as deletions
|
|
231
|
+
if (!cm.ok) {
|
|
232
|
+
git('reset', '-q', '--', ...pathspecs, ...ignoreSpec); // unstage only OUR allowlist for a clean retry
|
|
233
|
+
fail(`git commit failed — ${cm.stderr.split('\n')[0] || cm.code}`);
|
|
234
|
+
process.exitCode = 1;
|
|
235
|
+
return { message };
|
|
236
|
+
}
|
|
237
|
+
} finally {
|
|
238
|
+
for (const h of held) if (!fs.existsSync(h.abs)) fs.writeFileSync(h.abs, h.buf);
|
|
139
239
|
}
|
|
140
|
-
ok(`published ${
|
|
240
|
+
ok(`published ${fileset.length} file(s): ${c.dim(label)}`);
|
|
141
241
|
|
|
142
242
|
if (!push) return { message };
|
|
143
243
|
pushHead();
|
package/cli/repo.mjs
CHANGED
|
@@ -73,8 +73,9 @@ export async function runRepo(root, { action = 'list', name, today, push = false
|
|
|
73
73
|
if (push) {
|
|
74
74
|
// Publish whatever tracked code-context now differs (the AI-regenerated code-maps + the stamped
|
|
75
75
|
// registry) straight to the hub's default branch as one audit-trail commit. The pack itself is
|
|
76
|
-
// gitignored
|
|
77
|
-
//
|
|
76
|
+
// gitignored (packRepo scaffolds the ignore; publish self-heals a pre-ignore tracked pack); the
|
|
77
|
+
// code-map is regenerated by the AI (yad-connect-repos) — run that first if a repo's map is stale,
|
|
78
|
+
// then `yad repo refresh --push` lands it.
|
|
78
79
|
await publishCodeContext(root, { push: true, allowBranch, name });
|
|
79
80
|
} else {
|
|
80
81
|
hand('regenerate the code-map in Claude Code (yad-connect-repos) — the pack is cached, the map is the AI step');
|
package/cli/setup.mjs
CHANGED
|
@@ -740,12 +740,40 @@ export async function runSetup(root, opts = {}) {
|
|
|
740
740
|
log(c.dim('Re-run anytime: `yad check` (report) / `yad check --fix` (reconcile).'));
|
|
741
741
|
}
|
|
742
742
|
|
|
743
|
+
// The repomix pack is a large, regenerable artifact — the hub tracks the AI-authored code-map, not the
|
|
744
|
+
// pack. `yad repo refresh --push` relies on the pack being gitignored (repo-publish.mjs never stages it);
|
|
745
|
+
// this makes that assumption true in every hub, so a regenerated pack never strands as a dirty tree.
|
|
746
|
+
export const PACK_IGNORE_GLOB = '.sdlc/code-context/*/pack.md';
|
|
747
|
+
|
|
748
|
+
// The exact lines ensurePackIgnored appends — a comment pair + the glob. Kept as data (not inline
|
|
749
|
+
// strings) so the publish gate can verify a staged `.gitignore` change is ONLY this managed block and
|
|
750
|
+
// never sweep an unrelated user edit into the audit commit (repo-publish.mjs, invariant 1).
|
|
751
|
+
export const PACK_IGNORE_BLOCK = [
|
|
752
|
+
'# Repomix code-context packs are large, regenerable artifacts (yad repo refresh) — the',
|
|
753
|
+
'# tracked code-map.md is the reviewed AI output; the pack itself is never committed.',
|
|
754
|
+
PACK_IGNORE_GLOB,
|
|
755
|
+
];
|
|
756
|
+
|
|
757
|
+
// Idempotently ensure the hub `.gitignore` ignores the repomix pack. No-op (returns false) if the line
|
|
758
|
+
// is already present (as its own entry); otherwise appends the managed block to a fresh or existing file
|
|
759
|
+
// and returns true.
|
|
760
|
+
export function ensurePackIgnored(root) {
|
|
761
|
+
const gi = path.join(root, '.gitignore');
|
|
762
|
+
const lines = exists(gi) ? fs.readFileSync(gi, 'utf8').split('\n') : [];
|
|
763
|
+
if (lines.some((l) => l.trim() === PACK_IGNORE_GLOB)) return false;
|
|
764
|
+
const body = lines.join('\n').replace(/\n*$/, '');
|
|
765
|
+
const prefix = body ? `${body}\n\n` : '';
|
|
766
|
+
fs.writeFileSync(gi, `${prefix}${PACK_IGNORE_BLOCK.join('\n')}\n`);
|
|
767
|
+
return true;
|
|
768
|
+
}
|
|
769
|
+
|
|
743
770
|
// Deterministic repomix pack (code-map generation itself is an AI step, handed off).
|
|
744
771
|
export function packRepo(root, repo) {
|
|
745
772
|
const repoRoot = path.resolve(root, repo.path);
|
|
746
773
|
const out = path.join(root, repo.contextPack);
|
|
747
774
|
if (!has('npx')) { warn(`${repo.name}: npx missing — skipped repomix pack`); return false; }
|
|
748
775
|
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
776
|
+
ensurePackIgnored(root); // keep the pack out of git before it is (re)written — see repo-publish.mjs invariant 1
|
|
749
777
|
info(`${repo.name}: packing with repomix …`);
|
|
750
778
|
const r = run('npx', ['repomix@latest', '--compress', '--include-logs', '--style', 'markdown', '-o', out], { cwd: repoRoot });
|
|
751
779
|
if (r.ok) { ok(`${repo.name}: cached ${repo.contextPack}`); hand(`${repo.name}: generate the code-map in Claude Code (yad-connect-repos)`); return true; }
|
package/cli/skip.mjs
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// `yad skip <epic> <step> --reason "<why>"` (and `--undo`) — mark an OPTIONAL front step N/A for one
|
|
2
|
+
// epic. Today only `ui-design` is skippable: an epic with no user-facing surface (backend/API, data,
|
|
3
|
+
// infra) does not need a UI-design artifact + review gate. The skip stays VISIBLE and auditable — the
|
|
4
|
+
// step is pre-marked `done` with a recorded reason (and actor/date), short-circuited at the gate — and
|
|
5
|
+
// is reversible with `--undo` until the stories review opens. All state logic is the pure
|
|
6
|
+
// `skipStep`/`unskipStep` in epic-state.mjs; this is the thin file-load/save + attribution wrapper.
|
|
7
|
+
import { ok, info, hand, fail, run, writeJSON } from './lib.mjs';
|
|
8
|
+
import { epicRoot, loadLedger, skipStep, unskipStep } from './epic-state.mjs';
|
|
9
|
+
import { loadHub } from './gate.mjs';
|
|
10
|
+
import { resolveCommitterLogin } from './platform.mjs';
|
|
11
|
+
|
|
12
|
+
// Best-effort auditable actor for `skippedBy`: the roster login for the local git identity, else the
|
|
13
|
+
// raw git user.name, else null. A malformed/absent hub degrades to the raw name — attribution is a
|
|
14
|
+
// nicety on the audit trail, never a gate, so it must not block the skip.
|
|
15
|
+
function skipActor(root) {
|
|
16
|
+
let roster = [];
|
|
17
|
+
try { roster = loadHub(root)?.hub?.roster || []; } catch { /* no hub / malformed — attribute by raw git name */ }
|
|
18
|
+
return resolveCommitterLogin(root, roster)
|
|
19
|
+
|| (run('git', ['config', 'user.name'], { cwd: root }).stdout || '').trim()
|
|
20
|
+
|| null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function runSkip(root, { epic, step, reason, undo = false, today } = {}) {
|
|
24
|
+
const epicDir = epicRoot(root, epic);
|
|
25
|
+
const ledger = loadLedger(epicDir);
|
|
26
|
+
if (!ledger.state) { fail(`no epic state at ${epicDir} — seed the epic first with yad-epic`); process.exitCode = 1; return; }
|
|
27
|
+
if (!step) { fail('usage: yad skip <epic> <step> --reason "<why>" (or: yad skip <epic> <step> --undo)'); process.exitCode = 1; return; }
|
|
28
|
+
|
|
29
|
+
// Guard violations throw a YadError (YAD-STATE-004) with a hint — the top-level catch in bin/yad.mjs
|
|
30
|
+
// renders those. Here we only handle the happy path + the two plain-arg checks above.
|
|
31
|
+
if (undo) {
|
|
32
|
+
unskipStep(ledger.state, step);
|
|
33
|
+
writeJSON(ledger.files.state, ledger.state);
|
|
34
|
+
ok(`${step} un-skipped — back in the chain`);
|
|
35
|
+
hand(`currentStep is now ${ledger.state.currentStep}`);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const by = skipActor(root);
|
|
40
|
+
skipStep(ledger.state, step, { reason, by, at: today });
|
|
41
|
+
writeJSON(ledger.files.state, ledger.state);
|
|
42
|
+
ok(`${step} marked N/A${by ? ` by ${by}` : ''}${today ? ` on ${today}` : ''}`);
|
|
43
|
+
info(`reason: ${String(reason).trim()}`);
|
|
44
|
+
hand(`its review gate is short-circuited; currentStep is now ${ledger.state.currentStep} (reverse with \`yad skip ${epic} ${step} --undo\`)`);
|
|
45
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yadflow",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.10.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",
|
package/skills/sdlc/config.yaml
CHANGED
|
@@ -33,7 +33,8 @@ output_folder: "{project-root}/_bmad-output"
|
|
|
33
33
|
defaults:
|
|
34
34
|
assistance: review # none | review | heavy
|
|
35
35
|
automation: human_approve # human_approve | machine_advance
|
|
36
|
-
# Front steps (discovery [optional front-zero], analysis [optional], epic, architecture,
|
|
36
|
+
# Front steps (discovery [optional front-zero], analysis [optional], epic, architecture,
|
|
37
|
+
# ui-design [optional — skippable N/A for UI-less epics via `yad skip <epic> ui-design --reason "<why>"`],
|
|
37
38
|
# stories, test-cases) are locked to human_approve and may NOT be set to machine_advance in this
|
|
38
39
|
# version (build plan §1, §8.7).
|
|
39
40
|
front_steps_locked: true
|
|
@@ -215,7 +215,11 @@ The gates run identically under either CI; the config just invokes the scripts w
|
|
|
215
215
|
|
|
216
216
|
- **GitHub Actions** — `templates/github/yad-checks.yml` → `.github/workflows/yad-checks.yml`. The
|
|
217
217
|
jobs run on `pull_request` with `fetch-depth: 0`, passing `origin/${{ github.base_ref }}` as base
|
|
218
|
-
(verified-commits also gets a read-only `GH_TOKEN` for the Verified-badge lookup). The
|
|
218
|
+
(verified-commits also gets a read-only `GH_TOKEN` for the Verified-badge lookup). The trigger sets
|
|
219
|
+
`types: [opened, synchronize, reopened, edited]` — the extra `edited` so a title/body correction
|
|
220
|
+
re-runs the pattern gates without a close/reopen (a plain re-run replays the frozen original payload).
|
|
221
|
+
The commit-range jobs carry `if: github.event.action != 'edited'` so a bare body/title edit only
|
|
222
|
+
re-runs `pr-title`/`pr-template`, not the whole suite. The pattern jobs
|
|
219
223
|
read the title/body from the event payload: `pr-title` takes `${{ github.event.pull_request.title }}`
|
|
220
224
|
and `pr-template` writes `${{ github.event.pull_request.body }}` to a temp file. All `--profile code`.
|
|
221
225
|
The Phase 6 thread gates (`lineage-check`, `epic-open`, `reconcile-debt`) run as their own jobs with
|
|
@@ -6,11 +6,16 @@
|
|
|
6
6
|
name: yad-checks
|
|
7
7
|
on:
|
|
8
8
|
pull_request:
|
|
9
|
+
# `edited` (beyond the opened/synchronize/reopened defaults) so a PR title/body correction
|
|
10
|
+
# re-runs the pattern gates without a close/reopen — e.g. fixing a body the pr-template gate held.
|
|
11
|
+
# The commit-range jobs below skip a bare `edited` (only pr-title/pr-template need to re-check).
|
|
12
|
+
types: [opened, synchronize, reopened, edited]
|
|
9
13
|
branches: ["**"]
|
|
10
14
|
|
|
11
15
|
jobs:
|
|
12
16
|
spec-link:
|
|
13
17
|
runs-on: ubuntu-latest
|
|
18
|
+
if: github.event.action != 'edited'
|
|
14
19
|
steps:
|
|
15
20
|
- uses: actions/checkout@v4
|
|
16
21
|
with: { fetch-depth: 0 }
|
|
@@ -18,6 +23,7 @@ jobs:
|
|
|
18
23
|
|
|
19
24
|
contract-check:
|
|
20
25
|
runs-on: ubuntu-latest
|
|
26
|
+
if: github.event.action != 'edited'
|
|
21
27
|
steps:
|
|
22
28
|
- uses: actions/checkout@v4
|
|
23
29
|
with: { fetch-depth: 0 }
|
|
@@ -25,6 +31,7 @@ jobs:
|
|
|
25
31
|
|
|
26
32
|
build-test-lint:
|
|
27
33
|
runs-on: ubuntu-latest
|
|
34
|
+
if: github.event.action != 'edited'
|
|
28
35
|
env:
|
|
29
36
|
YAD_TEST_MAX_WORKERS: "2" # cap jest/vitest test workers in CI; ignored by other runners
|
|
30
37
|
steps:
|
|
@@ -41,6 +48,7 @@ jobs:
|
|
|
41
48
|
# story->epic resolution and degrade to a note when the product repo is not reachable from CI.
|
|
42
49
|
lineage-check:
|
|
43
50
|
runs-on: ubuntu-latest
|
|
51
|
+
if: github.event.action != 'edited'
|
|
44
52
|
steps:
|
|
45
53
|
- uses: actions/checkout@v4
|
|
46
54
|
with: { fetch-depth: 0 }
|
|
@@ -48,6 +56,7 @@ jobs:
|
|
|
48
56
|
|
|
49
57
|
epic-open:
|
|
50
58
|
runs-on: ubuntu-latest
|
|
59
|
+
if: github.event.action != 'edited'
|
|
51
60
|
steps:
|
|
52
61
|
- uses: actions/checkout@v4
|
|
53
62
|
with: { fetch-depth: 0 }
|
|
@@ -55,14 +64,17 @@ jobs:
|
|
|
55
64
|
|
|
56
65
|
reconcile-debt:
|
|
57
66
|
runs-on: ubuntu-latest
|
|
67
|
+
if: github.event.action != 'edited'
|
|
58
68
|
steps:
|
|
59
69
|
- uses: actions/checkout@v4
|
|
60
70
|
with: { fetch-depth: 0 }
|
|
61
71
|
- run: bash checks/reconcile-debt-check.sh "origin/${{ github.base_ref }}"
|
|
62
72
|
|
|
63
73
|
# Pattern gates: commit subject + PR title + PR body all follow the convention (profile: code).
|
|
74
|
+
# commit-message reads the commit range, not the title/body — skip it on a bare `edited` event.
|
|
64
75
|
commit-message:
|
|
65
76
|
runs-on: ubuntu-latest
|
|
77
|
+
if: github.event.action != 'edited'
|
|
66
78
|
steps:
|
|
67
79
|
- uses: actions/checkout@v4
|
|
68
80
|
with: { fetch-depth: 0 }
|
|
@@ -88,8 +100,10 @@ jobs:
|
|
|
88
100
|
bash checks/pr-template.sh --profile code "$body"
|
|
89
101
|
|
|
90
102
|
# No unverified commits from unverified users: platform-Verified signature + allowlisted author.
|
|
103
|
+
# Reads the commit range, not the title/body — skip it on a bare `edited` event.
|
|
91
104
|
verified-commits:
|
|
92
105
|
runs-on: ubuntu-latest
|
|
106
|
+
if: github.event.action != 'edited'
|
|
93
107
|
permissions:
|
|
94
108
|
contents: read
|
|
95
109
|
env:
|
|
@@ -82,7 +82,13 @@ side-effect:** when a repo is stale (HEAD ≠ `syncedHead`), the phase **flags i
|
|
|
82
82
|
silently re-packing the whole repo. A phase never refreshes the registry on its own; the human runs
|
|
83
83
|
`yad repo refresh` (or `yad check --fix`). After the AI regenerates the code-map, `yad repo refresh
|
|
84
84
|
--push` publishes the refreshed code-maps + registry to the hub's default branch as a `chore(hub): sync
|
|
85
|
-
code-context … [skip ci]` audit commit (never
|
|
85
|
+
code-context … [skip ci]` audit commit (never the pack's content; `--allow-branch` overrides the branch
|
|
86
|
+
guard). The `pack.md` is gitignored — `yad repo refresh`/`yad setup` scaffold
|
|
87
|
+
`.sdlc/code-context/*/pack.md` into the hub `.gitignore` (so a regenerated pack never dirties the tree),
|
|
88
|
+
and a hub that tracked the pack *before* that ignore existed is self-healed: `--push` untracks it and
|
|
89
|
+
lands the removal + the managed `.gitignore` line in the same audit commit. The commit is a scoped
|
|
90
|
+
`git commit -- <paths>` — it never sweeps unrelated staged work, and an unrelated hand-edit to
|
|
91
|
+
`.gitignore` is left for the human to commit rather than riding the `[skip ci]` audit commit.
|
|
86
92
|
|
|
87
93
|
## Why this stays DRY with backfill
|
|
88
94
|
|
package/skills/yad-epic/SKILL.md
CHANGED
|
@@ -156,6 +156,12 @@ Notes:
|
|
|
156
156
|
and an empty comments ledger `{project-root}/epics/EP-<slug>/.sdlc/comments.json`, each containing
|
|
157
157
|
`[]`, and the `reviews/` directory. (`comments.json` is the machine-readable counterpart to the
|
|
158
158
|
`reviews/*--comments.md` markdown — `yad-review-gate` appends to it on every `comment`.)
|
|
159
|
+
- **No UI?** Seed the chain **as-is** (always include the two `ui-design` steps). If this epic has no
|
|
160
|
+
user-facing surface (a backend/API service, data pipeline, infra), the `ui-design` step is optional
|
|
161
|
+
and can be marked N/A now with `yad skip EP-<slug> ui-design --reason "<why>"` — it stays visible,
|
|
162
|
+
short-circuits its gate, and advances straight to `stories` when architecture is approved. It is
|
|
163
|
+
reversible with `--undo` until the stories review opens. Don't hand-edit the seed to drop the steps;
|
|
164
|
+
the skip is the single, auditable mechanism (see `references/state-schema.md` → "ui-design is optional").
|
|
159
165
|
|
|
160
166
|
### Step 5b — Advance the authoring step — analysis-ran only
|
|
161
167
|
*(Only when analysis ran — `state.json` already exists from `yad-analysis`.)*
|
|
@@ -37,6 +37,32 @@ existing `state.json` and does **not** re-seed.
|
|
|
37
37
|
`analysis-review`, `ui-design-review`, and `test-cases-review` carry no `risk_tags` (base rule:
|
|
38
38
|
owner + 1 reviewer).
|
|
39
39
|
|
|
40
|
+
### `ui-design` is optional (skippable)
|
|
41
|
+
|
|
42
|
+
The `ui-design` step (and its `ui-design-review` gate) is **optional** for an epic with no
|
|
43
|
+
user-facing surface — a backend/API service, a data pipeline, infra work. Unlike `analysis` (which is
|
|
44
|
+
optional by being **omitted** from the chain at seed time), `ui-design` is **always seeded** and then
|
|
45
|
+
**marked N/A in place** so the skip stays visible and auditable. The single mechanism is
|
|
46
|
+
`yad skip EP-<slug> ui-design --reason "<why>"` (reverse with `--undo`), usable at epic-authoring time
|
|
47
|
+
or any point **up to authoring the `ui-design` step** — the skip is refused once its review gate has
|
|
48
|
+
opened (the UI work is committed by then) or once `stories` has started. `--undo` is allowed until the
|
|
49
|
+
`stories` review opens.
|
|
50
|
+
|
|
51
|
+
A skipped step gets four extra fields and is pre-marked `done`:
|
|
52
|
+
|
|
53
|
+
| Field | Values | Meaning |
|
|
54
|
+
|-------|--------|---------|
|
|
55
|
+
| `skipped` | `true` | This step is N/A for this epic; pre-marked `done`, short-circuited by `gatePredicate` (`rule: "skipped"`) so no review is required. |
|
|
56
|
+
| `skipReason` | string | Why it was skipped (e.g. "backend-only service, no UI"). |
|
|
57
|
+
| `skippedBy` | login/name or `null` | Who marked it N/A (best-effort, from the roster/git identity). |
|
|
58
|
+
| `skippedAt` | `YYYY-MM-DD` or `null` | When it was marked N/A. |
|
|
59
|
+
|
|
60
|
+
Both the `ui-design` **and** `ui-design-review` entries carry these fields. `advanceState` steps over
|
|
61
|
+
any `skipped` step, so approving `architecture-review` on a UI-less epic lands directly on `stories`;
|
|
62
|
+
`preconditionsMet` treats the pre-`done` steps as satisfied. `unskipStep` (via `yad skip … --undo`)
|
|
63
|
+
strips the fields and restores the chain, refused once `stories-review` has opened. Only `ui-design` is
|
|
64
|
+
skippable today (engine `SKIPPABLE_STEPS`).
|
|
65
|
+
|
|
40
66
|
### `test-cases` is a parallel, non-blocking track
|
|
41
67
|
|
|
42
68
|
`test-cases` (and its `test-cases-review` gate) sit in `steps[]` after `stories-review`, but they are a
|
|
@@ -46,6 +46,13 @@ Print, in this order:
|
|
|
46
46
|
passes and runs alongside the build half, so when `currentStep` is `ready-for-build` the `test-cases`
|
|
47
47
|
step may still be `in_progress`/`in_review` — show its status, and note "parallel" so it is clear it
|
|
48
48
|
does not gate the build. Always render exactly the steps present in `steps[]`.
|
|
49
|
+
- **Skipped (N/A) steps:** the optional `ui-design` step may be marked N/A for an epic with no
|
|
50
|
+
user-facing surface. **Both** the `ui-design` author step **and** its `ui-design-review` gate stay
|
|
51
|
+
in `steps[]`, each carrying `skipped: true`, `status: "done"`, and a `skipReason`. Render **each**
|
|
52
|
+
as `<id> — SKIPPED (N/A: <skipReason>)` (with `skippedBy`/`skippedAt` if present) instead of the
|
|
53
|
+
plain status, so the deliberate skip and its reason are visible and the gate does not appear to
|
|
54
|
+
vanish. The chain then reads `… architecture-review → ui-design (N/A) → ui-design-review (N/A) →
|
|
55
|
+
stories → …`; the review gate never needs approvals.
|
|
49
56
|
3. **Active gate** — for the `currentStep` (if it is a `review+approve` step), compute and show:
|
|
50
57
|
- the reviewer rule in force — **base** (`owner + 1 reviewer`), **escalated** (list the required
|
|
51
58
|
domains), or **per-repo** for `stories-review` (list each repo needing sign-off),
|
|
@@ -45,6 +45,11 @@ Read `epic.md` (scope, acceptance signals, `repos`), `architecture.md` (componen
|
|
|
45
45
|
`contract.md` (the shared surface stories must honour), and `ui-design.md` (screens/flows). Stories
|
|
46
46
|
must collectively satisfy the epic's acceptance signals and stay within the contract surface.
|
|
47
47
|
|
|
48
|
+
- **UI-optional-safe:** `ui-design.md` may be absent — the `ui-design` step is optional and can be
|
|
49
|
+
marked N/A for an epic with no user-facing surface (state.json shows it `skipped`). When there is no
|
|
50
|
+
UI design, proceed without UI screens; the epic's acceptance signals + contract remain the source of
|
|
51
|
+
truth for the stories.
|
|
52
|
+
|
|
48
53
|
### Step 2b — Load existing-code context (make the brain code-aware)
|
|
49
54
|
Read the registry `{project-root}/.sdlc/repos.json` (`config.yaml` `code_context`). For **each repo in
|
|
50
55
|
`epic.repos`**, load the code-map `{project-root}/.sdlc/code-context/<repo>/code-map.md` so each story's
|
|
@@ -60,6 +60,10 @@ Read `epic.md` (user-level acceptance signals, scope), `architecture.md` (flows,
|
|
|
60
60
|
**all** approved `stories/EP-<slug>-S0N.md`. Each story's **acceptance criteria are the source of truth**
|
|
61
61
|
for the cases — the test cases must collectively verify every story's criteria.
|
|
62
62
|
|
|
63
|
+
- **UI-optional-safe:** `ui-design.md` may be absent — the `ui-design` step is optional and can be
|
|
64
|
+
marked N/A for a UI-less epic (state.json shows it `skipped`). When there is no UI design, there are
|
|
65
|
+
simply no screens to exercise; the stories' acceptance criteria remain the source of truth.
|
|
66
|
+
|
|
63
67
|
### Step 2b — Load existing-code context (make the brain code-aware)
|
|
64
68
|
Read the registry `{project-root}/.sdlc/repos.json` (`config.yaml` `code_context`). For **each repo in
|
|
65
69
|
`epic.repos`**, load the code-map `{project-root}/.sdlc/code-context/<repo>/code-map.md` so the
|
package/skills/yad-ui/SKILL.md
CHANGED
|
@@ -42,6 +42,19 @@ it passes.
|
|
|
42
42
|
This passes when `ui-design` is the next runnable step per the state sequence — every prior step
|
|
43
43
|
(through the architecture review) is `done` and `ui-design` is not already `done`.
|
|
44
44
|
|
|
45
|
+
**This step is OPTIONAL.** If the epic has no user-facing surface (a backend/API service, a data
|
|
46
|
+
pipeline, infra work), it does not need a UI design. Two signals to watch for:
|
|
47
|
+
|
|
48
|
+
- **Already skipped:** if `ui-design.status` is `done` with `skipped: true` in `state.json` (the
|
|
49
|
+
`--check` above will report `ui-design is already done`), the step was marked N/A — **STOP**, there
|
|
50
|
+
is nothing to author; point the user at `yad next EP-<slug>` (the next step is `stories`).
|
|
51
|
+
- **Should be skipped:** if you reach this step and the epic clearly produces no screens, do **not**
|
|
52
|
+
invent a hollow UI artifact. Offer to mark it N/A instead:
|
|
53
|
+
`yad skip EP-<slug> ui-design --reason "<why, e.g. backend-only service>"`. That pre-marks both the
|
|
54
|
+
`ui-design` and `ui-design-review` steps `done` (recorded reason + actor), short-circuits the review
|
|
55
|
+
gate, and advances to `stories`. It is reversible with `yad skip EP-<slug> ui-design --undo` until the
|
|
56
|
+
stories review opens. See `../yad-epic/references/state-schema.md` → "ui-design is optional".
|
|
57
|
+
|
|
45
58
|
### Step 1b — Open the authoring branch
|
|
46
59
|
Open the UI authoring branch `ui-design/EP-<slug>` per the shared procedure
|
|
47
60
|
(`../yad-epic/references/state-schema.md` → "Authoring branches"): git-safe (skip with a note
|