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/setup.mjs
CHANGED
|
@@ -3,14 +3,14 @@ import path from 'node:path';
|
|
|
3
3
|
import fs from 'node:fs';
|
|
4
4
|
import os from 'node:os';
|
|
5
5
|
import {
|
|
6
|
-
c, log, step, guide, ok, info, warn, hand, fail, ask, askYesNo, run, has,
|
|
6
|
+
c, log, step, guide, ok, info, warn, hand, fail, ask, askYesNo, run, runLauncher, has,
|
|
7
7
|
exists, readJSON, readJSONStrict, writeJSON,
|
|
8
|
-
writeProductConfig,
|
|
8
|
+
writeProductConfig, forTerminal,
|
|
9
9
|
} from './lib.mjs';
|
|
10
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';
|
|
11
11
|
import {
|
|
12
|
-
moduleActions, repoActions, productActions, hookActions, captureHookActions,
|
|
13
|
-
legacyModuleActions, removedModuleActions, legacyRepoActions,
|
|
12
|
+
moduleActions, repoActions, productActions, hookActions, captureHookActions, legacyHookScriptActions, gitHookActions, gitHookState, gitHookAdvice,
|
|
13
|
+
legacyModuleActions, removedModuleActions, legacyRepoActions, legacyProductActions, withoutKeptRenames,
|
|
14
14
|
safeIdeTargetsFor, detectedIdeTargetStateFor, recordManagedWrites,
|
|
15
15
|
} from './plan.mjs';
|
|
16
16
|
import { modeFields, modeOf } from './mode.mjs';
|
|
@@ -55,6 +55,15 @@ export function detectPlatform(remoteUrl = '') {
|
|
|
55
55
|
}
|
|
56
56
|
export const gitHead = (cwd) => run('git', ['rev-parse', 'HEAD'], { cwd }).stdout || null;
|
|
57
57
|
|
|
58
|
+
// A repo's (one `yad init` found beside the Product) default branch: the remote's published default, else the branch it is on (a repo with
|
|
59
|
+
// no remote has nothing else to say), else main.
|
|
60
|
+
export function detectDefaultBranch(repoDir) {
|
|
61
|
+
const originHead = run('git', ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], { cwd: repoDir });
|
|
62
|
+
if (originHead.ok && originHead.stdout) return originHead.stdout.replace(/^origin\//, '');
|
|
63
|
+
const current = run('git', ['symbolic-ref', '--short', 'HEAD'], { cwd: repoDir });
|
|
64
|
+
return (current.ok && current.stdout) || 'main';
|
|
65
|
+
}
|
|
66
|
+
|
|
58
67
|
// Containment: a repo path must live inside the WORKSPACE — the Product root's parent. The standard
|
|
59
68
|
// multi-repo layout puts the code repos BESIDE the Product, not under it (project/{product,backend,frontend}),
|
|
60
69
|
// so `../backend` has to register; containing to the Product root instead forced separate git repos to nest
|
|
@@ -76,15 +85,33 @@ export function insideWorkspace(root, rpath) {
|
|
|
76
85
|
const parent = path.dirname(projectRoot);
|
|
77
86
|
const workspace = parent === projectRoot ? projectRoot : parent; // degenerate: root is the fs root
|
|
78
87
|
const resolved = path.resolve(projectRoot, rpath);
|
|
88
|
+
if (throughGitDir(root, rpath)) return false;
|
|
79
89
|
return resolved === projectRoot || resolved.startsWith(workspace + path.sep);
|
|
80
90
|
}
|
|
81
91
|
|
|
92
|
+
// A repo path that runs through a `.git` folder is never a code repo (E79 review 2). `yad join` clones
|
|
93
|
+
// every registered path with no person looking, and a clone that lands at `../.git` makes the workspace
|
|
94
|
+
// a repo whose `config` the Product's writer chose (`core.fsmonitor` runs a command on `git status`);
|
|
95
|
+
// one at `.git/hooks` puts their hooks in the Product. Git refuses a `.git` part in a tracked path for
|
|
96
|
+
// the same reason. Matched without case (macOS and Windows file systems ignore it), and Windows' short
|
|
97
|
+
// name for `.git` (`GIT~1`) too.
|
|
98
|
+
export function throughGitDir(root, rpath) {
|
|
99
|
+
const projectRoot = path.resolve(root);
|
|
100
|
+
const parent = path.dirname(projectRoot);
|
|
101
|
+
const workspace = parent === projectRoot ? projectRoot : parent;
|
|
102
|
+
const parts = path.relative(workspace, path.resolve(projectRoot, rpath)).split(/[\\/]+/);
|
|
103
|
+
// Windows also drops trailing dots and spaces from a name and reads `name:stream` as `name`, so
|
|
104
|
+
// `.git.`, `.git ` and `.git::$INDEX_ALLOCATION` are all `.git` there — judged as git itself does.
|
|
105
|
+
const ntfs = (p) => p.split(':')[0].replace(/[. ]+$/, '');
|
|
106
|
+
return parts.some((p) => /^\.git$/i.test(ntfs(p)) || /^git~\d+$/i.test(ntfs(p)));
|
|
107
|
+
}
|
|
108
|
+
|
|
82
109
|
// Build the hub.json object for a (re)configure write: the fields this run collected, laid over the
|
|
83
110
|
// existing file so nothing the wizard does not ask about is dropped. Mirrors the safe { ...cur } merge
|
|
84
111
|
// on the keep path. That includes a `roster` or `verified_authors` an older release wrote (E62): the
|
|
85
112
|
// wizard no longer collects or reads either, and it never deletes them — `yad doctor` names them as
|
|
86
113
|
// unused instead.
|
|
87
|
-
export function
|
|
114
|
+
export function buildReconfiguredProductConfig(cur, fields) {
|
|
88
115
|
return { ...(cur || {}), ...fields };
|
|
89
116
|
}
|
|
90
117
|
|
|
@@ -92,6 +119,7 @@ export function buildReconfiguredHub(cur, fields) {
|
|
|
92
119
|
// A path that is not a git repository is rejected and NOTHING is written — a registry entry with
|
|
93
120
|
// syncedHead:null would only surface later as an unexplained "unknown status" in the CI gates.
|
|
94
121
|
export function registerRepo(root, registry, { name, rpath, platform, default_branch = 'main', today = null, pack = true }) {
|
|
122
|
+
if (throughGitDir(root, rpath)) { warn(`${rpath} runs through a .git folder — not a code repo, skipped`); return null; }
|
|
95
123
|
if (!insideWorkspace(root, rpath)) {
|
|
96
124
|
warn(`${rpath} resolves outside the workspace (the project root's parent) — skipped`);
|
|
97
125
|
return null;
|
|
@@ -245,21 +273,21 @@ function applyActions(actions, { force = false } = {}) {
|
|
|
245
273
|
// otherwise we prompt with a default. Pure of side effects — it only reads. Returns
|
|
246
274
|
// { solo, team_size, codebase, repo_layout, configureTools }.
|
|
247
275
|
export async function resolveProfile(root, opts = {}) {
|
|
248
|
-
const
|
|
249
|
-
const prev = (
|
|
276
|
+
const productConfig = readJSON(productConfigPath(root), null);
|
|
277
|
+
const prev = (productConfig && productConfig.profile) || {};
|
|
250
278
|
|
|
251
|
-
// 1. Solo or team (+ size). --solo / --team <n> win; else carry
|
|
279
|
+
// 1. Solo or team (+ size). --solo / --team <n> win; else carry the Product's `solo` forward; else ask.
|
|
252
280
|
let solo, team_size;
|
|
253
281
|
if (opts.solo) { solo = true; team_size = 1; }
|
|
254
282
|
else if (opts.team != null) { team_size = Math.max(1, parseInt(opts.team, 10) || 1); solo = team_size <= 1; }
|
|
255
283
|
// Either spelling carries forward: the older `review_gate.solo: true` also waives the gates, and asking again
|
|
256
284
|
// would default a configured team Product to solo and switch approvals off with nobody choosing it (E10).
|
|
257
|
-
else if (typeof
|
|
285
|
+
else if (typeof productConfig?.solo === 'boolean' || productConfig?.review_gate?.solo === true) { solo = modeOf(productConfig) === 'solo'; team_size = prev.team_size ?? (solo ? 1 : 2); }
|
|
258
286
|
else {
|
|
259
287
|
// No mode recorded. The default used to come from the roster's size; it now comes from the team size
|
|
260
288
|
// setup recorded, and a Product that exists with neither defaults to TEAM (E62). Solo waives every
|
|
261
289
|
// 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 ?? (
|
|
290
|
+
const known = prev.team_size ?? (productConfig ? 2 : 1);
|
|
263
291
|
solo = !(await ask('Solo or team?', known > 1 ? 'team' : 'solo')).toLowerCase().startsWith('t');
|
|
264
292
|
team_size = solo ? 1 : Math.max(2, parseInt(await ask(' how many team members?', String(Math.max(2, known))), 10) || 2);
|
|
265
293
|
}
|
|
@@ -312,7 +340,7 @@ export async function runSetup(root, opts = {}) {
|
|
|
312
340
|
if (await askYesNo('Not a git repo. Run `git init` here?', true)) {
|
|
313
341
|
run('git', ['init'], { cwd: root });
|
|
314
342
|
ok('git initialized');
|
|
315
|
-
} else warn('continuing without git —
|
|
343
|
+
} else warn('continuing without git — the Product platform will not be detected');
|
|
316
344
|
} else ok('git repo detected');
|
|
317
345
|
for (const tool of ['git', 'node']) has(tool) ? ok(`${tool} present`) : warn(`${tool} not found on PATH`);
|
|
318
346
|
if (!has('npx')) warn('npx not found — repomix packing will be skipped');
|
|
@@ -365,18 +393,18 @@ export async function runSetup(root, opts = {}) {
|
|
|
365
393
|
S('Product platform');
|
|
366
394
|
guide(solo
|
|
367
395
|
? [
|
|
368
|
-
'Your
|
|
396
|
+
'Your Product is this repo on GitHub/GitLab (or none for a local gate).',
|
|
369
397
|
'Solo: you review by merging your own PR (approval waived).',
|
|
370
398
|
]
|
|
371
399
|
: [
|
|
372
|
-
'Your
|
|
400
|
+
'Your Product is this repo on GitHub/GitLab; reviewers approve artifacts there.',
|
|
373
401
|
'yad keeps no list of people: anyone with access to the repo can approve, and the platform records who did.',
|
|
374
402
|
'A gate needs one approval, which should not come from the author (GitHub blocks self-approval; GitLab only if its settings do).',
|
|
375
403
|
'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.',
|
|
376
404
|
]);
|
|
377
405
|
const productPath = productConfigPath(root);
|
|
378
|
-
if (exists(productPath) && !(await askYesNo(
|
|
379
|
-
info(
|
|
406
|
+
if (exists(productPath) && !(await askYesNo(`${path.relative(root, productPath)} exists — reconfigure?`, false))) {
|
|
407
|
+
info(`keeping the existing ${path.relative(root, productPath)}`);
|
|
380
408
|
} else {
|
|
381
409
|
const remote = run('git', ['remote', 'get-url', 'origin'], { cwd: root });
|
|
382
410
|
if (!remote.ok && exists(path.join(root, '.git'))) info('no origin remote — platform detection skipped');
|
|
@@ -407,14 +435,14 @@ export async function runSetup(root, opts = {}) {
|
|
|
407
435
|
// the reader falls back to when `ledger` is absent, so nothing is lost by waiting: `yad migrate`
|
|
408
436
|
// adds the key, and the setting it computes is the one these booleans just recorded.
|
|
409
437
|
const onNewShape = (cur.schemaVersion ?? 1) >= 2 || !Object.keys(cur).length;
|
|
410
|
-
const next =
|
|
438
|
+
const next = buildReconfiguredProductConfig(cur, {
|
|
411
439
|
platform: enabled ? platform : null, git_url,
|
|
412
440
|
...(onNewShape ? { ledger: enabled ? 'verified' : 'local' } : {}),
|
|
413
441
|
bridge_enabled: enabled, bridge: enabled,
|
|
414
442
|
default_branch, ...setupModeFields(root, cur, solo, opts), profile: { codebase, repo_layout, team_size },
|
|
415
443
|
});
|
|
416
444
|
writeProductConfig(root, next);
|
|
417
|
-
ok(`wrote ${PROJECT_FILES.productConfig} + ${PROJECT_FILES.
|
|
445
|
+
ok(`wrote ${PROJECT_FILES.productConfig} + ${PROJECT_FILES.productConfigLegacy}${solo ? ' (solo mode)' : ''}`);
|
|
418
446
|
}
|
|
419
447
|
// Persist the profile + solo flag even on the "keeping existing" path, so re-running setup with new
|
|
420
448
|
// flags (e.g. `yad setup --solo`) updates the mode without a full reconfigure. Merge, never clobber.
|
|
@@ -431,7 +459,7 @@ export async function runSetup(root, opts = {}) {
|
|
|
431
459
|
if (modeMoved || JSON.stringify(cur.profile || {}) !== JSON.stringify({ codebase, repo_layout, team_size }) || backfillUrl) {
|
|
432
460
|
writeProductConfig(root, { ...cur, ...(backfillUrl ? { git_url: backfillUrl } : {}), ...mode, profile: { codebase, repo_layout, team_size } });
|
|
433
461
|
if (mode.mode_set) info(`mode: ${mode.mode_set.from} → ${mode.mode_set.to} (recorded as mode_set)`);
|
|
434
|
-
if (backfillUrl) info(`backfilled
|
|
462
|
+
if (backfillUrl) info(`backfilled the Product's git_url from origin: ${backfillUrl}`);
|
|
435
463
|
else info(`recorded profile: ${solo ? 'solo' : `team(${team_size})`}, ${codebase}, ${repo_layout}`);
|
|
436
464
|
}
|
|
437
465
|
}
|
|
@@ -533,6 +561,24 @@ export async function runSetup(root, opts = {}) {
|
|
|
533
561
|
const known = new Set(registry.repos.map((r) => r.name));
|
|
534
562
|
const greenfield = codebase === 'greenfield';
|
|
535
563
|
const mono = repo_layout === 'monorepo';
|
|
564
|
+
// `yad init` (E79) found these beside the Product: one yes/no each, with what git already knows about
|
|
565
|
+
// them — the platform from `origin`, the default branch from origin/HEAD or the branch it is on.
|
|
566
|
+
for (const d of (opts.discovered || []).filter((x) => !known.has(x.name))) {
|
|
567
|
+
const repoRoot = path.resolve(root, d.rpath);
|
|
568
|
+
const remote = run('git', ['remote', 'get-url', 'origin'], { cwd: repoRoot });
|
|
569
|
+
const platform = detectPlatform(remote.ok ? remote.stdout : '') || 'github';
|
|
570
|
+
const default_branch = detectDefaultBranch(repoRoot);
|
|
571
|
+
// "Connect" means what it means in the loop below: register it, and the wiring step then writes the
|
|
572
|
+
// CI gates and the PR template into it. Said here, because init asks for every repo at once.
|
|
573
|
+
if (!(await askYesNo(`Connect and wire ${d.name} (${d.rpath}, ${platform}, default branch ${default_branch}) — writes its CI gates + PR template?`, true))) continue;
|
|
574
|
+
const repo = registerRepo(root, registry, { name: d.name, rpath: d.rpath, platform, default_branch, today: opts.today ?? null, pack: !greenfield });
|
|
575
|
+
if (!repo) continue;
|
|
576
|
+
known.add(d.name);
|
|
577
|
+
ok(`registered ${d.name}`);
|
|
578
|
+
if (greenfield) info(`${d.name}: greenfield — skipped repomix pack (run \`yad repo refresh ${d.name}\` once it has code)`);
|
|
579
|
+
else packRepo(root, repo);
|
|
580
|
+
if (mono) { info('monorepo — one repo connected'); break; }
|
|
581
|
+
}
|
|
536
582
|
if (await askYesNo(`Connect a code repo? ${c.dim(`(${registry.repos.length} already registered)`)}`, registry.repos.length === 0)) {
|
|
537
583
|
for (;;) {
|
|
538
584
|
const name = await ask(' repo name (blank to finish)', '');
|
|
@@ -540,6 +586,7 @@ export async function runSetup(root, opts = {}) {
|
|
|
540
586
|
if (known.has(name)) { warn(`${name} already registered — skipping`); continue; }
|
|
541
587
|
// Siblings of the Product are the common layout (project/{product,backend}) — `../backend` is valid.
|
|
542
588
|
const rpath = await ask(' path (relative to project root, e.g. ../backend)', `demo-repos/${name}`);
|
|
589
|
+
if (throughGitDir(root, rpath)) { warn(`${rpath} runs through a .git folder — not a code repo, skipped`); continue; }
|
|
543
590
|
if (!insideWorkspace(root, rpath)) { warn(`${rpath} resolves outside the workspace (the project root's parent) — skipped`); continue; }
|
|
544
591
|
const detected = run('git', ['remote', 'get-url', 'origin'], { cwd: path.resolve(root, rpath) });
|
|
545
592
|
const platform = (await ask(' platform (github/gitlab)', detectPlatform(detected.ok ? detected.stdout : '') || 'github')).toLowerCase();
|
|
@@ -563,38 +610,52 @@ export async function runSetup(root, opts = {}) {
|
|
|
563
610
|
const wired = [];
|
|
564
611
|
for (const repo of registry.repos) {
|
|
565
612
|
log(` ${c.bold(repo.name)} ${c.dim(`(${repo.platform})`)}`);
|
|
566
|
-
|
|
613
|
+
// Migrate renamed wired CI (marker-owned sdlc-*.yml -> yad-*.yml); a user-authored same-named file is
|
|
614
|
+
// never touched, and an edited one is kept without its new name beside it (E123, `withoutKeptRenames`).
|
|
615
|
+
const repoLegacy = legacyRepoActions(root, repo);
|
|
616
|
+
const repoWiring = withoutKeptRenames([...repoActions(root, repo), ...repoLegacy]).filter((a) => !repoLegacy.includes(a));
|
|
567
617
|
applyActions(repoWiring, { force: true });
|
|
568
618
|
wired.push(...repoWiring);
|
|
569
|
-
|
|
570
|
-
// same-named file is never touched.
|
|
571
|
-
applyActions(legacyRepoActions(root, repo), { force: true });
|
|
619
|
+
applyActions(repoLegacy, { force: true });
|
|
572
620
|
}
|
|
573
621
|
// the Product: event-driven gate-sync CI, so platform approvals/merges drive `yad gate ci`
|
|
574
|
-
const
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
622
|
+
const productLegacy = legacyProductActions(root);
|
|
623
|
+
const productWiring = withoutKeptRenames([...productActions(root), ...productLegacy]).filter((a) => !productLegacy.includes(a));
|
|
624
|
+
if (productWiring.length) {
|
|
625
|
+
log(` ${c.bold('product')} ${c.dim('(gate-sync + verified-commits CI)')}`);
|
|
626
|
+
applyActions(productWiring, { force: true });
|
|
627
|
+
wired.push(...productWiring);
|
|
579
628
|
}
|
|
580
|
-
applyActions(
|
|
629
|
+
applyActions(productLegacy, { force: true });
|
|
581
630
|
// the Product, locally: the harness ledger guard, so an agent is refused the CI-owned ledger write at
|
|
582
631
|
// the moment it tries it rather than by a failed pipeline later (#171). Verified-only like the CI
|
|
583
632
|
// above — with no bridge the ledger is locally owned and the guard would be wrong.
|
|
584
633
|
const hookWiring = hookActions(root, ideTargets);
|
|
585
634
|
if (hookWiring.length) {
|
|
586
|
-
log(` ${c.bold('
|
|
635
|
+
log(` ${c.bold('product')} ${c.dim('(agent ledger guard)')}`);
|
|
587
636
|
applyActions(hookWiring, { force: true });
|
|
588
637
|
wired.push(...hookWiring);
|
|
589
638
|
}
|
|
639
|
+
// E48: the same guard for a person's `git commit`, installed in this clone's git hooks. Not committed,
|
|
640
|
+
// so it is not pushed to `wired` (nothing to stage).
|
|
641
|
+
const gitHook = gitHookActions(root);
|
|
642
|
+
if (gitHook.length) {
|
|
643
|
+
log(` ${c.bold('product')} ${c.dim('(git pre-commit ledger guard, this clone)')}`);
|
|
644
|
+
applyActions(gitHook, { force: true });
|
|
645
|
+
}
|
|
646
|
+
const gitHookNote = gitHookAdvice(gitHookState(root));
|
|
647
|
+
if (gitHookNote) warn(gitHookNote);
|
|
590
648
|
// The post-edit capture hook (E43): every change to an artifact is snapshotted onto the person's private
|
|
591
649
|
// `yad/wip/<name>/<epic>` branches. Both ledger modes; `"capture": false` in the Product config turns it off.
|
|
592
650
|
const captureWiring = captureHookActions(root, ideTargets);
|
|
593
651
|
if (captureWiring.length) {
|
|
594
|
-
log(` ${c.bold('
|
|
652
|
+
log(` ${c.bold('product')} ${c.dim('(wip capture)')}`);
|
|
595
653
|
applyActions(captureWiring, { force: true });
|
|
596
654
|
wired.push(...captureWiring);
|
|
597
655
|
}
|
|
656
|
+
// E113: the shell-script hooks a re-run replaces, once the entries above no longer name them.
|
|
657
|
+
const retiredHooks = legacyHookScriptActions(root, ideTargets);
|
|
658
|
+
if (retiredHooks.length) applyActions(retiredHooks, { force: true });
|
|
598
659
|
// After every write to a managed path has landed (including the legacy renames), so the recorded
|
|
599
660
|
// sha is the file's final state.
|
|
600
661
|
recordManagedWrites(wired);
|
|
@@ -675,7 +736,12 @@ export const PACK_IGNORE_BLOCK = [
|
|
|
675
736
|
// and returns true.
|
|
676
737
|
export function ensurePackIgnored(root) {
|
|
677
738
|
const gi = path.join(root, '.gitignore');
|
|
678
|
-
|
|
739
|
+
// Only a plain file, or none: the Product can commit `.gitignore` as a link, and reading then writing
|
|
740
|
+
// through it would append this block to whatever file it points at, outside the workspace (E81 review 16).
|
|
741
|
+
let st = null;
|
|
742
|
+
try { st = fs.lstatSync(gi); } catch { /* none yet: written below */ }
|
|
743
|
+
if (st && !st.isFile()) { warn('.gitignore in the Product is not a plain file (a link?) — the code-context pack ignore line was not added'); return false; }
|
|
744
|
+
const lines = st ? fs.readFileSync(gi, 'utf8').split('\n') : [];
|
|
679
745
|
if (lines.some((l) => l.trim() === PACK_IGNORE_GLOB)) return false;
|
|
680
746
|
const body = lines.join('\n').replace(/\n*$/, '');
|
|
681
747
|
const prefix = body ? `${body}\n\n` : '';
|
|
@@ -687,12 +753,15 @@ export function ensurePackIgnored(root) {
|
|
|
687
753
|
export function packRepo(root, repo) {
|
|
688
754
|
const repoRoot = path.resolve(root, repo.path);
|
|
689
755
|
const out = path.join(root, repo.contextPack);
|
|
690
|
-
|
|
756
|
+
// Printed through `forTerminal` (E81 review 12): the name and pack path come from the shared registry,
|
|
757
|
+
// and repomix's error line from a run in a registered folder.
|
|
758
|
+
const nm = forTerminal(repo.name);
|
|
759
|
+
if (!has('npx')) { warn(`${nm}: npx missing — skipped repomix pack`); return false; }
|
|
691
760
|
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
692
761
|
ensurePackIgnored(root); // keep the pack out of git before it is (re)written — see repo-publish.mjs invariant 1
|
|
693
|
-
info(`${
|
|
694
|
-
const r =
|
|
695
|
-
if (r.ok) { ok(`${
|
|
696
|
-
fail(`${
|
|
762
|
+
info(`${nm}: packing with repomix …`);
|
|
763
|
+
const r = runLauncher('npx', ['repomix@latest', '--compress', '--include-logs', '--style', 'markdown', '-o', out], { cwd: repoRoot });
|
|
764
|
+
if (r.ok) { ok(`${nm}: cached ${forTerminal(repo.contextPack)}`); hand(`${nm}: generate the code-map in your AI agent (yad-connect-repos)`); return true; }
|
|
765
|
+
fail(`${nm}: repomix failed — ${forTerminal(r.stderr.split('\n')[0]) || 'unknown error'}`);
|
|
697
766
|
return false;
|
|
698
767
|
}
|
package/cli/ship.mjs
CHANGED
|
@@ -28,7 +28,7 @@ export async function runShip(root, opts = {}) {
|
|
|
28
28
|
// from the committed subject (the full `<type>: …` form), which the pr-title gate expects — passing
|
|
29
29
|
// the bare --message here would override that with a type-less title and fail the gate.
|
|
30
30
|
const opened = await runOpenPr(root, {
|
|
31
|
-
repo: opts.repo, platform: opts.platform, base: opts.base,
|
|
31
|
+
repo: opts.repo, product: opts.product, platform: opts.platform, base: opts.base,
|
|
32
32
|
title: opts.title, task: opts.task,
|
|
33
33
|
risk: opts.risk, contractChange: opts.contractChange,
|
|
34
34
|
});
|
package/cli/skip.mjs
CHANGED
|
@@ -32,7 +32,7 @@ import { actorName } from './platform.mjs';
|
|
|
32
32
|
// gate, so it must not block the verb.
|
|
33
33
|
export function recordActor(root) {
|
|
34
34
|
let platform = null;
|
|
35
|
-
try { platform = loadProduct(root)?.
|
|
35
|
+
try { platform = loadProduct(root)?.productConfig?.platform || null; } catch { /* no Product / malformed — attribute by raw git name */ }
|
|
36
36
|
return actorName(root, platform);
|
|
37
37
|
}
|
|
38
38
|
|
|
@@ -70,9 +70,9 @@ const VERBS = {
|
|
|
70
70
|
// these verbs on a local ledger (`yad doctor` reports both), and the hook and the verb must give the same
|
|
71
71
|
// answer on the same file.
|
|
72
72
|
function ciOwnsLedger(root, { epic, command, undoCommand = null, runner }) {
|
|
73
|
-
const
|
|
74
|
-
if (!isVerifiedLedger(
|
|
75
|
-
const seeded = seededSlugs(root,
|
|
73
|
+
const productConfig = readJSON(productConfigPath(root), null);
|
|
74
|
+
if (!isVerifiedLedger(productConfig)) return false;
|
|
75
|
+
const seeded = seededSlugs(root, productConfig, runner);
|
|
76
76
|
if (seeded === null) {
|
|
77
77
|
warn(`this Product's ledger is verified, and origin could not be read — cannot tell whether CI already owns ${epicRel(epic)}/.sdlc/state.json. If the epic's first review PR has merged, ledger-guard will reject this change`);
|
|
78
78
|
return false;
|
package/cli/tidy.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import { productConfigPath } from './manifest.mjs';
|
|
|
13
13
|
import { loadProduct } from './gate.mjs';
|
|
14
14
|
import { platformLogin } from './platform.mjs';
|
|
15
15
|
import { checkpointAuthor } from './checkpoint.mjs';
|
|
16
|
-
import { productGit, resolveDefaultBranch, guardDefaultBranch } from './
|
|
16
|
+
import { productGit, resolveDefaultBranch, guardDefaultBranch } from './productcommit.mjs';
|
|
17
17
|
import { foldTrust, foldBuild } from './ledger.mjs';
|
|
18
18
|
import { epicRoot, isValidEpicId, readFrontmatter } from './epic-state.mjs';
|
|
19
19
|
|
|
@@ -40,16 +40,16 @@ export async function runTidy(root, opts = {}) {
|
|
|
40
40
|
log(c.bold('\nyad tidy up'));
|
|
41
41
|
if (!exists(path.join(root, '.git'))) { fail('not a git repo'); process.exitCode = 1; return; }
|
|
42
42
|
if (!exists(productConfigPath(root))) {
|
|
43
|
-
fail('no .sdlc/
|
|
43
|
+
fail('no .sdlc/product.json — run `yad tidy up` from the Product');
|
|
44
44
|
process.exitCode = 1;
|
|
45
45
|
return;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
const {
|
|
48
|
+
const { productConfig } = loadProduct(root);
|
|
49
49
|
const git = productGit(root);
|
|
50
50
|
const branch = git('rev-parse', '--abbrev-ref', 'HEAD').stdout;
|
|
51
|
-
const defaultBranch = resolveDefaultBranch(git,
|
|
52
|
-
if (!guardDefaultBranch(branch, defaultBranch, { allowBranch: opts.allowBranch, cmd: 'yad tidy up' })) return;
|
|
51
|
+
const defaultBranch = resolveDefaultBranch(git, productConfig);
|
|
52
|
+
if (!guardDefaultBranch(branch, defaultBranch, { allowBranch: opts.allowBranch, cmd: 'yad tidy up', root })) return;
|
|
53
53
|
|
|
54
54
|
if (opts.epic && !isValidEpicId(opts.epic)) { fail(`invalid epic id '${opts.epic}'`); process.exitCode = 1; return; }
|
|
55
55
|
if (opts.epic && !exists(epicRoot(root, opts.epic))) { fail(`no such epic '${opts.epic}'`); process.exitCode = 1; return; }
|
|
@@ -70,9 +70,9 @@ export async function runTidy(root, opts = {}) {
|
|
|
70
70
|
}
|
|
71
71
|
if (!folded) { info('nothing to tidy — no finished shards to fold'); return { folded: 0, epics: [], message: null, committed: false, pushed: false, dryRun: !!opts.dryRun }; }
|
|
72
72
|
|
|
73
|
-
const author = checkpointAuthor(platformLogin(root,
|
|
73
|
+
const author = checkpointAuthor(platformLogin(root, productConfig?.platform), git('config', 'user.name').stdout);
|
|
74
74
|
const label = touched.length === 1 ? touched[0] : `${touched.length} epics`;
|
|
75
|
-
const message = `chore(
|
|
75
|
+
const message = `chore(product): tidy Build ledgers — ${label} by ${author} [skip ci]`;
|
|
76
76
|
|
|
77
77
|
if (opts.dryRun) {
|
|
78
78
|
log('\n' + c.dim(message) + '\n');
|
package/cli/update-commit.mjs
CHANGED
|
@@ -21,18 +21,18 @@
|
|
|
21
21
|
// Task trailer, no Co-Authored-By (human-owned machine state, not an authored code change).
|
|
22
22
|
import fs from 'node:fs';
|
|
23
23
|
import path from 'node:path';
|
|
24
|
-
import { c, log, ok, info, warn, fail, hand, run, pushWithRebase } from './lib.mjs';
|
|
24
|
+
import { c, log, ok, info, warn, fail, hand, run, pushWithRebase, samePath } from './lib.mjs';
|
|
25
25
|
import { VERSION } from './manifest.mjs';
|
|
26
|
-
import { productGit, resolveDefaultBranch } from './
|
|
26
|
+
import { productGit, resolveDefaultBranch } from './productcommit.mjs';
|
|
27
27
|
|
|
28
28
|
// Collapse whitespace/newline runs to a single space — keeps a stray path or hostile value from
|
|
29
29
|
// breaking the one-line subject or injecting a fake trailer line.
|
|
30
30
|
const oneLine = (s = '') => String(s).replace(/\s+/g, ' ').trim();
|
|
31
31
|
|
|
32
|
-
// A short, human label for a repo root relative to the Product: '
|
|
32
|
+
// A short, human label for a repo root relative to the Product: 'product' for the Product itself, else the
|
|
33
33
|
// registered path (e.g. demo-repos/backend), else the basename.
|
|
34
34
|
export function repoLabel(productRoot, root) {
|
|
35
|
-
if (root === productRoot) return '
|
|
35
|
+
if (root === productRoot) return 'product';
|
|
36
36
|
const r = path.relative(productRoot, root);
|
|
37
37
|
return r && !r.startsWith('..') ? r.split(path.sep).join('/') : path.basename(root);
|
|
38
38
|
}
|
|
@@ -92,25 +92,28 @@ function stageAllowlist(git, root, paths) {
|
|
|
92
92
|
}
|
|
93
93
|
|
|
94
94
|
// Commit (and, with push, push) one repo group. `defaultBranch` is the repo's configured default
|
|
95
|
-
// (
|
|
95
|
+
// (the Product's default_branch / repo.default_branch); falls back to origin/HEAD then 'main'. Returns a small
|
|
96
96
|
// result object; never throws. On any hard error it sets process.exitCode so the CLI reports failure.
|
|
97
97
|
export function commitAndPush(group, { push = false, allowBranch = false, productRoot, defaultBranch } = {}) {
|
|
98
98
|
const { root, paths, items } = group;
|
|
99
99
|
const label = repoLabel(productRoot ?? root, root);
|
|
100
|
+
// Whether this is the Product itself, from the folder — never from the label, which a repo named `product`
|
|
101
|
+
// shares (E124 review 1).
|
|
102
|
+
const isProduct = root === (productRoot ?? root);
|
|
100
103
|
const git = productGit(root);
|
|
101
104
|
|
|
102
105
|
// `root` must be the TOP of its OWN git repo — not merely "inside a work tree". A registered repo
|
|
103
106
|
// whose clone is missing (reconcile's apply() happily recreates the wiring files) but whose path
|
|
104
|
-
// sits under the Product would otherwise report inside-work-tree=true against the
|
|
105
|
-
// pathspecs relative to cwd, so we would stage the connected repo's files into the
|
|
106
|
-
// push them to the
|
|
107
|
+
// sits under the Product would otherwise report inside-work-tree=true against the Product: git resolves the
|
|
108
|
+
// pathspecs relative to cwd, so we would stage the connected repo's files into the Product's index and
|
|
109
|
+
// push them to the Product's remote, mislabeled. Require the worktree top to BE this root.
|
|
107
110
|
const top = git('rev-parse', '--show-toplevel');
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
+
// `samePath`, not a plain realpath compare: on Windows git's long name and Node's 8.3 short name for the
|
|
112
|
+
// same folder differed, and the repo was skipped with nothing committed.
|
|
113
|
+
const sameRepo = top.ok && samePath(top.stdout, root);
|
|
111
114
|
if (!sameRepo) {
|
|
112
115
|
warn(`${label}: not its own git repo (missing/renamed clone?) — skipped (changes left in the working tree)`);
|
|
113
|
-
return { label, committed: false, pushed: false, skipped: true, error: false };
|
|
116
|
+
return { label, product: isProduct, committed: false, pushed: false, skipped: true, error: false };
|
|
114
117
|
}
|
|
115
118
|
const branch = git('rev-parse', '--abbrev-ref', 'HEAD').stdout;
|
|
116
119
|
const target = defaultBranch || resolveDefaultBranch(git);
|
|
@@ -120,13 +123,13 @@ export function commitAndPush(group, { push = false, allowBranch = false, produc
|
|
|
120
123
|
warn(`${label}: on '${branch}', not default '${target}' — --allow-branch: commit/push go to origin/${branch}`);
|
|
121
124
|
} else {
|
|
122
125
|
warn(`${label}: on '${branch}', not the default branch '${target}' — skipped (switch to '${target}' or pass --allow-branch)`);
|
|
123
|
-
return { label, committed: false, pushed: false, skipped: true, error: false };
|
|
126
|
+
return { label, product: isProduct, committed: false, pushed: false, skipped: true, error: false };
|
|
124
127
|
}
|
|
125
128
|
}
|
|
126
129
|
|
|
127
130
|
const { staged, addError } = stageAllowlist(git, root, paths);
|
|
128
131
|
if (addError) warn(`${label}: git add reported "${addError}" — staging may be incomplete`);
|
|
129
|
-
if (!staged.length) { info(`${label}: nothing to commit (unchanged or ignored)`); return { label, committed: false, pushed: false, skipped: false, error: false }; }
|
|
132
|
+
if (!staged.length) { info(`${label}: nothing to commit (unchanged or ignored)`); return { label, product: isProduct, committed: false, pushed: false, skipped: false, error: false }; }
|
|
130
133
|
|
|
131
134
|
// Pushing HEAD lands any local commits ahead of the remote too. Warn before we add ours so the
|
|
132
135
|
// operator sees unpublished WIP about to ride the update push (never silently publish it).
|
|
@@ -142,18 +145,18 @@ export function commitAndPush(group, { push = false, allowBranch = false, produc
|
|
|
142
145
|
git('reset', '-q', '--', ...staged); // don't leave our allowlist staged for an unrelated commit to sweep up
|
|
143
146
|
fail(`${label}: git commit failed — ${cm.stderr.split('\n')[0] || cm.code}`);
|
|
144
147
|
process.exitCode = 1;
|
|
145
|
-
return { label, committed: false, pushed: false, skipped: false, error: true };
|
|
148
|
+
return { label, product: isProduct, committed: false, pushed: false, skipped: false, error: true };
|
|
146
149
|
}
|
|
147
150
|
ok(`${label}: committed ${staged.length} file(s)`);
|
|
148
151
|
|
|
149
|
-
if (!push) return { label, committed: true, pushed: false, skipped: false, error: false };
|
|
150
|
-
if (pushWithRebase(root, branch).ok) { ok(`${label}: pushed to origin/${branch}`); return { label, committed: true, pushed: true, skipped: false, error: false }; }
|
|
152
|
+
if (!push) return { label, product: isProduct, committed: true, pushed: false, skipped: false, error: false };
|
|
153
|
+
if (pushWithRebase(root, branch).ok) { ok(`${label}: pushed to origin/${branch}`); return { label, product: isProduct, committed: true, pushed: true, skipped: false, error: false }; }
|
|
151
154
|
// The commit already landed locally — a re-run of `yad update --push` would see no drift and skip
|
|
152
155
|
// this repo, so point the operator at the direct push of the commit that already exists.
|
|
153
156
|
fail(`${label}: could not push to origin/${branch} — a protected branch, or an unresolvable rebase conflict`);
|
|
154
|
-
hand(`resolve it in ${
|
|
157
|
+
hand(`resolve it in ${isProduct ? '.' : label}, then push the existing commit with \`git push origin ${branch}\``);
|
|
155
158
|
process.exitCode = 1;
|
|
156
|
-
return { label, committed: true, pushed: false, skipped: false, error: true };
|
|
159
|
+
return { label, product: isProduct, committed: true, pushed: false, skipped: false, error: true };
|
|
157
160
|
}
|
|
158
161
|
|
|
159
162
|
// Orchestrate the per-repo commit/push over the grouped applied actions, bookended by the announce
|