@event4u/agent-config 8.4.0 → 8.4.1

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 (126) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +6 -0
  3. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  4. package/dist/discovery/deprecation-report.md +1 -1
  5. package/dist/discovery/discovery-manifest.json +1 -1
  6. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  7. package/dist/discovery/discovery-manifest.summary.md +1 -1
  8. package/dist/discovery/orphan-report.md +1 -1
  9. package/dist/discovery/packs.json +1 -1
  10. package/dist/discovery/trust-report.md +1 -1
  11. package/dist/discovery/workspaces.json +1 -1
  12. package/dist/mcp/registry-manifest.json +1 -1
  13. package/package.json +4 -1
  14. package/src/agent-src/README.md +64 -0
  15. package/src/agent-src/scripts/archive_completed_roadmaps.ts +497 -0
  16. package/src/agent-src/scripts/scan-seeder-violations.php +145 -0
  17. package/src/agent-src/scripts/update_roadmap_progress.ts +1087 -0
  18. package/src/agent-src/templates/AGENTS.md +34 -0
  19. package/src/agent-src/templates/scripts/README.md +195 -0
  20. package/src/agent-src/templates/scripts/check_memory.ts +662 -0
  21. package/src/agent-src/templates/scripts/check_memory_proposal.ts +370 -0
  22. package/src/agent-src/templates/scripts/historical-bug-patterns.example.yml +84 -0
  23. package/src/agent-src/templates/scripts/implement_ticket/__main__.ts +27 -0
  24. package/src/agent-src/templates/scripts/memory_hash.ts +355 -0
  25. package/src/agent-src/templates/scripts/memory_lookup.ts +1089 -0
  26. package/src/agent-src/templates/scripts/memory_report.ts +868 -0
  27. package/src/agent-src/templates/scripts/memory_signal.ts +444 -0
  28. package/src/agent-src/templates/scripts/memory_status.ts +215 -0
  29. package/src/agent-src/templates/scripts/ownership-map.example.yml +87 -0
  30. package/src/agent-src/templates/scripts/pr-risk-config.example.yml +76 -0
  31. package/src/agent-src/templates/scripts/pr_review_routing.ts +544 -0
  32. package/src/agent-src/templates/scripts/pr_risk_review.ts +0 -0
  33. package/src/agent-src/templates/scripts/telemetry/aggregator.ts +0 -0
  34. package/src/agent-src/templates/scripts/telemetry/boundary.ts +164 -0
  35. package/src/agent-src/templates/scripts/telemetry/engagement.ts +479 -0
  36. package/src/agent-src/templates/scripts/telemetry/report_renderer.ts +394 -0
  37. package/src/agent-src/templates/scripts/telemetry/settings.ts +210 -0
  38. package/src/agent-src/templates/scripts/telemetry_record.ts +276 -0
  39. package/src/agent-src/templates/scripts/telemetry_report.ts +211 -0
  40. package/src/agent-src/templates/scripts/telemetry_status.ts +333 -0
  41. package/src/agent-src/templates/scripts/tier_usage_report.ts +619 -0
  42. package/src/agent-src/templates/scripts/work_engine/__main__.ts +14 -0
  43. package/src/agent-src/templates/scripts/work_engine/_lib/agent_settings.ts +1261 -0
  44. package/src/agent-src/templates/scripts/work_engine/_lib/user_global_paths.ts +329 -0
  45. package/src/agent-src/templates/scripts/work_engine/cli.ts +206 -0
  46. package/src/agent-src/templates/scripts/work_engine/cli_args.ts +249 -0
  47. package/src/agent-src/templates/scripts/work_engine/delivery_state.ts +225 -0
  48. package/src/agent-src/templates/scripts/work_engine/directives/backend/analyze.ts +125 -0
  49. package/src/agent-src/templates/scripts/work_engine/directives/backend/implement.ts +189 -0
  50. package/src/agent-src/templates/scripts/work_engine/directives/backend/index.ts +94 -0
  51. package/src/agent-src/templates/scripts/work_engine/directives/backend/memory.ts +193 -0
  52. package/src/agent-src/templates/scripts/work_engine/directives/backend/plan.ts +267 -0
  53. package/src/agent-src/templates/scripts/work_engine/directives/backend/refine.ts +518 -0
  54. package/src/agent-src/templates/scripts/work_engine/directives/backend/report.ts +379 -0
  55. package/src/agent-src/templates/scripts/work_engine/directives/backend/test.ts +268 -0
  56. package/src/agent-src/templates/scripts/work_engine/directives/backend/verify.ts +258 -0
  57. package/src/agent-src/templates/scripts/work_engine/directives/index.ts +32 -0
  58. package/src/agent-src/templates/scripts/work_engine/directives/mixed/contract.ts +243 -0
  59. package/src/agent-src/templates/scripts/work_engine/directives/mixed/index.ts +108 -0
  60. package/src/agent-src/templates/scripts/work_engine/directives/mixed/stitch.ts +259 -0
  61. package/src/agent-src/templates/scripts/work_engine/directives/mixed/ui.ts +216 -0
  62. package/src/agent-src/templates/scripts/work_engine/directives/ui/_passthrough.ts +40 -0
  63. package/src/agent-src/templates/scripts/work_engine/directives/ui/app_spec.ts +241 -0
  64. package/src/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +216 -0
  65. package/src/agent-src/templates/scripts/work_engine/directives/ui/audit.ts +506 -0
  66. package/src/agent-src/templates/scripts/work_engine/directives/ui/design.ts +325 -0
  67. package/src/agent-src/templates/scripts/work_engine/directives/ui/index.ts +102 -0
  68. package/src/agent-src/templates/scripts/work_engine/directives/ui/polish.ts +462 -0
  69. package/src/agent-src/templates/scripts/work_engine/directives/ui/review.ts +474 -0
  70. package/src/agent-src/templates/scripts/work_engine/directives/ui/scaffold.ts +352 -0
  71. package/src/agent-src/templates/scripts/work_engine/directives/ui_trivial/_skipped.ts +33 -0
  72. package/src/agent-src/templates/scripts/work_engine/directives/ui_trivial/apply.ts +213 -0
  73. package/src/agent-src/templates/scripts/work_engine/directives/ui_trivial/index.ts +111 -0
  74. package/src/agent-src/templates/scripts/work_engine/directives/ui_trivial/refine.ts +126 -0
  75. package/src/agent-src/templates/scripts/work_engine/directives/ui_trivial/report.ts +112 -0
  76. package/src/agent-src/templates/scripts/work_engine/directives/ui_trivial/test.ts +164 -0
  77. package/src/agent-src/templates/scripts/work_engine/dispatcher.ts +515 -0
  78. package/src/agent-src/templates/scripts/work_engine/emitters.ts +119 -0
  79. package/src/agent-src/templates/scripts/work_engine/errors.ts +24 -0
  80. package/src/agent-src/templates/scripts/work_engine/hook_bootstrap.ts +104 -0
  81. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/_chat_history_base.ts +189 -0
  82. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_append.ts +41 -0
  83. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/chat_history_halt_append.ts +89 -0
  84. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/decision_gate.ts +193 -0
  85. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/decision_trace.ts +304 -0
  86. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/directive_set_guard.ts +110 -0
  87. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/halt_surface_audit.ts +118 -0
  88. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/index.ts +17 -0
  89. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/memory_visibility.ts +161 -0
  90. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/state_shape_validation.ts +45 -0
  91. package/src/agent-src/templates/scripts/work_engine/hooks/builtin/trace.ts +134 -0
  92. package/src/agent-src/templates/scripts/work_engine/hooks/context.ts +94 -0
  93. package/src/agent-src/templates/scripts/work_engine/hooks/events.ts +58 -0
  94. package/src/agent-src/templates/scripts/work_engine/hooks/exceptions.ts +85 -0
  95. package/src/agent-src/templates/scripts/work_engine/hooks/index.ts +27 -0
  96. package/src/agent-src/templates/scripts/work_engine/hooks/registry.ts +66 -0
  97. package/src/agent-src/templates/scripts/work_engine/hooks/runner.ts +90 -0
  98. package/src/agent-src/templates/scripts/work_engine/hooks/settings.ts +260 -0
  99. package/src/agent-src/templates/scripts/work_engine/input_builders.ts +260 -0
  100. package/src/agent-src/templates/scripts/work_engine/intent/classify.ts +466 -0
  101. package/src/agent-src/templates/scripts/work_engine/migration/v0_to_v1.ts +531 -0
  102. package/src/agent-src/templates/scripts/work_engine/orchestration.ts +366 -0
  103. package/src/agent-src/templates/scripts/work_engine/persona_policy.ts +97 -0
  104. package/src/agent-src/templates/scripts/work_engine/resolvers/diff.ts +135 -0
  105. package/src/agent-src/templates/scripts/work_engine/resolvers/file.ts +175 -0
  106. package/src/agent-src/templates/scripts/work_engine/resolvers/prompt.ts +115 -0
  107. package/src/agent-src/templates/scripts/work_engine/scoring/confidence.ts +415 -0
  108. package/src/agent-src/templates/scripts/work_engine/scoring/decision_engine.ts +466 -0
  109. package/src/agent-src/templates/scripts/work_engine/scoring/decision_trace.ts +298 -0
  110. package/src/agent-src/templates/scripts/work_engine/scoring/memory_visibility.ts +444 -0
  111. package/src/agent-src/templates/scripts/work_engine/stack/detect.ts +252 -0
  112. package/src/agent-src/templates/scripts/work_engine/stack/runner.ts +745 -0
  113. package/src/agent-src/templates/scripts/work_engine/state.ts +1151 -0
  114. package/src/agent-src/templates/scripts/work_engine/state_io.ts +413 -0
  115. package/src/install/atomic.ts +111 -0
  116. package/src/install/conflict.ts +226 -0
  117. package/src/install/detect.ts +280 -0
  118. package/src/install/paths.ts +90 -0
  119. package/src/install/plan.ts +205 -0
  120. package/src/install/preflight.ts +210 -0
  121. package/src/install/selectedTools.ts +52 -0
  122. package/src/install/toolDetection.ts +120 -0
  123. package/src/install/txlog.ts +161 -0
  124. package/src/install/types.ts +175 -0
  125. package/src/install/wizard-plan.ts +216 -0
  126. package/src/scripts/prepack-check.mjs +108 -5
@@ -0,0 +1,1151 @@
1
+ /**
2
+ * Schema v1 of the universal-engine work state.
3
+ *
4
+ * TypeScript twin of `work_engine/state.py` (ADR-200 py2ts). Byte-for-byte
5
+ * serialization parity with the Python original is the contract: field order,
6
+ * defaults, validation order, and error text are all part of the wire format
7
+ * the dispatcher and the freeze-guard replay depend on.
8
+ *
9
+ * The wire format adds five envelope fields on top of the legacy
10
+ * `DeliveryState` shape from `implement_ticket.delivery_state`:
11
+ *
12
+ * - `version` — integer schema version, currently `1`.
13
+ * - `input.kind` — typed input variant (only `"ticket"` for R1).
14
+ * - `input.data` — the original payload (was `state.ticket` in v0).
15
+ * - `intent` — coarse intent label (`"backend-coding"` for R1).
16
+ * - `directive_set` — name of the directive bundle the dispatcher
17
+ * loads. The enum is forward-compatible: `ui`, `ui-trivial`, and
18
+ * `mixed` are accepted by the schema even though only `backend`
19
+ * has working directives in R1 (Phase 4 Step 4 — pre-listed to avoid
20
+ * a schema bump when R3 V2 lands).
21
+ * - `stack` — optional `{frontend, mtime}` cache populated by
22
+ * `work_engine.stack.detect` (R3 Phase 1). `null` while the
23
+ * detector has not yet run; the dispatcher fills it on the first UI
24
+ * dispatch and re-runs detection when `mtime` no longer matches the
25
+ * filesystem (manifest edited).
26
+ * - `ui_audit` — optional inventory written by the
27
+ * `existing-ui-audit` skill (R3 Phase 2). `null` while the audit
28
+ * has not run; populated dict once the skill returns. `greenfield`
29
+ * flag plus `greenfield_decision` carry the user's scaffolding
30
+ * pick. The audit gate (`work_engine.directives.ui.audit`)
31
+ * refuses to advance to design/apply while the slot is empty or
32
+ * while `greenfield` is set without a recorded decision.
33
+ * - `app_spec` — optional greenfield grounding artifact written by
34
+ * `work_engine.directives.ui.app_spec` (greenfield-scaffold
35
+ * Phase 2). Derives a `pages` set, `entity_model`, and `flow_map`
36
+ * from the prompt and carries a `confirmed` flag (the lightweight
37
+ * confirm halt) plus a `bypassed` flag (the "just scaffold" escape).
38
+ * `null` for every non-greenfield-scaffold flow — the app-spec gate
39
+ * is a no-op outside `greenfield_decision == "scaffold"`.
40
+ * - `ui_design` — optional design brief produced by
41
+ * `work_engine.directives.ui.design` (R3 Phase 3 Step 1). Locks
42
+ * layout / components / states / microcopy / a11y; `design_confirmed`
43
+ * carries the user's sign-off.
44
+ * - `ui_scaffold` — optional greenfield scaffold plan written by
45
+ * `work_engine.directives.ui.scaffold` (greenfield-scaffold
46
+ * Phase 3). Plan-only and stack-agnostic
47
+ * (`{pages, routes, layout_strategy, component_manifest, token_seed}`);
48
+ * the engine writes no files — a stack scaffold skill consumes the plan,
49
+ * creates the skeleton, and sets `scaffolded = true` + `artifacts`.
50
+ * `null` for every non-greenfield-scaffold flow. The scaffold gate
51
+ * sits in the `plan` slot (after design): design fixes the abstract
52
+ * visual language, scaffold maps it onto concrete structure.
53
+ * - `ui_review` — optional review-pass output written by
54
+ * `work_engine.directives.ui.review` (R3 Phase 3 Step 4). Carries
55
+ * the design-review findings list and a `review_clean` flag set when
56
+ * no findings remain.
57
+ * - `ui_polish` — optional polish-pass log written by
58
+ * `work_engine.directives.ui.polish` (R3 Phase 3 Step 5). Tracks
59
+ * the round counter (`rounds` <= 2 ceiling) and the per-round
60
+ * applied-fix list so a re-entry knows whether the loop has been
61
+ * exhausted.
62
+ * - `contract` — optional backend-contract envelope written by
63
+ * `work_engine.directives.mixed.contract` (R3 Phase 4 Step 1).
64
+ * Locks `data_model` and `api_surface` before any UI work starts;
65
+ * `contract_confirmed` carries the user's sign-off. The mixed UI
66
+ * step refuses to advance without a confirmed contract — this is the
67
+ * sentinel that prevents UI work from racing ahead of the backend.
68
+ * - `stitch` — optional integration-verification envelope written by
69
+ * `work_engine.directives.mixed.stitch` (R3 Phase 4 Step 3).
70
+ * Carries the end-to-end smoke `scenarios` list, an aggregate
71
+ * `verdict` (success / blocked / partial), and the
72
+ * `integration_confirmed` flag the user sets after reviewing the
73
+ * integration evidence.
74
+ * - `halts` — append-only log of `HookHalt` events the engine
75
+ * persisted into state. Populated by `emitters._emit_halt` when
76
+ * it runs against an already-persisted state file (the P3 branch table
77
+ * is preserved: fresh-run halts before the first `_save` still leave
78
+ * no state on disk). The `explain_last` trace builder reads the
79
+ * tail entry to fill the `trace.halt` slot. Each entry is
80
+ * `{reason, step, surface, timestamp}`. The list defaults to empty
81
+ * and is omitted from older state files via `from_dict`'s tolerant
82
+ * reader — no schema-version bump.
83
+ *
84
+ * All other fields keep their v0 names so the dispatcher can read the
85
+ * legacy slice unchanged once Phase 3 wires the steps over.
86
+ *
87
+ * The module exposes:
88
+ *
89
+ * - `SCHEMA_VERSION` — the integer carried in `version`.
90
+ * - `KNOWN_INPUT_KINDS` / `KNOWN_DIRECTIVE_SETS` — the
91
+ * whitelists used by validation.
92
+ * - `Input`, `WorkState` — typed classes.
93
+ * - `from_dict` / `to_dict` — JSON round-trip helpers.
94
+ * - `SchemaError` — raised on every validation failure.
95
+ */
96
+ import * as fs from 'node:fs';
97
+ import * as path from 'node:path';
98
+
99
+ // ── JSON value model ────────────────────────────────────────────────────
100
+ //
101
+ // The persisted state carries arbitrary user JSON (the input payload, the
102
+ // plan / changes / tests / verify slots). Model it as the standard JSON
103
+ // union so the heterogeneous dict shape and any-missing-key access survive
104
+ // without `any`.
105
+
106
+ export type JsonValue =
107
+ | string
108
+ | number
109
+ | boolean
110
+ | null
111
+ | JsonValue[]
112
+ | { [key: string]: JsonValue };
113
+
114
+ /** A heterogeneous JSON object, mirroring a Python `dict[str, Any]`. */
115
+ export type Dict = { [key: string]: JsonValue };
116
+
117
+ export const SCHEMA_VERSION = 1;
118
+ /** Integer version stored under the `version` key on disk. */
119
+
120
+ export const DEFAULT_INTENT = 'backend-coding';
121
+ /** Intent applied when migrating a v0 file or building a fresh state. */
122
+
123
+ export const DEFAULT_DIRECTIVE_SET = 'backend';
124
+ /** Directive set applied when migrating a v0 file or building a fresh state. */
125
+
126
+ /**
127
+ * Input kinds accepted by the schema.
128
+ *
129
+ * `ticket` is the R1 kind: pre-structured `{id, title, acceptance_criteria, ...}`
130
+ * fed by the `/implement-ticket` flow. `prompt` is the R2 kind: a free-form
131
+ * user prompt wrapped via `work_engine.resolvers.prompt` into
132
+ * `{raw, reconstructed_ac, assumptions}`; the engine refines the raw text
133
+ * into actionable AC + a confidence band before plan/apply/test/review run.
134
+ *
135
+ * `diff` and `file` are the R3 Phase 1 UI-improve kinds. `diff` carries a
136
+ * unified-diff / patch payload (`{raw, reconstructed_ac, assumptions}`) so the
137
+ * `directives/ui` set can take an "improve this screen" PR-style input; `file`
138
+ * carries a path reference to an existing component/page (`{path,
139
+ * reconstructed_ac, assumptions}`) for the same surface. Both default-route to
140
+ * `ui-improve` via `work_engine.intent.populate_routing`.
141
+ *
142
+ * Per the schema/capability split documented on
143
+ * `work_engine.directives.backend.SUPPORTED_KINDS`, presence here only
144
+ * means the *envelope* is accepted on disk — a directive set still has to
145
+ * list the kind in its `SUPPORTED_KINDS` tuple before the dispatcher will
146
+ * route it. R2 widens the envelope; R2 Phase 3 widens backend's capability
147
+ * tuple in lockstep with the `refine-prompt` skill landing. R3 Phase 1 widens
148
+ * the envelope further; `ui` capability is wired in Phase 3 of the UI track.
149
+ *
150
+ * Other kinds are rejected so unknown values surface as errors instead of
151
+ * silently falling through to a default branch.
152
+ */
153
+ export const KNOWN_INPUT_KINDS: ReadonlySet<string> = new Set([
154
+ 'ticket',
155
+ 'prompt',
156
+ 'diff',
157
+ 'file',
158
+ ]);
159
+
160
+ /**
161
+ * Directive sets recognised by the schema.
162
+ *
163
+ * Per the roadmap (Phase 4 Step 4), `ui`, `ui-trivial`, and `mixed`
164
+ * are intentionally pre-listed so a future R3 V2 release does not need a
165
+ * schema bump. Only `backend` has working directives in R1; the others
166
+ * raise `NotImplementedError` at dispatch time.
167
+ */
168
+ export const KNOWN_DIRECTIVE_SETS: ReadonlySet<string> = new Set([
169
+ 'backend',
170
+ 'ui',
171
+ 'ui-trivial',
172
+ 'mixed',
173
+ ]);
174
+
175
+ /** Raised when a state payload violates the v1 contract. */
176
+ export class SchemaError extends Error {
177
+ constructor(message: string) {
178
+ super(message);
179
+ this.name = 'SchemaError';
180
+ Object.setPrototypeOf(this, SchemaError.prototype);
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Typed envelope for the user-supplied work item.
186
+ *
187
+ * `kind` is one of `KNOWN_INPUT_KINDS`; `data` is the raw
188
+ * payload. The legacy `state.ticket` dict lands here as
189
+ * `Input(kind="ticket", data=<dict>)` after migration.
190
+ */
191
+ export class Input {
192
+ kind: string;
193
+ data: Dict;
194
+
195
+ constructor(kind: string, data: Dict | null = null) {
196
+ this.kind = kind;
197
+ // Mirror `field(default_factory=dict)` — a fresh empty dict per instance.
198
+ this.data = data ?? {};
199
+ }
200
+ }
201
+
202
+ /**
203
+ * Schema v1 of the persisted work state.
204
+ *
205
+ * Field order mirrors the on-disk JSON: envelope (`version`,
206
+ * `input`, `intent`, `directive_set`, `stack`) first, then
207
+ * the legacy `DeliveryState` slice (`persona` ... `report`) so a
208
+ * diff between a v1 file and its v0 ancestor stays readable.
209
+ */
210
+ export class WorkState {
211
+ input: Input;
212
+ intent: string;
213
+ directive_set: string;
214
+ stack: Dict | null;
215
+ ui_audit: Dict | null;
216
+ app_spec: Dict | null;
217
+ ui_design: Dict | null;
218
+ ui_scaffold: Dict | null;
219
+ ui_review: Dict | null;
220
+ ui_polish: Dict | null;
221
+ contract: Dict | null;
222
+ stitch: Dict | null;
223
+ halts: Dict[];
224
+ version: number;
225
+ persona: string;
226
+ memory: Dict[];
227
+ plan: JsonValue;
228
+ changes: Dict[];
229
+ tests: JsonValue;
230
+ verify: JsonValue;
231
+ outcomes: { [key: string]: string };
232
+ questions: string[];
233
+ report: string;
234
+
235
+ constructor(opts: {
236
+ input: Input;
237
+ intent?: string;
238
+ directive_set?: string;
239
+ stack?: Dict | null;
240
+ ui_audit?: Dict | null;
241
+ app_spec?: Dict | null;
242
+ ui_design?: Dict | null;
243
+ ui_scaffold?: Dict | null;
244
+ ui_review?: Dict | null;
245
+ ui_polish?: Dict | null;
246
+ contract?: Dict | null;
247
+ stitch?: Dict | null;
248
+ halts?: Dict[];
249
+ version?: number;
250
+ persona?: string;
251
+ memory?: Dict[];
252
+ plan?: JsonValue;
253
+ changes?: Dict[];
254
+ tests?: JsonValue;
255
+ verify?: JsonValue;
256
+ outcomes?: { [key: string]: string };
257
+ questions?: string[];
258
+ report?: string;
259
+ }) {
260
+ this.input = opts.input;
261
+ this.intent = opts.intent ?? DEFAULT_INTENT;
262
+ this.directive_set = opts.directive_set ?? DEFAULT_DIRECTIVE_SET;
263
+ this.stack = opts.stack ?? null;
264
+ this.ui_audit = opts.ui_audit ?? null;
265
+ this.app_spec = opts.app_spec ?? null;
266
+ this.ui_design = opts.ui_design ?? null;
267
+ this.ui_scaffold = opts.ui_scaffold ?? null;
268
+ this.ui_review = opts.ui_review ?? null;
269
+ this.ui_polish = opts.ui_polish ?? null;
270
+ this.contract = opts.contract ?? null;
271
+ this.stitch = opts.stitch ?? null;
272
+ this.halts = opts.halts ?? [];
273
+ this.version = opts.version ?? SCHEMA_VERSION;
274
+ this.persona = opts.persona ?? 'senior-engineer';
275
+ this.memory = opts.memory ?? [];
276
+ this.plan = opts.plan ?? null;
277
+ this.changes = opts.changes ?? [];
278
+ this.tests = opts.tests ?? null;
279
+ this.verify = opts.verify ?? null;
280
+ this.outcomes = opts.outcomes ?? {};
281
+ this.questions = opts.questions ?? [];
282
+ this.report = opts.report ?? '';
283
+ }
284
+ }
285
+
286
+ /**
287
+ * Serialise `state` to the canonical v1 JSON shape.
288
+ *
289
+ * Field order is fixed: `version` -> `input` -> `intent` ->
290
+ * `directive_set` -> legacy slice. Stable order keeps state
291
+ * snapshots diff-friendly across re-runs and across the freeze-guard
292
+ * replay. Validation runs before serialisation so an in-memory
293
+ * object that was mutated past the schema cannot reach disk.
294
+ */
295
+ export function to_dict(state: WorkState): Dict {
296
+ _validate_kind(state.input.kind);
297
+ _validate_directive_set(state.directive_set);
298
+ if (state.version !== SCHEMA_VERSION) {
299
+ throw new SchemaError(
300
+ `version must be ${SCHEMA_VERSION}; got ${pyRepr(state.version)}`,
301
+ );
302
+ }
303
+ _validate_stack(state.stack);
304
+ _validate_ui_audit(state.ui_audit);
305
+ _validate_app_spec(state.app_spec);
306
+ _validate_ui_design(state.ui_design);
307
+ _validate_ui_scaffold(state.ui_scaffold);
308
+ _validate_ui_review(state.ui_review);
309
+ _validate_ui_polish(state.ui_polish);
310
+ _validate_contract(state.contract);
311
+ _validate_stitch(state.stitch);
312
+ _validate_halts(state.halts);
313
+ return {
314
+ version: state.version,
315
+ input: { kind: state.input.kind, data: state.input.data },
316
+ intent: state.intent,
317
+ directive_set: state.directive_set,
318
+ stack: state.stack,
319
+ ui_audit: state.ui_audit,
320
+ app_spec: state.app_spec,
321
+ ui_design: state.ui_design,
322
+ ui_scaffold: state.ui_scaffold,
323
+ ui_review: state.ui_review,
324
+ ui_polish: state.ui_polish,
325
+ contract: state.contract,
326
+ stitch: state.stitch,
327
+ halts: [...state.halts],
328
+ persona: state.persona,
329
+ memory: state.memory,
330
+ plan: state.plan,
331
+ changes: state.changes,
332
+ tests: state.tests,
333
+ verify: state.verify,
334
+ outcomes: state.outcomes,
335
+ questions: state.questions,
336
+ report: state.report,
337
+ };
338
+ }
339
+
340
+ /**
341
+ * Build a `WorkState` from a parsed JSON payload.
342
+ *
343
+ * Validates the envelope (`version`, `input.kind`,
344
+ * `directive_set`) before instantiating the class. Unknown
345
+ * top-level keys are tolerated and dropped — the schema is additive,
346
+ * not strict-rejecting, so a future field rolled out by a newer
347
+ * engine version does not crash an older reader.
348
+ */
349
+ export function from_dict(payload: JsonValue): WorkState {
350
+ if (!_isDict(payload)) {
351
+ throw new SchemaError(
352
+ `state payload must be a JSON object; got ${pyTypeName(payload)}`,
353
+ );
354
+ }
355
+
356
+ const version = payload['version'];
357
+ if (version !== SCHEMA_VERSION) {
358
+ throw new SchemaError(
359
+ `version must be ${SCHEMA_VERSION}; got ${pyRepr(version)}. ` +
360
+ 'Run the v0→v1 migration before loading legacy files.',
361
+ );
362
+ }
363
+
364
+ const raw_input = payload['input'];
365
+ if (!_isDict(raw_input)) {
366
+ throw new SchemaError(
367
+ "state.input must be a JSON object with 'kind' and 'data' keys",
368
+ );
369
+ }
370
+ const kind = raw_input['kind'];
371
+ _validate_kind(kind);
372
+ const data = 'data' in raw_input ? raw_input['data'] : {};
373
+ if (!_isDict(data)) {
374
+ throw new SchemaError(
375
+ `state.input.data must be a JSON object; got ${pyTypeName(data)}`,
376
+ );
377
+ }
378
+
379
+ const directive_set = _get(payload, 'directive_set', DEFAULT_DIRECTIVE_SET);
380
+ _validate_directive_set(directive_set);
381
+
382
+ const stack = payload['stack'] ?? null;
383
+ _validate_stack(stack);
384
+
385
+ const ui_audit = payload['ui_audit'] ?? null;
386
+ _validate_ui_audit(ui_audit);
387
+
388
+ const app_spec = payload['app_spec'] ?? null;
389
+ _validate_app_spec(app_spec);
390
+
391
+ const ui_design = payload['ui_design'] ?? null;
392
+ _validate_ui_design(ui_design);
393
+
394
+ const ui_scaffold = payload['ui_scaffold'] ?? null;
395
+ _validate_ui_scaffold(ui_scaffold);
396
+
397
+ const ui_review = payload['ui_review'] ?? null;
398
+ _validate_ui_review(ui_review);
399
+
400
+ const ui_polish = payload['ui_polish'] ?? null;
401
+ _validate_ui_polish(ui_polish);
402
+
403
+ const contract = payload['contract'] ?? null;
404
+ _validate_contract(contract);
405
+
406
+ const stitch = payload['stitch'] ?? null;
407
+ _validate_stitch(stitch);
408
+
409
+ const halts = 'halts' in payload ? payload['halts'] : [];
410
+ _validate_halts(halts);
411
+
412
+ return new WorkState({
413
+ input: new Input(kind as string, { ...(data as Dict) }),
414
+ intent: _get(payload, 'intent', DEFAULT_INTENT) as string,
415
+ directive_set: directive_set as string,
416
+ stack: _isDict(stack) ? { ...stack } : null,
417
+ ui_audit: _isDict(ui_audit) ? { ...ui_audit } : null,
418
+ app_spec: _isDict(app_spec) ? { ...app_spec } : null,
419
+ ui_design: _isDict(ui_design) ? { ...ui_design } : null,
420
+ ui_scaffold: _isDict(ui_scaffold) ? { ...ui_scaffold } : null,
421
+ ui_review: _isDict(ui_review) ? { ...ui_review } : null,
422
+ ui_polish: _isDict(ui_polish) ? { ...ui_polish } : null,
423
+ contract: _isDict(contract) ? { ...contract } : null,
424
+ stitch: _isDict(stitch) ? { ...stitch } : null,
425
+ halts: Array.isArray(halts) ? ([...halts] as Dict[]) : [],
426
+ version: version as number,
427
+ persona: _get(payload, 'persona', 'senior-engineer') as string,
428
+ memory: [...(_listOrEmpty(_get(payload, 'memory', [])))] as Dict[],
429
+ plan: _get(payload, 'plan', null),
430
+ changes: [...(_listOrEmpty(_get(payload, 'changes', [])))] as Dict[],
431
+ tests: _get(payload, 'tests', null),
432
+ verify: _get(payload, 'verify', null),
433
+ outcomes: { ...(_dictOrEmpty(_get(payload, 'outcomes', {}))) } as {
434
+ [key: string]: string;
435
+ },
436
+ questions: [
437
+ ...(_listOrEmpty(_get(payload, 'questions', []))),
438
+ ] as string[],
439
+ report: _get(payload, 'report', '') as string,
440
+ });
441
+ }
442
+
443
+ /** Read a v1 state file from disk and return a `WorkState`. */
444
+ export function load(p: string): WorkState {
445
+ const raw = fs.readFileSync(p, 'utf-8');
446
+ let payload: JsonValue;
447
+ try {
448
+ payload = JSON.parse(raw) as JsonValue;
449
+ } catch (exc) {
450
+ throw new SchemaError(`invalid JSON in ${p}: ${(exc as Error).message}`);
451
+ }
452
+ return from_dict(payload);
453
+ }
454
+
455
+ /** Write `state` to `p` as pretty JSON, terminating newline included. */
456
+ export function dump(state: WorkState, p: string): void {
457
+ fs.mkdirSync(path.dirname(p), { recursive: true });
458
+ fs.writeFileSync(p, jsonDumps(to_dict(state)) + '\n', 'utf-8');
459
+ }
460
+
461
+ function _validate_kind(kind: JsonValue | undefined): void {
462
+ if (typeof kind !== 'string') {
463
+ throw new SchemaError(
464
+ `state.input.kind must be a string; got ${pyTypeName(kind)}`,
465
+ );
466
+ }
467
+ if (!KNOWN_INPUT_KINDS.has(kind)) {
468
+ throw new SchemaError(
469
+ `unknown input.kind ${pyRepr(kind)}; ` +
470
+ `expected one of ${pyListRepr(pySorted(KNOWN_INPUT_KINDS))}`,
471
+ );
472
+ }
473
+ }
474
+
475
+ function _validate_directive_set(name: JsonValue): void {
476
+ if (typeof name !== 'string') {
477
+ throw new SchemaError(
478
+ `state.directive_set must be a string; got ${pyTypeName(name)}`,
479
+ );
480
+ }
481
+ if (!KNOWN_DIRECTIVE_SETS.has(name)) {
482
+ throw new SchemaError(
483
+ `unknown directive_set ${pyRepr(name)}; ` +
484
+ `expected one of ${pyListRepr(pySorted(KNOWN_DIRECTIVE_SETS))}`,
485
+ );
486
+ }
487
+ }
488
+
489
+ /**
490
+ * Reject malformed stack envelopes; tolerate `null` (not yet detected).
491
+ *
492
+ * The detector populates `state.stack` lazily — the first dispatch
493
+ * of a new state file may run without it set, then the dispatcher
494
+ * fills it in before any UI handler reads it. We only validate the
495
+ * shape when present so the absence-of-detection case stays a normal
496
+ * code path, not an error.
497
+ */
498
+ function _validate_stack(stack: JsonValue): void {
499
+ if (stack === null) {
500
+ return;
501
+ }
502
+ if (!_isDict(stack)) {
503
+ throw new SchemaError(
504
+ `state.stack must be a JSON object or null; ` +
505
+ `got ${pyTypeName(stack)}`,
506
+ );
507
+ }
508
+ const frontend = stack['frontend'];
509
+ if (typeof frontend !== 'string' || !frontend) {
510
+ throw new SchemaError('state.stack.frontend must be a non-empty string');
511
+ }
512
+ const mtime = 'mtime' in stack ? stack['mtime'] : 0.0;
513
+ if (!_isNumber(mtime)) {
514
+ throw new SchemaError(
515
+ `state.stack.mtime must be a number; got ${pyTypeName(mtime)}`,
516
+ );
517
+ }
518
+ }
519
+
520
+ /**
521
+ * Reject malformed `ui_audit` envelopes; tolerate `null` and `{}`.
522
+ *
523
+ * `null` means the audit has not run yet — the dispatcher's audit
524
+ * gate (`directives.ui.audit`) will emit the agent-directive that
525
+ * populates it. An empty dict is the in-progress shape after the
526
+ * skill returns but before findings land; the gate treats it the
527
+ * same as `null`. Once populated, `greenfield` (when present)
528
+ * must be a bool, and `greenfield_decision` (when present) must
529
+ * be one of the three documented choices. Other keys (`components`,
530
+ * `patterns`, ...) are validated by the audit handler against the
531
+ * skill contract — the schema only enforces shape, not content.
532
+ */
533
+ function _validate_ui_audit(ui_audit: JsonValue): void {
534
+ if (ui_audit === null) {
535
+ return;
536
+ }
537
+ if (!_isDict(ui_audit)) {
538
+ throw new SchemaError(
539
+ `state.ui_audit must be a JSON object or null; ` +
540
+ `got ${pyTypeName(ui_audit)}`,
541
+ );
542
+ }
543
+ if ('greenfield' in ui_audit && typeof ui_audit['greenfield'] !== 'boolean') {
544
+ throw new SchemaError(
545
+ 'state.ui_audit.greenfield must be a boolean when present',
546
+ );
547
+ }
548
+ const decision = 'greenfield_decision' in ui_audit
549
+ ? ui_audit['greenfield_decision']
550
+ : null;
551
+ if (
552
+ decision !== null &&
553
+ decision !== 'scaffold' &&
554
+ decision !== 'bare' &&
555
+ decision !== 'external_reference'
556
+ ) {
557
+ throw new SchemaError(
558
+ `state.ui_audit.greenfield_decision must be one of ` +
559
+ `'scaffold', 'bare', 'external_reference', or null; ` +
560
+ `got ${pyRepr(decision)}`,
561
+ );
562
+ }
563
+ if (
564
+ 'a11y_baseline' in ui_audit &&
565
+ !Array.isArray(ui_audit['a11y_baseline'])
566
+ ) {
567
+ throw new SchemaError(
568
+ 'state.ui_audit.a11y_baseline must be a list when present',
569
+ );
570
+ }
571
+ }
572
+
573
+ /**
574
+ * Reject malformed `app_spec` envelopes; tolerate `null` and `{}`.
575
+ *
576
+ * `null` means the app-spec gate has not produced a grounding
577
+ * artifact yet — the greenfield-scaffold `app_spec` directive
578
+ * (greenfield-scaffold Phase 2) emits the agent-directive that
579
+ * populates it, and the gate is a no-op for every non-greenfield
580
+ * flow so the slot stays `null` there. An empty dict is the
581
+ * in-progress shape after the skill returns but before the page-set
582
+ * lands; the gate treats it the same as `null`. Once populated,
583
+ * `pages` / `entity_model` (when present) must be lists,
584
+ * `flow_map` (when present) must be a list or dict, and
585
+ * `confirmed` / `bypassed` (when present) must be bools — the
586
+ * app-spec gate's confirm/bypass sentinels are simple equality
587
+ * tests, so the schema enforces only shape, not content.
588
+ */
589
+ function _validate_app_spec(app_spec: JsonValue): void {
590
+ if (app_spec === null) {
591
+ return;
592
+ }
593
+ if (!_isDict(app_spec)) {
594
+ throw new SchemaError(
595
+ `state.app_spec must be a JSON object or null; ` +
596
+ `got ${pyTypeName(app_spec)}`,
597
+ );
598
+ }
599
+ for (const key of ['pages', 'entity_model']) {
600
+ if (key in app_spec && !Array.isArray(app_spec[key])) {
601
+ throw new SchemaError(
602
+ `state.app_spec.${key} must be a list when present`,
603
+ );
604
+ }
605
+ }
606
+ if (
607
+ 'flow_map' in app_spec &&
608
+ !Array.isArray(app_spec['flow_map']) &&
609
+ !_isDict(app_spec['flow_map'])
610
+ ) {
611
+ throw new SchemaError(
612
+ 'state.app_spec.flow_map must be a list or object when present',
613
+ );
614
+ }
615
+ for (const key of ['confirmed', 'bypassed']) {
616
+ if (key in app_spec && typeof app_spec[key] !== 'boolean') {
617
+ throw new SchemaError(
618
+ `state.app_spec.${key} must be a boolean when present`,
619
+ );
620
+ }
621
+ }
622
+ }
623
+
624
+ /**
625
+ * Reject malformed `ui_design` envelopes; tolerate `null` and `{}`.
626
+ *
627
+ * `null` means the design step has not produced a brief yet — the
628
+ * dispatcher's design gate (`directives.ui.design`) emits the
629
+ * agent-directive that populates it. An empty dict is the in-progress
630
+ * shape after the skill returns but before the brief lands; the gate
631
+ * treats it the same as `null`. Once populated, `design_confirmed`
632
+ * (when present) must be a bool. Other keys (`layout`, `components`,
633
+ * `states`, `microcopy`, `a11y`, `reused_from_audit`) are
634
+ * validated by the design handler against the skill contract — the
635
+ * schema only enforces shape, not content.
636
+ */
637
+ function _validate_ui_design(ui_design: JsonValue): void {
638
+ if (ui_design === null) {
639
+ return;
640
+ }
641
+ if (!_isDict(ui_design)) {
642
+ throw new SchemaError(
643
+ `state.ui_design must be a JSON object or null; ` +
644
+ `got ${pyTypeName(ui_design)}`,
645
+ );
646
+ }
647
+ if (
648
+ 'design_confirmed' in ui_design &&
649
+ typeof ui_design['design_confirmed'] !== 'boolean'
650
+ ) {
651
+ throw new SchemaError(
652
+ 'state.ui_design.design_confirmed must be a boolean when present',
653
+ );
654
+ }
655
+ }
656
+
657
+ /**
658
+ * Reject malformed `ui_scaffold` envelopes; tolerate `null` and `{}`.
659
+ *
660
+ * `null` means the scaffold gate has not produced a plan yet — the
661
+ * greenfield-scaffold `scaffold` directive (greenfield-scaffold
662
+ * Phase 3) emits the agent-directive that populates it, and the gate
663
+ * is a no-op for every non-greenfield flow so the slot stays `null`
664
+ * there. An empty dict is the in-progress shape. Once populated, the
665
+ * stack-agnostic plan keys `pages` / `routes` / `component_manifest`
666
+ * / `artifacts` (when present) must be lists, `layout_strategy`
667
+ * (when present) must be a string, `token_seed` (when present) must
668
+ * be an object, and `scaffolded` (when present) must be a bool — the
669
+ * scaffold gate's "plan produced" and "files created" sentinels are
670
+ * simple shape/equality tests, so the schema enforces only shape.
671
+ */
672
+ function _validate_ui_scaffold(ui_scaffold: JsonValue): void {
673
+ if (ui_scaffold === null) {
674
+ return;
675
+ }
676
+ if (!_isDict(ui_scaffold)) {
677
+ throw new SchemaError(
678
+ `state.ui_scaffold must be a JSON object or null; ` +
679
+ `got ${pyTypeName(ui_scaffold)}`,
680
+ );
681
+ }
682
+ for (const key of ['pages', 'routes', 'component_manifest', 'artifacts']) {
683
+ if (key in ui_scaffold && !Array.isArray(ui_scaffold[key])) {
684
+ throw new SchemaError(
685
+ `state.ui_scaffold.${key} must be a list when present`,
686
+ );
687
+ }
688
+ }
689
+ if (
690
+ 'layout_strategy' in ui_scaffold &&
691
+ typeof ui_scaffold['layout_strategy'] !== 'string'
692
+ ) {
693
+ throw new SchemaError(
694
+ 'state.ui_scaffold.layout_strategy must be a string when present',
695
+ );
696
+ }
697
+ if ('token_seed' in ui_scaffold && !_isDict(ui_scaffold['token_seed'])) {
698
+ throw new SchemaError(
699
+ 'state.ui_scaffold.token_seed must be a JSON object when present',
700
+ );
701
+ }
702
+ if (
703
+ 'scaffolded' in ui_scaffold &&
704
+ typeof ui_scaffold['scaffolded'] !== 'boolean'
705
+ ) {
706
+ throw new SchemaError(
707
+ 'state.ui_scaffold.scaffolded must be a boolean when present',
708
+ );
709
+ }
710
+ }
711
+
712
+ /**
713
+ * Reject malformed `ui_review` envelopes; tolerate `null` and `{}`.
714
+ *
715
+ * `null` means the review pass has not run yet — the dispatcher's
716
+ * review gate (`directives.ui.review`) emits the agent-directive
717
+ * that populates it. An empty dict is the in-progress shape after
718
+ * the skill returns but before findings land. Once populated,
719
+ * `findings` (when present) must be a list and `review_clean`
720
+ * (when present) must be a bool. Field content (severity labels,
721
+ * fix suggestions) is validated by the review handler; the schema
722
+ * enforces only shape.
723
+ */
724
+ function _validate_ui_review(ui_review: JsonValue): void {
725
+ if (ui_review === null) {
726
+ return;
727
+ }
728
+ if (!_isDict(ui_review)) {
729
+ throw new SchemaError(
730
+ `state.ui_review must be a JSON object or null; ` +
731
+ `got ${pyTypeName(ui_review)}`,
732
+ );
733
+ }
734
+ if ('findings' in ui_review && !Array.isArray(ui_review['findings'])) {
735
+ throw new SchemaError(
736
+ 'state.ui_review.findings must be a list when present',
737
+ );
738
+ }
739
+ if (
740
+ 'review_clean' in ui_review &&
741
+ typeof ui_review['review_clean'] !== 'boolean'
742
+ ) {
743
+ throw new SchemaError(
744
+ 'state.ui_review.review_clean must be a boolean when present',
745
+ );
746
+ }
747
+ const a11y = 'a11y' in ui_review ? ui_review['a11y'] : null;
748
+ if (a11y !== null) {
749
+ if (!_isDict(a11y)) {
750
+ throw new SchemaError(
751
+ 'state.ui_review.a11y must be a JSON object or null when present',
752
+ );
753
+ }
754
+ if ('violations' in a11y && !Array.isArray(a11y['violations'])) {
755
+ throw new SchemaError(
756
+ 'state.ui_review.a11y.violations must be a list when present',
757
+ );
758
+ }
759
+ const floor = 'severity_floor' in a11y ? a11y['severity_floor'] : null;
760
+ if (
761
+ floor !== null &&
762
+ floor !== 'minor' &&
763
+ floor !== 'moderate' &&
764
+ floor !== 'serious' &&
765
+ floor !== 'critical'
766
+ ) {
767
+ throw new SchemaError(
768
+ `state.ui_review.a11y.severity_floor must be one of ` +
769
+ `'minor', 'moderate', 'serious', 'critical', or null; ` +
770
+ `got ${pyRepr(floor)}`,
771
+ );
772
+ }
773
+ if (
774
+ 'accepted_violations' in a11y &&
775
+ !Array.isArray(a11y['accepted_violations'])
776
+ ) {
777
+ throw new SchemaError(
778
+ 'state.ui_review.a11y.accepted_violations must be a list when present',
779
+ );
780
+ }
781
+ }
782
+ const preview = 'preview' in ui_review ? ui_review['preview'] : null;
783
+ if (preview !== null) {
784
+ if (!_isDict(preview)) {
785
+ throw new SchemaError(
786
+ 'state.ui_review.preview must be a JSON object or null when present',
787
+ );
788
+ }
789
+ if ('render_ok' in preview && typeof preview['render_ok'] !== 'boolean') {
790
+ throw new SchemaError(
791
+ 'state.ui_review.preview.render_ok must be a boolean when present',
792
+ );
793
+ }
794
+ }
795
+ }
796
+
797
+ /**
798
+ * Reject malformed `ui_polish` envelopes; tolerate `null` and `{}`.
799
+ *
800
+ * `null` means the polish loop has not entered yet. Once
801
+ * populated, `rounds` (when present) must be an int in `[0, 2]`
802
+ * by default — the polish-loop ceiling defined in
803
+ * `agents/roadmaps/road-to-product-ui-track.md` Phase 3 Step 5.
804
+ * R4 Phase 2 widens the upper bound to `3` when
805
+ * `extension_used` is `True` (one-shot a11y extension halt).
806
+ * `applied` (when present) must be a list. The polish handler
807
+ * enforces ceiling semantics; the schema enforces only shape.
808
+ */
809
+ function _validate_ui_polish(ui_polish: JsonValue): void {
810
+ if (ui_polish === null) {
811
+ return;
812
+ }
813
+ if (!_isDict(ui_polish)) {
814
+ throw new SchemaError(
815
+ `state.ui_polish must be a JSON object or null; ` +
816
+ `got ${pyTypeName(ui_polish)}`,
817
+ );
818
+ }
819
+ if (
820
+ 'extension_used' in ui_polish &&
821
+ typeof ui_polish['extension_used'] !== 'boolean'
822
+ ) {
823
+ throw new SchemaError(
824
+ 'state.ui_polish.extension_used must be a boolean when present',
825
+ );
826
+ }
827
+ const extension_used = _pyBool(
828
+ 'extension_used' in ui_polish ? ui_polish['extension_used'] : false,
829
+ );
830
+ if ('rounds' in ui_polish) {
831
+ const rounds = ui_polish['rounds'];
832
+ if (!_isPyInt(rounds)) {
833
+ throw new SchemaError(
834
+ `state.ui_polish.rounds must be an integer; got ${pyTypeName(rounds)}`,
835
+ );
836
+ }
837
+ const max_rounds = extension_used ? 3 : 2;
838
+ if (rounds < 0 || rounds > max_rounds) {
839
+ throw new SchemaError(
840
+ `state.ui_polish.rounds must be in [0, ${max_rounds}]; ` +
841
+ `got ${rounds} (extension_used=${pyBoolRepr(extension_used)})`,
842
+ );
843
+ }
844
+ }
845
+ if ('applied' in ui_polish && !Array.isArray(ui_polish['applied'])) {
846
+ throw new SchemaError(
847
+ 'state.ui_polish.applied must be a list when present',
848
+ );
849
+ }
850
+ }
851
+
852
+ /**
853
+ * Reject malformed `contract` envelopes; tolerate `null` and `{}`.
854
+ *
855
+ * `null` means the contract step has not run yet — the mixed
856
+ * `contract` directive (R3 Phase 4 Step 1) emits the
857
+ * agent-directive that populates it. Once populated,
858
+ * `data_model` and `api_surface` (when present) must be lists,
859
+ * and `contract_confirmed` (when present) must be a bool. Field
860
+ * content (entity names, endpoint shapes) is validated by the
861
+ * contract handler; the schema enforces only shape so the mixed UI
862
+ * step's sentinel check (`contract_confirmed is True`) stays a
863
+ * simple equality test.
864
+ */
865
+ function _validate_contract(contract: JsonValue): void {
866
+ if (contract === null) {
867
+ return;
868
+ }
869
+ if (!_isDict(contract)) {
870
+ throw new SchemaError(
871
+ `state.contract must be a JSON object or null; ` +
872
+ `got ${pyTypeName(contract)}`,
873
+ );
874
+ }
875
+ if ('data_model' in contract && !Array.isArray(contract['data_model'])) {
876
+ throw new SchemaError(
877
+ 'state.contract.data_model must be a list when present',
878
+ );
879
+ }
880
+ if ('api_surface' in contract && !Array.isArray(contract['api_surface'])) {
881
+ throw new SchemaError(
882
+ 'state.contract.api_surface must be a list when present',
883
+ );
884
+ }
885
+ if (
886
+ 'contract_confirmed' in contract &&
887
+ typeof contract['contract_confirmed'] !== 'boolean'
888
+ ) {
889
+ throw new SchemaError(
890
+ 'state.contract.contract_confirmed must be a bool when present',
891
+ );
892
+ }
893
+ }
894
+
895
+ /**
896
+ * Reject malformed `stitch` envelopes; tolerate `null` and `{}`.
897
+ *
898
+ * `null` means the integration-verification step has not run yet
899
+ * — the mixed `stitch` directive (R3 Phase 4 Step 3) emits the
900
+ * agent-directive that populates it. Once populated, `scenarios`
901
+ * (when present) must be a list of integration smoke cases,
902
+ * `verdict` (when present) must be one of
903
+ * `{"success", "blocked", "partial"}`, and
904
+ * `integration_confirmed` (when present) must be a bool. The
905
+ * stitch handler enforces verdict semantics; the schema enforces
906
+ * only shape.
907
+ */
908
+ function _validate_stitch(stitch: JsonValue): void {
909
+ if (stitch === null) {
910
+ return;
911
+ }
912
+ if (!_isDict(stitch)) {
913
+ throw new SchemaError(
914
+ `state.stitch must be a JSON object or null; ` +
915
+ `got ${pyTypeName(stitch)}`,
916
+ );
917
+ }
918
+ if ('scenarios' in stitch && !Array.isArray(stitch['scenarios'])) {
919
+ throw new SchemaError(
920
+ 'state.stitch.scenarios must be a list when present',
921
+ );
922
+ }
923
+ if ('verdict' in stitch) {
924
+ const verdict = stitch['verdict'];
925
+ if (
926
+ verdict !== 'success' &&
927
+ verdict !== 'blocked' &&
928
+ verdict !== 'partial'
929
+ ) {
930
+ throw new SchemaError(
931
+ `state.stitch.verdict must be one of success/blocked/partial; ` +
932
+ `got ${pyRepr(verdict)}`,
933
+ );
934
+ }
935
+ }
936
+ if (
937
+ 'integration_confirmed' in stitch &&
938
+ typeof stitch['integration_confirmed'] !== 'boolean'
939
+ ) {
940
+ throw new SchemaError(
941
+ 'state.stitch.integration_confirmed must be a bool when present',
942
+ );
943
+ }
944
+ }
945
+
946
+ /**
947
+ * Reject malformed `halts` envelopes; tolerate `[]`.
948
+ *
949
+ * Each entry must be a dict with at minimum `reason` (str) and
950
+ * `surface` (list of str). `step` and `timestamp` are optional
951
+ * metadata appended by `emitters._emit_halt`. The schema enforces
952
+ * only shape — the explain renderer is responsible for fallback
953
+ * rendering when optional fields are absent.
954
+ */
955
+ function _validate_halts(halts: JsonValue): void {
956
+ if (!Array.isArray(halts)) {
957
+ throw new SchemaError(
958
+ `state.halts must be a list; got ${pyTypeName(halts)}`,
959
+ );
960
+ }
961
+ for (let idx = 0; idx < halts.length; idx++) {
962
+ const entry = halts[idx];
963
+ if (!_isDict(entry)) {
964
+ throw new SchemaError(
965
+ `state.halts[${idx}] must be a JSON object; ` +
966
+ `got ${pyTypeName(entry)}`,
967
+ );
968
+ }
969
+ const reason = 'reason' in entry ? entry['reason'] : undefined;
970
+ if (typeof reason !== 'string' || !reason) {
971
+ throw new SchemaError(
972
+ `state.halts[${idx}].reason must be a non-empty string`,
973
+ );
974
+ }
975
+ const surface = 'surface' in entry ? entry['surface'] : [];
976
+ if (!Array.isArray(surface)) {
977
+ throw new SchemaError(
978
+ `state.halts[${idx}].surface must be a list; ` +
979
+ `got ${pyTypeName(surface)}`,
980
+ );
981
+ }
982
+ for (let j = 0; j < surface.length; j++) {
983
+ if (typeof surface[j] !== 'string') {
984
+ throw new SchemaError(
985
+ `state.halts[${idx}].surface[${j}] must be a string`,
986
+ );
987
+ }
988
+ }
989
+ }
990
+ }
991
+
992
+ // ── Python-parity helpers ───────────────────────────────────────────────
993
+
994
+ /**
995
+ * True when `v` is a plain JSON object (Python `dict`). Arrays and `null`
996
+ * are excluded, mirroring `isinstance(x, dict)`.
997
+ */
998
+ function _isDict(v: JsonValue | undefined): v is Dict {
999
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
1000
+ }
1001
+
1002
+ /**
1003
+ * Mirror Python `isinstance(x, (int, float))` for the `mtime` check. JSON
1004
+ * numbers (int or float) are all JS `number`; booleans are NOT numbers in
1005
+ * Python's `(int, float)` test only because `bool` IS a subclass of `int`
1006
+ * — but `_validate_stack` does not exclude bools, so neither do we: a JSON
1007
+ * `true`/`false` never reaches `mtime` as a JS boolean is `typeof boolean`,
1008
+ * not `number`. Python's `isinstance(True, (int, float))` is True; to match
1009
+ * that edge we treat a JS boolean as a number here too.
1010
+ */
1011
+ function _isNumber(v: JsonValue): boolean {
1012
+ return typeof v === 'number' || typeof v === 'boolean';
1013
+ }
1014
+
1015
+ /**
1016
+ * Mirror Python `isinstance(x, int) and not isinstance(x, bool)` for the
1017
+ * `rounds` check. A JSON integer parses to a JS `number` with no fractional
1018
+ * part; a JSON float with a fractional part is rejected; a JSON bool is
1019
+ * rejected (Python excludes bool explicitly).
1020
+ */
1021
+ function _isPyInt(v: JsonValue | undefined): v is number {
1022
+ return typeof v === 'number' && Number.isInteger(v);
1023
+ }
1024
+
1025
+ /** Mirror Python `bool(x)` truthiness for the values `ui_polish` can hold. */
1026
+ function _pyBool(v: JsonValue | undefined): boolean {
1027
+ if (v === null || v === undefined) return false;
1028
+ if (typeof v === 'boolean') return v;
1029
+ if (typeof v === 'number') return v !== 0;
1030
+ if (typeof v === 'string') return v.length > 0;
1031
+ if (Array.isArray(v)) return v.length > 0;
1032
+ if (_isDict(v)) return Object.keys(v).length > 0;
1033
+ return true;
1034
+ }
1035
+
1036
+ /** Python `dict.get(key, default)` over a JSON object. */
1037
+ function _get(obj: Dict, key: string, dflt: JsonValue): JsonValue {
1038
+ return key in obj ? (obj[key] as JsonValue) : dflt;
1039
+ }
1040
+
1041
+ /** `list(x)` where x may be a list (copy) or anything else (Python would error,
1042
+ * but `from_dict` only feeds it the result of `payload.get(k, [])`, so it is a
1043
+ * list or the default `[]`). Returns the array itself for spreading. */
1044
+ function _listOrEmpty(v: JsonValue): JsonValue[] {
1045
+ return Array.isArray(v) ? v : [];
1046
+ }
1047
+
1048
+ /** `dict(x)` where x is the result of `payload.get(k, {})`. */
1049
+ function _dictOrEmpty(v: JsonValue): Dict {
1050
+ return _isDict(v) ? v : {};
1051
+ }
1052
+
1053
+ /**
1054
+ * Python `type(x).__name__` for the JSON value classes that appear in the
1055
+ * error messages: `dict`, `list`, `str`, `int`, `float`, `bool`, `NoneType`.
1056
+ */
1057
+ function pyTypeName(v: JsonValue | undefined): string {
1058
+ if (v === null || v === undefined) return 'NoneType';
1059
+ if (typeof v === 'boolean') return 'bool';
1060
+ if (typeof v === 'string') return 'str';
1061
+ if (typeof v === 'number') return Number.isInteger(v) ? 'int' : 'float';
1062
+ if (Array.isArray(v)) return 'list';
1063
+ return 'dict';
1064
+ }
1065
+
1066
+ /**
1067
+ * Python `repr(x)` for the scalar values that flow into error messages
1068
+ * (`version`, `kind`, `decision`, `floor`, `verdict`). Strings → single-quoted
1069
+ * with Python escaping; `None` → `None`; bools → `True`/`False`; numbers as-is.
1070
+ */
1071
+ function pyRepr(v: JsonValue | undefined): string {
1072
+ if (v === undefined || v === null) return 'None';
1073
+ if (typeof v === 'boolean') return v ? 'True' : 'False';
1074
+ if (typeof v === 'number') return _pyNumRepr(v);
1075
+ if (typeof v === 'string') return pyStrRepr(v);
1076
+ if (Array.isArray(v)) {
1077
+ return '[' + v.map((x) => pyRepr(x)).join(', ') + ']';
1078
+ }
1079
+ // dict repr is not exercised by the .py error paths; render Python-ish.
1080
+ const items = Object.keys(v).map((k) => `${pyStrRepr(k)}: ${pyRepr(v[k])}`);
1081
+ return '{' + items.join(', ') + '}';
1082
+ }
1083
+
1084
+ /** Python numeric repr — integer-valued floats are never produced here because
1085
+ * JSON-parsed numbers carry no float/int tag; an integer renders without `.0`. */
1086
+ function _pyNumRepr(n: number): string {
1087
+ return String(n);
1088
+ }
1089
+
1090
+ /**
1091
+ * Python `repr(str)` — prefers single quotes, switches to double quotes only
1092
+ * when the string contains a single quote but no double quote, and escapes
1093
+ * backslash plus the active quote and the standard control characters.
1094
+ */
1095
+ function pyStrRepr(s: string): string {
1096
+ const hasSingle = s.includes("'");
1097
+ const hasDouble = s.includes('"');
1098
+ const quote = hasSingle && !hasDouble ? '"' : "'";
1099
+ let out = quote;
1100
+ for (const ch of s) {
1101
+ const code = ch.codePointAt(0) as number;
1102
+ if (ch === '\\') out += '\\\\';
1103
+ else if (ch === quote) out += '\\' + quote;
1104
+ else if (ch === '\n') out += '\\n';
1105
+ else if (ch === '\r') out += '\\r';
1106
+ else if (ch === '\t') out += '\\t';
1107
+ else if (code < 0x20 || code === 0x7f) {
1108
+ out += `\\x${code.toString(16).padStart(2, '0')}`;
1109
+ } else {
1110
+ out += ch;
1111
+ }
1112
+ }
1113
+ return out + quote;
1114
+ }
1115
+
1116
+ /** Python `repr(list_of_str)` for the `sorted(KNOWN_*)` error tails. */
1117
+ function pyListRepr(items: string[]): string {
1118
+ return '[' + items.map((s) => pyStrRepr(s)).join(', ') + ']';
1119
+ }
1120
+
1121
+ /** Python `repr(bool)` — `True` / `False`. */
1122
+ function pyBoolRepr(b: boolean): string {
1123
+ return b ? 'True' : 'False';
1124
+ }
1125
+
1126
+ /**
1127
+ * Python `sorted(set_of_str)` — code-point ascending, the default `str`
1128
+ * ordering CPython uses. `Array.prototype.sort()` with no comparator sorts
1129
+ * by UTF-16 code unit, which matches for the BMP ASCII tokens in these sets;
1130
+ * use an explicit code-point comparator to be faithful for any input.
1131
+ */
1132
+ function pySorted(values: ReadonlySet<string>): string[] {
1133
+ return [...values].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
1134
+ }
1135
+
1136
+ /**
1137
+ * Mirror Python `json.dumps(obj, indent=2, ensure_ascii=False)`.
1138
+ *
1139
+ * For round-tripped JSON (the only producer of a state dict), `JSON.stringify`
1140
+ * with a 2-space indent matches CPython byte-for-byte: 2-space indent,
1141
+ * `": "` key separator, no trailing space after the array/object item commas,
1142
+ * `{}` / `[]` for empties, non-ASCII left verbatim. The one structural
1143
+ * difference CPython has — integer-valued floats rendered as `N.0` — cannot
1144
+ * arise here: JSON has no float/int tag, so a parsed `1.0` is already the JS
1145
+ * number `1` before it ever reaches this serialiser, exactly as a Python
1146
+ * reader that parsed the same bytes would diverge. `to_dict` never injects a
1147
+ * float that did not come from the parsed payload.
1148
+ */
1149
+ function jsonDumps(obj: Dict): string {
1150
+ return JSON.stringify(obj, null, 2);
1151
+ }