yadflow 3.4.1 → 3.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 +11 -1
- package/README.md +4 -1
- package/bin/yad.mjs +16 -1
- package/cli/doctor.mjs +22 -10
- package/cli/errors.mjs +1 -0
- package/cli/setup.mjs +12 -4
- package/cli/usage.mjs +389 -0
- package/package.json +1 -1
- package/skills/yad-connect-repos/references/hub-config.md +6 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,4 +1,14 @@
|
|
|
1
|
-
|
|
1
|
+
# [3.5.0](https://github.com/abdelrahmannasr/yadflow/compare/v3.4.2...v3.5.0) (2026-07-02)
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
### Bug Fixes
|
|
5
|
+
|
|
6
|
+
* **usage:** address CodeRabbit review on PR [#98](https://github.com/abdelrahmannasr/yadflow/issues/98) ([e5e64fa](https://github.com/abdelrahmannasr/yadflow/commit/e5e64fa1c19de53b553a8679e76e8304c2001073))
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
* **usage:** add derived team-member usage & behavior report ([1813026](https://github.com/abdelrahmannasr/yadflow/commit/181302613b6585b696fbf3ee132a236543c7fd63))
|
|
2
12
|
|
|
3
13
|
# [2.2.0](https://github.com/abdelrahmannasr/yadflow/compare/v2.1.0...v2.2.0) (2026-06-14)
|
|
4
14
|
|
package/README.md
CHANGED
|
@@ -83,7 +83,10 @@ surfaces blocks like a normal review comment.
|
|
|
83
83
|
|
|
84
84
|
Tech leads and engineering managers who want their team to move fast with AI **without** giving up
|
|
85
85
|
review, architectural control, or an audit trail — the governance layer around AI-assisted
|
|
86
|
-
development, not another code generator.
|
|
86
|
+
development, not another code generator. And because the audit trail *is* the repo, `yad usage` turns
|
|
87
|
+
it into a per-member **adoption & behavior report** (HTML/JSON/MD) — who authored, reviewed, approved,
|
|
88
|
+
and shipped, with factual workflow-hygiene flags — derived read-only, so an EM can see how the team
|
|
89
|
+
actually uses the flow.
|
|
87
90
|
|
|
88
91
|
## Documentation
|
|
89
92
|
|
package/bin/yad.mjs
CHANGED
|
@@ -18,6 +18,7 @@ import { runNext } from '../cli/next.mjs';
|
|
|
18
18
|
import { syncStatuses } from '../cli/artifact-status.mjs';
|
|
19
19
|
import { runThread, runReconcile } from '../cli/thread.mjs';
|
|
20
20
|
import { runReport } from '../cli/report.mjs';
|
|
21
|
+
import { runUsage } from '../cli/usage.mjs';
|
|
21
22
|
|
|
22
23
|
const HELP = `${c.bold('yad')} — setup, review-gate & build helpers for the SDLC Workflow module ${c.dim('v' + VERSION)}
|
|
23
24
|
|
|
@@ -46,6 +47,13 @@ ${c.bold('Reviewer roster')}
|
|
|
46
47
|
yad roster revoke <name> <repo> <role...> Remove role(s) for a repo
|
|
47
48
|
yad roster remove <login> Delete a member from the roster
|
|
48
49
|
|
|
50
|
+
${c.bold('Team usage (EM adoption & behavior report)')}
|
|
51
|
+
yad usage Build a per-member report (HTML) from git + the SDLC ledgers
|
|
52
|
+
(derived, read-only — writes no tracked state)
|
|
53
|
+
flags: --out <path> (default ./usage-report.html),
|
|
54
|
+
--since <YYYY-MM-DD> --until <YYYY-MM-DD> | --all,
|
|
55
|
+
--member <name>, --format html|json|md, --repos (code commits)
|
|
56
|
+
|
|
49
57
|
${c.bold('Where am I / what next')}
|
|
50
58
|
yad next Project-wide: the one next action to take (or run setup)
|
|
51
59
|
yad next <epic> The single next action for one epic (skill or yad command)
|
|
@@ -115,7 +123,7 @@ ${c.bold('Options')}
|
|
|
115
123
|
-h, --help Show this help
|
|
116
124
|
-v, --version Print version`;
|
|
117
125
|
|
|
118
|
-
const VALUE_FLAGS = new Set(['--dir', '--type', '--message', '--task', '--ai', '--risk', '--repo', '--platform', '--base', '--title', '--scope', '--branch', '--pr', '--epic', '--name', '--email', '--roles', '--team', '--body']);
|
|
126
|
+
const VALUE_FLAGS = new Set(['--dir', '--type', '--message', '--task', '--ai', '--risk', '--repo', '--platform', '--base', '--title', '--scope', '--branch', '--pr', '--epic', '--name', '--email', '--roles', '--team', '--body', '--out', '--since', '--until', '--member', '--format']);
|
|
119
127
|
|
|
120
128
|
function parseArgs(argv) {
|
|
121
129
|
const o = { _: [], dir: process.cwd(), fix: false, force: false, scope: 'all' };
|
|
@@ -141,6 +149,7 @@ function parseArgs(argv) {
|
|
|
141
149
|
else if (a === '--separate') o.separate = true;
|
|
142
150
|
else if (a === '--tools') o.tools = true;
|
|
143
151
|
else if (a === '--refresh') o.refresh = true;
|
|
152
|
+
else if (a === '--repos') o.repos = true;
|
|
144
153
|
else if (a === '--wire') o.wire = true;
|
|
145
154
|
else if (a === '--dry-run') o.dryRun = true;
|
|
146
155
|
else if (a === '--json') o.json = true;
|
|
@@ -188,6 +197,12 @@ async function main() {
|
|
|
188
197
|
case 'report':
|
|
189
198
|
await runReport(o.dir, { message: o.message });
|
|
190
199
|
break;
|
|
200
|
+
case 'usage':
|
|
201
|
+
runUsage(o.dir, {
|
|
202
|
+
out: o.out, since: o.since, until: o.until, all: o.all, member: o.member,
|
|
203
|
+
format: o.format, repos: o.repos, json: o.json, today,
|
|
204
|
+
});
|
|
205
|
+
break;
|
|
191
206
|
case 'sync-status': {
|
|
192
207
|
const [, epic] = o._;
|
|
193
208
|
if (epic && !isValidEpicId(epic)) { log(c.red(`invalid epic id: ${epic} (expected EP-<slug>, [a-z0-9-] only)`)); process.exitCode = 1; break; }
|
package/cli/doctor.mjs
CHANGED
|
@@ -76,13 +76,25 @@ export function projectChecks(checks, root) {
|
|
|
76
76
|
// platform CLI + auth (best-effort; auth probing is the user's own session)
|
|
77
77
|
const cli = cliFor(hub.platform);
|
|
78
78
|
if (cli) {
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
|
|
79
|
+
// git_url is required whenever a platform is set — doctor needs it to scope the auth probe
|
|
80
|
+
// and the bridge/PR flow needs it to open PRs. Warn on its absence directly (not on the
|
|
81
|
+
// resolved host), so it fires even when an origin remote can substitute: the field itself
|
|
82
|
+
// is required regardless.
|
|
83
|
+
if (!hostFromGitUrl(hub.git_url)) {
|
|
84
|
+
check(checks, 'hub-git-url', 'project', 'warn',
|
|
85
|
+
`${PROJECT_FILES.hubConfig} sets platform '${hub.platform}' but has no git_url [YAD-CFG-005]`,
|
|
86
|
+
'add git_url to hub.json (or re-run `yad setup`) — auth/PR checks need the hub host');
|
|
87
|
+
}
|
|
88
|
+
// Scope the auth probe to the hub's own host (derived from git_url, falling back to the
|
|
89
|
+
// origin remote). `${cli} auth status` without --hostname exits non-zero when ANY configured
|
|
90
|
+
// instance fails, so an unrelated stale login (e.g. a dead gitlab.com token) would falsely
|
|
91
|
+
// flag a working self-hosted hub — so we SKIP the probe entirely when no host resolves
|
|
92
|
+
// rather than run the flaky unscoped form.
|
|
93
|
+
const host = hostFromGitUrl(hub.git_url)
|
|
94
|
+
|| hostFromGitUrl(run('git', ['remote', 'get-url', 'origin'], { cwd: root }).stdout);
|
|
84
95
|
if (!has(cli)) check(checks, 'platform-cli', 'project', 'warn', `${cli} not found on PATH [YAD-ENV-002]`, `install ${cli} — the gate degrades to file-only without it`);
|
|
85
|
-
else if (!
|
|
96
|
+
else if (!host) check(checks, 'platform-cli', 'project', 'warn', 'auth check skipped — hub host unknown (no git_url / origin)', 'add git_url to hub.json so the auth probe can target the right host');
|
|
97
|
+
else if (!run(cli, ['auth', 'status', '--hostname', host]).ok) check(checks, 'platform-cli', 'project', 'warn', `${cli} present but not authenticated for ${host} [YAD-ENV-002]`, `run \`${cli} auth login --hostname ${host}\``);
|
|
86
98
|
else {
|
|
87
99
|
check(checks, 'platform-cli', 'project', 'ok', `${cli} present and authenticated`);
|
|
88
100
|
// Re-validate each roster login against the hub (warn-only). Skips when a login is already
|
|
@@ -99,10 +111,10 @@ export function projectChecks(checks, root) {
|
|
|
99
111
|
// probe a cheap api call (warn-only) to surface it before a sync silently holds the gate.
|
|
100
112
|
if (hub.platform === 'gitlab') {
|
|
101
113
|
// Scope the probe to the hub's own host (like the auth check above) so a multi-instance
|
|
102
|
-
// setup doesn't hit the wrong GitLab
|
|
103
|
-
|
|
104
|
-
if (!run('glab',
|
|
105
|
-
check(checks, 'gitlab-api', 'project', 'warn', `glab is authenticated but \`glab api\` failed
|
|
114
|
+
// setup doesn't hit the wrong GitLab. `host` is guaranteed truthy here (we skip the whole
|
|
115
|
+
// auth branch when it cannot be resolved), so the probe is always host-scoped.
|
|
116
|
+
if (!run('glab', ['api', 'version', '--hostname', host]).ok) {
|
|
117
|
+
check(checks, 'gitlab-api', 'project', 'warn', `glab is authenticated but \`glab api\` failed for ${host} [YAD-ENV-002]`, 'ensure the token has `api` scope — the gate reads MR approvals/discussions via the API');
|
|
106
118
|
}
|
|
107
119
|
}
|
|
108
120
|
// Solo + GitHub: a branch that "requires approvals" would block the solo dev's own merge
|
package/cli/errors.mjs
CHANGED
|
@@ -23,6 +23,7 @@ export const CODES = {
|
|
|
23
23
|
'YAD-CFG-002': 'design.json names an unknown design tool (expected one of config.yaml design.tools, or none)',
|
|
24
24
|
'YAD-CFG-003': 'testing.json names an unknown testing tool (expected one of config.yaml testing.tools, or none)',
|
|
25
25
|
'YAD-CFG-004': 'learning.json names an unknown learning tool (expected one of config.yaml learning.tools, or none)',
|
|
26
|
+
'YAD-CFG-005': 'hub.json sets a platform but is missing git_url (required to scope auth + open PRs)',
|
|
26
27
|
};
|
|
27
28
|
|
|
28
29
|
export const err = (code, message, hint) => new YadError(code, message, hint);
|
package/cli/setup.mjs
CHANGED
|
@@ -482,16 +482,24 @@ export async function runSetup(root, opts = {}) {
|
|
|
482
482
|
// `bridge_enabled` is the canonical flag (hub-config schema); keep the legacy `bridge` spelling
|
|
483
483
|
// for anything that still reads it.
|
|
484
484
|
const enabled = platform !== 'none';
|
|
485
|
-
|
|
485
|
+
// Record git_url — doctor needs it to scope the auth probe (YAD-CFG-005) and the bridge/PR flow
|
|
486
|
+
// needs it to open PRs. Derived from the origin remote already resolved above; null when local-only.
|
|
487
|
+
const git_url = enabled ? ((remote.ok && remote.stdout.trim()) || null) : null;
|
|
488
|
+
writeJSON(hubPath, { platform: enabled ? platform : null, git_url, bridge_enabled: enabled, bridge: enabled, default_branch, roster, solo, profile: { codebase, repo_layout, team_size } });
|
|
486
489
|
ok(`wrote ${PROJECT_FILES.hubConfig} (${roster.length} reviewer(s)${solo ? ', solo mode' : ''})`);
|
|
487
490
|
}
|
|
488
491
|
// Persist the profile + solo flag even on the "keeping existing" path, so re-running setup with new
|
|
489
492
|
// flags (e.g. `yad setup --solo`) updates the mode without a full reconfigure. Merge, never clobber.
|
|
493
|
+
// Also backfill a missing git_url from origin here (idempotent repair for the doctor's YAD-CFG-005).
|
|
490
494
|
if (exists(hubPath)) {
|
|
491
495
|
const cur = readJSON(hubPath, {}) || {};
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
496
|
+
const backfillUrl = (cur.platform && !cur.git_url)
|
|
497
|
+
? ((run('git', ['remote', 'get-url', 'origin'], { cwd: root }).stdout || '').trim() || null)
|
|
498
|
+
: null;
|
|
499
|
+
if (cur.solo !== solo || JSON.stringify(cur.profile || {}) !== JSON.stringify({ codebase, repo_layout, team_size }) || backfillUrl) {
|
|
500
|
+
writeJSON(hubPath, { ...cur, ...(backfillUrl ? { git_url: backfillUrl } : {}), solo, profile: { codebase, repo_layout, team_size } });
|
|
501
|
+
if (backfillUrl) info(`backfilled hub git_url from origin: ${backfillUrl}`);
|
|
502
|
+
else info(`recorded profile: ${solo ? 'solo' : `team(${team_size})`}, ${codebase}, ${repo_layout}`);
|
|
495
503
|
}
|
|
496
504
|
}
|
|
497
505
|
|
package/cli/usage.mjs
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
// `yad usage` — the team-member adoption & behavior report.
|
|
2
|
+
//
|
|
3
|
+
// DERIVED, READ-ONLY, NO NEW SOURCE OF TRUTH. This command reconstructs each roster member's audit
|
|
4
|
+
// trail entirely from data ALREADY in git — the approval/comment/ship ledgers + git authorship — and
|
|
5
|
+
// renders it as a portable report (HTML by default) to a location the caller chooses. It writes no
|
|
6
|
+
// tracked state, hooks no commands, and stores nothing that cannot be rebuilt from the repos. This
|
|
7
|
+
// mirrors how `yad-status` and `thread-resolved.md` derive their views (see docs/phase-5-build-plan.md:
|
|
8
|
+
// instrumentation is derived and read-only, never a new ledger).
|
|
9
|
+
//
|
|
10
|
+
// PRIVACY: the report is FACTUAL — names/logins, action kinds, epic IDs, integer counts, dates, and
|
|
11
|
+
// explainable hygiene flags. It NEVER emits emails, commit messages, or free-text comment bodies. It is
|
|
12
|
+
// a workflow-hygiene / adoption view for an EM, not a judgmental scorecard (see memory:
|
|
13
|
+
// no-private-data-in-reports).
|
|
14
|
+
import fs from 'node:fs';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { c, log, ok, note, readJSON, run } from './lib.mjs';
|
|
17
|
+
import { PROJECT_FILES, epicFiles } from './manifest.mjs';
|
|
18
|
+
import { rolesForScope } from './platform.mjs';
|
|
19
|
+
|
|
20
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
21
|
+
|
|
22
|
+
// Ledger read for the report. A MISSING file is normal (the epic hasn't reached that gate) and yields
|
|
23
|
+
// `def` silently; a file that EXISTS but fails to parse is surfaced to stderr and skipped — NOT thrown,
|
|
24
|
+
// since one corrupt ledger must not abort the whole derived view, but a silent under-count must never
|
|
25
|
+
// masquerade as "no activity" (an active reviewer wrongly shown dormant). Mirrors the `readJSONStrict`
|
|
26
|
+
// hazard note in lib.mjs, softened to warn-and-continue for this read-only aggregation.
|
|
27
|
+
function readLedger(p, def) {
|
|
28
|
+
if (!fs.existsSync(p)) return def;
|
|
29
|
+
try {
|
|
30
|
+
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
|
31
|
+
} catch (e) {
|
|
32
|
+
note(c.yellow(`skipped unreadable ledger ${path.basename(path.dirname(path.dirname(p)))}/${path.basename(p)}: ${e.message}`));
|
|
33
|
+
return def;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// The five kinds of workflow action we can attribute to an individual from git-tracked data.
|
|
38
|
+
export const ACTIONS = ['authored', 'commented', 'approved', 'shipped', 'committed'];
|
|
39
|
+
|
|
40
|
+
// Artifacts whose authoring commit counts as an "authored" event (a top-level epic artifact, or any
|
|
41
|
+
// story file). Ledger files under .sdlc/ are gate machinery, not authoring, and are excluded.
|
|
42
|
+
const ARTIFACT_FILES = new Set([
|
|
43
|
+
'epic.md', 'analysis.md', 'architecture.md', 'contract.md', 'ui-design.md', 'DESIGN.md', 'test-cases.md',
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
// ---- roster / attribution ----------------------------------------------------------------------
|
|
47
|
+
|
|
48
|
+
function loadRoster(root) {
|
|
49
|
+
const hub = readJSON(path.join(root, PROJECT_FILES.hubConfig), null);
|
|
50
|
+
return hub && Array.isArray(hub.roster) ? hub.roster : [];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// A member's hub-scope role label (supports both the per-scope `roles` map and the legacy flat `role`).
|
|
54
|
+
function rosterRole(m) {
|
|
55
|
+
const scoped = rolesForScope(m, 'hub');
|
|
56
|
+
if (scoped.length) return scoped.join(', ');
|
|
57
|
+
return m.role || '';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Resolve a display name / login / git-author identity to a canonical roster member (or null when the
|
|
61
|
+
// person is not in the roster — we still attribute the event to the raw name so nothing is dropped).
|
|
62
|
+
function makeResolver(roster) {
|
|
63
|
+
const byEmail = new Map();
|
|
64
|
+
const byName = new Map();
|
|
65
|
+
const byLogin = new Map();
|
|
66
|
+
for (const m of roster) {
|
|
67
|
+
if (m.email) byEmail.set(m.email.toLowerCase(), m);
|
|
68
|
+
if (m.name) byName.set(m.name, m);
|
|
69
|
+
if (m.login) byLogin.set(m.login, m);
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
// Ledgers store the roster `name` (approver/commenter) — map back to the full entry when possible.
|
|
73
|
+
byNameOrLogin: (s) => (s == null ? null : byName.get(s) || byLogin.get(s) || null),
|
|
74
|
+
// git authorship maps by commit email first (the reliable key), then by author name == roster name.
|
|
75
|
+
byGitAuthor: (name, email) =>
|
|
76
|
+
byEmail.get((email || '').toLowerCase()) || byName.get(name) || byLogin.get(name) || null,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// ---- epic enumeration --------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
function listEpics(root) {
|
|
83
|
+
const dir = path.join(root, 'epics');
|
|
84
|
+
if (!fs.existsSync(dir)) return [];
|
|
85
|
+
return fs
|
|
86
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
87
|
+
.filter((e) => e.isDirectory() && /^EP-[a-z0-9-]+$/i.test(e.name))
|
|
88
|
+
.map((e) => e.name)
|
|
89
|
+
.sort();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const inWindow = (date, since, until) => !!date && (!since || date >= since) && (!until || date <= until);
|
|
93
|
+
|
|
94
|
+
// ---- event derivation --------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
// Ledger-sourced events for one epic: approvals, comments, and ship engineer-reviews. Each carries the
|
|
97
|
+
// roster `name` already, so attribution is direct.
|
|
98
|
+
function ledgerEvents(root, epic, resolver) {
|
|
99
|
+
const f = epicFiles(path.join(root, 'epics', epic));
|
|
100
|
+
const events = [];
|
|
101
|
+
const emit = (rawName, action, date, extra = {}) => {
|
|
102
|
+
if (!rawName || !date) return;
|
|
103
|
+
const m = resolver.byNameOrLogin(rawName);
|
|
104
|
+
events.push({ ts: date, actor: m ? m.name || m.login : rawName, login: m ? m.login || null : null, rostered: !!m, action, epic, ...extra });
|
|
105
|
+
};
|
|
106
|
+
for (const a of readLedger(f.approvals, []) || []) emit(a.approver, 'approved', a.date, { artifact: a.artifact, role: a.role });
|
|
107
|
+
for (const cm of readLedger(f.comments, []) || []) emit(cm.commenter, 'commented', cm.date, { artifact: cm.artifact, role: cm.role });
|
|
108
|
+
const bl = readLedger(f.buildLog, null);
|
|
109
|
+
for (const s of bl?.ships || []) {
|
|
110
|
+
for (const er of s.engineer_review || []) emit(er.approver, 'shipped', s.shippedAt, { story: s.story, task: s.task, repo: s.repo, risk: s.risk });
|
|
111
|
+
}
|
|
112
|
+
return events;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// True for `epics/<EP>/<artifact>.md` or `epics/<EP>/stories/<file>.md`.
|
|
116
|
+
function isArtifactPath(rel) {
|
|
117
|
+
const parts = rel.split('/');
|
|
118
|
+
if (parts[0] !== 'epics' || parts.length < 3) return false;
|
|
119
|
+
if (parts[2] === 'stories') return parts.length >= 4 && parts[3].endsWith('.md');
|
|
120
|
+
return parts.length === 3 && ARTIFACT_FILES.has(parts[2]);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Parse `git log --name-only` output into a flat list of {an, ae, ad, files[]} commits. Uses \x01 as
|
|
124
|
+
// the record marker and \x00 as the field separator so author names containing spaces never confuse it.
|
|
125
|
+
function parseGitLog(stdout) {
|
|
126
|
+
const commits = [];
|
|
127
|
+
let cur = null;
|
|
128
|
+
for (const line of stdout.split('\n')) {
|
|
129
|
+
if (line.startsWith('\x01')) {
|
|
130
|
+
const [an, ae, ad] = line.slice(1).split('\x00');
|
|
131
|
+
cur = { an, ae, ad, files: [] };
|
|
132
|
+
commits.push(cur);
|
|
133
|
+
} else if (line.trim() && cur) {
|
|
134
|
+
cur.files.push(line.trim());
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return commits;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const GIT_PRETTY = '--pretty=format:\x01%an%x00%ae%x00%ad';
|
|
141
|
+
|
|
142
|
+
// git-sourced "authored" events: who committed which epic artifact, when. Degrades to [] when the hub
|
|
143
|
+
// is not a git repo (e.g. a test fixture dir), so the command never depends on git being present.
|
|
144
|
+
function gitAuthoredEvents(root, resolver) {
|
|
145
|
+
const r = run('git', ['-C', root, 'log', '--no-merges', '--date=short', GIT_PRETTY, '--name-only', '--', 'epics']);
|
|
146
|
+
if (!r.ok || !r.stdout) return [];
|
|
147
|
+
const events = [];
|
|
148
|
+
for (const cm of parseGitLog(r.stdout)) {
|
|
149
|
+
const m = resolver.byGitAuthor(cm.an, cm.ae);
|
|
150
|
+
for (const rel of cm.files) {
|
|
151
|
+
if (!isArtifactPath(rel)) continue;
|
|
152
|
+
events.push({
|
|
153
|
+
ts: cm.ad, actor: m ? m.name || m.login : cm.an, login: m ? m.login || null : null, rostered: !!m,
|
|
154
|
+
action: 'authored', epic: rel.split('/')[1], artifact: rel.split('/').pop().replace(/\.md$/, ''),
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return events;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Optional (`--repos`): code commits in each connected code repo, attributed by author → roster.
|
|
162
|
+
function repoCommitEvents(root, resolver) {
|
|
163
|
+
const reg = readJSON(path.join(root, PROJECT_FILES.reposRegistry), { repos: [] });
|
|
164
|
+
const events = [];
|
|
165
|
+
for (const repo of reg?.repos || []) {
|
|
166
|
+
if (!repo.path) continue;
|
|
167
|
+
const abs = path.isAbsolute(repo.path) ? repo.path : path.join(root, repo.path);
|
|
168
|
+
const r = run('git', ['-C', abs, 'log', '--no-merges', '--date=short', GIT_PRETTY]);
|
|
169
|
+
if (!r.ok || !r.stdout) continue;
|
|
170
|
+
for (const cm of parseGitLog(r.stdout)) {
|
|
171
|
+
const m = resolver.byGitAuthor(cm.an, cm.ae);
|
|
172
|
+
events.push({ ts: cm.ad, actor: m ? m.name || m.login : cm.an, login: m ? m.login || null : null, rostered: !!m, action: 'committed', repo: repo.name });
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
return events;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// The full, window-filtered event stream. Deterministic: sorted by (date, action, epic/repo, actor).
|
|
179
|
+
export function deriveEvents(root, resolver, { since, until, repos = false } = {}) {
|
|
180
|
+
const events = [...gitAuthoredEvents(root, resolver)];
|
|
181
|
+
for (const epic of listEpics(root)) events.push(...ledgerEvents(root, epic, resolver));
|
|
182
|
+
if (repos) events.push(...repoCommitEvents(root, resolver));
|
|
183
|
+
return events
|
|
184
|
+
.filter((e) => inWindow(e.ts, since, until))
|
|
185
|
+
.sort((a, b) =>
|
|
186
|
+
a.ts.localeCompare(b.ts) || a.action.localeCompare(b.action) ||
|
|
187
|
+
String(a.epic || a.repo || '').localeCompare(String(b.epic || b.repo || '')) || a.actor.localeCompare(b.actor));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ---- analysis ----------------------------------------------------------------------------------
|
|
191
|
+
|
|
192
|
+
const zeroCounts = () => Object.fromEntries(ACTIONS.map((a) => [a, 0]));
|
|
193
|
+
|
|
194
|
+
// Per-member rollup + explainable hygiene flags + team totals. `window` echoes the requested range.
|
|
195
|
+
export function analyze(events, roster, window = { since: null, until: null }) {
|
|
196
|
+
const members = new Map();
|
|
197
|
+
const seed = (key, name, login, role, rostered, isReviewer = false) => {
|
|
198
|
+
if (!members.has(key)) {
|
|
199
|
+
members.set(key, { key, name, login: login || null, role: role || '', rostered, isReviewer, counts: zeroCounts(), total: 0, firstActive: null, lastActive: null, epics: new Set(), timeline: [] });
|
|
200
|
+
}
|
|
201
|
+
return members.get(key);
|
|
202
|
+
};
|
|
203
|
+
// Seed every roster member first so dormant members appear at zero, not missing.
|
|
204
|
+
for (const m of roster) seed(m.login || m.name, m.name || m.login, m.login || null, rosterRole(m), true, isReviewerAnywhere(m));
|
|
205
|
+
for (const e of events) {
|
|
206
|
+
const m = seed(e.login || e.actor, e.actor, e.login, '', e.rostered);
|
|
207
|
+
m.counts[e.action] = (m.counts[e.action] || 0) + 1;
|
|
208
|
+
m.total += 1;
|
|
209
|
+
if (e.epic) m.epics.add(e.epic);
|
|
210
|
+
if (!m.firstActive || e.ts < m.firstActive) m.firstActive = e.ts;
|
|
211
|
+
if (!m.lastActive || e.ts > m.lastActive) m.lastActive = e.ts;
|
|
212
|
+
m.timeline.push({ ts: e.ts, action: e.action, epic: e.epic || null, repo: e.repo || null, artifact: e.artifact || null });
|
|
213
|
+
}
|
|
214
|
+
const list = [...members.values()].map((m) => ({
|
|
215
|
+
name: m.name, login: m.login, role: m.role, rostered: m.rostered,
|
|
216
|
+
counts: m.counts, total: m.total, firstActive: m.firstActive, lastActive: m.lastActive,
|
|
217
|
+
epics: [...m.epics].sort(), flags: memberFlags(m), timeline: m.timeline,
|
|
218
|
+
}));
|
|
219
|
+
list.sort((a, b) => a.name.localeCompare(b.name));
|
|
220
|
+
const totals = zeroCounts();
|
|
221
|
+
for (const m of list) for (const a of ACTIONS) totals[a] += m.counts[a];
|
|
222
|
+
return { window, generatedFrom: 'derived', members: list, totals };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Is this roster entry a reviewer in ANY scope? Reviewer roles are usually repo-scoped
|
|
226
|
+
// (`roles: { backend: ['reviewer'] }`), not hub-scoped, so a hub-only check would miss most of them.
|
|
227
|
+
export function isReviewerAnywhere(entry) {
|
|
228
|
+
if (!entry) return false;
|
|
229
|
+
if ((entry.role || '') === 'reviewer') return true; // legacy flat role
|
|
230
|
+
if (Array.isArray(entry.roles)) return entry.roles.includes('reviewer'); // legacy hub-scope array (rolesForScope shape)
|
|
231
|
+
return Object.values(entry.roles || {}).some((list) => Array.isArray(list) && list.includes('reviewer'));
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Factual, per-member flags — each is a plain derivation, never a score.
|
|
235
|
+
function memberFlags(m) {
|
|
236
|
+
const flags = [];
|
|
237
|
+
if (m.rostered && m.total === 0) flags.push('dormant'); // in the roster, no activity in range
|
|
238
|
+
const reviews = m.counts.commented + m.counts.approved;
|
|
239
|
+
if (m.total > 0 && m.counts.authored > 0 && reviews === 0) flags.push('no-review-participation'); // authors but never reviews
|
|
240
|
+
if (m.total > 0 && m.isReviewer && reviews === 0) flags.push('reviewer-not-reviewing'); // a reviewer (any scope) who never reviews
|
|
241
|
+
return flags;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Team-level hygiene, keyed by epic/story — a ship with no recorded engineer review is a process gap,
|
|
245
|
+
// not attributable to one person, so it lives here rather than in a member's flag list.
|
|
246
|
+
export function shipHygiene(root, { since, until } = {}) {
|
|
247
|
+
const items = [];
|
|
248
|
+
for (const epic of listEpics(root)) {
|
|
249
|
+
const bl = readLedger(epicFiles(path.join(root, 'epics', epic)).buildLog, null);
|
|
250
|
+
for (const s of bl?.ships || []) {
|
|
251
|
+
if (!inWindow(s.shippedAt, since, until)) continue;
|
|
252
|
+
if (!Array.isArray(s.engineer_review) || s.engineer_review.length === 0) {
|
|
253
|
+
items.push({ epic, story: s.story || null, task: s.task || null, repo: s.repo || null, shippedAt: s.shippedAt || null });
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return items.sort((a, b) => String(a.epic).localeCompare(String(b.epic)) || String(a.story).localeCompare(String(b.story)));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ---- rendering ---------------------------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[ch]));
|
|
263
|
+
const rangeLabel = (w) => (!w.since && !w.until ? 'all time' : `${w.since || '…'} → ${w.until || '…'}`);
|
|
264
|
+
|
|
265
|
+
// A single self-contained HTML file — inline CSS + inline SVG bars, no build step, no external assets —
|
|
266
|
+
// so the caller can drop it anywhere and open it in a browser.
|
|
267
|
+
export function renderHtml(model, today = '') {
|
|
268
|
+
const flagChip = (f) => `<span class="flag flag-${esc(f)}">${esc(f)}</span>`;
|
|
269
|
+
const bar = (m) => {
|
|
270
|
+
const max = Math.max(1, ...model.members.map((x) => x.total));
|
|
271
|
+
const w = Math.round((m.total / max) * 100);
|
|
272
|
+
return `<div class="bar"><span style="width:${w}%"></span></div>`;
|
|
273
|
+
};
|
|
274
|
+
const memberCard = (m) => `
|
|
275
|
+
<section class="member${m.total === 0 ? ' idle' : ''}">
|
|
276
|
+
<header>
|
|
277
|
+
<h3>${esc(m.name)} ${m.login ? `<span class="login">@${esc(m.login)}</span>` : ''} ${m.role ? `<span class="role">${esc(m.role)}</span>` : ''}${m.rostered ? '' : ' <span class="role">off-roster</span>'}</h3>
|
|
278
|
+
<div class="flags">${m.flags.map(flagChip).join(' ')}</div>
|
|
279
|
+
</header>
|
|
280
|
+
${bar(m)}
|
|
281
|
+
<table class="counts"><tr>${ACTIONS.map((a) => `<th>${a}</th>`).join('')}<th>total</th></tr>
|
|
282
|
+
<tr>${ACTIONS.map((a) => `<td>${m.counts[a]}</td>`).join('')}<td><b>${m.total}</b></td></tr></table>
|
|
283
|
+
<p class="meta">${m.total ? `active ${esc(m.firstActive)} → ${esc(m.lastActive)} · epics: ${m.epics.length ? esc(m.epics.join(', ')) : '—'}` : 'no activity in range'}</p>
|
|
284
|
+
${m.timeline.length ? `<details><summary>timeline (${m.timeline.length})</summary><ul class="timeline">${m.timeline
|
|
285
|
+
.map((t) => `<li><time>${esc(t.ts)}</time> <b>${esc(t.action)}</b> ${esc(t.epic || t.repo || '')}${t.artifact ? ` · ${esc(t.artifact)}` : ''}</li>`)
|
|
286
|
+
.join('')}</ul></details>` : ''}
|
|
287
|
+
</section>`;
|
|
288
|
+
const hygiene = model.hygiene?.length
|
|
289
|
+
? `<section class="hygiene"><h2>Workflow hygiene</h2><p>Ships with no recorded engineer review (process gaps, not attributed to one person):</p><ul>${model.hygiene
|
|
290
|
+
.map((h) => `<li><b>${esc(h.epic)}</b> ${esc(h.story || '')}${h.task ? `/${esc(h.task)}` : ''} ${h.repo ? `(${esc(h.repo)})` : ''} — shipped ${esc(h.shippedAt || '?')}</li>`)
|
|
291
|
+
.join('')}</ul></section>`
|
|
292
|
+
: '<section class="hygiene"><h2>Workflow hygiene</h2><p>No ship-without-review gaps in range. ✓</p></section>';
|
|
293
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
294
|
+
<title>yadflow — team usage report</title><style>
|
|
295
|
+
:root{--bg:#0f1115;--fg:#e6e8ee;--dim:#9aa0ad;--card:#181b22;--line:#262a33;--accent:#5b9dff}
|
|
296
|
+
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif}
|
|
297
|
+
.wrap{max-width:920px;margin:0 auto;padding:32px 20px}
|
|
298
|
+
h1{font-size:22px;margin:0 0 4px}.sub{color:var(--dim);margin:0 0 24px}
|
|
299
|
+
.totals{display:flex;gap:16px;flex-wrap:wrap;margin:0 0 24px;padding:14px;background:var(--card);border:1px solid var(--line);border-radius:10px}
|
|
300
|
+
.totals div{font-size:13px;color:var(--dim)}.totals b{display:block;font-size:20px;color:var(--fg)}
|
|
301
|
+
.member{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:16px;margin:0 0 14px}
|
|
302
|
+
.member.idle{opacity:.6}.member header{display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap}
|
|
303
|
+
h3{margin:0;font-size:15px}.login{color:var(--dim);font-weight:400;font-size:13px}
|
|
304
|
+
.role{color:var(--accent);font-size:11px;border:1px solid var(--line);border-radius:6px;padding:1px 6px;margin-left:4px}
|
|
305
|
+
.bar{height:6px;background:var(--line);border-radius:4px;margin:10px 0;overflow:hidden}.bar span{display:block;height:100%;background:var(--accent)}
|
|
306
|
+
table.counts{border-collapse:collapse;font-size:12px;margin:6px 0}table.counts th{color:var(--dim);text-align:left;font-weight:500;padding:2px 14px 2px 0}table.counts td{padding:2px 14px 2px 0}
|
|
307
|
+
.meta{color:var(--dim);font-size:12px;margin:6px 0 0}
|
|
308
|
+
.flags{display:flex;gap:6px;flex-wrap:wrap}.flag{font-size:11px;border-radius:6px;padding:1px 8px;background:#3a2a12;color:#ffcf7a;border:1px solid #5a3f14}
|
|
309
|
+
.flag-dormant{background:#2a2f3a;color:#9aa7bd;border-color:#39414f}
|
|
310
|
+
details{margin-top:10px}summary{cursor:pointer;color:var(--dim);font-size:12px}
|
|
311
|
+
ul.timeline{list-style:none;padding:8px 0 0;margin:0;font-size:12px}ul.timeline li{padding:2px 0;border-top:1px solid var(--line)}
|
|
312
|
+
time{color:var(--dim);font-variant-numeric:tabular-nums;margin-right:6px}
|
|
313
|
+
.hygiene{background:var(--card);border:1px solid var(--line);border-radius:10px;padding:16px;margin:24px 0 0}.hygiene h2{font-size:15px;margin:0 0 8px}.hygiene ul{margin:6px 0 0;padding-left:18px}
|
|
314
|
+
footer{color:var(--dim);font-size:12px;margin-top:28px;border-top:1px solid var(--line);padding-top:12px}
|
|
315
|
+
</style></head><body><div class="wrap">
|
|
316
|
+
<h1>Team usage & behavior report</h1>
|
|
317
|
+
<p class="sub">Range: ${esc(rangeLabel(model.window))} · ${model.members.length} member(s)${today ? ` · generated ${esc(today)}` : ''}</p>
|
|
318
|
+
<div class="totals">${ACTIONS.map((a) => `<div>${a}<b>${model.totals[a]}</b></div>`).join('')}</div>
|
|
319
|
+
${model.members.map(memberCard).join('')}
|
|
320
|
+
${hygiene}
|
|
321
|
+
<footer>Derived, read-only view — reconstructed from git history and the SDLC ledgers. Regenerate any time with <code>yad usage</code>. No emails, commit messages, or comment bodies are included.</footer>
|
|
322
|
+
</div></body></html>\n`;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// A compact Markdown variant for quick reads / pasting into a PR. Dynamic values are sanitized so a
|
|
326
|
+
// name/role/repo containing `|` or a newline can't corrupt the table or list structure.
|
|
327
|
+
export function renderMarkdown(model, today = '') {
|
|
328
|
+
const mdCell = (s) => String(s ?? '').replace(/\\/g, '\\\\').replace(/\|/g, '\\|').replace(/\r?\n/g, ' ');
|
|
329
|
+
const mdText = (s) => String(s ?? '').replace(/\r?\n/g, ' ');
|
|
330
|
+
const L = [`# Team usage & behavior report`, ``, `- Range: **${rangeLabel(model.window)}**${today ? ` · generated ${today}` : ''}`, `- Members: ${model.members.length}`, `- Totals: ${ACTIONS.map((a) => `${a} ${model.totals[a]}`).join(' · ')}`, ``, `| member | role | ${ACTIONS.join(' | ')} | total | flags |`, `|---|---|${ACTIONS.map(() => '--:').join('|')}|--:|---|`];
|
|
331
|
+
for (const m of model.members) {
|
|
332
|
+
L.push(`| ${mdCell(`${m.name}${m.login ? ` (@${m.login})` : ''}`)} | ${mdCell(m.role || '—')} | ${ACTIONS.map((a) => m.counts[a]).join(' | ')} | ${m.total} | ${mdCell(m.flags.join(', ') || '—')} |`);
|
|
333
|
+
}
|
|
334
|
+
L.push('', '## Workflow hygiene');
|
|
335
|
+
if (model.hygiene?.length) {
|
|
336
|
+
L.push('Ships with no recorded engineer review:');
|
|
337
|
+
for (const h of model.hygiene) L.push(`- **${mdText(h.epic)}** ${mdText(h.story || '')}${h.task ? `/${mdText(h.task)}` : ''} ${h.repo ? `(${mdText(h.repo)})` : ''} — shipped ${mdText(h.shippedAt || '?')}`);
|
|
338
|
+
} else {
|
|
339
|
+
L.push('No ship-without-review gaps in range. ✓');
|
|
340
|
+
}
|
|
341
|
+
L.push('', '_Derived, read-only — reconstructed from git + the SDLC ledgers; no emails/comment bodies._', '');
|
|
342
|
+
return L.join('\n');
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ---- CLI entry ---------------------------------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
export function buildModel(root, { since, until, repos = false, member } = {}) {
|
|
348
|
+
const roster = loadRoster(root);
|
|
349
|
+
const resolver = makeResolver(roster);
|
|
350
|
+
const events = deriveEvents(root, resolver, { since, until, repos });
|
|
351
|
+
const model = analyze(events, roster, { since: since || null, until: until || null });
|
|
352
|
+
model.hygiene = shipHygiene(root, { since, until });
|
|
353
|
+
if (member) {
|
|
354
|
+
model.members = model.members.filter((m) => m.name === member || m.login === member);
|
|
355
|
+
const totals = zeroCounts(); // totals track the shown members, not the whole team
|
|
356
|
+
for (const m of model.members) for (const a of ACTIONS) totals[a] += m.counts[a];
|
|
357
|
+
model.totals = totals;
|
|
358
|
+
}
|
|
359
|
+
return model;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// Ensure a report's parent directory exists, then write it (mirrors copyFile/writeJSON in lib.mjs,
|
|
363
|
+
// which always mkdir the dirname first) so `--out sub/dir/report.html` never throws a raw ENOENT.
|
|
364
|
+
function writeReport(dest, content) {
|
|
365
|
+
fs.mkdirSync(path.dirname(path.resolve(dest)), { recursive: true });
|
|
366
|
+
fs.writeFileSync(dest, content);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export function runUsage(root, { out, since, until, all, member, format = 'html', repos = false, json = false, today = '' } = {}) {
|
|
370
|
+
if (all) { since = undefined; until = undefined; }
|
|
371
|
+
// Dates compare lexically as strings, so an unpadded value (2026-6-1) silently mis-windows — warn.
|
|
372
|
+
for (const [flag, val] of [['--since', since], ['--until', until]]) {
|
|
373
|
+
if (val && !DATE_RE.test(val)) note(c.yellow(`${flag} ${val} is not YYYY-MM-DD — dates compare lexically, so a non-padded value may filter incorrectly`));
|
|
374
|
+
}
|
|
375
|
+
let fmt = json ? 'json' : format;
|
|
376
|
+
if (!['html', 'json', 'md'].includes(fmt)) { note(c.yellow(`unknown --format ${fmt} (html|json|md) — using html`)); fmt = 'html'; }
|
|
377
|
+
const model = buildModel(root, { since, until, repos, member });
|
|
378
|
+
|
|
379
|
+
if (fmt === 'json') {
|
|
380
|
+
const s = JSON.stringify(model, null, 2);
|
|
381
|
+
if (out) { writeReport(out, s + '\n'); note(`wrote JSON → ${out}`); } else { log(s); } // stdout stays pure JSON
|
|
382
|
+
return model;
|
|
383
|
+
}
|
|
384
|
+
const content = fmt === 'md' ? renderMarkdown(model, today) : renderHtml(model, today);
|
|
385
|
+
const dest = out || `usage-report.${fmt === 'md' ? 'md' : 'html'}`;
|
|
386
|
+
writeReport(dest, content);
|
|
387
|
+
ok(`wrote ${fmt.toUpperCase()} report → ${c.bold(dest)} (${model.members.length} member(s), range: ${rangeLabel(model.window)})`);
|
|
388
|
+
return model;
|
|
389
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "yadflow",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.5.0",
|
|
4
4
|
"description": "Yadflow — the gated, team, multi-repo SDLC: author → review → build with a PR-driven review gate and a zero-dependency `yad` CLI (setup, gate, commit, open-pr, ship, repo, thread, reconcile). A BMAD module + 37 yad-* skills.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"author": "AbdelRahman Nasr",
|
|
@@ -16,7 +16,7 @@ login to an SDLC name + role. It is a single object for the hub itself — the s
|
|
|
16
16
|
```json
|
|
17
17
|
{
|
|
18
18
|
"platform": "github", // github | gitlab (from the hub's own remote host); null when local-only
|
|
19
|
-
"git_url": "https://github.com/abdelrahmannasr/yadflow.git",
|
|
19
|
+
"git_url": "https://github.com/abdelrahmannasr/yadflow.git", // REQUIRED when platform is non-null (scopes auth + opens PRs); yad doctor warns YAD-CFG-005 if absent
|
|
20
20
|
"default_branch": "main",
|
|
21
21
|
"bridge_enabled": true, // open review PRs/MRs on the hub for front-half reviews
|
|
22
22
|
"review": { "requireEngagement": false }, // Review Companion: false (soft) counts bare approves but nudges; true counts only verified-engagement approvals
|
|
@@ -69,6 +69,11 @@ run `git remote get-url origin` **on the hub itself** and read the host —
|
|
|
69
69
|
Auth is the **local user's own** `gh`/`glab`/git credentials; **no tokens are ever stored** (same rule
|
|
70
70
|
as the registry). `detect-hub` upserts `hub.json` in place — it is idempotent and safe to re-run.
|
|
71
71
|
|
|
72
|
+
**`git_url` is required whenever `platform` is non-null.** `yad doctor` uses it to scope the auth
|
|
73
|
+
probe to the hub's own host (an unscoped `glab auth status` fails on any unrelated broken instance),
|
|
74
|
+
and the bridge/PR flow uses it to open PRs. Doctor flags its absence with a warn (`YAD-CFG-005`);
|
|
75
|
+
re-running `yad setup` backfills it from the origin remote (idempotent, non-interactive).
|
|
76
|
+
|
|
72
77
|
## Bridge enable / degradation
|
|
73
78
|
|
|
74
79
|
- `bridge_enabled: true` **and** a non-null `platform` **and** `gh`/`glab` authenticated → the front-half
|