@patronage/software-factory 1.0.0-alpha.38 → 1.0.0-alpha.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.d.ts CHANGED
@@ -22,6 +22,11 @@ declare const githubAppConfigSchema: z.ZodObject<{
22
22
  privateKeyPath: z.ZodString;
23
23
  }, z.core.$strip>;
24
24
  type GithubAppConfig = z.infer<typeof githubAppConfigSchema>;
25
+ declare const hqIngestCredentialReferencesSchema: z.ZodObject<{
26
+ clientIdRef: z.ZodString;
27
+ clientSecretRef: z.ZodString;
28
+ }, z.core.$strip>;
29
+ type HqIngestCredentialReferences = z.infer<typeof hqIngestCredentialReferencesSchema>;
25
30
  declare const factoryUserConfigSchema: z.ZodObject<{
26
31
  githubApp: z.ZodOptional<z.ZodObject<{
27
32
  appId: z.ZodUnion<readonly [z.ZodString, z.ZodNumber]>;
@@ -2226,6 +2231,8 @@ declare const isSupersededHostedVerifyCheck: (check: StatusCheckRollup, cutoffIs
2226
2231
  * `gh` is injectable so a test can hand the real read a fake GitHub.
2227
2232
  */
2228
2233
  declare const fetchGitHubBranchTipSha: (repository: CheckoutRepository, branch: string, cwd: string, gh?: <T>(args: string[], cwd: string) => T) => string;
2234
+ /** An asynchronous `gh` JSON read. Injected so a test can hand the reads a fake GitHub. */
2235
+ type GhBoundedJsonReader = (args: string[], cwd: string) => Promise<unknown>;
2229
2236
  //#endregion
2230
2237
  //#region src/github-app-client.d.ts
2231
2238
  interface FactoryAppRequest {
@@ -4939,6 +4946,92 @@ interface HqReadinessCheckDependencies {
4939
4946
  timeoutMs?: number;
4940
4947
  }
4941
4948
  //#endregion
4949
+ //#region src/hq-last-stored.d.ts
4950
+ declare const lastStoredSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
4951
+ found: z.ZodLiteral<true>;
4952
+ lastFromCaller: z.ZodNullable<z.ZodObject<{
4953
+ anyRepository: z.ZodNullable<z.ZodObject<{
4954
+ receivedAt: z.ZodString;
4955
+ }, z.core.$strip>>;
4956
+ inRepository: z.ZodNullable<z.ZodObject<{
4957
+ receivedAt: z.ZodString;
4958
+ }, z.core.$strip>>;
4959
+ machine: z.ZodString;
4960
+ }, z.core.$strip>>;
4961
+ lastProofEvent: z.ZodNullable<z.ZodObject<{
4962
+ receivedAt: z.ZodString;
4963
+ kind: z.ZodString;
4964
+ observedAt: z.ZodString;
4965
+ }, z.core.$strip>>;
4966
+ lastWebhookEvent: z.ZodNullable<z.ZodObject<{
4967
+ receivedAt: z.ZodString;
4968
+ event: z.ZodString;
4969
+ }, z.core.$strip>>;
4970
+ repository: z.ZodString;
4971
+ }, z.core.$strip>, z.ZodObject<{
4972
+ found: z.ZodLiteral<false>;
4973
+ lastFromCaller: z.ZodNullable<z.ZodObject<{
4974
+ anyRepository: z.ZodNullable<z.ZodObject<{
4975
+ receivedAt: z.ZodString;
4976
+ }, z.core.$strip>>;
4977
+ inRepository: z.ZodNullable<z.ZodObject<{
4978
+ receivedAt: z.ZodString;
4979
+ }, z.core.$strip>>;
4980
+ machine: z.ZodString;
4981
+ }, z.core.$strip>>;
4982
+ repository: z.ZodString;
4983
+ }, z.core.$strip>], "found">;
4984
+ type HqLastStoredAnswer = z.infer<typeof lastStoredSchema>;
4985
+ /**
4986
+ * Why HQ gave no answer. `detail` is the operator-facing sentence; it can name
4987
+ * a reference or a status, never a credential value.
4988
+ */
4989
+ type HqLastStoredUnknownReason = "no-credentials" | "no-endpoint" | "origin-not-authorized" | "unexpected-response" | "unreachable";
4990
+ type HqLastStored = {
4991
+ answer: HqLastStoredAnswer;
4992
+ status: "answered";
4993
+ } | {
4994
+ detail: string;
4995
+ reason: HqLastStoredUnknownReason;
4996
+ status: "unknown";
4997
+ };
4998
+ interface HqLastStoredInput {
4999
+ /** The profile's `hq.endpoint`, HQ's origin. */
5000
+ endpoint: string;
5001
+ env: NodeJS.ProcessEnv;
5002
+ /** The operator's `hqAllowedOrigins`. */
5003
+ hqAllowedOrigins?: readonly string[];
5004
+ hqIngestCredentials?: HqIngestCredentialReferences;
5005
+ repository: {
5006
+ owner: string;
5007
+ repo: string;
5008
+ };
5009
+ }
5010
+ /** Test seams. Production never sets these. */
5011
+ interface HqLastStoredDependencies {
5012
+ fetch?: typeof fetch;
5013
+ resolveSecret?: SecretReferenceResolver;
5014
+ timeoutMs?: number;
5015
+ }
5016
+ /** Asks HQ when it last stored an event for `repository`. Never throws. */
5017
+ declare function fetchHqLastStored(input: HqLastStoredInput, dependencies?: HqLastStoredDependencies): Promise<HqLastStored>;
5018
+ //#endregion
5019
+ //#region src/doctor-hq-last-stored.d.ts
5020
+ /** Test seams. Production never sets these. */
5021
+ interface HqLastStoredCheckDependencies {
5022
+ countSpool?: typeof countHqSpoolWork;
5023
+ lastStored?: typeof fetchHqLastStored;
5024
+ now?: () => Date;
5025
+ }
5026
+ //#endregion
5027
+ //#region src/doctor-hq-webhook.d.ts
5028
+ /** Test seams. Production never sets these. */
5029
+ interface HqWebhookCheckDependencies {
5030
+ gh?: GhBoundedJsonReader;
5031
+ lastStored?: typeof fetchHqLastStored;
5032
+ now?: () => Date;
5033
+ }
5034
+ //#endregion
4942
5035
  //#region src/doctor-machine-checks.d.ts
4943
5036
  /** Test seams. Production never sets these. */
4944
5037
  interface MachineCheckDependencies {
@@ -4969,10 +5062,14 @@ interface DoctorProjectProfileInput extends LoadProjectProfileInput {
4969
5062
  /** Diff base for the admission preflight; ignored unless `preflight`. */
4970
5063
  base?: string;
4971
5064
  env?: NodeJS.ProcessEnv;
5065
+ /** Test seam for the hq-last-stored row (#1342); production never sets this. */
5066
+ hqLastStoredDependencies?: HqLastStoredCheckDependencies;
4972
5067
  /** Test seam for the HQ readiness rows (#1315); production never sets this. */
4973
5068
  hqReadinessDependencies?: HqReadinessCheckDependencies;
4974
5069
  /** Test seam for the local HQ spool check (#394); production never sets this. */
4975
5070
  hqSpoolDependencies?: HqSpoolCheckDependencies;
5071
+ /** Test seam for the hq-webhook row (#1341); production never sets this. */
5072
+ hqWebhookDependencies?: HqWebhookCheckDependencies;
4976
5073
  /** Test seam for the machine readiness rows (#1315); production never sets this. */
4977
5074
  machineDependencies?: MachineCheckDependencies;
4978
5075
  /**
package/dist/index.js CHANGED
@@ -2,7 +2,7 @@ import { t as __exportAll } from "./chunk-DrSxFLj_.js";
2
2
  import { createRequire } from "node:module";
3
3
  import path from "node:path";
4
4
  import { Command, InvalidArgumentError } from "commander";
5
- import { execFileSync, spawn, spawnSync } from "node:child_process";
5
+ import { execFile, execFileSync, spawn, spawnSync } from "node:child_process";
6
6
  import { EventEmitter, once } from "node:events";
7
7
  import * as nodeFs from "node:fs";
8
8
  import { accessSync, appendFileSync, closeSync, constants, cpSync, existsSync, fsyncSync, mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
@@ -24,7 +24,7 @@ import { pathToFileURL } from "node:url";
24
24
  import { choice, noul, score } from "@typesafe-ai/sdk";
25
25
  //#region package.json
26
26
  var name = "@patronage/software-factory";
27
- var version = "1.0.0-alpha.38";
27
+ var version = "1.0.0-alpha.39";
28
28
  //#endregion
29
29
  //#region src/cli-entry.ts
30
30
  /**
@@ -4018,6 +4018,35 @@ const fetchGitHubFileAtRef = (repository, filePath, ref, cwd) => {
4018
4018
  const response = repositoryFileSchema.parse(runGhJsonAt(["api", `/repos/${repository.owner}/${repository.name}/contents/${encodedPath}?ref=${encodeURIComponent(ref)}`], cwd));
4019
4019
  return Buffer$1.from(response.content, "base64").toString("utf-8");
4020
4020
  };
4021
+ /** Ceiling for one bounded `gh` read. Expiry kills gh and rejects. */
4022
+ const GITHUB_READ_TIMEOUT_MS = 1e4;
4023
+ /**
4024
+ * `runGhJsonAt` with a deadline, for a diagnostic that must not hang (#1341).
4025
+ * A failure rejects with the `execFile` error: its `stderr` carries gh's
4026
+ * `(HTTP nnn)`, and `killed` marks the deadline.
4027
+ */
4028
+ const runGhJsonBounded = async (args, cwd) => {
4029
+ const { stdout } = await promisify(execFile)("gh", args, {
4030
+ cwd,
4031
+ encoding: "utf-8",
4032
+ timeout: GITHUB_READ_TIMEOUT_MS
4033
+ });
4034
+ return JSON.parse(stdout);
4035
+ };
4036
+ const repositoryHooksSchema = z.array(z.object({
4037
+ active: z.boolean(),
4038
+ config: z.object({ url: z.string().optional() }),
4039
+ id: z.number()
4040
+ }));
4041
+ /**
4042
+ * A repository's webhooks (`GET /repos/{owner}/{repo}/hooks`). This needs
4043
+ * admin read: a token without it gets 403 or 404, never an empty list. Rejects
4044
+ * on every failure; the caller decides what an unreadable list means.
4045
+ */
4046
+ const fetchRepositoryHooks = async (repository, cwd, gh = runGhJsonBounded) => repositoryHooksSchema.parse(await gh(["api", `/repos/${repository.owner}/${repository.name}/hooks`], cwd));
4047
+ const hookDeliveriesSchema = z.array(z.object({ status_code: z.number() }));
4048
+ /** A webhook's last 30 deliveries, newest first. Rejects like `fetchRepositoryHooks`. */
4049
+ const fetchHookDeliveries = async (repository, hookId, cwd, gh = runGhJsonBounded) => hookDeliveriesSchema.parse(await gh(["api", `/repos/${repository.owner}/${repository.name}/hooks/${hookId}/deliveries?per_page=30`], cwd));
4021
4050
  /**
4022
4051
  * Fetch and normalize the issue/PR comment thread for `<owner>/<repo>`.
4023
4052
  *
@@ -5296,6 +5325,15 @@ const loadHqAllowedOrigins = async (env, deadline) => {
5296
5325
  * No prefix, wildcard, or host-only match.
5297
5326
  */
5298
5327
  const isHqOriginAuthorized = (allowedOrigins, endpoint) => allowedOrigins.includes(endpoint.origin);
5328
+ //#endregion
5329
+ //#region src/hq-user-agent.ts
5330
+ /**
5331
+ * The `User-Agent` every HQ ingest sink request carries (#1343). Without it
5332
+ * the sink sends fetch's default, `node`. HQ stores the header in
5333
+ * `ingest_events.sender_agent` (#1337), so a look-back can split events by the
5334
+ * factory version that sent them.
5335
+ */
5336
+ const HQ_SINK_USER_AGENT = `patronage-software-factory/${version} (${process.platform}; node/${process.versions.node})`;
5299
5337
  const DEFAULT_HQ_TRANSPORT_TIMEOUT_MS = 2500;
5300
5338
  const HQ_RETRY_SPOOL_DIRNAME = "hq-retry-spool";
5301
5339
  /**
@@ -6048,7 +6086,10 @@ async function attemptTransport(request, endpoint, clientId, clientSecret, body,
6048
6086
  clientSecret
6049
6087
  }, {
6050
6088
  body,
6051
- headers: { "content-type": "application/json" },
6089
+ headers: {
6090
+ "content-type": "application/json",
6091
+ "user-agent": HQ_SINK_USER_AGENT
6092
+ },
6052
6093
  method: "POST",
6053
6094
  signal: abort.signal
6054
6095
  }))), transportBudgetMs);
@@ -14923,7 +14964,7 @@ const BASELINE_BLINDSPOT = {
14923
14964
  };
14924
14965
  const NO_CROSS_FAMILY_TOTAL_BLINDSPOT = {
14925
14966
  id: "no-cross-family-token-total",
14926
- note: "Token rows are per model family. Claude and GPT use different tokenizers and accounting conventions, so this closeout reports no combined token total. USD is the only cross-family summable unit, and pricing policy stays with the patronage-software-factory-cost-analysis skill."
14967
+ note: "Token rows are per model family. Claude and GPT use different tokenizers and accounting conventions, so this closeout reports no combined token total. USD is the only cross-family summable unit, and pricing policy stays with the patronage-software-factory-lookback skill."
14927
14968
  };
14928
14969
  const assertMetricSources = (metrics) => {
14929
14970
  for (const metric of metrics) if (!metric.source.detail.trim()) throw new CloseoutValidationError(`metric ${metric.metric} is missing source.detail`);
@@ -14983,7 +15024,7 @@ function buildEpicCloseoutReport(input) {
14983
15024
  * `factory:closeout` reads the lane's native at-rest provider logs by its
14984
15025
  * session join keys and records what they say as closeout metric rows. The
14985
15026
  * rows land in the one local closeout markdown, which is the read path the
14986
- * `patronage-software-factory-cost-analysis` skill uses.
15027
+ * `patronage-software-factory-lookback` skill uses.
14987
15028
  *
14988
15029
  * Harvest NEVER throws: a missing or unreadable log degrades to an absent
14989
15030
  * family plus a data gap, so telemetry content can never fail the closeout.
@@ -16150,7 +16191,7 @@ const notProbed = (reason) => ({
16150
16191
  status: "warning"
16151
16192
  });
16152
16193
  /** A code or an error name. A transport message is never rendered. */
16153
- const networkFailure = (error) => {
16194
+ const networkFailure$1 = (error) => {
16154
16195
  if (!(error instanceof Error)) return "the request failed";
16155
16196
  if (error.name === "TimeoutError" || error.name === "AbortError") return "it did not answer in time";
16156
16197
  return `the request failed (${error.cause?.code ?? error.name})`;
@@ -16167,7 +16208,7 @@ const reachableCheck = async (ingest, credentials, dependencies) => {
16167
16208
  }));
16168
16209
  } catch (error) {
16169
16210
  return {
16170
- message: `HQ at ${ingest.href} is unreachable from this machine: ${networkFailure(error)}. Fix: check this machine's network path to ${ingest.origin}, then rerun doctor.`,
16211
+ message: `HQ at ${ingest.href} is unreachable from this machine: ${networkFailure$1(error)}. Fix: check this machine's network path to ${ingest.origin}, then rerun doctor.`,
16171
16212
  name: HQ_REACHABLE_CHECK,
16172
16213
  status: "error"
16173
16214
  };
@@ -16227,6 +16268,383 @@ async function hqReadinessDoctorChecks(input, dependencies = {}) {
16227
16268
  ];
16228
16269
  }
16229
16270
  //#endregion
16271
+ //#region src/hq-last-stored.ts
16272
+ /**
16273
+ * The HQ client for `GET /api/ingest/last-stored` (#1339): when HQ last stored
16274
+ * an event for a repository. Doctor's `hq-webhook` row asks it when GitHub
16275
+ * will not show a repository's deliveries (#1341).
16276
+ *
16277
+ * Whatever HQ cannot answer comes back as an explicit `unknown` with its
16278
+ * reason, never a guess. The commonest today is `no-endpoint`: an HQ deployed
16279
+ * before #1339 answers the route with 404.
16280
+ *
16281
+ * The token goes only to an origin the operator listed in `hqAllowedOrigins`
16282
+ * or `FACTORY_HQ_ALLOWED_ORIGINS` (ADR 0015, `resolveHqAllowedOrigins`, the
16283
+ * rule doctor's `hq-origin` row applies), checked here so no caller can skip
16284
+ * it. Credentials resolve the way `hq:flush` resolves them, environment first
16285
+ * and then the operator's references, and are held only long enough to send
16286
+ * the request.
16287
+ */
16288
+ /** HQ's route. `software-factory-hq/src/ingest-config.ts` owns it as `INGEST_LAST_STORED_PATH`. */
16289
+ const HQ_LAST_STORED_PATH = "/api/ingest/last-stored";
16290
+ /** Ceiling for the request, reply included. Expiry reads as unreachable. */
16291
+ const HQ_LAST_STORED_TIMEOUT_MS = 1e4;
16292
+ const storedTimeSchema = z.object({ receivedAt: z.string() });
16293
+ const lastFromCallerSchema = z.object({
16294
+ anyRepository: storedTimeSchema.nullable(),
16295
+ inRepository: storedTimeSchema.nullable(),
16296
+ machine: z.string()
16297
+ }).nullable();
16298
+ const lastStoredSchema = z.discriminatedUnion("found", [z.object({
16299
+ found: z.literal(true),
16300
+ lastFromCaller: lastFromCallerSchema,
16301
+ lastProofEvent: storedTimeSchema.extend({
16302
+ kind: z.string(),
16303
+ observedAt: z.string()
16304
+ }).nullable(),
16305
+ lastWebhookEvent: storedTimeSchema.extend({ event: z.string() }).nullable(),
16306
+ repository: z.string()
16307
+ }), z.object({
16308
+ found: z.literal(false),
16309
+ lastFromCaller: lastFromCallerSchema,
16310
+ repository: z.string()
16311
+ })]);
16312
+ const unknown = (reason, detail) => ({
16313
+ detail,
16314
+ reason,
16315
+ status: "unknown"
16316
+ });
16317
+ /** A code or an error name. A transport message is never rendered. */
16318
+ const networkFailure = (error) => {
16319
+ if (!(error instanceof Error)) return "the request failed";
16320
+ if (error.name === "TimeoutError" || error.name === "AbortError") return "it did not answer in time";
16321
+ return `the request failed (${error.cause?.code ?? error.name})`;
16322
+ };
16323
+ /** Asks HQ when it last stored an event for `repository`. Never throws. */
16324
+ async function fetchHqLastStored(input, dependencies = {}) {
16325
+ const origin = validatedHqOrigin(input.endpoint);
16326
+ if (origin === void 0 || !isHqOriginAuthorized(resolveHqAllowedOrigins(input.env, input.hqAllowedOrigins ?? []), origin)) return unknown("origin-not-authorized", `HQ was not asked: ${input.endpoint} is not listed in hqAllowedOrigins in the operator user config or in FACTORY_HQ_ALLOWED_ORIGINS.`);
16327
+ const resolution = resolveHqCredentials({
16328
+ env: input.env,
16329
+ ...input.hqIngestCredentials === void 0 ? {} : { references: input.hqIngestCredentials },
16330
+ ...dependencies.resolveSecret === void 0 ? {} : { resolve: dependencies.resolveSecret }
16331
+ });
16332
+ if (resolution.status !== "resolved") return unknown("no-credentials", describeHqCredentialFailure(resolution));
16333
+ const url = new URL(HQ_LAST_STORED_PATH, origin.origin);
16334
+ url.searchParams.set("repository", `${input.repository.owner}/${input.repository.repo}`);
16335
+ const request = dependencies.fetch ?? fetch;
16336
+ let response;
16337
+ try {
16338
+ response = await request(url, buildCloudflareAccessRequestInit(resolution.credentials, {
16339
+ method: "GET",
16340
+ signal: AbortSignal.timeout(dependencies.timeoutMs ?? HQ_LAST_STORED_TIMEOUT_MS)
16341
+ }));
16342
+ } catch (error) {
16343
+ return unknown("unreachable", `HQ at ${origin.origin} is unreachable: ${networkFailure(error)}.`);
16344
+ }
16345
+ if (response.status !== 200) {
16346
+ try {
16347
+ await response.body?.cancel();
16348
+ } catch {}
16349
+ return response.status === 404 ? unknown("no-endpoint", `HQ at ${origin.origin} answered 404: it was deployed before GET ${HQ_LAST_STORED_PATH} (#1339).`) : unknown("unexpected-response", `HQ at ${origin.origin} answered ${response.status}.`);
16350
+ }
16351
+ try {
16352
+ return {
16353
+ answer: lastStoredSchema.parse(await response.json()),
16354
+ status: "answered"
16355
+ };
16356
+ } catch {
16357
+ return unknown("unexpected-response", `HQ at ${origin.origin} answered 200 with a reply that is not a last-stored answer.`);
16358
+ }
16359
+ }
16360
+ //#endregion
16361
+ //#region src/doctor-hq-last-stored.ts
16362
+ /**
16363
+ * The `hq-last-stored` doctor row (#1342): the silent-spool alarm. A machine
16364
+ * whose gates stop reaching HQ looks, from HQ, like a machine that stopped
16365
+ * working; this row says so from the machine's side.
16366
+ *
16367
+ * It asks HQ when it last stored an event for this repository (#1339) and
16368
+ * prints one line: from this machine, from anyone in this repository, and the
16369
+ * last webhook. It warns when this checkout's pr:verify proof is more than an
16370
+ * hour newer than HQ's last event from this machine here, or when this
16371
+ * repository's spool holds events or cannot be inspected. The spool is read
16372
+ * with `countHqSpoolWork`, the reader the `hq-spool` row and `hq:flush` use.
16373
+ *
16374
+ * Whatever HQ cannot answer (an HQ older than the endpoint, no credentials, a
16375
+ * token HQ does not map to a machine) reads "unknown", never a warning. A
16376
+ * warning is still never an error: HQ is advisory (ADR 0015, #1228), so
16377
+ * doctor's exit code is unchanged. Doctor never flushes; it points at
16378
+ * `psf hq:flush`.
16379
+ */
16380
+ const HQ_LAST_STORED_CHECK = "hq-last-stored";
16381
+ /** How much newer a local proof may be than HQ's copy before the row warns. */
16382
+ const BEHIND_TOLERANCE_MS = 36e5;
16383
+ const MINUTE_MS = 6e4;
16384
+ /** Where pr:verify writes its proof, relative to its cwd (`DEFAULT_PR_VERIFY_PROOF_PATH`). */
16385
+ const PR_VERIFY_PROOF_PATH = path.join(".factory-memory", "pr-verify.json");
16386
+ /**
16387
+ * How long after stamping `endedAt` a pr:verify run writes its own proof, at
16388
+ * most, and how far a coarse filesystem clock may put the write before it.
16389
+ */
16390
+ const PROOF_WRITE_WINDOW_MS = 6e4;
16391
+ const MTIME_GRANULARITY_MS = 2e3;
16392
+ /**
16393
+ * This checkout's pr:verify proof, and whether a pr:verify run here wrote it.
16394
+ * Only such a run handed the proof to the HQ sink, at `endedAt`.
16395
+ *
16396
+ * `pr:verify --reuse-accepted` writes another run's proof to the same path,
16397
+ * byte for byte, with its producer's provenance and time, and sends HQ
16398
+ * nothing. It is the one such writer: a proof never travels between heads or
16399
+ * stores (ADR 0038). So a proof counts only when it names this profile's
16400
+ * path and repository, the identity pr:verify itself uses to call a proof
16401
+ * this profile's, and when the file was written as its run ended. Anything
16402
+ * else is unattributed: whether HQ is behind is then unknown, never a
16403
+ * warning. A missing or unreadable proof is no proof.
16404
+ */
16405
+ const readLocalProof = (input) => {
16406
+ const proofPath = path.join(input.cwd, PR_VERIFY_PROOF_PATH);
16407
+ let proof;
16408
+ let writtenAtMs;
16409
+ try {
16410
+ proof = validatePrVerifyProof(JSON.parse(readFileSync(proofPath, "utf-8")));
16411
+ writtenAtMs = statSync(proofPath).mtimeMs;
16412
+ } catch {
16413
+ return { status: "none" };
16414
+ }
16415
+ const { name, owner } = input.profile.repository;
16416
+ const writeLag = writtenAtMs - Date.parse(proof.endedAt);
16417
+ return proof.profilePath === input.profilePath && proof.repository === `${owner}/${name}` && writeLag >= -MTIME_GRANULARITY_MS && writeLag <= PROOF_WRITE_WINDOW_MS ? {
16418
+ endedAt: proof.endedAt,
16419
+ status: "local"
16420
+ } : { status: "unattributed" };
16421
+ };
16422
+ const ago = (at, now) => {
16423
+ const minutes = Math.floor((now.getTime() - Date.parse(at)) / MINUTE_MS);
16424
+ if (!Number.isFinite(minutes)) return `at ${at}`;
16425
+ if (minutes < 60) return `${Math.max(minutes, 0)} min ago`;
16426
+ const hours = Math.floor(minutes / 60);
16427
+ return hours < 24 ? `${hours} h ago` : `${Math.floor(hours / 24)} d ago`;
16428
+ };
16429
+ const summary$1 = (answer, now) => {
16430
+ const caller = answer.lastFromCaller;
16431
+ let machine = "unknown sender";
16432
+ if (caller?.inRepository) machine = ago(caller.inRepository.receivedAt, now);
16433
+ else if (caller?.anyRepository) machine = `never here (elsewhere ${ago(caller.anyRepository.receivedAt, now)})`;
16434
+ else if (caller) machine = "never";
16435
+ const proof = answer.found ? answer.lastProofEvent : null;
16436
+ const webhook = answer.found ? answer.lastWebhookEvent : null;
16437
+ return [
16438
+ `HQ last stored: from this machine ${machine}`,
16439
+ `from this repository ${proof ? ago(proof.receivedAt, now) : "never"}`,
16440
+ `webhook ${webhook ? ago(webhook.receivedAt, now) : "never"}.`
16441
+ ].join(" · ");
16442
+ };
16443
+ /** What this checkout's proof says about HQ's copy, when it says anything. */
16444
+ const comparison = (answer, local) => {
16445
+ const caller = answer.lastFromCaller;
16446
+ if (caller === null || local.status === "none") return;
16447
+ if (local.status === "unattributed") return {
16448
+ behind: false,
16449
+ note: "This checkout's pr:verify proof was not written here by pr:verify for this repository (a reused proof keeps its producer's time), so whether HQ is behind this machine is unknown."
16450
+ };
16451
+ if (caller.inRepository === null) return {
16452
+ behind: true,
16453
+ note: `This checkout's pr:verify proof ended at ${local.endedAt}, and HQ has stored nothing from this machine in this repository.`
16454
+ };
16455
+ return Date.parse(local.endedAt) - Date.parse(caller.inRepository.receivedAt) > BEHIND_TOLERANCE_MS ? {
16456
+ behind: true,
16457
+ note: `This checkout's pr:verify proof ended at ${local.endedAt}, more than 1 h after HQ last stored an event from this machine here.`
16458
+ } : void 0;
16459
+ };
16460
+ /**
16461
+ * The spool as the reason HQ is behind; the hq-spool row has the detail. The
16462
+ * spool is called empty only when it was fully inspected.
16463
+ */
16464
+ const spoolNotes = (spool, behind) => {
16465
+ const notes = [...spool.pending > 0 ? [`HQ is behind by the ${spool.pending} event(s) the hq-spool row reports; \`psf hq:flush\` sends them.`] : [], ...spool.unlistable > 0 ? [`${spool.unlistable} spool location(s) could not be inspected, so the events waiting there are unknown; the hq-spool row names them.`] : []];
16466
+ return notes.length === 0 && behind ? ["Nothing is spooled here, so `psf hq:flush` has nothing to send."] : notes;
16467
+ };
16468
+ const unknownLine = (lastStored) => lastStored.reason === "no-credentials" ? `HQ last stored: unknown (no HQ credentials). ${lastStored.detail}` : `HQ last stored: unknown. ${lastStored.detail}`;
16469
+ async function hqLastStoredDoctorCheck(input, dependencies = {}) {
16470
+ const { hq, repository } = input.profile;
16471
+ if (!hq?.enabled) return {
16472
+ message: "Not an HQ repository: the profile does not enable HQ ingest, so HQ is not asked.",
16473
+ name: HQ_LAST_STORED_CHECK,
16474
+ status: "ok"
16475
+ };
16476
+ const slug = {
16477
+ owner: repository.owner,
16478
+ repo: repository.name
16479
+ };
16480
+ const [lastStored, spool] = await Promise.all([(dependencies.lastStored ?? fetchHqLastStored)({
16481
+ endpoint: hq.endpoint,
16482
+ env: input.env,
16483
+ hqAllowedOrigins: input.userConfig?.hqAllowedOrigins,
16484
+ hqIngestCredentials: input.userConfig?.hqIngestCredentials,
16485
+ repository: slug
16486
+ }), (dependencies.countSpool ?? countHqSpoolWork)({ repository: slug }, { env: input.env })]);
16487
+ const now = dependencies.now?.() ?? /* @__PURE__ */ new Date();
16488
+ const compared = lastStored.status === "answered" ? comparison(lastStored.answer, readLocalProof(input)) : void 0;
16489
+ const behind = compared?.behind ?? false;
16490
+ const unidentified = lastStored.status === "answered" && lastStored.answer.lastFromCaller === null ? "HQ does not know which machine this token belongs to, so this machine's proofs are not compared." : void 0;
16491
+ return {
16492
+ message: [
16493
+ lastStored.status === "answered" ? summary$1(lastStored.answer, now) : unknownLine(lastStored),
16494
+ unidentified,
16495
+ compared?.note,
16496
+ ...spoolNotes(spool, behind)
16497
+ ].filter((note) => note !== void 0).join(" "),
16498
+ name: HQ_LAST_STORED_CHECK,
16499
+ status: behind || spool.pending > 0 || spool.unlistable > 0 ? "warning" : "ok"
16500
+ };
16501
+ }
16502
+ //#endregion
16503
+ //#region src/doctor-hq-webhook.ts
16504
+ /**
16505
+ * The `hq-webhook` doctor row (#1341): whether GitHub's webhook deliveries
16506
+ * from this repository reach HQ. paitronage and firedup answered 401 from
16507
+ * 2026-07-17 on and nothing said so; this row warns on the first one.
16508
+ *
16509
+ * It reads the repository hook whose URL is the profile's HQ origin plus
16510
+ * `/webhooks/github`, then that hook's last 30 deliveries, with the `gh`
16511
+ * credential doctor already uses. It warns on a 401 (the secret does not match
16512
+ * HQ's), any other 4xx, a 5xx, a delivery that got no response, or no hook at
16513
+ * all. HQ answers an out-of-scope delivery 202 (#1338), so any 4xx is a broken
16514
+ * delivery: HQ rejected it and stored nothing.
16515
+ *
16516
+ * Reading hooks needs admin on the repository, which many operators do not
16517
+ * have. When GitHub will not show the deliveries, the row warns and falls back
16518
+ * to HQ's own record, `GET /api/ingest/last-stored`, so a non-admin still gets
16519
+ * an answer, or an explicit "unknown" with its reason.
16520
+ *
16521
+ * A warning, never an error: HQ is advisory (ADR 0015, #1228), so HQ being
16522
+ * deaf never changes doctor's exit code. The row only reads; it never creates,
16523
+ * edits or redelivers a hook.
16524
+ */
16525
+ const HQ_WEBHOOK_CHECK = "hq-webhook";
16526
+ /** HQ's webhook route. `software-factory-hq/src/webhooks/config.ts` owns it as `GITHUB_WEBHOOK_PATH`. */
16527
+ const HQ_WEBHOOK_PATH = "/webhooks/github";
16528
+ const HTTP_STATUS_PATTERN$1 = /\bHTTP (?<status>\d{3})\b/u;
16529
+ const DAY_MS = 864e5;
16530
+ const sameUrl = (candidate, expected) => {
16531
+ try {
16532
+ return candidate !== void 0 && new URL(candidate).href === expected;
16533
+ } catch {
16534
+ return false;
16535
+ }
16536
+ };
16537
+ /**
16538
+ * Why a GitHub read failed, from gh's own `(HTTP nnn)` and the deadline. The
16539
+ * error text is read here and dropped.
16540
+ */
16541
+ const githubFailure = (error, slug) => {
16542
+ const failure = error;
16543
+ if (failure.killed === true) return "GitHub did not answer in time";
16544
+ const text = typeof failure.stderr === "string" ? failure.stderr : failure.message ?? "";
16545
+ const status = Number(HTTP_STATUS_PATTERN$1.exec(text)?.groups?.status ?? 0);
16546
+ if (status === 403 || status === 404) return `GitHub answered ${status}, so this gh token has no admin read on ${slug}`;
16547
+ if (status !== 0) return `GitHub answered ${status}`;
16548
+ return "gh could not read GitHub; check `gh auth status`";
16549
+ };
16550
+ const daysAgo = (receivedAt, now) => {
16551
+ const days = Math.floor((now.getTime() - Date.parse(receivedAt)) / DAY_MS);
16552
+ if (!Number.isFinite(days)) return `at ${receivedAt}`;
16553
+ return `${days} day${days === 1 ? "" : "s"} ago (${receivedAt})`;
16554
+ };
16555
+ const describeLastStored = (lastStored, now) => {
16556
+ if (lastStored.status === "unknown") return `HQ's last stored webhook is unknown: ${lastStored.detail}`;
16557
+ const { answer } = lastStored;
16558
+ if (!answer.found) return "HQ has never stored an event from this repository.";
16559
+ if (answer.lastWebhookEvent === null) return "HQ has stored events from this repository, but never a webhook delivery.";
16560
+ return `HQ last stored a webhook from this repository ${daysAgo(answer.lastWebhookEvent.receivedAt, now)}, a ${answer.lastWebhookEvent.event} event.`;
16561
+ };
16562
+ /**
16563
+ * GitHub will not show the deliveries, so the row warns, says why, and reports
16564
+ * what HQ last stored instead.
16565
+ */
16566
+ const unreadableCheck = async (input, hq, error, dependencies) => {
16567
+ const { repository } = input.profile;
16568
+ const slug = `${repository.owner}/${repository.name}`;
16569
+ const lastStored = await (dependencies.lastStored ?? fetchHqLastStored)({
16570
+ endpoint: hq.endpoint,
16571
+ env: input.env,
16572
+ hqAllowedOrigins: input.userConfig?.hqAllowedOrigins,
16573
+ hqIngestCredentials: input.userConfig?.hqIngestCredentials,
16574
+ repository: {
16575
+ owner: repository.owner,
16576
+ repo: repository.name
16577
+ }
16578
+ });
16579
+ return {
16580
+ message: `Can't read ${slug}'s webhook deliveries: ${githubFailure(error, slug)}. ${describeLastStored(lastStored, dependencies.now?.() ?? /* @__PURE__ */ new Date())}`,
16581
+ name: HQ_WEBHOOK_CHECK,
16582
+ status: "warning"
16583
+ };
16584
+ };
16585
+ /** `n × code` per status, in the order given. Code 0 is no response. */
16586
+ const describeCounts = (counted) => counted.map(([code, n]) => `${n} × ${code === 0 ? "no response" : code}`).join(", ");
16587
+ const deliveriesCheck = (hookId, hookUrl, deliveries) => {
16588
+ if (deliveries.length === 0) return {
16589
+ message: `Hook ${hookId} delivers to ${hookUrl}; GitHub lists no recent deliveries for it.`,
16590
+ name: HQ_WEBHOOK_CHECK,
16591
+ status: "ok"
16592
+ };
16593
+ const tally = /* @__PURE__ */ new Map();
16594
+ for (const { status_code: code } of deliveries) {
16595
+ const key = code < 100 ? 0 : code;
16596
+ tally.set(key, (tally.get(key) ?? 0) + 1);
16597
+ }
16598
+ const counted = [...tally].toSorted(([left], [right]) => left - right);
16599
+ const unauthorized = tally.get(401) ?? 0;
16600
+ const rejected = counted.filter(([code]) => code >= 400 && code < 500 && code !== 401);
16601
+ const rejectedCount = rejected.reduce((total, [, n]) => total + n, 0);
16602
+ const noResponse = tally.get(0) ?? 0;
16603
+ const serverErrors = deliveries.filter(({ status_code: code }) => code >= 500).length;
16604
+ const warnings = [
16605
+ ...unauthorized > 0 ? [`${unauthorized} got 401: the webhook secret does not match HQ's; see #1310.`] : [],
16606
+ ...rejectedCount > 0 ? [`${rejectedCount} got a 4xx other than 401 (${describeCounts(rejected)}): HQ rejected each delivery and stored nothing.`] : [],
16607
+ ...serverErrors > 0 ? [`${serverErrors} got a 5xx from HQ.`] : [],
16608
+ ...noResponse > 0 ? [`${noResponse} got no response from HQ (timed out or unreachable).`] : []
16609
+ ];
16610
+ return {
16611
+ message: [`Hook ${hookId} delivers to ${hookUrl}; its last ${deliveries.length} deliveries: ${describeCounts(counted)}.`, ...warnings].join(" "),
16612
+ name: HQ_WEBHOOK_CHECK,
16613
+ status: warnings.length > 0 ? "warning" : "ok"
16614
+ };
16615
+ };
16616
+ async function hqWebhookDoctorCheck(input, dependencies = {}) {
16617
+ const { hq, repository } = input.profile;
16618
+ if (!hq?.enabled) return {
16619
+ message: "The profile does not enable HQ ingest, so no webhook delivery is checked.",
16620
+ name: HQ_WEBHOOK_CHECK,
16621
+ status: "ok"
16622
+ };
16623
+ const hookUrl = new URL(HQ_WEBHOOK_PATH, hq.endpoint).href;
16624
+ let hooks;
16625
+ try {
16626
+ hooks = await fetchRepositoryHooks(repository, input.cwd, dependencies.gh);
16627
+ } catch (error) {
16628
+ return unreadableCheck(input, hq, error, dependencies);
16629
+ }
16630
+ const hook = hooks.find((candidate) => sameUrl(candidate.config.url, hookUrl));
16631
+ if (hook === void 0) return {
16632
+ message: `No webhook on ${repository.owner}/${repository.name} delivers to ${hookUrl}, so HQ hears no GitHub event from this repository. Fix: a repository admin adds one with payload URL ${hookUrl}, content type json, the events check_run, issues, pull_request and push, and the secret HQ holds.`,
16633
+ name: HQ_WEBHOOK_CHECK,
16634
+ status: "warning"
16635
+ };
16636
+ if (!hook.active) return {
16637
+ message: `Hook ${hook.id} to ${hookUrl} is disabled in GitHub, so no new event from ${repository.owner}/${repository.name} reaches HQ. Fix: a repository admin re-enables it in Settings → Webhooks.`,
16638
+ name: HQ_WEBHOOK_CHECK,
16639
+ status: "warning"
16640
+ };
16641
+ try {
16642
+ return deliveriesCheck(hook.id, hookUrl, await fetchHookDeliveries(repository, hook.id, input.cwd, dependencies.gh));
16643
+ } catch (error) {
16644
+ return unreadableCheck(input, hq, error, dependencies);
16645
+ }
16646
+ }
16647
+ //#endregion
16230
16648
  //#region src/doctor-machine-checks.ts
16231
16649
  /**
16232
16650
  * Machine readiness rows for `doctor` (#1315): facts about this machine, not
@@ -16430,6 +16848,19 @@ async function doctorProjectProfile(input = {}) {
16430
16848
  }),
16431
16849
  ...hqReadinessChecks,
16432
16850
  hqSpoolCheck,
16851
+ await hqWebhookDoctorCheck({
16852
+ cwd,
16853
+ env,
16854
+ profile,
16855
+ userConfig: userConfig.loaded?.config
16856
+ }, input.hqWebhookDependencies),
16857
+ await hqLastStoredDoctorCheck({
16858
+ cwd,
16859
+ env,
16860
+ profile,
16861
+ profilePath: path,
16862
+ userConfig: userConfig.loaded?.config
16863
+ }, input.hqLastStoredDependencies),
16433
16864
  ...machineDoctorChecks({
16434
16865
  env,
16435
16866
  profile,
@@ -16888,12 +17319,18 @@ const publishEpicStructure = async (args) => {
16888
17319
  const doFetch = args.fetchImpl ?? globalThis.fetch;
16889
17320
  const requestInit = args.accessServiceToken ? buildCloudflareAccessRequestInit(args.accessServiceToken, {
16890
17321
  body: JSON.stringify(args.event),
16891
- headers: { "content-type": "application/json" },
17322
+ headers: {
17323
+ "content-type": "application/json",
17324
+ "user-agent": HQ_SINK_USER_AGENT
17325
+ },
16892
17326
  method: "POST",
16893
17327
  ...args.signal ? { signal: args.signal } : {}
16894
17328
  }) : {
16895
17329
  body: JSON.stringify(args.event),
16896
- headers: { "content-type": "application/json" },
17330
+ headers: {
17331
+ "content-type": "application/json",
17332
+ "user-agent": HQ_SINK_USER_AGENT
17333
+ },
16897
17334
  method: "POST",
16898
17335
  ...args.signal ? { signal: args.signal } : {}
16899
17336
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@patronage/software-factory",
3
- "version": "1.0.0-alpha.38",
3
+ "version": "1.0.0-alpha.39",
4
4
  "description": "Shared Patronage software factory CLI and project-profile validation tools",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -42,7 +42,7 @@
42
42
  "picomatch": "^4.0.5",
43
43
  "yaml": "^2.9.0",
44
44
  "zod": "4.4.3",
45
- "@patronage/factory-ci": "1.0.0-alpha.38"
45
+ "@patronage/factory-ci": "1.0.0-alpha.39"
46
46
  },
47
47
  "devDependencies": {
48
48
  "@oxlint/plugins": "1.76.0",