@h9-foundry/agentforge-cli 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,11 +1,12 @@
1
- import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
+ import { execFileSync } from "node:child_process";
3
4
  import yaml from "js-yaml";
4
5
  import { renderAuditBundleMarkdown } from "@h9-foundry/agentforge-audit";
5
6
  import { createWorkflowState, findWorkspaceRoot } from "@h9-foundry/agentforge-context-engine";
6
7
  import { createPolicyEngine, loadPolicyDocument, resolvePolicy } from "@h9-foundry/agentforge-policy-engine";
7
8
  import { runWorkflow } from "@h9-foundry/agentforge-runtime";
8
- import { agentforgeConfigSchema, auditBundleSchema, designArtifactSchema, designRequestSchema, implementationRequestSchema, planningArtifactSchema, planningRequestSchema, qaRequestSchema, workflowDefinitionSchema } from "@h9-foundry/agentforge-schemas";
9
+ import { agentforgeConfigSchema, auditBundleSchema, designArtifactSchema, designRequestSchema, implementationRequestSchema, incidentRequestSchema, maintenanceRequestSchema, planningArtifactSchema, planningRequestSchema, qaRequestSchema, releaseRequestSchema, securityRequestSchema, workflowDefinitionSchema } from "@h9-foundry/agentforge-schemas";
9
10
  import { createBuiltinAdapters } from "./internal/builtin-adapters.js";
10
11
  import { createBuiltinAgentRegistry } from "./internal/builtin-agents.js";
11
12
  import { LocalPluginRegistry } from "./internal/local-plugin-registry.js";
@@ -181,7 +182,7 @@ nodes:
181
182
  `;
182
183
  const qaWorkflowTemplate = `version: 1
183
184
  name: qa-review
184
- description: Validate a bounded QA request and prepare it for later QA analysis stages.
185
+ description: Validate a bounded QA request and synthesize a read-only QA report.
185
186
  trigger: manual
186
187
  catalog:
187
188
  domain: test
@@ -193,6 +194,118 @@ nodes:
193
194
  kind: deterministic
194
195
  agent: qa-intake
195
196
  outputs_to: agentResults.intake
197
+ - id: evidence
198
+ kind: deterministic
199
+ agent: qa-evidence-normalizer
200
+ outputs_to: agentResults.evidence
201
+ - id: qa
202
+ kind: reasoning
203
+ agent: qa-analyst
204
+ outputs_to: agentResults.qa
205
+ - id: report
206
+ kind: report
207
+ outputs_to: reports.final
208
+ `;
209
+ const securityWorkflowTemplate = `version: 1
210
+ name: security-review
211
+ description: Validate a bounded security request while preserving the default local security posture.
212
+ trigger: manual
213
+ catalog:
214
+ domain: security
215
+ supportLevel: official
216
+ maturity: mvp
217
+ trustScope: official-core-only
218
+ nodes:
219
+ - id: intake
220
+ kind: deterministic
221
+ agent: security-intake
222
+ outputs_to: agentResults.intake
223
+ - id: evidence
224
+ kind: deterministic
225
+ agent: security-evidence-normalizer
226
+ outputs_to: agentResults.evidence
227
+ - id: security
228
+ kind: reasoning
229
+ agent: security-analyst
230
+ outputs_to: agentResults.security
231
+ - id: report
232
+ kind: report
233
+ outputs_to: reports.final
234
+ `;
235
+ const releaseWorkflowTemplate = `version: 1
236
+ name: release-readiness
237
+ description: Validate a bounded release-readiness request while keeping trusted publish automation separate.
238
+ trigger: manual
239
+ catalog:
240
+ domain: release
241
+ supportLevel: partial
242
+ maturity: mvp
243
+ trustScope: official-core-only
244
+ nodes:
245
+ - id: intake
246
+ kind: deterministic
247
+ agent: release-intake
248
+ outputs_to: agentResults.intake
249
+ - id: evidence
250
+ kind: deterministic
251
+ agent: release-evidence-normalizer
252
+ outputs_to: agentResults.evidence
253
+ - id: release
254
+ kind: reasoning
255
+ agent: release-analyst
256
+ outputs_to: agentResults.release
257
+ - id: report
258
+ kind: report
259
+ outputs_to: reports.final
260
+ `;
261
+ const incidentWorkflowTemplate = `version: 1
262
+ name: incident-handoff
263
+ description: Validate staged incident evidence while keeping the default path local, read-only, and explicit.
264
+ trigger: manual
265
+ catalog:
266
+ domain: operate
267
+ supportLevel: partial
268
+ maturity: mvp
269
+ trustScope: official-core-only
270
+ nodes:
271
+ - id: intake
272
+ kind: deterministic
273
+ agent: incident-intake
274
+ outputs_to: agentResults.intake
275
+ - id: evidence
276
+ kind: deterministic
277
+ agent: incident-evidence-normalizer
278
+ outputs_to: agentResults.evidence
279
+ - id: incident
280
+ kind: reasoning
281
+ agent: incident-analyst
282
+ outputs_to: agentResults.incident
283
+ - id: report
284
+ kind: report
285
+ outputs_to: reports.final
286
+ `;
287
+ const maintenanceWorkflowTemplate = `version: 1
288
+ name: maintenance-triage
289
+ description: Validate a bounded maintenance request while keeping the default path local, read-only, and routing-oriented.
290
+ trigger: manual
291
+ catalog:
292
+ domain: maintain
293
+ supportLevel: partial
294
+ maturity: mvp
295
+ trustScope: official-core-only
296
+ nodes:
297
+ - id: intake
298
+ kind: deterministic
299
+ agent: maintenance-intake
300
+ outputs_to: agentResults.intake
301
+ - id: evidence
302
+ kind: deterministic
303
+ agent: maintenance-evidence-normalizer
304
+ outputs_to: agentResults.evidence
305
+ - id: maintenance
306
+ kind: reasoning
307
+ agent: maintenance-analyst
308
+ outputs_to: agentResults.maintenance
196
309
  - id: report
197
310
  kind: report
198
311
  outputs_to: reports.final
@@ -206,6 +319,136 @@ function isRecord(value) {
206
319
  function asArray(value) {
207
320
  return Array.isArray(value) ? value : [];
208
321
  }
322
+ function runGit(root, args) {
323
+ try {
324
+ return execFileSync("git", args, { cwd: root, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
325
+ }
326
+ catch {
327
+ return "";
328
+ }
329
+ }
330
+ function parseGitHubRepositoryUrl(value) {
331
+ const trimmed = value.trim();
332
+ const sshMatch = trimmed.match(/^git@([^:]+):([^/]+)\/([^/]+?)(?:\.git)?$/i);
333
+ if (sshMatch) {
334
+ return {
335
+ host: sshMatch[1].toLowerCase(),
336
+ owner: sshMatch[2],
337
+ repo: sshMatch[3]
338
+ };
339
+ }
340
+ const httpsMatch = trimmed.match(/^https?:\/\/([^/]+)\/([^/]+)\/([^/]+?)(?:\.git)?(?:\/)?$/i);
341
+ if (!httpsMatch) {
342
+ return undefined;
343
+ }
344
+ return {
345
+ host: httpsMatch[1].toLowerCase(),
346
+ owner: httpsMatch[2],
347
+ repo: httpsMatch[3]
348
+ };
349
+ }
350
+ function inferGitHubRepoContext(root) {
351
+ const packageJsonPath = join(root, "package.json");
352
+ if (existsSync(packageJsonPath)) {
353
+ const parsed = JSON.parse(readFileSync(packageJsonPath, "utf8"));
354
+ if (isRecord(parsed)) {
355
+ const repository = parsed.repository;
356
+ if (typeof repository === "string") {
357
+ const context = parseGitHubRepositoryUrl(repository);
358
+ if (context) {
359
+ return context;
360
+ }
361
+ }
362
+ if (isRecord(repository) && typeof repository.url === "string") {
363
+ const context = parseGitHubRepositoryUrl(repository.url);
364
+ if (context) {
365
+ return context;
366
+ }
367
+ }
368
+ }
369
+ }
370
+ const remoteUrl = runGit(root, ["config", "--get", "remote.origin.url"]);
371
+ return remoteUrl ? parseGitHubRepositoryUrl(remoteUrl) : undefined;
372
+ }
373
+ function normalizeGitHubReference(rawValue, repoContext) {
374
+ const raw = rawValue.trim();
375
+ if (!raw) {
376
+ return undefined;
377
+ }
378
+ const fromParts = (context, kind, number) => ({
379
+ platform: "github",
380
+ host: context.host,
381
+ owner: context.owner,
382
+ repo: context.repo,
383
+ kind,
384
+ number,
385
+ canonical: kind === "issue"
386
+ ? `${context.owner}/${context.repo}#${number}`
387
+ : `${context.owner}/${context.repo}/pull/${number}`,
388
+ url: kind === "issue"
389
+ ? `https://${context.host}/${context.owner}/${context.repo}/issues/${number}`
390
+ : `https://${context.host}/${context.owner}/${context.repo}/pull/${number}`,
391
+ source: raw
392
+ });
393
+ const urlMatch = raw.match(/^https?:\/\/([^/]+)\/([^/]+)\/([^/]+)\/(issues|pull)\/(\d+)(?:\/)?$/i);
394
+ if (urlMatch) {
395
+ return fromParts({ host: urlMatch[1].toLowerCase(), owner: urlMatch[2], repo: urlMatch[3] }, urlMatch[4].toLowerCase() === "pull" ? "pull_request" : "issue", Number.parseInt(urlMatch[5], 10));
396
+ }
397
+ const repoIssueMatch = raw.match(/^([^/\s]+)\/([^#\s]+)#(\d+)$/);
398
+ if (repoIssueMatch) {
399
+ return fromParts({ host: repoContext?.host ?? "github.com", owner: repoIssueMatch[1], repo: repoIssueMatch[2] }, "issue", Number.parseInt(repoIssueMatch[3], 10));
400
+ }
401
+ const repoPullMatch = raw.match(/^([^/\s]+)\/([^/\s]+)\/pull\/(\d+)$/i);
402
+ if (repoPullMatch) {
403
+ return fromParts({ host: repoContext?.host ?? "github.com", owner: repoPullMatch[1], repo: repoPullMatch[2] }, "pull_request", Number.parseInt(repoPullMatch[3], 10));
404
+ }
405
+ const shortIssueMatch = raw.match(/^#(\d+)$/);
406
+ if (shortIssueMatch && repoContext) {
407
+ return fromParts(repoContext, "issue", Number.parseInt(shortIssueMatch[1], 10));
408
+ }
409
+ const shortPullMatch = raw.match(/^(?:PR|pr)\s*#(\d+)$/);
410
+ if (shortPullMatch && repoContext) {
411
+ return fromParts(repoContext, "pull_request", Number.parseInt(shortPullMatch[1], 10));
412
+ }
413
+ return undefined;
414
+ }
415
+ function normalizeGitHubReferences(rawValues, repoContext) {
416
+ const seen = new Set();
417
+ const normalized = [];
418
+ for (const rawValue of rawValues) {
419
+ const githubRef = normalizeGitHubReference(rawValue, repoContext);
420
+ if (!githubRef || seen.has(githubRef.canonical)) {
421
+ continue;
422
+ }
423
+ seen.add(githubRef.canonical);
424
+ normalized.push(githubRef);
425
+ }
426
+ return normalized;
427
+ }
428
+ export function mapWorkflowRunStatusToGitHubStatus(workflow, localRunStatus) {
429
+ if (localRunStatus === "success") {
430
+ return {
431
+ workflow,
432
+ localRunStatus,
433
+ githubStatus: "completed",
434
+ reason: "Successful local workflow runs map to completed GitHub handoff status."
435
+ };
436
+ }
437
+ if (localRunStatus === "partial") {
438
+ return {
439
+ workflow,
440
+ localRunStatus,
441
+ githubStatus: "blocked",
442
+ reason: "Partial local workflow runs map to blocked GitHub handoff status until follow-up work resolves them."
443
+ };
444
+ }
445
+ return {
446
+ workflow,
447
+ localRunStatus,
448
+ githubStatus: "failed",
449
+ reason: "Failed local workflow runs map to failed GitHub handoff status."
450
+ };
451
+ }
209
452
  function ensureReadablePath(policyEngine, pathValue, purpose) {
210
453
  const decision = policyEngine.canReadPath(pathValue);
211
454
  if (!decision.allowed) {
@@ -229,9 +472,31 @@ function validatePlanningRequestCompleteness(request) {
229
472
  }
230
473
  return request;
231
474
  }
475
+ function validateIncidentRequestCompleteness(request) {
476
+ const evidenceSignalCount = request.evidenceSources.length + request.releaseReportRefs.length;
477
+ if (evidenceSignalCount === 0) {
478
+ throw new Error("Incident request is underspecified. Add at least one of evidenceSources or releaseReportRefs.");
479
+ }
480
+ return request;
481
+ }
482
+ function validateMaintenanceRequestCompleteness(request) {
483
+ const supportingSignalCount = request.dependencyAlertRefs.length +
484
+ request.docsTaskRefs.length +
485
+ request.releaseReportRefs.length +
486
+ request.issueRefs.length;
487
+ if (supportingSignalCount === 0) {
488
+ throw new Error("Maintenance request is underspecified. Add at least one of dependencyAlertRefs, docsTaskRefs, releaseReportRefs, or issueRefs.");
489
+ }
490
+ return request;
491
+ }
232
492
  function validateWorkflowLifecyclePosture(workflow, policyEngine) {
233
493
  const domain = workflow.catalog?.domain;
234
- if (domain !== "plan" && domain !== "design" && domain !== "build") {
494
+ if (domain !== "plan" &&
495
+ domain !== "design" &&
496
+ domain !== "build" &&
497
+ domain !== "security" &&
498
+ domain !== "release" &&
499
+ domain !== "operate") {
235
500
  return;
236
501
  }
237
502
  if (policyEngine.snapshot.defaults.network !== "deny") {
@@ -265,6 +530,52 @@ function loadDesignBundleArtifact(root, designRecordRef) {
265
530
  }
266
531
  return designArtifactSchema.parse(designArtifact);
267
532
  }
533
+ function ensureBundleContainsArtifactKind(root, bundleRef, artifactKind, purpose) {
534
+ const artifactKinds = loadLifecycleArtifactKinds(root, bundleRef);
535
+ if (!artifactKinds.includes(artifactKind)) {
536
+ throw new Error(`Referenced ${purpose} does not contain a ${artifactKind} artifact: ${bundleRef}`);
537
+ }
538
+ }
539
+ function validateReleaseRequestCompleteness(request) {
540
+ const evidenceSignalCount = request.qaReportRefs.length + request.securityReportRefs.length + request.evidenceSources.length;
541
+ if (evidenceSignalCount === 0) {
542
+ throw new Error("Release request is underspecified. Add at least one of qaReportRefs, securityReportRefs, or evidenceSources.");
543
+ }
544
+ return request;
545
+ }
546
+ function loadLifecycleArtifactKinds(root, bundleRef) {
547
+ const bundlePath = join(root, bundleRef);
548
+ if (!existsSync(bundlePath)) {
549
+ throw new Error(`Referenced bundle not found: ${bundleRef}`);
550
+ }
551
+ const bundle = auditBundleSchema.parse(JSON.parse(readFileSync(bundlePath, "utf8")));
552
+ return bundle.lifecycleArtifacts.map((artifact) => artifact.artifactKind);
553
+ }
554
+ function loadLifecycleArtifactSourceReferences(root, bundleRef) {
555
+ const bundlePath = join(root, bundleRef);
556
+ if (!existsSync(bundlePath)) {
557
+ throw new Error(`Referenced bundle not found: ${bundleRef}`);
558
+ }
559
+ const bundle = auditBundleSchema.parse(JSON.parse(readFileSync(bundlePath, "utf8")));
560
+ const repoContext = inferGitHubRepoContext(root);
561
+ const issueRefs = new Set();
562
+ const githubRefs = new Map();
563
+ for (const artifact of bundle.lifecycleArtifacts) {
564
+ for (const issueRef of artifact.source.issueRefs) {
565
+ issueRefs.add(issueRef);
566
+ }
567
+ for (const githubRef of artifact.source.githubRefs ?? []) {
568
+ githubRefs.set(githubRef.canonical, githubRef);
569
+ }
570
+ for (const githubRef of normalizeGitHubReferences(artifact.source.issueRefs, repoContext)) {
571
+ githubRefs.set(githubRef.canonical, githubRef);
572
+ }
573
+ }
574
+ return {
575
+ issueRefs: [...issueRefs],
576
+ githubRefs: [...githubRefs.values()]
577
+ };
578
+ }
268
579
  function prepareWorkflowInputs(workflow, root, policyEngine) {
269
580
  const requestsDir = join(root, ".agentops", "requests");
270
581
  ensureDirectory(requestsDir);
@@ -272,8 +583,10 @@ function prepareWorkflowInputs(workflow, root, policyEngine) {
272
583
  const requestPath = ".agentops/requests/planning.yaml";
273
584
  ensureReadablePath(policyEngine, requestPath, "planning request");
274
585
  const planningRequest = validatePlanningRequestCompleteness(readYamlFile(join(root, requestPath), planningRequestSchema, "planning request"));
586
+ const planningGithubRefs = normalizeGitHubReferences(planningRequest.issueRefs, inferGitHubRepoContext(root));
275
587
  return {
276
588
  planningRequest,
589
+ planningGithubRefs,
277
590
  requestFile: requestPath
278
591
  };
279
592
  }
@@ -306,11 +619,163 @@ function prepareWorkflowInputs(workflow, root, policyEngine) {
306
619
  ensureReadablePath(policyEngine, requestPath, "QA request");
307
620
  const qaRequest = readYamlFile(join(root, requestPath), qaRequestSchema, "QA request");
308
621
  ensureReadablePath(policyEngine, qaRequest.targetRef, "QA target reference");
622
+ if (!existsSync(join(root, qaRequest.targetRef))) {
623
+ throw new Error(`QA target reference not found: ${qaRequest.targetRef}`);
624
+ }
309
625
  for (const evidenceSource of qaRequest.evidenceSources) {
310
626
  ensureReadablePath(policyEngine, evidenceSource, "QA evidence source");
311
627
  }
628
+ const referencedSourceRefs = qaRequest.targetRef.endsWith("bundle.json")
629
+ ? loadLifecycleArtifactSourceReferences(root, qaRequest.targetRef)
630
+ : { issueRefs: [], githubRefs: [] };
312
631
  return {
313
632
  qaRequest: qaRequest,
633
+ qaIssueRefs: referencedSourceRefs.issueRefs,
634
+ qaGithubRefs: referencedSourceRefs.githubRefs,
635
+ requestFile: requestPath
636
+ };
637
+ }
638
+ if (workflow.name === "security-review") {
639
+ const requestPath = ".agentops/requests/security.yaml";
640
+ ensureReadablePath(policyEngine, requestPath, "security request");
641
+ const securityRequest = readYamlFile(join(root, requestPath), securityRequestSchema, "security request");
642
+ ensureReadablePath(policyEngine, securityRequest.targetRef, "security target reference");
643
+ if (!existsSync(join(root, securityRequest.targetRef))) {
644
+ throw new Error(`Security target reference not found: ${securityRequest.targetRef}`);
645
+ }
646
+ for (const evidenceSource of securityRequest.evidenceSources) {
647
+ ensureReadablePath(policyEngine, evidenceSource, "security evidence source");
648
+ }
649
+ const referencedArtifactKinds = securityRequest.targetRef.endsWith("bundle.json")
650
+ ? loadLifecycleArtifactKinds(root, securityRequest.targetRef)
651
+ : [];
652
+ const allowedSecurityTargets = new Set(["design-record", "implementation-proposal", "qa-report", "release-report"]);
653
+ if (securityRequest.targetRef.endsWith("bundle.json") && !referencedArtifactKinds.some((kind) => allowedSecurityTargets.has(kind))) {
654
+ throw new Error(`Referenced security bundle does not contain a supported lifecycle artifact: ${securityRequest.targetRef}`);
655
+ }
656
+ const referencedSourceRefs = securityRequest.targetRef.endsWith("bundle.json")
657
+ ? loadLifecycleArtifactSourceReferences(root, securityRequest.targetRef)
658
+ : { issueRefs: [], githubRefs: [] };
659
+ return {
660
+ securityRequest: securityRequest,
661
+ securityTargetArtifactKinds: referencedArtifactKinds,
662
+ securityIssueRefs: referencedSourceRefs.issueRefs,
663
+ securityGithubRefs: referencedSourceRefs.githubRefs,
664
+ requestFile: requestPath
665
+ };
666
+ }
667
+ if (workflow.name === "release-readiness") {
668
+ const requestPath = ".agentops/requests/release.yaml";
669
+ ensureReadablePath(policyEngine, requestPath, "release request");
670
+ const releaseRequest = validateReleaseRequestCompleteness(readYamlFile(join(root, requestPath), releaseRequestSchema, "release request"));
671
+ const releaseIssueRefs = new Set();
672
+ const releaseGithubRefMap = new Map();
673
+ for (const qaReportRef of releaseRequest.qaReportRefs) {
674
+ ensureReadablePath(policyEngine, qaReportRef, "QA report reference");
675
+ ensureBundleContainsArtifactKind(root, qaReportRef, "qa-report", "QA report reference");
676
+ const refs = loadLifecycleArtifactSourceReferences(root, qaReportRef);
677
+ for (const issueRef of refs.issueRefs) {
678
+ releaseIssueRefs.add(issueRef);
679
+ }
680
+ for (const githubRef of refs.githubRefs) {
681
+ releaseGithubRefMap.set(githubRef.canonical, githubRef);
682
+ }
683
+ }
684
+ for (const securityReportRef of releaseRequest.securityReportRefs) {
685
+ ensureReadablePath(policyEngine, securityReportRef, "security report reference");
686
+ ensureBundleContainsArtifactKind(root, securityReportRef, "security-report", "security report reference");
687
+ const refs = loadLifecycleArtifactSourceReferences(root, securityReportRef);
688
+ for (const issueRef of refs.issueRefs) {
689
+ releaseIssueRefs.add(issueRef);
690
+ }
691
+ for (const githubRef of refs.githubRefs) {
692
+ releaseGithubRefMap.set(githubRef.canonical, githubRef);
693
+ }
694
+ }
695
+ for (const evidenceSource of releaseRequest.evidenceSources) {
696
+ ensureReadablePath(policyEngine, evidenceSource, "release evidence source");
697
+ if (!existsSync(join(root, evidenceSource))) {
698
+ throw new Error(`Release evidence source not found: ${evidenceSource}`);
699
+ }
700
+ }
701
+ return {
702
+ releaseRequest: releaseRequest,
703
+ releaseIssueRefs: [...releaseIssueRefs],
704
+ releaseGithubRefs: [...releaseGithubRefMap.values()],
705
+ requestFile: requestPath
706
+ };
707
+ }
708
+ if (workflow.name === "incident-handoff") {
709
+ const requestPath = ".agentops/requests/incident.yaml";
710
+ ensureReadablePath(policyEngine, requestPath, "incident request");
711
+ const incidentRequest = validateIncidentRequestCompleteness(readYamlFile(join(root, requestPath), incidentRequestSchema, "incident request"));
712
+ const repoContext = inferGitHubRepoContext(root);
713
+ const incidentIssueRefs = new Set(incidentRequest.issueRefs);
714
+ const incidentGithubRefMap = new Map();
715
+ for (const githubRef of normalizeGitHubReferences(incidentRequest.issueRefs, repoContext)) {
716
+ incidentGithubRefMap.set(githubRef.canonical, githubRef);
717
+ }
718
+ for (const releaseReportRef of incidentRequest.releaseReportRefs) {
719
+ ensureReadablePath(policyEngine, releaseReportRef, "release report reference");
720
+ ensureBundleContainsArtifactKind(root, releaseReportRef, "release-report", "release report reference");
721
+ const refs = loadLifecycleArtifactSourceReferences(root, releaseReportRef);
722
+ for (const issueRef of refs.issueRefs) {
723
+ incidentIssueRefs.add(issueRef);
724
+ }
725
+ for (const githubRef of refs.githubRefs) {
726
+ incidentGithubRefMap.set(githubRef.canonical, githubRef);
727
+ }
728
+ }
729
+ for (const evidenceSource of incidentRequest.evidenceSources) {
730
+ ensureReadablePath(policyEngine, evidenceSource, "incident evidence source");
731
+ if (!existsSync(join(root, evidenceSource))) {
732
+ throw new Error(`Incident evidence source not found: ${evidenceSource}`);
733
+ }
734
+ }
735
+ return {
736
+ incidentRequest: incidentRequest,
737
+ incidentIssueRefs: [...incidentIssueRefs],
738
+ incidentGithubRefs: [...incidentGithubRefMap.values()],
739
+ requestFile: requestPath
740
+ };
741
+ }
742
+ if (workflow.name === "maintenance-triage") {
743
+ const requestPath = ".agentops/requests/maintenance.yaml";
744
+ ensureReadablePath(policyEngine, requestPath, "maintenance request");
745
+ const maintenanceRequest = validateMaintenanceRequestCompleteness(readYamlFile(join(root, requestPath), maintenanceRequestSchema, "maintenance request"));
746
+ const repoContext = inferGitHubRepoContext(root);
747
+ const maintenanceIssueRefs = new Set(maintenanceRequest.issueRefs);
748
+ const maintenanceGithubRefMap = new Map();
749
+ for (const githubRef of normalizeGitHubReferences(maintenanceRequest.issueRefs, repoContext)) {
750
+ maintenanceGithubRefMap.set(githubRef.canonical, githubRef);
751
+ }
752
+ for (const releaseReportRef of maintenanceRequest.releaseReportRefs) {
753
+ ensureReadablePath(policyEngine, releaseReportRef, "release report reference");
754
+ ensureBundleContainsArtifactKind(root, releaseReportRef, "release-report", "release report reference");
755
+ const refs = loadLifecycleArtifactSourceReferences(root, releaseReportRef);
756
+ for (const issueRef of refs.issueRefs) {
757
+ maintenanceIssueRefs.add(issueRef);
758
+ }
759
+ for (const githubRef of refs.githubRefs) {
760
+ maintenanceGithubRefMap.set(githubRef.canonical, githubRef);
761
+ }
762
+ }
763
+ for (const dependencyAlertRef of maintenanceRequest.dependencyAlertRefs) {
764
+ ensureReadablePath(policyEngine, dependencyAlertRef, "dependency alert reference");
765
+ if (!existsSync(join(root, dependencyAlertRef))) {
766
+ throw new Error(`Dependency alert reference not found: ${dependencyAlertRef}`);
767
+ }
768
+ }
769
+ for (const docsTaskRef of maintenanceRequest.docsTaskRefs) {
770
+ ensureReadablePath(policyEngine, docsTaskRef, "docs task reference");
771
+ if (!existsSync(join(root, docsTaskRef))) {
772
+ throw new Error(`Docs task reference not found: ${docsTaskRef}`);
773
+ }
774
+ }
775
+ return {
776
+ maintenanceRequest: maintenanceRequest,
777
+ maintenanceIssueRefs: [...maintenanceIssueRefs],
778
+ maintenanceGithubRefs: [...maintenanceGithubRefMap.values()],
314
779
  requestFile: requestPath
315
780
  };
316
781
  }
@@ -383,6 +848,51 @@ function normalizeAgentForgeConfigInput(value) {
383
848
  }
384
849
  };
385
850
  }
851
+ function readLatestCompleteRunBundle(runsRoot) {
852
+ if (!existsSync(runsRoot)) {
853
+ return undefined;
854
+ }
855
+ const parseRunTimestampMs = (value) => {
856
+ if (typeof value !== "string" || value.length === 0) {
857
+ return undefined;
858
+ }
859
+ const parsedDate = Date.parse(value);
860
+ if (!Number.isNaN(parsedDate)) {
861
+ return parsedDate;
862
+ }
863
+ const timestampPrefix = Number.parseInt(value.split("-")[0] ?? "", 10);
864
+ return Number.isNaN(timestampPrefix) ? undefined : timestampPrefix;
865
+ };
866
+ const candidates = readdirSync(runsRoot)
867
+ .map((entry) => {
868
+ const bundlePath = join(runsRoot, entry, "bundle.json");
869
+ if (!existsSync(bundlePath)) {
870
+ return undefined;
871
+ }
872
+ const stats = statSync(bundlePath);
873
+ const bundle = JSON.parse(readFileSync(bundlePath, "utf8"));
874
+ const bundleRunId = typeof bundle.runId === "string" ? bundle.runId : entry;
875
+ const completedAtMs = parseRunTimestampMs(bundle.finishedAt) ??
876
+ parseRunTimestampMs(bundle.startedAt) ??
877
+ parseRunTimestampMs(bundleRunId) ??
878
+ parseRunTimestampMs(entry) ??
879
+ stats.mtimeMs;
880
+ return {
881
+ runDir: entry,
882
+ bundle,
883
+ bundleRunId,
884
+ completedAtMs
885
+ };
886
+ })
887
+ .filter((candidate) => Boolean(candidate))
888
+ .sort((left, right) => {
889
+ if (left.completedAtMs !== right.completedAtMs) {
890
+ return right.completedAtMs - left.completedAtMs;
891
+ }
892
+ return right.bundleRunId.localeCompare(left.bundleRunId);
893
+ });
894
+ return candidates[0] ? { runDir: candidates[0].runDir, bundle: candidates[0].bundle } : undefined;
895
+ }
386
896
  function loadAgentForgeConfig(root) {
387
897
  const configPath = join(root, ".agentops", "agentops.yaml");
388
898
  if (!existsSync(configPath)) {
@@ -446,6 +956,22 @@ function ensureInitFiles(root) {
446
956
  {
447
957
  path: join(workflowsDir, "qa-review.yaml"),
448
958
  contents: qaWorkflowTemplate
959
+ },
960
+ {
961
+ path: join(workflowsDir, "security-review.yaml"),
962
+ contents: securityWorkflowTemplate
963
+ },
964
+ {
965
+ path: join(workflowsDir, "release-readiness.yaml"),
966
+ contents: releaseWorkflowTemplate
967
+ },
968
+ {
969
+ path: join(workflowsDir, "incident-handoff.yaml"),
970
+ contents: incidentWorkflowTemplate
971
+ },
972
+ {
973
+ path: join(workflowsDir, "maintenance-triage.yaml"),
974
+ contents: maintenanceWorkflowTemplate
449
975
  }
450
976
  ];
451
977
  for (const file of files) {
@@ -599,18 +1125,17 @@ export function explainLastRun(cwd = process.cwd()) {
599
1125
  const root = findWorkspaceRoot(cwd);
600
1126
  const config = loadAgentForgeConfig(root);
601
1127
  const runsRoot = join(root, config.runtime.runsPath);
602
- const entries = existsSync(runsRoot) ? readdirSync(runsRoot).sort() : [];
603
- const latest = entries.at(-1);
1128
+ const latest = readLatestCompleteRunBundle(runsRoot);
604
1129
  if (!latest) {
605
- throw new Error("No recorded runs found.");
1130
+ throw new Error("No complete recorded runs found.");
606
1131
  }
607
- const bundle = JSON.parse(readFileSync(join(runsRoot, latest, "bundle.json"), "utf8"));
1132
+ const bundle = latest.bundle;
608
1133
  const findings = asArray(bundle.findings);
609
1134
  const blockedPlugins = asArray(bundle.blockedPlugins);
610
1135
  const lifecycleArtifacts = asArray(bundle.lifecycleArtifacts);
611
1136
  const runEntries = asArray(bundle.entries);
612
1137
  return {
613
- runId: typeof bundle.runId === "string" ? bundle.runId : latest,
1138
+ runId: typeof bundle.runId === "string" ? bundle.runId : latest.runDir,
614
1139
  status: typeof bundle.status === "string" ? bundle.status : "unknown",
615
1140
  findings: findings.length,
616
1141
  blockedActions: runEntries.reduce((total, entry) => {
@@ -620,8 +1145,8 @@ export function explainLastRun(cwd = process.cwd()) {
620
1145
  return total + asArray(entry.blockedActions).length;
621
1146
  }, 0),
622
1147
  blockedPlugins: blockedPlugins.length,
623
- jsonPath: join(runsRoot, latest, "bundle.json"),
624
- markdownPath: join(runsRoot, latest, "summary.md"),
1148
+ jsonPath: join(runsRoot, latest.runDir, "bundle.json"),
1149
+ markdownPath: join(runsRoot, latest.runDir, "summary.md"),
625
1150
  artifactCount: lifecycleArtifacts.length,
626
1151
  artifactKinds: lifecycleArtifacts
627
1152
  .map((artifact) => (isRecord(artifact) && typeof artifact.artifactKind === "string" ? artifact.artifactKind : undefined))