yadflow 4.0.0-next.3 → 4.0.0
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 +699 -0
- package/README.md +85 -22
- package/bin/commands.mjs +3 -1
- package/bin/yad.mjs +183 -29
- package/cli/capture.mjs +4 -2
- package/cli/checkpoint.mjs +10 -10
- package/cli/claims.mjs +4 -4
- package/cli/commit.mjs +48 -12
- package/cli/docs.mjs +15 -13
- package/cli/doctor.mjs +557 -141
- package/cli/epic-state.mjs +41 -18
- package/cli/errors.mjs +3 -2
- package/cli/find-product.mjs +197 -0
- package/cli/fold.mjs +4 -4
- package/cli/gate-local.mjs +13 -13
- package/cli/gate.mjs +111 -103
- package/cli/history.mjs +13 -13
- package/cli/hook.mjs +201 -30
- package/cli/index-command.mjs +6 -6
- package/cli/lib.mjs +91 -29
- package/cli/manifest.mjs +171 -104
- package/cli/migrate.mjs +208 -17
- package/cli/mode.mjs +22 -22
- package/cli/next.mjs +6 -6
- package/cli/openpr.mjs +32 -24
- package/cli/people.mjs +22 -10
- package/cli/plan.mjs +718 -45
- package/cli/platform.mjs +13 -7
- package/cli/product-index.mjs +4 -4
- package/cli/{hubcommit.mjs → productcommit.mjs} +11 -7
- package/cli/protection.mjs +19 -9
- package/cli/reconcile.mjs +98 -23
- package/cli/repo-publish.mjs +22 -16
- package/cli/repo.mjs +128 -33
- package/cli/report.mjs +4 -2
- package/cli/review.mjs +26 -19
- package/cli/setup.mjs +105 -36
- package/cli/ship.mjs +1 -1
- package/cli/skip.mjs +4 -4
- package/cli/tidy.mjs +7 -7
- package/cli/update-commit.mjs +22 -19
- package/cli/workspace.mjs +495 -0
- package/package.json +7 -4
- package/skills/sdlc/config.yaml +14 -13
- package/skills/sdlc/module-help.csv +8 -8
- package/skills/yad-analysis/SKILL.md +6 -4
- package/skills/yad-architecture/SKILL.md +4 -3
- package/skills/yad-backfill/SKILL.md +11 -5
- package/skills/yad-backfill/references/backfill.md +33 -3
- package/skills/yad-backfill/templates/checks/backfill-check.sh +200 -17
- package/skills/yad-change/SKILL.md +1 -1
- package/skills/yad-checks/SKILL.md +48 -27
- package/skills/yad-checks/references/check-gates.md +213 -53
- package/skills/yad-checks/templates/checks/commit-message.sh +39 -13
- package/skills/yad-checks/templates/checks/contract-check.sh +472 -23
- package/skills/yad-checks/templates/checks/epic-open.sh +398 -8
- package/skills/yad-checks/templates/checks/ledger-guard.sh +75 -25
- package/skills/yad-checks/templates/checks/lineage-check.sh +391 -7
- package/skills/yad-checks/templates/checks/product-checkout.sh +141 -0
- package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +399 -8
- package/skills/yad-checks/templates/checks/risk-map-check.sh +35 -3
- package/skills/yad-checks/templates/checks/spec-link.sh +36 -3
- package/skills/yad-checks/templates/checks/verified-commits.sh +26 -2
- package/skills/yad-checks/templates/github/yad-checks.yml +33 -13
- package/skills/yad-checks/templates/github/{yad-hub-checks.yml → yad-product-checks.yml} +14 -10
- package/skills/yad-checks/templates/github/yad-update-guard.yml +1 -1
- package/skills/yad-checks/templates/github/yad-verified-commits.yml +1 -1
- package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +12 -0
- package/skills/yad-checks/templates/gitlab/{yad-hub-checks.gitlab-ci.yml → yad-product-checks.gitlab-ci.yml} +16 -16
- package/skills/yad-checks/templates/gitlab/yad-verified-commits.gitlab-ci.yml +2 -2
- package/skills/yad-checks/templates/hooks/ledger-guard-cursor.mjs +130 -0
- package/skills/yad-checks/templates/hooks/ledger-guard.mjs +117 -0
- package/skills/yad-checks/templates/hooks/yad-capture.mjs +117 -0
- package/skills/yad-commit/SKILL.md +18 -7
- package/skills/yad-connect-design/SKILL.md +1 -1
- package/skills/yad-connect-design/references/design-registry.md +1 -1
- package/skills/yad-connect-docs/SKILL.md +16 -11
- package/skills/yad-connect-docs/references/docs-registry.md +8 -8
- package/skills/yad-connect-learning/SKILL.md +1 -1
- package/skills/yad-connect-learning/references/learning-registry.md +1 -1
- package/skills/yad-connect-repos/SKILL.md +31 -8
- package/skills/yad-connect-repos/references/code-context.md +1 -1
- package/skills/yad-connect-repos/references/{hub-config.md → product-config.md} +19 -11
- package/skills/yad-connect-repos/references/repos-registry.md +1 -1
- package/skills/yad-connect-testing/SKILL.md +1 -1
- package/skills/yad-connect-testing/references/testing-registry.md +1 -1
- package/skills/yad-discovery/SKILL.md +2 -2
- package/skills/yad-discovery/references/discovery-schema.md +1 -1
- package/skills/yad-docs/SKILL.md +1 -1
- package/skills/yad-docs/references/data-mapping.md +1 -1
- package/skills/yad-docs/templates/app/package-lock.json +8 -8
- package/skills/yad-docs/templates/app/package.json +1 -1
- package/skills/yad-docs/templates/app/src/components/Canvas/FlowCanvas.tsx +2 -2
- package/skills/yad-docs-overview/references/pipeline-model.md +3 -3
- package/skills/yad-engineer-review/SKILL.md +2 -2
- package/skills/yad-engineer-review/references/ship-and-record.md +3 -3
- package/skills/yad-epic/SKILL.md +6 -4
- package/skills/yad-epic/references/state-schema.md +13 -12
- package/skills/yad-open-pr/SKILL.md +1 -1
- package/skills/yad-pair-review/SKILL.md +5 -5
- package/skills/yad-pair-review/references/session-state.md +2 -2
- package/skills/yad-pr-template/SKILL.md +14 -12
- package/skills/yad-pr-template/templates/checks/pr-template.sh +18 -16
- package/skills/yad-pr-template/templates/checks/pr-title.sh +15 -13
- package/skills/yad-pr-template/templates/{hub → product}/github/pull_request_template.md +2 -2
- package/skills/yad-pr-template/templates/{hub → product}/gitlab/merge_request_templates/Default.md +2 -2
- package/skills/{yad-hub-bridge → yad-product-bridge}/SKILL.md +15 -15
- package/skills/{yad-hub-bridge → yad-product-bridge}/references/bridge.md +15 -10
- package/skills/{yad-hub-bridge → yad-product-bridge}/references/login-roster.md +2 -2
- package/skills/{yad-hub-bridge/templates/checks/hub-route.sh → yad-product-bridge/templates/checks/product-route.sh} +2 -2
- package/skills/{yad-hub-bridge → yad-product-bridge}/templates/github/yad-gate-sync.yml +29 -16
- package/skills/{yad-hub-bridge → yad-product-bridge}/templates/gitlab/gitlab-ci.include-root.yml +1 -1
- package/skills/{yad-hub-bridge → yad-product-bridge}/templates/gitlab/yad-gate-sync.gitlab-ci.yml +5 -3
- package/skills/yad-report/SKILL.md +1 -1
- package/skills/yad-review-companion/SKILL.md +3 -3
- package/skills/yad-review-gate/SKILL.md +22 -23
- package/skills/yad-review-gate/references/gating.md +13 -13
- package/skills/yad-run/SKILL.md +2 -2
- package/skills/yad-run/references/run-loop.md +2 -2
- package/skills/yad-spec/SKILL.md +2 -1
- package/skills/yad-spec/references/spec-handoff.md +19 -10
- package/skills/yad-stories/SKILL.md +2 -2
- package/skills/yad-sync-repos/SKILL.md +19 -3
- package/skills/yad-test-cases/SKILL.md +2 -2
- package/skills/yad-ui/SKILL.md +2 -2
- package/skills/yad-checks/templates/hooks/ledger-guard-cursor.sh +0 -91
- package/skills/yad-checks/templates/hooks/ledger-guard.sh +0 -100
- package/skills/yad-checks/templates/hooks/yad-capture.sh +0 -53
package/cli/migrate.mjs
CHANGED
|
@@ -17,16 +17,17 @@
|
|
|
17
17
|
import fs from 'node:fs';
|
|
18
18
|
import path from 'node:path';
|
|
19
19
|
|
|
20
|
-
import { c, exists, fail, hand, info, isPlainObject, log, ok, readJSON, warn, writeJSON, writeProductConfig, emitJSON, collectWarning } from './lib.mjs';
|
|
20
|
+
import { ask, c, exists, fail, hand, info, isPlainObject, log, ok, readJSON, warn, writeFileAtomic, writeJSON, writeProductConfig, emitJSON, collectWarning } from './lib.mjs';
|
|
21
|
+
import { err } from './errors.mjs';
|
|
21
22
|
import {
|
|
22
23
|
ADVANCE_FROM_AUTOMATION, BACKUP_SUFFIX, DRIVER_FROM_ASSISTANCE, epicFiles, isVerifiedLedger,
|
|
23
|
-
MANAGED_LEDGER, MIRRORED_FILES, PROJECT_FILES, preferring, productConfigPath, SCHEMA_VERSION,
|
|
24
|
+
MANAGED_LEDGER, MIRRORED_FILES, PRODUCT_RENAME_GUIDE_URL, PROJECT_FILES, preferring, productConfigPath, SCHEMA_VERSION,
|
|
24
25
|
VERSION,
|
|
25
26
|
} from './manifest.mjs';
|
|
26
27
|
import { backupPathFor } from './plan.mjs';
|
|
27
28
|
import { refreshIndexAfterWrite } from './product-index.mjs';
|
|
28
29
|
import {
|
|
29
|
-
artifactHash, canonicalApprovals, canonicalComments,
|
|
30
|
+
artifactHash, canonicalApprovals, canonicalComments, canonicalProductPrs, DISCOVERY_EPIC, epicIds, epicRel, productDriftPairs,
|
|
30
31
|
epicRoot, FOUNDATION_DIR, FOUNDATION_EPIC, isGateStep, isPassed, stampProfile, stampStepStates, stampWorkItemType, writeState,
|
|
31
32
|
} from './epic-state.mjs';
|
|
32
33
|
|
|
@@ -77,6 +78,7 @@ export const MIGRATIONS = [
|
|
|
77
78
|
? { ...obj, ledger: isVerifiedLedger(obj) ? 'verified' : 'local' }
|
|
78
79
|
: obj),
|
|
79
80
|
},
|
|
81
|
+
// hub-keep:start shape-3-step
|
|
80
82
|
{
|
|
81
83
|
from: 2,
|
|
82
84
|
to: 3,
|
|
@@ -117,6 +119,7 @@ export const MIGRATIONS = [
|
|
|
117
119
|
};
|
|
118
120
|
},
|
|
119
121
|
},
|
|
122
|
+
// hub-keep:end
|
|
120
123
|
{
|
|
121
124
|
from: 3,
|
|
122
125
|
to: 4,
|
|
@@ -494,8 +497,8 @@ export function applyProductMove(root, move, { migrations = MIGRATIONS, copy = f
|
|
|
494
497
|
};
|
|
495
498
|
relabel(f.approvals, canonicalApprovals);
|
|
496
499
|
relabel(f.comments, canonicalComments);
|
|
497
|
-
relabel(f.productPrs,
|
|
498
|
-
relabel(f.
|
|
500
|
+
relabel(f.productPrs, canonicalProductPrs);
|
|
501
|
+
relabel(f.productPrsLegacy, canonicalProductPrs);
|
|
499
502
|
for (const rel of move.reshape || []) {
|
|
500
503
|
const file = path.join(root, rel);
|
|
501
504
|
const { obj, version } = applyMigrations(JSON.parse(fs.readFileSync(file, 'utf8')), migrations, { base: path.basename(file), rel: path.join(...rel.split('/')), root });
|
|
@@ -689,7 +692,7 @@ export function projectJsonFiles(root) {
|
|
|
689
692
|
for (const epic of epicIds(root)) {
|
|
690
693
|
const epicDir = epicRoot(root, epic);
|
|
691
694
|
const f = epicFiles(epicDir);
|
|
692
|
-
files.push(f.state, f.approvals, f.comments, preferring(f.productPrs, f.
|
|
695
|
+
files.push(f.state, f.approvals, f.comments, preferring(f.productPrs, f.productPrsLegacy), f.contractLock,
|
|
693
696
|
f.buildLog, f.trustLog, f.change, f.reconcileDebt);
|
|
694
697
|
files.push(...shardFiles(f.buildLogDir), ...shardFiles(f.trustLogDir), ...shardFiles(f.buildStateDir));
|
|
695
698
|
// The docs-build cache (cli/docs.mjs) lives in the same directory and is written by the engine,
|
|
@@ -717,8 +720,8 @@ export function projectJsonFiles(root) {
|
|
|
717
720
|
// ANY reason (a hand re-indent, say), and `ci-owned`/`ahead`/`list` short-circuit before the byte
|
|
718
721
|
// comparison happens at all.
|
|
719
722
|
export function planMigration(root, { migrations = MIGRATIONS } = {}) {
|
|
720
|
-
const
|
|
721
|
-
const verified = isVerifiedLedger(
|
|
723
|
+
const productConfig = readJSON(productConfigPath(root), null);
|
|
724
|
+
const verified = isVerifiedLedger(productConfig);
|
|
722
725
|
// On a verified Product the ledger guard refuses a human commit to these, so rewriting them locally
|
|
723
726
|
// would produce a change that cannot be committed. Of the four the guard names, only state.json is
|
|
724
727
|
// an object; the rest are arrays and would be skipped anyway.
|
|
@@ -733,8 +736,10 @@ export function planMigration(root, { migrations = MIGRATIONS } = {}) {
|
|
|
733
736
|
|
|
734
737
|
const rows = [];
|
|
735
738
|
for (const file of projectJsonFiles(root)) {
|
|
736
|
-
|
|
737
|
-
|
|
739
|
+
// Forward slashes on every OS: the mirrored-pair, state-file and drift-repair checks all compare
|
|
740
|
+
// project-relative paths spelled that way, and on Windows `path.relative` says `.sdlc\product.json`.
|
|
741
|
+
const rel = path.relative(root, file).split(path.sep).join('/');
|
|
742
|
+
if (moving && rel.startsWith(moving)) continue;
|
|
738
743
|
const raw = readRaw(file);
|
|
739
744
|
if (!raw.ok) {
|
|
740
745
|
rows.push({ file: rel, from: null, to: null, action: 'unreadable', changes: false, stamped: false, detail: raw.error });
|
|
@@ -857,6 +862,147 @@ function printProductMove(p, { apply }) {
|
|
|
857
862
|
log(` ${c.dim(`a full copy stays at ${p.backup}/`)}`);
|
|
858
863
|
}
|
|
859
864
|
|
|
865
|
+
// ---- two names that say different things (E122) -----------------------------------------------
|
|
866
|
+
//
|
|
867
|
+
// `.sdlc/product.json` + `.sdlc/hub.json`, and each epic's `product-prs.json` + `hub-prs.json`, are one
|
|
868
|
+
// file under two names until v5. When they disagree every other command refuses (YAD-STATE-008), and
|
|
869
|
+
// this is the command that ends it. It runs whatever shape the project is on: the drift is not a shape
|
|
870
|
+
// step, it is a repair — like the product-level move above, work outside the per-file rows.
|
|
871
|
+
//
|
|
872
|
+
// Only a person knows which copy they meant, so nothing is chosen for them: the preview shows the
|
|
873
|
+
// difference, and `--apply` asks — or, with no terminal or under `--json` (where a command never asks,
|
|
874
|
+
// E1), takes `--keep product` / `--keep hub` and refuses without it. The copy kept is written over the
|
|
875
|
+
// other one byte for byte, after the other one is backed up beside itself as `<file>.yad-orig`.
|
|
876
|
+
export const KEEP_CHOICES = ['product', 'hub'];
|
|
877
|
+
|
|
878
|
+
// What differs, in words a person can check against the files: the top-level keys of an object, the
|
|
879
|
+
// positions of an array. Never the values — they can be long, and the files are right there.
|
|
880
|
+
export function describeDrift(aPath, bPath) {
|
|
881
|
+
let a; let b;
|
|
882
|
+
try { a = JSON.parse(fs.readFileSync(aPath, 'utf8')); } catch { return [`${path.basename(aPath)} does not parse`]; }
|
|
883
|
+
try { b = JSON.parse(fs.readFileSync(bPath, 'utf8')); } catch { return [`${path.basename(bPath)} does not parse`]; }
|
|
884
|
+
const same = (x, y) => JSON.stringify(x) === JSON.stringify(y);
|
|
885
|
+
if (isPlainObject(a) && isPlainObject(b)) {
|
|
886
|
+
const keys = [...new Set([...Object.keys(a), ...Object.keys(b)])].sort();
|
|
887
|
+
const out = keys.filter((k) => !same(a[k], b[k])).map((k) => (!(k in a) ? `\`${k}\` only in ${path.basename(bPath)}` : !(k in b) ? `\`${k}\` only in ${path.basename(aPath)}` : `\`${k}\` differs`));
|
|
888
|
+
// Same keys and values in another order, or only the spacing differs: still two different files.
|
|
889
|
+
return out.length ? out : ['the same settings, written differently (spacing or key order)'];
|
|
890
|
+
}
|
|
891
|
+
if (Array.isArray(a) && Array.isArray(b)) {
|
|
892
|
+
const n = Math.max(a.length, b.length);
|
|
893
|
+
const at = [];
|
|
894
|
+
for (let i = 0; i < n; i++) if (!same(a[i], b[i])) at.push(i + 1);
|
|
895
|
+
const count = a.length === b.length ? '' : `${path.basename(aPath)} has ${a.length} entries, ${path.basename(bPath)} ${b.length}; `;
|
|
896
|
+
return at.length ? [`${count}entries ${at.join(', ')} differ`] : ['the same entries, written differently (spacing or key order)'];
|
|
897
|
+
}
|
|
898
|
+
return ['the two files hold different kinds of value'];
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
const bytesOrNull = (abs) => { try { return fs.readFileSync(abs); } catch { return null; } };
|
|
902
|
+
|
|
903
|
+
// Every drifted pair under `root`, project-relative, with what differs.
|
|
904
|
+
export function driftedPairs(root) {
|
|
905
|
+
return productDriftPairs(root).map(({ canonical, legacy }) => {
|
|
906
|
+
const pair = { canonical, legacy, differences: describeDrift(path.join(root, canonical), path.join(root, legacy)) };
|
|
907
|
+
// What each name held when the difference was shown — not part of the report (not enumerable), but
|
|
908
|
+
// `checkDrift` refuses a pair that has changed since, because it is not the pair the person chose for.
|
|
909
|
+
Object.defineProperty(pair, 'seen', { value: [canonical, legacy].map((rel) => bytesOrNull(path.join(root, rel))) });
|
|
910
|
+
return pair;
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// Settle each pair on the copy `keep` names, in two steps: `checkDrift` first, which writes nothing and
|
|
915
|
+
// refuses if any pair cannot be settled, so a refusal leaves every pair — and `.gitignore` — as it was;
|
|
916
|
+
// then `settleDrift`, which writes.
|
|
917
|
+
// - a name that is a symbolic link is refused. The kept copy's bytes are copied into a committed file,
|
|
918
|
+
// and a link would copy whatever file it points at on this machine (E115 refuses links in the same
|
|
919
|
+
// spirit). The file itself is checked, not the folders above it: a linked `.sdlc/` holds both names,
|
|
920
|
+
// so nothing from outside it can reach a committed file through it;
|
|
921
|
+
// - a kept copy that does not parse is refused: settling on it would leave two broken files, and every
|
|
922
|
+
// command refusing them for another reason;
|
|
923
|
+
// - a name that is not a plain readable file (a folder, a file yad cannot read) is refused;
|
|
924
|
+
// - a name that has gone, or changed, since the difference was found (while the question waited) is
|
|
925
|
+
// refused too: the pair is not the one the person chose for.
|
|
926
|
+
function settlePlan(root, pairs, keep) {
|
|
927
|
+
return pairs.map(({ canonical, legacy }) => {
|
|
928
|
+
const [from, to] = keep === 'product' ? [canonical, legacy] : [legacy, canonical];
|
|
929
|
+
return { from, to, src: path.join(root, from), dst: path.join(root, to) };
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
function checkDrift(root, pairs, keep) {
|
|
934
|
+
const plan = settlePlan(root, pairs, keep);
|
|
935
|
+
for (const [i, { from, to, src }] of plan.entries()) {
|
|
936
|
+
for (const rel of [from, to]) {
|
|
937
|
+
let st;
|
|
938
|
+
try { st = fs.lstatSync(path.join(root, rel)); } catch (e) {
|
|
939
|
+
if (e.code !== 'ENOENT') throw e;
|
|
940
|
+
throw err('YAD-STATE-008', `${rel} is gone since the difference was found — nothing was written`, 'run `yad migrate` again to see the pair as it is now');
|
|
941
|
+
}
|
|
942
|
+
if (st.isSymbolicLink()) {
|
|
943
|
+
throw err('YAD-STATE-008', `${rel} is a symbolic link — yad does not settle a pair through one, and nothing was written`, 'replace it with the file itself (or delete it and keep the other name), then run `yad migrate --apply` again');
|
|
944
|
+
}
|
|
945
|
+
if (!st.isFile()) throw err('YAD-STATE-008', `${rel} is not a file — nothing was written`, `make ${rel} a file again (restore it from git), then run \`yad migrate --apply\` again`);
|
|
946
|
+
}
|
|
947
|
+
const seen = pairs[i].seen;
|
|
948
|
+
const now = [pairs[i].canonical, pairs[i].legacy].map((rel) => bytesOrNull(path.join(root, rel)));
|
|
949
|
+
if (now.some((b) => b === null)) throw err('YAD-STATE-008', `${from} or ${to} cannot be read — nothing was written`, 'check the two files can be read, then run `yad migrate --apply` again');
|
|
950
|
+
if (seen && seen.some((b, j) => !b || !b.equals(now[j]))) {
|
|
951
|
+
throw err('YAD-STATE-008', `${pairs[i].canonical} or ${pairs[i].legacy} changed since the difference was shown — nothing was written`, 'run `yad migrate` again to see the pair as it is now');
|
|
952
|
+
}
|
|
953
|
+
try { JSON.parse(fs.readFileSync(src, 'utf8')); } catch {
|
|
954
|
+
throw err('YAD-STATE-001', `${from} does not parse, so it cannot be the copy kept — nothing was written`, `fix it or restore it from git, or keep the other copy (--keep ${keep === 'product' ? 'hub' : 'product'})`);
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// Returns the paths written (the losing names). Only after `checkDrift` has passed.
|
|
960
|
+
function settleDrift(root, pairs, keep) {
|
|
961
|
+
const written = [];
|
|
962
|
+
for (const { to, src, dst } of settlePlan(root, pairs, keep)) {
|
|
963
|
+
writeFileAtomic(backupPathFor(dst), fs.readFileSync(dst));
|
|
964
|
+
// The kept copy's exact bytes — the gates compare with `cmp -s` — written through a temporary file
|
|
965
|
+
// and a rename, so a crash never leaves a half file.
|
|
966
|
+
writeFileAtomic(dst, fs.readFileSync(src));
|
|
967
|
+
written.push(to);
|
|
968
|
+
}
|
|
969
|
+
return written;
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// Which copy wins: the flag, else the person at the terminal. `choose` stands in for the prompt in tests.
|
|
973
|
+
async function chooseKeep(pairs, { keep, json, choose }) {
|
|
974
|
+
if (keep) return keep;
|
|
975
|
+
const flagHint = 'pass `--keep product` to keep .sdlc/product.json (and each product-prs.json), or `--keep hub` to keep the old names — `yad migrate` shows the difference first';
|
|
976
|
+
if (json) throw err('YAD-CLI-001', 'a --json run cannot ask which copy to keep', flagHint);
|
|
977
|
+
if (!choose && (!process.stdin.isTTY || process.env.SDLC_NONINTERACTIVE)) throw err('YAD-STATE-008', 'there is no terminal to ask which copy to keep', flagHint);
|
|
978
|
+
const question = `${pairs.length === 1 ? 'Two copies disagree' : `${pairs.length} pairs disagree`} — keep which: product (the new name) or hub (the old name)? Enter to stop`;
|
|
979
|
+
const answer = String(await (choose ? choose(question) : ask(question, ''))).trim().toLowerCase();
|
|
980
|
+
if (!answer) throw err('YAD-STATE-008', 'stopped — nothing was written', flagHint);
|
|
981
|
+
if (!KEEP_CHOICES.includes(answer)) throw err('YAD-STATE-008', `"${answer}" is not product or hub — nothing was written`, flagHint);
|
|
982
|
+
return answer;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
function printDrift(pairs) {
|
|
986
|
+
log('');
|
|
987
|
+
log(c.bold(' two names that disagree'));
|
|
988
|
+
for (const p of pairs) {
|
|
989
|
+
log(` ${p.canonical} ≠ ${p.legacy}`);
|
|
990
|
+
for (const d of p.differences) log(` ${c.dim(d)}`);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// What this command does NOT rename, said once, so nobody takes a finished migrate for a finished
|
|
995
|
+
// rename. Printed only while the old settings name is still on disk.
|
|
996
|
+
function printNotRenamedHere(root) {
|
|
997
|
+
if (!exists(path.join(root, PROJECT_FILES.productConfigLegacy))) return;
|
|
998
|
+
log('');
|
|
999
|
+
log(c.bold(' hub → Product'));
|
|
1000
|
+
log(` ${PROJECT_FILES.productConfig} is the name read; ${PROJECT_FILES.productConfigLegacy} is still written beside it for check gates an older yadflow installed, and v5 deletes it`);
|
|
1001
|
+
log(` ${c.dim('not done here:')}`);
|
|
1002
|
+
log(` ${c.dim('the names installed into your repos (the yad-hub-checks workflow → yad-product-checks, the yad-hub-bridge skill → yad-product-bridge, the GitLab yad-hub-* jobs → yad-product-*) — `yad update` renames those')}`);
|
|
1003
|
+
log(` ${c.dim('your own CI settings and scripts that name .sdlc/hub.json, hub-prs.json or SDLC_HUB_CONFIG — they work until v5; change them to .sdlc/product.json, product-prs.json and SDLC_PRODUCT_CONFIG by hand')}`);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
860
1006
|
// ---- the command ---------------------------------------------------------------------------
|
|
861
1007
|
// `.sdlc/cli-version.json` is migrated as an ordinary row, like every other project file — it is in
|
|
862
1008
|
// PROJECT_FILES, so it is previewed, backed up and stamped along with the rest. Nothing here writes it
|
|
@@ -864,7 +1010,10 @@ function printProductMove(p, { apply }) {
|
|
|
864
1010
|
// which means no backup, no row in the report, and a preview that under-reports what an apply does. It
|
|
865
1011
|
// would also be free to overwrite the one file the same run had just declared corrupt or newer than
|
|
866
1012
|
// this engine. What shape a file is in is recorded in that file, which is the whole point of rule 1.
|
|
867
|
-
export async function runMigrate(root, { apply = false, json = false } = {}, { migrations = MIGRATIONS, copy = fs.cpSync } = {}) {
|
|
1013
|
+
export async function runMigrate(root, { apply = false, json = false, keep = null } = {}, { migrations = MIGRATIONS, copy = fs.cpSync, choose = null } = {}) {
|
|
1014
|
+
if (keep != null && !KEEP_CHOICES.includes(keep)) {
|
|
1015
|
+
throw err('YAD-STATE-008', `--keep takes product or hub, not "${keep}"`, '`--keep product` keeps .sdlc/product.json (and each product-prs.json); `--keep hub` keeps the old names');
|
|
1016
|
+
}
|
|
868
1017
|
if (!exists(path.join(root, PROJECT_FILES.version)) && !exists(productConfigPath(root))) {
|
|
869
1018
|
const message = 'no yad project here (.sdlc/ not initialised)';
|
|
870
1019
|
if (json) { emitJSON({ ok: false, error: message }); }
|
|
@@ -873,20 +1022,40 @@ export async function runMigrate(root, { apply = false, json = false } = {}, { m
|
|
|
873
1022
|
return { ok: false };
|
|
874
1023
|
}
|
|
875
1024
|
|
|
1025
|
+
// E122: settled first, so the plan below reads the copy that was kept.
|
|
1026
|
+
const drift = driftedPairs(root);
|
|
1027
|
+
let kept = null;
|
|
1028
|
+
let ignored = false;
|
|
1029
|
+
const settledFiles = [];
|
|
1030
|
+
if (drift.length && apply) {
|
|
1031
|
+
if (!json) printDrift(drift);
|
|
1032
|
+
kept = await chooseKeep(drift, { keep, json, choose });
|
|
1033
|
+
checkDrift(root, drift, kept);
|
|
1034
|
+
// Before the backup is made, as below — and after every check, so a refusal changes nothing.
|
|
1035
|
+
ignored = ensureBackupsIgnored(root);
|
|
1036
|
+
settledFiles.push(...settleDrift(root, drift, kept));
|
|
1037
|
+
}
|
|
1038
|
+
|
|
876
1039
|
const plan = planMigration(root, { migrations });
|
|
1040
|
+
// On a verified Product an epic's PR ledger is CI's to write, and `ledger-guard` refuses a person's
|
|
1041
|
+
// commit of it. The repair is still made — the pair has to agree before any command runs — and the
|
|
1042
|
+
// way to commit it is named: a drift there came from a hand edit that got past the guard already.
|
|
1043
|
+
const ciOwnedSettled = plan.verified ? settledFiles.filter((f) => !f.startsWith('.sdlc/')) : [];
|
|
1044
|
+
if (ciOwnedSettled.length) {
|
|
1045
|
+
warn(`${ciOwnedSettled.join(', ')} ${ciOwnedSettled.length === 1 ? 'is' : 'are'} CI-owned on this verified Product: the ledger-guard check refuses a hand commit of ${ciOwnedSettled.length === 1 ? 'it' : 'them'} — commit the repair with \`yad commit --manual --reason "<why>" --type chore -m "<subject>"\`, which records the reason the check quotes`);
|
|
1046
|
+
}
|
|
877
1047
|
const move = plan.product?.action === 'move' ? plan.product : null;
|
|
878
1048
|
const pending = plan.rows.filter((r) => r.changes);
|
|
879
1049
|
const blocked = plan.rows.filter((r) => r.action === 'ahead' || r.action === 'unreadable');
|
|
880
1050
|
// An unreadable product-level ledger blocks like an unreadable file. The other refusals do not: the
|
|
881
1051
|
// project stays on the old spelling, which this release reads correctly, so nothing is broken.
|
|
882
1052
|
const productBlocked = plan.product?.action === 'unreadable';
|
|
883
|
-
const written = [];
|
|
1053
|
+
const written = [...settledFiles];
|
|
884
1054
|
|
|
885
|
-
let ignored = false;
|
|
886
1055
|
if (apply) {
|
|
887
1056
|
// Before the first backup exists, not after — otherwise a gate advance racing this run could stage
|
|
888
1057
|
// one. A no-op when the line is already there.
|
|
889
|
-
if (pending.length || move) ignored = ensureBackupsIgnored(root);
|
|
1058
|
+
if (pending.length || move) ignored = ensureBackupsIgnored(root) || ignored;
|
|
890
1059
|
if (move) {
|
|
891
1060
|
// Reported through `fail`, never as a stack trace, and before any per-file write: a move that did
|
|
892
1061
|
// not happen leaves the project exactly as it was, so the rest of this run must not half-proceed.
|
|
@@ -906,7 +1075,9 @@ export async function runMigrate(root, { apply = false, json = false } = {}, { m
|
|
|
906
1075
|
// Back up first, always. Unlike the wiring copies in plan.mjs — which skip the backup when the
|
|
907
1076
|
// file's bytes are provably ours — a ledger has no provenance record, so there is nothing to
|
|
908
1077
|
// prove and the copy is unconditional.
|
|
909
|
-
|
|
1078
|
+
// A file the drift repair above already backed up keeps THAT backup: it is the copy the person did
|
|
1079
|
+
// not keep, and its only record. Its bytes now are the kept copy's, backed up under the other name.
|
|
1080
|
+
if (!settledFiles.includes(row.file)) fs.copyFileSync(file, backupPathFor(file));
|
|
910
1081
|
// A mirrored row writes its partner too, so the partner needs a backup of its OWN pre-migration
|
|
911
1082
|
// bytes — taken here, before either write. Backing up only the row's file leaves the other name
|
|
912
1083
|
// rewritten with no way back, and on the NEXT shape bump its `.yad-orig` would be overwritten
|
|
@@ -914,7 +1085,7 @@ export async function runMigrate(root, { apply = false, json = false } = {}, { m
|
|
|
914
1085
|
const partnerRel = isMirroredPath(row.file) ? mirrorPartner(row.file) : null;
|
|
915
1086
|
if (partnerRel) {
|
|
916
1087
|
const partnerFile = path.join(root, partnerRel);
|
|
917
|
-
if (exists(partnerFile)) fs.copyFileSync(partnerFile, backupPathFor(partnerFile));
|
|
1088
|
+
if (exists(partnerFile) && !settledFiles.includes(partnerRel)) fs.copyFileSync(partnerFile, backupPathFor(partnerFile));
|
|
918
1089
|
}
|
|
919
1090
|
// The SAME ctx the preview used. Passing it in one place and not the other is how a preview
|
|
920
1091
|
// promises one thing and an apply writes another — the single worst failure this command can
|
|
@@ -947,7 +1118,11 @@ export async function runMigrate(root, { apply = false, json = false } = {}, { m
|
|
|
947
1118
|
// The preview names each row's mirror partner too (`creates` / `rewrites`), exactly as the text
|
|
948
1119
|
// report does: the apply writes the product config under BOTH names, so leaving the partner out
|
|
949
1120
|
// made this list shorter than the apply's `written` — a preview that under-reports.
|
|
1121
|
+
// A drifted pair names BOTH its names on a preview: which one the apply rewrites depends on the
|
|
1122
|
+
// copy the person keeps, and a preview may over-report, never under-report. Its `rows` are planned
|
|
1123
|
+
// from the copy read now (the new name); an apply that keeps the old copy plans from that one.
|
|
950
1124
|
changed: apply ? written : [...new Set([
|
|
1125
|
+
...drift.flatMap((d) => [d.canonical, d.legacy]),
|
|
951
1126
|
...(move ? productMoveFiles(move) : []),
|
|
952
1127
|
...pending.flatMap((r) => [r.file, ...(r.creates ?? []), ...(r.rewrites ?? [])]),
|
|
953
1128
|
])],
|
|
@@ -955,15 +1130,28 @@ export async function runMigrate(root, { apply = false, json = false } = {}, { m
|
|
|
955
1130
|
rows: plan.rows,
|
|
956
1131
|
// The product-level move (shape 8), or null when the project has no old-spelling product level.
|
|
957
1132
|
product: plan.product,
|
|
1133
|
+
// E122: the pairs whose two names disagreed, and which copy was kept (null on a preview).
|
|
1134
|
+
drift,
|
|
1135
|
+
kept,
|
|
958
1136
|
});
|
|
959
1137
|
} else {
|
|
960
1138
|
log(c.bold(`\nyad migrate ${c.dim(`shape ${plan.engine}`)}`));
|
|
961
1139
|
log(c.dim(`target: ${root}\n`));
|
|
962
1140
|
printRows(plan.rows);
|
|
963
1141
|
if (plan.product) printProductMove(plan.product, { apply });
|
|
1142
|
+
if (drift.length) {
|
|
1143
|
+
if (apply) ok(`kept the ${kept === 'product' ? 'new' : 'old'} name's copy of ${drift.length === 1 ? 'the pair' : `${drift.length} pairs`} — the other is backed up beside itself as <file>${BACKUP_SUFFIX}`);
|
|
1144
|
+
else printDrift(drift);
|
|
1145
|
+
}
|
|
1146
|
+
printNotRenamedHere(root);
|
|
964
1147
|
log('');
|
|
1148
|
+
if (drift.length && !apply) {
|
|
1149
|
+
info('every other yad command refuses until one copy is chosen');
|
|
1150
|
+
hand('run `yad migrate --apply` to choose which copy to keep (or `--apply --keep product` / `--keep hub`)');
|
|
1151
|
+
}
|
|
965
1152
|
if (!pending.length && !move) {
|
|
966
|
-
ok(`nothing to do — this project is already on shape ${plan.engine}`);
|
|
1153
|
+
if (!drift.length) ok(`nothing to do — this project is already on shape ${plan.engine}`);
|
|
1154
|
+
else if (apply) ok(`nothing else to do — this project is already on shape ${plan.engine}`);
|
|
967
1155
|
} else if (apply) {
|
|
968
1156
|
if (move) ok(`the product level moved to ${move.to}/ as ${FOUNDATION_EPIC} — the original is kept whole at ${move.backup}/`);
|
|
969
1157
|
ok(`${written.length} file(s) updated — a copy of each is beside it as <file>${BACKUP_SUFFIX}`);
|
|
@@ -983,6 +1171,9 @@ export async function runMigrate(root, { apply = false, json = false } = {}, { m
|
|
|
983
1171
|
if (r.action === 'unreadable') hand(`${r.file} does not parse — restore it from git before migrating`);
|
|
984
1172
|
}
|
|
985
1173
|
if (blocked.length) warn('some files were left untouched — see above');
|
|
1174
|
+
if (apply && written.length) hand('then run `yad update`, which refreshes the check gates installed in your repos');
|
|
1175
|
+
// E124: the last line, for as long as the old settings name is on disk — the changes no command makes.
|
|
1176
|
+
if (exists(path.join(root, PROJECT_FILES.productConfigLegacy))) info(`the hub → Product changes only you can make (your own CI settings, scripts, badges), and the order to run things in: ${PRODUCT_RENAME_GUIDE_URL}`);
|
|
986
1177
|
}
|
|
987
1178
|
|
|
988
1179
|
if (blocked.length || productBlocked) process.exitCode = 1;
|
package/cli/mode.mjs
CHANGED
|
@@ -29,7 +29,7 @@ export const MODES = ['solo', 'team'];
|
|
|
29
29
|
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
30
30
|
|
|
31
31
|
// The mode the gates act on today. Read from `solo` (and the older `review_gate.solo`), never from `mode`.
|
|
32
|
-
export const modeOf = (
|
|
32
|
+
export const modeOf = (productConfig) => (isSolo(productConfig) ? 'solo' : 'team');
|
|
33
33
|
|
|
34
34
|
// PURE. The fields a write of mode `to` puts on the Product config, given what is there now. Shared by this
|
|
35
35
|
// command and `yad setup`, so the two writers can never spell the switch differently (staged-rename trap 1).
|
|
@@ -37,8 +37,8 @@ export const modeOf = (hub) => (isSolo(hub) ? 'solo' : 'team');
|
|
|
37
37
|
// review_gate `review_gate.solo: true` also switches solo on, so switching to team turns it off too;
|
|
38
38
|
// otherwise the old flag would keep the gates waived under a file that says `team`.
|
|
39
39
|
// mode_set only when the mode the gates act on actually changes: who, when, why, and from what.
|
|
40
|
-
export function modeFields(
|
|
41
|
-
const cur = isObj(
|
|
40
|
+
export function modeFields(productConfig, to, { by = null, date = null, reason = null } = {}) {
|
|
41
|
+
const cur = isObj(productConfig) ? productConfig : {};
|
|
42
42
|
const from = modeOf(cur);
|
|
43
43
|
const fields = { solo: to === 'solo', mode: to };
|
|
44
44
|
if (to === 'team' && isObj(cur.review_gate) && cur.review_gate.solo === true) {
|
|
@@ -52,11 +52,11 @@ export function modeFields(hub, to, { by = null, date = null, reason = null } =
|
|
|
52
52
|
// changed: false the file already says it both ways — nothing to write.
|
|
53
53
|
// flipped: true the gates act differently from the next sync; `mode_set` records it.
|
|
54
54
|
// flipped: false, changed the gates act the same, and the new name is added beside the old one.
|
|
55
|
-
export function planMode(
|
|
55
|
+
export function planMode(productConfig, { to, reason = null, by = null, date = null } = {}) {
|
|
56
56
|
if (!MODES.includes(to)) {
|
|
57
57
|
return { ok: false, message: `unknown mode: ${to ?? '(none)'}`, hint: 'usage: yad mode [solo --reason "<why>" | team [--reason "<why>"]]' };
|
|
58
58
|
}
|
|
59
|
-
const cur = isObj(
|
|
59
|
+
const cur = isObj(productConfig) ? productConfig : {};
|
|
60
60
|
const from = modeOf(cur);
|
|
61
61
|
const why = typeof reason === 'string' ? reason.trim() : '';
|
|
62
62
|
if (from !== to && to === 'solo' && !why) {
|
|
@@ -69,7 +69,7 @@ export function planMode(hub, { to, reason = null, by = null, date = null } = {}
|
|
|
69
69
|
const fields = modeFields(cur, to, { by, date, reason: why || null });
|
|
70
70
|
const next = { ...cur, ...fields };
|
|
71
71
|
const changed = Object.keys(fields).some((k) => JSON.stringify(cur[k]) !== JSON.stringify(fields[k]));
|
|
72
|
-
return { ok: true, from, to, flipped: from !== to, changed,
|
|
72
|
+
return { ok: true, from, to, flipped: from !== to, changed, productConfig: next };
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
// Every review gate that is open right now, across the feature epics and the Foundation. These are the ones
|
|
@@ -113,22 +113,22 @@ export async function runMode(root, { to = null, reason = null, json = false, to
|
|
|
113
113
|
const bail = makeBail(json);
|
|
114
114
|
const file = productConfigPath(root);
|
|
115
115
|
const rel = path.relative(root, file).split(path.sep).join('/');
|
|
116
|
-
let
|
|
116
|
+
let productConfig;
|
|
117
117
|
try {
|
|
118
|
-
|
|
118
|
+
productConfig = readJSONStrict(file, null);
|
|
119
119
|
} catch {
|
|
120
120
|
return bail(`${rel} does not parse — nothing is written over it`, 'fix the JSON or restore it from git, then run this again');
|
|
121
121
|
}
|
|
122
|
-
if (
|
|
122
|
+
if (productConfig !== null && !isObj(productConfig)) return bail(`${rel} has the wrong shape — nothing is written over it`, 'expected a JSON object; fix it or re-run `yad setup`');
|
|
123
123
|
|
|
124
124
|
if (to === null) {
|
|
125
|
-
const mode = modeOf(
|
|
126
|
-
const name = isObj(
|
|
127
|
-
const set = isObj(
|
|
128
|
-
const hint = soloTeamHint(root,
|
|
125
|
+
const mode = modeOf(productConfig);
|
|
126
|
+
const name = isObj(productConfig) && productConfig.mode !== undefined ? productConfig.mode : null;
|
|
127
|
+
const set = isObj(productConfig) && isObj(productConfig.mode_set) ? productConfig.mode_set : null;
|
|
128
|
+
const hint = soloTeamHint(root, productConfig, { solo: mode === 'solo', headCount, today });
|
|
129
129
|
if (json) return emitJSON({ ok: true, mode, name, agrees: name === null || name === mode, set, suggest: hint });
|
|
130
130
|
ok(`mode: ${mode} — ${MEANING[mode]}`);
|
|
131
|
-
if (!
|
|
131
|
+
if (!productConfig) info('no Product config yet — team mode is the default; `yad setup` records it');
|
|
132
132
|
if (set) info(setLine(set));
|
|
133
133
|
if (name !== null && name !== mode) warn(`the file also says mode: ${JSON.stringify(name)}, but the old \`solo\` flag is the one read — run \`yad mode ${mode}\` to make them agree`);
|
|
134
134
|
printTeamHint(hint, { unknown: true });
|
|
@@ -137,26 +137,26 @@ export async function runMode(root, { to = null, reason = null, json = false, to
|
|
|
137
137
|
|
|
138
138
|
// The word is checked first, so a typo on a fresh directory is named as a typo, not as a missing setup.
|
|
139
139
|
if (!MODES.includes(to)) return bail(`unknown mode: ${to}`, 'usage: yad mode [solo --reason "<why>" | team [--reason "<why>"]]');
|
|
140
|
-
if (!
|
|
141
|
-
const plan = planMode(
|
|
140
|
+
if (!productConfig) return bail(`no Product config at ${rel}`, 'run `yad setup` first — it records the mode with everything else');
|
|
141
|
+
const plan = planMode(productConfig, { to, reason, by: recordActor(root), date: today });
|
|
142
142
|
if (!plan.ok) return bail(plan.message, plan.hint);
|
|
143
|
-
if (plan.changed) writeProductConfig(root, plan.
|
|
143
|
+
if (plan.changed) writeProductConfig(root, plan.productConfig);
|
|
144
144
|
// On a verified Product CI writes the ledger only at a merge, so an open review is never `in_review` in
|
|
145
145
|
// state.json — the platform's open PRs are the list. Say so, rather than print nothing and read as "none".
|
|
146
|
-
const verified = isVerifiedLedger(
|
|
146
|
+
const verified = isVerifiedLedger(productConfig);
|
|
147
147
|
const open = plan.flipped && !verified ? openReviews(root) : [];
|
|
148
148
|
if (json) {
|
|
149
|
-
return emitJSON({ ok: true, mode: to, changed: plan.changed, flipped: plan.flipped, set: plan.flipped ? plan.
|
|
149
|
+
return emitJSON({ ok: true, mode: to, changed: plan.changed, flipped: plan.flipped, set: plan.flipped ? plan.productConfig.mode_set : null, openReviews: open, openReviewsKnown: !verified });
|
|
150
150
|
}
|
|
151
151
|
if (!plan.changed) return ok(`already ${to} — nothing changed`);
|
|
152
152
|
if (!plan.flipped) {
|
|
153
153
|
// Reached two ways: `mode` was absent (a Product set up before E10), or a hand edit left it saying the
|
|
154
154
|
// other mode — which is where `mode:disagree` sends people. Say which one happened.
|
|
155
|
-
const what =
|
|
155
|
+
const what = productConfig.mode === undefined ? 'the new name `mode` was added beside `solo`' : '`mode` now matches `solo`';
|
|
156
156
|
ok(`already ${to} — ${what}; the gates act exactly as before`);
|
|
157
157
|
} else {
|
|
158
158
|
ok(`mode: ${to} — ${MEANING[to]}`);
|
|
159
|
-
if (plan.
|
|
159
|
+
if (plan.productConfig.mode_set.reason) info(`reason: ${plan.productConfig.mode_set.reason}`);
|
|
160
160
|
if (verified) {
|
|
161
161
|
info(`this Product's ledger is verified, so its open reviews live on the platform, not in state.json — every open review PR/MR follows the ${to} rule from its next CI run`);
|
|
162
162
|
} else if (open.length) {
|
|
@@ -166,5 +166,5 @@ export async function runMode(root, { to = null, reason = null, json = false, to
|
|
|
166
166
|
if (to === 'solo') info(c.dim('`yad doctor` warns if branch protection still requires an approval you cannot give yourself'));
|
|
167
167
|
}
|
|
168
168
|
const back = to === 'solo' ? 'yad mode team' : 'yad mode solo --reason "<why>"';
|
|
169
|
-
hand(`commit ${PROJECT_FILES.productConfig} and ${PROJECT_FILES.
|
|
169
|
+
hand(`commit ${PROJECT_FILES.productConfig} and ${PROJECT_FILES.productConfigLegacy} so every machine and CI run sees it (reverse with \`${back}\`)`);
|
|
170
170
|
}
|
package/cli/next.mjs
CHANGED
|
@@ -19,7 +19,7 @@ import { dedupeConsecutive, epicIds, isGateStep, killSwitchOn, loadAutomation, e
|
|
|
19
19
|
|
|
20
20
|
// Is solo mode on? Persisted in hub.json by setup (Phase C/D); default false. Read defensively so a
|
|
21
21
|
// missing/old hub.json never breaks the driver.
|
|
22
|
-
const soloOf = (
|
|
22
|
+
const soloOf = (productConfig) => !!(productConfig && (productConfig.solo === true || productConfig.review_gate?.solo === true));
|
|
23
23
|
function isSolo(root) {
|
|
24
24
|
return soloOf(readJSON(productConfigPath(root), null));
|
|
25
25
|
}
|
|
@@ -354,22 +354,22 @@ function generalNext(root, { all, headCount = null } = {}) {
|
|
|
354
354
|
return;
|
|
355
355
|
}
|
|
356
356
|
// The Product config, read ONCE for this view.
|
|
357
|
-
const
|
|
358
|
-
const solo = soloOf(
|
|
357
|
+
const productConfig = readJSON(productConfigPath(root), null);
|
|
358
|
+
const solo = soloOf(productConfig);
|
|
359
359
|
// E74: in solo mode, suggest `yad mode team` when the count shows more than one person may work here.
|
|
360
360
|
// Printed first, so it is not lost under a long list of epics. An unknown count is not said here —
|
|
361
361
|
// `yad doctor` and `yad mode` say it (the user's choice, 2026-09-22). `headCount` is for tests.
|
|
362
|
-
printTeamHint(soloTeamHint(root,
|
|
362
|
+
printTeamHint(soloTeamHint(root, productConfig, { solo, headCount }));
|
|
363
363
|
// `profile` here is the SETUP profile `yad setup` records (codebase / repo_layout / team_size), not the
|
|
364
364
|
// lifecycle profile an epic walks (`LIFECYCLE_PROFILES`, E5): two things share the word.
|
|
365
|
-
const brownfield =
|
|
365
|
+
const brownfield = productConfig?.profile?.codebase === 'brownfield';
|
|
366
366
|
// The PRODUCT level (the Foundation, or a ledger still in its old `discovery` spelling) is not a
|
|
367
367
|
// feature epic — split it out so it is surfaced on its own line and never mixed into the roll-up.
|
|
368
368
|
const allEpics = listEpics(root);
|
|
369
369
|
// One read of `.sdlc/skills.json` for the whole roll-up, not one per epic.
|
|
370
370
|
const bindings = loadSkillBindings(root);
|
|
371
371
|
const automation = loadAutomation(root);
|
|
372
|
-
const verified = isVerifiedLedger(
|
|
372
|
+
const verified = isVerifiedLedger(productConfig);
|
|
373
373
|
// The Foundation wins when both exist. Two product levels is a fault `yad doctor` fails on; until it
|
|
374
374
|
// is fixed the new spelling is the one worth acting on, and the old one is named so it is not lost.
|
|
375
375
|
const productIds = PRODUCT_EPICS.filter((id) => allEpics.includes(id));
|