yadflow 4.0.0-next.3 → 4.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +699 -0
- package/README.md +85 -22
- package/bin/commands.mjs +3 -1
- package/bin/yad.mjs +183 -29
- package/cli/capture.mjs +4 -2
- package/cli/checkpoint.mjs +10 -10
- package/cli/claims.mjs +4 -4
- package/cli/commit.mjs +48 -12
- package/cli/docs.mjs +15 -13
- package/cli/doctor.mjs +557 -141
- package/cli/epic-state.mjs +41 -18
- package/cli/errors.mjs +3 -2
- package/cli/find-product.mjs +197 -0
- package/cli/fold.mjs +4 -4
- package/cli/gate-local.mjs +13 -13
- package/cli/gate.mjs +111 -103
- package/cli/history.mjs +13 -13
- package/cli/hook.mjs +201 -30
- package/cli/index-command.mjs +6 -6
- package/cli/lib.mjs +91 -29
- package/cli/manifest.mjs +171 -104
- package/cli/migrate.mjs +208 -17
- package/cli/mode.mjs +22 -22
- package/cli/next.mjs +6 -6
- package/cli/openpr.mjs +32 -24
- package/cli/people.mjs +22 -10
- package/cli/plan.mjs +718 -45
- package/cli/platform.mjs +13 -7
- package/cli/product-index.mjs +4 -4
- package/cli/{hubcommit.mjs → productcommit.mjs} +11 -7
- package/cli/protection.mjs +19 -9
- package/cli/reconcile.mjs +98 -23
- package/cli/repo-publish.mjs +22 -16
- package/cli/repo.mjs +128 -33
- package/cli/report.mjs +4 -2
- package/cli/review.mjs +26 -19
- package/cli/setup.mjs +105 -36
- package/cli/ship.mjs +1 -1
- package/cli/skip.mjs +4 -4
- package/cli/tidy.mjs +7 -7
- package/cli/update-commit.mjs +22 -19
- package/cli/workspace.mjs +495 -0
- package/package.json +7 -4
- package/skills/sdlc/config.yaml +14 -13
- package/skills/sdlc/module-help.csv +8 -8
- package/skills/yad-analysis/SKILL.md +6 -4
- package/skills/yad-architecture/SKILL.md +4 -3
- package/skills/yad-backfill/SKILL.md +11 -5
- package/skills/yad-backfill/references/backfill.md +33 -3
- package/skills/yad-backfill/templates/checks/backfill-check.sh +200 -17
- package/skills/yad-change/SKILL.md +1 -1
- package/skills/yad-checks/SKILL.md +48 -27
- package/skills/yad-checks/references/check-gates.md +213 -53
- package/skills/yad-checks/templates/checks/commit-message.sh +39 -13
- package/skills/yad-checks/templates/checks/contract-check.sh +472 -23
- package/skills/yad-checks/templates/checks/epic-open.sh +398 -8
- package/skills/yad-checks/templates/checks/ledger-guard.sh +75 -25
- package/skills/yad-checks/templates/checks/lineage-check.sh +391 -7
- package/skills/yad-checks/templates/checks/product-checkout.sh +141 -0
- package/skills/yad-checks/templates/checks/reconcile-debt-check.sh +399 -8
- package/skills/yad-checks/templates/checks/risk-map-check.sh +35 -3
- package/skills/yad-checks/templates/checks/spec-link.sh +36 -3
- package/skills/yad-checks/templates/checks/verified-commits.sh +26 -2
- package/skills/yad-checks/templates/github/yad-checks.yml +33 -13
- package/skills/yad-checks/templates/github/{yad-hub-checks.yml → yad-product-checks.yml} +14 -10
- package/skills/yad-checks/templates/github/yad-update-guard.yml +1 -1
- package/skills/yad-checks/templates/github/yad-verified-commits.yml +1 -1
- package/skills/yad-checks/templates/gitlab/yad-checks.gitlab-ci.yml +12 -0
- package/skills/yad-checks/templates/gitlab/{yad-hub-checks.gitlab-ci.yml → yad-product-checks.gitlab-ci.yml} +16 -16
- package/skills/yad-checks/templates/gitlab/yad-verified-commits.gitlab-ci.yml +2 -2
- package/skills/yad-checks/templates/hooks/ledger-guard-cursor.mjs +130 -0
- package/skills/yad-checks/templates/hooks/ledger-guard.mjs +117 -0
- package/skills/yad-checks/templates/hooks/yad-capture.mjs +117 -0
- package/skills/yad-commit/SKILL.md +18 -7
- package/skills/yad-connect-design/SKILL.md +1 -1
- package/skills/yad-connect-design/references/design-registry.md +1 -1
- package/skills/yad-connect-docs/SKILL.md +16 -11
- package/skills/yad-connect-docs/references/docs-registry.md +8 -8
- package/skills/yad-connect-learning/SKILL.md +1 -1
- package/skills/yad-connect-learning/references/learning-registry.md +1 -1
- package/skills/yad-connect-repos/SKILL.md +31 -8
- package/skills/yad-connect-repos/references/code-context.md +1 -1
- package/skills/yad-connect-repos/references/{hub-config.md → product-config.md} +19 -11
- package/skills/yad-connect-repos/references/repos-registry.md +1 -1
- package/skills/yad-connect-testing/SKILL.md +1 -1
- package/skills/yad-connect-testing/references/testing-registry.md +1 -1
- package/skills/yad-discovery/SKILL.md +2 -2
- package/skills/yad-discovery/references/discovery-schema.md +1 -1
- package/skills/yad-docs/SKILL.md +1 -1
- package/skills/yad-docs/references/data-mapping.md +1 -1
- package/skills/yad-docs/templates/app/package-lock.json +8 -8
- package/skills/yad-docs/templates/app/package.json +1 -1
- package/skills/yad-docs/templates/app/src/components/Canvas/FlowCanvas.tsx +2 -2
- package/skills/yad-docs-overview/references/pipeline-model.md +3 -3
- package/skills/yad-engineer-review/SKILL.md +2 -2
- package/skills/yad-engineer-review/references/ship-and-record.md +3 -3
- package/skills/yad-epic/SKILL.md +6 -4
- package/skills/yad-epic/references/state-schema.md +13 -12
- package/skills/yad-open-pr/SKILL.md +1 -1
- package/skills/yad-pair-review/SKILL.md +5 -5
- package/skills/yad-pair-review/references/session-state.md +2 -2
- package/skills/yad-pr-template/SKILL.md +14 -12
- package/skills/yad-pr-template/templates/checks/pr-template.sh +18 -16
- package/skills/yad-pr-template/templates/checks/pr-title.sh +15 -13
- package/skills/yad-pr-template/templates/{hub → product}/github/pull_request_template.md +2 -2
- package/skills/yad-pr-template/templates/{hub → product}/gitlab/merge_request_templates/Default.md +2 -2
- package/skills/{yad-hub-bridge → yad-product-bridge}/SKILL.md +15 -15
- package/skills/{yad-hub-bridge → yad-product-bridge}/references/bridge.md +15 -10
- package/skills/{yad-hub-bridge → yad-product-bridge}/references/login-roster.md +2 -2
- package/skills/{yad-hub-bridge/templates/checks/hub-route.sh → yad-product-bridge/templates/checks/product-route.sh} +2 -2
- package/skills/{yad-hub-bridge → yad-product-bridge}/templates/github/yad-gate-sync.yml +29 -16
- package/skills/{yad-hub-bridge → yad-product-bridge}/templates/gitlab/gitlab-ci.include-root.yml +1 -1
- package/skills/{yad-hub-bridge → yad-product-bridge}/templates/gitlab/yad-gate-sync.gitlab-ci.yml +5 -3
- package/skills/yad-report/SKILL.md +1 -1
- package/skills/yad-review-companion/SKILL.md +3 -3
- package/skills/yad-review-gate/SKILL.md +22 -23
- package/skills/yad-review-gate/references/gating.md +13 -13
- package/skills/yad-run/SKILL.md +2 -2
- package/skills/yad-run/references/run-loop.md +2 -2
- package/skills/yad-spec/SKILL.md +2 -1
- package/skills/yad-spec/references/spec-handoff.md +19 -10
- package/skills/yad-stories/SKILL.md +2 -2
- package/skills/yad-sync-repos/SKILL.md +19 -3
- package/skills/yad-test-cases/SKILL.md +2 -2
- package/skills/yad-ui/SKILL.md +2 -2
- package/skills/yad-checks/templates/hooks/ledger-guard-cursor.sh +0 -91
- package/skills/yad-checks/templates/hooks/ledger-guard.sh +0 -100
- package/skills/yad-checks/templates/hooks/yad-capture.sh +0 -53
package/cli/repo.mjs
CHANGED
|
@@ -1,17 +1,31 @@
|
|
|
1
|
-
// `yad repo list|refresh|sync` — connected-repo maintenance as explicit HUMAN decisions.
|
|
1
|
+
// `yad repo list|refresh|sync|clone` — connected-repo maintenance as explicit HUMAN decisions.
|
|
2
2
|
// list/refresh: staleness of the cached code-context pack (HEAD != syncedHead). Skill steps no longer
|
|
3
3
|
// silently repack a stale repo; they flag it and point here. (`yad check --fix` refreshes too — also
|
|
4
4
|
// human-invoked.) sync: switch every connected repo to its registry default_branch and fast-forward
|
|
5
5
|
// it to origin — a working-tree-only op (it never writes the registry; a dirty tree is skipped).
|
|
6
|
+
// clone (E81): clone every registered repo that is missing on this machine — `yad join`'s clone step,
|
|
7
|
+
// for the repo a teammate registered after you joined. It never runs git in a repo already there.
|
|
8
|
+
import fs from 'node:fs';
|
|
6
9
|
import path from 'node:path';
|
|
7
|
-
import { c, log, ok, info, warn, hand, fail,
|
|
10
|
+
import { c, log, ok, info, warn, hand, fail, writeJSON, run } from './lib.mjs';
|
|
8
11
|
import { PROJECT_FILES } from './manifest.mjs';
|
|
12
|
+
import { judgeRepo, cloneMissingRepos, reportClones, readRegistry, codeContextPathOk, CODE_CONTEXT_DIR, runnable, shown } from './workspace.mjs';
|
|
9
13
|
import { gitHead, packRepo } from './setup.mjs';
|
|
10
|
-
import { publishCodeContext } from './repo-publish.mjs';
|
|
14
|
+
import { publishCodeContext, packOf, codeMapOf } from './repo-publish.mjs';
|
|
11
15
|
|
|
16
|
+
// Why `refresh` will not pack this entry, or null when it will (E81): see the loop in `runRepo`.
|
|
17
|
+
function notPackable(root, repo, j) {
|
|
18
|
+
if (j.state === 'missing') return 'not cloned on this machine (`yad repo clone`)';
|
|
19
|
+
if (j.state === 'refused') return j.reason;
|
|
20
|
+
if (j.linked) return 'a folder on its path, inside a repo, is a link, so the checkout is not where the path says';
|
|
21
|
+
if (!codeContextPathOk(root, packOf(repo)) || !codeContextPathOk(root, codeMapOf(repo))) return `its contextPack or codeMap is not a path under ${CODE_CONTEXT_DIR}/ in the Product`;
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Strict (E81): a registry that does not parse is said, not read as "no repos" — and `refresh` writes it
|
|
26
|
+
// back, so a lenient read would replace a broken file (and every entry in it) with an empty list.
|
|
12
27
|
function load(root) {
|
|
13
|
-
|
|
14
|
-
return { regPath, registry: readJSON(regPath, { repos: [] }) };
|
|
28
|
+
return { regPath: path.join(root, PROJECT_FILES.reposRegistry), ...readRegistry(root) };
|
|
15
29
|
}
|
|
16
30
|
|
|
17
31
|
// HEAD != syncedHead => stale (config.yaml code_context.staleness: head-sha). A repo that has a HEAD but
|
|
@@ -29,45 +43,83 @@ const git = (cwd, ...args) => run('git', args, { cwd });
|
|
|
29
43
|
const hasRemote = (cwd, remote = 'origin') => git(cwd, 'remote', 'get-url', remote).ok;
|
|
30
44
|
const isDirty = (cwd) => { const r = git(cwd, 'status', '--porcelain'); return r.ok && r.stdout.length > 0; };
|
|
31
45
|
const currentBranch = (cwd) => { const r = git(cwd, 'rev-parse', '--abbrev-ref', 'HEAD'); return r.ok ? r.stdout : null; };
|
|
32
|
-
// Registry default_branch wins; else origin/HEAD; else 'main'.
|
|
46
|
+
// Registry default_branch wins; else origin/HEAD; else 'main'. The registry is shared content, and the
|
|
47
|
+
// name is handed to `git fetch` and `git checkout`: `--upload-pack=<command>` would run a command. So a
|
|
48
|
+
// recorded name must be a valid `refs/heads/<name>` (which also rules out `@{-1}`) and must not start
|
|
49
|
+
// with `-` or `+` (a forced refspec); anything else is null.
|
|
33
50
|
function defaultBranch(cwd, repo) {
|
|
34
|
-
if (repo.default_branch
|
|
51
|
+
if (repo.default_branch != null && repo.default_branch !== '') {
|
|
52
|
+
const b = repo.default_branch;
|
|
53
|
+
return typeof b === 'string' && !/^[-+]/.test(b) && git(cwd, 'check-ref-format', `refs/heads/${b}`).ok ? b : null;
|
|
54
|
+
}
|
|
55
|
+
// origin/HEAD is what the remote says — the remote the registry chose — so it passes the same test.
|
|
35
56
|
const r = git(cwd, 'symbolic-ref', '--short', 'refs/remotes/origin/HEAD');
|
|
36
|
-
|
|
57
|
+
if (!r.ok || !r.stdout) return 'main';
|
|
58
|
+
const b = r.stdout.replace(/^origin\//, '');
|
|
59
|
+
return !/^[-+]/.test(b) && git(cwd, 'check-ref-format', `refs/heads/${b}`).ok ? b : null;
|
|
37
60
|
}
|
|
38
61
|
|
|
39
62
|
export async function runRepo(root, { action = 'list', name, today, push = false, allowBranch = false } = {}) {
|
|
40
|
-
const { regPath, registry } = load(root);
|
|
63
|
+
const { regPath, registry, problem } = load(root);
|
|
64
|
+
if (problem) { fail(`${problem} — fix it in the Product and re-run`); process.exitCode = 1; return { action, repos: [] }; }
|
|
41
65
|
if (!registry.repos.length) { warn('no repos registered (.sdlc/repos.json) — run `yad setup`'); return { action, repos: [] }; }
|
|
66
|
+
// An entry that is not an object (`null`, `5`) is named where it would be acted on, never dereferenced.
|
|
67
|
+
const isEntry = (r) => !!r && typeof r === 'object' && !Array.isArray(r);
|
|
68
|
+
const odd = registry.repos.filter((r) => !isEntry(r)).length;
|
|
69
|
+
if (odd) warn(`${odd} entr${odd > 1 ? 'ies' : 'y'} in ${PROJECT_FILES.reposRegistry} ${odd > 1 ? 'are' : 'is'} not an object — skipped; fix ${odd > 1 ? 'them' : 'it'} in the Product`);
|
|
70
|
+
const entries = registry.repos.filter(isEntry);
|
|
42
71
|
|
|
43
72
|
if (action === 'list') {
|
|
44
73
|
log(c.bold('\nconnected repos'));
|
|
45
74
|
let staleCount = 0;
|
|
46
75
|
const rows = []; // the --json answer (E1): each repo, as the list reads it
|
|
47
|
-
|
|
76
|
+
let missing = 0;
|
|
77
|
+
for (const repo of entries) {
|
|
78
|
+
// Judged before git runs (E81): only a checkout that is there is read. A missing repo says so and
|
|
79
|
+
// names the command that clones it; a refused entry says why — git is never run in it.
|
|
80
|
+
const j = judgeRepo(root, repo);
|
|
81
|
+
// A checkout behind a link inside a repo's tree is not read either, as `sync` and `refresh` skip it.
|
|
82
|
+
if (j.linked) Object.assign(j, { state: 'refused', reason: 'a folder on its path, inside a repo, is a link, so the checkout is not where the path says' });
|
|
83
|
+
if (j.state === 'missing' || j.state === 'refused') {
|
|
84
|
+
rows.push({ name: repo.name ?? null, path: repo.path ?? null, head: null, syncedHead: repo.syncedHead ?? null, state: j.state === 'missing' ? 'missing' : 'refused', ...(j.reason ? { reason: j.reason } : {}) });
|
|
85
|
+
if (j.state === 'missing') { missing++; warn(`${j.name} ${c.dim(`(${j.path})`)} — ${c.yellow('not cloned on this machine')}`); }
|
|
86
|
+
else warn(`${j.name} ${c.dim(`(${j.path})`)} — ${j.reason}`);
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
48
89
|
const { head, stale, unknown, neverPacked } = staleness(root, repo);
|
|
49
90
|
rows.push({ name: repo.name ?? null, path: repo.path ?? null, head: head || null, syncedHead: repo.syncedHead ?? null,
|
|
50
91
|
state: unknown ? 'unreadable' : neverPacked ? 'no-pack' : stale ? 'stale' : 'fresh' });
|
|
51
|
-
if (unknown) { warn(`${
|
|
52
|
-
if (neverPacked) { staleCount++; warn(`${
|
|
53
|
-
else if (stale) { staleCount++; warn(`${
|
|
54
|
-
else ok(`${
|
|
92
|
+
if (unknown) { warn(`${j.name} ${c.dim(`(${j.path})`)} — HEAD unreadable`); continue; }
|
|
93
|
+
if (neverPacked) { staleCount++; warn(`${j.name} ${c.dim(`(${j.path})`)} — ${c.yellow('no code-context pack yet')} (registered without one)`); }
|
|
94
|
+
else if (stale) { staleCount++; warn(`${j.name} ${c.dim(`(${j.path})`)} — ${c.yellow('stale')} (HEAD moved since last pack)`); }
|
|
95
|
+
else ok(`${j.name} ${c.dim('— fresh')}`);
|
|
55
96
|
}
|
|
56
|
-
if (
|
|
57
|
-
|
|
97
|
+
if (missing) hand('clone the missing repo(s) with `yad repo clone`');
|
|
98
|
+
if (staleCount) hand(`refresh with \`yad repo refresh${entries.length > 1 ? ' <name>' : ''}\` (or \`yad repo refresh\` for all)`);
|
|
99
|
+
return { action, repos: rows, stale: staleCount, missing };
|
|
58
100
|
}
|
|
59
101
|
|
|
60
102
|
if (action === 'refresh') {
|
|
61
|
-
const targets = name ?
|
|
62
|
-
if (name && !targets.length) { fail(`unknown repo: ${name}`); process.exitCode = 1; return { action, refreshed: 0, repos: [], published: null }; }
|
|
103
|
+
const targets = name ? entries.filter((r) => r.name === name) : entries;
|
|
104
|
+
if (name && !targets.length) { fail(`unknown repo: ${shown(name)}`); process.exitCode = 1; return { action, refreshed: 0, repos: [], published: null }; }
|
|
63
105
|
let refreshed = 0;
|
|
64
106
|
let published = null;
|
|
65
107
|
const rows = [];
|
|
66
108
|
for (const repo of targets) {
|
|
109
|
+
// Judged before git runs (E81), as `sync` judges it: only a checkout that is there is packed, never
|
|
110
|
+
// one reached through a link inside a repo's tree (repomix would pack wherever the link points,
|
|
111
|
+
// git logs included). And the pack is written only where the Product keeps code context.
|
|
112
|
+
const j = judgeRepo(root, repo);
|
|
113
|
+
const why = notPackable(root, repo, j);
|
|
114
|
+
if (why) {
|
|
115
|
+
warn(`${j.name}: ${why} — skipped`);
|
|
116
|
+
rows.push({ name: repo.name ?? null, refreshed: false, head: null });
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
67
119
|
const { head, unknown } = staleness(root, repo);
|
|
68
|
-
if (unknown) { warn(`${
|
|
69
|
-
log(` ${c.bold(
|
|
70
|
-
const packed = !!packRepo(root, repo);
|
|
120
|
+
if (unknown) { warn(`${j.name}: HEAD unreadable — skipped`); rows.push({ name: repo.name ?? null, refreshed: false, head: null }); continue; }
|
|
121
|
+
log(` ${c.bold(j.name)}`);
|
|
122
|
+
const packed = !!packRepo(root, { ...repo, contextPack: packOf(repo) });
|
|
71
123
|
if (packed) {
|
|
72
124
|
repo.syncedHead = head;
|
|
73
125
|
if (today) repo.lastSyncedAt = today; // always stamp when a date is supplied (the CLI passes today)
|
|
@@ -75,6 +127,16 @@ export async function runRepo(root, { action = 'list', name, today, push = false
|
|
|
75
127
|
}
|
|
76
128
|
rows.push({ name: repo.name ?? null, refreshed: packed, head: head || null });
|
|
77
129
|
}
|
|
130
|
+
// Never through a `.sdlc` the Product commits as a link: the rename would land in the folder it names
|
|
131
|
+
// (E81 review 17).
|
|
132
|
+
let sdlcLinked = false;
|
|
133
|
+
try { sdlcLinked = fs.lstatSync(path.dirname(regPath)).isSymbolicLink(); } catch { /* not there */ }
|
|
134
|
+
if (sdlcLinked) {
|
|
135
|
+
// A refusal stops here, `--push` included (E81 review 18): nothing is published after it.
|
|
136
|
+
fail(`${path.dirname(PROJECT_FILES.reposRegistry)} in the Product is a link — ${PROJECT_FILES.reposRegistry} was not written`);
|
|
137
|
+
process.exitCode = 1;
|
|
138
|
+
return { action, refreshed, repos: rows, published: null };
|
|
139
|
+
}
|
|
78
140
|
writeJSON(regPath, registry);
|
|
79
141
|
refreshed ? ok(`refreshed ${refreshed} repo(s)`) : info('nothing refreshed');
|
|
80
142
|
if (push) {
|
|
@@ -94,43 +156,76 @@ export async function runRepo(root, { action = 'list', name, today, push = false
|
|
|
94
156
|
}
|
|
95
157
|
|
|
96
158
|
if (action === 'sync') {
|
|
97
|
-
const targets = name ?
|
|
98
|
-
if (name && !targets.length) { fail(`unknown repo: ${name}`); process.exitCode = 1; return { action, synced: 0, skipped: 0, repos: [] }; }
|
|
159
|
+
const targets = name ? entries.filter((r) => r.name === name) : entries;
|
|
160
|
+
if (name && !targets.length) { fail(`unknown repo: ${shown(name)}`); process.exitCode = 1; return { action, synced: 0, skipped: 0, repos: [] }; }
|
|
99
161
|
log(c.bold('\nsync connected repos'));
|
|
100
162
|
let synced = 0, skipped = 0;
|
|
101
163
|
for (const repo of targets) {
|
|
102
|
-
|
|
103
|
-
|
|
164
|
+
// The registry is shared content, and sync runs git in each path (E81): the same judgement the
|
|
165
|
+
// clone step makes comes first. A path outside the workspace or through `.git` is refused; so is a
|
|
166
|
+
// folder with no `.git` (a committed folder shaped like a bare repo would have git read its
|
|
167
|
+
// `config`), and a checkout reached through a link inside a repo's tree (the Product's
|
|
168
|
+
// `evil -> ../../..`). A link of the person's own, directly in the workspace folder, is followed.
|
|
169
|
+
const j = judgeRepo(root, repo);
|
|
170
|
+
const tag = `${j.name} ${c.dim(`(${j.path})`)}`;
|
|
171
|
+
if (j.state === 'missing') { warn(`${tag} — not cloned on this machine — skipped (\`yad repo clone\`)`); skipped++; continue; }
|
|
172
|
+
if (j.state === 'refused') { warn(`${tag} — ${j.reason} — skipped`); skipped++; continue; }
|
|
173
|
+
if (j.linked) { warn(`${tag} — a folder on its path, inside a repo, is a link, so the checkout is not where the path says — skipped`); skipped++; continue; }
|
|
174
|
+
// A folder inside another checkout (a monorepo's `apps/web`): switching "its" branch would switch
|
|
175
|
+
// the whole checkout, on this entry's say-so. Sync the checkout it belongs to instead.
|
|
176
|
+
if (j.inside) { info(`${tag} — a folder inside the checkout ${shown(path.relative(root, j.inside) || '.')}, not a repo of its own — skipped (sync that checkout)`); skipped++; continue; }
|
|
177
|
+
const repoRoot = j.target || path.resolve(root, repo.path);
|
|
104
178
|
if (!gitHead(repoRoot)) { warn(`${tag} — not a git repo / HEAD unreadable — skipped`); skipped++; continue; }
|
|
105
|
-
if (isDirty(repoRoot)) { warn(`${
|
|
179
|
+
if (isDirty(repoRoot)) { warn(`${j.name} — ${c.yellow('dirty')} → SKIPPED (commit/stash first)`); skipped++; continue; }
|
|
106
180
|
const branch = defaultBranch(repoRoot, repo);
|
|
181
|
+
if (!branch) { warn(`${tag} — its default branch (recorded, or origin/HEAD) is not a plain branch name git accepts — skipped (fix default_branch in ${PROJECT_FILES.reposRegistry})`); skipped++; continue; }
|
|
107
182
|
const remote = hasRemote(repoRoot);
|
|
183
|
+
// Printed through `shown` (E81 review 9): git accepts bytes above 0x7f in a branch name, and the
|
|
184
|
+
// remote a registered clone fetches from was chosen by the shared registry, so both can carry text
|
|
185
|
+
// a terminal obeys.
|
|
186
|
+
const b = shown(branch);
|
|
187
|
+
const firstLine = (s) => shown(String(s || '').split('\n')[0]);
|
|
108
188
|
if (remote) {
|
|
109
189
|
const f = git(repoRoot, 'fetch', 'origin', branch, '--prune');
|
|
110
|
-
if (!f.ok) { warn(`${
|
|
190
|
+
if (!f.ok) { warn(`${j.name} — fetch failed (${firstLine(f.stderr) || 'auth?'}) — skipped`); skipped++; continue; }
|
|
111
191
|
}
|
|
112
192
|
if (currentBranch(repoRoot) !== branch) {
|
|
113
193
|
const co = git(repoRoot, 'checkout', branch);
|
|
114
|
-
if (!co.ok) { warn(`${
|
|
194
|
+
if (!co.ok) { warn(`${j.name} — cannot switch to ${b} (${firstLine(co.stderr) || 'no such branch'}) — skipped`); skipped++; continue; }
|
|
115
195
|
}
|
|
116
196
|
if (remote) {
|
|
117
197
|
const before = gitHead(repoRoot);
|
|
118
198
|
const m = git(repoRoot, 'merge', '--ff-only', `origin/${branch}`);
|
|
119
|
-
if (!m.ok) { warn(`${
|
|
120
|
-
ok(`${
|
|
199
|
+
if (!m.ok) { warn(`${j.name} — ${c.yellow('diverged')} on ${b} → not fast-forwarded (resolve manually)`); skipped++; continue; }
|
|
200
|
+
ok(`${j.name} ${c.dim('—')} ${before === gitHead(repoRoot) ? `already current on ${b}` : `switched to ${b}, pulled (ff)`}`);
|
|
121
201
|
} else {
|
|
122
|
-
ok(`${
|
|
202
|
+
ok(`${j.name} ${c.dim('—')} on ${b} (local-only, no remote)`);
|
|
123
203
|
}
|
|
124
204
|
synced++;
|
|
125
205
|
}
|
|
126
206
|
// A pulled repo's HEAD moves, so its cached code-context pack goes stale — point the human at refresh.
|
|
127
|
-
|
|
207
|
+
// Only the checkouts the loop would run git in (E81) — never a refused or linked entry.
|
|
208
|
+
const staleCount = entries.filter((r) => runnable(judgeRepo(root, r)) && staleness(root, r).stale).length;
|
|
128
209
|
info(`synced ${synced}, skipped ${skipped}`);
|
|
129
210
|
if (staleCount) hand(`${staleCount} repo(s) now have a stale code-context pack — \`yad repo refresh\` to repack`);
|
|
130
211
|
return { action, synced, skipped, stale: staleCount };
|
|
131
212
|
}
|
|
132
213
|
|
|
133
|
-
|
|
214
|
+
if (action === 'clone') {
|
|
215
|
+
const targets = name ? entries.filter((r) => r.name === name) : entries;
|
|
216
|
+
if (name && !targets.length) { fail(`unknown repo: ${shown(name)}`); process.exitCode = 1; return { action, cloned: [], present: [], failed: [] }; }
|
|
217
|
+
log(c.bold('\nclone missing repos'));
|
|
218
|
+
const repos = cloneMissingRepos(root, { repos: targets });
|
|
219
|
+
reportClones(repos);
|
|
220
|
+
if (!repos.cloned.length && !repos.failed.length) info(name ? `${shown(name)} is already on this machine` : 'nothing missing — every registered repo is on this machine');
|
|
221
|
+
// Unlike `join` (whose other steps still matter), cloning is this command's only job: a repo it could
|
|
222
|
+
// not clone fails it, so a script sees it.
|
|
223
|
+
if (repos.failed.length) { hand(`${repos.failed.length} repo(s) not cloned — fix what is named above and re-run \`yad repo clone\``); process.exitCode = 1; }
|
|
224
|
+
if (repos.cloned.length) hand('build the cloned repos\' code-context pack with `yad repo refresh <name>` when you need it');
|
|
225
|
+
return { action, ...repos };
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
fail(`unknown repo action: ${action} (list | refresh | sync | clone)`);
|
|
134
229
|
process.exitCode = 1;
|
|
135
230
|
return { action };
|
|
136
231
|
}
|
package/cli/report.mjs
CHANGED
|
@@ -52,6 +52,8 @@ const SAFE_VERBS = new Set([
|
|
|
52
52
|
'approve', 'advance', 'comment',
|
|
53
53
|
// E43: the background capture of Shape artifacts.
|
|
54
54
|
'capture',
|
|
55
|
+
// E79: the workspace verbs (`new` is also `epic new`'s action).
|
|
56
|
+
'new', 'init', 'join',
|
|
55
57
|
]);
|
|
56
58
|
|
|
57
59
|
// Reduce an argv to a safe command line: the leading verb chain (at most `command subcommand`, and
|
|
@@ -78,8 +80,8 @@ export function sanitizeArgv(argv = []) {
|
|
|
78
80
|
// Build the safe, postable context — the ONLY data that can reach the issue. Reads doctor for tool
|
|
79
81
|
// state but keeps just the booleans, never the raw checks (which carry names + paths).
|
|
80
82
|
export function sanitizeContext(dir, { error = null, argv = process.argv.slice(2) } = {}) {
|
|
81
|
-
const
|
|
82
|
-
const platform =
|
|
83
|
+
const productConfig = readJSON(productConfigPath(dir), null);
|
|
84
|
+
const platform = productConfig && ['github', 'gitlab'].includes(productConfig.platform) ? productConfig.platform : 'local';
|
|
83
85
|
// Derive tool auth from doctor's checks without keeping any check text.
|
|
84
86
|
const toolState = (cli, p) => (has(cli) ? (platformAuthed(p) ? 'present + authenticated' : 'present, not authenticated') : 'not installed');
|
|
85
87
|
const ctx = {
|
package/cli/review.mjs
CHANGED
|
@@ -10,6 +10,7 @@ import { log, ok, info, warn, fail, note, run, readJSON, emitJSON, refuse, inJSO
|
|
|
10
10
|
import { PROJECT_FILES , productConfigPath } from './manifest.mjs';
|
|
11
11
|
import { updateShip } from './ledger.mjs';
|
|
12
12
|
import { epicRoot } from './epic-state.mjs';
|
|
13
|
+
import { registryEntryFor } from './find-product.mjs';
|
|
13
14
|
import {
|
|
14
15
|
detectPlatform, readPr, mapApprovers, getPrBody, editPrBody, postComment, prNumberFromUrl,
|
|
15
16
|
resolveBaseBranch,
|
|
@@ -22,14 +23,20 @@ const NUDGE_CMD = 'yad review chat';
|
|
|
22
23
|
// Resolve the target code repo: --repo <name> from the registry (platform + path), else cwd.
|
|
23
24
|
// An explicit --repo that is NOT in the registry is an error — never silently fall through to cwd (that
|
|
24
25
|
// would operate on the wrong repo). Returns { error } in that case for the caller to surface.
|
|
25
|
-
|
|
26
|
+
//
|
|
27
|
+
// `product` (E80) is the Product found from a code repo beside it: the registry, the code map and the
|
|
28
|
+
// epics are read there, and a repo run without --repo is looked up in the registry by its path.
|
|
29
|
+
function resolveRepo(root, { repo, dir, product }) {
|
|
30
|
+
const productRoot = product || root;
|
|
26
31
|
if (repo) {
|
|
27
|
-
const reg = readJSON(path.join(
|
|
32
|
+
const reg = readJSON(path.join(productRoot, PROJECT_FILES.reposRegistry), { repos: [] });
|
|
28
33
|
const found = (reg.repos || []).find((r) => r.name === repo);
|
|
29
34
|
if (!found) return { error: `repo '${repo}' is not in .sdlc/repos.json — connect it first (yad-connect-repos)` };
|
|
30
|
-
return { repoRoot: path.resolve(
|
|
35
|
+
return { repoRoot: path.resolve(productRoot, found.path), meta: found, productRoot };
|
|
31
36
|
}
|
|
32
|
-
|
|
37
|
+
const here = path.resolve(root, dir || '.');
|
|
38
|
+
const hit = registryEntryFor(product, here);
|
|
39
|
+
return { repoRoot: hit ? hit.top : here, meta: hit ? hit.meta : null, productRoot };
|
|
33
40
|
}
|
|
34
41
|
|
|
35
42
|
function platformOf(root, repoRoot, meta) {
|
|
@@ -41,8 +48,8 @@ function platformOf(root, repoRoot, meta) {
|
|
|
41
48
|
// Assemble (but don't print) the Build grounding bundle. Shared by `context` and `walkthrough` so the
|
|
42
49
|
// pair walkthrough adds an ordered stop-list on top of the exact same grounding the companion uses.
|
|
43
50
|
// Returns { error } on a bad --repo, else { bundle, repoRoot, base }.
|
|
44
|
-
function contextBundle(root, { repo, dir, pr, runner = run } = {}) {
|
|
45
|
-
const rr = resolveRepo(root, { repo, dir });
|
|
51
|
+
function contextBundle(root, { repo, dir, product, pr, runner = run } = {}) {
|
|
52
|
+
const rr = resolveRepo(root, { repo, dir, product });
|
|
46
53
|
if (rr.error) return { error: rr.error };
|
|
47
54
|
const { repoRoot, meta } = rr;
|
|
48
55
|
const platform = platformOf(root, repoRoot, meta);
|
|
@@ -58,8 +65,8 @@ function contextBundle(root, { repo, dir, pr, runner = run } = {}) {
|
|
|
58
65
|
pr: pr || null,
|
|
59
66
|
base,
|
|
60
67
|
diffCmd: `git -C ${repoRoot} diff ${base}...HEAD`,
|
|
61
|
-
codeMap: meta?.name ? path.join(
|
|
62
|
-
pack: meta?.name ? path.join(
|
|
68
|
+
codeMap: meta?.name ? path.join(rr.productRoot, '.sdlc/code-context', meta.name, 'code-map.md') : null,
|
|
69
|
+
pack: meta?.name ? path.join(rr.productRoot, '.sdlc/code-context', meta.name, 'pack.md') : null,
|
|
63
70
|
contract: meta?.contract || null,
|
|
64
71
|
markers: {
|
|
65
72
|
trailerBegin: '<!-- yad:trailer -->', noblock: '<!-- yad:noblock -->',
|
|
@@ -71,8 +78,8 @@ function contextBundle(root, { repo, dir, pr, runner = run } = {}) {
|
|
|
71
78
|
|
|
72
79
|
// `yad review context --repo <r> --pr <n>` — print the grounding bundle the companion uses to generate
|
|
73
80
|
// the trailer / cards and run the chat over the CODE diff (grounded in the repo code-map + the PR).
|
|
74
|
-
export async function reviewContext(root, { repo, dir, pr, runner = run } = {}) {
|
|
75
|
-
const r = contextBundle(root, { repo, dir, pr, runner });
|
|
81
|
+
export async function reviewContext(root, { repo, dir, product, pr, runner = run } = {}) {
|
|
82
|
+
const r = contextBundle(root, { repo, dir, product, pr, runner });
|
|
76
83
|
if (r.error) { refuse(r.error, null, { json: true }); return; }
|
|
77
84
|
emitJSON(r.bundle);
|
|
78
85
|
return r.bundle;
|
|
@@ -82,8 +89,8 @@ export async function reviewContext(root, { repo, dir, pr, runner = run } = {})
|
|
|
82
89
|
// ordered `stops[]` (the code diff parsed into hunk-anchored, risk-tagged review stops, highest-risk
|
|
83
90
|
// first). The CLI sequences deterministically; the skill (yad-pair-review) walks the stops, generates
|
|
84
91
|
// the per-stop briefing + Socratic question, and runs the two-way session. No LLM here, no ledger write.
|
|
85
|
-
export async function reviewWalkthrough(root, { repo, dir, pr, runner = run } = {}) {
|
|
86
|
-
const r = contextBundle(root, { repo, dir, pr, runner });
|
|
92
|
+
export async function reviewWalkthrough(root, { repo, dir, product, pr, runner = run } = {}) {
|
|
93
|
+
const r = contextBundle(root, { repo, dir, product, pr, runner });
|
|
87
94
|
if (r.error) { refuse(r.error, null, { json: true }); return; }
|
|
88
95
|
const { bundle, repoRoot, base } = r;
|
|
89
96
|
const diff = runner('git', ['-C', repoRoot, 'diff', `${base}...HEAD`]);
|
|
@@ -99,10 +106,10 @@ export async function reviewWalkthrough(root, { repo, dir, pr, runner = run } =
|
|
|
99
106
|
|
|
100
107
|
// `yad review trailer --repo <r> --pr <n> --body <text>` — idempotently upsert the 60-sec briefing into
|
|
101
108
|
// the code PR/MR description (delimited block; safe to re-run after a push).
|
|
102
|
-
export async function reviewTrailer(root, { repo, dir, pr, body, getBody = getPrBody, editBody = editPrBody } = {}) {
|
|
109
|
+
export async function reviewTrailer(root, { repo, dir, product, pr, body, getBody = getPrBody, editBody = editPrBody } = {}) {
|
|
103
110
|
if (!pr) { fail('--pr <n> is required'); process.exitCode = 1; return; }
|
|
104
111
|
if (!body || !String(body).trim()) { fail('trailer body is required: `yad review trailer --repo <r> --pr <n> --body <text>`'); process.exitCode = 1; return; }
|
|
105
|
-
const rr = resolveRepo(root, { repo, dir });
|
|
112
|
+
const rr = resolveRepo(root, { repo, dir, product });
|
|
106
113
|
if (rr.error) { fail(rr.error); process.exitCode = 1; return; }
|
|
107
114
|
const { repoRoot, meta } = rr;
|
|
108
115
|
const platform = platformOf(root, repoRoot, meta);
|
|
@@ -117,9 +124,9 @@ export async function reviewTrailer(root, { repo, dir, pr, body, getBody = getPr
|
|
|
117
124
|
|
|
118
125
|
// `yad review nudge --repo <r> --pr <n>` — friendly public @-mention on a bare approve (engagement none)
|
|
119
126
|
// of a code PR. A platform comment (carries the noblock marker so it never blocks); call once per PR.
|
|
120
|
-
export async function reviewNudge(root, { repo, dir, pr, reader = readPr, poster = postComment } = {}) {
|
|
127
|
+
export async function reviewNudge(root, { repo, dir, product, pr, reader = readPr, poster = postComment } = {}) {
|
|
121
128
|
if (!pr) { fail('--pr <n> is required'); process.exitCode = 1; return; }
|
|
122
|
-
const rr = resolveRepo(root, { repo, dir });
|
|
129
|
+
const rr = resolveRepo(root, { repo, dir, product });
|
|
123
130
|
if (rr.error) { fail(rr.error); process.exitCode = 1; return; }
|
|
124
131
|
const { repoRoot, meta } = rr;
|
|
125
132
|
const platform = platformOf(root, repoRoot, meta);
|
|
@@ -141,10 +148,10 @@ export async function reviewNudge(root, { repo, dir, pr, reader = readPr, poster
|
|
|
141
148
|
// so the build ledger reflects who actually engaged. The first CLI to write build-log.json. Matches the
|
|
142
149
|
// ship record by its `pr` field (url or number); if none exists yet, prints the engineer_review block
|
|
143
150
|
// for the engineer/CI to attach at ship time (we never fabricate a story/task).
|
|
144
|
-
export async function reviewReconcile(root, { epic, repo, dir, pr, reader = readPr } = {}) {
|
|
151
|
+
export async function reviewReconcile(root, { epic, repo, dir, product, pr, reader = readPr } = {}) {
|
|
145
152
|
if (!epic) { fail('--epic <id> is required'); process.exitCode = 1; return; }
|
|
146
153
|
if (!pr) { fail('--pr <n> is required'); process.exitCode = 1; return; }
|
|
147
|
-
const rr = resolveRepo(root, { repo, dir });
|
|
154
|
+
const rr = resolveRepo(root, { repo, dir, product });
|
|
148
155
|
if (rr.error) { fail(rr.error); process.exitCode = 1; return; }
|
|
149
156
|
const { repoRoot, meta } = rr;
|
|
150
157
|
const platform = platformOf(root, repoRoot, meta);
|
|
@@ -167,7 +174,7 @@ export async function reviewReconcile(root, { epic, repo, dir, pr, reader = read
|
|
|
167
174
|
// build-log is shard-then-fold now: updateShip mutates the ship's own loose shard (authoritative
|
|
168
175
|
// until `yad tidy up` folds it), or the folded file if already folded — never both.
|
|
169
176
|
const res = updateShip(
|
|
170
|
-
epicRoot(
|
|
177
|
+
epicRoot(rr.productRoot, epic),
|
|
171
178
|
(s) => s.pr != null && (String(s.pr) === String(pr) || prNumberFromUrl(s.pr) === String(pr)),
|
|
172
179
|
(s) => { s.engineer_review = engineerReview; },
|
|
173
180
|
);
|