@xemahq/dsl 0.5.5 → 0.5.7

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 (77) hide show
  1. package/LICENSE +176 -0
  2. package/dist/deliverable-spec/lib/schema.d.ts.map +1 -1
  3. package/dist/deliverable-spec/lib/schema.js.map +1 -1
  4. package/dist/payload-codec/index.d.ts +1 -0
  5. package/dist/payload-codec/index.d.ts.map +1 -1
  6. package/dist/payload-codec/index.js.map +1 -1
  7. package/dist/payload-codec/lib/codec.d.ts +1 -1
  8. package/dist/payload-codec/lib/codec.d.ts.map +1 -1
  9. package/dist/payload-codec/lib/codec.js +12 -1
  10. package/dist/payload-codec/lib/codec.js.map +1 -1
  11. package/dist/payload-codec/lib/payload.d.ts +9 -0
  12. package/dist/payload-codec/lib/payload.d.ts.map +1 -0
  13. package/dist/payload-codec/lib/payload.js +3 -0
  14. package/dist/payload-codec/lib/payload.js.map +1 -0
  15. package/dist/payload-codec/temporal/index.d.ts +5 -0
  16. package/dist/payload-codec/temporal/index.d.ts.map +1 -0
  17. package/dist/payload-codec/temporal/index.js +7 -0
  18. package/dist/payload-codec/temporal/index.js.map +1 -0
  19. package/dist/schema/workflow.schema.json +29 -2
  20. package/dist/workflow/index.d.ts +2 -2
  21. package/dist/workflow/index.d.ts.map +1 -1
  22. package/dist/workflow/index.js +3 -1
  23. package/dist/workflow/index.js.map +1 -1
  24. package/dist/workflow/lib/compiler/canonical-json.js +15 -4
  25. package/dist/workflow/lib/compiler/canonical-json.js.map +1 -1
  26. package/dist/workflow/lib/compiler/compile.d.ts.map +1 -1
  27. package/dist/workflow/lib/compiler/compile.js +3 -1
  28. package/dist/workflow/lib/compiler/compile.js.map +1 -1
  29. package/dist/workflow/lib/compiler/gate-defaults.d.ts +5 -0
  30. package/dist/workflow/lib/compiler/gate-defaults.d.ts.map +1 -0
  31. package/dist/workflow/lib/compiler/gate-defaults.js +76 -0
  32. package/dist/workflow/lib/compiler/gate-defaults.js.map +1 -0
  33. package/dist/workflow/lib/compiler/index.d.ts +1 -0
  34. package/dist/workflow/lib/compiler/index.d.ts.map +1 -1
  35. package/dist/workflow/lib/compiler/index.js +4 -1
  36. package/dist/workflow/lib/compiler/index.js.map +1 -1
  37. package/dist/workflow/lib/duration.d.ts.map +1 -1
  38. package/dist/workflow/lib/duration.js +3 -9
  39. package/dist/workflow/lib/duration.js.map +1 -1
  40. package/dist/workflow/lib/types.d.ts +9 -0
  41. package/dist/workflow/lib/types.d.ts.map +1 -1
  42. package/dist/workspace-manifest/lib/compile.js.map +1 -1
  43. package/dist/workspace-manifest/lib/interpolate.js +26 -4
  44. package/dist/workspace-manifest/lib/interpolate.js.map +1 -1
  45. package/dist/workspace-manifest/lib/schema.d.ts +4 -0
  46. package/dist/workspace-manifest/lib/schema.d.ts.map +1 -1
  47. package/dist/workspace-manifest/lib/schema.js +8 -0
  48. package/dist/workspace-manifest/lib/schema.js.map +1 -1
  49. package/dist/workspace-manifest/lib/types.d.ts +1 -0
  50. package/dist/workspace-manifest/lib/types.d.ts.map +1 -1
  51. package/package.json +43 -20
  52. package/schema/workflow.schema.json +29 -2
  53. package/src/deliverable-spec/index.ts +1 -1
  54. package/src/deliverable-spec/lib/schema.ts +1 -2
  55. package/src/payload-codec/index.ts +8 -4
  56. package/src/payload-codec/lib/codec.ts +14 -2
  57. package/src/payload-codec/lib/payload.ts +26 -0
  58. package/src/payload-codec/temporal/index.ts +36 -0
  59. package/src/workflow/index.ts +3 -0
  60. package/src/workflow/lib/compiler/canonical-json.ts +17 -4
  61. package/src/workflow/lib/compiler/compile.ts +13 -6
  62. package/src/workflow/lib/compiler/gate-defaults.ts +153 -0
  63. package/src/workflow/lib/compiler/index.ts +1 -0
  64. package/src/workflow/lib/compiler/payload-reach-in.ts +6 -6
  65. package/src/workflow/lib/compiler/review-step.ts +3 -3
  66. package/src/workflow/lib/duration.ts +5 -10
  67. package/src/workflow/lib/expression/context.ts +2 -2
  68. package/src/workflow/lib/expression/evaluator.ts +2 -2
  69. package/src/workflow/lib/expression/walk-artifact-refs.ts +2 -2
  70. package/src/workflow/lib/serializer.ts +1 -1
  71. package/src/workflow/lib/types.ts +27 -1
  72. package/src/workflow/lib/validate.ts +2 -2
  73. package/src/workspace-manifest/lib/compile.ts +1 -2
  74. package/src/workspace-manifest/lib/interpolate.ts +29 -3
  75. package/src/workspace-manifest/lib/resolve-extends.ts +3 -3
  76. package/src/workspace-manifest/lib/schema.ts +13 -2
  77. package/src/workspace-manifest/lib/types.ts +10 -3
@@ -0,0 +1,153 @@
1
+ // ═══════════════════════════════════════════════════════════════════════════
2
+ // ── Workflow-level decision-gate defaults (`defaults.gate`) ──
3
+ //
4
+ // Gate-heavy workflows (e.g. the software-dev product-development pipeline)
5
+ // repeat the same decision-gate configuration on every gate job:
6
+ // timeoutSeconds, onTimeout, autonomyMode, recipients, policy. `defaults.gate`
7
+ // lets the author declare that configuration ONCE at the workflow level; the
8
+ // compiler folds it into each gate job's `with:` before any other compile
9
+ // pass runs, so downstream validation, expression compilation, and the
10
+ // CompiledRun snapshot all observe the merged document — expressions inside
11
+ // defaults compile per-job exactly as if written inline.
12
+ //
13
+ // Merge contract (deterministic, no silent deep-merging):
14
+ // • Applies ONLY to jobs whose `uses:` resolves to the `xema/decision-gate`
15
+ // action id — matched on the resolved ref's id, regardless of version.
16
+ // • Fills ABSENT `with:` keys only. Explicit per-gate values always win.
17
+ // • Fill is per top-level key: a gate that declares its own `policy` or
18
+ // `recipients` keeps that value verbatim — there is no partial merge
19
+ // inside a key's object/array value.
20
+ // • Fail-fast validation: every `defaults.gate` key must be declared in
21
+ // the resolved gate manifest's `inputs.properties` (guards drift between
22
+ // the DSL schema's closed key set and the pinned gate action version).
23
+ // ═══════════════════════════════════════════════════════════════════════════
24
+
25
+ import { WorkflowErrorCode } from '@xemahq/kernel-contracts/workflow';
26
+ import { WorkflowDslError } from '../errors';
27
+ import { compileExpression, extractInterpolations } from '../expression';
28
+ import type { WorkflowDocument, WorkflowGateDefaults } from '../types';
29
+ import type { ResolvedRef } from './types';
30
+
31
+ /**
32
+ * Canonical action id of the decision-gate primitive. `defaults.gate`
33
+ * targets jobs by this id — never by the raw `uses:` string — so any
34
+ * pinned version (`xema/decision-gate@1.2.0`, `@2.x`, …) participates.
35
+ */
36
+ export const DECISION_GATE_ACTION_ID = 'xema/decision-gate';
37
+
38
+ /**
39
+ * Fold `defaults.gate` into every decision-gate job's `with:` block and
40
+ * return the (possibly rewritten) document. Pure: never mutates the input.
41
+ *
42
+ * Runs BEFORE all other compile passes so the merged `with:` flows through
43
+ * expression validation, needs-shape validation, review rewriting, the
44
+ * action input-schema pin, and the snapshot hash exactly like authored
45
+ * values.
46
+ *
47
+ * Jobs whose `uses:` is missing from `resolvedRefs` are skipped here — the
48
+ * per-job compile loop already fails them with `DSL_UNKNOWN_ACTION`, and
49
+ * pre-empting that error would reorder author-facing diagnostics.
50
+ */
51
+ export function applyGateDefaults(
52
+ workflow: WorkflowDocument,
53
+ resolvedRefs: Readonly<Record<string, ResolvedRef>>,
54
+ ): WorkflowDocument {
55
+ const gate = workflow.defaults?.gate;
56
+ if (gate === undefined) return workflow;
57
+ const gateEntries = Object.entries(gate).filter(
58
+ ([, value]) => value !== undefined,
59
+ );
60
+ if (gateEntries.length === 0) return workflow;
61
+
62
+ // Validate every `${{ … }}` expression inside the defaults block up
63
+ // front — even when no gate job consumes it. A malformed expression in
64
+ // `defaults.gate` is an authoring error regardless of merge fan-out;
65
+ // deferring it until a gate job appears would be a silent latency.
66
+ for (const ext of extractInterpolations(
67
+ gate as Readonly<Record<string, unknown>>,
68
+ ['defaults', 'gate'],
69
+ )) {
70
+ compileExpression(ext.source);
71
+ }
72
+
73
+ let anyChanged = false;
74
+ const rewrittenJobs: Record<string, WorkflowDocument['jobs'][string]> = {};
75
+ for (const [jobKey, job] of Object.entries(workflow.jobs)) {
76
+ const resolved = resolvedRefs[job.uses];
77
+ if (
78
+ resolved === undefined ||
79
+ resolved.isReusableWorkflow ||
80
+ resolved.id !== DECISION_GATE_ACTION_ID
81
+ ) {
82
+ rewrittenJobs[jobKey] = job;
83
+ continue;
84
+ }
85
+
86
+ assertGateDefaultKeysDeclared(jobKey, gate, resolved);
87
+
88
+ const authored = job.with ?? {};
89
+ let jobChanged = false;
90
+ const merged: Record<string, unknown> = { ...authored };
91
+ for (const [key, value] of gateEntries) {
92
+ if (Object.hasOwn(authored, key)) continue;
93
+ merged[key] = value;
94
+ jobChanged = true;
95
+ }
96
+ if (!jobChanged) {
97
+ rewrittenJobs[jobKey] = job;
98
+ continue;
99
+ }
100
+ anyChanged = true;
101
+ rewrittenJobs[jobKey] = { ...job, with: merged };
102
+ }
103
+
104
+ if (!anyChanged) return workflow;
105
+ return { ...workflow, jobs: rewrittenJobs };
106
+ }
107
+
108
+ /**
109
+ * Fail-fast subset check: every `defaults.gate` key must be a declared
110
+ * input of the resolved gate action version. The JSON schema already
111
+ * closes the key set to the canonical decision-gate input names; this
112
+ * check guards manifest drift (e.g. a future gate version renaming or
113
+ * dropping an input while a workflow still defaults it).
114
+ *
115
+ * Skipped only when the manifest declares no introspectable
116
+ * `inputs.properties` map — nothing to prove against; the worker's
117
+ * pinned input-schema validation still rejects unknown keys at dispatch.
118
+ */
119
+ function assertGateDefaultKeysDeclared(
120
+ jobKey: string,
121
+ gate: WorkflowGateDefaults,
122
+ resolved: ResolvedRef,
123
+ ): void {
124
+ const inputs = resolved.actionManifest?.spec.inputs;
125
+ const properties =
126
+ inputs && typeof inputs === 'object'
127
+ ? (inputs as { properties?: unknown }).properties
128
+ : undefined;
129
+ if (
130
+ properties === null ||
131
+ properties === undefined ||
132
+ typeof properties !== 'object' ||
133
+ Array.isArray(properties)
134
+ ) {
135
+ return;
136
+ }
137
+ const declared = properties as Readonly<Record<string, unknown>>;
138
+ for (const [key, value] of Object.entries(gate)) {
139
+ if (value === undefined) continue;
140
+ if (Object.hasOwn(declared, key)) continue;
141
+ throw new WorkflowDslError(
142
+ WorkflowErrorCode.DSL_SEMANTIC_INVALID,
143
+ `defaults.gate.${key} is not a declared input of ${resolved.id}@${resolved.version} (consumed by job '${jobKey}'). Declared inputs: [${Object.keys(declared).sort((a, b) => a.localeCompare(b)).join(', ')}].`,
144
+ {
145
+ jobKey,
146
+ key,
147
+ actionId: resolved.id,
148
+ actionVersion: resolved.version,
149
+ declaredInputs: Object.keys(declared),
150
+ },
151
+ );
152
+ }
153
+ }
@@ -1,4 +1,5 @@
1
1
  export { compileWorkflow } from './compile';
2
+ export { DECISION_GATE_ACTION_ID, applyGateDefaults } from './gate-defaults';
2
3
  export { canonicalJsonSha256, canonicalJsonStringify } from './canonical-json';
3
4
  export { compileManifestSource } from './manifest-source';
4
5
  export { getWorkspaceManifestContract, isAgentShapedAction } from './action-shape';
@@ -1,5 +1,5 @@
1
1
  // ═══════════════════════════════════════════════════════════════════════════
2
- // ── Payload reach-in validation (plan §C.1) ──
2
+ // ── Payload reach-in validation ──
3
3
  //
4
4
  // Workflow YAML supports two flavours of `needs.<X>.outputs.<name>.<field>`:
5
5
  //
@@ -74,7 +74,7 @@ function lookupOutputBindingSpecRef(
74
74
  * Decision table for each `<name>.<field>`:
75
75
  * • `<field>` is an ArtifactRef envelope field → OK (no payload fetch).
76
76
  * • Upstream activity manifest declares `outputBindings.<name>.deliverableSpecRef`
77
- * (plan §B per-output binding) → use that spec; otherwise fall back
77
+ * (per-output binding) → use that spec; otherwise fall back
78
78
  * to the upstream job's `with.deliverableSpecRef`. Then:
79
79
  * - resolved spec has `topLevelKeys` and `<field>` is one → OK.
80
80
  * - resolved spec has `topLevelKeys` and `<field>` is NOT one →
@@ -136,7 +136,7 @@ export function validatePayloadReachIns(
136
136
  /**
137
137
  * Walk every reach-in for a single job and emit one pin per
138
138
  * `(upstreamJobKey, specRef)` pair. Pins drive the runtime spec-version
139
- * assertion (plan §C.1 risks-and-mitigations) — the worker pre-fetches
139
+ * assertion — the worker pre-fetches
140
140
  * the artifact and refuses to proceed when its `schemaVersion` doesn't
141
141
  * match the pin's `specVersion`.
142
142
  *
@@ -203,8 +203,8 @@ function resolveReachInPin(
203
203
  return null;
204
204
  }
205
205
 
206
- // Prefer per-output binding from the upstream activity manifest
207
- // (plan §B). Falls back to the job-level `with.deliverableSpecRef`
206
+ // Prefer per-output binding from the upstream activity manifest.
207
+ // Falls back to the job-level `with.deliverableSpecRef`
208
208
  // when no per-output binding is declared OR when the reach-in's
209
209
  // outputName is one of the indexed sentinels.
210
210
  let specRef: string | null = null;
@@ -431,7 +431,7 @@ function validateReachIn(
431
431
  return;
432
432
  }
433
433
 
434
- // Per-output binding from the upstream activity manifest (plan §B)
434
+ // Per-output binding from the upstream activity manifest
435
435
  // wins over the job-level `with.deliverableSpecRef` when present.
436
436
  const perOutputBinding =
437
437
  access.outputName === '<indexed>' || access.outputName === '<byKey>'
@@ -75,15 +75,15 @@ interface ReviewWithShape {
75
75
  /**
76
76
  * Per-job redraft-strategy descriptor carried on the IR `redraft` block
77
77
  * when the producer step has a matrix strategy. Mirrors the worker
78
- * consumer in `biomes/workflow-runtime/api/workflow-runtime-worker/src/workflows/review.workflow.ts`
79
- * — kept JSON-serializable so the IR survives Temporal payload encoding.
78
+ * consumer that runs the review workflow — kept JSON-serializable so the
79
+ * IR survives Temporal payload encoding.
80
80
  *
81
81
  * `static` entries are already cartesian-expanded at compile time;
82
82
  * `dynamic.fromExpression` is the producer's expression body (already
83
83
  * `${{ … }}`-stripped) which the parent dispatcher evaluates against
84
84
  * the review step's expression context before spawning the child
85
85
  * workflow. `keyBy` is informational only — per-CU keyed targeting is
86
- * a Phase-2 feature; Phase 1 always re-runs every matrix entry.
86
+ * a planned future feature; today every matrix entry is always re-run.
87
87
  */
88
88
  export type ReviewRedraftStrategyIR =
89
89
  | {
@@ -7,7 +7,8 @@ import { WorkflowDslError } from './errors';
7
7
  * no silent zero fallback — callers must pass a value that matches the
8
8
  * schema pattern (already enforced by Ajv upstream).
9
9
  *
10
- * Cap: hours limited to 8760 (= 365 days). The cap is a sanity guard
10
+ * Cap: total duration limited to 8760h (= 365 days), regardless of the
11
+ * unit used. The cap is a sanity guard
11
12
  * against typos (`100000h` ≈ 11 years), not a policy bound — real
12
13
  * human-driven flows (compliance reviews, multi-stakeholder approvals,
13
14
  * SCM PRs that go a quarter without merging) routinely take months.
@@ -30,19 +31,13 @@ export function parseDurationMs(literal: string): number {
30
31
  }
31
32
  const value = Number.parseInt(match[1]!, 10);
32
33
  const unit = match[2] as 's' | 'm' | 'h';
33
- if (unit === 'h' && value > MAX_HOURS) {
34
+ const ms = unit === 's' ? value * 1_000 : unit === 'm' ? value * 60_000 : value * 3_600_000;
35
+ if (ms > MAX_HOURS * 3_600_000) {
34
36
  throw new WorkflowDslError(
35
37
  WorkflowErrorCode.DSL_SCHEMA_INVALID,
36
38
  `Duration exceeds cap of ${MAX_HOURS}h: ${literal}`,
37
39
  { literal, maxHours: MAX_HOURS },
38
40
  );
39
41
  }
40
- switch (unit) {
41
- case 's':
42
- return value * 1_000;
43
- case 'm':
44
- return value * 60_000;
45
- case 'h':
46
- return value * 3_600_000;
47
- }
42
+ return ms;
48
43
  }
@@ -3,8 +3,8 @@ import type { TriggerPayload } from '@xemahq/kernel-contracts/workflow';
3
3
  /**
4
4
  * Per-run, read-only cache of pre-fetched artifact payloads keyed by
5
5
  * `versionId`. Populated by the workflow runtime BEFORE each step's
6
- * expressions are evaluated see `biomes/workflow-runtime/api/workflow-runtime-worker` for the
7
- * fetch + cache plumbing. The evaluator only ever reads from this map;
6
+ * expressions are evaluated, which owns the fetch + cache plumbing.
7
+ * The evaluator only ever reads from this map;
8
8
  * concurrent reads are safe by construction (no writes once frozen for a
9
9
  * step). Empty when no expression in scope reaches into a typed
10
10
  * payload or invokes `fromJSON(ref)`.
@@ -172,8 +172,8 @@ function evalCall(node: CallNode, ctx: ExpressionContext): unknown {
172
172
  * evaluator runs. A cache miss is a runtime infra bug, not an
173
173
  * author bug, so it carries a runtime-coded error.
174
174
  *
175
- * This is the §C.2 escape hatch. Authors who want compile-time field
176
- * validation use the typed reach-in (`outputs.foo.field`, §C.1).
175
+ * This is the untyped escape hatch. Authors who want compile-time field
176
+ * validation use the typed reach-in (`outputs.foo.field`).
177
177
  */
178
178
  function evalFromJson(node: CallNode, ctx: ExpressionContext): unknown {
179
179
  const argNode = singleArg(node, 'fromJSON');
@@ -1,6 +1,6 @@
1
1
  // ═══════════════════════════════════════════════════════════════════════════
2
2
  // ── Runtime walker — find every AST sub-expression that resolves to an
3
- // ArtifactRef the runtime must pre-fetch (plan §C.1 / §C.2). ──
3
+ // ArtifactRef the runtime must pre-fetch. ──
4
4
  //
5
5
  // Two flavours of pre-fetch target:
6
6
  //
@@ -202,7 +202,7 @@ export function extractUpstreamJobKey(node: ExpressionNode): string | null {
202
202
  * known there.
203
203
  *
204
204
  * Pair with `extractUpstreamJobKey` to resolve per-output spec pins at
205
- * runtime (plan §B). Falling back to `null` is the cue the prefetch
205
+ * runtime. Falling back to `null` is the cue the prefetch
206
206
  * helper uses to consult the job-level pin instead.
207
207
  */
208
208
  export function extractUpstreamOutputName(
@@ -8,7 +8,7 @@ import type { WorkflowDocument } from './types';
8
8
  * Note on comment preservation: this serializer does NOT carry over
9
9
  * source comments from the original YAML — it dumps the AST verbatim.
10
10
  * Use it when the canonical representation lives in the AST (e.g. the
11
- * canvas editor in PR 5.x) and the YAML view is a derived presentation.
11
+ * canvas editor) and the YAML view is a derived presentation.
12
12
  * If you need to apply a single mutation to existing hand-authored YAML
13
13
  * while keeping comments + key ordering intact, use the `yaml`
14
14
  * package's CST/Document API directly (follow-up; not in this PR).
@@ -171,6 +171,32 @@ export interface WorkflowConcurrencyDeclaration {
171
171
  export interface WorkflowDefaults {
172
172
  readonly retry?: WorkflowRetryDeclaration;
173
173
  readonly timeout?: string;
174
+ readonly gate?: WorkflowGateDefaults;
175
+ }
176
+
177
+ /**
178
+ * Workflow-level defaults for decision-gate jobs (`defaults.gate`).
179
+ *
180
+ * For every job whose `uses:` resolves to the `xema/decision-gate` action
181
+ * (matched on the action id, regardless of version), the compiler fills
182
+ * each ABSENT `with:` key from this block before any other compile pass
183
+ * runs. Explicit per-gate values always win, and the fill happens per
184
+ * top-level key only — a gate that declares its own `policy` /
185
+ * `recipients` keeps that value verbatim (no partial merge inside it).
186
+ *
187
+ * Every value may be a `${{ … }}` expression; merged values compile and
188
+ * evaluate per-job exactly as if they had been written inline. The key
189
+ * set is closed by the JSON schema AND cross-validated at compile time
190
+ * against the resolved gate manifest's declared `inputs` (fail-fast on
191
+ * drift between the schema's key set and the pinned gate version).
192
+ */
193
+ export interface WorkflowGateDefaults {
194
+ readonly timeoutSeconds?: unknown;
195
+ readonly onTimeout?: unknown;
196
+ readonly autonomyMode?: unknown;
197
+ readonly recipients?: unknown;
198
+ readonly policy?: unknown;
199
+ readonly escalationChain?: unknown;
174
200
  }
175
201
 
176
202
  export interface WorkflowRetryDeclaration {
@@ -263,7 +289,7 @@ export interface ActionManifestSpec {
263
289
  readonly inputs: Readonly<Record<string, unknown>>;
264
290
  readonly outputs: Readonly<Record<string, unknown>>;
265
291
  /**
266
- * Per-output deliverable-spec bindings (plan §B per-output bindings).
292
+ * Per-output deliverable-spec bindings.
267
293
  * Each entry pins a named output to a deliverable-spec ref like
268
294
  * `agent-summary@1.0.0`. The DSL compiler consults this first when
269
295
  * resolving typed payload reach-in (`outputs.<name>.<field>`); it
@@ -137,7 +137,7 @@ function parseAndValidate<T>(
137
137
  { kind },
138
138
  );
139
139
  }
140
- // Plan §G shorthand: `needs: <string>` → `[<string>]`. Normalize
140
+ // Shorthand: `needs: <string>` → `[<string>]`. Normalize
141
141
  // BEFORE validation so the schema stays strict (one canonical shape)
142
142
  // and the rest of the compiler never branches on shorthand. Workflow +
143
143
  // reusable-workflow documents both have `jobs: { <key>: { needs?... } }`;
@@ -151,7 +151,7 @@ function parseAndValidate<T>(
151
151
  /**
152
152
  * Mutates `doc.jobs.<key>.needs` from `<string>` to `[<string>]` in
153
153
  * place. Authors can write either form; the canonical form is the
154
- * array. Documented in plan §G under "Simplified DSL".
154
+ * array. Documented under "Simplified DSL".
155
155
  */
156
156
  function normalizeJobNeedsShorthand(doc: object): void {
157
157
  const jobs = (doc as { jobs?: unknown }).jobs;
@@ -580,8 +580,7 @@ function compileWorkingFiles(
580
580
  }
581
581
  // `binaryFormat` is the closed-enum hint that signals a companion
582
582
  // binary (XLSX/PPTX/DOCX/PDF) lives alongside the synopsis. Validate now so
583
- // a typo cannot reach the worker as a silent no-op — see
584
- // `.claude/plans/xlsx-pptx-binary-kb-attachment.md` D6.
583
+ // a typo cannot reach the worker as a silent no-op.
585
584
  const binaryFormatRaw = wf.sourceRef['binaryFormat'];
586
585
  if (
587
586
  typeof binaryFormatRaw === 'string' &&
@@ -42,7 +42,7 @@ export function interpolate(
42
42
  basePath = '$',
43
43
  ): { readonly resolved: unknown; readonly issues: readonly InterpolationIssue[] } {
44
44
  const issues: InterpolationIssue[] = [];
45
- const resolved = walk(value, ctx, basePath, issues);
45
+ const resolved = walk(value, ctx, basePath, issues, new Set());
46
46
  return { resolved, issues };
47
47
  }
48
48
 
@@ -51,16 +51,42 @@ function walk(
51
51
  ctx: InterpolationContext,
52
52
  path: string,
53
53
  issues: InterpolationIssue[],
54
+ seen: Set<object>,
54
55
  ): unknown {
55
56
  if (typeof value === 'string') return interpolateString(value, ctx, path, issues);
56
57
  if (Array.isArray(value)) {
57
- return value.map((item, idx) => walk(item, ctx, `${path}[${idx}]`, issues));
58
+ // Reject cyclic structures (e.g. self-referential YAML anchors) with a
59
+ // clear error rather than recursing until the stack overflows.
60
+ if (seen.has(value)) {
61
+ throw new Error(`interpolate: circular reference at ${path}`);
62
+ }
63
+ seen.add(value);
64
+ const out = value.map((item, idx) => walk(item, ctx, `${path}[${idx}]`, issues, seen));
65
+ seen.delete(value);
66
+ return out;
58
67
  }
59
68
  if (value !== null && typeof value === 'object') {
69
+ if (seen.has(value)) {
70
+ throw new Error(`interpolate: circular reference at ${path}`);
71
+ }
72
+ seen.add(value);
60
73
  const out: Record<string, unknown> = {};
61
74
  for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
62
- out[key] = walk(child, ctx, `${path}.${key}`, issues);
75
+ const resolvedChild = walk(child, ctx, `${path}.${key}`, issues, seen);
76
+ // Assign `__proto__` as an own data property instead of invoking the
77
+ // prototype setter, which would silently drop the key and reparent `out`.
78
+ if (key === '__proto__') {
79
+ Object.defineProperty(out, key, {
80
+ value: resolvedChild,
81
+ writable: true,
82
+ enumerable: true,
83
+ configurable: true,
84
+ });
85
+ } else {
86
+ out[key] = resolvedChild;
87
+ }
63
88
  }
89
+ seen.delete(value);
64
90
  return out;
65
91
  }
66
92
  return value;
@@ -1,9 +1,9 @@
1
1
  // ═══════════════════════════════════════════════════════════════════════════
2
2
  // ── WorkspaceManifest `extends:` resolver ──
3
3
  //
4
- // Section 4.4 of the engineering plan. Provides a deterministic merge
5
- // primitive plus a thin orchestrator the composer / preview endpoint /
6
- // llm-registry calls to materialize the effective manifest.
4
+ // Provides a deterministic merge primitive plus a thin orchestrator the
5
+ // composer / preview endpoint / llm-registry calls to materialize the
6
+ // effective manifest.
7
7
  //
8
8
  // Design constraints:
9
9
  // • Single-level extends only. The base MUST NOT carry its own
@@ -31,7 +31,7 @@ const SlugRegex = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
31
31
  const VersionRegex = /^\d+\.\d+\.\d+$/;
32
32
 
33
33
  /**
34
- * Upper bound on authored node-level `instructions` (WS5 Phase B: author a
34
+ * Upper bound on authored node-level `instructions` (author a
35
35
  * migrated agent as `base agent slug + instructions`). A prompt fragment
36
36
  * layered onto the referenced agent definition's intrinsic system prompt —
37
37
  * the same lever as `AgentNode.instructions`. The cap is a sanity
@@ -330,6 +330,17 @@ export const ManifestSlotPersistenceOverrideSchema = z
330
330
  ),
331
331
  branchSuffix: GitPushBranchSuffixEnumSchema.optional(),
332
332
  concurrencyMode: GitPushConcurrencyModeEnumSchema.optional(),
333
+ mergeTarget: z
334
+ .string()
335
+ .min(1)
336
+ .regex(
337
+ /^[a-zA-Z0-9][a-zA-Z0-9._\-/]*$/,
338
+ 'mergeTarget must be a git-safe branch name ([A-Za-z0-9._-/], no leading slash)',
339
+ )
340
+ .refine((v) => !v.includes('..'), {
341
+ message: 'mergeTarget must not contain `..` segments',
342
+ })
343
+ .optional(),
333
344
  })
334
345
  .strict();
335
346
 
@@ -641,7 +652,7 @@ export const WorkspaceManifestMetadataSchema = z.object({
641
652
  * Optional in the YAML for additive rollout; the compiler defaults
642
653
  * absent values to `[workflow, agent-session]` so existing
643
654
  * manifests keep compiling. New biome/org manifests SHOULD declare
644
- * this explicitly — Phase 9 sweeps the shipped manifests.
655
+ * this explicitly — a later step sweeps the shipped manifests.
645
656
  */
646
657
  surfaceCompat: z
647
658
  .array(ManifestSurfaceEnumSchema)
@@ -210,6 +210,13 @@ export interface ManifestSlotPersistenceOverride {
210
210
  readonly branchPrefix: string;
211
211
  readonly branchSuffix?: import('@xemahq/kernel-contracts/workflow').ManifestGitPushBranchSuffix;
212
212
  readonly concurrencyMode?: import('@xemahq/kernel-contracts/workflow').ManifestGitPushConcurrencyMode;
213
+ /**
214
+ * Fully-qualified long-lived branch the per-session branch auto-merges
215
+ * into on pause (per-session-branch + auto-merge model). Mirrors
216
+ * `SlotPersistencePolicy.git-push.mergeTarget` in
217
+ * `@xemahq/kernel-contracts/agent-workspace`.
218
+ */
219
+ readonly mergeTarget?: string;
213
220
  }
214
221
 
215
222
  export interface ManifestPersistence {
@@ -312,7 +319,7 @@ export interface ManifestAgentBlock {
312
319
  */
313
320
  readonly defaultModel?: ManifestModelRef;
314
321
  /**
315
- * Optional node-level instructions (WS5 Phase B authoring path). A prompt
322
+ * Optional node-level instructions. A prompt
316
323
  * fragment layered ONTO the referenced base agent's intrinsic system
317
324
  * prompt — the same lever as `AgentNode.instructions`, exposed here
318
325
  * so a WorkspaceManifest can author a migrated agent as
@@ -329,7 +336,7 @@ export interface ManifestAgentBlock {
329
336
  */
330
337
  readonly promptMode?: PromptMergeMode;
331
338
  /**
332
- * Optional per-agent permission override (WS5 kernel extension). A partial
339
+ * Optional per-agent permission override. A partial
333
340
  * OpenCode-style `PermissionMap` deep-merged (per top-level key) over the
334
341
  * referenced base agent's authored permission before the system tiers run.
335
342
  * Lets a generic base agent be specialized into a bounded role (read-only
@@ -365,7 +372,7 @@ export interface ManifestSubAgent {
365
372
  readonly alias?: string;
366
373
  readonly defaultModel?: ManifestModelRef;
367
374
  /**
368
- * Optional node-level instructions for this delegate (WS5 Phase B). Same
375
+ * Optional node-level instructions for this delegate. Same
369
376
  * `base agent slug + instructions` lever as the primary agent block;
370
377
  * layered onto the referenced agent's intrinsic system prompt. May
371
378
  * reference `${input.x}`. Bounded by `MAX_AGENT_INSTRUCTIONS_LENGTH`.