yadflow 3.18.1 → 4.0.0-next.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 +355 -0
- package/README.md +79 -26
- package/bin/commands.mjs +41 -0
- package/bin/yad.mjs +437 -124
- package/cli/artifact-status.mjs +34 -15
- package/cli/checkpoint.mjs +69 -49
- package/cli/codeowners-command.mjs +170 -0
- package/cli/codeowners.mjs +397 -0
- package/cli/commit.mjs +13 -9
- package/cli/companion.mjs +2 -2
- package/cli/dial.mjs +183 -0
- package/cli/docs.mjs +88 -32
- package/cli/doctor.mjs +1472 -97
- package/cli/epic-state.mjs +3478 -232
- package/cli/epic.mjs +506 -0
- package/cli/errors.mjs +4 -1
- package/cli/gate.mjs +1002 -209
- package/cli/history.mjs +556 -0
- package/cli/hook.mjs +266 -55
- package/cli/hubcommit.mjs +6 -17
- package/cli/index-command.mjs +87 -0
- package/cli/ledger.mjs +57 -7
- package/cli/lib.mjs +184 -18
- package/cli/manifest.mjs +367 -56
- package/cli/migrate.mjs +726 -53
- package/cli/mode.mjs +170 -0
- package/cli/next.mjs +349 -90
- package/cli/openpr.mjs +191 -39
- package/cli/people.mjs +654 -0
- package/cli/plan.mjs +417 -132
- package/cli/platform.mjs +110 -129
- package/cli/product-index.mjs +287 -0
- package/cli/protection.mjs +706 -0
- package/cli/reconcile.mjs +38 -12
- package/cli/repo-publish.mjs +24 -26
- package/cli/repo.mjs +23 -14
- package/cli/report.mjs +21 -15
- package/cli/review.mjs +24 -27
- package/cli/riskmap-command.mjs +289 -0
- package/cli/riskmap.mjs +373 -0
- package/cli/setup.mjs +139 -287
- package/cli/ship.mjs +7 -6
- package/cli/skill.mjs +180 -0
- package/cli/skip.mjs +211 -30
- package/cli/thread.mjs +42 -17
- package/cli/tidy.mjs +20 -20
- package/cli/update-commit.mjs +22 -22
- package/cli/usage.mjs +115 -109
- package/package.json +3 -3
- package/skills/sdlc/config.yaml +166 -87
- package/skills/sdlc/module-help.csv +35 -35
- package/skills/yad-analysis/SKILL.md +125 -65
- package/skills/yad-architecture/SKILL.md +34 -23
- package/skills/yad-architecture/references/contract-format.md +10 -8
- package/skills/yad-backfill/SKILL.md +14 -8
- package/skills/yad-backfill/references/backfill.md +1 -1
- package/skills/yad-change/SKILL.md +127 -52
- package/skills/yad-change/references/triage.md +42 -28
- package/skills/yad-checks/SKILL.md +89 -45
- package/skills/yad-checks/references/check-gates.md +315 -92
- package/skills/yad-checks/templates/checks/build-test-lint.sh +25 -7
- package/skills/yad-checks/templates/checks/commit-message.sh +17 -3
- package/skills/yad-checks/templates/checks/contract-check.sh +58 -2
- package/skills/yad-checks/templates/checks/epic-open.sh +3 -3
- package/skills/yad-checks/templates/checks/install-deps.sh +46 -0
- package/skills/yad-checks/templates/checks/ledger-guard.sh +94 -18
- package/skills/yad-checks/templates/checks/lineage-check.sh +23 -9
- package/skills/yad-checks/templates/checks/package-manager.sh +140 -0
- package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +4 -4
- package/skills/yad-checks/templates/checks/risk-map-check.sh +438 -0
- package/skills/yad-checks/templates/checks/verified-commits.sh +20 -46
- package/skills/yad-checks/templates/github/yad-checks.yml +37 -5
- package/skills/yad-checks/templates/github/yad-hub-checks.yml +5 -5
- package/skills/yad-checks/templates/github/yad-update-guard.yml +3 -4
- package/skills/yad-checks/templates/github/yad-verified-commits.yml +4 -4
- package/skills/yad-checks/templates/gitlab/.gitlab-ci.yml +7 -1
- package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +22 -4
- package/skills/yad-checks/templates/gitlab/yad-hub-checks.gitlab-ci.yml +5 -5
- package/skills/yad-checks/templates/gitlab/yad-verified-commits.gitlab-ci.yml +4 -4
- package/skills/yad-checks/templates/hooks/ledger-guard-cursor.sh +91 -0
- package/skills/yad-checks/templates/hooks/ledger-guard.sh +38 -7
- package/skills/yad-commit/SKILL.md +6 -6
- package/skills/yad-connect-design/SKILL.md +6 -6
- package/skills/yad-connect-design/references/design-context.md +1 -1
- package/skills/yad-connect-design/references/design-registry.md +2 -2
- package/skills/yad-connect-docs/SKILL.md +12 -12
- package/skills/yad-connect-docs/references/docs-registry.md +1 -1
- package/skills/yad-connect-learning/SKILL.md +5 -5
- package/skills/yad-connect-learning/references/learning-registry.md +2 -2
- package/skills/yad-connect-repos/SKILL.md +92 -54
- package/skills/yad-connect-repos/references/code-context.md +6 -6
- package/skills/yad-connect-repos/references/hub-config.md +68 -58
- package/skills/yad-connect-repos/references/repos-registry.md +10 -9
- package/skills/yad-connect-repos/references/risk-map.md +81 -0
- package/skills/yad-connect-testing/SKILL.md +6 -6
- package/skills/yad-connect-testing/references/testing-context.md +3 -4
- package/skills/yad-connect-testing/references/testing-registry.md +2 -2
- package/skills/yad-defects/SKILL.md +8 -8
- package/skills/yad-discovery/SKILL.md +130 -94
- package/skills/yad-discovery/references/discovery-schema.md +23 -7
- package/skills/yad-discovery/references/foundation-schema.md +374 -0
- package/skills/yad-docs/SKILL.md +16 -11
- package/skills/yad-docs/references/data-mapping.md +9 -7
- package/skills/yad-docs/templates/app/package-lock.json +3 -3
- package/skills/yad-docs-overview/SKILL.md +32 -17
- package/skills/yad-docs-overview/references/pipeline-model.md +47 -28
- package/skills/yad-docs-sync/SKILL.md +10 -5
- package/skills/yad-docs-sync/references/staleness.md +8 -7
- package/skills/yad-engineer-review/SKILL.md +88 -24
- package/skills/yad-engineer-review/references/ship-and-record.md +25 -16
- package/skills/yad-epic/SKILL.md +178 -100
- package/skills/yad-epic/references/state-schema.md +626 -117
- package/skills/yad-hub-bridge/SKILL.md +66 -48
- package/skills/yad-hub-bridge/references/bridge.md +110 -83
- package/skills/yad-hub-bridge/references/login-roster.md +163 -70
- package/skills/yad-hub-bridge/templates/checks/hub-route.sh +22 -19
- package/skills/yad-hub-bridge/templates/github/yad-gate-sync.yml +34 -14
- package/skills/yad-hub-bridge/templates/gitlab/gitlab-ci.include-root.yml +2 -2
- package/skills/yad-hub-bridge/templates/gitlab/yad-gate-sync.gitlab-ci.yml +22 -12
- package/skills/yad-implement/SKILL.md +29 -15
- package/skills/yad-implement/references/implement-conventions.md +2 -2
- package/skills/yad-learn/SKILL.md +9 -9
- package/skills/yad-learn/references/learning-state.md +2 -2
- package/skills/yad-open-pr/SKILL.md +64 -29
- package/skills/yad-pair-review/SKILL.md +18 -16
- package/skills/yad-pair-review/references/session-state.md +4 -4
- package/skills/yad-pr-template/SKILL.md +48 -27
- package/skills/yad-pr-template/references/risk-routing.md +97 -24
- package/skills/yad-pr-template/templates/checks/pr-template.sh +37 -15
- package/skills/yad-pr-template/templates/checks/pr-title.sh +27 -13
- package/skills/yad-pr-template/templates/checks/risk-route.sh +107 -14
- package/skills/yad-pr-template/templates/github/pull_request_template.md +7 -5
- package/skills/yad-pr-template/templates/gitlab/merge_request_templates/Default.md +7 -5
- package/skills/yad-pr-template/templates/hub/github/pull_request_template.md +15 -14
- package/skills/yad-pr-template/templates/hub/gitlab/merge_request_templates/Default.md +15 -13
- package/skills/yad-reconcile/SKILL.md +3 -3
- package/skills/yad-report/SKILL.md +5 -5
- package/skills/yad-review-companion/SKILL.md +12 -9
- package/skills/yad-review-gate/SKILL.md +198 -79
- package/skills/yad-review-gate/references/gating.md +230 -54
- package/skills/yad-run/SKILL.md +86 -56
- package/skills/yad-run/references/run-loop.md +67 -45
- package/skills/yad-ship/SKILL.md +18 -14
- package/skills/yad-spec/SKILL.md +31 -17
- package/skills/yad-spec/references/spec-handoff.md +17 -5
- package/skills/yad-status/SKILL.md +114 -56
- package/skills/yad-stories/SKILL.md +42 -27
- package/skills/yad-stories/references/story-schema.md +10 -9
- package/skills/yad-stub/SKILL.md +59 -48
- package/skills/yad-sync-repos/SKILL.md +3 -3
- package/skills/yad-test-cases/SKILL.md +37 -30
- package/skills/yad-test-cases/references/test-cases-schema.md +8 -5
- package/skills/yad-timeline/SKILL.md +8 -7
- package/skills/yad-ui/SKILL.md +46 -25
- package/cli/roster.mjs +0 -164
- package/skills/sdlc/install.sh +0 -68
package/cli/setup.mjs
CHANGED
|
@@ -5,36 +5,23 @@ import os from 'node:os';
|
|
|
5
5
|
import {
|
|
6
6
|
c, log, step, guide, ok, info, warn, hand, fail, ask, askYesNo, run, has,
|
|
7
7
|
exists, readJSON, readJSONStrict, writeJSON,
|
|
8
|
+
writeProductConfig,
|
|
8
9
|
} from './lib.mjs';
|
|
9
|
-
import { VERSION, IDE_TARGETS, PROJECT_FILES, DESIGN_TOOLS, DESIGN_PRIMARY, TESTING_TOOLS, TESTING_PRIMARY, LEARNING_TOOLS, LEARNING_PRIMARY } from './manifest.mjs';
|
|
10
|
+
import { VERSION, IDE_TARGETS, IDE_AGENTS, DEFAULT_IDE_TARGETS, PROJECT_FILES, DESIGN_TOOLS, DESIGN_PRIMARY, TESTING_TOOLS, TESTING_PRIMARY, LEARNING_TOOLS, LEARNING_PRIMARY , productConfigPath } from './manifest.mjs';
|
|
10
11
|
import {
|
|
11
|
-
moduleActions, repoActions,
|
|
12
|
+
moduleActions, repoActions, productActions, hookActions,
|
|
12
13
|
legacyModuleActions, removedModuleActions, legacyRepoActions, legacyHubActions,
|
|
13
14
|
safeIdeTargetsFor, detectedIdeTargetStateFor, recordManagedWrites,
|
|
14
15
|
} from './plan.mjs';
|
|
15
|
-
import {
|
|
16
|
+
import { modeFields, modeOf } from './mode.mjs';
|
|
17
|
+
import { recordActor } from './skip.mjs';
|
|
18
|
+
import { loadSkillBindings, stepSkills } from './epic-state.mjs';
|
|
16
19
|
|
|
17
20
|
// Parse a comma/space separated list into a clean, deduped array of trimmed tokens.
|
|
18
21
|
export function parseList(s) {
|
|
19
22
|
return [...new Set((s || '').split(/[,\s]+/).map((x) => x.trim()).filter(Boolean))];
|
|
20
23
|
}
|
|
21
24
|
|
|
22
|
-
// Parse a per-scope roles spec — `"hub=owner,reviewer backend=domain-owner"` — into the roster's
|
|
23
|
-
// per-scope map `{ hub: ['owner','reviewer'], backend: ['domain-owner'] }`. Tokens are whitespace
|
|
24
|
-
// separated; each is `scope=role[,role...]`. Malformed tokens (no `=`, empty scope/roles) are skipped.
|
|
25
|
-
export function parseRolesSpec(s) {
|
|
26
|
-
const out = {};
|
|
27
|
-
for (const tok of (s || '').split(/\s+/).map((x) => x.trim()).filter(Boolean)) {
|
|
28
|
-
const eq = tok.indexOf('=');
|
|
29
|
-
if (eq < 0) continue;
|
|
30
|
-
const scope = tok.slice(0, eq).trim();
|
|
31
|
-
const roles = parseList(tok.slice(eq + 1));
|
|
32
|
-
if (!scope || !roles.length) continue;
|
|
33
|
-
out[scope] = [...new Set([...(out[scope] || []), ...roles])];
|
|
34
|
-
}
|
|
35
|
-
return out;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
25
|
// Programmatic setup is strict; interactive setup keeps asking until it receives at least one valid
|
|
39
26
|
// canonical target. Both paths return the same trimmed, ordered, deduplicated representation.
|
|
40
27
|
export async function selectIdeTargets(root, provided, asker = ask) {
|
|
@@ -42,9 +29,14 @@ export async function selectIdeTargets(root, provided, asker = ask) {
|
|
|
42
29
|
const detected = detectedIdeTargetStateFor(root);
|
|
43
30
|
const present = detected.targets;
|
|
44
31
|
for (const unsafe of detected.unsafe) warn(`${unsafe.message}; excluded from IDE defaults`);
|
|
45
|
-
|
|
32
|
+
// Detected directories win: a project that already has `.cursor/` gets it offered back, and is not
|
|
33
|
+
// talked into a default it did not choose. Only a project with NO agent directory sees the default.
|
|
34
|
+
const def = (present.length ? present : DEFAULT_IDE_TARGETS).join(',');
|
|
46
35
|
for (;;) {
|
|
47
|
-
|
|
36
|
+
// Each target is listed with the agents that actually read it, because the directory name does not
|
|
37
|
+
// say. A Codex or Gemini CLI user shown a bare `.agents` has no way to know it is the one for them.
|
|
38
|
+
const menu = IDE_TARGETS.map((t) => `${t} = ${(IDE_AGENTS[t] || []).join(', ')}`).join('; ');
|
|
39
|
+
const answer = await asker(`IDE targets to install ${c.dim('(comma-separated — ' + menu + ')')}`, def);
|
|
48
40
|
if (answer === undefined || answer === null) throw new Error('IDE target selection ended before a valid choice was provided');
|
|
49
41
|
const values = String(answer ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
50
42
|
try {
|
|
@@ -63,22 +55,22 @@ export function detectPlatform(remoteUrl = '') {
|
|
|
63
55
|
}
|
|
64
56
|
export const gitHead = (cwd) => run('git', ['rev-parse', 'HEAD'], { cwd }).stdout || null;
|
|
65
57
|
|
|
66
|
-
// Containment: a repo path must live inside the WORKSPACE — the
|
|
67
|
-
// multi-repo layout puts the code repos BESIDE the
|
|
68
|
-
// so `../backend` has to register; containing to the
|
|
69
|
-
// inside the
|
|
58
|
+
// Containment: a repo path must live inside the WORKSPACE — the Product root's parent. The standard
|
|
59
|
+
// multi-repo layout puts the code repos BESIDE the Product, not under it (project/{product,backend,frontend}),
|
|
60
|
+
// so `../backend` has to register; containing to the Product root instead forced separate git repos to nest
|
|
61
|
+
// inside the Product's own repo (issue #129). A nested path (demo-repos/api) still works.
|
|
70
62
|
//
|
|
71
63
|
// The bound stays real: the registry path is later joined and executed against (repomix cwd,
|
|
72
64
|
// .coderabbit.yaml + CI wiring), and even the read-only remote probe must not run against an arbitrary
|
|
73
65
|
// outside path — so `../../elsewhere` and absolute-outside paths are still rejected. The path.sep-suffixed
|
|
74
66
|
// compare avoids the /project vs /project-evil prefix trap, now one level up at the workspace.
|
|
75
|
-
// A sibling of the
|
|
76
|
-
// ordinary workspace members. The workspace DIRECTORY ITSELF (`..`) is not: it contains the
|
|
77
|
-
// registering it as a code repo would point repomix and the CI writes at the whole tree. Only the
|
|
67
|
+
// A sibling of the Product (../product-evil) is, correctly, indistinguishable from ../backend: both are
|
|
68
|
+
// ordinary workspace members. The workspace DIRECTORY ITSELF (`..`) is not: it contains the Product, so
|
|
69
|
+
// registering it as a code repo would point repomix and the CI writes at the whole tree. Only the Product
|
|
78
70
|
// root itself (a monorepo, `.`) and strict descendants of the workspace pass.
|
|
79
71
|
//
|
|
80
|
-
// Place the
|
|
81
|
-
// workspace is the trust boundary, and a shallow
|
|
72
|
+
// Place the Product one level below the workspace root (project/product), not directly in $HOME — the
|
|
73
|
+
// workspace is the trust boundary, and a shallow Product makes every sibling of it registerable.
|
|
82
74
|
export function insideWorkspace(root, rpath) {
|
|
83
75
|
const projectRoot = path.resolve(root);
|
|
84
76
|
const parent = path.dirname(projectRoot);
|
|
@@ -87,145 +79,19 @@ export function insideWorkspace(root, rpath) {
|
|
|
87
79
|
return resolved === projectRoot || resolved.startsWith(workspace + path.sep);
|
|
88
80
|
}
|
|
89
81
|
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
//
|
|
94
|
-
//
|
|
95
|
-
// the roster is warned about and skipped (the roster is the source of identity). Idempotent.
|
|
96
|
-
export function addRepoRoles(root, repo, grants = {}) {
|
|
97
|
-
const hubPath = path.join(root, PROJECT_FILES.hubConfig);
|
|
98
|
-
const hub = readJSON(hubPath, null);
|
|
99
|
-
if (!hub || !Array.isArray(hub.roster)) return;
|
|
100
|
-
const byName = new Map(hub.roster.map((e) => [e.name, e]));
|
|
101
|
-
let touched = false;
|
|
102
|
-
for (const [role, names] of Object.entries(grants)) {
|
|
103
|
-
for (const nm of names) {
|
|
104
|
-
const entry = byName.get(nm);
|
|
105
|
-
if (!entry) { warn(`'${nm}' is not in the roster — skipped ${role} for ${repo}`); continue; }
|
|
106
|
-
// Normalize to the per-scope map, migrating the legacy shapes: a flat array or a single
|
|
107
|
-
// `role` string both become hub roles so nothing is lost.
|
|
108
|
-
if (!entry.roles || typeof entry.roles !== 'object' || Array.isArray(entry.roles)) {
|
|
109
|
-
const hub = Array.isArray(entry.roles) ? entry.roles : (entry.role ? [entry.role] : []);
|
|
110
|
-
entry.roles = hub.length ? { hub } : {};
|
|
111
|
-
delete entry.role;
|
|
112
|
-
}
|
|
113
|
-
const list = Array.isArray(entry.roles[repo]) ? entry.roles[repo] : [];
|
|
114
|
-
if (!list.includes(role)) { list.push(role); touched = true; }
|
|
115
|
-
entry.roles[repo] = list;
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
if (touched) writeJSON(hubPath, hub);
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// Normalize a roster entry's roles in place to the per-scope map, migrating the two legacy shapes
|
|
122
|
-
// (a flat array, or a single `role` string) into `roles.hub` so nothing is lost. Mirrors the
|
|
123
|
-
// migration addRepoRoles does; pulled out so upsert/remove share it.
|
|
124
|
-
function normalizeRoles(entry) {
|
|
125
|
-
if (!entry.roles || typeof entry.roles !== 'object' || Array.isArray(entry.roles)) {
|
|
126
|
-
const hub = Array.isArray(entry.roles) ? entry.roles : (entry.role ? [entry.role] : []);
|
|
127
|
-
entry.roles = hub.length ? { hub } : {};
|
|
128
|
-
delete entry.role;
|
|
129
|
-
}
|
|
130
|
-
return entry.roles;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// Build the hub.json object for a (re)configure write, preserving user-owned identity data the wizard
|
|
134
|
-
// does not re-collect: top-level `verified_authors` (and any other existing fields), plus a previously
|
|
135
|
-
// populated `roster` when this run collected none (solo re-runs skip the reviewer loop). Never blanks a
|
|
136
|
-
// non-empty roster; never drops verified_authors. Mirrors the safe { ...cur } merge on the keep path.
|
|
137
|
-
// Trade-off (deliberate, fail-safe): a reconfigure can no longer EMPTY a populated roster — collecting
|
|
138
|
-
// zero reviewers keeps the existing entries. To actually remove members, use `yad roster remove` (or
|
|
139
|
-
// edit hub.json directly); this flow only ever grows or replaces the roster, never silently clears it.
|
|
82
|
+
// Build the hub.json object for a (re)configure write: the fields this run collected, laid over the
|
|
83
|
+
// existing file so nothing the wizard does not ask about is dropped. Mirrors the safe { ...cur } merge
|
|
84
|
+
// on the keep path. That includes a `roster` or `verified_authors` an older release wrote (E62): the
|
|
85
|
+
// wizard no longer collects or reads either, and it never deletes them — `yad doctor` names them as
|
|
86
|
+
// unused instead.
|
|
140
87
|
export function buildReconfiguredHub(cur, fields) {
|
|
141
|
-
|
|
142
|
-
const keptRoster = (Array.isArray(roster) && roster.length)
|
|
143
|
-
? roster
|
|
144
|
-
: (Array.isArray(cur?.roster) ? cur.roster : []);
|
|
145
|
-
return { ...(cur || {}), ...rest, roster: keptRoster };
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
// Upsert one roster member into hub.json, keyed by `login`. Deep-merges the per-scope `roles` map so
|
|
149
|
-
// scopes the caller did not name are preserved; sets `name`/`email` when given; validates the login
|
|
150
|
-
// against the hub (warn-only — a miss flags `unverified`, `checked:false` skips silently). Creates the
|
|
151
|
-
// hub.json shell if absent. Returns { entry, created }.
|
|
152
|
-
export function upsertRosterEntry(root, { login, name, email, roles = {}, platform } = {}) {
|
|
153
|
-
if (!login) { warn('roster upsert needs a login — skipped'); return { entry: null, created: false }; }
|
|
154
|
-
const hubPath = path.join(root, PROJECT_FILES.hubConfig);
|
|
155
|
-
const hub = readJSON(hubPath, null) || { platform: platform && platform !== 'none' ? platform : null, bridge_enabled: false, bridge: false, default_branch: 'main', roster: [] };
|
|
156
|
-
if (!Array.isArray(hub.roster)) hub.roster = [];
|
|
157
|
-
let entry = hub.roster.find((e) => e.login === login);
|
|
158
|
-
const created = !entry;
|
|
159
|
-
if (!entry) { entry = { login, name: name || login, roles: {} }; hub.roster.push(entry); }
|
|
160
|
-
if (name) entry.name = name;
|
|
161
|
-
if (email) entry.email = email;
|
|
162
|
-
normalizeRoles(entry);
|
|
163
|
-
for (const [scope, list] of Object.entries(roles || {})) {
|
|
164
|
-
const cur = Array.isArray(entry.roles[scope]) ? entry.roles[scope] : [];
|
|
165
|
-
entry.roles[scope] = [...new Set([...cur, ...list])];
|
|
166
|
-
}
|
|
167
|
-
if (email && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) warn(`'${email}' does not look like an email address`);
|
|
168
|
-
const plat = platform || hub.platform;
|
|
169
|
-
if (plat && plat !== 'none') {
|
|
170
|
-
const v = validateLogin(plat, login);
|
|
171
|
-
if (v.checked && !v.exists) { warn(`'${login}' not found on ${plat} — saved as unverified`); entry.unverified = true; }
|
|
172
|
-
else if (v.checked && v.exists) { ok(`verified ${login} on ${plat}`); delete entry.unverified; }
|
|
173
|
-
}
|
|
174
|
-
writeJSON(hubPath, hub);
|
|
175
|
-
return { entry, created };
|
|
88
|
+
return { ...(cur || {}), ...fields };
|
|
176
89
|
}
|
|
177
90
|
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
const hub = readJSON(hubPath, null);
|
|
183
|
-
if (!hub || !Array.isArray(hub.roster)) return;
|
|
184
|
-
const entry = hub.roster.find((e) => e.name === name);
|
|
185
|
-
if (!entry) { warn(`'${name}' is not in the roster — nothing to revoke for ${repo}`); return; }
|
|
186
|
-
normalizeRoles(entry);
|
|
187
|
-
const cur = Array.isArray(entry.roles[repo]) ? entry.roles[repo] : [];
|
|
188
|
-
const next = cur.filter((r) => !roles.includes(r));
|
|
189
|
-
if (next.length === cur.length) return; // nothing removed
|
|
190
|
-
if (next.length) entry.roles[repo] = next; else delete entry.roles[repo];
|
|
191
|
-
writeJSON(hubPath, hub);
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// Keep repos.json `domain_owners` in sync when a domain-owner role is granted/revoked via the roster,
|
|
195
|
-
// so the gate's per-repo reviewer-routing and the derivation fallback never drift from hub.json. Adds
|
|
196
|
-
// or removes the yad `name` and mirrors `domain_owner = domain_owners[0]`. No-op + warn if the repo is
|
|
197
|
-
// not registered. Returns true when the registry was written.
|
|
198
|
-
export function setRepoDomainOwners(root, repo, name, { add = true } = {}) {
|
|
199
|
-
const regPath = path.join(root, PROJECT_FILES.reposRegistry);
|
|
200
|
-
const registry = readJSON(regPath, { repos: [] });
|
|
201
|
-
const entry = (registry.repos || []).find((r) => r.name === repo);
|
|
202
|
-
if (!entry) { warn(`repo '${repo}' is not registered (.sdlc/repos.json) — domain_owners not synced`); return false; }
|
|
203
|
-
const owners = Array.isArray(entry.domain_owners) ? [...entry.domain_owners] : (entry.domain_owner ? [entry.domain_owner] : []);
|
|
204
|
-
const has = owners.includes(name);
|
|
205
|
-
let next;
|
|
206
|
-
if (add) { if (has) return false; next = [...owners, name]; }
|
|
207
|
-
else { if (!has) return false; next = owners.filter((o) => o !== name); }
|
|
208
|
-
entry.domain_owners = next;
|
|
209
|
-
entry.domain_owner = next[0] || '';
|
|
210
|
-
writeJSON(regPath, registry);
|
|
211
|
-
return true;
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
// Reconcile one repo's roles for a member to exactly `want`: grant what is new, revoke what is gone,
|
|
215
|
-
// and mirror domain-owner changes into repos.json. `current` is the member's existing roles for the
|
|
216
|
-
// repo. Shared by the `yad roster` walk and the `yad setup` per-repo role step. Idempotent.
|
|
217
|
-
export function reconcileRepoRoles(root, name, repo, current = [], want = []) {
|
|
218
|
-
const toAdd = want.filter((r) => !current.includes(r));
|
|
219
|
-
const toRemove = current.filter((r) => !want.includes(r));
|
|
220
|
-
if (!toAdd.length && !toRemove.length) { info(` ${repo}: unchanged`); return; }
|
|
221
|
-
if (toAdd.length) addRepoRoles(root, repo, Object.fromEntries(toAdd.map((r) => [r, [name]])));
|
|
222
|
-
if (toRemove.length) removeRepoRole(root, name, repo, toRemove);
|
|
223
|
-
if (toAdd.includes('domain-owner')) setRepoDomainOwners(root, repo, name, { add: true });
|
|
224
|
-
if (toRemove.includes('domain-owner')) setRepoDomainOwners(root, repo, name, { add: false });
|
|
225
|
-
ok(` ${repo}: ${want.length ? want.join(', ') : 'cleared'}`);
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
export function registerRepo(root, registry, { name, rpath, platform, domain_owner = '', domain_owners = null, default_branch = 'main', today = null, pack = true }) {
|
|
91
|
+
// Validate + record one code repo into the registry (the testable half of the connect loop).
|
|
92
|
+
// A path that is not a git repository is rejected and NOTHING is written — a registry entry with
|
|
93
|
+
// syncedHead:null would only surface later as an unexplained "unknown status" in the CI gates.
|
|
94
|
+
export function registerRepo(root, registry, { name, rpath, platform, default_branch = 'main', today = null, pack = true }) {
|
|
229
95
|
if (!insideWorkspace(root, rpath)) {
|
|
230
96
|
warn(`${rpath} resolves outside the workspace (the project root's parent) — skipped`);
|
|
231
97
|
return null;
|
|
@@ -240,12 +106,11 @@ export function registerRepo(root, registry, { name, rpath, platform, domain_own
|
|
|
240
106
|
if (plat) warn(`unknown platform '${platform}' — using ${detected}`);
|
|
241
107
|
plat = detected;
|
|
242
108
|
}
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
const owners = (domain_owners && domain_owners.length) ? domain_owners : (domain_owner ? [domain_owner] : []);
|
|
109
|
+
// No `domain_owner` / `domain_owners` (E62): people are not stored. A registry entry an older release
|
|
110
|
+
// wrote may still carry them; nothing reads them, and `yad doctor` names them as unused.
|
|
246
111
|
const repo = {
|
|
247
112
|
name, path: rpath, git_url: (remote.ok && remote.stdout) || null, platform: plat,
|
|
248
|
-
|
|
113
|
+
default_branch,
|
|
249
114
|
connectedAt: today, lastSyncedAt: today,
|
|
250
115
|
// Only claim a synced HEAD when a pack is actually produced. The greenfield path skips packing
|
|
251
116
|
// (pack:false), so leave syncedHead null — the repo then reads as "needs an initial pack" in
|
|
@@ -263,7 +128,7 @@ export function registerRepo(root, registry, { name, rpath, platform, domain_own
|
|
|
263
128
|
// Record the project's design-tool connection into .sdlc/design.json (the deterministic half of the
|
|
264
129
|
// connect loop; MCP detection itself is an AI step, handed off to `yad-connect-design`). An unknown tool
|
|
265
130
|
// falls back to the primary adapter rather than being rejected — mirrors registerRepo's platform
|
|
266
|
-
// fallback and the
|
|
131
|
+
// fallback and the Product step. `none` is the explicit markdown-only choice.
|
|
267
132
|
export function registerDesign(root, { tool, project_url = null, files = null, today = null } = {}) {
|
|
268
133
|
// Idempotent re-connect: carry the original first-connect date forward (the schema defines
|
|
269
134
|
// connectedAt as "first connect"); only lastSyncedAt moves. Mirrors repo.mjs refresh.
|
|
@@ -345,6 +210,18 @@ export function registerLearning(root, { tool, kb = null, today = null } = {}) {
|
|
|
345
210
|
return learning;
|
|
346
211
|
}
|
|
347
212
|
|
|
213
|
+
// The solo switch as setup writes it: `solo` and `mode` in step, through the same `modeFields` `yad mode` uses
|
|
214
|
+
// (E10). Setup records `mode_set` only when it CHANGES the mode of a Product that already had a config — a
|
|
215
|
+
// first run is not a switch. It takes no reason, because an interview answer or a `--solo` / `--team` flag
|
|
216
|
+
// is the choice itself; the record names `yad setup` so the change is still attributable.
|
|
217
|
+
export function setupModeFields(root, cur, solo, opts = {}) {
|
|
218
|
+
const had = !!cur && typeof cur === 'object' && Object.keys(cur).length > 0;
|
|
219
|
+
const { mode_set: set, ...fields } = modeFields(cur, solo ? 'solo' : 'team', {
|
|
220
|
+
by: had ? recordActor(root) : null, date: opts.today ?? null, reason: 'yad setup',
|
|
221
|
+
});
|
|
222
|
+
return had && set ? { ...fields, mode_set: set } : fields;
|
|
223
|
+
}
|
|
224
|
+
|
|
348
225
|
function applyActions(actions, { force = false } = {}) {
|
|
349
226
|
let changed = 0;
|
|
350
227
|
for (const a of actions) {
|
|
@@ -357,7 +234,7 @@ function applyActions(actions, { force = false } = {}) {
|
|
|
357
234
|
if (a.status === 'ok' && !force) continue;
|
|
358
235
|
a.apply();
|
|
359
236
|
changed++;
|
|
360
|
-
info(`${a.status === 'missing' ? 'installed' : 'updated'} ${a.scope}/${a.item}${a.backup ? ` (previous content saved to ${path.basename(a.backup)})` : ''}`);
|
|
237
|
+
info(`${a.status === 'missing' || a.status === 'new' ? 'installed' : 'updated'} ${a.scope}/${a.item}${a.backup ? ` (previous content saved to ${path.basename(a.backup)})` : ''}`);
|
|
361
238
|
}
|
|
362
239
|
if (!changed) info('already up to date');
|
|
363
240
|
return changed;
|
|
@@ -368,19 +245,23 @@ function applyActions(actions, { force = false } = {}) {
|
|
|
368
245
|
// otherwise we prompt with a default. Pure of side effects — it only reads. Returns
|
|
369
246
|
// { solo, team_size, codebase, repo_layout, configureTools }.
|
|
370
247
|
export async function resolveProfile(root, opts = {}) {
|
|
371
|
-
const hub = readJSON(
|
|
248
|
+
const hub = readJSON(productConfigPath(root), null);
|
|
372
249
|
const prev = (hub && hub.profile) || {};
|
|
373
250
|
|
|
374
251
|
// 1. Solo or team (+ size). --solo / --team <n> win; else carry hub.solo forward; else ask.
|
|
375
252
|
let solo, team_size;
|
|
376
253
|
if (opts.solo) { solo = true; team_size = 1; }
|
|
377
254
|
else if (opts.team != null) { team_size = Math.max(1, parseInt(opts.team, 10) || 1); solo = team_size <= 1; }
|
|
378
|
-
|
|
255
|
+
// Either spelling carries forward: the older `review_gate.solo: true` also waives the gates, and asking again
|
|
256
|
+
// would default a configured team Product to solo and switch approvals off with nobody choosing it (E10).
|
|
257
|
+
else if (typeof hub?.solo === 'boolean' || hub?.review_gate?.solo === true) { solo = modeOf(hub) === 'solo'; team_size = prev.team_size ?? (solo ? 1 : 2); }
|
|
379
258
|
else {
|
|
380
|
-
//
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
259
|
+
// No mode recorded. The default used to come from the roster's size; it now comes from the team size
|
|
260
|
+
// setup recorded, and a Product that exists with neither defaults to TEAM (E62). Solo waives every
|
|
261
|
+
// approval, so it must be a choice, never what a scripted re-run of an old Product falls into.
|
|
262
|
+
const known = prev.team_size ?? (hub ? 2 : 1);
|
|
263
|
+
solo = !(await ask('Solo or team?', known > 1 ? 'team' : 'solo')).toLowerCase().startsWith('t');
|
|
264
|
+
team_size = solo ? 1 : Math.max(2, parseInt(await ask(' how many team members?', String(Math.max(2, known))), 10) || 2);
|
|
384
265
|
}
|
|
385
266
|
|
|
386
267
|
// 2. Greenfield (new code) or brownfield (existing code).
|
|
@@ -406,14 +287,14 @@ export async function resolveProfile(root, opts = {}) {
|
|
|
406
287
|
}
|
|
407
288
|
|
|
408
289
|
// The guided, idempotent first-run wizard: a Step 0 profile interview (resolveProfile) that branches
|
|
409
|
-
// the remaining steps — install,
|
|
290
|
+
// the remaining steps — install, Product platform, optional tools, repos, wiring — and persists the profile.
|
|
410
291
|
export async function runSetup(root, opts = {}) {
|
|
411
292
|
log(c.bold(`\nSDLC Workflow setup ${c.dim('v' + VERSION)}`));
|
|
412
293
|
log(c.dim(`target: ${root}`));
|
|
413
294
|
|
|
414
295
|
// 0. Profile interview — branch the wizard to the user's situation (solo/team, code, repo layout).
|
|
415
296
|
const { solo, team_size, codebase, repo_layout, configureTools } = await resolveProfile(root, opts);
|
|
416
|
-
// Steps: interview, preflight, install,
|
|
297
|
+
// Steps: interview, preflight, install, Product, tools (1 if deferred else 3), repos, wire, coderabbit, done.
|
|
417
298
|
const total = 8 + (configureTools ? 3 : 1);
|
|
418
299
|
let _n = 0;
|
|
419
300
|
const S = (title) => step(++_n, total, title);
|
|
@@ -437,9 +318,9 @@ export async function runSetup(root, opts = {}) {
|
|
|
437
318
|
if (!has('npx')) warn('npx not found — repomix packing will be skipped');
|
|
438
319
|
|
|
439
320
|
// Install the module
|
|
440
|
-
S('Install the module (skills +
|
|
321
|
+
S('Install the module (skills + .sdlc/config.yaml)');
|
|
441
322
|
guide([
|
|
442
|
-
'Copies the yad-* skills into your AI tool(s) so they appear in Claude Code
|
|
323
|
+
'Copies the yad-* skills into your AI tool(s) so they appear in Claude Code, Codex CLI, Cursor, Gemini CLI, Copilot, Zencoder or opencode.',
|
|
443
324
|
'Enter the IDE folders to install into, comma-separated; default = whatever is already present.',
|
|
444
325
|
]);
|
|
445
326
|
const ideTargets = await selectIdeTargets(root, opts.ideTargets);
|
|
@@ -480,89 +361,76 @@ export async function runSetup(root, opts = {}) {
|
|
|
480
361
|
}
|
|
481
362
|
}
|
|
482
363
|
|
|
483
|
-
// Detect
|
|
484
|
-
S(
|
|
364
|
+
// Detect Product platform. No people are collected (E62): anyone with access approves on the platform.
|
|
365
|
+
S('Product platform');
|
|
485
366
|
guide(solo
|
|
486
367
|
? [
|
|
487
|
-
'Your hub is this repo on GitHub/GitLab (or none for a
|
|
488
|
-
'Solo:
|
|
368
|
+
'Your hub is this repo on GitHub/GitLab (or none for a local gate).',
|
|
369
|
+
'Solo: you review by merging your own PR (approval waived).',
|
|
489
370
|
]
|
|
490
371
|
: [
|
|
491
372
|
'Your hub is this repo on GitHub/GitLab; reviewers approve artifacts there.',
|
|
492
|
-
|
|
493
|
-
'
|
|
373
|
+
'yad keeps no list of people: anyone with access to the repo can approve, and the platform records who did.',
|
|
374
|
+
'A gate needs one approval, which should not come from the author (GitHub blocks self-approval; GitLab only if its settings do).',
|
|
375
|
+
'It also reports how many people it would like: 3 on a contract review, 2 where auth or payments is touched, 1 elsewhere. That extra number is advisory for now.',
|
|
494
376
|
]);
|
|
495
|
-
const
|
|
496
|
-
if (exists(
|
|
377
|
+
const productPath = productConfigPath(root);
|
|
378
|
+
if (exists(productPath) && !(await askYesNo('hub.json exists — reconfigure?', false))) {
|
|
497
379
|
info('keeping existing .sdlc/hub.json');
|
|
498
380
|
} else {
|
|
499
381
|
const remote = run('git', ['remote', 'get-url', 'origin'], { cwd: root });
|
|
500
382
|
if (!remote.ok && exists(path.join(root, '.git'))) info('no origin remote — platform detection skipped');
|
|
501
383
|
let platform = detectPlatform(remote.ok ? remote.stdout : '');
|
|
502
|
-
platform = (await ask('
|
|
384
|
+
platform = (await ask('Product platform (github/gitlab/none)', platform || 'none')).toLowerCase();
|
|
503
385
|
if (!['github', 'gitlab', 'none'].includes(platform)) {
|
|
504
|
-
warn(`unknown platform '${platform}' — using none (
|
|
386
|
+
warn(`unknown platform '${platform}' — using none (local gate)`);
|
|
505
387
|
platform = 'none';
|
|
506
388
|
}
|
|
507
|
-
const
|
|
508
|
-
//
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
const name = await ask(' yad name', login);
|
|
514
|
-
const email = await ask(' commit email (committer→login lookup + verified-commits gate; blank to skip)', '');
|
|
515
|
-
// Per-scope roles: capture the hub roles here; per-repo roles are added in step 7 when the
|
|
516
|
-
// repo is connected. A person can hold several roles (owner reviewer) at once.
|
|
517
|
-
const hubRoles = parseList(await ask(' hub roles (owner/reviewer, space-separated)', 'reviewer'));
|
|
518
|
-
const entry = { login, name, ...(email ? { email } : {}), roles: { hub: hubRoles } };
|
|
519
|
-
// Validate the login exists on the hub — warn-only (fail-open): a miss is flagged unverified
|
|
520
|
-
// but still saved. `checked:false` (no CLI/auth) skips the check silently.
|
|
521
|
-
if (platform !== 'none') {
|
|
522
|
-
const v = validateLogin(platform, login);
|
|
523
|
-
if (v.checked && !v.exists) { warn(`'${login}' not found on ${platform} — saved as unverified`); entry.unverified = true; }
|
|
524
|
-
else if (v.checked && v.exists) ok(`verified ${login} on ${platform}`);
|
|
525
|
-
}
|
|
526
|
-
if (email && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) warn(`'${email}' does not look like an email address`);
|
|
527
|
-
roster.push(entry);
|
|
528
|
-
}
|
|
529
|
-
}
|
|
530
|
-
const default_branch = platform === 'none' ? 'main' : await ask('Hub default branch', 'main');
|
|
531
|
-
// `bridge_enabled` is the canonical flag (hub-config schema); keep the legacy `bridge` spelling
|
|
532
|
-
// for anything that still reads it.
|
|
389
|
+
const default_branch = platform === 'none' ? 'main' : await ask('Product default branch', 'main');
|
|
390
|
+
// `ledger` is the canonical switch (shape 2): "verified" = CI writes the ledger, "local" = this
|
|
391
|
+
// machine does. The two booleans below say the same thing in the older spelling and are written
|
|
392
|
+
// ALONGSIDE it, not instead of it — add before you remove (rule 3). They are what a check gate
|
|
393
|
+
// that has not been refreshed by `yad update` yet still reads, and what a Product that is rolled back
|
|
394
|
+
// to a 3.x CLI would fall back to. They go in a later major, once nothing on either side reads them.
|
|
533
395
|
const enabled = platform !== 'none';
|
|
534
|
-
// Record git_url — doctor needs it to scope the auth probe (YAD-CFG-005) and the
|
|
396
|
+
// Record git_url — doctor needs it to scope the auth probe (YAD-CFG-005) and the verified ledger/PR flow
|
|
535
397
|
// needs it to open PRs. Derived from the origin remote already resolved above; null when local-only.
|
|
536
398
|
const git_url = enabled ? ((remote.ok && remote.stdout.trim()) || null) : null;
|
|
537
|
-
// Merge into the existing file, never clobber:
|
|
538
|
-
//
|
|
539
|
-
//
|
|
540
|
-
|
|
541
|
-
//
|
|
542
|
-
|
|
399
|
+
// Merge into the existing file, never clobber: a key this wizard does not collect is kept as it is.
|
|
400
|
+
// Read strict so a corrupt Product aborts here (YAD-STATE-001) rather than fail-open to `{}` and rewrite
|
|
401
|
+
// the file with everything it did not collect stripped.
|
|
402
|
+
const cur = readJSONStrict(productPath, {}) || {};
|
|
403
|
+
// `ledger` belongs to shape 2. On a project still on shape 1 — one that has not run
|
|
404
|
+
// `yad migrate` yet — writing it would leave a file DECLARING shape 1 while carrying a shape-2
|
|
405
|
+
// field, which is rule 1 read backwards and makes `yad doctor`'s drift report a lie about the
|
|
406
|
+
// one file this shape change is about. The old booleans below say the same thing and are what
|
|
407
|
+
// the reader falls back to when `ledger` is absent, so nothing is lost by waiting: `yad migrate`
|
|
408
|
+
// adds the key, and the setting it computes is the one these booleans just recorded.
|
|
409
|
+
const onNewShape = (cur.schemaVersion ?? 1) >= 2 || !Object.keys(cur).length;
|
|
543
410
|
const next = buildReconfiguredHub(cur, {
|
|
544
|
-
platform: enabled ? platform : null, git_url,
|
|
545
|
-
|
|
411
|
+
platform: enabled ? platform : null, git_url,
|
|
412
|
+
...(onNewShape ? { ledger: enabled ? 'verified' : 'local' } : {}),
|
|
413
|
+
bridge_enabled: enabled, bridge: enabled,
|
|
414
|
+
default_branch, ...setupModeFields(root, cur, solo, opts), profile: { codebase, repo_layout, team_size },
|
|
546
415
|
});
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
}
|
|
550
|
-
if (cur.verified_authors?.length) info(`preserved ${cur.verified_authors.length} verified_authors entry(ies)`);
|
|
551
|
-
writeJSON(hubPath, next);
|
|
552
|
-
ok(`wrote ${PROJECT_FILES.hubConfig} (${next.roster.length} reviewer(s)${solo ? ', solo mode' : ''})`);
|
|
416
|
+
writeProductConfig(root, next);
|
|
417
|
+
ok(`wrote ${PROJECT_FILES.productConfig} + ${PROJECT_FILES.hubConfig}${solo ? ' (solo mode)' : ''}`);
|
|
553
418
|
}
|
|
554
419
|
// Persist the profile + solo flag even on the "keeping existing" path, so re-running setup with new
|
|
555
420
|
// flags (e.g. `yad setup --solo`) updates the mode without a full reconfigure. Merge, never clobber.
|
|
556
421
|
// Also backfill a missing git_url from origin here (idempotent repair for the doctor's YAD-CFG-005).
|
|
557
|
-
if (exists(
|
|
558
|
-
// Strict read for the same reason as the reconfigure write above: a corrupt
|
|
559
|
-
// fail-open to `{}` and get rewritten with
|
|
560
|
-
const cur = readJSONStrict(
|
|
422
|
+
if (exists(productPath)) {
|
|
423
|
+
// Strict read for the same reason as the reconfigure write above: a corrupt Product must abort, never
|
|
424
|
+
// fail-open to `{}` and get rewritten with everything else stripped on a plain re-run.
|
|
425
|
+
const cur = readJSONStrict(productPath, {}) || {};
|
|
561
426
|
const backfillUrl = (cur.platform && !cur.git_url)
|
|
562
427
|
? ((run('git', ['remote', 'get-url', 'origin'], { cwd: root }).stdout || '').trim() || null)
|
|
563
428
|
: null;
|
|
564
|
-
|
|
565
|
-
|
|
429
|
+
const mode = setupModeFields(root, cur, solo, opts);
|
|
430
|
+
const modeMoved = Object.keys(mode).some((k) => JSON.stringify(cur[k]) !== JSON.stringify(mode[k]));
|
|
431
|
+
if (modeMoved || JSON.stringify(cur.profile || {}) !== JSON.stringify({ codebase, repo_layout, team_size }) || backfillUrl) {
|
|
432
|
+
writeProductConfig(root, { ...cur, ...(backfillUrl ? { git_url: backfillUrl } : {}), ...mode, profile: { codebase, repo_layout, team_size } });
|
|
433
|
+
if (mode.mode_set) info(`mode: ${mode.mode_set.from} → ${mode.mode_set.to} (recorded as mode_set)`);
|
|
566
434
|
if (backfillUrl) info(`backfilled hub git_url from origin: ${backfillUrl}`);
|
|
567
435
|
else info(`recorded profile: ${solo ? 'solo' : `team(${team_size})`}, ${codebase}, ${repo_layout}`);
|
|
568
436
|
}
|
|
@@ -652,13 +520,13 @@ export async function runSetup(root, opts = {}) {
|
|
|
652
520
|
S(repo_layout === 'monorepo' ? 'Connect your code repo (monorepo)' : 'Connect code repos');
|
|
653
521
|
guide(repo_layout === 'monorepo'
|
|
654
522
|
? [
|
|
655
|
-
'One repo holds all the code; the contract lives in the
|
|
656
|
-
codebase === 'greenfield' ? 'Greenfield: no code yet — the repomix code-pack step is skipped.' : 'Brownfield: the repo is packed so the
|
|
523
|
+
'One repo holds all the code; the contract lives in the Product and stories tag this single repo.',
|
|
524
|
+
codebase === 'greenfield' ? 'Greenfield: no code yet — the repomix code-pack step is skipped.' : 'Brownfield: the repo is packed so the Shape phases see what already exists.',
|
|
657
525
|
]
|
|
658
526
|
: [
|
|
659
527
|
'Register each code repo the feature touches; stories get tagged with the repos that implement them.',
|
|
660
|
-
'Per repo: name → path (inside this project) → platform →
|
|
661
|
-
codebase === 'greenfield' ? 'Greenfield: no code yet — the repomix code-pack step is skipped.' : 'Brownfield: each repo is packed so the
|
|
528
|
+
'Per repo: name → path (inside this project) → platform → default branch.',
|
|
529
|
+
codebase === 'greenfield' ? 'Greenfield: no code yet — the repomix code-pack step is skipped.' : 'Brownfield: each repo is packed so the Shape phases see what already exists.',
|
|
662
530
|
]);
|
|
663
531
|
const regPath = path.join(root, PROJECT_FILES.reposRegistry);
|
|
664
532
|
const registry = readJSON(regPath, { repos: [] });
|
|
@@ -670,20 +538,14 @@ export async function runSetup(root, opts = {}) {
|
|
|
670
538
|
const name = await ask(' repo name (blank to finish)', '');
|
|
671
539
|
if (!name) break;
|
|
672
540
|
if (known.has(name)) { warn(`${name} already registered — skipping`); continue; }
|
|
673
|
-
// Siblings of the
|
|
541
|
+
// Siblings of the Product are the common layout (project/{product,backend}) — `../backend` is valid.
|
|
674
542
|
const rpath = await ask(' path (relative to project root, e.g. ../backend)', `demo-repos/${name}`);
|
|
675
543
|
if (!insideWorkspace(root, rpath)) { warn(`${rpath} resolves outside the workspace (the project root's parent) — skipped`); continue; }
|
|
676
544
|
const detected = run('git', ['remote', 'get-url', 'origin'], { cwd: path.resolve(root, rpath) });
|
|
677
545
|
const platform = (await ask(' platform (github/gitlab)', detectPlatform(detected.ok ? detected.stdout : '') || 'github')).toLowerCase();
|
|
678
|
-
// Domain owners route the per-repo review. Solo (no roster) and monorepo (one repo = one owner)
|
|
679
|
-
// skip these prompts — there is no second person to route to.
|
|
680
|
-
const domain_owners = solo || mono ? [] : parseList(await ask(' domain owner(s) (yad names, space-separated)', ''));
|
|
681
|
-
const repoReviewers = solo || mono ? [] : parseList(await ask(' repo reviewer(s) (yad names, space-separated; blank to skip)', ''));
|
|
682
|
-
const repoOwners = solo || mono ? [] : parseList(await ask(' repo owner(s) (yad names, space-separated; blank to skip)', ''));
|
|
683
546
|
const default_branch = await ask(' default branch', 'main');
|
|
684
|
-
const repo = registerRepo(root, registry, { name, rpath, platform,
|
|
547
|
+
const repo = registerRepo(root, registry, { name, rpath, platform, default_branch, today: opts.today ?? null, pack: !greenfield });
|
|
685
548
|
if (!repo) continue;
|
|
686
|
-
addRepoRoles(root, name, { 'domain-owner': domain_owners, reviewer: repoReviewers, owner: repoOwners });
|
|
687
549
|
known.add(name);
|
|
688
550
|
ok(`registered ${name}`);
|
|
689
551
|
if (greenfield) info(`${name}: greenfield — skipped repomix pack (run \`yad repo refresh ${name}\` once it has code)`);
|
|
@@ -692,26 +554,8 @@ export async function runSetup(root, opts = {}) {
|
|
|
692
554
|
}
|
|
693
555
|
}
|
|
694
556
|
|
|
695
|
-
//
|
|
696
|
-
|
|
697
|
-
// in an earlier run can be set without reconnecting. Mirrors `yad roster` (repo-driven).
|
|
698
|
-
const hub7 = readJSON(hubPath, null);
|
|
699
|
-
if (!solo && registry.repos.length && hub7 && Array.isArray(hub7.roster) && hub7.roster.length
|
|
700
|
-
&& await askYesNo('Assign/update roles for connected repos?', false)) {
|
|
701
|
-
for (const member of hub7.roster) {
|
|
702
|
-
if (!(await askYesNo(` edit ${member.name}'s repo roles?`, false))) continue;
|
|
703
|
-
for (const repo of registry.repos) {
|
|
704
|
-
const cur = rolesForScope(member, repo.name);
|
|
705
|
-
if (!(await askYesNo(` set ${member.name}'s role on ${repo.name}? (current: ${cur.length ? cur.join(', ') : 'none'})`, false))) continue;
|
|
706
|
-
const input = await ask(' roles (domain-owner/reviewer/owner, space-separated; blank = clear)', cur.join(' '));
|
|
707
|
-
const want = parseList(input).filter((x) => ['owner', 'reviewer', 'domain-owner'].includes(x));
|
|
708
|
-
reconcileRepoRoles(root, member.name, repo.name, cur, want);
|
|
709
|
-
}
|
|
710
|
-
}
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
// Wire each connected repo + the hub itself
|
|
714
|
-
S('Wire connected repos + the hub (CI gates, PR template, gate-sync)');
|
|
557
|
+
// Wire each connected repo + the Product itself
|
|
558
|
+
S('Wire connected repos + the Product (CI gates, PR template, gate-sync)');
|
|
715
559
|
guide(['Installs the CI safety gates, PR/MR template, and gate-sync — automatic, no input needed.']);
|
|
716
560
|
if (registry.repos.length === 0) info('no repos to wire');
|
|
717
561
|
// Every managed file this step writes is recorded (sha per repo root) so a LATER `yad update` can
|
|
@@ -726,16 +570,16 @@ export async function runSetup(root, opts = {}) {
|
|
|
726
570
|
// same-named file is never touched.
|
|
727
571
|
applyActions(legacyRepoActions(root, repo), { force: true });
|
|
728
572
|
}
|
|
729
|
-
// the
|
|
730
|
-
const hubWiring =
|
|
573
|
+
// the Product: event-driven gate-sync CI, so platform approvals/merges drive `yad gate ci`
|
|
574
|
+
const hubWiring = productActions(root);
|
|
731
575
|
if (hubWiring.length) {
|
|
732
576
|
log(` ${c.bold('hub')} ${c.dim('(gate-sync + verified-commits CI)')}`);
|
|
733
577
|
applyActions(hubWiring, { force: true });
|
|
734
578
|
wired.push(...hubWiring);
|
|
735
579
|
}
|
|
736
580
|
applyActions(legacyHubActions(root), { force: true });
|
|
737
|
-
// the
|
|
738
|
-
// the moment it tries it rather than by a failed pipeline later (#171).
|
|
581
|
+
// the Product, locally: the harness ledger guard, so an agent is refused the CI-owned ledger write at
|
|
582
|
+
// the moment it tries it rather than by a failed pipeline later (#171). Verified-only like the CI
|
|
739
583
|
// above — with no bridge the ledger is locally owned and the guard would be wrong.
|
|
740
584
|
const hookWiring = hookActions(root, ideTargets);
|
|
741
585
|
if (hookWiring.length) {
|
|
@@ -746,8 +590,6 @@ export async function runSetup(root, opts = {}) {
|
|
|
746
590
|
// After every write to a managed path has landed (including the legacy renames), so the recorded
|
|
747
591
|
// sha is the file's final state.
|
|
748
592
|
recordManagedWrites(wired);
|
|
749
|
-
// author allowlists for the verified-commits gate (hub + every repo), from the roster emails
|
|
750
|
-
applyActions(authorsActions(root, registry.repos), { force: true });
|
|
751
593
|
|
|
752
594
|
// Optional CodeRabbit
|
|
753
595
|
S('AI review (CodeRabbit)');
|
|
@@ -768,17 +610,20 @@ export async function runSetup(root, opts = {}) {
|
|
|
768
610
|
log('');
|
|
769
611
|
// Tailored fastest path to the first epic, by profile.
|
|
770
612
|
log(c.bold('Next:'));
|
|
613
|
+
// Which skill authors the epic is the PROJECT's setting (E6), and `yad setup` re-runs on a project
|
|
614
|
+
// that already has one — so naming the catalogue default here would send a team to a skill their own
|
|
615
|
+
// `yad next` never mentions again. `yad-backfill` stays literal: waking a brownfield anchor is the
|
|
616
|
+
// engine's own promote verb, not a step on any chain.
|
|
617
|
+
const epicSkill = stepSkills('epic', loadSkillBindings(root))[0] || 'yad-epic';
|
|
771
618
|
if (codebase === 'brownfield' && registry.repos.length) {
|
|
772
|
-
hand(
|
|
619
|
+
hand(`capture what already exists first: run \`yad-backfill\`, then your first epic with \`${epicSkill}\``);
|
|
773
620
|
} else {
|
|
774
|
-
hand(
|
|
621
|
+
hand(`author your first epic: run \`${epicSkill}\``);
|
|
775
622
|
}
|
|
776
623
|
hand('your single next action, anytime: `yad next`');
|
|
777
|
-
if (!solo
|
|
778
|
-
hand('add reviewers when ready: `yad roster add <login>` (an owner + 1 reviewer passes a gate)');
|
|
779
|
-
}
|
|
624
|
+
if (!solo) hand('reviewers need no setup: anyone with access approves on the platform; a gate needs one approval (not the author\'s own), resolved threads and the merge');
|
|
780
625
|
log('');
|
|
781
|
-
log(c.bold('Then — AI-only steps (run in
|
|
626
|
+
log(c.bold('Then — AI-only steps (run in your AI agent):'));
|
|
782
627
|
if (registry.repos.length) hand('generate code-maps: run `yad-connect-repos` for each connected repo');
|
|
783
628
|
const design = readJSON(designPath, null);
|
|
784
629
|
if (design && design.tool && design.tool !== 'none') {
|
|
@@ -794,11 +639,18 @@ export async function runSetup(root, opts = {}) {
|
|
|
794
639
|
}
|
|
795
640
|
log('');
|
|
796
641
|
log(c.dim('Re-run anytime: `yad check` (report) / `yad check --fix` (reconcile).'));
|
|
642
|
+
// The --json answer (E1). A --json setup never prompts: every answer comes from a flag, or the run is
|
|
643
|
+
// refused (YAD-CLI-001) at the first question it would have asked — which can come after an earlier
|
|
644
|
+
// step has written, so a scripted setup passes every flag (or SDLC_NONINTERACTIVE for the defaults).
|
|
645
|
+
return {
|
|
646
|
+
profile: { solo, team_size, codebase, repo_layout }, ideTargets,
|
|
647
|
+
repos: registry.repos.map((r) => r.name ?? null), next: epicSkill,
|
|
648
|
+
};
|
|
797
649
|
}
|
|
798
650
|
|
|
799
|
-
// The repomix pack is a large, regenerable artifact — the
|
|
651
|
+
// The repomix pack is a large, regenerable artifact — the Product tracks the AI-authored code-map, not the
|
|
800
652
|
// pack. `yad repo refresh --push` relies on the pack being gitignored (repo-publish.mjs never stages it);
|
|
801
|
-
// this makes that assumption true in every
|
|
653
|
+
// this makes that assumption true in every Product, so a regenerated pack never strands as a dirty tree.
|
|
802
654
|
export const PACK_IGNORE_GLOB = '.sdlc/code-context/*/pack.md';
|
|
803
655
|
|
|
804
656
|
// The exact lines ensurePackIgnored appends — a comment pair + the glob. Kept as data (not inline
|
|
@@ -810,7 +662,7 @@ export const PACK_IGNORE_BLOCK = [
|
|
|
810
662
|
PACK_IGNORE_GLOB,
|
|
811
663
|
];
|
|
812
664
|
|
|
813
|
-
// Idempotently ensure the
|
|
665
|
+
// Idempotently ensure the Product `.gitignore` ignores the repomix pack. No-op (returns false) if the line
|
|
814
666
|
// is already present (as its own entry); otherwise appends the managed block to a fresh or existing file
|
|
815
667
|
// and returns true.
|
|
816
668
|
export function ensurePackIgnored(root) {
|
|
@@ -832,7 +684,7 @@ export function packRepo(root, repo) {
|
|
|
832
684
|
ensurePackIgnored(root); // keep the pack out of git before it is (re)written — see repo-publish.mjs invariant 1
|
|
833
685
|
info(`${repo.name}: packing with repomix …`);
|
|
834
686
|
const r = run('npx', ['repomix@latest', '--compress', '--include-logs', '--style', 'markdown', '-o', out], { cwd: repoRoot });
|
|
835
|
-
if (r.ok) { ok(`${repo.name}: cached ${repo.contextPack}`); hand(`${repo.name}: generate the code-map in
|
|
687
|
+
if (r.ok) { ok(`${repo.name}: cached ${repo.contextPack}`); hand(`${repo.name}: generate the code-map in your AI agent (yad-connect-repos)`); return true; }
|
|
836
688
|
fail(`${repo.name}: repomix failed — ${r.stderr.split('\n')[0] || 'unknown error'}`);
|
|
837
689
|
return false;
|
|
838
690
|
}
|