@brainervirus/workit-core 6.0.1 → 7.1.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/package.json +1 -1
- package/skills/workit-deslop/SKILL.md +1 -1
- package/skills/workit-implement/SKILL.md +9 -4
- package/skills/workit-review/SKILL.md +2 -0
- package/skills/workit-shape/SKILL.md +1 -1
- package/skills/workit-ship/SKILL.md +1 -1
- package/src/autonomy.ts +24 -0
- package/src/core/branch.ts +63 -6
- package/src/core/config.ts +9 -3
- package/src/core/methods.ts +7 -5
- package/src/core/operation-input.ts +506 -0
- package/src/core/policy/derive.ts +338 -0
- package/src/core/policy/judgment.ts +192 -0
- package/src/core/policy-resolver.ts +32 -427
- package/src/core/pr-create.ts +8 -3
- package/src/core/release-tracks.ts +779 -0
- package/src/core/task-contract.ts +40 -229
- package/src/core/task-engine.ts +51 -58
- package/src/core/task-evaluation.ts +35 -1
- package/src/core/vcs-config.ts +65 -16
- package/src/core/workspaces.ts +82 -16
- package/src/core.ts +8 -6
- package/src/forge/pr-ops.ts +31 -0
- package/src/git/ops.ts +61 -13
- package/src/hooks/handle.ts +19 -4
- package/src/hooks/hosts/claude-code.ts +20 -1
- package/src/hooks/hosts/codex.ts +3 -0
- package/src/hooks/hosts/cursor.ts +11 -2
- package/src/hooks/hosts/fields.ts +35 -0
- package/src/hooks/hosts/opencode.ts +1 -0
- package/src/hooks/hosts/pi.ts +1 -0
- package/src/hooks/index.ts +2 -0
- package/src/hooks/protocol.ts +3 -0
- package/src/hooks/write-gate.ts +303 -0
- package/src/ledger.ts +20 -0
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
// Flat operation inputs (S17, principle 4). Every host advertises one flat
|
|
2
|
+
// tool per family: `action` plus primitive or string-array fields, no nested
|
|
3
|
+
// objects, so no provider needs a depth projection. The canonical nested
|
|
4
|
+
// request is built here; nested canonical payloads still pass through.
|
|
5
|
+
import {
|
|
6
|
+
OPERATION_ACTIONS,
|
|
7
|
+
SCHEMA_VERSION,
|
|
8
|
+
advertisedOperationSchemas,
|
|
9
|
+
failure,
|
|
10
|
+
success,
|
|
11
|
+
type Host,
|
|
12
|
+
type OperationFamily,
|
|
13
|
+
type Ref,
|
|
14
|
+
type Result,
|
|
15
|
+
} from "./task-contract";
|
|
16
|
+
|
|
17
|
+
type Json = Record<string, unknown>;
|
|
18
|
+
type Field = { type: "string" | "integer" | "boolean" | "array" | "object"; enum?: string[] };
|
|
19
|
+
type FamilySpec = { fields: Record<string, Field & { d?: string }> };
|
|
20
|
+
|
|
21
|
+
const str = (d?: string, values?: string[]): Field & { d?: string } => ({
|
|
22
|
+
type: "string",
|
|
23
|
+
...(values ? { enum: values } : {}),
|
|
24
|
+
...(d ? { d } : {}),
|
|
25
|
+
});
|
|
26
|
+
const list = (d?: string): Field & { d?: string } => ({ type: "array", ...(d ? { d } : {}) });
|
|
27
|
+
const bool = (d?: string): Field & { d?: string } => ({ type: "boolean", ...(d ? { d } : {}) });
|
|
28
|
+
const int = (d?: string): Field & { d?: string } => ({ type: "integer", ...(d ? { d } : {}) });
|
|
29
|
+
|
|
30
|
+
const scopeFields = {
|
|
31
|
+
paths: list("Scope paths (default: whole checkout)."),
|
|
32
|
+
exclusions: list(),
|
|
33
|
+
scope: str("Scope description."),
|
|
34
|
+
};
|
|
35
|
+
const refs = list("Paths or URLs.");
|
|
36
|
+
|
|
37
|
+
/** Field set per family. Omit taskId for the branch's implicit task; omit revisions. */
|
|
38
|
+
const FLAT: Record<OperationFamily, FamilySpec> = {
|
|
39
|
+
task: {
|
|
40
|
+
fields: {
|
|
41
|
+
objective: str(),
|
|
42
|
+
...scopeFields,
|
|
43
|
+
refs,
|
|
44
|
+
reason: str(),
|
|
45
|
+
summary: str(),
|
|
46
|
+
nextAction: str(),
|
|
47
|
+
blockers: list(),
|
|
48
|
+
outcome: str(undefined, ["verified", "accepted_limitations", "stopped"]),
|
|
49
|
+
decisionIds: list(),
|
|
50
|
+
status: str(undefined, ["open", "closed", "all"]),
|
|
51
|
+
query: str(),
|
|
52
|
+
limit: int(),
|
|
53
|
+
view: str(undefined, ["summary", "full"]),
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
policy: {
|
|
57
|
+
fields: {
|
|
58
|
+
riskTier: str("low=trivial, medium=normal.", ["trivial", "normal", "high", "low", "medium"]),
|
|
59
|
+
behaviorChange: bool("Observable behavior changes."),
|
|
60
|
+
productChoiceOpen: bool("A product/preference choice is still open."),
|
|
61
|
+
needsPlan: bool("Needs a written plan or spec first."),
|
|
62
|
+
note: str(),
|
|
63
|
+
refs: list("Plan/spec paths or URLs."),
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
evidence: {
|
|
67
|
+
fields: {
|
|
68
|
+
kind: str(undefined, ["check", "review", "investigation", "artifact"]),
|
|
69
|
+
claim: str(),
|
|
70
|
+
result: str(undefined, ["passed", "failed", "missing", "skipped"]),
|
|
71
|
+
summary: str(),
|
|
72
|
+
exitCode: int(),
|
|
73
|
+
refs,
|
|
74
|
+
requirementIds: list(),
|
|
75
|
+
candidateId: str(),
|
|
76
|
+
beforeCandidateId: str(),
|
|
77
|
+
reviewSession: str("Reviewer session handle (review evidence)."),
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
finding: {
|
|
81
|
+
fields: {
|
|
82
|
+
claim: str(),
|
|
83
|
+
consequence: str(),
|
|
84
|
+
...scopeFields,
|
|
85
|
+
candidateId: str(),
|
|
86
|
+
refs,
|
|
87
|
+
findingId: str(),
|
|
88
|
+
disposition: str(undefined, ["open", "fixed", "dismissed", "deferred"]),
|
|
89
|
+
reason: str(),
|
|
90
|
+
evidenceIds: list(),
|
|
91
|
+
decisionIds: list(),
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
decision: {
|
|
95
|
+
fields: {
|
|
96
|
+
purpose: str(undefined, ["design", "action", "limitation", "preference"]),
|
|
97
|
+
response: str(undefined, ["approved", "rejected", "stated"]),
|
|
98
|
+
presented: str("What the user was asked."),
|
|
99
|
+
choice: str("The user's stated choice (response=stated)."),
|
|
100
|
+
...scopeFields,
|
|
101
|
+
refs,
|
|
102
|
+
requirementIds: list(),
|
|
103
|
+
decisionId: str(),
|
|
104
|
+
reason: str(),
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
worker: {
|
|
108
|
+
fields: {
|
|
109
|
+
role: str(undefined, ["investigator", "reviewer", "implementer"]),
|
|
110
|
+
objective: str(),
|
|
111
|
+
...scopeFields,
|
|
112
|
+
decisionIds: list(),
|
|
113
|
+
requirementIds: list(),
|
|
114
|
+
candidateId: str(),
|
|
115
|
+
stoppingCondition: str(),
|
|
116
|
+
workerId: str(),
|
|
117
|
+
outcome: str(undefined, ["completed", "failed", "cancelled"]),
|
|
118
|
+
summary: str(),
|
|
119
|
+
evidenceIds: list(),
|
|
120
|
+
findingIds: list(),
|
|
121
|
+
reason: str(),
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
state: {
|
|
125
|
+
fields: { bundle: { type: "object", d: "An export bundle (object or JSON string)." } },
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const COMMON: Record<string, Field & { d?: string }> = {
|
|
130
|
+
taskId: str("Omit for this branch's task."),
|
|
131
|
+
expectedRevision: str(),
|
|
132
|
+
expectedWorkspaceRevision: str(),
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const DESCRIPTIONS: Record<OperationFamily, string> = {
|
|
136
|
+
task: "Workit task lifecycle for this branch's task (optional continuity).",
|
|
137
|
+
policy:
|
|
138
|
+
"Judge the work in four flat calls (riskTier, behaviorChange, productChoiceOpen, needsPlan); Workit derives the checks, verdicts, decisions and plan it needs.",
|
|
139
|
+
evidence: "Record a note-level evidence entry; only `workit check` runs satisfy check gates.",
|
|
140
|
+
finding: "Record or resolve a review finding.",
|
|
141
|
+
decision: "Record or revoke a user decision (never an authorization).",
|
|
142
|
+
worker: "Assign, report or cancel a bounded helper.",
|
|
143
|
+
state: "Export or import a task handoff bundle.",
|
|
144
|
+
};
|
|
145
|
+
/** One-line tool description shared by every host. */
|
|
146
|
+
export const operationDescription = (family: OperationFamily): string =>
|
|
147
|
+
`${DESCRIPTIONS[family]} Flat fields; omit taskId and revisions.`;
|
|
148
|
+
|
|
149
|
+
const READ_TASK_FIELDS = new Set(["status", "query", "limit", "view"]);
|
|
150
|
+
const READ_ONLY_ACTIONS = new Set(["list", "inspect", "preview", "explain", "export"]);
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The advertised flat JSON Schema for a family: depth 1, `action` required.
|
|
154
|
+
* `readOnly` limits the actions to reads (unattested MCP); `stringTolerant`
|
|
155
|
+
* also accepts JSON-encoded strings for non-string fields (Pi's transport).
|
|
156
|
+
* Families with no read-only action return null under `readOnly`.
|
|
157
|
+
*/
|
|
158
|
+
export function flatOperationJsonSchema(
|
|
159
|
+
family: OperationFamily,
|
|
160
|
+
options: { readOnly?: boolean; stringTolerant?: boolean } = {},
|
|
161
|
+
): Record<string, unknown> | null {
|
|
162
|
+
const spec = FLAT[family];
|
|
163
|
+
const actions = options.readOnly
|
|
164
|
+
? OPERATION_ACTIONS[family].filter((action) => READ_ONLY_ACTIONS.has(action))
|
|
165
|
+
: [...OPERATION_ACTIONS[family]];
|
|
166
|
+
if (!actions.length) return null;
|
|
167
|
+
const property = (field: Field & { d?: string }): Record<string, unknown> => {
|
|
168
|
+
const base =
|
|
169
|
+
field.type === "array"
|
|
170
|
+
? { type: "array", items: { type: "string" } }
|
|
171
|
+
: field.type === "object"
|
|
172
|
+
? { type: "object" }
|
|
173
|
+
: { type: field.type };
|
|
174
|
+
const tolerant =
|
|
175
|
+
options.stringTolerant && field.type !== "string" ? { type: [field.type, "string"] } : {};
|
|
176
|
+
return {
|
|
177
|
+
...base,
|
|
178
|
+
...tolerant,
|
|
179
|
+
...(field.enum ? { enum: field.enum } : {}),
|
|
180
|
+
...(field.d ? { description: field.d } : {}),
|
|
181
|
+
};
|
|
182
|
+
};
|
|
183
|
+
const properties: Record<string, unknown> = { action: { type: "string", enum: actions } };
|
|
184
|
+
// Reads advertise only what reads take: no revisions, and task reads only their filters.
|
|
185
|
+
const readField = (name: string) =>
|
|
186
|
+
name === "taskId" || (family === "task" ? READ_TASK_FIELDS.has(name) : family === "policy");
|
|
187
|
+
for (const [name, field] of Object.entries({ ...COMMON, ...spec.fields }))
|
|
188
|
+
if (options.readOnly ? readField(name) : !(family === "state" && name === "expectedRevision"))
|
|
189
|
+
properties[name] = property(field);
|
|
190
|
+
return {
|
|
191
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
192
|
+
type: "object",
|
|
193
|
+
properties,
|
|
194
|
+
required: ["action"],
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** The advertised schema of a family (all actions). */
|
|
199
|
+
export const operationJsonSchema = (family: OperationFamily): Record<string, unknown> =>
|
|
200
|
+
flatOperationJsonSchema(family)!;
|
|
201
|
+
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
// normalization
|
|
204
|
+
|
|
205
|
+
const isObject = (value: unknown): value is Json =>
|
|
206
|
+
typeof value === "object" && value !== null && !Array.isArray(value);
|
|
207
|
+
|
|
208
|
+
/** A JSON-encoded string decoded (Pi stringifies structured params). */
|
|
209
|
+
const decoded = (value: unknown): unknown => {
|
|
210
|
+
if (typeof value !== "string") return value;
|
|
211
|
+
const trimmed = value.trim();
|
|
212
|
+
if (!/^[[{]/.test(trimmed) && !/^(true|false|null|-?\d+)$/.test(trimmed)) return value;
|
|
213
|
+
try {
|
|
214
|
+
return JSON.parse(trimmed) as unknown;
|
|
215
|
+
} catch {
|
|
216
|
+
return value;
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
const strings = (value: unknown): unknown => {
|
|
221
|
+
const plain = decoded(value);
|
|
222
|
+
if (typeof plain === "string") return plain.trim() ? [plain] : [];
|
|
223
|
+
return plain;
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
/** "docs/x.md" → file ref, "https://…" → external ref; ref objects pass through. */
|
|
227
|
+
export const refOf = (value: unknown): unknown => {
|
|
228
|
+
if (typeof value !== "string") return value;
|
|
229
|
+
return /^https?:\/\//i.test(value)
|
|
230
|
+
? ({ kind: "external", url: value } satisfies Ref)
|
|
231
|
+
: ({ kind: "file", path: value.replace(/^\.\//, ""), digest: null } satisfies Ref);
|
|
232
|
+
};
|
|
233
|
+
const refList = (value: unknown): unknown => {
|
|
234
|
+
const items = strings(value);
|
|
235
|
+
return Array.isArray(items) ? items.map(refOf) : items;
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
/** Take `keys` off `flat` (decoded), returning the ones present. */
|
|
239
|
+
const take = (flat: Json, keys: string[], map: (value: unknown) => unknown = decoded): Json => {
|
|
240
|
+
const out: Json = {};
|
|
241
|
+
for (const key of keys)
|
|
242
|
+
if (key in flat) {
|
|
243
|
+
out[key] = map(flat[key]);
|
|
244
|
+
delete flat[key];
|
|
245
|
+
}
|
|
246
|
+
return out;
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
/** Merge flat values under `key`, keeping nested values the caller sent. */
|
|
250
|
+
const nest = (request: Json, key: string, flat: Json, defaults: Json = {}): void => {
|
|
251
|
+
const nested = isObject(decoded(request[key])) ? (decoded(request[key]) as Json) : null;
|
|
252
|
+
if (!nested && !Object.keys(flat).length) return;
|
|
253
|
+
request[key] = { ...defaults, ...flat, ...nested };
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const scopeOf = (flat: Json, description: unknown): Json | null => {
|
|
257
|
+
const scope = decoded(flat.scope);
|
|
258
|
+
const has = "paths" in flat || "exclusions" in flat || typeof scope === "string";
|
|
259
|
+
const out = isObject(scope)
|
|
260
|
+
? scope
|
|
261
|
+
: has
|
|
262
|
+
? {
|
|
263
|
+
description: typeof scope === "string" ? scope : String(description ?? ""),
|
|
264
|
+
paths: strings(flat.paths ?? ["."]),
|
|
265
|
+
exclusions: strings(flat.exclusions ?? []),
|
|
266
|
+
}
|
|
267
|
+
: null;
|
|
268
|
+
delete flat.scope;
|
|
269
|
+
delete flat.paths;
|
|
270
|
+
delete flat.exclusions;
|
|
271
|
+
return out;
|
|
272
|
+
};
|
|
273
|
+
const defaultScope = (description: unknown): Json => ({
|
|
274
|
+
description: typeof description === "string" ? description : "",
|
|
275
|
+
paths: ["."],
|
|
276
|
+
exclusions: [],
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
const LISTS = [
|
|
280
|
+
"decisionIds",
|
|
281
|
+
"evidenceIds",
|
|
282
|
+
"findingIds",
|
|
283
|
+
"requirementIds",
|
|
284
|
+
"refs",
|
|
285
|
+
"blockers",
|
|
286
|
+
] as const;
|
|
287
|
+
/** Request envelope keys; every other policy key is a judgment (mapped or ignored, D17). */
|
|
288
|
+
const ENVELOPE = new Set([
|
|
289
|
+
"schemaVersion",
|
|
290
|
+
"action",
|
|
291
|
+
"taskId",
|
|
292
|
+
"expectedRevision",
|
|
293
|
+
"expectedWorkspaceRevision",
|
|
294
|
+
"judgment",
|
|
295
|
+
]);
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Build the canonical request from a flat (or already canonical) one: add a
|
|
299
|
+
* missing `schemaVersion`, decode stringified values, nest flat fields, and
|
|
300
|
+
* default the empty lists the contract requires. Idempotent on canonical
|
|
301
|
+
* input; anything it cannot map is left for the contract to reject.
|
|
302
|
+
*/
|
|
303
|
+
export function normalizeOperationInput(
|
|
304
|
+
family: OperationFamily,
|
|
305
|
+
input: unknown,
|
|
306
|
+
host: Host,
|
|
307
|
+
): unknown {
|
|
308
|
+
if (!isObject(input)) return input;
|
|
309
|
+
const request: Json = { ...input };
|
|
310
|
+
if (!("schemaVersion" in request)) request.schemaVersion = 1;
|
|
311
|
+
else request.schemaVersion = decoded(request.schemaVersion);
|
|
312
|
+
for (const key of ["limit", "exitCode"] as const)
|
|
313
|
+
if (key in request) request[key] = decoded(request[key]);
|
|
314
|
+
const action = request.action;
|
|
315
|
+
const flat = request;
|
|
316
|
+
switch (family) {
|
|
317
|
+
case "task": {
|
|
318
|
+
if (action === "start" || action === "revise") {
|
|
319
|
+
const intent = isObject(decoded(request.intent)) ? (decoded(request.intent) as Json) : {};
|
|
320
|
+
const objective = flat.objective ?? intent.objective;
|
|
321
|
+
const scope = scopeOf(flat, objective);
|
|
322
|
+
const fields = take(flat, ["objective"]);
|
|
323
|
+
const authorityRefs = "refs" in flat ? refList(flat.refs) : undefined;
|
|
324
|
+
delete flat.refs;
|
|
325
|
+
nest(
|
|
326
|
+
request,
|
|
327
|
+
"intent",
|
|
328
|
+
{
|
|
329
|
+
...fields,
|
|
330
|
+
...(scope ? { scope } : {}),
|
|
331
|
+
...(authorityRefs ? { authorityRefs } : {}),
|
|
332
|
+
},
|
|
333
|
+
{ scope: defaultScope(objective), authorityRefs: [] },
|
|
334
|
+
);
|
|
335
|
+
} else if (action === "progress") {
|
|
336
|
+
const fields = take(flat, ["summary", "nextAction"]);
|
|
337
|
+
const blockers = "blockers" in flat ? strings(flat.blockers) : undefined;
|
|
338
|
+
delete flat.blockers;
|
|
339
|
+
nest(
|
|
340
|
+
request,
|
|
341
|
+
"progress",
|
|
342
|
+
{
|
|
343
|
+
...fields,
|
|
344
|
+
...(Array.isArray(blockers)
|
|
345
|
+
? {
|
|
346
|
+
blockers: blockers.map((item) =>
|
|
347
|
+
typeof item === "string"
|
|
348
|
+
? { reason: item, dependentAction: "", refs: [] }
|
|
349
|
+
: item,
|
|
350
|
+
),
|
|
351
|
+
}
|
|
352
|
+
: {}),
|
|
353
|
+
},
|
|
354
|
+
{ nextAction: null, blockers: [] },
|
|
355
|
+
);
|
|
356
|
+
} else if (action === "close" && "decisionIds" in flat)
|
|
357
|
+
flat.decisionIds = strings(flat.decisionIds);
|
|
358
|
+
break;
|
|
359
|
+
}
|
|
360
|
+
case "policy": {
|
|
361
|
+
if (action === "assess" || action === "preview") {
|
|
362
|
+
const judgment: Json = isObject(decoded(request.judgment))
|
|
363
|
+
? { ...(decoded(request.judgment) as Json) }
|
|
364
|
+
: {};
|
|
365
|
+
for (const key of Object.keys(flat))
|
|
366
|
+
if (!ENVELOPE.has(key)) {
|
|
367
|
+
if (!(key in judgment)) judgment[key] = decoded(flat[key]);
|
|
368
|
+
delete flat[key];
|
|
369
|
+
}
|
|
370
|
+
request.judgment = judgment;
|
|
371
|
+
}
|
|
372
|
+
break;
|
|
373
|
+
}
|
|
374
|
+
case "evidence": {
|
|
375
|
+
if (action === "record") {
|
|
376
|
+
const review = "reviewSession" in flat ? decoded(flat.reviewSession) : undefined;
|
|
377
|
+
delete flat.reviewSession;
|
|
378
|
+
const fields = take(flat, [
|
|
379
|
+
"kind",
|
|
380
|
+
"claim",
|
|
381
|
+
"result",
|
|
382
|
+
"summary",
|
|
383
|
+
"exitCode",
|
|
384
|
+
"candidateId",
|
|
385
|
+
"beforeCandidateId",
|
|
386
|
+
"reviewContext",
|
|
387
|
+
]);
|
|
388
|
+
if ("refs" in flat) fields.refs = refList(flat.refs);
|
|
389
|
+
if ("requirementIds" in flat) fields.requirementIds = strings(flat.requirementIds);
|
|
390
|
+
delete flat.refs;
|
|
391
|
+
delete flat.requirementIds;
|
|
392
|
+
if (typeof review === "string" && review)
|
|
393
|
+
fields.reviewContext = { kind: "host", host, handle: review };
|
|
394
|
+
nest(request, "evidence", fields, {
|
|
395
|
+
summary: "",
|
|
396
|
+
refs: [],
|
|
397
|
+
requirementIds: [],
|
|
398
|
+
exitCode: null,
|
|
399
|
+
reviewContext: null,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
break;
|
|
403
|
+
}
|
|
404
|
+
case "finding": {
|
|
405
|
+
if (action === "record") {
|
|
406
|
+
const scope = scopeOf(flat, flat.claim);
|
|
407
|
+
if (scope) request.scope = scope;
|
|
408
|
+
else if (!("scope" in request)) request.scope = defaultScope(request.claim);
|
|
409
|
+
request.refs = "refs" in flat ? refList(flat.refs) : [];
|
|
410
|
+
}
|
|
411
|
+
break;
|
|
412
|
+
}
|
|
413
|
+
case "decision": {
|
|
414
|
+
if (action === "record") {
|
|
415
|
+
const binding = isObject(decoded(request.binding))
|
|
416
|
+
? (decoded(request.binding) as Json)
|
|
417
|
+
: {};
|
|
418
|
+
const presented = flat.presented ?? binding.presented;
|
|
419
|
+
const scope = scopeOf(flat, presented);
|
|
420
|
+
const fields = take(flat, ["presented"]);
|
|
421
|
+
const choice = flat.choice;
|
|
422
|
+
delete flat.choice;
|
|
423
|
+
const contentRefs = "refs" in flat ? refList(flat.refs) : undefined;
|
|
424
|
+
delete flat.refs;
|
|
425
|
+
nest(
|
|
426
|
+
request,
|
|
427
|
+
"binding",
|
|
428
|
+
{
|
|
429
|
+
...fields,
|
|
430
|
+
...(scope ? { scope } : {}),
|
|
431
|
+
...(contentRefs ? { contentRefs } : {}),
|
|
432
|
+
...(typeof choice === "string"
|
|
433
|
+
? { statedChoice: { ref: "choice", text: choice } }
|
|
434
|
+
: {}),
|
|
435
|
+
},
|
|
436
|
+
{ scope: defaultScope(presented), contentRefs: [] },
|
|
437
|
+
);
|
|
438
|
+
}
|
|
439
|
+
break;
|
|
440
|
+
}
|
|
441
|
+
case "worker": {
|
|
442
|
+
if (action === "assign") {
|
|
443
|
+
const objective = flat.objective;
|
|
444
|
+
const scope = scopeOf(flat, objective);
|
|
445
|
+
const fields = take(flat, ["role", "objective", "candidateId", "stoppingCondition"]);
|
|
446
|
+
for (const key of ["decisionIds", "requirementIds"])
|
|
447
|
+
if (key in flat) {
|
|
448
|
+
fields[key] = strings(flat[key]);
|
|
449
|
+
delete flat[key];
|
|
450
|
+
}
|
|
451
|
+
nest(
|
|
452
|
+
request,
|
|
453
|
+
"assignment",
|
|
454
|
+
{ ...fields, ...(scope ? { scope } : {}) },
|
|
455
|
+
{ scope: defaultScope(objective), decisionIds: [], requirementIds: [] },
|
|
456
|
+
);
|
|
457
|
+
} else if (action === "report") {
|
|
458
|
+
const fields = take(flat, ["outcome", "summary"]);
|
|
459
|
+
for (const key of ["evidenceIds", "findingIds"])
|
|
460
|
+
if (key in flat) {
|
|
461
|
+
fields[key] = strings(flat[key]);
|
|
462
|
+
delete flat[key];
|
|
463
|
+
}
|
|
464
|
+
nest(request, "report", fields, { evidenceIds: [], findingIds: [] });
|
|
465
|
+
}
|
|
466
|
+
break;
|
|
467
|
+
}
|
|
468
|
+
case "state": {
|
|
469
|
+
if ("bundle" in request) request.bundle = decoded(request.bundle);
|
|
470
|
+
break;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
// Lists the contract requires but agents routinely omit.
|
|
474
|
+
const defaults: Record<string, Partial<Record<string, string[]>>> = {
|
|
475
|
+
finding: { resolve: ["evidenceIds", "decisionIds"] },
|
|
476
|
+
decision: { record: ["requirementIds"] },
|
|
477
|
+
};
|
|
478
|
+
for (const key of defaults[family]?.[String(action)] ?? [])
|
|
479
|
+
if (!(key in request)) request[key] = [];
|
|
480
|
+
for (const key of LISTS)
|
|
481
|
+
if (key in request && typeof request[key] === "string") request[key] = strings(request[key]);
|
|
482
|
+
return request;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* Host-side validation before the engine runs: normalize, then parse against
|
|
487
|
+
* the advertised contract (taskId optional: the engine resolves the branch's
|
|
488
|
+
* implicit task). Returns the normalized request for the engine.
|
|
489
|
+
*/
|
|
490
|
+
export function parseAdvertisedOperation(
|
|
491
|
+
family: OperationFamily,
|
|
492
|
+
input: unknown,
|
|
493
|
+
host: Host,
|
|
494
|
+
): Result<unknown> {
|
|
495
|
+
const request = normalizeOperationInput(family, input, host);
|
|
496
|
+
if (isObject(request) && request.schemaVersion !== SCHEMA_VERSION)
|
|
497
|
+
return failure("unsupported_version", "unsupported schema version", { operation: family });
|
|
498
|
+
const parsed = advertisedOperationSchemas[family].safeParse(request);
|
|
499
|
+
if (parsed.success) return success(null, null, request);
|
|
500
|
+
return failure("invalid_input", "operation input is invalid", {
|
|
501
|
+
fields: parsed.error.issues.map((issue) => ({
|
|
502
|
+
path: issue.code === "unrecognized_keys" ? issue.keys.join(".") : issue.path.join("."),
|
|
503
|
+
reason: issue.message,
|
|
504
|
+
})),
|
|
505
|
+
});
|
|
506
|
+
}
|