@workit-dev/core 0.0.1 → 1.0.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 (234) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +22 -43
  3. package/package.json +56 -6
  4. package/schemas/config.json +74 -0
  5. package/schemas/vcs.json +79 -0
  6. package/schemas/workspaces.json +1029 -0
  7. package/schemas/youtrack.json +84 -0
  8. package/scripts/analyze-release-scope.ts +215 -0
  9. package/scripts/bundled-deps.json +131 -0
  10. package/scripts/fixtures/sample-plan.md +15 -0
  11. package/scripts/fixtures/sample-sdd/progress.md +2 -0
  12. package/scripts/install-codex-plugin.sh +28 -0
  13. package/scripts/install-cursor-plugin.sh +146 -0
  14. package/scripts/install-opencode-plugin.sh +79 -0
  15. package/scripts/install-pi-package.sh +56 -0
  16. package/scripts/lib/config-dir.sh +28 -0
  17. package/scripts/publish-changed-packages.ts +395 -0
  18. package/scripts/rewrite-workspace-deps.ts +53 -0
  19. package/scripts/run-cursor-mcp.sh +6 -0
  20. package/scripts/sync-release-manifests.ts +82 -0
  21. package/scripts/sync-runtime.sh +142 -0
  22. package/scripts/validate-cursor-marketplace.ts +171 -0
  23. package/scripts/verify-bundle-sources.ts +134 -0
  24. package/scripts/write-schemas.ts +16 -0
  25. package/skills/workit-architecture/SKILL.md +72 -0
  26. package/skills/workit-architecture/agents/openai.yaml +3 -0
  27. package/skills/workit-architecture/references/instruction-files.md +52 -0
  28. package/skills/workit-architecture/references/vocabulary.md +53 -0
  29. package/skills/workit-bdd/SKILL.md +53 -0
  30. package/skills/workit-continue/SKILL.md +54 -0
  31. package/skills/workit-debug/SKILL.md +45 -0
  32. package/skills/workit-deslop/SKILL.md +58 -0
  33. package/skills/workit-fanout/SKILL.md +71 -0
  34. package/skills/workit-fanout/references/brief.md +141 -0
  35. package/skills/workit-fanout/references/orchestration.md +169 -0
  36. package/skills/workit-implement/SKILL.md +71 -0
  37. package/skills/workit-retro/SKILL.md +72 -0
  38. package/skills/workit-retro/agents/openai.yaml +3 -0
  39. package/skills/workit-retro/references/sources.md +35 -0
  40. package/skills/workit-retro/references/steering.md +39 -0
  41. package/skills/workit-review/SKILL.md +66 -0
  42. package/skills/workit-review/references/impact.md +24 -0
  43. package/skills/workit-shape/SKILL.md +71 -0
  44. package/skills/workit-shape/references/diagrams.md +17 -0
  45. package/skills/workit-shape/references/knowledge.md +61 -0
  46. package/skills/workit-shape/references/mockups.md +15 -0
  47. package/skills/workit-shape/references/slicing.md +44 -0
  48. package/skills/workit-ship/SKILL.md +71 -0
  49. package/skills/workit-ship/references/changelog.md +44 -0
  50. package/skills/workit-ship/references/pr-body.md +32 -0
  51. package/skills/workit-ship/references/release.md +39 -0
  52. package/skills/workit-test-audit/SKILL.md +54 -0
  53. package/skills/workit-verify-app/SKILL.md +68 -0
  54. package/skills/workit-verify-app/references/template.md +51 -0
  55. package/src/autonomy.ts +910 -0
  56. package/src/check-config.ts +613 -0
  57. package/src/checks.ts +534 -0
  58. package/src/ci-waiter.ts +159 -0
  59. package/src/core/boundary.ts +55 -0
  60. package/src/core/branch-policy.ts +112 -0
  61. package/src/core/branch.ts +1192 -0
  62. package/src/core/changelog-gate.ts +349 -0
  63. package/src/core/changelog-merge.ts +214 -0
  64. package/src/core/changelog.ts +392 -0
  65. package/src/core/cleanup.ts +638 -0
  66. package/src/core/cli-locate.ts +173 -0
  67. package/src/core/commit-flavors.ts +65 -0
  68. package/src/core/config-schemas.ts +653 -0
  69. package/src/core/config.ts +351 -0
  70. package/src/core/context-read.ts +144 -0
  71. package/src/core/docs-gate.ts +69 -0
  72. package/src/core/finding-evidence.ts +89 -0
  73. package/src/core/finding-queue.ts +36 -0
  74. package/src/core/git.ts +73 -0
  75. package/src/core/gitignore.ts +52 -0
  76. package/src/core/heavy-eta.ts +170 -0
  77. package/src/core/heavy-lease.ts +290 -0
  78. package/src/core/heavy-rules.ts +373 -0
  79. package/src/core/heavy-scope.ts +131 -0
  80. package/src/core/heavy-words.ts +83 -0
  81. package/src/core/heavy.ts +535 -0
  82. package/src/core/hygiene.ts +149 -0
  83. package/src/core/init.ts +218 -0
  84. package/src/core/live-session.ts +333 -0
  85. package/src/core/logger.ts +310 -0
  86. package/src/core/methods.ts +185 -0
  87. package/src/core/model-fallback.ts +208 -0
  88. package/src/core/node-resolve.mjs +249 -0
  89. package/src/core/operation-input.ts +632 -0
  90. package/src/core/package-root.ts +28 -0
  91. package/src/core/policy/attributes.ts +97 -0
  92. package/src/core/policy/bookkeeping.ts +119 -0
  93. package/src/core/policy/carry.ts +122 -0
  94. package/src/core/policy/check-evidence.ts +115 -0
  95. package/src/core/policy/current-judgment.ts +20 -0
  96. package/src/core/policy/derive.ts +387 -0
  97. package/src/core/policy/judgment.ts +246 -0
  98. package/src/core/policy/lane.ts +562 -0
  99. package/src/core/policy/pr-lane.ts +32 -0
  100. package/src/core/policy/substance.ts +365 -0
  101. package/src/core/policy-resolver.ts +94 -0
  102. package/src/core/pr-create.ts +448 -0
  103. package/src/core/pr-template.ts +162 -0
  104. package/src/core/release-notes.ts +197 -0
  105. package/src/core/release-tracks.ts +779 -0
  106. package/src/core/remotes.ts +436 -0
  107. package/src/core/repo-context.ts +542 -0
  108. package/src/core/repo-paths.ts +29 -0
  109. package/src/core/resources.ts +1299 -0
  110. package/src/core/role-models.ts +603 -0
  111. package/src/core/route-intent.ts +305 -0
  112. package/src/core/rules.ts +65 -0
  113. package/src/core/runtime-identity.ts +63 -0
  114. package/src/core/safe-write.ts +87 -0
  115. package/src/core/scope-glob.ts +56 -0
  116. package/src/core/scripts.ts +6 -0
  117. package/src/core/skill-manifests.ts +173 -0
  118. package/src/core/store-lock.ts +374 -0
  119. package/src/core/support-matrix.ts +24 -0
  120. package/src/core/task-context.ts +247 -0
  121. package/src/core/task-contract.ts +1279 -0
  122. package/src/core/task-delivery.ts +109 -0
  123. package/src/core/task-engine.ts +2248 -0
  124. package/src/core/task-evaluation.ts +1147 -0
  125. package/src/core/task-store.ts +2649 -0
  126. package/src/core/task-view.ts +180 -0
  127. package/src/core/templates.ts +22 -0
  128. package/src/core/tracker-issues.ts +174 -0
  129. package/src/core/vcs-config.ts +540 -0
  130. package/src/core/workers.ts +62 -0
  131. package/src/core/workit-paths.mjs +242 -0
  132. package/src/core/workspace-branch-policy.ts +123 -0
  133. package/src/core/workspaces.ts +1745 -0
  134. package/src/core/youtrack-backend.ts +288 -0
  135. package/src/core/youtrack-tools.ts +260 -0
  136. package/src/core/youtrack-writes.ts +73 -0
  137. package/src/core/youtrack-ytrack.ts +217 -0
  138. package/src/core/youtrack.ts +454 -0
  139. package/src/core/ytrack-skill.ts +297 -0
  140. package/src/core.ts +171 -0
  141. package/src/deslop/lex.ts +271 -0
  142. package/src/deslop/scan.ts +232 -0
  143. package/src/duration.ts +27 -0
  144. package/src/fanout-brief.ts +486 -0
  145. package/src/fanout-check.ts +455 -0
  146. package/src/fanout-landed.ts +370 -0
  147. package/src/fanout-stack.ts +108 -0
  148. package/src/fanout-status.ts +362 -0
  149. package/src/fanout-worktree.ts +893 -0
  150. package/src/fanout.ts +1473 -0
  151. package/src/forge/exec.ts +385 -0
  152. package/src/forge/github.ts +831 -0
  153. package/src/forge/gitlab.ts +906 -0
  154. package/src/forge/issue-forge.ts +209 -0
  155. package/src/forge/issue-link.ts +468 -0
  156. package/src/forge/issue-ops.ts +314 -0
  157. package/src/forge/merge-gate-base-red.ts +72 -0
  158. package/src/forge/pr-body.ts +100 -0
  159. package/src/forge/pr-edit.ts +292 -0
  160. package/src/forge/pr-ops.ts +1109 -0
  161. package/src/forge/pr-status.ts +10 -0
  162. package/src/forge/redact.ts +195 -0
  163. package/src/forge/report.ts +994 -0
  164. package/src/forge/reruns.ts +132 -0
  165. package/src/forge/resolve.ts +509 -0
  166. package/src/forge/types.ts +242 -0
  167. package/src/forge/verify.ts +398 -0
  168. package/src/git/default-base.ts +130 -0
  169. package/src/git/env-redirect.ts +28 -0
  170. package/src/git/facts.ts +122 -0
  171. package/src/git/ops.ts +1823 -0
  172. package/src/git/refs-read.ts +332 -0
  173. package/src/git/release.ts +1020 -0
  174. package/src/git/rev.ts +1175 -0
  175. package/src/git/session-hook.ts +597 -0
  176. package/src/git/stage.ts +63 -0
  177. package/src/hooks/agent-model.ts +149 -0
  178. package/src/hooks/budget-signal.ts +284 -0
  179. package/src/hooks/context.ts +461 -0
  180. package/src/hooks/cwd-normalize.ts +79 -0
  181. package/src/hooks/deadline.ts +228 -0
  182. package/src/hooks/delivery-verbs.ts +182 -0
  183. package/src/hooks/descriptor.ts +124 -0
  184. package/src/hooks/handle.ts +433 -0
  185. package/src/hooks/heavy-gate.ts +99 -0
  186. package/src/hooks/hook-runs.ts +130 -0
  187. package/src/hooks/hosts/claude-code.ts +380 -0
  188. package/src/hooks/hosts/codex.ts +372 -0
  189. package/src/hooks/hosts/cursor.ts +419 -0
  190. package/src/hooks/hosts/fields.ts +98 -0
  191. package/src/hooks/hosts/opencode.ts +79 -0
  192. package/src/hooks/hosts/pi.ts +78 -0
  193. package/src/hooks/index.ts +60 -0
  194. package/src/hooks/model-limit.ts +378 -0
  195. package/src/hooks/model-switch.ts +35 -0
  196. package/src/hooks/policy.ts +29 -0
  197. package/src/hooks/process.ts +48 -0
  198. package/src/hooks/protocol.ts +135 -0
  199. package/src/hooks/raw-git.ts +2289 -0
  200. package/src/hooks/repeat-failure.ts +115 -0
  201. package/src/hooks/run.ts +54 -0
  202. package/src/hooks/session-marker.ts +151 -0
  203. package/src/hooks/shell-words.ts +532 -0
  204. package/src/hooks/skill-nudge.ts +295 -0
  205. package/src/hooks/spawn-gate.ts +72 -0
  206. package/src/hooks/standing-signal.ts +99 -0
  207. package/src/hooks/standing.ts +225 -0
  208. package/src/hooks/steering.ts +74 -0
  209. package/src/hooks/stop.ts +408 -0
  210. package/src/hooks/worktree-confine.ts +415 -0
  211. package/src/hooks/write-gate.ts +1174 -0
  212. package/src/host-session.ts +459 -0
  213. package/src/knowledge.ts +368 -0
  214. package/src/ledger.ts +2913 -0
  215. package/src/stack.ts +2570 -0
  216. package/src/store/event-log.ts +217 -0
  217. package/src/store/notes.ts +29 -0
  218. package/src/store/patch.ts +135 -0
  219. package/src/store/paths.ts +368 -0
  220. package/src/store/reduce.ts +203 -0
  221. package/src/test-audit-mocks.ts +309 -0
  222. package/src/verdict-cover.ts +151 -0
  223. package/src/worktree-deps.ts +142 -0
  224. package/templates/headers.md +3 -0
  225. package/templates/hygiene/.editorconfig +8 -0
  226. package/templates/hygiene/.gitattributes +3 -0
  227. package/templates/hygiene/CHANGELOG.md +14 -0
  228. package/templates/hygiene/CONTRIBUTING.md +3 -0
  229. package/templates/hygiene/LICENSE +21 -0
  230. package/templates/hygiene/README.md +3 -0
  231. package/templates/issue-update.md +5 -0
  232. package/templates/plan-template.md +26 -0
  233. package/templates/spec-template.md +64 -0
  234. package/templates/workit-contract.md +13 -0
@@ -0,0 +1,632 @@
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
+ // Only advertise what some listed action takes (derived from the contract).
188
+ const taken = (name: string) =>
189
+ family === "policy" || actions.some((action) => actionKeys(family, action)?.accepted.has(name));
190
+ for (const [name, field] of Object.entries({ ...COMMON, ...spec.fields }))
191
+ if (options.readOnly ? readField(name) : taken(name)) properties[name] = property(field);
192
+ return {
193
+ $schema: "https://json-schema.org/draft/2020-12/schema",
194
+ type: "object",
195
+ properties,
196
+ required: ["action"],
197
+ };
198
+ }
199
+
200
+ /** The advertised schema of a family (all actions). */
201
+ export const operationJsonSchema = (family: OperationFamily): Record<string, unknown> =>
202
+ flatOperationJsonSchema(family)!;
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // normalization
206
+
207
+ const isObject = (value: unknown): value is Json =>
208
+ typeof value === "object" && value !== null && !Array.isArray(value);
209
+
210
+ /** A JSON-encoded string decoded (Pi stringifies structured params). */
211
+ const decoded = (value: unknown): unknown => {
212
+ if (typeof value !== "string") return value;
213
+ const trimmed = value.trim();
214
+ if (!/^[[{]/.test(trimmed) && !/^(true|false|null|-?\d+)$/.test(trimmed)) return value;
215
+ try {
216
+ return JSON.parse(trimmed) as unknown;
217
+ } catch {
218
+ return value;
219
+ }
220
+ };
221
+
222
+ const strings = (value: unknown): unknown => {
223
+ const plain = decoded(value);
224
+ if (typeof plain === "string") return plain.trim() ? [plain] : [];
225
+ return plain;
226
+ };
227
+
228
+ /** "docs/x.md" → file ref, "https://…" → external ref; ref objects pass through. */
229
+ export const refOf = (value: unknown): unknown => {
230
+ if (typeof value !== "string") return value;
231
+ return /^https?:\/\//i.test(value)
232
+ ? ({ kind: "external", url: value } satisfies Ref)
233
+ : ({ kind: "file", path: value.replace(/^\.\//, ""), digest: null } satisfies Ref);
234
+ };
235
+ const refList = (value: unknown): unknown => {
236
+ const items = strings(value);
237
+ return Array.isArray(items) ? items.map(refOf) : items;
238
+ };
239
+
240
+ /** Take `keys` off `flat` (decoded), returning the ones present. */
241
+ const take = (flat: Json, keys: string[], map: (value: unknown) => unknown = decoded): Json => {
242
+ const out: Json = {};
243
+ for (const key of keys)
244
+ if (key in flat) {
245
+ out[key] = map(flat[key]);
246
+ delete flat[key];
247
+ }
248
+ return out;
249
+ };
250
+
251
+ /** Merge flat values under `key`, keeping nested values the caller sent. */
252
+ const nest = (request: Json, key: string, flat: Json, defaults: Json = {}): void => {
253
+ const nested = isObject(decoded(request[key])) ? (decoded(request[key]) as Json) : null;
254
+ if (!nested && !Object.keys(flat).length) return;
255
+ request[key] = { ...defaults, ...flat, ...nested };
256
+ };
257
+
258
+ const scopeOf = (flat: Json, description: unknown): Json | null => {
259
+ const scope = decoded(flat.scope);
260
+ const has = "paths" in flat || "exclusions" in flat || typeof scope === "string";
261
+ const out = isObject(scope)
262
+ ? scope
263
+ : has
264
+ ? {
265
+ description: typeof scope === "string" ? scope : String(description ?? ""),
266
+ paths: strings(flat.paths ?? ["."]),
267
+ exclusions: strings(flat.exclusions ?? []),
268
+ }
269
+ : null;
270
+ delete flat.scope;
271
+ delete flat.paths;
272
+ delete flat.exclusions;
273
+ return out;
274
+ };
275
+ const defaultScope = (description: unknown): Json => ({
276
+ description: typeof description === "string" ? description : "",
277
+ paths: ["."],
278
+ exclusions: [],
279
+ });
280
+
281
+ const LISTS = [
282
+ "decisionIds",
283
+ "evidenceIds",
284
+ "findingIds",
285
+ "requirementIds",
286
+ "refs",
287
+ "blockers",
288
+ ] as const;
289
+ /** Request envelope keys; every other policy key is a judgment (mapped or ignored, D17). */
290
+ const ENVELOPE = new Set([
291
+ "schemaVersion",
292
+ "action",
293
+ "taskId",
294
+ "expectedRevision",
295
+ "expectedWorkspaceRevision",
296
+ "judgment",
297
+ ]);
298
+
299
+ /**
300
+ * Build the canonical request from a flat (or already canonical) one: add a
301
+ * missing `schemaVersion`, decode stringified values, nest flat fields, and
302
+ * default the empty lists the contract requires. Idempotent on canonical
303
+ * input; anything it cannot map is left for the contract to reject.
304
+ */
305
+ export function normalizeOperationInput(
306
+ family: OperationFamily,
307
+ input: unknown,
308
+ host: Host,
309
+ ): unknown {
310
+ if (!isObject(input)) return input;
311
+ const request: Json = { ...input };
312
+ if (!("schemaVersion" in request)) request.schemaVersion = 1;
313
+ else request.schemaVersion = decoded(request.schemaVersion);
314
+ for (const key of ["limit", "exitCode"] as const)
315
+ if (key in request) request[key] = decoded(request[key]);
316
+ const action = request.action;
317
+ const flat = request;
318
+ switch (family) {
319
+ case "task": {
320
+ if (action === "start" || action === "revise") {
321
+ const intent = isObject(decoded(request.intent)) ? (decoded(request.intent) as Json) : {};
322
+ const objective = flat.objective ?? intent.objective;
323
+ const scope = scopeOf(flat, objective);
324
+ const fields = take(flat, ["objective"]);
325
+ const authorityRefs = "refs" in flat ? refList(flat.refs) : undefined;
326
+ delete flat.refs;
327
+ nest(
328
+ request,
329
+ "intent",
330
+ {
331
+ ...fields,
332
+ ...(scope ? { scope } : {}),
333
+ ...(authorityRefs ? { authorityRefs } : {}),
334
+ },
335
+ { scope: defaultScope(objective), authorityRefs: [] },
336
+ );
337
+ } else if (action === "progress") {
338
+ const fields = take(flat, ["summary", "nextAction"]);
339
+ const blockers = "blockers" in flat ? strings(flat.blockers) : undefined;
340
+ delete flat.blockers;
341
+ nest(
342
+ request,
343
+ "progress",
344
+ {
345
+ ...fields,
346
+ ...(Array.isArray(blockers)
347
+ ? {
348
+ blockers: blockers.map((item) =>
349
+ typeof item === "string"
350
+ ? { reason: item, dependentAction: "", refs: [] }
351
+ : item,
352
+ ),
353
+ }
354
+ : {}),
355
+ },
356
+ { nextAction: null, blockers: [] },
357
+ );
358
+ } else if (action === "close" && "decisionIds" in flat)
359
+ flat.decisionIds = strings(flat.decisionIds);
360
+ break;
361
+ }
362
+ case "policy": {
363
+ if (action === "assess" || action === "preview") {
364
+ const judgment: Json = isObject(decoded(request.judgment))
365
+ ? { ...(decoded(request.judgment) as Json) }
366
+ : {};
367
+ for (const key of Object.keys(flat))
368
+ if (!ENVELOPE.has(key)) {
369
+ if (!(key in judgment)) judgment[key] = decoded(flat[key]);
370
+ delete flat[key];
371
+ }
372
+ request.judgment = judgment;
373
+ }
374
+ break;
375
+ }
376
+ case "evidence": {
377
+ if (action === "record") {
378
+ const review = "reviewSession" in flat ? decoded(flat.reviewSession) : undefined;
379
+ delete flat.reviewSession;
380
+ const fields = take(flat, [
381
+ "kind",
382
+ "claim",
383
+ "result",
384
+ "summary",
385
+ "exitCode",
386
+ "candidateId",
387
+ "beforeCandidateId",
388
+ "reviewContext",
389
+ ]);
390
+ if ("refs" in flat) fields.refs = refList(flat.refs);
391
+ if ("requirementIds" in flat) fields.requirementIds = strings(flat.requirementIds);
392
+ delete flat.refs;
393
+ delete flat.requirementIds;
394
+ if (typeof review === "string" && review)
395
+ fields.reviewContext = { kind: "host", host, handle: review };
396
+ nest(request, "evidence", fields, {
397
+ summary: "",
398
+ refs: [],
399
+ requirementIds: [],
400
+ exitCode: null,
401
+ reviewContext: null,
402
+ });
403
+ }
404
+ break;
405
+ }
406
+ case "finding": {
407
+ if (action === "record") {
408
+ const scope = scopeOf(flat, flat.claim);
409
+ if (scope) request.scope = scope;
410
+ else if (!("scope" in request)) request.scope = defaultScope(request.claim);
411
+ request.refs = "refs" in flat ? refList(flat.refs) : [];
412
+ }
413
+ break;
414
+ }
415
+ case "decision": {
416
+ if (action === "record") {
417
+ const binding = isObject(decoded(request.binding))
418
+ ? (decoded(request.binding) as Json)
419
+ : {};
420
+ const presented = flat.presented ?? binding.presented;
421
+ const scope = scopeOf(flat, presented);
422
+ const fields = take(flat, ["presented"]);
423
+ const choice = flat.choice;
424
+ delete flat.choice;
425
+ const contentRefs = "refs" in flat ? refList(flat.refs) : undefined;
426
+ delete flat.refs;
427
+ nest(
428
+ request,
429
+ "binding",
430
+ {
431
+ ...fields,
432
+ ...(scope ? { scope } : {}),
433
+ ...(contentRefs ? { contentRefs } : {}),
434
+ ...(typeof choice === "string"
435
+ ? { statedChoice: { ref: "choice", text: choice } }
436
+ : {}),
437
+ },
438
+ { scope: defaultScope(presented), contentRefs: [] },
439
+ );
440
+ }
441
+ break;
442
+ }
443
+ case "worker": {
444
+ if (action === "assign") {
445
+ const objective = flat.objective;
446
+ const scope = scopeOf(flat, objective);
447
+ const fields = take(flat, ["role", "objective", "candidateId", "stoppingCondition"]);
448
+ for (const key of ["decisionIds", "requirementIds"])
449
+ if (key in flat) {
450
+ fields[key] = strings(flat[key]);
451
+ delete flat[key];
452
+ }
453
+ nest(
454
+ request,
455
+ "assignment",
456
+ { ...fields, ...(scope ? { scope } : {}) },
457
+ { scope: defaultScope(objective), decisionIds: [], requirementIds: [] },
458
+ );
459
+ } else if (action === "report") {
460
+ const fields = take(flat, ["outcome", "summary"]);
461
+ for (const key of ["evidenceIds", "findingIds"])
462
+ if (key in flat) {
463
+ fields[key] = strings(flat[key]);
464
+ delete flat[key];
465
+ }
466
+ nest(request, "report", fields, { evidenceIds: [], findingIds: [] });
467
+ }
468
+ break;
469
+ }
470
+ case "state": {
471
+ if ("bundle" in request) request.bundle = decoded(request.bundle);
472
+ break;
473
+ }
474
+ }
475
+ // Lists the contract requires but agents routinely omit.
476
+ const defaults: Record<string, Partial<Record<string, string[]>>> = {
477
+ finding: { resolve: ["evidenceIds", "decisionIds"] },
478
+ decision: { record: ["requirementIds"] },
479
+ };
480
+ for (const key of defaults[family]?.[String(action)] ?? [])
481
+ if (!(key in request)) request[key] = [];
482
+ for (const key of LISTS)
483
+ if (key in request && typeof request[key] === "string") request[key] = strings(request[key]);
484
+ return request;
485
+ }
486
+
487
+ // ---------------------------------------------------------------------------
488
+ // per-action keys, derived from the contract
489
+
490
+ type ZodLike = {
491
+ _zod: { def: { type: string; shape?: Record<string, ZodLike>; innerType?: ZodLike } };
492
+ };
493
+ const defOf = (node: ZodLike) => node._zod.def;
494
+ const unwrap = (node: ZodLike): ZodLike => {
495
+ let inner = node;
496
+ while (["optional", "nullable"].includes(defOf(inner).type) && defOf(inner).innerType)
497
+ inner = defOf(inner).innerType!;
498
+ return inner;
499
+ };
500
+ /** Contract keys the flat form renames. */
501
+ const FLAT_NAME: Record<string, string> = {
502
+ authorityRefs: "refs",
503
+ contentRefs: "refs",
504
+ statedChoice: "choice",
505
+ reviewContext: "reviewSession",
506
+ };
507
+ const NOT_FLAT = new Set(["schemaVersion", "action", "workspaceId"]);
508
+
509
+ export type ActionKeys = { accepted: Set<string>; required: string[] };
510
+
511
+ /**
512
+ * What one action of a family takes, read from the advertised contract:
513
+ * every top-level key, the keys of its nested objects (flat form, renamed per
514
+ * FLAT_NAME), and the required scalars. Lists, scopes and nullables are
515
+ * defaulted by normalization, so they are accepted but never required.
516
+ */
517
+ export function actionKeys(family: OperationFamily, action: string): ActionKeys | null {
518
+ const options = (advertisedOperationSchemas[family] as unknown as { options: ZodLike[] }).options;
519
+ const option = options.find((item) => {
520
+ const literal = defOf(item).shape?.action as unknown as { _zod: { def: { values: string[] } } };
521
+ return literal._zod.def.values.includes(action);
522
+ });
523
+ if (!option) return null;
524
+ const out: ActionKeys = { accepted: new Set(), required: [] };
525
+ const walk = (shape: Record<string, ZodLike>, nested: boolean): void => {
526
+ for (const [key, node] of Object.entries(shape)) {
527
+ if (NOT_FLAT.has(key) || (nested && key === "taskId")) continue;
528
+ const inner = unwrap(node);
529
+ const type = defOf(inner).type;
530
+ const needed = !["optional", "nullable"].includes(defOf(node).type);
531
+ if (key === "scope")
532
+ for (const name of ["scope", "paths", "exclusions"]) out.accepted.add(name);
533
+ else if (type === "object" && key !== "bundle" && !(key in FLAT_NAME)) {
534
+ out.accepted.add(key);
535
+ walk(defOf(inner).shape!, true);
536
+ } else {
537
+ const name = FLAT_NAME[key] ?? key;
538
+ out.accepted.add(name);
539
+ if (needed && type !== "array" && type !== "record" && !out.required.includes(name))
540
+ out.required.push(name);
541
+ }
542
+ }
543
+ };
544
+ walk(defOf(option).shape!, false);
545
+ return out;
546
+ }
547
+
548
+ /** A minimal valid flat payload for one action: required scalars with placeholders. */
549
+ export function exampleOperation(family: OperationFamily, action: string): Record<string, unknown> {
550
+ const keys = actionKeys(family, action);
551
+ const example: Record<string, unknown> = { action };
552
+ for (const name of keys?.required ?? [])
553
+ example[name] = FLAT[family].fields[name]?.enum?.[0] ?? `<${name}>`;
554
+ if (family === "policy" && !keys?.required.length && action !== "explain")
555
+ example.riskTier = "normal";
556
+ return example;
557
+ }
558
+
559
+ const oneOf = (values: readonly string[]): string => values.join(" | ");
560
+
561
+ /** Every invalid_input the hosts return: the exact fix for this family and action. */
562
+ function invalidOperation(
563
+ family: OperationFamily,
564
+ input: unknown,
565
+ error: ZodErrorLike,
566
+ ): Result<never> {
567
+ const request = isObject(input) ? input : {};
568
+ const action = typeof request.action === "string" ? request.action : "";
569
+ const actions = OPERATION_ACTIONS[family];
570
+ if (!actions.includes(action)) {
571
+ // The family's read (list, else the first action) is the safe corrected call.
572
+ const common = actions.find((name) => READ_ONLY_ACTIONS.has(name)) ?? actions[0];
573
+ const example = JSON.stringify(exampleOperation(family, common));
574
+ return failure(
575
+ "invalid_input",
576
+ `${family}: action must be one of ${oneOf(actions)}. Example: ${example}`,
577
+ { fields: [{ path: "action", reason: `got ${JSON.stringify(request.action ?? null)}` }] },
578
+ );
579
+ }
580
+ const own = actionKeys(family, action)!;
581
+ const fields: { path: string; reason: string }[] = [];
582
+ const sentence: string[] = [];
583
+ for (const issue of error.issues) {
584
+ if (issue.code === "unrecognized_keys") {
585
+ for (const key of issue.keys) {
586
+ const home = actions.find(
587
+ (other) => other !== action && actionKeys(family, other)!.accepted.has(key),
588
+ );
589
+ const reason = home
590
+ ? `${key} belongs to action: ${home}`
591
+ : `${key} is not a field of ${family} ${action}; it takes ${[...own.accepted].join(", ")}`;
592
+ fields.push({ path: key, reason });
593
+ sentence.push(reason);
594
+ }
595
+ continue;
596
+ }
597
+ const last = String(issue.path.at(-1) ?? "");
598
+ const name = Object.entries(FLAT_NAME).find(([contract]) => contract === last)?.[1] ?? last;
599
+ const missing = issue.code === "invalid_type" && /received undefined/.test(issue.message);
600
+ // A nested container (binding, intent, ...) is absent because its flat fields are.
601
+ const absent = own.required.filter((key) => !(key in request));
602
+ const needs = missing && issue.path.length === 1 && absent.length ? absent.join(", ") : name;
603
+ const reason = missing ? `${family} ${action} requires ${needs}` : `${name}: ${issue.message}`;
604
+ fields.push({ path: issue.path.join("."), reason });
605
+ sentence.push(reason);
606
+ }
607
+ const example = JSON.stringify(exampleOperation(family, action));
608
+ return failure("invalid_input", `${[...new Set(sentence)].join("; ")}. Example: ${example}`, {
609
+ fields,
610
+ });
611
+ }
612
+ type ZodErrorLike = {
613
+ issues: { code: string; keys: string[]; path: PropertyKey[]; message: string }[];
614
+ };
615
+
616
+ /**
617
+ * Host-side validation before the engine runs: normalize, then parse against
618
+ * the advertised contract (taskId optional: the engine resolves the branch's
619
+ * implicit task). Returns the normalized request for the engine.
620
+ */
621
+ export function parseAdvertisedOperation(
622
+ family: OperationFamily,
623
+ input: unknown,
624
+ host: Host,
625
+ ): Result<unknown> {
626
+ const request = normalizeOperationInput(family, input, host);
627
+ if (isObject(request) && request.schemaVersion !== SCHEMA_VERSION)
628
+ return failure("unsupported_version", "unsupported schema version", { operation: family });
629
+ const parsed = advertisedOperationSchemas[family].safeParse(request);
630
+ if (parsed.success) return success(null, null, request);
631
+ return invalidOperation(family, request, parsed.error as unknown as ZodErrorLike);
632
+ }
@@ -0,0 +1,28 @@
1
+ import { existsSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ // Package-local asset root. Core modules resolve their static assets relative to
6
+ // their own package root (templates/, commands/, skills/, hygiene/). In the
7
+ // monorepo the source package keeps assets at the package root; packaged
8
+ // adapters ship the same content under an explicit `assets/` directory.
9
+ //
10
+ // The walk finds the nearest ancestor directory that owns a package.json, which
11
+ // is the package root for both the monorepo source (packages/<pkg>/src/core/…)
12
+ // and a bundled adapter entry (packages/<pkg>/dist/….js).
13
+ export const packageRoot = (): string => {
14
+ let dir = path.dirname(fileURLToPath(import.meta.url));
15
+ while (true) {
16
+ const parent = path.dirname(dir);
17
+ // ponytail: stop at the filesystem root even without an ancestor package.json,
18
+ // so the upward walk can never loop forever.
19
+ if (existsSync(path.join(dir, "package.json")) || parent === dir) return dir;
20
+ dir = parent;
21
+ }
22
+ };
23
+
24
+ export const assetRoot = (): string => {
25
+ const root = packageRoot();
26
+ const assets = path.join(root, "assets");
27
+ return existsSync(assets) ? assets : root;
28
+ };