@bamr87/fleet-engines 0.1.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.
@@ -0,0 +1,617 @@
1
+ // Deep-facts extractor for one workflow file — the Fleet Ops "machine inspection
2
+ // report" behind the audit rules, the metrics labeler, and the cockpit UI.
3
+ // `extractFacts` reuses parseWorkflow (name/triggers/sinks/cross-repo) and then mines
4
+ // everything else the WorkflowFacts contract asks for; `classifyDashType` is an exact
5
+ // port of the dash's ordered-substring cost classifier (actions_analytics.py);
6
+ // `classifyArchetype` maps the extracted signals onto the fleet's design-pattern
7
+ // taxonomy. PURE and total: no I/O, never throws on malformed input.
8
+ //
9
+ // Like parse.ts, structure (jobs, permissions, concurrency, dispatch inputs) is read
10
+ // via the `yaml` parser when the file parses, but most signals come from raw-text
11
+ // scans: in the real bamr87 fleet they live in `env:`/`run:` blocks and agent prompt
12
+ // strings as often as in the YAML tree. Notably, the dominant AI path is a local
13
+ // composite (./.github/actions/claude-run) invisible to naive `uses:` scans, and a PR
14
+ // sink is often declared only as a prompt obligation ("open exactly ONE pull
15
+ // request") with no `gh pr create` anywhere in the file.
16
+ import { parse } from 'yaml';
17
+ import { parseWorkflow } from './parse.js';
18
+ function isDict(v) {
19
+ return v !== null && typeof v === 'object' && !Array.isArray(v);
20
+ }
21
+ /** Parse the whole document defensively; null when the YAML does not parse. */
22
+ function parseDoc(yamlText) {
23
+ try {
24
+ const parsed = parse(yamlText);
25
+ return isDict(parsed) ? parsed : null;
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ }
31
+ /** Unique first-capture-group values of a global regex, in scan order. */
32
+ function uniqueCaptures(text, re) {
33
+ const out = [];
34
+ for (const m of text.matchAll(re)) {
35
+ if (m[1] !== undefined && !out.includes(m[1]))
36
+ out.push(m[1]);
37
+ }
38
+ return out;
39
+ }
40
+ /**
41
+ * The file with comment lines (`# …`) and `name:` label lines dropped — i.e. the
42
+ * lines that actually run commands or wire steps. Used so a runner referenced only
43
+ * in prose (a `#` comment or a step's `name:`) never counts as a live AI invocation:
44
+ * agent-plan-then-act mentions `scripts/ai/run.sh` only in its header comment and a
45
+ * step title, and must read as a non-AI control-plane demo.
46
+ */
47
+ function commandLines(yamlText) {
48
+ return yamlText
49
+ .split('\n')
50
+ .filter((line) => !/^\s*#/.test(line) && !/^\s*(?:-\s+)?name:\s/.test(line))
51
+ .join('\n');
52
+ }
53
+ // ── dash type (cost-grouping) classifier ──────────────────────────────────────
54
+ // Ordered rules, matched against "<name> <path>" lowercased; the FIRST rule with any
55
+ // substring hit wins. Purpose-specific types precede the generic "ci".
56
+ const DASH_RULES = [
57
+ ['dependencies', ['dependabot', 'dependency', 'renovate', 'update-deps', 'bump ', 'deps']],
58
+ ['security', ['codeql', 'security', 'secret', 'scan', 'trivy', 'snyk', 'sast', 'audit']],
59
+ [
60
+ 'ai',
61
+ ['evolve', 'evolution', 'claude', 'agent', 'autopilot', 'quest', 'content-factory',
62
+ 'content-review', 'content-quality', 'content-auto', 'cms-', 'theme-scout', 'ai-',
63
+ 'llm', 'vally', 'skill-eval'],
64
+ ],
65
+ [
66
+ 'release',
67
+ ['release', 'publish', 'semantic', 'changelog', 'adopt-release', 'version',
68
+ 'release-please', ' tag'],
69
+ ],
70
+ [
71
+ 'deploy',
72
+ ['deploy', 'gh-pages', 'pages', 'vercel', 'netlify', 'build-dash', 'chat-proxy',
73
+ 'cd-', 'cd.'],
74
+ ],
75
+ [
76
+ 'docs',
77
+ ['docs', 'mkdocs', 'jekyll', 'link-check', 'linkcheck', 'frontmatter',
78
+ 'sync-gh-pages', 'convert-notebook'],
79
+ ],
80
+ [
81
+ 'automation',
82
+ ['auto-merge', 'automerge', 'issue', 'pr-auto', 'sync', 'dispatcher', 'cleanup',
83
+ 'stale', 'milestone', 'contributor', 'maintenance', 'refresh', 'drift',
84
+ 'standardize', 'submodule', 'giscus', 'triage', 'dispatch', 'self-repair',
85
+ 'new-feature'],
86
+ ],
87
+ [
88
+ 'ci',
89
+ ['ci', 'test', 'lint', 'build', 'check', 'validate', 'quality', 'coverage',
90
+ 'actionlint', 'shellcheck', 'matrix', 'harness', 'install'],
91
+ ],
92
+ ];
93
+ /**
94
+ * Exact port of the dash's ordered-substring workflow-type classifier: the first
95
+ * rule with any keyword contained in `"<name> <path>".toLowerCase()` wins;
96
+ * anything unmatched is 'other'. PURE.
97
+ */
98
+ export function classifyDashType(name, path) {
99
+ const hay = `${name} ${path}`.toLowerCase();
100
+ for (const [type, keys] of DASH_RULES) {
101
+ if (keys.some((k) => hay.includes(k)))
102
+ return type;
103
+ }
104
+ return 'other';
105
+ }
106
+ // ── uses: references ──────────────────────────────────────────────────────────
107
+ // Matches `uses:` at step level (`- uses: x`) and job level (reusable-workflow
108
+ // calls). Comment lines never match: `#` is not whitespace.
109
+ const USES_RE = /^\s*(?:-\s+)?uses:\s*['"]?([^\s'"#]+)/gm;
110
+ // A reusable-workflow call: owner/repo/.github/workflows/<file>[@ref].
111
+ const REUSABLE_RE = /^[\w.-]+\/[\w.-]+\/\.github\/workflows\/[^@\s]+/;
112
+ /** Classify how one `uses:` reference is pinned. */
113
+ function actionUseOf(uses) {
114
+ if (uses.startsWith('./'))
115
+ return { action: uses, ref: null, pin: 'local' };
116
+ const at = uses.indexOf('@');
117
+ if (at === -1)
118
+ return { action: uses, ref: null, pin: 'unpinned' };
119
+ const action = uses.slice(0, at);
120
+ const ref = uses.slice(at + 1);
121
+ if (/^[0-9a-f]{40}$/.test(ref))
122
+ return { action, ref, pin: 'sha' };
123
+ if (/^v?\d/.test(ref))
124
+ return { action, ref, pin: 'tag' };
125
+ return { action, ref, pin: 'branch' };
126
+ }
127
+ /**
128
+ * First-match archetype classification, ground-truthed against the real fleet.
129
+ * Name-flavored checks run against `"<name> <path>"` (not the raw text) because
130
+ * real workflows *mention* their siblings constantly — zer0's auto-merge denylists
131
+ * `release-please-config.json`, the UI audit describes the issue autopilot — and a
132
+ * raw-text match on those words would misfile them. Raw text is only consulted for
133
+ * signals that genuinely live in the body (the @claude mention, seeded-kit script
134
+ * names, dispatch plans). PURE.
135
+ */
136
+ export function classifyArchetype(f, rawText) {
137
+ const hay = `${f.name} ${f.path}`.toLowerCase();
138
+ const kinds = f.triggers.map((t) => t.kind);
139
+ const has = (k) => kinds.includes(k);
140
+ const sink = (s) => f.sinks.includes(s);
141
+ // A write to the issue tracker in any form (open, comment, or label).
142
+ const writesIssue = sink('issue') || sink('comment') || sink('label');
143
+ if (f.generated !== null)
144
+ return 'generated-line';
145
+ if (/markdown-oneline|unwrap-prose/.test(rawText) || /markdown-oneline|unwrap-prose/.test(hay)) {
146
+ return 'prose-kit';
147
+ }
148
+ if (has('issue_comment') && rawText.includes('@claude'))
149
+ return 'mention-handler';
150
+ if (!f.isReusable &&
151
+ f.reusableCalls.length > 0 &&
152
+ f.jobCount <= 2 &&
153
+ f.reusableCalls.some((c) => /ci[^/]*\.ya?ml/i.test(c.split('/').pop() ?? ''))) {
154
+ return 'standard-ci-caller';
155
+ }
156
+ if (f.isReusable)
157
+ return 'reusable-library';
158
+ if (/release-please/.test(hay) ||
159
+ /release\.ya?ml$/.test(f.path.toLowerCase()) ||
160
+ f.reusableCalls.some((c) => /release|publish/i.test(c))) {
161
+ return 'release';
162
+ }
163
+ if (/codeql|secret-scan|evidence-gate|lint-workflows|trivy|snyk/.test(hay)) {
164
+ return 'security-gate';
165
+ }
166
+ if (/dependabot|renovate|update-dep|fetch-metadata/.test(hay) ||
167
+ rawText.includes('dependabot/fetch-metadata')) {
168
+ return 'dependency-bot';
169
+ }
170
+ if (/self-repair|auto-fix/.test(hay) ||
171
+ (has('workflow_run') && (sink('commit') || f.ai.present) && f.guards.includes('attempt-limit'))) {
172
+ return 'self-repair';
173
+ }
174
+ // A Pages/registry publisher, even when it does so by merging a mirror PR
175
+ // (sync-gh-pages) — checked BEFORE auto-merge-bot so a publish isn't misread as one.
176
+ if (sink('deploy') || /gh-pages|deploy-pages|wrangler|docker[^\n]*push|\bdeploy\b/.test(hay)) {
177
+ return 'deploy';
178
+ }
179
+ if (sink('merge') && !f.ai.present)
180
+ return 'auto-merge-bot';
181
+ // The daily quest-perfection orchestrator: a scheduled AI loop that fans out over a
182
+ // runtime `fromJSON(…plan…)` matrix. Beats dispatch-hub and content-factory, which
183
+ // also see the plan matrix / the cron+AI+PR shape.
184
+ if (f.crons.length > 0 && f.ai.present && f.matrixDynamic && /fromJSON\([^)]*plan/i.test(rawText)) {
185
+ return 'perfection-loop';
186
+ }
187
+ // The agentic quest engine playing/validating content end to end.
188
+ if (f.ai.runners.includes('agentic-engine') && /agentic_validate|--mode\s+(execute|review)/.test(rawText)) {
189
+ return 'agentic-validator';
190
+ }
191
+ // The issue-queue autopilot: writes to the issue tracker AND is fired by an
192
+ // issues/label event (so a scheduled evolve loop that merely `gh issue create`s
193
+ // does not qualify — it has no issues trigger).
194
+ if (writesIssue &&
195
+ has('issues') &&
196
+ (/autopilot/.test(hay) || (f.crons.length > 0 && f.ai.present && f.hasMatrix))) {
197
+ return 'issue-autopilot';
198
+ }
199
+ // Issue-triggered AI: opens a content PR (issue-to-content) vs only labels/comments
200
+ // back on the issue (agent-gatekeeper).
201
+ if (has('issues') && f.ai.present && f.crons.length === 0 && sink('pr')) {
202
+ return 'issue-to-content';
203
+ }
204
+ if (has('issues') &&
205
+ f.ai.present &&
206
+ f.crons.length === 0 &&
207
+ (sink('comment') || sink('label')) &&
208
+ !sink('pr')) {
209
+ return 'agent-gatekeeper';
210
+ }
211
+ // A scheduled self-audit/evolve loop that opens an improvement PR.
212
+ if (f.crons.length > 0 &&
213
+ sink('pr') &&
214
+ /evolve|autonomy|nightly.*(test|autonom)/.test(hay)) {
215
+ return 'autonomy-loop';
216
+ }
217
+ if (/ai-usage|actions-usage|usage[- ]refresh/.test(hay) ||
218
+ (f.crons.length > 0 &&
219
+ (sink('commit') || sink('pr')) &&
220
+ !f.ai.present &&
221
+ /gh api[^\n]*artifacts|download-artifact[^\n]*ledger|ai-usage|actions-usage/.test(rawText))) {
222
+ return 'ledger';
223
+ }
224
+ if (/fromJSON\([^)]*plan/i.test(rawText) ||
225
+ rawText.includes('dispatch.rb') ||
226
+ (/dispatch/.test(hay) && kinds.length > 0 && kinds.every((k) => k === 'workflow_dispatch'))) {
227
+ return 'dispatch-hub';
228
+ }
229
+ if (f.crossRepoTargets.length > 0)
230
+ return 'cross-repo-filer';
231
+ if (/scout|explore/.test(hay))
232
+ return 'scout';
233
+ if (/loop-tuner|agent-review|agent-audit|devops-audit|actions-review|loop_metrics|improvements\.ya?ml/.test(hay)) {
234
+ return 'meta-loop';
235
+ }
236
+ if (f.crons.length > 0 && f.ai.present && sink('pr'))
237
+ return 'content-factory';
238
+ // A PR-only lane (never a push+PR CI pipeline): an AI editor (pr-editor) or a
239
+ // deterministic gate that only comments/labels (pr-gate).
240
+ if (has('pull_request') && !has('push') && f.ai.present && !sink('merge'))
241
+ return 'pr-editor';
242
+ if (has('pull_request') &&
243
+ !has('push') &&
244
+ !f.ai.present &&
245
+ f.sinks.every((s) => s === 'comment' || s === 'label')) {
246
+ return 'pr-gate';
247
+ }
248
+ if (f.crons.length > 0 && sink('issue'))
249
+ return 'nightly-audit';
250
+ if (!f.ai.present && /create-pull-request|sync|convert/.test(hay))
251
+ return 'data-sync';
252
+ if (has('push') &&
253
+ has('pull_request') &&
254
+ !sink('pr') &&
255
+ !sink('issue') &&
256
+ !sink('merge') &&
257
+ !sink('deploy')) {
258
+ return 'ci-gate';
259
+ }
260
+ return 'other';
261
+ }
262
+ // ── extraction ────────────────────────────────────────────────────────────────
263
+ /**
264
+ * Dormant automation: a commented-OUT `schedule:` list item — `# - cron: '…'` (the
265
+ * disable-a-cron idiom seen across the fleet). Requires the comment to be a commented
266
+ * YAML mapping entry (`#` then optional `- ` then `cron:` immediately followed by a
267
+ * quoted value), so prose like `# runs on a cron: nightly` is not misread as a schedule.
268
+ */
269
+ function dormantCronsOf(yamlText, activeCrons) {
270
+ const out = [];
271
+ for (const line of yamlText.split('\n')) {
272
+ const m = /^\s*#\s*-?\s*cron:\s*['"]([0-9*][^'"]*)['"]/.exec(line);
273
+ if (m && !activeCrons.includes(m[1]) && !out.includes(m[1]))
274
+ out.push(m[1]);
275
+ }
276
+ return out;
277
+ }
278
+ /** AI invocation shapes, models, agents, turn/cost caps, and the auth convention. */
279
+ function aiFactsOf(yamlText) {
280
+ // Runner detection reads only the command lines, so a runner named in a comment
281
+ // or a step title never counts as a live invocation (see commandLines).
282
+ const cmd = commandLines(yamlText);
283
+ const runners = [];
284
+ if (/anthropics\/claude-code-action/.test(cmd))
285
+ runners.push('claude-code-action');
286
+ if (/\bclaude\s+-p\b/.test(cmd))
287
+ runners.push('claude-cli');
288
+ if (/\.github\/actions\/claude-run/.test(cmd))
289
+ runners.push('claude-run');
290
+ if (/scripts\/ai\/run\.sh/.test(cmd))
291
+ runners.push('run-sh');
292
+ // The agentic engine — the it-journey quest driver (agentic_validate.py) or a bare
293
+ // `@anthropic-ai/claude-code` CLI invoked directly (a real `claude <flag>` command,
294
+ // never the claude-run composite). The fleet's single biggest AI cost center, and
295
+ // invisible to the four runner patterns above.
296
+ const bareClaudeCli = /(?:^|[\s;&|`(])claude\s+(?:-|["'$])/m.test(cmd);
297
+ if (/agentic_validate\.py/.test(cmd) || (/@anthropic-ai\/claude-code/.test(cmd) && bareClaudeCli)) {
298
+ runners.push('agentic-engine');
299
+ }
300
+ const models = [
301
+ ...uniqueCaptures(yamlText, /--model[= ]([\w.-]+)/g),
302
+ ...uniqueCaptures(yamlText, /(?<![_-])\bmodel:\s*['"]?([\w.-]+)/g),
303
+ ].filter((m, i, all) => m !== 'model_hint' && all.indexOf(m) === i);
304
+ const turns = uniqueCaptures(yamlText, /--max-turns[= ](\d+)/g).map(Number);
305
+ const costs = uniqueCaptures(yamlText, /--max-cost-usd[= ]([0-9.]+)/g).map(Number);
306
+ const agents = [
307
+ ...uniqueCaptures(yamlText, /\bagent:\s*['"]?([A-Za-z0-9_-]+)/g),
308
+ ...uniqueCaptures(yamlText, /--agent[= ]([A-Za-z0-9_-]+)/g),
309
+ ].filter((a, i, all) => all.indexOf(a) === i);
310
+ const hasOauth = /CLAUDE_CODE_OAUTH_TOKEN/.test(yamlText);
311
+ const hasKey = /ANTHROPIC_API_KEY/.test(yamlText);
312
+ const authMode = hasOauth && hasKey ? 'oauth-first' : hasOauth ? 'oauth-only' : hasKey ? 'api-key-only' : 'none';
313
+ return {
314
+ present: runners.length > 0,
315
+ runners,
316
+ models,
317
+ maxTurns: turns.length > 0 ? Math.max(...turns) : null,
318
+ maxCostUsd: costs.length > 0 ? Math.max(...costs) : null,
319
+ authMode,
320
+ agents,
321
+ };
322
+ }
323
+ /** Loop-safety / governance guard mechanisms, in GuardKind declaration order. */
324
+ function guardsOf(yamlText, ctx) {
325
+ const guards = [];
326
+ if (/MAX_ATTEMPTS|auto-fix-attempt|attempt.?count/i.test(yamlText))
327
+ guards.push('attempt-limit');
328
+ if (/if:[^\n]*label/i.test(yamlText))
329
+ guards.push('label-opt-in');
330
+ if (/author_association|user\.login\s*==|sender\.type|user\.type/.test(yamlText)) {
331
+ guards.push('actor-guard');
332
+ }
333
+ if (/gh run list[^\n]*--created/.test(yamlText) || /name:[^\n]*rate.?limit/i.test(yamlText)) {
334
+ guards.push('rate-limiter');
335
+ }
336
+ // A push-triggered re-run is impossible when the file either short-circuits its own
337
+ // `synchronize` event, or its `pull_request` trigger declares `types:` that OMIT
338
+ // synchronize (so the editor's own commit can never re-fire it).
339
+ const prTypesOmitSync = ctx.triggers.some((t) => t.kind === 'pull_request' && t.detail !== undefined && !t.detail.split(', ').includes('synchronize'));
340
+ if (/event\.action\s*[!=]=\s*'synchronize'/.test(yamlText) || prTypesOmitSync) {
341
+ guards.push('synchronize-skip');
342
+ }
343
+ if (/classify_changes\.(rb|py)/.test(yamlText))
344
+ guards.push('smuggle-guard');
345
+ // Idempotent issue/comment upsert: an HTML marker, a `gh issue list … --search`
346
+ // title lookup, or a title-equality upsert — all dedupe on a stable identity.
347
+ if (/<!--\s*[\w-]+\s*-->/.test(yamlText) ||
348
+ /gh issue list[^\n]*--search/.test(yamlText) ||
349
+ /test\(["'][^"']*Issues?["']/.test(yamlText) ||
350
+ /--title[^\n]*gh issue/.test(yamlText)) {
351
+ guards.push('sticky-marker');
352
+ }
353
+ if (/head_repository\.full_name\s*==|head\.repo\.full_name\s*==/.test(yamlText)) {
354
+ guards.push('same-repo-only');
355
+ }
356
+ if (/contains\([^)]*@claude/.test(yamlText))
357
+ guards.push('mention-phrase');
358
+ // A `gate` job that other jobs `needs:`, guarding the line on a `*_ENABLED`-style
359
+ // repo-variable kill switch.
360
+ if (/^\s*gate:/m.test(yamlText) && /needs:[^\n]*gate/.test(yamlText) && ctx.hasKillSwitch) {
361
+ guards.push('gate-job');
362
+ }
363
+ // A per-entity concurrency group that serializes the line (cancel-in-progress off,
364
+ // or conditional) instead of racing parallel runs.
365
+ if (ctx.concurrency.present &&
366
+ ctx.concurrency.group !== null &&
367
+ (ctx.concurrency.cancelInProgress === 'never' || ctx.concurrency.cancelInProgress === 'conditional')) {
368
+ guards.push('concurrency-singleton');
369
+ }
370
+ return guards;
371
+ }
372
+ // A pure token fallback chain: `${{ secrets.A || secrets.B || github.token }}` and
373
+ // nothing else in the expression — comparisons/ternaries (the OAuth-vs-key auth
374
+ // expression, `secrets.X != ''` probes) deliberately do not qualify.
375
+ const TOKEN_CHAIN_RE = /\$\{\{\s*((?:secrets\.[A-Za-z0-9_]+|github\.token)(?:\s*\|\|\s*(?:secrets\.[A-Za-z0-9_]+|github\.token))+)\s*\}\}/g;
376
+ /** Longest token fallback chain in the file, in privilege order. */
377
+ function tokenChainOf(yamlText) {
378
+ let longest = [];
379
+ for (const m of yamlText.matchAll(TOKEN_CHAIN_RE)) {
380
+ const names = [];
381
+ for (const part of m[1].split('||')) {
382
+ const name = part.trim();
383
+ const entry = name === 'github.token' ? 'github.token' : name.replace(/^secrets\./, '');
384
+ if (!names.includes(entry))
385
+ names.push(entry);
386
+ }
387
+ if (names.length > longest.length)
388
+ longest = names;
389
+ }
390
+ return longest;
391
+ }
392
+ const KILL_SWITCH_SUFFIXES = ['_ENABLED', '_AUTOMERGE', '_SUBSTANTIVE'];
393
+ /**
394
+ * Extract the full {@link WorkflowFacts} for one workflow file. PURE and total:
395
+ * never throws — unparseable YAML degrades to raw-text signals (jobCount 0, raw
396
+ * fallbacks for matrix/timeout/concurrency), exactly like parseWorkflow.
397
+ */
398
+ export function extractFacts(path, yamlText) {
399
+ const base = parseWorkflow(path, yamlText);
400
+ const doc = parseDoc(yamlText);
401
+ // triggers & scheduling — crons/isReusable ride parseWorkflow's trigger summaries.
402
+ const crons = [];
403
+ for (const t of base.triggers) {
404
+ if (t.kind === 'schedule' && t.detail !== undefined)
405
+ crons.push(t.detail);
406
+ }
407
+ const isReusable = base.triggers.some((t) => t.kind === 'workflow_call');
408
+ const dormantCrons = dormantCronsOf(yamlText, crons);
409
+ // uses: references (deduped, in file order).
410
+ const usesRefs = uniqueCaptures(yamlText, USES_RE);
411
+ const reusableCalls = usesRefs.filter((u) => REUSABLE_RE.test(u));
412
+ const compositeLocals = usesRefs.filter((u) => u.startsWith('./.github/actions/'));
413
+ // Same-repo reusable-workflow calls (`uses: ./.github/workflows/x.yml`) — kept
414
+ // distinct from reusableCalls (owner/repo-prefixed) and compositeLocals (actions/).
415
+ const localWorkflowCalls = usesRefs.filter((u) => /^\.\/\.github\/workflows\/[^@\s]+/.test(u));
416
+ const actions = usesRefs.map(actionUseOf);
417
+ // structure — jobs tree, defensively.
418
+ const jobsVal = doc?.jobs;
419
+ const jobs = isDict(jobsVal) ? jobsVal : null;
420
+ const jobCount = jobs ? Object.keys(jobs).length : 0;
421
+ let hasMatrix = false;
422
+ const timeouts = [];
423
+ const runners = [];
424
+ if (jobs) {
425
+ for (const j of Object.values(jobs)) {
426
+ if (!isDict(j))
427
+ continue;
428
+ const strategy = j.strategy;
429
+ if (isDict(strategy) && strategy.matrix !== undefined)
430
+ hasMatrix = true;
431
+ const timeout = j['timeout-minutes'];
432
+ if (typeof timeout === 'number')
433
+ timeouts.push(timeout);
434
+ const runsOn = j['runs-on'];
435
+ if (typeof runsOn === 'string' && !runners.includes(runsOn))
436
+ runners.push(runsOn);
437
+ else if (Array.isArray(runsOn)) {
438
+ for (const r of runsOn) {
439
+ if (typeof r === 'string' && !runners.includes(r))
440
+ runners.push(r);
441
+ }
442
+ }
443
+ }
444
+ }
445
+ if (!hasMatrix && doc === null && /\bmatrix:/.test(yamlText))
446
+ hasMatrix = true;
447
+ // A matrix computed at runtime (`matrix: ${{ fromJSON(needs.*.outputs.*) }}`) — the
448
+ // fan-out width is unknown until the upstream job runs. A static `matrix:` list is not.
449
+ const matrixDynamic = /matrix:\s*\$\{\{\s*fromJSON?\(/i.test(yamlText);
450
+ if (timeouts.length === 0) {
451
+ for (const m of yamlText.matchAll(/timeout-minutes:\s*(\d+)/g))
452
+ timeouts.push(Number(m[1]));
453
+ }
454
+ const timeoutMinutes = timeouts.length > 0 ? Math.max(...timeouts) : null;
455
+ // Wait-bound: the file spends its minutes WAITING on checks (a `--watch` or a
456
+ // `sleep` + `gh pr checks/view` poll loop), not computing — retireable with `--auto`
457
+ // + required checks.
458
+ const waitBound = /gh pr checks[^\n]*--watch/.test(yamlText) ||
459
+ (/\bsleep\b/.test(yamlText) && /gh pr (?:checks|view)/.test(yamlText));
460
+ // concurrency.
461
+ let concurrency = {
462
+ present: false,
463
+ group: null,
464
+ cancelInProgress: 'unset',
465
+ };
466
+ const cVal = doc?.concurrency;
467
+ if (cVal !== undefined) {
468
+ let group = null;
469
+ let cip;
470
+ if (typeof cVal === 'string')
471
+ group = cVal;
472
+ else if (isDict(cVal)) {
473
+ if (typeof cVal.group === 'string')
474
+ group = cVal.group;
475
+ cip = cVal['cancel-in-progress'];
476
+ }
477
+ concurrency = {
478
+ present: true,
479
+ group,
480
+ cancelInProgress: cip === true ? 'always' : cip === false ? 'never' : typeof cip === 'string' ? 'conditional' : 'unset',
481
+ };
482
+ }
483
+ else if (doc === null && /^concurrency:/m.test(yamlText)) {
484
+ concurrency = {
485
+ present: true,
486
+ group: null,
487
+ cancelInProgress: /cancel-in-progress:\s*true\b/.test(yamlText)
488
+ ? 'always'
489
+ : /cancel-in-progress:\s*false\b/.test(yamlText)
490
+ ? 'never'
491
+ : /cancel-in-progress:\s*\$\{\{/.test(yamlText)
492
+ ? 'conditional'
493
+ : 'unset',
494
+ };
495
+ }
496
+ // permissions.
497
+ const topPerms = doc?.permissions;
498
+ let declared = topPerms !== undefined;
499
+ if (!declared && jobs) {
500
+ for (const j of Object.values(jobs)) {
501
+ if (isDict(j) && j.permissions !== undefined)
502
+ declared = true;
503
+ }
504
+ }
505
+ if (!declared && doc === null && /^\s*permissions:/m.test(yamlText))
506
+ declared = true;
507
+ const topLevelRead = topPerms === 'read-all' ||
508
+ (isDict(topPerms) && Object.keys(topPerms).length === 1 && topPerms.contents === 'read');
509
+ const topLevelWrite = topPerms === 'write-all' || (isDict(topPerms) && Object.values(topPerms).includes('write'));
510
+ const writeScopes = uniqueCaptures(yamlText, /^\s*([a-z-]+):\s*write\s*$/gm);
511
+ // governance.
512
+ const varsUsed = uniqueCaptures(yamlText, /vars\.([A-Za-z0-9_]+)/g);
513
+ const killSwitches = uniqueCaptures(yamlText, /vars\.([A-Z][A-Z0-9_]*)/g).filter((v) => KILL_SWITCH_SUFFIXES.some((s) => v.endsWith(s)));
514
+ const secretsUsed = uniqueCaptures(yamlText, /secrets\.([A-Za-z0-9_]+)/g).filter((s) => s !== 'GITHUB_TOKEN');
515
+ const guards = guardsOf(yamlText, { triggers: base.triggers, concurrency, hasKillSwitch: killSwitches.length > 0 });
516
+ const tokenChain = tokenChainOf(yamlText);
517
+ // plan/apply dispatch duality: an `apply`/`dry_run` input, or a false-defaulting
518
+ // boolean dispatch toggle that opts INTO the write half of the loop (resolve,
519
+ // enable_*, full_audit, all_paths, substantive, mechanical).
520
+ const PLAN_APPLY_INPUT = /^(resolve|enable_\w+|full_audit|all_paths|substantive|mechanical)$/;
521
+ let planApply = false;
522
+ const onVal = doc ? (doc.on ?? doc['true']) : undefined;
523
+ if (isDict(onVal)) {
524
+ const wd = onVal.workflow_dispatch;
525
+ if (isDict(wd)) {
526
+ const inputs = wd.inputs;
527
+ if (isDict(inputs)) {
528
+ planApply =
529
+ 'apply' in inputs ||
530
+ 'dry_run' in inputs ||
531
+ Object.entries(inputs).some(([name, cfg]) => PLAN_APPLY_INPUT.test(name) && isDict(cfg) && cfg.type === 'boolean' && cfg.default === false);
532
+ }
533
+ }
534
+ }
535
+ if (!planApply && doc === null) {
536
+ planApply = /\b(apply|dry_run):\s*\n\s+(description|type|default)/.test(yamlText);
537
+ }
538
+ // outputs — parseWorkflow's sinks, plus the merge verb, the agent-obligation PR, and
539
+ // the sinks that live in github-script / embedded scripts (comments, filed issues).
540
+ const sinks = [...base.sinks];
541
+ if (!sinks.includes('merge') && /gh pr merge|enablePullRequestAutoMerge/.test(yamlText)) {
542
+ sinks.push('merge');
543
+ }
544
+ if (!sinks.includes('pr') &&
545
+ /open\s+(exactly\s+)?one\s+(pull\s+request|pr\b)|peter-evans\/create-pull-request/i.test(yamlText)) {
546
+ sinks.push('pr');
547
+ }
548
+ if (!sinks.includes('comment') && /issues\.createComment|createComment\(/.test(yamlText)) {
549
+ sinks.push('comment');
550
+ }
551
+ if (!sinks.includes('issue') &&
552
+ /issues\.create\b|--create-issue|gh issue (?:close|reopen)\b/.test(yamlText)) {
553
+ sinks.push('issue');
554
+ }
555
+ // Cross-repo targets, re-derived from the command lines only: a `--repo owner/name`
556
+ // that appears solely in a header comment (a one-time `gh variable set … --repo …`
557
+ // setup note) is documentation, not a real cross-repo belt.
558
+ const crossRepoTargets = uniqueCaptures(commandLines(yamlText), /--repo\s+([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+)/g);
559
+ // provenance.
560
+ let generated = null;
561
+ if (/GENERATED BY GITFACTORY/.test(yamlText)) {
562
+ const bp = /blueprint:\s*(\S+)/.exec(yamlText);
563
+ const hash = /hash:\s*([0-9a-f]+)/.exec(yamlText);
564
+ generated = { blueprintPath: bp ? bp[1] : null, hash: hash ? hash[1] : null };
565
+ }
566
+ const manualEditMarkers = yamlText.match(/MANUAL EDIT/g)?.length ?? 0;
567
+ const ai = aiFactsOf(yamlText);
568
+ const signals = {
569
+ path,
570
+ name: base.name,
571
+ triggers: base.triggers,
572
+ crons,
573
+ isReusable,
574
+ reusableCalls,
575
+ jobCount,
576
+ hasMatrix,
577
+ matrixDynamic,
578
+ sinks,
579
+ crossRepoTargets,
580
+ ai,
581
+ guards,
582
+ generated,
583
+ };
584
+ return {
585
+ path,
586
+ name: base.name,
587
+ dashType: classifyDashType(base.name, path),
588
+ archetype: classifyArchetype(signals, yamlText),
589
+ triggers: base.triggers,
590
+ crons,
591
+ dormantCrons,
592
+ isReusable,
593
+ reusableCalls,
594
+ jobCount,
595
+ hasMatrix,
596
+ matrixDynamic,
597
+ localWorkflowCalls,
598
+ waitBound,
599
+ timeoutMinutes,
600
+ runners,
601
+ concurrency,
602
+ permissions: { declared, topLevelRead, topLevelWrite, writeScopes },
603
+ actions,
604
+ compositeLocals,
605
+ ai,
606
+ killSwitches,
607
+ planApply,
608
+ guards,
609
+ tokenChain,
610
+ secretsUsed,
611
+ varsUsed,
612
+ sinks,
613
+ crossRepoTargets,
614
+ generated,
615
+ manualEditMarkers,
616
+ };
617
+ }
@@ -0,0 +1,17 @@
1
+ import type { GithubClient, RepoRef } from '../github/types.js';
2
+ import { type FleetManifest } from '../harness/lanes.js';
3
+ import type { Fleet, WorkflowFacts } from './types.js';
4
+ export declare const MANIFEST_PATH = "fleet.manifest.yml";
5
+ /** The repo's committed manifest, or null — a missing or unreadable file never fails an import. */
6
+ export declare function fetchFleetManifest(client: GithubClient, repo: RepoRef): Promise<FleetManifest | null>;
7
+ /** Pin each recorded lane to its workflow (by implementation path, else by id = basename). */
8
+ export declare function attachLanes(fleet: Fleet, manifest: FleetManifest | null): Fleet;
9
+ export declare function importFleet(client: GithubClient, repo: RepoRef): Promise<Fleet>;
10
+ /**
11
+ * Fleet Ops import: one fetch pass, both parsers — the observe graph (Fleet) plus the
12
+ * deep per-workflow facts the audit/metrics engines consume. Each file is read once.
13
+ */
14
+ export declare function importFleetDeep(client: GithubClient, repo: RepoRef): Promise<{
15
+ fleet: Fleet;
16
+ facts: WorkflowFacts[];
17
+ }>;