@uchibeke/gallivant 0.2.0 → 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 (133) hide show
  1. package/README.md +120 -60
  2. package/SECURITY.md +9 -0
  3. package/bin/gallivant.mjs +41 -14
  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 +6 -2
  8. package/docs/agent-setup.md +55 -41
  9. package/docs/architecture.md +11 -3
  10. package/docs/bitbucket-promotion.md +69 -0
  11. package/docs/bitbucket-review.md +330 -0
  12. package/docs/chains.md +30 -1
  13. package/docs/codex-cloud.md +7 -0
  14. package/docs/codex-review.md +50 -0
  15. package/docs/commands.md +103 -14
  16. package/docs/config.md +45 -13
  17. package/docs/execution.md +1 -1
  18. package/docs/first-party-review.md +266 -0
  19. package/docs/git-hosts.md +294 -0
  20. package/docs/guardrails.md +56 -0
  21. package/docs/install.md +137 -67
  22. package/docs/launch.md +7 -4
  23. package/docs/network-recovery.md +9 -0
  24. package/docs/openai-agents.md +3 -2
  25. package/docs/prd.md +23 -2
  26. package/docs/preparation.md +2 -2
  27. package/docs/release-qualification.md +1 -1
  28. package/docs/releasing.md +46 -22
  29. package/docs/task-delivery.md +5 -5
  30. package/docs/task-profiles.md +537 -0
  31. package/docs/tasks.md +59 -197
  32. package/lib/attribution.mjs +7 -3
  33. package/lib/block.mjs +10 -1
  34. package/lib/chains.mjs +101 -30
  35. package/lib/codex_cloud.mjs +2 -1
  36. package/lib/codex_cloud_input.mjs +15 -6
  37. package/lib/codex_cloud_result.mjs +1 -1
  38. package/lib/commands/doctor.mjs +22 -453
  39. package/lib/commands/init.mjs +281 -28
  40. package/lib/commands/register.mjs +81 -13
  41. package/lib/commands/resume.mjs +5 -1
  42. package/lib/commands/review.mjs +66 -0
  43. package/lib/commands/serve.mjs +8 -0
  44. package/lib/commands/start.mjs +30 -11
  45. package/lib/commands/status.mjs +18 -2
  46. package/lib/commands/task.mjs +9 -1
  47. package/lib/commands/up.mjs +19 -11
  48. package/lib/commands/why.mjs +7 -3
  49. package/lib/config.mjs +42 -9
  50. package/lib/context.mjs +28 -21
  51. package/lib/control.mjs +21 -6
  52. package/lib/credential.mjs +20 -14
  53. package/lib/deliverable.mjs +2 -0
  54. package/lib/events.mjs +96 -53
  55. package/lib/execution.mjs +132 -11
  56. package/lib/execution_preparation.mjs +16 -1
  57. package/lib/execution_result.mjs +26 -5
  58. package/lib/first_party_review.mjs +618 -0
  59. package/lib/fs.mjs +10 -3
  60. package/lib/gh.mjs +144 -56
  61. package/lib/git.mjs +133 -32
  62. package/lib/git_diff.mjs +121 -0
  63. package/lib/harness.mjs +14 -10
  64. package/lib/host_contract.mjs +71 -0
  65. package/lib/host_definitions.mjs +22 -0
  66. package/lib/hosts/bitbucket.mjs +553 -0
  67. package/lib/hosts/bitbucket_contract.mjs +57 -0
  68. package/lib/hosts/bitbucket_credential.mjs +62 -0
  69. package/lib/hosts/bitbucket_review.mjs +44 -0
  70. package/lib/hosts/bitbucket_review_producer.mjs +50 -0
  71. package/lib/hosts/github.mjs +57 -0
  72. package/lib/hosts/github_contract.mjs +27 -0
  73. package/lib/hosts/github_doctor.mjs +482 -0
  74. package/lib/hosts/github_review_summary.mjs +38 -0
  75. package/lib/hosts/transport_doctor.mjs +35 -0
  76. package/lib/hosts.mjs +130 -0
  77. package/lib/learning_qualification.mjs +1 -1
  78. package/lib/notify.mjs +1 -1
  79. package/lib/openai_agents_input.mjs +7 -2
  80. package/lib/openai_agents_sources.mjs +16 -8
  81. package/lib/plugin_package.mjs +13 -1
  82. package/lib/pr_operation.mjs +41 -0
  83. package/lib/prd.mjs +18 -3
  84. package/lib/preparation_codex.mjs +6 -1
  85. package/lib/probe.mjs +6 -4
  86. package/lib/process.mjs +24 -0
  87. package/lib/promotion.mjs +132 -0
  88. package/lib/promotion_git.mjs +63 -0
  89. package/lib/prompt.mjs +16 -13
  90. package/lib/provider_health.mjs +14 -3
  91. package/lib/repository.mjs +68 -0
  92. package/lib/repository_preflight.mjs +11 -0
  93. package/lib/review_evaluation.mjs +25 -0
  94. package/lib/review_evidence.mjs +500 -0
  95. package/lib/review_history.mjs +73 -0
  96. package/lib/review_image.mjs +145 -0
  97. package/lib/review_link.mjs +12 -0
  98. package/lib/review_policy.mjs +132 -0
  99. package/lib/review_policy_contract.mjs +18 -0
  100. package/lib/review_state.mjs +13 -0
  101. package/lib/review_visual.mjs +94 -0
  102. package/lib/runtime-environment.mjs +6 -0
  103. package/lib/runtime_node.mjs +3 -2
  104. package/lib/runtime_state.mjs +39 -5
  105. package/lib/sources/prd.mjs +6 -3
  106. package/lib/state.mjs +137 -24
  107. package/lib/task_completion.mjs +7 -3
  108. package/lib/task_contract.mjs +201 -27
  109. package/lib/task_delivery.mjs +9 -2
  110. package/lib/task_delivery_contract.mjs +13 -9
  111. package/lib/task_delivery_view.mjs +18 -2
  112. package/lib/task_environment.mjs +13 -1
  113. package/lib/task_limits.mjs +13 -0
  114. package/lib/task_model_config.mjs +99 -0
  115. package/lib/task_model_route.mjs +243 -0
  116. package/lib/task_prepare.mjs +21 -8
  117. package/lib/task_profile.mjs +1875 -0
  118. package/lib/task_repository_context.mjs +65 -0
  119. package/lib/tasks.mjs +252 -51
  120. package/lib/tick.mjs +373 -98
  121. package/lib/time.mjs +1 -0
  122. package/lib/tracker/pages.mjs +19 -5
  123. package/lib/tracker/review.mjs +21 -0
  124. package/lib/tracker_process.mjs +8 -1
  125. package/lib/verification_sandbox.mjs +2 -1
  126. package/lib/work_artifacts.mjs +79 -0
  127. package/npm-shrinkwrap.json +12 -3
  128. package/package.json +9 -3
  129. package/templates/plugin/task-delivery.SKILL.md +17 -10
  130. package/templates/plugin/task-intake-contract.json +18 -0
  131. package/templates/plugin/task-profile-requests.mjs +1448 -0
  132. package/templates/tracker/tracker.css +3 -0
  133. package/templates/tracker/tracker.js +38 -0
@@ -0,0 +1,65 @@
1
+ // A bounded, read-only description of the selected repository for drafting:
2
+ // the HEAD revision and the tracked, non-ignored, non-private path listing,
3
+ // digested together so the snapshot is pinned to the prepared revision. File
4
+ // contents still reach the planner only through explicitly selected documents.
5
+ // Collection failures are reported as unavailable rather than guessed.
6
+ import { createHash } from "node:crypto";
7
+ import { redact } from "./execution_io.mjs";
8
+
9
+ export const REPOSITORY_CONTEXT_LIMITS = Object.freeze({ paths: 400, pathBytes: 16_384 });
10
+ // Secret, credential and agent-configuration locations never enter the listing
11
+ // even when a repository tracks them; the same families intake refuses as sources.
12
+ const PRIVATE_PATH = /(?:^|\/)(?:\.env(?:\.[^/]*)?|\.aws|\.ssh|\.codex|\.claude|\.aport|\.gallivant|\.agents|\.config|\.npmrc|\.netrc|\.git|credentials?(?:\.[^/]*)?|secrets?(?:\.[^/]*)?|[^/]*\.(?:pem|key|p12|pfx|keystore|jks)|id_(?:rsa|dsa|ecdsa|ed25519)(?:\.pub)?)(?:\/|$)/i;
13
+ const unavailable = reason => ({ status: "unavailable", reason, revision: null, digest: null, paths: [], counts: null, readOnly: true });
14
+ const sha = value => createHash("sha256").update(value).digest("hex");
15
+
16
+ export async function repositorySnapshot(git, checkout, { secrets = [] } = {}) {
17
+ if (typeof git?.revision !== "function" || typeof git?.treePaths !== "function" || typeof git?.indexPaths !== "function" || typeof git?.ignoredTrackedPaths !== "function" || typeof git?.ignoreRuleChanges !== "function") {
18
+ return unavailable("the Git adapter cannot enumerate the repository read-only; only explicitly selected documents are supplied as context");
19
+ }
20
+ try {
21
+ const revision = String(await git.revision(checkout)).trim();
22
+ if (!/^[0-9a-f]{40}$/.test(revision)) return unavailable("the checkout has no resolvable HEAD commit; commit or fetch before preparing");
23
+ // The listing is the recorded commit's tree, so revision and digest describe
24
+ // one state. The index decides ignores; a tree path the index no longer
25
+ // holds (a staged deletion) cannot be evaluated and is excluded.
26
+ // Ignore rules are evaluated from the checkout's in-tree .gitignore files.
27
+ // They equal the recorded revision's rules only when none of those files
28
+ // differs from it; otherwise the listing could change or expose ignored
29
+ // names without a revision change, so it is reported as unavailable.
30
+ const ruleChanges = await git.ignoreRuleChanges(checkout, revision);
31
+ if (ruleChanges.length) return unavailable(`${ruleChanges.length} .gitignore file(s) differ from the recorded revision (${ruleChanges.slice(0, 3).map(p => redact(p, secrets)).join(", ")}${ruleChanges.length > 3 ? ", ..." : ""}); commit or revert them before preparing so ignored paths are evaluated at that revision`);
32
+ const tracked = await git.treePaths(checkout, revision);
33
+ const index = new Set(await git.indexPaths(checkout));
34
+ const ignored = new Set(await git.ignoredTrackedPaths(checkout));
35
+ // The three listings are not one atomic read. Recheck the revision and the
36
+ // ignore files afterwards so a concurrent edit between the reads cannot
37
+ // pair the recorded revision with rules or paths from another state.
38
+ const [revisionAfter, changesAfter, indexAfter] = [String(await git.revision(checkout)).trim(), await git.ignoreRuleChanges(checkout, revision), await git.indexPaths(checkout)];
39
+ const sameIndex = indexAfter.length === index.size && indexAfter.every(path => index.has(path));
40
+ if (revisionAfter !== revision || changesAfter.length || !sameIndex) return unavailable("the checkout's revision, index or .gitignore files changed while the snapshot was being collected; retry once the checkout is stable");
41
+ const counts = { tracked: tracked.length, ignored: 0, private: 0, unverified: 0, listed: 0, truncated: 0 };
42
+ const approved = [];
43
+ for (const path of [...tracked].sort()) {
44
+ if (ignored.has(path)) { counts.ignored++; continue; }
45
+ if (typeof path !== "string" || PRIVATE_PATH.test(path) || /[\x00-\x1f\x7f]/.test(path)) { counts.private++; continue; }
46
+ if (!index.has(path)) { counts.unverified++; continue; }
47
+ approved.push(path);
48
+ }
49
+ // The digest covers the complete approved listing at this revision, even
50
+ // when the prompt receives a truncated prefix of it.
51
+ const digest = sha(JSON.stringify({ revision, paths: approved }));
52
+ const paths = [];
53
+ let bytes = 0;
54
+ for (const path of approved) {
55
+ bytes += Buffer.byteLength(path) + 1;
56
+ if (paths.length >= REPOSITORY_CONTEXT_LIMITS.paths || bytes > REPOSITORY_CONTEXT_LIMITS.pathBytes) break;
57
+ paths.push(path);
58
+ }
59
+ counts.listed = paths.length;
60
+ counts.truncated = approved.length - paths.length;
61
+ return { status: "collected", revision, digest, paths, counts, readOnly: true, collectedAt: new Date().toISOString() };
62
+ } catch (error) {
63
+ return unavailable(`repository context could not be collected safely: ${redact(String(error?.message || error), secrets).slice(0, 500)}`);
64
+ }
65
+ }
package/lib/tasks.mjs CHANGED
@@ -1,3 +1,6 @@
1
+ import { hosts } from "./hosts.mjs";
2
+ import { assertRepositoryAccess } from "./host_contract.mjs";
3
+ import { hostApi } from "./hosts.mjs";
1
4
  // Versioned, in-process task API. Importing this module reads no user config,
2
5
  // starts no daemon and makes no network calls. Clients supply the usual context.
3
6
  import { acquireServiceMutation } from "./daemon.mjs";
@@ -21,11 +24,76 @@ import { executionRunner } from "./execution.mjs";
21
24
  import { recoverControl } from "./control.mjs";
22
25
  import { canonical } from "./learning_data.mjs";
23
26
  import { COMPLETION_CAPABILITIES, completionPolicy, completionView } from "./task_completion.mjs";
24
- import { ownerDelivery, ownerImplementation, assertDeliveryAnswer } from "./owner_delivery.mjs";
27
+ import { assertDeliveryAnswer } from "./owner_delivery.mjs";
25
28
  import { prepareTurn, assertPreparationBoundary, preparationCancellationPending, preparationPreflightRetryable, PREPARATION_PENDING } from "./task_prepare.mjs";
26
- import { TASK_VERSION, MAX_REPAIRS, MAX_CONTEXT_BYTES, MANAGER_LOCKFILES, requireText, fragments, requireTaskCapacity, parseTaskSources, parseDraft, renderDraft, validateDraft, collectCommands, mappedQuestions } from "./task_contract.mjs";
29
+ import { TASK_VERSION, MAX_REPAIRS, MAX_CONTEXT_BYTES, MANAGER_LOCKFILES, requireText, fragments, fragmentsForTask, FRAGMENT_VERSION, sourceFragmentVersion, requireTaskCapacity, parseTaskSources, parseDraft, renderDraft, validateDraft, collectCommands, mappedQuestions } from "./task_contract.mjs";
30
+ import { MAX_ANSWER_BYTES, MAX_ANSWER_COUNT, MAX_LEGACY_ANSWER_COUNT, MAX_LEGACY_ANSWER_BATCH_BYTES, MAX_ANSWER_ID_CHARACTERS } from "./task_limits.mjs";
31
+ import { DELIVERY_CAPABILITIES, deliveryProfile, savedProfile, typedEraTask, assertProfileBound, profileView, stableProfile, profileAssumption, deliveryQuestion } from "./task_profile.mjs";
32
+ import { prepareModelTurn } from "./task_model_route.mjs";
33
+ import { intakeModelRoute, intakeModelRouteSummary, pinnedModelRoute } from "./task_model_config.mjs";
34
+ import { repositorySnapshot } from "./task_repository_context.mjs";
27
35
 
28
36
  export { TASK_VERSION } from "./task_contract.mjs";
37
+ export const TASK_ROUTES = Object.freeze(["harness", "model"]);
38
+
39
+ // The planner route is a saved task property. Harness drafting keeps the
40
+ // existing dispatch; the model route is the bounded text-only request.
41
+ const routedPrepare = (ctx, task, store, failures) => task.route === "model" ? prepareModelTurn(ctx, task, store, failures) : prepareTurn(ctx, task, store, failures);
42
+
43
+ // A saved typed profile must validate before any mutation can act on it; an
44
+ // unsupported version, or a typed-era record with no profile at all, fails
45
+ // closed with its recovery path and leaves reads open.
46
+ const assertProfileUsable = task => { sourceFragmentVersion(task); const profile = savedProfile(task); if (profile) assertProfileBound(profile, task); };
47
+
48
+ const authorizedStart = task => task.runAuthorized && (!task.deliveryProfile || Boolean(task.approval));
49
+
50
+ // Reported, bounded or unknown. Never zero while any call lacks evidence.
51
+ function costView(attempts) {
52
+ const calls = attempts.filter(a => a.startedAt);
53
+ if (!calls.length) return { status: "none", usd: 0, detail: "no planning call has been made" };
54
+ const unknown = calls.filter(a => a.cost == null);
55
+ if (!unknown.length) return { status: "reported", usd: calls.reduce((sum, a) => sum + a.cost, 0), detail: "sum of provider-reported or usage-derived costs" };
56
+ if (unknown.every(a => Number.isFinite(a.reservedUsd))) return { status: "bounded", usd: calls.reduce((sum, a) => sum + Math.max(a.cost ?? 0, a.reservedUsd ?? 0), 0), detail: `${unknown.length} call(s) reported no cost; their reservations bound the total` };
57
+ return { status: "unknown", usd: null, detail: `${unknown.length} call(s) reported no cost and carry no reservation` };
58
+ }
59
+ function routeView(task) {
60
+ const attempts = task.attempts || [];
61
+ const usage = attempts.reduce((sum, a) => {
62
+ const u = a.usage && !Array.isArray(a.usage) ? a.usage : null;
63
+ return u && Number.isSafeInteger(u.inputTokens) ? { inputTokens: (sum?.inputTokens || 0) + u.inputTokens, outputTokens: (sum?.outputTokens || 0) + (u.outputTokens || 0) } : sum;
64
+ }, null);
65
+ const base = { route: task.route || "harness", calls: attempts.length, usage, cost: costView(attempts), tools: [] };
66
+ if (task.route === "model") return { ...base, kind: "model", adapter: task.modelRoute?.adapter || null, model: task.modelRoute?.model || null, endpointHost: task.modelRoute?.endpointHost || null, limits: task.modelRoute?.budget || null, boundary: attempts.at(-1)?.boundary || null, harness: task.harness };
67
+ return { ...base, kind: "harness", harness: task.harness, boundary: attempts.at(-1)?.boundary || null, account: attempts.at(-1)?.receipt?.account || null };
68
+ }
69
+ // Everything an owner must see before approving, pinned to one revision and
70
+ // digested so a changed material input is detectable, not merely re-rendered.
71
+ // Reads tolerate an unsupported saved profile (inspection must keep working);
72
+ // every mutation validates it first and fails closed.
73
+ const safeProfile = task => { try { return savedProfile(task); } catch { return null; } };
74
+ function reviewDigest(task) {
75
+ const profile = safeProfile(task);
76
+ return contentDigest(JSON.stringify({ original: task.original, ...(task.fragmentVersion === undefined ? {} : { fragmentVersion: task.fragmentVersion }), requirements: task.requirements, answers: task.answers, deliveryProfile: profile ? stableProfile(profile) : task.deliveryProfile ? { unsupported: task.deliveryProfile } : typedEraTask(task) ? { unsupported: null } : null, completion: task.completion || null,
77
+ harness: task.harness, route: task.route || "harness", modelRoute: task.modelRoute ? { adapter: task.modelRoute.adapter, model: task.modelRoute.model, endpoint: task.modelRoute.endpoint, budget: task.modelRoute.budget, timeoutMs: task.modelRoute.timeoutMs } : null, draft: task.draft?.hash || null, sources: task.context.map(c => ({ path: c.path, hash: c.hash })), verify: task.explicitVerifyCommands || [], repository: task.repositoryContext?.digest || null, allowance: task.allowance }));
78
+ }
79
+ function reviewView(task) {
80
+ if (!task.draft || !task.output) return null;
81
+ const profile = safeProfile(task);
82
+ const requirements = new Map(task.requirements.map(r => [r.id, r]));
83
+ const screened = [];
84
+ const requirementMap = task.output.mapping.map(m => {
85
+ const r = requirements.get(m.requirementId);
86
+ if (profile && m.screen?.tier === "assumption") screened.push(profileAssumption(profile, r, m.screen));
87
+ return { requirementId: m.requirementId, source: r?.source || null, text: r?.text,
88
+ target: m.questionId ? { kind: "question", id: m.questionId } : m.assumption !== undefined ? { kind: "assumption", text: m.assumption } : { kind: "criteria", ids: m.criteria },
89
+ resolvedBy: m.resolvedBy || null, delivery: m.delivery || null, covered: m.covered ?? null, screen: m.screen || null, conflict: m.conflict || null };
90
+ });
91
+ return { revision: task.revision, digest: reviewDigest(task), request: { text: task.original, immutable: true, hash: contentDigest(task.original) }, fragmentVersion: task.fragmentVersion ?? 1,
92
+ requirements: task.requirements, requirementMap, questions: task.questions, assumptions: [...task.output.assumptions, ...screened],
93
+ draft: { path: task.draft.path, hash: task.draft.hash, revision: task.draft.revision, text: readText(task.draft.path) },
94
+ deliveryProfile: profileView(task), harness: task.harness, allowance: task.allowance, modelRoute: routeView(task), cost: costView(task.attempts || []),
95
+ repositoryContext: task.repositoryContext || null, readiness: task.readiness, approval: task.approval || null };
96
+ }
29
97
 
30
98
  function selectedPaths(selected) {
31
99
  if (!Array.isArray(selected) || selected.some(p => typeof p !== "string")) throw taskError("TASK_INPUT", "context must be an array of selected repository file paths");
@@ -85,7 +153,7 @@ function commandCandidates(context, explicit = []) {
85
153
 
86
154
  const same = isDeepStrictEqual;
87
155
  function implementationMatches(task, entry) {
88
- return entry.task?.id === task.id && entry.task?.digest === task.readiness?.digest && same(entry.task?.completion, task.completion) && same(entry.task?.ownerDelivery, task.ownerDelivery);
156
+ return entry.task?.id === task.id && entry.task?.digest === task.readiness?.digest && same(entry.task?.completion, task.completion) && same(entry.task?.ownerDelivery, task.ownerDelivery) && same(entry.task?.deliveryProfile, task.deliveryProfile);
89
157
  }
90
158
  function questionOwnership(task) {
91
159
  const ownership = { ...task.questionMappings };
@@ -105,24 +173,29 @@ const taskRejection = (code, message) => Object.assign(taskError(code, message),
105
173
  const beforeTaskEffects = fn => {
106
174
  try { return fn(); } catch (error) { error.taskEffects = "none"; throw error; }
107
175
  };
176
+ const assertImplementationAvailable = (harness, capability) => {
177
+ if (!capability.available) throw taskError("EXECUTION_UNAVAILABLE", `${harness} cannot dispatch implementation: ${capability.reason || "implementation transport is unavailable"}; ${capability.fix || "select an available configured implementation harness"}; no planning call was made`);
178
+ };
108
179
  const expectRevision = (task, expected) => {
109
180
  if (!Number.isSafeInteger(expected) || expected !== task.revision) throw taskRejection("TASK_CONFLICT", `expected revision ${expected ?? "missing"}, current revision ${task.revision}; inspect the task and retry with its current revision`);
110
181
  };
111
182
  const mutate = (task, store) => { task.revision++; task.updatedAt = new Date().toISOString(); store.save(task); };
112
183
 
113
- export function createTaskService(ctx, { prepare = prepareTurn, decideReady = ready, dispatch = start, daemon = upInBackground, store = taskStore(ctx.home), mutationOwner = null } = {}) {
184
+ export function createTaskService(ctx, { prepare = routedPrepare, decideReady = ready, dispatch = start, daemon = upInBackground, store = taskStore(ctx.home), mutationOwner = null } = {}) {
114
185
  const result = task => {
115
186
  const entry = task.implementation && ctx.state.get(task.implementation.id);
116
187
  const claim = ctx.state.startClaim(entryId(task.repo, task.id));
117
188
  const candidates = collectCommands(task.context, task.explicitVerifyCommands || []);
118
189
  return {
119
190
  version: TASK_VERSION, taskId: task.id, revision: task.revision, status: task.status,
120
- original: task.original, repo: task.repo, checkout: task.checkout, callerCwd: task.callerCwd || task.checkout, base: task.base, baseSource: task.baseSource, harness: task.harness,
191
+ original: task.original, fragmentVersion: task.fragmentVersion ?? 1, repo: task.repo, checkout: task.checkout, callerCwd: task.callerCwd || task.checkout, base: task.base, baseSource: task.baseSource, harness: task.harness,
121
192
  questions: task.questions, answers: task.answers, requirements: task.requirements,
122
193
  assumptions: task.output?.assumptions || [], mapping: task.output?.mapping || [], stories: task.output?.stories || [],
123
194
  draft: task.draft ? { path: task.draft.path, hash: task.draft.hash, revision: task.draft.revision, current: task.draft.requirementsHash === contentDigest(JSON.stringify(task.requirements)), text: readText(task.draft.path) } : null,
124
195
  readiness: task.readiness, validation: task.validation || null,
125
196
  completion: completionView(task.completion, entry, task.ownerDelivery, entry ? executionRunner(ctx, task.implementation.id, entry.harness)?.current?.() : null),
197
+ deliveryProfile: profileView(task), route: task.route || "harness", modelRoute: routeView(task), cost: costView(task.attempts || []),
198
+ repositoryContext: task.repositoryContext || null, review: reviewView(task), approval: task.approval || null,
126
199
  errorCode: task.errorCode || null,
127
200
  implementation: task.implementation ? { ...task.implementation, state: entry?.state || null, pr: entry?.pr || null, url: entry?.url || null } : null, failures: task.failures,
128
201
  dispatchRecovery: claim?.clone || null,
@@ -130,7 +203,7 @@ export function createTaskService(ctx, { prepare = prepareTurn, decideReady = re
130
203
  sources: task.context.map(c => ({ path: c.path, hash: c.hash })),
131
204
  verifyCommands: candidates.commands.map(command => ({ command, sources: candidates.sources[command] || [] })), commandIssues: candidates.issues,
132
205
  artifacts: task.draft ? [{ id: "brief", path: task.draft.path, hash: task.draft.hash }, ...task.output.sources.map(path => ({ id: `source:${path}`, path: join(task.draft.root, path), hash: task.context.find(c => c.path === path).hash }))] : [],
133
- nextAction: preparationCancellationPending(task) ? "resume the owned cancellation with the current revision; no new preparation or implementation is authorized" : task.status === "needs-input" ? "answer the saved question IDs using the current revision" : task.status === "ready" ? (task.runAuthorized ? "start the approved revision" : "review the draft, then start with explicit run authorization") : task.status === "started" ? "follow the implementation through status, why, and the tracker" : task.status === "blocked" ? (preparationPreflightRetryable(task) ? "resume the current revision to retry the pre-submit provider check; no model job was submitted" : "resolve the reported failure; inspect the saved evidence before retrying") : "resume preparation with the current revision",
206
+ nextAction: preparationCancellationPending(task) ? "resume the owned cancellation with the current revision; no new preparation or implementation is authorized" : task.status === "needs-input" ? "answer the saved question IDs using the current revision" : task.status === "ready" ? (authorizedStart(task) ? "start the approved revision" : "review the draft, then start with explicit run authorization") : task.status === "started" ? "follow the implementation through status, why, and the tracker" : task.status === "blocked" ? (preparationPreflightRetryable(task) ? "resume the current revision to retry the pre-submit provider check; no model job was submitted" : "resolve the reported failure; inspect the saved evidence before retrying") : "resume preparation with the current revision",
134
207
  };
135
208
  };
136
209
  const requestForTask = task => {
@@ -146,6 +219,18 @@ export function createTaskService(ctx, { prepare = prepareTurn, decideReady = re
146
219
  }
147
220
  return req;
148
221
  };
222
+ const saveDraft = (task, output) => {
223
+ task.output = output;
224
+ task.questions = output.questions;
225
+ for (const q of output.questions) task.questionHistory[q.id] = q;
226
+ task.questionMappings = { ...task.questionMappings, ...mappedQuestions(output) };
227
+ const root = ensureDir(join(taskRoot(ctx.home, task.id), "drafts", `revision-${task.revision + 1}`));
228
+ for (const c of task.context) writeRepositoryText(root, c.path, c.text);
229
+ const rel = `docs/prds/${task.id}.md`, text = renderDraft(task, output);
230
+ writeRepositoryText(root, rel, text);
231
+ task.draft = { root, path: join(root, rel), hash: contentDigest(text), revision: task.revision + 1, requirementsHash: contentDigest(JSON.stringify(task.requirements)) };
232
+ task.readiness = null;
233
+ };
149
234
  const savedDraft = task => {
150
235
  const req = requestForTask(task);
151
236
  if (contentDigest(req.text) !== task.draft.hash) {
@@ -208,7 +293,7 @@ export function createTaskService(ctx, { prepare = prepareTurn, decideReady = re
208
293
  const claim = ctx.state.startClaim(id);
209
294
  if (claim) return block(task, taskError("TASK_DISPATCH_UNKNOWN", `unresolved start claim for ${id}; inspect ${claim.clone?.worktree ? `its clone at ${claim.clone.worktree}, ` : ""}its run and snapshots before recovery; no duplicate was dispatched`));
210
295
  if (task.dispatchIntent) return block(task, taskError("TASK_DISPATCH_UNKNOWN", `dispatch acceptance for ${id} is unknown; inspect its saved artifacts before recovery; no duplicate was dispatched`));
211
- if (!task.runAuthorized) throw taskError("TASK_AUTHORIZATION", "start requires explicit run authorization");
296
+ if (!authorizedStart(task)) throw taskError("TASK_AUTHORIZATION", "review the current revision, then start with explicit --run authorization");
212
297
  assertPreparationBoundary(task, ctx);
213
298
  const req = savedDraft(task);
214
299
  if (task.questions.length || !task.readiness?.allow || task.readiness.hash !== task.draft.hash || task.readiness.digest !== captureStartInput(req, task.base).digest) throw taskError("TASK_NOT_READY", "current revision has unresolved requirements or no exact readiness approval; validate it before starting");
@@ -221,10 +306,16 @@ export function createTaskService(ctx, { prepare = prepareTurn, decideReady = re
221
306
  deliveryAllowance = { budgetUsd: task.allowance.budgetUsd - spent, runtimeMs: task.allowance.runtimeMs - task.attempts.reduce((sum, a) => sum + (a.elapsedMs || 0), 0) };
222
307
  if (deliveryAllowance.budgetUsd <= 0 || deliveryAllowance.runtimeMs <= 0) throw taskError("TASK_ALLOWANCE", "Preparation exhausted the task allowance; no implementation was dispatched");
223
308
  }
309
+ // The approval pins the reviewed revision. A separate explicit start
310
+ // records it for typed tasks; historical tasks retain their saved consent.
311
+ // Any later material change clears it.
312
+ const digest = reviewDigest(task);
313
+ if (task.approval && task.approval.digest !== digest) throw taskError("TASK_CONFLICT", "the approved review revision changed; inspect the current review and approve it again before starting");
314
+ task.approval ??= { revision: task.revision, digest, draftHash: task.draft.hash, readinessDigest: task.readiness.digest, at: new Date().toISOString() };
224
315
  task.dispatchIntent = { id, revision: task.revision, digest: task.readiness.digest, at: new Date().toISOString() };
225
316
  store.save(task);
226
317
  try {
227
- task.implementation = await dispatch(ctx, { path: req.path, request: req, base: task.base, task: { id: task.id, revision: task.revision, digest: task.readiness.digest, baseSource: task.baseSource, ...(task.completion ? { completion: task.completion } : {}), ...(task.ownerDelivery ? { ownerDelivery: task.ownerDelivery } : {}), ...(deliveryAllowance ? { deliveryAllowance } : {}) } });
318
+ task.implementation = await dispatch(ctx, { path: req.path, request: req, base: task.base, task: { id: task.id, revision: task.revision, digest: task.readiness.digest, baseSource: task.baseSource, ...(task.completion ? { completion: task.completion } : {}), ...(task.ownerDelivery ? { ownerDelivery: task.ownerDelivery } : {}), ...(task.deliveryProfile ? { deliveryProfile: task.deliveryProfile, approval: task.approval } : {}), ...(deliveryAllowance ? { deliveryAllowance } : {}) } });
228
319
  } catch (e) {
229
320
  if (e.startUncertain === false && !ctx.state.get(id) && !ctx.state.startClaim(id)) task.dispatchIntent = null;
230
321
  return block(task, e);
@@ -239,6 +330,11 @@ export function createTaskService(ctx, { prepare = prepareTurn, decideReady = re
239
330
  }
240
331
  return { ...result(task), daemonError: task.daemonError || null };
241
332
  };
333
+ const assertReadyDraft = task => {
334
+ if (task.status !== "ready") return;
335
+ const validation = validateDraft(task, savedDraft(task));
336
+ if (!validation.ok) throw taskError("TASK_CONFLICT", validation.failures.join("\n"));
337
+ };
242
338
  const prepareInternal = async (task, noDaemon = false) => {
243
339
  if (task.status === "started") return startInternal(task, noDaemon);
244
340
  // An implementation accepted before this safety change still belongs to its
@@ -247,14 +343,15 @@ export function createTaskService(ctx, { prepare = prepareTurn, decideReady = re
247
343
  if (["needs-input", "ready"].includes(task.status)) {
248
344
  try { assertPreparationBoundary(task, ctx); }
249
345
  catch (e) { return block(task, e); }
250
- return task.status === "ready" && task.runAuthorized ? startInternal(task, noDaemon) : result(task);
346
+ assertReadyDraft(task);
347
+ return task.status === "ready" && authorizedStart(task) ? startInternal(task, noDaemon) : result(task);
251
348
  }
252
349
  // A blocked legacy timeout may still own a pending remote stop. An explicit
253
350
  // resume reconciles only that control; it cannot retry preparation/refusals.
254
351
  if (task.status === "blocked" && !preparationCancellationPending(task)) {
255
352
  if (!preparationPreflightRetryable(task)) return result(task);
256
353
  task.status = "preparing";
257
- task.failures = task.preflightFailure.validationFailures || [];
354
+ task.failures = task.preflightFailure?.validationFailures || [];
258
355
  task.errorCode = null;
259
356
  delete task.preflightFailure;
260
357
  mutate(task, store);
@@ -266,7 +363,7 @@ export function createTaskService(ctx, { prepare = prepareTurn, decideReady = re
266
363
  if (task.status === "validating") {
267
364
  try {
268
365
  const validated = await validateInternal(task);
269
- return task.runAuthorized && validated.readiness?.allow ? await startInternal(task, noDaemon) : validated;
366
+ return authorizedStart(task) && validated.readiness?.allow ? await startInternal(task, noDaemon) : validated;
270
367
  } catch (e) { return block(task, e); }
271
368
  }
272
369
  // Rebuild inferred candidates on reopen; a legacy cached command is not an
@@ -279,7 +376,15 @@ export function createTaskService(ctx, { prepare = prepareTurn, decideReady = re
279
376
  // Reconcile an already-owned turn before rejecting legacy oversized
280
377
  // state, so usage/cleanup survive recovery. Never launch a new turn.
281
378
  const previous = task.attempts.at(-1);
282
- if (!previous || (previous.settled && previous.consumed)) requireTaskCapacity(task);
379
+ if (!previous || (previous.settled && previous.consumed)) {
380
+ requireTaskCapacity(task);
381
+ assertRepositoryAccess(ctx.capabilitiesFor?.(task.harness) || capabilitiesFor(task.harness), (ctx.hosts ?? hosts).select(task).repository);
382
+ try { assertImplementationAvailable(task.harness, ctx.capabilitiesFor?.(task.harness) || capabilitiesFor(task.harness)); }
383
+ catch (error) {
384
+ if (error.code === "EXECUTION_UNAVAILABLE") task.preflightFailure = { code: error.code, stage: "implementation-availability", attemptCount: task.attempts.length, validationFailures: [...failures] };
385
+ throw error;
386
+ }
387
+ }
283
388
  message = await prepare(ctx, task, store, failures);
284
389
  if (message === PREPARATION_PENDING) return result(task); // Owned remote preparation is pending.
285
390
  if (Array.isArray(ctx.taskSecrets) && message !== redact(message, ctx.taskSecrets)) throw taskError("TASK_SECRET", "Preparation output contains credential material; inspect the private provider record before recovery");
@@ -288,17 +393,7 @@ export function createTaskService(ctx, { prepare = prepareTurn, decideReady = re
288
393
  catch (e) { if (e.code === "TASK_INPUT") task.readiness = null; return block(task, e); }
289
394
  try {
290
395
  const output = parseDraft(message, task);
291
- task.output = output;
292
- task.questions = output.questions;
293
- for (const q of output.questions) task.questionHistory[q.id] = q;
294
- task.questionMappings = { ...task.questionMappings, ...mappedQuestions(output) };
295
- const root = ensureDir(join(taskRoot(ctx.home, task.id), "drafts", `revision-${task.revision + 1}`));
296
- for (const c of task.context) writeRepositoryText(root, c.path, c.text);
297
- const rel = `docs/prds/${task.id}.md`;
298
- const text = renderDraft(task, output);
299
- writeRepositoryText(root, rel, text);
300
- task.draft = { root, path: join(root, rel), hash: contentDigest(text), revision: task.revision + 1, requirementsHash: contentDigest(JSON.stringify(task.requirements)) };
301
- task.readiness = null;
396
+ saveDraft(task, output);
302
397
  const validation = validateDraft(task, requestForTask(task));
303
398
  task.validation = { ok: validation.ok, failures: validation.failures };
304
399
  if (output.questions.length) {
@@ -324,31 +419,49 @@ export function createTaskService(ctx, { prepare = prepareTurn, decideReady = re
324
419
  try {
325
420
  const validated = await validateInternal(task);
326
421
  if (!validated.readiness?.allow) return validated;
327
- return task.runAuthorized ? await startInternal(task, noDaemon) : validated;
422
+ return authorizedStart(task) ? await startInternal(task, noDaemon) : validated;
328
423
  } catch (e) { return block(task, e); }
329
424
  }
330
425
  };
331
426
  const service = {
332
427
  version: TASK_VERSION,
333
- capabilities: () => ({ version: TASK_VERSION, providers: executionCapabilities(), completion: COMPLETION_CAPABILITIES }),
428
+ capabilities: () => ({ version: TASK_VERSION, providers: executionCapabilities(), completion: COMPLETION_CAPABILITIES, delivery: DELIVERY_CAPABILITIES, routes: TASK_ROUTES, modelRoute: intakeModelRouteSummary(ctx.config, ctx.env ?? process.env) }),
334
429
  inspect: ({ taskId }) => result(store.read(taskId)),
335
430
  list: () => ({ version: TASK_VERSION, tasks: store.list().map(result) }),
336
- prepare: async ({ text, dir = process.cwd(), repo = null, base = null, harness = null, context = [], requestId = null, run = false, noDaemon = false, verifyCommands = [], completion }) => {
431
+ prepare: async ({ text, dir = process.cwd(), repo = null, base = null, harness = null, context = [], requestId = null, run = false, noDaemon = false, verifyCommands = [], completion, delivery, route = null }) => {
337
432
  // Capture before any async repository resolution or isolated planning turn.
338
433
  // Repository selection can differ from the invocation directory.
339
434
  const callerCwd = process.cwd();
340
435
  beforeTaskEffects(() => requireText(text, "task request"));
341
436
  const selectedCompletion = beforeTaskEffects(() => completionPolicy(completion));
342
- const originalRequirements = beforeTaskEffects(() => requireTaskCapacity({ requirements: fragments(text) }));
437
+ // Typed delivery input is validated before any effect; the resolved
438
+ // repository and base are bound into the profile once they are known.
439
+ beforeTaskEffects(() => deliveryProfile(delivery, { completion: completion === undefined ? undefined : selectedCompletion.target, repository: "owner/name", base: "main" }));
440
+ if (route !== null && !TASK_ROUTES.includes(route)) throw taskRejection("TASK_INPUT", `route must be one of ${TASK_ROUTES.join(", ")}`);
441
+ // Identity retries may belong to the original line-based fragmenter.
442
+ // Enforce the legacy floor before repository reads, then apply current
443
+ // clause limits only after confirming that this identity is new.
444
+ const capacityVersion = requestId === null ? FRAGMENT_VERSION : 1;
445
+ const originalRequirements = beforeTaskEffects(() => requireTaskCapacity({ fragmentVersion: capacityVersion, requirements: fragmentsForTask({ fragmentVersion: capacityVersion }, text) }));
343
446
  if (requestId !== null) beforeTaskEffects(() => requireText(requestId, "request identity", 256));
344
447
  beforeTaskEffects(() => selectedPaths(context)); // Pure path checks precede even legacy identity replay.
345
448
  const checkout = await ctx.git.toplevel(resolve(callerCwd, dir));
346
- const info = await ctx.gh.repoInfo(checkout);
347
- if (!/^[\w.-]+\/[\w.-]+$/.test(info.nameWithOwner)) throw taskRejection("TASK_INPUT", "repository must resolve to owner/name");
449
+ const info = await hostApi(ctx).repoInfo(checkout);
450
+ const selectedHost = beforeTaskEffects(() => (ctx.hosts ?? hosts).select(info.nameWithOwner));
348
451
  if (repo && repo.toLowerCase() !== info.nameWithOwner.toLowerCase()) throw taskRejection("TASK_CONFLICT", "selected repository does not match the checkout");
349
452
  const selected = harness || Object.keys(ctx.config.harness)[0];
350
- beforeTaskEffects(() => capabilitiesFor(selected));
453
+ const capability = beforeTaskEffects(() => ctx.capabilitiesFor?.(selected) || capabilitiesFor(selected));
454
+ beforeTaskEffects(() => assertRepositoryAccess(capability, selectedHost.repository));
351
455
  if (!ctx.config.harness[selected]) throw taskRejection("TASK_INPUT", `harness ${selected} is not configured`);
456
+ // Route selection: an available harness with a declared preparation
457
+ // boundary drafts through existing dispatch; otherwise the operator's
458
+ // configured model route, if enabled. Neither is chosen by request text.
459
+ const modelRoute = intakeModelRoute(ctx.config, ctx.env ?? process.env);
460
+ const harnessUsable = Boolean(capability.available && capability.preparation);
461
+ // Live route availability matters only when a task is created; an exact
462
+ // retry of a saved task replays below even if the credential is absent now.
463
+ let selectedRoute = route;
464
+ if (route === null) selectedRoute = harnessUsable ? "harness" : modelRoute.enabled ? "model" : "harness";
352
465
  const selection = beforeTaskEffects(() => resolveBase({ config: ctx.config, repo: info.nameWithOwner, defaultBranch: info.defaultBranch, asked: base }));
353
466
  const resolvedBase = selection.base;
354
467
  if (!resolvedBase) throw taskRejection("TASK_INPUT", "repository has no resolved base; supply --base");
@@ -357,38 +470,54 @@ export function createTaskService(ctx, { prepare = prepareTurn, decideReady = re
357
470
  const id = requestId ? `task-${contentDigest(JSON.stringify([info.nameWithOwner.toLowerCase(), requestId])).slice(0, 24)}` : `task-${randomBytes(12).toString("hex")}`;
358
471
  return store.locked(id, async () => {
359
472
  // Omitted policy retains legacy request signatures and replay identity.
360
- const signature = contentDigest(JSON.stringify({ text, checkout, base: resolvedBase, harness: selected, context, verifyCommands, run, ...(completion === undefined ? {} : { completion: selectedCompletion }) }));
473
+ const profile = beforeTaskEffects(() => deliveryProfile(delivery, { completion: completion === undefined ? undefined : selectedCompletion.target, repository: info.nameWithOwner, base: resolvedBase }));
474
+ // Omitted typed inputs retain legacy request signatures and replay identity.
475
+ const signature = contentDigest(JSON.stringify({ text, checkout, base: resolvedBase, harness: selected, context, verifyCommands, run, ...(completion === undefined ? {} : { completion: selectedCompletion }), ...(delivery === undefined ? {} : { delivery: stableProfile(profile) }), ...(route === null ? {} : { route }) }));
361
476
  let task;
362
477
  try { task = store.read(id); } catch (e) { if (e.code !== "TASK_NOT_FOUND") throw e; }
363
478
  if (task) {
364
479
  if (task.signature !== signature) throw taskRejection("TASK_CONFLICT", "request identity already belongs to different input; reopen that task or use a new identity");
480
+ beforeTaskEffects(() => assertProfileUsable(task));
481
+ const savedVersion = beforeTaskEffects(() => sourceFragmentVersion(task));
482
+ if (savedVersion !== capacityVersion) beforeTaskEffects(() => requireTaskCapacity({ fragmentVersion: savedVersion, requirements: fragmentsForTask(task, text) }));
365
483
  if (["ready", "needs-input"].includes(task.status)) {
366
484
  if (ctx.state.get(entryId(task.repo, task.id))) return startInternal(task, noDaemon);
367
485
  try { assertPreparationBoundary(task, ctx); }
368
486
  catch (e) { return block(task, e); }
369
487
  }
488
+ assertReadyDraft(task);
370
489
  // Retries retain the first accepted path base, including version-1
371
490
  // records whose signatures predate callerCwd. Readiness may have been
372
491
  // saved just before a crash; honor its already-persisted authorization.
373
- return ["preparing", "validating"].includes(task.status) || (task.status === "ready" && task.runAuthorized)
492
+ return ["preparing", "validating"].includes(task.status) || (task.status === "ready" && authorizedStart(task))
374
493
  ? prepareInternal(task, noDaemon) : result(task);
375
494
  }
376
- // Only new tasks use this ownership version. Saved request identities,
377
- // approved drafts and provider receipts retain their original semantics.
378
- const delivery = beforeTaskEffects(() => ownerDelivery(text));
379
- const requirements = delivery ? ownerImplementation(delivery).flatMap(text => fragments(text)).map((r, i) => ({ ...r, id: `r-${i + 1}` })) : originalRequirements;
380
- beforeTaskEffects(() => requireTaskCapacity({ requirements, ...(delivery ? { ownerDelivery: delivery } : {}) }));
495
+ // Planning cannot make an unavailable implementation transport usable.
496
+ // Saved results above remain inspectable/replayable without a fresh call.
497
+ beforeTaskEffects(() => assertImplementationAvailable(selected, capability));
498
+ if (route === "model" && !modelRoute.enabled) throw taskRejection("TASK_MODEL_ROUTE", `${modelRoute.reason}; no model call was made`);
499
+ // New tasks use only the typed profile. The free-text ownership
500
+ // partition is never created here; saved request identities, approved
501
+ // drafts and provider receipts retain their original semantics.
502
+ const requirements = capacityVersion === FRAGMENT_VERSION ? originalRequirements : beforeTaskEffects(() => requireTaskCapacity({ fragmentVersion: FRAGMENT_VERSION, requirements: fragments(text) }));
503
+ beforeTaskEffects(() => requireTaskCapacity({ requirements, deliveryProfile: profile }));
381
504
  const selectedFiles = beforeTaskEffects(() => selectedContext(checkout, context, [], ctx.taskSecrets ?? null));
382
505
  const explicit = beforeTaskEffects(() => [...explicitCommands(verifyCommands)]);
383
506
  const candidates = beforeTaskEffects(() => commandCandidates(selectedFiles, explicit));
507
+ const repositoryContext = await repositorySnapshot(ctx.git, checkout, { secrets: ctx.taskSecrets ?? [] });
508
+ // Pin the complete route selection; later turns execute against it and
509
+ // the review digest covers it, so a configuration change cannot swap the
510
+ // route under an existing draft. The credential stays in the environment.
511
+ const pinnedRoute = selectedRoute === "model" ? pinnedModelRoute(modelRoute.route) : null;
384
512
  task = {
385
- version: TASK_VERSION, id, revision: 1, original: text, signature, requestId,
513
+ version: TASK_VERSION, fragmentVersion: FRAGMENT_VERSION, id, revision: 1, original: text, signature, requestId,
386
514
  checkout, callerCwd, repo: info.nameWithOwner.toLowerCase(), defaultBranch: info.defaultBranch, base: resolvedBase, baseSource: selection.source, harness: selected,
387
- context: selectedFiles, explicitVerifyCommands: explicit, commandCandidates: candidates, completion: selectedCompletion,
388
- ...(delivery ? { ownerDelivery: delivery } : {}),
515
+ context: selectedFiles, explicitVerifyCommands: explicit, commandCandidates: candidates, completion: { version: 1, target: profile.completion.condition },
516
+ deliveryProfile: profile, route: selectedRoute, repositoryContext, approval: null,
517
+ ...(pinnedRoute ? { modelRoute: pinnedRoute } : {}),
389
518
  requirements, answers: {}, questionHistory: {}, questionMappings: {}, questions: [],
390
519
  output: null, draft: null, readiness: null, implementation: null, dispatchIntent: null,
391
- runAuthorized: run === true, status: "preparing", repairs: 0, attempts: [], failures: [],
520
+ runAuthorized: false, status: "preparing", repairs: 0, attempts: [], failures: [],
392
521
  allowance: { budgetUsd: ctx.config.max_budget_usd, runtimeMs: ctx.config.run_timeout_minutes * 60_000 },
393
522
  createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), operations: {},
394
523
  };
@@ -399,36 +528,72 @@ export function createTaskService(ctx, { prepare = prepareTurn, decideReady = re
399
528
  answer: async ({ taskId, expectedRevision, answers, operationId, context = [], verifyCommands = [], noDaemon = false }) => store.locked(taskId, async () => {
400
529
  const task = store.read(taskId);
401
530
  beforeTaskEffects(() => requireText(operationId, "answer operation identity", 256));
402
- if (!answers || Array.isArray(answers) || typeof answers !== "object" || !Object.keys(answers).length) throw taskRejection("TASK_INPUT", "answers must map saved question IDs to text");
531
+ if (!answers || Array.isArray(answers) || typeof answers !== "object") throw taskRejection("TASK_INPUT", "answers must map saved question IDs to text");
532
+ const answerIds = Object.keys(answers);
533
+ if (!answerIds.length) throw taskRejection("TASK_INPUT", "answers must map saved question IDs to text");
534
+ const replayingOperation = Object.hasOwn(task.operations, operationId);
535
+ const answerCountLimit = replayingOperation ? MAX_LEGACY_ANSWER_COUNT : MAX_ANSWER_COUNT;
536
+ if (answerIds.length > answerCountLimit) throw taskRejection("TASK_INPUT", `answer batch exceeds ${answerCountLimit} question IDs`);
537
+ const answerByteLimit = replayingOperation ? MAX_LEGACY_ANSWER_BATCH_BYTES : MAX_ANSWER_BYTES;
538
+ let answerBytes = 0;
539
+ const answerEntries = [];
540
+ for (const id of answerIds) {
541
+ const value = answers[id];
542
+ if (typeof value !== "string") {
543
+ // Saved operations accepted text only, so a non-text retry cannot
544
+ // match. Preserve its conflict result without serializing the value.
545
+ if (replayingOperation) throw taskRejection("TASK_CONFLICT", "answer identity already belongs to different answers");
546
+ throw taskRejection("TASK_INPUT", "answers must map saved question IDs to text");
547
+ }
548
+ answerEntries.push([id, value]);
549
+ answerBytes += Buffer.byteLength(id) + Buffer.byteLength(value);
550
+ if (answerBytes > answerByteLimit) {
551
+ const limitDescription = replayingOperation ? "legacy answer operation" : "answer batch";
552
+ throw taskRejection("TASK_INPUT", `${limitDescription} exceeds ${answerByteLimit} total UTF-8 bytes`);
553
+ }
554
+ }
555
+ // Caller-owned accessors/proxies must not change values after the byte
556
+ // check. Hash, validate, construct requirements and persist this snapshot.
557
+ const answerSnapshot = Object.fromEntries(answerEntries);
558
+ // New over-limit work stops before canonical hashing or semantic parsing.
559
+ // Existing receipts can replay only within the maximum batch that the old
560
+ // per-answer limits and question-ID schema could have accepted.
403
561
  beforeTaskEffects(() => selectedPaths(context)); // Duplicate answers still never reread accepted files.
404
562
  // Retain exact legacy retries; new receipts ignore object member order.
405
- const signatureInput = { answers, verifyCommands, ...(context.length ? { context } : {}) };
563
+ const signatureInput = { answers: answerSnapshot, verifyCommands, ...(context.length ? { context } : {}) };
406
564
  const signature = "canonical-v1:" + contentDigest(canonical(JSON.parse(JSON.stringify(signatureInput))));
407
- if (Object.hasOwn(task.operations, operationId)) {
565
+ // An exact replay can resume preparation or dispatch; it needs a usable
566
+ // saved profile just like a new answer does.
567
+ beforeTaskEffects(() => assertProfileUsable(task));
568
+ if (replayingOperation) {
408
569
  if (task.operations[operationId] !== signature && task.operations[operationId] !== contentDigest(JSON.stringify(signatureInput))) throw taskRejection("TASK_CONFLICT", "answer identity already belongs to different answers");
409
- return ["preparing", "validating"].includes(task.status) || (task.status === "ready" && task.runAuthorized)
570
+ assertReadyDraft(task);
571
+ return ["preparing", "validating"].includes(task.status) || (task.status === "ready" && authorizedStart(task))
410
572
  ? prepareInternal(task, noDaemon) : result(task);
411
573
  }
412
574
  expectRevision(task, expectedRevision);
413
575
  if (task.status !== "needs-input") throw taskRejection("TASK_CONFLICT", "this intake is not waiting for answers");
576
+ beforeTaskEffects(() => assertProfileUsable(task));
414
577
  beforeTaskEffects(() => assertPreparationBoundary(task, ctx));
415
578
  let questionMappings;
416
579
  try { questionMappings = questionOwnership(task); }
417
580
  catch (e) { throw taskRejection("TASK_CONFLICT", `${e.message}; inspect the saved brief before answering`); }
418
- for (const [id, value] of Object.entries(answers)) {
581
+ for (const [id, value] of answerEntries) {
419
582
  beforeTaskEffects(() => requireText(value, `answer ${id}`));
420
- beforeTaskEffects(() => assertDeliveryAnswer(task, value));
583
+ // Legacy free-text contracts keep their saved answer grammar. Typed
584
+ // tasks screen answer fragments against the profile after drafting.
585
+ if (task.ownerDelivery || !task.deliveryProfile) beforeTaskEffects(() => assertDeliveryAnswer(task, value));
421
586
  if (!task.questions.some(q => q.id === id)) throw taskRejection("TASK_INPUT", `unknown answer identifier ${id}`);
422
587
  if (task.answers[id] && task.answers[id] !== value) throw taskRejection("TASK_CONFLICT", `answer ${id} is already accepted`);
423
588
  }
424
- const requirements = beforeTaskEffects(() => requireTaskCapacity({ ...task, questionMappings, answers: { ...task.answers, ...answers }, requirements: [...task.requirements, ...Object.entries(answers).flatMap(([id, value]) => fragments(value, `a-${id}`))] }));
589
+ const requirements = beforeTaskEffects(() => requireTaskCapacity({ ...task, questionMappings, answers: { ...task.answers, ...answerSnapshot }, requirements: [...task.requirements, ...answerEntries.flatMap(([id, value]) => fragmentsForTask(task, value, `a-${id}`))] }));
425
590
  const selectedFiles = beforeTaskEffects(() => context.length ? selectedContext(task.checkout, context, task.context, ctx.taskSecrets ?? null) : task.context);
426
591
  const explicit = beforeTaskEffects(() => [...new Set([...(task.explicitVerifyCommands || []), ...explicitCommands(verifyCommands)])]);
427
592
  const candidates = beforeTaskEffects(() => commandCandidates(selectedFiles, explicit));
428
593
  // Backfill only ownership proven by a saved unresolved mapping, never by
429
594
  // an older question's claimed requirement list alone.
430
595
  task.questionMappings = questionMappings;
431
- for (const [id, value] of Object.entries(answers)) {
596
+ for (const [id, value] of answerEntries) {
432
597
  task.answers[id] = value;
433
598
  }
434
599
  task.requirements = requirements;
@@ -441,13 +606,44 @@ export function createTaskService(ctx, { prepare = prepareTurn, decideReady = re
441
606
  task.questions = task.questions.filter(q => !task.answers[q.id]);
442
607
  task.status = task.questions.length ? "needs-input" : "preparing";
443
608
  task.readiness = null;
609
+ // New material input: the owner reviews the next revision again. For typed
610
+ // tasks that includes earlier `--run` consent, which covered the revision
611
+ // the owner saw, not the one an answer will produce. Legacy tasks keep
612
+ // their documented persisted authorization.
613
+ task.approval = null;
614
+ if (task.deliveryProfile) task.runAuthorized = false;
444
615
  mutate(task, store);
445
616
  return task.status === "preparing" ? prepareInternal(task, noDaemon) : result(task);
446
617
  }),
447
618
  resume: async ({ taskId, expectedRevision, noDaemon = false }) => store.locked(taskId, async () => {
448
619
  const task = store.read(taskId);
449
620
  expectRevision(task, expectedRevision);
621
+ beforeTaskEffects(() => assertProfileUsable(task));
450
622
  const id=entryId(task.repo,task.id),entry=ctx.state.get(id);
623
+ if (!entry && task.status === "needs-input" && task.deliveryProfile && task.questions.some(q => q.id.length > MAX_ANSWER_ID_CHARACTERS)) {
624
+ // An old pending question may exceed the wire limit. Explicit resume
625
+ // replaces only proven generated IDs, with no inference or dispatch;
626
+ // immutable sources, accepted answers and old history remain intact.
627
+ beforeTaskEffects(() => assertPreparationBoundary(task, ctx));
628
+ if (task.implementation || task.dispatchIntent || ctx.state.startClaim(id)) throw taskRejection("TASK_CONFLICT", "implementation ownership must be reconciled before replacing a saved question identifier; inspect the saved run and start claim");
629
+ try { task.questionMappings = questionOwnership(task); }
630
+ catch (error) { throw taskRejection("TASK_CONFLICT", `${error.message}; inspect the saved brief before resuming`); }
631
+ const replacements = new Map();
632
+ for (const q of task.questions.filter(q => q.id.length > MAX_ANSWER_ID_CHARACTERS)) {
633
+ const requirement = task.requirements.find(r => q.requirements.length === 1 && r.id === q.requirements[0]);
634
+ if (!requirement || q.id !== `q-delivery-${requirement.id}` || Object.hasOwn(task.answers, q.id)) throw taskRejection("TASK_CONFLICT", "oversized question is not a pending generated delivery question; preserve its history and prepare a new task");
635
+ replacements.set(q.id, deliveryQuestion(task.deliveryProfile, requirement, task));
636
+ }
637
+ const output = parseDraft(JSON.stringify({ ...task.output,
638
+ questions: task.output.questions.map(q => replacements.get(q.id) || q),
639
+ mapping: task.output.mapping.map(m => replacements.has(m.questionId) ? { ...m, questionId: replacements.get(m.questionId).id } : m),
640
+ }), task);
641
+ saveDraft(task, output);
642
+ task.approval = null; task.runAuthorized = false;
643
+ task.validation = { ok: false, failures: ["answer the bounded delivery question on this revision before readiness"] };
644
+ mutate(task, store);
645
+ return result(task);
646
+ }
451
647
  if (entry) {
452
648
  if (!implementationMatches(task, entry)) throw taskRejection("TASK_CONFLICT", "implementation ownership or completion target differs from the approved intake");
453
649
  const runner=executionRunner(ctx,id,task.harness);
@@ -475,17 +671,22 @@ export function createTaskService(ctx, { prepare = prepareTurn, decideReady = re
475
671
  if (task.status === "preparing") throw taskRejection("TASK_NOT_READY", "resume preparation with the current revision before validating its draft");
476
672
  if (!task.draft) throw taskRejection("TASK_NOT_READY", "no generated draft to validate");
477
673
  if (task.status === "started") return result(task);
674
+ beforeTaskEffects(() => assertProfileUsable(task));
478
675
  beforeTaskEffects(() => assertPreparationBoundary(task, ctx));
479
676
  return validateInternal(task);
480
677
  }),
481
678
  start: async ({ taskId, expectedRevision, run = false, noDaemon = false }) => store.locked(taskId, async () => {
482
679
  const task = store.read(taskId);
483
680
  expectRevision(task, expectedRevision);
681
+ beforeTaskEffects(() => assertProfileUsable(task));
484
682
  if (task.status !== "started" && !ctx.state.get(entryId(task.repo, task.id))) beforeTaskEffects(() => assertPreparationBoundary(task, ctx));
485
- if (run === true && !task.runAuthorized) {
683
+ if (run === true && (!task.runAuthorized || (task.deliveryProfile && !task.approval))) {
486
684
  if (task.questions.length || !task.readiness?.allow) throw taskRejection("TASK_NOT_READY", "resolve requirements and validate the current draft before authorizing start");
487
685
  const request = savedDraft(task);
488
- if (!validateDraft(task, request).ok) throw taskRejection("TASK_CONFLICT", "approved requirements changed; no start authorization was saved");
686
+ const validation = validateDraft(task, request);
687
+ if (!validation.ok) throw taskRejection("TASK_CONFLICT", validation.failures.join("\n"));
688
+ // Explicit approval of exactly this reviewed revision.
689
+ task.approval = { revision: task.revision, digest: reviewDigest(task), draftHash: task.draft.hash, readinessDigest: task.readiness.digest, at: new Date().toISOString() };
489
690
  task.runAuthorized = true; mutate(task, store);
490
691
  }
491
692
  return startInternal(task, noDaemon);