yadflow 3.19.0-next.2 → 4.0.0-next.3
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 +371 -0
- package/README.md +160 -22
- package/bin/commands.mjs +46 -0
- package/bin/yad.mjs +506 -117
- package/cli/artifact-status.mjs +30 -11
- package/cli/capture.mjs +417 -0
- package/cli/checkpoint.mjs +47 -27
- package/cli/claims.mjs +165 -0
- package/cli/codeowners-command.mjs +170 -0
- package/cli/codeowners.mjs +397 -0
- package/cli/commit.mjs +13 -9
- package/cli/dial.mjs +183 -0
- package/cli/docs.mjs +88 -32
- package/cli/doctor.mjs +1682 -90
- package/cli/epic-state.mjs +3460 -211
- package/cli/epic.mjs +506 -0
- package/cli/errors.mjs +3 -0
- package/cli/fold.mjs +247 -0
- package/cli/gate-local.mjs +296 -0
- package/cli/gate.mjs +977 -183
- package/cli/history.mjs +556 -0
- package/cli/hook.mjs +264 -53
- package/cli/hubcommit.mjs +9 -19
- package/cli/index-command.mjs +87 -0
- package/cli/ledger.mjs +54 -4
- package/cli/lib.mjs +161 -9
- package/cli/manifest.mjs +376 -38
- package/cli/migrate.mjs +683 -52
- package/cli/mode.mjs +170 -0
- package/cli/next.mjs +365 -86
- package/cli/openpr.mjs +193 -34
- package/cli/owners.mjs +266 -0
- package/cli/people.mjs +657 -0
- package/cli/plan.mjs +466 -125
- package/cli/platform.mjs +109 -128
- package/cli/product-index.mjs +287 -0
- package/cli/protection.mjs +706 -0
- package/cli/reconcile.mjs +40 -12
- package/cli/repo-publish.mjs +24 -26
- package/cli/repo.mjs +23 -14
- package/cli/report.mjs +24 -14
- package/cli/review.mjs +19 -22
- package/cli/riskmap-command.mjs +289 -0
- package/cli/riskmap.mjs +373 -0
- package/cli/setup.mjs +127 -279
- package/cli/ship.mjs +6 -5
- package/cli/skill.mjs +180 -0
- package/cli/skip.mjs +211 -30
- package/cli/thread.mjs +41 -16
- package/cli/tidy.mjs +18 -18
- package/cli/update-commit.mjs +21 -21
- package/cli/usage.mjs +115 -109
- package/package.json +3 -3
- package/skills/sdlc/config.yaml +149 -70
- package/skills/sdlc/module-help.csv +24 -24
- package/skills/yad-analysis/SKILL.md +121 -59
- package/skills/yad-architecture/SKILL.md +30 -18
- package/skills/yad-architecture/references/contract-format.md +8 -5
- package/skills/yad-backfill/SKILL.md +10 -4
- package/skills/yad-backfill/references/backfill.md +8 -1
- package/skills/yad-backfill/templates/checks/backfill-check.sh +10 -2
- package/skills/yad-change/SKILL.md +119 -42
- package/skills/yad-change/references/triage.md +42 -27
- package/skills/yad-checks/SKILL.md +68 -35
- package/skills/yad-checks/references/check-gates.md +310 -85
- package/skills/yad-checks/templates/checks/commit-message.sh +17 -3
- package/skills/yad-checks/templates/checks/contract-check.sh +130 -9
- package/skills/yad-checks/templates/checks/epic-open.sh +2 -2
- package/skills/yad-checks/templates/checks/ledger-guard.sh +55 -8
- package/skills/yad-checks/templates/checks/lineage-check.sh +23 -9
- package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +1 -1
- 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 +13 -2
- package/skills/yad-checks/templates/github/yad-hub-checks.yml +5 -5
- package/skills/yad-checks/templates/github/yad-update-guard.yml +7 -5
- package/skills/yad-checks/templates/github/yad-verified-commits.yml +4 -4
- package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +10 -1
- 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 +3 -3
- package/skills/yad-checks/templates/hooks/ledger-guard-cursor.sh +91 -0
- package/skills/yad-checks/templates/hooks/ledger-guard.sh +37 -6
- package/skills/yad-checks/templates/hooks/yad-capture.sh +53 -0
- package/skills/yad-commit/SKILL.md +4 -4
- package/skills/yad-connect-design/SKILL.md +5 -5
- 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 +11 -11
- 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 +66 -45
- package/skills/yad-connect-repos/references/code-context.md +4 -4
- package/skills/yad-connect-repos/references/hub-config.md +47 -51
- package/skills/yad-connect-repos/references/repos-registry.md +8 -7
- package/skills/yad-connect-repos/references/risk-map.md +81 -0
- package/skills/yad-connect-testing/SKILL.md +5 -5
- 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 +7 -7
- package/skills/yad-discovery/SKILL.md +131 -94
- package/skills/yad-discovery/references/discovery-schema.md +22 -6
- package/skills/yad-discovery/references/foundation-schema.md +374 -0
- package/skills/yad-docs/SKILL.md +13 -8
- 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 +30 -15
- package/skills/yad-docs-overview/references/pipeline-model.md +33 -20
- 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 +81 -17
- package/skills/yad-engineer-review/references/ship-and-record.md +17 -8
- package/skills/yad-epic/SKILL.md +173 -92
- package/skills/yad-epic/references/state-schema.md +610 -94
- package/skills/yad-hub-bridge/SKILL.md +57 -38
- package/skills/yad-hub-bridge/references/bridge.md +94 -67
- 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 +21 -11
- package/skills/yad-implement/SKILL.md +27 -13
- 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 +61 -26
- package/skills/yad-pair-review/SKILL.md +7 -5
- package/skills/yad-pair-review/references/session-state.md +1 -1
- package/skills/yad-pr-template/SKILL.md +47 -26
- package/skills/yad-pr-template/references/risk-routing.md +97 -24
- package/skills/yad-pr-template/templates/checks/pr-template.sh +34 -11
- package/skills/yad-pr-template/templates/checks/pr-title.sh +32 -9
- 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 +14 -13
- package/skills/yad-pr-template/templates/hub/gitlab/merge_request_templates/Default.md +14 -12
- package/skills/yad-reconcile/SKILL.md +3 -3
- package/skills/yad-report/SKILL.md +5 -5
- package/skills/yad-review-companion/SKILL.md +6 -3
- package/skills/yad-review-gate/SKILL.md +203 -93
- package/skills/yad-review-gate/references/gating.md +232 -54
- package/skills/yad-run/SKILL.md +81 -51
- package/skills/yad-run/references/run-loop.md +61 -39
- package/skills/yad-ship/SKILL.md +15 -11
- package/skills/yad-spec/SKILL.md +21 -6
- package/skills/yad-spec/references/spec-handoff.md +17 -5
- package/skills/yad-status/SKILL.md +115 -50
- package/skills/yad-stories/SKILL.md +37 -21
- package/skills/yad-stories/references/story-schema.md +7 -6
- package/skills/yad-stub/SKILL.md +58 -47
- package/skills/yad-sync-repos/SKILL.md +2 -2
- package/skills/yad-test-cases/SKILL.md +32 -23
- package/skills/yad-test-cases/references/test-cases-schema.md +7 -4
- package/skills/yad-timeline/SKILL.md +8 -7
- package/skills/yad-ui/SKILL.md +41 -18
- 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, captureHookActions,
|
|
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, ledger: 'local', 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 };
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
// Inverse of addRepoRoles: drop the named role(s) from a member's `roles[<repo>]` scope, removing the
|
|
179
|
-
// scope key when it empties. Member is found by yad `name` (matching addRepoRoles). Idempotent.
|
|
180
|
-
export function removeRepoRole(root, name, repo, roles = []) {
|
|
181
|
-
const hubPath = path.join(root, PROJECT_FILES.hubConfig);
|
|
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'}`);
|
|
88
|
+
return { ...(cur || {}), ...fields };
|
|
226
89
|
}
|
|
227
90
|
|
|
228
|
-
|
|
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,69 +361,45 @@ 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
368
|
'Your hub is this repo on GitHub/GitLab (or none for a local gate).',
|
|
488
|
-
'Solo:
|
|
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
386
|
warn(`unknown platform '${platform}' — using none (local gate)`);
|
|
505
387
|
platform = 'none';
|
|
506
388
|
}
|
|
507
|
-
const
|
|
508
|
-
// Solo mode needs no roster — the lone developer is owner and reviewer-by-merge.
|
|
509
|
-
if (!solo && await askYesNo('Add reviewers to the roster now?', true)) {
|
|
510
|
-
for (;;) {
|
|
511
|
-
const login = await ask(' reviewer platform login (blank to finish)', '');
|
|
512
|
-
if (!login) break;
|
|
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');
|
|
389
|
+
const default_branch = platform === 'none' ? 'main' : await ask('Product default branch', 'main');
|
|
531
390
|
// `ledger` is the canonical switch (shape 2): "verified" = CI writes the ledger, "local" = this
|
|
532
391
|
// machine does. The two booleans below say the same thing in the older spelling and are written
|
|
533
392
|
// ALONGSIDE it, not instead of it — add before you remove (rule 3). They are what a check gate
|
|
534
|
-
// that has not been refreshed by `yad update` yet still reads, and what a
|
|
393
|
+
// that has not been refreshed by `yad update` yet still reads, and what a Product that is rolled back
|
|
535
394
|
// to a 3.x CLI would fall back to. They go in a later major, once nothing on either side reads them.
|
|
536
395
|
const enabled = platform !== 'none';
|
|
537
396
|
// Record git_url — doctor needs it to scope the auth probe (YAD-CFG-005) and the verified ledger/PR flow
|
|
538
397
|
// needs it to open PRs. Derived from the origin remote already resolved above; null when local-only.
|
|
539
398
|
const git_url = enabled ? ((remote.ok && remote.stdout.trim()) || null) : null;
|
|
540
|
-
// Merge into the existing file, never clobber:
|
|
541
|
-
//
|
|
542
|
-
//
|
|
543
|
-
|
|
544
|
-
// the file with identity stripped — the same silent-loss hole, just triggered by a parse failure.
|
|
545
|
-
const cur = readJSONStrict(hubPath, {}) || {};
|
|
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, {}) || {};
|
|
546
403
|
// `ledger` belongs to shape 2. On a project still on shape 1 — one that has not run
|
|
547
404
|
// `yad migrate` yet — writing it would leave a file DECLARING shape 1 while carrying a shape-2
|
|
548
405
|
// field, which is rule 1 read backwards and makes `yad doctor`'s drift report a lie about the
|
|
@@ -554,27 +411,26 @@ export async function runSetup(root, opts = {}) {
|
|
|
554
411
|
platform: enabled ? platform : null, git_url,
|
|
555
412
|
...(onNewShape ? { ledger: enabled ? 'verified' : 'local' } : {}),
|
|
556
413
|
bridge_enabled: enabled, bridge: enabled,
|
|
557
|
-
default_branch,
|
|
414
|
+
default_branch, ...setupModeFields(root, cur, solo, opts), profile: { codebase, repo_layout, team_size },
|
|
558
415
|
});
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
}
|
|
562
|
-
if (cur.verified_authors?.length) info(`preserved ${cur.verified_authors.length} verified_authors entry(ies)`);
|
|
563
|
-
writeJSON(hubPath, next);
|
|
564
|
-
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)' : ''}`);
|
|
565
418
|
}
|
|
566
419
|
// Persist the profile + solo flag even on the "keeping existing" path, so re-running setup with new
|
|
567
420
|
// flags (e.g. `yad setup --solo`) updates the mode without a full reconfigure. Merge, never clobber.
|
|
568
421
|
// Also backfill a missing git_url from origin here (idempotent repair for the doctor's YAD-CFG-005).
|
|
569
|
-
if (exists(
|
|
570
|
-
// Strict read for the same reason as the reconfigure write above: a corrupt
|
|
571
|
-
// fail-open to `{}` and get rewritten with
|
|
572
|
-
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, {}) || {};
|
|
573
426
|
const backfillUrl = (cur.platform && !cur.git_url)
|
|
574
427
|
? ((run('git', ['remote', 'get-url', 'origin'], { cwd: root }).stdout || '').trim() || null)
|
|
575
428
|
: null;
|
|
576
|
-
|
|
577
|
-
|
|
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)`);
|
|
578
434
|
if (backfillUrl) info(`backfilled hub git_url from origin: ${backfillUrl}`);
|
|
579
435
|
else info(`recorded profile: ${solo ? 'solo' : `team(${team_size})`}, ${codebase}, ${repo_layout}`);
|
|
580
436
|
}
|
|
@@ -664,12 +520,12 @@ export async function runSetup(root, opts = {}) {
|
|
|
664
520
|
S(repo_layout === 'monorepo' ? 'Connect your code repo (monorepo)' : 'Connect code repos');
|
|
665
521
|
guide(repo_layout === 'monorepo'
|
|
666
522
|
? [
|
|
667
|
-
'One repo holds all the code; the contract lives in the
|
|
523
|
+
'One repo holds all the code; the contract lives in the Product and stories tag this single repo.',
|
|
668
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.',
|
|
669
525
|
]
|
|
670
526
|
: [
|
|
671
527
|
'Register each code repo the feature touches; stories get tagged with the repos that implement them.',
|
|
672
|
-
'Per repo: name → path (inside this project) → platform →
|
|
528
|
+
'Per repo: name → path (inside this project) → platform → default branch.',
|
|
673
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.',
|
|
674
530
|
]);
|
|
675
531
|
const regPath = path.join(root, PROJECT_FILES.reposRegistry);
|
|
@@ -682,20 +538,14 @@ export async function runSetup(root, opts = {}) {
|
|
|
682
538
|
const name = await ask(' repo name (blank to finish)', '');
|
|
683
539
|
if (!name) break;
|
|
684
540
|
if (known.has(name)) { warn(`${name} already registered — skipping`); continue; }
|
|
685
|
-
// Siblings of the
|
|
541
|
+
// Siblings of the Product are the common layout (project/{product,backend}) — `../backend` is valid.
|
|
686
542
|
const rpath = await ask(' path (relative to project root, e.g. ../backend)', `demo-repos/${name}`);
|
|
687
543
|
if (!insideWorkspace(root, rpath)) { warn(`${rpath} resolves outside the workspace (the project root's parent) — skipped`); continue; }
|
|
688
544
|
const detected = run('git', ['remote', 'get-url', 'origin'], { cwd: path.resolve(root, rpath) });
|
|
689
545
|
const platform = (await ask(' platform (github/gitlab)', detectPlatform(detected.ok ? detected.stdout : '') || 'github')).toLowerCase();
|
|
690
|
-
// Domain owners route the per-repo review. Solo (no roster) and monorepo (one repo = one owner)
|
|
691
|
-
// skip these prompts — there is no second person to route to.
|
|
692
|
-
const domain_owners = solo || mono ? [] : parseList(await ask(' domain owner(s) (yad names, space-separated)', ''));
|
|
693
|
-
const repoReviewers = solo || mono ? [] : parseList(await ask(' repo reviewer(s) (yad names, space-separated; blank to skip)', ''));
|
|
694
|
-
const repoOwners = solo || mono ? [] : parseList(await ask(' repo owner(s) (yad names, space-separated; blank to skip)', ''));
|
|
695
546
|
const default_branch = await ask(' default branch', 'main');
|
|
696
|
-
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 });
|
|
697
548
|
if (!repo) continue;
|
|
698
|
-
addRepoRoles(root, name, { 'domain-owner': domain_owners, reviewer: repoReviewers, owner: repoOwners });
|
|
699
549
|
known.add(name);
|
|
700
550
|
ok(`registered ${name}`);
|
|
701
551
|
if (greenfield) info(`${name}: greenfield — skipped repomix pack (run \`yad repo refresh ${name}\` once it has code)`);
|
|
@@ -704,26 +554,8 @@ export async function runSetup(root, opts = {}) {
|
|
|
704
554
|
}
|
|
705
555
|
}
|
|
706
556
|
|
|
707
|
-
//
|
|
708
|
-
|
|
709
|
-
// in an earlier run can be set without reconnecting. Mirrors `yad roster` (repo-driven).
|
|
710
|
-
const hub7 = readJSON(hubPath, null);
|
|
711
|
-
if (!solo && registry.repos.length && hub7 && Array.isArray(hub7.roster) && hub7.roster.length
|
|
712
|
-
&& await askYesNo('Assign/update roles for connected repos?', false)) {
|
|
713
|
-
for (const member of hub7.roster) {
|
|
714
|
-
if (!(await askYesNo(` edit ${member.name}'s repo roles?`, false))) continue;
|
|
715
|
-
for (const repo of registry.repos) {
|
|
716
|
-
const cur = rolesForScope(member, repo.name);
|
|
717
|
-
if (!(await askYesNo(` set ${member.name}'s role on ${repo.name}? (current: ${cur.length ? cur.join(', ') : 'none'})`, false))) continue;
|
|
718
|
-
const input = await ask(' roles (domain-owner/reviewer/owner, space-separated; blank = clear)', cur.join(' '));
|
|
719
|
-
const want = parseList(input).filter((x) => ['owner', 'reviewer', 'domain-owner'].includes(x));
|
|
720
|
-
reconcileRepoRoles(root, member.name, repo.name, cur, want);
|
|
721
|
-
}
|
|
722
|
-
}
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
// Wire each connected repo + the hub itself
|
|
726
|
-
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)');
|
|
727
559
|
guide(['Installs the CI safety gates, PR/MR template, and gate-sync — automatic, no input needed.']);
|
|
728
560
|
if (registry.repos.length === 0) info('no repos to wire');
|
|
729
561
|
// Every managed file this step writes is recorded (sha per repo root) so a LATER `yad update` can
|
|
@@ -738,15 +570,15 @@ export async function runSetup(root, opts = {}) {
|
|
|
738
570
|
// same-named file is never touched.
|
|
739
571
|
applyActions(legacyRepoActions(root, repo), { force: true });
|
|
740
572
|
}
|
|
741
|
-
// the
|
|
742
|
-
const hubWiring =
|
|
573
|
+
// the Product: event-driven gate-sync CI, so platform approvals/merges drive `yad gate ci`
|
|
574
|
+
const hubWiring = productActions(root);
|
|
743
575
|
if (hubWiring.length) {
|
|
744
576
|
log(` ${c.bold('hub')} ${c.dim('(gate-sync + verified-commits CI)')}`);
|
|
745
577
|
applyActions(hubWiring, { force: true });
|
|
746
578
|
wired.push(...hubWiring);
|
|
747
579
|
}
|
|
748
580
|
applyActions(legacyHubActions(root), { force: true });
|
|
749
|
-
// the
|
|
581
|
+
// the Product, locally: the harness ledger guard, so an agent is refused the CI-owned ledger write at
|
|
750
582
|
// the moment it tries it rather than by a failed pipeline later (#171). Verified-only like the CI
|
|
751
583
|
// above — with no bridge the ledger is locally owned and the guard would be wrong.
|
|
752
584
|
const hookWiring = hookActions(root, ideTargets);
|
|
@@ -755,11 +587,17 @@ export async function runSetup(root, opts = {}) {
|
|
|
755
587
|
applyActions(hookWiring, { force: true });
|
|
756
588
|
wired.push(...hookWiring);
|
|
757
589
|
}
|
|
590
|
+
// The post-edit capture hook (E43): every change to an artifact is snapshotted onto the person's private
|
|
591
|
+
// `yad/wip/<name>/<epic>` branches. Both ledger modes; `"capture": false` in the Product config turns it off.
|
|
592
|
+
const captureWiring = captureHookActions(root, ideTargets);
|
|
593
|
+
if (captureWiring.length) {
|
|
594
|
+
log(` ${c.bold('hub')} ${c.dim('(wip capture)')}`);
|
|
595
|
+
applyActions(captureWiring, { force: true });
|
|
596
|
+
wired.push(...captureWiring);
|
|
597
|
+
}
|
|
758
598
|
// After every write to a managed path has landed (including the legacy renames), so the recorded
|
|
759
599
|
// sha is the file's final state.
|
|
760
600
|
recordManagedWrites(wired);
|
|
761
|
-
// author allowlists for the verified-commits gate (hub + every repo), from the roster emails
|
|
762
|
-
applyActions(authorsActions(root, registry.repos), { force: true });
|
|
763
601
|
|
|
764
602
|
// Optional CodeRabbit
|
|
765
603
|
S('AI review (CodeRabbit)');
|
|
@@ -780,17 +618,20 @@ export async function runSetup(root, opts = {}) {
|
|
|
780
618
|
log('');
|
|
781
619
|
// Tailored fastest path to the first epic, by profile.
|
|
782
620
|
log(c.bold('Next:'));
|
|
621
|
+
// Which skill authors the epic is the PROJECT's setting (E6), and `yad setup` re-runs on a project
|
|
622
|
+
// that already has one — so naming the catalogue default here would send a team to a skill their own
|
|
623
|
+
// `yad next` never mentions again. `yad-backfill` stays literal: waking a brownfield anchor is the
|
|
624
|
+
// engine's own promote verb, not a step on any chain.
|
|
625
|
+
const epicSkill = stepSkills('epic', loadSkillBindings(root))[0] || 'yad-epic';
|
|
783
626
|
if (codebase === 'brownfield' && registry.repos.length) {
|
|
784
|
-
hand(
|
|
627
|
+
hand(`capture what already exists first: run \`yad-backfill\`, then your first epic with \`${epicSkill}\``);
|
|
785
628
|
} else {
|
|
786
|
-
hand(
|
|
629
|
+
hand(`author your first epic: run \`${epicSkill}\``);
|
|
787
630
|
}
|
|
788
631
|
hand('your single next action, anytime: `yad next`');
|
|
789
|
-
if (!solo
|
|
790
|
-
hand('add reviewers when ready: `yad roster add <login>` (an owner + 1 reviewer passes a gate)');
|
|
791
|
-
}
|
|
632
|
+
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');
|
|
792
633
|
log('');
|
|
793
|
-
log(c.bold('Then — AI-only steps (run in
|
|
634
|
+
log(c.bold('Then — AI-only steps (run in your AI agent):'));
|
|
794
635
|
if (registry.repos.length) hand('generate code-maps: run `yad-connect-repos` for each connected repo');
|
|
795
636
|
const design = readJSON(designPath, null);
|
|
796
637
|
if (design && design.tool && design.tool !== 'none') {
|
|
@@ -806,11 +647,18 @@ export async function runSetup(root, opts = {}) {
|
|
|
806
647
|
}
|
|
807
648
|
log('');
|
|
808
649
|
log(c.dim('Re-run anytime: `yad check` (report) / `yad check --fix` (reconcile).'));
|
|
650
|
+
// The --json answer (E1). A --json setup never prompts: every answer comes from a flag, or the run is
|
|
651
|
+
// refused (YAD-CLI-001) at the first question it would have asked — which can come after an earlier
|
|
652
|
+
// step has written, so a scripted setup passes every flag (or SDLC_NONINTERACTIVE for the defaults).
|
|
653
|
+
return {
|
|
654
|
+
profile: { solo, team_size, codebase, repo_layout }, ideTargets,
|
|
655
|
+
repos: registry.repos.map((r) => r.name ?? null), next: epicSkill,
|
|
656
|
+
};
|
|
809
657
|
}
|
|
810
658
|
|
|
811
|
-
// The repomix pack is a large, regenerable artifact — the
|
|
659
|
+
// The repomix pack is a large, regenerable artifact — the Product tracks the AI-authored code-map, not the
|
|
812
660
|
// pack. `yad repo refresh --push` relies on the pack being gitignored (repo-publish.mjs never stages it);
|
|
813
|
-
// this makes that assumption true in every
|
|
661
|
+
// this makes that assumption true in every Product, so a regenerated pack never strands as a dirty tree.
|
|
814
662
|
export const PACK_IGNORE_GLOB = '.sdlc/code-context/*/pack.md';
|
|
815
663
|
|
|
816
664
|
// The exact lines ensurePackIgnored appends — a comment pair + the glob. Kept as data (not inline
|
|
@@ -822,7 +670,7 @@ export const PACK_IGNORE_BLOCK = [
|
|
|
822
670
|
PACK_IGNORE_GLOB,
|
|
823
671
|
];
|
|
824
672
|
|
|
825
|
-
// Idempotently ensure the
|
|
673
|
+
// Idempotently ensure the Product `.gitignore` ignores the repomix pack. No-op (returns false) if the line
|
|
826
674
|
// is already present (as its own entry); otherwise appends the managed block to a fresh or existing file
|
|
827
675
|
// and returns true.
|
|
828
676
|
export function ensurePackIgnored(root) {
|
|
@@ -844,7 +692,7 @@ export function packRepo(root, repo) {
|
|
|
844
692
|
ensurePackIgnored(root); // keep the pack out of git before it is (re)written — see repo-publish.mjs invariant 1
|
|
845
693
|
info(`${repo.name}: packing with repomix …`);
|
|
846
694
|
const r = run('npx', ['repomix@latest', '--compress', '--include-logs', '--style', 'markdown', '-o', out], { cwd: repoRoot });
|
|
847
|
-
if (r.ok) { ok(`${repo.name}: cached ${repo.contextPack}`); hand(`${repo.name}: generate the code-map in
|
|
695
|
+
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; }
|
|
848
696
|
fail(`${repo.name}: repomix failed — ${r.stderr.split('\n')[0] || 'unknown error'}`);
|
|
849
697
|
return false;
|
|
850
698
|
}
|