@akagilnc/pi-workflow-roles 0.1.3758 → 0.1.3783

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 (63) hide show
  1. package/README.md +3 -2
  2. package/README.zh-CN.md +3 -2
  3. package/dist/acp-host/production-host.js +1735 -948
  4. package/dist/collector-config.js +0 -1
  5. package/dist/collector-github.js +199 -2
  6. package/dist/collector-identity.js +128 -41
  7. package/dist/collector-ledger.js +38 -9
  8. package/dist/collector-receipt.js +19 -7
  9. package/dist/collector-role.js +330 -369
  10. package/dist/collector-target.js +169 -0
  11. package/dist/collector-tool-schemas.js +51 -14
  12. package/dist/headless-host/description.js +77 -0
  13. package/dist/headless-host/mcp-relay.mjs +119 -0
  14. package/dist/headless-host/production-host.js +26590 -0
  15. package/dist/host-descriptions.js +44 -3
  16. package/dist/package-contracts/collector-output.js +32 -0
  17. package/dist/package-contracts/terminating-infrastructure.js +13 -12
  18. package/dist/pi/role-turn-host.js +1 -2
  19. package/dist/public-cli/github-remote.js +45 -0
  20. package/dist/public-cli/invocation.js +53 -54
  21. package/dist/public-cli/load-production-external-host.js +19 -0
  22. package/dist/public-cli/load-production-headless-host.js +36 -0
  23. package/dist/public-cli/main.js +857 -310
  24. package/dist/public-cli/option-definitions.js +6 -4
  25. package/dist/public-cli/run-lifecycle.js +3 -3
  26. package/dist/public-cli/settlement.js +83 -6
  27. package/dist/public-role-summons.js +40 -5
  28. package/dist/role-runtime.js +137 -7
  29. package/dist/submission-correctable-error.js +24 -0
  30. package/extensions/role-runtime.ts +0 -1
  31. package/package.json +1 -1
  32. package/scripts/build-package.mjs +28 -0
  33. package/src/acp-host/role-envelope.ts +146 -103
  34. package/src/acp-host/role-turn-host.ts +12 -0
  35. package/src/collector-config.ts +0 -1
  36. package/src/collector-github.ts +236 -2
  37. package/src/collector-identity.ts +148 -40
  38. package/src/collector-ledger.ts +48 -10
  39. package/src/collector-receipt.ts +33 -14
  40. package/src/collector-role.ts +376 -450
  41. package/src/collector-target.ts +207 -0
  42. package/src/collector-tool-schemas.ts +62 -15
  43. package/src/headless-host/description.ts +123 -0
  44. package/src/headless-host/production-host.ts +75 -0
  45. package/src/headless-host/role-turn-host.ts +424 -0
  46. package/src/host-contracts.ts +2 -1
  47. package/src/host-descriptions.ts +53 -6
  48. package/src/package-contracts/collector-output.ts +72 -0
  49. package/src/package-contracts/terminating-infrastructure.ts +24 -13
  50. package/src/pi/role-turn-host.ts +1 -2
  51. package/src/public-cli/cli.ts +12 -3
  52. package/src/public-cli/collector-run.ts +3 -2
  53. package/src/public-cli/github-remote.ts +45 -0
  54. package/src/public-cli/invocation.ts +60 -59
  55. package/src/public-cli/load-production-external-host.ts +29 -0
  56. package/src/public-cli/load-production-headless-host.ts +48 -0
  57. package/src/public-cli/main.ts +5 -0
  58. package/src/public-cli/option-definitions.ts +6 -4
  59. package/src/public-cli/run-lifecycle.ts +2 -2
  60. package/src/public-cli/settlement.ts +82 -6
  61. package/src/public-role-summons.ts +62 -9
  62. package/src/role-runtime.ts +166 -13
  63. package/src/submission-correctable-error.ts +38 -0
@@ -0,0 +1,424 @@
1
+ /**
2
+ * Generic headless CLI RoleTurnHost (#645 / #752).
3
+ * One process per turn: spawn → read result envelope/exit → structured_output / MCP → envelope.
4
+ * No reads of the host's private home; session id is package-owned binding only.
5
+ * No permanent stdout/stderr/init probe copies — sitian + binding are the dossier.
6
+ */
7
+ import { spawn } from "node:child_process";
8
+ import { randomUUID } from "node:crypto";
9
+ import { writeFile } from "node:fs/promises";
10
+ import { join } from "node:path";
11
+
12
+ import type { RoleTurnHost, RoleTurnKnownFailure, RoleTurnRequest, RoleTurnResult } from "../host-contracts.ts";
13
+ import {
14
+ renderAcpSystemPromptOverride,
15
+ type AcpPreparedTurn,
16
+ type AcpSessionIdentityAuthority,
17
+ } from "../acp-host/role-turn-host.ts";
18
+ import {
19
+ headlessMcpConfigDocument,
20
+ headlessTurnArgs,
21
+ type HeadlessHostDescription,
22
+ } from "./description.ts";
23
+
24
+ export type HeadlessRoleTurnHostConfig = Readonly<{
25
+ description: HeadlessHostDescription;
26
+ sessionIdentity: AcpSessionIdentityAuthority;
27
+ binary: string;
28
+ prepare(request: RoleTurnRequest): Promise<AcpPreparedTurn>;
29
+ env?: NodeJS.ProcessEnv;
30
+ }>;
31
+
32
+ function failure(
33
+ cause: "activation" | "session" | "output" | "provider",
34
+ name: string,
35
+ code: string,
36
+ details?: Readonly<Record<string, unknown>>,
37
+ diagnostic?: string,
38
+ ): RoleTurnResult {
39
+ return {
40
+ code: null,
41
+ stderr: "",
42
+ timedOut: false,
43
+ knownFailure: {
44
+ cause,
45
+ identity: { name, code },
46
+ ...(diagnostic === undefined ? {} : { diagnostic }),
47
+ ...(details === undefined ? {} : { details }),
48
+ },
49
+ };
50
+ }
51
+
52
+ /** One headless CLI result envelope (`--output-format json`). */
53
+ export type HeadlessCliResult = Readonly<{
54
+ session_id?: string;
55
+ is_error?: boolean;
56
+ subtype?: string;
57
+ result?: unknown;
58
+ structured_output?: unknown;
59
+ errors?: unknown;
60
+ permission_denials?: unknown;
61
+ [key: string]: unknown;
62
+ }>;
63
+
64
+ /**
65
+ * Parse host stdout into the result envelope.
66
+ * Production uses `--output-format json` (one document). stream-json last-result
67
+ * parsing remains so a misconfigured description still yields a typed miss rather
68
+ * than a silent empty parse — not a permanent probe path.
69
+ */
70
+ export function parseHeadlessCliStdout(stdout: string): HeadlessCliResult | undefined {
71
+ const trimmed = stdout.trim();
72
+ if (trimmed === "") return undefined;
73
+ try {
74
+ const single = JSON.parse(trimmed) as unknown;
75
+ if (typeof single === "object" && single !== null && !Array.isArray(single)) {
76
+ const record = single as HeadlessCliResult & { type?: unknown };
77
+ if (record.type === undefined || record.type === "result" || record.structured_output !== undefined) {
78
+ return record;
79
+ }
80
+ }
81
+ } catch {
82
+ // fall through
83
+ }
84
+ // Defensive: if a description still requests stream-json, keep only the result line.
85
+ let last: HeadlessCliResult | undefined;
86
+ for (const line of trimmed.split("\n")) {
87
+ const text = line.trim();
88
+ if (text === "") continue;
89
+ try {
90
+ const value = JSON.parse(text) as unknown;
91
+ if (typeof value !== "object" || value === null || Array.isArray(value)) continue;
92
+ const record = value as HeadlessCliResult & { type?: unknown };
93
+ if (record.type === "result" || record.structured_output !== undefined) {
94
+ last = record;
95
+ }
96
+ } catch {
97
+ // skip non-JSON noise lines
98
+ }
99
+ }
100
+ return last;
101
+ }
102
+
103
+ /** Bound stderr retained only for failure diagnostics (not a dossier copy). */
104
+ const STDERR_DIAGNOSTIC_CAP = 16 * 1024;
105
+
106
+ function clipDiagnostic(text: string): string {
107
+ if (text.length <= STDERR_DIAGNOSTIC_CAP) return text;
108
+ return `${text.slice(0, STDERR_DIAGNOSTIC_CAP)}\n…[stderr clipped]`;
109
+ }
110
+
111
+ function spawnHeadlessTurn(options: {
112
+ readonly binary: string;
113
+ readonly args: readonly string[];
114
+ readonly cwd: string;
115
+ readonly env: NodeJS.ProcessEnv;
116
+ readonly signal?: AbortSignal;
117
+ readonly timeoutMs?: number;
118
+ }): Promise<{ code: number | null; stdout: string; stderr: string; timedOut: boolean }> {
119
+ return new Promise((resolve, reject) => {
120
+ if (options.signal?.aborted) {
121
+ reject(Object.assign(new Error("headless host aborted"), { code: "host-aborted" }));
122
+ return;
123
+ }
124
+ const child = spawn(options.binary, [...options.args], {
125
+ cwd: options.cwd,
126
+ env: options.env,
127
+ stdio: ["ignore", "pipe", "pipe"],
128
+ });
129
+ let stdout = "";
130
+ let stderr = "";
131
+ let settled = false;
132
+ let timedOut = false;
133
+ let timer: NodeJS.Timeout | undefined;
134
+ const settle = (code: number | null): void => {
135
+ if (settled) return;
136
+ settled = true;
137
+ if (timer !== undefined) clearTimeout(timer);
138
+ options.signal?.removeEventListener("abort", onAbort);
139
+ resolve({ code, stdout, stderr, timedOut });
140
+ };
141
+ const onAbort = (): void => {
142
+ child.kill("SIGTERM");
143
+ if (settled) return;
144
+ settled = true;
145
+ if (timer !== undefined) clearTimeout(timer);
146
+ reject(Object.assign(new Error("headless host aborted"), { code: "host-aborted" }));
147
+ };
148
+ child.stdout.setEncoding("utf8").on("data", (chunk: string) => { stdout += chunk; });
149
+ child.stderr.setEncoding("utf8").on("data", (chunk: string) => {
150
+ // Cap retained stderr: diagnostics only, not an unbounded transcript face.
151
+ if (stderr.length < STDERR_DIAGNOSTIC_CAP) {
152
+ stderr = clipDiagnostic(stderr + chunk);
153
+ }
154
+ });
155
+ child.on("error", (error) => {
156
+ if (settled) return;
157
+ settled = true;
158
+ if (timer !== undefined) clearTimeout(timer);
159
+ options.signal?.removeEventListener("abort", onAbort);
160
+ reject(error);
161
+ });
162
+ child.on("close", (code) => settle(code));
163
+ options.signal?.addEventListener("abort", onAbort, { once: true });
164
+ if (options.timeoutMs !== undefined && options.timeoutMs > 0) {
165
+ timer = setTimeout(() => {
166
+ timedOut = true;
167
+ child.kill("SIGTERM");
168
+ }, options.timeoutMs);
169
+ }
170
+ });
171
+ }
172
+
173
+ function cleanupErrorMessage(error: unknown): string {
174
+ return error instanceof Error ? error.message : String(error);
175
+ }
176
+
177
+ /**
178
+ * If dispose/cleanup failed: success becomes typed failure; existing failure keeps
179
+ * its primary cause and records the cleanup error in details (failure-honesty).
180
+ */
181
+ function withCleanupFailure(outcome: RoleTurnResult, cleanupError: unknown): RoleTurnResult {
182
+ const message = cleanupErrorMessage(cleanupError);
183
+ if (outcome.knownFailure === undefined) {
184
+ return failure(
185
+ "session",
186
+ "HeadlessDisposeFailure",
187
+ "dispose-failed",
188
+ { cleanupError: message },
189
+ message,
190
+ );
191
+ }
192
+ return {
193
+ ...outcome,
194
+ knownFailure: {
195
+ ...outcome.knownFailure,
196
+ details: {
197
+ ...(outcome.knownFailure.details ?? {}),
198
+ cleanupError: message,
199
+ },
200
+ },
201
+ };
202
+ }
203
+
204
+ /**
205
+ * Main-session headless adapter. prepare() is the shared envelope boundary;
206
+ * this module owns only CLI spawn / parse / session-id bind / resume loop.
207
+ */
208
+ export function createHeadlessRoleTurnHost(config: HeadlessRoleTurnHostConfig): RoleTurnHost {
209
+ let serial = Promise.resolve();
210
+ return {
211
+ executeTurn(request) {
212
+ const execution = serial.then(async (): Promise<RoleTurnResult> => {
213
+ const prepared = await config.prepare(request);
214
+ const systemPrompt = renderAcpSystemPromptOverride(prepared.systemPrompt);
215
+ let outcome: RoleTurnResult = failure("session", "HeadlessNoOutcome", "no-outcome");
216
+ try {
217
+ // Same-host resume reuses the bound native session id via --resume.
218
+ let sessionId = await config.sessionIdentity.load(request.principal);
219
+ let sessionKind: "new" | "resume" =
220
+ request.continuation.kind === "resume" && sessionId !== undefined && sessionId !== ""
221
+ ? "resume"
222
+ : "new";
223
+ if (sessionKind === "new") {
224
+ sessionId = randomUUID();
225
+ await config.sessionIdentity.bind(request.principal, sessionId);
226
+ }
227
+
228
+ // Cross-host handoff: prior-native paths ride the user prompt (DK-7).
229
+ const priorNativePaths =
230
+ request.continuation.kind === "resume"
231
+ ? request.hostTransition?.priorNativePaths
232
+ : undefined;
233
+ let prompt =
234
+ priorNativePaths !== undefined && priorNativePaths.length > 0
235
+ ? `${prepared.prompt}\n${priorNativePaths.join("\n")}`
236
+ : prepared.prompt;
237
+
238
+ const abortSignal =
239
+ request.signal === undefined
240
+ ? prepared.abortSignal
241
+ : prepared.abortSignal === undefined
242
+ ? request.signal
243
+ : AbortSignal.any([prepared.abortSignal, request.signal]);
244
+
245
+ // config.env is the production authority for package-root / host child env
246
+ // (see createProductionHeadlessRoleTurnHost). Do not re-override keys from
247
+ // process.env after the spread — that erased AK_PACKAGE_ROOT.
248
+ const env: NodeJS.ProcessEnv = {
249
+ ...process.env,
250
+ ...(config.env ?? {}),
251
+ };
252
+
253
+ // Materialize system prompt + MCP config once per prepare (stable across
254
+ // correctable retries). Paths live under the run directory we already own.
255
+ // Envelope always projects the AK MCP relay row; no empty-server branch.
256
+ const systemPromptPath = join(request.runDirectory, "headless-system-prompt.txt");
257
+ await writeFile(systemPromptPath, systemPrompt, "utf8");
258
+ const mcpConfigPath = join(request.runDirectory, "headless-mcp-config.json");
259
+ await writeFile(
260
+ mcpConfigPath,
261
+ `${JSON.stringify(headlessMcpConfigDocument(prepared.mcpServers), null, 2)}\n`,
262
+ "utf8",
263
+ );
264
+
265
+ // No round cap on content review (#750); this bound is only for
266
+ // correctable mechanical resubmit (non-sole etc.), matching ACP's 8.
267
+ for (let attempt = 0; attempt < 8; attempt += 1) {
268
+ if (abortSignal?.aborted) {
269
+ const closure = await prepared.closeRound();
270
+ if ("failure" in closure) {
271
+ outcome = { code: null, stderr: "", timedOut: false, knownFailure: closure.failure };
272
+ break;
273
+ }
274
+ outcome = failure("session", "HostAborted", "host-aborted", { sessionId });
275
+ break;
276
+ }
277
+
278
+ const args = headlessTurnArgs({
279
+ description: config.description,
280
+ prompt,
281
+ systemPromptPath,
282
+ jsonSchema: prepared.jsonSchema,
283
+ mcpConfigPath,
284
+ ...(request.model?.model !== undefined ? { model: request.model.model } : {}),
285
+ ...(request.model?.thinking !== undefined ? { effort: request.model.thinking } : {}),
286
+ session: sessionKind === "new"
287
+ ? { kind: "new", id: sessionId! }
288
+ : { kind: "resume", id: sessionId! },
289
+ });
290
+
291
+ let spawned: { code: number | null; stdout: string; stderr: string; timedOut: boolean };
292
+ try {
293
+ spawned = await spawnHeadlessTurn({
294
+ binary: config.binary,
295
+ args,
296
+ cwd: request.cwd,
297
+ env,
298
+ ...(abortSignal === undefined ? {} : { signal: abortSignal }),
299
+ ...(request.timeoutMs === undefined ? {} : { timeoutMs: request.timeoutMs }),
300
+ });
301
+ } catch (error) {
302
+ if (
303
+ typeof error === "object"
304
+ && error !== null
305
+ && (error as { code?: unknown }).code === "host-aborted"
306
+ ) {
307
+ const closure = await prepared.closeRound();
308
+ if ("failure" in closure) {
309
+ outcome = { code: null, stderr: "", timedOut: false, knownFailure: closure.failure };
310
+ break;
311
+ }
312
+ outcome = failure("session", "HostAborted", "host-aborted", { sessionId });
313
+ break;
314
+ }
315
+ const message = error instanceof Error ? error.message : String(error);
316
+ outcome = failure("activation", "HeadlessSpawnFailure", "spawn-failed", {
317
+ diagnostic: message,
318
+ binary: config.binary,
319
+ }, message);
320
+ break;
321
+ }
322
+
323
+ if (spawned.timedOut) {
324
+ outcome = {
325
+ code: spawned.code,
326
+ stderr: spawned.stderr,
327
+ timedOut: true,
328
+ knownFailure: {
329
+ cause: "timeout",
330
+ identity: { name: "HeadlessTimeout", code: "timeout" },
331
+ details: { sessionId },
332
+ },
333
+ };
334
+ break;
335
+ }
336
+
337
+ const envelope = parseHeadlessCliStdout(spawned.stdout);
338
+ if (envelope === undefined) {
339
+ outcome = {
340
+ code: spawned.code,
341
+ stderr: spawned.stderr,
342
+ timedOut: false,
343
+ knownFailure: {
344
+ cause: "output",
345
+ identity: { name: "HeadlessEmptyOutput", code: "empty-stdout" },
346
+ diagnostic: clipDiagnostic(spawned.stderr.trim() || "headless CLI produced no parseable result"),
347
+ details: { sessionId, exitCode: spawned.code },
348
+ },
349
+ };
350
+ break;
351
+ }
352
+
353
+ // Bind the host-reported session id (authoritative for --resume).
354
+ if (typeof envelope.session_id === "string" && envelope.session_id !== "") {
355
+ sessionId = envelope.session_id;
356
+ await config.sessionIdentity.bind(request.principal, sessionId);
357
+ }
358
+
359
+ if (envelope.is_error === true || (typeof envelope.subtype === "string" && envelope.subtype.startsWith("error_"))) {
360
+ const errorCode =
361
+ typeof envelope.subtype === "string" && envelope.subtype.startsWith("error_")
362
+ ? envelope.subtype
363
+ : envelope.is_error === true
364
+ ? "is_error"
365
+ : "cli-error";
366
+ const diagnostic = typeof envelope.result === "string"
367
+ ? envelope.result
368
+ : Array.isArray(envelope.errors)
369
+ ? envelope.errors.map(String).join("\n")
370
+ : clipDiagnostic(spawned.stderr.trim() || "headless CLI reported is_error");
371
+ outcome = {
372
+ code: spawned.code,
373
+ stderr: spawned.stderr,
374
+ timedOut: false,
375
+ knownFailure: {
376
+ cause: "output",
377
+ identity: { name: "HeadlessCliError", code: errorCode },
378
+ diagnostic,
379
+ details: {
380
+ sessionId,
381
+ subtype: envelope.subtype,
382
+ errors: envelope.errors,
383
+ exitCode: spawned.code,
384
+ },
385
+ },
386
+ };
387
+ break;
388
+ }
389
+
390
+ // Schema channel: host-native structured_output is the terminating receipt
391
+ // (#750). Intermediate tools may have already run via MCP during the process.
392
+ if (envelope.structured_output !== undefined) {
393
+ await prepared.ingestStructuredOutput(envelope.structured_output);
394
+ }
395
+ const closure = await prepared.closeRound();
396
+ if (closure.accepted) {
397
+ outcome = { code: 0, stderr: "", timedOut: false };
398
+ break;
399
+ }
400
+ if ("failure" in closure) {
401
+ outcome = { code: null, stderr: spawned.stderr, timedOut: false, knownFailure: closure.failure };
402
+ break;
403
+ }
404
+ // Correctable rejection → resume same session with plain resubmit prompt.
405
+ prompt = `The prior terminal submission was rejected (${closure.retry.code}). Resubmit it as the sole terminal structured output (or the sole terminating tool call). Rejected call ids: ${closure.retry.toolCallIds.join(", ") || "none"}.`;
406
+ sessionKind = "resume";
407
+ if (attempt === 7) {
408
+ outcome = failure("output", "HeadlessRoundLimit", "round-retry-limit", { sessionId });
409
+ }
410
+ }
411
+ } finally {
412
+ try {
413
+ await prepared.dispose?.();
414
+ } catch (cleanupError) {
415
+ outcome = withCleanupFailure(outcome, cleanupError);
416
+ }
417
+ }
418
+ return outcome;
419
+ });
420
+ serial = execution.then(() => undefined, () => undefined);
421
+ return execution;
422
+ },
423
+ };
424
+ }
@@ -103,7 +103,8 @@ export type RoleTurnActivation =
103
103
  | {
104
104
  readonly role: "collector";
105
105
  readonly repo: string;
106
- readonly pr: string;
106
+ /** Bound PR when known at admission; omit when role will bind from materials. */
107
+ readonly pr?: string;
107
108
  readonly requestManifestPath?: string;
108
109
  }
109
110
  | { readonly role: "doctor"; readonly casePath: string }
@@ -1,10 +1,13 @@
1
1
  /**
2
- * Packaged host description table (#729 / #731).
3
- * Key = seat-table `host` value; the row is the generic ACP factory's input.
2
+ * Packaged host description tables (#729 / #731 / #645).
3
+ * Key = seat-table `host` value.
4
+ * - ACP family rows feed the generic ACP factory.
5
+ * - Headless CLI family rows feed the generic headless factory (#645; #646 codex next).
4
6
  * pi is the in-process default, not a row.
5
- * Unregistered names fail closed (#510); this table does not fallback.
7
+ * Unregistered names fail closed (#510); these tables do not fallback.
6
8
  */
7
9
  import type { AcpHostDescription } from "./acp-host/description.ts";
10
+ import type { HeadlessHostDescription } from "./headless-host/description.ts";
8
11
 
9
12
  /** Grok CLI reads vendor-private compat surfaces unless each is disabled by name. */
10
13
  const PRIVATE_COMPAT_ENV = Object.fromEntries(
@@ -15,6 +18,8 @@ const PRIVATE_COMPAT_ENV = Object.fromEntries(
15
18
 
16
19
  export const DEFAULT_ROLE_TURN_HOST = "pi" as const;
17
20
 
21
+ export type HostFamily = "acp" | "headless";
22
+
18
23
  export const HOST_DESCRIPTIONS: Readonly<Record<string, AcpHostDescription>> = Object.freeze({
19
24
  /** Operator home `~/.grok`, native session/load resume, `agent [--model X] stdio`. */
20
25
  "grok-build": Object.freeze({
@@ -60,17 +65,59 @@ export const HOST_DESCRIPTIONS: Readonly<Record<string, AcpHostDescription>> = O
60
65
  }),
61
66
  });
62
67
 
68
+ /**
69
+ * Headless CLI family (#645). Claude is the first row; codex (#646) adds another.
70
+ * fixedArgs: print mode, isolation without `--bare` (OAuth stays), full permissions.
71
+ * `--setting-sources` empty = load no user/project/local CLAUDE.md/hooks/skills
72
+ * (role envelope is delivered via `--system-prompt` wholesale replace).
73
+ * `--strict-mcp-config` with no `--mcp-config` drops operator MCP + claude.ai connectors.
74
+ */
75
+ export const HEADLESS_HOST_DESCRIPTIONS: Readonly<Record<string, HeadlessHostDescription>> = Object.freeze({
76
+ "claude": Object.freeze({
77
+ binaryFromHome: Object.freeze([".local", "bin", "claude"]),
78
+ sessionBindingFile: "claude-headless-session.json",
79
+ fixedArgs: Object.freeze([
80
+ // One result envelope (not stream-json): typed receipt only; no event-stream copy.
81
+ "--output-format", "json",
82
+ "--permission-mode", "bypassPermissions",
83
+ // Empty sources: no user/project/local operator surface (envelope owns materials).
84
+ "--setting-sources", "",
85
+ // With adapter-supplied --mcp-config only (AK relay); drops operator + claude.ai MCP.
86
+ "--strict-mcp-config",
87
+ ]),
88
+ promptFlag: "-p",
89
+ modelFlag: "--model",
90
+ effortFlag: "--effort",
91
+ // File path keeps large role envelopes off ARG_MAX.
92
+ systemPromptFlag: "--system-prompt-file",
93
+ jsonSchemaFlag: "--json-schema",
94
+ mcpConfigFlag: "--mcp-config",
95
+ sessionIdFlag: "--session-id",
96
+ resumeFlag: "--resume",
97
+ }),
98
+ });
99
+
63
100
  export function lookupHostDescription(host: string): AcpHostDescription | undefined {
64
101
  return Object.hasOwn(HOST_DESCRIPTIONS, host) ? HOST_DESCRIPTIONS[host] : undefined;
65
102
  }
66
103
 
104
+ export function lookupHeadlessHostDescription(host: string): HeadlessHostDescription | undefined {
105
+ return Object.hasOwn(HEADLESS_HOST_DESCRIPTIONS, host) ? HEADLESS_HOST_DESCRIPTIONS[host] : undefined;
106
+ }
107
+
108
+ export function lookupHostFamily(host: string): HostFamily | undefined {
109
+ if (lookupHostDescription(host) !== undefined) return "acp";
110
+ if (lookupHeadlessHostDescription(host) !== undefined) return "headless";
111
+ return undefined;
112
+ }
113
+
67
114
  export function packagedExternalHostNames(): readonly string[] {
68
- return Object.keys(HOST_DESCRIPTIONS);
115
+ return [...Object.keys(HOST_DESCRIPTIONS), ...Object.keys(HEADLESS_HOST_DESCRIPTIONS)];
69
116
  }
70
117
 
71
- /** Legal persistent/invocation host: default pi, or a table key. */
118
+ /** Legal persistent/invocation host: default pi, or a table key (any family). */
72
119
  export function assertRegisteredHostName(host: string): string {
73
- if (host === DEFAULT_ROLE_TURN_HOST || lookupHostDescription(host) !== undefined) {
120
+ if (host === DEFAULT_ROLE_TURN_HOST || lookupHostFamily(host) !== undefined) {
74
121
  return host;
75
122
  }
76
123
  throw new Error(`unregistered host: ${host}`);
@@ -59,10 +59,29 @@ export type CollectorEvidenceRecord = {
59
59
  authoritativeTime?: string | null;
60
60
  };
61
61
 
62
+ /**
63
+ * #676 C: sole structured projection-fact type (builder + package leaf).
64
+ * Builder always emits the full object; package leaf keeps it optional so old
65
+ * volumes without the field stay readable — missing is unknown, never defaulted.
66
+ */
67
+ export type CollectorSubmissionProjection = {
68
+ findingsSource: "absent" | "array" | "unreadable";
69
+ findingsProjectedCount: number;
70
+ findingsUnprojected: boolean;
71
+ unfinishedReasonsSource: "absent" | "array" | "unreadable";
72
+ unfinishedReasonsProjectedCount: number;
73
+ unfinishedReasonsUnprojected: boolean;
74
+ };
75
+
62
76
  export type CollectorReceipt = {
63
77
  host: "github.com";
64
78
  repository: string;
65
79
  prNumber: number;
80
+ /**
81
+ * Final observed PR state; non-OPEN still delivers materials (#676 D6).
82
+ * Absent/unreadable on old volumes is unknown — never defaulted to OPEN.
83
+ */
84
+ prState?: string;
66
85
  manifestDigest: string;
67
86
  activationTime: string;
68
87
  deadlineTime: string;
@@ -70,6 +89,13 @@ export type CollectorReceipt = {
70
89
  finalSnapshotId: string;
71
90
  targetHead: string;
72
91
  groups: CollectorIdentityGroup[];
92
+ unfinishedReasons?: string[];
93
+ /**
94
+ * Distinguishes readable-empty from unprojected original submission content (#676 C).
95
+ * Package leaf may carry a partial projection when some sibling keys are unreadable —
96
+ * missing keys stay absent (never defaulted false/0/OPEN).
97
+ */
98
+ submissionProjection?: Partial<CollectorSubmissionProjection>;
73
99
  requestAttempts: CollectorRequestAttempt[];
74
100
  snapshots: CollectorSnapshot[];
75
101
  evidenceRecords: CollectorEvidenceRecord[];
@@ -97,10 +123,18 @@ export function validateAcceptedCollectorReceipt(value: unknown): CollectorRecei
97
123
  materials: records(safeGet(group, "materials")),
98
124
  findings: records(safeGet(group, "findings")),
99
125
  }));
126
+ const unfinishedRaw = safeGet(value, "unfinishedReasons");
127
+ const unfinishedReasons = strings(unfinishedRaw);
128
+ // #676 C: project readable submissionProjection fields independently — do not
129
+ // drop the whole block when one sibling key is the wrong type (left-拾遗1).
130
+ const submissionProjection = projectSubmissionProjection(safeGet(value, "submissionProjection"));
131
+ const prStateRaw = safeGet(value, "prState");
132
+ const prState = typeof prStateRaw === "string" ? prStateRaw : undefined;
100
133
  return {
101
134
  host: safeGet(value, "host") as "github.com",
102
135
  repository: safeGet(value, "repository") as string,
103
136
  prNumber: safeGet(value, "prNumber") as number,
137
+ ...(prState === undefined ? {} : { prState }),
104
138
  manifestDigest: safeGet(value, "manifestDigest") as string,
105
139
  activationTime: safeGet(value, "activationTime") as string,
106
140
  deadlineTime: safeGet(value, "deadlineTime") as string,
@@ -108,6 +142,8 @@ export function validateAcceptedCollectorReceipt(value: unknown): CollectorRecei
108
142
  finalSnapshotId: safeGet(value, "finalSnapshotId") as string,
109
143
  targetHead: safeGet(value, "targetHead") as string,
110
144
  groups,
145
+ ...(unfinishedReasons.length > 0 ? { unfinishedReasons } : {}),
146
+ ...(submissionProjection === undefined ? {} : { submissionProjection }),
111
147
  requestAttempts: records(safeGet(value, "requestAttempts")) as unknown as CollectorRequestAttempt[],
112
148
  snapshots: records(safeGet(value, "snapshots")).map((snapshot) => ({
113
149
  snapshotId: safeGet(snapshot, "snapshotId"), observedAt: safeGet(snapshot, "observedAt"), completedAt: safeGet(snapshot, "completedAt"), completedMono: safeGet(snapshot, "completedMono"), host: safeGet(snapshot, "host"), repository: safeGet(snapshot, "repository"), prNumber: safeGet(snapshot, "prNumber"), prState: safeGet(snapshot, "prState"), headOid: safeGet(snapshot, "headOid"), complete: safeGet(snapshot, "complete"), evidenceIds: strings(safeGet(snapshot, "evidenceIds")), pageDiagnostics: records(safeGet(snapshot, "pageDiagnostics")), normalizedByteLength: safeGet(snapshot, "normalizedByteLength"),
@@ -115,3 +151,39 @@ export function validateAcceptedCollectorReceipt(value: unknown): CollectorRecei
115
151
  evidenceRecords: records(safeGet(value, "evidenceRecords")).map((record) => ({ evidenceId: safeGet(record, "evidenceId"), kind: safeGet(record, "kind"), versionId: safeGet(record, "versionId"), contentDigest: safeGet(record, "contentDigest"), firstObservedAt: safeGet(record, "firstObservedAt"), githubId: safeGet(record, "githubId"), authorLogin: safeGet(record, "authorLogin"), htmlUrl: safeGet(record, "htmlUrl"), authoritativeTime: safeGet(record, "authoritativeTime") } as CollectorEvidenceRecord)),
116
152
  };
117
153
  }
154
+
155
+ /** Readable projection facts only; omit keys that are the wrong type — never fabricate defaults. */
156
+ function projectSubmissionProjection(
157
+ raw: unknown,
158
+ ): Partial<CollectorSubmissionProjection> | undefined {
159
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return undefined;
160
+ const p = raw as Record<string, unknown>;
161
+ const out: Partial<CollectorSubmissionProjection> = {};
162
+ if (
163
+ p["findingsSource"] === "absent" ||
164
+ p["findingsSource"] === "array" ||
165
+ p["findingsSource"] === "unreadable"
166
+ ) {
167
+ out.findingsSource = p["findingsSource"];
168
+ }
169
+ if (typeof p["findingsProjectedCount"] === "number") {
170
+ out.findingsProjectedCount = p["findingsProjectedCount"];
171
+ }
172
+ if (typeof p["findingsUnprojected"] === "boolean") {
173
+ out.findingsUnprojected = p["findingsUnprojected"];
174
+ }
175
+ if (
176
+ p["unfinishedReasonsSource"] === "absent" ||
177
+ p["unfinishedReasonsSource"] === "array" ||
178
+ p["unfinishedReasonsSource"] === "unreadable"
179
+ ) {
180
+ out.unfinishedReasonsSource = p["unfinishedReasonsSource"];
181
+ }
182
+ if (typeof p["unfinishedReasonsProjectedCount"] === "number") {
183
+ out.unfinishedReasonsProjectedCount = p["unfinishedReasonsProjectedCount"];
184
+ }
185
+ if (typeof p["unfinishedReasonsUnprojected"] === "boolean") {
186
+ out.unfinishedReasonsUnprojected = p["unfinishedReasonsUnprojected"];
187
+ }
188
+ return Object.keys(out).length > 0 ? out : undefined;
189
+ }
@@ -18,21 +18,32 @@ export const INFRASTRUCTURE_FAILURE_DECLARATION_KEY =
18
18
  "infrastructureFailure" as const;
19
19
  export const INFRASTRUCTURE_FAILURE_DIAGNOSTIC_KEY = "diagnostic" as const;
20
20
 
21
- /** Shared typed declaration fragment: `infrastructureFailure.diagnostic` = non-empty string. */
21
+ /**
22
+ * Shared declaration fragment for model guidance (#541 / #676 C / ADR 0057).
23
+ * Nested field declarations + descriptions only — host must not pure-shape-reject
24
+ * the envelope (第 0 条). Runtime `failOnInfrastructureFailureDeclaration` still
25
+ * recognizes a real non-empty diagnostic string as the failure declaration.
26
+ * No required/minLength/type host gates on the declaration fragment.
27
+ */
28
+ const infrastructureFailureNested = Type.Object(
29
+ {
30
+ [INFRASTRUCTURE_FAILURE_DIAGNOSTIC_KEY]: Type.Unknown({
31
+ description:
32
+ "非空基础设施失败诊断字符串。无失败时必须省略整个 infrastructureFailure。形状指引,非 schema 闸。",
33
+ }),
34
+ },
35
+ {
36
+ additionalProperties: true,
37
+ description:
38
+ "基础设施真实失败声明(如需)。规范形:{ diagnostic: 非空诊断字符串 };无失败时必须省略。形状指引,非 schema 闸。",
39
+ },
40
+ );
41
+ // Open nested required so host cannot pure-shape-reject the declaration fragment.
42
+ (infrastructureFailureNested as unknown as { required: string[] }).required = [];
43
+
22
44
  const infrastructureFailureDeclarationSchema = Type.Object(
23
45
  {
24
- [INFRASTRUCTURE_FAILURE_DECLARATION_KEY]: Type.Object(
25
- {
26
- [INFRASTRUCTURE_FAILURE_DIAGNOSTIC_KEY]: Type.String({
27
- minLength: 1,
28
- description: "非空基础设施失败诊断",
29
- }),
30
- },
31
- {
32
- additionalProperties: true,
33
- description: "基础设施失败声明",
34
- },
35
- ),
46
+ [INFRASTRUCTURE_FAILURE_DECLARATION_KEY]: infrastructureFailureNested,
36
47
  },
37
48
  { additionalProperties: true },
38
49
  );