yadflow 3.11.1 → 3.12.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 CHANGED
@@ -1,3 +1,18 @@
1
+ ## [3.12.1](https://github.com/abdelrahmannasr/yadflow/compare/v3.12.0...v3.12.1) (2026-07-14)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * **cli:** reject unsafe detected IDE targets and opencode write destinations ([792a40b](https://github.com/abdelrahmannasr/yadflow/commit/792a40b399b92f8db9d560e314110c432b98d93e)), closes [#134](https://github.com/abdelrahmannasr/yadflow/issues/134)
7
+ * **cli:** repair and validate persisted IDE targets ([81242ed](https://github.com/abdelrahmannasr/yadflow/commit/81242ed9a075ea067acb2f4497a745ee40e540a6))
8
+
9
+ # [3.12.0](https://github.com/abdelrahmannasr/yadflow/compare/v3.11.1...v3.12.0) (2026-07-11)
10
+
11
+
12
+ ### Features
13
+
14
+ * render an epic's kind as its noun in next/thread/status ([42e80e1](https://github.com/abdelrahmannasr/yadflow/commit/42e80e19e20e129a2a3941c85db6777a66ab00cc))
15
+
1
16
  ## [3.11.1](https://github.com/abdelrahmannasr/yadflow/compare/v3.11.0...v3.11.1) (2026-07-11)
2
17
 
3
18
 
@@ -755,6 +755,13 @@ export function readFrontmatter(file) {
755
755
 
756
756
  const asList = (v) => (Array.isArray(v) ? v : v ? [v] : []);
757
757
 
758
+ // The human-facing noun for a lineage kind. Presentation only — the artifact is always an epic
759
+ // (`EP-<slug>`); this just renders WHAT KIND of work it is so `yad next`/`yad thread`/`yad status`
760
+ // read as "Defect EP-…" / "Change request EP-…" instead of a generic "Epic". `feature` (and any
761
+ // unknown/absent kind) falls back to "Epic". A bug is a defect (kind:defect) — no separate noun.
762
+ export const KIND_NOUN = { feature: 'Epic', change: 'Change request', defect: 'Defect', hotfix: 'Hotfix' };
763
+ export const kindNoun = (kind) => KIND_NOUN[kind] || 'Epic';
764
+
758
765
  // The lineage of an epic from epic.md frontmatter. `kind` defaults to `feature` (genesis) when absent,
759
766
  // so an un-migrated genesis epic behaves as the thread root. Greenfield/missing-safe.
760
767
  export function epicLineage(root, epic) {
package/cli/manifest.mjs CHANGED
@@ -116,8 +116,10 @@ export const LEGACY_HUB_FILES = {
116
116
  };
117
117
 
118
118
  // IDE install targets (relative to the target project root).
119
- export const IDE_FOLDER_TARGETS = ['.claude', '.agents', '.zencoder']; // <ide>/skills/<skill>/ (folder copy)
120
- export const IDE_OPENCODE_DIR = '.opencode/commands'; // <skill>.md (flat SKILL.md copy)
119
+ export const IDE_FOLDER_TARGETS = Object.freeze(['.claude', '.agents', '.zencoder']); // <ide>/skills/<skill>/ (folder copy)
120
+ export const IDE_OPENCODE_TARGET = '.opencode';
121
+ export const IDE_TARGETS = Object.freeze([...IDE_FOLDER_TARGETS, IDE_OPENCODE_TARGET]);
122
+ export const IDE_OPENCODE_DIR = `${IDE_OPENCODE_TARGET}/commands`; // <skill>.md (flat SKILL.md copy)
121
123
 
122
124
  // Module registration files copied from skills/sdlc/ into _bmad/sdlc/.
123
125
  export const MODULE_FILES = ['config.yaml', 'module-help.csv'];
package/cli/next.mjs CHANGED
@@ -13,7 +13,7 @@ import fs from 'node:fs';
13
13
  import path from 'node:path';
14
14
  import { c, log, ok, info, warn, hand, fail, readJSON, exists } from './lib.mjs';
15
15
  import { PROJECT_FILES } from './manifest.mjs';
16
- import { epicRoot, loadLedger, nextAction, preconditionsMet, isValidEpicId, DISCOVERY_EPIC } from './epic-state.mjs';
16
+ import { epicRoot, loadLedger, nextAction, preconditionsMet, isValidEpicId, epicLineage, kindNoun, DISCOVERY_EPIC } from './epic-state.mjs';
17
17
 
18
18
  // Is solo mode on? Persisted in hub.json by setup (Phase C/D); default false. Read defensively so a
19
19
  // missing/old hub.json never breaks the driver.
@@ -102,7 +102,10 @@ function actionLine(a, { solo } = {}) {
102
102
 
103
103
  // Full, friendly printout for a single epic.
104
104
  function printAction(a, { solo } = {}) {
105
- log(`\n ${c.bold(a.epicId || '(epic)')} ${c.dim(`— ${a.why}`)}`);
105
+ // Prefix the id with the kind noun (Defect / Change request / Hotfix / Epic) so a glance says what
106
+ // kind of work this is. The discovery front-zero is not a feature epic — leave it un-prefixed.
107
+ const noun = a.lineageKind && a.epicId !== DISCOVERY_EPIC ? `${kindNoun(a.lineageKind)} ` : '';
108
+ log(`\n ${c.bold(`${noun}${a.epicId || '(epic)'}`)} ${c.dim(`— ${a.why}`)}`);
106
109
  // In the build half with live lanes, print each story/repo's next sub-step + remaining chain instead
107
110
  // of the single static hint; otherwise the one actionable line.
108
111
  if (a.kind === 'build' && a.builds?.length) printBuildLanes(a.builds);
@@ -139,7 +142,10 @@ function generalNext(root, { all } = {}) {
139
142
  return;
140
143
  }
141
144
 
142
- const actions = featureEpics.map((id) => nextAction(loadLedger(epicRoot(root, id)), { epic: id }));
145
+ const actions = featureEpics.map((id) => ({
146
+ ...nextAction(loadLedger(epicRoot(root, id)), { epic: id }),
147
+ lineageKind: epicLineage(root, id).kind,
148
+ }));
143
149
  if (discoveryOpen) printAction(discoveryAction, { solo }); // an unfinished discovery comes first
144
150
 
145
151
  if (featureEpics.length === 1 || all) {
@@ -148,7 +154,7 @@ function generalNext(root, { all } = {}) {
148
154
  }
149
155
  // Several epics — list each with a one-liner, then point at the per-epic / --all views.
150
156
  log(`\n ${c.bold(`${featureEpics.length} epics`)} ${c.dim('— next action each:')}`);
151
- for (const a of actions) log(` ${c.cyan(a.epicId)} ${actionLine(a, { solo })}`);
157
+ for (const a of actions) log(` ${c.cyan(`${kindNoun(a.lineageKind)} ${a.epicId}`)} ${actionLine(a, { solo })}`);
152
158
  info(c.dim(`detail: ${c.bold('yad next <epic>')} • all at once: ${c.bold('yad next --all')}`));
153
159
  }
154
160
 
@@ -183,5 +189,8 @@ export async function runNext(root, { epic, check, all } = {}) {
183
189
  process.exitCode = 1;
184
190
  return;
185
191
  }
186
- printAction(nextAction(loadLedger(epicDir), { epic }), { solo: isSolo(root) });
192
+ printAction(
193
+ { ...nextAction(loadLedger(epicDir), { epic }), lineageKind: epicLineage(root, epic).kind },
194
+ { solo: isSolo(root) },
195
+ );
187
196
  }
package/cli/plan.mjs CHANGED
@@ -7,7 +7,7 @@ import {
7
7
  asset, exists, copyDir, copyFile, dirMatches, sameContent, readJSON,
8
8
  } from './lib.mjs';
9
9
  import {
10
- SKILLS, IDE_FOLDER_TARGETS, IDE_OPENCODE_DIR, MODULE_FILES, wiringFor, HUB_WIRING, PROJECT_FILES,
10
+ SKILLS, IDE_TARGETS, IDE_OPENCODE_DIR, MODULE_FILES, wiringFor, HUB_WIRING, PROJECT_FILES,
11
11
  LEGACY_SKILLS, REMOVED_SKILLS, LEGACY_MARKER, LEGACY_REPO_FILES, LEGACY_HUB_FILES,
12
12
  } from './manifest.mjs';
13
13
 
@@ -36,13 +36,149 @@ const dirAction = (scope, item, src, dest, { root } = {}) => ({
36
36
  apply: () => copyDir(src, dest),
37
37
  });
38
38
 
39
- // Which IDE targets this project wants. Recorded at setup time; falls back to
40
- // whichever IDE base dirs already exist, else .claude.
39
+ // Persisted state gets one deliberately narrow compatibility repair. Explicit setup/planner input
40
+ // does not: a caller typo is an error, while the known v3.11.1 `.cluade` stamp is safely migrated.
41
+ const PERSISTED_IDE_ALIASES = new Map([['.cluade', '.claude']]);
42
+ const IDE_TARGET_ERROR_CODE = 'YAD_IDE_TARGET';
43
+ const sameTargets = (a, b) => Array.isArray(a) && a.length === b.length && a.every((v, i) => v === b[i]);
44
+ const displayTarget = (value) => {
45
+ if (value === undefined) return 'undefined';
46
+ try { return JSON.stringify(value) ?? String(value); } catch { return String(value); }
47
+ };
48
+ const ideTargetError = (message) => Object.assign(new Error(message), { code: IDE_TARGET_ERROR_CODE });
49
+ const lstatIfPresent = (full) => {
50
+ try {
51
+ return fs.lstatSync(full);
52
+ } catch (e) {
53
+ if (e?.code === 'ENOENT') return null;
54
+ throw e;
55
+ }
56
+ };
57
+ const ideContainers = (ide) => ide === '.opencode'
58
+ ? [ide, IDE_OPENCODE_DIR]
59
+ : [ide, path.join(ide, 'skills')];
60
+
61
+ function assertSafeIdeContainers(root, ide) {
62
+ for (const relPath of ideContainers(ide)) {
63
+ const stat = lstatIfPresent(path.join(root, relPath));
64
+ if (!stat) continue;
65
+ if (stat.isSymbolicLink() || !stat.isDirectory()) {
66
+ const kind = stat.isSymbolicLink() ? 'a symbolic link' : 'not a directory';
67
+ throw ideTargetError(`unsafe IDE target '${ide}': ${relPath} is ${kind}`);
68
+ }
69
+ }
70
+ }
71
+
72
+ function assertSafeOpenCodeWriteDestinations(root, skills) {
73
+ for (const skill of new Set(skills)) {
74
+ const relPath = path.join(IDE_OPENCODE_DIR, `${skill}.md`);
75
+ const stat = lstatIfPresent(path.join(root, relPath));
76
+ if (!stat) continue;
77
+ let kind = null;
78
+ if (stat.isSymbolicLink()) kind = 'a symbolic link';
79
+ else if (!stat.isFile()) kind = 'not a regular file';
80
+ else if (stat.nlink > 1) kind = 'linked to multiple paths';
81
+ if (kind) throw ideTargetError(`unsafe IDE target '.opencode': ${relPath} is ${kind}`);
82
+ }
83
+ }
84
+
85
+ // Pure target inspection. Valid entries are trimmed and deduplicated in input order; unsupported
86
+ // entries are returned to the caller for reporting rather than ever becoming filesystem paths.
87
+ export function normalizeIdeTargets(input, { repairAliases = false } = {}) {
88
+ const shapeValid = Array.isArray(input);
89
+ const invalid = shapeValid ? [] : [input];
90
+ const repaired = [];
91
+ const targets = [];
92
+ const seen = new Set();
93
+ for (const raw of shapeValid ? input : []) {
94
+ if (typeof raw !== 'string') { invalid.push(raw); continue; }
95
+ const trimmed = raw.trim();
96
+ const target = repairAliases ? (PERSISTED_IDE_ALIASES.get(trimmed) || trimmed) : trimmed;
97
+ if (!IDE_TARGETS.includes(target)) { invalid.push(raw); continue; }
98
+ if (target !== trimmed) repaired.push({ from: trimmed, to: target });
99
+ if (!seen.has(target)) { seen.add(target); targets.push(target); }
100
+ }
101
+ return { targets, invalid, repaired, shapeValid };
102
+ }
103
+
104
+ // Strict boundary for every explicit action-builder/setup input. Returning only canonical values
105
+ // makes path construction below safe by construction.
106
+ export function canonicalIdeTargets(input) {
107
+ const state = normalizeIdeTargets(input);
108
+ if (!state.shapeValid) {
109
+ throw ideTargetError(`IDE targets must be a non-empty array (supported: ${IDE_TARGETS.join(', ')})`);
110
+ }
111
+ if (state.invalid.length) {
112
+ throw ideTargetError(`unsupported IDE target(s): ${state.invalid.map(displayTarget).join(', ')} (supported: ${IDE_TARGETS.join(', ')})`);
113
+ }
114
+ if (!state.targets.length) {
115
+ throw ideTargetError(`at least one IDE target is required (supported: ${IDE_TARGETS.join(', ')})`);
116
+ }
117
+ return state.targets;
118
+ }
119
+
120
+ // A canonical relative name is necessary but not sufficient: an existing IDE root (or its install
121
+ // container) could be a file or symlink that redirects writes/removals outside the project. Validate
122
+ // every target before constructing ANY actions, so a bad later target cannot cause a partial install.
123
+ export function safeIdeTargetsFor(root, input) {
124
+ const targets = canonicalIdeTargets(input);
125
+ for (const ide of targets) assertSafeIdeContainers(root, ide);
126
+ return targets;
127
+ }
128
+
129
+ // Fallback discovery must not promote a supported-looking file/symlink into an install target.
130
+ // Keep unsafe entries for diagnostics, while returning only real IDE directories with safe install
131
+ // containers. Unexpected filesystem errors remain fatal instead of being mistaken for bad input.
132
+ export function detectedIdeTargetStateFor(root) {
133
+ const targets = [];
134
+ const unsafe = [];
135
+ for (const ide of IDE_TARGETS) {
136
+ if (!lstatIfPresent(path.join(root, ide))) continue;
137
+ try {
138
+ assertSafeIdeContainers(root, ide);
139
+ targets.push(ide);
140
+ } catch (e) {
141
+ if (e?.code !== IDE_TARGET_ERROR_CODE) throw e;
142
+ unsafe.push({ target: ide, message: e.message });
143
+ }
144
+ }
145
+ return { targets, unsafe };
146
+ }
147
+
148
+ // Which IDE targets this project wants. Persisted values are recovery-oriented: repair the one known
149
+ // alias, filter everything else, then fall back to supported IDE dirs already present (or .claude).
150
+ // The full state lets reconcile report drift without mutating during a read-only check.
151
+ export function ideTargetStateFor(root) {
152
+ const stampPath = path.join(root, PROJECT_FILES.version);
153
+ const hasStamp = exists(stampPath);
154
+ const rec = readJSON(stampPath);
155
+ const recordIsObject = !!rec && typeof rec === 'object' && !Array.isArray(rec);
156
+ const hasField = recordIsObject && Object.hasOwn(rec, 'ideTargets');
157
+ const raw = hasField ? rec.ideTargets : undefined;
158
+ const normalized = normalizeIdeTargets(raw, { repairAliases: true });
159
+ let targets = normalized.targets;
160
+ let usedFallback = false;
161
+ let unsafeDetected = [];
162
+ if (!targets.length) {
163
+ const detected = detectedIdeTargetStateFor(root);
164
+ targets = detected.targets.length ? detected.targets : ['.claude'];
165
+ unsafeDetected = detected.unsafe;
166
+ usedFallback = true;
167
+ }
168
+ return {
169
+ ...normalized,
170
+ targets,
171
+ hasStamp,
172
+ recordIsObject,
173
+ hasField,
174
+ usedFallback,
175
+ unsafeDetected,
176
+ needsRepair: hasStamp && !sameTargets(raw, targets),
177
+ };
178
+ }
179
+
41
180
  export function ideTargetsFor(root) {
42
- const rec = readJSON(path.join(root, PROJECT_FILES.version));
43
- if (rec?.ideTargets?.length) return rec.ideTargets;
44
- const present = [...IDE_FOLDER_TARGETS, '.opencode'].filter((d) => exists(path.join(root, d)));
45
- return present.length ? present : ['.claude'];
181
+ return ideTargetStateFor(root).targets;
46
182
  }
47
183
 
48
184
  // A brand-new first-party skill is `missing` on every existing install. Relabel that to status `'new'`
@@ -53,8 +189,10 @@ const asNewSkill = (a) => (a.status === 'missing' ? { ...a, status: 'new' } : a)
53
189
 
54
190
  // Module = skills installed into each IDE target + the _bmad/sdlc registration.
55
191
  export function moduleActions(root, ideTargets = ideTargetsFor(root)) {
192
+ const targets = safeIdeTargetsFor(root, ideTargets);
193
+ if (targets.includes('.opencode')) assertSafeOpenCodeWriteDestinations(root, SKILLS);
56
194
  const actions = [];
57
- for (const ide of ideTargets) {
195
+ for (const ide of targets) {
58
196
  if (ide === '.opencode') {
59
197
  for (const s of SKILLS) {
60
198
  actions.push(asNewSkill(fileAction(
@@ -92,8 +230,15 @@ export function moduleActions(root, ideTargets = ideTargetsFor(root)) {
92
230
  // one, so a single update completes the rename even when the new copy would otherwise be
93
231
  // skipped as missing-scope.
94
232
  export function legacyModuleActions(root, ideTargets = ideTargetsFor(root)) {
233
+ const targets = safeIdeTargetsFor(root, ideTargets);
234
+ if (targets.includes('.opencode')) {
235
+ const writes = Object.entries(LEGACY_SKILLS)
236
+ .filter(([, old]) => exists(path.join(root, IDE_OPENCODE_DIR, `${old}.md`)))
237
+ .map(([skill]) => skill);
238
+ assertSafeOpenCodeWriteDestinations(root, writes);
239
+ }
95
240
  const actions = [];
96
- for (const ide of ideTargets) {
241
+ for (const ide of targets) {
97
242
  for (const [skill, old] of Object.entries(LEGACY_SKILLS)) {
98
243
  if (ide === '.opencode') {
99
244
  const oldDest = path.join(root, IDE_OPENCODE_DIR, `${old}.md`);
@@ -137,8 +282,9 @@ export function legacyModuleActions(root, ideTargets = ideTargetsFor(root)) {
137
282
  // clean tree yields nothing and the purge is idempotent. apply() just deletes the install (no
138
283
  // replacement — that is what makes this a removal, not a rename).
139
284
  export function removedModuleActions(root, ideTargets = ideTargetsFor(root)) {
285
+ const targets = safeIdeTargetsFor(root, ideTargets);
140
286
  const actions = [];
141
- for (const ide of ideTargets) {
287
+ for (const ide of targets) {
142
288
  for (const skill of REMOVED_SKILLS) {
143
289
  if (ide === '.opencode') {
144
290
  const dest = path.join(root, IDE_OPENCODE_DIR, `${skill}.md`);
package/cli/reconcile.mjs CHANGED
@@ -14,6 +14,7 @@ import { VERSION, PROJECT_FILES } from './manifest.mjs';
14
14
  import {
15
15
  moduleActions, repoActions, hubActions, authorsActions,
16
16
  legacyModuleActions, removedModuleActions, legacyRepoActions, legacyHubActions,
17
+ ideTargetStateFor,
17
18
  } from './plan.mjs';
18
19
  import { gitHead, packRepo } from './setup.mjs';
19
20
  import { groupByRoot, commitUpdates } from './update-commit.mjs';
@@ -31,14 +32,36 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
31
32
  const registry = readJSON(path.join(root, PROJECT_FILES.reposRegistry), { repos: [] });
32
33
  if (!exists(path.join(root, PROJECT_FILES.reposRegistry))) gaps.push('no repos registered (.sdlc/repos.json absent)');
33
34
 
35
+ // Resolve untrusted persisted IDE targets once, before any filesystem action is constructed.
36
+ // The returned list contains canonical allowlisted roots only.
37
+ const ideState = ideTargetStateFor(root);
38
+ const ideTargets = ideState.targets;
39
+ const stampPath = path.join(root, PROJECT_FILES.version);
40
+ const writeCanonicalStamp = () => {
41
+ const current = readJSON(stampPath, {});
42
+ const record = current && typeof current === 'object' && !Array.isArray(current) ? current : {};
43
+ writeJSON(stampPath, { ...record, version: VERSION, ideTargets });
44
+ };
45
+
34
46
  // --- deterministic file actions (module + hub CI + author allowlists + every registered repo),
35
47
  // plus pre-2.0 sdlc-* -> yad-* migrations ('legacy': old name installed; rename in place)
36
48
  // and purge of skills removed in a later release ('removed': delete the lingering install) ---
37
49
  const actions = [
38
- ...moduleActions(root), ...legacyModuleActions(root), ...removedModuleActions(root),
50
+ ...moduleActions(root, ideTargets), ...legacyModuleActions(root, ideTargets), ...removedModuleActions(root, ideTargets),
39
51
  ...hubActions(root), ...legacyHubActions(root),
40
52
  ...authorsActions(root, registry.repos),
41
53
  ];
54
+ if (ideState.needsRepair) {
55
+ actions.push({
56
+ scope: 'hub',
57
+ item: `${PROJECT_FILES.version} ideTargets`,
58
+ status: 'outdated',
59
+ root,
60
+ paths: [PROJECT_FILES.version],
61
+ // The canonical stamp is written once, after every filesystem action succeeds below.
62
+ apply: () => undefined,
63
+ });
64
+ }
42
65
  for (const repo of registry.repos) actions.push(...repoActions(root, repo), ...legacyRepoActions(root, repo));
43
66
 
44
67
  // --- stale code-context (HEAD moved since last pack) ---
@@ -77,6 +100,30 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
77
100
  for (const i of notOk) log(` ${MARK[i.status]} ${i.item}`);
78
101
  }
79
102
  for (const g of gaps) warn(g);
103
+ const shownInvalid = ideState.invalid.map((v) => {
104
+ try { return JSON.stringify(v) ?? String(v); } catch { return String(v); }
105
+ });
106
+ if (ideState.hasStamp && !ideState.recordIsObject) {
107
+ warn(`${PROJECT_FILES.version}: version stamp is unreadable or not a JSON object; using safe targets: ${ideTargets.join(', ')}`);
108
+ } else if (ideState.hasStamp && !ideState.hasField) {
109
+ warn(`${PROJECT_FILES.version}: ideTargets is missing; using safe targets: ${ideTargets.join(', ')}`);
110
+ } else if (ideState.hasStamp && !ideState.shapeValid) {
111
+ warn(`${PROJECT_FILES.version}: ideTargets is not an array; ignored persisted value: ${shownInvalid.join(', ')}; using safe targets: ${ideTargets.join(', ')}`);
112
+ } else if (ideState.hasStamp && ideState.usedFallback && !ideState.invalid.length) {
113
+ warn(`${PROJECT_FILES.version}: ideTargets is empty; using safe targets: ${ideTargets.join(', ')}`);
114
+ }
115
+ if (ideState.invalid.length && ideState.shapeValid) {
116
+ warn(`${PROJECT_FILES.version}: ignored unsupported persisted IDE target(s): ${shownInvalid.join(', ')}`);
117
+ }
118
+ if (ideState.repaired.length) {
119
+ warn(`${PROJECT_FILES.version}: persisted .cluade target will be repaired to .claude`);
120
+ }
121
+ for (const unsafe of ideState.unsafeDetected) {
122
+ warn(`${PROJECT_FILES.version}: ignored unsafe detected IDE path '${unsafe.target}'; ${unsafe.message}; using safe targets: ${ideTargets.join(', ')}`);
123
+ }
124
+ if (exists(path.join(root, '.cluade'))) {
125
+ warn('existing .cluade path was left untouched; review its contents and remove it manually');
126
+ }
80
127
 
81
128
  const fixable = actions.filter((a) =>
82
129
  a.status !== 'ok' && (scope === 'all' ? true : a.status !== 'missing'),
@@ -103,9 +150,9 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
103
150
  if (force) {
104
151
  for (const a of actions.filter((a) => a.status === 'ok')) { a.apply(); appliedActions.push(a); }
105
152
  }
106
- // refresh the version stamp (preserve recorded ideTargets) and let it ride the hub's update commit
107
- const rec = readJSON(path.join(root, PROJECT_FILES.version), {});
108
- writeJSON(path.join(root, PROJECT_FILES.version), { ...rec, version: VERSION });
153
+ // Refresh the version stamp and persist only the canonical targets used to build actions. This also
154
+ // completes legacy/corrupt target migration even when no skill content itself needed an update.
155
+ writeCanonicalStamp();
109
156
  appliedActions.push({ scope: 'hub', item: PROJECT_FILES.version, status: 'stamp', root, paths: [PROJECT_FILES.version] });
110
157
  applied ? ok(`reconciled ${applied} item(s)`) : info('nothing to fix');
111
158
  if (gaps.length) hand('one-time setup still missing — run `yad setup`.');
package/cli/setup.mjs CHANGED
@@ -6,10 +6,11 @@ import {
6
6
  c, log, step, guide, ok, info, warn, hand, fail, ask, askYesNo, run, has,
7
7
  exists, readJSON, readJSONStrict, writeJSON,
8
8
  } from './lib.mjs';
9
- import { VERSION, IDE_FOLDER_TARGETS, PROJECT_FILES, DESIGN_TOOLS, DESIGN_PRIMARY, TESTING_TOOLS, TESTING_PRIMARY, LEARNING_TOOLS, LEARNING_PRIMARY } from './manifest.mjs';
9
+ import { VERSION, IDE_TARGETS, PROJECT_FILES, DESIGN_TOOLS, DESIGN_PRIMARY, TESTING_TOOLS, TESTING_PRIMARY, LEARNING_TOOLS, LEARNING_PRIMARY } from './manifest.mjs';
10
10
  import {
11
11
  moduleActions, repoActions, hubActions, authorsActions,
12
12
  legacyModuleActions, removedModuleActions, legacyRepoActions, legacyHubActions,
13
+ safeIdeTargetsFor, detectedIdeTargetStateFor,
13
14
  } from './plan.mjs';
14
15
  import { validateLogin, rolesForScope } from './platform.mjs';
15
16
 
@@ -34,7 +35,26 @@ export function parseRolesSpec(s) {
34
35
  return out;
35
36
  }
36
37
 
37
- const ALL_IDES = [...IDE_FOLDER_TARGETS, '.opencode'];
38
+ // Programmatic setup is strict; interactive setup keeps asking until it receives at least one valid
39
+ // canonical target. Both paths return the same trimmed, ordered, deduplicated representation.
40
+ export async function selectIdeTargets(root, provided, asker = ask) {
41
+ if (provided !== undefined) return safeIdeTargetsFor(root, provided);
42
+ const detected = detectedIdeTargetStateFor(root);
43
+ const present = detected.targets;
44
+ for (const unsafe of detected.unsafe) warn(`${unsafe.message}; excluded from IDE defaults`);
45
+ const def = (present.length ? present : ['.claude']).join(',');
46
+ for (;;) {
47
+ const answer = await asker(`IDE targets to install ${c.dim('(comma-separated: ' + IDE_TARGETS.join(', ') + ')')}`, def);
48
+ if (answer === undefined || answer === null) throw new Error('IDE target selection ended before a valid choice was provided');
49
+ const values = String(answer ?? '').split(',').map((s) => s.trim()).filter(Boolean);
50
+ try {
51
+ return safeIdeTargetsFor(root, values);
52
+ } catch (e) {
53
+ if (process.env.SDLC_NONINTERACTIVE || e?.code !== 'YAD_IDE_TARGET') throw e;
54
+ warn(e.message);
55
+ }
56
+ }
57
+ }
38
58
 
39
59
  export function detectPlatform(remoteUrl = '') {
40
60
  if (/gitlab/i.test(remoteUrl)) return 'gitlab';
@@ -416,13 +436,7 @@ export async function runSetup(root, opts = {}) {
416
436
  'Copies the yad-* skills into your AI tool(s) so they appear in Claude Code / agents / opencode.',
417
437
  'Enter the IDE folders to install into, comma-separated; default = whatever is already present.',
418
438
  ]);
419
- let ideTargets = opts.ideTargets;
420
- if (!ideTargets) {
421
- const present = ALL_IDES.filter((d) => exists(path.join(root, d)));
422
- const def = (present.length ? present : ['.claude']).join(',');
423
- const answer = await ask(`IDE targets to install ${c.dim('(comma-separated: ' + ALL_IDES.join(', ') + ')')}`, def);
424
- ideTargets = answer.split(',').map((s) => s.trim()).filter(Boolean);
425
- }
439
+ const ideTargets = await selectIdeTargets(root, opts.ideTargets);
426
440
  applyActions(moduleActions(root, ideTargets), { force: true });
427
441
  // Migrate any pre-2.0 install in place: remove the old sdlc-* skill copies in the project's
428
442
  // IDE targets and install their yad-* renames. Without this, setup only ADDED yad-* and left
package/cli/thread.mjs CHANGED
@@ -7,7 +7,7 @@ import fs from 'node:fs';
7
7
  import { c, log, ok, info, warn, hand, readJSON, exists } from './lib.mjs';
8
8
  import { readShips } from './ledger.mjs';
9
9
  import {
10
- epicRoot, isValidEpicId, epicLineage, readFrontmatter, isStubEpic,
10
+ epicRoot, isValidEpicId, epicLineage, readFrontmatter, isStubEpic, kindNoun,
11
11
  resolveThread, threadEpics, resolveCurrentArtifacts, resolveCurrentStories, THREAD_ARTIFACT_BASES,
12
12
  } from './epic-state.mjs';
13
13
 
@@ -73,7 +73,10 @@ export function threadSummary(root, threadOrEpic) {
73
73
  };
74
74
  }
75
75
 
76
- const KIND_TAG = { feature: c.green('feature'), change: c.cyan('change'), defect: c.yellow('defect'), hotfix: c.red('hotfix') };
76
+ // Colour a node's kind noun for the tree render. The noun words live in one place (`kindNoun`); this
77
+ // only layers the per-kind colour on top, so the two never drift. Unknown kind → uncoloured noun.
78
+ const KIND_COLOR = { feature: c.green, change: c.cyan, defect: c.yellow, hotfix: c.red };
79
+ const kindTag = (kind) => (KIND_COLOR[kind] || ((s) => s))(kindNoun(kind));
77
80
 
78
81
  export async function runThread(root, { epic, json = false } = {}) {
79
82
  if (!epic) {
@@ -103,7 +106,7 @@ export async function runThread(root, { epic, json = false } = {}) {
103
106
  log(c.bold(`\nThread ${s.thread}`) + c.dim(' (genesis → tip)'));
104
107
  if (s.broken) log(c.red(` ✗ broken lineage: ${s.broken}`));
105
108
  for (const n of s.nodes) {
106
- const tag = KIND_TAG[n.kind] || n.kind;
109
+ const tag = kindTag(n.kind);
107
110
  const seal = n.sealed ? c.dim(' [sealed]') : '';
108
111
  const stub = n.stub ? c.yellow(' [stub · backfill pending]') : '';
109
112
  const dep = n.depth ? c.dim(` ${n.depth}`) : '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yadflow",
3
- "version": "3.11.1",
3
+ "version": "3.12.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",
@@ -35,8 +35,11 @@ Do not modify any of them.
35
35
  ### Step 3 — Report
36
36
  Print, in this order:
37
37
 
38
- 1. **Epic:** `epicId`, `status` from `epic.md` frontmatter, `currentStep`, and `repos` (the touched
39
- domains).
38
+ 1. **Header:** render the kind noun from `epic.md` frontmatter `kind` **Change request** (`change`),
39
+ **Defect** (`defect`), **Hotfix** (`hotfix`), or **Epic** (`feature`, and the default when `kind` is
40
+ absent) — followed by `epicId`, then `status` from `epic.md` frontmatter, `currentStep`, and `repos`
41
+ (the touched domains). Example: `Defect EP-istifta-queue-filter — draft @ stories`. A bug is a defect
42
+ (`kind: defect`) — there is no separate noun. This is presentation only; the artifact is still an epic.
40
43
  2. **Steps table** — for every front step in `steps[]` order (10, or 12 when the optional analysis step
41
44
  was run): `id`, `type`, `status`, `assistance`, `automation`, `locked`, and `risk_tags`. Mark the
42
45
  `currentStep` with `→`. The gating chain is `[analysis → analysis-review →] epic → epic-review →
@@ -48,7 +48,9 @@ deterministically; theme from the design system). The thread maps onto the shell
48
48
  = what it re-authored, its side-effects = the ships it produced + any contract re-lock.
49
49
  - **System components** = the artifacts (epic/architecture/contract/ui/stories/test-cases), each labelled
50
50
  with the epic that currently **owns** it (from the resolved map).
51
- - Colour nodes by `kind` (feature/change/defect/hotfix); mark sealed epics and open debt.
51
+ - Label and colour nodes by `kind` render each node's kind noun (**Change request** / **Defect** /
52
+ **Hotfix** / **Epic** for feature) alongside its id, not the generic word "epic"; mark sealed epics and
53
+ open debt. (A bug is a defect — `kind: defect`. Presentation only; every node is still an epic.)
52
54
 
53
55
  ### Step 4 — Emit `thread-resolved.md` (the current-truth map — derived, non-authoritative)
54
56
  Write `epics/<thread>/thread-resolved.md`: for each artifact base, the **owning epic** (the latest in the
@@ -58,7 +60,7 @@ is the file the next `yad-change` / `yad-epic` reads as "the feature's current t
58
60
 
59
61
  ### Step 5 — Emit `TIMELINE.md` + (optional) deploy
60
62
  Write a short `epics/<thread>/TIMELINE.md` (the chain, what each node changed, ships, open debt) for a
61
- plain-text read. On `action: deploy`, `yad docs deploy` the site (build-only when no target).
63
+ plain-text read — head each node with its kind noun (Change request / Defect / Hotfix / Epic) + id. On `action: deploy`, `yad docs deploy` the site (build-only when no target).
62
64
 
63
65
  ## Hard rules
64
66