@patronage/software-factory 1.0.0-alpha.0 → 1.0.0-alpha.2

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
@@ -4,7 +4,7 @@
4
4
 
5
5
  The factory admits a pull request from evidence bound to its candidate. It validates policy and proof; it does not schedule workers, choose models, or run reviews. Those are jobs for the harness or operator.
6
6
 
7
- Admission is fail closed. A clean-session review and required verification produce typed proof, then readiness decides whether the candidate may proceed. Evidence stays current when the candidate patch is unchanged; the push-triggered Verify workflow covers integration risk on every factory merge target and freezes further merges into a target when that target goes red. Epic policy, including review minimums and auto-merge authority, lives in the boundary manifest. HQ observes this work; it never blocks a gate.
7
+ Admission is fail closed. A clean-session review and required verification produce typed proof, then readiness decides whether the candidate may proceed. Evidence stays current when the candidate patch is unchanged; the push-triggered Verify workflow covers integration risk on every factory merge target — `main` and the `epic/**` integration branches. When a target goes red, the factory refuses new readiness decisions and authorized arming against that target; candidates armed before the target went red can still land. Epic policy, including review minimums and auto-merge authority, lives in the boundary manifest. HQ observes this work; it never blocks a gate.
8
8
 
9
9
  ## Develop the package
10
10
 
@@ -30,18 +30,24 @@ This is the release checklist. It lives here and nowhere else; the repository ru
30
30
 
31
31
  Releases are attended and hand-cut. Nothing in the factory publishes, tags, or releases on its own, and no step below may be automated as a drive-by.
32
32
 
33
+ The factory family has one version posture (ADR 0026, `docs/adr/0026-factory-family-version-lines-move-in-lockstep.md` in the source repository): `@patronage/factory-ci` moves in lockstep majors with this CLI. From factory-ci's first release in the CLI's current series onward, majors and prerelease series move together while patch and prerelease increments stay independent per package. A consumer pins a factory-ci from the same major/prerelease series as its CLI; there is no compatibility matrix. Until that first lockstep release, factory-ci's published 0.x line is the named pre-adoption state and pairs with any CLI version. `@patronage/alchemy-d1-state` keeps independent SemVer. Lockstep is a rule the maintainer follows when cutting releases, never automation.
34
+
35
+ A stacked release train has one terminal release-owning slice. Interior slices do not bump `package.json` or cut a checkpoint unless the boundary explicitly intends a separately published release at that slice: a lower-slice bump consumes the release window before the terminal slice lands, and the train must then advance the version and re-earn its terminal evidence.
36
+
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
+
33
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.
34
40
  2. Publish to npm per the [repository operations runbook](../docs/repository-operations.md#publishing) (attended, hand-cut, `pnpm publish` only). A stable version uses `pnpm --filter @patronage/software-factory publish`, which assigns npm `latest`. A prerelease must use `pnpm --filter @patronage/software-factory publish --tag next`; bare `pnpm publish` defaults to `latest` and would move it before the post-hoc assertion can reject the release.
35
41
  3. Cut the `software-factory-vX.Y.Z` tag and push it.
36
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.
37
- 5. Update affected `software-factory-docs/` pages. 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.
38
- 6. Confirm the five facts converge:
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.
44
+ 6. After the docs PR merges, confirm the six facts converge from a clean checkout of `main`:
39
45
 
40
46
  ```bash
41
47
  pnpm --filter @patronage/software-factory exec tsx scripts/assert-publish-manifest.ts --release .
42
48
  ```
43
49
 
44
- It reads what you just did and derives the channel from the manifest version. A stable release must converge exactly among manifest, npm `latest`, non-prerelease GitHub release, tag, and stable docs. A prerelease must exist on npm, own npm `next`, leave npm `latest` at the stable docs version, and have its prerelease GitHub release and tag. It publishes nothing. Step 6 is the only step a machine performs, and it performs it after you.
50
+ It reads what you just did and derives the channel from the manifest version. A stable release must converge exactly among manifest, npm `latest`, non-prerelease GitHub release, tag, and stable docs. A prerelease must exist on npm, own npm `next`, leave npm `latest` at the stable docs version, and have its prerelease GitHub release and tag. The sixth fact is the factory-ci pairing above: the assertion passes while every observed published `@patronage/factory-ci` version is pre-adoption 0.x and names that state, fails once a lockstep factory-ci release exists whose newest published version is outside the CLI's major/prerelease series, and fails when the registry read yields no readable factory-ci versions or contains an entry that does not parse — missing or unreadable data is an unsatisfied fact, not pre-adoption. It publishes nothing. Step 6 is the only step a machine performs, and it performs it after you.
45
51
 
46
52
  ## Use it in a project
47
53
 
package/dist/index.d.ts CHANGED
@@ -537,6 +537,7 @@ declare const factoryProjectProfileSchema: z.ZodObject<{
537
537
  targets: z.ZodArray<z.ZodObject<{
538
538
  importers: z.ZodArray<z.ZodString>;
539
539
  name: z.ZodString;
540
+ paths: z.ZodOptional<z.ZodArray<z.ZodString>>;
540
541
  }, z.core.$strict>>;
541
542
  }, z.core.$strict>>;
542
543
  proof: z.ZodObject<{
@@ -752,8 +753,8 @@ declare const mergeFreezeStateSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
752
753
  generationId: z.ZodNumber;
753
754
  headSha: z.ZodString;
754
755
  outcome: z.ZodEnum<{
755
- active: "active";
756
756
  stale: "stale";
757
+ active: "active";
757
758
  }>;
758
759
  reason: z.ZodString;
759
760
  recordedAt: z.ZodISODateTime;
@@ -1233,8 +1234,8 @@ declare const managedReadinessLedgerSchema: z.ZodObject<{
1233
1234
  "run-interior-cycle": "run-interior-cycle";
1234
1235
  }>;
1235
1236
  stage: z.ZodEnum<{
1236
- gate: "gate";
1237
1237
  interior: "interior";
1238
+ gate: "gate";
1238
1239
  "interior-complete": "interior-complete";
1239
1240
  }>;
1240
1241
  }, z.core.$strip>>;
@@ -1250,10 +1251,10 @@ declare const managedReadinessLedgerSchema: z.ZodObject<{
1250
1251
  reviewedHeadSha: z.ZodOptional<z.ZodString>;
1251
1252
  reviewedPatchId: z.ZodOptional<z.ZodString>;
1252
1253
  status: z.ZodEnum<{
1253
- blocked: "blocked";
1254
1254
  "not-required": "not-required";
1255
- stale: "stale";
1255
+ blocked: "blocked";
1256
1256
  current: "current";
1257
+ stale: "stale";
1257
1258
  missing: "missing";
1258
1259
  }>;
1259
1260
  }, z.core.$strip>;
@@ -1262,10 +1263,10 @@ declare const managedReadinessLedgerSchema: z.ZodObject<{
1262
1263
  reviewedHeadSha: z.ZodOptional<z.ZodString>;
1263
1264
  reviewedPatchId: z.ZodOptional<z.ZodString>;
1264
1265
  status: z.ZodEnum<{
1265
- blocked: "blocked";
1266
1266
  "not-required": "not-required";
1267
- stale: "stale";
1267
+ blocked: "blocked";
1268
1268
  current: "current";
1269
+ stale: "stale";
1269
1270
  missing: "missing";
1270
1271
  }>;
1271
1272
  }, z.core.$strip>>;
@@ -1276,8 +1277,8 @@ declare const managedReadinessLedgerSchema: z.ZodObject<{
1276
1277
  docsOnlyDeltaAccepted: z.ZodOptional<z.ZodBoolean>;
1277
1278
  docsOnlyVerifiedHeadSha: z.ZodOptional<z.ZodString>;
1278
1279
  prVerify: z.ZodEnum<{
1279
- stale: "stale";
1280
1280
  passed: "passed";
1281
+ stale: "stale";
1281
1282
  missing: "missing";
1282
1283
  }>;
1283
1284
  trivialDeltaAccepted: z.ZodOptional<z.ZodBoolean>;
@@ -1522,11 +1523,11 @@ declare function runPrReady(args: PrReadyArgs, dependencies?: PrReadyDependencie
1522
1523
  */
1523
1524
  declare const impactStampSchema: z.ZodObject<{
1524
1525
  basis: z.ZodEnum<{
1525
- "lockfile-scoped": "lockfile-scoped";
1526
+ "target-scoped": "target-scoped";
1526
1527
  conservative: "conservative";
1527
1528
  }>;
1528
1529
  reasons: z.ZodArray<z.ZodString>;
1529
- stampVersion: z.ZodLiteral<1>;
1530
+ stampVersion: z.ZodLiteral<2>;
1530
1531
  targets: z.ZodArray<z.ZodObject<{
1531
1532
  basis: z.ZodString;
1532
1533
  impact: z.ZodEnum<{
@@ -1560,7 +1561,7 @@ interface ImpactScopeDecision {
1560
1561
  * external proof demand (#542 wave 2), a verification-battery command, or a
1561
1562
  * preview (Alchemy) lifecycle stack (#430 wave 3).
1562
1563
  *
1563
- * The only release path is a `lockfile-scoped` stamp of exactly this build's
1564
+ * The only release path is a `target-scoped` stamp of exactly this build's
1564
1565
  * stamp version whose target entry for exactly this name is provably
1565
1566
  * `not-affected`. Every other input — no trusted stamp, an unknown stamp
1566
1567
  * version, a conservative stamp, a name the stamp does not classify, or an
@@ -1777,10 +1778,10 @@ declare const loadEvidenceEnvelopes: (cwd: string) => LoadedEvidenceEnvelope[];
1777
1778
  //#region src/review-proof-applicability.d.ts
1778
1779
  declare const REVIEW_STATUS_VALUES: readonly ["not-required", "current", "stale", "missing", "blocked"];
1779
1780
  declare const reviewStatusSchema: z.ZodEnum<{
1780
- blocked: "blocked";
1781
1781
  "not-required": "not-required";
1782
- stale: "stale";
1782
+ blocked: "blocked";
1783
1783
  current: "current";
1784
+ stale: "stale";
1784
1785
  missing: "missing";
1785
1786
  }>;
1786
1787
  type ReviewStatus = z.infer<typeof reviewStatusSchema>;
@@ -2186,9 +2187,9 @@ declare const retroEnvelopeV1Schema: z.ZodObject<{
2186
2187
  kind: z.ZodLiteral<"retro-envelope">;
2187
2188
  outcome: z.ZodOptional<z.ZodObject<{
2188
2189
  status: z.ZodEnum<{
2189
- blocked: "blocked";
2190
2190
  success: "success";
2191
2191
  fail: "fail";
2192
+ blocked: "blocked";
2192
2193
  "ship-with-followups": "ship-with-followups";
2193
2194
  }>;
2194
2195
  verdict: z.ZodOptional<z.ZodString>;
@@ -2948,8 +2949,8 @@ declare const FACTORY_TRACE_EVENT_DEFINITIONS: readonly [DefinedTraceEvent<"revi
2948
2949
  sessionId: z.ZodOptional<z.ZodString>;
2949
2950
  }, z.core.$strict>>;
2950
2951
  stage: z.ZodEnum<{
2951
- gate: "gate";
2952
2952
  interior: "interior";
2953
+ gate: "gate";
2953
2954
  }>;
2954
2955
  usage: z.ZodOptional<z.ZodObject<{
2955
2956
  estimatedCostUsd: z.ZodOptional<z.ZodNumber>;
@@ -3001,8 +3002,8 @@ declare const FACTORY_TRACE_EVENT_DEFINITIONS: readonly [DefinedTraceEvent<"revi
3001
3002
  sessionId: z.ZodOptional<z.ZodString>;
3002
3003
  }, z.core.$strict>>;
3003
3004
  stage: z.ZodEnum<{
3004
- gate: "gate";
3005
3005
  interior: "interior";
3006
+ gate: "gate";
3006
3007
  }>;
3007
3008
  usage: z.ZodOptional<z.ZodObject<{
3008
3009
  estimatedCostUsd: z.ZodOptional<z.ZodNumber>;
@@ -3045,8 +3046,8 @@ declare const FACTORY_TRACE_EVENT_DEFINITIONS: readonly [DefinedTraceEvent<"revi
3045
3046
  sessionId: z.ZodOptional<z.ZodString>;
3046
3047
  }, z.core.$strict>>;
3047
3048
  stage: z.ZodEnum<{
3048
- gate: "gate";
3049
3049
  interior: "interior";
3050
+ gate: "gate";
3050
3051
  }>;
3051
3052
  usage: z.ZodOptional<z.ZodObject<{
3052
3053
  estimatedCostUsd: z.ZodOptional<z.ZodNumber>;
@@ -3076,7 +3077,7 @@ declare const FACTORY_TRACE_EVENT_DEFINITIONS: readonly [DefinedTraceEvent<"revi
3076
3077
  staleRepeats: number;
3077
3078
  };
3078
3079
  observedAt: string;
3079
- stage: "gate" | "interior";
3080
+ stage: "interior" | "gate";
3080
3081
  issue?: number | undefined;
3081
3082
  pr?: number | undefined;
3082
3083
  threadId?: string | undefined;
@@ -4075,13 +4076,13 @@ declare const evaluateReadiness: (input: EvaluationInput) => {
4075
4076
  reviews: {
4076
4077
  correctness: {
4077
4078
  required: boolean;
4078
- status: "blocked" | "not-required" | "stale" | "current" | "missing";
4079
+ status: "not-required" | "blocked" | "current" | "stale" | "missing";
4079
4080
  reviewedHeadSha?: string | undefined;
4080
4081
  reviewedPatchId?: string | undefined;
4081
4082
  };
4082
4083
  security?: {
4083
4084
  required: boolean;
4084
- status: "blocked" | "not-required" | "stale" | "current" | "missing";
4085
+ status: "not-required" | "blocked" | "current" | "stale" | "missing";
4085
4086
  reviewedHeadSha?: string | undefined;
4086
4087
  reviewedPatchId?: string | undefined;
4087
4088
  } | undefined;
@@ -4089,7 +4090,7 @@ declare const evaluateReadiness: (input: EvaluationInput) => {
4089
4090
  schemaVersion: 1;
4090
4091
  verification: {
4091
4092
  command: "patronage-factory pr:verify";
4092
- prVerify: "stale" | "passed" | "missing";
4093
+ prVerify: "passed" | "stale" | "missing";
4093
4094
  docsOnlyDeltaAccepted?: boolean | undefined;
4094
4095
  docsOnlyVerifiedHeadSha?: string | undefined;
4095
4096
  trivialDeltaAccepted?: boolean | undefined;
@@ -4132,7 +4133,7 @@ declare const evaluateReadiness: (input: EvaluationInput) => {
4132
4133
  interior: number;
4133
4134
  };
4134
4135
  nextAction: "run-gate-cycle" | "accept-nonblocking-findings" | "escalate-to-triage" | "ready-for-human" | "advance-to-gate" | "run-interior-cycle";
4135
- stage: "gate" | "interior" | "interior-complete";
4136
+ stage: "interior" | "gate" | "interior-complete";
4136
4137
  forcedTransition?: "gate-cap-exhausted" | "interior-cap-reached" | undefined;
4137
4138
  } | undefined;
4138
4139
  reviewRuns?: PrReviewResult[] | undefined;
package/dist/index.js CHANGED
@@ -17,7 +17,7 @@ import picomatch from "picomatch";
17
17
  import { parse } from "yaml";
18
18
  import { promisify } from "node:util";
19
19
  //#region package.json
20
- var version = "1.0.0-alpha.0";
20
+ var version = "1.0.0-alpha.2";
21
21
  //#endregion
22
22
  //#region src/review-rungs.ts
23
23
  const EVIDENCE_REVIEW_RUNGS$1 = [
@@ -894,8 +894,15 @@ function isValidGlob(pattern) {
894
894
  return false;
895
895
  }
896
896
  }
897
+ function isRepoRelativePath(value) {
898
+ const segments = value.split("/");
899
+ return value.length > 0 && !value.startsWith("/") && !/^[A-Za-z]:\//u.test(value) && !value.includes("\\") && !segments.some((segment) => segment === "" || segment === "." || segment === "..");
900
+ }
901
+ function isValidRepoRelativeGlob(pattern) {
902
+ return isRepoRelativePath(pattern) && !(pattern.startsWith("!") && !pattern.startsWith("!(")) && isValidGlob(pattern);
903
+ }
897
904
  function matchesAnyGlob(patterns, file) {
898
- return patterns.length > 0 && picomatch.isMatch(file, patterns);
905
+ return patterns.length > 0 && picomatch.isMatch(file, [...patterns]);
899
906
  }
900
907
  //#endregion
901
908
  //#region src/diff-classification.ts
@@ -939,8 +946,9 @@ const LEGACY_PROFILE_SCHEMA_VERSIONS = [
939
946
  3,
940
947
  4
941
948
  ];
942
- const PROFILE_JSON_SCHEMA_DESCRIPTION = "Editor validation covers the profile input shape and straightforward cross-field constraints. Zod remains the load-time enforcement authority: verification command names must be distinct and requiredChecks entries must have distinct names; these invariants are enforced at load time, not by this schema.";
949
+ const PROFILE_JSON_SCHEMA_DESCRIPTION = "Editor validation covers the profile input shape and straightforward cross-field constraints. Zod remains the load-time enforcement authority: verification command names must be distinct and requiredChecks entries must have distinct names; these invariants are enforced at load time, not by this schema. Impact target path patterns encode repo-relative safety in JSON Schema, while complete picomatch syntax validity remains a runtime-only refinement.";
943
950
  const globSchema = z.string().min(1).refine(isValidGlob, "must be a valid glob");
951
+ const repoRelativeGlobSchema = z.string().min(1).regex(/^(?!(?:\/[\s\S]*|[A-Za-z]:\/[\s\S]*|!(?!\()[\s\S]*|[\s\S]*\\[\s\S]*|[\s\S]*\/\/[\s\S]*|[\s\S]*\/$|\.{1,2}(?:\/|$)[\s\S]*|[\s\S]*\/\.{1,2}(?:\/|$)[\s\S]*)).+$/u, "must be a repo-relative positive glob").refine(isValidRepoRelativeGlob, "must be a valid repo-relative glob");
944
952
  const commandSchema = z.object({
945
953
  command: z.string().min(1),
946
954
  description: z.string().min(1),
@@ -972,7 +980,8 @@ const requiredCheckSchema = z.object({
972
980
  }).strict();
973
981
  const impactTargetSchema = z.object({
974
982
  importers: z.array(z.string().min(1)).min(1),
975
- name: z.string().min(1)
983
+ name: z.string().min(1),
984
+ paths: z.array(repoRelativeGlobSchema).min(1).optional()
976
985
  }).strict();
977
986
  const impactConfigSchema = z.object({ targets: z.array(impactTargetSchema).min(1).superRefine((targets, context) => {
978
987
  const names = targets.map((target) => target.name);
@@ -3845,9 +3854,9 @@ const impactStampTargetSchema = z.object({
3845
3854
  * input widened every target to full impact.
3846
3855
  */
3847
3856
  const impactStampSchema = z.object({
3848
- basis: z.enum(["lockfile-scoped", "conservative"]),
3857
+ basis: z.enum(["target-scoped", "conservative"]),
3849
3858
  reasons: z.array(z.string()),
3850
- stampVersion: z.literal(1),
3859
+ stampVersion: z.literal(2),
3851
3860
  targets: z.array(impactStampTargetSchema).superRefine((targets, context) => {
3852
3861
  const names = targets.map((target) => target.name);
3853
3862
  if (new Set(names).size !== names.length) context.addIssue({
@@ -3886,7 +3895,7 @@ const IMPACT_SCOPE_SURFACES = {
3886
3895
  * external proof demand (#542 wave 2), a verification-battery command, or a
3887
3896
  * preview (Alchemy) lifecycle stack (#430 wave 3).
3888
3897
  *
3889
- * The only release path is a `lockfile-scoped` stamp of exactly this build's
3898
+ * The only release path is a `target-scoped` stamp of exactly this build's
3890
3899
  * stamp version whose target entry for exactly this name is provably
3891
3900
  * `not-affected`. Every other input — no trusted stamp, an unknown stamp
3892
3901
  * version, a conservative stamp, a name the stamp does not classify, or an
@@ -3905,11 +3914,11 @@ const impactStampScopeDecision = ({ stamp, surface, targetName }) => {
3905
3914
  reason: `no trusted identity-bound impact stamp covers this candidate; ${floor}`,
3906
3915
  scoped: false
3907
3916
  };
3908
- if (stamp.stampVersion !== 1) return {
3909
- reason: `impact stamp version ${String(stamp.stampVersion)} is not this build's version 1; ${floor}`,
3917
+ if (stamp.stampVersion !== 2) return {
3918
+ reason: `impact stamp version ${String(stamp.stampVersion)} is not this build's version 2; ${floor}`,
3910
3919
  scoped: false
3911
3920
  };
3912
- if (stamp.basis !== "lockfile-scoped") return {
3921
+ if (stamp.basis !== "target-scoped") return {
3913
3922
  reason: `impact stamp is conservative (full impact assumed); ${floor}`,
3914
3923
  scoped: false
3915
3924
  };
@@ -3951,7 +3960,7 @@ const impactStampDemandRelease = ({ checkName, stamp }) => {
3951
3960
  const conservativeImpactStamp = (targets, reasons) => ({
3952
3961
  basis: "conservative",
3953
3962
  reasons,
3954
- stampVersion: 1,
3963
+ stampVersion: 2,
3955
3964
  targets: targets.map((target) => ({
3956
3965
  basis: CONSERVATIVE_TARGET_BASIS,
3957
3966
  impact: "affected",
@@ -4087,11 +4096,10 @@ const targetImpactForLockfileDelta = (target, delta) => {
4087
4096
  /**
4088
4097
  * Compute the impact stamp for one candidate: every declared target recorded
4089
4098
  * as affected/not-affected with its exact basis. `not-affected` is only ever
4090
- * returned for a provably-scoped pnpm-lock.yaml (v9) delta; every other input
4091
- * non-lockfile changed files, an empty diff, a missing, unparseable, or
4092
- * structurally malformed lockfile side, an unsupported lockfile version, or a
4093
- * delta outside the importers/packages/snapshots graph sections widens to
4094
- * full impact.
4099
+ * returned only when every changed input is owned by a declared source glob or
4100
+ * is an analyzable pnpm-lock.yaml (v9) delta. Source and lockfile reachability
4101
+ * are unioned per target. Any invalid, unmatched, unreadable, unsupported, or
4102
+ * otherwise doubtful input widens the whole stamp to full impact.
4095
4103
  *
4096
4104
  * Total by contract: this function never throws. Any unexpected failure in
4097
4105
  * reading, parsing, or traversal is itself a doubt path and returns the
@@ -4154,22 +4162,73 @@ const readLockfileSides = (readLockfile) => {
4154
4162
  const assertDeclaredImporterRoots = (targets, sides) => {
4155
4163
  for (const target of targets) for (const root of target.importers) if (!sides.some((lockfile) => Object.keys(lockfile.importers).some((importerPath) => importerMatchesTarget(importerPath, [root])))) throw new ImpactStampConfigError(`impact target "${target.name}" declares importer root "${root}", but no importer under that root exists in pnpm-lock.yaml on either side of the delta; fix the profile's impact.targets declaration`);
4156
4164
  };
4157
- const computeImpactStampOrThrow = ({ changedFiles, readLockfile }, targets) => {
4165
+ const assertDeclaredPathGlobs = (targets) => {
4166
+ for (const target of targets) for (const pattern of target.paths ?? []) if (!isValidRepoRelativeGlob(pattern)) throw new ImpactStampConfigError(`impact target "${target.name}" declares invalid repo-relative path glob "${pattern}"; fix the profile's impact.targets declaration`);
4167
+ };
4168
+ const analyzeSourcePaths = (sourceFiles, targets, profilePath) => {
4169
+ for (const file of sourceFiles) {
4170
+ if (!isRepoRelativePath(file)) return {
4171
+ kind: "conservative",
4172
+ reason: `changed source path "${file}" is not a valid repo-relative path; fail closed to full impact`
4173
+ };
4174
+ if (file === profilePath) return {
4175
+ kind: "conservative",
4176
+ reason: `changed source path "${file}" is the impact profile being evaluated; fail closed to full impact`
4177
+ };
4178
+ if (!targets.some((target) => matchesAnyGlob(target.paths ?? [], file))) return {
4179
+ kind: "conservative",
4180
+ reason: `changed source path "${file}" has no declared impact target owner; fail closed to full impact`
4181
+ };
4182
+ }
4183
+ return {
4184
+ kind: "ok",
4185
+ targets: targets.map((target) => {
4186
+ const matched = sourceFiles.find((file) => matchesAnyGlob(target.paths ?? [], file));
4187
+ return matched === void 0 ? {
4188
+ basis: "no changed source path matches this target's declared paths",
4189
+ impact: "not-affected",
4190
+ name: target.name
4191
+ } : {
4192
+ basis: `changed source path "${matched}" matches this target's declared paths`,
4193
+ impact: "affected",
4194
+ name: target.name
4195
+ };
4196
+ })
4197
+ };
4198
+ };
4199
+ const unionTargetImpact = (source, lockfile) => {
4200
+ if (source.impact === "affected") return source;
4201
+ if (lockfile.impact === "affected") return lockfile;
4202
+ return {
4203
+ basis: `${source.basis}; ${lockfile.basis}`,
4204
+ impact: "not-affected",
4205
+ name: source.name
4206
+ };
4207
+ };
4208
+ const computeImpactStampOrThrow = ({ changedFiles, profilePath = "software-factory.profile.json", readLockfile }, targets) => {
4158
4209
  if (targets.length === 0) return {
4159
4210
  basis: "conservative",
4160
4211
  reasons: ["no impact targets declared in the profile; every surface keeps full demand"],
4161
- stampVersion: 1,
4212
+ stampVersion: 2,
4162
4213
  targets: []
4163
4214
  };
4215
+ assertDeclaredPathGlobs(targets);
4164
4216
  const sides = readLockfileSides(readLockfile);
4165
4217
  if (sides.kind === "ok") assertDeclaredImporterRoots(targets, sides.parsed);
4166
4218
  if (changedFiles.length === 0) return conservativeStamp(targets, ["no changed files detected; full impact assumed"]);
4167
- const unmodelledFiles = changedFiles.filter((file) => file !== LOCKFILE_PATH);
4168
- if (unmodelledFiles.length > 0) return conservativeStamp(targets, [`changed files outside the lockfile classifier's model (${unmodelledFiles.toSorted().join(", ")}); full impact assumed`]);
4219
+ const sourceFiles = changedFiles.filter((file) => file !== LOCKFILE_PATH);
4220
+ const source = analyzeSourcePaths(sourceFiles, targets, profilePath);
4221
+ if (source.kind === "conservative") return conservativeStamp(targets, [source.reason]);
4169
4222
  if (sides.kind === "threw") throw sides.error;
4170
4223
  if (sides.kind === "unreadable") return conservativeStamp(targets, [`pnpm-lock.yaml is unreadable at the ${sides.side} side; fail closed to full impact`]);
4171
4224
  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`]);
4172
4225
  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`]);
4226
+ if (!changedFiles.includes("pnpm-lock.yaml")) return {
4227
+ basis: "target-scoped",
4228
+ reasons: ["all changed source paths have declared target ownership; impact scoped by repo-relative path globs"],
4229
+ stampVersion: 2,
4230
+ targets: source.targets
4231
+ };
4173
4232
  const [baseLock, headLock] = sides.parsed;
4174
4233
  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"]);
4175
4234
  const delta = {
@@ -4178,11 +4237,13 @@ const computeImpactStampOrThrow = ({ changedFiles, readLockfile }, targets) => {
4178
4237
  changedSnapshots: changedSectionKeys(baseLock.snapshots, headLock.snapshots),
4179
4238
  sides: [baseLock, headLock]
4180
4239
  };
4240
+ const lockfileTargets = targets.map((target) => targetImpactForLockfileDelta(target, delta));
4241
+ const mixed = sourceFiles.length > 0;
4181
4242
  return {
4182
- basis: "lockfile-scoped",
4183
- reasons: ["pnpm-lock.yaml is the only changed file; impact scoped by importer/snapshot graph analysis"],
4184
- stampVersion: 1,
4185
- targets: targets.map((target) => targetImpactForLockfileDelta(target, delta))
4243
+ basis: "target-scoped",
4244
+ 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"],
4245
+ stampVersion: 2,
4246
+ targets: mixed ? source.targets.map((sourceTarget, index) => unionTargetImpact(sourceTarget, lockfileTargets[index])) : lockfileTargets
4186
4247
  };
4187
4248
  };
4188
4249
  //#endregion
@@ -4361,7 +4422,7 @@ const assertBatteryCompleteness = (proof, context) => {
4361
4422
  * so authorization is bound to the same identities the proof binds. The
4362
4423
  * predicate is the shared {@link impactStampScopeDecision}, not a second
4363
4424
  * reading of the stamp: an entry is authorized exactly when the stamp would
4364
- * have released that target's command in the first place (lockfile-scoped
4425
+ * have released that target's command in the first place (target-scoped
4365
4426
  * basis, this build's stamp version, exactly one row for the target,
4366
4427
  * `not-affected`). A conservative stamp, an unknown version, a
4367
4428
  * self-contradictory stamp, an unclassified name, or an `affected` verdict all
@@ -8844,7 +8905,7 @@ const renderReport = (record) => {
8844
8905
  return `${lines.join("\n")}\n`;
8845
8906
  };
8846
8907
  function createBoundaryCheckCommand(output, action) {
8847
- 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("--json", "print the full proof record as JSON").action(withGateTiming({
8908
+ 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({
8848
8909
  gate: "boundary:check",
8849
8910
  resolveLedgerRoot: (options) => resolveCwdOption(options.cwd),
8850
8911
  stderr: output.stderr
@@ -8860,7 +8921,10 @@ function createBoundaryCheckCommand(output, action) {
8860
8921
  if (!action) {
8861
8922
  const [owner, name] = record.repo.split("/");
8862
8923
  if (owner && name) {
8863
- const hqLaneBaseUrl = hqLaneRefBaseUrlFromProfile(tryLoadProjectProfile({ cwd: args.cwd })?.profile);
8924
+ const hqLaneBaseUrl = hqLaneRefBaseUrlFromProfile(tryLoadProjectProfile({
8925
+ cwd: args.cwd,
8926
+ profilePath: options.profile
8927
+ })?.profile);
8864
8928
  try {
8865
8929
  publishFactoryCheckSafely(createFactoryCheckPublisher(output), {
8866
8930
  conclusion: record.status === "ready" ? "success" : "failure",
@@ -9547,6 +9611,8 @@ const buildRetroEnvelope = (input) => {
9547
9611
  const RETRO_ENVELOPE_LEDGER_DIR = ".factory-memory/retro-envelope";
9548
9612
  const safeEnvelopeName = (agentRunId) => agentRunId.replaceAll(/[^\w.-]/gu, "-").slice(0, 120);
9549
9613
  const retroEnvelopePath = (root, agentRunId) => path.join(root, RETRO_ENVELOPE_LEDGER_DIR, `${safeEnvelopeName(agentRunId)}.json`);
9614
+ const CLAUDE_CODE_SESSION_ID_ENV = "CLAUDE_CODE_SESSION_ID";
9615
+ const resolveNativeSessionId = (env) => normalizeSessionId(env[CLAUDE_CODE_SESSION_ID_ENV]);
9550
9616
  const splitRepo = (repo) => {
9551
9617
  const [owner, name] = repo.split("/");
9552
9618
  return {
@@ -9572,11 +9638,11 @@ const otherLedgersExist = (repoRoot) => {
9572
9638
  const buildAndPersistRetroEnvelope = (input) => {
9573
9639
  const env = input.env ?? process.env;
9574
9640
  const now = input.now ?? (() => /* @__PURE__ */ new Date());
9575
- const laneIdentity = resolveGateTimingAgentRunId(env);
9641
+ const laneIdentity = resolveGateTimingAgentRunId(env) ?? resolveNativeSessionId(env);
9576
9642
  const agentRunId = laneIdentity ?? `closeout-${safeEnvelopeName(input.epic)}`;
9577
9643
  const ledgerPath = gateTimingLedgerPath(input.repoRoot, agentRunId);
9578
9644
  const ledger = laneIdentity === void 0 ? [] : readGateTimingLedger(ledgerPath);
9579
- const claudeSessionId = discoverFactorySessionId(env);
9645
+ const claudeSessionId = discoverFactorySessionId(env) ?? resolveNativeSessionId(env);
9580
9646
  const dataGaps = [];
9581
9647
  if (laneIdentity === void 0) dataGaps.push("agentRunId is synthetic (no lane run identity exported): keyed to the epic, ledger not folded.");
9582
9648
  else if (!existsSync(ledgerPath) && otherLedgersExist(input.repoRoot)) dataGaps.push(`gates[] empty: no gate-timing ledger for agentRunId "${agentRunId}" although other lane ledgers exist under ${GATE_TIMING_LEDGER_DIR} — possible identity drift between ledger writes and closeout.`);
@@ -9855,6 +9921,7 @@ function createCloseoutCommand(output, dependencies = {}) {
9855
9921
  });
9856
9922
  const retro = buildRetroGate({
9857
9923
  codexThreadIds: codexThreadIdsForCloseout(dependencies.env),
9924
+ ...dependencies.env === void 0 ? {} : { env: dependencies.env },
9858
9925
  epic: options.epic,
9859
9926
  ...options.issue === void 0 ? {} : { issue: options.issue },
9860
9927
  ...options.pr === void 0 ? {} : { pr: options.pr },
@@ -11167,6 +11234,7 @@ function runPrVerify(args, dependencies = {}) {
11167
11234
  impactStamp = computeImpactStamp({
11168
11235
  changedFiles: files,
11169
11236
  profile,
11237
+ profilePath: relativeProfile,
11170
11238
  readLockfile: (side) => readLockfileAtRef(cwd, side === "base" ? proofMergeBaseSha : headSha, path.resolve(cwd, LOCKFILE_PATH))
11171
11239
  });
11172
11240
  } catch (error) {
package/dist/schemas.d.ts CHANGED
@@ -493,11 +493,11 @@ interface PrReadyProof {
493
493
  */
494
494
  declare const impactStampSchema: z.ZodObject<{
495
495
  basis: z.ZodEnum<{
496
- "lockfile-scoped": "lockfile-scoped";
496
+ "target-scoped": "target-scoped";
497
497
  conservative: "conservative";
498
498
  }>;
499
499
  reasons: z.ZodArray<z.ZodString>;
500
- stampVersion: z.ZodLiteral<1>;
500
+ stampVersion: z.ZodLiteral<2>;
501
501
  targets: z.ZodArray<z.ZodObject<{
502
502
  basis: z.ZodString;
503
503
  impact: z.ZodEnum<{
@@ -1362,11 +1362,11 @@ declare const prVerifyProofSchema: z.ZodObject<{
1362
1362
  headSha: z.ZodString;
1363
1363
  impactStamp: z.ZodOptional<z.ZodObject<{
1364
1364
  basis: z.ZodEnum<{
1365
- "lockfile-scoped": "lockfile-scoped";
1365
+ "target-scoped": "target-scoped";
1366
1366
  conservative: "conservative";
1367
1367
  }>;
1368
1368
  reasons: z.ZodArray<z.ZodString>;
1369
- stampVersion: z.ZodLiteral<1>;
1369
+ stampVersion: z.ZodLiteral<2>;
1370
1370
  targets: z.ZodArray<z.ZodObject<{
1371
1371
  basis: z.ZodString;
1372
1372
  impact: z.ZodEnum<{
package/dist/schemas.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import "yaml";
3
+ import "picomatch";
3
4
  //#region src/review-rungs.ts
4
5
  const EVIDENCE_REVIEW_RUNGS$1 = [
5
6
  "independent-model",
@@ -239,9 +240,9 @@ const impactStampTargetSchema = z.object({
239
240
  * input widened every target to full impact.
240
241
  */
241
242
  const impactStampSchema = z.object({
242
- basis: z.enum(["lockfile-scoped", "conservative"]),
243
+ basis: z.enum(["target-scoped", "conservative"]),
243
244
  reasons: z.array(z.string()),
244
- stampVersion: z.literal(1),
245
+ stampVersion: z.literal(2),
245
246
  targets: z.array(impactStampTargetSchema).superRefine((targets, context) => {
246
247
  const names = targets.map((target) => target.name);
247
248
  if (new Set(names).size !== names.length) context.addIssue({
@@ -267,7 +268,7 @@ const IMPACT_SCOPE_SURFACES = {
267
268
  * external proof demand (#542 wave 2), a verification-battery command, or a
268
269
  * preview (Alchemy) lifecycle stack (#430 wave 3).
269
270
  *
270
- * The only release path is a `lockfile-scoped` stamp of exactly this build's
271
+ * The only release path is a `target-scoped` stamp of exactly this build's
271
272
  * stamp version whose target entry for exactly this name is provably
272
273
  * `not-affected`. Every other input — no trusted stamp, an unknown stamp
273
274
  * version, a conservative stamp, a name the stamp does not classify, or an
@@ -286,11 +287,11 @@ const impactStampScopeDecision = ({ stamp, surface, targetName }) => {
286
287
  reason: `no trusted identity-bound impact stamp covers this candidate; ${floor}`,
287
288
  scoped: false
288
289
  };
289
- if (stamp.stampVersion !== 1) return {
290
- reason: `impact stamp version ${String(stamp.stampVersion)} is not this build's version 1; ${floor}`,
290
+ if (stamp.stampVersion !== 2) return {
291
+ reason: `impact stamp version ${String(stamp.stampVersion)} is not this build's version 2; ${floor}`,
291
292
  scoped: false
292
293
  };
293
- if (stamp.basis !== "lockfile-scoped") return {
294
+ if (stamp.basis !== "target-scoped") return {
294
295
  reason: `impact stamp is conservative (full impact assumed); ${floor}`,
295
296
  scoped: false
296
297
  };
@@ -435,7 +436,7 @@ const assertBatteryCompleteness = (proof, context) => {
435
436
  * so authorization is bound to the same identities the proof binds. The
436
437
  * predicate is the shared {@link impactStampScopeDecision}, not a second
437
438
  * reading of the stamp: an entry is authorized exactly when the stamp would
438
- * have released that target's command in the first place (lockfile-scoped
439
+ * have released that target's command in the first place (target-scoped
439
440
  * basis, this build's stamp version, exactly one row for the target,
440
441
  * `not-affected`). A conservative stamp, an unknown version, a
441
442
  * self-contradictory stamp, an unclassified name, or an `affected` verdict all
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patronage/software-factory",
3
- "version": "1.0.0-alpha.0",
3
+ "version": "1.0.0-alpha.2",
4
4
  "description": "Shared Patronage software factory CLI and project-profile validation tools",
5
5
  "license": "MIT",
6
6
  "repository": {