@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
@@ -5,6 +5,8 @@ import { isDeepStrictEqual } from "node:util";
5
5
  import { parseSourcesMeta } from "./config.mjs";
6
6
  import { contentDigest } from "./start_contract.mjs";
7
7
  import { OWNER_DELIVERY_SECTION, validateOwnerDelivery } from "./owner_delivery.mjs";
8
+ import { MAX_ANSWER_ID_CHARACTERS } from "./task_limits.mjs";
9
+ import { FRAGMENT_VERSION, sourceFragmentVersion, sourceFragments, DELIVERY_PROFILE_SECTION, savedProfile, stableProfile, describeProfile, deliveryScreen, deliveryScreens, validateDeliveryTag, profileCovers, unaccountedTerms, deliveryReplyGrounded, deliveryQuestion, deliveryQuestionId, isDeliveryQuestion, profileAssumption } from "./task_profile.mjs";
8
10
 
9
11
  export const TASK_VERSION = 1;
10
12
  export const MAX_REPAIRS = 3;
@@ -44,12 +46,18 @@ export function requireText(value, name, max = MAX_INPUT_BYTES) {
44
46
 
45
47
  // Cover all nonblank input, rather than entrusting requirement extraction to a
46
48
  // model that could silently omit a sentence. IDs are immutable across repairs.
47
- export function fragments(text, prefix = "r") {
48
- return text.split(/\r?\n/).map(s => s.trim()).filter(Boolean).map((text, i) => ({ id: `${prefix}-${i + 1}`, text }));
49
+ // Each fragment records its exact UTF-16 span in the immutable source text, so
50
+ // a mapping always points back at the owner's words rather than a paraphrase.
51
+ export { FRAGMENT_VERSION, sourceFragmentVersion };
52
+ export function fragments(text, prefix = "r", source = prefix === "r" ? "original" : `answer:${prefix.replace(/^a-/, "")}`, version = FRAGMENT_VERSION) {
53
+ return sourceFragments(text, { prefix, source, version });
54
+ }
55
+ export function fragmentsForTask(task, text, prefix = "r", source = prefix === "r" ? "original" : `answer:${prefix.replace(/^a-/, "")}`) {
56
+ return fragments(text, prefix, source, sourceFragmentVersion(task));
49
57
  }
50
58
 
51
59
  export function requireRequirementCapacity(requirements) {
52
- if (requirements.length > MAX_REQUIREMENTS) throw Object.assign(new Error(`task has ${requirements.length} requirements; the mapping limit is ${MAX_REQUIREMENTS}; prepare a smaller task with fewer nonblank request and answer lines`), { code: "TASK_INPUT" });
60
+ if (requirements.length > MAX_REQUIREMENTS) throw Object.assign(new Error(`task has ${requirements.length} requirements; the mapping limit is ${MAX_REQUIREMENTS}; prepare a smaller task with fewer material request and answer clauses`), { code: "TASK_INPUT" });
53
61
  return requirements;
54
62
  }
55
63
 
@@ -57,10 +65,12 @@ export function requireRequirementCapacity(requirements) {
57
65
  // criteria, plus at least one story. Render the smallest possible organization
58
66
  // with the real quoting/clarification rules; this is a lower bound, not a draft.
59
67
  export function requireTaskCapacity(task) {
68
+ sourceFragmentVersion(task);
60
69
  if (task.ownerDelivery) {
61
70
  validateOwnerDelivery(task.ownerDelivery);
62
71
  if (task.original !== undefined && task.ownerDelivery.source !== task.original) throw Object.assign(new Error("Delivery ownership must preserve the exact original request"), { code: "TASK_DELIVERY_SCOPE" });
63
72
  }
73
+ savedProfile(task);
64
74
  requireRequirementCapacity(task.requirements);
65
75
  const minimum = {
66
76
  title: "Taskbrief", problem: "context ".repeat(40).trim(),
@@ -82,8 +92,8 @@ const line = (s, label, max = MAX_INPUT_BYTES) => {
82
92
  if (/[\r\n]/.test(s)) throw new Error(`${label} must be one line`);
83
93
  return s;
84
94
  };
85
- const list = (v, label) => {
86
- if (!Array.isArray(v) || !v.length || v.length > 64) throw new Error(`${label} must have 1 to 64 entries`);
95
+ const list = (v, label, max = 64) => {
96
+ if (!Array.isArray(v) || !v.length || v.length > max) throw new Error(`${label} must have 1 to ${max} entries`);
87
97
  return v;
88
98
  };
89
99
 
@@ -209,7 +219,7 @@ JSON schema (version 1):
209
219
  {"version":1,"title":"8 to 80 characters","problem":"at least 40 words",
210
220
  "stories":[{"id":"us-1","text":"As a user, I want an outcome, so that I can benefit.","requirements":["r-1"]}],
211
221
  "criteria":[{"id":"ac-1","text":"Given a condition, when an action occurs, then an observable outcome holds."}],
212
- "mapping":[{"requirementId":"r-1","criteria":["ac-1"]}],
222
+ "mapping":[{"requirementId":"r-1","criteria":["ac-1"]},{"requirementId":"r-2","assumption":"exact text also listed in assumptions"},{"requirementId":"r-3","criteria":["ac-1"],"delivery":{"action":"pull_request","prohibits":["merge"]}}],
213
223
  "questions":[{"id":"q-r-1","text":"One concise material question?","requirements":["r-1"]}],
214
224
  "assumptions":["An explicitly identified assumption, or None."],
215
225
  "verify":["exact command from the supplied command candidates"],
@@ -218,8 +228,36 @@ JSON schema (version 1):
218
228
  "exclusions":["An explicit exclusion"]}
219
229
 
220
230
  Every immutable requirement ID must appear exactly once in mapping, with
221
- nonempty criteria OR a questionId from questions. Ask only material unknowns or
222
- conflicting constraints. Question IDs are stable q- identifiers; retain them on
231
+ nonempty criteria, OR one explicit assumption string that also appears in
232
+ assumptions (only for context or notes with no executable content; the caller
233
+ still preserves the exact owner words in executable scope), OR a
234
+ questionId from questions. Ask only material unknowns or conflicting constraints.
235
+ deliveryProfile, when present, is the owner's typed delivery selection and the
236
+ only delivery authority. Only when deliveryProfile is present and a clause asks for, forbids or conditions an
237
+ external action (pull request, merge, deploy, release, publish including
238
+ submitting or uploading a package or build to a registry or store, push,
239
+ commit, branch, waiting for someone's approval or review, local-only output),
240
+ add a delivery tag
241
+ that reads the clause literally: action is what the clause asks for
242
+ (pull_request, merge, deploy, release, publish, local_only, wait_for_approval,
243
+ or restrict when it only forbids effects); prohibits lists effects it forbids
244
+ (branch, commit, push, pull_request, merge, deploy, release, publish, and
245
+ direct_push for a write aimed at the base branch or at skipping the pull
246
+ request);
247
+ completion is delivery_ready or integrated when the clause names one. target
248
+ retains any named repository (owner/name) and/or base branch literally; never
249
+ replace a requested destination with the selected profile's destination. Still map the clause's implementation
250
+ content to criteria. Never drop, soften or rewrite such a clause, never treat
251
+ the profile as satisfied by prose. For typed-profile tasks, never author new question IDs beginning with
252
+ q-delivery-; the caller generates those from the profile. For typed-profile tasks, every fragment of an
253
+ answer to a q-delivery- question must carry a delivery tag that reads the
254
+ reply literally: restrict with the effects it explicitly forbids or the action
255
+ it explicitly requests. An empty restriction or a tag unsupported by the reply's
256
+ own text cannot resolve a delivery question; ask for an explicit restatement of
257
+ the selected delivery action or prohibited effects. Historical tasks without a deliveryProfile
258
+ must not emit delivery tags or adopt typed-profile question ownership. Retain every
259
+ saved question ID, including q-delivery- IDs, with its original text and requirement
260
+ ownership. Question IDs are stable q- identifiers; retain them on
223
261
  resumption. Each question's requirements must exactly match the requirements
224
262
  whose mappings use its questionId, without duplicates. A prior requirement
225
263
  resolved by an explicit answer may instead map
@@ -252,7 +290,14 @@ the original fragments remain unchanged. Generated rationale, assumptions,
252
290
  locations and exclusions cannot authorize, replace or override owner scope.
253
291
 
254
292
  INTAKE DATA (JSON):
255
- ${JSON.stringify({ original: task.original, ...(task.ownerDelivery ? { ownerDelivery: task.ownerDelivery } : {}), requirements: task.requirements, answers: task.answers, questionHistory: task.questionHistory, repository: task.repo, base: task.base, harness: task.harness, context: task.context, commandCandidates: candidates.commands, commandSources: candidates.sources, commandIssues: candidates.issues, previous: task.output, validationFailures: failures })}`;
293
+ ${JSON.stringify({ original: task.original, ...(task.ownerDelivery ? { ownerDelivery: task.ownerDelivery } : {}), ...(savedProfile(task) ? { deliveryProfile: savedProfile(task) } : {}), requirements: task.requirements, answers: task.answers, questionHistory: task.questionHistory, repository: task.repo, base: task.base, harness: task.harness, ...(task.repositoryContext ? { repositoryContext: promptRepositoryContext(task.repositoryContext) } : {}), context: task.context, commandCandidates: candidates.commands, commandSources: candidates.sources, commandIssues: candidates.issues, previous: task.output, validationFailures: failures })}`;
294
+ }
295
+
296
+ // The snapshot reaches the planner as bounded metadata only: revision, digest
297
+ // and tracked paths. Unavailable context is passed through as unavailable.
298
+ function promptRepositoryContext(snapshot) {
299
+ if (snapshot.status !== "collected") return { status: snapshot.status, reason: snapshot.reason || null };
300
+ return { status: "collected", revision: snapshot.revision, digest: snapshot.digest, paths: snapshot.paths, counts: snapshot.counts };
256
301
  }
257
302
 
258
303
  export function parseDraft(message, task) {
@@ -274,7 +319,9 @@ export function parseDraft(message, task) {
274
319
  // Owner quotations plus the wrapper can exceed the raw input's byte limit.
275
320
  // The whole new or saved envelope remains bounded by the result limit.
276
321
  line(story.text, "story text", 256 * 1024);
277
- list(story.requirements, "story requirements");
322
+ // Normalization carries assumption-mapped owner clauses into a story.
323
+ // Its requirement IDs have the same ceiling as the complete task mapping.
324
+ list(story.requirements, "story requirements", MAX_REQUIREMENTS);
278
325
  if (new Set(story.requirements).size !== story.requirements.length || story.requirements.some(id => !requirements.has(id))) throw new Error("story requirements must be unique authorized requirement IDs");
279
326
  });
280
327
  const criteria = new Set(output.criteria.map((c, i) => {
@@ -282,11 +329,18 @@ export function parseDraft(message, task) {
282
329
  line(c.text, "criterion", 256 * 1024);
283
330
  return c.id;
284
331
  }));
332
+ const profile = savedProfile(task);
285
333
  const questions = new Map();
286
334
  for (const q of output.questions) {
287
- if (!/^q-[a-z0-9-]{1,64}$/.test(q.id) || questions.has(q.id)) throw new Error("question IDs must be unique stable q- identifiers");
288
- line(q.text, "question");
289
- if (q.text.length > 300) throw new Error("question exceeds 300 characters");
335
+ const savedDelivery = profile && isDeliveryQuestion(q.id) && q.id.length <= MAX_ANSWER_ID_CHARACTERS && task.questionHistory?.[q.id];
336
+ if ((!/^q-[a-z0-9-]{1,64}$/.test(q.id) && !(savedDelivery && /^q-[a-z0-9-]+$/.test(q.id))) || questions.has(q.id)) throw new Error("question IDs must be unique stable bounded q- identifiers; resume a saved oversized delivery question before answering it");
337
+ line(q.text, "question", 1024);
338
+ if (profile && isDeliveryQuestion(q.id)) {
339
+ // Reserved service questions are reproduced only from the profile and the
340
+ // immutable clause; a planner cannot author or vary them.
341
+ const requirement = requirements.get(q.requirements?.[0]);
342
+ if (!profile || !requirement || !isDeepStrictEqual(q, deliveryQuestion(profile, requirement, task))) throw new Error(`question ${q.id} is reserved for the delivery profile and must not be planner-authored`);
343
+ } else if (q.text.length > 300) throw new Error("question exceeds 300 characters");
290
344
  list(q.requirements, "question requirements");
291
345
  if (q.requirements.some(id => !requirements.has(id))) throw new Error("question names an unknown requirement");
292
346
  const previous = task.questionHistory[q.id];
@@ -298,12 +352,24 @@ export function parseDraft(message, task) {
298
352
  for (const m of output.mapping) {
299
353
  if (!requirements.has(m.requirementId) || seen.has(m.requirementId)) throw new Error("mapping has an unknown or duplicate requirement");
300
354
  seen.add(m.requirementId);
355
+ if (m.delivery !== undefined) {
356
+ if (!profile) throw new Error("delivery tags require a typed delivery profile; legacy tasks keep their saved contract");
357
+ validateDeliveryTag(m.delivery);
358
+ }
301
359
  if (m.questionId) {
302
- if (m.criteria?.length || m.resolvedBy || !questions.get(m.questionId)?.requirements.includes(m.requirementId)) throw new Error(`invalid unresolved mapping for ${m.requirementId}`);
360
+ if (m.criteria?.length || m.assumption !== undefined || m.resolvedBy || !questions.get(m.questionId)?.requirements.includes(m.requirementId)) throw new Error(`invalid unresolved mapping for ${m.requirementId}`);
303
361
  } else {
304
- list(m.criteria, "mapped criteria");
305
- if (m.criteria.some(id => !criteria.has(id))) throw new Error("mapping names an unknown criterion");
306
- for (const id of m.criteria) mappedCriteria.add(id);
362
+ if (m.assumption !== undefined) {
363
+ // An explicit assumption is a visible, reviewable target; it is never a
364
+ // silent omission and it cannot coexist with executable criteria.
365
+ if (m.criteria?.length) throw new Error(`mapping for ${m.requirementId} must select criteria or one explicit assumption, not both`);
366
+ line(m.assumption, "mapped assumption", 4096);
367
+ if (!output.assumptions.includes(m.assumption)) throw new Error(`mapped assumption for ${m.requirementId} must appear verbatim in assumptions`);
368
+ } else {
369
+ list(m.criteria, "mapped criteria");
370
+ if (m.criteria.some(id => !criteria.has(id))) throw new Error("mapping names an unknown criterion");
371
+ for (const id of m.criteria) mappedCriteria.add(id);
372
+ }
307
373
  if (m.resolvedBy) {
308
374
  if (!clarifications.get(m.requirementId)?.includes(m.resolvedBy)) throw new Error("only an explicit answer to a mapped question can resolve a conflicting requirement; saved question ownership is missing or inconsistent");
309
375
  }
@@ -323,15 +389,106 @@ export function parseDraft(message, task) {
323
389
  const missing = (candidates.sources[command] || []).filter(path => !paths.includes(path));
324
390
  if (missing.length) throw new Error(`Verify command ${command} requires carried sources: ${missing.join(", ")}`);
325
391
  }
326
- if (!questions.size && !output.verify.length) throw new Error("no repository Verify command; ask a material question");
392
+ const generated = [];
393
+ // An answer to a delivery question clarifies the original conflict only when
394
+ // every fragment of that answer carries a typed reading the profile covers
395
+ // (or is itself clarified the same way). An anaphoric reply such as "Do it
396
+ // anyway" has no delivery vocabulary to screen, so without that reading the
397
+ // reply fragment gets its own question instead of closing the conflict.
398
+ const screens = profile ? requirementDeliveryScreens(task, profile.target) : new Map();
399
+ const unresolvedReplies = profile ? unresolvedDeliveryReplies(task, profile, output.mapping, screens) : new Set();
327
400
  const mapping = output.mapping.map(m => {
328
401
  const ids = clarifications.get(m.requirementId);
329
- return !m.questionId && ids?.length ? { ...m, resolvedBy: ids.at(-1) } : m;
402
+ const base = !m.questionId && ids?.length ? { ...m, resolvedBy: ids.at(-1) } : m;
403
+ if (!profile || m.questionId) return base;
404
+ return screenMapping(task, profile, requirements.get(m.requirementId), base, generated, unresolvedReplies, screens.get(m.requirementId));
330
405
  });
331
- const normalized = normalizeCriteria(task, { ...output, mapping });
406
+ for (const q of generated) if (!questions.has(q.id)) questions.set(q.id, q);
407
+ const withQuestions = { ...output, mapping, questions: [...output.questions, ...generated] };
408
+ mappedQuestions(withQuestions);
409
+ if (!questions.size && !output.verify.length) throw new Error("no repository Verify command; ask a material question");
410
+ const normalized = normalizeCriteria(task, withQuestions);
332
411
  return { ...normalized, ...deriveExecutableScope(task, normalized), sources: paths };
333
412
  }
334
413
 
414
+ // Compare one mapped clause with the owner's typed profile. A planner tag that
415
+ // the profile does not cover, or escalating delivery vocabulary the planner
416
+ // left untagged, becomes a deterministic clarification; ordinary vocabulary
417
+ // becomes a visible assumption. Nothing here can grant an external action.
418
+ // Reply fragments of answered delivery questions that lack a covered typed
419
+ // reading and whose own delivery question is not yet answered. Each is asked
420
+ // about explicitly; a chain of replies resolves only when every link does.
421
+ function requirementDeliveryScreens(task, target) {
422
+ const groups = new Map(), screens = new Map();
423
+ for (const requirement of task.requirements) {
424
+ const source = requirement.source?.of;
425
+ if (!groups.has(source)) groups.set(source, []);
426
+ groups.get(source).push(requirement);
427
+ }
428
+ for (const [source, requirements] of groups) {
429
+ const text = source === "original" ? task.original : source?.startsWith("answer:") ? task.answers?.[source.slice(7)] : null;
430
+ if (typeof text === "string") for (const [id, screen] of deliveryScreens(text, requirements, target)) screens.set(id, screen);
431
+ else for (const requirement of requirements) screens.set(requirement.id, deliveryScreen(requirement.text, target));
432
+ }
433
+ return screens;
434
+ }
435
+ function unresolvedDeliveryReplies(task, profile, entries, screens) {
436
+ const byId = new Map(entries.map(m => [m.requirementId, m]));
437
+ const answers = task.answers || {}, unresolved = new Set();
438
+ const resolved = requirementId => {
439
+ const entry = byId.get(requirementId), tag = entry?.delivery === undefined ? null : entry.delivery;
440
+ let covered = false;
441
+ try {
442
+ const reading = tag && validateDeliveryTag(tag);
443
+ const screen = screens.get(requirementId);
444
+ covered = Boolean(reading) && profileCovers(profile, reading).covered && unaccountedTerms(screen, reading).length === 0 && deliveryReplyGrounded(screen, reading);
445
+ } catch { covered = false; }
446
+ if (covered) return true;
447
+ const questionId = deliveryQuestionId(requirementId, task);
448
+ if (!Object.hasOwn(answers, questionId)) { unresolved.add(requirementId); return false; }
449
+ const replies = task.requirements.filter(r => r.id.startsWith(`a-${questionId}-`));
450
+ return replies.length > 0 && replies.map(r => resolved(r.id)).every(Boolean);
451
+ };
452
+ for (const id of Object.keys(answers)) {
453
+ if (!isDeliveryQuestion(id)) continue;
454
+ for (const reply of task.requirements.filter(r => r.id.startsWith(`a-${id}-`))) resolved(reply.id);
455
+ }
456
+ return unresolved;
457
+ }
458
+
459
+ const deliveryConflictReason = screen => [...(screen.targetConflicts || []), ...(screen.modeConflicts || []), ...(screen.externalConflicts || [])].join("; ");
460
+ function screenMapping(task, profile, requirement, entry, generated, unresolvedReplies = new Set(), screen = deliveryScreen(requirement.text, profile.target)) {
461
+ const questionId = deliveryQuestionId(requirement.id, task);
462
+ const answered = Object.hasOwn(task.answers || {}, questionId);
463
+ const tag = entry.delivery === undefined ? null : validateDeliveryTag(entry.delivery);
464
+ const coverage = tag ? profileCovers(profile, tag) : null;
465
+ if (unresolvedReplies.has(requirement.id) && !answered) {
466
+ generated.push(deliveryQuestion(profile, requirement, task));
467
+ const { delivery: _d, covered: _c, screen: _s, conflict: _k, criteria: _cr, assumption: _a, resolvedBy: _r, ...unresolved } = entry;
468
+ const unaccounted = tag ? unaccountedTerms(screen, tag) : [];
469
+ return { ...unresolved, questionId, conflict: { reason: deliveryConflictReason(screen) || (tag ? (coverage.reason || (unaccounted.length ? `the planner reading does not account for ${unaccounted.join(", ")} in the reply` : "the reply must explicitly restate the supported delivery action or prohibited effects; its planner tag is not grounded in the reply text")) : "the reply to a delivery question carries no typed delivery reading the profile covers"), ...(tag ? { delivery: tag } : {}), screen } };
470
+ }
471
+ // Replies to delivery questions are screened like every other clause. An
472
+ // owner who insists on an action the profile does not perform gets the
473
+ // same deterministic question for the reply; only a planner reading that the
474
+ // profile covers (for example a `restrict` tag for a restated prohibition)
475
+ // lets escalating vocabulary in a reply map to criteria.
476
+ // A covered tag speaks only for the terms it names. Escalating terms (and,
477
+ // under a control word, effect terms) that the tag leaves unaccounted for
478
+ // keep the screen's own conflict, so "open a PR and deploy it" tagged as
479
+ // pull_request still asks about the deployment.
480
+ const unaccounted = tag ? unaccountedTerms(screen, tag) : [];
481
+ const conflict = tag ? !coverage.covered || unaccounted.length > 0 : screen.tier === "question";
482
+ const { delivery: _delivery, covered: _covered, screen: _screen, conflict: _conflict, ...rest } = entry;
483
+ if (conflict && !answered) {
484
+ generated.push(deliveryQuestion(profile, requirement, task));
485
+ const { criteria: _criteria, assumption: _assumption, resolvedBy: _resolvedBy, ...unresolved } = rest;
486
+ const reason = deliveryConflictReason(screen) || (tag ? (coverage.covered ? `the planner reading ${tag.action} does not account for ${unaccounted.join(", ")} in the clause` : coverage.reason) : `the clause mentions ${screen.terms.join(", ")} with ${screen.controls.join(", ") || "an escalating delivery term"}`);
487
+ return { ...unresolved, questionId, conflict: { reason, ...(tag ? { delivery: tag } : {}), screen } };
488
+ }
489
+ return { ...rest, ...(tag ? { delivery: tag, covered: coverage.covered } : {}), ...(screen.terms.length ? { screen: { terms: screen.terms, tier: conflict ? "clarified" : screen.tier } } : {}) };
490
+ }
491
+
335
492
  // Criteria prose is derived from owner requirements, so two identical coverage
336
493
  // sets are the same criterion. Normalize only new completion-aware tasks: old
337
494
  // approved drafts/receipts retain their criterion IDs and are never rewritten.
@@ -376,7 +533,7 @@ function questionClarifications(task) {
376
533
  for (const requirementId of owned) for (const parent of origins.get(requirementId)?.scope || []) scope.add(parent);
377
534
  targets.set(id, scope);
378
535
  for (const requirementId of scope) result.set(requirementId, [...(result.get(requirementId) || []), id]);
379
- for (const fragment of fragments(answer, `a-${id}`)) {
536
+ for (const fragment of fragmentsForTask(task, answer, `a-${id}`)) {
380
537
  if (requirements.get(fragment.id)?.text === fragment.text) origins.set(fragment.id, { index, scope });
381
538
  }
382
539
  }
@@ -401,8 +558,14 @@ function deriveExecutableScope(task, output) {
401
558
  return ` ${label} clarified requirement ${id}: ${quoteRequirement(requirements.get(id).text)}` + ids.map(q => ` ${label} answer ${q}: ${quoteRequirement(task.answers[q])} The answer governs any conflict with that requirement.`).join("") + (ids.length > 1 ? " Later answers govern conflicts with earlier answers." : "");
402
559
  };
403
560
  return {
404
- stories: output.stories.map(s => ({ id: s.id, requirements: s.requirements,
405
- text: "As a task owner, I want the quoted requirements fulfilled, so that the result matches my request." + s.requirements.map(id => quoted(id, "Owner")).join("") })),
561
+ // An assumption target organizes context; it cannot remove owner input
562
+ // from executable scope. Carry its exact words once in the first story,
563
+ // without trusting the planner to identify every language's imperatives.
564
+ stories: output.stories.map((s, index) => {
565
+ const ids = [...new Set([...s.requirements, ...(index === 0 ? output.mapping.filter(m => m.assumption !== undefined && !m.questionId).map(m => m.requirementId) : [])])];
566
+ return { id: s.id, requirements: ids,
567
+ text: "As a task owner, I want the quoted requirements fulfilled, so that the result matches my request." + ids.map(id => quoted(id, "Owner")).join("") };
568
+ }),
406
569
  criteria: output.criteria.map(c => ({ id: c.id,
407
570
  text: "Given the selected repository, when this task is implemented, then the following owner requirements hold." + output.mapping.filter(m => m.criteria?.includes(c.id)).map(m => quoted(m.requirementId)).join("") })),
408
571
  };
@@ -415,16 +578,27 @@ export function renderDraft(task, output) {
415
578
  const stories = scope.stories.map(s => `${s.text} Authorized requirements: ${s.requirements.join(", ")}.`);
416
579
  const mapped = scope.criteria.map(c => c.text);
417
580
  const clarifications = questionClarifications(task);
581
+ const profile = savedProfile(task);
582
+ const screened = [];
418
583
  const mapping = output.mapping.map(m => {
419
584
  const r = task.requirements.find(r => r.id === m.requirementId);
420
585
  const ids = clarifications.get(m.requirementId);
421
- const target = (m.questionId ? `unresolved ${m.questionId}` : m.criteria.join(", ")) + (ids?.length ? `; clarified by answer ${ids.join(", ")}` : "");
586
+ // Exact source spans live in the structured requirement record and review;
587
+ // the brief quotes the owner's words verbatim and stays within the checker's
588
+ // word limit at full mapping capacity.
589
+ let target = m.questionId ? `unresolved ${m.questionId}` : m.assumption !== undefined ? `assumption ${quoteRequirement(m.assumption)}` : m.criteria.join(", ");
590
+ target += ids?.length ? `; clarified by answer ${ids.join(", ")}` : "";
591
+ if (m.conflict) target += `; delivery conflict: ${m.conflict.reason}`;
592
+ else if (m.delivery) target += `; delivery reading ${m.delivery.action}${m.covered ? " is covered by the typed profile" : " was clarified by the owner"}`;
593
+ if (m.screen?.tier === "assumption" && profile) screened.push(profileAssumption(profile, r, m.screen));
594
+ else if (m.screen) target += `; delivery screen: mentions ${m.screen.terms.join(", ")} (${m.screen.tier})`;
422
595
  return `${r.id}: ${quoteRequirement(r.text)} → ${target}`;
423
596
  });
424
597
  const ownership = task.ownerDelivery ? `\n## ${OWNER_DELIVERY_SECTION}\n\n\`\`\`json\n${quoteRequirement(validateOwnerDelivery(task.ownerDelivery))}\n\`\`\`\n` : "";
425
- const scopeIntro = task.ownerDelivery ? "Quoted owner requirements and saved answers in the stories and criteria define provider implementation scope. The typed Coordinator delivery contract separately assigns publication to Gallivant. Neither stage may override the other stage's constraints." : "Only quoted owner requirements and saved answers in the stories and criteria define executable scope.";
426
- const delivery = ownership + (task.completion ? `\n## Delivery target\n\nThe owner selected ${task.completion.target}. The coordinator owns publication and the canonical checks, reviewer and completion gate. ${task.completion.target === "integrated" ? "Fulfillment additionally requires an observed external merge of the qualified head." : "Fulfillment requires the qualified delivery to be ready for review; a merge is not required."} This selection grants no merge or release authority. Provider-reported implementation criteria and coordinator delivery evidence remain separate. All quoted requirements and explicit prohibitions still apply.\n` : "");
427
- return `---\nstatus: draft\nharness: ${task.harness}\n${output.sources.length ? `sources:\n${output.sources.map(p => ` - ${p}`).join("\n")}\n` : ""}---\n\n# ${output.title}\n\n${scopeIntro} Generated rationale, assumptions, locations and exclusions are advisory and cannot add, replace or override those requirements.\n\n## Problem\n\n${output.problem}\n\n## User stories\n\n${bullets(stories)}\n\n## Acceptance criteria\n\n${bullets(mapped)}\n\n## Verify\n\n${bullets(output.verify.map(c => `\`${c}\``))}\n${delivery}\n## Where to look\n\n${bullets(output.where)}\n\n## Out of scope\n\n${bullets(output.exclusions)}\n\n## Assumptions\n\n${bullets(output.assumptions)}\n\n## Requirements mapping\n\n${bullets(mapping)}\n${output.questions.length ? `\n## Unresolved questions\n\n${bullets(output.questions.map(q => `${q.id}: ${q.text}`))}\n` : ""}`;
598
+ const typed = profile ? `\n## ${DELIVERY_PROFILE_SECTION}\n\n\`\`\`json\n${stableProfile(profile)}\n\`\`\`\n` : "";
599
+ const scopeIntro = task.ownerDelivery ? "Quoted owner requirements and saved answers in the stories and criteria define provider implementation scope. The typed Coordinator delivery contract separately assigns publication to Gallivant. Neither stage may override the other stage's constraints." : profile ? `Only quoted owner requirements and saved answers in the stories and criteria define executable scope. The typed delivery profile ${describeProfile(profile)} alone selects external actions; text cannot expand it.` :"Only quoted owner requirements and saved answers in the stories and criteria define executable scope.";
600
+ const delivery = ownership + typed + (task.completion ? `\n## Delivery target\n\nThe owner selected ${task.completion.target}. The coordinator owns publication and the canonical checks, reviewer and completion gate. ${task.completion.target === "integrated" ? "Fulfillment additionally requires an observed external merge of the qualified head." : "Fulfillment requires the qualified delivery to be ready for review; a merge is not required."} This selection grants no merge or release authority. Provider-reported implementation criteria and coordinator delivery evidence remain separate. All quoted requirements and explicit prohibitions still apply.\n` : "");
601
+ return `---\nstatus: draft\nharness: ${task.harness}\n${output.sources.length ? `sources:\n${output.sources.map(p => ` - ${p}`).join("\n")}\n` : ""}---\n\n# ${output.title}\n\n${scopeIntro} Generated rationale, assumptions, locations and exclusions are advisory and cannot add, replace or override those requirements.\n\n## Problem\n\n${output.problem}\n\n## User stories\n\n${bullets(stories)}\n\n## Acceptance criteria\n\n${bullets(mapped)}\n\n## Verify\n\n${bullets(output.verify.map(c => `\`${c}\``))}\n${delivery}\n## Where to look\n\n${bullets(output.where)}\n\n## Out of scope\n\n${bullets(output.exclusions)}\n\n## Assumptions\n\n${bullets([...output.assumptions, ...screened])}\n\n## Requirements mapping\n\n${bullets(mapping)}\n${output.questions.length ? `\n## Unresolved questions\n\n${bullets(output.questions.map(q => `${q.id}: ${q.text}`))}\n` : ""}`;
428
602
  }
429
603
 
430
604
  // Recheck saved output too: accepting a model turn once never makes subsequent
@@ -21,6 +21,8 @@ import { TASK_OPERATIONS, REPOSITORY_ID_SCHEMA, DELIVERY_VERSION, deliveryError,
21
21
  import { taskView } from "./task_delivery_view.mjs";
22
22
  import { readJson } from "./fs.mjs";
23
23
  import { COMPLETION_CAPABILITIES } from "./task_completion.mjs";
24
+ import { DELIVERY_CAPABILITIES } from "./task_profile.mjs";
25
+ import { intakeModelRouteSummary } from "./task_model_config.mjs";
24
26
  const validators = new Map(TASK_OPERATIONS.map(op => [op.name, new Ajv({ strict: true, allErrors: true }).compile(op.inputSchema)]));
25
27
  const validRepositoryId = new Ajv({ strict: true }).compile(REPOSITORY_ID_SCHEMA);
26
28
  const hash = value => createHash("sha256").update(JSON.stringify(value)).digest("hex");
@@ -76,7 +78,12 @@ export function createTaskDelivery({ authorize, contextFor, store, schedule, man
76
78
  const ids = [...new Set(scope.repositories)].sort().filter(id => !input.cursor || id > input.cursor);
77
79
  for (const id of ids.slice(0, 100)) {
78
80
  const { ctx } = await getContext(id);
79
- repositories.push({ id, completion: COMPLETION_CAPABILITIES, providers: executionCapabilities().map(capability => ({ ...capability,
81
+ // Delivery actions and the model route are operator/server facts: no
82
+ // endpoint, credential name or budget secret leaves this projection.
83
+ const modelRoute = intakeModelRouteSummary(ctx.config, ctx.env ?? process.env);
84
+ repositories.push({ id, completion: COMPLETION_CAPABILITIES, delivery: DELIVERY_CAPABILITIES, routes: ["harness", "model"],
85
+ modelRoute: { enabled: modelRoute.enabled, adapter: modelRoute.adapter, model: modelRoute.model, budget: modelRoute.budget, ceilings: modelRoute.ceilings, tools: [], reason: modelRoute.enabled ? null : "APort must configure intake_model and its credential for this account" },
86
+ providers: executionCapabilities().map(capability => ({ ...capability,
80
87
  configured: Boolean(ctx.config.harness[capability.name]),
81
88
  available: capability.available && Boolean(ctx.config.harness[capability.name]),
82
89
  accountAccess: "unverified-until-setup-check", setup: capability.reason || (!ctx.config.harness[capability.name] ? "APort must configure this provider for the authorized account" : "Dispatch validates the configured account/model/environment and allowance"),
@@ -175,7 +182,7 @@ export function createTaskDelivery({ authorize, contextFor, store, schedule, man
175
182
  let result, cancellationPending;
176
183
  const shared = { taskId: input.taskId, expectedRevision: input.expectedRevision, noDaemon: true };
177
184
  try {
178
- if (name === "prepare") result = await service.prepare({ text: input.text, dir: repository.dir, repo: repository.repo, harness: input.harness, context: ctxPaths, verifyCommands: input.verifyCommands || [], completion: input.completion, requestId: hash([scopeKey(scope), key]), noDaemon: true });
185
+ if (name === "prepare") result = await service.prepare({ text: input.text, dir: repository.dir, repo: repository.repo, harness: input.harness, context: ctxPaths, verifyCommands: input.verifyCommands || [], completion: input.completion, delivery: input.delivery, route: input.route ?? null, requestId: hash([scopeKey(scope), key]), noDaemon: true });
179
186
  else if (name === "answer") result = await service.answer({ ...shared, answers: input.answers, verifyCommands: input.verifyCommands || [], operationId: key });
180
187
  else if (name === "control") {
181
188
  if (!task.implementation) throw deliveryError("DELIVERY_CONTROL", "No implementation exists to control");
@@ -1,15 +1,19 @@
1
1
  // Portable wire contract. No filesystem, credentials, configuration or providers.
2
2
  import { COMPLETION_SCHEMA } from "./task_completion.mjs";
3
+ import { DELIVERY_PROFILE_SCHEMA } from "./task_profile.mjs";
4
+ import { MAX_ANSWER_BYTES, MAX_ANSWER_CHARACTERS, MAX_ANSWER_COUNT, MAX_LEGACY_ANSWER_COUNT, MAX_ANSWER_ID_CHARACTERS } from "./task_limits.mjs";
3
5
  export { COMPLETION_SCHEMA, COMPLETION_CAPABILITIES, coordinatorEntryActive } from "./task_completion.mjs";
6
+ export { DELIVERY_PROFILE_SCHEMA, DELIVERY_CAPABILITIES, PROFILE_VERSION } from "./task_profile.mjs";
7
+ export const ROUTE_SCHEMA = Object.freeze({ type: "string", enum: ["harness", "model"] });
4
8
  export const DELIVERY_VERSION = 1;
5
- export const CORE_VERSION = "0.2.0";
9
+ export const CORE_VERSION = "0.3.0";
6
10
  // One byte limit for the built APort MCP resource, package and runtime reader.
7
11
  export const MAX_TASK_UI_BYTES = 2_000_000;
8
12
  export const deliveryError = (code, message) => Object.assign(new Error(message), { code, version: DELIVERY_VERSION });
9
13
  // Only the delivery receipt boundary may label a bounded/redacted rejection.
10
- // Arbitrary provider errors with a TASK_* code must not expose their message.
14
+ // Arbitrary provider errors cannot expose their message by copying a code.
11
15
  const settledRejections = new WeakMap();
12
- const safeRejectionCodes = new Set(["DELIVERY_CONFLICT", "TASK_INPUT", "TASK_DELIVERY_SCOPE", "TASK_CONFLICT", "TASK_NOT_READY", "TASK_AUTHORIZATION", "TASK_SECRET", "TASK_PREPARATION_BOUNDARY"]);
16
+ const safeRejectionCodes = new Set(["EXECUTION_UNAVAILABLE", "DELIVERY_CONFLICT", "TASK_INPUT", "TASK_DELIVERY_SCOPE", "TASK_CONFLICT", "TASK_NOT_READY", "TASK_AUTHORIZATION", "TASK_SECRET", "TASK_PREPARATION_BOUNDARY", "TASK_MODEL_ROUTE", "TASK_PROFILE_UNSUPPORTED"]);
13
17
  export function settledTaskRejection(code, message) {
14
18
  const error = deliveryError(code, message);
15
19
  if (safeRejectionCodes.has(code) && typeof message === "string") settledRejections.set(error, { code, message: message.slice(0, 500) });
@@ -17,10 +21,10 @@ export function settledTaskRejection(code, message) {
17
21
  }
18
22
  export const taskRejectionForModel = error => settledRejections.get(error) || null;
19
23
 
20
- const id = { type: "string", pattern: "^[A-Za-z0-9_-]{1,128}$" };
24
+ const id = { type: "string", maxLength: MAX_ANSWER_ID_CHARACTERS, pattern: "^[A-Za-z0-9_-]{1,128}$" };
21
25
  export const REPOSITORY_ID_SCHEMA = { type: "string", maxLength: 128, pattern: "^[A-Za-z0-9_-][A-Za-z0-9_.-]*(/[A-Za-z0-9_-][A-Za-z0-9_.-]*)*$" };
22
26
  const taskId = { type: "string", pattern: "^task-[a-f0-9]{24}$" };
23
- const text = { type: "string", minLength: 1, maxLength: 32768 };
27
+ const text = { type: "string", minLength: 1, maxLength: MAX_ANSWER_CHARACTERS };
24
28
  const revision = { type: "integer", minimum: 1 };
25
29
  const verifyCommands = { type: "array", maxItems: 32, uniqueItems: true, items: { type: "string", minLength: 1, maxLength: 500 } };
26
30
  const object = (properties, required = Object.keys(properties)) => ({ type: "object", properties, required, additionalProperties: false });
@@ -31,12 +35,12 @@ const definition = (name, description, inputSchema, { write = false, destructive
31
35
  });
32
36
  export const TASK_OPERATIONS = Object.freeze([
33
37
  definition("capabilities", "Read up to 100 authorized repositories and configured harness capabilities; pass nextCursor as cursor for more. Does not probe providers.", object({ cursor: REPOSITORY_ID_SCHEMA }, [])),
34
- definition("prepare", "Prepare requirements using an authorized repository and allowance; may use paid inference.", object({
35
- repositoryId: REPOSITORY_ID_SCHEMA, text, harness: id, contextIds: { type: "array", items: id, maxItems: 100, uniqueItems: true }, verifyCommands, completion: COMPLETION_SCHEMA,
38
+ definition("prepare", "Prepare requirements using an authorized repository and allowance; may use paid inference. The typed delivery profile (action, completion) is the only delivery authority; request text cannot expand it. route selects the connected harness or the operator's bounded model route.", object({
39
+ repositoryId: REPOSITORY_ID_SCHEMA, text, harness: id, contextIds: { type: "array", items: id, maxItems: 100, uniqueItems: true }, verifyCommands, completion: COMPLETION_SCHEMA, delivery: DELIVERY_PROFILE_SCHEMA, route: ROUTE_SCHEMA,
36
40
  allowance: object({ budgetUsd: { type: "number", exclusiveMinimum: 0 }, runtimeMs: { type: "integer", minimum: 1 } }),
37
41
  operationId: id, expectedRevision: { type: "integer", const: 0 },
38
42
  }, ["repositoryId", "text", "harness", "allowance", "operationId", "expectedRevision"]), { write: true, open: true }),
39
- definition("answer", "Answer saved question IDs and optionally select verification commands on the expected revision; may continue paid preparation.", mutation({ taskId, answers: { type: "object", minProperties: 1, maxProperties: 100, propertyNames: id, additionalProperties: text }, verifyCommands }, ["taskId", "answers"]), { write: true, open: true }),
43
+ definition("answer", `Answer saved question IDs on the expected revision; new operations allow at most ${MAX_ANSWER_COUNT} answers and ${MAX_ANSWER_BYTES} combined UTF-8 bytes across question IDs and strings. Exact saved retries may contain up to ${MAX_LEGACY_ANSWER_COUNT} saved answers. Optionally select up to 32 verification commands. New over-limit answer batches return TASK_INPUT. May continue paid preparation.`, mutation({ taskId, answers: { type: "object", minProperties: 1, maxProperties: MAX_LEGACY_ANSWER_COUNT, propertyNames: id, additionalProperties: text }, verifyCommands }, ["taskId", "answers"]), { write: true, open: true }),
40
44
  definition("resume", "Resume owned preparation without allocating another task.", mutation({ taskId }), { write: true, open: true }),
41
45
  definition("validate", "Run the canonical readiness gate and preserve its decision.", mutation({ taskId }), { write: true, open: true }),
42
46
  definition("start", "Start the exact approved task revision with explicit execution authorization.", mutation({ taskId, run: { type: "boolean", const: true } }), { write: true, open: true }),
@@ -48,7 +52,7 @@ export const TASK_OPERATIONS = Object.freeze([
48
52
  ]);
49
53
 
50
54
  export function assertCompatible({ coreVersion, operationVersion, taskVersion, executionVersion }) {
51
- if (!/^0\.2\.\d+$/.test(coreVersion || "") || operationVersion !== DELIVERY_VERSION || taskVersion !== 1 || executionVersion !== 1) {
55
+ if (!/^0\.3\.\d+$/.test(coreVersion || "") || operationVersion !== DELIVERY_VERSION || taskVersion !== 1 || executionVersion !== 1) {
52
56
  throw deliveryError("DELIVERY_VERSION", "Incompatible core or contract. Stop wake-ups, back up the durable namespace, migrate with a compatible package, or restore the matching package and state backup. Never replace active-job code.");
53
57
  }
54
58
  }
@@ -1,3 +1,4 @@
1
+ import { pullRequestUrl } from "./host_definitions.mjs";
1
2
  import { DELIVERY_VERSION } from "./task_delivery_contract.mjs";
2
3
  import { redact } from "./execution_io.mjs";
3
4
 
@@ -7,13 +8,19 @@ export function taskView(task, { entry = null, run = null, result = null, verifi
7
8
  // the implementation owns an execution, without polling from the projection.
8
9
  const ex = run?.execution || entry?.execution || task.preparation?.attempts?.at(-1)?.execution || {};
9
10
  const pr = task.implementation?.pr || entry?.pr || null;
10
- const url = pr && /^[-\w.]+\/[-\w.]+$/.test(task.repo) ? `https://github.com/${task.repo}/pull/${Number(pr)}` : null;
11
+ const url = pr ? pullRequestUrl(task.repo, pr) : null;
11
12
  return redact({ version: DELIVERY_VERSION, taskId: task.taskId, revision: task.revision, status: task.status,
12
13
  repo: task.repo, base: task.base, harness: task.harness, original: task.original,
13
14
  questions: task.questions, answers: task.answers, requirements: task.requirements,
14
15
  assumptions: task.assumptions, mapping: task.mapping, stories: task.stories,
15
16
  brief: task.draft ? { hash: task.draft.hash, revision: task.draft.revision, current: task.draft.current, text: task.draft.text } : null,
16
17
  readiness: task.readiness, validation: task.validation, allowance: task.preparation?.allowance, completion: task.completion,
18
+ // One revision-pinned review: immutable request, clause map, open questions,
19
+ // assumptions, draft, typed profile, harness, allowance, route and cost.
20
+ deliveryProfile: task.deliveryProfile ?? null, route: task.route ?? "harness", modelRoute: task.modelRoute ?? null, cost: task.cost ?? null,
21
+ repositoryContext: task.repositoryContext ? { status: task.repositoryContext.status, revision: task.repositoryContext.revision, digest: task.repositoryContext.digest, counts: task.repositoryContext.counts, reason: task.repositoryContext.reason ?? null } : null,
22
+ review: task.review ? { ...task.review, draft: task.review.draft ? { hash: task.review.draft.hash, revision: task.review.draft.revision, text: task.review.draft.text } : null, repositoryContext: task.review.repositoryContext ? { status: task.review.repositoryContext.status, revision: task.review.repositoryContext.revision, digest: task.review.repositoryContext.digest } : null } : null,
23
+ approval: task.approval ?? null,
17
24
  failures: task.failures, errorCode: task.errorCode, nextAction: task.nextAction,
18
25
  progress: { workflowState: entry?.state || task.status, providerState: ex.providerState || null,
19
26
  observedAt: ex.observedAt || null, reconciledAt: ex.reconciledAt || null, nextAttemptAt: ex.nextAttemptAt || null,
@@ -32,7 +39,11 @@ export function taskView(task, { entry = null, run = null, result = null, verifi
32
39
  export function modelSummary(view) {
33
40
  // Only bounded redacted recovery reasons enter model context; raw diagnostics,
34
41
  // briefs and logs remain in the explicit UI/evidence projection.
42
+ const profile = view.deliveryProfile;
35
43
  return { version: view.version, taskId: view.taskId, revision: view.revision, status: view.status, harness: view.harness, repo: boundedText(view.repo, 256), completion: view.completion,
44
+ deliveryProfile: profile ? { version: profile.version ?? null, action: boundedText(profile.action, 64), completion: boundedText(profile.completion?.condition, 64), legacy: profile.legacy === true, supported: profile.supported !== false } : null,
45
+ route: boundedText(view.route, 16), cost: view.cost ? { status: boundedText(view.cost.status, 32), usd: Number.isFinite(view.cost.usd) ? view.cost.usd : null } : null,
46
+ review: view.review ? { revision: view.review.revision, digest: boundedText(view.review.digest, 128), openQuestions: (view.review.questions || []).length, approved: Boolean(view.approval) } : null,
36
47
  recovery: view.status === "blocked" ? {
37
48
  code: /^TASK_[A-Z_]{1,64}$/.test(view.errorCode || "") ? view.errorCode : "TASK_BLOCKED",
38
49
  reasons: (Array.isArray(view.failures) ? view.failures : []).slice(0, 5).map(reason => boundedText(reason, 500)).filter(Boolean),
@@ -48,7 +59,12 @@ export function modelSummary(view) {
48
59
  // fields are projected; server config, paths and arbitrary adapter extras stay out.
49
60
  export function capabilitySummary(view) {
50
61
  return { version: view.version, repositories: view.repositories.slice(0, 100).map(repo => ({
51
- id: repo.id, completion: repo.completion, providers: repo.providers.map(provider => ({
62
+ id: repo.id, completion: repo.completion,
63
+ // The typed delivery actions and planner routes a prepare call selects from.
64
+ // The model route summary carries no endpoint, credential name or key.
65
+ delivery: repo.delivery ?? null, routes: repo.routes ?? ["harness"],
66
+ modelRoute: repo.modelRoute ? { enabled: repo.modelRoute.enabled === true, adapter: repo.modelRoute.adapter ?? null, model: repo.modelRoute.model ?? null, budget: repo.modelRoute.budget ?? null, ceilings: repo.modelRoute.ceilings ?? null, tools: [], reason: repo.modelRoute.reason ?? null } : null,
67
+ providers: repo.providers.map(provider => ({
52
68
  name: provider.name, configured: provider.configured, available: provider.available,
53
69
  transport: provider.transport, continuation: provider.continuation,
54
70
  controls: { cancel: provider.controls?.cancel, interrupt: provider.controls?.interrupt },
@@ -95,6 +95,9 @@ export function preparationEnvironment(harness, cwd, adapterEnv, source = proces
95
95
  const nativeData = providerPath(source.XDG_DATA_HOME || join(nativeHome, ".local/share"));
96
96
  const nativeState = providerPath(source.XDG_STATE_HOME || join(nativeHome, ".local/state"));
97
97
  const home = ensureDir(join(cwd, "home"));
98
+ if (!isInside(realpathSync(home), realpathSync(cwd)) || realpathSync(home) === realpathSync(cwd)) {
99
+ throw taskError("TASK_PREPARATION_ENV", "the private preparation home must remain inside its workspace; remove the unsafe alias before retrying");
100
+ }
98
101
  const config = ensureDir(join(home, ".config")), data = ensureDir(join(home, ".local/share")), state = ensureDir(join(home, ".local/state"));
99
102
  const env = { ...pick(source, BASICS), ...pick(source, adapterFor(harness).preparationEnv), ...adapterEnv,
100
103
  HOME: home, USERPROFILE: home, XDG_CONFIG_HOME: config, XDG_DATA_HOME: data, XDG_STATE_HOME: state, XDG_CACHE_HOME: ensureDir(join(home, ".cache")),
@@ -206,7 +209,16 @@ export function preparationEnvironment(harness, cwd, adapterEnv, source = proces
206
209
  const googleAdc = () => carry(join(nativeConfig, "gcloud/application_default_credentials.json"), join(config, "gcloud/application_default_credentials.json"));
207
210
  if (harness === "codex") {
208
211
  env.CODEX_HOME = join(home, ".codex");
209
- profile(providerPath(source.CODEX_HOME || join(nativeHome, ".codex")), env.CODEX_HOME, ["auth.json", "config.toml", "managed_config.toml", "requirements.toml", "hooks.json", "rules", "plugins", "skills"]);
212
+ const selectedHome = providerPath(source.CODEX_HOME || join(nativeHome, ".codex"));
213
+ selectedResource(selectedHome); // Reject source/private overlap before cleanup.
214
+ // Rebuild the private root as well as omitting disabled caches. A reused
215
+ // pre-fix home can contain aliases into the native profile; unlink aliases
216
+ // without following them before writing any selected authentication file.
217
+ rmSync(env.CODEX_HOME, { recursive: true, force: true });
218
+ // Preparation verifies plugins and skills disabled before inference. Their
219
+ // potentially huge caches are neither provider credentials nor selected
220
+ // context; copying them for every review piece wastes disk and startup I/O.
221
+ profile(selectedHome, env.CODEX_HOME, ["auth.json", "config.toml", "managed_config.toml", "requirements.toml", "hooks.json", "rules"]);
210
222
  } else if (harness === "claude") {
211
223
  env.CLAUDE_CONFIG_DIR = join(home, ".claude");
212
224
  const from = providerPath(source.CLAUDE_CONFIG_DIR || join(nativeHome, ".claude"));
@@ -0,0 +1,13 @@
1
+ // Keep the public answer schema and coordinator-side work limits aligned.
2
+ // A single answer keeps the normal task-text bound; a batch may not multiply it.
3
+ export const MAX_ANSWER_COUNT = 100;
4
+ // The earlier in-process service could accept every question in a 128-item
5
+ // planner output as one answer operation; only new operations use the lower cap.
6
+ export const MAX_LEGACY_ANSWER_COUNT = 128;
7
+ export const MAX_ANSWER_CHARACTERS = 32 * 1024;
8
+ export const MAX_ANSWER_BYTES = 32 * 1024;
9
+ // Prior releases bounded each answer and ID individually but not their batch.
10
+ // This is the largest aggregate that could have passed those checks, so exact
11
+ // stored-operation retries remain compatible without reopening unbounded hashes.
12
+ export const MAX_ANSWER_ID_CHARACTERS = 128;
13
+ export const MAX_LEGACY_ANSWER_BATCH_BYTES = MAX_LEGACY_ANSWER_COUNT * (MAX_ANSWER_CHARACTERS + MAX_ANSWER_ID_CHARACTERS);