@uchibeke/gallivant 0.2.1 → 0.3.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 (129) hide show
  1. package/README.md +38 -16
  2. package/SECURITY.md +9 -0
  3. package/bin/gallivant.mjs +33 -10
  4. package/bin/promote-bitbucket.mjs +40 -0
  5. package/bin/task-pr.mjs +9 -0
  6. package/bin/task-review.mjs +15 -0
  7. package/docs/README.md +2 -0
  8. package/docs/architecture.md +11 -3
  9. package/docs/bitbucket-promotion.md +69 -0
  10. package/docs/bitbucket-review.md +330 -0
  11. package/docs/chains.md +30 -1
  12. package/docs/codex-cloud.md +7 -0
  13. package/docs/codex-review.md +50 -0
  14. package/docs/commands.md +65 -5
  15. package/docs/config.md +35 -10
  16. package/docs/first-party-review.md +266 -0
  17. package/docs/git-hosts.md +294 -0
  18. package/docs/guardrails.md +56 -0
  19. package/docs/install.md +18 -8
  20. package/docs/network-recovery.md +9 -0
  21. package/docs/openai-agents.md +2 -1
  22. package/docs/prd.md +23 -2
  23. package/docs/preparation.md +1 -1
  24. package/docs/release-qualification.md +1 -1
  25. package/docs/releasing.md +17 -2
  26. package/docs/task-delivery.md +4 -4
  27. package/docs/task-profiles.md +537 -0
  28. package/docs/tasks.md +59 -197
  29. package/lib/attribution.mjs +7 -3
  30. package/lib/block.mjs +10 -1
  31. package/lib/chains.mjs +101 -30
  32. package/lib/codex_cloud.mjs +2 -1
  33. package/lib/codex_cloud_input.mjs +15 -6
  34. package/lib/codex_cloud_result.mjs +1 -1
  35. package/lib/commands/doctor.mjs +22 -462
  36. package/lib/commands/init.mjs +72 -30
  37. package/lib/commands/register.mjs +25 -11
  38. package/lib/commands/resume.mjs +5 -1
  39. package/lib/commands/review.mjs +66 -0
  40. package/lib/commands/serve.mjs +8 -0
  41. package/lib/commands/start.mjs +30 -11
  42. package/lib/commands/status.mjs +18 -2
  43. package/lib/commands/task.mjs +9 -1
  44. package/lib/commands/up.mjs +19 -11
  45. package/lib/commands/why.mjs +7 -3
  46. package/lib/config.mjs +42 -9
  47. package/lib/context.mjs +23 -18
  48. package/lib/control.mjs +21 -6
  49. package/lib/credential.mjs +20 -14
  50. package/lib/deliverable.mjs +2 -0
  51. package/lib/events.mjs +96 -53
  52. package/lib/execution.mjs +48 -3
  53. package/lib/execution_preparation.mjs +16 -1
  54. package/lib/execution_result.mjs +26 -5
  55. package/lib/first_party_review.mjs +618 -0
  56. package/lib/gh.mjs +144 -56
  57. package/lib/git.mjs +133 -32
  58. package/lib/git_diff.mjs +121 -0
  59. package/lib/harness.mjs +14 -10
  60. package/lib/host_contract.mjs +71 -0
  61. package/lib/host_definitions.mjs +22 -0
  62. package/lib/hosts/bitbucket.mjs +553 -0
  63. package/lib/hosts/bitbucket_contract.mjs +57 -0
  64. package/lib/hosts/bitbucket_credential.mjs +62 -0
  65. package/lib/hosts/bitbucket_review.mjs +44 -0
  66. package/lib/hosts/bitbucket_review_producer.mjs +50 -0
  67. package/lib/hosts/github.mjs +57 -0
  68. package/lib/hosts/github_contract.mjs +27 -0
  69. package/lib/hosts/github_doctor.mjs +482 -0
  70. package/lib/hosts/github_review_summary.mjs +38 -0
  71. package/lib/hosts/transport_doctor.mjs +35 -0
  72. package/lib/hosts.mjs +130 -0
  73. package/lib/learning_qualification.mjs +1 -1
  74. package/lib/notify.mjs +1 -1
  75. package/lib/openai_agents_input.mjs +7 -2
  76. package/lib/openai_agents_sources.mjs +16 -8
  77. package/lib/plugin_package.mjs +13 -1
  78. package/lib/pr_operation.mjs +41 -0
  79. package/lib/prd.mjs +18 -3
  80. package/lib/preparation_codex.mjs +6 -1
  81. package/lib/probe.mjs +6 -4
  82. package/lib/process.mjs +24 -0
  83. package/lib/promotion.mjs +132 -0
  84. package/lib/promotion_git.mjs +63 -0
  85. package/lib/prompt.mjs +16 -13
  86. package/lib/provider_health.mjs +14 -3
  87. package/lib/repository.mjs +68 -0
  88. package/lib/repository_preflight.mjs +11 -0
  89. package/lib/review_evaluation.mjs +25 -0
  90. package/lib/review_evidence.mjs +500 -0
  91. package/lib/review_history.mjs +73 -0
  92. package/lib/review_image.mjs +145 -0
  93. package/lib/review_link.mjs +12 -0
  94. package/lib/review_policy.mjs +132 -0
  95. package/lib/review_policy_contract.mjs +18 -0
  96. package/lib/review_state.mjs +13 -0
  97. package/lib/review_visual.mjs +94 -0
  98. package/lib/runtime-environment.mjs +6 -0
  99. package/lib/runtime_node.mjs +3 -2
  100. package/lib/runtime_state.mjs +39 -5
  101. package/lib/sources/prd.mjs +6 -3
  102. package/lib/state.mjs +137 -24
  103. package/lib/task_completion.mjs +7 -3
  104. package/lib/task_contract.mjs +201 -27
  105. package/lib/task_delivery.mjs +9 -2
  106. package/lib/task_delivery_contract.mjs +13 -9
  107. package/lib/task_delivery_view.mjs +18 -2
  108. package/lib/task_environment.mjs +13 -1
  109. package/lib/task_limits.mjs +13 -0
  110. package/lib/task_model_config.mjs +99 -0
  111. package/lib/task_model_route.mjs +243 -0
  112. package/lib/task_prepare.mjs +21 -8
  113. package/lib/task_profile.mjs +1875 -0
  114. package/lib/task_repository_context.mjs +65 -0
  115. package/lib/tasks.mjs +252 -51
  116. package/lib/tick.mjs +353 -97
  117. package/lib/time.mjs +1 -0
  118. package/lib/tracker/pages.mjs +19 -5
  119. package/lib/tracker/review.mjs +21 -0
  120. package/lib/tracker_process.mjs +8 -1
  121. package/lib/verification_sandbox.mjs +2 -1
  122. package/lib/work_artifacts.mjs +79 -0
  123. package/npm-shrinkwrap.json +12 -3
  124. package/package.json +9 -3
  125. package/templates/plugin/task-delivery.SKILL.md +17 -10
  126. package/templates/plugin/task-intake-contract.json +18 -0
  127. package/templates/plugin/task-profile-requests.mjs +1448 -0
  128. package/templates/tracker/tracker.css +3 -0
  129. package/templates/tracker/tracker.js +38 -0
@@ -0,0 +1,121 @@
1
+ const lines = section => section.split(/\r?\n/);
2
+
3
+ function quotedPath(value) {
4
+ if (!value.startsWith('"')) return null;
5
+ const bytes = [];
6
+ for (let i = 1; i < value.length;) {
7
+ const char = value[i++];
8
+ if (char === '"') {
9
+ let path;
10
+ try { path = new TextDecoder("utf-8", { fatal: true }).decode(Uint8Array.from(bytes)); } catch { return null; }
11
+ return { path, rest: value.slice(i) };
12
+ }
13
+ if (char !== "\\") {
14
+ const point = value.codePointAt(i - 1), width = point > 0xffff ? 2 : 1;
15
+ bytes.push(...Buffer.from(value.slice(i - 1, i - 1 + width))); i += width - 1; continue;
16
+ }
17
+ if (i >= value.length) return null;
18
+ const escape = value[i++], simple = { a: 7, b: 8, t: 9, n: 10, v: 11, f: 12, r: 13, '"': 34, "\\": 92 }[escape];
19
+ if (simple !== undefined) { bytes.push(simple); continue; }
20
+ if (!/[0-7]/.test(escape)) return null;
21
+ let octal = escape;
22
+ while (octal.length < 3 && /[0-7]/.test(value[i] || "")) octal += value[i++];
23
+ const byte = Number.parseInt(octal, 8);
24
+ if (byte > 255) return null;
25
+ bytes.push(byte);
26
+ }
27
+ return null;
28
+ }
29
+
30
+ function consumePath(value, expected) {
31
+ if (value.startsWith('"')) {
32
+ const parsed = quotedPath(value);
33
+ return parsed?.path === expected ? parsed.rest : null;
34
+ }
35
+ return value.startsWith(expected) ? value.slice(expected.length) : null;
36
+ }
37
+
38
+ function pathLineMatches(value, expected, allowMetadata = false) {
39
+ const rest = consumePath(value, expected);
40
+ return rest === "" || allowMetadata && rest?.startsWith("\t");
41
+ }
42
+
43
+ function diffHeaderMatches(line, oldPath, newPath) {
44
+ if (!line?.startsWith("diff --git ")) return false;
45
+ const first = consumePath(line.slice("diff --git ".length), `a/${oldPath}`);
46
+ if (!first?.startsWith(" ")) return false;
47
+ return pathLineMatches(first.slice(1), `b/${newPath}`);
48
+ }
49
+
50
+ /** Match one complete raw Git diff section to one host changed-path record. */
51
+ export function gitDiffSectionMatches(section, { status, oldPath, newPath, binary = false }) {
52
+ if (typeof section !== "string" || !["added", "modified", "removed", "renamed"].includes(status)) return false;
53
+ const oldName = oldPath ?? newPath, newName = newPath ?? oldPath, rows = lines(section);
54
+ if (!oldName || !newName || !diffHeaderMatches(rows[0], oldName, newName)) return false;
55
+ if (binary && rows.some(row => /^Binary files .+ and .+ differ$/.test(row))) return true;
56
+ const marker = (prefix, path) => rows.some(row => row.startsWith(`${prefix} `) && pathLineMatches(row.slice(prefix.length + 1), path, true));
57
+ const metadata = (prefix, path) => rows.some(row => row.startsWith(prefix) && pathLineMatches(row.slice(prefix.length), path));
58
+ const oldMarker = marker("---", `a/${oldName}`), newMarker = marker("+++", `b/${newName}`), hasMarkers = rows.some(row => /^(?:---|\+\+\+) /.test(row));
59
+ if (status === "added") return marker("---", "/dev/null") && newMarker || rows.some(row => /^new file mode [0-7]{6}$/.test(row)) && !hasMarkers;
60
+ if (status === "removed") return oldMarker && marker("+++", "/dev/null") || rows.some(row => /^deleted file mode [0-7]{6}$/.test(row)) && !hasMarkers;
61
+ if (status === "renamed") return metadata("rename from ", oldName) && metadata("rename to ", newName) || oldMarker && newMarker;
62
+ return oldMarker && newMarker || rows.some(row => /^old mode [0-7]{6}$/.test(row)) && rows.some(row => /^new mode [0-7]{6}$/.test(row)) && !hasMarkers;
63
+ }
64
+
65
+ /** Non-deleted destinations in a complete host diff, including quoted names and binary/mode-only changes. */
66
+ export function gitDiffChangedPaths(diff) {
67
+ if (typeof diff !== "string") throw new Error("publication diff is unavailable");
68
+ if (!diff.trim()) return [];
69
+ if (!diff.startsWith("diff --git ")) throw new Error("publication diff has no complete Git file header");
70
+ const result = [];
71
+ const decode = value => {
72
+ if (!value.startsWith('"')) return value;
73
+ const parsed = quotedPath(value);
74
+ if (!parsed || parsed.rest && !parsed.rest.startsWith("\t")) throw new Error("publication diff has an invalid quoted path");
75
+ return parsed.path;
76
+ };
77
+ for (const section of diff.split(/(?=^diff --git )/m)) {
78
+ const rows = lines(section), header = rows[0];
79
+ // Read metadata only: hunk contents can contain lines that resemble headers.
80
+ const end = rows.findIndex(row => row.startsWith("@@") || row === "GIT binary patch");
81
+ const metadata = end < 0 ? rows : rows.slice(0, end);
82
+ if (metadata.some(row => /^deleted file mode [0-7]{6}$/.test(row) || row === "+++ /dev/null")) continue;
83
+ const destination = metadata.find(row => row.startsWith("+++ "));
84
+ const rename = metadata.find(row => row.startsWith("rename to ") || row.startsWith("copy to "));
85
+ let path;
86
+ if (destination) {
87
+ const value = destination.slice(4);
88
+ const decoded = decode(value.startsWith('"') ? value : value.split("\t")[0]);
89
+ if (!decoded.startsWith("b/")) throw new Error("publication diff has an invalid destination");
90
+ path = decoded.slice(2);
91
+ if (!rows.some(row => row.startsWith("@@"))) throw new Error("publication diff has no content hunk");
92
+ } else if (rename) path = decode(rename.slice(rename.startsWith("rename") ? 10 : 8));
93
+ else {
94
+ if (!rows.some(row => /^(?:new file mode |old mode |Binary files |GIT binary patch$)/.test(row))) throw new Error("publication diff has incomplete file metadata");
95
+ // Git omits ---/+++ for binary and mode-only changes. Their same-path
96
+ // header is unambiguous even when an unquoted filename contains spaces.
97
+ const value = header.slice("diff --git ".length);
98
+ if (value.startsWith('"')) {
99
+ const first = quotedPath(value);
100
+ if (!first?.path.startsWith("a/") || !first.rest.startsWith(" ")) throw new Error("publication diff has an invalid file header");
101
+ const second = decode(first.rest.slice(1));
102
+ if (second !== `b/${first.path.slice(2)}`) throw new Error("publication diff has an ambiguous destination");
103
+ path = second.slice(2);
104
+ } else {
105
+ const candidates = [...value.matchAll(/ b\//g)].map(match => [value.slice(0, match.index), value.slice(match.index + 1)])
106
+ .filter(([oldPath, newPath]) => oldPath.startsWith("a/") && oldPath.slice(2) === newPath.slice(2));
107
+ if (candidates.length !== 1) throw new Error("publication diff has an ambiguous destination");
108
+ path = candidates[0][1].slice(2);
109
+ }
110
+ }
111
+ if (!path) throw new Error("publication diff has an empty destination");
112
+ const original = metadata.find(row => row.startsWith("rename from ") || row.startsWith("copy from "));
113
+ const oldMarker = metadata.find(row => row.startsWith("--- a/") || row.startsWith('--- "a/'));
114
+ const oldValue = oldMarker?.slice(4);
115
+ const oldPath = original ? decode(original.slice(original.startsWith("rename") ? 12 : 10))
116
+ : oldValue ? decode(oldValue.startsWith('"') ? oldValue : oldValue.split("\t")[0]).slice(2) : path;
117
+ if (!diffHeaderMatches(header, oldPath, path)) throw new Error("publication diff destination differs from its file header");
118
+ result.push(path);
119
+ }
120
+ return [...new Set(result)];
121
+ }
package/lib/harness.mjs CHANGED
@@ -8,7 +8,7 @@
8
8
  // Coding/push evidence and current blocks are separate: docs/audit/harness-live-validation.md.
9
9
  import { randomUUID } from "node:crypto";
10
10
  import { dirname } from "node:path";
11
- import { runtimeEnvironment } from "./runtime-environment.mjs";
11
+ import { runtimeEnvironment, CLAUDE_FOREGROUND_ENV } from "./runtime-environment.mjs";
12
12
  import { codexPreparation } from "./preparation_codex.mjs";
13
13
  import { agentsPreparation } from "./preparation_agents.mjs";
14
14
  import { CLOUD_DISPATCH_UNAVAILABLE } from "./codex_cloud_config.mjs";
@@ -32,8 +32,9 @@ const CODEX_OVERRIDES = [
32
32
  const GEMINI_COMMON = ["--output-format", "stream-json", "-p"];
33
33
  const GOOSE_COMMON = ["--output-format", "stream-json"];
34
34
 
35
- const claudeCommon = ({ budget, settings }) => [
35
+ const claudeCommon = ({ budget, settings, foreground }) => [
36
36
  "-p", "--permission-mode", "auto", "--permission-prompts", "none",
37
+ ...(foreground ? ["--disallowedTools", "Monitor"] : []),
37
38
  "--max-budget-usd", String(budget), "--settings", settings,
38
39
  "--output-format", "stream-json", "--verbose",
39
40
  ];
@@ -111,12 +112,15 @@ export const adapters = {
111
112
  newSession: () => randomUUID(),
112
113
  // `--session-id` takes a UUID and claude refuses anything else, so that is exactly what a block may carry.
113
114
  isSession: s => UUID.test(s),
114
- start: ({ session, prompt, budget, settings }) => ["--session-id", session, ...claudeCommon({ budget, settings }), prompt],
115
- resume: ({ session, prompt, budget, settings }) => ["--resume", session, ...claudeCommon({ budget, settings }), prompt],
116
- env: ({ configDir }) => (configDir ? { APORT_CLAUDE_CODE_CONFIG_DIR: configDir } : {}),
115
+ start: ({ session, prompt, budget, settings, foreground = true }) => ["--session-id", session, ...claudeCommon({ budget, settings, foreground }), prompt],
116
+ resume: ({ session, prompt, budget, settings, foreground = true }) => ["--resume", session, ...claudeCommon({ budget, settings, foreground }), prompt],
117
+ env: ({ configDir }) => ({ ...CLAUDE_FOREGROUND_ENV, ...(configDir ? { APORT_CLAUDE_CODE_CONFIG_DIR: configDir } : {}) }),
117
118
  sessionFrom: lines => findLast(lines, ev => ev.type === "system" && ev.subtype === "init")?.session_id ?? null,
118
119
  outcome(lines) {
119
- const result = findLast(lines, ev => ev.type === "result");
120
+ // A resumed Monitor may emit an empty successful notification result
121
+ // before the user's turn has even begun (or after that turn failed).
122
+ // It cannot settle the worker or mask a terminal provider error.
123
+ const result = findLast(lines, ev => ev.type === "result" && ev.origin?.kind !== "task-notification");
120
124
  const lastText = findLast(lines, ev => ev.type === "assistant")?.message?.content?.filter(c => c.type === "text").map(c => c.text).join("\n") ?? null;
121
125
  if (!result) return { ok: false, finished: false, lastMessage: lastText, denials: [], cost: null, error: "no result event" };
122
126
  const reasons = denialReasons(lines);
@@ -333,14 +337,14 @@ export const adapters = {
333
337
  // Remote providers implement transport; the coordinator owns review decisions.
334
338
  export const executionRegistry = Object.freeze({
335
339
  ...Object.fromEntries(Object.entries(adapters).map(([name, adapter]) => [name, Object.freeze({
336
- name, adapter, available: true, transport: EXECUTION_TRANSPORTS[name], authentication: "operator-cli",
340
+ name, adapter, available: true, repositoryAccess: { mode: "local-worktree" }, transport: EXECUTION_TRANSPORTS[name], authentication: "operator-cli",
337
341
  environments: ["isolated-checkout"], continuation: "native-session", observability: ["jsonl", "process"],
338
342
  controls: { cancel: true, interrupt: true }, cost: { source: name === "claude" ? "provider-usd" : "unknown-or-provider", hardCap: name === "claude" },
339
343
  policy: name === "codex" ? ["workspace-write", "auto-review"] : name === "claude" ? ["configured-hooks"] : [],
340
344
  preparation: adapter.preparation || null,
341
345
  })])),
342
- "openai-agents": Object.freeze({ name: "openai-agents", available: true, isSession: value => typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(value), transport: EXECUTION_TRANSPORTS["openai-agents"], authentication: "project-api-key", environments: ["openai_hosted"], continuation: "native-session", observability: ["events", "saved-items", "polling"], controls: { cancel: true, interrupt: false }, cost: { source: "api-usage-and-hosted-compute", hardCap: false }, policy: [], preparation: agentsPreparation }),
343
- "codex-cloud": Object.freeze({ name: "codex-cloud", available: false, reason: CLOUD_DISPATCH_UNAVAILABLE.reason, fix: CLOUD_DISPATCH_UNAVAILABLE.fix, isSession: value => typeof value === "string" && /^task_[A-Za-z0-9_-]{1,200}$/.test(value), transport: EXECUTION_TRANSPORTS["codex-cloud"], authentication: "operator-chatgpt", environments: [], continuation: "successor-task", observability: ["polling", "summary", "diff", "task-link"], controls: { cancel: false, interrupt: false }, cost: { source: "chatgpt-account", hardCap: false }, policy: [], preparation: null, liveQualified: false }),
346
+ "openai-agents": Object.freeze({ name: "openai-agents", repositoryAccess: { mode: "snapshot" }, available: true, isSession: value => typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/.test(value), transport: EXECUTION_TRANSPORTS["openai-agents"], authentication: "project-api-key", environments: ["openai_hosted"], continuation: "native-session", observability: ["events", "saved-items", "polling"], controls: { cancel: true, interrupt: false }, cost: { source: "api-usage-and-hosted-compute", hardCap: false }, policy: [], preparation: agentsPreparation }),
347
+ "codex-cloud": Object.freeze({ name: "codex-cloud", repositoryAccess: { mode: "bound-host", hosts: ["github.com"] }, available: false, reason: CLOUD_DISPATCH_UNAVAILABLE.reason, fix: CLOUD_DISPATCH_UNAVAILABLE.fix, isSession: value => typeof value === "string" && /^task_[A-Za-z0-9_-]{1,200}$/.test(value), transport: EXECUTION_TRANSPORTS["codex-cloud"], authentication: "operator-chatgpt", environments: [], continuation: "successor-task", observability: ["polling", "summary", "diff", "task-link"], controls: { cancel: false, interrupt: false }, cost: { source: "chatgpt-account", hardCap: false }, policy: [], preparation: null, liveQualified: false }),
344
348
  });
345
349
  export const capabilitiesFor = name => {
346
350
  const c = executionRegistry[name];
@@ -467,7 +471,7 @@ export function buildRun({ harness, config, mode, session, prompt, cwd, contextP
467
471
  "For task-authorized tests of this harness, invoke that exact executable, quoted as one path, instead of assuming the bare command on PATH is the same installation. This does not authorize nested jobs, model/provider changes, extra paid calls or weaker permissions. Keep the task's existing scope and security settings.",
468
472
  "Before substantial implementation or retrying blocked work, check the brief's mandatory external prerequisites: required host/plugin support, model access and any browser or capture surface. Use the available native tools and the smallest authorized check; version/sign-in checks alone do not prove model access or installed-plugin behavior. If a required capability is unavailable, report the observed failure, affected criteria and exact remedy early. Continue useful independent work, preserve unmet attestations, and do not repeat unchanged blocked calls or replace required live evidence with fixtures.",
469
473
  ].join("\n");
470
- const ctx = { session, prompt: `${runtime}\n\n${prompt}`, budget: config.max_budget_usd, settings: h.settings, cwd };
474
+ const ctx = { session, prompt: `${runtime}\n\n${prompt}`, budget: config.max_budget_usd, settings: h.settings, cwd, foreground: runtimeSetup };
471
475
  const base = mode === "start" ? a.start(ctx) : a.resume(ctx);
472
476
  const args = assertSafeArgv(withContext(a, base, contextPaths));
473
477
  const env = assertSafeEnv({ ...runtimeEnvironment(process.env, process.execPath, cwd), ...a.env({ configDir: h.settings ? dirname(h.settings) : null }) });
@@ -0,0 +1,71 @@
1
+ export { ROUND_MARKER, NUDGE_MARKER, RESET_MARKER, READY_LABEL, NEEDS_LABEL } from "./public-format.mjs";
2
+ export const MAX_DIFF_BYTES = 16 * 1024 * 1024;
3
+ export const normalizeLogin = login => String(login || "").replace(/^app\//, "").replace(/\[bot\]$/, "").toLowerCase();
4
+ export const actor = a => {
5
+ if (!a) return { login: "", bot: false };
6
+ const raw = a.login ?? a.user?.login ?? "";
7
+ return { login: normalizeLogin(raw), bot: a.bot === true || a.is_bot === true || a.type === "Bot" || a.user?.type === "Bot" || /\[bot\]$/.test(raw) || /^app\//.test(raw) };
8
+ };
9
+
10
+ export const REQUIRED_HOST_CAPABILITIES = Object.freeze(["pullRequests", "reviews", "comments", "checks", "diff", "mergeProof", "recovery"]);
11
+ export function requireHostCapabilities(adapter) {
12
+ for (const name of REQUIRED_HOST_CAPABILITIES) if (adapter.capabilities?.[name] !== true) throw new Error(`Host ${adapter.host} lacks required ${name} evidence; preserve state and configure a qualified host adapter before retrying`);
13
+ }
14
+ export function completeItems(value, operation) {
15
+ if (Array.isArray(value)) return value;
16
+ if (value?.complete === true && Array.isArray(value.items) && !value.rateLimited) return value.items;
17
+ throw new Error(`Incomplete ${operation} evidence; preserve state and retry after host access recovers`);
18
+ }
19
+
20
+ /** Required observations are checked before the shared engine can consume them. */
21
+ export function validateHostRecord(record, host, { pr = false, full = false, event = false } = {}) {
22
+ if (!record || record.complete === false || record.rateLimited || record.source?.host !== host ||
23
+ typeof record.source?.adapter !== "string" || typeof record.eventId !== "string" || !record.eventId ||
24
+ typeof record.actor?.login !== "string" || typeof record.actor?.bot !== "boolean" ||
25
+ (record.headSha !== null && typeof record.headSha !== "string") ||
26
+ [record.createdAt, record.updatedAt].some(value => value !== null && (typeof value !== "string" || !Number.isFinite(Date.parse(value))))) {
27
+ throw new Error("Incomplete normalized host evidence; preserve state and recover a complete observation");
28
+ }
29
+ if (event && record.state !== "PENDING" && !Number.isFinite(Date.parse(record.at))) throw new Error("Incomplete event timestamp; preserve the review cursor");
30
+ if (pr && (!Number.isSafeInteger(record.number) || record.number < 1 || !record.headRefOid || record.headSha !== record.headRefOid ||
31
+ !["OPEN", "CLOSED", "MERGED"].includes(record.state))) throw new Error("Incomplete pull request evidence");
32
+ if (full) {
33
+ if (record.feedback !== undefined) {
34
+ const f = record.feedback;
35
+ if (f?.complete !== true || f.headSha !== record.headSha || ![f.reviews, f.inline, f.comments].every(Array.isArray)) throw new Error("Incomplete normalized feedback snapshot");
36
+ for (const item of [...f.reviews, ...f.inline, ...f.comments]) validateHostRecord(item, host, { event: true });
37
+ }
38
+ if (!Array.isArray(record.statusCheckRollup)) throw new Error("Incomplete CI evidence");
39
+ for (const check of record.statusCheckRollup) {
40
+ validateHostRecord(check, host);
41
+ if (check.headSha !== record.headSha || !["passed", "failed", "pending"].includes(check.outcome)) throw new Error("Incomplete or mismatched CI evidence");
42
+ }
43
+ }
44
+ return record;
45
+ }
46
+
47
+ /** Providers declare access requirements; no host-by-harness switch table. */
48
+ export function assertRepositoryAccess(capabilities, repository) {
49
+ const access = capabilities?.repositoryAccess;
50
+ if (access?.mode === "local-worktree" || access?.mode === "snapshot") return;
51
+ if (access?.mode === "bound-host" && access.hosts?.includes(repository.host)) return;
52
+ throw new Error(`Execution provider cannot access repository host ${repository.host}; select a compatible provider before dispatch`);
53
+ }
54
+
55
+ /** Adapters opt into immutable task identity; legacy observations retain their semantics. */
56
+ export function observedIdentityProblem(entry, pr) {
57
+ if (!pr.strictIdentity) return null;
58
+ if (entry.pr != null && pr.number !== entry.pr || pr.isCrossRepository !== false || entry.url && pr.url !== entry.url || pr.headRefName !== entry.branch || entry.base && pr.baseRefName !== entry.base) return "PR repository, branch or approved base changed; restore the recorded identity before resuming";
59
+ return null;
60
+ }
61
+
62
+ export async function terminalIdentityProblem(host, entry, pr) {
63
+ if (!pr.strictIdentity) return null;
64
+ const mismatch = observedIdentityProblem(entry, pr);
65
+ if (mismatch) return mismatch;
66
+ if (pr.state !== "MERGED") return `PR is ${pr.hostState || pr.state}; it was not merged. Reopen and register it or cancel the task`;
67
+ if (!entry.cursor?.fixed_sha || entry.cursor.fixed_sha !== pr.headRefOid) return "Merged head differs from the recorded head; inspect the PR and saved readiness before continuing";
68
+ const merge = pr.mergeCommit?.oid;
69
+ if (!/^[a-f0-9]{40,64}$/i.test(merge || "") || !pr.mergedAt || !await host.containsCommit(entry.repo, entry.base, merge)) return "Cannot prove the merge commit is in the approved base; restore repository access and inspect merge ancestry";
70
+ return null;
71
+ }
@@ -0,0 +1,22 @@
1
+ // The one shipped host catalogue. Portable consumers read policy only; Node's
2
+ // hosts.mjs binds these declarations to transport implementations by adapter ID.
3
+ import { githubDefinition } from "./hosts/github_contract.mjs";
4
+ import { bitbucketDefinition } from "./hosts/bitbucket_contract.mjs";
5
+ import { repositoryIdentity } from "./repository.mjs";
6
+ export const HOST_DEFINITIONS = Object.freeze([githubDefinition, bitbucketDefinition]);
7
+ export function hostDefinition(repo = "owner/name", { transport = false } = {}) {
8
+ const repository = repositoryIdentity(repo), adapter = HOST_DEFINITIONS.find(d => d.host === repository.host);
9
+ if (!adapter) throw new Error(`Unsupported repository host ${repository.host}; configure a qualified host before dispatch`);
10
+ if (!transport && adapter.dispatch === false) throw new Error(adapter.dispatchReason);
11
+ return { repository, adapter };
12
+ }
13
+ export const completionAdapter = repo => hostDefinition(repo || "owner/name").adapter.id;
14
+ export const hostPrompts = repo => hostDefinition(repo || "owner/name").adapter.prompts;
15
+ export function hostGuidance(repo) {
16
+ try { return hostDefinition(repo, { transport: true }).adapter.dispatchReason ?? null; }
17
+ catch (error) { return error.message; }
18
+ }
19
+ export function pullRequestUrl(repo, number) {
20
+ if (!Number.isSafeInteger(Number(number)) || Number(number) < 1) return null;
21
+ try { const { adapter, repository } = hostDefinition(repo, { transport: true }); return adapter.prUrl(repository, Number(number)); } catch { return null; }
22
+ }