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 +15 -0
- package/cli/epic-state.mjs +7 -0
- package/cli/manifest.mjs +4 -2
- package/cli/next.mjs +14 -5
- package/cli/plan.mjs +156 -10
- package/cli/reconcile.mjs +51 -4
- package/cli/setup.mjs +23 -9
- package/cli/thread.mjs +6 -3
- package/package.json +1 -1
- package/skills/yad-status/SKILL.md +5 -2
- package/skills/yad-timeline/SKILL.md +4 -2
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
|
|
package/cli/epic-state.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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) =>
|
|
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(
|
|
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,
|
|
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
|
-
//
|
|
40
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
107
|
-
|
|
108
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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.
|
|
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. **
|
|
39
|
-
|
|
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
|
-
-
|
|
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
|
|