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

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/CONTEXT.md CHANGED
@@ -6,12 +6,22 @@ The shared Patronage software factory: typed lifecycle state, proof gates, and o
6
6
 
7
7
  ## Language
8
8
 
9
+ ### Admission
10
+
11
+ **Candidate**: The exact patch under admission — the `headSha` / `patchId` / `mergeBaseSha` identity every piece of evidence binds to. A GitHub draft pull request is not a Candidate; GitHub's draft boolean is the machine lifecycle authority, and ready-for-review is the Candidate state. Experiment and interactive describe human intent only and create no machine states.
12
+
13
+ **Promotion**: The one transition from a draft pull request into a Candidate. `pr:publish` is the promotion verb. Its composed handoff finishes proof while draft, promotes, then evaluates Candidate demands at the unchanged head. Draft open and synchronize events run no substantive hosted Verify battery; `ready_for_review` starts the complete applicable Candidate pass.
14
+
9
15
  ### Proof reuse
10
16
 
11
17
  **Proof gate**: A command that emits a typed proof — the self-timed set is `pr:verify`, `pr:review`, `pr:ready`, `boundary:check`, and `factory:closeout`; one execution is a **gate run**. Typed proof files are the source of readiness truth; prose never substitutes.
12
18
 
13
19
  **Proof-reuse gate**: The decision that consumes trusted verification proof and determines whether hosted CI may skip work already performed. Refusing reuse runs the hosted suite; it does not fail the candidate. Defined by ADR 0022. _Avoid_: proof gate (that produces readiness proof rather than reusing it).
14
20
 
21
+ ### Impact
22
+
23
+ **Impact**: The candidate-bound classification of which declared product targets a changed path or lockfile graph can reach. Path impact is opt-in: a target is affected if and only if a valid changed path matches one of its declared globs. A valid path matched by no target is **unsubscribed** and demands no target-scoped work, while remaining explicit in the stamp basis and human summary. Shared paths affect every target that subscribes to them. Invalid paths and unreadable or malformed required inputs still fail closed. Impact may withdraw target-scoped work; it never certifies a merge target, whose push-triggered verification stays full. Defined by ADR 0027. _Avoid_: affectedness; ownership (the declaration is a subscription, not an implicit filesystem owner).
24
+
15
25
  ### Planning intake
16
26
 
17
27
  **Spec**: The single through-line planning artifact — a technical, non-technical, or blended description of the work to be built, produced by `to-spec`. The term supersedes "PRD"; older artifacts titled "PRD:" (e.g. #232) are specs by another name. _Avoid_: PRD (retired), requirements doc.
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). **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.
40
+ 2. Build and review one pnpm tarball, then publish that exact tarball per the [repository operations runbook](../docs/repository-operations.md#publishing) (attended and hand-cut). Publishing the workspace directory is not allowed because it rebuilds during `prepack` after the artifact review. **Always pass `--tag` explicitly, on every publish, prerelease or stable**: `pnpm publish "$SOFTWARE_FACTORY_TARBALL" --tag next --access public` for a prerelease, `--tag latest` for a stable cut. A filtered directory 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: it moves `latest`, which the convergence assertion can only report after the fact. The attended token path does not promise an npm provenance attestation; the source SHA and reviewed tarball SRI are its operational provenance.
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.
@@ -60,6 +60,8 @@ pnpm exec patronage-factory --help # or: psf --help
60
60
 
61
61
  The repository profile declares its admission policy. Operator-local settings and HQ credentials are documented separately, so they do not become committed project configuration.
62
62
 
63
+ Generated production workflows may call `psf production:impact --before <sha> --after <sha> --github-output <path>`. The command recomputes the profile's opt-in impact subscriptions from those exact real commits and emits stable demanded/withdrawn target outputs, the changed-file decision basis, and unsubscribed paths. It only withdraws work: missing, zero, malformed, unreachable, non-ancestral, or otherwise unclassifiable identities leave every known target demanded. The factory owns this decision seam; each consumer owns its deploy jobs, commands, credentials, topology, and post-deploy convergence/no-op proof. Merge-target Verify remains full and does not consult this command.
64
+
63
65
  For factory workflow, install the four canonical skills from the repository:
64
66
 
65
67
  ```bash
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
- active: "active";
747
746
  stale: "stale";
747
+ active: "active";
748
748
  }>;
749
749
  reason: z.ZodString;
750
750
  recordedAt: z.ZodISODateTime;
@@ -1243,8 +1243,8 @@ declare const managedReadinessLedgerSchema: z.ZodObject<{
1243
1243
  status: z.ZodEnum<{
1244
1244
  blocked: "blocked";
1245
1245
  "not-required": "not-required";
1246
- stale: "stale";
1247
1246
  current: "current";
1247
+ stale: "stale";
1248
1248
  missing: "missing";
1249
1249
  }>;
1250
1250
  }, z.core.$strip>;
@@ -1255,8 +1255,8 @@ declare const managedReadinessLedgerSchema: z.ZodObject<{
1255
1255
  status: z.ZodEnum<{
1256
1256
  blocked: "blocked";
1257
1257
  "not-required": "not-required";
1258
- stale: "stale";
1259
1258
  current: "current";
1259
+ stale: "stale";
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
- stale: "stale";
1271
1270
  passed: "passed";
1271
+ stale: "stale";
1272
1272
  missing: "missing";
1273
1273
  }>;
1274
1274
  trivialDeltaAccepted: z.ZodOptional<z.ZodBoolean>;
@@ -1517,7 +1517,7 @@ declare const impactStampSchema: z.ZodObject<{
1517
1517
  conservative: "conservative";
1518
1518
  }>;
1519
1519
  reasons: z.ZodArray<z.ZodString>;
1520
- stampVersion: z.ZodLiteral<2>;
1520
+ stampVersion: z.ZodLiteral<3>;
1521
1521
  targets: z.ZodArray<z.ZodObject<{
1522
1522
  basis: z.ZodString;
1523
1523
  impact: z.ZodEnum<{
@@ -1526,6 +1526,7 @@ declare const impactStampSchema: z.ZodObject<{
1526
1526
  }>;
1527
1527
  name: z.ZodString;
1528
1528
  }, z.core.$strip>>;
1529
+ unsubscribedPaths: z.ZodArray<z.ZodString>;
1529
1530
  }, z.core.$strip>;
1530
1531
  type ImpactStamp = z.infer<typeof impactStampSchema>;
1531
1532
  /**
@@ -1770,8 +1771,8 @@ declare const REVIEW_STATUS_VALUES: readonly ["not-required", "current", "stale"
1770
1771
  declare const reviewStatusSchema: z.ZodEnum<{
1771
1772
  blocked: "blocked";
1772
1773
  "not-required": "not-required";
1773
- stale: "stale";
1774
1774
  current: "current";
1775
+ stale: "stale";
1775
1776
  missing: "missing";
1776
1777
  }>;
1777
1778
  type ReviewStatus = z.infer<typeof reviewStatusSchema>;
@@ -2177,9 +2178,9 @@ declare const retroEnvelopeV1Schema: z.ZodObject<{
2177
2178
  kind: z.ZodLiteral<"retro-envelope">;
2178
2179
  outcome: z.ZodOptional<z.ZodObject<{
2179
2180
  status: z.ZodEnum<{
2180
- blocked: "blocked";
2181
2181
  success: "success";
2182
2182
  fail: "fail";
2183
+ blocked: "blocked";
2183
2184
  "ship-with-followups": "ship-with-followups";
2184
2185
  }>;
2185
2186
  verdict: z.ZodOptional<z.ZodString>;
@@ -3323,8 +3324,8 @@ declare const FACTORY_TRACE_EVENT_DEFINITIONS: readonly [DefinedTraceEvent<"revi
3323
3324
  interiorCycle: z.ZodOptional<z.ZodNumber>;
3324
3325
  observedAt: z.ZodString;
3325
3326
  purpose: z.ZodEnum<{
3326
- review: "review";
3327
3327
  code: "code";
3328
+ review: "review";
3328
3329
  }>;
3329
3330
  slotResolution: z.ZodObject<{
3330
3331
  effort: z.ZodEnum<{
@@ -3402,8 +3403,8 @@ declare const FACTORY_TRACE_EVENT_DEFINITIONS: readonly [DefinedTraceEvent<"revi
3402
3403
  interiorCycle: z.ZodOptional<z.ZodNumber>;
3403
3404
  observedAt: z.ZodString;
3404
3405
  purpose: z.ZodEnum<{
3405
- review: "review";
3406
3406
  code: "code";
3407
+ review: "review";
3407
3408
  }>;
3408
3409
  slotResolution: z.ZodObject<{
3409
3410
  effort: z.ZodEnum<{
@@ -3472,8 +3473,8 @@ declare const FACTORY_TRACE_EVENT_DEFINITIONS: readonly [DefinedTraceEvent<"revi
3472
3473
  interiorCycle: z.ZodOptional<z.ZodNumber>;
3473
3474
  observedAt: z.ZodString;
3474
3475
  purpose: z.ZodEnum<{
3475
- review: "review";
3476
3476
  code: "code";
3477
+ review: "review";
3477
3478
  }>;
3478
3479
  slotResolution: z.ZodObject<{
3479
3480
  effort: z.ZodEnum<{
@@ -3545,7 +3546,7 @@ declare const FACTORY_TRACE_EVENT_DEFINITIONS: readonly [DefinedTraceEvent<"revi
3545
3546
  exitCode: number | null;
3546
3547
  harness: string;
3547
3548
  observedAt: string;
3548
- purpose: "review" | "code";
3549
+ purpose: "code" | "review";
3549
3550
  slotResolution: {
3550
3551
  effort: "high" | "low" | "medium" | "xhigh";
3551
3552
  engine: string;
@@ -4066,13 +4067,13 @@ declare const evaluateReadiness: (input: EvaluationInput) => {
4066
4067
  reviews: {
4067
4068
  correctness: {
4068
4069
  required: boolean;
4069
- status: "blocked" | "not-required" | "stale" | "current" | "missing";
4070
+ status: "blocked" | "not-required" | "current" | "stale" | "missing";
4070
4071
  reviewedHeadSha?: string | undefined;
4071
4072
  reviewedPatchId?: string | undefined;
4072
4073
  };
4073
4074
  security?: {
4074
4075
  required: boolean;
4075
- status: "blocked" | "not-required" | "stale" | "current" | "missing";
4076
+ status: "blocked" | "not-required" | "current" | "stale" | "missing";
4076
4077
  reviewedHeadSha?: string | undefined;
4077
4078
  reviewedPatchId?: string | undefined;
4078
4079
  } | undefined;
@@ -4080,7 +4081,7 @@ declare const evaluateReadiness: (input: EvaluationInput) => {
4080
4081
  schemaVersion: 1;
4081
4082
  verification: {
4082
4083
  command: "patronage-factory pr:verify";
4083
- prVerify: "stale" | "passed" | "missing";
4084
+ prVerify: "passed" | "stale" | "missing";
4084
4085
  docsOnlyDeltaAccepted?: boolean | undefined;
4085
4086
  docsOnlyVerifiedHeadSha?: string | undefined;
4086
4087
  trivialDeltaAccepted?: boolean | undefined;
package/dist/index.js CHANGED
@@ -15,9 +15,9 @@ 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 { FACTORY_PROOF_GATE_STEP_NAME, GitHubApiError, mintInstallationToken, readVitestProfileDocument } from "@patronage/factory-ci";
18
+ import { FACTORY_PROOF_GATE_STEP_NAME, GitHubApiError, mintInstallationToken, productionImpactTargetOutput, readVitestProfileDocument } from "@patronage/factory-ci";
19
19
  //#region package.json
20
- var version = "1.0.0-alpha.8";
20
+ var version = "1.0.0-alpha.9";
21
21
  //#endregion
22
22
  //#region src/review-rungs.ts
23
23
  const EVIDENCE_REVIEW_RUNGS$1 = [
@@ -981,7 +981,7 @@ const requiredCheckSchema = z.object({
981
981
  const impactTargetSchema = z.object({
982
982
  importers: z.array(z.string().min(1)).min(1),
983
983
  name: z.string().min(1),
984
- paths: z.array(repoRelativeGlobSchema).min(1).optional()
984
+ paths: z.array(repoRelativeGlobSchema).min(1).optional().describe("Opt-in repo-relative path subscriptions. A target is affected only when a valid changed path matches one of its globs; valid unmatched paths affect no target and are recorded as unsubscribed. Shared files must be listed by every subscribing target.")
985
985
  }).strict();
986
986
  const impactConfigSchema = z.object({ targets: z.array(impactTargetSchema).min(1).superRefine((targets, context) => {
987
987
  const names = targets.map((target) => target.name);
@@ -3553,15 +3553,18 @@ const impactStampTargetSchema = z.object({
3553
3553
  const impactStampSchema = z.object({
3554
3554
  basis: z.enum(["target-scoped", "conservative"]),
3555
3555
  reasons: z.array(z.string()),
3556
- stampVersion: z.literal(2),
3556
+ stampVersion: z.literal(3),
3557
3557
  targets: z.array(impactStampTargetSchema).superRefine((targets, context) => {
3558
3558
  const names = targets.map((target) => target.name);
3559
3559
  if (new Set(names).size !== names.length) context.addIssue({
3560
3560
  code: "custom",
3561
3561
  message: "impact stamp targets must have distinct names."
3562
3562
  });
3563
- })
3563
+ }),
3564
+ unsubscribedPaths: z.array(z.string())
3564
3565
  });
3566
+ /** Human-readable lines for every impact decision, including quiet releases. */
3567
+ const impactStampSummaryLines = (stamp) => [`Impact stamp: ${stamp.basis}; ${stamp.targets.filter(({ impact }) => impact === "affected").length}/${stamp.targets.length} target(s) affected.`, stamp.unsubscribedPaths.length === 0 ? "Unsubscribed changed paths: none." : `Unsubscribed changed paths (demand no target): ${stamp.unsubscribedPaths.join(", ")}`];
3565
3568
  /**
3566
3569
  * A configuration contradiction between the profile's impact declarations and
3567
3570
  * the repository's actual lockfile — e.g. a declared importer root that
@@ -3611,8 +3614,8 @@ const impactStampScopeDecision = ({ stamp, surface, targetName }) => {
3611
3614
  reason: `no trusted identity-bound impact stamp covers this candidate; ${floor}`,
3612
3615
  scoped: false
3613
3616
  };
3614
- if (stamp.stampVersion !== 2) return {
3615
- reason: `impact stamp version ${String(stamp.stampVersion)} is not this build's version 2; ${floor}`,
3617
+ if (stamp.stampVersion !== 3) return {
3618
+ reason: `impact stamp version ${String(stamp.stampVersion)} is not this build's version 3; ${floor}`,
3616
3619
  scoped: false
3617
3620
  };
3618
3621
  if (stamp.basis !== "target-scoped") return {
@@ -3654,15 +3657,16 @@ const impactStampDemandRelease = ({ checkName, stamp }) => {
3654
3657
  * every doubt path lands on, and the stamp a producer must fall back to if
3655
3658
  * stamp computation itself fails for any reason.
3656
3659
  */
3657
- const conservativeImpactStamp = (targets, reasons) => ({
3660
+ const conservativeImpactStamp = (targets, reasons, unsubscribedPaths = []) => ({
3658
3661
  basis: "conservative",
3659
3662
  reasons,
3660
- stampVersion: 2,
3663
+ stampVersion: 3,
3661
3664
  targets: targets.map((target) => ({
3662
3665
  basis: CONSERVATIVE_TARGET_BASIS,
3663
3666
  impact: "affected",
3664
3667
  name: target.name
3665
- }))
3668
+ })),
3669
+ unsubscribedPaths
3666
3670
  });
3667
3671
  const conservativeStamp = conservativeImpactStamp;
3668
3672
  const DEPENDENCY_FIELDS = [
@@ -3793,9 +3797,10 @@ const targetImpactForLockfileDelta = (target, delta) => {
3793
3797
  /**
3794
3798
  * Compute the impact stamp for one candidate: every declared target recorded
3795
3799
  * as affected/not-affected with its exact basis. `not-affected` is only ever
3796
- * returned only when every changed input is owned by a declared source glob or
3797
- * is an analyzable pnpm-lock.yaml (v9) delta. Source and lockfile reachability
3798
- * are unioned per target. Any invalid, unmatched, unreadable, unsupported, or
3800
+ * returned only when every changed input is a valid source path or an
3801
+ * analyzable pnpm-lock.yaml (v9) delta. Source and lockfile reachability are
3802
+ * unioned per target. A valid source path matched by no target is recorded in
3803
+ * `unsubscribedPaths` and affects none. Invalid, unreadable, unsupported, or
3799
3804
  * otherwise doubtful input widens the whole stamp to full impact.
3800
3805
  *
3801
3806
  * Total by contract: this function never throws. Any unexpected failure in
@@ -3872,10 +3877,6 @@ const analyzeSourcePaths = (sourceFiles, targets, profilePath) => {
3872
3877
  kind: "conservative",
3873
3878
  reason: `changed source path "${file}" is the impact profile being evaluated; fail closed to full impact`
3874
3879
  };
3875
- if (!targets.some((target) => matchesAnyGlob(target.paths ?? [], file))) return {
3876
- kind: "conservative",
3877
- reason: `changed source path "${file}" has no declared impact target owner; fail closed to full impact`
3878
- };
3879
3880
  }
3880
3881
  return {
3881
3882
  kind: "ok",
@@ -3890,7 +3891,8 @@ const analyzeSourcePaths = (sourceFiles, targets, profilePath) => {
3890
3891
  impact: "affected",
3891
3892
  name: target.name
3892
3893
  };
3893
- })
3894
+ }),
3895
+ unsubscribedPaths: sourceFiles.filter((file) => !targets.some((target) => matchesAnyGlob(target.paths ?? [], file)))
3894
3896
  };
3895
3897
  };
3896
3898
  const unionTargetImpact = (source, lockfile) => {
@@ -3906,8 +3908,9 @@ const computeImpactStampOrThrow = ({ changedFiles, profilePath = "software-facto
3906
3908
  if (targets.length === 0) return {
3907
3909
  basis: "conservative",
3908
3910
  reasons: ["no impact targets declared in the profile; every surface keeps full demand"],
3909
- stampVersion: 2,
3910
- targets: []
3911
+ stampVersion: 3,
3912
+ targets: [],
3913
+ unsubscribedPaths: changedFiles.filter((file) => file !== "pnpm-lock.yaml" && isRepoRelativePath(file))
3911
3914
  };
3912
3915
  assertDeclaredPathGlobs(targets);
3913
3916
  const sides = readLockfileSides(readLockfile);
@@ -3916,18 +3919,19 @@ const computeImpactStampOrThrow = ({ changedFiles, profilePath = "software-facto
3916
3919
  const sourceFiles = changedFiles.filter((file) => file !== LOCKFILE_PATH);
3917
3920
  const source = analyzeSourcePaths(sourceFiles, targets, profilePath);
3918
3921
  if (source.kind === "conservative") return conservativeStamp(targets, [source.reason]);
3919
- if (sides.kind === "threw") throw sides.error;
3920
- if (sides.kind === "unreadable") return conservativeStamp(targets, [`pnpm-lock.yaml is unreadable at the ${sides.side} side; fail closed to full impact`]);
3921
- if (sides.kind === "unparseable") return conservativeStamp(targets, [`pnpm-lock.yaml is unparseable or structurally malformed at the ${sides.side} side; fail closed to full impact`]);
3922
- if (sides.kind === "unsupported-version") return conservativeStamp(targets, [`pnpm-lock.yaml is not lockfileVersion ${SUPPORTED_LOCKFILE_VERSION}; only pnpm v9 lockfiles are modelled; fail closed to full impact`]);
3922
+ if (sides.kind === "threw") return conservativeStamp(targets, [`impact stamp computation failed (${sides.error instanceof Error ? sides.error.message : String(sides.error)}); fail closed to full impact`], source.unsubscribedPaths);
3923
+ if (sides.kind === "unreadable") return conservativeStamp(targets, [`pnpm-lock.yaml is unreadable at the ${sides.side} side; fail closed to full impact`], source.unsubscribedPaths);
3924
+ if (sides.kind === "unparseable") return conservativeStamp(targets, [`pnpm-lock.yaml is unparseable or structurally malformed at the ${sides.side} side; fail closed to full impact`], source.unsubscribedPaths);
3925
+ if (sides.kind === "unsupported-version") return conservativeStamp(targets, [`pnpm-lock.yaml is not lockfileVersion ${SUPPORTED_LOCKFILE_VERSION}; only pnpm v9 lockfiles are modelled; fail closed to full impact`], source.unsubscribedPaths);
3923
3926
  if (!changedFiles.includes("pnpm-lock.yaml")) return {
3924
3927
  basis: "target-scoped",
3925
- reasons: ["all changed source paths have declared target ownership; impact scoped by repo-relative path globs"],
3926
- stampVersion: 2,
3927
- targets: source.targets
3928
+ reasons: [source.unsubscribedPaths.length === 0 ? "all changed source paths match at least one declared target subscription; impact scoped by repo-relative path globs" : `${source.unsubscribedPaths.length} changed source path(s) match no declared target subscription and affect no target`],
3929
+ stampVersion: 3,
3930
+ targets: source.targets,
3931
+ unsubscribedPaths: source.unsubscribedPaths
3928
3932
  };
3929
3933
  const [baseLock, headLock] = sides.parsed;
3930
- if (stableStringify$1(nonGraphSections(baseLock)) !== stableStringify$1(nonGraphSections(headLock))) return conservativeStamp(targets, ["pnpm-lock.yaml delta touches sections outside importers/packages/snapshots (e.g. settings, overrides, patchedDependencies); fail closed to full impact"]);
3934
+ if (stableStringify$1(nonGraphSections(baseLock)) !== stableStringify$1(nonGraphSections(headLock))) return conservativeStamp(targets, ["pnpm-lock.yaml delta touches sections outside importers/packages/snapshots (e.g. settings, overrides, patchedDependencies); fail closed to full impact"], source.unsubscribedPaths);
3931
3935
  const delta = {
3932
3936
  changedImporters: changedSectionKeys(baseLock.importers, headLock.importers),
3933
3937
  changedPackages: changedSectionKeys(baseLock.packages, headLock.packages),
@@ -3938,9 +3942,10 @@ const computeImpactStampOrThrow = ({ changedFiles, profilePath = "software-facto
3938
3942
  const mixed = sourceFiles.length > 0;
3939
3943
  return {
3940
3944
  basis: "target-scoped",
3941
- reasons: mixed ? ["all changed source paths have declared target ownership and pnpm-lock.yaml is analyzable; impact is the union of path ownership and importer/snapshot graph analysis"] : ["pnpm-lock.yaml is the only changed file; impact scoped by importer/snapshot graph analysis"],
3942
- stampVersion: 2,
3943
- targets: mixed ? source.targets.map((sourceTarget, index) => unionTargetImpact(sourceTarget, lockfileTargets[index])) : lockfileTargets
3945
+ reasons: mixed ? [source.unsubscribedPaths.length === 0 ? "all changed source paths match at least one declared target subscription and pnpm-lock.yaml is analyzable; impact is the union of path subscriptions and importer/snapshot graph analysis" : `${source.unsubscribedPaths.length} changed source path(s) match no declared target subscription; subscribed path impact is unioned with importer/snapshot graph analysis`] : ["pnpm-lock.yaml is the only changed file; impact scoped by importer/snapshot graph analysis"],
3946
+ stampVersion: 3,
3947
+ targets: mixed ? source.targets.map((sourceTarget, index) => unionTargetImpact(sourceTarget, lockfileTargets[index])) : lockfileTargets,
3948
+ unsubscribedPaths: source.unsubscribedPaths
3944
3949
  };
3945
3950
  };
3946
3951
  //#endregion
@@ -5543,17 +5548,21 @@ const asClassification = (value) => DIFF_CLASSIFICATIONS$1.includes(value) ? val
5543
5548
  const prVerifyCheckPayloadFor = (proof) => {
5544
5549
  const value = proof;
5545
5550
  if (!value || typeof value.headSha !== "string" || value.headSha.length === 0 || !isResolvedPrVerifyMode(value.mode)) return;
5551
+ const carriesReleases = value.notRequiredCommands !== void 0;
5546
5552
  return {
5547
5553
  classification: asClassification(value.classification),
5548
5554
  executedCommands: (value.executedCommands ?? []).map(({ name }) => name).filter((name) => typeof name === "string"),
5549
5555
  headSha: value.headSha,
5556
+ ...carriesReleases && value.impactStamp !== void 0 ? { impactStamp: value.impactStamp } : {},
5550
5557
  kind: PR_VERIFY_CHECK_PAYLOAD_KIND,
5551
5558
  mode: value.mode,
5559
+ ...carriesReleases ? { notRequiredCommands: value.notRequiredCommands } : {},
5552
5560
  outcome: outcomeFor(value),
5553
5561
  proofSchemaVersion: typeof value.schemaVersion === "number" ? value.schemaVersion : 0,
5554
5562
  schemaVersion: PR_VERIFY_CHECK_PAYLOAD_SCHEMA_VERSION,
5555
5563
  ...typeof value.patchId === "string" ? { patchId: value.patchId } : {},
5556
- ...typeof value.repository === "string" ? { repository: value.repository } : {}
5564
+ ...typeof value.repository === "string" ? { repository: value.repository } : {},
5565
+ ...carriesReleases && value.verificationCommands !== void 0 ? { verificationCommands: value.verificationCommands } : {}
5557
5566
  };
5558
5567
  };
5559
5568
  const renderPrVerifyCheckPayloadText = (payload) => `\`\`\`json\n${JSON.stringify(payload, null, 2)}\n\`\`\``;
@@ -7442,9 +7451,15 @@ const installationToken = (repository, config, request, now, timeoutMs = GITHUB_
7442
7451
  function verifyOutput(proof) {
7443
7452
  const value = proof;
7444
7453
  const commands = value.executedCommands?.length ?? 0;
7454
+ const unsubscribedBasis = value.impactStamp?.unsubscribedPaths;
7455
+ let unsubscribedSummary;
7456
+ if (Array.isArray(unsubscribedBasis)) {
7457
+ const unsubscribed = unsubscribedBasis.filter((path) => typeof path === "string");
7458
+ unsubscribedSummary = unsubscribed.length === 0 ? "Unsubscribed changed paths: none." : `Unsubscribed changed paths (demand no target): ${unsubscribed.join(", ")}.`;
7459
+ } else unsubscribedSummary = "Unsubscribed changed paths: unavailable (legacy or malformed stamp).";
7445
7460
  const payload = prVerifyCheckPayloadFor(proof);
7446
7461
  return {
7447
- summary: `Schema v${value.schemaVersion}; mode **${value.mode ?? "unknown"}**; classification **${value.classification ?? "unknown"}**; outcome **${value.outcome ?? "unknown"}**; ${commands} command(s) executed.`,
7462
+ summary: `Schema v${value.schemaVersion}; mode **${value.mode ?? "unknown"}**; classification **${value.classification ?? "unknown"}**; outcome **${value.outcome ?? "unknown"}**; ${commands} command(s) executed. ${unsubscribedSummary}`,
7448
7463
  ...payload ? { text: renderPrVerifyCheckPayloadText(payload) } : {},
7449
7464
  title: `pr:verify ${value.outcome ?? "unknown"}`
7450
7465
  };
@@ -7458,8 +7473,10 @@ function reviewOutput(proof) {
7458
7473
  for (const { disposition } of value.ladder ? ladderLedgerFor(value.ladder) : []) dispositionCounts[disposition] = (dispositionCounts[disposition] ?? 0) + 1;
7459
7474
  if (!value.ladder && findings > 0) dispositionCounts.open = findings;
7460
7475
  const dispositions = Object.entries(dispositionCounts).map(([name, count]) => `${name} ${count}`).join(", ");
7476
+ const unavailable = "unavailable (legacy proof)";
7477
+ const provenance = reviews.length === 0 ? "not required" : reviews.map(({ kind, model, producer, rung }) => `${kind ?? "unknown mode"}: rung ${rung ?? unavailable}; producer ${producer ?? unavailable}; model ${model ?? unavailable}`).join(" | ");
7461
7478
  return {
7462
- summary: `Schema v${value.schemaVersion}; modes **${modes}**; ${findings} finding(s) recorded; dispositions: ${dispositions || "none"}.`,
7479
+ summary: `Schema v${value.schemaVersion}; modes **${modes}**; ${findings} finding(s) recorded; dispositions: ${dispositions || "none"}. Review provenance: ${provenance}.`,
7463
7480
  title: findings === 0 ? "pr:review clean" : "pr:review proof recorded"
7464
7481
  };
7465
7482
  }
@@ -11632,6 +11649,7 @@ function runPrVerify(args, dependencies = {}) {
11632
11649
  if (error instanceof ImpactStampConfigError) throw error;
11633
11650
  impactStamp = conservativeImpactStamp(profile.impact?.targets ?? [], [`impact stamp computation failed (${error instanceof Error ? error.message : String(error)}); fail closed to full impact`]);
11634
11651
  }
11652
+ for (const line of impactStampSummaryLines(impactStamp)) console.log(line);
11635
11653
  const vetoedTargets = boundFailingCheckNames({
11636
11654
  candidate: {
11637
11655
  headSha,
@@ -13593,6 +13611,13 @@ const prVerifyFollowUp = (authoringSession) => followUpFromArgv([
13593
13611
  "pr:verify",
13594
13612
  ...authoringSession ? ["--authoring-session", authoringSession] : []
13595
13613
  ]);
13614
+ /** The route-owned Promotion from a GitHub draft into a Candidate. */
13615
+ const prUndraftFollowUp = (pr) => followUpFromArgv([
13616
+ "gh",
13617
+ "pr",
13618
+ "ready",
13619
+ String(pr)
13620
+ ]);
13596
13621
  /** Canonical zod schema for the optional follow-up field on JSON outputs. */
13597
13622
  const FollowUpActionSchema = z.object({
13598
13623
  argv: z.array(z.string()),
@@ -14708,15 +14733,18 @@ const collectBlockers = ({ correctnessRequired, correctnessStatus, correctnessUn
14708
14733
  command: `gh pr checks ${input.pr} --watch`
14709
14734
  } } : {}
14710
14735
  });
14711
- if (input.draft) blockers.push({
14712
- demand: DEMAND_KEYS.draft,
14713
- reason: DRAFT_BLOCKER_REASON,
14714
- repair: {
14715
- action: "Mark the PR ready for review; draft is route-owned once proof gates pass.",
14716
- code: "undraft-pr",
14717
- command: `gh pr ready ${input.pr}`
14718
- }
14719
- });
14736
+ if (input.draft) {
14737
+ const promotion = prUndraftFollowUp(input.pr);
14738
+ blockers.push({
14739
+ demand: DEMAND_KEYS.draft,
14740
+ reason: DRAFT_BLOCKER_REASON,
14741
+ repair: {
14742
+ action: "Mark the PR ready for review; draft is route-owned once proof gates pass.",
14743
+ code: "undraft-pr",
14744
+ command: promotion.command
14745
+ }
14746
+ });
14747
+ }
14720
14748
  return blockers;
14721
14749
  };
14722
14750
  const orderRepairs = (repairs) => repairs.toSorted((a, b) => READINESS_REPAIR_CODES.indexOf(a.code) - READINESS_REPAIR_CODES.indexOf(b.code));
@@ -15380,12 +15408,7 @@ async function runPrReady(args, dependencies = {}) {
15380
15408
  ]);
15381
15409
  let followUp;
15382
15410
  if (evaluation.status === "ready") followUp = arming?.outcome === "not-armed" ? readyRedispatch() : void 0;
15383
- else if (undraftRepair && evaluation.humanBlockingReasons.length === 0) followUp = followUpFromArgv([
15384
- "gh",
15385
- "pr",
15386
- "ready",
15387
- String(args.pr)
15388
- ]);
15411
+ else if (undraftRepair && evaluation.humanBlockingReasons.length === 0) followUp = prUndraftFollowUp(args.pr);
15389
15412
  else if (!pendingExternalChecksOnly) followUp = prVerifyFollowUp(authoringSession);
15390
15413
  const notices = [
15391
15414
  ...evaluation.notices,
@@ -16184,6 +16207,50 @@ const reevaluateReadiness = (args, dependencies) => (dependencies.runPrReady ??
16184
16207
  ...args,
16185
16208
  throwWhenBlocked: false
16186
16209
  }, dependencies);
16210
+ const requirePromotionHead = ({ actualHeadSha, expectedHeadSha, phase }) => {
16211
+ if (!sameHeadSha(actualHeadSha, expectedHeadSha)) throw new PrPublishTransactionAbortedError(`${phase}: live PR head ${actualHeadSha} differs from the proved publish head ${expectedHeadSha}. Re-run pr:publish against the current head.`);
16212
+ };
16213
+ /**
16214
+ * Promote a proved draft before the first Candidate demand evaluation (#649).
16215
+ * The route-owned action is shared with pr:ready; this helper only orders it.
16216
+ */
16217
+ const promoteDraftBeforeDemandEvaluation = async ({ cwd, expectedHeadSha, factoryCliInvocation, fetchPr, pr, prSnapshot, runFollowUp }) => {
16218
+ if (!prSnapshot.isDraft) return { status: "not-requested" };
16219
+ const before = fetchPr();
16220
+ requirePromotionHead({
16221
+ actualHeadSha: before.headRefOid,
16222
+ expectedHeadSha,
16223
+ phase: "Promotion refused before transition"
16224
+ });
16225
+ if (!before.isDraft) return { status: "not-requested" };
16226
+ const promotion = prUndraftFollowUp(pr);
16227
+ try {
16228
+ await (runFollowUp ?? defaultFollowUpRunner(factoryCliInvocation))(promotion, cwd);
16229
+ } catch (error) {
16230
+ throw new PrPublishTransactionAbortedError(`Promotion failed (${promotion.command}): ${errorMessage(error)}`);
16231
+ }
16232
+ const after = fetchPr();
16233
+ requirePromotionHead({
16234
+ actualHeadSha: after.headRefOid,
16235
+ expectedHeadSha,
16236
+ phase: "Promotion invalidated during transition"
16237
+ });
16238
+ if (after.isDraft) throw new PrPublishTransactionAbortedError(`Promotion did not make PR #${pr} ready for review at proved head ${expectedHeadSha}. Re-run pr:publish after GitHub settles.`);
16239
+ return {
16240
+ action: promotion,
16241
+ reason: "Promotion precedes Candidate demand evaluation on the unchanged head",
16242
+ status: "succeeded"
16243
+ };
16244
+ };
16245
+ const requirePromotedProofHead = ({ expectedHeadSha, promotion, proof }) => {
16246
+ if (promotion.status !== "succeeded") return;
16247
+ requirePromotionHead({
16248
+ actualHeadSha: proof.ledger.headSha,
16249
+ expectedHeadSha,
16250
+ phase: "Promotion invalidated before Candidate demand evaluation completed"
16251
+ });
16252
+ };
16253
+ const candidateFollowUpAfterPromotion = (promotion, proof) => promotion.status === "not-requested" ? proof.followUp : void 0;
16187
16254
  /**
16188
16255
  * A proof with no follow-up that is not `ready` means `pr:ready` found the
16189
16256
  * candidate blocked solely on a pending hosted check for the family excluded
@@ -16318,6 +16385,11 @@ async function runPrPublish(args, dependencies = {}) {
16318
16385
  reviewProofPath
16319
16386
  });
16320
16387
  const pr = fetchPr();
16388
+ requirePromotionHead({
16389
+ actualHeadSha: pr.headRefOid,
16390
+ expectedHeadSha: verifyProof.headSha,
16391
+ phase: "Publish refused before managed body update"
16392
+ });
16321
16393
  const parts = renderPrBodySectionParts({
16322
16394
  reviewProof,
16323
16395
  verifyProof: verifyProof && verifyProofPassed(verifyProof) ? verifyProof : void 0
@@ -16342,13 +16414,28 @@ async function runPrPublish(args, dependencies = {}) {
16342
16414
  json: false,
16343
16415
  report: false
16344
16416
  };
16417
+ const promotion = await promoteDraftBeforeDemandEvaluation({
16418
+ cwd,
16419
+ expectedHeadSha: verifyProof.headSha,
16420
+ factoryCliInvocation,
16421
+ fetchPr,
16422
+ pr: prNumber,
16423
+ prSnapshot: pr,
16424
+ runFollowUp: dependencies.runFollowUp
16425
+ });
16345
16426
  const initialProof = await reevaluateReadiness(readyEvalArgs, publishDependencies);
16427
+ requirePromotedProofHead({
16428
+ expectedHeadSha: verifyProof.headSha,
16429
+ promotion,
16430
+ proof: initialProof
16431
+ });
16346
16432
  const initialHandoff = summarizeHumanHandoff(initialProof);
16347
- let followUp = { status: "not-requested" };
16433
+ let followUp = promotion;
16348
16434
  let currentProof = initialProof;
16349
- if (initialProof.followUp) {
16435
+ const candidateFollowUp = candidateFollowUpAfterPromotion(promotion, initialProof);
16436
+ if (candidateFollowUp) {
16350
16437
  followUp = await settleFollowUp({
16351
- action: initialProof.followUp,
16438
+ action: candidateFollowUp,
16352
16439
  cwd,
16353
16440
  factoryCliInvocation,
16354
16441
  onFailure: (failed, error) => {
@@ -16363,13 +16450,13 @@ async function runPrPublish(args, dependencies = {}) {
16363
16450
  },
16364
16451
  plan: planPublishFollowUp({
16365
16452
  binding: verifyProofBinding,
16366
- followUp: initialProof.followUp,
16453
+ followUp: candidateFollowUp,
16367
16454
  proofHeadSha: verifyProof.headSha,
16368
16455
  verifiedHeadSha: verified.verifiedHeadSha
16369
16456
  }),
16370
16457
  runFollowUp: dependencies.runFollowUp
16371
16458
  });
16372
- if (followUp.status === "succeeded" && isSelfInvalidatingFollowUp(initialProof.followUp)) currentProof = await reevaluateReadiness(readyEvalArgs, publishDependencies);
16459
+ if (followUp.status === "succeeded" && isSelfInvalidatingFollowUp(candidateFollowUp)) currentProof = await reevaluateReadiness(readyEvalArgs, publishDependencies);
16373
16460
  }
16374
16461
  const proof = await awaitCurrentReadyVerdict({
16375
16462
  args: readyEvalArgs,
@@ -16420,7 +16507,7 @@ async function deliverSpooledEvidence({ awaitPending, cwd, flush, profilePath })
16420
16507
  //#endregion
16421
16508
  //#region src/commands/pr-publish.ts
16422
16509
  function createPrPublishCommand(_output, action = runPrPublish) {
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) => {
16510
+ return markCwdOptionDefault(new Command("pr:publish").description("Promote a proved draft, then evaluate Candidate demands; 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) => {
16424
16511
  await action({
16425
16512
  authoringSessionIds: options.authoringSession ? [options.authoringSession] : void 0,
16426
16513
  base: options.base ?? "",
@@ -16546,6 +16633,146 @@ function modeFor(options) {
16546
16633
  if (options.full) return "full";
16547
16634
  return "auto";
16548
16635
  }
16636
+ const COMMIT_SHA_PATTERN = /^[0-9a-f]{40}$/iu;
16637
+ const ZERO_SHA_PATTERN = /^0{40}$/u;
16638
+ const demandedTargets = (profile, reason) => (profile?.impact?.targets ?? []).map(({ name }) => ({
16639
+ basis: reason,
16640
+ name,
16641
+ state: "demanded"
16642
+ }));
16643
+ const refusal = ({ after, before, profile, reason }) => ({
16644
+ ...after ? { afterSha: after } : {},
16645
+ basis: {
16646
+ changedFiles: [],
16647
+ classification: "refused",
16648
+ reasons: [reason]
16649
+ },
16650
+ ...before ? { beforeSha: before } : {},
16651
+ decision: "refused",
16652
+ schemaVersion: 1,
16653
+ targets: demandedTargets(profile, reason),
16654
+ unsubscribedPaths: []
16655
+ });
16656
+ const assertCommitIdentity = (value, name) => {
16657
+ if (value === void 0 || !COMMIT_SHA_PATTERN.test(value) || ZERO_SHA_PATTERN.test(value)) throw new Error(`${name} must be a non-zero 40-character commit SHA`);
16658
+ return value.toLowerCase();
16659
+ };
16660
+ const assertReachableCommit = (cwd, sha, name) => {
16661
+ try {
16662
+ runCapture("git", [
16663
+ "cat-file",
16664
+ "-e",
16665
+ `${sha}^{commit}`
16666
+ ], cwd);
16667
+ } catch {
16668
+ throw new Error(`${name} commit ${sha} is not reachable in this checkout`);
16669
+ }
16670
+ };
16671
+ const writeGithubOutputs = (outputPath, decision) => {
16672
+ appendFileSync(outputPath, `${[
16673
+ `decision=${decision.decision}`,
16674
+ `basis=${JSON.stringify(decision.basis)}`,
16675
+ `unsubscribed_paths=${JSON.stringify(decision.unsubscribedPaths)}`,
16676
+ ...decision.targets.map((target) => `${productionImpactTargetOutput(target.name)}=${target.state}`)
16677
+ ].join("\n")}\n`, "utf-8");
16678
+ };
16679
+ /**
16680
+ * Recompute impact from one merge push's exact before/after commits. The
16681
+ * result may withdraw consumer work only; every doubt path returns a refused
16682
+ * decision whose known targets remain demanded.
16683
+ */
16684
+ const runProductionImpact = (args) => {
16685
+ const cwd = path.resolve(args.cwd ?? process.cwd());
16686
+ let profile;
16687
+ let profilePath;
16688
+ let decision;
16689
+ try {
16690
+ const { path: loadedPath, profile: loadedProfile } = loadProjectProfile({
16691
+ cwd,
16692
+ profilePath: args.profilePath
16693
+ });
16694
+ profile = loadedProfile;
16695
+ profilePath = loadedPath;
16696
+ const before = assertCommitIdentity(args.before, "before");
16697
+ const after = assertCommitIdentity(args.after, "after");
16698
+ assertReachableCommit(cwd, before, "before");
16699
+ assertReachableCommit(cwd, after, "after");
16700
+ const checkedOutHead = runCapture("git", ["rev-parse", "HEAD"], cwd).stdout.trim().toLowerCase();
16701
+ if (checkedOutHead !== after) throw new Error(`after commit ${after} is not the checked-out HEAD ${checkedOutHead}; refusing to mix commit identities with declarations from another tree`);
16702
+ try {
16703
+ runCapture("git", [
16704
+ "merge-base",
16705
+ "--is-ancestor",
16706
+ before,
16707
+ after
16708
+ ], cwd);
16709
+ } catch {
16710
+ throw new Error(`before commit ${before} is not an ancestor of after commit ${after}`);
16711
+ }
16712
+ const changedFiles = [...new Set(filesFromNameStatus(runCapture("git", [
16713
+ "diff",
16714
+ "--find-renames",
16715
+ "--name-status",
16716
+ before,
16717
+ after
16718
+ ], cwd).stdout))].toSorted();
16719
+ const stamp = computeImpactStamp({
16720
+ changedFiles,
16721
+ profile,
16722
+ profilePath: path.relative(cwd, profilePath),
16723
+ readLockfile: (side) => showFileAtRef(cwd, side === "base" ? before : after, path.join(cwd, LOCKFILE_PATH))
16724
+ });
16725
+ const usable = stamp.basis === "target-scoped";
16726
+ decision = {
16727
+ afterSha: after,
16728
+ basis: {
16729
+ changedFiles,
16730
+ classification: stamp.basis,
16731
+ reasons: stamp.reasons
16732
+ },
16733
+ beforeSha: before,
16734
+ decision: usable ? "usable" : "refused",
16735
+ schemaVersion: 1,
16736
+ targets: stamp.targets.map((target) => ({
16737
+ basis: target.basis,
16738
+ name: target.name,
16739
+ state: usable && target.impact === "not-affected" ? "withdrawn" : "demanded"
16740
+ })),
16741
+ unsubscribedPaths: stamp.unsubscribedPaths
16742
+ };
16743
+ } catch (error) {
16744
+ decision = refusal({
16745
+ after: args.after,
16746
+ before: args.before,
16747
+ profile,
16748
+ reason: `production impact classification refused: ${error instanceof Error ? error.message : String(error)}`
16749
+ });
16750
+ }
16751
+ if (args.githubOutput) writeGithubOutputs(path.resolve(cwd, args.githubOutput), decision);
16752
+ if (args.githubSummary) appendFileSync(path.resolve(cwd, args.githubSummary), `### Production impact\n\n${productionImpactSummaryLines(decision).join("\n")}\n`, "utf-8");
16753
+ return decision;
16754
+ };
16755
+ const productionImpactSummaryLines = (decision) => decision.basis.classification === "refused" ? [
16756
+ "Production impact: refused; all consumer work remains demanded.",
16757
+ ...decision.basis.reasons,
16758
+ "Unsubscribed changed paths: unavailable because classification was refused."
16759
+ ] : [`Production impact: ${decision.basis.classification}; ${decision.targets.filter(({ state }) => state === "demanded").length}/${decision.targets.length} target(s) demanded.`, decision.unsubscribedPaths.length === 0 ? "Unsubscribed changed paths: none." : `Unsubscribed changed paths (demand no target): ${decision.unsubscribedPaths.join(", ")}`];
16760
+ //#endregion
16761
+ //#region src/commands/production-impact.ts
16762
+ function createProductionImpactCommand(output) {
16763
+ return markCwdOptionDefault(new Command("production:impact").description("Classify production targets from an exact merge-push before/after diff").option("--before <sha>", "merge-push before commit SHA").option("--after <sha>", "merge-push after commit SHA").option("--cwd <path>", "repository working directory", collectCwdOption).option("--profile <path>", "path to the project profile JSON file").option("--github-output <path>", "append stable decision outputs to a GitHub Actions output file").option("--github-summary <path>", "append a human-readable decision to a GitHub Actions summary file").option("--json", "print the complete decision as JSON").action((options) => {
16764
+ const decision = runProductionImpact({
16765
+ after: options.after,
16766
+ before: options.before,
16767
+ cwd: resolveCwdOption(options.cwd),
16768
+ githubOutput: options.githubOutput,
16769
+ githubSummary: options.githubSummary,
16770
+ profilePath: options.profile
16771
+ });
16772
+ if (options.json) output.stdout.write(`${JSON.stringify(decision, null, 2)}\n`);
16773
+ else output.stdout.write(`${productionImpactSummaryLines(decision).join("\n")}\n`);
16774
+ }));
16775
+ }
16549
16776
  //#endregion
16550
16777
  //#region src/worktree-scratch-files.ts
16551
16778
  var worktree_scratch_files_exports = /* @__PURE__ */ __exportAll({
@@ -16837,6 +17064,7 @@ function createProgram(options = {}) {
16837
17064
  program.addCommand(createHqFlushCommand(output));
16838
17065
  program.addCommand(createBoundaryCheckCommand(output, options.actions?.boundaryCheck));
16839
17066
  program.addCommand(createPrVerifyCommand(output));
17067
+ program.addCommand(createProductionImpactCommand(output));
16840
17068
  program.addCommand(createCloseoutCommand(output));
16841
17069
  program.addCommand(createEpicPublishStructureCommand(output));
16842
17070
  program.addCommand(createPrReviewCommand(output, options.actions?.prReview));
package/dist/schemas.d.ts CHANGED
@@ -497,7 +497,7 @@ declare const impactStampSchema: z.ZodObject<{
497
497
  conservative: "conservative";
498
498
  }>;
499
499
  reasons: z.ZodArray<z.ZodString>;
500
- stampVersion: z.ZodLiteral<2>;
500
+ stampVersion: z.ZodLiteral<3>;
501
501
  targets: z.ZodArray<z.ZodObject<{
502
502
  basis: z.ZodString;
503
503
  impact: z.ZodEnum<{
@@ -506,6 +506,7 @@ declare const impactStampSchema: z.ZodObject<{
506
506
  }>;
507
507
  name: z.ZodString;
508
508
  }, z.core.$strip>>;
509
+ unsubscribedPaths: z.ZodArray<z.ZodString>;
509
510
  }, z.core.$strip>;
510
511
  type ImpactStamp = z.infer<typeof impactStampSchema>;
511
512
  //#endregion
@@ -1366,7 +1367,7 @@ declare const prVerifyProofSchema: z.ZodObject<{
1366
1367
  conservative: "conservative";
1367
1368
  }>;
1368
1369
  reasons: z.ZodArray<z.ZodString>;
1369
- stampVersion: z.ZodLiteral<2>;
1370
+ stampVersion: z.ZodLiteral<3>;
1370
1371
  targets: z.ZodArray<z.ZodObject<{
1371
1372
  basis: z.ZodString;
1372
1373
  impact: z.ZodEnum<{
@@ -1375,6 +1376,7 @@ declare const prVerifyProofSchema: z.ZodObject<{
1375
1376
  }>;
1376
1377
  name: z.ZodString;
1377
1378
  }, z.core.$strip>>;
1379
+ unsubscribedPaths: z.ZodArray<z.ZodString>;
1378
1380
  }, z.core.$strip>>;
1379
1381
  mergeBaseSha: z.ZodOptional<z.ZodString>;
1380
1382
  mode: z.ZodEnum<{
package/dist/schemas.js CHANGED
@@ -242,14 +242,15 @@ const impactStampTargetSchema = z.object({
242
242
  const impactStampSchema = z.object({
243
243
  basis: z.enum(["target-scoped", "conservative"]),
244
244
  reasons: z.array(z.string()),
245
- stampVersion: z.literal(2),
245
+ stampVersion: z.literal(3),
246
246
  targets: z.array(impactStampTargetSchema).superRefine((targets, context) => {
247
247
  const names = targets.map((target) => target.name);
248
248
  if (new Set(names).size !== names.length) context.addIssue({
249
249
  code: "custom",
250
250
  message: "impact stamp targets must have distinct names."
251
251
  });
252
- })
252
+ }),
253
+ unsubscribedPaths: z.array(z.string())
253
254
  });
254
255
  /**
255
256
  * The surfaces that consume the stamp. One classification, consumed
@@ -287,8 +288,8 @@ const impactStampScopeDecision = ({ stamp, surface, targetName }) => {
287
288
  reason: `no trusted identity-bound impact stamp covers this candidate; ${floor}`,
288
289
  scoped: false
289
290
  };
290
- if (stamp.stampVersion !== 2) return {
291
- reason: `impact stamp version ${String(stamp.stampVersion)} is not this build's version 2; ${floor}`,
291
+ if (stamp.stampVersion !== 3) return {
292
+ reason: `impact stamp version ${String(stamp.stampVersion)} is not this build's version 3; ${floor}`,
292
293
  scoped: false
293
294
  };
294
295
  if (stamp.basis !== "target-scoped") return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patronage/software-factory",
3
- "version": "1.0.0-alpha.8",
3
+ "version": "1.0.0-alpha.9",
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.8"
40
+ "@patronage/factory-ci": "1.0.0-alpha.9"
41
41
  },
42
42
  "devDependencies": {
43
43
  "@types/node": "24.13.3",