@deftai/directive-core 0.105.0 → 0.107.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (173) hide show
  1. package/dist/authz/classify.js +591 -34
  2. package/dist/check/gate-lists.js +2 -0
  3. package/dist/check/named-cause.js +1 -1
  4. package/dist/design-critique/exclusive-chip.d.ts +33 -0
  5. package/dist/design-critique/exclusive-chip.js +68 -0
  6. package/dist/design-critique/parent-audit.d.ts +59 -0
  7. package/dist/design-critique/parent-audit.js +121 -0
  8. package/dist/doctor/checks.d.ts +6 -0
  9. package/dist/doctor/checks.js +35 -0
  10. package/dist/forward-coverage/diff-coverage.d.ts +39 -0
  11. package/dist/forward-coverage/diff-coverage.js +112 -0
  12. package/dist/forward-coverage/diff-lines.d.ts +19 -0
  13. package/dist/forward-coverage/diff-lines.js +173 -0
  14. package/dist/forward-coverage/evaluate.d.ts +24 -9
  15. package/dist/forward-coverage/evaluate.js +125 -19
  16. package/dist/hooks/dest-form.d.ts +46 -0
  17. package/dist/hooks/dest-form.js +575 -0
  18. package/dist/hooks/dispatcher.d.ts +17 -1
  19. package/dist/hooks/dispatcher.js +82 -11
  20. package/dist/hooks/index.d.ts +1 -0
  21. package/dist/hooks/index.js +1 -0
  22. package/dist/init-deposit/agent-hooks.d.ts +1 -1
  23. package/dist/init-deposit/agent-hooks.js +1 -1
  24. package/dist/intake/clause-derivation.d.ts +9 -0
  25. package/dist/intake/clause-derivation.js +30 -3
  26. package/dist/intake/github-auth-modes-cli.js +14 -2
  27. package/dist/intake/github-auth-modes.d.ts +54 -9
  28. package/dist/intake/github-auth-modes.js +326 -123
  29. package/dist/lifecycle/completed-tracked-on-delivery.d.ts +26 -0
  30. package/dist/lifecycle/completed-tracked-on-delivery.js +37 -14
  31. package/dist/lifecycle/completed-write-guard.d.ts +48 -0
  32. package/dist/lifecycle/completed-write-guard.js +373 -0
  33. package/dist/lifecycle/index.d.ts +1 -0
  34. package/dist/lifecycle/index.js +1 -0
  35. package/dist/lifecycle-visible/evaluate.d.ts +98 -0
  36. package/dist/lifecycle-visible/evaluate.js +710 -0
  37. package/dist/lifecycle-visible/index.d.ts +2 -0
  38. package/dist/lifecycle-visible/index.js +2 -0
  39. package/dist/literal-acceptance/capture.d.ts +16 -0
  40. package/dist/literal-acceptance/capture.js +78 -10
  41. package/dist/literal-acceptance/evaluate.js +25 -3
  42. package/dist/literal-acceptance/index.d.ts +2 -2
  43. package/dist/literal-acceptance/index.js +2 -2
  44. package/dist/literal-acceptance/run.js +9 -3
  45. package/dist/literal-acceptance/types.d.ts +6 -0
  46. package/dist/literal-acceptance/types.js +6 -0
  47. package/dist/orchestration/judgment-policy.d.ts +13 -0
  48. package/dist/orchestration/judgment-policy.js +15 -0
  49. package/dist/orchestration/probe-session.js +13 -8
  50. package/dist/orchestration/verify-judgment-gates.d.ts +4 -0
  51. package/dist/orchestration/verify-judgment-gates.js +43 -11
  52. package/dist/policy/ceremony-dial.js +7 -7
  53. package/dist/policy/host-hooks.d.ts +20 -0
  54. package/dist/policy/host-hooks.js +121 -2
  55. package/dist/policy/org-force-on-migration.js +2 -1
  56. package/dist/policy/plan-extensions.d.ts +1 -1
  57. package/dist/policy/plan-extensions.js +2 -0
  58. package/dist/policy/product-signal.js +6 -7
  59. package/dist/policy/require-human-merge.js +8 -6
  60. package/dist/policy/resolve.d.ts +7 -2
  61. package/dist/policy/resolve.js +25 -8
  62. package/dist/policy/runtime-authority.d.ts +18 -0
  63. package/dist/policy/runtime-authority.js +11 -0
  64. package/dist/policy/value-feedback.js +7 -8
  65. package/dist/policy/write-fence.js +3 -0
  66. package/dist/preflight-cache/evaluate.d.ts +12 -0
  67. package/dist/preflight-cache/evaluate.js +24 -4
  68. package/dist/preflight-cache/index.d.ts +1 -1
  69. package/dist/preflight-cache/index.js +1 -1
  70. package/dist/product-first-done-gate/evaluate.d.ts +4 -0
  71. package/dist/product-first-done-gate/evaluate.js +82 -10
  72. package/dist/release/closed-verb-gate.d.ts +12 -0
  73. package/dist/release/closed-verb-gate.js +51 -0
  74. package/dist/release/index.d.ts +1 -0
  75. package/dist/release/index.js +1 -0
  76. package/dist/release/pipeline.js +7 -0
  77. package/dist/release/types.d.ts +12 -0
  78. package/dist/render/constants.d.ts +2 -1
  79. package/dist/render/constants.js +2 -1
  80. package/dist/render/framework-commands.js +4 -0
  81. package/dist/render/spec-validate.js +23 -12
  82. package/dist/run-summary/types.d.ts +4 -0
  83. package/dist/scm/design-critique-chip.d.ts +37 -0
  84. package/dist/scm/design-critique-chip.js +171 -0
  85. package/dist/scm/index.d.ts +1 -0
  86. package/dist/scm/index.js +1 -0
  87. package/dist/scm/main.d.ts +3 -0
  88. package/dist/scm/main.js +15 -1
  89. package/dist/scm/readiness-cli.d.ts +2 -0
  90. package/dist/scm/readiness-cli.js +42 -1
  91. package/dist/scm/readiness.d.ts +2 -1
  92. package/dist/scm/readiness.js +7 -1
  93. package/dist/scope/decompose.js +2 -1
  94. package/dist/scope/delivery-evidence.d.ts +27 -1
  95. package/dist/scope/index.d.ts +1 -0
  96. package/dist/scope/index.js +1 -0
  97. package/dist/scope/lifecycle-write.d.ts +24 -0
  98. package/dist/scope/lifecycle-write.js +64 -0
  99. package/dist/scope/main.js +18 -6
  100. package/dist/scope/promote-from-issue.js +5 -5
  101. package/dist/scope/transition.js +90 -11
  102. package/dist/scope-provenance/evaluate.js +8 -4
  103. package/dist/session/ac-pass-banking.d.ts +5 -0
  104. package/dist/session/ac-pass-banking.js +3 -2
  105. package/dist/session/git.d.ts +18 -0
  106. package/dist/session/git.js +81 -1
  107. package/dist/session/occupancy.d.ts +2 -1
  108. package/dist/session/occupancy.js +11 -4
  109. package/dist/session/process-cost.d.ts +32 -10
  110. package/dist/session/process-cost.js +189 -1
  111. package/dist/session/product-state-hash.js +41 -24
  112. package/dist/session/ritual-entrypoint.js +16 -1
  113. package/dist/session/session-start.d.ts +18 -0
  114. package/dist/session/session-start.js +49 -2
  115. package/dist/session/verify-session-ritual.d.ts +46 -4
  116. package/dist/session/verify-session-ritual.js +118 -10
  117. package/dist/swarm/finalize-cohort-cli.d.ts +7 -2
  118. package/dist/swarm/finalize-cohort-cli.js +165 -35
  119. package/dist/swarm/finalize-cohort.d.ts +1 -0
  120. package/dist/swarm/finalize-cohort.js +14 -2
  121. package/dist/swarm/launch.d.ts +69 -0
  122. package/dist/swarm/launch.js +191 -1
  123. package/dist/swarm/routing-set-cli.js +2 -0
  124. package/dist/swarm/routing-verify.d.ts +1 -1
  125. package/dist/swarm/routing-verify.js +2 -2
  126. package/dist/triage/actions/index.d.ts +4 -0
  127. package/dist/triage/actions/index.js +6 -0
  128. package/dist/triage/bootstrap/index.js +6 -6
  129. package/dist/triage/evaluate/evaluate.d.ts +11 -0
  130. package/dist/triage/evaluate/evaluate.js +169 -0
  131. package/dist/triage/evaluate/github.d.ts +16 -0
  132. package/dist/triage/evaluate/github.js +153 -0
  133. package/dist/triage/evaluate/index.d.ts +9 -0
  134. package/dist/triage/evaluate/index.js +8 -0
  135. package/dist/triage/evaluate/paths.d.ts +7 -0
  136. package/dist/triage/evaluate/paths.js +25 -0
  137. package/dist/triage/evaluate/sink.d.ts +5 -0
  138. package/dist/triage/evaluate/sink.js +52 -0
  139. package/dist/triage/evaluate/types.d.ts +93 -0
  140. package/dist/triage/evaluate/types.js +13 -0
  141. package/dist/triage/evaluate/validity.d.ts +8 -0
  142. package/dist/triage/evaluate/validity.js +87 -0
  143. package/dist/triage/evaluate/value.d.ts +12 -0
  144. package/dist/triage/evaluate/value.js +33 -0
  145. package/dist/triage/evaluate/wip-census.d.ts +5 -0
  146. package/dist/triage/evaluate/wip-census.js +39 -0
  147. package/dist/triage/evaluate/worktrees.d.ts +8 -0
  148. package/dist/triage/evaluate/worktrees.js +68 -0
  149. package/dist/triage/evaluate/xbrief-refs.d.ts +8 -0
  150. package/dist/triage/evaluate/xbrief-refs.js +60 -0
  151. package/dist/triage/help/registry-data.d.ts +22 -8
  152. package/dist/triage/help/registry-data.js +54 -6
  153. package/dist/triage/index.d.ts +1 -0
  154. package/dist/triage/index.js +1 -0
  155. package/dist/triage/welcome/writers.d.ts +1 -1
  156. package/dist/triage/welcome/writers.js +10 -5
  157. package/dist/value/feedback-file.d.ts +17 -2
  158. package/dist/value/feedback-file.js +80 -6
  159. package/dist/value/readback.d.ts +4 -1
  160. package/dist/value/readback.js +51 -12
  161. package/dist/vbrief-reconcile/labels.d.ts +1 -0
  162. package/dist/vbrief-reconcile/labels.js +30 -0
  163. package/dist/vbrief-validate/schema.d.ts +4 -0
  164. package/dist/vbrief-validate/schema.js +1 -1
  165. package/dist/vbrief-validation/story-quality.d.ts +7 -0
  166. package/dist/vbrief-validation/story-quality.js +8 -1
  167. package/dist/verify-env/agent-hook-readiness.d.ts +2 -2
  168. package/dist/verify-env/agent-hook-readiness.js +12 -8
  169. package/dist/verify-env/agent-hooks-live-probe.d.ts +5 -1
  170. package/dist/verify-env/agent-hooks-live-probe.js +26 -5
  171. package/dist/verify-env/agent-hooks.js +3 -4
  172. package/dist/xbrief-migrate/agents-header.js +69 -7
  173. package/package.json +7 -3
@@ -7,12 +7,18 @@ export const KNOWN_GITHUB_AUTH_MODES = new Set([
7
7
  GITHUB_AUTH_MODE_INJECTED_TOKEN,
8
8
  GITHUB_AUTH_MODE_HOST_GH,
9
9
  ]);
10
- export const DEFAULT_VALIDATION_REPO = "deftai/directive";
10
+ export const PRINCIPAL_KIND_USER = "user";
11
11
  export const FAILURE_MISSING_INJECTED_TOKEN = "missing_injected_token";
12
12
  export const FAILURE_GH_AUTH = "gh_auth_failed";
13
13
  export const FAILURE_API_UNREACHABLE = "api_unreachable";
14
14
  export const FAILURE_REPO_ACCESS = "repo_access_denied";
15
15
  export const FAILURE_INVALID_MODE = "invalid_auth_mode";
16
+ export const FAILURE_MISSING_EXPECTED_PRINCIPAL = "missing_expected_principal";
17
+ export const FAILURE_PRINCIPAL_MISMATCH = "principal_mismatch";
18
+ export const FAILURE_MISSING_TARGET_REPO = "missing_target_repo";
19
+ export const FAILURE_INSTALLATION_IDENTITY_UNVERIFIABLE = "installation_identity_unverifiable";
20
+ export const ENV_EXPECTED_GITHUB_LOGIN = "DEFT_EXPECTED_GITHUB_LOGIN";
21
+ export const INSTALLATION_IDENTITY_ISSUE_URL = "https://github.com/deftai/directive/issues/3693";
16
22
  const INJECTED_TOKEN_ENV_VARS = ["GH_TOKEN", "GITHUB_TOKEN", "GH_ENTERPRISE_TOKEN"];
17
23
  const SANDBOX_REMEDIATION = "Remediation options for worker sandbox GitHub auth failures:\n" +
18
24
  " - Run the GitHub step with full-access execution\n" +
@@ -22,6 +28,15 @@ const REPO_ACCESS_REMEDIATION = "Remediation options for repo-access failures:\n
22
28
  " - Confirm the worker credential can read the target repository\n" +
23
29
  " - Run the GitHub step with full-access execution if host gh has access\n" +
24
30
  " - Use injected-token handoff scoped to the required repository";
31
+ const PRINCIPAL_REMEDIATION = "Remediation for GitHub worker-principal failures:\n" +
32
+ " - User-bearing credential: pass expectedPrincipal.login or set DEFT_EXPECTED_GITHUB_LOGIN\n" +
33
+ " - Do not treat a /user 403 on an installation token as API unreachability\n" +
34
+ " - GitHub App installation identity cannot be verified from the token; see #3693";
35
+ const INSTALLATION_IDENTITY_REMEDIATION = "A GitHub App installation token cannot prove which App it belongs to.\n" +
36
+ `Tracked in ${INSTALLATION_IDENTITY_ISSUE_URL}. Do not treat a /user 403 as API unreachability.`;
37
+ const TARGET_REPO_REMEDIATION = "Remediation for missing target repository:\n" +
38
+ " - Pass repo as owner/repo, or set GH_REPO / GITHUB_REPOSITORY, or run inside a git checkout with origin\n" +
39
+ " - Do not fall back to a hard-coded public default";
25
40
  export function findInjectedToken(environ) {
26
41
  for (const name of INJECTED_TOKEN_ENV_VARS) {
27
42
  const value = environ[name]?.trim() ?? "";
@@ -63,6 +78,113 @@ function defaultRunGh(args, environ) {
63
78
  return { args: [binary, ...args], returncode: 1, stdout: "", stderr: message };
64
79
  }
65
80
  }
81
+ export function defaultReadGitOriginUrl(cwd) {
82
+ try {
83
+ const result = spawnSync("git", ["remote", "get-url", "origin"], {
84
+ cwd,
85
+ encoding: "utf8",
86
+ timeout: 10_000,
87
+ stdio: ["ignore", "pipe", "pipe"],
88
+ });
89
+ if ((result.status ?? 1) !== 0) {
90
+ return null;
91
+ }
92
+ const url = (typeof result.stdout === "string" ? result.stdout : "").trim();
93
+ return url.length > 0 ? url : null;
94
+ }
95
+ catch {
96
+ return null;
97
+ }
98
+ }
99
+ export function parseOwnerRepoSlug(input) {
100
+ const raw = input.trim();
101
+ if (raw.length === 0) {
102
+ return null;
103
+ }
104
+ const direct = raw.match(/^([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)$/);
105
+ if (direct) {
106
+ return `${direct[1]}/${direct[2]}`;
107
+ }
108
+ const stripped = raw.replace(/\.git$/i, "");
109
+ // SCP form including GitHub Enterprise: git@host:owner/repo
110
+ if (!stripped.includes("://")) {
111
+ const scp = stripped.match(/:([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)$/);
112
+ if (scp) {
113
+ return `${scp[1]}/${scp[2]}`;
114
+ }
115
+ }
116
+ const fromUrl = stripped.match(/[:/]([A-Za-z0-9._-]+)\/([A-Za-z0-9._-]+)$/);
117
+ if (fromUrl) {
118
+ return `${fromUrl[1]}/${fromUrl[2]}`;
119
+ }
120
+ return null;
121
+ }
122
+ export function deriveValidationRepo(options) {
123
+ if (options.repo !== undefined && options.repo !== null) {
124
+ const parsed = parseOwnerRepoSlug(options.repo);
125
+ if (parsed === null) {
126
+ return {
127
+ ok: false,
128
+ detail: `invalid repository slug: ${JSON.stringify(options.repo)} (expected owner/repo)`,
129
+ };
130
+ }
131
+ return { ok: true, repo: parsed };
132
+ }
133
+ const env = options.environ ?? {};
134
+ const fromEnv = (env.GH_REPO ?? env.GITHUB_REPOSITORY ?? "").trim();
135
+ if (fromEnv.length > 0) {
136
+ const parsed = parseOwnerRepoSlug(fromEnv);
137
+ if (parsed === null) {
138
+ return {
139
+ ok: false,
140
+ detail: `invalid repository slug from GH_REPO/GITHUB_REPOSITORY: ${JSON.stringify(fromEnv)}`,
141
+ };
142
+ }
143
+ return { ok: true, repo: parsed };
144
+ }
145
+ const remote = options.gitRemoteUrl !== undefined && options.gitRemoteUrl !== null
146
+ ? options.gitRemoteUrl
147
+ : (options.readGitRemote ?? defaultReadGitOriginUrl)(options.cwd);
148
+ if (typeof remote === "string" && remote.trim().length > 0) {
149
+ const parsed = parseOwnerRepoSlug(remote);
150
+ if (parsed === null) {
151
+ return {
152
+ ok: false,
153
+ detail: `invalid git origin URL for repository derivation: ${JSON.stringify(remote.trim())}`,
154
+ };
155
+ }
156
+ return { ok: true, repo: parsed };
157
+ }
158
+ return {
159
+ ok: false,
160
+ detail: "cannot derive target repository; pass repo as owner/repo, set GH_REPO or GITHUB_REPOSITORY, or run inside a git checkout with origin",
161
+ };
162
+ }
163
+ export function resolveExpectedGithubWorkerPrincipal(environ, explicit) {
164
+ if (explicit === null) {
165
+ return null;
166
+ }
167
+ if (explicit !== undefined) {
168
+ return normalizeExpectedPrincipal(explicit);
169
+ }
170
+ const login = environ[ENV_EXPECTED_GITHUB_LOGIN]?.trim() ?? "";
171
+ if (login.length > 0) {
172
+ return { kind: PRINCIPAL_KIND_USER, login };
173
+ }
174
+ return null;
175
+ }
176
+ function normalizeExpectedPrincipal(principal) {
177
+ if (principal.kind !== PRINCIPAL_KIND_USER) {
178
+ return {
179
+ error: `unknown expected principal kind ${pyRepr(principal.kind)}`,
180
+ };
181
+ }
182
+ const login = principal.login.trim();
183
+ if (login.length === 0) {
184
+ return { error: "user principal requires a non-empty login" };
185
+ }
186
+ return { kind: PRINCIPAL_KIND_USER, login };
187
+ }
66
188
  function splitRepo(repo) {
67
189
  const idx = repo.indexOf("/");
68
190
  if (idx <= 0 || idx >= repo.length - 1) {
@@ -76,13 +198,30 @@ function sandboxRemediation(runtimeMode, failureKind) {
76
198
  }
77
199
  if (failureKind === FAILURE_GH_AUTH ||
78
200
  failureKind === FAILURE_API_UNREACHABLE ||
79
- failureKind === FAILURE_REPO_ACCESS) {
201
+ failureKind === FAILURE_REPO_ACCESS ||
202
+ failureKind === FAILURE_MISSING_EXPECTED_PRINCIPAL ||
203
+ failureKind === FAILURE_PRINCIPAL_MISMATCH ||
204
+ failureKind === FAILURE_MISSING_TARGET_REPO ||
205
+ failureKind === FAILURE_INSTALLATION_IDENTITY_UNVERIFIABLE) {
80
206
  return SANDBOX_REMEDIATION;
81
207
  }
82
208
  return null;
83
209
  }
84
- function repoAccessRemediation(failureKind) {
85
- return failureKind === FAILURE_REPO_ACCESS ? REPO_ACCESS_REMEDIATION : null;
210
+ function extraRemediation(failureKind) {
211
+ if (failureKind === FAILURE_REPO_ACCESS) {
212
+ return REPO_ACCESS_REMEDIATION;
213
+ }
214
+ if (failureKind === FAILURE_INSTALLATION_IDENTITY_UNVERIFIABLE) {
215
+ return INSTALLATION_IDENTITY_REMEDIATION;
216
+ }
217
+ if (failureKind === FAILURE_MISSING_EXPECTED_PRINCIPAL ||
218
+ failureKind === FAILURE_PRINCIPAL_MISMATCH) {
219
+ return PRINCIPAL_REMEDIATION;
220
+ }
221
+ if (failureKind === FAILURE_MISSING_TARGET_REPO) {
222
+ return TARGET_REPO_REMEDIATION;
223
+ }
224
+ return null;
86
225
  }
87
226
  function mergeRemediation(runtimeMode, failureKind) {
88
227
  const parts = [];
@@ -90,9 +229,9 @@ function mergeRemediation(runtimeMode, failureKind) {
90
229
  if (sandbox !== null) {
91
230
  parts.push(sandbox);
92
231
  }
93
- const repo = repoAccessRemediation(failureKind);
94
- if (repo !== null && !parts.includes(repo)) {
95
- parts.push(repo);
232
+ const extra = extraRemediation(failureKind);
233
+ if (extra !== null && !parts.includes(extra)) {
234
+ parts.push(extra);
96
235
  }
97
236
  return parts.length > 0 ? parts.join("\n\n") : null;
98
237
  }
@@ -118,147 +257,193 @@ function parseLogin(stdout) {
118
257
  }
119
258
  return null;
120
259
  }
121
- export function validateInjectedTokenMode(environ, options = {}) {
122
- const runner = options.runGh ?? defaultRunGh;
123
- const repo = options.repo ?? DEFAULT_VALIDATION_REPO;
124
- const runtimeMode = options.runtimeMode ?? null;
125
- const token = findInjectedToken(environ);
126
- if (token === null) {
127
- return {
128
- ok: false,
129
- githubAuthMode: GITHUB_AUTH_MODE_INJECTED_TOKEN,
130
- runtimeMode,
131
- failureKind: FAILURE_MISSING_INJECTED_TOKEN,
132
- detail: "injected-token mode requires GH_TOKEN, GITHUB_TOKEN, or GH_ENTERPRISE_TOKEN; host gh credential store is not used",
133
- remediation: null,
134
- login: null,
135
- };
260
+ function combinedGhText(proc) {
261
+ return `${proc.stdout}\n${proc.stderr}`;
262
+ }
263
+ export function isInstallationUserEndpointInapplicable(proc) {
264
+ const blob = combinedGhText(proc).toLowerCase();
265
+ return (blob.includes("resource not accessible by integration") ||
266
+ blob.includes("not accessible by integration"));
267
+ }
268
+ function clipFailureText(text, max = 240) {
269
+ const compact = text.replace(/\s+/g, " ").trim();
270
+ if (compact.length === 0) {
271
+ return "";
136
272
  }
137
- const authStatus = runner(["auth", "status"], environ);
138
- if (authStatus.returncode !== 0) {
139
- return {
140
- ok: false,
141
- githubAuthMode: GITHUB_AUTH_MODE_INJECTED_TOKEN,
142
- runtimeMode,
143
- failureKind: FAILURE_GH_AUTH,
144
- detail: "injected token present but gh auth status failed in worker",
145
- remediation: mergeRemediation(runtimeMode, FAILURE_GH_AUTH),
146
- login: null,
147
- };
273
+ if (compact.length <= max) {
274
+ return compact;
148
275
  }
149
- const userApi = runner(["api", "user", "--jq", ".login"], environ);
150
- if (userApi.returncode !== 0) {
151
- return {
152
- ok: false,
153
- githubAuthMode: GITHUB_AUTH_MODE_INJECTED_TOKEN,
154
- runtimeMode,
155
- failureKind: FAILURE_API_UNREACHABLE,
156
- detail: "injected token present but GitHub API is unreachable",
157
- remediation: mergeRemediation(runtimeMode, FAILURE_API_UNREACHABLE),
158
- login: null,
159
- };
276
+ return `${compact.slice(0, max - 3)}...`;
277
+ }
278
+ function parseGhApiMessage(proc) {
279
+ for (const chunk of [proc.stdout, proc.stderr]) {
280
+ const text = chunk.trim();
281
+ if (text.length === 0) {
282
+ continue;
283
+ }
284
+ try {
285
+ const payload = JSON.parse(text);
286
+ if (payload !== null && typeof payload === "object" && !Array.isArray(payload)) {
287
+ const message = payload.message;
288
+ if (typeof message === "string" && message.length > 0) {
289
+ const status = payload.status;
290
+ if (typeof status === "string" || typeof status === "number") {
291
+ return `${message} (HTTP ${status})`;
292
+ }
293
+ return message;
294
+ }
295
+ }
296
+ }
297
+ catch {
298
+ // fall through to raw text
299
+ }
160
300
  }
161
- const login = parseLogin(userApi.stdout);
301
+ return null;
302
+ }
303
+ function isNetworkUnreachableText(text) {
304
+ return /timed out|timeout|econnrefused|enotfound|eai_again|network is unreachable|could not resolve host/i.test(text);
305
+ }
306
+ export function formatUserApiFailureDetail(mode, proc) {
307
+ const parsed = parseGhApiMessage(proc);
308
+ const raw = clipFailureText(combinedGhText(proc));
309
+ const cause = parsed ?? (raw.length > 0 ? raw : `exit ${proc.returncode}`);
310
+ const prefix = mode === GITHUB_AUTH_MODE_INJECTED_TOKEN
311
+ ? "injected token present but GitHub /user failed"
312
+ : "gh auth status passed but GitHub /user failed";
313
+ if (isNetworkUnreachableText(cause) || isNetworkUnreachableText(raw)) {
314
+ return `${prefix}: GitHub API is unreachable (${cause})`;
315
+ }
316
+ return `${prefix}: ${cause}`;
317
+ }
318
+ function emptyResult(mode, runtimeMode, failureKind, detail, options = {}) {
319
+ const ok = options.ok ?? false;
320
+ return {
321
+ ok,
322
+ githubAuthMode: mode,
323
+ runtimeMode,
324
+ failureKind: ok ? null : failureKind,
325
+ detail,
326
+ remediation: ok ? null : mergeRemediation(runtimeMode, failureKind ?? ""),
327
+ login: options.login ?? null,
328
+ principal: options.principal ?? null,
329
+ validationRepo: options.validationRepo ?? null,
330
+ };
331
+ }
332
+ function loginsMatch(expected, observed) {
333
+ return expected.localeCompare(observed, undefined, { sensitivity: "accent" }) === 0;
334
+ }
335
+ function failClosedInstallationCredential(mode, runtimeMode, repo) {
336
+ return emptyResult(mode, runtimeMode, FAILURE_INSTALLATION_IDENTITY_UNVERIFIABLE, "GitHub /user is inapplicable to a GitHub App installation credential (no authenticated user). " +
337
+ "The API is reachable. An installation token cannot disclose which App it belongs to, " +
338
+ `so identity cannot be verified. Deferred to ${INSTALLATION_IDENTITY_ISSUE_URL}.`, { validationRepo: repo });
339
+ }
340
+ function checkTargetRepoAccess(mode, runtimeMode, runner, environ, repo, login, principal) {
162
341
  const [owner, name] = splitRepo(repo);
163
342
  const repoApi = runner(["api", `repos/${owner}/${name}`], environ);
164
343
  if (repoApi.returncode !== 0) {
165
- return {
166
- ok: false,
167
- githubAuthMode: GITHUB_AUTH_MODE_INJECTED_TOKEN,
168
- runtimeMode,
169
- failureKind: FAILURE_REPO_ACCESS,
170
- detail: `injected token can reach GitHub API but cannot access ${repo}`,
171
- remediation: mergeRemediation(runtimeMode, FAILURE_REPO_ACCESS),
344
+ const detail = mode === GITHUB_AUTH_MODE_INJECTED_TOKEN
345
+ ? `injected token can reach GitHub API but cannot access ${repo}`
346
+ : `GitHub API reachable but repository access failed for ${repo}`;
347
+ return emptyResult(mode, runtimeMode, FAILURE_REPO_ACCESS, detail, {
172
348
  login,
173
- };
349
+ principal,
350
+ validationRepo: repo,
351
+ });
174
352
  }
175
- return {
353
+ const okDetail = mode === GITHUB_AUTH_MODE_INJECTED_TOKEN
354
+ ? "injected-token mode validated in worker environment"
355
+ : "host-gh mode validated in worker environment";
356
+ return emptyResult(mode, runtimeMode, null, okDetail, {
176
357
  ok: true,
177
- githubAuthMode: GITHUB_AUTH_MODE_INJECTED_TOKEN,
178
- runtimeMode,
179
- failureKind: null,
180
- detail: "injected-token mode validated in worker environment",
181
- remediation: null,
182
358
  login,
183
- };
359
+ principal,
360
+ validationRepo: repo,
361
+ });
184
362
  }
185
- export function validateHostGhMode(environ, options = {}) {
363
+ function validateAfterAuth(mode, environ, options, runtimeMode, runner) {
364
+ const derived = deriveValidationRepo({
365
+ repo: options.repo,
366
+ environ,
367
+ gitRemoteUrl: options.gitRemoteUrl,
368
+ readGitRemote: options.readGitRemote,
369
+ cwd: options.cwd,
370
+ });
371
+ if (!derived.ok) {
372
+ return emptyResult(mode, runtimeMode, FAILURE_MISSING_TARGET_REPO, derived.detail);
373
+ }
374
+ const repo = derived.repo;
375
+ const expectedPrincipal = resolveExpectedGithubWorkerPrincipal(environ, options.expectedPrincipal);
376
+ if (expectedPrincipal !== null && "error" in expectedPrincipal && options.expectedPrincipal) {
377
+ return emptyResult(mode, runtimeMode, FAILURE_MISSING_EXPECTED_PRINCIPAL, expectedPrincipal.error, {
378
+ validationRepo: repo,
379
+ });
380
+ }
381
+ const userApi = runner(["api", "user"], environ);
382
+ if (userApi.returncode !== 0) {
383
+ if (isInstallationUserEndpointInapplicable(userApi)) {
384
+ return failClosedInstallationCredential(mode, runtimeMode, repo);
385
+ }
386
+ return emptyResult(mode, runtimeMode, FAILURE_API_UNREACHABLE, formatUserApiFailureDetail(mode, userApi), { validationRepo: repo });
387
+ }
388
+ const login = parseLogin(userApi.stdout);
389
+ if (login === null) {
390
+ return emptyResult(mode, runtimeMode, FAILURE_PRINCIPAL_MISMATCH, "GitHub /user succeeded but returned no login", { validationRepo: repo });
391
+ }
392
+ if (expectedPrincipal !== null && "error" in expectedPrincipal) {
393
+ return emptyResult(mode, runtimeMode, FAILURE_MISSING_EXPECTED_PRINCIPAL, expectedPrincipal.error, {
394
+ login,
395
+ validationRepo: repo,
396
+ });
397
+ }
398
+ if (expectedPrincipal !== null) {
399
+ if (!loginsMatch(expectedPrincipal.login, login)) {
400
+ return emptyResult(mode, runtimeMode, FAILURE_PRINCIPAL_MISMATCH, `identity mismatch: expected ${expectedPrincipal.login}, observed ${login}`, { login, validationRepo: repo });
401
+ }
402
+ }
403
+ const principal = { kind: PRINCIPAL_KIND_USER, login };
404
+ return checkTargetRepoAccess(mode, runtimeMode, runner, environ, repo, login, principal);
405
+ }
406
+ export function validateInjectedTokenMode(environ, options = {}) {
186
407
  const runner = options.runGh ?? defaultRunGh;
187
- const repo = options.repo ?? DEFAULT_VALIDATION_REPO;
188
408
  const runtimeMode = options.runtimeMode ?? null;
409
+ const tokenPresent = findInjectedToken(environ) !== null;
410
+ if (!tokenPresent) {
411
+ return emptyResult(GITHUB_AUTH_MODE_INJECTED_TOKEN, runtimeMode, FAILURE_MISSING_INJECTED_TOKEN, "injected-token mode requires GH_TOKEN, GITHUB_TOKEN, or GH_ENTERPRISE_TOKEN; host gh credential store is not used");
412
+ }
189
413
  const authStatus = runner(["auth", "status"], environ);
190
414
  if (authStatus.returncode !== 0) {
191
- return {
192
- ok: false,
193
- githubAuthMode: GITHUB_AUTH_MODE_HOST_GH,
194
- runtimeMode,
195
- failureKind: FAILURE_GH_AUTH,
196
- detail: "gh auth status failed in worker environment",
197
- remediation: mergeRemediation(runtimeMode, FAILURE_GH_AUTH),
198
- login: null,
199
- };
200
- }
201
- const userApi = runner(["api", "user", "--jq", ".login"], environ);
202
- if (userApi.returncode !== 0) {
203
- return {
204
- ok: false,
205
- githubAuthMode: GITHUB_AUTH_MODE_HOST_GH,
206
- runtimeMode,
207
- failureKind: FAILURE_API_UNREACHABLE,
208
- detail: "gh auth status passed but GitHub API is unreachable",
209
- remediation: mergeRemediation(runtimeMode, FAILURE_API_UNREACHABLE),
210
- login: null,
211
- };
415
+ return emptyResult(GITHUB_AUTH_MODE_INJECTED_TOKEN, runtimeMode, FAILURE_GH_AUTH, "injected token present but gh auth status failed in worker");
212
416
  }
213
- const [owner, name] = splitRepo(repo);
214
- const repoApi = runner(["api", `repos/${owner}/${name}`], environ);
215
- if (repoApi.returncode !== 0) {
216
- return {
217
- ok: false,
218
- githubAuthMode: GITHUB_AUTH_MODE_HOST_GH,
219
- runtimeMode,
220
- failureKind: FAILURE_REPO_ACCESS,
221
- detail: `GitHub API reachable but repository access failed for ${repo}`,
222
- remediation: mergeRemediation(runtimeMode, FAILURE_REPO_ACCESS),
223
- login: parseLogin(userApi.stdout),
224
- };
417
+ return validateAfterAuth(GITHUB_AUTH_MODE_INJECTED_TOKEN, environ, options, runtimeMode, runner);
418
+ }
419
+ export function validateHostGhMode(environ, options = {}) {
420
+ const runner = options.runGh ?? defaultRunGh;
421
+ const runtimeMode = options.runtimeMode ?? null;
422
+ const authStatus = runner(["auth", "status"], environ);
423
+ if (authStatus.returncode !== 0) {
424
+ return emptyResult(GITHUB_AUTH_MODE_HOST_GH, runtimeMode, FAILURE_GH_AUTH, "gh auth status failed in worker environment");
225
425
  }
226
- return {
227
- ok: true,
228
- githubAuthMode: GITHUB_AUTH_MODE_HOST_GH,
229
- runtimeMode,
230
- failureKind: null,
231
- detail: "host-gh mode validated in worker environment",
232
- remediation: null,
233
- login: parseLogin(userApi.stdout),
234
- };
426
+ return validateAfterAuth(GITHUB_AUTH_MODE_HOST_GH, environ, options, runtimeMode, runner);
235
427
  }
236
428
  export function validateGithubAuth(githubAuthMode, options = {}) {
237
429
  const env = options.environ ?? process.env;
238
430
  const runtimeMode = options.runtimeReport?.runtimeMode ?? null;
239
431
  if (!KNOWN_GITHUB_AUTH_MODES.has(githubAuthMode)) {
240
- return {
241
- ok: false,
242
- githubAuthMode,
243
- runtimeMode,
244
- failureKind: FAILURE_INVALID_MODE,
245
- detail: `unknown github_auth_mode ${pyRepr(githubAuthMode)}; expected one of ${pyRepr([...KNOWN_GITHUB_AUTH_MODES].sort())}`,
246
- remediation: null,
247
- login: null,
248
- };
249
- }
250
- if (githubAuthMode === GITHUB_AUTH_MODE_INJECTED_TOKEN) {
251
- return validateInjectedTokenMode(env, {
252
- repo: options.repo,
253
- runtimeMode,
254
- runGh: options.runGh,
255
- });
432
+ return emptyResult(githubAuthMode, runtimeMode, FAILURE_INVALID_MODE, `unknown github_auth_mode ${pyRepr(githubAuthMode)}; expected one of ${pyRepr([...KNOWN_GITHUB_AUTH_MODES].sort())}`);
256
433
  }
257
- return validateHostGhMode(env, {
434
+ const shared = {
258
435
  repo: options.repo,
259
436
  runtimeMode,
260
437
  runGh: options.runGh,
261
- });
438
+ expectedPrincipal: options.expectedPrincipal,
439
+ gitRemoteUrl: options.gitRemoteUrl,
440
+ readGitRemote: options.readGitRemote,
441
+ cwd: options.cwd,
442
+ };
443
+ if (githubAuthMode === GITHUB_AUTH_MODE_INJECTED_TOKEN) {
444
+ return validateInjectedTokenMode(env, shared);
445
+ }
446
+ return validateHostGhMode(env, shared);
262
447
  }
263
448
  export function validateGithubAuthForWorker(githubAuthMode = null, options = {}) {
264
449
  const report = options.runtimeReport ?? getPlatformCapabilities();
@@ -274,12 +459,30 @@ export function resultToDict(result) {
274
459
  detail: result.detail,
275
460
  remediation: result.remediation,
276
461
  login: result.login,
462
+ principal_kind: result.principal?.kind ?? null,
463
+ validation_repo: result.validationRepo,
277
464
  };
278
465
  }
466
+ function expectedPrincipalFromCliArgs(args) {
467
+ if (args.expectedPrincipal !== undefined) {
468
+ return args.expectedPrincipal ?? undefined;
469
+ }
470
+ const login = args.expectedLogin?.trim() ?? "";
471
+ if (login.length > 0) {
472
+ return { kind: PRINCIPAL_KIND_USER, login };
473
+ }
474
+ return undefined;
475
+ }
279
476
  export function githubAuthModesMain(args) {
477
+ const fromFlags = expectedPrincipalFromCliArgs(args);
478
+ if (fromFlags !== undefined && "error" in fromFlags) {
479
+ process.stderr.write(`${fromFlags.error}\n`);
480
+ return 2;
481
+ }
280
482
  const result = validateGithubAuthForWorker(args.githubAuthMode ?? null, {
281
- repo: args.repo ?? DEFAULT_VALIDATION_REPO,
483
+ repo: args.repo,
282
484
  runGh: args.runGh,
485
+ expectedPrincipal: fromFlags,
283
486
  });
284
487
  if (args.json) {
285
488
  process.stdout.write(`${JSON.stringify(resultToDict(result), null, 2)}\n`);
@@ -35,7 +35,33 @@ export interface EvaluateCompletedTrackedOptions {
35
35
  readonly runGit?: GitRunner;
36
36
  /** Test seam: override issue state resolution. */
37
37
  readonly resolveIssueState?: (ref: IssueRef) => "open" | "closed" | null;
38
+ /**
39
+ * Progress hook (#3673). Fired after tip listing and after the batch
40
+ * blob read so a CLI can announce an up-front count only when elapsed
41
+ * time crosses a measured threshold.
42
+ */
43
+ readonly onProgress?: (event: CompletedTrackedProgress) => void;
44
+ /** Test seam: override wall clock for progress elapsedMs. */
45
+ readonly now?: () => number;
46
+ }
47
+ /** Default silence window so a ~2s batch read does not emit progress noise. */
48
+ export declare const COMPLETED_TRACKED_PROGRESS_THRESHOLD_MS = 3000;
49
+ /**
50
+ * Up-front count floor (#3673 Greptile P2). Listing is cheap; the batch
51
+ * read is the remaining stall. Announce the blob count after listing
52
+ * (before `cat-file --batch`) when the corpus is large enough that a
53
+ * silent wait is the old DONE-path failure mode. Fixture-sized runs stay
54
+ * quiet unless the duration threshold is also crossed.
55
+ */
56
+ export declare const COMPLETED_TRACKED_PROGRESS_MIN_BLOBS = 32;
57
+ export interface CompletedTrackedProgress {
58
+ readonly phase: "listed" | "read";
59
+ readonly terminalCount: number;
60
+ readonly nonterminalCount: number;
61
+ readonly elapsedMs: number;
38
62
  }
63
+ export declare function shouldAnnounceProgress(elapsedMs: number, thresholdMs?: number): boolean;
64
+ export declare function shouldAnnounceUpFrontCount(terminalCount: number, nonterminalCount: number, minBlobs?: number): boolean;
39
65
  /**
40
66
  * Resolve the git tip to inspect for tracked completed/cancelled land.
41
67
  * Prefers origin/<deliveryBranch> when present.
@@ -16,7 +16,7 @@ import { hasArtifactSuffix, LEGACY_ARTIFACT_DIR, MIGRATED_ARTIFACT_DIR, resolveL
16
16
  import { collectGithubRefs } from "../orphan-active/refs.js";
17
17
  import { resolveDeliveryBranch } from "../policy/delivery-branch.js";
18
18
  import { defaultRunGh } from "../pr-protected-issues/gh.js";
19
- import { defaultGitRunner } from "../session/git.js";
19
+ import { defaultGitRunner, showBlobsBatch } from "../session/git.js";
20
20
  import { CACHE_DIR_NAME, CACHE_SOURCE_GITHUB_ISSUE } from "../triage/queue/constants.js";
21
21
  import { resolveRepo } from "../triage/queue/repo.js";
22
22
  /** Lifecycle folders scanned for local "known origin" xBRIEFs. */
@@ -31,6 +31,22 @@ export const LOCAL_ORIGIN_FOLDERS = [
31
31
  export const TIP_NONTERMINAL_FOLDERS = ["proposed", "pending", "active"];
32
32
  /** Terminal tip folders that satisfy the land rule. */
33
33
  export const TIP_TERMINAL_FOLDERS = ["completed", "cancelled"];
34
+ /** Default silence window so a ~2s batch read does not emit progress noise. */
35
+ export const COMPLETED_TRACKED_PROGRESS_THRESHOLD_MS = 3_000;
36
+ /**
37
+ * Up-front count floor (#3673 Greptile P2). Listing is cheap; the batch
38
+ * read is the remaining stall. Announce the blob count after listing
39
+ * (before `cat-file --batch`) when the corpus is large enough that a
40
+ * silent wait is the old DONE-path failure mode. Fixture-sized runs stay
41
+ * quiet unless the duration threshold is also crossed.
42
+ */
43
+ export const COMPLETED_TRACKED_PROGRESS_MIN_BLOBS = 32;
44
+ export function shouldAnnounceProgress(elapsedMs, thresholdMs = COMPLETED_TRACKED_PROGRESS_THRESHOLD_MS) {
45
+ return elapsedMs >= thresholdMs;
46
+ }
47
+ export function shouldAnnounceUpFrontCount(terminalCount, nonterminalCount, minBlobs = COMPLETED_TRACKED_PROGRESS_MIN_BLOBS) {
48
+ return terminalCount + nonterminalCount >= minBlobs;
49
+ }
34
50
  function readJson(path) {
35
51
  try {
36
52
  const parsed = JSON.parse(readFileSync(path, "utf8"));
@@ -223,13 +239,6 @@ function listTreePaths(projectRoot, tip, prefixes, runGit) {
223
239
  .map((line) => line.trim().replace(/\\/g, "/"))
224
240
  .filter((line) => line.length > 0 && hasArtifactSuffix(line));
225
241
  }
226
- function showBlob(projectRoot, tip, path, runGit) {
227
- const result = runGit(projectRoot, ["show", `${tip}:${path}`]);
228
- if (result.code !== 0) {
229
- return null;
230
- }
231
- return result.stdout;
232
- }
233
242
  function terminalPrefixes() {
234
243
  const out = [];
235
244
  for (const root of [MIGRATED_ARTIFACT_DIR, LEGACY_ARTIFACT_DIR]) {
@@ -248,11 +257,11 @@ function nonterminalPrefixes() {
248
257
  }
249
258
  return out;
250
259
  }
251
- function issuesFromBlobPaths(projectRoot, tip, paths, defaultRepo, runGit, originPrefix) {
260
+ function issuesFromBlobBodies(paths, bodies, defaultRepo, originPrefix) {
252
261
  const hits = [];
253
262
  for (const path of paths) {
254
- const body = showBlob(projectRoot, tip, path, runGit);
255
- if (body === null) {
263
+ const body = bodies.get(path);
264
+ if (body === undefined || body === null) {
256
265
  continue;
257
266
  }
258
267
  let parsed;
@@ -375,17 +384,31 @@ export function evaluateCompletedTracked(projectRoot, options = {}) {
375
384
  tip: null,
376
385
  };
377
386
  }
387
+ const startedAt = (options.now ?? Date.now)();
388
+ const emitProgress = (phase, terminalCount, nonterminalCount) => {
389
+ options.onProgress?.({
390
+ phase,
391
+ terminalCount,
392
+ nonterminalCount,
393
+ elapsedMs: (options.now ?? Date.now)() - startedAt,
394
+ });
395
+ };
378
396
  const localHits = scanLocalOrigins(root, defaultRepo);
379
397
  const tipNonterminalPaths = listTreePaths(root, tip, nonterminalPrefixes(), runGit);
380
- const tipNonterminalHits = issuesFromBlobPaths(root, tip, tipNonterminalPaths, defaultRepo, runGit, `tip:${tip}`);
381
- let originMap = mergeOrigins([...localHits, ...tipNonterminalHits]);
382
398
  // Delivery tip only — the land invariant is post-merge tip truth (#3264 AC).
383
399
  // Lifecycle PRs that commit completed/ on a feature branch are not expected
384
400
  // to satisfy this gate until merge; run the verb with --tip HEAD when
385
401
  // validating an in-flight land PR. The gate is a standalone verify verb
386
402
  // (not wired into check:consumer) so product PRs are not deadlocked.
387
403
  const tipTerminalPaths = listTreePaths(root, tip, terminalPrefixes(), runGit);
388
- const tipTerminalHits = issuesFromBlobPaths(root, tip, tipTerminalPaths, defaultRepo, runGit, `tip:${tip}`);
404
+ emitProgress("listed", tipTerminalPaths.length, tipNonterminalPaths.length);
405
+ // One process for every tip artifact. Issue identity still comes from
406
+ // parsed plan.references / plan.metadata["x-tracking"] — never the path.
407
+ const tipBodies = showBlobsBatch(root, tip, [...tipNonterminalPaths, ...tipTerminalPaths], runGit);
408
+ emitProgress("read", tipTerminalPaths.length, tipNonterminalPaths.length);
409
+ const tipNonterminalHits = issuesFromBlobBodies(tipNonterminalPaths, tipBodies, defaultRepo, `tip:${tip}`);
410
+ let originMap = mergeOrigins([...localHits, ...tipNonterminalHits]);
411
+ const tipTerminalHits = issuesFromBlobBodies(tipTerminalPaths, tipBodies, defaultRepo, `tip:${tip}`);
389
412
  const landedKeys = new Set(tipTerminalHits.map((h) => issueKey(h.issue)));
390
413
  const issueFilter = options.issue ?? null;
391
414
  if (issueFilter !== null) {