@patronage/software-factory 0.30.0-beta.1 → 0.30.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/dist/index.js CHANGED
@@ -1,6 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { t as __exportAll } from "./chunk-DrSxFLj_.js";
3
- import { createRequire } from "node:module";
2
+ import { t as __exportAll } from "./chunk-pbuEa-1d.js";
4
3
  import { appendFileSync, constants, cpSync, createReadStream, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
5
4
  import { pathToFileURL } from "node:url";
6
5
  import { Command, InvalidArgumentError } from "commander";
@@ -18,7 +17,7 @@ import picomatch from "picomatch";
18
17
  import { promisify } from "node:util";
19
18
  import { parse } from "yaml";
20
19
  //#region package.json
21
- var version = "0.30.0-beta.1";
20
+ var version = "0.30.0";
22
21
  //#endregion
23
22
  //#region src/review-rungs.ts
24
23
  const EVIDENCE_REVIEW_RUNGS$1 = [
@@ -931,12 +930,16 @@ const requiredCheckInScope = ({ context, scope }) => {
931
930
  //#region src/profile.ts
932
931
  const DEFAULT_FACTORY_REPOSITORY = "unknown/unknown";
933
932
  /**
934
- * The shapes v4 replaced. Not loadable — {@link legacyProfileMigration} turns
935
- * an encounter with either into a per-field migration error rather than a
933
+ * The shapes v5 replaced. Not loadable — {@link legacyProfileMigration} turns
934
+ * an encounter with one into a per-field migration error rather than a
936
935
  * schema complaint about a shape the profile was never written in.
937
936
  */
938
- const LEGACY_PROFILE_SCHEMA_VERSIONS = [2, 3];
939
- const PROFILE_JSON_SCHEMA_DESCRIPTION = "Editor validation covers the profile input shape and straightforward cross-field constraints. Zod remains the load-time enforcement authority. The following invariants are enforced at load time, not by this schema: verification command names must be distinct; requiredChecks entries must have distinct names; and every review.conditional mode must also appear in review.modes.";
937
+ const LEGACY_PROFILE_SCHEMA_VERSIONS = [
938
+ 2,
939
+ 3,
940
+ 4
941
+ ];
942
+ const PROFILE_JSON_SCHEMA_DESCRIPTION = "Editor validation covers the profile input shape and straightforward cross-field constraints. Zod remains the load-time enforcement authority: verification command names must be distinct and requiredChecks entries must have distinct names; these invariants are enforced at load time, not by this schema.";
940
943
  const globSchema = z.string().min(1).refine(isValidGlob, "must be a valid glob");
941
944
  const commandSchema = z.object({
942
945
  command: z.string().min(1),
@@ -949,11 +952,7 @@ const commandSchema = z.object({
949
952
  "full"
950
953
  ]).default("full")
951
954
  });
952
- const reviewModeSchema = z.enum(["correctness", "security"]);
953
- const conditionalReviewSchema = z.object({
954
- modes: z.array(reviewModeSchema).min(1),
955
- paths: z.array(z.string().min(1).refine(isValidGlob, "must be a valid glob")).min(1)
956
- }).strict();
955
+ const conditionalReviewSchema = z.object({ paths: z.array(z.string().min(1).refine(isValidGlob, "must be a valid glob")).min(1) }).strict();
957
956
  const hqEndpointDeclarationSchema = z.intersection(z.string().url(), z.string().regex(/^https:\/\/[^/@\s]+(?:[/?#]|$)/u, "Must use HTTPS without embedded credentials"));
958
957
  const hqIngestConfigSchema = z.object({
959
958
  enabled: z.boolean(),
@@ -967,22 +966,18 @@ const hqIngestConfigSchema = z.object({
967
966
  }, "Must be an HTTPS origin without a path, query, fragment, or embedded credentials").describe("Repository-declared HQ origin (scheme and host only; the factory composes the ingest path). Credential delivery additionally requires exact-origin authorization in the operator user config hqAllowedOrigins list.")
968
967
  }).strict();
969
968
  const requiredCheckSchema = z.object({
970
- checkType: z.enum(EVIDENCE_CHECK_TYPES$1),
971
969
  name: z.string().min(1),
972
970
  scope: requiredCheckScopeSchema$1.optional()
973
971
  }).strict();
974
972
  const factoryProjectProfileSchema = z.object({
975
973
  $schema: z.string().min(1).optional(),
976
- env: z.object({ required: z.array(z.string().regex(/^[A-Z_][A-Z0-9_]*$/u)) }).strict().optional(),
977
974
  extensions: z.record(z.string(), z.unknown()).optional(),
978
975
  hq: hqIngestConfigSchema.optional(),
979
- project: z.object({ key: z.string().min(1) }).strict(),
980
976
  proof: z.object({ classificationPolicy: z.object({
981
977
  docsOnly: z.array(globSchema).default([]),
982
978
  trivial: z.array(globSchema).default([])
983
979
  }).strict() }).strict(),
984
980
  repository: z.object({
985
- defaultBranch: z.string().min(1),
986
981
  name: z.string().min(1),
987
982
  owner: z.string().min(1)
988
983
  }).strict(),
@@ -993,12 +988,8 @@ const factoryProjectProfileSchema = z.object({
993
988
  message: "requiredChecks entries must have distinct names."
994
989
  });
995
990
  }).optional(),
996
- review: z.object({
997
- conditional: z.array(conditionalReviewSchema).optional(),
998
- defaultMaxCycles: z.number().int().positive(),
999
- modes: z.array(reviewModeSchema).min(1)
1000
- }).strict(),
1001
- schemaVersion: z.literal(4),
991
+ review: z.object({ conditional: z.array(conditionalReviewSchema).min(1) }).strict().optional(),
992
+ schemaVersion: z.literal(5),
1002
993
  verification: z.object({ commands: z.array(commandSchema).min(1) })
1003
994
  }).strict().superRefine((profile, context) => {
1004
995
  const commandNames = /* @__PURE__ */ new Set();
@@ -1019,8 +1010,7 @@ const factoryProjectProfileSchema = z.object({
1019
1010
  const mappedChecks = /* @__PURE__ */ new Set();
1020
1011
  for (const [commandIndex, command] of profile.verification.commands.entries()) {
1021
1012
  if (!command.requiredCheck) continue;
1022
- const requiredCheck = requiredChecks.get(command.requiredCheck);
1023
- if (!requiredCheck) {
1013
+ if (!requiredChecks.get(command.requiredCheck)) {
1024
1014
  context.addIssue({
1025
1015
  code: "custom",
1026
1016
  message: `Verification command requiredCheck "${command.requiredCheck}" does not name a declared requiredChecks entry.`,
@@ -1033,16 +1023,6 @@ const factoryProjectProfileSchema = z.object({
1033
1023
  });
1034
1024
  continue;
1035
1025
  }
1036
- if (requiredCheck.checkType !== "verify") context.addIssue({
1037
- code: "custom",
1038
- message: `Verification command requiredCheck "${command.requiredCheck}" must target a verify requiredCheck.`,
1039
- path: [
1040
- "verification",
1041
- "commands",
1042
- commandIndex,
1043
- "requiredCheck"
1044
- ]
1045
- });
1046
1026
  if (mappedChecks.has(command.requiredCheck)) context.addIssue({
1047
1027
  code: "custom",
1048
1028
  message: `Required check "${command.requiredCheck}" is mapped by more than one verification command.`,
@@ -1055,16 +1035,6 @@ const factoryProjectProfileSchema = z.object({
1055
1035
  });
1056
1036
  mappedChecks.add(command.requiredCheck);
1057
1037
  }
1058
- for (const [scopeIndex, conditional] of (profile.review.conditional ?? []).entries()) for (const mode of conditional.modes) if (!profile.review.modes.includes(mode)) context.addIssue({
1059
- code: "custom",
1060
- message: `Conditional review mode "${mode}" must also be declared in review.modes.`,
1061
- path: [
1062
- "review",
1063
- "conditional",
1064
- scopeIndex,
1065
- "modes"
1066
- ]
1067
- });
1068
1038
  }).meta({ description: PROFILE_JSON_SCHEMA_DESCRIPTION });
1069
1039
  const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1070
1040
  const stringList = (value) => Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
@@ -1127,11 +1097,35 @@ function v2MigrationLines(input, root) {
1127
1097
  } catch {}
1128
1098
  return lines;
1129
1099
  }
1100
+ function hasCorrectnessOnlyReview(review) {
1101
+ const { modes } = review;
1102
+ return Object.keys(review).length > 0 && Object.keys(review).every((field) => field === "conditional" || field === "defaultMaxCycles" || field === "modes") && Array.isArray(modes) && modes.length > 0 && modes.every((mode) => mode === "correctness") && (review.conditional === void 0 || Array.isArray(review.conditional) && review.conditional.length === 0);
1103
+ }
1104
+ const v4MigrationLines = (input) => {
1105
+ const lines = [];
1106
+ if (isRecord(input.project) && "key" in input.project) lines.push("project.key -> removed; current proof writers derive projectKey from repository.name");
1107
+ if (isRecord(input.repository) && "defaultBranch" in input.repository) lines.push("repository.defaultBranch -> removed; commands resolve the live base from Git/GitHub or an explicit --base");
1108
+ if (isRecord(input.env) && "required" in input.env) lines.push("env.required -> removed; the command that needs an environment value owns its fail-closed diagnostic");
1109
+ if (isRecord(input.review)) {
1110
+ const isCorrectnessOnlyReview = hasCorrectnessOnlyReview(input.review);
1111
+ if ("defaultMaxCycles" in input.review) lines.push("review.defaultMaxCycles -> removed; the factory review cap is 5");
1112
+ if ("modes" in input.review) lines.push("review.modes -> removed; correctness is always required");
1113
+ if (isCorrectnessOnlyReview) lines.push("review -> removed; a correctness-only review has no v5 configuration, so delete the object");
1114
+ if (Array.isArray(input.review.conditional)) {
1115
+ for (const [index, conditional] of input.review.conditional.entries()) if (isRecord(conditional) && "modes" in conditional) lines.push(`review.conditional[${index}].modes -> removed; each retained conditional path list demands security review`);
1116
+ }
1117
+ }
1118
+ if (Array.isArray(input.requiredChecks)) {
1119
+ for (const [index, check] of input.requiredChecks.entries()) if (isRecord(check) && "checkType" in check) lines.push(`requiredChecks[${index}].checkType -> removed; external required checks are verify-type`);
1120
+ }
1121
+ return lines;
1122
+ };
1130
1123
  function legacyProfileMigration(input, root) {
1131
1124
  if (!(isRecord(input) && typeof input.schemaVersion === "number" && LEGACY_PROFILE_SCHEMA_VERSIONS.includes(input.schemaVersion))) return;
1132
1125
  const foundVersion = input.schemaVersion;
1133
- const lines = [`schemaVersion ${foundVersion} -> 4`];
1126
+ const lines = [`schemaVersion ${foundVersion} -> 5`];
1134
1127
  if (foundVersion === 2) lines.push(...v2MigrationLines(input, root));
1128
+ lines.push(...v4MigrationLines(input));
1135
1129
  return {
1136
1130
  foundVersion,
1137
1131
  lines
@@ -1198,7 +1192,7 @@ function hqLaneRefBaseUrlFromProfile(profile) {
1198
1192
  return hqLaneRefBaseUrl(profile.hq.endpoint);
1199
1193
  }
1200
1194
  function formatLegacyProfileError(profilePath, foundVersion, migration) {
1201
- return [`Profile is invalid: ${profilePath}: schemaVersion ${foundVersion} is not supported; migrate to schemaVersion 4 (ADR 0023).`, ...migration.map((line) => ` ${line}`)].join("\n");
1195
+ return [`Profile is invalid: ${profilePath}: schemaVersion ${foundVersion} is not supported; migrate to schemaVersion 5 (ADR 0023).`, ...migration.map((line) => ` ${line}`)].join("\n");
1202
1196
  }
1203
1197
  function formatProfileError(profilePath, error) {
1204
1198
  return `Profile is invalid: ${profilePath}: ${error.issues.map((issue) => {
@@ -3798,6 +3792,7 @@ const authorizeDemandWaiver = ({ authenticatedLogin, session }) => {
3798
3792
  ...normalizedSession && normalizedSession !== "unknown" ? { session: normalizedSession } : {}
3799
3793
  } };
3800
3794
  };
3795
+ const LEGACY_CLOSEOUT_SCHEMA_VERSION = 3;
3801
3796
  const MAX_CLOSEOUT_ROWS = 1e3;
3802
3797
  const IdentifierSchema = z.string().min(1).max(500);
3803
3798
  const NarrativeSchema = z.string().min(1).max(1e4);
@@ -3809,11 +3804,15 @@ const CloseoutLandingSpotSchema = z.enum([
3809
3804
  "skill",
3810
3805
  "ADR"
3811
3806
  ]);
3812
- const CloseoutSourceSchema = z.object({
3807
+ const CurrentCloseoutSourceSchema = z.object({
3808
+ detail: NarrativeSchema,
3809
+ kind: z.literal("boundary-thermo-attestation")
3810
+ }).strict();
3811
+ const LegacyCloseoutSourceSchema = z.object({
3813
3812
  detail: NarrativeSchema,
3814
3813
  kind: z.enum(["interior-telemetry", "boundary-thermo-attestation"])
3815
3814
  }).strict();
3816
- const CloseoutScopingSchema = z.object({
3815
+ const LegacyCloseoutScopingSchema = z.object({
3817
3816
  epic: IdentifierSchema,
3818
3817
  issueFilter: z.number().int().positive().optional(),
3819
3818
  prFilter: z.number().int().positive().optional(),
@@ -3825,9 +3824,13 @@ const CloseoutScopingSchema = z.object({
3825
3824
  }).strict()
3826
3825
  }).strict();
3827
3826
  const CloseoutBudgetSchema = z.object({
3827
+ note: NarrativeSchema,
3828
+ tier: z.literal("overseer")
3829
+ }).strict();
3830
+ const LegacyCloseoutBudgetSchema = z.object({
3828
3831
  interiorSourcesConsumed: z.number().int().nonnegative(),
3829
3832
  note: NarrativeSchema,
3830
- scoping: CloseoutScopingSchema.optional(),
3833
+ scoping: LegacyCloseoutScopingSchema.optional(),
3831
3834
  tier: z.literal("overseer")
3832
3835
  }).strict();
3833
3836
  const BoundaryThermoAttestationSchema = z.object({
@@ -3839,7 +3842,23 @@ const BoundaryThermoAttestationSchema = z.object({
3839
3842
  const CloseoutMetricRowSchema = z.object({
3840
3843
  label: IdentifierSchema,
3841
3844
  metric: IdentifierSchema,
3842
- source: CloseoutSourceSchema,
3845
+ source: CurrentCloseoutSourceSchema,
3846
+ unit: z.enum([
3847
+ "count",
3848
+ "boolean",
3849
+ "text"
3850
+ ]),
3851
+ value: z.union([
3852
+ z.number(),
3853
+ NarrativeSchema,
3854
+ z.boolean(),
3855
+ z.null()
3856
+ ])
3857
+ }).strict();
3858
+ const LegacyCloseoutMetricRowSchema = z.object({
3859
+ label: IdentifierSchema,
3860
+ metric: IdentifierSchema,
3861
+ source: LegacyCloseoutSourceSchema,
3843
3862
  unit: z.enum([
3844
3863
  "tokens",
3845
3864
  "usd",
@@ -3860,14 +3879,21 @@ const CloseoutLessonSchema = z.object({
3860
3879
  landingSpot: CloseoutLandingSpotSchema,
3861
3880
  lesson: NarrativeSchema,
3862
3881
  rationale: NarrativeSchema.optional(),
3863
- source: CloseoutSourceSchema.optional()
3882
+ source: CurrentCloseoutSourceSchema.optional()
3883
+ }).strict();
3884
+ const LegacyCloseoutLessonSchema = z.object({
3885
+ id: IdentifierSchema,
3886
+ landingSpot: CloseoutLandingSpotSchema,
3887
+ lesson: NarrativeSchema,
3888
+ rationale: NarrativeSchema.optional(),
3889
+ source: LegacyCloseoutSourceSchema.optional()
3864
3890
  }).strict();
3865
3891
  const CloseoutBlindspotSchema = z.object({
3866
3892
  id: IdentifierSchema,
3867
3893
  note: NarrativeSchema,
3868
3894
  reason: NarrativeSchema.optional()
3869
3895
  }).strict();
3870
- const CloseoutLedgerRowSchema = z.object({
3896
+ const closeoutLedgerRowSchema = (schemaVersion) => z.object({
3871
3897
  detail: NarrativeSchema.optional(),
3872
3898
  epic: IdentifierSchema,
3873
3899
  generatedAt: z.iso.datetime(),
@@ -3879,7 +3905,7 @@ const CloseoutLedgerRowSchema = z.object({
3879
3905
  "lesson",
3880
3906
  "blindspot"
3881
3907
  ]),
3882
- schemaVersion: z.literal(3),
3908
+ schemaVersion: z.literal(schemaVersion),
3883
3909
  source: NarrativeSchema,
3884
3910
  unit: IdentifierSchema.optional(),
3885
3911
  value: z.union([
@@ -3888,19 +3914,37 @@ const CloseoutLedgerRowSchema = z.object({
3888
3914
  z.null()
3889
3915
  ])
3890
3916
  }).strict();
3891
- /** Strict, bounded runtime transport contract emitted by factory:closeout. */
3892
- const closeoutArtifactSchema = z.object({
3917
+ const CloseoutLedgerRowSchema = closeoutLedgerRowSchema(4);
3918
+ const LegacyCloseoutLedgerRowSchema = closeoutLedgerRowSchema(LEGACY_CLOSEOUT_SCHEMA_VERSION);
3919
+ const closeoutArtifactShape = (input) => z.object({
3893
3920
  boundaryThermo: BoundaryThermoAttestationSchema,
3894
- budget: CloseoutBudgetSchema,
3921
+ budget: input.budget,
3895
3922
  couldNotSee: z.array(CloseoutBlindspotSchema).min(1).max(MAX_CLOSEOUT_ROWS),
3896
3923
  epic: IdentifierSchema,
3897
3924
  generatedAt: z.iso.datetime(),
3898
- ledgerRows: z.array(CloseoutLedgerRowSchema).max(MAX_CLOSEOUT_ROWS),
3899
- lessons: z.array(CloseoutLessonSchema).max(MAX_CLOSEOUT_ROWS),
3900
- metrics: z.array(CloseoutMetricRowSchema).max(MAX_CLOSEOUT_ROWS),
3925
+ ledgerRows: z.array(input.ledgerRows).max(MAX_CLOSEOUT_ROWS),
3926
+ lessons: z.array(input.lessons).max(MAX_CLOSEOUT_ROWS),
3927
+ metrics: z.array(input.metrics).max(MAX_CLOSEOUT_ROWS),
3901
3928
  repo: RepoSchema,
3902
- schemaVersion: z.literal(3)
3929
+ schemaVersion: z.literal(input.schemaVersion)
3903
3930
  }).strict();
3931
+ /** Strict, bounded runtime transport contract emitted by factory:closeout. */
3932
+ const closeoutArtifactSchema = closeoutArtifactShape({
3933
+ budget: CloseoutBudgetSchema,
3934
+ ledgerRows: CloseoutLedgerRowSchema,
3935
+ lessons: CloseoutLessonSchema,
3936
+ metrics: CloseoutMetricRowSchema,
3937
+ schemaVersion: 4
3938
+ });
3939
+ /** Reader-only compatibility for closeout artifacts written before #554. */
3940
+ const legacyCloseoutArtifactSchema = closeoutArtifactShape({
3941
+ budget: LegacyCloseoutBudgetSchema,
3942
+ ledgerRows: LegacyCloseoutLedgerRowSchema,
3943
+ lessons: LegacyCloseoutLessonSchema,
3944
+ metrics: LegacyCloseoutMetricRowSchema,
3945
+ schemaVersion: LEGACY_CLOSEOUT_SCHEMA_VERSION
3946
+ });
3947
+ z.union([closeoutArtifactSchema, legacyCloseoutArtifactSchema]);
3904
3948
  //#endregion
3905
3949
  //#region src/retro-envelope.ts
3906
3950
  /**
@@ -5246,8 +5290,8 @@ const resolveFindingBlocking = (finding, disposition = "open") => {
5246
5290
  return severity === "critical" || severity === "high" || severity === "unknown";
5247
5291
  };
5248
5292
  const DEFAULT_INTERIOR_REVIEW_CAP = 0;
5249
- const resolveReviewLadderPolicy = (profile) => ({
5250
- gate: { cap: profile.review.defaultMaxCycles },
5293
+ const resolveReviewLadderPolicy = () => ({
5294
+ gate: { cap: 5 },
5251
5295
  interior: { cap: DEFAULT_INTERIOR_REVIEW_CAP }
5252
5296
  });
5253
5297
  //#endregion
@@ -7458,579 +7502,6 @@ function createFactoryCheckPublisher(output, dependencies = {}) {
7458
7502
  };
7459
7503
  }
7460
7504
  //#endregion
7461
- //#region src/interior-telemetry/codex-usage.ts
7462
- const walkCodexJson = (value, visit) => {
7463
- if (!value || typeof value !== "object") return;
7464
- if (Array.isArray(value)) {
7465
- for (const entry of value) walkCodexJson(entry, visit);
7466
- return;
7467
- }
7468
- const record = value;
7469
- visit(record);
7470
- for (const entry of Object.values(record)) walkCodexJson(entry, visit);
7471
- };
7472
- const parseCodexJsonlEvents = (stdout) => stdout.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean).flatMap((line) => {
7473
- try {
7474
- return [JSON.parse(line)];
7475
- } catch {
7476
- return [];
7477
- }
7478
- });
7479
- const integerValue = (value) => typeof value === "number" && Number.isInteger(value) && value >= 0 ? value : void 0;
7480
- const valueForKeys = (record, keys) => {
7481
- for (const key of keys) {
7482
- const value = integerValue(record[key]);
7483
- if (value !== void 0) return value;
7484
- }
7485
- };
7486
- const usageFromRecord = (record) => {
7487
- const inputTokens = valueForKeys(record, [
7488
- "input_tokens",
7489
- "inputTokens",
7490
- "prompt_tokens",
7491
- "promptTokens"
7492
- ]);
7493
- const outputTokens = valueForKeys(record, [
7494
- "completion_tokens",
7495
- "completionTokens",
7496
- "output_tokens",
7497
- "outputTokens"
7498
- ]);
7499
- const totalTokens = valueForKeys(record, [
7500
- "total_token_count",
7501
- "totalTokenCount",
7502
- "total_tokens",
7503
- "totalTokens"
7504
- ]) ?? (inputTokens !== void 0 && outputTokens !== void 0 ? inputTokens + outputTokens : void 0);
7505
- if (totalTokens === void 0) return;
7506
- return {
7507
- cachedInputTokens: valueForKeys(record, [
7508
- "cached_input_tokens",
7509
- "cachedInputTokens",
7510
- "cacheReadTokens"
7511
- ]) ?? null,
7512
- inputTokens: inputTokens ?? null,
7513
- outputTokens: outputTokens ?? null,
7514
- reasoningOutputTokens: valueForKeys(record, ["reasoning_output_tokens", "reasoningOutputTokens"]) ?? null,
7515
- source: "codex-json",
7516
- status: "available",
7517
- totalTokens
7518
- };
7519
- };
7520
- const isInteriorTurn = (record) => {
7521
- const role = typeof record.role === "string" ? record.role : "";
7522
- return (typeof record.type === "string" ? record.type : "").includes("agent") || role === "assistant";
7523
- };
7524
- const CODEX_TOOL_EVENT_TYPES = new Set([
7525
- "function_call",
7526
- "function_call_output",
7527
- "local_shell_call",
7528
- "local_shell_call_output",
7529
- "custom_tool_call",
7530
- "custom_tool_call_output",
7531
- "tool_call",
7532
- "tool_result"
7533
- ]);
7534
- const isToolCall = (record) => {
7535
- const role = typeof record.role === "string" ? record.role : "";
7536
- const type = typeof record.type === "string" ? record.type : "";
7537
- return role === "tool" || type.includes("tool") || CODEX_TOOL_EVENT_TYPES.has(type);
7538
- };
7539
- const processCodexEvent = (event, metrics) => {
7540
- let turnCounted = false;
7541
- let toolCallCounted = false;
7542
- walkCodexJson(event, (record) => {
7543
- if (!turnCounted && isInteriorTurn(record)) {
7544
- metrics.interiorTurns += 1;
7545
- turnCounted = true;
7546
- }
7547
- if (!toolCallCounted && isToolCall(record)) {
7548
- metrics.toolCalls += 1;
7549
- toolCallCounted = true;
7550
- }
7551
- });
7552
- };
7553
- const parseCodexExecJsonContent = (output) => {
7554
- const events = parseCodexJsonlEvents(output);
7555
- if (events.length === 0) return {
7556
- interiorTurns: 0,
7557
- tokensByKind: {
7558
- missingReason: "codex-json-events-missing",
7559
- status: "unavailable"
7560
- },
7561
- toolCalls: 0
7562
- };
7563
- const metrics = {
7564
- interiorTurns: 0,
7565
- toolCalls: 0
7566
- };
7567
- const usageCandidates = [];
7568
- for (const event of events) {
7569
- processCodexEvent(event, metrics);
7570
- walkCodexJson(event, (record) => {
7571
- const usage = usageFromRecord(record);
7572
- if (usage) usageCandidates.push(usage);
7573
- });
7574
- }
7575
- return {
7576
- interiorTurns: metrics.interiorTurns,
7577
- tokensByKind: usageCandidates.at(-1) ?? {
7578
- missingReason: "codex-json-usage-not-emitted",
7579
- status: "unavailable"
7580
- },
7581
- toolCalls: metrics.toolCalls
7582
- };
7583
- };
7584
- const parseCodexExecJsonFile = (logPath) => {
7585
- try {
7586
- return parseCodexExecJsonContent(readFileSync(logPath, "utf-8"));
7587
- } catch {
7588
- return {
7589
- interiorTurns: 0,
7590
- tokensByKind: {
7591
- missingReason: "codex-log-missing-or-unreadable",
7592
- status: "unavailable"
7593
- },
7594
- toolCalls: 0
7595
- };
7596
- }
7597
- };
7598
- //#endregion
7599
- //#region src/interior-telemetry/types.ts
7600
- const ROLE_CLASSIFICATION_FALLBACK = "coder";
7601
- const ROLE_CLASSIFICATION_FALLBACK_REASON = "ambiguous-driving-prompt-default-coder";
7602
- //#endregion
7603
- //#region src/interior-telemetry/cursor-store.ts
7604
- const PREAMBLE_CLOSE_TAGS = ["</user_info>", "</git_status>"];
7605
- const REVIEWER_MARKERS = [
7606
- "reviewer",
7607
- "review",
7608
- "findings",
7609
- "pr-review-proof"
7610
- ];
7611
- /** Strong reviewer signals take precedence over incidental coder markers. */
7612
- const STRONG_REVIEWER_MARKERS = [
7613
- "reviewer",
7614
- "findings",
7615
- "pr-review-proof"
7616
- ];
7617
- const CODER_MARKERS = [
7618
- "implementation worker",
7619
- "coder",
7620
- "implement ",
7621
- "write the diff",
7622
- "write the code",
7623
- "you write the"
7624
- ];
7625
- /**
7626
- * Remove negated "write" phrasing before coder marker matching so prohibitions
7627
- * like "do not write" / "never write" never count as coder signals.
7628
- */
7629
- const NEGATED_WRITE_PATTERN = /\b(?:do not|does not|don't|never|no)\s+writ\w*/giu;
7630
- const READ_ONLY_PATTERN = /\bread[\s-]?only\b/giu;
7631
- const scrubNegatedCoderPhrasing = (text) => text.replace(NEGATED_WRITE_PATTERN, " ").replace(READ_ONLY_PATTERN, " ");
7632
- const escapeRegex = (value) => value.replaceAll(/[.*+?^${}()|[\]\\]/gu, "\\$&");
7633
- /** Match markers on token boundaries so substrings like "preview" never hit "review". */
7634
- const markerMatches = (text, marker) => {
7635
- const parts = marker.trim().split(/\s+/u).filter((part) => part.length > 0);
7636
- if (parts.length === 0) return false;
7637
- const pattern = parts.map(escapeRegex).join("\\s+");
7638
- return new RegExp(`(?<![a-z0-9])${pattern}(?![a-z0-9])`, "iu").test(text);
7639
- };
7640
- const VERDICT_MARKER = "PR-REVIEW-PROOF-END";
7641
- const asBytes = (data) => {
7642
- if (Buffer.isBuffer(data)) return data;
7643
- if (data instanceof Uint8Array) return data;
7644
- };
7645
- const isJsonBlob = (data) => {
7646
- for (const byte of data) {
7647
- if (byte === 32 || byte === 9 || byte === 10 || byte === 13) continue;
7648
- return byte === 123;
7649
- }
7650
- return false;
7651
- };
7652
- const partText = (part) => {
7653
- if (typeof part === "string") return part;
7654
- if (!part || typeof part !== "object") return "";
7655
- const record = part;
7656
- if (typeof record.text === "string") return record.text;
7657
- if (typeof record.content === "string") return record.content;
7658
- return "";
7659
- };
7660
- const normalizeMessageContent = (content) => {
7661
- if (typeof content === "string") return content;
7662
- if (!Array.isArray(content)) return "";
7663
- return content.map(partText).join("");
7664
- };
7665
- const stripPreamble = (text) => {
7666
- let remainder = text;
7667
- for (const closeTag of PREAMBLE_CLOSE_TAGS) {
7668
- const index = remainder.indexOf(closeTag);
7669
- if (index !== -1) remainder = remainder.slice(index + closeTag.length);
7670
- }
7671
- return remainder.trim();
7672
- };
7673
- const classifyRoleFromDrivingPrompt = (drivingPrompt) => {
7674
- const normalized = drivingPrompt.toLowerCase();
7675
- const scrubbed = scrubNegatedCoderPhrasing(normalized);
7676
- const reviewerHits = REVIEWER_MARKERS.filter((marker) => markerMatches(normalized, marker));
7677
- const coderHits = CODER_MARKERS.filter((marker) => markerMatches(scrubbed, marker));
7678
- if (reviewerHits.length > 0 && coderHits.length === 0) return {
7679
- reason: `reviewer-markers:${reviewerHits.join(",")}`,
7680
- role: "reviewer"
7681
- };
7682
- if (coderHits.length > 0 && reviewerHits.length === 0) return {
7683
- reason: `coder-markers:${coderHits.join(",")}`,
7684
- role: "coder"
7685
- };
7686
- if (reviewerHits.length > 0 && coderHits.length > 0) {
7687
- if (markerMatches(scrubbed, "implementation worker")) return {
7688
- reason: "conflicting-markers-implementation-worker-priority",
7689
- role: "coder"
7690
- };
7691
- const strongReviewerHits = STRONG_REVIEWER_MARKERS.filter((marker) => markerMatches(normalized, marker));
7692
- if (strongReviewerHits.length > 0) return {
7693
- reason: `conflicting-markers-strong-reviewer-priority:${strongReviewerHits.join(",")}`,
7694
- role: "reviewer"
7695
- };
7696
- }
7697
- return {
7698
- reason: ROLE_CLASSIFICATION_FALLBACK_REASON,
7699
- role: ROLE_CLASSIFICATION_FALLBACK
7700
- };
7701
- };
7702
- const drivingPromptFromMessages = (messages) => {
7703
- const userMessages = messages.filter((message) => message.role === "user");
7704
- if (userMessages.length === 0) return "";
7705
- const afterPreamble = stripPreamble(normalizeMessageContent(userMessages[0]?.content));
7706
- if (afterPreamble.length > 0) return afterPreamble;
7707
- for (const message of userMessages.slice(1)) {
7708
- const text = normalizeMessageContent(message.content).trim();
7709
- if (text.length > 0) return text;
7710
- }
7711
- return "";
7712
- };
7713
- const parseStoreMessages = (storePath) => {
7714
- const require = createRequire(import.meta.url);
7715
- let DatabaseSync;
7716
- try {
7717
- ({DatabaseSync} = require("node:sqlite"));
7718
- } catch {
7719
- throw new Error("node:sqlite requires Node >= 22.5.0 to read cursor store.db");
7720
- }
7721
- const db = new DatabaseSync(storePath, { readOnly: true });
7722
- try {
7723
- if (!db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='blobs'").get()) throw new Error("cursor store.db has no blobs table");
7724
- const rows = db.prepare("SELECT data FROM blobs").all();
7725
- const messages = [];
7726
- for (const row of rows) {
7727
- const data = asBytes(row.data);
7728
- if (!data || !isJsonBlob(data)) continue;
7729
- try {
7730
- const parsed = JSON.parse(Buffer.from(data).toString("utf-8"));
7731
- if (parsed && typeof parsed === "object") messages.push(parsed);
7732
- } catch {
7733
- continue;
7734
- }
7735
- }
7736
- return messages;
7737
- } finally {
7738
- db.close();
7739
- }
7740
- };
7741
- const metricsFromMessages = (messages) => {
7742
- let interiorTurns = 0;
7743
- let toolCalls = 0;
7744
- let verdictEmitted = false;
7745
- for (const message of messages) {
7746
- if (message.role === "assistant") {
7747
- interiorTurns += 1;
7748
- if (normalizeMessageContent(message.content).includes(VERDICT_MARKER)) verdictEmitted = true;
7749
- }
7750
- if (message.role === "tool") toolCalls += 1;
7751
- }
7752
- const classification = classifyRoleFromDrivingPrompt(drivingPromptFromMessages(messages));
7753
- return {
7754
- interiorTurns,
7755
- role: classification.role,
7756
- roleClassificationReason: classification.reason,
7757
- toolCalls,
7758
- verdictEmitted
7759
- };
7760
- };
7761
- const readChatMeta = (storePath) => {
7762
- const metaPath = path.join(path.dirname(storePath), "meta.json");
7763
- try {
7764
- const raw = readFileSync(metaPath, "utf-8");
7765
- const parsed = JSON.parse(raw);
7766
- const meta = {};
7767
- if (typeof parsed.cwd === "string") meta.cwd = parsed.cwd;
7768
- if (typeof parsed.createdAtMs === "number") meta.createdAtMs = parsed.createdAtMs;
7769
- return Object.keys(meta).length > 0 ? meta : void 0;
7770
- } catch {
7771
- return;
7772
- }
7773
- };
7774
- const chatIdFromStorePath = (storePath) => path.basename(path.dirname(storePath));
7775
- const parseCursorStore = (storePath) => {
7776
- const metrics = metricsFromMessages(parseStoreMessages(storePath));
7777
- return {
7778
- chatId: chatIdFromStorePath(storePath),
7779
- meta: readChatMeta(storePath),
7780
- storePath,
7781
- ...metrics
7782
- };
7783
- };
7784
- //#endregion
7785
- //#region src/interior-telemetry/usage-csv.ts
7786
- const COLUMN_ALIASES = {
7787
- cachedInputTokens: [
7788
- "Cache Read",
7789
- "cache read",
7790
- "cache_read",
7791
- "cachedInputTokens"
7792
- ],
7793
- inputTokens: [
7794
- "Input",
7795
- "input",
7796
- "input_tokens",
7797
- "inputTokens",
7798
- "Input (w/ Cache Write)",
7799
- "Input (w/o Cache Write)"
7800
- ],
7801
- outputTokens: [
7802
- "Output Tokens",
7803
- "output tokens",
7804
- "output_tokens",
7805
- "outputTokens"
7806
- ],
7807
- totalTokens: [
7808
- "Total Tokens",
7809
- "total tokens",
7810
- "total_tokens",
7811
- "totalTokens"
7812
- ]
7813
- };
7814
- const parseCsvCell = (value) => {
7815
- if (value === void 0) return null;
7816
- const trimmed = value.trim();
7817
- return trimmed.length === 0 ? null : trimmed;
7818
- };
7819
- const parseIntegerCell = (value) => {
7820
- const cell = parseCsvCell(value);
7821
- if (cell === null) return null;
7822
- const parsed = Math.trunc(Number(cell));
7823
- return Number.isInteger(parsed) && parsed >= 0 ? parsed : null;
7824
- };
7825
- const parseCsvLine = (line) => {
7826
- const fields = [];
7827
- let current = "";
7828
- let inQuotes = false;
7829
- for (let index = 0; index < line.length; index += 1) {
7830
- const char = line[index];
7831
- if (char === "\"") {
7832
- if (inQuotes && line[index + 1] === "\"") {
7833
- current += "\"";
7834
- index += 1;
7835
- } else inQuotes = !inQuotes;
7836
- continue;
7837
- }
7838
- if (char === "," && !inQuotes) {
7839
- fields.push(current);
7840
- current = "";
7841
- continue;
7842
- }
7843
- current += char;
7844
- }
7845
- fields.push(current);
7846
- return fields;
7847
- };
7848
- const headerIndex = (headers, aliases) => {
7849
- const normalized = headers.map((header) => header.trim());
7850
- for (const alias of aliases) {
7851
- const index = normalized.indexOf(alias);
7852
- if (index !== -1) return index;
7853
- }
7854
- return -1;
7855
- };
7856
- const headerIndices = (headers, aliases) => {
7857
- const normalized = headers.map((header) => header.trim());
7858
- const indices = [];
7859
- for (let index = 0; index < normalized.length; index += 1) {
7860
- const header = normalized[index];
7861
- if (header !== void 0 && aliases.some((alias) => alias === header)) indices.push(index);
7862
- }
7863
- return indices;
7864
- };
7865
- const aggregateColumn = (rows, columnIndex) => {
7866
- if (columnIndex === -1) return null;
7867
- let sum = 0;
7868
- let sawValue = false;
7869
- for (const row of rows) {
7870
- const value = parseIntegerCell(row[columnIndex]);
7871
- if (value !== null) {
7872
- sum += value;
7873
- sawValue = true;
7874
- }
7875
- }
7876
- return sawValue ? sum : null;
7877
- };
7878
- const aggregateColumns = (rows, columnIndices) => {
7879
- if (columnIndices.length === 0) return null;
7880
- let sum = 0;
7881
- let sawValue = false;
7882
- for (const row of rows) for (const columnIndex of columnIndices) {
7883
- const value = parseIntegerCell(row[columnIndex]);
7884
- if (value !== null) {
7885
- sum += value;
7886
- sawValue = true;
7887
- }
7888
- }
7889
- return sawValue ? sum : null;
7890
- };
7891
- const parseUsageCsvContent = (content) => {
7892
- const lines = content.split(/\r?\n/u).map((line) => line.trim()).filter((line) => line.length > 0);
7893
- if (lines.length < 2) return {
7894
- missingReason: "cursor-csv-empty-or-header-only",
7895
- status: "unavailable"
7896
- };
7897
- const headers = parseCsvLine(lines[0] ?? "");
7898
- const dataRows = lines.slice(1).map(parseCsvLine);
7899
- const inputIndices = headerIndices(headers, COLUMN_ALIASES.inputTokens);
7900
- const cachedIndex = headerIndex(headers, COLUMN_ALIASES.cachedInputTokens);
7901
- const outputIndex = headerIndex(headers, COLUMN_ALIASES.outputTokens);
7902
- const totalIndex = headerIndex(headers, COLUMN_ALIASES.totalTokens);
7903
- const inputTokens = aggregateColumns(dataRows, inputIndices);
7904
- const cachedInputTokens = aggregateColumn(dataRows, cachedIndex);
7905
- const outputTokens = aggregateColumn(dataRows, outputIndex);
7906
- const totalTokens = aggregateColumn(dataRows, totalIndex) ?? (inputTokens !== null && outputTokens !== null ? inputTokens + outputTokens : null);
7907
- if (totalTokens === null) return {
7908
- missingReason: "cursor-csv-no-total-tokens",
7909
- status: "unavailable"
7910
- };
7911
- return {
7912
- cachedInputTokens,
7913
- inputTokens,
7914
- outputTokens,
7915
- reasoningOutputTokens: null,
7916
- source: "cursor-csv",
7917
- status: "available",
7918
- totalTokens
7919
- };
7920
- };
7921
- const parseUsageCsvFile = (csvPath) => {
7922
- try {
7923
- return parseUsageCsvContent(readFileSync(csvPath, "utf-8"));
7924
- } catch {
7925
- return {
7926
- missingReason: "cursor-csv-missing-or-unreadable",
7927
- status: "unavailable"
7928
- };
7929
- }
7930
- };
7931
- //#endregion
7932
- //#region src/interior-telemetry/extract.ts
7933
- /**
7934
- * Returns true when the store should be ingested under the given epic scope.
7935
- * Absent/empty allowlists match everything (back-compat glob-all behavior).
7936
- */
7937
- const chatMatchesEpicScope = (metrics, scope) => {
7938
- const chatIds = scope.chatIds ?? [];
7939
- const cwds = scope.cwds ?? [];
7940
- if (chatIds.length === 0 && cwds.length === 0) return true;
7941
- if (new Set(chatIds).has(metrics.chatId)) return true;
7942
- const cwd = metrics.meta?.cwd;
7943
- if (cwd !== void 0 && new Set(cwds).has(cwd)) return true;
7944
- return false;
7945
- };
7946
- const UNAVAILABLE_CURSOR_TOKENS = {
7947
- missingReason: "cursor-usage-csv-not-provided",
7948
- status: "unavailable"
7949
- };
7950
- /** Lane CSV has no per-chat breakdown; tokens attach to one cursor row only. */
7951
- const UNAVAILABLE_LANE_LEVEL_CURSOR_TOKENS = {
7952
- missingReason: "cursor-csv-is-lane-level-tokens-attributed-once",
7953
- status: "unavailable"
7954
- };
7955
- const discoverStoreDbPaths = (rootDir) => {
7956
- const storePaths = [];
7957
- const visit = (currentPath) => {
7958
- let entries;
7959
- try {
7960
- entries = readdirSync(currentPath);
7961
- } catch {
7962
- return;
7963
- }
7964
- for (const entry of entries) {
7965
- const fullPath = path.join(currentPath, entry);
7966
- let stats;
7967
- try {
7968
- stats = statSync(fullPath);
7969
- } catch {
7970
- continue;
7971
- }
7972
- if (stats.isFile() && entry === "store.db") {
7973
- storePaths.push(fullPath);
7974
- continue;
7975
- }
7976
- if (stats.isDirectory()) visit(fullPath);
7977
- }
7978
- };
7979
- visit(rootDir);
7980
- return storePaths.toSorted();
7981
- };
7982
- const extractInteriorTelemetry = (options) => {
7983
- const rows = [];
7984
- const cursorTokens = options.usageCsvPath ? parseUsageCsvFile(options.usageCsvPath) : UNAVAILABLE_CURSOR_TOKENS;
7985
- if (options.chatsDir) {
7986
- const storePaths = discoverStoreDbPaths(options.chatsDir);
7987
- let cursorTokensAttributed = false;
7988
- for (const storePath of storePaths) {
7989
- let metrics;
7990
- try {
7991
- metrics = parseCursorStore(storePath);
7992
- } catch {
7993
- continue;
7994
- }
7995
- if (options.epicChatScope && !chatMatchesEpicScope(metrics, options.epicChatScope)) continue;
7996
- let tokensByKind = cursorTokens;
7997
- if (cursorTokensAttributed && cursorTokens.status === "available") tokensByKind = UNAVAILABLE_LANE_LEVEL_CURSOR_TOKENS;
7998
- else if (cursorTokens.status === "available") cursorTokensAttributed = true;
7999
- rows.push({
8000
- engine: "cursor",
8001
- interiorTurns: metrics.interiorTurns,
8002
- lane: options.lane,
8003
- role: metrics.role,
8004
- source: {
8005
- chatId: metrics.chatId,
8006
- kind: "cursor-store",
8007
- path: storePath
8008
- },
8009
- tokensByKind,
8010
- toolCalls: metrics.toolCalls,
8011
- verdictEmitted: metrics.verdictEmitted
8012
- });
8013
- }
8014
- }
8015
- if (options.codexLogPath) {
8016
- const codexMetrics = parseCodexExecJsonFile(options.codexLogPath);
8017
- rows.push({
8018
- engine: "codex",
8019
- interiorTurns: codexMetrics.interiorTurns,
8020
- lane: options.lane,
8021
- role: "coder",
8022
- source: {
8023
- kind: "codex-exec",
8024
- path: options.codexLogPath
8025
- },
8026
- tokensByKind: codexMetrics.tokensByKind,
8027
- toolCalls: codexMetrics.toolCalls,
8028
- verdictEmitted: false
8029
- });
8030
- }
8031
- return rows;
8032
- };
8033
- //#endregion
8034
7505
  //#region src/interior-telemetry/pricing-snapshot.ts
8035
7506
  /** Human-readable provenance tag, embedded in data-gap messages so an unpriced
8036
7507
  * block names the exact snapshot that lacked its model. */
@@ -8760,6 +8231,54 @@ function createBoundaryCheckCommand(output, action) {
8760
8231
  if (record.status !== "ready") throw new Error(`boundary:check refused: ${record.blockingReasons.join("; ")}`);
8761
8232
  }));
8762
8233
  }
8234
+ //#endregion
8235
+ //#region src/checkout-repository.ts
8236
+ function normalizeRepositoryIdentity(repository) {
8237
+ return {
8238
+ name: repository.name.replace(/\.git$/iu, "").toLowerCase(),
8239
+ owner: repository.owner.toLowerCase()
8240
+ };
8241
+ }
8242
+ const GITHUB_HOSTNAME = "github.com";
8243
+ const SCP_GITHUB_REMOTE_PATTERN = /^(?:[^@\s]+@)?github\.com:(?<path>[^\s]+)$/iu;
8244
+ function githubRepositoryPath(remoteUrl) {
8245
+ const scpPath = SCP_GITHUB_REMOTE_PATTERN.exec(remoteUrl)?.groups?.path;
8246
+ if (scpPath) return scpPath;
8247
+ try {
8248
+ const url = new URL(remoteUrl);
8249
+ if (url.hostname.toLowerCase() !== GITHUB_HOSTNAME) return;
8250
+ return url.pathname.replace(/^\//u, "");
8251
+ } catch {
8252
+ return;
8253
+ }
8254
+ }
8255
+ function parseCheckoutRepository(remoteUrl) {
8256
+ const segments = githubRepositoryPath(remoteUrl.trim())?.split("/") ?? [];
8257
+ const [owner, rawName] = segments;
8258
+ const name = rawName?.replace(/\.git$/u, "");
8259
+ if (!(owner && name) || segments.length !== 2) throw new Error(`Could not derive a GitHub repository from checkout origin ${remoteUrl}; configure origin as a github.com owner/repository remote.`);
8260
+ return normalizeRepositoryIdentity({
8261
+ name,
8262
+ owner
8263
+ });
8264
+ }
8265
+ function checkoutRepository(cwd) {
8266
+ let remoteUrl;
8267
+ try {
8268
+ remoteUrl = runCapture("git", [
8269
+ "remote",
8270
+ "get-url",
8271
+ "origin"
8272
+ ], cwd).stdout;
8273
+ } catch {
8274
+ throw new Error("Could not read checkout origin; repository routing must be rooted in trusted checkout configuration.");
8275
+ }
8276
+ return parseCheckoutRepository(remoteUrl);
8277
+ }
8278
+ const repositorySlug = (repository) => {
8279
+ const normalized = normalizeRepositoryIdentity(repository);
8280
+ return `${normalized.owner}/${normalized.name}`;
8281
+ };
8763
8282
  var CloseoutValidationError = class extends Error {
8764
8283
  constructor(message) {
8765
8284
  super(message);
@@ -8802,18 +8321,8 @@ const boundaryThermoStatus = (attestation) => {
8802
8321
  };
8803
8322
  //#endregion
8804
8323
  //#region src/epic-closeout/gather.ts
8805
- const BUDGET_NOTE = "bounded aggregation of pre-captured telemetry; no per-commit forensic dig";
8806
- const hasInteriorInputs = (opts) => Boolean(opts.chatsDir ?? opts.usageCsvPath ?? opts.codexLogPath);
8324
+ const BUDGET_NOTE = "bounded closeout report: trace diagnostics scan plus boundary-thermo attestation; no per-commit forensic dig";
8807
8325
  function gatherCloseoutInputs(opts) {
8808
- const hasEpicChatAllowlist = (opts.epicChatScope?.chatIds?.length ?? 0) > 0 || (opts.epicChatScope?.cwds?.length ?? 0) > 0;
8809
- const scopedByEpicIssueSet = Boolean(opts.chatsDir) && hasEpicChatAllowlist;
8810
- const interiorRows = hasInteriorInputs(opts) ? extractInteriorTelemetry({
8811
- chatsDir: opts.chatsDir,
8812
- codexLogPath: opts.codexLogPath,
8813
- ...opts.epicChatScope === void 0 ? {} : { epicChatScope: opts.epicChatScope },
8814
- lane: opts.lane,
8815
- usageCsvPath: opts.usageCsvPath
8816
- }) : [];
8817
8326
  const diagnostics = scanFactoryTraceDiagnostics({
8818
8327
  filters: {
8819
8328
  repo: opts.repo,
@@ -8826,114 +8335,15 @@ function gatherCloseoutInputs(opts) {
8826
8335
  });
8827
8336
  return {
8828
8337
  budget: {
8829
- interiorSourcesConsumed: interiorRows.length,
8830
8338
  note: BUDGET_NOTE,
8831
- scoping: {
8832
- epic: opts.epic,
8833
- issueFilter: opts.issue,
8834
- prFilter: opts.pr,
8835
- repo: opts.repo,
8836
- scopedByEpicIssueSet,
8837
- window: {
8838
- from: opts.from,
8839
- to: opts.to
8840
- }
8841
- },
8842
8339
  tier: "overseer"
8843
8340
  },
8844
- diagnostics,
8845
- interiorRows
8341
+ diagnostics
8846
8342
  };
8847
8343
  }
8848
8344
  //#endregion
8849
- //#region src/epic-closeout/row-identity.ts
8850
- const interiorRowDisambig = (row) => row.source.chatId ?? path.basename(row.source.path);
8851
- //#endregion
8852
- //#region src/epic-closeout/metric-rows.ts
8853
- const INTERIOR_TOTAL_TOOL_CALLS_METRIC = "interior.total-tool-calls";
8854
- const INTERIOR_TOTAL_TURNS_METRIC = "interior.total-turns";
8855
- const interiorSourceDetail = (row) => row.source.chatId ?? row.source.path;
8856
- const interiorPrefix = (row) => {
8857
- const disambig = interiorRowDisambig(row);
8858
- return `interior.${row.lane}.${row.role}.${row.engine}.${disambig}`;
8859
- };
8860
- function buildMetricRows(input) {
8861
- const rows = [];
8862
- let totalInteriorTurns = 0;
8863
- let totalInteriorToolCalls = 0;
8864
- for (const interior of input.interiorRows) {
8865
- const prefix = interiorPrefix(interior);
8866
- const sourceDetail = interiorSourceDetail(interior);
8867
- rows.push({
8868
- label: `${interior.lane} ${interior.role} ${interior.engine} interior turns`,
8869
- metric: `${prefix}.turns`,
8870
- source: {
8871
- detail: sourceDetail,
8872
- kind: "interior-telemetry"
8873
- },
8874
- unit: "count",
8875
- value: interior.interiorTurns
8876
- }, {
8877
- label: `${interior.lane} ${interior.role} ${interior.engine} tool calls`,
8878
- metric: `${prefix}.tool-calls`,
8879
- source: {
8880
- detail: sourceDetail,
8881
- kind: "interior-telemetry"
8882
- },
8883
- unit: "count",
8884
- value: interior.toolCalls
8885
- }, {
8886
- label: `${interior.lane} ${interior.role} ${interior.engine} verdict emitted`,
8887
- metric: `${prefix}.verdict-emitted`,
8888
- source: {
8889
- detail: sourceDetail,
8890
- kind: "interior-telemetry"
8891
- },
8892
- unit: "boolean",
8893
- value: interior.verdictEmitted
8894
- });
8895
- if (interior.tokensByKind.status === "available") rows.push({
8896
- label: `${interior.lane} ${interior.role} ${interior.engine} total tokens`,
8897
- metric: `${prefix}.total-tokens`,
8898
- source: {
8899
- detail: `${sourceDetail};tokensSource=${interior.tokensByKind.source}`,
8900
- kind: "interior-telemetry"
8901
- },
8902
- unit: "tokens",
8903
- value: interior.tokensByKind.totalTokens
8904
- });
8905
- totalInteriorTurns += interior.interiorTurns;
8906
- totalInteriorToolCalls += interior.toolCalls;
8907
- }
8908
- if (input.interiorRows.length > 0) {
8909
- const interiorPaths = [...new Set(input.interiorRows.map((row) => interiorSourceDetail(row)))].join(",");
8910
- rows.push({
8911
- label: "Interior total turns",
8912
- metric: INTERIOR_TOTAL_TURNS_METRIC,
8913
- source: {
8914
- detail: interiorPaths,
8915
- kind: "interior-telemetry"
8916
- },
8917
- unit: "count",
8918
- value: totalInteriorTurns
8919
- }, {
8920
- label: "Interior total tool calls",
8921
- metric: INTERIOR_TOTAL_TOOL_CALLS_METRIC,
8922
- source: {
8923
- detail: interiorPaths,
8924
- kind: "interior-telemetry"
8925
- },
8926
- unit: "count",
8927
- value: totalInteriorToolCalls
8928
- });
8929
- }
8930
- return rows.toSorted((left, right) => left.metric.localeCompare(right.metric));
8931
- }
8932
- //#endregion
8933
8345
  //#region src/epic-closeout/report.ts
8934
- const INTERIOR_TURN_ATTENTION = 3;
8935
8346
  const ROW_LIMIT_BLINDSPOT_ID = "closeout-row-limit";
8936
- const SUMMARY_METRICS = new Set([INTERIOR_TOTAL_TOOL_CALLS_METRIC, INTERIOR_TOTAL_TURNS_METRIC]);
8937
8347
  const boundaryThermoSource = (owner) => ({
8938
8348
  detail: `deep thermo owner: ${owner} (#559 boundary-thermo attestation)`,
8939
8349
  kind: "boundary-thermo-attestation"
@@ -8984,76 +8394,31 @@ const assertMetricSources = (metrics) => {
8984
8394
  for (const metric of metrics) if (!metric.source.detail.trim()) throw new CloseoutValidationError(`metric ${metric.metric} is missing source.detail`);
8985
8395
  };
8986
8396
  const boundCloseoutRows = (input) => {
8987
- const summaries = input.interiorMetrics.filter((metric) => SUMMARY_METRICS.has(metric.metric));
8988
- const details = input.interiorMetrics.filter((metric) => !SUMMARY_METRICS.has(metric.metric));
8989
- const retainedAlways = [...summaries, ...input.boundaryMetrics];
8397
+ const retainedAlways = [...input.boundaryMetrics];
8990
8398
  let remaining = Math.max(0, MAX_CLOSEOUT_ROWS - retainedAlways.length - 1);
8991
8399
  const blindspots = input.blindspots.filter((blindspot) => blindspot.id !== ROW_LIMIT_BLINDSPOT_ID).slice(0, remaining);
8992
8400
  remaining -= blindspots.length;
8993
8401
  const lessons = input.lessons.slice(0, remaining);
8994
8402
  remaining -= lessons.length;
8995
- const metrics = [...details.slice(0, remaining), ...retainedAlways].toSorted((left, right) => left.metric.localeCompare(right.metric));
8403
+ const metrics = [...retainedAlways].toSorted((left, right) => left.metric.localeCompare(right.metric));
8996
8404
  const omittedBlindspots = input.blindspots.length - blindspots.length;
8997
8405
  const omittedLessons = input.lessons.length - lessons.length;
8998
- const omittedMetrics = input.interiorMetrics.length + input.boundaryMetrics.length - metrics.length;
8406
+ const omittedMetrics = input.boundaryMetrics.length - metrics.length;
8999
8407
  return {
9000
8408
  blindspots: [...blindspots, {
9001
8409
  id: ROW_LIMIT_BLINDSPOT_ID,
9002
- note: `Closeout omitted ${omittedMetrics} metric row(s), ${omittedLessons} lesson row(s), and ${omittedBlindspots} blindspot row(s) to keep the strict artifact and ledger within ${MAX_CLOSEOUT_ROWS} rows; aggregate interior totals and boundary-thermo metrics are retained.`
8410
+ note: `Closeout omitted ${omittedMetrics} metric row(s), ${omittedLessons} lesson row(s), and ${omittedBlindspots} blindspot row(s) to keep the strict artifact and ledger within ${MAX_CLOSEOUT_ROWS} rows; boundary-thermo metrics are retained.`
9003
8411
  }],
9004
8412
  lessons,
9005
8413
  metrics
9006
8414
  };
9007
8415
  };
9008
- const epicScopingBlindspotNote = (scoping) => {
9009
- const filters = [];
9010
- if (scoping.issueFilter !== void 0) filters.push(`issue=${scoping.issueFilter}`);
9011
- if (scoping.prFilter !== void 0) filters.push(`pr=${scoping.prFilter}`);
9012
- const filterSuffix = filters.length > 0 ? ` + ${filters.join(" + ")} filter(s)` : "";
9013
- const narrowNote = filters.length > 0 ? " Supplied trace filter(s) narrow the scan but do not guarantee epic-exact attribution." : "";
9014
- const windowRepoScope = `date window (${scoping.window.from}..${scoping.window.to}) + repo ${scoping.repo}${filterSuffix}`;
9015
- if (scoping.scopedByEpicIssueSet) return `Cursor-store interior telemetry was scoped to this epic's lanes via the chat/cwd allowlist; codex-exec logs are operator-supplied and not chat-filtered. Interior telemetry remains scoped by ${windowRepoScope}, NOT by the epic's child-issue set, so rows in this window may include concurrent non-epic factory work; closeout does not resolve child issues (bounded, no network).${narrowNote}`;
9016
- return `Telemetry is scoped by ${windowRepoScope}, NOT by the epic's child-issue set. Interior rows in this window may include concurrent non-epic factory work; closeout does not resolve child issues (bounded, no network).${narrowNote}`;
9017
- };
9018
8416
  const DOLLAR_PRICING_DEFERRED_BLINDSPOT = {
9019
8417
  id: "dollar-pricing-deferred-to-cost-analysis-skill",
9020
- note: "Dollar pricing is intentionally deferred to the patronage-software-factory-cost-analysis skill (canonical PRICES/TIERS). This closeout reports token volumes only, to avoid forking that registry."
8418
+ note: "Native token families are harvested in the separate retro envelope. Pricing policy remains with the patronage-software-factory-cost-analysis skill (canonical PRICES/TIERS), so this boundary-thermo closeout neither reports token volume nor prices it."
9021
8419
  };
9022
8420
  const deriveBlindspots = (input, boundaryThermo) => {
9023
8421
  const blindspots = [BASELINE_BLINDSPOT, DOLLAR_PRICING_DEFERRED_BLINDSPOT];
9024
- if (input.budget.scoping) blindspots.push({
9025
- id: "epic-scoping-is-window-not-child-issue-set",
9026
- note: epicScopingBlindspotNote(input.budget.scoping)
9027
- });
9028
- else blindspots.push({
9029
- id: "epic-scoping-is-window-not-child-issue-set",
9030
- note: epicScopingBlindspotNote({
9031
- epic: String(input.epic),
9032
- issueFilter: void 0,
9033
- prFilter: void 0,
9034
- repo: input.repo,
9035
- scopedByEpicIssueSet: false,
9036
- window: {
9037
- from: "unknown",
9038
- to: "unknown"
9039
- }
9040
- })
9041
- });
9042
- if (input.interiorRows.length === 0) blindspots.push({
9043
- id: "no-interior-telemetry",
9044
- note: "no interior telemetry collected for this epic"
9045
- });
9046
- const unavailableByReason = /* @__PURE__ */ new Map();
9047
- for (const row of input.interiorRows) if (row.tokensByKind.status === "unavailable") {
9048
- const reason = row.tokensByKind.missingReason;
9049
- unavailableByReason.set(reason, (unavailableByReason.get(reason) ?? 0) + 1);
9050
- }
9051
- const aggregatedUnavailable = [...unavailableByReason.entries()].map(([missingReason, count]) => ({
9052
- id: `interior-tokens-unavailable-${missingReason}`,
9053
- note: `token capture unavailable for ${count} interior row(s): ${missingReason}`,
9054
- reason: missingReason
9055
- })).toSorted((left, right) => left.id.localeCompare(right.id));
9056
- blindspots.push(...aggregatedUnavailable);
9057
8422
  if (isBoundaryThermoTripwireTripped(boundaryThermo)) blindspots.push({
9058
8423
  id: BOUNDARY_THERMO_NOWHERE_BLINDSPOT_ID,
9059
8424
  note: `TRIPWIRE (#559 'thermo nowhere'): this wave/epic closed with zero boundary-thermo runs and no waiver. Deep thermo owner: ${boundaryThermo.owner}. A wave closing with zero boundary-thermo runs is flagged; run the deep Oracle/Fable thermo at the boundary or record an explicit waiver.`,
@@ -9072,43 +8437,6 @@ const deriveLessons = (input, boundaryThermo) => {
9072
8437
  rationale: waiverRationale,
9073
8438
  source: boundaryThermoSource(boundaryThermo.owner)
9074
8439
  });
9075
- for (const row of input.interiorRows) {
9076
- const disambig = interiorRowDisambig(row);
9077
- if (row.interiorTurns >= INTERIOR_TURN_ATTENTION) lessons.push({
9078
- id: `interior-turn-cap-${row.lane}-${row.role}-${row.engine}-${disambig}`,
9079
- landingSpot: "profile",
9080
- lesson: `interior loop hit/near the cap on ${row.lane}/${row.role}; consider tightening the brief or raising review signal`,
9081
- rationale: `interiorTurns=${row.interiorTurns} >= cap ${INTERIOR_TURN_ATTENTION}`,
9082
- source: {
9083
- detail: row.source.chatId ?? row.source.path,
9084
- kind: "interior-telemetry"
9085
- }
9086
- });
9087
- }
9088
- const unavailableLessonCounts = /* @__PURE__ */ new Map();
9089
- for (const row of input.interiorRows) {
9090
- if (row.tokensByKind.status !== "unavailable") continue;
9091
- const { missingReason } = row.tokensByKind;
9092
- const key = `${row.engine}\0${missingReason}`;
9093
- const existing = unavailableLessonCounts.get(key);
9094
- if (existing) existing.count += 1;
9095
- else unavailableLessonCounts.set(key, {
9096
- count: 1,
9097
- engine: row.engine,
9098
- missingReason
9099
- });
9100
- }
9101
- const aggregatedTokenLessons = [...unavailableLessonCounts.values()].map(({ count, engine, missingReason }) => ({
9102
- id: `wire-token-export-${engine}-${missingReason}`,
9103
- landingSpot: "CLI",
9104
- lesson: `token volume capture missing for ${count} ${engine} interior row(s); wire usage export so closeout can report its token volume (dollar pricing stays in the patronage-software-factory-cost-analysis skill)`,
9105
- rationale: missingReason,
9106
- source: {
9107
- detail: `${count} row(s)`,
9108
- kind: "interior-telemetry"
9109
- }
9110
- })).toSorted((left, right) => left.id.localeCompare(right.id));
9111
- lessons.push(...aggregatedTokenLessons);
9112
8440
  if (input.lessons) lessons.push(...input.lessons);
9113
8441
  return lessons;
9114
8442
  };
@@ -9120,7 +8448,7 @@ const buildLedgerRows = (input) => {
9120
8448
  key: metric.metric,
9121
8449
  repo: input.repo,
9122
8450
  rowType: "metric",
9123
- schemaVersion: 3,
8451
+ schemaVersion: 4,
9124
8452
  source: flattenSource(metric.source),
9125
8453
  unit: metric.unit,
9126
8454
  value: ledgerValue(metric.value)
@@ -9133,7 +8461,7 @@ const buildLedgerRows = (input) => {
9133
8461
  landingSpot: lesson.landingSpot,
9134
8462
  repo: input.repo,
9135
8463
  rowType: "lesson",
9136
- schemaVersion: 3,
8464
+ schemaVersion: 4,
9137
8465
  source: lesson.source ? flattenSource(lesson.source) : "derived",
9138
8466
  value: lesson.lesson
9139
8467
  });
@@ -9144,7 +8472,7 @@ const buildLedgerRows = (input) => {
9144
8472
  key: blindspot.id,
9145
8473
  repo: input.repo,
9146
8474
  rowType: "blindspot",
9147
- schemaVersion: 3,
8475
+ schemaVersion: 4,
9148
8476
  source: "derived",
9149
8477
  value: blindspot.note
9150
8478
  });
@@ -9156,9 +8484,8 @@ function buildEpicCloseoutReport(input) {
9156
8484
  const epic = String(input.epic);
9157
8485
  const generatedAt = input.generatedAt ?? (/* @__PURE__ */ new Date()).toISOString();
9158
8486
  const boundaryThermo = resolveBoundaryThermo(input.boundaryThermo);
9159
- const interiorMetrics = buildMetricRows({ interiorRows: input.interiorRows });
9160
8487
  const boundaryMetrics = boundaryThermoMetrics(boundaryThermo);
9161
- let metrics = [...interiorMetrics, ...boundaryMetrics];
8488
+ let metrics = [...boundaryMetrics];
9162
8489
  let couldNotSee = deriveBlindspots(input, boundaryThermo);
9163
8490
  if (couldNotSee.length === 0) throw new CloseoutValidationError("closeout must include a non-empty 'what this closeout could not see' section");
9164
8491
  let lessons = deriveLessons(input, boundaryThermo);
@@ -9166,7 +8493,6 @@ function buildEpicCloseoutReport(input) {
9166
8493
  const { blindspots: boundedBlindspots, lessons: boundedLessons, metrics: boundedMetrics } = boundCloseoutRows({
9167
8494
  blindspots: couldNotSee,
9168
8495
  boundaryMetrics,
9169
- interiorMetrics,
9170
8496
  lessons
9171
8497
  });
9172
8498
  couldNotSee = boundedBlindspots;
@@ -9196,7 +8522,7 @@ function buildEpicCloseoutReport(input) {
9196
8522
  lessons,
9197
8523
  metrics,
9198
8524
  repo: input.repo,
9199
- schemaVersion: 3
8525
+ schemaVersion: 4
9200
8526
  };
9201
8527
  return closeoutArtifactSchema.parse(artifact);
9202
8528
  }
@@ -9704,7 +9030,7 @@ function renderCloseoutMarkdown(artifact) {
9704
9030
  const ledgerLines = [
9705
9031
  "## HQ ledger row-set",
9706
9032
  "",
9707
- `${artifact.ledgerRows.length} append-ready rows emitted to the sibling \`${sanitizeEpicFilename(artifact.epic)}.json\` (schemaVersion 3) for the HQ lessons ledger (#433).`,
9033
+ `${artifact.ledgerRows.length} append-ready rows emitted to the sibling \`${sanitizeEpicFilename(artifact.epic)}.json\` (schemaVersion 4) for the HQ lessons ledger (#433).`,
9708
9034
  ""
9709
9035
  ];
9710
9036
  return [
@@ -9748,532 +9074,88 @@ function writeCloseoutArtifact(opts) {
9748
9074
  };
9749
9075
  }
9750
9076
  //#endregion
9751
- //#region src/epic-structure.ts
9752
- /**
9753
- * Producer-side `epic-structure` v1 emitter (epic #132, lane #135).
9754
- *
9755
- * `psf epic:publish-structure` reads a planner's ephemeral `dag.yml` (never
9756
- * committed — see epic #132 / ADR 0018), validates the graph fail-closed, and
9757
- * POSTs an `epic-structure` v1 event to HQ's `/api/ingest` boundary. This
9758
- * module is the pure core: parse-to-IR validation, the wire mapping, and the
9759
- * deterministic content-addressed eventId. All I/O (stdin/file read, fetch)
9760
- * lives in the command wrapper.
9761
- *
9762
- * WIRE PARITY (do not fork the schema): the emitted payload is the exact shape
9763
- * HQ's `EpicGraphSchema` accepts (`software-factory-hq/src/contracts/
9764
- * epic-schemas.ts`) and is built by hand the same way `seed-dev.ts` builds it
9765
- * — no re-declared zod twin. HQ's `epic-structure-parity.test.ts` imports
9766
- * {@link buildEpicStructureEvent} and runs its output through the real
9767
- * `EpicGraphSchema`, so any drift (field, enum, bound, strictness) fails CI.
9768
- *
9769
- * The `dag.yml` schema itself is documented with the epic skill (lane #137);
9770
- * this module is the executable contract, not a second source of truth.
9771
- */
9772
- const EPIC_STRUCTURE_SCHEMA_VERSION = 1;
9773
- /**
9774
- * DAG-node PLANNING statuses — the planning-plane vocabulary a planner authors
9775
- * on a `dag.yml` node. Carried forward by ADR 0018 §4 and defined by the epic
9776
- * skill (`reference/epic-artifacts.md`) and both `CONTEXT.md` glossaries.
9777
- *
9778
- * A DAG node is a unit of *plan*; an HQ lane is a unit of *runtime execution*.
9779
- * These are two planes with two vocabularies and must not be conflated — the
9780
- * producer validates and emits ONLY planning statuses:
9781
- *
9782
- * - `open` authored, not yet done
9783
- * - `closed` done (a PR merged, or otherwise resolved)
9784
- * - `satisfied-on-main` already true on main without a dedicated PR
9785
- * - `parked` real node, scope still moving — relabeled off
9786
- * `ready-for-agent`, never closed
9787
- *
9788
- * HQ's `DagNodeSchema` (`software-factory-hq/src/contracts/epic-schemas.ts`)
9789
- * accepts this planning vocabulary on ingest AND overlays a runtime LANE status
9790
- * onto a node once a lane is linked (`syncNodesWithLanes` /
9791
- * `projectEpicMembership`) — the overlay is HQ-internal; the producer never
9792
- * emits a lane status. HQ's `epic-structure-parity.test.ts` fails CI if the
9793
- * producer emits any status HQ won't accept.
9794
- */
9795
- const EPIC_STRUCTURE_NODE_STATUSES = [
9796
- "open",
9797
- "closed",
9798
- "satisfied-on-main",
9799
- "parked"
9800
- ];
9801
- /**
9802
- * Fail-closed validation error carrying every offending node/edge diagnostic.
9803
- */
9804
- var EpicStructureValidationError = class extends Error {
9805
- diagnostics;
9806
- constructor(diagnostics) {
9807
- super(`epic:publish-structure refused: dag graph is invalid\n${diagnostics.map((line) => ` - ${line}`).join("\n")}`);
9808
- this.name = "EpicStructureValidationError";
9809
- this.diagnostics = diagnostics;
9810
- }
9077
+ //#region src/commands/closeout.ts
9078
+ const formatUtcDate = (date) => date.toISOString().slice(0, 10);
9079
+ const nonEmptyRepoRoot = (value) => {
9080
+ if (value.trim() === "") throw new InvalidArgumentError("--repo-root requires a non-empty path");
9081
+ return value;
9811
9082
  };
9812
- const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
9813
- const nonEmptyString = (value) => typeof value === "string" && value.trim().length > 0;
9814
- const isPositiveInteger = (value) => typeof value === "number" && Number.isInteger(value) && value > 0;
9815
- const slugify = (value) => value.trim().toLowerCase().replaceAll(/[^a-z0-9]+/gu, "-").replaceAll(/^-+|-+$/gu, "") || "epic";
9816
- const laneIdFor = (repo, issue) => `lane-${repo}-issue-${issue}`;
9817
- const validateNodes = (rawNodes, diagnostics) => {
9818
- const nodes = [];
9819
- const slugs = /* @__PURE__ */ new Set();
9820
- for (const [index, raw] of rawNodes.entries()) {
9821
- if (!isPlainObject(raw)) {
9822
- diagnostics.push(`node[${index}]: expected a mapping`);
9823
- continue;
9824
- }
9825
- const label = nonEmptyString(raw.slug) ? `"${raw.slug}"` : `node[${index}]`;
9826
- if (!nonEmptyString(raw.slug)) diagnostics.push(`node[${index}]: missing required \`slug\``);
9827
- else if (slugs.has(raw.slug)) diagnostics.push(`node ${label}: duplicate slug`);
9828
- else slugs.add(raw.slug);
9829
- if (!nonEmptyString(raw.title)) diagnostics.push(`node ${label}: missing required \`title\``);
9830
- const external = raw.external === true;
9831
- if (!external && !isPositiveInteger(raw.issue)) diagnostics.push(`node ${label}: \`issue\` number is required — non-external nodes must carry a minted issue (this runs post-ratification); set \`external: true\` to exempt an adoption/upstream node`);
9832
- let status = "open";
9833
- if (raw.status === void 0) status = "open";
9834
- else if (EPIC_STRUCTURE_NODE_STATUSES.includes(raw.status)) status = raw.status;
9835
- else diagnostics.push(`node ${label}: \`status\` must be one of ${EPIC_STRUCTURE_NODE_STATUSES.join(", ")} (DAG-node planning statuses, not HQ lane statuses)`);
9836
- if (raw.laneId !== void 0 && !nonEmptyString(raw.laneId)) diagnostics.push(`node ${label}: \`laneId\` must be a non-empty string`);
9837
- nodes.push({
9838
- external,
9839
- ...isPositiveInteger(raw.issue) ? { issue: raw.issue } : {},
9840
- ...nonEmptyString(raw.laneId) ? { laneId: raw.laneId } : {},
9841
- slug: nonEmptyString(raw.slug) ? raw.slug : `node[${index}]`,
9842
- status,
9843
- title: nonEmptyString(raw.title) ? raw.title : ""
9844
- });
9845
- }
9083
+ const codexThreadIdsForCloseout = (env = process.env) => {
9084
+ const threadId = normalizeSessionId(env.CODEX_THREAD_ID);
9085
+ return threadId === void 0 ? [] : [threadId];
9086
+ };
9087
+ const resolveRetroSubject = (issue, pr) => {
9088
+ if (issue !== void 0) return { issueNumber: issue };
9089
+ if (pr !== void 0) return { prNumber: pr };
9090
+ };
9091
+ const tokenFamilySummary = (tokenFamilies) => {
9092
+ const available = [];
9093
+ const unavailable = [];
9094
+ for (const family of ["claude", "gpt"]) if (tokenFamilies[family] === void 0) unavailable.push(family);
9095
+ else available.push(family);
9846
9096
  return {
9847
- nodes,
9848
- slugs
9097
+ available,
9098
+ unavailable
9849
9099
  };
9850
9100
  };
9851
- const validateEdges = (rawEdges, slugs, diagnostics) => {
9852
- const edges = [];
9853
- for (const [index, raw] of rawEdges.entries()) {
9854
- if (!isPlainObject(raw)) {
9855
- diagnostics.push(`edge[${index}]: expected a mapping`);
9856
- continue;
9857
- }
9858
- if (raw.type !== "depends-on") continue;
9859
- if (!nonEmptyString(raw.from) || !nonEmptyString(raw.to)) {
9860
- diagnostics.push(`edge[${index}]: depends-on edge requires string \`from\` and \`to\``);
9861
- continue;
9862
- }
9863
- if (!slugs.has(raw.from)) diagnostics.push(`edge ${raw.from} -> ${raw.to}: \`from\` references unknown slug "${raw.from}"`);
9864
- if (!slugs.has(raw.to)) diagnostics.push(`edge ${raw.from} -> ${raw.to}: \`to\` references unknown slug "${raw.to}"`);
9865
- if (raw.from === raw.to) diagnostics.push(`edge ${raw.from} -> ${raw.to}: self-dependency (cycle)`);
9866
- edges.push({
9867
- from: raw.from,
9868
- to: raw.to
9869
- });
9870
- }
9871
- return edges;
9101
+ const reportLocalCloseoutOutcome = (output, input) => {
9102
+ const harvest = tokenFamilySummary(input.retro.envelope.tokenFamilies);
9103
+ output.stdout.write(`local artifacts: ${input.paths.markdownPath}, ${input.paths.jsonPath}, and ${input.retro.envelopePath}\n`);
9104
+ output.stdout.write(`boundary thermo input: runs=${input.boundaryThermo.runs}, owner=${input.boundaryThermo.owner}, waived=${input.boundaryThermo.waived}${input.boundaryThermo.waiverRationale === void 0 ? "" : `, waiver rationale=${input.boundaryThermo.waiverRationale}`}\n`);
9105
+ if (harvest.unavailable.length > 0) output.stderr.write(`harvest data unavailable: ${harvest.available.length === 0 ? "no token families were recorded" : `missing ${harvest.unavailable.join(" and ")} token family data`}; recorded gaps: ${input.retro.envelope.dataGaps.join(" | ")}\n`);
9106
+ else if (input.retro.envelope.dataGaps.length > 0) output.stderr.write(`retro-envelope data gaps recorded: ${input.retro.envelope.dataGaps.join(" | ")}\n`);
9872
9107
  };
9873
- /**
9874
- * Detects a dependency cycle over the depends-on edges and returns the offending
9875
- * cycle path (`a -> b -> a`) if one exists, else undefined. Only edges whose
9876
- * endpoints both resolve to known slugs are walked, so unknown-slug diagnostics
9877
- * are reported independently and never masquerade as cycles.
9878
- */
9879
- const findCycle = (slugs, edges) => {
9880
- const adjacency = /* @__PURE__ */ new Map();
9881
- for (const { from, to } of edges) if (slugs.has(from) && slugs.has(to)) {
9882
- const list = adjacency.get(from) ?? [];
9883
- list.push(to);
9884
- adjacency.set(from, list);
9885
- }
9886
- const UNVISITED = 0;
9887
- const IN_STACK = 1;
9888
- const DONE = 2;
9889
- const state = /* @__PURE__ */ new Map();
9890
- const stack = [];
9891
- const walk = (node) => {
9892
- state.set(node, IN_STACK);
9893
- stack.push(node);
9894
- for (const next of adjacency.get(node) ?? []) {
9895
- const marker = state.get(next) ?? UNVISITED;
9896
- if (marker === IN_STACK) {
9897
- const start = stack.indexOf(next);
9898
- return [...stack.slice(start), next];
9899
- }
9900
- if (marker === UNVISITED) {
9901
- const found = walk(next);
9902
- if (found) return found;
9903
- }
9904
- }
9905
- stack.pop();
9906
- state.set(node, DONE);
9907
- };
9908
- for (const slug of slugs) if ((state.get(slug) ?? UNVISITED) === UNVISITED) {
9909
- const found = walk(slug);
9910
- if (found) return found;
9108
+ const reportRetroHqDeliveryOutcome = (output, outcome) => {
9109
+ output.stderr.write(`advisory HQ handoff ${outcome} for the retro envelope; local closeout success does not depend on HQ.\n`);
9110
+ };
9111
+ const reportArtifactHqHandoffOutcome = (output, outcome) => {
9112
+ output.stderr.write(`advisory HQ handoff ${outcome} for the closeout artifact; local closeout success does not depend on HQ.\n`);
9113
+ };
9114
+ const handoffCloseoutToHq = async (input) => {
9115
+ const epicIssue = Number(input.artifact.epic);
9116
+ let artifactOutcome = "deferred";
9117
+ try {
9118
+ artifactOutcome = await deferProjectHqIngest({
9119
+ cwd: input.repoRoot,
9120
+ kind: "epic-closeout",
9121
+ payload: input.artifact,
9122
+ payloadPath: input.written.jsonPath,
9123
+ ...Number.isSafeInteger(epicIssue) && epicIssue > 0 ? { subject: { issueNumber: epicIssue } } : {}
9124
+ }, input.hq);
9125
+ } catch {
9126
+ artifactOutcome = "held";
9911
9127
  }
9128
+ try {
9129
+ reportArtifactHqHandoffOutcome(input.output, artifactOutcome);
9130
+ } catch {}
9131
+ const retroSubject = resolveRetroSubject(input.options.issue, input.options.pr);
9132
+ let outcome = "held";
9133
+ try {
9134
+ outcome = await deferProjectHqIngest({
9135
+ cwd: input.repoRoot,
9136
+ kind: "retro-envelope",
9137
+ payload: input.retro.envelope,
9138
+ payloadPath: input.retro.envelopePath,
9139
+ ...retroSubject === void 0 ? {} : { subject: retroSubject }
9140
+ }, input.hq);
9141
+ } catch {}
9142
+ try {
9143
+ reportRetroHqDeliveryOutcome(input.output, outcome);
9144
+ } catch {}
9912
9145
  };
9913
- const resolveEpicId = (epic) => {
9914
- if (typeof epic === "number") return String(epic);
9915
- if (nonEmptyString(epic)) return epic.trim();
9916
- };
9917
- /**
9918
- * Validates a dag document fail-closed. Throws {@link
9919
- * EpicStructureValidationError} with a diagnostic per offending node/edge.
9920
- */
9921
- const validateDagDocument = (input) => {
9922
- const doc = input.document;
9923
- const diagnostics = [];
9924
- if (!isPlainObject(doc)) throw new EpicStructureValidationError(["dag document must be a YAML/JSON mapping with `epic` and `nodes`"]);
9925
- const epicId = resolveEpicId(doc.epic);
9926
- if (epicId === void 0) diagnostics.push("dag document requires a top-level `epic` id");
9927
- const repo = input.repo ?? (nonEmptyString(doc.repo) ? doc.repo.trim() : void 0);
9928
- if (repo === void 0) diagnostics.push("repo is required — pass `--repo <name>` or set `repo:` in the dag document (needed for the epic record and lane-id derivation)");
9929
- const rawNodes = Array.isArray(doc.nodes) ? doc.nodes : void 0;
9930
- if (rawNodes === void 0 || rawNodes.length === 0) diagnostics.push("dag document requires a non-empty `nodes` array");
9931
- const { nodes, slugs } = validateNodes(rawNodes ?? [], diagnostics);
9932
- const edges = validateEdges(Array.isArray(doc.edges) ? doc.edges : [], slugs, diagnostics);
9933
- const cycle = findCycle(slugs, edges);
9934
- if (cycle) diagnostics.push(`dependency cycle detected: ${cycle.join(" -> ")}`);
9935
- if (diagnostics.length > 0 || epicId === void 0 || repo === void 0) throw new EpicStructureValidationError(diagnostics);
9936
- return {
9937
- boundary: slugify(input.boundary ?? (nonEmptyString(doc.boundary) ? doc.boundary : `epic-${epicId}`)),
9938
- edges,
9939
- epicId,
9940
- name: input.name ?? (nonEmptyString(doc.name) ? doc.name.trim() : `Epic ${epicId}`),
9941
- nodes,
9942
- repo
9943
- };
9944
- };
9945
- /**
9946
- * Builds the wire payload with deterministic ordering (nodes by slug, edges by
9947
- * from then to) so re-emission of the same graph is byte-stable regardless of
9948
- * the planner's source ordering — the basis for the content-addressed eventId.
9949
- */
9950
- const buildEpicStructurePayload = (dag) => {
9951
- const nodes = dag.nodes.map((node) => {
9952
- const laneId = node.laneId ?? (node.issue === void 0 ? void 0 : laneIdFor(dag.repo, node.issue));
9953
- return {
9954
- epicId: dag.epicId,
9955
- ...laneId === void 0 ? {} : { laneId },
9956
- slug: node.slug,
9957
- status: node.status,
9958
- title: node.title
9959
- };
9960
- }).toSorted((a, b) => a.slug.localeCompare(b.slug));
9961
- return {
9962
- edges: dag.edges.map((edge) => ({
9963
- epicId: dag.epicId,
9964
- from: edge.from,
9965
- to: edge.to,
9966
- type: "depends-on"
9967
- })).toSorted((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to)),
9968
- epics: [{
9969
- id: dag.epicId,
9970
- name: dag.name,
9971
- repo: dag.repo
9972
- }],
9973
- nodes
9974
- };
9975
- };
9976
- /** Stable, key-sorted JSON for content hashing (values already ordered). */
9977
- const stableStringify = (value) => {
9978
- if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
9979
- if (isPlainObject(value)) return `{${Object.keys(value).toSorted().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`;
9980
- return JSON.stringify(value ?? null);
9981
- };
9982
- /**
9983
- * Deterministic eventId = `epic-structure-<boundary>-<sha256(payload)[0:16]>`.
9984
- * Re-emitting identical graph content yields the same eventId (HQ dedups →
9985
- * 200/duplicate); an amendment changes the content hash → a new eventId that
9986
- * supersedes the prior structure wholesale (HQ keeps the latest). This mirrors
9987
- * seed-dev's fixed-eventId idempotency, content-addressed instead of literal.
9988
- */
9989
- const epicStructureEventId = (boundary, payload) => `epic-structure-${slugify(boundary)}-${createHash("sha256").update(stableStringify(payload)).digest("hex").slice(0, 16)}`;
9990
- /**
9991
- * Validates and builds the full `epic-structure` v1 ingest event, ready to POST
9992
- * to `/api/ingest`. Throws {@link EpicStructureValidationError} fail-closed.
9993
- */
9994
- const buildEpicStructureEvent = (input) => {
9995
- const dag = validateDagDocument(input);
9996
- const payload = buildEpicStructurePayload(dag);
9997
- return {
9998
- eventId: epicStructureEventId(dag.boundary, payload),
9999
- kind: "epic-structure",
10000
- observedAt: input.observedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
10001
- payload,
10002
- schemaVersion: 1
10003
- };
10004
- };
10005
- /** The prod HQ worker is reachable at `hq.patronage.com` and its frozen
10006
- * `-v1.*.workers.dev` alias. Emitting there without an Access service token is
10007
- * fail-closed. */
10008
- const isProductionHqUrl = (url) => url.includes("hq.patronage.com") || /-v1\.[^/]*\.workers\.dev/iu.test(url);
10009
- /**
10010
- * POSTs the event to HQ ingest. Fail-closed: a production URL without a
10011
- * Cloudflare Access service token is refused before any request; a 401/403 is
10012
- * surfaced with actionable guidance; any non-2xx throws.
10013
- */
10014
- const publishEpicStructure = async (args) => {
10015
- if (!args.accessServiceToken && isProductionHqUrl(args.url)) throw new Error(`epic:publish-structure refusing to publish to production ${args.url} without a Cloudflare Access service token: set CF-Access-Client-Id and CF-Access-Client-Secret`);
10016
- const doFetch = args.fetchImpl ?? globalThis.fetch;
10017
- const requestInit = args.accessServiceToken ? buildCloudflareAccessRequestInit(args.accessServiceToken, {
10018
- body: JSON.stringify(args.event),
10019
- headers: { "content-type": "application/json" },
10020
- method: "POST",
10021
- ...args.signal ? { signal: args.signal } : {}
10022
- }) : {
10023
- body: JSON.stringify(args.event),
10024
- headers: { "content-type": "application/json" },
10025
- method: "POST",
10026
- ...args.signal ? { signal: args.signal } : {}
10027
- };
10028
- let response;
10029
- try {
10030
- const normalizedUrl = args.url.replace(/\/+$/u, "");
10031
- response = await doFetch(normalizedUrl.endsWith("/api/ingest") ? normalizedUrl : `${normalizedUrl}/api/ingest`, requestInit);
10032
- } catch {
10033
- throw new Error(`epic:publish-structure could not reach HQ ingest at ${args.url}: request failed. Check the endpoint and network; redirects are refused to protect Cloudflare Access credentials.`);
10034
- }
10035
- const text = await response.text();
10036
- if (response.status === 401 || response.status === 403) throw new Error(`epic:publish-structure rejected by Cloudflare Access or HQ ingest (${response.status}): set valid CF-Access-Client-Id and CF-Access-Client-Secret service-token inputs. Response: ${text}`);
10037
- if (response.status < 200 || response.status >= 300) throw new Error(`epic:publish-structure failed: HQ ingest returned ${response.status} ${text}`);
10038
- let duplicate = false;
10039
- try {
10040
- duplicate = JSON.parse(text).duplicate === true;
10041
- } catch {
10042
- duplicate = response.status === 200;
10043
- }
10044
- return {
10045
- duplicate,
10046
- eventId: args.event.eventId,
10047
- status: response.status
10048
- };
10049
- };
10050
- //#endregion
10051
- //#region src/commands/epic-publish-structure.ts
10052
- const STDIN = "-";
10053
- /** Reads a dag.yml source path, or stdin when `source` is `-`. */
10054
- const readSource = (source) => {
10055
- const fd = source === STDIN ? 0 : source;
10056
- try {
10057
- return readFileSync(fd, "utf-8");
10058
- } catch (error) {
10059
- const cause = error instanceof Error ? error.message : String(error);
10060
- throw new Error(`epic:publish-structure could not read ${source === STDIN ? "stdin" : source} (${cause})`, { cause: error });
10061
- }
10062
- };
10063
- /** Parses a dag.yml/JSON document read from `readSource`. */
10064
- const parseDocument = (raw, source) => {
10065
- if (raw.trim() === "") throw new Error(`epic:publish-structure received empty input from ${source === STDIN ? "stdin" : source}`);
10066
- try {
10067
- return parse(raw);
10068
- } catch (error) {
10069
- const cause = error instanceof Error ? error.message : String(error);
10070
- throw new Error(`epic:publish-structure could not parse YAML: ${cause}`, { cause: error });
10071
- }
10072
- };
10073
- /**
10074
- * Reads and validates a dag.yml/JSON source into a full `epic-structure` v1
10075
- * event, ready to POST or dry-run print. Shared with `factory:closeout`'s
10076
- * terminal-snapshot re-emission (issue #392) so both commands build the exact
10077
- * same wire event from the exact same source format — one current contract,
10078
- * not a forked parser.
10079
- */
10080
- const buildEvent = (source, options = {}) => {
10081
- return buildEpicStructureEvent({
10082
- document: parseDocument(readSource(source), source),
10083
- ...options.boundary ? { boundary: options.boundary } : {},
10084
- ...options.name ? { name: options.name } : {},
10085
- ...options.repo ? { repo: options.repo } : {}
10086
- });
10087
- };
10088
- function createEpicPublishStructureCommand(output, deps = {}) {
10089
- const env = deps.env ?? process.env;
10090
- return new Command("epic:publish-structure").description("Emit-only: read a planner's ephemeral dag.yml (or - for stdin), validate the graph fail-closed (YAML parses, required fields, edges reference known slugs, acyclic, issue numbers on every non-external node), and POST an epic-structure v1 event to HQ ingest. Idempotent by a content-addressed eventId (re-emission of an amendment supersedes the prior structure). Production auth uses the CF-Access-Client-Id and CF-Access-Client-Secret environment inputs. URL: --url or HQ_INGEST_URL. The dag.yml schema is documented with the epic skill (#137).").argument("[source]", "path to dag.yml, or - for stdin", STDIN).option("--url <url>", "HQ ingest base URL (default: HQ_INGEST_URL env). The command POSTs to <url>/api/ingest").option("--boundary <slug>", "boundary slug for the eventId namespace (default: dag `boundary:`, else `epic-<id>`)").option("--repo <name>", "repository name for the epic record and lane-id derivation (default: dag `repo:`)").option("--name <title>", "epic display name (default: dag `name:`)").option("--dry-run", "validate and build the event, print it, and exit without POSTing").option("--json", "print the built event / publish result as JSON").action(async (source, options) => {
10091
- const event = buildEvent(source, options);
10092
- if (options.dryRun) {
10093
- output.stdout.write(options.json ? `${JSON.stringify(event, null, 2)}\n` : `epic:publish-structure OK (dry-run) — eventId ${event.eventId}, ${event.payload.nodes.length} node(s), ${event.payload.edges.length} edge(s)\n`);
10094
- return;
10095
- }
10096
- const url = options.url ?? env.HQ_INGEST_URL;
10097
- if (!url) throw new Error("epic:publish-structure requires an HQ ingest URL: pass --url or set HQ_INGEST_URL");
10098
- const clientId = env[CF_ACCESS_CLIENT_ID_ENV];
10099
- const clientSecret = env[CF_ACCESS_CLIENT_SECRET_ENV];
10100
- const result = await publishEpicStructure({
10101
- ...clientId && clientSecret ? { accessServiceToken: {
10102
- clientId,
10103
- clientSecret
10104
- } } : {},
10105
- event,
10106
- fetchImpl: deps.fetchImpl,
10107
- url
10108
- });
10109
- output.stdout.write(options.json ? `${JSON.stringify(result, null, 2)}\n` : `epic:publish-structure ${result.duplicate ? "no-op (duplicate)" : "accepted"} — eventId ${result.eventId} [HTTP ${result.status}]\n`);
10110
- });
10111
- }
10112
- //#endregion
10113
- //#region src/commands/closeout.ts
10114
- const formatUtcDate = (date) => date.toISOString().slice(0, 10);
10115
- const nonEmptyRepoRoot = (value) => {
10116
- if (value.trim() === "") throw new InvalidArgumentError("--repo-root requires a non-empty path");
10117
- return value;
10118
- };
10119
- const codexThreadIdsForCloseout = (env = process.env) => {
10120
- const threadId = normalizeSessionId(env.CODEX_THREAD_ID);
10121
- return threadId === void 0 ? [] : [threadId];
10122
- };
10123
- const resolveRetroSubject = (issue, pr) => {
10124
- if (issue !== void 0) return { issueNumber: issue };
10125
- if (pr !== void 0) return { prNumber: pr };
10126
- };
10127
- const tokenFamilySummary = (tokenFamilies) => {
10128
- const available = [];
10129
- const unavailable = [];
10130
- for (const family of ["claude", "gpt"]) if (tokenFamilies[family] === void 0) unavailable.push(family);
10131
- else available.push(family);
10132
- return {
10133
- available,
10134
- unavailable
10135
- };
10136
- };
10137
- const reportLocalCloseoutOutcome = (output, input) => {
10138
- const harvest = tokenFamilySummary(input.retro.envelope.tokenFamilies);
10139
- output.stdout.write(`local artifacts: ${input.paths.markdownPath}, ${input.paths.jsonPath}, and ${input.retro.envelopePath}\n`);
10140
- output.stdout.write(`boundary thermo input: runs=${input.boundaryThermo.runs}, owner=${input.boundaryThermo.owner}, waived=${input.boundaryThermo.waived}${input.boundaryThermo.waiverRationale === void 0 ? "" : `, waiver rationale=${input.boundaryThermo.waiverRationale}`}\n`);
10141
- if (harvest.unavailable.length > 0) output.stderr.write(`harvest data unavailable: ${harvest.available.length === 0 ? "no token families were recorded" : `missing ${harvest.unavailable.join(" and ")} token family data`}; recorded gaps: ${input.retro.envelope.dataGaps.join(" | ")}\n`);
10142
- else if (input.retro.envelope.dataGaps.length > 0) output.stderr.write(`retro-envelope data gaps recorded: ${input.retro.envelope.dataGaps.join(" | ")}\n`);
10143
- };
10144
- const reportRetroHqDeliveryOutcome = (output, outcome) => {
10145
- output.stderr.write(`advisory HQ handoff ${outcome} for the retro envelope; local closeout success does not depend on HQ.\n`);
10146
- };
10147
- const reportArtifactHqHandoffOutcome = (output, outcome) => {
10148
- output.stderr.write(`advisory HQ handoff ${outcome} for the closeout artifact; local closeout success does not depend on HQ.\n`);
10149
- };
10150
- const reportStructureReemissionOutcome = (output, outcome, detail) => {
10151
- output.stderr.write(`advisory HQ epic-structure re-emission ${outcome}${detail ? ` (${detail})` : ""}; local closeout success does not depend on HQ.\n`);
10152
- };
10153
- const DEFAULT_STRUCTURE_REEMISSION_TIMEOUT_MS = 5e3;
10154
- const withTimeout = async (operation, timeoutMs, message) => {
10155
- const raceAbort = new AbortController();
10156
- const fetchAbort = new AbortController();
10157
- const settleTimeout = async () => {
10158
- try {
10159
- await setTimeout$1(Math.max(0, timeoutMs), void 0, { signal: raceAbort.signal });
10160
- } catch {}
10161
- return { status: "timed-out" };
10162
- };
10163
- const settleOperation = async () => ({
10164
- status: "fulfilled",
10165
- value: await operation(fetchAbort.signal)
10166
- });
10167
- try {
10168
- const result = await Promise.race([settleOperation(), settleTimeout()]);
10169
- if (result.status === "timed-out") {
10170
- fetchAbort.abort();
10171
- throw new Error(message);
10172
- }
10173
- return result.value;
10174
- } finally {
10175
- raceAbort.abort();
10176
- }
10177
- };
10178
- const bareRepoName = (ownerRepo) => {
10179
- const separatorIndex = ownerRepo.lastIndexOf("/");
10180
- return separatorIndex === -1 ? ownerRepo : ownerRepo.slice(separatorIndex + 1);
10181
- };
10182
- const structureIdentityMismatch = (event, expected) => {
10183
- const expectedRepo = bareRepoName(expected.repo);
10184
- const { epics } = event.payload;
10185
- if (epics.length === 0) return;
10186
- if (epics.every((epic) => epic.id !== expected.epic)) return `dag epic id(s) [${epics.map((epic) => epic.id).join(", ")}] do not include --epic ${expected.epic}`;
10187
- if (epics.every((epic) => epic.repo !== expectedRepo)) return `dag repo(s) [${epics.map((epic) => epic.repo).join(", ")}] do not include --repo ${expected.repo} (bare "${expectedRepo}")`;
10188
- };
10189
- const reemitEpicStructureAtCloseout = async (input) => {
10190
- if (input.dagSource === void 0) return;
10191
- const build = input.buildEvent ?? buildEvent;
10192
- const publish = input.publish ?? publishEpicStructure;
10193
- const url = input.url ?? input.env.HQ_INGEST_URL;
10194
- if (!url) {
10195
- reportStructureReemissionOutcome(input.output, "skipped", "no HQ ingest URL: pass --structure-url or set HQ_INGEST_URL");
10196
- return;
10197
- }
10198
- let event;
10199
- try {
10200
- event = build(input.dagSource);
10201
- } catch (error) {
10202
- reportStructureReemissionOutcome(input.output, "failed", error instanceof Error ? error.message : String(error));
10203
- return;
10204
- }
10205
- const mismatch = structureIdentityMismatch(event, {
10206
- epic: input.expectedEpic,
10207
- repo: input.expectedRepo
10208
- });
10209
- if (mismatch !== void 0) {
10210
- reportStructureReemissionOutcome(input.output, "failed", mismatch);
10211
- return;
10212
- }
10213
- const clientId = input.env[CF_ACCESS_CLIENT_ID_ENV];
10214
- const clientSecret = input.env[CF_ACCESS_CLIENT_SECRET_ENV];
10215
- const timeoutMs = input.timeoutMs ?? DEFAULT_STRUCTURE_REEMISSION_TIMEOUT_MS;
10216
- try {
10217
- const result = await withTimeout((signal) => publish({
10218
- ...clientId && clientSecret ? { accessServiceToken: {
10219
- clientId,
10220
- clientSecret
10221
- } } : {},
10222
- event,
10223
- fetchImpl: input.fetchImpl,
10224
- signal,
10225
- url
10226
- }), timeoutMs, `epic-structure re-emission exceeded its ${timeoutMs}ms advisory deadline`);
10227
- reportStructureReemissionOutcome(input.output, result.duplicate ? "duplicate" : "accepted", `eventId ${result.eventId}`);
10228
- } catch (error) {
10229
- reportStructureReemissionOutcome(input.output, "failed", error instanceof Error ? error.message : String(error));
10230
- }
10231
- };
10232
- const handoffCloseoutToHq = async (input) => {
10233
- const epicIssue = Number(input.artifact.epic);
10234
- let artifactOutcome = "deferred";
10235
- try {
10236
- artifactOutcome = await deferProjectHqIngest({
10237
- cwd: input.repoRoot,
10238
- kind: "epic-closeout",
10239
- payload: input.artifact,
10240
- payloadPath: input.written.jsonPath,
10241
- ...Number.isSafeInteger(epicIssue) && epicIssue > 0 ? { subject: { issueNumber: epicIssue } } : {}
10242
- }, input.hq);
10243
- } catch {
10244
- artifactOutcome = "held";
10245
- }
10246
- try {
10247
- reportArtifactHqHandoffOutcome(input.output, artifactOutcome);
10248
- } catch {}
10249
- const retroSubject = resolveRetroSubject(input.options.issue, input.options.pr);
10250
- let outcome = "held";
10251
- try {
10252
- outcome = await deferProjectHqIngest({
10253
- cwd: input.repoRoot,
10254
- kind: "retro-envelope",
10255
- payload: input.retro.envelope,
10256
- payloadPath: input.retro.envelopePath,
10257
- ...retroSubject === void 0 ? {} : { subject: retroSubject }
10258
- }, input.hq);
10259
- } catch {}
10260
- try {
10261
- reportRetroHqDeliveryOutcome(input.output, outcome);
10262
- } catch {}
10263
- };
10264
- const defaultTraceWindow = () => {
10265
- const to = /* @__PURE__ */ new Date();
10266
- const from = new Date(to);
10267
- from.setUTCDate(from.getUTCDate() - 30);
10268
- return {
10269
- from: formatUtcDate(from),
10270
- to: formatUtcDate(to)
10271
- };
9146
+ const defaultTraceWindow = () => {
9147
+ const to = /* @__PURE__ */ new Date();
9148
+ const from = new Date(to);
9149
+ from.setUTCDate(from.getUTCDate() - 30);
9150
+ return {
9151
+ from: formatUtcDate(from),
9152
+ to: formatUtcDate(to)
9153
+ };
10272
9154
  };
10273
9155
  function createCloseoutCommand(output, dependencies = {}) {
10274
9156
  const buildRetroGate = dependencies.buildRetroEnvelope ?? buildAndPersistRetroEnvelope;
10275
9157
  const persistArtifact = dependencies.writeArtifact ?? writeCloseoutArtifact;
10276
- return new Command("factory:closeout").description("Emit an epic-close metrics + lessons closeout (markdown + HQ-ledger JSON)").requiredOption("--epic <id>", "epic identifier").option("--repo <owner/repo>", "repository", DEFAULT_FACTORY_REPOSITORY).option("--repo-root <path>", "consumer repo root for trace shards and output", nonEmptyRepoRoot, ".").option("--out-dir <path>", "output directory under repo root", ".factory-memory/closeouts").option("--from <YYYY-MM-DD>", "trace diagnostics scan window start — surfaces malformed trace shards only, no metrics (default: today minus 30 days UTC)").option("--to <YYYY-MM-DD>", "trace diagnostics scan window end (default: today UTC)").option("--lane <name>", "interior telemetry lane label", "epic-closeout").option("--chats-dir <path>", "Cursor chats directory for interior telemetry").option("--usage-csv <path>", "Cursor usage CSV for interior telemetry").option("--codex-log <path>", "Codex exec JSON log for interior telemetry").option("--epic-chat-id <id>", "allowlist a Cursor chatId for epic interior scoping (repeatable)", (value, previous) => [...previous, value], []).option("--epic-chat-cwd <path>", "allowlist a worktree cwd for epic interior scoping (repeatable)", (value, previous) => [...previous, value], []).option("--issue <n>", "optional issue-number filter for the trace diagnostics scan", Number.parseInt).option("--pr <n>", "optional pull-request-number filter for the trace diagnostics scan", Number.parseInt).option("--json", "print the artifact JSON to stdout").option("--dry-run", "run aggregation without writing files; print intended output paths").option("--boundary-thermo-runs <n>", "deep boundary-thermo run count for this wave/epic (default: 0 → tripwire)", (value) => sanitizeBoundaryThermoRuns(value)).option("--boundary-thermo-owner <name>", "named owner of the boundary-thermo gate (default: epic-closeout orchestrator)").option("--boundary-thermo-waived", "record an explicit waiver for zero boundary-thermo runs").option("--boundary-thermo-waiver <text>", "waiver rationale when --boundary-thermo-waived is set").option("--structure-dag <path>", "path to a terminal dag.yml/JSON document (or - for stdin) to re-emit as the closeout epic-structure snapshot, via the same builder epic:publish-structure uses. Advisory and idempotent by content-addressed eventId. Omit to skip re-emission entirely — closeout never fabricates a structure snapshot that was never authored.").option("--structure-url <url>", "HQ ingest base URL for the closeout structure re-emission (default: HQ_INGEST_URL env)").action(withGateTiming({
9158
+ return new Command("factory:closeout").description("Emit an epic-close metrics + lessons closeout (markdown + HQ-ledger JSON)").requiredOption("--epic <id>", "epic identifier").option("--repo-root <path>", "consumer repo root for trace shards and output", nonEmptyRepoRoot, ".").option("--out-dir <path>", "output directory under repo root", ".factory-memory/closeouts").option("--from <YYYY-MM-DD>", "trace diagnostics scan window start — surfaces malformed trace shards only, no metrics (default: today minus 30 days UTC)").option("--to <YYYY-MM-DD>", "trace diagnostics scan window end (default: today UTC)").option("--issue <n>", "optional issue-number filter for the trace diagnostics scan", Number.parseInt).option("--pr <n>", "optional pull-request-number filter for the trace diagnostics scan", Number.parseInt).option("--json", "print the artifact JSON to stdout").option("--dry-run", "run aggregation without writing files; print intended output paths").option("--boundary-thermo-runs <n>", "deep boundary-thermo run count for this wave/epic (default: 0 → tripwire)", (value) => sanitizeBoundaryThermoRuns(value)).option("--boundary-thermo-owner <name>", "named owner of the boundary-thermo gate (default: epic-closeout orchestrator)").option("--boundary-thermo-waived", "record an explicit waiver for zero boundary-thermo runs").option("--boundary-thermo-waiver <text>", "waiver rationale when --boundary-thermo-waived is set").action(withGateTiming({
10277
9159
  gate: "factory:closeout",
10278
9160
  resolveLedgerRoot: (options) => resolveCwdOption(options.repoRoot),
10279
9161
  shouldRecord: (options) => options.dryRun !== true,
@@ -10283,25 +9165,15 @@ function createCloseoutCommand(output, dependencies = {}) {
10283
9165
  const from = options.from ?? defaults.from;
10284
9166
  const to = options.to ?? defaults.to;
10285
9167
  const repoRoot = resolveCwdOption(options.repoRoot);
10286
- const epicChatIds = options.epicChatId ?? [];
10287
- const epicChatCwds = options.epicChatCwd ?? [];
10288
- const epicChatScope = epicChatIds.length > 0 || epicChatCwds.length > 0 ? {
10289
- ...epicChatIds.length > 0 ? { chatIds: epicChatIds } : {},
10290
- ...epicChatCwds.length > 0 ? { cwds: epicChatCwds } : {}
10291
- } : void 0;
9168
+ const repo = repositorySlug(checkoutRepository(repoRoot));
10292
9169
  const gathered = gatherCloseoutInputs({
10293
- chatsDir: options.chatsDir,
10294
- codexLogPath: options.codexLog,
10295
9170
  epic: options.epic,
10296
- ...epicChatScope === void 0 ? {} : { epicChatScope },
10297
9171
  from,
10298
9172
  issue: options.issue,
10299
- lane: options.lane,
10300
9173
  pr: options.pr,
10301
- repo: options.repo,
9174
+ repo,
10302
9175
  repoRoot,
10303
- to,
10304
- usageCsvPath: options.usageCsv
9176
+ to
10305
9177
  });
10306
9178
  if (gathered.diagnostics.length > 0) output.stderr.write(`factory:closeout trace diagnostics: ${gathered.diagnostics.length}\n`);
10307
9179
  const artifact = buildEpicCloseoutReport({
@@ -10313,8 +9185,7 @@ function createCloseoutCommand(output, dependencies = {}) {
10313
9185
  },
10314
9186
  budget: gathered.budget,
10315
9187
  epic: options.epic,
10316
- interiorRows: gathered.interiorRows,
10317
- repo: options.repo
9188
+ repo
10318
9189
  });
10319
9190
  if (options.json) output.stdout.write(`${JSON.stringify(artifact, null, 2)}\n`);
10320
9191
  const paths = closeoutArtifactPaths({
@@ -10332,11 +9203,11 @@ function createCloseoutCommand(output, dependencies = {}) {
10332
9203
  repoRoot
10333
9204
  });
10334
9205
  const retro = buildRetroGate({
10335
- codexThreadIds: codexThreadIdsForCloseout(),
9206
+ codexThreadIds: codexThreadIdsForCloseout(dependencies.env),
10336
9207
  epic: options.epic,
10337
9208
  ...options.issue === void 0 ? {} : { issue: options.issue },
10338
9209
  ...options.pr === void 0 ? {} : { pr: options.pr },
10339
- repo: options.repo,
9210
+ repo,
10340
9211
  repoRoot
10341
9212
  });
10342
9213
  reportLocalCloseoutOutcome(output, {
@@ -10353,18 +9224,6 @@ function createCloseoutCommand(output, dependencies = {}) {
10353
9224
  retro,
10354
9225
  written
10355
9226
  });
10356
- await reemitEpicStructureAtCloseout({
10357
- buildEvent: dependencies.buildEpicStructureEvent,
10358
- dagSource: options.structureDag,
10359
- env: dependencies.env ?? process.env,
10360
- expectedEpic: options.epic,
10361
- expectedRepo: options.repo,
10362
- fetchImpl: dependencies.structureFetch,
10363
- output,
10364
- publish: dependencies.publishEpicStructure,
10365
- timeoutMs: dependencies.structureTimeoutMs,
10366
- url: options.structureUrl
10367
- });
10368
9227
  output.stdout.write(`wrote ${written.markdownPath} and ${written.jsonPath}\n`);
10369
9228
  }));
10370
9229
  }
@@ -10431,54 +9290,6 @@ function createConfigCommand(output, deps = {}) {
10431
9290
  return config;
10432
9291
  }
10433
9292
  //#endregion
10434
- //#region src/checkout-repository.ts
10435
- function normalizeRepositoryIdentity(repository) {
10436
- return {
10437
- name: repository.name.replace(/\.git$/iu, "").toLowerCase(),
10438
- owner: repository.owner.toLowerCase()
10439
- };
10440
- }
10441
- const GITHUB_HOSTNAME = "github.com";
10442
- const SCP_GITHUB_REMOTE_PATTERN = /^(?:[^@\s]+@)?github\.com:(?<path>[^\s]+)$/iu;
10443
- function githubRepositoryPath(remoteUrl) {
10444
- const scpPath = SCP_GITHUB_REMOTE_PATTERN.exec(remoteUrl)?.groups?.path;
10445
- if (scpPath) return scpPath;
10446
- try {
10447
- const url = new URL(remoteUrl);
10448
- if (url.hostname.toLowerCase() !== GITHUB_HOSTNAME) return;
10449
- return url.pathname.replace(/^\//u, "");
10450
- } catch {
10451
- return;
10452
- }
10453
- }
10454
- function parseCheckoutRepository(remoteUrl) {
10455
- const segments = githubRepositoryPath(remoteUrl.trim())?.split("/") ?? [];
10456
- const [owner, rawName] = segments;
10457
- const name = rawName?.replace(/\.git$/u, "");
10458
- if (!(owner && name) || segments.length !== 2) throw new Error(`Could not derive a GitHub repository from checkout origin ${remoteUrl}; configure origin as a github.com owner/repository remote.`);
10459
- return normalizeRepositoryIdentity({
10460
- name,
10461
- owner
10462
- });
10463
- }
10464
- function checkoutRepository(cwd) {
10465
- let remoteUrl;
10466
- try {
10467
- remoteUrl = runCapture("git", [
10468
- "remote",
10469
- "get-url",
10470
- "origin"
10471
- ], cwd).stdout;
10472
- } catch {
10473
- throw new Error("Could not read checkout origin; repository routing must be rooted in trusted checkout configuration.");
10474
- }
10475
- return parseCheckoutRepository(remoteUrl);
10476
- }
10477
- const repositorySlug = (repository) => {
10478
- const normalized = normalizeRepositoryIdentity(repository);
10479
- return `${normalized.owner}/${normalized.name}`;
10480
- };
10481
- //#endregion
10482
9293
  //#region src/candidate-identity.ts
10483
9294
  var CandidateIdentityError = class extends Error {
10484
9295
  constructor(message) {
@@ -10684,23 +9495,18 @@ const bindingRefusalReason = (name) => `Required check "${name}" has no passing,
10684
9495
  /**
10685
9496
  * The `evidence:emit` invocation that would satisfy a specific demanded check.
10686
9497
  *
10687
- * Every flag here is one the command requires today: `--check`, `--check-type`,
10688
- * `--outcome`, and `--producer` all fail closed when omitted, and a review-type
10689
- * envelope additionally MUST carry a rung (ADR 0014 §3), so the rung flag
10690
- * appears only for review checks and never for verify checks, which must not
10691
- * carry one. `--cwd` and `--base` are left off deliberately: their defaults
9498
+ * Every flag here is one the current verify-only writer requires: `--check`,
9499
+ * `--outcome`, and `--producer`. `--cwd` and `--base` are left off deliberately: their defaults
10692
9500
  * (`.` and `origin/main`) are already what a lane in its own checkout wants,
10693
9501
  * and the default output path is {@link EVIDENCE_DIR}, which is where this
10694
9502
  * evaluation just looked. Only `--producer` is left as a placeholder, because
10695
9503
  * the producing identity is the caller's to name.
10696
9504
  */
10697
- const evidenceEmitInvocation = (check) => [
9505
+ const evidenceEmitInvocation = (check) => check.checkType === "review" ? "review-type requiredChecks are historical-only; current profiles cannot declare or emit them" : [
10698
9506
  "patronage-factory evidence:emit",
10699
9507
  `--check "${check.name}"`,
10700
- `--check-type ${check.checkType}`,
10701
9508
  "--outcome pass",
10702
- "--producer <id>",
10703
- ...check.checkType === "review" ? ["--rung <rung>"] : []
9509
+ "--producer <id>"
10704
9510
  ].join(" ");
10705
9511
  /**
10706
9512
  * The refusal when a demanded check has no envelope at all.
@@ -10901,7 +9707,7 @@ function buildEvidenceEnvelope(args, dependencies = {}) {
10901
9707
  const sessionId = resolveEmitSessionId(args.sessionId, env);
10902
9708
  const parsed = parseEvidenceEnvelope({
10903
9709
  check: args.check,
10904
- checkType: args.checkType,
9710
+ checkType: "verify",
10905
9711
  headSha,
10906
9712
  mergeBaseSha: merge,
10907
9713
  outcome: args.outcome,
@@ -10912,11 +9718,7 @@ function buildEvidenceEnvelope(args, dependencies = {}) {
10912
9718
  ...args.findingsPointer ? { findingsPointer: args.findingsPointer } : {},
10913
9719
  ...args.policyVersion ? { policyVersion: args.policyVersion } : {},
10914
9720
  ...sessionId ? { sessionId } : {},
10915
- ...args.requestId ? { requestId: args.requestId } : {},
10916
- ...args.checkType === "review" ? {
10917
- ...args.rung ? { rung: args.rung } : {},
10918
- ...args.model ? { model: args.model } : {}
10919
- } : {}
9721
+ ...args.requestId ? { requestId: args.requestId } : {}
10920
9722
  });
10921
9723
  if (!parsed.ok) throw new Error(`Refusing to emit an invalid evidence envelope: ${parsed.error}`);
10922
9724
  return parsed.envelope;
@@ -11657,7 +10459,7 @@ function runPrVerify(args, dependencies = {}) {
11657
10459
  mode: resolvedMode,
11658
10460
  patchId,
11659
10461
  profilePath,
11660
- projectKey: profile.project.key,
10462
+ projectKey: profile.repository.name,
11661
10463
  repository,
11662
10464
  startedAt
11663
10465
  };
@@ -11740,7 +10542,7 @@ function runPrVerify(args, dependencies = {}) {
11740
10542
  const baselineFullProofs = resolvedMode === "docs-only" ? baselineFullProofsForDocsOnly({
11741
10543
  previousProof,
11742
10544
  profilePath,
11743
- projectKey: profile.project.key,
10545
+ projectKey: profile.repository.name,
11744
10546
  repository
11745
10547
  }) : void 0;
11746
10548
  const proof = buildPrVerifyProof({
@@ -11755,7 +10557,6 @@ function runPrVerify(args, dependencies = {}) {
11755
10557
  runEvidenceEmit({
11756
10558
  base: args.base,
11757
10559
  check: command.requiredCheck,
11758
- checkType: "verify",
11759
10560
  cwd,
11760
10561
  outcome: "pass",
11761
10562
  policyVersion: "patronage-factory/pr-verify/v1",
@@ -11932,23 +10733,24 @@ function createDemandWaiveCommand(_output, action = runDemandWaive) {
11932
10733
  /**
11933
10734
  * Resolve the review modes demanded by a candidate's changed files.
11934
10735
  *
11935
- * Modes keep their first-declared profile order. A mode with no conditional
11936
- * rule is unconditional. A conditional mode is demanded when any rule that
11937
- * names it matches any changed file.
10736
+ * Correctness is structural factory policy. Security is additionally demanded
10737
+ * when any repository-declared security path matches the candidate.
11938
10738
  */
11939
10739
  const resolveApplicableReviewModes = (profile, changedFiles) => {
11940
- const conditional = profile.review.conditional ?? [];
11941
- const conditionalModes = new Set(conditional.flatMap((scope) => scope.modes));
11942
- const seen = /* @__PURE__ */ new Set();
11943
- return profile.review.modes.filter((mode) => {
11944
- if (seen.has(mode)) return false;
11945
- seen.add(mode);
11946
- return !conditionalModes.has(mode) || conditional.some((scope) => scope.modes.includes(mode) && changedFiles.some((file) => matchesAnyGlob(scope.paths, file)));
11947
- });
10740
+ return (profile.review?.conditional ?? []).some((scope) => changedFiles.some((file) => matchesAnyGlob(scope.paths, file))) ? ["correctness", "security"] : ["correctness"];
11948
10741
  };
10742
+ /**
10743
+ * Convert schema-v5 profile requiredChecks into the executable evidence
10744
+ * demands consumed by readiness and its preflight presentation. Schema-v5
10745
+ * checks are verify-only; no other module should reintroduce that policy.
10746
+ */
10747
+ const resolveProfileRequiredChecks = (profile) => (profile.requiredChecks ?? []).map((check) => ({
10748
+ ...check,
10749
+ checkType: "verify"
10750
+ }));
11949
10751
  const resolveCandidateDemands = ({ changedFiles, profile }) => ({
11950
- ladderPolicy: resolveReviewLadderPolicy(profile),
11951
- requiredChecks: profile.requiredChecks ?? [],
10752
+ ladderPolicy: resolveReviewLadderPolicy(),
10753
+ requiredChecks: resolveProfileRequiredChecks(profile),
11952
10754
  reviewModes: resolveApplicableReviewModes(profile, changedFiles)
11953
10755
  });
11954
10756
  /**
@@ -12383,7 +11185,7 @@ const verifyProofCheck = ({ candidate, candidateError, preread, verifyProofPath
12383
11185
  * modes; currency and acceptance come from the same identity and
12384
11186
  * terminal-state helpers `pr:publish` uses.
12385
11187
  */
12386
- const reviewProofCheck = ({ candidate, candidateError, profile, proof, reviewModes }) => {
11188
+ const reviewProofCheck = ({ candidate, candidateError, proof, reviewModes }) => {
12387
11189
  const name = "admission:review-proof";
12388
11190
  if (!candidate) return {
12389
11191
  message: `Review proof state is unknown: no git candidate is resolvable here (${candidateError ?? "unknown error"}).`,
@@ -12402,7 +11204,7 @@ const reviewProofCheck = ({ candidate, candidateError, profile, proof, reviewMod
12402
11204
  reviewedHeadSha: proof?.headSha,
12403
11205
  reviewedPatchId: proof?.patchId
12404
11206
  });
12405
- const terminal = proof ? resolveReviewTerminalStateAcrossReviewsForPolicy(proof, resolveReviewLadderPolicy(profile)) : void 0;
11207
+ const terminal = proof ? resolveReviewTerminalStateAcrossReviewsForPolicy(proof, resolveReviewLadderPolicy()) : void 0;
12406
11208
  const accepted = status === "current" && (terminal === "clean" || terminal === "accepted-with-findings");
12407
11209
  return {
12408
11210
  message: accepted ? `pr:review proof is current for this candidate (${terminal}) across ${reviewModes.join(", ")}.` : `pr:review proof is ${status}${terminal ? ` (${terminal})` : ""}; ${reviewModes.join(", ")} review(s) are required. Have an independent clean session write findings and pass them to pr:publish --findings <path>.`,
@@ -12422,7 +11224,7 @@ const requiredCheckMessage = (outcome) => {
12422
11224
  * independence stay exactly one implementation.
12423
11225
  */
12424
11226
  const requiredCheckChecks = ({ candidate, candidateError, cwd, profile, verifyProof }) => {
12425
- const requiredChecks = profile.requiredChecks ?? [];
11227
+ const requiredChecks = resolveProfileRequiredChecks(profile);
12426
11228
  if (requiredChecks.length === 0) return [{
12427
11229
  message: "Profile declares no requiredChecks; no external evidence envelope (including a preview-deploy proof) is demanded.",
12428
11230
  name: "admission:required-checks",
@@ -12479,9 +11281,8 @@ const admissionPreflightChecks = ({ base = "origin/main", cwd, env = process.env
12479
11281
  reviewProofCheck({
12480
11282
  candidate,
12481
11283
  candidateError,
12482
- profile,
12483
11284
  proof: reviewProof,
12484
- reviewModes: candidate ? resolveApplicableReviewModes(profile, candidate.files) : profile.review.modes
11285
+ reviewModes: candidate ? resolveApplicableReviewModes(profile, candidate.files) : ["correctness"]
12485
11286
  }),
12486
11287
  ...requiredCheckChecks({
12487
11288
  candidate,
@@ -12751,7 +11552,6 @@ async function doctorProjectProfile(input = {}) {
12751
11552
  }, input.hqRetroReadbackDependencies)]);
12752
11553
  const checks = [
12753
11554
  ...buildProfileChecks(profile, cwd, {
12754
- env,
12755
11555
  userConfig: userConfig.loaded?.config,
12756
11556
  userConfigCheck: userConfig.check
12757
11557
  }),
@@ -12770,9 +11570,8 @@ async function doctorProjectProfile(input = {}) {
12770
11570
  checks,
12771
11571
  ok: checks.every((check) => check.status !== "error"),
12772
11572
  profilePath: path,
12773
- projectKey: profile.project.key,
11573
+ projectKey: profile.repository.name,
12774
11574
  repository: {
12775
- defaultBranch: profile.repository.defaultBranch,
12776
11575
  name: profile.repository.name,
12777
11576
  owner: profile.repository.owner
12778
11577
  },
@@ -12811,12 +11610,12 @@ function resolveDoctorUserConfig(env, provided) {
12811
11610
  function buildProfileChecks(profile, cwd, userConfigInput) {
12812
11611
  return [
12813
11612
  {
12814
- message: `Loaded schema version ${profile.schemaVersion} profile for ${profile.project.key}.`,
11613
+ message: `Loaded schema version ${profile.schemaVersion} profile for ${profile.repository.name}.`,
12815
11614
  name: "profile",
12816
11615
  status: "ok"
12817
11616
  },
12818
11617
  {
12819
- message: `${profile.repository.owner}/${profile.repository.name} uses ${profile.repository.defaultBranch}.`,
11618
+ message: `${profile.repository.owner}/${profile.repository.name} repository identity loaded.`,
12820
11619
  name: "repository",
12821
11620
  status: "ok"
12822
11621
  },
@@ -12825,31 +11624,16 @@ function buildProfileChecks(profile, cwd, userConfigInput) {
12825
11624
  name: "verification",
12826
11625
  status: "ok"
12827
11626
  },
12828
- requiredEnvironmentCheck(profile, userConfigInput.env),
12829
11627
  {
12830
- message: `${profile.review.modes.join(", ")} review modes configured with ${profile.review.defaultMaxCycles} cycle cap.`,
11628
+ message: `Correctness review is structural with a 5-cycle cap; ${profile.review?.conditional?.length ?? 0} security path group(s) configured.`,
12831
11629
  name: "review-policy",
12832
11630
  status: "ok"
12833
11631
  },
12834
- reviewLadderCheck(profile),
11632
+ reviewLadderCheck(),
12835
11633
  userConfigInput.userConfigCheck,
12836
- githubAppCheck(userConfigInput.userConfig),
12837
- gitRemoteCheck(profile, cwd)
12838
- ];
12839
- }
12840
- function requiredEnvironmentCheck(profile, env) {
12841
- const required = profile.env?.required ?? [];
12842
- const unset = required.filter((name) => !env[name]);
12843
- if (unset.length > 0) return {
12844
- message: `Required environment variables are unset: ${unset.join(", ")}.`,
12845
- name: "environment",
12846
- status: "error"
12847
- };
12848
- return {
12849
- message: required.length === 0 ? "No required environment variables declared." : `${required.length} required environment variable name(s) are set.`,
12850
- name: "environment",
12851
- status: "ok"
12852
- };
11634
+ githubAppCheck(userConfigInput.userConfig),
11635
+ gitRemoteCheck(profile, cwd)
11636
+ ];
12853
11637
  }
12854
11638
  function githubAppCheck(userConfig) {
12855
11639
  const app = userConfig?.githubApp;
@@ -12864,85 +11648,440 @@ function githubAppCheck(userConfig) {
12864
11648
  status: "warning"
12865
11649
  };
12866
11650
  return {
12867
- message: `Patronage Factory GitHub App id ${app.appId} is configured${app.installationId ? ` for installation ${app.installationId}` : " with repository installation discovery"}.`,
12868
- name: "github-app",
12869
- status: "ok"
11651
+ message: `Patronage Factory GitHub App id ${app.appId} is configured${app.installationId ? ` for installation ${app.installationId}` : " with repository installation discovery"}.`,
11652
+ name: "github-app",
11653
+ status: "ok"
11654
+ };
11655
+ }
11656
+ function reviewLadderCheck() {
11657
+ return {
11658
+ message: `Review ladder: gate cap ${resolveReviewLadderPolicy().gate.cap}.`,
11659
+ name: "review-ladder",
11660
+ status: "ok"
11661
+ };
11662
+ }
11663
+ function gitRemoteCheck(profile, cwd) {
11664
+ const origin = readGitOrigin(cwd);
11665
+ if (!origin) return {
11666
+ message: "No git origin remote was available for this working directory.",
11667
+ name: "git-origin",
11668
+ status: "warning"
11669
+ };
11670
+ if (repositoryUrlMatches(origin, profile)) return {
11671
+ message: `Git origin matches ${profile.repository.owner}/${profile.repository.name}.`,
11672
+ name: "git-origin",
11673
+ status: "ok"
11674
+ };
11675
+ return {
11676
+ message: `Git origin ${origin} does not match ${profile.repository.owner}/${profile.repository.name}.`,
11677
+ name: "git-origin",
11678
+ status: "warning"
11679
+ };
11680
+ }
11681
+ function readGitOrigin(cwd) {
11682
+ try {
11683
+ return execFileSync("git", [
11684
+ "remote",
11685
+ "get-url",
11686
+ "origin"
11687
+ ], {
11688
+ cwd,
11689
+ encoding: "utf-8",
11690
+ stdio: [
11691
+ "ignore",
11692
+ "pipe",
11693
+ "ignore"
11694
+ ]
11695
+ }).trim();
11696
+ } catch {
11697
+ return null;
11698
+ }
11699
+ }
11700
+ function repositoryUrlMatches(origin, profile) {
11701
+ const normalized = origin.replace(/\.git$/u, "");
11702
+ const repoPath = `${profile.repository.owner}/${profile.repository.name}`;
11703
+ return normalized.endsWith(`github.com:${repoPath}`) || normalized.endsWith(`github.com/${repoPath}`) || normalized.endsWith(repoPath);
11704
+ }
11705
+ //#endregion
11706
+ //#region src/commands/doctor.ts
11707
+ function createDoctorCommand(output) {
11708
+ return new Command("doctor").description("Validate a project profile without mutating GitHub or local files").option("--base <ref>", "base branch or ref for --preflight", "origin/main").option("--cwd <path>", "working directory to validate", ".").option("--json", "print the doctor report as JSON").option("--preflight", "also list the pre-checkable admission requirements for the current candidate (read-only; never blocks); findings-file shape and wave membership have no read-only pre-check").option("--profile <path>", "path to the project profile JSON file").action(async (options) => {
11709
+ const report = await doctorProjectProfile({
11710
+ base: options.base,
11711
+ cwd: resolveCwdOption(options.cwd),
11712
+ preflight: options.preflight,
11713
+ profilePath: options.profile
11714
+ });
11715
+ if (options.json) output.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
11716
+ else output.stdout.write(formatHumanReport(report));
11717
+ if (!report.ok) process.exitCode = 1;
11718
+ });
11719
+ }
11720
+ function formatHumanReport(report) {
11721
+ return [
11722
+ `Profile: ${report.projectKey} (schema ${report.schemaVersion})`,
11723
+ `Repository: ${report.repository.owner}/${report.repository.name}`,
11724
+ `Path: ${report.profilePath}`,
11725
+ "",
11726
+ ...report.checks.map((check) => `[${check.status}] ${check.name}: ${check.message}`),
11727
+ ""
11728
+ ].join("\n");
11729
+ }
11730
+ //#endregion
11731
+ //#region src/epic-structure.ts
11732
+ /**
11733
+ * Producer-side `epic-structure` v1 emitter (epic #132, lane #135).
11734
+ *
11735
+ * `psf epic:publish-structure` reads a planner's ephemeral `dag.yml` (never
11736
+ * committed — see epic #132 / ADR 0018), validates the graph fail-closed, and
11737
+ * POSTs an `epic-structure` v1 event to HQ's `/api/ingest` boundary. This
11738
+ * module is the pure core: parse-to-IR validation, the wire mapping, and the
11739
+ * deterministic content-addressed eventId. All I/O (stdin/file read, fetch)
11740
+ * lives in the command wrapper.
11741
+ *
11742
+ * WIRE PARITY (do not fork the schema): the emitted payload is the exact shape
11743
+ * HQ's `EpicGraphSchema` accepts (`software-factory-hq/src/contracts/
11744
+ * epic-schemas.ts`) and is built by hand the same way `seed-dev.ts` builds it
11745
+ * — no re-declared zod twin. HQ's `epic-structure-parity.test.ts` imports
11746
+ * {@link buildEpicStructureEvent} and runs its output through the real
11747
+ * `EpicGraphSchema`, so any drift (field, enum, bound, strictness) fails CI.
11748
+ *
11749
+ * The `dag.yml` schema itself is documented with the epic skill (lane #137);
11750
+ * this module is the executable contract, not a second source of truth.
11751
+ */
11752
+ const EPIC_STRUCTURE_SCHEMA_VERSION = 1;
11753
+ /**
11754
+ * DAG-node PLANNING statuses — the planning-plane vocabulary a planner authors
11755
+ * on a `dag.yml` node. Carried forward by ADR 0018 §4 and defined by the epic
11756
+ * skill (`reference/epic-artifacts.md`) and both `CONTEXT.md` glossaries.
11757
+ *
11758
+ * A DAG node is a unit of *plan*; an HQ lane is a unit of *runtime execution*.
11759
+ * These are two planes with two vocabularies and must not be conflated — the
11760
+ * producer validates and emits ONLY planning statuses:
11761
+ *
11762
+ * - `open` authored, not yet done
11763
+ * - `closed` done (a PR merged, or otherwise resolved)
11764
+ * - `satisfied-on-main` already true on main without a dedicated PR
11765
+ * - `parked` real node, scope still moving — relabeled off
11766
+ * `ready-for-agent`, never closed
11767
+ *
11768
+ * HQ's `DagNodeSchema` (`software-factory-hq/src/contracts/epic-schemas.ts`)
11769
+ * accepts this planning vocabulary on ingest AND overlays a runtime LANE status
11770
+ * onto a node once a lane is linked (`syncNodesWithLanes` /
11771
+ * `projectEpicMembership`) — the overlay is HQ-internal; the producer never
11772
+ * emits a lane status. HQ's `epic-structure-parity.test.ts` fails CI if the
11773
+ * producer emits any status HQ won't accept.
11774
+ */
11775
+ const EPIC_STRUCTURE_NODE_STATUSES = [
11776
+ "open",
11777
+ "closed",
11778
+ "satisfied-on-main",
11779
+ "parked"
11780
+ ];
11781
+ /**
11782
+ * Fail-closed validation error carrying every offending node/edge diagnostic.
11783
+ */
11784
+ var EpicStructureValidationError = class extends Error {
11785
+ diagnostics;
11786
+ constructor(diagnostics) {
11787
+ super(`epic:publish-structure refused: dag graph is invalid\n${diagnostics.map((line) => ` - ${line}`).join("\n")}`);
11788
+ this.name = "EpicStructureValidationError";
11789
+ this.diagnostics = diagnostics;
11790
+ }
11791
+ };
11792
+ const isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
11793
+ const nonEmptyString = (value) => typeof value === "string" && value.trim().length > 0;
11794
+ const isPositiveInteger = (value) => typeof value === "number" && Number.isInteger(value) && value > 0;
11795
+ const slugify = (value) => value.trim().toLowerCase().replaceAll(/[^a-z0-9]+/gu, "-").replaceAll(/^-+|-+$/gu, "") || "epic";
11796
+ const laneIdFor = (repo, issue) => `lane-${repo}-issue-${issue}`;
11797
+ const validateNodes = (rawNodes, diagnostics) => {
11798
+ const nodes = [];
11799
+ const slugs = /* @__PURE__ */ new Set();
11800
+ for (const [index, raw] of rawNodes.entries()) {
11801
+ if (!isPlainObject(raw)) {
11802
+ diagnostics.push(`node[${index}]: expected a mapping`);
11803
+ continue;
11804
+ }
11805
+ const label = nonEmptyString(raw.slug) ? `"${raw.slug}"` : `node[${index}]`;
11806
+ if (!nonEmptyString(raw.slug)) diagnostics.push(`node[${index}]: missing required \`slug\``);
11807
+ else if (slugs.has(raw.slug)) diagnostics.push(`node ${label}: duplicate slug`);
11808
+ else slugs.add(raw.slug);
11809
+ if (!nonEmptyString(raw.title)) diagnostics.push(`node ${label}: missing required \`title\``);
11810
+ const external = raw.external === true;
11811
+ if (!external && !isPositiveInteger(raw.issue)) diagnostics.push(`node ${label}: \`issue\` number is required — non-external nodes must carry a minted issue (this runs post-ratification); set \`external: true\` to exempt an adoption/upstream node`);
11812
+ let status = "open";
11813
+ if (raw.status === void 0) status = "open";
11814
+ else if (EPIC_STRUCTURE_NODE_STATUSES.includes(raw.status)) status = raw.status;
11815
+ else diagnostics.push(`node ${label}: \`status\` must be one of ${EPIC_STRUCTURE_NODE_STATUSES.join(", ")} (DAG-node planning statuses, not HQ lane statuses)`);
11816
+ if (raw.laneId !== void 0 && !nonEmptyString(raw.laneId)) diagnostics.push(`node ${label}: \`laneId\` must be a non-empty string`);
11817
+ nodes.push({
11818
+ external,
11819
+ ...isPositiveInteger(raw.issue) ? { issue: raw.issue } : {},
11820
+ ...nonEmptyString(raw.laneId) ? { laneId: raw.laneId } : {},
11821
+ slug: nonEmptyString(raw.slug) ? raw.slug : `node[${index}]`,
11822
+ status,
11823
+ title: nonEmptyString(raw.title) ? raw.title : ""
11824
+ });
11825
+ }
11826
+ return {
11827
+ nodes,
11828
+ slugs
11829
+ };
11830
+ };
11831
+ const validateEdges = (rawEdges, slugs, diagnostics) => {
11832
+ const edges = [];
11833
+ for (const [index, raw] of rawEdges.entries()) {
11834
+ if (!isPlainObject(raw)) {
11835
+ diagnostics.push(`edge[${index}]: expected a mapping`);
11836
+ continue;
11837
+ }
11838
+ if (raw.type !== "depends-on") continue;
11839
+ if (!nonEmptyString(raw.from) || !nonEmptyString(raw.to)) {
11840
+ diagnostics.push(`edge[${index}]: depends-on edge requires string \`from\` and \`to\``);
11841
+ continue;
11842
+ }
11843
+ if (!slugs.has(raw.from)) diagnostics.push(`edge ${raw.from} -> ${raw.to}: \`from\` references unknown slug "${raw.from}"`);
11844
+ if (!slugs.has(raw.to)) diagnostics.push(`edge ${raw.from} -> ${raw.to}: \`to\` references unknown slug "${raw.to}"`);
11845
+ if (raw.from === raw.to) diagnostics.push(`edge ${raw.from} -> ${raw.to}: self-dependency (cycle)`);
11846
+ edges.push({
11847
+ from: raw.from,
11848
+ to: raw.to
11849
+ });
11850
+ }
11851
+ return edges;
11852
+ };
11853
+ /**
11854
+ * Detects a dependency cycle over the depends-on edges and returns the offending
11855
+ * cycle path (`a -> b -> a`) if one exists, else undefined. Only edges whose
11856
+ * endpoints both resolve to known slugs are walked, so unknown-slug diagnostics
11857
+ * are reported independently and never masquerade as cycles.
11858
+ */
11859
+ const findCycle = (slugs, edges) => {
11860
+ const adjacency = /* @__PURE__ */ new Map();
11861
+ for (const { from, to } of edges) if (slugs.has(from) && slugs.has(to)) {
11862
+ const list = adjacency.get(from) ?? [];
11863
+ list.push(to);
11864
+ adjacency.set(from, list);
11865
+ }
11866
+ const UNVISITED = 0;
11867
+ const IN_STACK = 1;
11868
+ const DONE = 2;
11869
+ const state = /* @__PURE__ */ new Map();
11870
+ const stack = [];
11871
+ const walk = (node) => {
11872
+ state.set(node, IN_STACK);
11873
+ stack.push(node);
11874
+ for (const next of adjacency.get(node) ?? []) {
11875
+ const marker = state.get(next) ?? UNVISITED;
11876
+ if (marker === IN_STACK) {
11877
+ const start = stack.indexOf(next);
11878
+ return [...stack.slice(start), next];
11879
+ }
11880
+ if (marker === UNVISITED) {
11881
+ const found = walk(next);
11882
+ if (found) return found;
11883
+ }
11884
+ }
11885
+ stack.pop();
11886
+ state.set(node, DONE);
11887
+ };
11888
+ for (const slug of slugs) if ((state.get(slug) ?? UNVISITED) === UNVISITED) {
11889
+ const found = walk(slug);
11890
+ if (found) return found;
11891
+ }
11892
+ };
11893
+ const resolveEpicId = (epic) => {
11894
+ if (typeof epic === "number") return String(epic);
11895
+ if (nonEmptyString(epic)) return epic.trim();
11896
+ };
11897
+ /**
11898
+ * Validates a dag document fail-closed. Throws {@link
11899
+ * EpicStructureValidationError} with a diagnostic per offending node/edge.
11900
+ */
11901
+ const validateDagDocument = (input) => {
11902
+ const doc = input.document;
11903
+ const diagnostics = [];
11904
+ if (!isPlainObject(doc)) throw new EpicStructureValidationError(["dag document must be a YAML/JSON mapping with `epic` and `nodes`"]);
11905
+ const epicId = resolveEpicId(doc.epic);
11906
+ if (epicId === void 0) diagnostics.push("dag document requires a top-level `epic` id");
11907
+ const repo = nonEmptyString(doc.repo) ? doc.repo.trim() : void 0;
11908
+ if (repo === void 0) diagnostics.push("repo is required in the dag document — set top-level `repo:` (needed for the epic record and lane-id derivation)");
11909
+ const rawNodes = Array.isArray(doc.nodes) ? doc.nodes : void 0;
11910
+ if (rawNodes === void 0 || rawNodes.length === 0) diagnostics.push("dag document requires a non-empty `nodes` array");
11911
+ const { nodes, slugs } = validateNodes(rawNodes ?? [], diagnostics);
11912
+ const edges = validateEdges(Array.isArray(doc.edges) ? doc.edges : [], slugs, diagnostics);
11913
+ const cycle = findCycle(slugs, edges);
11914
+ if (cycle) diagnostics.push(`dependency cycle detected: ${cycle.join(" -> ")}`);
11915
+ if (diagnostics.length > 0 || epicId === void 0 || repo === void 0) throw new EpicStructureValidationError(diagnostics);
11916
+ return {
11917
+ boundary: slugify(nonEmptyString(doc.boundary) ? doc.boundary : `epic-${epicId}`),
11918
+ edges,
11919
+ epicId,
11920
+ name: nonEmptyString(doc.name) ? doc.name.trim() : `Epic ${epicId}`,
11921
+ nodes,
11922
+ repo
12870
11923
  };
12871
- }
12872
- function reviewLadderCheck(profile) {
11924
+ };
11925
+ /**
11926
+ * Builds the wire payload with deterministic ordering (nodes by slug, edges by
11927
+ * from then to) so re-emission of the same graph is byte-stable regardless of
11928
+ * the planner's source ordering — the basis for the content-addressed eventId.
11929
+ */
11930
+ const buildEpicStructurePayload = (dag) => {
11931
+ const nodes = dag.nodes.map((node) => {
11932
+ const laneId = node.laneId ?? (node.issue === void 0 ? void 0 : laneIdFor(dag.repo, node.issue));
11933
+ return {
11934
+ epicId: dag.epicId,
11935
+ ...laneId === void 0 ? {} : { laneId },
11936
+ slug: node.slug,
11937
+ status: node.status,
11938
+ title: node.title
11939
+ };
11940
+ }).toSorted((a, b) => a.slug.localeCompare(b.slug));
12873
11941
  return {
12874
- message: `Review ladder: gate cap ${resolveReviewLadderPolicy(profile).gate.cap}.`,
12875
- name: "review-ladder",
12876
- status: "ok"
12877
- };
12878
- }
12879
- function gitRemoteCheck(profile, cwd) {
12880
- const origin = readGitOrigin(cwd);
12881
- if (!origin) return {
12882
- message: "No git origin remote was available for this working directory.",
12883
- name: "git-origin",
12884
- status: "warning"
12885
- };
12886
- if (repositoryUrlMatches(origin, profile)) return {
12887
- message: `Git origin matches ${profile.repository.owner}/${profile.repository.name}.`,
12888
- name: "git-origin",
12889
- status: "ok"
11942
+ edges: dag.edges.map((edge) => ({
11943
+ epicId: dag.epicId,
11944
+ from: edge.from,
11945
+ to: edge.to,
11946
+ type: "depends-on"
11947
+ })).toSorted((a, b) => a.from.localeCompare(b.from) || a.to.localeCompare(b.to)),
11948
+ epics: [{
11949
+ id: dag.epicId,
11950
+ name: dag.name,
11951
+ repo: dag.repo
11952
+ }],
11953
+ nodes
12890
11954
  };
11955
+ };
11956
+ /** Stable, key-sorted JSON for content hashing (values already ordered). */
11957
+ const stableStringify = (value) => {
11958
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
11959
+ if (isPlainObject(value)) return `{${Object.keys(value).toSorted().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`;
11960
+ return JSON.stringify(value ?? null);
11961
+ };
11962
+ /**
11963
+ * Deterministic eventId = `epic-structure-<boundary>-<sha256(payload)[0:16]>`.
11964
+ * Re-emitting identical graph content yields the same eventId (HQ dedups →
11965
+ * 200/duplicate); an amendment changes the content hash → a new eventId that
11966
+ * supersedes the prior structure wholesale (HQ keeps the latest). This mirrors
11967
+ * seed-dev's fixed-eventId idempotency, content-addressed instead of literal.
11968
+ */
11969
+ const epicStructureEventId = (boundary, payload) => `epic-structure-${slugify(boundary)}-${createHash("sha256").update(stableStringify(payload)).digest("hex").slice(0, 16)}`;
11970
+ /**
11971
+ * Validates and builds the full `epic-structure` v1 ingest event, ready to POST
11972
+ * to `/api/ingest`. Throws {@link EpicStructureValidationError} fail-closed.
11973
+ */
11974
+ const buildEpicStructureEvent = (input) => {
11975
+ const dag = validateDagDocument(input);
11976
+ const payload = buildEpicStructurePayload(dag);
12891
11977
  return {
12892
- message: `Git origin ${origin} does not match ${profile.repository.owner}/${profile.repository.name}.`,
12893
- name: "git-origin",
12894
- status: "warning"
11978
+ eventId: epicStructureEventId(dag.boundary, payload),
11979
+ kind: "epic-structure",
11980
+ observedAt: input.observedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
11981
+ payload,
11982
+ schemaVersion: 1
12895
11983
  };
12896
- }
12897
- function readGitOrigin(cwd) {
11984
+ };
11985
+ /** The prod HQ worker is reachable at `hq.patronage.com` and its frozen
11986
+ * `-v1.*.workers.dev` alias. Emitting there without an Access service token is
11987
+ * fail-closed. */
11988
+ const isProductionHqUrl = (url) => url.includes("hq.patronage.com") || /-v1\.[^/]*\.workers\.dev/iu.test(url);
11989
+ /**
11990
+ * POSTs the event to HQ ingest. Fail-closed: a production URL without a
11991
+ * Cloudflare Access service token is refused before any request; a 401/403 is
11992
+ * surfaced with actionable guidance; any non-2xx throws.
11993
+ */
11994
+ const publishEpicStructure = async (args) => {
11995
+ if (!args.accessServiceToken && isProductionHqUrl(args.url)) throw new Error(`epic:publish-structure refusing to publish to production ${args.url} without a Cloudflare Access service token: set CF-Access-Client-Id and CF-Access-Client-Secret`);
11996
+ const doFetch = args.fetchImpl ?? globalThis.fetch;
11997
+ const requestInit = args.accessServiceToken ? buildCloudflareAccessRequestInit(args.accessServiceToken, {
11998
+ body: JSON.stringify(args.event),
11999
+ headers: { "content-type": "application/json" },
12000
+ method: "POST",
12001
+ ...args.signal ? { signal: args.signal } : {}
12002
+ }) : {
12003
+ body: JSON.stringify(args.event),
12004
+ headers: { "content-type": "application/json" },
12005
+ method: "POST",
12006
+ ...args.signal ? { signal: args.signal } : {}
12007
+ };
12008
+ let response;
12898
12009
  try {
12899
- return execFileSync("git", [
12900
- "remote",
12901
- "get-url",
12902
- "origin"
12903
- ], {
12904
- cwd,
12905
- encoding: "utf-8",
12906
- stdio: [
12907
- "ignore",
12908
- "pipe",
12909
- "ignore"
12910
- ]
12911
- }).trim();
12010
+ const normalizedUrl = args.url.replace(/\/+$/u, "");
12011
+ response = await doFetch(normalizedUrl.endsWith("/api/ingest") ? normalizedUrl : `${normalizedUrl}/api/ingest`, requestInit);
12912
12012
  } catch {
12913
- return null;
12013
+ throw new Error(`epic:publish-structure could not reach HQ ingest at ${args.url}: request failed. Check the endpoint and network; redirects are refused to protect Cloudflare Access credentials.`);
12914
12014
  }
12915
- }
12916
- function repositoryUrlMatches(origin, profile) {
12917
- const normalized = origin.replace(/\.git$/u, "");
12918
- const repoPath = `${profile.repository.owner}/${profile.repository.name}`;
12919
- return normalized.endsWith(`github.com:${repoPath}`) || normalized.endsWith(`github.com/${repoPath}`) || normalized.endsWith(repoPath);
12920
- }
12015
+ const text = await response.text();
12016
+ if (response.status === 401 || response.status === 403) throw new Error(`epic:publish-structure rejected by Cloudflare Access or HQ ingest (${response.status}): set valid CF-Access-Client-Id and CF-Access-Client-Secret service-token inputs. Response: ${text}`);
12017
+ if (response.status < 200 || response.status >= 300) throw new Error(`epic:publish-structure failed: HQ ingest returned ${response.status} ${text}`);
12018
+ let duplicate = false;
12019
+ try {
12020
+ duplicate = JSON.parse(text).duplicate === true;
12021
+ } catch {
12022
+ duplicate = response.status === 200;
12023
+ }
12024
+ return {
12025
+ duplicate,
12026
+ eventId: args.event.eventId,
12027
+ status: response.status
12028
+ };
12029
+ };
12921
12030
  //#endregion
12922
- //#region src/commands/doctor.ts
12923
- function createDoctorCommand(output) {
12924
- return new Command("doctor").description("Validate a project profile without mutating GitHub or local files").option("--base <ref>", "base branch or ref for --preflight", "origin/main").option("--cwd <path>", "working directory to validate", ".").option("--json", "print the doctor report as JSON").option("--preflight", "also list the pre-checkable admission requirements for the current candidate (read-only; never blocks); findings-file shape and wave membership have no read-only pre-check").option("--profile <path>", "path to the project profile JSON file").action(async (options) => {
12925
- const report = await doctorProjectProfile({
12926
- base: options.base,
12927
- cwd: resolveCwdOption(options.cwd),
12928
- preflight: options.preflight,
12929
- profilePath: options.profile
12031
+ //#region src/commands/epic-publish-structure.ts
12032
+ const STDIN = "-";
12033
+ /** Reads a dag.yml source path, or stdin when `source` is `-`. */
12034
+ const readSource = (source) => {
12035
+ const fd = source === STDIN ? 0 : source;
12036
+ try {
12037
+ return readFileSync(fd, "utf-8");
12038
+ } catch (error) {
12039
+ const cause = error instanceof Error ? error.message : String(error);
12040
+ throw new Error(`epic:publish-structure could not read ${source === STDIN ? "stdin" : source} (${cause})`, { cause: error });
12041
+ }
12042
+ };
12043
+ /** Parses a dag.yml/JSON document read from `readSource`. */
12044
+ const parseDocument = (raw, source) => {
12045
+ if (raw.trim() === "") throw new Error(`epic:publish-structure received empty input from ${source === STDIN ? "stdin" : source}`);
12046
+ try {
12047
+ return parse(raw);
12048
+ } catch (error) {
12049
+ const cause = error instanceof Error ? error.message : String(error);
12050
+ throw new Error(`epic:publish-structure could not parse YAML: ${cause}`, { cause: error });
12051
+ }
12052
+ };
12053
+ /**
12054
+ * Reads and validates a dag.yml/JSON source into a full `epic-structure` v1
12055
+ * event, ready to POST or dry-run print. Required event identity lives in the
12056
+ * document; the command does not patch authored structure with override flags.
12057
+ */
12058
+ const buildEvent = (source) => {
12059
+ return buildEpicStructureEvent({ document: parseDocument(readSource(source), source) });
12060
+ };
12061
+ function createEpicPublishStructureCommand(output, deps = {}) {
12062
+ const env = deps.env ?? process.env;
12063
+ return new Command("epic:publish-structure").description("Emit-only: read a planner's ephemeral dag.yml (or - for stdin), validate the graph fail-closed (YAML parses, required fields, edges reference known slugs, acyclic, issue numbers on every non-external node), and POST an epic-structure v1 event to HQ ingest. Idempotent by a content-addressed eventId (re-emission of an amendment supersedes the prior structure). Production auth uses the CF-Access-Client-Id and CF-Access-Client-Secret environment inputs. URL: --url or HQ_INGEST_URL. The dag.yml schema is documented with the epic skill (#137).").argument("[source]", "path to dag.yml, or - for stdin", STDIN).option("--url <url>", "HQ ingest base URL (default: HQ_INGEST_URL env). The command POSTs to <url>/api/ingest").option("--dry-run", "validate and build the event, print it, and exit without POSTing").option("--json", "print the built event / publish result as JSON").action(async (source, options) => {
12064
+ const event = buildEvent(source);
12065
+ if (options.dryRun) {
12066
+ output.stdout.write(options.json ? `${JSON.stringify(event, null, 2)}\n` : `epic:publish-structure OK (dry-run) — eventId ${event.eventId}, ${event.payload.nodes.length} node(s), ${event.payload.edges.length} edge(s)\n`);
12067
+ return;
12068
+ }
12069
+ const url = options.url ?? env.HQ_INGEST_URL;
12070
+ if (!url) throw new Error("epic:publish-structure requires an HQ ingest URL: pass --url or set HQ_INGEST_URL");
12071
+ const clientId = env[CF_ACCESS_CLIENT_ID_ENV];
12072
+ const clientSecret = env[CF_ACCESS_CLIENT_SECRET_ENV];
12073
+ const result = await publishEpicStructure({
12074
+ ...clientId && clientSecret ? { accessServiceToken: {
12075
+ clientId,
12076
+ clientSecret
12077
+ } } : {},
12078
+ event,
12079
+ fetchImpl: deps.fetchImpl,
12080
+ url
12930
12081
  });
12931
- if (options.json) output.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
12932
- else output.stdout.write(formatHumanReport(report));
12933
- if (!report.ok) process.exitCode = 1;
12082
+ output.stdout.write(options.json ? `${JSON.stringify(result, null, 2)}\n` : `epic:publish-structure ${result.duplicate ? "no-op (duplicate)" : "accepted"} — eventId ${result.eventId} [HTTP ${result.status}]\n`);
12934
12083
  });
12935
12084
  }
12936
- function formatHumanReport(report) {
12937
- return [
12938
- `Profile: ${report.projectKey} (schema ${report.schemaVersion})`,
12939
- `Repository: ${report.repository.owner}/${report.repository.name} default ${report.repository.defaultBranch}`,
12940
- `Path: ${report.profilePath}`,
12941
- "",
12942
- ...report.checks.map((check) => `[${check.status}] ${check.name}: ${check.message}`),
12943
- ""
12944
- ].join("\n");
12945
- }
12946
12085
  //#endregion
12947
12086
  //#region src/commands/evidence-emit.ts
12948
12087
  const readStdin = () => {
@@ -12957,6 +12096,15 @@ const readStdin = () => {
12957
12096
  if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error("evidence:emit stdin must be a JSON object of field values");
12958
12097
  return parsed;
12959
12098
  };
12099
+ const RETIRED_STDIN_FIELDS = [
12100
+ "checkType",
12101
+ "rung",
12102
+ "model"
12103
+ ];
12104
+ function rejectRetiredStdinFields(input) {
12105
+ const found = RETIRED_STDIN_FIELDS.filter((field) => field in input);
12106
+ if (found.length > 0) throw new Error(`evidence:emit stdin no longer accepts ${found.join(", ")}; external evidence is verify-only. Remove ${found.join(", ")} from the JSON payload and emit the check with --check, --outcome, and --producer.`);
12107
+ }
12960
12108
  const stringField = (value, name) => {
12961
12109
  if (value === void 0) return;
12962
12110
  if (typeof value !== "string" || value.trim() === "") throw new Error(`evidence:emit ${name} must be a non-empty string`);
@@ -12966,10 +12114,6 @@ const requireField = (value, name) => {
12966
12114
  if (value === void 0) throw new Error(`evidence:emit requires ${name} (flag or stdin field)`);
12967
12115
  return value;
12968
12116
  };
12969
- const asCheckType = (value) => {
12970
- if (!EVIDENCE_CHECK_TYPES$1.includes(value)) throw new Error(`evidence:emit --check-type must be one of: ${EVIDENCE_CHECK_TYPES$1.join(", ")}`);
12971
- return value;
12972
- };
12973
12117
  const asOutcome = (value) => {
12974
12118
  if (value !== "pass" && value !== "fail") throw new Error("evidence:emit --outcome must be \"pass\" or \"fail\"");
12975
12119
  return value;
@@ -12978,48 +12122,25 @@ function optionalString(key, flagValue, stdinValue) {
12978
12122
  const resolved = stringField(flagValue, `--${key}`) ?? stringField(stdinValue, key);
12979
12123
  return resolved ? { [key]: resolved } : {};
12980
12124
  }
12981
- const findRequiredCheckInProfile = ({ check, cwd, profilePath }) => {
12982
- if (profilePath === void 0) return;
12983
- return loadProjectProfile({
12984
- cwd,
12985
- profilePath
12986
- }).profile.requiredChecks?.find((entry) => entry.name === check);
12987
- };
12988
- const resolveCheckType = ({ explicitCheckType, requiredCheck }) => {
12989
- if (explicitCheckType !== void 0 && requiredCheck !== void 0 && explicitCheckType !== requiredCheck.checkType) throw new Error(`evidence:emit --check-type "${explicitCheckType}" conflicts with the profile's requiredChecks declaration for "${requiredCheck.name}" (checkType "${requiredCheck.checkType}")`);
12990
- return explicitCheckType ?? requiredCheck?.checkType;
12991
- };
12992
- const asRung = (value) => {
12993
- if (!EVIDENCE_REVIEW_RUNGS$1.includes(value)) throw new Error(`evidence:emit --rung must be one of: ${EVIDENCE_REVIEW_RUNGS$1.join(", ")}`);
12994
- return value;
12995
- };
12996
- function createEvidenceEmitCommand(output, action = runEvidenceEmit) {
12997
- return new Command("evidence:emit").description("Build, validate, and write an external-evidence envelope (ADR 0014)").option("--cwd <path>", "repository working directory", ".").option("--base <ref>", "base branch or ref for the three-way binding", "origin/main").option("--check <name>", "declared requiredChecks name this evidence satisfies").option("--check-type <type>", `one of: ${EVIDENCE_CHECK_TYPES$1.join(", ")}`).option("--outcome <pass|fail>", "the check outcome").option("--producer <id>", "the producing identity (recorded, not gated)").option("--rung <rung>", `review-type only: ${EVIDENCE_REVIEW_RUNGS$1.join(", ")}`).option("--model <model>", "review-type only: the concrete model").option("--policy-version <version>", "canonical prompt / Warden rules version").option("--session-id <id>", "producer session id (independence join key)").option("--request-id <id>", "producer request id (join key)").option("--findings-pointer <ref>", "pointer to findings (never embedded)").option("--profile <path>", "path to the project profile JSON file (accepted for a uniform consumer-wrapper flag set; when the profile declares a requiredChecks entry matching --check, its checkType fills --check-type if omitted)").option("--output <path>", "write the envelope to a specific path").option("--stdin", "merge JSON field values from stdin (flags win)").option("--json", "print the written envelope as JSON").action((options) => {
12125
+ function createEvidenceEmitCommand(output, action = runEvidenceEmit, readInput = readStdin) {
12126
+ return new Command("evidence:emit").description("Build, validate, and write an external-evidence envelope (ADR 0014)").option("--cwd <path>", "repository working directory", ".").option("--base <ref>", "base branch or ref for the three-way binding", "origin/main").option("--check <name>", "declared requiredChecks name this evidence satisfies").option("--outcome <pass|fail>", "the check outcome").option("--producer <id>", "the producing identity (recorded, not gated)").option("--policy-version <version>", "canonical prompt / Warden rules version").option("--session-id <id>", "producer session id (independence join key)").option("--request-id <id>", "producer request id (join key)").option("--findings-pointer <ref>", "pointer to findings (never embedded)").option("--profile <path>", "path to the project profile JSON file (accepted for a uniform consumer-wrapper flag set and loaded strictly when supplied)").option("--output <path>", "write the envelope to a specific path").option("--stdin", "merge JSON field values from stdin (flags win)").option("--json", "print the written envelope as JSON").action((options) => {
12998
12127
  const cwd = resolveCwdOption(options.cwd);
12999
- const fromStdin = options.stdin ? readStdin() : {};
12128
+ const fromStdin = options.stdin ? readInput() : {};
12129
+ rejectRetiredStdinFields(fromStdin);
13000
12130
  const check = requireField(stringField(options.check, "--check") ?? stringField(fromStdin.check, "check"), "--check");
13001
- const requiredCheck = findRequiredCheckInProfile({
13002
- check,
12131
+ if (options.profile !== void 0) loadProjectProfile({
13003
12132
  cwd,
13004
12133
  profilePath: options.profile
13005
12134
  });
13006
- const checkType = asCheckType(requireField(resolveCheckType({
13007
- explicitCheckType: stringField(options.checkType, "--check-type") ?? stringField(fromStdin.checkType, "checkType"),
13008
- requiredCheck
13009
- }), "--check-type"));
13010
12135
  const outcome = asOutcome(requireField(stringField(options.outcome, "--outcome") ?? stringField(fromStdin.outcome, "outcome"), "--outcome"));
13011
12136
  const producer = requireField(stringField(options.producer, "--producer") ?? stringField(fromStdin.producer, "producer"), "--producer");
13012
- const rungValue = stringField(options.rung, "--rung") ?? stringField(fromStdin.rung, "rung");
13013
12137
  const result = action({
13014
12138
  base: options.base,
13015
12139
  check,
13016
- checkType,
13017
12140
  cwd,
13018
12141
  outcome,
13019
12142
  producer,
13020
12143
  ...options.profile ? { profilePath: options.profile } : {},
13021
- ...rungValue ? { rung: asRung(rungValue) } : {},
13022
- ...optionalString("model", options.model, fromStdin.model),
13023
12144
  ...optionalString("policyVersion", options.policyVersion, fromStdin.policyVersion),
13024
12145
  ...optionalString("sessionId", options.sessionId, fromStdin.sessionId),
13025
12146
  ...optionalString("requestId", options.requestId, fromStdin.requestId),
@@ -13032,89 +12153,6 @@ function createEvidenceEmitCommand(output, action = runEvidenceEmit) {
13032
12153
  });
13033
12154
  }
13034
12155
  //#endregion
13035
- //#region src/lane-threads.ts
13036
- const LANE_THREADS_PATH = ".factory-memory/lane-threads";
13037
- const laneThreadEntrySchema = z.object({
13038
- agent: z.string().min(1),
13039
- branch: z.string().min(1).optional(),
13040
- dispatchedAt: z.iso.datetime(),
13041
- issue: z.number().int().positive(),
13042
- model: z.string().min(1),
13043
- threadId: z.string().min(1)
13044
- }).strict();
13045
- const laneThreadFilename = /^(?<issue>\d+)\.json$/u;
13046
- function issueFromFilename(file) {
13047
- return Number(laneThreadFilename.exec(file)?.groups?.issue ?? "");
13048
- }
13049
- function readLaneThreads(cwd) {
13050
- const directory = path.join(cwd, LANE_THREADS_PATH);
13051
- let files;
13052
- try {
13053
- files = readdirSync(directory).filter((file) => laneThreadFilename.test(file)).toSorted((left, right) => issueFromFilename(left) - issueFromFilename(right));
13054
- } catch (error) {
13055
- if (error.code === "ENOENT") return [];
13056
- throw error;
13057
- }
13058
- return files.map((file) => {
13059
- const entry = laneThreadEntrySchema.parse(JSON.parse(readFileSync(path.join(directory, file), "utf-8")));
13060
- const issue = issueFromFilename(file);
13061
- if (entry.issue !== issue) throw new Error(`Lane thread file ${file} contains issue ${entry.issue}; expected ${issue}.`);
13062
- return entry;
13063
- });
13064
- }
13065
- function writeLaneThread(cwd, entry) {
13066
- const parsed = laneThreadEntrySchema.parse(entry);
13067
- const directory = path.join(cwd, LANE_THREADS_PATH);
13068
- const file = path.join(directory, `${parsed.issue}.json`);
13069
- const temporary = path.join(directory, `${parsed.issue}.${process.pid}.${randomUUID()}.tmp`);
13070
- mkdirSync(directory, { recursive: true });
13071
- try {
13072
- writeFileSync(temporary, `${JSON.stringify(parsed, null, 2)}\n`, "utf-8");
13073
- renameSync(temporary, file);
13074
- } finally {
13075
- rmSync(temporary, { force: true });
13076
- }
13077
- }
13078
- //#endregion
13079
- //#region src/commands/guard.ts
13080
- function resolveGuardCwd(command) {
13081
- return resolveCwdOption(command.optsWithGlobals().cwd ?? ".");
13082
- }
13083
- function createGuardCommand(output) {
13084
- const guard = new Command("guard").description("Factory worker safety guards").option("--cwd <path>", "working directory").enablePositionalOptions();
13085
- guard.command("worker-checkout").description("Refuse branch creation or tracked-file mutation in the control/primary checkout").option("--cwd <path>", "working directory to check").action((_options, command) => {
13086
- assertWorkerCheckoutAllowed({
13087
- cwd: resolveGuardCwd(command),
13088
- operation: "create a branch or mutate tracked files"
13089
- });
13090
- output.stdout.write("Worker checkout guard: OK\n");
13091
- });
13092
- guard.command("no-stash").description("Refuse `git stash` in a linked/shared worktree (stash reflog is shared across worktrees)").option("--cwd <path>", "working directory to check").action((_options, command) => {
13093
- assertNoSharedStash({
13094
- args: ["stash"],
13095
- command: "git",
13096
- cwd: resolveGuardCwd(command)
13097
- });
13098
- output.stdout.write("Stash guard: OK\n");
13099
- });
13100
- const laneThread = guard.command("lane-thread").description("Record or read lane-to-agent-thread identities");
13101
- laneThread.command("append").requiredOption("--agent <kind>", "agent kind").option("--branch <branch>", "lane branch").option("--cwd <path>", "working directory").option("--dispatched-at <timestamp>", "ISO dispatch timestamp").requiredOption("--issue <number>", "lane issue number", Number).requiredOption("--model <model>", "agent model").requiredOption("--thread-id <id>", "agent thread or session id").action((options, command) => {
13102
- writeLaneThread(resolveGuardCwd(command), {
13103
- agent: options.agent,
13104
- branch: options.branch,
13105
- dispatchedAt: options.dispatchedAt ?? (/* @__PURE__ */ new Date()).toISOString(),
13106
- issue: options.issue,
13107
- model: options.model,
13108
- threadId: options.threadId
13109
- });
13110
- output.stdout.write("Lane thread recorded.\n");
13111
- });
13112
- laneThread.command("read").option("--cwd <path>", "working directory").action((_options, command) => {
13113
- output.stdout.write(`${JSON.stringify(readLaneThreads(resolveGuardCwd(command)), null, 2)}\n`);
13114
- });
13115
- return guard;
13116
- }
13117
- //#endregion
13118
12156
  //#region src/hq-flush.ts
13119
12157
  /**
13120
12158
  * Spools holding this repository's evidence under an earlier key of its own
@@ -14014,7 +13052,7 @@ const proofInputs = (input, epoch) => {
14014
13052
  };
14015
13053
  const gateCapMismatchBlockers = (input) => {
14016
13054
  if (!input.reviewProof || input.ladderPolicy === void 0 || input.reviewProof.maxReviewCycles === input.ladderPolicy.gate.cap) return [];
14017
- return [{ reason: `Review proof window (${input.reviewProof.maxReviewCycles ?? "unset"}) does not equal review.defaultMaxCycles (${input.ladderPolicy.gate.cap}); regenerate pr:review against the current profile.` }];
13055
+ return [{ reason: `Review proof window (${input.reviewProof.maxReviewCycles ?? "unset"}) does not equal the factory review cap (${input.ladderPolicy.gate.cap}); regenerate pr:review.` }];
14018
13056
  };
14019
13057
  const reviewGateFor = ({ epoch, input, reviewModes }) => {
14020
13058
  const correctnessRequired = reviewModes.includes("correctness");
@@ -15307,7 +14345,7 @@ const assertReviewCycleInWindow = (cycle, maxCycles) => {
15307
14345
  * alone would not demand it. Real review evidence is never discarded (#339).
15308
14346
  */
15309
14347
  function enabledKinds(profile, mode, files = [], suppliedFindingsKinds = []) {
15310
- const profileModes = new Set(profile.review.modes);
14348
+ const profileModes = new Set(["correctness", ...(profile.review?.conditional?.length ?? 0) > 0 ? ["security"] : []]);
15311
14349
  const configuredKinds = (mode === "all" ? [...profileModes] : [mode]).filter((kind) => profileModes.has(kind));
15312
14350
  if (configuredKinds.length === 0) throw new Error(`No configured review mode matches ${mode}.`);
15313
14351
  const applicableKinds = new Set(resolveApplicableReviewModes(profile, files));
@@ -15414,9 +14452,8 @@ async function runPrReview(args, dependencies = {}) {
15414
14452
  cwd,
15415
14453
  profilePath: args.profilePath
15416
14454
  });
15417
- const ladderPolicy = resolveReviewLadderPolicy(profile);
15418
- const maxCycles = args.maxCycles ?? ladderPolicy.gate.cap;
15419
- if (maxCycles !== ladderPolicy.gate.cap) throw new Error(`--max-cycles must equal review.defaultMaxCycles (${ladderPolicy.gate.cap}).`);
14455
+ const ladderPolicy = resolveReviewLadderPolicy();
14456
+ const maxCycles = ladderPolicy.gate.cap;
15420
14457
  assertReviewCycleInWindow(args.cycle, maxCycles);
15421
14458
  const { headSha, patchId } = createCandidateIdentityResolver({
15422
14459
  cwd,
@@ -15734,9 +14771,9 @@ async function ensureVerifyProof({ args, cwd, dependencies, headSha, verifyProof
15734
14771
  };
15735
14772
  }
15736
14773
  const reviewNotRequired = (proof) => proof.reviewRequirement?.status === "not-required";
15737
- async function ensureReviewProof({ args, cwd, dependencies, currentReviewIdentity, profile, reviewProofPath }) {
14774
+ async function ensureReviewProof({ args, cwd, dependencies, currentReviewIdentity, reviewProofPath }) {
15738
14775
  const proof = readOptionalReviewProof(cwd, reviewProofPath);
15739
- const ladderPolicy = resolveReviewLadderPolicy(profile);
14776
+ const ladderPolicy = resolveReviewLadderPolicy();
15740
14777
  const terminalFor = (candidate) => resolveReviewTerminalStateAcrossReviewsForPolicy(candidate, ladderPolicy);
15741
14778
  const terminal = proof ? terminalFor(proof) : void 0;
15742
14779
  const identityStatus = proof ? reviewStatus({
@@ -16005,7 +15042,6 @@ async function runPrPublish(args, dependencies = {}) {
16005
15042
  }),
16006
15043
  cwd,
16007
15044
  dependencies,
16008
- profile,
16009
15045
  reviewProofPath
16010
15046
  });
16011
15047
  const pr = fetchPr();
@@ -16177,7 +15213,7 @@ function assertForegroundGate({ gate, env = process.env }) {
16177
15213
  //#endregion
16178
15214
  //#region src/commands/pr-review.ts
16179
15215
  function createPrReviewCommand(output, action) {
16180
- return new Command("pr:review").description("Validate clean-session findings and write typed review proof").option("--base <ref>", "base branch or ref", "origin/main").option("--cwd <path>", "working directory to review", ".").option("--cycle <number>", "review cycle number", positiveInteger("--cycle"), 1).option("--max-cycles <number>", "authorized review-cycle window end", positiveInteger("--max-cycles")).option("--mode <mode>", "correctness, security, or all", "all").option("--findings <path>", "typed clean-session findings JSON file").option("--dispositions <path>", "JSON file of ladder disposition declarations (waived / follow-up-filed / fixed-in-thread) for previously flagged findings").option("--issue <number>", "issue number recorded on the review ladder trace", positiveInteger("--issue")).option("--output <path>", "write proof JSON to a file").option("--profile <path>", "path to the project profile JSON file").option("--verify-proof <path>", "pr:verify proof JSON path").action(withGateTiming({
15216
+ return new Command("pr:review").description("Validate clean-session findings and write typed review proof").option("--base <ref>", "base branch or ref", "origin/main").option("--cwd <path>", "working directory to review", ".").option("--cycle <number>", "review cycle number", positiveInteger("--cycle"), 1).option("--mode <mode>", "correctness, security, or all", "all").option("--findings <path>", "typed clean-session findings JSON file").option("--dispositions <path>", "JSON file of ladder disposition declarations (waived / follow-up-filed / fixed-in-thread) for previously flagged findings").option("--issue <number>", "issue number recorded on the review ladder trace", positiveInteger("--issue")).option("--output <path>", "write proof JSON to a file").option("--profile <path>", "path to the project profile JSON file").option("--verify-proof <path>", "pr:verify proof JSON path").action(withGateTiming({
16181
15217
  gate: "pr:review",
16182
15218
  resolveCycle: (options) => options.cycle,
16183
15219
  resolveLedgerRoot: (options) => resolveCwdOption(options.cwd),
@@ -16189,7 +15225,6 @@ function createPrReviewCommand(output, action) {
16189
15225
  "security",
16190
15226
  "all"
16191
15227
  ].includes(options.mode)) throw new Error("--mode requires correctness, security, or all");
16192
- if (typeof options.maxCycles === "number" && options.cycle > options.maxCycles) throw new Error("--cycle must be less than or equal to --max-cycles");
16193
15228
  const args = {
16194
15229
  base: options.base,
16195
15230
  cwd: resolveCwdOption(options.cwd),
@@ -16197,7 +15232,6 @@ function createPrReviewCommand(output, action) {
16197
15232
  dispositions: options.dispositions,
16198
15233
  findings: options.findings,
16199
15234
  issue: options.issue,
16200
- maxCycles: options.maxCycles,
16201
15235
  mode: options.mode,
16202
15236
  output: options.output,
16203
15237
  profilePath: options.profile,
@@ -16471,7 +15505,6 @@ function createProgram(options = {}) {
16471
15505
  program.name("patronage-factory").description("Shared Patronage software factory CLI").version(version);
16472
15506
  program.addCommand(createDoctorCommand(output));
16473
15507
  program.addCommand(createConfigCommand(output));
16474
- program.addCommand(createGuardCommand(output));
16475
15508
  program.addCommand(createEvidenceEmitCommand(output));
16476
15509
  program.addCommand(createHqFlushCommand(output));
16477
15510
  program.addCommand(createBoundaryCheckCommand(output, options.actions?.boundaryCheck));