yadflow 4.3.0 → 4.5.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 +63 -0
- package/README.md +4 -2
- package/bin/commands.mjs +3 -1
- package/bin/yad.mjs +80 -14
- package/cli/claims.mjs +16 -13
- package/cli/detect.mjs +2 -2
- package/cli/docs.mjs +1 -1
- package/cli/doctor.mjs +108 -3
- package/cli/epic-state.mjs +121 -24
- package/cli/epic.mjs +7 -5
- package/cli/manifest.mjs +3 -0
- package/cli/members.mjs +822 -0
- package/cli/next.mjs +21 -9
- package/cli/people.mjs +13 -3
- package/cli/platform.mjs +1 -1
- package/cli/pr-list.mjs +125 -0
- package/cli/protection.mjs +4 -4
- package/cli/recommend.mjs +301 -0
- package/cli/setup.mjs +3 -2
- package/cli/skill.mjs +263 -63
- package/cli/standup.mjs +709 -0
- package/cli/toolbox.mjs +433 -11
- package/cli/usage.mjs +18 -11
- package/cli/workspace.mjs +14 -5
- package/package.json +5 -3
- package/skills/sdlc/config.yaml +1 -1
- package/skills/sdlc/module-help.csv +1 -1
- package/skills/yad-backfill/SKILL.md +40 -7
- package/skills/yad-backfill/references/backfill.md +5 -4
- package/skills/yad-change/SKILL.md +1 -1
- package/skills/yad-checks/templates/checks/member-check.mjs +197 -0
- package/skills/yad-checks/templates/github/yad-product-checks.yml +15 -0
- package/skills/yad-checks/templates/gitlab/yad-product-checks.gitlab-ci.yml +9 -0
- package/skills/yad-connect-design/SKILL.md +16 -0
- package/skills/yad-connect-learning/SKILL.md +14 -0
- package/skills/yad-connect-repos/SKILL.md +34 -5
- package/skills/yad-connect-repos/references/code-context.md +5 -5
- package/skills/yad-connect-testing/SKILL.md +20 -0
- package/skills/yad-docs/SKILL.md +1 -1
- package/skills/yad-epic/references/state-schema.md +10 -2
- package/skills/yad-learn/SKILL.md +14 -0
- package/skills/yad-run/SKILL.md +7 -4
- package/skills/yad-run/references/run-loop.md +2 -1
- package/skills/yad-spec/SKILL.md +37 -6
- package/skills/yad-spec/references/spec-handoff.md +2 -2
- package/skills/yad-status/SKILL.md +3 -0
- package/skills/yad-test-cases/SKILL.md +20 -0
- package/skills/yad-ui/SKILL.md +36 -3
package/cli/next.mjs
CHANGED
|
@@ -15,7 +15,7 @@ import { c, log, ok, info, warn, hand, fail, readJSON, exists, emitJSON } from '
|
|
|
15
15
|
import { PROJECT_FILES, isVerifiedLedger, productConfigPath, stepAdvance } from './manifest.mjs';
|
|
16
16
|
import { printTeamHint, soloTeamHint } from './people.mjs';
|
|
17
17
|
import { OWNER_STEPS, liveOwner, readState as loadLedgerState } from './owners.mjs';
|
|
18
|
-
import { dedupeConsecutive, epicIds, isGateStep, killSwitchOn, loadAutomation, epicRel, epicRoot, loadLedger, loadSkillBindings, stepSkills, nextAction, preconditionsMet, isValidEpicId, epicLineage, typeNoun, phaseOf, stepPhase, profileSteps, lifecycleProfile, PHASES, PRODUCT_DONE, PRODUCT_EPICS, STEPS } from './epic-state.mjs';
|
|
18
|
+
import { bindingsForEpic, bindingsForProfile, dedupeConsecutive, epicIds, isGateStep, killSwitchOn, loadAutomation, epicRel, epicRoot, loadLedger, loadSkillBindings, stepSkills, nextAction, preconditionsMet, isValidEpicId, epicLineage, typeNoun, phaseOf, stepPhase, profileSteps, lifecycleProfile, PHASES, PRODUCT_DONE, PRODUCT_EPICS, STEPS } from './epic-state.mjs';
|
|
19
19
|
|
|
20
20
|
// Is solo mode on? Persisted in hub.json by setup (Phase C/D); default false. Read defensively so a
|
|
21
21
|
// missing/old hub.json never breaks the driver.
|
|
@@ -46,10 +46,16 @@ function listEpics(root) {
|
|
|
46
46
|
// `bindings` is passed in for the same reason `lin` is: every loop below renders many epics, and the
|
|
47
47
|
// project's skill bindings are ONE file for all of them. The default keeps the single-epic callers a
|
|
48
48
|
// one-liner; a loop reads the file once and hands the same object to every row.
|
|
49
|
-
|
|
49
|
+
//
|
|
50
|
+
// E51: the bindings are narrowed to THIS epic's route here, once, and `rowFor` hands that same view to
|
|
51
|
+
// the printed line. `nextAction` would narrow them itself, but `actionLine` names skills the action
|
|
52
|
+
// never carried (the Build fallback, the hand-off to the next epic) — a view built in two places is two
|
|
53
|
+
// answers for one step.
|
|
54
|
+
function epicAction(root, id, lin = epicLineage(root, id), bindings = loadSkillBindings(root)) {
|
|
50
55
|
const ledger = loadLedger(epicRoot(root, id));
|
|
51
|
-
|
|
52
|
-
|
|
56
|
+
const view = bindingsForEpic(bindings, ledger?.state);
|
|
57
|
+
const action = {
|
|
58
|
+
...nextAction(ledger, { epic: id, bindings: view }),
|
|
53
59
|
lineageKind: lin.type,
|
|
54
60
|
// Which ROUTE this epic RECORDED, carried so `phaseLine` can mark a phase that route never enters
|
|
55
61
|
// (E40). The recorded key ONLY — never matched from the chain, which is the one place in this file
|
|
@@ -64,13 +70,19 @@ const actionFor = (root, id, lin = epicLineage(root, id), bindings = loadSkillBi
|
|
|
64
70
|
// which route it is on simply gets the line it got before the short lanes existed.
|
|
65
71
|
route: lifecycleProfile(ledger?.state?.profile) ? ledger.state.profile : null,
|
|
66
72
|
};
|
|
67
|
-
};
|
|
73
|
+
return { action, view };
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const actionFor = (root, id, lin, bindings) => epicAction(root, id, lin, bindings).action;
|
|
68
77
|
|
|
69
78
|
// One `epic.md` read, both things that come out of it: the action to print and the tag to print
|
|
70
79
|
// beside it. Every printed path in this file goes through here.
|
|
80
|
+
// `bindings` on the row is the epic's own view, and every printer spreads the row AFTER its own
|
|
81
|
+
// `bindings`, so the row's wins.
|
|
71
82
|
const rowFor = (root, id, bindings = loadSkillBindings(root)) => {
|
|
72
83
|
const lin = epicLineage(root, id);
|
|
73
|
-
|
|
84
|
+
const { action, view } = epicAction(root, id, lin, bindings);
|
|
85
|
+
return { action, theme: lin.theme, owners: liveOwners(root, id), bindings: view };
|
|
74
86
|
};
|
|
75
87
|
|
|
76
88
|
// The live step owners of one epic (E47), by step. Beside the action, never in it: `yad next --json`'s
|
|
@@ -388,7 +400,7 @@ function generalNext(root, { all, headCount = null } = {}) {
|
|
|
388
400
|
if (brownfield) hand(`capture what already exists first: invoke the ${c.bold('yad-backfill')} skill`);
|
|
389
401
|
// Both name a STEP's skill, so both ask the project. `yad-backfill` above does not: waking a
|
|
390
402
|
// brownfield anchor is the engine's own promote verb, not a step on any chain.
|
|
391
|
-
if (!productId) hand(`frame the whole product first (purpose, scope, MVP, roadmap, stack): run ${c.bold('yad foundation new')}, then invoke the ${c.bold(stepSkills('foundation', bindings)[0] || 'yad-discovery')} skill ${c.dim('(optional)')}`);
|
|
403
|
+
if (!productId) hand(`frame the whole product first (purpose, scope, MVP, roadmap, stack): run ${c.bold('yad foundation new')}, then invoke the ${c.bold(stepSkills('foundation', bindingsForProfile(bindings, 'foundation'))[0] || 'yad-discovery')} skill ${c.dim('(optional)')}`);
|
|
392
404
|
hand(`start your first epic: invoke the ${c.bold(stepSkills('epic', bindings)[0] || 'yad-epic')} skill${productId ? c.dim(' (it reads the approved roadmap.md)') : ''}`);
|
|
393
405
|
return;
|
|
394
406
|
}
|
|
@@ -412,12 +424,12 @@ function generalNext(root, { all, headCount = null } = {}) {
|
|
|
412
424
|
// The grouping theme rides this list too. This is the ONE `yad next` view that shows several epics
|
|
413
425
|
// side by side, so it is where seeing which of them belong together is worth most — and leaving it
|
|
414
426
|
// off would have meant bare `yad next`, the command people run by default, never showed the tag.
|
|
415
|
-
for (const { action: a, theme: tag } of rows) {
|
|
427
|
+
for (const { action: a, theme: tag, bindings: view } of rows) {
|
|
416
428
|
const theme = tag ? ` ${c.dim(`#${tag}`)}` : '';
|
|
417
429
|
// Debt is reminded here too (E41): the roll-up is the view people run most, and a reminder that only
|
|
418
430
|
// appears once you already look at the one epic is not a reminder.
|
|
419
431
|
const owed = a.debt?.length ? ` ${c.yellow(`· ${a.debt.length} owed as debt`)}` : '';
|
|
420
|
-
log(` ${c.cyan(`${typeNoun(a.lineageKind)} ${a.epicId}`)}${theme} ${actionLine(a, { solo, verified })}${owed}`);
|
|
432
|
+
log(` ${c.cyan(`${typeNoun(a.lineageKind)} ${a.epicId}`)}${theme} ${actionLine(a, { solo, bindings: view, verified })}${owed}`);
|
|
421
433
|
}
|
|
422
434
|
// Painted per segment, not dim-wrapping a bold: `paint` closes with a full reset, so the nested
|
|
423
435
|
// form loses the dim from the first bold word to the end of the line.
|
package/cli/people.mjs
CHANGED
|
@@ -40,6 +40,7 @@ import { PROJECT_FILES, epicFiles } from './manifest.mjs';
|
|
|
40
40
|
import { epicIds, epicRoot, unlistedLedgerDirs, FOUNDATION_EPIC, FOUNDATION_DIR, ledgerPersonLogin, legacyLogins, capLimit, capSeat, smallestTeam } from './epic-state.mjs';
|
|
41
41
|
import { corruptShards, readShips } from './ledger.mjs';
|
|
42
42
|
import { isBot, loginFromEmail } from './riskmap.mjs';
|
|
43
|
+
import { gateMemberMap, hashEmail } from './members.mjs';
|
|
43
44
|
|
|
44
45
|
// yadflow's OWN gate bot on GitLab. The GitHub workflow commits as `yad-gate-sync[bot]`, which `isBot`
|
|
45
46
|
// already skips; the GitLab one commits as `yad-gate-sync` <yad-gate-sync@noreply.<host>> (the wired
|
|
@@ -313,7 +314,9 @@ export function gitHas(repoRoot, sha) {
|
|
|
313
314
|
return r.status === 0;
|
|
314
315
|
}
|
|
315
316
|
|
|
316
|
-
|
|
317
|
+
// Exported for `yad standup` (E132), which reads the same authors over its own window: `at` is the commit's
|
|
318
|
+
// exact time in epoch seconds, beside the UTC day in `ts` the count reads.
|
|
319
|
+
export function gitAuthors(dir, since) {
|
|
317
320
|
const git = (args) => spawnSync('git', args, { cwd: dir, encoding: 'utf8', maxBuffer: 1 << 30 });
|
|
318
321
|
// Every reason below is printed: the folder is built from the shared registry (E81 review 15).
|
|
319
322
|
const repoRoot = forTerminal(dir);
|
|
@@ -354,7 +357,8 @@ function gitAuthors(dir, since) {
|
|
|
354
357
|
const secs = Number(ct);
|
|
355
358
|
const ts = Number.isFinite(secs) && ct !== '' ? dayString(Math.floor(secs / 86400)) : null;
|
|
356
359
|
if (ts === null) return { unknown: `${repoRoot}: a commit carries a date git cannot express as a calendar day` };
|
|
357
|
-
|
|
360
|
+
// The email itself is never kept — only its hash, which a member file (E131) can match.
|
|
361
|
+
out.push({ ts, at: Math.floor(secs), name: String(name || '').trim(), login: loginFromEmail(email), emailHash: hashEmail(email), how: 'committed' });
|
|
358
362
|
}
|
|
359
363
|
return { events: out };
|
|
360
364
|
}
|
|
@@ -393,7 +397,11 @@ function connectedRepos(root) {
|
|
|
393
397
|
}
|
|
394
398
|
|
|
395
399
|
// Every piece of evidence, from every source, with one list of the reasons any of it is missing.
|
|
396
|
-
|
|
400
|
+
// E131: a commit whose author email a member file pairs with a login is that login's — one person, not a
|
|
401
|
+
// name and a login counted twice. `members` is that pairing (`gateMemberMap`): only proven pairs on a GitHub
|
|
402
|
+
// Product, never one two files share. It can only JOIN two keys, so it never removes anyone from the count;
|
|
403
|
+
// a member file that cannot be read adds no pairing, and the count stays as it was (over, not under).
|
|
404
|
+
export function peopleEvidence(root, { today = todayString(), aliases = new Map(), sinceDays = CAPACITY_MAX_DAYS * WALK_PAD, members = null } = {}) {
|
|
397
405
|
const led = ledgerEvidence(root, aliases);
|
|
398
406
|
const events = [...led.events];
|
|
399
407
|
const unknown = [...led.unknown];
|
|
@@ -426,6 +434,8 @@ export function peopleEvidence(root, { today = todayString(), aliases = new Map(
|
|
|
426
434
|
else events.push(...got.events);
|
|
427
435
|
}
|
|
428
436
|
}
|
|
437
|
+
const pairs = members || gateMemberMap(root);
|
|
438
|
+
if (pairs.size) for (const e of events) if (!e.login && e.emailHash && pairs.has(e.emailHash)) e.login = pairs.get(e.emailHash);
|
|
429
439
|
return { events, merges: led.merges, unknown };
|
|
430
440
|
}
|
|
431
441
|
|
package/cli/platform.mjs
CHANGED
|
@@ -67,7 +67,7 @@ export function platformReady(platform) {
|
|
|
67
67
|
// both github.com and a GitHub Enterprise server would be recorded under the wrong account. `host` is the
|
|
68
68
|
// caller's (the Product's `git_url`), else the directory's origin remote. `glab api` already resolves the
|
|
69
69
|
// host from the repo.
|
|
70
|
-
const LOGIN_RE = /^[A-Za-z0-9_][A-Za-z0-9._-]*(\[bot\])?$/; // GitLab allows a leading underscore
|
|
70
|
+
export const LOGIN_RE = /^[A-Za-z0-9_][A-Za-z0-9._-]*(\[bot\])?$/; // GitLab allows a leading underscore
|
|
71
71
|
const loginCache = new Map();
|
|
72
72
|
export function platformLogin(cwd, platform, { runner = run, env = process.env, host } = {}) {
|
|
73
73
|
if (env.YAD_PLATFORM_LOGIN === '0') return null;
|
package/cli/pr-list.mjs
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// E132 — the open and recently merged PRs/MRs of one repo, for `yad standup`. A READ, and E70's rule holds
|
|
2
|
+
// here word for word: an empty list is an answer only when the platform answered it. Anything else — no
|
|
3
|
+
// platform, reads turned off, no `gh`/`glab`, not logged in, offline, a 403 — is `{ ok: false, why }`, which
|
|
4
|
+
// the standup prints as "platform not read — <why>", never as "no PRs".
|
|
5
|
+
//
|
|
6
|
+
// One call per list, never one per PR: GitHub's GraphQL answers the open PRs with their review decision
|
|
7
|
+
// and check state, and the merged ones, in one query; GitLab's MR list carries `detailed_merge_status`.
|
|
8
|
+
// A page is 100. A FULL page proves nothing about what is past it, so it is said ("more than 100 …, not
|
|
9
|
+
// all read"), never read as the whole list.
|
|
10
|
+
//
|
|
11
|
+
// Facts only: the number, the author's login, draft or not, the check state and the review state the
|
|
12
|
+
// platform gives. Never a title, a body, a branch name or a comment (no free text — `yad usage`'s rule).
|
|
13
|
+
// `runner` is injectable so every answer is testable without a network (`YAD_PLATFORM_READ=0` in tests).
|
|
14
|
+
import { run } from './lib.mjs';
|
|
15
|
+
import { cliFor, detectPlatform, hostFromGitUrl, plainHost } from './platform.mjs';
|
|
16
|
+
import { api, httpStatus, loggedIn, repoPathFromGitUrl, shown, whyFailed, PAGE, TIMEOUT } from './protection.mjs';
|
|
17
|
+
|
|
18
|
+
// The check state, one word: GitHub's rollup, or what GitLab's merge status says about the pipeline.
|
|
19
|
+
const GH_CHECKS = { SUCCESS: 'passing', FAILURE: 'failing', ERROR: 'failing', PENDING: 'running', EXPECTED: 'running' };
|
|
20
|
+
|
|
21
|
+
const GH_QUERY = `query($owner:String!,$name:String!){repository(owner:$owner,name:$name){
|
|
22
|
+
open:pullRequests(states:OPEN,first:${PAGE},orderBy:{field:CREATED_AT,direction:DESC}){totalCount nodes{number isDraft author{__typename login} reviewDecision reviews{totalCount} latestOpinionatedReviews(first:100){nodes{state}} commits(last:1){nodes{commit{statusCheckRollup{state}}}}}}
|
|
23
|
+
merged:pullRequests(states:MERGED,first:${PAGE},orderBy:{field:UPDATED_AT,direction:DESC}){nodes{number mergedAt updatedAt author{__typename login}}}}}`;
|
|
24
|
+
|
|
25
|
+
// GitHub's review state, in the words the standup prints. `none` = no review of any kind yet.
|
|
26
|
+
// `reviewDecision` is null on a repo with no required-review rule — even on an approved PR — so the latest
|
|
27
|
+
// opinionated review of each reviewer decides when it is null: a change request wins, then an approval.
|
|
28
|
+
function ghReview(n) {
|
|
29
|
+
if (n.reviewDecision === 'CHANGES_REQUESTED') return 'changes-requested';
|
|
30
|
+
if (n.reviewDecision === 'APPROVED') return 'approved';
|
|
31
|
+
const states = (n.latestOpinionatedReviews?.nodes || []).map((r) => r?.state);
|
|
32
|
+
if (n.reviewDecision == null && states.includes('CHANGES_REQUESTED')) return 'changes-requested';
|
|
33
|
+
if (n.reviewDecision == null && states.includes('APPROVED')) return 'approved';
|
|
34
|
+
return (n.reviews?.totalCount || 0) > 0 ? 'reviewed' : 'none';
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// GitLab's `detailed_merge_status`. Only the two pipeline words are turned into a check state; every other
|
|
38
|
+
// status is printed as GitLab wrote it, because reading more into it would say what GitLab did not —
|
|
39
|
+
// `mergeable` on a project with no approval rule was approved by nobody. `waits` lists the statuses where
|
|
40
|
+
// the MR cannot merge until something happens: those put it under "waiting".
|
|
41
|
+
const GL_CHECKS = { ci_must_pass: 'failing', ci_still_running: 'running' };
|
|
42
|
+
export const GL_WAITS = ['ci_must_pass', 'ci_still_running', 'not_approved', 'requested_changes', 'discussions_not_resolved', 'need_rebase', 'conflict'];
|
|
43
|
+
|
|
44
|
+
const author = (a) => (a && typeof a.login === 'string' && a.__typename !== 'Bot' && !/\[bot\]$/i.test(a.login) ? a.login : null);
|
|
45
|
+
|
|
46
|
+
// Where to ask, or why yad cannot: the same preamble E70's `readProtection` runs, in the same order.
|
|
47
|
+
export function prTarget({ platform = null, gitUrl = '' } = {}, { runner = run, env = process.env, authCache = new Map() } = {}) {
|
|
48
|
+
const rawHost = hostFromGitUrl(gitUrl || '');
|
|
49
|
+
const host = plainHost(rawHost);
|
|
50
|
+
const repo = repoPathFromGitUrl(gitUrl || '');
|
|
51
|
+
const plat = platform || detectPlatform(gitUrl || '') || null;
|
|
52
|
+
const base = { platform: plat, host, repo };
|
|
53
|
+
const no = (kind, why) => ({ ...base, ok: false, kind, why });
|
|
54
|
+
const cli = cliFor(plat);
|
|
55
|
+
if (!cli) return no('no-platform', plat ? `yad does not know the platform ${JSON.stringify(shown(plat))} (it reads GitHub and GitLab)` : 'no platform (GitHub or GitLab) is known for it');
|
|
56
|
+
if (env.YAD_PLATFORM_READ === '0') return no('off', 'platform reads are turned off (YAD_PLATFORM_READ=0)');
|
|
57
|
+
if (rawHost && !host) return no('no-url', 'its git URL names a host that is not a plain host name, so yad does not ask about it');
|
|
58
|
+
if (!host || !repo) return no('no-url', 'no git URL yad can read, so it cannot tell which repo to ask about');
|
|
59
|
+
if (!runner(process.platform === 'win32' ? 'where' : 'which', [cli], {}).ok) return no('no-cli', `${cli} is not installed`);
|
|
60
|
+
if (!loggedIn(runner, cli, host, authCache)) return no('no-login', `${cli} is not logged in for ${host} (or ${host} did not answer)`);
|
|
61
|
+
return { ...base, ok: true, cli };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// The PRs/MRs of one repo: `{ ok: true, open: [...], merged: [...], openPartial, mergedPartial }` or
|
|
65
|
+
// `{ ok: false, kind, why }`. `sinceMs` bounds the merged list (epoch milliseconds).
|
|
66
|
+
// open: { number, author, draft, checks: passing|failing|running|none|null, review: none|reviewed|approved|changes-requested|null,
|
|
67
|
+
// status? (GitLab's own word), waits? (GitLab: a status that holds the merge) }
|
|
68
|
+
// merged: { number, author, mergedAt (ISO) }
|
|
69
|
+
export function listPrs(target, { sinceMs, runner = run, env = process.env, authCache = new Map() } = {}) {
|
|
70
|
+
const t = target?.cli ? target : prTarget(target, { runner, env, authCache });
|
|
71
|
+
if (!t.ok) return t;
|
|
72
|
+
return t.platform === 'github' ? listGitHub(t, sinceMs, runner) : listGitLab(t, sinceMs, runner);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function listGitHub(t, sinceMs, runner) {
|
|
76
|
+
const parts = t.repo.split('/');
|
|
77
|
+
const fail = (res) => ({ ...t, ok: false, kind: 'other', why: whyFailed(res, { platform: 'github', host: t.host, what: `the pull requests of ${shown(t.repo)}`, plural: true }) });
|
|
78
|
+
if (parts.length !== 2) return { ...t, ok: false, kind: 'no-url', why: `${shown(t.repo)} is not an owner/name GitHub repo path` };
|
|
79
|
+
const r = runner('gh', ['api', '--hostname', t.host, 'graphql', '-f', `query=${GH_QUERY}`, '-f', `owner=${parts[0]}`, '-f', `name=${parts[1]}`], { timeout: TIMEOUT });
|
|
80
|
+
// gh exits non-zero when the answer carries GraphQL `errors`: a partial answer is not an answer. Such an
|
|
81
|
+
// error comes with no HTTP status (`GraphQL: Could not resolve to a Repository …`), and is not "offline".
|
|
82
|
+
if (!r.ok && httpStatus(r) == null && /\bGraphQL:/.test(`${r.stderr || ''}${r.stdout || ''}`)) {
|
|
83
|
+
return { ...t, ok: false, kind: 'other', why: `GitHub answered the pull-request query for ${shown(t.repo)} with an error (the repo may not exist, or your login may not see it)` };
|
|
84
|
+
}
|
|
85
|
+
if (!r.ok) return fail({ ok: false, status: httpStatus(r) });
|
|
86
|
+
let body;
|
|
87
|
+
try { body = JSON.parse(r.stdout); } catch { return fail({ unreadable: true }); }
|
|
88
|
+
const repo = body?.data?.repository;
|
|
89
|
+
if (!repo || !Array.isArray(repo.open?.nodes) || !Array.isArray(repo.merged?.nodes)) return fail({ unreadable: true });
|
|
90
|
+
const open = repo.open.nodes.map((n) => ({
|
|
91
|
+
number: n.number, author: author(n.author), draft: n.isDraft === true,
|
|
92
|
+
checks: GH_CHECKS[n.commits?.nodes?.[0]?.commit?.statusCheckRollup?.state] || (n.commits?.nodes?.[0]?.commit?.statusCheckRollup ? null : 'none'),
|
|
93
|
+
review: ghReview(n),
|
|
94
|
+
}));
|
|
95
|
+
const nodes = repo.merged.nodes;
|
|
96
|
+
const merged = nodes.filter((n) => Date.parse(n.mergedAt) >= sinceMs).map((n) => ({ number: n.number, author: author(n.author), mergedAt: n.mergedAt }));
|
|
97
|
+
// Sorted by last update, newest first: the list is whole for the window once its oldest entry is older.
|
|
98
|
+
const last = nodes[nodes.length - 1];
|
|
99
|
+
return {
|
|
100
|
+
...t, ok: true, open, merged,
|
|
101
|
+
openPartial: (repo.open.totalCount ?? 0) > open.length,
|
|
102
|
+
mergedPartial: nodes.length >= PAGE && !(Date.parse(last?.updatedAt) < sinceMs),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function listGitLab(t, sinceMs, runner) {
|
|
107
|
+
const at = `projects/${encodeURIComponent(t.repo)}/merge_requests`;
|
|
108
|
+
const what = `the merge requests of ${shown(t.repo)}`;
|
|
109
|
+
const fail = (res) => ({ ...t, ok: false, kind: 'other', why: whyFailed(res, { platform: 'gitlab', host: t.host, what, plural: true }) });
|
|
110
|
+
const o = api(runner, 'glab', t.host, `${at}?state=opened&per_page=${PAGE}`);
|
|
111
|
+
if (!o.ok) return fail(o);
|
|
112
|
+
const since = new Date(sinceMs).toISOString();
|
|
113
|
+
const m = api(runner, 'glab', t.host, `${at}?state=merged&order_by=updated_at&sort=desc&updated_after=${encodeURIComponent(since)}&per_page=${PAGE}`);
|
|
114
|
+
if (!m.ok) return fail(m);
|
|
115
|
+
if (!Array.isArray(o.body) || !Array.isArray(m.body)) return fail({ unreadable: true });
|
|
116
|
+
const who = (a) => (a && typeof a.username === 'string' && a.bot !== true && !/\[bot\]$/i.test(a.username) ? a.username : null);
|
|
117
|
+
const open = o.body.map((x) => {
|
|
118
|
+
const st = typeof x.detailed_merge_status === 'string' ? x.detailed_merge_status : null;
|
|
119
|
+
const checks = st && Object.hasOwn(GL_CHECKS, st) ? GL_CHECKS[st] : null;
|
|
120
|
+
return { number: x.iid, author: who(x.author), draft: x.draft === true, checks, review: null, ...(st && !checks ? { status: st } : {}), waits: !!st && GL_WAITS.includes(st) };
|
|
121
|
+
});
|
|
122
|
+
const merged = m.body.filter((x) => Date.parse(x.merged_at) >= sinceMs).map((x) => ({ number: x.iid, author: who(x.author), mergedAt: x.merged_at }));
|
|
123
|
+
return { ...t, ok: true, open, merged, openPartial: o.body.length >= PAGE, mergedPartial: m.body.length >= PAGE };
|
|
124
|
+
}
|
|
125
|
+
|
package/cli/protection.mjs
CHANGED
|
@@ -28,7 +28,7 @@ import { SETTINGS_EDIT_HINT } from './manifest.mjs';
|
|
|
28
28
|
import { cliFor, hostFromGitUrl, detectPlatform, plainHost } from './platform.mjs';
|
|
29
29
|
|
|
30
30
|
export const PLATFORM_NAME = { github: 'GitHub', gitlab: 'GitLab' };
|
|
31
|
-
const TIMEOUT = 10_000;
|
|
31
|
+
export const TIMEOUT = 10_000;
|
|
32
32
|
|
|
33
33
|
// A word with an `@` after its first character may be an e-mail address, and no address is ever printed
|
|
34
34
|
// (E67). A branch or a repo path is printed as written otherwise.
|
|
@@ -67,7 +67,7 @@ export function httpStatus(r) {
|
|
|
67
67
|
// A failed call keeps its body too (E109): both CLIs print the platform's answer on stdout, so a caller
|
|
68
68
|
// can read WHY it failed. A body that is not JSON is null, never an error. Only the GitLab branch read
|
|
69
69
|
// looks at it today — see `gitlabBranchCause`.
|
|
70
|
-
function api(runner, cli, host, pathname) {
|
|
70
|
+
export function api(runner, cli, host, pathname) {
|
|
71
71
|
const r = runner(cli, ['api', '--hostname', host, pathname], { timeout: TIMEOUT });
|
|
72
72
|
if (r.ok) {
|
|
73
73
|
try { return { ok: true, body: JSON.parse(r.stdout) }; } catch { return { ok: false, status: null, unreadable: true }; }
|
|
@@ -96,7 +96,7 @@ const gitlabBranchCause = (res) => (res.status === 404 && typeof res.body?.messa
|
|
|
96
96
|
export const PAGE = 100;
|
|
97
97
|
|
|
98
98
|
// Why a call gave no answer, as a clause that finishes "not known — ".
|
|
99
|
-
function whyFailed(res, { platform, host, what, plural = false }) {
|
|
99
|
+
export function whyFailed(res, { platform, host, what, plural = false }) {
|
|
100
100
|
const name = PLATFORM_NAME[platform];
|
|
101
101
|
if (res.unreadable) return `${name} answered ${what} with something yad could not read`;
|
|
102
102
|
if (res.status == null) return `yad could not reach ${host} to read ${what} (offline, or the host did not answer)`;
|
|
@@ -134,7 +134,7 @@ const count = (v) => (typeof v === 'number' && Number.isInteger(v) && v >= 0 ? v
|
|
|
134
134
|
// `gh auth status` is asked once per CLI and host per doctor run (`authCache`, which the caller makes
|
|
135
135
|
// fresh for each run): on a network that drops packets each ask can take the full timeout, and a Product
|
|
136
136
|
// with many repos on one host would pay it once per repo. Never kept longer — a login can change.
|
|
137
|
-
function loggedIn(runner, cli, host, authCache) {
|
|
137
|
+
export function loggedIn(runner, cli, host, authCache) {
|
|
138
138
|
const key = `${cli}\0${host}`;
|
|
139
139
|
if (!authCache.has(key)) authCache.set(key, runner(cli, ['auth', 'status', '--hostname', host], { timeout: TIMEOUT }).ok);
|
|
140
140
|
return authCache.get(key);
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
// The recommendation catalogue — a short, hand-picked list of skills worth binding to each step (E52).
|
|
2
|
+
//
|
|
3
|
+
// yad skill recommend [<step>] [--profile <p> | --epic <id>] [--json]
|
|
4
|
+
//
|
|
5
|
+
// WHAT IT IS FOR. The toolbox lists three skill pools (BMAD-METHOD, ECC, mattpocock/skills) as
|
|
6
|
+
// "recommended — listed, never pushed". A pool holds dozens of skills, and an agent with three pools
|
|
7
|
+
// installed chooses between hundreds of descriptions. This list narrows that to a few named skills per
|
|
8
|
+
// step, each with the reason it fits. It recommends only: `yad skill bind` stays the one writer, and
|
|
9
|
+
// nothing is ever bound for you (E84: listed, never pushed; E51: a step's line is always a team's choice).
|
|
10
|
+
//
|
|
11
|
+
// A CHAIN, NEVER A REPLACEMENT. yadflow's own skill for a step does work no pool skill knows about — it
|
|
12
|
+
// writes the artifact where the gate looks, locks the contract, seeds the ledger. So every suggestion is
|
|
13
|
+
// the pool skill FIRST and yadflow's skill LAST: `yad skill bind architecture <pool-skill> yad-architecture`.
|
|
14
|
+
// A chain hands each skill what the one before it produced, and the last output is the artifact (closed
|
|
15
|
+
// decision 7), so the pool skill shapes the thinking and yadflow's skill files the result.
|
|
16
|
+
//
|
|
17
|
+
// VERSIONED SEPARATELY (decided with the row). The list carries its own `version` and `checked` day, apart
|
|
18
|
+
// from yadflow's package version, and every command that shows it prints its age. It still ships inside
|
|
19
|
+
// yadflow and moves with a release: no network, no cache, no project file, so no shape change. A test
|
|
20
|
+
// holds each version to one content: changing an entry without moving `version` fails.
|
|
21
|
+
//
|
|
22
|
+
// EVERY ENTRY IS VETTED, THE E88 WAY. Each one carries the same `vetted` record as a toolbox tool —
|
|
23
|
+
// where the licence was read, the official source, the release tag on that day — plus `file`, the exact
|
|
24
|
+
// SKILL.md at that tag, so the name was read from the real repository and never guessed from a pool's
|
|
25
|
+
// naming habit. The release check refuses to publish while any record is older than VET_MAX_AGE_DAYS
|
|
26
|
+
// (`scripts/vet-check.mjs`), exactly as for the toolbox.
|
|
27
|
+
//
|
|
28
|
+
// NOT THE ORCHESTRATOR. Part 7's separate package (E92–E102) ranks skills from measured evidence. This is
|
|
29
|
+
// the hand-made list that comes first: a person read each skill and wrote down why it fits.
|
|
30
|
+
import { createHash } from 'node:crypto';
|
|
31
|
+
import { c, emitJSON, fail, hand, info, log, warn } from './lib.mjs';
|
|
32
|
+
import { bindingsForProfile, loadSkillBindings, stepDef, stepSkills, STEPS } from './epic-state.mjs';
|
|
33
|
+
import { TOOLBOX, isCalendarDay, vettingProblems } from './toolbox.mjs';
|
|
34
|
+
|
|
35
|
+
// The list's own version and the day it was last read through as a whole. Move `version` whenever an
|
|
36
|
+
// entry is added, removed or changed — the test names the new digest to record beside it.
|
|
37
|
+
export const CATALOGUE = Object.freeze({ version: 1, checked: '2026-10-03' });
|
|
38
|
+
|
|
39
|
+
const VETTED_ON = '2026-10-03';
|
|
40
|
+
|
|
41
|
+
// ---- the list --------------------------------------------------------------------------------------
|
|
42
|
+
//
|
|
43
|
+
// step the step id it fits (a step a skill runs — never a review gate)
|
|
44
|
+
// skill the name to bind, as an agent calls it once the pool is installed
|
|
45
|
+
// pool the toolbox id of the pool it comes from (its install routes are the pool's)
|
|
46
|
+
// reason one sentence: what it adds before yadflow's own skill runs
|
|
47
|
+
// caveat null, or one sentence a team must know before binding it (an install profile, a file it writes)
|
|
48
|
+
// file the skill's SKILL.md in the pool's repository, at `vetted.release`
|
|
49
|
+
// licence + vetted: the E88 record, read for this entry
|
|
50
|
+
// The vetting record each pool's skills share, read on VETTED_ON at the pool's release tag: the licence
|
|
51
|
+
// from its LICENSE file, the source, and the npm package its installer is. Every SKILL.md below was
|
|
52
|
+
// fetched at that tag and its `name` read; none sets `disable-model-invocation`, so an agent can run each
|
|
53
|
+
// one as a step of a chain. Deprecated BMAD names that only forward to a newer skill (`bmad-create-prd`,
|
|
54
|
+
// `bmad-create-architecture`, `bmad-dev-story` — removed in BMAD v7) are left out on purpose.
|
|
55
|
+
const BMAD = Object.freeze({
|
|
56
|
+
on: VETTED_ON, licenceFrom: 'https://github.com/bmad-code-org/BMAD-METHOD/blob/main/LICENSE',
|
|
57
|
+
source: 'https://github.com/bmad-code-org/BMAD-METHOD', release: 'v6.12.0',
|
|
58
|
+
packages: [{ registry: 'npm', name: 'bmad-method', version: '6.12.0' }],
|
|
59
|
+
note: "GitHub reads the LICENSE as NOASSERTION: it is MIT text with a trademark notice added. Named as `npx bmad-method install` copies it (a plugin install calls it <plugin>:<name>).",
|
|
60
|
+
});
|
|
61
|
+
const ECC = Object.freeze({
|
|
62
|
+
on: VETTED_ON, licenceFrom: 'https://github.com/affaan-m/ECC/blob/main/LICENSE',
|
|
63
|
+
source: 'https://github.com/affaan-m/ECC', release: 'v2.2.3',
|
|
64
|
+
packages: [{ registry: 'npm', name: 'ecc-universal', version: '2.2.3' }],
|
|
65
|
+
note: 'Named as `npx ecc-universal install` copies it; the `ecc@ecc` plugin calls it ecc:<name>.',
|
|
66
|
+
});
|
|
67
|
+
const MATT = Object.freeze({
|
|
68
|
+
on: VETTED_ON, licenceFrom: 'https://github.com/mattpocock/skills/blob/main/LICENSE',
|
|
69
|
+
source: 'https://github.com/mattpocock/skills', release: 'v1.2.3',
|
|
70
|
+
packages: [],
|
|
71
|
+
note: 'Named as the mattpocock-skills plugin calls it (<plugin>:<name>): a plugin is the one install route the toolbox lists for this pool.',
|
|
72
|
+
});
|
|
73
|
+
const B = 'https://github.com/bmad-code-org/BMAD-METHOD/blob/v6.12.0/src';
|
|
74
|
+
const E = 'https://github.com/affaan-m/ECC/blob/v2.2.3/skills';
|
|
75
|
+
const M = 'https://github.com/mattpocock/skills/blob/v1.2.3/skills';
|
|
76
|
+
const ECC_PROFILE = (p) => `Not in ECC's smaller install profiles: install with --profile ${p}, or use the ecc@ecc plugin.`;
|
|
77
|
+
|
|
78
|
+
export const RECOMMENDATIONS = Object.freeze([
|
|
79
|
+
// ---- foundation: the product's purpose, scope and principles ----
|
|
80
|
+
{ step: 'foundation', skill: 'bmad-prfaq', pool: 'bmad-method', licence: 'MIT', file: `${B}/bmm-skills/plan/bmad-prfaq/SKILL.md`, vetted: BMAD, caveat: null,
|
|
81
|
+
reason: 'Works backwards from a press release and its hard questions, a sharp test of the product\'s purpose before the Foundation fixes it.' },
|
|
82
|
+
{ step: 'foundation', skill: 'bmad-brainstorming', pool: 'bmad-method', licence: 'MIT', file: `${B}/core-skills/bmad-brainstorming/SKILL.md`, vetted: BMAD, caveat: null,
|
|
83
|
+
reason: 'Guided ideation with several techniques, so the options are wide before the principles are narrowed.' },
|
|
84
|
+
{ step: 'foundation', skill: 'council', pool: 'ecc', licence: 'MIT', file: `${E}/council/SKILL.md`, vetted: ECC, caveat: null,
|
|
85
|
+
reason: 'A four-voice structured disagreement for the go/no-go and trade-off calls a Foundation records.' },
|
|
86
|
+
|
|
87
|
+
// ---- analysis: pressure-test the idea ----
|
|
88
|
+
{ step: 'analysis', skill: 'bmad-forge-idea', pool: 'bmad-method', licence: 'MIT', file: `${B}/core-skills/bmad-forge-idea/SKILL.md`, vetted: BMAD, caveat: null,
|
|
89
|
+
reason: 'Several personas attack a half-formed idea, so the analysis starts from one that has already been tested.' },
|
|
90
|
+
{ step: 'analysis', skill: 'bmad-product-brief', pool: 'bmad-method', licence: 'MIT', file: `${B}/bmm-skills/plan/bmad-product-brief/SKILL.md`, vetted: BMAD, caveat: null,
|
|
91
|
+
reason: 'A structured product-brief template and validation pass to feed the discovery brief.' },
|
|
92
|
+
|
|
93
|
+
// ---- epic ----
|
|
94
|
+
{ step: 'epic', skill: 'bmad-prd', pool: 'bmad-method', licence: 'MIT', file: `${B}/bmm-skills/plan/bmad-prd/SKILL.md`, vetted: BMAD, caveat: null,
|
|
95
|
+
reason: 'BMAD\'s product-manager workflow for writing and validating a PRD, the closest counterpart of the epic.' },
|
|
96
|
+
{ step: 'epic', skill: 'mattpocock-skills:grilling', pool: 'mattpocock-skills', licence: 'MIT', file: `${M}/productivity/grilling/SKILL.md`, vetted: MATT, caveat: null,
|
|
97
|
+
reason: 'Questions the plan hard before the product manager writes it down.' },
|
|
98
|
+
|
|
99
|
+
// ---- architecture: the design and the locked contract ----
|
|
100
|
+
{ step: 'architecture', skill: 'bmad-architecture', pool: 'bmad-method', licence: 'MIT', file: `${B}/bmm-skills/plan/bmad-architecture/SKILL.md`, vetted: BMAD, caveat: null,
|
|
101
|
+
reason: 'Records the decisions that keep separately built parts consistent, which is this step\'s cross-repo concern.' },
|
|
102
|
+
{ step: 'architecture', skill: 'contract-first', pool: 'ecc', licence: 'MIT', file: `${E}/contract-first/SKILL.md`, vetted: ECC, caveat: ECC_PROFILE('developer'),
|
|
103
|
+
reason: 'Puts the contract in a machine-checkable form (OpenAPI, AsyncAPI, Protobuf or JSON Schema) before it is locked.' },
|
|
104
|
+
{ step: 'architecture', skill: 'architecture-decision-records', pool: 'ecc', licence: 'MIT', file: `${E}/architecture-decision-records/SKILL.md`, vetted: ECC,
|
|
105
|
+
caveat: 'It writes numbered records under docs/adr/ as well.',
|
|
106
|
+
reason: 'Captures each architecture choice as a numbered decision record with the alternatives weighed.' },
|
|
107
|
+
|
|
108
|
+
// ---- ui-design ----
|
|
109
|
+
{ step: 'ui-design', skill: 'bmad-ux', pool: 'bmad-method', licence: 'MIT', file: `${B}/bmm-skills/plan/bmad-ux/SKILL.md`, vetted: BMAD,
|
|
110
|
+
caveat: 'It writes its own DESIGN.md, the file yad-ui writes last, so review that yad-ui kept what you want.',
|
|
111
|
+
reason: 'BMAD\'s UX designer: how the product looks and how it behaves, the direct counterpart of this step.' },
|
|
112
|
+
{ step: 'ui-design', skill: 'accessibility', pool: 'ecc', licence: 'MIT', file: `${E}/accessibility/SKILL.md`, vetted: ECC, caveat: ECC_PROFILE('developer'),
|
|
113
|
+
reason: 'Adds a WCAG 2.2 AA pass for web, iOS and Android to the design.' },
|
|
114
|
+
|
|
115
|
+
// ---- stories ----
|
|
116
|
+
{ step: 'stories', skill: 'bmad-create-epics-and-stories', pool: 'bmad-method', licence: 'MIT', file: `${B}/bmm-skills/plan/bmad-create-epics-and-stories/SKILL.md`, vetted: BMAD, caveat: null,
|
|
117
|
+
reason: 'BMAD\'s own breakdown of requirements into stories, as a first draft for yad-stories to file and tag by repo.' },
|
|
118
|
+
|
|
119
|
+
// ---- test-cases ----
|
|
120
|
+
{ step: 'test-cases', skill: 'intent-driven-development', pool: 'ecc', licence: 'MIT', file: `${E}/intent-driven-development/SKILL.md`, vetted: ECC, caveat: null,
|
|
121
|
+
reason: 'Turns stories into scoped, checkable acceptance criteria with a way to verify each, the raw material of test cases.' },
|
|
122
|
+
|
|
123
|
+
// ---- implement: one small task ----
|
|
124
|
+
{ step: 'implement', skill: 'mattpocock-skills:tdd', pool: 'mattpocock-skills', licence: 'MIT', file: `${M}/engineering/tdd/SKILL.md`, vetted: MATT, caveat: null,
|
|
125
|
+
reason: 'Red-green-refactor discipline inside the task\'s small diff.' },
|
|
126
|
+
{ step: 'implement', skill: 'tdd-workflow', pool: 'ecc', licence: 'MIT', file: `${E}/tdd-workflow/SKILL.md`, vetted: ECC, caveat: null,
|
|
127
|
+
reason: 'Test-first work with a coverage target, for teams that want the number enforced as they go.' },
|
|
128
|
+
{ step: 'implement', skill: 'bmad-build', pool: 'bmad-method', licence: 'MIT', file: `${B}/bmm-skills/ship/bmad-build/SKILL.md`, vetted: BMAD,
|
|
129
|
+
caveat: 'It aims to finish a whole story, so it can push past yad-implement\'s one task and three files; yad-implement still stops it there.',
|
|
130
|
+
reason: 'BMAD\'s official implementation method, with its own review and verification steps.' },
|
|
131
|
+
|
|
132
|
+
// ---- checks ----
|
|
133
|
+
{ step: 'checks', skill: 'verification-loop', pool: 'ecc', licence: 'MIT', file: `${E}/verification-loop/SKILL.md`, vetted: ECC, caveat: null,
|
|
134
|
+
reason: 'Runs build, type check, lint, tests, a security search and a diff review locally, with a pass/fail report, before the CI gates run.' },
|
|
135
|
+
|
|
136
|
+
// ---- engineer-review: a first pass before the human review is recorded ----
|
|
137
|
+
{ step: 'engineer-review', skill: 'bmad-code-review', pool: 'bmad-method', licence: 'MIT', file: `${B}/bmm-skills/ship/bmad-code-review/SKILL.md`, vetted: BMAD, caveat: null,
|
|
138
|
+
reason: 'Several independent reviewers in parallel, then sorted findings, as a first pass before a person reviews.' },
|
|
139
|
+
{ step: 'engineer-review', skill: 'bmad-walkthrough', pool: 'bmad-method', licence: 'MIT', file: `${B}/bmm-skills/ship/bmad-walkthrough/SKILL.md`, vetted: BMAD, caveat: null,
|
|
140
|
+
reason: 'Walks the human reviewer through what the change is for, what to look at, and how to test it.' },
|
|
141
|
+
]);
|
|
142
|
+
|
|
143
|
+
// ---- checking an entry ------------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
const POOLS = () => TOOLBOX.filter((t) => t.tier === 'recommended').map((t) => t.id);
|
|
146
|
+
const bindable = (step) => !!stepDef(step)?.skill && STEPS.some((s) => s.id === step);
|
|
147
|
+
|
|
148
|
+
// Every way an entry is malformed, as sentences; [] means it is well formed. The vetting record is
|
|
149
|
+
// checked by the toolbox's own `vettingProblems`, so one rule holds both lists.
|
|
150
|
+
export function recommendationProblems(r) {
|
|
151
|
+
const out = [];
|
|
152
|
+
const need = (ok, what) => { if (!ok) out.push(what); };
|
|
153
|
+
need(typeof r?.step === 'string' && bindable(r.step), 'step is a step a skill runs (not a review gate)');
|
|
154
|
+
need(typeof r?.skill === 'string' && /^[a-z0-9][a-z0-9:_-]*$/i.test(r.skill), 'skill is a skill name');
|
|
155
|
+
need(POOLS().includes(r?.pool), `pool is one of the toolbox's recommended pools (${POOLS().join(', ')})`);
|
|
156
|
+
need(typeof r?.reason === 'string' && r.reason.length > 0 && /\.$/.test(r.reason), 'reason is one sentence ending in a full stop');
|
|
157
|
+
need(typeof r?.file === 'string' && /^https:\/\/[^/\s]+\/.+\/SKILL\.md$/.test(r.file), 'file is the https URL of the skill\'s SKILL.md');
|
|
158
|
+
need(typeof r?.licence === 'string' && r.licence.length > 0, 'licence is set');
|
|
159
|
+
need(r?.caveat === null || (typeof r?.caveat === 'string' && /\.$/.test(r.caveat)), 'caveat is null or one sentence ending in a full stop');
|
|
160
|
+
for (const p of vettingProblems(r)) out.push(p);
|
|
161
|
+
// The file must be read at the release the record names, so the name was checked at that tag.
|
|
162
|
+
if (r?.vetted?.release && typeof r?.file === 'string') need(r.file.includes(`/${r.vetted.release}/`), 'file is read at vetted.release');
|
|
163
|
+
return out;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// An entry seen through the toolbox's vetting helpers, which name a record by `id` and `name`.
|
|
167
|
+
export const asVetted = (r) => ({ ...r, id: `${r.step}:${r.skill}`, name: `${r.skill} for ${r.step}` });
|
|
168
|
+
|
|
169
|
+
// A digest of the list's content, so a test can hold each version to exactly one list.
|
|
170
|
+
export const catalogueDigest = (list = RECOMMENDATIONS) =>
|
|
171
|
+
createHash('sha256').update(JSON.stringify(list)).digest('hex').slice(0, 16);
|
|
172
|
+
|
|
173
|
+
// How many whole days old the list is on `today` (a Date), counted in UTC like every record date.
|
|
174
|
+
export function catalogueAge(today = new Date(), checked = CATALOGUE.checked) {
|
|
175
|
+
if (!isCalendarDay(checked)) return null;
|
|
176
|
+
const day = Date.UTC(today.getUTCFullYear(), today.getUTCMonth(), today.getUTCDate());
|
|
177
|
+
return Math.floor((day - Date.parse(`${checked}T00:00:00Z`)) / 86400000);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// "version 1, checked 2026-10-03 (12 days ago)" — said wherever the list is shown.
|
|
181
|
+
export function catalogueLabel(today = new Date()) {
|
|
182
|
+
const age = catalogueAge(today);
|
|
183
|
+
const ago = age === null ? '' : age <= 0 ? ' (today)' : age === 1 ? ' (1 day ago)' : ` (${age} days ago)`;
|
|
184
|
+
return `recommendation catalogue version ${CATALOGUE.version}, checked ${CATALOGUE.checked}${ago}`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// The recommendations for one step, in the list's order.
|
|
188
|
+
export const recommendationsFor = (step, list = RECOMMENDATIONS) => list.filter((r) => r.step === step);
|
|
189
|
+
|
|
190
|
+
// The chain a suggestion binds: the pool skill first, yadflow's own skill last (see the header).
|
|
191
|
+
export function suggestedChain(r) {
|
|
192
|
+
const own = stepDef(r.step)?.skill;
|
|
193
|
+
return own && own !== r.skill ? [r.skill, own] : [r.skill];
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function bindCommand(r, profile = null) {
|
|
197
|
+
return `yad skill bind ${r.step} ${suggestedChain(r).join(' ')}${profile ? ` --profile ${profile}` : ''}`;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ---- yad skill recommend -----------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
const poolName = (id) => TOOLBOX.find((t) => t.id === id)?.name ?? id;
|
|
203
|
+
|
|
204
|
+
// Is the BOUND name this catalogue pick? A team that installed a pool as a plugin binds
|
|
205
|
+
// `ecc:contract-first`, and that is the catalogue's bare `contract-first`. Only that direction: a pick the
|
|
206
|
+
// catalogue names WITH its plugin (`mattpocock-skills:tdd`) is matched exactly, because a bare `tdd` is
|
|
207
|
+
// just as likely a team's own skill (review 2) — matching it would hide the bind command from a team that
|
|
208
|
+
// never ran the pool's skill. These are the plugin spellings `installedSkillNames` also accepts — including
|
|
209
|
+
// `<plugin>@<marketplace>:<skill>`, whose marketplace part is dropped before the exact comparison.
|
|
210
|
+
const bareName = (s) => String(s).split(':').at(-1);
|
|
211
|
+
const withoutMarketplace = (s) => String(s).replace(/^([^:@]+)@[^:]+:/, '$1:');
|
|
212
|
+
export const sameSkill = (bound, pick) => withoutMarketplace(bound) === pick || (!pick.includes(':') && bareName(bound) === pick);
|
|
213
|
+
|
|
214
|
+
// One row per recommendation: what it is, whether the step already runs it, whether it is installed here.
|
|
215
|
+
// `bindings` is the view for the route shown; `names` is `installedSkillNames`' answer (null: unknown).
|
|
216
|
+
// `onRoute(step)` is false for a step the route never walks — `yad skill bind --profile` refuses those, so
|
|
217
|
+
// a command for one would be a command that fails.
|
|
218
|
+
export function recommendRows({ step = null, bindings, names, list = RECOMMENDATIONS, profile = null, onRoute = () => true }) {
|
|
219
|
+
return list.filter((r) => (!step || r.step === step) && onRoute(r.step)).map((r) => {
|
|
220
|
+
const running = stepSkills(r.step, bindings);
|
|
221
|
+
const own = stepDef(r.step)?.skill;
|
|
222
|
+
return {
|
|
223
|
+
step: r.step, skill: r.skill, pool: r.pool, reason: r.reason, caveat: r.caveat, file: r.file, licence: r.licence,
|
|
224
|
+
bound: running.some((s) => sameSkill(s, r.skill)),
|
|
225
|
+
// Bound so that yadflow's own skill does not run LAST — in its place, or before it. Allowed (the
|
|
226
|
+
// file wins), never quiet: the last output of a chain is the artifact (closed decision 7), so the
|
|
227
|
+
// pool skill's output is then filed and yadflow's skill does not write it where the gate looks.
|
|
228
|
+
// yadflow's own skill is compared exactly: yadflow ships no plugin, so it has no `<plugin>:` spelling.
|
|
229
|
+
ownSkillDropped: running.some((s) => sameSkill(s, r.skill)) && !!own && running.at(-1) !== own,
|
|
230
|
+
installed: names ? names.has(r.skill) : null,
|
|
231
|
+
bind: suggestedChain(r),
|
|
232
|
+
command: bindCommand(r, profile),
|
|
233
|
+
vetted: r.vetted,
|
|
234
|
+
};
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// The printer and --json answer of `yad skill recommend`. `yad skill` (cli/skill.mjs) works out the route
|
|
239
|
+
// and asks `yad detect` what is installed, then calls this.
|
|
240
|
+
export function showRecommendations(root, { step = null, json = false, route = { profile: null }, names = null, today = new Date(), onRoute = () => true } = {}) {
|
|
241
|
+
if (step !== null && step !== undefined && !stepDef(step)?.skill) {
|
|
242
|
+
// A review gate is a step, but no skill runs it, so nothing could be recommended for it — the same
|
|
243
|
+
// reason `yad skill bind` refuses one.
|
|
244
|
+
fail(stepDef(step) ? `\`${step}\` is a review gate — no skill runs it` : `\`${step}\` is not a step this yadflow runs`);
|
|
245
|
+
hand(`steps with a recommendation: ${[...new Set(RECOMMENDATIONS.map((r) => r.step))].join(' · ') || '(none yet)'}`);
|
|
246
|
+
process.exitCode = 1;
|
|
247
|
+
return undefined;
|
|
248
|
+
}
|
|
249
|
+
if (step && !onRoute(step)) {
|
|
250
|
+
fail(`route \`${route.profile}\` has no \`${step}\` step — nothing would ever run a binding there`);
|
|
251
|
+
hand(`see \`yad skill recommend --profile ${route.profile}\` for the steps it walks`);
|
|
252
|
+
process.exitCode = 1;
|
|
253
|
+
return undefined;
|
|
254
|
+
}
|
|
255
|
+
const bindings = bindingsForProfile(loadSkillBindings(root), route.profile);
|
|
256
|
+
const rows = recommendRows({ step, bindings, names, profile: route.profile, onRoute });
|
|
257
|
+
const catalogue = { version: CATALOGUE.version, checked: CATALOGUE.checked, age: catalogueAge(today) };
|
|
258
|
+
|
|
259
|
+
if (json) {
|
|
260
|
+
return emitJSON({
|
|
261
|
+
ok: true, catalogue, step: step ?? null, profile: route.profile, ...(route.epic ? { epic: route.epic } : {}),
|
|
262
|
+
installedChecked: !!names, recommendations: rows,
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
info(catalogueLabel(today));
|
|
267
|
+
if (route.profile) info(`route ${c.bold(route.profile)}${route.epic ? ` (${route.epic})` : ''} — the bind commands apply to it only`);
|
|
268
|
+
if (!rows.length) {
|
|
269
|
+
info(step ? `no recommendation for ${c.bold(step)} — yadflow's own skill (${stepDef(step)?.skill ?? 'none'}) is the one to run`
|
|
270
|
+
: route.profile ? `no recommendation for a step route ${route.profile} walks` : 'the catalogue is empty');
|
|
271
|
+
return { rows };
|
|
272
|
+
}
|
|
273
|
+
let last = null;
|
|
274
|
+
for (const r of rows) {
|
|
275
|
+
if (r.step !== last) {
|
|
276
|
+
log(`\n ${c.bold(r.step)} ${c.dim(`— runs ${stepSkills(r.step, bindings).join(' → ') || '(none)'} now`)}`);
|
|
277
|
+
last = r.step;
|
|
278
|
+
}
|
|
279
|
+
const mark = r.bound ? c.green('✓') : r.installed === false ? c.yellow('?') : '•';
|
|
280
|
+
log(` ${mark} ${c.bold(r.skill)} ${c.dim(`(${poolName(r.pool)}, ${r.licence})`)}`);
|
|
281
|
+
log(` ${r.reason}`);
|
|
282
|
+
if (r.caveat) log(` ${c.yellow('note:')} ${r.caveat}`);
|
|
283
|
+
log(` ${r.bound ? c.dim('already bound to this step') : c.cyan(r.command)}`);
|
|
284
|
+
if (r.ownSkillDropped) {
|
|
285
|
+
warn(`${r.step} runs ${r.skill} without ${stepDef(r.step).skill} last — the last skill's output is the artifact, so yadflow's skill does not file it where the gate looks (\`${r.command}\` chains them)`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
log('');
|
|
289
|
+
info('each one runs BEFORE yadflow\'s own skill, which still writes the artifact — every extra skill is another model run');
|
|
290
|
+
if (rows.some((r) => r.bound)) info(`${c.green('✓')} = this step already runs it`);
|
|
291
|
+
if (!names) warn('could not check which skills are installed here — `yad detect` could not read this folder or the home folder');
|
|
292
|
+
else if (rows.some((r) => r.installed === false && !r.bound)) {
|
|
293
|
+
info(`${c.yellow('?')} = not installed here — install its pool first (\`yad toolbox list\` shows how)`);
|
|
294
|
+
}
|
|
295
|
+
hand(`nothing is bound for you: run a command above to choose one, and \`yad skill unbind <step>${route.profile ? ` --profile ${route.profile}` : ''}\` to undo it`);
|
|
296
|
+
return { rows };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Exported for `yad skill list`: the skills the catalogue recommends for a step (names only). Empty for a
|
|
300
|
+
// step the route never walks — `recommend` hides those too, and a bind for one is refused.
|
|
301
|
+
export const recommendedSkills = (step, onRoute = () => true) => (onRoute(step) ? recommendationsFor(step).map((r) => r.skill) : []);
|
package/cli/setup.mjs
CHANGED
|
@@ -16,7 +16,7 @@ import {
|
|
|
16
16
|
import { modeFields, modeOf } from './mode.mjs';
|
|
17
17
|
import { recordActor } from './skip.mjs';
|
|
18
18
|
import { loadSkillBindings, stepSkills } from './epic-state.mjs';
|
|
19
|
-
import { offerToolbox } from './toolbox.mjs';
|
|
19
|
+
import { offerToolbox, REPOMIX_VERSION } from './toolbox.mjs';
|
|
20
20
|
|
|
21
21
|
// Parse a comma/space separated list into a clean, deduped array of trimmed tokens.
|
|
22
22
|
export function parseList(s) {
|
|
@@ -769,7 +769,8 @@ export function packRepo(root, repo) {
|
|
|
769
769
|
fs.mkdirSync(path.dirname(out), { recursive: true });
|
|
770
770
|
ensurePackIgnored(root); // keep the pack out of git before it is (re)written — see repo-publish.mjs invariant 1
|
|
771
771
|
info(`${nm}: packing with repomix …`);
|
|
772
|
-
|
|
772
|
+
// The vetted version, never `@latest` (E88): a new Repomix release reaches users only once it is vetted.
|
|
773
|
+
const r = runLauncher('npx', [`repomix@${REPOMIX_VERSION}`, '--compress', '--include-logs', '--style', 'markdown', '-o', out], { cwd: repoRoot });
|
|
773
774
|
if (r.ok) { ok(`${nm}: cached ${forTerminal(repo.contextPack)}`); hand(`${nm}: generate the code-map in your AI agent (yad-connect-repos)`); return true; }
|
|
774
775
|
fail(`${nm}: repomix failed — ${forTerminal(r.stderr.split('\n')[0]) || 'unknown error'}`);
|
|
775
776
|
return false;
|