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/reconcile.mjs
CHANGED
|
@@ -10,10 +10,10 @@ import {
|
|
|
10
10
|
const readFileSafe = (p) => { try { return fs.readFileSync(p, 'utf8'); } catch { return ''; } };
|
|
11
11
|
|
|
12
12
|
import { preflightGuardReadiness } from './hubcommit.mjs';
|
|
13
|
-
import { VERSION, PROJECT_FILES, MANAGED_LEDGER, BACKUP_SUFFIX } from './manifest.mjs';
|
|
13
|
+
import { VERSION, PROJECT_FILES, MANAGED_LEDGER, BACKUP_SUFFIX , productConfigPath } from './manifest.mjs';
|
|
14
14
|
import {
|
|
15
|
-
moduleActions, repoActions,
|
|
16
|
-
legacyModuleActions, removedModuleActions, legacyRepoActions, legacyHubActions,
|
|
15
|
+
moduleActions, repoActions, productActions, hookActions,
|
|
16
|
+
legacyModuleActions, removedModuleActions, orphanHookActions, legacyRepoActions, legacyHubActions,
|
|
17
17
|
ideTargetStateFor, recordManagedWrites,
|
|
18
18
|
} from './plan.mjs';
|
|
19
19
|
import { gitHead, packRepo } from './setup.mjs';
|
|
@@ -21,6 +21,9 @@ import { groupByRoot, commitUpdates, repoLabel } from './update-commit.mjs';
|
|
|
21
21
|
|
|
22
22
|
const MARK = { missing: c.red('missing'), new: c.cyan('new'), outdated: c.yellow('outdated'), modified: c.cyan('modified'), stale: c.yellow('stale'), legacy: c.yellow('legacy'), removed: c.yellow('removed'), ok: c.green('ok') };
|
|
23
23
|
|
|
24
|
+
// The --json answer's list (E1): every managed item, as the report groups it — never the apply step.
|
|
25
|
+
const itemsOf = (actions) => actions.map((a) => ({ scope: a.scope, item: a.item, status: a.status }));
|
|
26
|
+
|
|
24
27
|
export async function reconcile(root, { fix = false, scope = 'all', force = false, push = false, allowBranch = false, overwriteLocal = false } = {}) {
|
|
25
28
|
log(c.bold(`\nSDLC reconcile ${c.dim('v' + VERSION)}`));
|
|
26
29
|
log(c.dim(`target: ${root}\n`));
|
|
@@ -28,7 +31,7 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
|
|
|
28
31
|
// --- missing one-time setup (needs the interactive wizard) ---
|
|
29
32
|
const gaps = [];
|
|
30
33
|
if (!exists(path.join(root, PROJECT_FILES.version))) gaps.push('module not installed (.sdlc/cli-version.json absent)');
|
|
31
|
-
if (!exists(
|
|
34
|
+
if (!exists(productConfigPath(root))) gaps.push('hub not configured (.sdlc/hub.json absent)');
|
|
32
35
|
const registry = readJSON(path.join(root, PROJECT_FILES.reposRegistry), { repos: [] });
|
|
33
36
|
if (!exists(path.join(root, PROJECT_FILES.reposRegistry))) gaps.push('no repos registered (.sdlc/repos.json absent)');
|
|
34
37
|
|
|
@@ -43,13 +46,13 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
|
|
|
43
46
|
writeJSON(stampPath, { ...record, version: VERSION, ideTargets });
|
|
44
47
|
};
|
|
45
48
|
|
|
46
|
-
// --- deterministic file actions (module +
|
|
49
|
+
// --- deterministic file actions (module + Product CI + every registered repo),
|
|
47
50
|
// plus pre-2.0 sdlc-* -> yad-* migrations ('legacy': old name installed; rename in place)
|
|
48
51
|
// and purge of skills removed in a later release ('removed': delete the lingering install) ---
|
|
49
52
|
const actions = [
|
|
50
53
|
...moduleActions(root, ideTargets), ...legacyModuleActions(root, ideTargets), ...removedModuleActions(root, ideTargets),
|
|
51
|
-
...
|
|
52
|
-
...
|
|
54
|
+
...productActions(root), ...legacyHubActions(root), ...hookActions(root, ideTargets),
|
|
55
|
+
...orphanHookActions(root, ideTargets),
|
|
53
56
|
];
|
|
54
57
|
if (ideState.needsRepair) {
|
|
55
58
|
actions.push({
|
|
@@ -71,7 +74,7 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
|
|
|
71
74
|
if (head && repo.syncedHead && head !== repo.syncedHead) {
|
|
72
75
|
staleRepos.push(repo);
|
|
73
76
|
// packRepo writes the repomix cache under the HUB root (root/repo.contextPack, e.g.
|
|
74
|
-
// .sdlc/code-context/<name>/pack.md), so the touched path belongs to the
|
|
77
|
+
// .sdlc/code-context/<name>/pack.md), so the touched path belongs to the Product — and is commonly
|
|
75
78
|
// gitignored, in which case the push stage's check-ignore drops it. codeMap is AI-generated
|
|
76
79
|
// later, not here, so only the pack is claimed.
|
|
77
80
|
actions.push({
|
|
@@ -132,6 +135,26 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
|
|
|
132
135
|
for (const m of modified) {
|
|
133
136
|
warn(`${m.scope}/${m.item} is locally modified — it matches neither the shipped template nor the copy yad wrote`);
|
|
134
137
|
}
|
|
138
|
+
// A gate-sync fragment is kept like any other edited file — but it also decides which yadflow CI runs.
|
|
139
|
+
// It trusts a committed version only from its own major (`YAD_MAJOR`), and the version stamp is
|
|
140
|
+
// re-written below whatever happens to the fragment. So an edited fragment left on an older major
|
|
141
|
+
// rejects the stamp this update writes, and CI silently floats on that older engine against a project
|
|
142
|
+
// on a newer file shape. The generic "locally modified" line does not say that; this does.
|
|
143
|
+
const pinMajorOf = (file) => {
|
|
144
|
+
try {
|
|
145
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
146
|
+
// `YAD_MAJOR=<n>` from this release on; before it, the major was written into the check itself.
|
|
147
|
+
const found = text.match(/^\s*YAD_MAJOR=(\d+)\s*$/m) || text.match(/grep -Eq ['"]\^(\d+)\\\./);
|
|
148
|
+
return found ? Number(found[1]) : null;
|
|
149
|
+
} catch { return null; }
|
|
150
|
+
};
|
|
151
|
+
for (const m of modified.filter((a) => a.managed && /yad-gate-sync\.ya?ml$/.test(a.item))) {
|
|
152
|
+
const kept = pinMajorOf(m.managed.dest);
|
|
153
|
+
const ships = pinMajorOf(m.managed.src);
|
|
154
|
+
if (kept === null || ships === null || kept === ships) continue;
|
|
155
|
+
warn(`${m.scope}/${m.item} trusts only yadflow ${kept}.x pins, but this release's fragment trusts ${ships}.x — kept as it is, CI will skip the new version stamp and run yadflow@${kept}`);
|
|
156
|
+
hand(`re-apply your edit on top of the new fragment, or replace it with \`yad update --overwrite-local\` (your copy is saved beside it as ${path.basename(m.managed.dest)}${BACKUP_SUFFIX})`);
|
|
157
|
+
}
|
|
135
158
|
if (modified.length && !overwriteLocal) {
|
|
136
159
|
hand(`keep the edits (reported as \`modified\` on every check), or replace them with \`yad update --overwrite-local\` — each previous version is saved beside the file as <file>${BACKUP_SUFFIX}`);
|
|
137
160
|
}
|
|
@@ -147,7 +170,7 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
|
|
|
147
170
|
if (!fix) {
|
|
148
171
|
if (push) warn('--push has no effect without --fix (there is nothing applied to commit).');
|
|
149
172
|
if (fixable.length || gaps.length) hand('run `yad check --fix` to reconcile (or `yad setup` for missing one-time setup).');
|
|
150
|
-
return { counts, gaps, applied: 0, modified: modified.length };
|
|
173
|
+
return { fix: false, counts, gaps, items: itemsOf(actions), applied: 0, modified: modified.length, commits: [] };
|
|
151
174
|
}
|
|
152
175
|
|
|
153
176
|
// --- apply --- (collect the applied actions so --push can stage each repo's exact allowlist) ---
|
|
@@ -187,9 +210,12 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
|
|
|
187
210
|
if (gaps.length) hand('one-time setup still missing — run `yad setup`.');
|
|
188
211
|
|
|
189
212
|
// --- publish: commit each repo's applied changes and push directly to its default branch ---
|
|
213
|
+
// Each repo's `{ label, committed, pushed }`, for the --json answer (E1): a push that failed after a
|
|
214
|
+
// commit landed is said per repo.
|
|
215
|
+
let commits = [];
|
|
190
216
|
if (push) {
|
|
191
217
|
preflightGuardReadiness(root);
|
|
192
|
-
const hub = readJSON(
|
|
218
|
+
const hub = readJSON(productConfigPath(root), {});
|
|
193
219
|
const defByRoot = new Map([[root, hub?.default_branch]]);
|
|
194
220
|
const platformByRoot = new Map([[root, hub?.platform]]);
|
|
195
221
|
for (const repo of registry.repos) {
|
|
@@ -197,7 +223,7 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
|
|
|
197
223
|
defByRoot.set(repoRoot, repo.default_branch);
|
|
198
224
|
platformByRoot.set(repoRoot, repo.platform);
|
|
199
225
|
}
|
|
200
|
-
commitUpdates(root, groupByRoot(appliedActions), {
|
|
226
|
+
commits = commitUpdates(root, groupByRoot(appliedActions), {
|
|
201
227
|
push: true, allowBranch,
|
|
202
228
|
defaultBranchFor: (r) => defByRoot.get(r),
|
|
203
229
|
// GitLab's yad-update-guard is an includable fragment: unlike a GitHub workflow it does nothing
|
|
@@ -211,5 +237,5 @@ export async function reconcile(root, { fix = false, scope = 'all', force = fals
|
|
|
211
237
|
},
|
|
212
238
|
});
|
|
213
239
|
}
|
|
214
|
-
return { counts, gaps, applied, modified: modified.length };
|
|
240
|
+
return { fix: true, counts, gaps, items: itemsOf(actions), applied, modified: modified.length, commits };
|
|
215
241
|
}
|
package/cli/repo-publish.mjs
CHANGED
|
@@ -1,26 +1,26 @@
|
|
|
1
1
|
// `yad repo refresh --push` — after a repack + registry stamp, commit the connected-repo code-context
|
|
2
2
|
// (the tracked `code-map.md` per repo + the registry `.sdlc/repos.json`) and push it straight to the
|
|
3
|
-
//
|
|
3
|
+
// Product's default branch, so a code-map refresh "just lands" for teammates / CI / `yad status` on other
|
|
4
4
|
// machines instead of leaving a dirty tree for someone to hand-commit. This is the code-context
|
|
5
5
|
// analogue of `yad checkpoint` (cli/checkpoint.mjs) and reuses its default-branch commit machinery.
|
|
6
6
|
//
|
|
7
7
|
// Invariants (shared with checkpoint):
|
|
8
8
|
// 1. Commit an EXPLICIT allowlist via `git commit -- <paths>` (--only) — the tracked code-maps, the
|
|
9
|
-
// registry, and (only when its change is the managed pack-ignore block alone) the
|
|
9
|
+
// registry, and (only when its change is the managed pack-ignore block alone) the Product `.gitignore`.
|
|
10
10
|
// NEVER `git add -A` and NEVER a whole-index `git reset`: both would mutate unrelated staged work.
|
|
11
11
|
// The repomix pack.md is gitignored (setup + publish scaffold the ignore via ensurePackIgnored) and
|
|
12
12
|
// never committed as content. A pack committed BEFORE that ignore existed is self-healed here: its
|
|
13
13
|
// on-disk file is held aside across the --only commit so the deletion is recorded (the regenerable
|
|
14
14
|
// cache is restored right after), clearing the stranded working tree — see the commit block below.
|
|
15
|
-
// 2. Commit ONLY on the
|
|
15
|
+
// 2. Commit ONLY on the Product's default branch (unless --allow-branch), so the `[skip ci]` audit
|
|
16
16
|
// commit never enters a PR's base..HEAD range where it would strand required checks.
|
|
17
17
|
import fs from 'node:fs';
|
|
18
18
|
import path from 'node:path';
|
|
19
19
|
import { c, ok, info, fail, hand, exists, pushWithRebase } from './lib.mjs';
|
|
20
|
-
import { PROJECT_FILES } from './manifest.mjs';
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
20
|
+
import { PROJECT_FILES , productConfigPath } from './manifest.mjs';
|
|
21
|
+
import { loadProduct } from './gate.mjs';
|
|
22
|
+
import { platformLogin } from './platform.mjs';
|
|
23
|
+
import { productGit, resolveDefaultBranch, guardDefaultBranch, preflightGuardReadiness } from './hubcommit.mjs';
|
|
24
24
|
import { ensurePackIgnored, PACK_IGNORE_BLOCK } from './setup.mjs';
|
|
25
25
|
import { checkpointAuthor } from './checkpoint.mjs';
|
|
26
26
|
|
|
@@ -51,7 +51,7 @@ export function codeMapPathspecs(root, registry = { repos: [] }, name = null) {
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
// PURE — each registered repo's on-disk pack path (scoped by `name` like codeMapPathspecs). These are
|
|
54
|
-
// UNTRACK candidates, not content to stage: the pack is gitignored, but a
|
|
54
|
+
// UNTRACK candidates, not content to stage: the pack is gitignored, but a Product that committed it before
|
|
55
55
|
// the ignore existed would otherwise strand a dirty pack on every refresh. publishCodeContext keeps only
|
|
56
56
|
// the still-tracked ones and records their removal in the audit commit (see the self-heal block there).
|
|
57
57
|
export function packPathspecs(root, registry = { repos: [] }, name = null) {
|
|
@@ -65,11 +65,11 @@ export function packPathspecs(root, registry = { repos: [] }, name = null) {
|
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
// True iff committing `.gitignore` would carry ONLY the managed pack-ignore block (comments + glob) and
|
|
68
|
-
// nothing else. Guards invariant 1: a
|
|
68
|
+
// nothing else. Guards invariant 1: a Product whose `.gitignore` also has unrelated uncommitted edits must
|
|
69
69
|
// keep them OUT of the `[skip ci]` audit commit. The publish commit is `git commit -- <paths>` (--only,
|
|
70
70
|
// reads the WORKING TREE), so this compares the working tree — an untracked `.gitignore` must be wholly
|
|
71
71
|
// managed; a tracked one must differ from HEAD by the managed block alone (added, nothing removed).
|
|
72
|
-
// `git` is a
|
|
72
|
+
// `git` is a productGit-style accessor; `root` is the Product root. Mirrors checkpoint's stagedStoryIsStatusOnly.
|
|
73
73
|
export function ignoreChangeIsManagedOnly(git, root) {
|
|
74
74
|
const gi = path.join(root, '.gitignore');
|
|
75
75
|
if (!fs.existsSync(gi)) return false;
|
|
@@ -121,7 +121,7 @@ export function summarizeCodeContext(files = []) {
|
|
|
121
121
|
return { label, basenames };
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
// PURE — the audit-trail commit message. Subject passes the
|
|
124
|
+
// PURE — the audit-trail commit message. Subject passes the Product commit-message gate (valid type
|
|
125
125
|
// `chore`, scope `hub`, non-empty description, no trailing period). No Task trailer and no
|
|
126
126
|
// Co-Authored-By: this is human-owned machine state, not an authored code change. `[skip ci]` mirrors
|
|
127
127
|
// `yad checkpoint` — it lands on the default branch and needs no PR gate suite. `label`/`author` are
|
|
@@ -132,19 +132,19 @@ export function buildCodeMapMessage({ label, author, basenames = [] }) {
|
|
|
132
132
|
return body ? `${subject}\n\n${body}` : subject;
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
-
// Commit the tracked code-context (and, with push, push it) on the
|
|
135
|
+
// Commit the tracked code-context (and, with push, push it) on the Product's default branch. Mirrors
|
|
136
136
|
// `runCheckpoint`. Never throws; sets process.exitCode on a hard error so the CLI reports failure.
|
|
137
137
|
export async function publishCodeContext(root, { push = false, allowBranch = false, name = null } = {}) {
|
|
138
138
|
if (!exists(path.join(root, '.git'))) { fail('not a git repo'); process.exitCode = 1; return; }
|
|
139
|
-
if (!exists(
|
|
140
|
-
fail('no .sdlc/hub.json — --push publishes the
|
|
139
|
+
if (!exists(productConfigPath(root))) {
|
|
140
|
+
fail('no .sdlc/hub.json — --push publishes the Product code-context; run it from the Product');
|
|
141
141
|
process.exitCode = 1;
|
|
142
142
|
return;
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
-
const { hub, repos } =
|
|
145
|
+
const { hub, repos } = loadProduct(root);
|
|
146
146
|
const registry = { repos: repos || [] };
|
|
147
|
-
const git =
|
|
147
|
+
const git = productGit(root);
|
|
148
148
|
|
|
149
149
|
const branch = git('rev-parse', '--abbrev-ref', 'HEAD').stdout;
|
|
150
150
|
const defaultBranch = resolveDefaultBranch(git, hub);
|
|
@@ -161,8 +161,8 @@ export async function publishCodeContext(root, { push = false, allowBranch = fal
|
|
|
161
161
|
}
|
|
162
162
|
|
|
163
163
|
// Make the "pack is gitignored" assumption true (idempotent; packRepo also does this on refresh) so a
|
|
164
|
-
//
|
|
165
|
-
// ONLY when the change is the managed pack-ignore block alone (invariant 1) — a
|
|
164
|
+
// Product whose pack was tracked before the ignore existed stops stranding a dirty tree. Publish `.gitignore`
|
|
165
|
+
// ONLY when the change is the managed pack-ignore block alone (invariant 1) — a Product whose `.gitignore`
|
|
166
166
|
// also carries unrelated uncommitted edits keeps them OUT of this audit commit; the pack is still ignored
|
|
167
167
|
// on disk and the human commits their own `.gitignore` edits through their own change. When we do carry
|
|
168
168
|
// it, `git add` makes an untracked `.gitignore` known so the --only commit can include it.
|
|
@@ -204,17 +204,16 @@ export async function publishCodeContext(root, { push = false, allowBranch = fal
|
|
|
204
204
|
const ahead = rev.ok ? (Number(rev.stdout) || 0) : 1; // no upstream ref yet -> attempt the push
|
|
205
205
|
if (!ahead) { info('code-context unchanged and already published — nothing to do'); return; }
|
|
206
206
|
info(`code-context unchanged — pushing ${ahead} already-committed change(s) not yet on origin/${branch}`);
|
|
207
|
-
pushHead();
|
|
208
|
-
return;
|
|
207
|
+
return { message: null, committed: false, pushed: pushHead() };
|
|
209
208
|
}
|
|
210
209
|
|
|
211
210
|
// Only relevant when we are about to push a commit straight to the default branch: warn (never block)
|
|
212
|
-
// if
|
|
211
|
+
// if unsigned commits would make the yad-update-guard reject it. Gated on `push` and deferred to
|
|
213
212
|
// here so it isn't noise on a guard-refused branch or a nothing-to-commit run.
|
|
214
213
|
if (push) preflightGuardReadiness(root);
|
|
215
214
|
|
|
216
215
|
const { label, basenames } = summarizeCodeContext(fileset);
|
|
217
|
-
const author = checkpointAuthor(
|
|
216
|
+
const author = checkpointAuthor(platformLogin(root, hub?.platform), git('config', 'user.name').stdout);
|
|
218
217
|
const message = buildCodeMapMessage({ label, author, basenames });
|
|
219
218
|
|
|
220
219
|
// Untrack the packs by holding their bytes and removing the files across the --only commit, then
|
|
@@ -232,14 +231,13 @@ export async function publishCodeContext(root, { push = false, allowBranch = fal
|
|
|
232
231
|
git('reset', '-q', '--', ...pathspecs, ...ignoreSpec); // unstage only OUR allowlist for a clean retry
|
|
233
232
|
fail(`git commit failed — ${cm.stderr.split('\n')[0] || cm.code}`);
|
|
234
233
|
process.exitCode = 1;
|
|
235
|
-
return { message };
|
|
234
|
+
return { message, committed: false, pushed: false };
|
|
236
235
|
}
|
|
237
236
|
} finally {
|
|
238
237
|
for (const h of held) if (!fs.existsSync(h.abs)) fs.writeFileSync(h.abs, h.buf);
|
|
239
238
|
}
|
|
240
239
|
ok(`published ${fileset.length} file(s): ${c.dim(label)}`);
|
|
241
240
|
|
|
242
|
-
if (!push) return { message };
|
|
243
|
-
pushHead();
|
|
244
|
-
return { message };
|
|
241
|
+
if (!push) return { message, committed: true, pushed: false };
|
|
242
|
+
return { message, committed: true, pushed: pushHead() };
|
|
245
243
|
}
|
package/cli/repo.mjs
CHANGED
|
@@ -38,55 +38,64 @@ function defaultBranch(cwd, repo) {
|
|
|
38
38
|
|
|
39
39
|
export async function runRepo(root, { action = 'list', name, today, push = false, allowBranch = false } = {}) {
|
|
40
40
|
const { regPath, registry } = load(root);
|
|
41
|
-
if (!registry.repos.length) { warn('no repos registered (.sdlc/repos.json) — run `yad setup`'); return { repos:
|
|
41
|
+
if (!registry.repos.length) { warn('no repos registered (.sdlc/repos.json) — run `yad setup`'); return { action, repos: [] }; }
|
|
42
42
|
|
|
43
43
|
if (action === 'list') {
|
|
44
44
|
log(c.bold('\nconnected repos'));
|
|
45
45
|
let staleCount = 0;
|
|
46
|
+
const rows = []; // the --json answer (E1): each repo, as the list reads it
|
|
46
47
|
for (const repo of registry.repos) {
|
|
47
|
-
const { stale, unknown, neverPacked } = staleness(root, repo);
|
|
48
|
+
const { head, stale, unknown, neverPacked } = staleness(root, repo);
|
|
49
|
+
rows.push({ name: repo.name ?? null, path: repo.path ?? null, head: head || null, syncedHead: repo.syncedHead ?? null,
|
|
50
|
+
state: unknown ? 'unreadable' : neverPacked ? 'no-pack' : stale ? 'stale' : 'fresh' });
|
|
48
51
|
if (unknown) { warn(`${repo.name} ${c.dim(`(${repo.path})`)} — HEAD unreadable`); continue; }
|
|
49
52
|
if (neverPacked) { staleCount++; warn(`${repo.name} ${c.dim(`(${repo.path})`)} — ${c.yellow('no code-context pack yet')} (registered without one)`); }
|
|
50
53
|
else if (stale) { staleCount++; warn(`${repo.name} ${c.dim(`(${repo.path})`)} — ${c.yellow('stale')} (HEAD moved since last pack)`); }
|
|
51
54
|
else ok(`${repo.name} ${c.dim('— fresh')}`);
|
|
52
55
|
}
|
|
53
56
|
if (staleCount) hand(`refresh with \`yad repo refresh${registry.repos.length > 1 ? ' <name>' : ''}\` (or \`yad repo refresh\` for all)`);
|
|
54
|
-
return { repos:
|
|
57
|
+
return { action, repos: rows, stale: staleCount };
|
|
55
58
|
}
|
|
56
59
|
|
|
57
60
|
if (action === 'refresh') {
|
|
58
61
|
const targets = name ? registry.repos.filter((r) => r.name === name) : registry.repos;
|
|
59
|
-
if (name && !targets.length) { fail(`unknown repo: ${name}`); process.exitCode = 1; return { refreshed: 0 }; }
|
|
62
|
+
if (name && !targets.length) { fail(`unknown repo: ${name}`); process.exitCode = 1; return { action, refreshed: 0, repos: [], published: null }; }
|
|
60
63
|
let refreshed = 0;
|
|
64
|
+
let published = null;
|
|
65
|
+
const rows = [];
|
|
61
66
|
for (const repo of targets) {
|
|
62
67
|
const { head, unknown } = staleness(root, repo);
|
|
63
|
-
if (unknown) { warn(`${repo.name}: HEAD unreadable — skipped`); continue; }
|
|
68
|
+
if (unknown) { warn(`${repo.name}: HEAD unreadable — skipped`); rows.push({ name: repo.name ?? null, refreshed: false, head: null }); continue; }
|
|
64
69
|
log(` ${c.bold(repo.name)}`);
|
|
65
|
-
|
|
70
|
+
const packed = !!packRepo(root, repo);
|
|
71
|
+
if (packed) {
|
|
66
72
|
repo.syncedHead = head;
|
|
67
73
|
if (today) repo.lastSyncedAt = today; // always stamp when a date is supplied (the CLI passes today)
|
|
68
74
|
refreshed++;
|
|
69
75
|
}
|
|
76
|
+
rows.push({ name: repo.name ?? null, refreshed: packed, head: head || null });
|
|
70
77
|
}
|
|
71
78
|
writeJSON(regPath, registry);
|
|
72
79
|
refreshed ? ok(`refreshed ${refreshed} repo(s)`) : info('nothing refreshed');
|
|
73
80
|
if (push) {
|
|
74
81
|
// Publish whatever tracked code-context now differs (the AI-regenerated code-maps + the stamped
|
|
75
|
-
// registry) straight to the
|
|
82
|
+
// registry) straight to the Product's default branch as one audit-trail commit. The pack itself is
|
|
76
83
|
// gitignored (packRepo scaffolds the ignore; publish self-heals a pre-ignore tracked pack); the
|
|
77
84
|
// code-map is regenerated by the AI (yad-connect-repos) — run that first if a repo's map is stale,
|
|
78
85
|
// then `yad repo refresh --push` lands it.
|
|
79
|
-
await publishCodeContext(root, { push: true, allowBranch, name });
|
|
86
|
+
published = (await publishCodeContext(root, { push: true, allowBranch, name })) ?? null;
|
|
80
87
|
} else {
|
|
81
|
-
hand('regenerate the code-map in
|
|
82
|
-
hand('then publish it to the
|
|
88
|
+
hand('regenerate the code-map in your AI agent (yad-connect-repos) — the pack is cached, the map is the AI step');
|
|
89
|
+
hand('then publish it to the Product default branch with `yad repo refresh --push`');
|
|
83
90
|
}
|
|
84
|
-
|
|
91
|
+
// `published`: what `--push` did (`{ message, committed, pushed }`), or null when it committed nothing
|
|
92
|
+
// — nothing to publish, or a refusal, which the answer's `error` then names.
|
|
93
|
+
return { action, refreshed, repos: rows, published };
|
|
85
94
|
}
|
|
86
95
|
|
|
87
96
|
if (action === 'sync') {
|
|
88
97
|
const targets = name ? registry.repos.filter((r) => r.name === name) : registry.repos;
|
|
89
|
-
if (name && !targets.length) { fail(`unknown repo: ${name}`); process.exitCode = 1; return { synced: 0 }; }
|
|
98
|
+
if (name && !targets.length) { fail(`unknown repo: ${name}`); process.exitCode = 1; return { action, synced: 0, skipped: 0, repos: [] }; }
|
|
90
99
|
log(c.bold('\nsync connected repos'));
|
|
91
100
|
let synced = 0, skipped = 0;
|
|
92
101
|
for (const repo of targets) {
|
|
@@ -118,10 +127,10 @@ export async function runRepo(root, { action = 'list', name, today, push = false
|
|
|
118
127
|
const staleCount = registry.repos.filter((r) => staleness(root, r).stale).length;
|
|
119
128
|
info(`synced ${synced}, skipped ${skipped}`);
|
|
120
129
|
if (staleCount) hand(`${staleCount} repo(s) now have a stale code-context pack — \`yad repo refresh\` to repack`);
|
|
121
|
-
return { synced, skipped, stale: staleCount };
|
|
130
|
+
return { action, synced, skipped, stale: staleCount };
|
|
122
131
|
}
|
|
123
132
|
|
|
124
133
|
fail(`unknown repo action: ${action} (list | refresh | sync)`);
|
|
125
134
|
process.exitCode = 1;
|
|
126
|
-
return {};
|
|
135
|
+
return { action };
|
|
127
136
|
}
|
package/cli/report.mjs
CHANGED
|
@@ -4,15 +4,14 @@
|
|
|
4
4
|
// PRIVACY IS THE POINT: the issue posts to a PUBLIC repo, so this module is allowlist-first. It
|
|
5
5
|
// assembles ONLY a known-safe set of fields (version, node/os, tool booleans, platform enum, error
|
|
6
6
|
// code/hint, a path-scrubbed message, and command + flag NAMES) and actively strips everything else —
|
|
7
|
-
// no absolute paths, hostnames, git URLs, repo names,
|
|
7
|
+
// no absolute paths, hostnames, git URLs, repo names, logins/emails, epic/story IDs, branch
|
|
8
8
|
// names, or flag values ever leave the machine. The user sees the exact payload and confirms before
|
|
9
9
|
// anything is posted. See memory: no-private-data-in-reports.
|
|
10
|
-
import
|
|
11
|
-
import {
|
|
12
|
-
import { VERSION, UPSTREAM_REPO, PROJECT_FILES } from './manifest.mjs';
|
|
10
|
+
import { c, log, info, ok, warn, note, ask, askYesNo, has, readJSON, run, inJSON } from './lib.mjs';
|
|
11
|
+
import { VERSION, UPSTREAM_REPO , productConfigPath } from './manifest.mjs';
|
|
13
12
|
import { createIssue, searchIssues, issueUrl, platformAuthed } from './platform.mjs';
|
|
14
13
|
|
|
15
|
-
// The upstream lives on GitHub — file there regardless of the user's own
|
|
14
|
+
// The upstream lives on GitHub — file there regardless of the user's own Product platform.
|
|
16
15
|
const UPSTREAM_PLATFORM = 'github';
|
|
17
16
|
const PLACEHOLDER = '‹redacted›';
|
|
18
17
|
|
|
@@ -39,13 +38,16 @@ export function scrub(s = '') {
|
|
|
39
38
|
}
|
|
40
39
|
|
|
41
40
|
// The verbs `yad` understands (top-level commands + their sub-actions). Anything NOT in this set —
|
|
42
|
-
//
|
|
41
|
+
// logins, repo names, roles, epic IDs, filenames, and every flag VALUE — is dropped, so the
|
|
43
42
|
// reported command carries only structure, never data.
|
|
44
43
|
const SAFE_VERBS = new Set([
|
|
45
44
|
'setup', 'check', 'update', 'doctor', 'report', 'sync-status', 'next', 'gate', 'review', 'commit',
|
|
45
|
+
// `roster` (and its `add`/`grant`/`revoke`/`remove`) stays: the command was removed in E62 but still
|
|
46
|
+
// answers with a notice, and a report about that notice must keep its structure.
|
|
46
47
|
'open-pr', 'ship', 'repo', 'roster', 'docs', 'thread', 'reconcile',
|
|
47
48
|
'open', 'sync', 'comments', 'status', 'walkthrough', 'trailer', 'ci', 'context', 'chat', 'cards',
|
|
48
49
|
'nudge', 'list', 'add', 'grant', 'revoke', 'remove', 'build', 'deploy', 'refresh', 'wire',
|
|
50
|
+
'risk-map', 'draft', 'codeowners',
|
|
49
51
|
]);
|
|
50
52
|
|
|
51
53
|
// Reduce an argv to a safe command line: the leading verb chain (at most `command subcommand`, and
|
|
@@ -72,8 +74,8 @@ export function sanitizeArgv(argv = []) {
|
|
|
72
74
|
// Build the safe, postable context — the ONLY data that can reach the issue. Reads doctor for tool
|
|
73
75
|
// state but keeps just the booleans, never the raw checks (which carry names + paths).
|
|
74
76
|
export function sanitizeContext(dir, { error = null, argv = process.argv.slice(2) } = {}) {
|
|
75
|
-
const hub = readJSON(
|
|
76
|
-
const platform = hub && ['github', 'gitlab'].includes(hub.platform) ? hub.platform : '
|
|
77
|
+
const hub = readJSON(productConfigPath(dir), null);
|
|
78
|
+
const platform = hub && ['github', 'gitlab'].includes(hub.platform) ? hub.platform : 'local';
|
|
77
79
|
// Derive tool auth from doctor's checks without keeping any check text.
|
|
78
80
|
const toolState = (cli, p) => (has(cli) ? (platformAuthed(p) ? 'present + authenticated' : 'present, not authenticated') : 'not installed');
|
|
79
81
|
const ctx = {
|
|
@@ -82,7 +84,7 @@ export function sanitizeContext(dir, { error = null, argv = process.argv.slice(2
|
|
|
82
84
|
os: process.platform,
|
|
83
85
|
git: has('git') ? 'present' : 'not found',
|
|
84
86
|
gh: toolState('gh', 'github'),
|
|
85
|
-
// The
|
|
87
|
+
// The Product CLI's auth is a useful diagnostic for a GitLab user (issues still file to GitHub upstream).
|
|
86
88
|
...(platform === 'gitlab' ? { glab: toolState('glab', 'gitlab') } : {}),
|
|
87
89
|
platform,
|
|
88
90
|
command: sanitizeArgv(argv) || '(none)',
|
|
@@ -154,7 +156,8 @@ export async function runReport(dir, opts = {}) {
|
|
|
154
156
|
opener = openUrl,
|
|
155
157
|
authed = () => platformAuthed(UPSTREAM_PLATFORM),
|
|
156
158
|
argv = process.argv.slice(2),
|
|
157
|
-
|
|
159
|
+
// A --json run never prompts, and so never posts (E1): it answers with the payload and the prefilled URL.
|
|
160
|
+
interactive = !process.env.SDLC_NONINTERACTIVE && !inJSON(),
|
|
158
161
|
} = opts;
|
|
159
162
|
|
|
160
163
|
try {
|
|
@@ -174,15 +177,17 @@ export async function runReport(dir, opts = {}) {
|
|
|
174
177
|
// Dedup — search open issues by the error code (or the first summary word). Advisory: a failed
|
|
175
178
|
// search just skips this step, it never blocks filing.
|
|
176
179
|
const query = ctx.error?.code || (summary.split(/\s+/)[0] || '').replace(/[^\w-]/g, '');
|
|
180
|
+
let related = [];
|
|
177
181
|
if (query) {
|
|
178
182
|
const { ok: searchedOk, matches } = searcher(UPSTREAM_PLATFORM, UPSTREAM_REPO, `${query} in:title`);
|
|
183
|
+
if (searchedOk) related = matches.map((m) => ({ number: m.number, title: m.title, url: m.url }));
|
|
179
184
|
if (searchedOk && matches.length) {
|
|
180
185
|
log(c.bold(`\nFound ${matches.length} possibly-related open issue(s):`));
|
|
181
186
|
for (const m of matches) info(`#${m.number} — ${m.title} ${c.dim(m.url)}`);
|
|
182
187
|
if (interactive && await prompter('Open an existing issue instead of filing a new one?', true)) {
|
|
183
188
|
opener(matches[0].url);
|
|
184
189
|
log(`\n → ${c.cyan(matches[0].url)}`);
|
|
185
|
-
return { filed: false, url: matches[0].url, deduped: true };
|
|
190
|
+
return { filed: false, url: matches[0].url, deduped: true, title, body, labels, related };
|
|
186
191
|
}
|
|
187
192
|
}
|
|
188
193
|
}
|
|
@@ -201,7 +206,8 @@ export async function runReport(dir, opts = {}) {
|
|
|
201
206
|
const url = issueUrl(UPSTREAM_PLATFORM, UPSTREAM_REPO, { title, body, labels });
|
|
202
207
|
note(interactive ? 'Not posted. You can file it yourself here (prefilled):' : 'Non-interactive — not posted. File it here (prefilled):');
|
|
203
208
|
log(` → ${c.cyan(url)}`);
|
|
204
|
-
|
|
209
|
+
// The payload exactly as it would be posted, for a --json caller to show or file itself.
|
|
210
|
+
return { filed: false, url, title, body, labels, related };
|
|
205
211
|
}
|
|
206
212
|
|
|
207
213
|
// File directly when the CLI is authenticated; otherwise fall back to the prefilled URL.
|
|
@@ -209,7 +215,7 @@ export async function runReport(dir, opts = {}) {
|
|
|
209
215
|
const r = filer(UPSTREAM_PLATFORM, UPSTREAM_REPO, { title, body, labels });
|
|
210
216
|
if (r.ok) {
|
|
211
217
|
ok(`Issue filed: ${c.cyan(r.url)}`);
|
|
212
|
-
return { filed: true, url: r.url };
|
|
218
|
+
return { filed: true, url: r.url, title, body, labels, related };
|
|
213
219
|
}
|
|
214
220
|
warn(`Could not file automatically: ${r.reason || 'unknown error'}`);
|
|
215
221
|
} else {
|
|
@@ -218,10 +224,10 @@ export async function runReport(dir, opts = {}) {
|
|
|
218
224
|
const url = issueUrl(UPSTREAM_PLATFORM, UPSTREAM_REPO, { title, body, labels });
|
|
219
225
|
opener(url);
|
|
220
226
|
log(` → ${c.cyan(url)}`);
|
|
221
|
-
return { filed: false, url };
|
|
227
|
+
return { filed: false, url, title, body, labels, related };
|
|
222
228
|
} catch (e) {
|
|
223
229
|
// Reporting itself must never crash the CLI.
|
|
224
230
|
note(`could not complete the report (${e?.message || e}) — please file manually at https://github.com/${UPSTREAM_REPO}/issues`);
|
|
225
|
-
return { filed: false, url: null, failed: true };
|
|
231
|
+
return { filed: false, url: null, title: null, body: null, labels: [], related: [], failed: true };
|
|
226
232
|
}
|
|
227
233
|
}
|
package/cli/review.mjs
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
|
-
// `yad review trailer|context|nudge|reconcile` — the
|
|
2
|
-
// PR/MRs (the analogue of `yad gate …` for
|
|
3
|
-
// makes the engineer review easy and visible; the bridge
|
|
1
|
+
// `yad review trailer|context|nudge|reconcile` — the Build Review Companion + bridge for code
|
|
2
|
+
// PR/MRs (the analogue of `yad gate …` for Shape). The fun process (trailer/cards/chat/nudge)
|
|
3
|
+
// makes the engineer review easy and visible; the bridge to code PR/MRs (reconcile) maps the code PR's review
|
|
4
4
|
// state — including the engagement signal — into the build ledger (build-log.json) at merge.
|
|
5
5
|
//
|
|
6
6
|
// The CLI never calls an LLM: the skill (yad-review-companion / yad-engineer-review) generates the
|
|
7
7
|
// trailer/cards/chat text and posts it via these primitives, all to the PLATFORM (never a ledger file).
|
|
8
8
|
import path from 'node:path';
|
|
9
|
-
import { log, ok, info, warn, fail, note, run, readJSON } from './lib.mjs';
|
|
10
|
-
import { PROJECT_FILES } from './manifest.mjs';
|
|
9
|
+
import { log, ok, info, warn, fail, note, run, readJSON, emitJSON, refuse, inJSON, collectWarning } from './lib.mjs';
|
|
10
|
+
import { PROJECT_FILES , productConfigPath } from './manifest.mjs';
|
|
11
11
|
import { updateShip } from './ledger.mjs';
|
|
12
12
|
import { epicRoot } from './epic-state.mjs';
|
|
13
13
|
import {
|
|
@@ -19,7 +19,7 @@ import { sequenceDiff } from './walkthrough.mjs';
|
|
|
19
19
|
|
|
20
20
|
const NUDGE_CMD = 'yad review chat';
|
|
21
21
|
|
|
22
|
-
// Resolve the target code repo: --repo <name> from the registry (platform + path
|
|
22
|
+
// Resolve the target code repo: --repo <name> from the registry (platform + path), else cwd.
|
|
23
23
|
// An explicit --repo that is NOT in the registry is an error — never silently fall through to cwd (that
|
|
24
24
|
// would operate on the wrong repo). Returns { error } in that case for the caller to surface.
|
|
25
25
|
function resolveRepo(root, { repo, dir }) {
|
|
@@ -35,10 +35,10 @@ function resolveRepo(root, { repo, dir }) {
|
|
|
35
35
|
function platformOf(root, repoRoot, meta) {
|
|
36
36
|
if (meta?.platform) return meta.platform;
|
|
37
37
|
const remote = run('git', ['remote', 'get-url', 'origin'], { cwd: repoRoot }).stdout;
|
|
38
|
-
return detectPlatform(remote) || readJSON(
|
|
38
|
+
return detectPlatform(remote) || readJSON(productConfigPath(root), {}).platform || null;
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
//
|
|
41
|
+
// Assemble (but don't print) the Build grounding bundle. Shared by `context` and `walkthrough` so the
|
|
42
42
|
// pair walkthrough adds an ordered stop-list on top of the exact same grounding the companion uses.
|
|
43
43
|
// Returns { error } on a bad --repo, else { bundle, repoRoot, base }.
|
|
44
44
|
function contextBundle(root, { repo, dir, pr, runner = run } = {}) {
|
|
@@ -73,8 +73,8 @@ function contextBundle(root, { repo, dir, pr, runner = run } = {}) {
|
|
|
73
73
|
// the trailer / cards and run the chat over the CODE diff (grounded in the repo code-map + the PR).
|
|
74
74
|
export async function reviewContext(root, { repo, dir, pr, runner = run } = {}) {
|
|
75
75
|
const r = contextBundle(root, { repo, dir, pr, runner });
|
|
76
|
-
if (r.error) {
|
|
77
|
-
|
|
76
|
+
if (r.error) { refuse(r.error, null, { json: true }); return; }
|
|
77
|
+
emitJSON(r.bundle);
|
|
78
78
|
return r.bundle;
|
|
79
79
|
}
|
|
80
80
|
|
|
@@ -84,15 +84,15 @@ export async function reviewContext(root, { repo, dir, pr, runner = run } = {})
|
|
|
84
84
|
// the per-stop briefing + Socratic question, and runs the two-way session. No LLM here, no ledger write.
|
|
85
85
|
export async function reviewWalkthrough(root, { repo, dir, pr, runner = run } = {}) {
|
|
86
86
|
const r = contextBundle(root, { repo, dir, pr, runner });
|
|
87
|
-
if (r.error) {
|
|
87
|
+
if (r.error) { refuse(r.error, null, { json: true }); return; }
|
|
88
88
|
const { bundle, repoRoot, base } = r;
|
|
89
89
|
const diff = runner('git', ['-C', repoRoot, 'diff', `${base}...HEAD`]);
|
|
90
90
|
// Diagnostics go to STDERR so STDOUT stays pure JSON (the skill / e2e parse it). The empty `stops: []`
|
|
91
91
|
// in the bundle already signals "nothing to walk".
|
|
92
|
-
if (!diff.ok)
|
|
92
|
+
if (!diff.ok) { const w = `could not read the diff (${base}...HEAD) in ${repoRoot} — is the branch pushed and the base correct?`; collectWarning(w); note(w); }
|
|
93
93
|
const stops = sequenceDiff(diff.ok ? diff.stdout : '', { contractPath: bundle.contract });
|
|
94
94
|
const out = { ...bundle, stops };
|
|
95
|
-
|
|
95
|
+
emitJSON(out);
|
|
96
96
|
if (!stops.length) note('no stops — the diff is empty (nothing to walk through)');
|
|
97
97
|
return out;
|
|
98
98
|
}
|
|
@@ -112,7 +112,7 @@ export async function reviewTrailer(root, { repo, dir, pr, body, getBody = getPr
|
|
|
112
112
|
const r = editBody(platform, pr, upsertTrailerBlock(cur.body, String(body).trim()), { cwd: repoRoot });
|
|
113
113
|
if (!r.ok) { fail(`could not update PR #${pr}: ${r.reason || 'unknown'}`); process.exitCode = 1; return; }
|
|
114
114
|
ok(`trailer posted to ${platform === 'gitlab' ? 'MR' : 'PR'} #${pr}`);
|
|
115
|
-
return { number: pr };
|
|
115
|
+
return { number: pr, posted: true };
|
|
116
116
|
}
|
|
117
117
|
|
|
118
118
|
// `yad review nudge --repo <r> --pr <n>` — friendly public @-mention on a bare approve (engagement none)
|
|
@@ -125,7 +125,7 @@ export async function reviewNudge(root, { repo, dir, pr, reader = readPr, poster
|
|
|
125
125
|
const platform = platformOf(root, repoRoot, meta);
|
|
126
126
|
if (!platform) { fail('could not detect platform (github/gitlab)'); process.exitCode = 1; return; }
|
|
127
127
|
const pull = reader(platform, pr, { cwd: repoRoot });
|
|
128
|
-
if (!pull.ok) {
|
|
128
|
+
if (!pull.ok) { fail(`could not read PR #${pr}: ${pull.reason}`); process.exitCode = 1; return; }
|
|
129
129
|
let nudged = 0;
|
|
130
130
|
for (const rv of pull.reviews) {
|
|
131
131
|
if (rv.state !== 'APPROVED' || parseEngagement(rv.body) === 'verified' || !rv.login) continue;
|
|
@@ -136,7 +136,7 @@ export async function reviewNudge(root, { repo, dir, pr, reader = readPr, poster
|
|
|
136
136
|
return { nudged };
|
|
137
137
|
}
|
|
138
138
|
|
|
139
|
-
// `yad review reconcile --epic <id> --repo <r> --pr <n>` — the
|
|
139
|
+
// `yad review reconcile --epic <id> --repo <r> --pr <n>` — the Build BRIDGE: read the code PR's
|
|
140
140
|
// approvals (with the engagement signal) and stamp them onto the matching build-log.json ship record,
|
|
141
141
|
// so the build ledger reflects who actually engaged. The first CLI to write build-log.json. Matches the
|
|
142
142
|
// ship record by its `pr` field (url or number); if none exists yet, prints the engineer_review block
|
|
@@ -149,22 +149,18 @@ export async function reviewReconcile(root, { epic, repo, dir, pr, reader = read
|
|
|
149
149
|
const { repoRoot, meta } = rr;
|
|
150
150
|
const platform = platformOf(root, repoRoot, meta);
|
|
151
151
|
if (!platform) { fail('could not detect platform (github/gitlab)'); process.exitCode = 1; return; }
|
|
152
|
-
const hub = readJSON(path.join(root, PROJECT_FILES.hubConfig), { roster: [] });
|
|
153
|
-
const registry = readJSON(path.join(root, PROJECT_FILES.reposRegistry), { repos: [] });
|
|
154
|
-
const domain = meta?.name ? [meta.name] : [];
|
|
155
152
|
const pull = reader(platform, pr, { cwd: repoRoot });
|
|
156
153
|
if (!pull.ok) { fail(`could not read PR #${pr}: ${pull.reason}`); process.exitCode = 1; return; }
|
|
157
154
|
|
|
158
|
-
// Map platform approvals → engineer_review entries,
|
|
159
|
-
// engagement signal read from the approve body/note.
|
|
160
|
-
const recs = mapApprovers(pull.reviews, {
|
|
155
|
+
// Map platform approvals → engineer_review entries, one per approving login (E62: no roster, so no
|
|
156
|
+
// role or domain to attach), carrying the engagement signal read from the approve body/note.
|
|
157
|
+
const recs = mapApprovers(pull.reviews, { headOid: pull.headOid });
|
|
161
158
|
const seen = new Set();
|
|
162
159
|
const engineerReview = [];
|
|
163
160
|
for (const r of recs) {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
engineerReview.push({ approver: r.name, role: r.role, ...(r.domain ? { domain: r.domain } : {}), engagement: r.engagement === 'verified' ? 'verified' : 'none' });
|
|
161
|
+
if (seen.has(r.name)) continue;
|
|
162
|
+
seen.add(r.name);
|
|
163
|
+
engineerReview.push({ approver: r.name, engagement: r.engagement === 'verified' ? 'verified' : 'none' });
|
|
168
164
|
}
|
|
169
165
|
|
|
170
166
|
// Match by exact PR number — never substring (`--pr 5` must not match a ship recorded against #15).
|
|
@@ -177,7 +173,8 @@ export async function reviewReconcile(root, { epic, repo, dir, pr, reader = read
|
|
|
177
173
|
);
|
|
178
174
|
if (!res.found) {
|
|
179
175
|
warn(`no build-log ship record matches PR #${pr} in ${epic} — attach this at ship time:`);
|
|
180
|
-
|
|
176
|
+
// The block to attach, for a person to copy. Under --json the answer carries it as `engineerReview`.
|
|
177
|
+
if (!inJSON()) log(` ${JSON.stringify({ engineer_review: engineerReview })}`);
|
|
181
178
|
return { engineerReview, written: false };
|
|
182
179
|
}
|
|
183
180
|
ok(`stamped engagement onto ${epic} ship ${res.ship.story || ''}${res.ship.task ? '/' + res.ship.task : ''} (PR #${pr})`);
|