@uchibeke/gallivant 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -16
- package/SECURITY.md +9 -0
- package/bin/gallivant.mjs +33 -10
- package/bin/promote-bitbucket.mjs +40 -0
- package/bin/task-pr.mjs +9 -0
- package/bin/task-review.mjs +15 -0
- package/docs/README.md +2 -0
- package/docs/architecture.md +11 -3
- package/docs/bitbucket-promotion.md +69 -0
- package/docs/bitbucket-review.md +330 -0
- package/docs/chains.md +30 -1
- package/docs/codex-cloud.md +7 -0
- package/docs/codex-review.md +50 -0
- package/docs/commands.md +65 -5
- package/docs/config.md +35 -10
- package/docs/first-party-review.md +266 -0
- package/docs/git-hosts.md +294 -0
- package/docs/guardrails.md +56 -0
- package/docs/install.md +18 -8
- package/docs/network-recovery.md +9 -0
- package/docs/openai-agents.md +2 -1
- package/docs/prd.md +23 -2
- package/docs/preparation.md +1 -1
- package/docs/release-qualification.md +1 -1
- package/docs/releasing.md +17 -2
- package/docs/task-delivery.md +4 -4
- package/docs/task-profiles.md +537 -0
- package/docs/tasks.md +59 -197
- package/lib/attribution.mjs +7 -3
- package/lib/block.mjs +10 -1
- package/lib/chains.mjs +101 -30
- package/lib/codex_cloud.mjs +2 -1
- package/lib/codex_cloud_input.mjs +15 -6
- package/lib/codex_cloud_result.mjs +1 -1
- package/lib/commands/doctor.mjs +22 -462
- package/lib/commands/init.mjs +72 -30
- package/lib/commands/register.mjs +25 -11
- package/lib/commands/resume.mjs +5 -1
- package/lib/commands/review.mjs +66 -0
- package/lib/commands/serve.mjs +8 -0
- package/lib/commands/start.mjs +30 -11
- package/lib/commands/status.mjs +18 -2
- package/lib/commands/task.mjs +9 -1
- package/lib/commands/up.mjs +19 -11
- package/lib/commands/why.mjs +7 -3
- package/lib/config.mjs +42 -9
- package/lib/context.mjs +23 -18
- package/lib/control.mjs +21 -6
- package/lib/credential.mjs +20 -14
- package/lib/deliverable.mjs +2 -0
- package/lib/events.mjs +96 -53
- package/lib/execution.mjs +48 -3
- package/lib/execution_preparation.mjs +16 -1
- package/lib/execution_result.mjs +26 -5
- package/lib/first_party_review.mjs +618 -0
- package/lib/gh.mjs +144 -56
- package/lib/git.mjs +133 -32
- package/lib/git_diff.mjs +121 -0
- package/lib/harness.mjs +14 -10
- package/lib/host_contract.mjs +71 -0
- package/lib/host_definitions.mjs +22 -0
- package/lib/hosts/bitbucket.mjs +553 -0
- package/lib/hosts/bitbucket_contract.mjs +57 -0
- package/lib/hosts/bitbucket_credential.mjs +62 -0
- package/lib/hosts/bitbucket_review.mjs +44 -0
- package/lib/hosts/bitbucket_review_producer.mjs +50 -0
- package/lib/hosts/github.mjs +57 -0
- package/lib/hosts/github_contract.mjs +27 -0
- package/lib/hosts/github_doctor.mjs +482 -0
- package/lib/hosts/github_review_summary.mjs +38 -0
- package/lib/hosts/transport_doctor.mjs +35 -0
- package/lib/hosts.mjs +130 -0
- package/lib/learning_qualification.mjs +1 -1
- package/lib/notify.mjs +1 -1
- package/lib/openai_agents_input.mjs +7 -2
- package/lib/openai_agents_sources.mjs +16 -8
- package/lib/plugin_package.mjs +13 -1
- package/lib/pr_operation.mjs +41 -0
- package/lib/prd.mjs +18 -3
- package/lib/preparation_codex.mjs +6 -1
- package/lib/probe.mjs +6 -4
- package/lib/process.mjs +24 -0
- package/lib/promotion.mjs +132 -0
- package/lib/promotion_git.mjs +63 -0
- package/lib/prompt.mjs +16 -13
- package/lib/provider_health.mjs +14 -3
- package/lib/repository.mjs +68 -0
- package/lib/repository_preflight.mjs +11 -0
- package/lib/review_evaluation.mjs +25 -0
- package/lib/review_evidence.mjs +500 -0
- package/lib/review_history.mjs +73 -0
- package/lib/review_image.mjs +145 -0
- package/lib/review_link.mjs +12 -0
- package/lib/review_policy.mjs +132 -0
- package/lib/review_policy_contract.mjs +18 -0
- package/lib/review_state.mjs +13 -0
- package/lib/review_visual.mjs +94 -0
- package/lib/runtime-environment.mjs +6 -0
- package/lib/runtime_node.mjs +3 -2
- package/lib/runtime_state.mjs +39 -5
- package/lib/sources/prd.mjs +6 -3
- package/lib/state.mjs +137 -24
- package/lib/task_completion.mjs +7 -3
- package/lib/task_contract.mjs +201 -27
- package/lib/task_delivery.mjs +9 -2
- package/lib/task_delivery_contract.mjs +13 -9
- package/lib/task_delivery_view.mjs +18 -2
- package/lib/task_environment.mjs +13 -1
- package/lib/task_limits.mjs +13 -0
- package/lib/task_model_config.mjs +99 -0
- package/lib/task_model_route.mjs +243 -0
- package/lib/task_prepare.mjs +21 -8
- package/lib/task_profile.mjs +1875 -0
- package/lib/task_repository_context.mjs +65 -0
- package/lib/tasks.mjs +252 -51
- package/lib/tick.mjs +353 -97
- package/lib/time.mjs +1 -0
- package/lib/tracker/pages.mjs +19 -5
- package/lib/tracker/review.mjs +21 -0
- package/lib/tracker_process.mjs +8 -1
- package/lib/verification_sandbox.mjs +2 -1
- package/lib/work_artifacts.mjs +79 -0
- package/npm-shrinkwrap.json +12 -3
- package/package.json +9 -3
- package/templates/plugin/task-delivery.SKILL.md +17 -10
- package/templates/plugin/task-intake-contract.json +18 -0
- package/templates/plugin/task-profile-requests.mjs +1448 -0
- package/templates/tracker/tracker.css +3 -0
- package/templates/tracker/tracker.js +38 -0
package/lib/task_contract.mjs
CHANGED
|
@@ -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
|
-
|
|
48
|
-
|
|
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
|
|
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 >
|
|
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
|
|
222
|
-
|
|
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
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
405
|
-
|
|
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
|
-
|
|
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
|
|
426
|
-
const
|
|
427
|
-
|
|
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
|
package/lib/task_delivery.mjs
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
|
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:
|
|
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",
|
|
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\.
|
|
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
|
|
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,
|
|
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 },
|
package/lib/task_environment.mjs
CHANGED
|
@@ -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
|
-
|
|
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);
|