yadflow 2.17.0 → 2.18.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/bin/yad.mjs CHANGED
@@ -15,6 +15,7 @@ import { runDocs } from '../cli/docs.mjs';
15
15
  import { runDoctor } from '../cli/doctor.mjs';
16
16
  import { runNext } from '../cli/next.mjs';
17
17
  import { syncStatuses } from '../cli/artifact-status.mjs';
18
+ import { runThread, runReconcile } from '../cli/thread.mjs';
18
19
 
19
20
  const HELP = `${c.bold('yad')} — setup, review-gate & build helpers for the SDLC Workflow module ${c.dim('v' + VERSION)}
20
21
 
@@ -63,6 +64,12 @@ ${c.bold('Build helpers')}
63
64
  yad repo list Show connected repos (fresh / stale)
64
65
  yad repo refresh [name] Re-pack a stale repo (a human decision)
65
66
 
67
+ ${c.bold('Feature threads (post-lock change management)')}
68
+ yad thread List every feature thread (genesis → changes → defects)
69
+ yad thread <epic> [--json] Show one thread: its epics, the resolved current truth, open debt
70
+ yad reconcile [check|refresh|wire] Flag orphan drift + open hotfix debt across threads (advisory,
71
+ never a gate — the gates block at merge)
72
+
66
73
  ${c.bold('Interactive docs (generated sites)')}
67
74
  yad docs list Show the docs target + per-site freshness
68
75
  yad docs build [--epic <id>|--overview] npm-build a generated doc site
@@ -216,6 +223,23 @@ async function main() {
216
223
  await runDocs(o.dir, { action: action || 'list', epic: o.epic, overview: o.overview, sync, today });
217
224
  break;
218
225
  }
226
+ case 'thread': {
227
+ const [, epic] = o._;
228
+ if (epic && !isValidEpicId(epic)) { log(c.red(`invalid epic id: ${epic} (expected EP-<slug>, [a-z0-9-] only)`)); process.exitCode = 1; break; }
229
+ await runThread(o.dir, { epic, json: o.json });
230
+ break;
231
+ }
232
+ case 'reconcile': {
233
+ const [, action] = o._;
234
+ const thread = o.epic || o.thread || null;
235
+ if (thread && !isValidEpicId(thread)) { log(c.red(`invalid epic id: ${thread} (expected EP-<slug>, [a-z0-9-] only)`)); process.exitCode = 1; break; }
236
+ const act = action || (o.wire ? 'wire' : o.refresh ? 'refresh' : 'check');
237
+ if (!['check', 'refresh', 'wire'].includes(act)) {
238
+ log(c.red(`unknown reconcile action: ${act} (check|refresh|wire)`)); process.exitCode = 1; break;
239
+ }
240
+ await runReconcile(o.dir, { action: act, thread });
241
+ break;
242
+ }
219
243
  default:
220
244
  log(c.red(`unknown command: ${cmd}`));
221
245
  log(HELP);
package/cli/docs.mjs CHANGED
@@ -121,6 +121,7 @@ export function docsStale(manifest, { artifactHash, repoHeads = {}, templateVers
121
121
  const BUILD_PUBLIC = [
122
122
  'mkdir -p public',
123
123
  'if [ -d docs/sdlc-site ]; then (cd docs/sdlc-site && npm ci && npm run build) && mkdir -p public/app && cp -r docs/sdlc-site/dist/. public/app/ && cp docs/sdlc-site/public/report.html public/index.html && cp docs/sdlc-site/public/report.html public/report.html; fi',
124
+ 'if [ -d docs/tutorial-site ]; then (cd docs/tutorial-site && npm ci && npm run build) && mkdir -p public/tutorial && cp -r docs/tutorial-site/dist/. public/tutorial/; fi',
124
125
  'for d in epics/*/docs-site; do [ -d "$d" ] || continue; id=$(basename "$(dirname "$d")"); (cd "$d" && npm ci && npm run build) && mkdir -p "public/epics/$id" && cp -r "$d/dist/." "public/epics/$id/"; done',
125
126
  ];
126
127
  export function pagesWorkflow(platform) {
@@ -160,19 +161,19 @@ jobs:
160
161
  name: github-pages
161
162
  url: \${{ steps.deployment.outputs.page_url }}
162
163
  steps:
163
- - uses: actions/checkout@v4
164
- - uses: actions/setup-node@v4
164
+ - uses: actions/checkout@v7
165
+ - uses: actions/setup-node@v6
165
166
  with:
166
167
  node-version: 20
167
168
  - name: Build the overview + per-epic sites into ./public
168
169
  run: |
169
170
  ${BUILD_PUBLIC.map((l) => ` ${l}`).join('\n')}
170
- - uses: actions/configure-pages@v5
171
- - uses: actions/upload-pages-artifact@v3
171
+ - uses: actions/configure-pages@v6
172
+ - uses: actions/upload-pages-artifact@v5
172
173
  with:
173
174
  path: public
174
175
  - id: deployment
175
- uses: actions/deploy-pages@v4
176
+ uses: actions/deploy-pages@v5
176
177
  `;
177
178
  }
178
179
  export function pagesWorkflowPath(platform) {
package/cli/doctor.mjs CHANGED
@@ -6,7 +6,8 @@ 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 } from './epic-state.mjs';
9
+ import { loadLedger, epicRoot, isValidEpicId, epicLineage, resolveThread } from './epic-state.mjs';
10
+ import { loadDebt } from './thread.mjs';
10
11
  import { gitHead } from './setup.mjs';
11
12
  import { cliFor, validateLogin, hostFromGitUrl } from './platform.mjs';
12
13
 
@@ -264,11 +265,40 @@ export function epicChecks(checks, root) {
264
265
  }
265
266
  }
266
267
 
268
+ // Phase 6 — feature-thread integrity. A change-epic must thread to a real parent and its denormalized
269
+ // `thread` cache must equal the computed root; an open hotfix reconcile-debt is a warn (the next change
270
+ // on that thread is blocked at the gate until it is paid). Pure reporting, like the other sections.
271
+ export function threadChecks(checks, root) {
272
+ const epicsDir = path.join(root, 'epics');
273
+ if (!exists(epicsDir)) return;
274
+ for (const e of fs.readdirSync(epicsDir).sort()) {
275
+ if (!fs.statSync(path.join(epicsDir, e)).isDirectory() || !isValidEpicId(e)) continue;
276
+ if (!exists(path.join(epicsDir, e, 'epic.md'))) continue;
277
+ const lin = epicLineage(root, e);
278
+ if (lin.kind === 'feature' && !lin.parent) continue; // genesis with no lineage — nothing to check
279
+ const { broken } = resolveThread(root, e);
280
+ if (broken) {
281
+ check(checks, `thread:${e}`, 'threads', 'fail', `${e}: ${broken}`,
282
+ 'a change-epic must thread to a real parent; fix `parent:`/`thread:` in epic.md frontmatter');
283
+ } else {
284
+ check(checks, `thread:${e}`, 'threads', 'ok', `${e}: ${lin.kind} threaded to ${lin.thread || lin.parent}`);
285
+ }
286
+ for (const d of loadDebt(root, e)) {
287
+ if (d.status === 'open') {
288
+ check(checks, `thread:${e}:debt`, 'threads', 'warn',
289
+ `${e}: open reconcile debt (${d.reason || 'hotfix shipped first'})`,
290
+ 'pay it — update the artifacts + add a regression test; the next change on this thread is blocked until then');
291
+ }
292
+ }
293
+ }
294
+ }
295
+
267
296
  export async function runDoctor(root, { json = false } = {}) {
268
297
  const checks = [];
269
298
  envChecks(checks);
270
299
  projectChecks(checks, root);
271
300
  epicChecks(checks, root);
301
+ threadChecks(checks, root);
272
302
 
273
303
  const failed = checks.filter((x) => x.status === 'fail');
274
304
  const warned = checks.filter((x) => x.status === 'warn');
@@ -190,6 +190,21 @@ export function gatePredicate({
190
190
  merged = true,
191
191
  solo = false,
192
192
  }) {
193
+ // Phase 6: an INHERITED step (a change-epic carrying a parent artifact by reference) is satisfied
194
+ // without re-review — its approval lives upstream in the thread, recorded as an `inherited` provenance
195
+ // entry. It is pre-marked `done` in state.json, so the gate is normally never invoked on it; this
196
+ // short-circuit makes a direct call safe and surfaces a corrupted boundHash (a referenced artifact
197
+ // cannot change under the child, so a mismatch is corruption — re-thread, do not silently pass).
198
+ if (step?.inherited) {
199
+ const drift = step.boundHash && currentHash && step.boundHash !== currentHash;
200
+ return {
201
+ approvalsSatisfied: true, threadsResolved: true, merged: true, staleDropped: 0,
202
+ passed: !drift,
203
+ missing: drift ? [`inherited artifact drifted from ${step.inheritedFrom || 'parent'} — re-thread`] : [],
204
+ rule: 'inherited',
205
+ };
206
+ }
207
+
193
208
  const forStep = approvals.filter((a) => a.step === step.id && a.status === 'approved');
194
209
  // Revoke-on-change: an approval bound to a stale content hash no longer counts.
195
210
  const stale = forStep.filter((a) => a.artifactHash && currentHash && a.artifactHash !== currentHash);
@@ -372,4 +387,141 @@ export function nextAction(ledger, { epic } = {}) {
372
387
  why: pr ? `review PR #${pr.number} is open — sync its state to advance` : `${step.id} is open — create the review PR/MR` };
373
388
  }
374
389
 
390
+ // ---- Phase 6: feature threads (lineage frontmatter on epic.md) -----------------------------------
391
+
392
+ // Minimal frontmatter reader (key: value, and `inherits: [a, b]` arrays). Mirrors gate.mjs's reader so
393
+ // the thread helpers and the gate agree on the same parse; shared here as the lineage source.
394
+ export function readFrontmatter(file) {
395
+ if (!fs.existsSync(file)) return {};
396
+ const m = fs.readFileSync(file, 'utf8').match(/^---\n([\s\S]*?)\n---/);
397
+ if (!m) return {};
398
+ const out = {};
399
+ for (const line of m[1].split('\n')) {
400
+ const kv = line.match(/^(\w[\w-]*):\s*(.*)$/);
401
+ if (!kv) continue;
402
+ const [, k, v] = kv;
403
+ out[k] = /^\[.*\]$/.test(v) ? v.slice(1, -1).split(',').map((s) => s.trim()).filter(Boolean) : v.trim();
404
+ }
405
+ return out;
406
+ }
407
+
408
+ const asList = (v) => (Array.isArray(v) ? v : v ? [v] : []);
409
+
410
+ // The lineage of an epic from epic.md frontmatter. `kind` defaults to `feature` (genesis) when absent,
411
+ // so an un-migrated genesis epic behaves as the thread root. Greenfield/missing-safe.
412
+ export function epicLineage(root, epic) {
413
+ const fm = readFrontmatter(path.join(epicRoot(root, epic), 'epic.md'));
414
+ return {
415
+ kind: fm.kind || 'feature',
416
+ parent: fm.parent || null,
417
+ thread: fm.thread || null,
418
+ inherits: asList(fm.inherits),
419
+ supersedes: asList(fm.supersedes),
420
+ };
421
+ }
422
+
423
+ // Walk `parent` to the thread root. Cycle- and missing-safe. Returns the genesis-first `chain`, the
424
+ // computed `rootId`, and a `broken` reason (missing parent dir, a cycle, or a denormalized `thread`
425
+ // cache that disagrees with the computed root) — the signal yad doctor / yad next --check report.
426
+ export function resolveThread(root, epicId) {
427
+ const chain = [];
428
+ const seen = new Set();
429
+ let cur = epicId;
430
+ let broken = null;
431
+ while (cur) {
432
+ if (seen.has(cur)) { broken = `cycle at ${cur}`; break; }
433
+ seen.add(cur);
434
+ if (!fs.existsSync(epicRoot(root, cur))) {
435
+ broken = cur === epicId ? `missing epic ${cur}` : `missing parent epic ${cur}`;
436
+ break;
437
+ }
438
+ chain.unshift(cur); // genesis ends up first
439
+ const { parent } = epicLineage(root, cur);
440
+ if (!parent) break; // reached genesis
441
+ cur = parent;
442
+ }
443
+ const rootId = chain[0] || epicId;
444
+ const tip = epicLineage(root, epicId);
445
+ // A non-genesis epic (has a parent) MUST carry a `thread:` cache that equals the computed root.
446
+ // A missing cache is corruption too — without it the bash gates' parent-walk is the only safety net,
447
+ // and a tool reading the field would mis-scope the thread.
448
+ if (!broken && tip.parent && !tip.thread) {
449
+ broken = `missing thread cache on ${epicId} (kind:${tip.kind}, parent:${tip.parent}) — should be '${rootId}'`;
450
+ }
451
+ if (!broken && tip.thread && tip.thread !== rootId) {
452
+ broken = `thread cache '${tip.thread}' != computed root '${rootId}'`;
453
+ }
454
+ return { rootId, chain, broken };
455
+ }
456
+
457
+ // Every epic that belongs to a thread (resolved root == this thread's root), ordered genesis-first by
458
+ // chain depth. Derived by scanning epics/ — no duplicated thread registry. Used by yad-timeline/yad-defects.
459
+ export function threadEpics(root, threadOrEpicId) {
460
+ const { rootId } = resolveThread(root, threadOrEpicId);
461
+ const dir = path.join(root, 'epics');
462
+ if (!fs.existsSync(dir)) return [rootId];
463
+ const depth = new Map();
464
+ const members = [];
465
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
466
+ if (!e.isDirectory() || !isValidEpicId(e.name) || !fs.existsSync(path.join(dir, e.name, 'epic.md'))) continue;
467
+ const rt = resolveThread(root, e.name); // one walk per member (not per comparison)
468
+ if (rt.rootId !== rootId) continue;
469
+ members.push(e.name);
470
+ depth.set(e.name, rt.chain.length);
471
+ }
472
+ // Genesis-first by depth, then a STABLE, machine-independent tie-break (id order) so the resolver is
473
+ // deterministic across filesystems even when two epics sit at the same depth (a branch).
474
+ return members.sort((a, b) => (depth.get(a) - depth.get(b)) || a.localeCompare(b));
475
+ }
476
+
477
+ // Compose the CURRENT authoritative source per artifact base across a thread: the LATEST epic in the
478
+ // chain that actually RE-AUTHORED it (did NOT list it in `inherits`). Genesis owns everything; a later
479
+ // change-epic shadows only what it re-authored. Returns { <base>: <owning epic id> } — the source-of-
480
+ // truth map AI/humans read for the next change (rendered by yad-timeline as thread-resolved.md).
481
+ export const THREAD_ARTIFACT_BASES = ['epic', 'architecture', 'contract', 'ui-design', 'stories', 'test-cases'];
482
+ // REPLACE bases — a re-author supersedes the prior version wholesale, so the LATEST re-author owns it
483
+ // (a contract-surface change re-locks and replaces; a re-authored architecture supersedes the old one).
484
+ const REPLACE_BASES = ['epic', 'architecture', 'contract', 'ui-design'];
485
+ // ADDITIVE bases — each re-authoring epic CONTRIBUTES (stories add files; a change adds its test-cases
486
+ // file), so the current truth is the UNION of contributors, never a single owner. Collapsing these to
487
+ // one epic would drop the parent's inherited stories/cases.
488
+ const ADDITIVE_BASES = ['stories', 'test-cases'];
489
+
490
+ // The owning epic per artifact base across a thread. REPLACE bases resolve to a single epic id (the
491
+ // latest re-author); ADDITIVE bases resolve to the ordered LIST of every epic that re-authored them
492
+ // (genesis-first) — use resolveCurrentStories for story-id-level ownership of the composed set.
493
+ export function resolveCurrentArtifacts(root, threadOrEpicId) {
494
+ const members = threadEpics(root, threadOrEpicId); // genesis-first
495
+ const out = {};
496
+ for (const b of REPLACE_BASES) out[b] = null;
497
+ for (const b of ADDITIVE_BASES) out[b] = [];
498
+ for (const id of members) {
499
+ const { inherits } = epicLineage(root, id);
500
+ for (const b of REPLACE_BASES) if (!inherits.includes(b)) out[b] = id;
501
+ for (const b of ADDITIVE_BASES) if (!inherits.includes(b)) out[b].push(id);
502
+ }
503
+ return out;
504
+ }
505
+
506
+ // Compose the current STORY SET at story-id granularity across the thread: each re-authoring epic's
507
+ // stories/ files are overlaid (a later same-id supersedes; a `supersedes:` entry retires a parent
508
+ // story). Returns { <story-id>: <owning epic id> } — the real current truth for stories, because a
509
+ // change-epic re-authors only the stories it changes and inherits the rest by reference. Without this,
510
+ // a defect-fix that adds one regression story would appear to drop every unchanged parent story.
511
+ export function resolveCurrentStories(root, threadOrEpicId) {
512
+ const members = threadEpics(root, threadOrEpicId); // genesis-first
513
+ const owner = {};
514
+ for (const id of members) {
515
+ const lin = epicLineage(root, id);
516
+ for (const sid of lin.supersedes) delete owner[sid]; // explicitly retired parent stories
517
+ if (lin.inherits.includes('stories')) continue; // inherited wholesale -> contributes nothing new
518
+ const sdir = path.join(epicRoot(root, id), 'stories');
519
+ if (!fs.existsSync(sdir)) continue;
520
+ for (const f of fs.readdirSync(sdir).filter((x) => /\.md$/.test(x))) {
521
+ owner[f.replace(/\.md$/, '')] = id; // contribute / override same-id
522
+ }
523
+ }
524
+ return owner;
525
+ }
526
+
375
527
  export { writeJSON };
package/cli/manifest.mjs CHANGED
@@ -157,6 +157,9 @@ export const epicFiles = (epicRoot) => ({
157
157
  comments: `${epicRoot}/.sdlc/comments.json`,
158
158
  hubPrs: `${epicRoot}/.sdlc/hub-prs.json`,
159
159
  contractLock: `${epicRoot}/.sdlc/contract-lock.json`,
160
+ buildLog: `${epicRoot}/.sdlc/build-log.json`,
161
+ change: `${epicRoot}/.sdlc/change.json`, // Phase 6 — change/defect intake + triage
162
+ reconcileDebt: `${epicRoot}/.sdlc/reconcile-debt.json`, // Phase 6 — hotfix ship-first debt
160
163
  });
161
164
 
162
165
  // Per-repo wiring: src is relative to PKG_ROOT, dest relative to the repo root.
@@ -166,6 +169,9 @@ export const REPO_WIRING = {
166
169
  { src: 'skills/yad-checks/templates/checks/spec-link.sh', dest: 'checks/spec-link.sh', exec: true },
167
170
  { src: 'skills/yad-checks/templates/checks/contract-check.sh', dest: 'checks/contract-check.sh', exec: true },
168
171
  { src: 'skills/yad-checks/templates/checks/build-test-lint.sh', dest: 'checks/build-test-lint.sh', exec: true },
172
+ { src: 'skills/yad-checks/templates/checks/lineage-check.sh', dest: 'checks/lineage-check.sh', exec: true },
173
+ { src: 'skills/yad-checks/templates/checks/epic-open.sh', dest: 'checks/epic-open.sh', exec: true },
174
+ { src: 'skills/yad-checks/templates/checks/reconcile-debt-check.sh', dest: 'checks/reconcile-debt-check.sh', exec: true },
169
175
  { src: 'skills/yad-checks/templates/checks/verified-commits.sh', dest: 'checks/verified-commits.sh', exec: true },
170
176
  { src: 'skills/yad-checks/templates/checks/commit-message.sh', dest: 'checks/commit-message.sh', exec: true },
171
177
  { src: 'skills/yad-pr-template/templates/checks/risk-route.sh', dest: 'checks/risk-route.sh', exec: true },
package/cli/thread.mjs ADDED
@@ -0,0 +1,174 @@
1
+ // `yad thread` (read-only: print a feature thread + its resolved current artifacts) and
2
+ // `yad reconcile` (Phase 6 change-reconciler: flag orphan drift + open hotfix debt, never a gate —
3
+ // mirrors `yad docs sync`). The hard merge BLOCK is the CI gates (lineage-check / reconcile-debt);
4
+ // this only DISCOVERS, exactly as yad-docs-sync flags and the build gates block. Node built-ins only.
5
+ import path from 'node:path';
6
+ import fs from 'node:fs';
7
+ import { c, log, ok, info, warn, hand, readJSON, exists } from './lib.mjs';
8
+ import {
9
+ epicRoot, isValidEpicId, epicLineage, readFrontmatter,
10
+ resolveThread, threadEpics, resolveCurrentArtifacts, resolveCurrentStories, THREAD_ARTIFACT_BASES,
11
+ } from './epic-state.mjs';
12
+
13
+ // ---- file readers (all derived; no DB) -----------------------------------------------------------
14
+
15
+ export const loadChange = (root, epic) => readJSON(path.join(epicRoot(root, epic), '.sdlc', 'change.json'), null);
16
+ export const loadDebt = (root, epic) => {
17
+ const v = readJSON(path.join(epicRoot(root, epic), '.sdlc', 'reconcile-debt.json'), []);
18
+ return Array.isArray(v) ? v : [];
19
+ };
20
+ export const loadBuildLog = (root, epic) => readJSON(path.join(epicRoot(root, epic), '.sdlc', 'build-log.json'), null);
21
+
22
+ // An epic is SEALED once every authored story is `shipped` (config.yaml change.seal_on). A sealed epic
23
+ // refuses new behaviour (epic-open.sh) — a further change must open a new threaded change-epic, which is
24
+ // what keeps the front artifacts from going stale. An epic with no stories is NOT sealed (nothing built).
25
+ export function sealedEpic(root, epic) {
26
+ const dir = path.join(epicRoot(root, epic), 'stories');
27
+ if (!exists(dir)) return false;
28
+ const stories = fs.readdirSync(dir, { withFileTypes: true })
29
+ .filter((e) => e.isFile() && /\.md$/.test(e.name)).map((e) => e.name);
30
+ if (!stories.length) return false;
31
+ return stories.every((f) => readFrontmatter(path.join(dir, f)).status === 'shipped');
32
+ }
33
+
34
+ // The OPEN hotfix debt across a thread (every epic that shares the root). An open entry blocks the next
35
+ // normal change on the thread until paid (reconcile-debt-check.sh enforces; this reports).
36
+ export function openDebtOnThread(root, threadOrEpic) {
37
+ const out = [];
38
+ for (const id of threadEpics(root, threadOrEpic)) {
39
+ for (const d of loadDebt(root, id)) if (d.status === 'open') out.push({ ...d, epicId: d.epicId || id });
40
+ }
41
+ return out;
42
+ }
43
+
44
+ // ---- thread summary (what `yad thread` + yad-status render) --------------------------------------
45
+
46
+ export function threadSummary(root, threadOrEpic) {
47
+ const { rootId, broken } = resolveThread(root, threadOrEpic);
48
+ const members = threadEpics(root, rootId);
49
+ const nodes = members.map((id) => {
50
+ const lin = epicLineage(root, id);
51
+ const state = readJSON(path.join(epicRoot(root, id), '.sdlc', 'state.json'), null);
52
+ const change = loadChange(root, id);
53
+ return {
54
+ id, kind: lin.kind, parent: lin.parent, inherits: lin.inherits,
55
+ currentStep: state?.currentStep || 'unseeded',
56
+ sealed: sealedEpic(root, id),
57
+ depth: change?.depth || null,
58
+ defect: change?.defect || null,
59
+ brokenThread: resolveThread(root, id).broken || null,
60
+ };
61
+ });
62
+ return {
63
+ thread: rootId,
64
+ broken,
65
+ nodes,
66
+ resolved: resolveCurrentArtifacts(root, rootId),
67
+ resolvedStories: resolveCurrentStories(root, rootId),
68
+ openDebt: openDebtOnThread(root, rootId),
69
+ };
70
+ }
71
+
72
+ const KIND_TAG = { feature: c.green('feature'), change: c.cyan('change'), defect: c.yellow('defect'), hotfix: c.red('hotfix') };
73
+
74
+ export async function runThread(root, { epic, json = false } = {}) {
75
+ if (!epic) {
76
+ // List every distinct thread root in the project.
77
+ const dir = path.join(root, 'epics');
78
+ if (!exists(dir)) { log(c.red('no epics/ directory')); process.exitCode = 1; return; }
79
+ const roots = new Set();
80
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
81
+ if (e.isDirectory() && isValidEpicId(e.name) && exists(path.join(dir, e.name, 'epic.md'))) {
82
+ roots.add(resolveThread(root, e.name).rootId);
83
+ }
84
+ }
85
+ log(c.bold('\nFeature threads'));
86
+ for (const r of [...roots].sort()) {
87
+ const s = threadSummary(root, r);
88
+ const debt = s.openDebt.length ? c.red(` ⚠ ${s.openDebt.length} open reconcile-debt`) : '';
89
+ log(` ${c.bold(r)} ${c.dim(`${s.nodes.length} epic(s)`)}${debt}`);
90
+ }
91
+ log(c.dim('\n yad thread <epic> show one thread in full'));
92
+ return;
93
+ }
94
+ if (!isValidEpicId(epic)) { log(c.red(`invalid epic id: ${epic}`)); process.exitCode = 1; return; }
95
+ const s = threadSummary(root, epic);
96
+ if (json) { log(JSON.stringify(s, null, 2)); return; }
97
+
98
+ log(c.bold(`\nThread ${s.thread}`) + c.dim(' (genesis → tip)'));
99
+ if (s.broken) log(c.red(` ✗ broken lineage: ${s.broken}`));
100
+ for (const n of s.nodes) {
101
+ const tag = KIND_TAG[n.kind] || n.kind;
102
+ const seal = n.sealed ? c.dim(' [sealed]') : '';
103
+ const dep = n.depth ? c.dim(` ${n.depth}`) : '';
104
+ log(` • ${c.bold(n.id)} ${tag}${dep} ${c.dim('@ ' + n.currentStep)}${seal}`);
105
+ if (n.parent) log(c.dim(` parent: ${n.parent} inherits: [${n.inherits.join(', ') || '—'}]`));
106
+ if (n.defect) log(c.dim(` defect: ${n.defect.severity || '?'} · escaped@${n.defect.escape_stage || '?'} · ${n.defect.root_cause || ''}`));
107
+ if (n.brokenThread) log(c.red(` ✗ ${n.brokenThread}`));
108
+ }
109
+ log(c.bold('\n Current truth') + c.dim(' (authoritative source per artifact)'));
110
+ for (const base of THREAD_ARTIFACT_BASES) {
111
+ const v = s.resolved[base];
112
+ const disp = Array.isArray(v) ? (v.length ? v.join(' + ') : c.dim('(none)')) : (v || c.dim('(none)'));
113
+ log(` ${base.padEnd(12)} ${c.dim('←')} ${disp}`);
114
+ }
115
+ const sids = Object.keys(s.resolvedStories).sort();
116
+ if (sids.length) {
117
+ log(c.dim(` composed stories (${sids.length}): ` + sids.map((id) => `${id}←${s.resolvedStories[id]}`).join(', ')));
118
+ }
119
+ if (s.openDebt.length) {
120
+ log(c.red('\n ⚠ Open reconcile debt (blocks the next change until paid):'));
121
+ for (const d of s.openDebt) log(` ${d.epicId}: ${d.reason || ''} — requires [${(d.requires || []).join(', ')}]`);
122
+ } else {
123
+ log(c.green('\n ✓ no open reconcile debt'));
124
+ }
125
+ }
126
+
127
+ // ---- the change-reconciler (yad reconcile — advisory, never a gate) ------------------------------
128
+
129
+ // Distinct thread roots in the project.
130
+ function threadRoots(root) {
131
+ const dir = path.join(root, 'epics');
132
+ if (!exists(dir)) return [];
133
+ const roots = new Set();
134
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
135
+ if (e.isDirectory() && isValidEpicId(e.name) && exists(path.join(dir, e.name, 'epic.md'))) {
136
+ roots.add(resolveThread(root, e.name).rootId);
137
+ }
138
+ }
139
+ return [...roots].sort();
140
+ }
141
+
142
+ export async function runReconcile(root, { action = 'check', thread = null } = {}) {
143
+ const roots = thread ? [resolveThread(root, thread).rootId] : threadRoots(root);
144
+ if (!roots.length) { info('no feature threads found (no epics with epic.md yet)'); return; }
145
+
146
+ log(c.bold(`\nChange reconcile ${c.dim(action)}`));
147
+ let flags = 0;
148
+ for (const r of roots) {
149
+ const s = threadSummary(root, r);
150
+ const issues = [];
151
+ if (s.broken) issues.push(`broken lineage: ${s.broken}`);
152
+ for (const n of s.nodes) if (n.brokenThread) issues.push(`${n.id}: ${n.brokenThread}`);
153
+ for (const d of s.openDebt) issues.push(`open reconcile debt on ${d.epicId} — next change blocked until paid`);
154
+ if (!issues.length) { ok(`${r} — clean`); continue; }
155
+ flags += issues.length;
156
+ warn(`${r}`);
157
+ for (const i of issues) hand(i);
158
+ }
159
+
160
+ if (action === 'refresh') {
161
+ log('');
162
+ info('refresh is advisory: open a reconcile change-epic with `yad-change` (kind: change) threaded to');
163
+ info('the affected feature, then pay any open debt (update artifacts + add a regression test).');
164
+ }
165
+ if (action === 'wire') {
166
+ log('');
167
+ info('wire installs an advisory CI job that runs `yad reconcile --check` on push (no block) — the');
168
+ info('hard enforcement is the lineage-check / epic-open / reconcile-debt gates in yad-checks.');
169
+ }
170
+
171
+ log('');
172
+ if (flags) { warn(`${flags} item(s) need attention — reconcile is advisory; the gates block at merge`); }
173
+ else { ok('all threads reconciled — no drift, no open debt'); }
174
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "yadflow",
3
- "version": "2.17.0",
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). A BMAD module + 31 yad-* skills.",
3
+ "version": "2.18.1",
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 + 35 yad-* skills.",
5
5
  "type": "module",
6
6
  "author": "AbdelRahman Nasr",
7
7
  "license": "MIT",
@@ -21,6 +21,7 @@
21
21
  "cli/",
22
22
  "!cli/test.mjs",
23
23
  "!cli/test-checks.mjs",
24
+ "!cli/test-threads.mjs",
24
25
  "skills/",
25
26
  "README.md",
26
27
  "LICENSE",
@@ -36,9 +37,9 @@
36
37
  "scripts": {
37
38
  "yad": "node bin/yad.mjs",
38
39
  "lint": "eslint cli bin",
39
- "test": "node --test cli/test.mjs cli/test-checks.mjs",
40
+ "test": "node --test cli/test.mjs cli/test-checks.mjs cli/test-threads.mjs",
40
41
  "test:e2e": "bash test/e2e/run.sh",
41
- "coverage": "node --test --experimental-test-coverage --test-coverage-exclude='cli/test*.mjs' --test-coverage-lines=70 --test-coverage-branches=70 cli/test.mjs cli/test-checks.mjs",
42
+ "coverage": "node --test --experimental-test-coverage --test-coverage-exclude='cli/test*.mjs' --test-coverage-lines=70 --test-coverage-branches=70 cli/test.mjs cli/test-checks.mjs cli/test-threads.mjs",
42
43
  "diagrams": "npx -y @mermaid-js/mermaid-cli -i docs/diagrams/sdlc-overview.mmd -o docs/diagrams/sdlc-overview.svg -b transparent && npx -y @mermaid-js/mermaid-cli -i docs/diagrams/review-loop.mmd -o docs/diagrams/review-loop.svg -b transparent",
43
44
  "prepublishOnly": "npm test"
44
45
  },
@@ -96,7 +96,11 @@ build:
96
96
  - { id: coderabbit, name: "CodeRabbit", email: "noreply@coderabbit.ai" }
97
97
  - { id: none, name: "(no AI assistance)", email: "" }
98
98
  # Step C (yad-checks) — the CI gates that must pass before merge. CI-agnostic bash in checks/.
99
- gates: [spec-link, contract-check, build-test-lint]
99
+ # Phase 6 adds three thread-aware gates: lineage-check (every change links a real threaded epic),
100
+ # epic-open (a SEALED epic — all stories shipped — refuses new behaviour, forcing a change-epic so the
101
+ # front artifacts can never go stale), and reconcile-debt (a hotfix's ship-first debt blocks the next
102
+ # change on its thread until paid). See the `change:` block below.
103
+ gates: [spec-link, contract-check, build-test-lint, lineage-check, epic-open, reconcile-debt]
100
104
  # Pattern gates — commit subject + PR/MR title + PR/MR template-usage. Profile-aware (code|hub): code
101
105
  # repos validate the Conventional-Commits / task-PR conventions; the product hub validates its
102
106
  # artifact-review conventions (review/EP-<slug>/<artifact>, the hub PR template). verified-commits is
@@ -256,3 +260,44 @@ automation:
256
260
  # Kill switch (phase-4-build-plan.md §Safety): true => every step forced to human_approve
257
261
  # system-wide, no per-step edits. One line, instantly reversible. Toggle via `yad-run action: kill`.
258
262
  kill_switch: false
263
+
264
+ # Phase 6 (post-lock change management) — FEATURE THREADS. After the contract locks and code ships, a
265
+ # change must not MUTATE a locked artifact (that destroys the audit trail and the lock). Instead every
266
+ # change request becomes a NEW epic, threaded to its parent: a feature is a thread of linked epics
267
+ # (genesis -> change -> defect -> ...). A change-epic INHERITS unchanged front artifacts from its parent
268
+ # BY REFERENCE and only RE-AUTHORS what it changes — so artifacts are never stale, only superseded; the
269
+ # feature's current truth is the head of the thread, and the chain IS the evolution timeline. See
270
+ # docs/phase-6-build-plan.md and skills/yad-change. yad-defects/yad-timeline render it; yad-reconcile
271
+ # (read-only, like yad-docs-sync) flags orphan/drift; the three gates above enforce it.
272
+ change:
273
+ # The kind of an epic (epic.md frontmatter `kind:`). `feature` is the genesis (default when absent).
274
+ kinds: [feature, change, defect, hotfix]
275
+ # Triage DEPTH (yad-change auto-proposes, human confirms) -> which front states are re-authored vs
276
+ # inherited. defect-fix re-authors stories+test-cases only; contract-surface re-authors architecture
277
+ # and RE-LOCKS (a new hash routes architecture-review through the contract escalation, as today).
278
+ depths: [defect-fix, behavioral-no-surface, contract-surface, new-capability]
279
+ thread_id: genesis_epic_id # the thread id = the genesis epic's id (never renamed -> stablest anchor)
280
+ # Lineage frontmatter added to epic.md (mirrors the design:/testing: enrichment blocks — the locked
281
+ # state.json step shape is untouched). `thread` is a DERIVED cache (authoritative = walk `parent` to
282
+ # the root); a mismatch is detectable corruption (yad doctor threadChecks). defect/hotfix also carry
283
+ # origin/severity/escape_stage/root_cause for the quality report (yad-defects).
284
+ lineage_frontmatter: [kind, thread, parent, inherits, supersedes, origin, severity, escape_stage, root_cause]
285
+ artifact_bases: [epic, architecture, contract, ui-design, stories, test-cases] # what `inherits` may list
286
+ severity: [sev1, sev2, sev3, sev4] # defect/hotfix severity
287
+ # Per-change-epic ledgers (siblings of approvals.json; the locked state.json shape is untouched).
288
+ ledgers:
289
+ change: "{project-root}/epics/EP-<slug>/.sdlc/change.json" # intake + triage (one per change/defect/hotfix epic)
290
+ debt: "{project-root}/epics/EP-<slug>/.sdlc/reconcile-debt.json" # append-only hotfix ship-first debt
291
+ # An inherited artifact is taken by REFERENCE: the change-epic's contract-lock.json is a POINTER-LOCK
292
+ # carrying the parent's hash verbatim (+ inheritedFrom/ref), so contract-check.sh passes UNCHANGED and
293
+ # the surface physically cannot drift (there is no contract.md in the child to edit). Omitting
294
+ # `architecture` from `inherits` is what triggers a real re-lock + the architecture gate.
295
+ inherit_by: reference
296
+ # An epic is SEALED once every story is `shipped` (build.story_build_states). epic-open.sh refuses new
297
+ # behaviour on a sealed epic -> the change must land in a new threaded change-epic (the front half is
298
+ # forced to stay current; staleness is unshippable).
299
+ seal_on: all-stories-shipped
300
+ hotfix:
301
+ ship_first: true # a hotfix may run the build half BEFORE its front gates approve
302
+ debt_blocks_next_change: true # but opens reconcile-debt.json; the next change on the thread is blocked until paid
303
+ debt_requires: [artifacts-updated, regression-test] # evidence that clears the debt
@@ -11,7 +11,7 @@ set -euo pipefail
11
11
  ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
12
12
  cd "$ROOT"
13
13
 
14
- SKILLS=(yad-discovery yad-analysis yad-epic yad-architecture yad-ui yad-stories yad-test-cases yad-connect-repos yad-sync-repos yad-connect-design yad-connect-testing yad-connect-learning yad-connect-docs yad-docs yad-docs-overview yad-docs-sync yad-learn yad-spec yad-implement yad-checks yad-pr-template yad-review-comments yad-hub-bridge yad-commit yad-open-pr yad-ship yad-engineer-review yad-backfill yad-run yad-review-gate yad-status)
14
+ SKILLS=(yad-discovery yad-analysis yad-epic yad-architecture yad-ui yad-stories yad-test-cases yad-connect-repos yad-sync-repos yad-connect-design yad-connect-testing yad-connect-learning yad-connect-docs yad-docs yad-docs-overview yad-docs-sync yad-learn yad-spec yad-implement yad-checks yad-pr-template yad-review-comments yad-hub-bridge yad-commit yad-open-pr yad-ship yad-engineer-review yad-backfill yad-run yad-review-gate yad-status yad-change yad-timeline yad-defects yad-reconcile)
15
15
 
16
16
  echo "Installing sdlc module from $ROOT/skills ..."
17
17
 
@@ -30,3 +30,7 @@ SDLC Workflow,yad-connect-docs,Connect Docs Target,DX,"Setup/maintenance: connec
30
30
  SDLC Workflow,yad-docs,Author Docs Site,DS,"Generate the per-epic interactive documentation SPA (animated flow canvas + role-based stakeholder doc pages) from the epic's approved artifacts (epic, architecture, locked contract, ui-design, stories, code-context, test-cases), themed by the connected design system, into epics/EP-<slug>/docs-site/. Copies the vendored React/Vite/Tailwind shell verbatim and generates src/data/*.ts deterministically; drives the yad docs build/deploy CLI to publish to Pages (or build-only). An OUTPUT ENRICHMENT — never a gate; never mutates state.json, approvals, or the contract lock.",,{epic: EP-<slug>} {action: generate|refresh|deploy} {login_gate: true|false},,yad-review-gate,,false,epics/EP-<slug>/docs-site/,docs-site/ docs-build.json
31
31
  SDLC Workflow,yad-docs-overview,Docs Overview Site,DO,"Generate the project SDLC-overview interactive site (docs/sdlc-site/) — every stage from setup to ship modeled as flow paths, system components, and stakeholder roles — reusing the same vendored shell, themed with yadflow's brand palette. Reads config.yaml + module-help.csv + the overview diagram as the pipeline source. Folds the hand-maintained docs/index.html report into the site as report.html (linked from the nav). Not a gate — a project-level enrichment that regenerates whenever the skill set / pipeline changes.",,{action: generate|deploy},,,,false,docs/sdlc-site/,sdlc-site/ .docs-build.json
32
32
  SDLC Workflow,yad-docs-sync,Docs Sync,DY,"Maintenance/CI: keep the generated doc sites fresh. Detect staleness (a content hash of the approved artifacts + the connected repos' HEAD shas + the doc-shell version vs each site's build manifest), report which sites drifted and why, regenerate + redeploy the stale ones, and wire a CI job that rebuilds on push (carrying [skip ci] + a concurrency group to prevent deploy loops). Generalizes the rule that feature work must hand-update docs/index.html + diagrams + skill counts. Refresh is always a human/CI decision; never a gate.",,{action: check|refresh|wire} {epic: EP-<slug>},,,,false,epics/EP-<slug>/.sdlc/,docs-build.json yad-docs.yml
33
+ SDLC Workflow,yad-change,Change/Defect Intake,CH,"Phase 6 post-lock change management: the INTAKE + TRIAGE step of a feature thread. Classifies the change DEPTH (defect-fix / behavioral-no-surface / contract-surface / new-capability), seeds a NEW EP-<slug> change-epic threaded to its parent (lineage frontmatter kind/parent/thread/inherits/supersedes + a state.json whose inherited steps are pre-marked done and only the changed steps run; a pointer-lock contract-lock.json when architecture is inherited), and records the intake in change.json (escape_stage + root_cause for defects). For hotfixes it records the ship-first exception and opens reconcile-debt.json. Never auto-advances — hands off to the normal authoring skills + yad-review-gate.",,{parent: EP-<slug>} {title: one-line} {kind: change|defect|hotfix} {origin: production|staging|qa|review} {severity: sev1..sev4} {description: text} {affected: artifacts},1-front,,yad-review-gate,false,epics/EP-<slug>/,epic.md state.json change.json reconcile-debt.json contract-lock.json
34
+ SDLC Workflow,yad-timeline,Feature Timeline,TL,"Render a feature THREAD (its linked epics, genesis->changes->defects) as an evolution view (the vendored React/Vite/Tailwind shell HTML + a TIMELINE.md summary) AND resolve the inheritance chain into the authoritative current artifact set (thread-resolved.md: the winning source per artifact + the resolved contract-lock hash) — the composed source-of-truth AI/humans read for the next change. Reads frontmatter lineage + each change.json + build-log.json. An OUTPUT ENRICHMENT — never a gate; never mutates state.",,{thread: EP-<genesis>} {action: generate|deploy},,,,false,epics/EP-<genesis>/,timeline-site/ thread-resolved.md TIMELINE.md
35
+ SDLC Workflow,yad-defects,Quality-Gap Report,DF,"Generate a per-epic AND per-thread defect/bug report (same vendored shell + DEFECTS.md). Walks the thread for every kind:defect change-epic + each change.json defect block + shipped regressions in build-log.json, aggregates by escape_stage (the SDLC gate that should have caught it) and root_cause, and visualizes WHERE quality gaps systematically come from (e.g. % of thread defects that escaped at the test-cases gate) so the team can harden the originating stage. An OUTPUT ENRICHMENT — never a gate; never mutates state.",,{epic: EP-<slug> | thread: EP-<genesis>} {action: generate|deploy},,,,false,epics/EP-<slug>/,defects-site/ DEFECTS.md
36
+ SDLC Workflow,yad-reconcile,Change Reconciler,RE,"Maintenance/CI (mirrors yad-docs-sync — never a gate): detect post-lock DRIFT/ORPHANS — shipped code or a repo HEAD advance (the repos.json syncedHead-vs-current-HEAD rule) with NO owning change-epic in any thread — plus open hotfix reconcile debt, and report which thread drifted and why. refresh scaffolds a reconcile change-epic stub (hands to yad-change) — never silent; wire commits advisory CI ([skip ci] + concurrency, like yad-docs-sync). The actual merge BLOCK is the lineage-check / reconcile-debt gates; this only discovers.",,{action: check|refresh|wire} {thread: EP-<genesis>},,,,false,epics/EP-<genesis>/.sdlc/,(report) reconcile-debt.json yad-reconcile.yml