@patronage/software-factory 1.0.0-alpha.6 → 1.0.0-alpha.8

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/README.md CHANGED
@@ -37,7 +37,7 @@ A stacked release train has one terminal release-owning slice. Interior slices d
37
37
  A stable cut has two phases. Phase 1 is the verified package candidate: steps 1 through 4 below, ending in the attended npm publish, tag, and GitHub release. Phase 2 is post-publication stable-doc convergence: steps 5 and 6, a normal admitted docs PR followed by the read-only convergence assertion from a clean `main`. Stable docs must never claim an unpublished version, so they cannot land in the phase-1 change. Prerelease behavior is unchanged: prerelease notes may land before publish while stable docs keep naming the prior stable version.
38
38
 
39
39
  1. Bump `package.json` to valid SemVer. The assertion derives its expected stable or prerelease channel from the version; it accepts no channel flag.
40
- 2. Publish to npm per the [repository operations runbook](../docs/repository-operations.md#publishing) (attended, hand-cut, `pnpm publish` only). Both factory packages pin `publishConfig.tag: next`, so a bare `pnpm --filter @patronage/software-factory publish` lands on `next` and cannot move `latest`. A prerelease is therefore the bare command; a stable version must say it out loud: `pnpm --filter @patronage/software-factory publish --tag latest`. Forgetting the flag on a stable cut leaves `latest` untouched for the convergence assertion to flag the safe failure direction.
40
+ 2. Publish to npm per the [repository operations runbook](../docs/repository-operations.md#publishing) (attended, hand-cut, `pnpm publish` only). **Always pass `--tag` explicitly, on every publish, prerelease or stable** — `pnpm --filter @patronage/software-factory publish --tag next` for a prerelease, `--tag latest` for a stable cut. A filtered publish takes pnpm's recursive path, which passes its own `--tag` (defaulting to `latest`) to npm and therefore ignores the `publishConfig.tag: next` pin both factory packages carry; that is how 1.0.0-alpha.6 briefly took `latest` (#658). The pins stay as belt and braces, but they are not the control. Omitting the flag is not a safe failure: on the filtered command it moves `latest`, which the convergence assertion can only report after the fact.
41
41
  3. Cut the `software-factory-vX.Y.Z` tag and push it.
42
42
  4. Cut the GitHub release for that tag, with notes covering features, deletions, breaking changes, and closed issues. Mark it as a prerelease exactly when its SemVer version has a prerelease component. When the release changes the profile `schemaVersion`, state it in the notes — the profile schema version is a separate axis from the package version.
43
43
  5. Converge the stable docs after publication. Update affected `software-factory-docs/` pages through a normal admitted docs PR. Stable docs must state the current stable version. Prerelease docs may record the candidate and its migration notes, but must not represent it as the current stable release.
package/dist/index.d.ts CHANGED
@@ -743,8 +743,8 @@ declare const mergeFreezeStateSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
743
743
  generationId: z.ZodNumber;
744
744
  headSha: z.ZodString;
745
745
  outcome: z.ZodEnum<{
746
- stale: "stale";
747
746
  active: "active";
747
+ stale: "stale";
748
748
  }>;
749
749
  reason: z.ZodString;
750
750
  recordedAt: z.ZodISODateTime;
@@ -1224,8 +1224,8 @@ declare const managedReadinessLedgerSchema: z.ZodObject<{
1224
1224
  "run-interior-cycle": "run-interior-cycle";
1225
1225
  }>;
1226
1226
  stage: z.ZodEnum<{
1227
- interior: "interior";
1228
1227
  gate: "gate";
1228
+ interior: "interior";
1229
1229
  "interior-complete": "interior-complete";
1230
1230
  }>;
1231
1231
  }, z.core.$strip>>;
@@ -1241,10 +1241,10 @@ declare const managedReadinessLedgerSchema: z.ZodObject<{
1241
1241
  reviewedHeadSha: z.ZodOptional<z.ZodString>;
1242
1242
  reviewedPatchId: z.ZodOptional<z.ZodString>;
1243
1243
  status: z.ZodEnum<{
1244
- "not-required": "not-required";
1245
1244
  blocked: "blocked";
1246
- current: "current";
1245
+ "not-required": "not-required";
1247
1246
  stale: "stale";
1247
+ current: "current";
1248
1248
  missing: "missing";
1249
1249
  }>;
1250
1250
  }, z.core.$strip>;
@@ -1253,10 +1253,10 @@ declare const managedReadinessLedgerSchema: z.ZodObject<{
1253
1253
  reviewedHeadSha: z.ZodOptional<z.ZodString>;
1254
1254
  reviewedPatchId: z.ZodOptional<z.ZodString>;
1255
1255
  status: z.ZodEnum<{
1256
- "not-required": "not-required";
1257
1256
  blocked: "blocked";
1258
- current: "current";
1257
+ "not-required": "not-required";
1259
1258
  stale: "stale";
1259
+ current: "current";
1260
1260
  missing: "missing";
1261
1261
  }>;
1262
1262
  }, z.core.$strip>>;
@@ -1267,8 +1267,8 @@ declare const managedReadinessLedgerSchema: z.ZodObject<{
1267
1267
  docsOnlyDeltaAccepted: z.ZodOptional<z.ZodBoolean>;
1268
1268
  docsOnlyVerifiedHeadSha: z.ZodOptional<z.ZodString>;
1269
1269
  prVerify: z.ZodEnum<{
1270
- passed: "passed";
1271
1270
  stale: "stale";
1271
+ passed: "passed";
1272
1272
  missing: "missing";
1273
1273
  }>;
1274
1274
  trivialDeltaAccepted: z.ZodOptional<z.ZodBoolean>;
@@ -1768,10 +1768,10 @@ declare const loadEvidenceEnvelopes: (cwd: string) => LoadedEvidenceEnvelope[];
1768
1768
  //#region src/review-proof-applicability.d.ts
1769
1769
  declare const REVIEW_STATUS_VALUES: readonly ["not-required", "current", "stale", "missing", "blocked"];
1770
1770
  declare const reviewStatusSchema: z.ZodEnum<{
1771
- "not-required": "not-required";
1772
1771
  blocked: "blocked";
1773
- current: "current";
1772
+ "not-required": "not-required";
1774
1773
  stale: "stale";
1774
+ current: "current";
1775
1775
  missing: "missing";
1776
1776
  }>;
1777
1777
  type ReviewStatus = z.infer<typeof reviewStatusSchema>;
@@ -2177,9 +2177,9 @@ declare const retroEnvelopeV1Schema: z.ZodObject<{
2177
2177
  kind: z.ZodLiteral<"retro-envelope">;
2178
2178
  outcome: z.ZodOptional<z.ZodObject<{
2179
2179
  status: z.ZodEnum<{
2180
+ blocked: "blocked";
2180
2181
  success: "success";
2181
2182
  fail: "fail";
2182
- blocked: "blocked";
2183
2183
  "ship-with-followups": "ship-with-followups";
2184
2184
  }>;
2185
2185
  verdict: z.ZodOptional<z.ZodString>;
@@ -2939,8 +2939,8 @@ declare const FACTORY_TRACE_EVENT_DEFINITIONS: readonly [DefinedTraceEvent<"revi
2939
2939
  sessionId: z.ZodOptional<z.ZodString>;
2940
2940
  }, z.core.$strict>>;
2941
2941
  stage: z.ZodEnum<{
2942
- interior: "interior";
2943
2942
  gate: "gate";
2943
+ interior: "interior";
2944
2944
  }>;
2945
2945
  usage: z.ZodOptional<z.ZodObject<{
2946
2946
  estimatedCostUsd: z.ZodOptional<z.ZodNumber>;
@@ -2992,8 +2992,8 @@ declare const FACTORY_TRACE_EVENT_DEFINITIONS: readonly [DefinedTraceEvent<"revi
2992
2992
  sessionId: z.ZodOptional<z.ZodString>;
2993
2993
  }, z.core.$strict>>;
2994
2994
  stage: z.ZodEnum<{
2995
- interior: "interior";
2996
2995
  gate: "gate";
2996
+ interior: "interior";
2997
2997
  }>;
2998
2998
  usage: z.ZodOptional<z.ZodObject<{
2999
2999
  estimatedCostUsd: z.ZodOptional<z.ZodNumber>;
@@ -3036,8 +3036,8 @@ declare const FACTORY_TRACE_EVENT_DEFINITIONS: readonly [DefinedTraceEvent<"revi
3036
3036
  sessionId: z.ZodOptional<z.ZodString>;
3037
3037
  }, z.core.$strict>>;
3038
3038
  stage: z.ZodEnum<{
3039
- interior: "interior";
3040
3039
  gate: "gate";
3040
+ interior: "interior";
3041
3041
  }>;
3042
3042
  usage: z.ZodOptional<z.ZodObject<{
3043
3043
  estimatedCostUsd: z.ZodOptional<z.ZodNumber>;
@@ -3067,7 +3067,7 @@ declare const FACTORY_TRACE_EVENT_DEFINITIONS: readonly [DefinedTraceEvent<"revi
3067
3067
  staleRepeats: number;
3068
3068
  };
3069
3069
  observedAt: string;
3070
- stage: "interior" | "gate";
3070
+ stage: "gate" | "interior";
3071
3071
  issue?: number | undefined;
3072
3072
  pr?: number | undefined;
3073
3073
  threadId?: string | undefined;
@@ -3323,8 +3323,8 @@ declare const FACTORY_TRACE_EVENT_DEFINITIONS: readonly [DefinedTraceEvent<"revi
3323
3323
  interiorCycle: z.ZodOptional<z.ZodNumber>;
3324
3324
  observedAt: z.ZodString;
3325
3325
  purpose: z.ZodEnum<{
3326
- code: "code";
3327
3326
  review: "review";
3327
+ code: "code";
3328
3328
  }>;
3329
3329
  slotResolution: z.ZodObject<{
3330
3330
  effort: z.ZodEnum<{
@@ -3402,8 +3402,8 @@ declare const FACTORY_TRACE_EVENT_DEFINITIONS: readonly [DefinedTraceEvent<"revi
3402
3402
  interiorCycle: z.ZodOptional<z.ZodNumber>;
3403
3403
  observedAt: z.ZodString;
3404
3404
  purpose: z.ZodEnum<{
3405
- code: "code";
3406
3405
  review: "review";
3406
+ code: "code";
3407
3407
  }>;
3408
3408
  slotResolution: z.ZodObject<{
3409
3409
  effort: z.ZodEnum<{
@@ -3472,8 +3472,8 @@ declare const FACTORY_TRACE_EVENT_DEFINITIONS: readonly [DefinedTraceEvent<"revi
3472
3472
  interiorCycle: z.ZodOptional<z.ZodNumber>;
3473
3473
  observedAt: z.ZodString;
3474
3474
  purpose: z.ZodEnum<{
3475
- code: "code";
3476
3475
  review: "review";
3476
+ code: "code";
3477
3477
  }>;
3478
3478
  slotResolution: z.ZodObject<{
3479
3479
  effort: z.ZodEnum<{
@@ -3545,7 +3545,7 @@ declare const FACTORY_TRACE_EVENT_DEFINITIONS: readonly [DefinedTraceEvent<"revi
3545
3545
  exitCode: number | null;
3546
3546
  harness: string;
3547
3547
  observedAt: string;
3548
- purpose: "code" | "review";
3548
+ purpose: "review" | "code";
3549
3549
  slotResolution: {
3550
3550
  effort: "high" | "low" | "medium" | "xhigh";
3551
3551
  engine: string;
@@ -4066,13 +4066,13 @@ declare const evaluateReadiness: (input: EvaluationInput) => {
4066
4066
  reviews: {
4067
4067
  correctness: {
4068
4068
  required: boolean;
4069
- status: "not-required" | "blocked" | "current" | "stale" | "missing";
4069
+ status: "blocked" | "not-required" | "stale" | "current" | "missing";
4070
4070
  reviewedHeadSha?: string | undefined;
4071
4071
  reviewedPatchId?: string | undefined;
4072
4072
  };
4073
4073
  security?: {
4074
4074
  required: boolean;
4075
- status: "not-required" | "blocked" | "current" | "stale" | "missing";
4075
+ status: "blocked" | "not-required" | "stale" | "current" | "missing";
4076
4076
  reviewedHeadSha?: string | undefined;
4077
4077
  reviewedPatchId?: string | undefined;
4078
4078
  } | undefined;
@@ -4080,7 +4080,7 @@ declare const evaluateReadiness: (input: EvaluationInput) => {
4080
4080
  schemaVersion: 1;
4081
4081
  verification: {
4082
4082
  command: "patronage-factory pr:verify";
4083
- prVerify: "passed" | "stale" | "missing";
4083
+ prVerify: "stale" | "passed" | "missing";
4084
4084
  docsOnlyDeltaAccepted?: boolean | undefined;
4085
4085
  docsOnlyVerifiedHeadSha?: string | undefined;
4086
4086
  trivialDeltaAccepted?: boolean | undefined;
@@ -4123,7 +4123,7 @@ declare const evaluateReadiness: (input: EvaluationInput) => {
4123
4123
  interior: number;
4124
4124
  };
4125
4125
  nextAction: "run-gate-cycle" | "accept-nonblocking-findings" | "escalate-to-triage" | "ready-for-human" | "advance-to-gate" | "run-interior-cycle";
4126
- stage: "interior" | "gate" | "interior-complete";
4126
+ stage: "gate" | "interior" | "interior-complete";
4127
4127
  forcedTransition?: "gate-cap-exhausted" | "interior-cap-reached" | undefined;
4128
4128
  } | undefined;
4129
4129
  reviewRuns?: PrReviewResult[] | undefined;
package/dist/index.js CHANGED
@@ -8,16 +8,16 @@ import crypto, { createHash, randomUUID } from "node:crypto";
8
8
  import { z } from "zod";
9
9
  import { execFileSync, spawnSync } from "node:child_process";
10
10
  import { link, lstat, mkdir, open, readFile, readdir, realpath, rename, stat, unlink } from "node:fs/promises";
11
- import os, { homedir } from "node:os";
11
+ import os, { homedir, tmpdir } from "node:os";
12
12
  import { setImmediate } from "node:timers";
13
13
  import { setImmediate as setImmediate$1, setTimeout as setTimeout$1 } from "node:timers/promises";
14
14
  import { Worker } from "node:worker_threads";
15
15
  import picomatch from "picomatch";
16
16
  import { parse } from "yaml";
17
17
  import { promisify } from "node:util";
18
- import { GitHubApiError, mintInstallationToken } from "@patronage/factory-ci";
18
+ import { FACTORY_PROOF_GATE_STEP_NAME, GitHubApiError, mintInstallationToken, readVitestProfileDocument } from "@patronage/factory-ci";
19
19
  //#region package.json
20
- var version = "1.0.0-alpha.6";
20
+ var version = "1.0.0-alpha.8";
21
21
  //#endregion
22
22
  //#region src/review-rungs.ts
23
23
  const EVIDENCE_REVIEW_RUNGS$1 = [
@@ -1082,7 +1082,7 @@ const factoryProjectProfileSchema = z.object({
1082
1082
  mappedChecks.add(command.requiredCheck);
1083
1083
  }
1084
1084
  }).meta({ description: PROFILE_JSON_SCHEMA_DESCRIPTION });
1085
- const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1085
+ const isRecord$2 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1086
1086
  const stringList = (value) => Array.isArray(value) ? value.filter((entry) => typeof entry === "string") : [];
1087
1087
  const quoteList = (values) => JSON.stringify(values);
1088
1088
  const namesExistingFile = (root, entry) => {
@@ -1131,13 +1131,13 @@ const migratedPolicyLines = (policy, root) => {
1131
1131
  */
1132
1132
  function v2MigrationLines(input, root) {
1133
1133
  const lines = [];
1134
- const proof = isRecord$1(input.proof) ? input.proof : void 0;
1135
- if (proof && isRecord$1(proof.classificationPolicy)) lines.push(...migratedPolicyLines(proof.classificationPolicy, root));
1136
- const commands = isRecord$1(input.verification) ? input.verification.commands : void 0;
1134
+ const proof = isRecord$2(input.proof) ? input.proof : void 0;
1135
+ if (proof && isRecord$2(proof.classificationPolicy)) lines.push(...migratedPolicyLines(proof.classificationPolicy, root));
1136
+ const commands = isRecord$2(input.verification) ? input.verification.commands : void 0;
1137
1137
  if (Array.isArray(commands)) {
1138
- for (const [index, command] of commands.entries()) if (isRecord$1(command) && command.scope === "always") lines.push(`verification.commands[${index}].scope "always" -> "docs-only"`);
1138
+ for (const [index, command] of commands.entries()) if (isRecord$2(command) && command.scope === "always") lines.push(`verification.commands[${index}].scope "always" -> "docs-only"`);
1139
1139
  }
1140
- if (isRecord$1(input.hq) && typeof input.hq.endpoint === "string") try {
1140
+ if (isRecord$2(input.hq) && typeof input.hq.endpoint === "string") try {
1141
1141
  const url = new URL(input.hq.endpoint);
1142
1142
  if (url.href !== `${url.origin}/` && url.href !== url.origin) lines.push(`hq.endpoint ${JSON.stringify(input.hq.endpoint)} -> ${JSON.stringify(url.origin)}`);
1143
1143
  } catch {}
@@ -1149,25 +1149,25 @@ function hasCorrectnessOnlyReview(review) {
1149
1149
  }
1150
1150
  const v4MigrationLines = (input) => {
1151
1151
  const lines = [];
1152
- if (isRecord$1(input.project) && "key" in input.project) lines.push("project.key -> removed; current proof writers derive projectKey from repository.name");
1153
- if (isRecord$1(input.repository) && "defaultBranch" in input.repository) lines.push("repository.defaultBranch -> removed; commands resolve the live base from Git/GitHub or an explicit --base");
1154
- if (isRecord$1(input.env) && "required" in input.env) lines.push("env.required -> removed; the command that needs an environment value owns its fail-closed diagnostic");
1155
- if (isRecord$1(input.review)) {
1152
+ if (isRecord$2(input.project) && "key" in input.project) lines.push("project.key -> removed; current proof writers derive projectKey from repository.name");
1153
+ if (isRecord$2(input.repository) && "defaultBranch" in input.repository) lines.push("repository.defaultBranch -> removed; commands resolve the live base from Git/GitHub or an explicit --base");
1154
+ if (isRecord$2(input.env) && "required" in input.env) lines.push("env.required -> removed; the command that needs an environment value owns its fail-closed diagnostic");
1155
+ if (isRecord$2(input.review)) {
1156
1156
  const isCorrectnessOnlyReview = hasCorrectnessOnlyReview(input.review);
1157
1157
  if ("defaultMaxCycles" in input.review) lines.push("review.defaultMaxCycles -> removed; the factory review cap is 5");
1158
1158
  if ("modes" in input.review) lines.push("review.modes -> removed; correctness is always required");
1159
1159
  if (isCorrectnessOnlyReview) lines.push("review -> removed; a correctness-only review has no v5 configuration, so delete the object");
1160
1160
  if (Array.isArray(input.review.conditional)) {
1161
- for (const [index, conditional] of input.review.conditional.entries()) if (isRecord$1(conditional) && "modes" in conditional) lines.push(`review.conditional[${index}].modes -> removed; each retained conditional path list demands security review`);
1161
+ for (const [index, conditional] of input.review.conditional.entries()) if (isRecord$2(conditional) && "modes" in conditional) lines.push(`review.conditional[${index}].modes -> removed; each retained conditional path list demands security review`);
1162
1162
  }
1163
1163
  }
1164
1164
  if (Array.isArray(input.requiredChecks)) {
1165
- for (const [index, check] of input.requiredChecks.entries()) if (isRecord$1(check) && "checkType" in check) lines.push(`requiredChecks[${index}].checkType -> removed; external required checks are verify-type`);
1165
+ for (const [index, check] of input.requiredChecks.entries()) if (isRecord$2(check) && "checkType" in check) lines.push(`requiredChecks[${index}].checkType -> removed; external required checks are verify-type`);
1166
1166
  }
1167
1167
  return lines;
1168
1168
  };
1169
1169
  function legacyProfileMigration(input, root) {
1170
- if (!(isRecord$1(input) && typeof input.schemaVersion === "number" && LEGACY_PROFILE_SCHEMA_VERSIONS.includes(input.schemaVersion))) return;
1170
+ if (!(isRecord$2(input) && typeof input.schemaVersion === "number" && LEGACY_PROFILE_SCHEMA_VERSIONS.includes(input.schemaVersion))) return;
1171
1171
  const foundVersion = input.schemaVersion;
1172
1172
  const lines = [`schemaVersion ${foundVersion} -> 5`];
1173
1173
  if (foundVersion === 2) lines.push(...v2MigrationLines(input, root));
@@ -3690,17 +3690,17 @@ const pnpmLockfileSchema = z.looseObject({
3690
3690
  packages: z.record(z.string(), z.unknown()).optional(),
3691
3691
  snapshots: z.record(z.string(), snapshotSectionSchema).optional()
3692
3692
  });
3693
- const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3693
+ const isRecord$1 = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
3694
3694
  const canonical = (value) => {
3695
3695
  if (Array.isArray(value)) return value.map(canonical);
3696
- if (isRecord(value)) return Object.fromEntries(Object.keys(value).toSorted().map((key) => [key, canonical(value[key])]));
3696
+ if (isRecord$1(value)) return Object.fromEntries(Object.keys(value).toSorted().map((key) => [key, canonical(value[key])]));
3697
3697
  return value;
3698
3698
  };
3699
3699
  const stableStringify$1 = (value) => JSON.stringify(canonical(value));
3700
3700
  const parseLockfile = (content) => {
3701
3701
  try {
3702
3702
  const parsed = parse(content);
3703
- if (!isRecord(parsed)) return;
3703
+ if (!isRecord$1(parsed)) return;
3704
3704
  const validated = pnpmLockfileSchema.safeParse(parsed);
3705
3705
  return validated.success ? validated.data : void 0;
3706
3706
  } catch {
@@ -8553,8 +8553,24 @@ function positiveInteger(name) {
8553
8553
  };
8554
8554
  }
8555
8555
  function resolveCwdOption(cwd) {
8556
- if (typeof cwd !== "string" || cwd.trim() === "") throw new Error("--cwd requires a non-empty path; relative values resolve against the current working directory");
8557
- return path.resolve(cwd);
8556
+ const value = typeof cwd === "string" ? cwd : ".";
8557
+ if (value.trim() === "") throw new Error("--cwd requires a non-empty path; relative values resolve against the current working directory");
8558
+ return path.resolve(value);
8559
+ }
8560
+ function collectCwdOption(rawValue, previous) {
8561
+ if (previous === void 0) return rawValue;
8562
+ const previousResolved = resolveCwdOption(previous);
8563
+ const currentResolved = resolveCwdOption(rawValue);
8564
+ if (previousResolved !== currentResolved) throw new Error(`--cwd was passed multiple times with conflicting values: "${previous}" (resolves to ${previousResolved}) and "${rawValue}" (resolves to ${currentResolved})`);
8565
+ return rawValue;
8566
+ }
8567
+ function markCwdOptionDefault(command) {
8568
+ const option = command.options.find((candidate) => candidate.attributeName() === "cwd");
8569
+ if (option) {
8570
+ option.defaultValue = ".";
8571
+ option.defaultValueDescription = ".";
8572
+ }
8573
+ return command;
8558
8574
  }
8559
8575
  function resolveCheckoutPath(cwd, target) {
8560
8576
  return path.resolve(cwd, target);
@@ -8577,7 +8593,7 @@ const renderReport = (record) => {
8577
8593
  return `${lines.join("\n")}\n`;
8578
8594
  };
8579
8595
  function createBoundaryCheckCommand(output, action) {
8580
- return new Command("boundary:check").description("Boundary readiness gate (T8): require a membership-fresh boundary-review proof at the declared closeout rung. Blocks closeout/enablement, never merge.").requiredOption("--epic <number>", "epic issue number carrying the factory-boundary manifest", positiveInteger("--epic")).option("--repo <owner/name>", "GitHub repository (default: resolved from --cwd)").option("--cwd <path>", "repository working directory", ".").option("--output <path>", "write an additional proof copy to this path").option("--profile <path>", "path to the project profile JSON file").option("--json", "print the full proof record as JSON").action(withGateTiming({
8596
+ return markCwdOptionDefault(new Command("boundary:check").description("Boundary readiness gate (T8): require a membership-fresh boundary-review proof at the declared closeout rung. Blocks closeout/enablement, never merge.").requiredOption("--epic <number>", "epic issue number carrying the factory-boundary manifest", positiveInteger("--epic")).option("--repo <owner/name>", "GitHub repository (default: resolved from --cwd)").option("--cwd <path>", "repository working directory", collectCwdOption).option("--output <path>", "write an additional proof copy to this path").option("--profile <path>", "path to the project profile JSON file").option("--json", "print the full proof record as JSON").action(withGateTiming({
8581
8597
  gate: "boundary:check",
8582
8598
  resolveLedgerRoot: (options) => resolveCwdOption(options.cwd),
8583
8599
  stderr: output.stderr
@@ -8616,7 +8632,710 @@ function createBoundaryCheckCommand(output, action) {
8616
8632
  }
8617
8633
  output.stdout.write(options.json ? `${JSON.stringify(record, null, 2)}\n` : renderReport(record));
8618
8634
  if (record.status !== "ready") throw new Error(`boundary:check refused: ${record.blockingReasons.join("; ")}`);
8635
+ })));
8636
+ }
8637
+ /** `tool` discriminator every emitted report carries. */
8638
+ const CI_ANALYZE_TOOL = "psf-ci-analyze";
8639
+ /** Cohort label for runs the caller did not assign to a cohort. */
8640
+ const UNGROUPED_COHORT = "ungrouped";
8641
+ const available = (value) => ({
8642
+ available: true,
8643
+ value
8644
+ });
8645
+ const unavailable = (reason) => ({
8646
+ available: false,
8647
+ reason
8648
+ });
8649
+ const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
8650
+ const stringField$1 = (source, key, where) => {
8651
+ const value = source[key];
8652
+ return typeof value === "string" && value.length > 0 ? available(value) : unavailable(`${where} has no ${key}`);
8653
+ };
8654
+ const numberField = (source, key, where) => {
8655
+ const value = source[key];
8656
+ return typeof value === "number" && Number.isFinite(value) ? available(value) : unavailable(`${where} has no ${key}`);
8657
+ };
8658
+ /**
8659
+ * An instant is only usable when it names its own timezone. `Date.parse`
8660
+ * interprets an offset-less `2026-08-06T12:00:00` in the *host's* zone, which
8661
+ * would make a duration depend on where the analyzer ran. The GitHub API
8662
+ * always sends `Z`-form, so requiring an explicit `Z` or numeric offset
8663
+ * rejects nothing genuine — anything else becomes explicitly unavailable.
8664
+ */
8665
+ const EXPLICIT_OFFSET_PATTERN = /(?:Z|[+-]\d{2}:?\d{2})$/u;
8666
+ const parseInstantMs = (value) => {
8667
+ if (!value.available) return;
8668
+ if (!EXPLICIT_OFFSET_PATTERN.test(value.value)) return;
8669
+ const parsed = Date.parse(value.value);
8670
+ return Number.isNaN(parsed) ? void 0 : parsed;
8671
+ };
8672
+ /**
8673
+ * Wall clock between two ISO instants. Unavailable — with the missing side
8674
+ * named — whenever either instant is absent or unparsable; a negative span is
8675
+ * refused rather than clamped to a fake zero.
8676
+ */
8677
+ const durationBetween = (start, end, label) => {
8678
+ const startMs = parseInstantMs(start);
8679
+ if (startMs === void 0) return unavailable(`${label}: start instant is ${start.available ? "unparsable" : `unavailable (${start.reason})`}`);
8680
+ const endMs = parseInstantMs(end);
8681
+ if (endMs === void 0) return unavailable(`${label}: end instant is ${end.available ? "unparsable" : `unavailable (${end.reason})`}`);
8682
+ if (endMs < startMs) return unavailable(`${label}: end instant precedes start instant`);
8683
+ return available(endMs - startMs);
8684
+ };
8685
+ const GITHUB_SETUP_STEP_NAME = "Set up job";
8686
+ const INSTALL_STEP_PATTERN = /install/iu;
8687
+ const analyzeStep = (step) => {
8688
+ const startedAt = stringField$1(step, "started_at", "step");
8689
+ const completedAt = stringField$1(step, "completed_at", "step");
8690
+ return {
8691
+ completedAt,
8692
+ conclusion: stringField$1(step, "conclusion", "step"),
8693
+ durationMs: durationBetween(startedAt, completedAt, "step duration"),
8694
+ name: typeof step.name === "string" ? step.name : "(unnamed step)",
8695
+ number: numberField(step, "number", "step"),
8696
+ startedAt
8697
+ };
8698
+ };
8699
+ const namedStepDuration = (steps, matches, description) => {
8700
+ const step = steps.find((candidate) => matches(candidate.name));
8701
+ return step === void 0 ? unavailable(`no ${description} step in this job`) : step.durationMs;
8702
+ };
8703
+ const analyzeJob = (job) => {
8704
+ const steps = Array.isArray(job.steps) ? job.steps.filter(isRecord).map(analyzeStep) : [];
8705
+ const createdAt = stringField$1(job, "created_at", "job");
8706
+ const startedAt = stringField$1(job, "started_at", "job");
8707
+ const completedAt = stringField$1(job, "completed_at", "job");
8708
+ const labels = Array.isArray(job.labels) ? job.labels.filter((label) => typeof label === "string") : void 0;
8709
+ return {
8710
+ completedAt,
8711
+ conclusion: stringField$1(job, "conclusion", "job"),
8712
+ createdAt,
8713
+ installMs: namedStepDuration(steps, (name) => INSTALL_STEP_PATTERN.test(name), "install"),
8714
+ jobId: numberField(job, "id", "job"),
8715
+ name: typeof job.name === "string" ? job.name : "(unnamed job)",
8716
+ queueMs: durationBetween(createdAt, startedAt, "job queue"),
8717
+ runnerLabels: labels === void 0 ? unavailable("job has no labels array") : available(labels),
8718
+ setupMs: namedStepDuration(steps, (name) => name === GITHUB_SETUP_STEP_NAME, `"${GITHUB_SETUP_STEP_NAME}"`),
8719
+ startedAt,
8720
+ steps,
8721
+ totalMs: durationBetween(startedAt, completedAt, "job duration"),
8722
+ url: stringField$1(job, "html_url", "job")
8723
+ };
8724
+ };
8725
+ /**
8726
+ * Classify proof reuse from step conclusions alone. A job whose gate step is
8727
+ * followed by at least one skipped step reused proof; a job whose post-gate
8728
+ * steps all executed ran the full fallback. No gate step anywhere means the
8729
+ * workflow does not expose the outcome, which is unavailable — not
8730
+ * full-fallback.
8731
+ */
8732
+ const classifyProofReuse = (jobs) => {
8733
+ const gateJobs = [];
8734
+ const verdicts = /* @__PURE__ */ new Set();
8735
+ for (const job of jobs) {
8736
+ const gateIndex = job.steps.findIndex((step) => step.name === FACTORY_PROOF_GATE_STEP_NAME);
8737
+ if (gateIndex === -1) continue;
8738
+ const skippedStepsAfterGate = job.steps.slice(gateIndex + 1).filter((step) => step.conclusion.available && step.conclusion.value === "skipped").length;
8739
+ verdicts.add(skippedStepsAfterGate > 0 ? "proof-reuse" : "full-fallback");
8740
+ gateJobs.push({
8741
+ gateConclusion: job.steps[gateIndex]?.conclusion ?? unavailable("gate step vanished"),
8742
+ jobName: job.name,
8743
+ skippedStepsAfterGate
8744
+ });
8745
+ }
8746
+ if (gateJobs.length === 0) return unavailable(`no "${FACTORY_PROOF_GATE_STEP_NAME}" step in any job; this workflow does not expose a proof-reuse outcome`);
8747
+ const [single] = [...verdicts];
8748
+ return available({
8749
+ classification: verdicts.size === 1 && single ? single : "mixed",
8750
+ gateJobs
8751
+ });
8752
+ };
8753
+ const parseArtifactListing = (value) => {
8754
+ if (!isRecord(value) || !Array.isArray(value.artifacts)) return {
8755
+ artifacts: [],
8756
+ totalCount: void 0
8757
+ };
8758
+ return {
8759
+ artifacts: value.artifacts.filter(isRecord).flatMap((artifact) => {
8760
+ const { id, name } = artifact;
8761
+ return typeof id === "number" && typeof name === "string" ? [{
8762
+ expired: artifact.expired === true,
8763
+ id,
8764
+ name
8765
+ }] : [];
8766
+ }),
8767
+ totalCount: typeof value.total_count === "number" ? value.total_count : void 0
8768
+ };
8769
+ };
8770
+ const profileFromDocument = (artifactName, value) => {
8771
+ const read = readVitestProfileDocument(value);
8772
+ if (read.kind === "unrecognized") return {
8773
+ artifactName,
8774
+ kind: "unreadable",
8775
+ reason: read.reason
8776
+ };
8777
+ const { profile } = read;
8778
+ const testCounts = profile.runs.flatMap((run) => run.counts === null ? [] : [run.counts.tests]);
8779
+ return {
8780
+ artifactName,
8781
+ kind: "profile",
8782
+ profile: {
8783
+ cpuCount: profile.environment.cpuCount,
8784
+ cpuModel: profile.environment.cpuModel,
8785
+ maxWorkers: profile.options.maxWorkers,
8786
+ sampleDurationsMs: profile.runs.map((run) => run.durationMs),
8787
+ scope: profile.command.join(" "),
8788
+ slowFiles: profile.summary.slowFiles.map((file) => ({
8789
+ medianMs: file.durationMs.median,
8790
+ path: file.path
8791
+ })),
8792
+ slowTests: profile.summary.slowTests.map((test) => ({
8793
+ file: test.file,
8794
+ medianMs: test.durationMs.median,
8795
+ name: test.name
8796
+ })),
8797
+ summaryDurationMs: profile.summary.durationMs,
8798
+ testCount: testCounts.length === 0 ? unavailable("no sample in this profile carries test counts") : available(Math.max(...testCounts))
8799
+ }
8800
+ };
8801
+ };
8802
+ const collectProfiles = async (client, runId) => {
8803
+ let listing;
8804
+ try {
8805
+ listing = parseArtifactListing(await client.listArtifacts(runId));
8806
+ } catch (error) {
8807
+ return unavailable(`artifact listing failed: ${error instanceof Error ? error.message : String(error)}`);
8808
+ }
8809
+ const { artifacts } = listing;
8810
+ if (listing.totalCount !== void 0 && listing.totalCount > artifacts.length) return unavailable(`artifact listing is truncated: ${artifacts.length} of ${listing.totalCount} artifacts served`);
8811
+ if (artifacts.length === 0) return unavailable("this run has no artifacts");
8812
+ const analyses = [];
8813
+ for (const artifact of artifacts) {
8814
+ if (artifact.expired) {
8815
+ analyses.push({
8816
+ artifactName: artifact.name,
8817
+ kind: "unreadable",
8818
+ reason: "the artifact is expired and can no longer be downloaded"
8819
+ });
8820
+ continue;
8821
+ }
8822
+ let files;
8823
+ try {
8824
+ files = await client.readArtifactTextFiles(artifact.id);
8825
+ } catch (error) {
8826
+ analyses.push({
8827
+ artifactName: artifact.name,
8828
+ kind: "unreadable",
8829
+ reason: `artifact download failed: ${error instanceof Error ? error.message : String(error)}`
8830
+ });
8831
+ continue;
8832
+ }
8833
+ for (const file of files) {
8834
+ let parsed;
8835
+ try {
8836
+ parsed = JSON.parse(file.text);
8837
+ } catch {
8838
+ analyses.push({
8839
+ artifactName: `${artifact.name}/${file.name}`,
8840
+ kind: "unreadable",
8841
+ reason: "the file is not valid JSON"
8842
+ });
8843
+ continue;
8844
+ }
8845
+ const analysis = profileFromDocument(`${artifact.name}/${file.name}`, parsed);
8846
+ if (analysis.kind === "profile" || isRecord(parsed) && parsed.tool === "factory-ci-vitest-profile") analyses.push(analysis);
8847
+ }
8848
+ }
8849
+ return analyses.length === 0 ? unavailable("no compatible factory-ci Vitest profile artifacts on this run") : available(analyses);
8850
+ };
8851
+ const earliest = (instants) => instants.length === 0 ? void 0 : Math.min(...instants);
8852
+ const latest = (instants) => instants.length === 0 ? void 0 : Math.max(...instants);
8853
+ const runQueueDuration = (createdAt, jobs) => {
8854
+ const createdMs = parseInstantMs(createdAt);
8855
+ if (createdMs === void 0) return unavailable("the run object has no parsable created_at");
8856
+ const firstStart = earliest(jobs.flatMap((job) => {
8857
+ const started = parseInstantMs(job.startedAt);
8858
+ return started === void 0 ? [] : [started];
8619
8859
  }));
8860
+ if (firstStart === void 0) return unavailable("no job carries a parsable started_at");
8861
+ return firstStart < createdMs ? unavailable("the earliest job start precedes the run creation instant") : available(firstStart - createdMs);
8862
+ };
8863
+ const runCompletionInstant = (jobs) => {
8864
+ const completions = jobs.flatMap((job) => {
8865
+ const completed = parseInstantMs(job.completedAt);
8866
+ return completed === void 0 ? [] : [{
8867
+ completed,
8868
+ iso: job.completedAt
8869
+ }];
8870
+ });
8871
+ const last = latest(completions.map((entry) => entry.completed));
8872
+ const match = completions.find((entry) => entry.completed === last);
8873
+ return match?.iso.available === true ? available(match.iso.value) : unavailable("no job carries a parsable completed_at");
8874
+ };
8875
+ /**
8876
+ * `cpuModel` never comes from the Actions API — only readable profile
8877
+ * artifacts carry silicon attribution, so both absence cases say so.
8878
+ */
8879
+ const cpuModelsField = (cpuModels, profiles) => {
8880
+ if (cpuModels === void 0) return unavailable(`CPU models come only from profile artifacts (${profiles.available ? "none usable" : profiles.reason}); the Actions API does not expose silicon`);
8881
+ if (cpuModels.length === 0) return unavailable("no readable profile artifact recorded a CPU model");
8882
+ return available(cpuModels);
8883
+ };
8884
+ const analyzeOneRun = async (client, request, entry) => {
8885
+ const cohort = entry.cohort ?? "ungrouped";
8886
+ let runValue;
8887
+ let jobsValue;
8888
+ try {
8889
+ runValue = await client.getRun(entry.runId);
8890
+ jobsValue = await client.listJobs(entry.runId);
8891
+ } catch (error) {
8892
+ return {
8893
+ cohort,
8894
+ kind: "unreadable",
8895
+ reason: error instanceof Error ? error.message : String(error),
8896
+ repository: request.repository,
8897
+ runId: entry.runId
8898
+ };
8899
+ }
8900
+ if (!isRecord(runValue)) return {
8901
+ cohort,
8902
+ kind: "unreadable",
8903
+ reason: "the run response is not an object",
8904
+ repository: request.repository,
8905
+ runId: entry.runId
8906
+ };
8907
+ const jobRows = isRecord(jobsValue) && Array.isArray(jobsValue.jobs) ? jobsValue.jobs.filter(isRecord) : [];
8908
+ if (isRecord(jobsValue) && typeof jobsValue.total_count === "number" && jobsValue.total_count > jobRows.length) return {
8909
+ cohort,
8910
+ kind: "unreadable",
8911
+ reason: `jobs listing is truncated: ${jobRows.length} of ${jobsValue.total_count} jobs served`,
8912
+ repository: request.repository,
8913
+ runId: entry.runId
8914
+ };
8915
+ const jobs = jobRows.map(analyzeJob);
8916
+ const createdAt = stringField$1(runValue, "created_at", "run");
8917
+ const startedAt = stringField$1(runValue, "run_started_at", "run");
8918
+ const completedAt = runCompletionInstant(jobs);
8919
+ const profiles = request.includeProfiles ? await collectProfiles(client, entry.runId) : unavailable("profile artifacts were not requested (pass --profiles)");
8920
+ const cpuModels = profiles.available ? [...new Set(profiles.value.flatMap((profile) => profile.kind === "profile" && profile.profile.cpuModel !== null ? [profile.profile.cpuModel] : []))] : void 0;
8921
+ const labels = [...new Set(jobs.flatMap((job) => job.runnerLabels.available ? job.runnerLabels.value : []))];
8922
+ return {
8923
+ cohort,
8924
+ completedAt,
8925
+ conclusion: stringField$1(runValue, "conclusion", "run"),
8926
+ cpuModels: cpuModelsField(cpuModels, profiles),
8927
+ createdAt,
8928
+ durations: {
8929
+ queueMs: runQueueDuration(createdAt, jobs),
8930
+ totalMs: durationBetween(startedAt, completedAt, "run duration")
8931
+ },
8932
+ event: stringField$1(runValue, "event", "run"),
8933
+ headSha: stringField$1(runValue, "head_sha", "run"),
8934
+ jobs,
8935
+ kind: "analyzed",
8936
+ profiles,
8937
+ proofReuse: classifyProofReuse(jobs),
8938
+ repository: request.repository,
8939
+ runId: entry.runId,
8940
+ runnerLabels: labels.length === 0 ? unavailable("no job carries runner labels") : available(labels),
8941
+ startedAt,
8942
+ url: stringField$1(runValue, "html_url", "run"),
8943
+ workflowName: stringField$1(runValue, "name", "run")
8944
+ };
8945
+ };
8946
+ /**
8947
+ * Quantile by the floor-index convention: over `sorted` (ascending), the
8948
+ * q-quantile is the element at index `floor(q * (n - 1))`. No interpolation —
8949
+ * every reported quantile is a duration that actually happened, and the
8950
+ * convention is stable for the small sample counts (3–10 runs) this command
8951
+ * exists for. median = q(0.5), p25 = q(0.25), p75 = q(0.75).
8952
+ */
8953
+ const quantileFloorIndex = (sorted, q) => sorted[Math.floor(q * (sorted.length - 1))] ?? NaN;
8954
+ const quantileSummary = (values) => {
8955
+ const sorted = values.toSorted((left, right) => left - right);
8956
+ return {
8957
+ count: sorted.length,
8958
+ maximum: sorted.at(-1) ?? NaN,
8959
+ median: quantileFloorIndex(sorted, .5),
8960
+ minimum: sorted[0] ?? NaN,
8961
+ p25: quantileFloorIndex(sorted, .25),
8962
+ p75: quantileFloorIndex(sorted, .75)
8963
+ };
8964
+ };
8965
+ const classificationOf = (run) => run.proofReuse.available ? run.proofReuse.value.classification : "unclassified";
8966
+ const aggregateCohorts = (runs) => {
8967
+ const cells = /* @__PURE__ */ new Map();
8968
+ for (const run of runs) {
8969
+ if (run.kind !== "analyzed") continue;
8970
+ const key = `${run.cohort}\0${classificationOf(run)}`;
8971
+ const cell = cells.get(key) ?? [];
8972
+ cell.push(run);
8973
+ cells.set(key, cell);
8974
+ }
8975
+ return [...cells.entries()].map(([key, cellRuns]) => {
8976
+ const [cohort = UNGROUPED_COHORT] = key.split("\0");
8977
+ const totals = cellRuns.flatMap((run) => run.durations.totalMs.available ? [run.durations.totalMs.value] : []);
8978
+ const excludedRunIds = cellRuns.filter((run) => !run.durations.totalMs.available).map((run) => run.runId);
8979
+ return {
8980
+ classification: classificationOf(cellRuns[0]),
8981
+ cohort,
8982
+ count: cellRuns.length,
8983
+ excludedRunIds,
8984
+ runIds: cellRuns.map((run) => run.runId),
8985
+ totalDurationMs: totals.length === 0 ? unavailable("no run in this cell has an available total duration") : available(quantileSummary(totals))
8986
+ };
8987
+ }).toSorted((left, right) => `${left.cohort}\0${left.classification}`.localeCompare(`${right.cohort}\0${right.classification}`));
8988
+ };
8989
+ const cohortWarnings = (runs) => {
8990
+ const warnings = [];
8991
+ const byCohort = /* @__PURE__ */ new Map();
8992
+ for (const run of runs) {
8993
+ if (run.kind !== "analyzed") continue;
8994
+ const cohort = byCohort.get(run.cohort) ?? [];
8995
+ cohort.push(run);
8996
+ byCohort.set(run.cohort, cohort);
8997
+ }
8998
+ for (const [cohort, cohortRuns] of [...byCohort.entries()].toSorted(([left], [right]) => left.localeCompare(right))) {
8999
+ const workflows = [...new Set(cohortRuns.flatMap((run) => run.workflowName.available ? [run.workflowName.value] : []))].toSorted();
9000
+ if (workflows.length > 1) warnings.push(`cohort "${cohort}" spans workflow shapes: ${workflows.join(", ")} — durations across different workflows are not comparable`);
9001
+ const models = [...new Set(cohortRuns.flatMap((run) => run.cpuModels.available ? run.cpuModels.value : []))].toSorted();
9002
+ if (models.length > 1) warnings.push(`cohort "${cohort}" spans CPU models: ${models.join(", ")} — stratify by cpuModel before comparing (see the CI runner analysis docs)`);
9003
+ }
9004
+ return warnings;
9005
+ };
9006
+ /**
9007
+ * Analyze the requested runs. Read-only: the client is only ever asked to GET.
9008
+ * One unreadable run never aborts the report — it is carried as an explicit
9009
+ * `unreadable` entry so a partially-served API answer stays auditable.
9010
+ */
9011
+ const analyzeCiRuns = async (request, client, dependencies = {}) => {
9012
+ if (request.runs.length === 0) throw new Error("ci:analyze requires at least one run id (--run, --runs-file, or --cohort).");
9013
+ const runs = [];
9014
+ for (const entry of request.runs) runs.push(await analyzeOneRun(client, request, entry));
9015
+ return {
9016
+ cohorts: aggregateCohorts(runs),
9017
+ generatedAt: (dependencies.now?.() ?? /* @__PURE__ */ new Date()).toISOString(),
9018
+ repository: request.repository,
9019
+ runs,
9020
+ schemaVersion: 1,
9021
+ tool: CI_ANALYZE_TOOL,
9022
+ warnings: cohortWarnings(runs)
9023
+ };
9024
+ };
9025
+ const formatSeconds = (ms) => `${(ms / 1e3).toFixed(1)}s`;
9026
+ const formatMaybeDuration = (value) => value.available ? formatSeconds(value.value) : `unavailable (${value.reason})`;
9027
+ const CLASSIFICATION_HEADINGS = {
9028
+ "full-fallback": "full hosted fallback",
9029
+ mixed: "mixed proof outcomes",
9030
+ "proof-reuse": "proof reuse",
9031
+ unclassified: "no proof-gate signal"
9032
+ };
9033
+ const renderRunLine = (run) => {
9034
+ const lines = [
9035
+ ` run ${run.runId} — ${run.workflowName.available ? run.workflowName.value : "unknown workflow"} (${run.conclusion.available ? run.conclusion.value : "no conclusion"})`,
9036
+ ` total ${formatMaybeDuration(run.durations.totalMs)}, queue ${formatMaybeDuration(run.durations.queueMs)}`,
9037
+ ` ${run.url.available ? run.url.value : "run URL unavailable"}`
9038
+ ];
9039
+ if (run.cpuModels.available) lines.push(` cpu: ${run.cpuModels.value.join(", ")}`);
9040
+ if (run.profiles.available) for (const profile of run.profiles.value) {
9041
+ if (profile.kind === "unreadable") {
9042
+ lines.push(` profile ${profile.artifactName}: unreadable (${profile.reason})`);
9043
+ continue;
9044
+ }
9045
+ const p = profile.profile;
9046
+ lines.push(` profile ${profile.artifactName}: median ${formatSeconds(p.summaryDurationMs.median)} over ${p.sampleDurationsMs.length} sample(s), ${p.testCount.available ? `${p.testCount.value} tests` : "test count unavailable"}, cpu ${p.cpuModel ?? "unrecorded"}`);
9047
+ const [slowFile] = p.slowFiles;
9048
+ if (slowFile) lines.push(` slowest file ${slowFile.path} (${formatSeconds(slowFile.medianMs)} median)`);
9049
+ const [slowTest] = p.slowTests;
9050
+ if (slowTest) lines.push(` slowest test ${slowTest.name} (${formatSeconds(slowTest.medianMs)} median)`);
9051
+ }
9052
+ return lines;
9053
+ };
9054
+ /**
9055
+ * Human rendering. Structure mirrors the aggregation rule: one section per
9056
+ * cohort × classification cell, so full fallback and proof reuse are never
9057
+ * visually blended, and before/after cohorts stay separate.
9058
+ */
9059
+ const renderCiAnalyzeReport = (report) => {
9060
+ const lines = [`ci:analyze — ${report.repository} (schema v${report.schemaVersion})`];
9061
+ const analyzed = report.runs.filter((run) => run.kind === "analyzed");
9062
+ for (const cohort of report.cohorts) {
9063
+ lines.push("", `cohort "${cohort.cohort}" — ${CLASSIFICATION_HEADINGS[cohort.classification]} (${cohort.count} run(s))`);
9064
+ if (cohort.totalDurationMs.available) {
9065
+ const stats = cohort.totalDurationMs.value;
9066
+ lines.push(` total duration over ${stats.count} run(s): median ${formatSeconds(stats.median)}, p25 ${formatSeconds(stats.p25)}, p75 ${formatSeconds(stats.p75)}, min ${formatSeconds(stats.minimum)}, max ${formatSeconds(stats.maximum)}`);
9067
+ } else lines.push(` total duration: unavailable (${cohort.totalDurationMs.reason})`);
9068
+ if (cohort.excludedRunIds.length > 0) lines.push(` excluded from aggregates (total unavailable): ${cohort.excludedRunIds.join(", ")}`);
9069
+ for (const run of analyzed.filter((candidate) => candidate.cohort === cohort.cohort && classificationOf(candidate) === cohort.classification)) lines.push(...renderRunLine(run));
9070
+ }
9071
+ const unreadable = report.runs.filter((run) => run.kind === "unreadable");
9072
+ const unreadableCohorts = [...new Set(unreadable.map((run) => run.cohort))].toSorted((left, right) => left.localeCompare(right));
9073
+ for (const cohort of unreadableCohorts) {
9074
+ lines.push("", `cohort "${cohort}" — unreadable runs:`);
9075
+ for (const run of unreadable.filter((candidate) => candidate.cohort === cohort)) lines.push(` run ${run.runId}: ${run.reason}`);
9076
+ }
9077
+ if (report.warnings.length > 0) {
9078
+ lines.push("", "warnings:");
9079
+ for (const warning of report.warnings) lines.push(` - ${warning}`);
9080
+ }
9081
+ return `${lines.join("\n")}\n`;
9082
+ };
9083
+ //#endregion
9084
+ //#region src/commands/ci-analyze.ts
9085
+ /**
9086
+ * `ci:analyze` command wiring (#647).
9087
+ *
9088
+ * The analysis itself lives in `../ci-analyze.js`; this module owns the flag
9089
+ * grammar, the run-id assembly (`--run`, `--runs-file`, `--cohort`), the
9090
+ * report output paths, and the default `gh`-backed {@link CiAnalyzeClient}.
9091
+ * Authentication is entirely the caller's: every request goes through the
9092
+ * `gh` CLI, which resolves its own token (GH_TOKEN / GITHUB_TOKEN / login) —
9093
+ * no credential is stored or minted here.
9094
+ */
9095
+ const REPOSITORY_PATTERN = /^[\w.-]+\/[\w.-]+$/u;
9096
+ const parseRepository = (value) => {
9097
+ if (!REPOSITORY_PATTERN.test(value)) throw new Error(`--repo must be owner/name, got "${value}"`);
9098
+ return value;
9099
+ };
9100
+ const parseRunId = (value) => {
9101
+ const parsed = Number(value);
9102
+ if (!Number.isSafeInteger(parsed) || parsed < 1) throw new Error(`run id must be a positive integer, got "${value}"`);
9103
+ return parsed;
9104
+ };
9105
+ const collectRunOption = (value, previous = []) => [...previous, parseRunId(value)];
9106
+ /**
9107
+ * `--cohort label=id,id` — the same `name=value` grammar the existing psf
9108
+ * flags use for structured values, repeatable once per label.
9109
+ *
9110
+ * The label `ungrouped` is reserved: it is what the report calls runs the
9111
+ * caller left unlabeled, so accepting it as a caller cohort would silently
9112
+ * blend a named population with the genuinely unlabeled one.
9113
+ */
9114
+ const parseCohortOption = (value, previous = []) => {
9115
+ const separator = value.indexOf("=");
9116
+ const label = separator === -1 ? "" : value.slice(0, separator).trim();
9117
+ const ids = separator === -1 ? "" : value.slice(separator + 1);
9118
+ if (label === "" || ids.trim() === "") throw new Error(`--cohort must be <label>=<runId>[,<runId>...], got "${value}"`);
9119
+ if (label === "ungrouped") throw new Error(`--cohort label "${UNGROUPED_COHORT}" is reserved for runs passed without a cohort; pick another label`);
9120
+ return [...previous, {
9121
+ label,
9122
+ runIds: ids.split(",").map((id) => parseRunId(id.trim()))
9123
+ }];
9124
+ };
9125
+ /** One run id per line; blank lines and `#` comments are ignored. */
9126
+ const parseRunsFile = (content) => content.split("\n").map((line) => line.trim()).filter((line) => line !== "" && !line.startsWith("#")).map(parseRunId);
9127
+ /**
9128
+ * Assemble the requested run set. A run id may appear in exactly one cohort;
9129
+ * uncohorted ids (from `--run` / `--runs-file`) stay unlabeled. Conflicting
9130
+ * cohort labels for one id are a hard error rather than a silent pick.
9131
+ */
9132
+ const assembleRunRequests = (input) => {
9133
+ const cohortByRun = /* @__PURE__ */ new Map();
9134
+ for (const cohort of input.cohorts) for (const runId of cohort.runIds) {
9135
+ const existing = cohortByRun.get(runId);
9136
+ if (existing !== void 0 && existing !== cohort.label) throw new Error(`run ${runId} is assigned to both cohort "${existing}" and cohort "${cohort.label}"`);
9137
+ cohortByRun.set(runId, cohort.label);
9138
+ }
9139
+ const ordered = [];
9140
+ const seen = /* @__PURE__ */ new Set();
9141
+ for (const cohort of input.cohorts) for (const runId of cohort.runIds) if (!seen.has(runId)) {
9142
+ seen.add(runId);
9143
+ ordered.push({
9144
+ cohort: cohort.label,
9145
+ runId
9146
+ });
9147
+ }
9148
+ for (const runId of input.runIds) if (!seen.has(runId)) {
9149
+ seen.add(runId);
9150
+ ordered.push({
9151
+ cohort: cohortByRun.get(runId) ?? null,
9152
+ runId
9153
+ });
9154
+ }
9155
+ return ordered;
9156
+ };
9157
+ const GH_ARTIFACT_MAX_BYTES = 256 * 1024 * 1024;
9158
+ const PAGE_SIZE = 100;
9159
+ const MAX_PAGES = 50;
9160
+ /**
9161
+ * Follow GitHub's pagination for one collection endpoint: request pages until
9162
+ * the served rows reach the response's `total_count` (or a page comes back
9163
+ * empty). The merged document keeps the `total_count` field, so if the page
9164
+ * budget is ever exhausted the analyzer's truncation guard reports the
9165
+ * shortfall as explicit unavailability instead of a silent partial read.
9166
+ */
9167
+ const listActionsCollection = (basePath, key) => {
9168
+ const rows = [];
9169
+ let totalCount;
9170
+ for (let page = 1; page <= MAX_PAGES; page += 1) {
9171
+ const response = runGhJson(["api", `${basePath}?per_page=${PAGE_SIZE}&page=${page}`]);
9172
+ const served = Array.isArray(response[key]) ? response[key] : [];
9173
+ rows.push(...served);
9174
+ totalCount = typeof response.total_count === "number" ? response.total_count : totalCount;
9175
+ if (served.length === 0 || rows.length >= (totalCount ?? 0)) break;
9176
+ }
9177
+ return {
9178
+ [key]: rows,
9179
+ total_count: totalCount ?? rows.length
9180
+ };
9181
+ };
9182
+ /**
9183
+ * A Unix-host entry line: permissions, zip version, host OS, decompressed
9184
+ * size, text/binary flags, method, date, time, then the member name (which
9185
+ * may itself contain spaces). This is the one form GitHub's artifact service
9186
+ * actually emits; other host forms are deliberately *not* supported — they
9187
+ * fail reconciliation below rather than being half-parsed.
9188
+ */
9189
+ const ZIP_ENTRY_PATTERN = /^(?<perms>[-dl][a-srwxt-]{9})\s+\d+\.\d+\s+\S+\s+(?<size>\d+)\s+\S\S\s+\S+\s+\S+\s+\S+\s(?<name>.+)$/u;
9190
+ /** `Zip file size: 12109 bytes, number of entries: 2` */
9191
+ const ZIP_HEADER_PATTERN = /^Zip file size: \d+ bytes, number of entries: (?<count>\d+)$/u;
9192
+ /** `2 files, 81410 bytes uncompressed, 11689 bytes compressed: 85.6%` */
9193
+ const ZIP_TRAILER_PATTERN = /^(?<count>\d+) files?, \d+ bytes? uncompressed, \d+ bytes? compressed:\s+-?[\d.]+%$/u;
9194
+ /**
9195
+ * Parse zipinfo's default listing and *reconcile* it against the archive's
9196
+ * own declared entry count — fail closed, never a partial view.
9197
+ *
9198
+ * The security property the caps and member policy rest on is that the parsed
9199
+ * set is the WHOLE central directory. A listing line this parser does not
9200
+ * recognize (a FAT-host form, a future zipinfo change) therefore refuses the
9201
+ * whole artifact rather than being skipped: a skipped line would let
9202
+ * `unzip <name>` extract members the caps never counted. Three refusals:
9203
+ * an unrecognized line, a parsed count that differs from the declared count
9204
+ * (every header and trailer declaration must agree — a listing that
9205
+ * contradicts itself has no unambiguous count and is refused), and any
9206
+ * duplicate member name across the full parsed set — a parsed safe name
9207
+ * shadowed by an unparsed or differently-shaped duplicate is exactly the
9208
+ * bypass this closes.
9209
+ */
9210
+ const parseZipInfoListing = (text) => {
9211
+ const members = [];
9212
+ const declaredCounts = [];
9213
+ for (const line of text.split("\n")) {
9214
+ if (line.trim() === "" || line.startsWith("Archive:")) continue;
9215
+ const header = ZIP_HEADER_PATTERN.exec(line);
9216
+ if (header?.groups) {
9217
+ declaredCounts.push(Number(header.groups.count));
9218
+ continue;
9219
+ }
9220
+ const trailer = ZIP_TRAILER_PATTERN.exec(line);
9221
+ if (trailer?.groups) {
9222
+ declaredCounts.push(Number(trailer.groups.count));
9223
+ continue;
9224
+ }
9225
+ const entry = ZIP_ENTRY_PATTERN.exec(line);
9226
+ if (entry?.groups) {
9227
+ members.push({
9228
+ isSymlink: entry.groups.perms?.startsWith("l") === true,
9229
+ name: entry.groups.name ?? "",
9230
+ sizeBytes: Number(entry.groups.size)
9231
+ });
9232
+ continue;
9233
+ }
9234
+ throw new Error(`zip listing reconciliation failed: unrecognized listing line "${line}"`);
9235
+ }
9236
+ const [declaredEntries] = declaredCounts;
9237
+ if (declaredEntries === void 0) throw new Error("zip listing reconciliation failed: the listing declares no entry count");
9238
+ if (declaredCounts.some((count) => count !== declaredEntries)) throw new Error(`zip listing reconciliation failed: contradictory declared entry counts (${declaredCounts.join(", ")})`);
9239
+ if (members.length !== declaredEntries) throw new Error(`zip listing reconciliation failed: parsed ${members.length} of ${declaredEntries} declared entries`);
9240
+ const seen = /* @__PURE__ */ new Set();
9241
+ for (const member of members) {
9242
+ if (seen.has(member.name)) throw new Error(`zip listing reconciliation failed: duplicate member name "${member.name}"`);
9243
+ seen.add(member.name);
9244
+ }
9245
+ return members;
9246
+ };
9247
+ const MAX_ARTIFACT_DECOMPRESSED_BYTES = 256 * 1024 * 1024;
9248
+ const UNSAFE_MEMBER_CHARACTERS = /[*?[\]]/u;
9249
+ /**
9250
+ * The member policy for artifact zips, as a pure function so the security
9251
+ * decisions are testable without `unzip`. The archive is refused outright —
9252
+ * not partially read — when any member is a symlink, an absolute path, a
9253
+ * `..` traversal, option-shaped (leading `-`), or glob-ambiguous, or when the
9254
+ * archive exceeds the count or decompressed-size caps. What survives is the
9255
+ * `*.json` member list actually handed to extraction.
9256
+ */
9257
+ const selectProfileZipMembers = (members) => {
9258
+ if (members.length > 512) throw new Error(`artifact zip refused: ${members.length} members exceeds the 512-member cap`);
9259
+ let totalBytes = 0;
9260
+ for (const member of members) {
9261
+ if (member.isSymlink) throw new Error(`artifact zip refused: member "${member.name}" is a symlink`);
9262
+ if (member.name.startsWith("/") || /^[A-Za-z]:/u.test(member.name)) throw new Error(`artifact zip refused: member "${member.name}" is an absolute path`);
9263
+ if (member.name.split("/").includes("..")) throw new Error(`artifact zip refused: member "${member.name}" traverses with ..`);
9264
+ if (member.name.startsWith("-")) throw new Error(`artifact zip refused: member "${member.name}" is option-shaped`);
9265
+ if (UNSAFE_MEMBER_CHARACTERS.test(member.name)) throw new Error(`artifact zip refused: member "${member.name}" contains glob metacharacters`);
9266
+ if (!Number.isFinite(member.sizeBytes) || member.sizeBytes < 0) throw new Error(`artifact zip refused: member "${member.name}" reports no usable size`);
9267
+ totalBytes += member.sizeBytes;
9268
+ }
9269
+ if (totalBytes > 268435456) throw new Error(`artifact zip refused: ${totalBytes} decompressed bytes exceeds the ${MAX_ARTIFACT_DECOMPRESSED_BYTES}-byte cap`);
9270
+ return members.filter((member) => !member.name.endsWith("/") && member.name.endsWith(".json")).map((member) => member.name).toSorted();
9271
+ };
9272
+ /**
9273
+ * The default client: `gh api` for JSON endpoints (paginated), `gh api` plus
9274
+ * system `unzip` for artifact zips, with every member vetted by
9275
+ * {@link selectProfileZipMembers} before extraction. The exec adapter itself
9276
+ * is deliberately thin and untested networked code — every decision made from
9277
+ * these bytes lives behind {@link CiAnalyzeClient} or the pure policy
9278
+ * functions above, where fixtures drive it.
9279
+ */
9280
+ const createGhCiAnalyzeClient = (repository) => ({
9281
+ getRun: (runId) => Promise.resolve(runGhJson(["api", `/repos/${repository}/actions/runs/${runId}`])),
9282
+ listArtifacts: (runId) => Promise.resolve(listActionsCollection(`/repos/${repository}/actions/runs/${runId}/artifacts`, "artifacts")),
9283
+ listJobs: (runId) => Promise.resolve(listActionsCollection(`/repos/${repository}/actions/runs/${runId}/jobs`, "jobs")),
9284
+ readArtifactTextFiles: (artifactId) => {
9285
+ const scratch = mkdtempSync(path.join(tmpdir(), "psf-ci-analyze-"));
9286
+ try {
9287
+ const zip = execFileSync("gh", ["api", `/repos/${repository}/actions/artifacts/${artifactId}/zip`], { maxBuffer: GH_ARTIFACT_MAX_BYTES });
9288
+ const zipPath = path.join(scratch, "artifact.zip");
9289
+ writeFileSync(zipPath, zip);
9290
+ const selected = selectProfileZipMembers(parseZipInfoListing(execFileSync("unzip", ["-Z", zipPath], {
9291
+ encoding: "utf-8",
9292
+ maxBuffer: GH_ARTIFACT_MAX_BYTES
9293
+ })));
9294
+ if (selected.length === 0) return Promise.resolve([]);
9295
+ const extracted = path.join(scratch, "extracted");
9296
+ execFileSync("unzip", [
9297
+ "-o",
9298
+ "-qq",
9299
+ zipPath,
9300
+ "-d",
9301
+ extracted,
9302
+ ...selected
9303
+ ], { stdio: [
9304
+ "ignore",
9305
+ "ignore",
9306
+ "pipe"
9307
+ ] });
9308
+ return Promise.resolve(selected.map((name) => ({
9309
+ name,
9310
+ text: readFileSync(path.join(extracted, name), "utf-8")
9311
+ })));
9312
+ } finally {
9313
+ rmSync(scratch, {
9314
+ force: true,
9315
+ recursive: true
9316
+ });
9317
+ }
9318
+ }
9319
+ });
9320
+ const defaultAction = (request) => analyzeCiRuns(request, createGhCiAnalyzeClient(request.repository));
9321
+ function createCiAnalyzeCommand(output, action = defaultAction) {
9322
+ return new Command("ci:analyze").description("Read-only GitHub Actions run lifecycle analysis: queue/setup/install/step/total durations, proof-reuse classification, cohort aggregates, and optional factory-ci Vitest profile readback").requiredOption("--repo <owner/name>", "repository the run ids belong to", parseRepository).option("--run <id>", "workflow run id to analyze (repeatable)", collectRunOption).option("--runs-file <path>", "file of run ids, one per line (# comments allowed)").option("--cohort <label=ids>", "label a comma-separated run-id list as one cohort, e.g. --cohort before=1,2 (repeatable; the label \"ungrouped\" is reserved for runs passed without a cohort)", parseCohortOption).option("--profiles", "download run artifacts and read compatible factory-ci Vitest profiles (the only source of cpuModel)").option("--output <path>", "write the versioned JSON report to a path").option("--json", "print the JSON report to stdout instead of prose").action(async (options) => {
9323
+ const fromFile = options.runsFile === void 0 ? [] : parseRunsFile(readFileSync(path.resolve(options.runsFile), "utf-8"));
9324
+ const report = await action({
9325
+ includeProfiles: options.profiles === true,
9326
+ repository: options.repo,
9327
+ runs: assembleRunRequests({
9328
+ cohorts: options.cohort ?? [],
9329
+ runIds: [...options.run ?? [], ...fromFile]
9330
+ })
9331
+ });
9332
+ if (options.output !== void 0) {
9333
+ const outputPath = path.resolve(options.output);
9334
+ writeFileSync(outputPath, `${JSON.stringify(report, null, 2)}\n`);
9335
+ output.stderr.write(`ci:analyze report written to ${outputPath}\n`);
9336
+ }
9337
+ output.stdout.write(options.json ? `${JSON.stringify(report, null, 2)}\n` : renderCiAnalyzeReport(report));
9338
+ });
8620
9339
  }
8621
9340
  //#endregion
8622
9341
  //#region src/checkout-repository.ts
@@ -11219,7 +11938,7 @@ const runDemandWaive = (args, dependencies = {}) => {
11219
11938
  * offers no way to declare a demand met, only to waive it on the record.
11220
11939
  */
11221
11940
  function createDemandWaiveCommand(_output, action = runDemandWaive) {
11222
- return new Command("demand:waive").description("Waive one resolved demand for one candidate, on the operator's identity, with the rationale recorded in the proof").requiredOption("--pr <number>", "pull request number", positiveInteger("--pr")).requiredOption("--demand <key>", "resolved demand key, e.g. review-rung:human, required-check:core, merge-freeze").requiredOption("--rationale <text>", "why this demand is being waived").option("--cwd <path>", "working directory to evaluate", ".").option("--json", "print the recorded waiver as JSON").option("--output <path>", "waiver record JSON path").action((options) => {
11941
+ return markCwdOptionDefault(new Command("demand:waive").description("Waive one resolved demand for one candidate, on the operator's identity, with the rationale recorded in the proof").requiredOption("--pr <number>", "pull request number", positiveInteger("--pr")).requiredOption("--demand <key>", "resolved demand key, e.g. review-rung:human, required-check:core, merge-freeze").requiredOption("--rationale <text>", "why this demand is being waived").option("--cwd <path>", "working directory to evaluate", collectCwdOption).option("--json", "print the recorded waiver as JSON").option("--output <path>", "waiver record JSON path").action((options) => {
11223
11942
  action({
11224
11943
  cwd: resolveCwdOption(options.cwd),
11225
11944
  demand: options.demand,
@@ -11228,7 +11947,7 @@ function createDemandWaiveCommand(_output, action = runDemandWaive) {
11228
11947
  pr: options.pr,
11229
11948
  rationale: options.rationale
11230
11949
  });
11231
- });
11950
+ }));
11232
11951
  }
11233
11952
  //#endregion
11234
11953
  //#region src/demand-resolution.ts
@@ -11821,7 +12540,7 @@ const describeOrphans = (orphans) => {
11821
12540
  return ` ${orphans.length} spool location(s) hold this repository's evidence under an earlier key it no longer resolves to — a key-format change strands evidence there where no drain looks: ${described}. Drain each with \`psf hq:flush --dir <path>\`.`;
11822
12541
  };
11823
12542
  /**
11824
- * Red when HQ evidence is waiting for this repository: its own spool or a
12543
+ * Warns when HQ evidence is waiting for this repository: its own spool or a
11825
12544
  * sibling location written under an earlier key of its own that it no longer
11826
12545
  * resolves to (#420). Another repository's spool under the shared root is that
11827
12546
  * repository's business, never this check's (#446).
@@ -11834,8 +12553,15 @@ const describeOrphans = (orphans) => {
11834
12553
  * the spool resolve exactly one key, so evidence written under an older one is
11835
12554
  * invisible to a drain and, before this, to doctor: the failure epic #389 was
11836
12555
  * chartered to end is evidence that is neither delivered nor visibly stranded.
11837
- * An orphan holding pending events is red for the same reason the repo-keyed
11838
- * spool is.
12556
+ * An orphan holding pending events warns for the same reason the repo-keyed
12557
+ * spool does.
12558
+ *
12559
+ * `warning`, not `error` (#659, epic #663 wave 1): a spooled event is real
12560
+ * HQ-delivery lag, but doctor is a read-only diagnostic that must never gain a
12561
+ * flush side-effect, and a preflight gate that treats doctor's exit code as
12562
+ * the bar must not block admission on delivery lag it cannot fix from here.
12563
+ * `psf hq:flush` — a trusted local session, not doctor — is still how the
12564
+ * spool actually drains.
11839
12565
  */
11840
12566
  async function hqSpoolDoctorCheck(input, dependencies = {}) {
11841
12567
  const countSpool = dependencies.countSpool ?? countHqSpoolWork;
@@ -11852,17 +12578,17 @@ async function hqSpoolDoctorCheck(input, dependencies = {}) {
11852
12578
  if (counted.pending > 0) return {
11853
12579
  message: `${counted.pending} HQ event(s) are spooled locally.${oldestNote}${locationsNote} ${FLUSH_REMEDY}${orphanNote}`,
11854
12580
  name: HQ_SPOOL_CHECK_NAME,
11855
- status: "error"
12581
+ status: "warning"
11856
12582
  };
11857
12583
  if (counted.unlistable > 0) return {
11858
12584
  message: `${counted.unlistable} spool location(s) exist but could not be listed within budget, so this cannot be reported as empty.${locationsNote} ${FLUSH_REMEDY}${orphanNote}`,
11859
12585
  name: HQ_SPOOL_CHECK_NAME,
11860
- status: "error"
12586
+ status: "warning"
11861
12587
  };
11862
12588
  return {
11863
12589
  message: `This repository's HQ spool is empty, but stranded evidence is waiting under the spool root ${swept.root}.${orphanNote}`,
11864
12590
  name: HQ_SPOOL_CHECK_NAME,
11865
- status: "error"
12591
+ status: "warning"
11866
12592
  };
11867
12593
  }
11868
12594
  const readJsonFiles = async (dir, readFileImpl) => {
@@ -12213,7 +12939,7 @@ function repositoryUrlMatches(origin, profile) {
12213
12939
  //#endregion
12214
12940
  //#region src/commands/doctor.ts
12215
12941
  function createDoctorCommand(output) {
12216
- 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) => {
12942
+ return markCwdOptionDefault(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", collectCwdOption).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) => {
12217
12943
  const report = await doctorProjectProfile({
12218
12944
  base: options.base,
12219
12945
  cwd: resolveCwdOption(options.cwd),
@@ -12223,7 +12949,7 @@ function createDoctorCommand(output) {
12223
12949
  if (options.json) output.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
12224
12950
  else output.stdout.write(formatHumanReport(report));
12225
12951
  if (!report.ok) process.exitCode = 1;
12226
- });
12952
+ }));
12227
12953
  }
12228
12954
  function formatHumanReport(report) {
12229
12955
  return [
@@ -12631,7 +13357,7 @@ function optionalString(key, flagValue, stdinValue) {
12631
13357
  return resolved ? { [key]: resolved } : {};
12632
13358
  }
12633
13359
  function createEvidenceEmitCommand(output, action = runEvidenceEmit, readInput = readStdin) {
12634
- 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) => {
13360
+ return markCwdOptionDefault(new Command("evidence:emit").description("Build, validate, and write an external-evidence envelope (ADR 0014)").option("--cwd <path>", "repository working directory", collectCwdOption).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) => {
12635
13361
  const cwd = resolveCwdOption(options.cwd);
12636
13362
  const fromStdin = options.stdin ? readInput() : {};
12637
13363
  rejectRetiredStdinFields(fromStdin);
@@ -12658,7 +13384,7 @@ function createEvidenceEmitCommand(output, action = runEvidenceEmit, readInput =
12658
13384
  });
12659
13385
  output.stdout.write(`evidence:emit wrote envelope for "${result.envelope.check}" to ${result.path}\n`);
12660
13386
  if (options.json && "envelope" in result) output.stdout.write(`${JSON.stringify(result.envelope, null, 2)}\n`);
12661
- });
13387
+ }));
12662
13388
  }
12663
13389
  //#endregion
12664
13390
  //#region src/hq-flush.ts
@@ -12821,7 +13547,7 @@ const hqFlushExitCode = (result) => {
12821
13547
  //#endregion
12822
13548
  //#region src/commands/hq-flush.ts
12823
13549
  function createHqFlushCommand(output, action = runHqFlush) {
12824
- return new Command("hq:flush").description("Drain this repository's spooled HQ evidence and report every event; exits 1 when transport failed and 2 when the drain did not finish").option("--cwd <path>", "working directory to evaluate", ".").option("--dir <path>", "drain an explicit spool directory instead of the default locations; repeatable", (value, previous = []) => [...previous, value]).option("--json", "print the flush result as JSON").option("--profile <path>", "path to the project profile JSON file").action(async (options) => {
13550
+ return markCwdOptionDefault(new Command("hq:flush").description("Drain this repository's spooled HQ evidence and report every event; exits 1 when transport failed and 2 when the drain did not finish").option("--cwd <path>", "working directory to evaluate", collectCwdOption).option("--dir <path>", "drain an explicit spool directory instead of the default locations; repeatable", (value, previous = []) => [...previous, value]).option("--json", "print the flush result as JSON").option("--profile <path>", "path to the project profile JSON file").action(async (options) => {
12825
13551
  const result = await action({
12826
13552
  cwd: resolveCwdOption(options.cwd),
12827
13553
  ...options.dir ? { dir: options.dir } : {},
@@ -12831,7 +13557,7 @@ function createHqFlushCommand(output, action = runHqFlush) {
12831
13557
  output.stdout.write(options.json ? `${JSON.stringify(result, null, 2)}\n` : renderHqFlush(result));
12832
13558
  const exitCode = hqFlushExitCode(result);
12833
13559
  if (exitCode !== 0) process.exitCode = exitCode;
12834
- });
13560
+ }));
12835
13561
  }
12836
13562
  //#endregion
12837
13563
  //#region src/follow-up.ts
@@ -15694,7 +16420,7 @@ async function deliverSpooledEvidence({ awaitPending, cwd, flush, profilePath })
15694
16420
  //#endregion
15695
16421
  //#region src/commands/pr-publish.ts
15696
16422
  function createPrPublishCommand(_output, action = runPrPublish) {
15697
- return new Command("pr:publish").description("Compose supplied proof into a ready-for-human handoff; never launches review").option("--pr <number>", "pull request number", positiveInteger("--pr")).option("--base <ref>", "explicit base branch or ref override").option("--authoring-session <id>", "known authoring session identity for verification retries").option("--cwd <path>", "working directory to evaluate", ".").option("--epic <number>", "epic issue containing the factory-boundary manifest; the composed readiness evaluation then applies the wave-demanded review rung (#351)", positiveInteger("--epic")).option("--findings <path>", "clean-session findings to validate when no current review proof is available").option("--json", "print the publish result as JSON").option("--output <path>", "write readiness proof JSON to a file").option("--profile <path>", "path to the project profile JSON file").option("--review-proof <path>", "pr:review proof JSON path").option("--verify-proof <path>", "pr:verify proof JSON path").action(async (options) => {
16423
+ return markCwdOptionDefault(new Command("pr:publish").description("Compose supplied proof into a ready-for-human handoff; never launches review").option("--pr <number>", "pull request number", positiveInteger("--pr")).option("--base <ref>", "explicit base branch or ref override").option("--authoring-session <id>", "known authoring session identity for verification retries").option("--cwd <path>", "working directory to evaluate", collectCwdOption).option("--epic <number>", "epic issue containing the factory-boundary manifest; the composed readiness evaluation then applies the wave-demanded review rung (#351)", positiveInteger("--epic")).option("--findings <path>", "clean-session findings to validate when no current review proof is available").option("--json", "print the publish result as JSON").option("--output <path>", "write readiness proof JSON to a file").option("--profile <path>", "path to the project profile JSON file").option("--review-proof <path>", "pr:review proof JSON path").option("--verify-proof <path>", "pr:verify proof JSON path").action(async (options) => {
15698
16424
  await action({
15699
16425
  authoringSessionIds: options.authoringSession ? [options.authoringSession] : void 0,
15700
16426
  base: options.base ?? "",
@@ -15708,12 +16434,12 @@ function createPrPublishCommand(_output, action = runPrPublish) {
15708
16434
  reviewProof: options.reviewProof,
15709
16435
  verifyProof: options.verifyProof
15710
16436
  });
15711
- });
16437
+ }));
15712
16438
  }
15713
16439
  //#endregion
15714
16440
  //#region src/commands/pr-ready.ts
15715
16441
  function createPrReadyCommand(output, action) {
15716
- return new Command("pr:ready").description("Evaluate typed proof and GitHub state for final readiness").requiredOption("--pr <number>", "pull request number", positiveInteger("--pr")).option("--base <ref>", "explicit base branch or ref override").option("--authoring-session <id...>", "recorded authoring session id(s) for review-type requiredCheck independence (ADR 0014 §5)").option("--cwd <path>", "working directory to evaluate", ".").option("--epic <number>", "epic issue containing the factory-boundary manifest; readiness then evaluates the wave-demanded review rung (#351)", positiveInteger("--epic")).option("--json", "print the readiness proof as JSON").option("--output <path>", "write proof JSON to a file").option("--profile <path>", "path to the project profile JSON file").option("--review-proof <path>", "pr:review proof JSON path").option("--verify-proof <path>", "pr:verify proof JSON path").action(withGateTiming({
16442
+ return markCwdOptionDefault(new Command("pr:ready").description("Evaluate typed proof and GitHub state for final readiness").requiredOption("--pr <number>", "pull request number", positiveInteger("--pr")).option("--base <ref>", "explicit base branch or ref override").option("--authoring-session <id...>", "recorded authoring session id(s) for review-type requiredCheck independence (ADR 0014 §5)").option("--cwd <path>", "working directory to evaluate", collectCwdOption).option("--epic <number>", "epic issue containing the factory-boundary manifest; readiness then evaluates the wave-demanded review rung (#351)", positiveInteger("--epic")).option("--json", "print the readiness proof as JSON").option("--output <path>", "write proof JSON to a file").option("--profile <path>", "path to the project profile JSON file").option("--review-proof <path>", "pr:review proof JSON path").option("--verify-proof <path>", "pr:verify proof JSON path").action(withGateTiming({
15717
16443
  gate: "pr:ready",
15718
16444
  resolveLedgerRoot: (options) => resolveCwdOption(options.cwd),
15719
16445
  stderr: output.stderr
@@ -15731,7 +16457,7 @@ function createPrReadyCommand(output, action) {
15731
16457
  verifyProof: options.verifyProof
15732
16458
  };
15733
16459
  await (action ? action(args) : runPrReady(args));
15734
- }));
16460
+ })));
15735
16461
  }
15736
16462
  //#endregion
15737
16463
  //#region src/gate-foreground-guard.ts
@@ -15760,7 +16486,7 @@ function assertForegroundGate({ gate, env = process.env }) {
15760
16486
  //#endregion
15761
16487
  //#region src/commands/pr-review.ts
15762
16488
  function createPrReviewCommand(output, action) {
15763
- 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({
16489
+ return markCwdOptionDefault(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", collectCwdOption).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({
15764
16490
  gate: "pr:review",
15765
16491
  resolveCycle: (options) => options.cycle,
15766
16492
  resolveLedgerRoot: (options) => resolveCwdOption(options.cwd),
@@ -15785,12 +16511,12 @@ function createPrReviewCommand(output, action) {
15785
16511
  verifyProof: options.verifyProof
15786
16512
  };
15787
16513
  await (action ? action(args) : runPrReview(args, { publishCheckRun: createFactoryCheckPublisher(output) }));
15788
- }));
16514
+ })));
15789
16515
  }
15790
16516
  //#endregion
15791
16517
  //#region src/commands/pr-verify.ts
15792
16518
  function createPrVerifyCommand(output, action = runPrVerify) {
15793
- return new Command("pr:verify").description("Run project-profile verification commands and write typed proof").option("--base <ref>", "base branch or ref", "origin/main").option("--cwd <path>", "working directory to verify", ".").option("--docs-only", "run the docs/process verification gate").option("--trivial", "run the trivial verification gate").option("--full", "run the full verification gate").option("--authoring-session <id>", "explicit authoring session identity (required for review evidence)").option("--json", "print the proof as JSON after verification").option("--output <path>", "write proof JSON to a file").option("--profile <path>", "path to the project profile JSON file").option("--no-status", "skip posting the patronage-factory/pr-verify commit status").action(withGateTiming({
16519
+ return markCwdOptionDefault(new Command("pr:verify").description("Run project-profile verification commands and write typed proof").option("--base <ref>", "base branch or ref", "origin/main").option("--cwd <path>", "working directory to verify", collectCwdOption).option("--docs-only", "run the docs/process verification gate").option("--trivial", "run the trivial verification gate").option("--full", "run the full verification gate").option("--authoring-session <id>", "explicit authoring session identity (required for review evidence)").option("--json", "print the proof as JSON after verification").option("--output <path>", "write proof JSON to a file").option("--profile <path>", "path to the project profile JSON file").option("--no-status", "skip posting the patronage-factory/pr-verify commit status").action(withGateTiming({
15794
16520
  gate: "pr:verify",
15795
16521
  resolveLedgerRoot: (options) => resolveCwdOption(options.cwd),
15796
16522
  stderr: output.stderr
@@ -15812,7 +16538,7 @@ function createPrVerifyCommand(output, action = runPrVerify) {
15812
16538
  requireKnownAuthoringSession: true
15813
16539
  }, dependencies);
15814
16540
  if (options.json) output.stdout.write(`${JSON.stringify(proof, null, 2)}\n`);
15815
- }));
16541
+ })));
15816
16542
  }
15817
16543
  function modeFor(options) {
15818
16544
  if (options.docsOnly) return "docs-only";
@@ -16107,6 +16833,7 @@ function createProgram(options = {}) {
16107
16833
  program.addCommand(createDoctorCommand(output));
16108
16834
  program.addCommand(createConfigCommand(output));
16109
16835
  program.addCommand(createEvidenceEmitCommand(output));
16836
+ program.addCommand(createCiAnalyzeCommand(output));
16110
16837
  program.addCommand(createHqFlushCommand(output));
16111
16838
  program.addCommand(createBoundaryCheckCommand(output, options.actions?.boundaryCheck));
16112
16839
  program.addCommand(createPrVerifyCommand(output));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patronage/software-factory",
3
- "version": "1.0.0-alpha.6",
3
+ "version": "1.0.0-alpha.8",
4
4
  "description": "Shared Patronage software factory CLI and project-profile validation tools",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -37,7 +37,7 @@
37
37
  "picomatch": "^4.0.5",
38
38
  "yaml": "^2.9.0",
39
39
  "zod": "4.4.3",
40
- "@patronage/factory-ci": "1.0.0-alpha.6"
40
+ "@patronage/factory-ci": "1.0.0-alpha.8"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/node": "24.13.3",