yadflow 1.0.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 +50 -0
- package/LICENSE +21 -0
- package/README.md +559 -0
- package/bin/sdlc.mjs +135 -0
- package/cli/commit.mjs +81 -0
- package/cli/epic-state.mjs +220 -0
- package/cli/gate.mjs +456 -0
- package/cli/lib.mjs +142 -0
- package/cli/manifest.mjs +119 -0
- package/cli/openpr.mjs +65 -0
- package/cli/plan.mjs +127 -0
- package/cli/platform.mjs +151 -0
- package/cli/reconcile.mjs +83 -0
- package/cli/repo.mjs +61 -0
- package/cli/setup.mjs +208 -0
- package/package.json +51 -0
- package/skills/sdlc/config.yaml +156 -0
- package/skills/sdlc/install.sh +51 -0
- package/skills/sdlc/module-help.csv +17 -0
- package/skills/sdlc-author-analysis/SKILL.md +136 -0
- package/skills/sdlc-author-architecture/SKILL.md +180 -0
- package/skills/sdlc-author-architecture/references/contract-format.md +72 -0
- package/skills/sdlc-author-epic/SKILL.md +154 -0
- package/skills/sdlc-author-epic/references/state-schema.md +187 -0
- package/skills/sdlc-author-stories/SKILL.md +109 -0
- package/skills/sdlc-author-stories/references/story-schema.md +46 -0
- package/skills/sdlc-author-ui/SKILL.md +113 -0
- package/skills/sdlc-backfill/SKILL.md +91 -0
- package/skills/sdlc-backfill/references/backfill.md +66 -0
- package/skills/sdlc-backfill/templates/checks/backfill-check.sh +42 -0
- package/skills/sdlc-checks/SKILL.md +138 -0
- package/skills/sdlc-checks/references/check-gates.md +168 -0
- package/skills/sdlc-checks/templates/checks/build-test-lint.sh +14 -0
- package/skills/sdlc-checks/templates/checks/contract-check.sh +62 -0
- package/skills/sdlc-checks/templates/checks/spec-link.sh +38 -0
- package/skills/sdlc-checks/templates/checks/verified-commits.sh +120 -0
- package/skills/sdlc-checks/templates/github/sdlc-checks.yml +45 -0
- package/skills/sdlc-checks/templates/github/sdlc-verified-commits.yml +22 -0
- package/skills/sdlc-checks/templates/gitlab/.gitlab-ci.yml +40 -0
- package/skills/sdlc-checks/templates/gitlab/gitlab-ci.include-root.yml +7 -0
- package/skills/sdlc-checks/templates/gitlab/sdlc-checks.gitlab-ci.yml +47 -0
- package/skills/sdlc-checks/templates/gitlab/sdlc-verified-commits.gitlab-ci.yml +21 -0
- package/skills/sdlc-connect-repos/SKILL.md +159 -0
- package/skills/sdlc-connect-repos/references/code-context.md +92 -0
- package/skills/sdlc-connect-repos/references/hub-config.md +77 -0
- package/skills/sdlc-connect-repos/references/repos-registry.md +62 -0
- package/skills/sdlc-hub-bridge/SKILL.md +119 -0
- package/skills/sdlc-hub-bridge/references/bridge.md +136 -0
- package/skills/sdlc-hub-bridge/references/login-roster.md +42 -0
- package/skills/sdlc-hub-bridge/templates/checks/hub-route.sh +50 -0
- package/skills/sdlc-hub-bridge/templates/github/sdlc-gate-sync.yml +63 -0
- package/skills/sdlc-hub-bridge/templates/gitlab/gitlab-ci.include-root.yml +7 -0
- package/skills/sdlc-hub-bridge/templates/gitlab/sdlc-gate-sync.gitlab-ci.yml +64 -0
- package/skills/sdlc-implement/SKILL.md +143 -0
- package/skills/sdlc-implement/references/implement-conventions.md +103 -0
- package/skills/sdlc-implement/templates/.gitmessage +17 -0
- package/skills/sdlc-pr-template/SKILL.md +86 -0
- package/skills/sdlc-pr-template/references/risk-routing.md +54 -0
- package/skills/sdlc-pr-template/templates/checks/risk-route.sh +44 -0
- package/skills/sdlc-pr-template/templates/github/pull_request_template.md +30 -0
- package/skills/sdlc-pr-template/templates/gitlab/merge_request_templates/Default.md +32 -0
- package/skills/sdlc-pr-template/templates/hub/github/pull_request_template.md +36 -0
- package/skills/sdlc-pr-template/templates/hub/gitlab/merge_request_templates/Default.md +37 -0
- package/skills/sdlc-review-comments/SKILL.md +63 -0
- package/skills/sdlc-review-comments/references/comment-conventions.md +55 -0
- package/skills/sdlc-review-comments/templates/github/REVIEW_COMMENTS.md +49 -0
- package/skills/sdlc-review-comments/templates/gitlab/REVIEW_COMMENTS.md +49 -0
- package/skills/sdlc-review-gate/SKILL.md +196 -0
- package/skills/sdlc-review-gate/references/gating.md +79 -0
- package/skills/sdlc-run/SKILL.md +109 -0
- package/skills/sdlc-run/references/run-loop.md +121 -0
- package/skills/sdlc-ship/SKILL.md +86 -0
- package/skills/sdlc-ship/references/ship-and-record.md +67 -0
- package/skills/sdlc-ship/templates/.coderabbit.yaml +19 -0
- package/skills/sdlc-spec/SKILL.md +119 -0
- package/skills/sdlc-spec/references/spec-handoff.md +101 -0
- package/skills/sdlc-status/SKILL.md +92 -0
package/cli/manifest.mjs
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// The single source of truth for what a set-up SDLC project should contain.
|
|
2
|
+
// Drives setup (install from), update (re-sync), and check (diff against).
|
|
3
|
+
// Keep the skill list here in sync with skills/sdlc/install.sh.
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
|
|
6
|
+
// Read the version from package.json (the one source of truth) so it always
|
|
7
|
+
// tracks the semantic-release-managed version — never a hardcoded constant
|
|
8
|
+
// that would drift after a release. package.json ships in the npm tarball and
|
|
9
|
+
// sits at the package root, one level up from this cli/ dir.
|
|
10
|
+
const { version } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
11
|
+
export const VERSION = version;
|
|
12
|
+
|
|
13
|
+
// The 17 hand-authored sdlc-* skills (mirrors skills/sdlc/install.sh).
|
|
14
|
+
export const SKILLS = [
|
|
15
|
+
'sdlc-author-analysis',
|
|
16
|
+
'sdlc-author-epic',
|
|
17
|
+
'sdlc-author-architecture',
|
|
18
|
+
'sdlc-author-ui',
|
|
19
|
+
'sdlc-author-stories',
|
|
20
|
+
'sdlc-connect-repos',
|
|
21
|
+
'sdlc-spec',
|
|
22
|
+
'sdlc-implement',
|
|
23
|
+
'sdlc-checks',
|
|
24
|
+
'sdlc-pr-template',
|
|
25
|
+
'sdlc-review-comments',
|
|
26
|
+
'sdlc-hub-bridge',
|
|
27
|
+
'sdlc-ship',
|
|
28
|
+
'sdlc-backfill',
|
|
29
|
+
'sdlc-run',
|
|
30
|
+
'sdlc-review-gate',
|
|
31
|
+
'sdlc-status',
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
// IDE install targets (relative to the target project root).
|
|
35
|
+
export const IDE_FOLDER_TARGETS = ['.claude', '.agents', '.zencoder']; // <ide>/skills/<skill>/ (folder copy)
|
|
36
|
+
export const IDE_OPENCODE_DIR = '.opencode/commands'; // <skill>.md (flat SKILL.md copy)
|
|
37
|
+
|
|
38
|
+
// Module registration files copied from skills/sdlc/ into _bmad/sdlc/.
|
|
39
|
+
export const MODULE_FILES = ['config.yaml', 'module-help.csv'];
|
|
40
|
+
|
|
41
|
+
// Project-level files setup produces (used by `check` to spot missing setup).
|
|
42
|
+
export const PROJECT_FILES = {
|
|
43
|
+
reposRegistry: '.sdlc/repos.json',
|
|
44
|
+
hubConfig: '.sdlc/hub.json',
|
|
45
|
+
version: '.sdlc/cli-version.json',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// ---- `sdlc commit` conventions (mirror skills/sdlc/config.yaml `build`) ----
|
|
49
|
+
// Conventional-commit types (config.yaml commit_subject_style).
|
|
50
|
+
export const COMMIT_TYPES = ['feat', 'fix', 'docs', 'refactor', 'test', 'perf', 'build', 'ci', 'chore', 'revert'];
|
|
51
|
+
// Per-commit AI co-author choices (config.yaml build.ai_coauthor.allowed). The human git author OWNS
|
|
52
|
+
// the commit; the AI is only a Co-Authored-By trailer. `none` => human-only (trailer omitted).
|
|
53
|
+
export const AI_COAUTHORS = {
|
|
54
|
+
claude: { name: 'Claude', email: 'noreply@anthropic.com' },
|
|
55
|
+
copilot: { name: 'GitHub Copilot', email: 'copilot@users.noreply.github.com' },
|
|
56
|
+
cursor: { name: 'Cursor', email: 'noreply@cursor.com' },
|
|
57
|
+
coderabbit: { name: 'CodeRabbit', email: 'noreply@coderabbit.ai' },
|
|
58
|
+
none: null,
|
|
59
|
+
};
|
|
60
|
+
// Atomic-commit guard: warn/refuse above this many staged files (build plan: ≤3 where possible).
|
|
61
|
+
export const ATOMIC_FILE_LIMIT = 3;
|
|
62
|
+
// Trailer order is fixed: Task -> Contract-Change -> Co-Authored-By (config.yaml build comment).
|
|
63
|
+
export const TASK_TRAILER = 'Task';
|
|
64
|
+
export const CONTRACT_CHANGE_TRAILER = 'Contract-Change';
|
|
65
|
+
export const COAUTHOR_TRAILER = 'Co-Authored-By';
|
|
66
|
+
|
|
67
|
+
// Per-epic ledger files under epics/<epic>/.sdlc/ (the file source of truth the gate reads/writes).
|
|
68
|
+
export const epicFiles = (epicRoot) => ({
|
|
69
|
+
state: `${epicRoot}/.sdlc/state.json`,
|
|
70
|
+
approvals: `${epicRoot}/.sdlc/approvals.json`,
|
|
71
|
+
comments: `${epicRoot}/.sdlc/comments.json`,
|
|
72
|
+
hubPrs: `${epicRoot}/.sdlc/hub-prs.json`,
|
|
73
|
+
contractLock: `${epicRoot}/.sdlc/contract-lock.json`,
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Per-repo wiring: src is relative to PKG_ROOT, dest relative to the repo root.
|
|
77
|
+
// `common` always installs; the platform key installs by detected platform.
|
|
78
|
+
export const REPO_WIRING = {
|
|
79
|
+
common: [
|
|
80
|
+
{ src: 'skills/sdlc-checks/templates/checks/spec-link.sh', dest: 'checks/spec-link.sh', exec: true },
|
|
81
|
+
{ src: 'skills/sdlc-checks/templates/checks/contract-check.sh', dest: 'checks/contract-check.sh', exec: true },
|
|
82
|
+
{ src: 'skills/sdlc-checks/templates/checks/build-test-lint.sh', dest: 'checks/build-test-lint.sh', exec: true },
|
|
83
|
+
{ src: 'skills/sdlc-checks/templates/checks/verified-commits.sh', dest: 'checks/verified-commits.sh', exec: true },
|
|
84
|
+
{ src: 'skills/sdlc-pr-template/templates/checks/risk-route.sh', dest: 'checks/risk-route.sh', exec: true },
|
|
85
|
+
],
|
|
86
|
+
github: [
|
|
87
|
+
{ src: 'skills/sdlc-checks/templates/github/sdlc-checks.yml', dest: '.github/workflows/sdlc-checks.yml' },
|
|
88
|
+
{ src: 'skills/sdlc-pr-template/templates/github/pull_request_template.md', dest: '.github/pull_request_template.md' },
|
|
89
|
+
{ src: 'skills/sdlc-review-comments/templates/github/REVIEW_COMMENTS.md', dest: '.github/REVIEW_COMMENTS.md' },
|
|
90
|
+
],
|
|
91
|
+
gitlab: [
|
|
92
|
+
{ src: 'skills/sdlc-checks/templates/gitlab/sdlc-checks.gitlab-ci.yml', dest: '.gitlab/ci/sdlc-checks.yml' },
|
|
93
|
+
{ src: 'skills/sdlc-pr-template/templates/gitlab/merge_request_templates/Default.md', dest: '.gitlab/merge_request_templates/Default.md' },
|
|
94
|
+
{ src: 'skills/sdlc-review-comments/templates/gitlab/REVIEW_COMMENTS.md', dest: '.gitlab/REVIEW_COMMENTS.md' },
|
|
95
|
+
],
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
export const wiringFor = (platform) => [
|
|
99
|
+
...REPO_WIRING.common,
|
|
100
|
+
...(REPO_WIRING[platform] || []),
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
// Hub wiring: CI installed on the PRODUCT HUB itself (dest is the project root — the hub IS the
|
|
104
|
+
// root). Installed only when hub.json has a platform and the bridge is enabled. Carries the
|
|
105
|
+
// event-driven gate sync (approvals/change requests/the merge trigger `sdlc gate ci`) and the
|
|
106
|
+
// verified-commits gate (no unverified commits from unverified users reach merge on the hub).
|
|
107
|
+
export const HUB_WIRING = {
|
|
108
|
+
common: [
|
|
109
|
+
{ src: 'skills/sdlc-checks/templates/checks/verified-commits.sh', dest: 'checks/verified-commits.sh', exec: true },
|
|
110
|
+
],
|
|
111
|
+
github: [
|
|
112
|
+
{ src: 'skills/sdlc-hub-bridge/templates/github/sdlc-gate-sync.yml', dest: '.github/workflows/sdlc-gate-sync.yml' },
|
|
113
|
+
{ src: 'skills/sdlc-checks/templates/github/sdlc-verified-commits.yml', dest: '.github/workflows/sdlc-verified-commits.yml' },
|
|
114
|
+
],
|
|
115
|
+
gitlab: [
|
|
116
|
+
{ src: 'skills/sdlc-hub-bridge/templates/gitlab/sdlc-gate-sync.gitlab-ci.yml', dest: '.gitlab/ci/sdlc-gate-sync.yml' },
|
|
117
|
+
{ src: 'skills/sdlc-checks/templates/gitlab/sdlc-verified-commits.gitlab-ci.yml', dest: '.gitlab/ci/sdlc-verified-commits.yml' },
|
|
118
|
+
],
|
|
119
|
+
};
|
package/cli/openpr.mjs
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// `sdlc open-pr` — open a code-repo task PR/MR from the repo's platform template (build half).
|
|
2
|
+
// Detects the platform, pushes the current branch, and creates the PR/MR with Summary / Story-task /
|
|
3
|
+
// Impact & Risk prefilled. Distinct from `sdlc gate open`, which opens a front-half artifact-review PR
|
|
4
|
+
// on the product hub.
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import fs from 'node:fs';
|
|
7
|
+
import { c, log, ok, info, hand, fail, run, exists, readJSON } from './lib.mjs';
|
|
8
|
+
import { PROJECT_FILES } from './manifest.mjs';
|
|
9
|
+
import { detectPlatform, createPr } from './platform.mjs';
|
|
10
|
+
import { taskFromBranch } from './commit.mjs';
|
|
11
|
+
|
|
12
|
+
// Resolve the target code repo: --repo <name> from the registry, else --dir, else cwd.
|
|
13
|
+
function resolveRepo(root, { repo, dir }) {
|
|
14
|
+
if (repo) {
|
|
15
|
+
const reg = readJSON(path.join(root, PROJECT_FILES.reposRegistry), { repos: [] });
|
|
16
|
+
const found = reg.repos.find((r) => r.name === repo);
|
|
17
|
+
if (found) return { repoRoot: path.resolve(root, found.path), meta: found };
|
|
18
|
+
}
|
|
19
|
+
return { repoRoot: path.resolve(root, dir || '.'), meta: null };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function templateBody(repoRoot, platform, { task, risk, contract, domains }) {
|
|
23
|
+
const tplPath = platform === 'gitlab'
|
|
24
|
+
? path.join(repoRoot, '.gitlab/merge_request_templates/Default.md')
|
|
25
|
+
: path.join(repoRoot, '.github/pull_request_template.md');
|
|
26
|
+
const base = exists(tplPath) ? fs.readFileSync(tplPath, 'utf8') : '## Summary\n\n## Impact & Risk\n';
|
|
27
|
+
// Fill the obvious fields; leave the rest of the committed template intact for the author.
|
|
28
|
+
return base
|
|
29
|
+
.replace(/EP-<slug>-S0N-T0N/g, task || 'EP-<slug>-S0N-T0N')
|
|
30
|
+
.replace(/(\*\*Risk level:\*\*)\s*\w+/i, `$1 ${risk}`)
|
|
31
|
+
.replace(/(\*\*Contract surface touched:\*\*)\s*\w+/i, `$1 ${contract ? 'yes' : 'no'}`)
|
|
32
|
+
.replace(/(\*\*Domains \/ repos touched:\*\*).*/i, `$1 ${domains || '<repo>'}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export async function runOpenPr(root, opts = {}) {
|
|
36
|
+
log(c.bold('\nsdlc open-pr'));
|
|
37
|
+
const { repoRoot, meta } = resolveRepo(root, opts);
|
|
38
|
+
if (!exists(path.join(repoRoot, '.git'))) { fail(`not a git repo: ${repoRoot}`); process.exitCode = 1; return; }
|
|
39
|
+
|
|
40
|
+
const remote = run('git', ['remote', 'get-url', 'origin'], { cwd: repoRoot }).stdout;
|
|
41
|
+
const platform = opts.platform || meta?.platform || detectPlatform(remote);
|
|
42
|
+
if (!platform) { fail('could not detect platform (github/gitlab) — pass --platform'); process.exitCode = 1; return; }
|
|
43
|
+
|
|
44
|
+
const branch = run('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: repoRoot }).stdout;
|
|
45
|
+
const baseBranch = opts.base || meta?.default_branch || 'main';
|
|
46
|
+
if (branch === baseBranch) { fail(`on ${baseBranch} — switch to your task branch first`); process.exitCode = 1; return; }
|
|
47
|
+
|
|
48
|
+
// Push the branch (sets upstream) using the user's own auth. Abort on failure — creating a PR for a
|
|
49
|
+
// branch that is not on the remote just fails with a more confusing error.
|
|
50
|
+
info(`pushing ${branch} …`);
|
|
51
|
+
const push = run('git', ['push', '-u', 'origin', branch], { cwd: repoRoot });
|
|
52
|
+
if (!push.ok) { fail(`git push failed — ${push.stderr.split('\n')[0] || 'unknown'}`); process.exitCode = 1; return; }
|
|
53
|
+
|
|
54
|
+
const task = opts.task || taskFromBranch(branch);
|
|
55
|
+
const title = opts.title || run('git', ['log', '-1', '--format=%s'], { cwd: repoRoot }).stdout || `task ${task || branch}`;
|
|
56
|
+
const body = templateBody(repoRoot, platform, {
|
|
57
|
+
task, risk: opts.risk || 'low', contract: !!opts.contractChange, domains: meta?.name,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const r = createPr(platform, { title, body, base: baseBranch, head: branch, cwd: repoRoot });
|
|
61
|
+
if (!r.ok) { fail(`could not open PR/MR — ${r.reason || 'unknown'}`); process.exitCode = 1; return; }
|
|
62
|
+
ok(`opened ${r.url}`);
|
|
63
|
+
if (opts.risk === 'high' || opts.contractChange) hand('high risk / contract surface — run `bash checks/risk-route.sh "<pr body>"` for required reviewers');
|
|
64
|
+
return { url: r.url };
|
|
65
|
+
}
|
package/cli/plan.mjs
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Builds the deterministic action list (module install + per-repo wiring) for a
|
|
2
|
+
// target project. Each action carries a current status and an apply() closure, so
|
|
3
|
+
// setup (apply all), update (apply changed), and check (report; fix non-ok) share it.
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import {
|
|
7
|
+
asset, exists, copyDir, copyFile, dirMatches, sameContent, readJSON,
|
|
8
|
+
} from './lib.mjs';
|
|
9
|
+
import {
|
|
10
|
+
SKILLS, IDE_FOLDER_TARGETS, IDE_OPENCODE_DIR, MODULE_FILES, wiringFor, HUB_WIRING, PROJECT_FILES,
|
|
11
|
+
} from './manifest.mjs';
|
|
12
|
+
|
|
13
|
+
// status: 'ok' | 'missing' | 'outdated'
|
|
14
|
+
const fileAction = (scope, item, src, dest, opts = {}) => ({
|
|
15
|
+
scope,
|
|
16
|
+
item,
|
|
17
|
+
status: !exists(dest) ? 'missing' : sameContent(src, dest) ? 'ok' : 'outdated',
|
|
18
|
+
apply: () => copyFile(src, dest, opts),
|
|
19
|
+
});
|
|
20
|
+
const dirAction = (scope, item, src, dest) => ({
|
|
21
|
+
scope,
|
|
22
|
+
item,
|
|
23
|
+
status: !exists(dest) ? 'missing' : dirMatches(src, dest) ? 'ok' : 'outdated',
|
|
24
|
+
apply: () => copyDir(src, dest),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Which IDE targets this project wants. Recorded at setup time; falls back to
|
|
28
|
+
// whichever IDE base dirs already exist, else .claude.
|
|
29
|
+
export function ideTargetsFor(root) {
|
|
30
|
+
const rec = readJSON(path.join(root, PROJECT_FILES.version));
|
|
31
|
+
if (rec?.ideTargets?.length) return rec.ideTargets;
|
|
32
|
+
const present = [...IDE_FOLDER_TARGETS, '.opencode'].filter((d) => exists(path.join(root, d)));
|
|
33
|
+
return present.length ? present : ['.claude'];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Module = skills installed into each IDE target + the _bmad/sdlc registration.
|
|
37
|
+
export function moduleActions(root, ideTargets = ideTargetsFor(root)) {
|
|
38
|
+
const actions = [];
|
|
39
|
+
for (const ide of ideTargets) {
|
|
40
|
+
if (ide === '.opencode') {
|
|
41
|
+
for (const s of SKILLS) {
|
|
42
|
+
actions.push(fileAction(
|
|
43
|
+
ide, s,
|
|
44
|
+
asset('skills', s, 'SKILL.md'),
|
|
45
|
+
path.join(root, IDE_OPENCODE_DIR, `${s}.md`),
|
|
46
|
+
));
|
|
47
|
+
}
|
|
48
|
+
} else {
|
|
49
|
+
for (const s of SKILLS) {
|
|
50
|
+
actions.push(dirAction(
|
|
51
|
+
ide, s,
|
|
52
|
+
asset('skills', s),
|
|
53
|
+
path.join(root, ide, 'skills', s),
|
|
54
|
+
));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
for (const f of MODULE_FILES) {
|
|
59
|
+
actions.push(fileAction(
|
|
60
|
+
'_bmad', f,
|
|
61
|
+
asset('skills', 'sdlc', f),
|
|
62
|
+
path.join(root, '_bmad', 'sdlc', f),
|
|
63
|
+
));
|
|
64
|
+
}
|
|
65
|
+
return actions;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Per-repo wiring (gate scripts, CI, PR template, comment scaffold).
|
|
69
|
+
export function repoActions(root, repo) {
|
|
70
|
+
const repoRoot = path.resolve(root, repo.path);
|
|
71
|
+
return wiringFor(repo.platform).map((w) =>
|
|
72
|
+
fileAction(repo.name, w.dest, asset(w.src), path.join(repoRoot, w.dest), { exec: !!w.exec }),
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Hub wiring (gate-sync + verified-commits CI on the product hub itself). Only when the hub has a
|
|
77
|
+
// platform and the bridge is explicitly enabled — a file-only hub stays file-only, with no error.
|
|
78
|
+
export function hubActions(root) {
|
|
79
|
+
const hub = readJSON(path.join(root, PROJECT_FILES.hubConfig));
|
|
80
|
+
// `bridge_enabled` is the canonical flag (the documented hub-config schema); older setup versions
|
|
81
|
+
// wrote `bridge` — accept an explicit true in either spelling, wire nothing otherwise.
|
|
82
|
+
if (!hub?.platform || !(hub.bridge_enabled === true || hub.bridge === true)) return [];
|
|
83
|
+
return [...HUB_WIRING.common, ...(HUB_WIRING[hub.platform] || [])].map((w) =>
|
|
84
|
+
fileAction('hub', w.dest, asset(w.src), path.join(root, w.dest), { exec: !!w.exec }),
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Every email the verified-commits gate should accept as a known author: the hub roster's `email`
|
|
89
|
+
// (or `emails`) fields plus hub.json's free-form `verified_authors` list. Lower-cased, deduped,
|
|
90
|
+
// sorted — deterministic so the generated file is drift-checkable like any wired file.
|
|
91
|
+
export function verifiedAuthorEmails(hub) {
|
|
92
|
+
const out = new Set();
|
|
93
|
+
for (const r of hub?.roster || []) {
|
|
94
|
+
for (const e of [r.email, ...(Array.isArray(r.emails) ? r.emails : [])]) {
|
|
95
|
+
if (e) out.add(String(e).toLowerCase());
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
for (const e of hub?.verified_authors || []) if (e) out.add(String(e).toLowerCase());
|
|
99
|
+
return [...out].sort();
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Generate .sdlc/verified-authors (one email per line) in the hub AND every registered repo, from
|
|
103
|
+
// the hub config. No emails configured → no actions (the gate then warns instead of blocking —
|
|
104
|
+
// never enforce an empty allowlist).
|
|
105
|
+
export function authorsActions(root, repos = []) {
|
|
106
|
+
const hub = readJSON(path.join(root, PROJECT_FILES.hubConfig));
|
|
107
|
+
const emails = verifiedAuthorEmails(hub);
|
|
108
|
+
if (!emails.length) return [];
|
|
109
|
+
const desired = [
|
|
110
|
+
'# Generated by `sdlc check --fix` from .sdlc/hub.json (roster emails + verified_authors).',
|
|
111
|
+
'# The verified-commits gate accepts only these author emails. Edit hub.json, not this file.',
|
|
112
|
+
...emails,
|
|
113
|
+
].join('\n') + '\n';
|
|
114
|
+
const targets = [
|
|
115
|
+
{ scope: 'hub', dest: path.join(root, '.sdlc', 'verified-authors') },
|
|
116
|
+
...repos.map((r) => ({ scope: r.name, dest: path.join(path.resolve(root, r.path), '.sdlc', 'verified-authors') })),
|
|
117
|
+
];
|
|
118
|
+
return targets.map(({ scope, dest }) => ({
|
|
119
|
+
scope,
|
|
120
|
+
item: '.sdlc/verified-authors',
|
|
121
|
+
status: !exists(dest) ? 'missing' : fs.readFileSync(dest, 'utf8') === desired ? 'ok' : 'outdated',
|
|
122
|
+
apply: () => {
|
|
123
|
+
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
|
124
|
+
fs.writeFileSync(dest, desired);
|
|
125
|
+
},
|
|
126
|
+
}));
|
|
127
|
+
}
|
package/cli/platform.mjs
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Platform adapter — the ONLY place that shells out to gh/glab. Read recipes mirror
|
|
2
|
+
// skills/sdlc-hub-bridge/references/bridge.md. Everything runs as the local user (gh/glab own auth);
|
|
3
|
+
// no tokens are stored. Pure mapping fns (resolveLogin/mapApprovers) are exported for unit tests;
|
|
4
|
+
// readPr is injectable so the gate can be tested with a fake.
|
|
5
|
+
import { run, has } from './lib.mjs';
|
|
6
|
+
|
|
7
|
+
// github | gitlab | null, from a repo/remote.
|
|
8
|
+
export function detectPlatform(remoteUrl = '') {
|
|
9
|
+
if (/gitlab/i.test(remoteUrl)) return 'gitlab';
|
|
10
|
+
if (/github/i.test(remoteUrl)) return 'github';
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function cliFor(platform) {
|
|
15
|
+
if (platform === 'gitlab') return 'glab';
|
|
16
|
+
if (platform === 'github') return 'gh';
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Is the platform CLI present? (auth is the user's own; we don't probe it here.)
|
|
21
|
+
export function platformReady(platform) {
|
|
22
|
+
const cli = cliFor(platform);
|
|
23
|
+
return !!cli && has(cli);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// ---- login -> sdlc identity (roster + derived domain-owner) -------------------------------------
|
|
27
|
+
// Returns the records this login's APPROVED review contributes. A roster reviewer who owns a touched
|
|
28
|
+
// repo's domain contributes BOTH a base record and a domain-owner record (bridge.md "Login -> role").
|
|
29
|
+
export function resolveLogin(login, roster = [], repos = [], touchedDomains = []) {
|
|
30
|
+
const entry = roster.find((r) => r.login === login);
|
|
31
|
+
if (!entry) return [{ name: login, role: 'reviewer', unverified: true }];
|
|
32
|
+
const records = [{ name: entry.name, role: entry.role }];
|
|
33
|
+
for (const repo of repos) {
|
|
34
|
+
if (repo.domain_owner === entry.name && touchedDomains.includes(repo.name)) {
|
|
35
|
+
records.push({ name: entry.name, role: 'domain-owner', domain: repo.name });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return records;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Normalized PR reviews -> approval records (only APPROVED states count). `submittedAt` rides along
|
|
42
|
+
// so the gate can tell a fresh re-approval from a stale one (revoke-on-change).
|
|
43
|
+
export function mapApprovers(reviews = [], { roster, repos, touchedDomains }) {
|
|
44
|
+
const out = [];
|
|
45
|
+
for (const r of reviews) {
|
|
46
|
+
if (r.state !== 'APPROVED') continue;
|
|
47
|
+
for (const rec of resolveLogin(r.login, roster, repos, touchedDomains)) {
|
|
48
|
+
out.push({ ...rec, submittedAt: r.submittedAt || null });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ---- read PR state (github) ---------------------------------------------------------------------
|
|
55
|
+
function readPrGitHub(n, { cwd } = {}) {
|
|
56
|
+
const view = run('gh', ['pr', 'view', String(n), '--json', 'state,mergedAt,headRefOid'], { cwd });
|
|
57
|
+
if (!view.ok) return { ok: false, reason: view.stderr || 'gh pr view failed' };
|
|
58
|
+
const meta = JSON.parse(view.stdout);
|
|
59
|
+
// latestReviews collapses a reviewer's superseded reviews to their current one.
|
|
60
|
+
const rev = run('gh', ['pr', 'view', String(n), '--json', 'latestReviews'], { cwd });
|
|
61
|
+
const reviews = rev.ok
|
|
62
|
+
? (JSON.parse(rev.stdout).latestReviews || []).map((x) => ({ login: x.author?.login, state: x.state, submittedAt: x.submittedAt }))
|
|
63
|
+
: [];
|
|
64
|
+
// Review-thread resolution via GraphQL (REST does not expose isResolved). Paginate so a PR with
|
|
65
|
+
// >100 threads is not mistakenly read as "all resolved".
|
|
66
|
+
let threads = [];
|
|
67
|
+
const nwo = run('gh', ['repo', 'view', '--json', 'owner,name'], { cwd });
|
|
68
|
+
if (nwo.ok) {
|
|
69
|
+
const { owner, name } = JSON.parse(nwo.stdout);
|
|
70
|
+
const q = `query($o:String!,$r:String!,$n:Int!,$c:String){repository(owner:$o,name:$r){pullRequest(number:$n){reviewThreads(first:100,after:$c){pageInfo{hasNextPage endCursor} nodes{isResolved comments(first:1){nodes{author{login} body}}}}}}}`;
|
|
71
|
+
let cursor = null;
|
|
72
|
+
for (let guard = 0; guard < 50; guard++) {
|
|
73
|
+
const args = ['api', 'graphql', '-f', `query=${q}`, '-F', `o=${owner.login}`, '-F', `r=${name}`, '-F', `n=${n}`];
|
|
74
|
+
if (cursor) args.push('-F', `c=${cursor}`);
|
|
75
|
+
const g = run('gh', args, { cwd });
|
|
76
|
+
if (!g.ok) break;
|
|
77
|
+
const page = JSON.parse(g.stdout)?.data?.repository?.pullRequest?.reviewThreads;
|
|
78
|
+
for (const t of page?.nodes || []) {
|
|
79
|
+
threads.push({
|
|
80
|
+
id: `thread-${threads.length}`,
|
|
81
|
+
resolved: !!t.isResolved,
|
|
82
|
+
login: t.comments?.nodes?.[0]?.author?.login,
|
|
83
|
+
body: t.comments?.nodes?.[0]?.body,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
if (!page?.pageInfo?.hasNextPage) break;
|
|
87
|
+
cursor = page.pageInfo.endCursor;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
ok: true,
|
|
92
|
+
state: meta.state,
|
|
93
|
+
merged: meta.state === 'MERGED' || !!meta.mergedAt,
|
|
94
|
+
headOid: meta.headRefOid,
|
|
95
|
+
reviews,
|
|
96
|
+
threads,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ---- read PR state (gitlab) ---------------------------------------------------------------------
|
|
101
|
+
function readPrGitLab(n, { cwd } = {}) {
|
|
102
|
+
const view = run('glab', ['mr', 'view', String(n), '-F', 'json'], { cwd });
|
|
103
|
+
if (!view.ok) return { ok: false, reason: view.stderr || 'glab mr view failed' };
|
|
104
|
+
const mr = JSON.parse(view.stdout);
|
|
105
|
+
const approvals = run('glab', ['api', `projects/:id/merge_requests/${mr.iid}/approvals`], { cwd });
|
|
106
|
+
const approvedBy = approvals.ok ? (JSON.parse(approvals.stdout).approved_by || []) : [];
|
|
107
|
+
const reviews = approvedBy.map((a) => ({ login: a.user?.username, state: 'APPROVED' }));
|
|
108
|
+
const disc = run('glab', ['api', `projects/:id/merge_requests/${mr.iid}/discussions`], { cwd });
|
|
109
|
+
let threads = [];
|
|
110
|
+
if (disc.ok) {
|
|
111
|
+
threads = (JSON.parse(disc.stdout) || [])
|
|
112
|
+
.filter((d) => d.notes?.some((nt) => nt.resolvable))
|
|
113
|
+
.map((d, i) => ({
|
|
114
|
+
id: d.id || `disc-${i}`,
|
|
115
|
+
resolved: !!d.notes.find((nt) => nt.resolvable)?.resolved,
|
|
116
|
+
login: d.notes[0]?.author?.username,
|
|
117
|
+
body: d.notes[0]?.body,
|
|
118
|
+
}));
|
|
119
|
+
}
|
|
120
|
+
return {
|
|
121
|
+
ok: true,
|
|
122
|
+
state: mr.state,
|
|
123
|
+
merged: mr.state === 'merged',
|
|
124
|
+
headOid: mr.diff_refs?.head_sha || mr.sha,
|
|
125
|
+
reviews,
|
|
126
|
+
threads,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Injectable entry point. gate.mjs accepts a `reader` override; default dispatches to gh/glab.
|
|
131
|
+
export function readPr(platform, n, opts = {}) {
|
|
132
|
+
if (!platformReady(platform)) return { ok: false, reason: `${cliFor(platform) || 'platform CLI'} not available` };
|
|
133
|
+
return platform === 'gitlab' ? readPrGitLab(n, opts) : readPrGitHub(n, opts);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---- create a PR/MR -----------------------------------------------------------------------------
|
|
137
|
+
export function createPr(platform, { title, body, base, head, reviewers = [], labels = [], cwd } = {}) {
|
|
138
|
+
if (!platformReady(platform)) return { ok: false, reason: `${cliFor(platform) || 'platform CLI'} not available` };
|
|
139
|
+
if (platform === 'gitlab') {
|
|
140
|
+
const args = ['mr', 'create', '--title', title, '--description', body, '--target-branch', base, '--source-branch', head, '--yes'];
|
|
141
|
+
if (reviewers.length) args.push('--reviewer', reviewers.join(','));
|
|
142
|
+
if (labels.length) args.push('--label', labels.join(','));
|
|
143
|
+
const r = run('glab', args, { cwd });
|
|
144
|
+
return { ok: r.ok, url: r.stdout.split('\n').pop(), reason: r.stderr };
|
|
145
|
+
}
|
|
146
|
+
const args = ['pr', 'create', '--title', title, '--body', body, '--base', base, '--head', head];
|
|
147
|
+
if (reviewers.length) args.push('--reviewer', reviewers.join(','));
|
|
148
|
+
if (labels.length) args.push('--label', labels.join(','));
|
|
149
|
+
const r = run('gh', args, { cwd });
|
|
150
|
+
return { ok: r.ok, url: r.stdout.split('\n').pop(), reason: r.stderr };
|
|
151
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// `sdlc check` (report) and `sdlc check --fix` (reconcile) — and `sdlc update`
|
|
2
|
+
// as a thin alias (--scope=changed). Inspects actual project state against the
|
|
3
|
+
// manifest: missing setup, drifted files, stale code-context.
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import {
|
|
6
|
+
c, log, ok, info, warn, hand, fail, readJSON, writeJSON, exists,
|
|
7
|
+
} from './lib.mjs';
|
|
8
|
+
import { VERSION, PROJECT_FILES } from './manifest.mjs';
|
|
9
|
+
import { moduleActions, repoActions, hubActions, authorsActions } from './plan.mjs';
|
|
10
|
+
import { gitHead, packRepo } from './setup.mjs';
|
|
11
|
+
|
|
12
|
+
const MARK = { missing: c.red('missing'), outdated: c.yellow('outdated'), stale: c.yellow('stale'), ok: c.green('ok') };
|
|
13
|
+
|
|
14
|
+
export async function reconcile(root, { fix = false, scope = 'all', force = false } = {}) {
|
|
15
|
+
log(c.bold(`\nSDLC reconcile ${c.dim('v' + VERSION)}`));
|
|
16
|
+
log(c.dim(`target: ${root}\n`));
|
|
17
|
+
|
|
18
|
+
// --- missing one-time setup (needs the interactive wizard) ---
|
|
19
|
+
const gaps = [];
|
|
20
|
+
if (!exists(path.join(root, PROJECT_FILES.version))) gaps.push('module not installed (.sdlc/cli-version.json absent)');
|
|
21
|
+
if (!exists(path.join(root, PROJECT_FILES.hubConfig))) gaps.push('hub not configured (.sdlc/hub.json absent)');
|
|
22
|
+
const registry = readJSON(path.join(root, PROJECT_FILES.reposRegistry), { repos: [] });
|
|
23
|
+
if (!exists(path.join(root, PROJECT_FILES.reposRegistry))) gaps.push('no repos registered (.sdlc/repos.json absent)');
|
|
24
|
+
|
|
25
|
+
// --- deterministic file actions (module + hub CI + author allowlists + every registered repo) ---
|
|
26
|
+
const actions = [...moduleActions(root), ...hubActions(root), ...authorsActions(root, registry.repos)];
|
|
27
|
+
for (const repo of registry.repos) actions.push(...repoActions(root, repo));
|
|
28
|
+
|
|
29
|
+
// --- stale code-context (HEAD moved since last pack) ---
|
|
30
|
+
const staleRepos = [];
|
|
31
|
+
for (const repo of registry.repos) {
|
|
32
|
+
const head = gitHead(path.resolve(root, repo.path));
|
|
33
|
+
if (head && repo.syncedHead && head !== repo.syncedHead) {
|
|
34
|
+
staleRepos.push(repo);
|
|
35
|
+
actions.push({ scope: repo.name, item: 'code-context', status: 'stale', apply: () => packRepo(root, repo) });
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// --- report, grouped by scope ---
|
|
40
|
+
const byScope = new Map();
|
|
41
|
+
for (const a of actions) {
|
|
42
|
+
if (!byScope.has(a.scope)) byScope.set(a.scope, []);
|
|
43
|
+
byScope.get(a.scope).push(a);
|
|
44
|
+
}
|
|
45
|
+
const counts = { missing: 0, outdated: 0, stale: 0, ok: 0 };
|
|
46
|
+
for (const [scopeName, items] of byScope) {
|
|
47
|
+
const notOk = items.filter((i) => i.status !== 'ok');
|
|
48
|
+
items.forEach((i) => counts[i.status]++);
|
|
49
|
+
if (notOk.length === 0) { ok(`${scopeName} ${c.dim('— up to date')}`); continue; }
|
|
50
|
+
log(` ${c.bold(scopeName)}`);
|
|
51
|
+
for (const i of notOk) log(` ${MARK[i.status]} ${i.item}`);
|
|
52
|
+
}
|
|
53
|
+
for (const g of gaps) warn(g);
|
|
54
|
+
|
|
55
|
+
const fixable = actions.filter((a) =>
|
|
56
|
+
a.status !== 'ok' && (scope === 'all' ? true : a.status !== 'missing'),
|
|
57
|
+
);
|
|
58
|
+
log('');
|
|
59
|
+
log(c.dim(`summary: ${counts.missing} missing, ${counts.outdated} outdated, ${counts.stale} stale, ${counts.ok} ok`));
|
|
60
|
+
|
|
61
|
+
if (!fix) {
|
|
62
|
+
if (fixable.length || gaps.length) hand('run `sdlc check --fix` to reconcile (or `sdlc setup` for missing one-time setup).');
|
|
63
|
+
return { counts, gaps, applied: 0 };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// --- apply ---
|
|
67
|
+
log('');
|
|
68
|
+
let applied = 0;
|
|
69
|
+
for (const a of fixable) {
|
|
70
|
+
a.apply();
|
|
71
|
+
applied++;
|
|
72
|
+
info(`${a.status} → fixed: ${a.scope}/${a.item}`);
|
|
73
|
+
}
|
|
74
|
+
if (force) {
|
|
75
|
+
for (const a of actions.filter((a) => a.status === 'ok')) a.apply();
|
|
76
|
+
}
|
|
77
|
+
// refresh the version stamp (preserve recorded ideTargets)
|
|
78
|
+
const rec = readJSON(path.join(root, PROJECT_FILES.version), {});
|
|
79
|
+
writeJSON(path.join(root, PROJECT_FILES.version), { ...rec, version: VERSION });
|
|
80
|
+
applied ? ok(`reconciled ${applied} item(s)`) : info('nothing to fix');
|
|
81
|
+
if (gaps.length) hand('one-time setup still missing — run `sdlc setup`.');
|
|
82
|
+
return { counts, gaps, applied };
|
|
83
|
+
}
|
package/cli/repo.mjs
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// `sdlc repo list|refresh` — connected-repo staleness as an explicit HUMAN decision.
|
|
2
|
+
// Skill steps no longer silently repack a stale repo; they flag it and point here. (`sdlc check --fix`
|
|
3
|
+
// still refreshes too — it is also human-invoked.)
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { c, log, ok, info, warn, hand, fail, readJSON, writeJSON } from './lib.mjs';
|
|
6
|
+
import { PROJECT_FILES } from './manifest.mjs';
|
|
7
|
+
import { gitHead, packRepo } from './setup.mjs';
|
|
8
|
+
|
|
9
|
+
function load(root) {
|
|
10
|
+
const regPath = path.join(root, PROJECT_FILES.reposRegistry);
|
|
11
|
+
return { regPath, registry: readJSON(regPath, { repos: [] }) };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// HEAD != syncedHead => stale (config.yaml code_context.staleness: head-sha).
|
|
15
|
+
function staleness(root, repo) {
|
|
16
|
+
const head = gitHead(path.resolve(root, repo.path));
|
|
17
|
+
const stale = head && repo.syncedHead && head !== repo.syncedHead;
|
|
18
|
+
return { head, stale: !!stale, unknown: !head };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function runRepo(root, { action = 'list', name, today } = {}) {
|
|
22
|
+
const { regPath, registry } = load(root);
|
|
23
|
+
if (!registry.repos.length) { warn('no repos registered (.sdlc/repos.json) — run `sdlc setup`'); return { repos: 0 }; }
|
|
24
|
+
|
|
25
|
+
if (action === 'list') {
|
|
26
|
+
log(c.bold('\nconnected repos'));
|
|
27
|
+
let staleCount = 0;
|
|
28
|
+
for (const repo of registry.repos) {
|
|
29
|
+
const { stale, unknown } = staleness(root, repo);
|
|
30
|
+
if (unknown) { warn(`${repo.name} ${c.dim(`(${repo.path})`)} — HEAD unreadable`); continue; }
|
|
31
|
+
if (stale) { staleCount++; warn(`${repo.name} ${c.dim(`(${repo.path})`)} — ${c.yellow('stale')} (HEAD moved since last pack)`); }
|
|
32
|
+
else ok(`${repo.name} ${c.dim('— fresh')}`);
|
|
33
|
+
}
|
|
34
|
+
if (staleCount) hand(`refresh with \`sdlc repo refresh${registry.repos.length > 1 ? ' <name>' : ''}\` (or \`sdlc repo refresh\` for all)`);
|
|
35
|
+
return { repos: registry.repos.length, stale: staleCount };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (action === 'refresh') {
|
|
39
|
+
const targets = name ? registry.repos.filter((r) => r.name === name) : registry.repos;
|
|
40
|
+
if (name && !targets.length) { fail(`unknown repo: ${name}`); process.exitCode = 1; return { refreshed: 0 }; }
|
|
41
|
+
let refreshed = 0;
|
|
42
|
+
for (const repo of targets) {
|
|
43
|
+
const { head, unknown } = staleness(root, repo);
|
|
44
|
+
if (unknown) { warn(`${repo.name}: HEAD unreadable — skipped`); continue; }
|
|
45
|
+
log(` ${c.bold(repo.name)}`);
|
|
46
|
+
if (packRepo(root, repo)) {
|
|
47
|
+
repo.syncedHead = head;
|
|
48
|
+
if (today) repo.lastSyncedAt = today; // always stamp when a date is supplied (the CLI passes today)
|
|
49
|
+
refreshed++;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
writeJSON(regPath, registry);
|
|
53
|
+
refreshed ? ok(`refreshed ${refreshed} repo(s)`) : info('nothing refreshed');
|
|
54
|
+
hand('regenerate the code-map in Claude Code (sdlc-connect-repos) — the pack is cached, the map is the AI step');
|
|
55
|
+
return { refreshed };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
fail(`unknown repo action: ${action} (list | refresh)`);
|
|
59
|
+
process.exitCode = 1;
|
|
60
|
+
return {};
|
|
61
|
+
}
|