@deksden-com/dd-flow-cli 0.4.0 → 0.4.2

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 (43) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +25 -9
  3. package/dist/build-info.json +5 -5
  4. package/dist/cli/help.js +45 -29
  5. package/dist/cli/run-cli.js +229 -49
  6. package/dist/domain/entity-ids.js +4 -4
  7. package/dist/domain/flow-contract.js +502 -36
  8. package/dist/domain/validation.js +34 -0
  9. package/dist/protocol/local-files.js +8 -6
  10. package/dist/schemas/code-stage-report.schema.json +197 -2
  11. package/dist/schemas/flow-contract.schema.json +126 -0
  12. package/dist/schemas/flow-run-index-v3.schema.json +203 -0
  13. package/dist/schemas/flow-run-index.schema.json +22 -2
  14. package/dist/schemas/flow-run.schema.json +36 -0
  15. package/dist/schemas/merge-stage-report.schema.json +213 -2
  16. package/dist/schemas/plan-stage-report.schema.json +156 -2
  17. package/dist/schemas/release-impact.schema.json +16 -0
  18. package/dist/services/audit.js +3 -3
  19. package/dist/services/branch-context.js +17 -5
  20. package/dist/services/canon.js +0 -1
  21. package/dist/services/cleanup.js +6 -6
  22. package/dist/services/cli-operation-classifier.js +1 -1
  23. package/dist/services/compatibility-preflight.js +3 -75
  24. package/dist/services/dashboard.js +48 -11
  25. package/dist/services/engines.js +124 -13
  26. package/dist/services/hooks.js +6 -6
  27. package/dist/services/ids.js +40 -49
  28. package/dist/services/merge-queue.js +33 -26
  29. package/dist/services/merge-worker.js +2 -1
  30. package/dist/services/migrations.js +64 -0
  31. package/dist/services/plans.js +23 -16
  32. package/dist/services/projects.js +2 -2
  33. package/dist/services/prompts.js +322 -0
  34. package/dist/services/protocols.js +77 -46
  35. package/dist/services/run-projection.js +80 -0
  36. package/dist/services/runs.js +360 -22
  37. package/dist/services/schema-validation.js +35 -12
  38. package/dist/services/sessions.js +81 -3
  39. package/dist/services/status.js +32 -1
  40. package/dist/services/usage.js +233 -0
  41. package/dist/services/worktrees.js +24 -19
  42. package/dist/storage/database.js +223 -9
  43. package/package.json +1 -1
@@ -1,11 +1,16 @@
1
+ import crypto from "node:crypto";
1
2
  import fs from "node:fs";
2
3
  import path from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { Ajv } from "ajv/dist/ajv.js";
3
6
  import { parse as parseYaml } from "yaml";
4
7
  import { AppError } from "../shared/errors.js";
5
8
  import { parseJsonObject } from "../shared/json.js";
9
+ let validateBundledFlowContract;
6
10
  export const defaultFlowContract = {
7
11
  id: "dd-flow-canonical-2026-05",
8
12
  version: 1,
13
+ capabilities: [],
9
14
  stages: {
10
15
  registered: { terminal: false },
11
16
  prime: { terminal: false },
@@ -59,7 +64,39 @@ export function loadProjectFlowContract(projectRoot) {
59
64
  if (!contractPath) {
60
65
  return defaultFlowContract;
61
66
  }
62
- return normalizeFlowContract(readFlowContractObject(contractPath), contractPath);
67
+ try {
68
+ const value = readFlowContractObject(contractPath);
69
+ validateFlowContractStructure(value);
70
+ return normalizeFlowContract(value);
71
+ }
72
+ catch (error) {
73
+ if (error instanceof AppError) {
74
+ throw new AppError(error.code, `${contractPath}: ${error.message}`, error.exitCode, { ...error.details, file: contractPath });
75
+ }
76
+ throw error;
77
+ }
78
+ }
79
+ function validateFlowContractStructure(value) {
80
+ const validate = validateBundledFlowContract ??= new Ajv({ allErrors: true, strict: false, validateFormats: false }).compile(JSON.parse(fs.readFileSync(path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", "schemas", "flow-contract.schema.json"), "utf8")));
81
+ if (validate(value))
82
+ return;
83
+ const errors = (validate.errors ?? []).map((error) => ({
84
+ path: ajvErrorPath(error),
85
+ message: error.message ?? "schema validation failed",
86
+ keyword: error.keyword
87
+ }));
88
+ const first = errors[0] ?? { path: "/", message: "schema validation failed", keyword: "schema" };
89
+ throw new AppError("validation", `${first.path} ${first.message}`, 2, { path: first.path, errors });
90
+ }
91
+ function ajvErrorPath(error) {
92
+ const property = error.keyword === "required"
93
+ ? error.params.missingProperty
94
+ : error.keyword === "additionalProperties"
95
+ ? error.params.additionalProperty
96
+ : error.keyword === "uniqueItems"
97
+ ? Math.max(Number(error.params.i), Number(error.params.j))
98
+ : undefined;
99
+ return property ? childPath(error.instancePath, String(property)) : error.instancePath || "/";
63
100
  }
64
101
  export function flowContractForState(state) {
65
102
  if (!state.flow_contract) {
@@ -68,7 +105,7 @@ export function flowContractForState(state) {
68
105
  if (typeof state.flow_contract !== "object" || Array.isArray(state.flow_contract)) {
69
106
  return defaultFlowContract;
70
107
  }
71
- return normalizeFlowContract(state.flow_contract, "state.flow_contract");
108
+ return normalizeFlowContract(state.flow_contract);
72
109
  }
73
110
  export function normalizeStage(value, contract) {
74
111
  return contract.legacy_aliases[value] ?? value;
@@ -95,16 +132,269 @@ export function canTransition(from, to, contract = defaultFlowContract) {
95
132
  }
96
133
  return contract.transitions[normalizedFrom]?.includes(normalizedTo) ?? false;
97
134
  }
98
- function normalizeFlowContract(value, label) {
99
- const id = requireString(value.id, `${label}.id`);
100
- const version = requireNumber(value.version, `${label}.version`);
101
- const stages = normalizeStages(value.stages, `${label}.stages`);
102
- const legacyAliases = normalizeStringMap(value.legacy_aliases ?? {}, `${label}.legacy_aliases`);
103
- const transitions = normalizeTransitions(value.transitions, `${label}.transitions`, stages, legacyAliases);
104
- const readiness = normalizeReadiness(value.readiness, `${label}.readiness`, stages, legacyAliases);
105
- const mergeQueue = normalizeMergeQueue(value.merge_queue, `${label}.merge_queue`, stages, legacyAliases);
106
- const defaults = normalizeDefaults(value.defaults, `${label}.defaults`);
107
- return { id, version, stages, transitions, readiness, merge_queue: mergeQueue, defaults, legacy_aliases: legacyAliases };
135
+ export function resolveFlowFlags(contract, input) {
136
+ const catalog = contract.flow_flags;
137
+ const now = input.now ?? new Date().toISOString();
138
+ if (!catalog) {
139
+ const values = {};
140
+ return {
141
+ contract: { id: contract.id, version: contract.version, capabilities: contract.capabilities },
142
+ flow_kind: input.flowKind,
143
+ preset: { requested: input.preset ?? null, applied: "none", source_ref: `${contract.id}#flow_flags.none` },
144
+ snapshot_revision: input.snapshotRevision ?? 1,
145
+ resolution_status: "valid",
146
+ resolved_at: now,
147
+ values,
148
+ compatibility_projection: { task_profile_revision: null, legacy_flow_profile_read: false },
149
+ snapshot_checksum: checksumForFlowFlagValues(values),
150
+ floors_applied: []
151
+ };
152
+ }
153
+ const flow = catalog.flows[input.flowKind] ?? catalog.flows.custom;
154
+ if (!flow) {
155
+ throw new AppError("validation", `Flow kind has no flow-flag contract: ${input.flowKind}`, 2, {
156
+ flow_kind: input.flowKind,
157
+ supported: Object.keys(catalog.flows)
158
+ });
159
+ }
160
+ const defaultPreset = flow.default_preset ?? flow.defaults?.preset;
161
+ if (!defaultPreset || !catalog.presets[defaultPreset]) {
162
+ throw new AppError("validation", `Flow kind has no valid default preset: ${input.flowKind}`, 2);
163
+ }
164
+ const appliedPreset = input.preset ?? defaultPreset;
165
+ const presetValues = catalog.presets[appliedPreset];
166
+ if (!presetValues) {
167
+ throw new AppError("validation", `Unknown flow preset: ${appliedPreset}`, 2, {
168
+ preset: appliedPreset,
169
+ allowed: Object.keys(catalog.presets)
170
+ });
171
+ }
172
+ const supported = new Set(flow.supported);
173
+ const values = {};
174
+ const floorsApplied = [];
175
+ applyFlagMap(values, catalog, supported, catalog.presets[defaultPreset] ?? {}, "flow_default", `${contract.id}#flow_flags.flows.${input.flowKind}`, "flow default preset", now);
176
+ if (input.preset) {
177
+ applyFlagMap(values, catalog, supported, presetValues, "preset", `${contract.id}#flow_flags.presets.${appliedPreset}`, `preset ${appliedPreset}`, now);
178
+ }
179
+ const taskProfile = recordValue(input.taskProfile);
180
+ const profileFlags = taskProfileFlagMap(taskProfile);
181
+ const profileSource = taskProfile?.flow_flags || taskProfile?.flags ? "task_profile" : taskProfile?.flow_profile ? "compatibility_alias" : "task_profile";
182
+ applyFlagMap(values, catalog, supported, profileFlags, profileSource, "task_profile", "task profile override", now, true);
183
+ applyFlagMap(values, catalog, supported, input.protocolOverrides ?? {}, "protocol_override", "protocol_override", "protocol override", now, true, input.snapshotRevision ?? 1);
184
+ applyFlagMap(values, catalog, supported, input.runOverrides ?? {}, "run_override", "run_override", "RUN override", now, true, input.snapshotRevision ?? 1);
185
+ for (const key of supported) {
186
+ if (!values[key]) {
187
+ throw new AppError("validation", `Preset resolution is incomplete for flag: ${key}`, 2, {
188
+ flow_kind: input.flowKind,
189
+ preset: appliedPreset
190
+ });
191
+ }
192
+ }
193
+ const facts = taskFacts(taskProfile);
194
+ for (const [floorIndex, floor] of (catalog.mandatory_floors ?? []).entries()) {
195
+ if (!floorMatches(floor.when, facts))
196
+ continue;
197
+ for (const [key, floorValue] of Object.entries(floor.values)) {
198
+ if (!supported.has(key))
199
+ continue;
200
+ const definition = catalog.definitions[key];
201
+ if (!definition)
202
+ continue;
203
+ validateFlagValue(key, definition, floorValue);
204
+ const current = values[key];
205
+ if (!current || flagStrength(key, current.value, definition) < flagStrength(key, floorValue, definition)) {
206
+ if (current && ["task_profile", "compatibility_alias", "protocol_override", "run_override"].includes(current.source.kind)) {
207
+ throw new AppError("validation", `Override is below mandatory floor for ${key}`, 2, {
208
+ flag: key,
209
+ requested: current.value,
210
+ floor: floorValue,
211
+ floor_index: floorIndex,
212
+ reason: floor.reason
213
+ });
214
+ }
215
+ values[key] = valueRecord(key, definition, floorValue, "escalation", `mandatory_floors[${floorIndex}]`, floor.reason, now, input.snapshotRevision ?? 1);
216
+ floorsApplied.push(`${key}=floor:${floorIndex}`);
217
+ }
218
+ }
219
+ }
220
+ const taskProfileRevision = numberValue(taskProfile?.revision);
221
+ return {
222
+ contract: { id: contract.id, version: contract.version, capabilities: contract.capabilities },
223
+ flow_kind: input.flowKind,
224
+ preset: {
225
+ requested: input.preset ?? null,
226
+ applied: appliedPreset,
227
+ source_ref: `${contract.id}#flow_flags.presets.${appliedPreset}`
228
+ },
229
+ snapshot_revision: input.snapshotRevision ?? 1,
230
+ resolution_status: "valid",
231
+ resolved_at: now,
232
+ values,
233
+ compatibility_projection: {
234
+ task_profile_revision: taskProfileRevision,
235
+ legacy_flow_profile_read: Boolean(taskProfile?.flow_profile)
236
+ },
237
+ snapshot_checksum: checksumForFlowFlagValues(values),
238
+ floors_applied: floorsApplied
239
+ };
240
+ }
241
+ function applyFlagMap(target, catalog, supported, sourceMap, sourceKind, sourceRef, rationale, now, rejectUnknown = false, revision = null) {
242
+ for (const [key, value] of Object.entries(sourceMap)) {
243
+ if (!catalog.definitions[key]) {
244
+ if (rejectUnknown)
245
+ throw new AppError("validation", `Unknown flow flag: ${key}`, 2, { flag: key, source: sourceRef });
246
+ continue;
247
+ }
248
+ if (!supported.has(key)) {
249
+ if (rejectUnknown)
250
+ throw new AppError("validation", `Flow flag is unsupported for this flow: ${key}`, 2, { flag: key });
251
+ continue;
252
+ }
253
+ const definition = catalog.definitions[key];
254
+ validateFlagValue(key, definition, value);
255
+ target[key] = valueRecord(key, definition, value, sourceKind, sourceRef, rationale, now, revision);
256
+ }
257
+ }
258
+ function valueRecord(key, definition, value, sourceKind, sourceRef, rationale, now, revision) {
259
+ return {
260
+ value: value,
261
+ value_type: definition.type,
262
+ owner: definition.owner,
263
+ source: { kind: sourceKind, ref: sourceRef, revision },
264
+ rationale: `${key}: ${rationale}`,
265
+ resolved_at: now
266
+ };
267
+ }
268
+ function validateFlagValue(key, definition, value, errorPath) {
269
+ const validType = definition.type === "boolean"
270
+ ? typeof value === "boolean"
271
+ : definition.type === "number"
272
+ ? typeof value === "number" && Number.isFinite(value)
273
+ : definition.type === "string"
274
+ ? typeof value === "string"
275
+ : typeof value === "string" || typeof value === "number" || typeof value === "boolean" || value === null;
276
+ if (!validType || (definition.values && !definition.values.some((candidate) => candidate === value))) {
277
+ throw new AppError("validation", `Invalid value for flow flag: ${key}`, 2, {
278
+ ...(errorPath ? { path: errorPath } : {}),
279
+ flag: key,
280
+ value,
281
+ expected: definition.values ?? definition.type
282
+ });
283
+ }
284
+ }
285
+ function taskProfileFlagMap(profile) {
286
+ if (!profile)
287
+ return {};
288
+ const flags = recordValue(profile.flow_flags) ?? recordValue(profile.flags);
289
+ const result = { ...(flags ?? {}) };
290
+ const route = recordValue(profile.route);
291
+ if (route?.planning !== undefined)
292
+ result["planning.mode"] = route.planning;
293
+ const verification = recordValue(profile.verification);
294
+ if (verification?.depth !== undefined)
295
+ result["verification.depth"] = verification.depth;
296
+ if (verification?.mode !== undefined)
297
+ result["verification.depth"] = verification.mode;
298
+ const evidence = recordValue(profile.evidence);
299
+ if (evidence?.level !== undefined)
300
+ result["evidence.level"] = evidence.level;
301
+ return result;
302
+ }
303
+ function taskFacts(profile) {
304
+ const impact = recordValue(profile?.impact);
305
+ return {
306
+ ...(profile ?? {}),
307
+ risk: profile?.risk ?? impact?.risk,
308
+ contract: profile?.contract ?? impact?.contract,
309
+ operations: profile?.operations ?? impact?.operations
310
+ };
311
+ }
312
+ function floorMatches(when, facts) {
313
+ for (const [key, expected] of Object.entries(when)) {
314
+ if (key === "contract_not") {
315
+ if (expected.includes(facts.contract))
316
+ return false;
317
+ continue;
318
+ }
319
+ const actual = facts[key];
320
+ const expectedValues = Array.isArray(expected) ? expected : [expected];
321
+ if (!expectedValues.includes(actual))
322
+ return false;
323
+ }
324
+ return true;
325
+ }
326
+ function flagStrength(key, value, definition) {
327
+ if (typeof value === "boolean")
328
+ return value ? 1 : 0;
329
+ const ranks = {
330
+ "planning.mode": ["no_plan", "compact_plan", "full_plan"],
331
+ "plan.review.mode": ["self", "grouped", "focused", "mixed"],
332
+ "subagents.route": ["self_check", "grouped_subagent", "focused_subagent", "mixed"],
333
+ "subagents.grouping": ["off", "allowlisted"],
334
+ "subagents.pool_fallback": ["one", "runtime"],
335
+ "observability.detail": ["compact", "normal", "full"],
336
+ "knowledge.extract": ["skip", "conditional", "required"],
337
+ "knowledge.promote": ["skip", "conditional", "required"],
338
+ "workspace.bootstrap.mode": ["not_required", "revalidate", "required"],
339
+ "merge.ceremony": ["compact", "normal", "full"],
340
+ "merge.report_detail": ["compact", "normal", "full"],
341
+ "verification.depth": ["minimal", "standard", "full"],
342
+ "evidence.level": ["final_report", "protocol_record", "proof_bundle", "verification_passport", "rollout_evidence"]
343
+ };
344
+ return ranks[key]?.indexOf(String(value)) ?? definition.values?.indexOf(value) ?? 0;
345
+ }
346
+ export function isFlowFlagDowngrade(key, candidate, baseline) {
347
+ return flagStrength(key, candidate, { type: "string", owner: "flow_contract" })
348
+ < flagStrength(key, baseline, { type: "string", owner: "flow_contract" });
349
+ }
350
+ export function checksumForFlowFlagValues(values) {
351
+ return crypto.createHash("sha256").update(JSON.stringify(sortRecord(values))).digest("hex");
352
+ }
353
+ function sortRecord(value) {
354
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, value[key]]));
355
+ }
356
+ function recordValue(value) {
357
+ return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
358
+ }
359
+ function numberValue(value) {
360
+ return typeof value === "number" && Number.isInteger(value) ? value : null;
361
+ }
362
+ export function normalizeFlowContract(value) {
363
+ const id = requireString(value.id, "/id");
364
+ const version = requireNumber(value.version, "/version");
365
+ if (version > 3)
366
+ fail("/version", `unsupported flow-contract version: ${version}`);
367
+ if (version === 3 && !/^dd-flow-canonical-[0-9]{4}-[0-9]{2}$/.test(id)) {
368
+ fail("/id", "must identify a canonical version 3 flow contract");
369
+ }
370
+ if (value.flow_flags !== undefined && version !== 3)
371
+ fail("/version", "must be 3 when flow_flags is present");
372
+ const capabilities = normalizeStringArray(value.capabilities ?? [], "/capabilities");
373
+ const stages = normalizeStages(value.stages, "/stages");
374
+ const legacyAliases = normalizeStringMap(value.legacy_aliases ?? {}, "/legacy_aliases");
375
+ const transitions = normalizeTransitions(value.transitions, "/transitions", stages, legacyAliases);
376
+ const readiness = normalizeReadiness(value.readiness, "/readiness", stages, legacyAliases);
377
+ const mergeQueue = normalizeMergeQueue(value.merge_queue, "/merge_queue", stages, legacyAliases);
378
+ const defaults = normalizeDefaults(value.defaults, "/defaults");
379
+ const flowFlags = value.flow_flags === undefined ? undefined : normalizeFlowFlags(value.flow_flags, "/flow_flags");
380
+ const entrypoints = recordValue(value.entrypoints);
381
+ const autoPolicy = recordValue(value.auto_policy);
382
+ const routeAliases = value.route_aliases === undefined ? undefined : normalizeStringMap(value.route_aliases, "/route_aliases");
383
+ return {
384
+ id,
385
+ version,
386
+ capabilities,
387
+ stages,
388
+ transitions,
389
+ readiness,
390
+ merge_queue: mergeQueue,
391
+ defaults,
392
+ legacy_aliases: legacyAliases,
393
+ ...(entrypoints ? { entrypoints } : {}),
394
+ ...(autoPolicy ? { auto_policy: autoPolicy } : {}),
395
+ ...(routeAliases ? { route_aliases: routeAliases } : {}),
396
+ ...(flowFlags ? { flow_flags: flowFlags } : {})
397
+ };
108
398
  }
109
399
  function findProjectFlowContractPath(projectRoot) {
110
400
  const basePath = path.join(projectRoot, ".memory-bank", "dd-flow", "flow-contract");
@@ -129,35 +419,39 @@ function readFlowContractObject(file) {
129
419
  }
130
420
  function normalizeStages(value, label) {
131
421
  if (!value || typeof value !== "object" || Array.isArray(value)) {
132
- throw new AppError("validation", `${label} must be an object`, 2);
422
+ fail(label, "must be an object");
133
423
  }
134
424
  const stages = {};
135
425
  for (const [stage, config] of Object.entries(value)) {
426
+ const stagePath = childPath(label, stage);
136
427
  if (!config || typeof config !== "object" || Array.isArray(config)) {
137
- throw new AppError("validation", `${label}.${stage} must be an object`, 2);
428
+ fail(stagePath, "must be an object");
138
429
  }
139
430
  const terminal = config.terminal;
140
- stages[stage] = { terminal: typeof terminal === "boolean" ? terminal : false };
431
+ if (typeof terminal !== "boolean")
432
+ fail(childPath(stagePath, "terminal"), "must be a boolean");
433
+ stages[stage] = { terminal };
141
434
  }
142
435
  if (!stages.registered || !stages.closed || !stages.cancelled) {
143
- throw new AppError("validation", `${label} must include registered, closed, and cancelled stages`, 2);
436
+ fail(label, "must include registered, closed, and cancelled stages");
144
437
  }
145
438
  return stages;
146
439
  }
147
440
  function normalizeTransitions(value, label, stages, aliases) {
148
441
  if (!value || typeof value !== "object" || Array.isArray(value)) {
149
- throw new AppError("validation", `${label} must be an object`, 2);
442
+ fail(label, "must be an object");
150
443
  }
151
444
  const transitions = {};
152
445
  for (const [from, tos] of Object.entries(value)) {
446
+ const transitionPath = childPath(label, from);
153
447
  const normalizedFrom = aliases[from] ?? from;
154
- requireKnownStage(normalizedFrom, stages, `${label}.${from}`);
448
+ requireKnownStage(normalizedFrom, stages, transitionPath);
155
449
  if (!Array.isArray(tos) || tos.some((to) => typeof to !== "string")) {
156
- throw new AppError("validation", `${label}.${from} must be a string array`, 2);
450
+ fail(transitionPath, "must be a string array");
157
451
  }
158
- transitions[normalizedFrom] = tos.map((to) => {
452
+ transitions[normalizedFrom] = tos.map((to, index) => {
159
453
  const normalizedTo = aliases[to] ?? to;
160
- requireKnownStage(normalizedTo, stages, `${label}.${from}`);
454
+ requireKnownStage(normalizedTo, stages, childPath(transitionPath, index));
161
455
  return normalizedTo;
162
456
  });
163
457
  }
@@ -165,59 +459,225 @@ function normalizeTransitions(value, label, stages, aliases) {
165
459
  }
166
460
  function normalizeReadiness(value, label, stages, aliases) {
167
461
  const object = requireObject(value, label);
168
- const command = requireString(object.command, `${label}.command`);
462
+ const command = requireString(object.command, childPath(label, "command"));
169
463
  if (command !== "ready-for-merge") {
170
- throw new AppError("validation", `${label}.command must be ready-for-merge`, 2);
464
+ fail(childPath(label, "command"), "must be ready-for-merge");
171
465
  }
172
466
  if (!Array.isArray(object.allowed_from) || object.allowed_from.some((stage) => typeof stage !== "string")) {
173
- throw new AppError("validation", `${label}.allowed_from must be a string array`, 2);
467
+ fail(childPath(label, "allowed_from"), "must be a string array");
174
468
  }
175
- const allowedFrom = object.allowed_from.map((stage) => normalizeKnownStage(stage, stages, aliases, `${label}.allowed_from`));
176
- const targetStage = normalizeKnownStage(requireString(object.target_stage, `${label}.target_stage`), stages, aliases, label);
469
+ const allowedFrom = object.allowed_from.map((stage, index) => normalizeKnownStage(stage, stages, aliases, childPath(childPath(label, "allowed_from"), index)));
470
+ const targetStagePath = childPath(label, "target_stage");
471
+ const targetStage = normalizeKnownStage(requireString(object.target_stage, targetStagePath), stages, aliases, targetStagePath);
177
472
  return { command, allowed_from: allowedFrom, target_stage: targetStage };
178
473
  }
179
474
  function normalizeMergeQueue(value, label, stages, aliases) {
180
475
  const object = requireObject(value, label);
181
- const complete = requireObject(object.complete, `${label}.complete`);
476
+ const completePath = childPath(label, "complete");
477
+ const complete = requireObject(object.complete, completePath);
478
+ const targetStagePath = childPath(completePath, "target_stage");
182
479
  return {
183
480
  complete: {
184
- target_stage: normalizeKnownStage(requireString(complete.target_stage, `${label}.complete.target_stage`), stages, aliases, label),
185
- next_action: requireString(complete.next_action, `${label}.complete.next_action`)
481
+ target_stage: normalizeKnownStage(requireString(complete.target_stage, targetStagePath), stages, aliases, targetStagePath),
482
+ next_action: requireString(complete.next_action, childPath(completePath, "next_action"))
186
483
  }
187
484
  };
188
485
  }
189
486
  function normalizeDefaults(value, label) {
190
487
  const object = requireObject(value, label);
191
- return { registered_next_action: requireString(object.registered_next_action, `${label}.registered_next_action`) };
488
+ return { registered_next_action: requireString(object.registered_next_action, childPath(label, "registered_next_action")) };
192
489
  }
193
490
  function normalizeStringMap(value, label) {
194
491
  if (!value || typeof value !== "object" || Array.isArray(value)) {
195
- throw new AppError("validation", `${label} must be an object`, 2);
492
+ fail(label, "must be an object");
196
493
  }
197
494
  const output = {};
198
495
  for (const [key, mapValue] of Object.entries(value)) {
199
496
  if (typeof mapValue !== "string" || mapValue.length === 0) {
200
- throw new AppError("validation", `${label}.${key} must be a non-empty string`, 2);
497
+ fail(childPath(label, key), "must be a non-empty string");
201
498
  }
202
499
  output[key] = mapValue;
203
500
  }
204
501
  return output;
205
502
  }
503
+ function normalizeStringArray(value, label) {
504
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.length === 0)) {
505
+ fail(label, "must be a string array");
506
+ }
507
+ return [...value];
508
+ }
509
+ function normalizeFlowFlags(value, label) {
510
+ const object = requireObject(value, label);
511
+ const definitionsPath = childPath(label, "definitions");
512
+ const definitionsObject = requireObject(object.definitions, definitionsPath);
513
+ const definitions = {};
514
+ for (const [key, rawDefinition] of Object.entries(definitionsObject)) {
515
+ const definitionPath = childPath(definitionsPath, key);
516
+ const definition = requireObject(rawDefinition, definitionPath);
517
+ const typePath = childPath(definitionPath, "type");
518
+ const type = requireString(definition.type, typePath);
519
+ if (!["boolean", "enum", "number", "string"].includes(type)) {
520
+ fail(typePath, "is unsupported");
521
+ }
522
+ const rawValues = definition.values;
523
+ if (rawValues !== undefined && (!Array.isArray(rawValues) || rawValues.some((item) => !isFlagScalar(item)))) {
524
+ fail(childPath(definitionPath, "values"), "must contain scalar values");
525
+ }
526
+ definitions[key] = {
527
+ type,
528
+ ...(rawValues ? { values: rawValues } : {}),
529
+ owner: requireString(definition.owner, childPath(definitionPath, "owner")),
530
+ ...(typeof definition.description === "string" ? { description: definition.description } : {})
531
+ };
532
+ }
533
+ const presetsPath = childPath(label, "presets");
534
+ const presetsObject = requireObject(object.presets, presetsPath);
535
+ const presets = {};
536
+ for (const [name, rawPreset] of Object.entries(presetsObject)) {
537
+ const presetPath = childPath(presetsPath, name);
538
+ const preset = normalizeFlagMap(rawPreset, presetPath);
539
+ for (const [key, item] of Object.entries(preset)) {
540
+ const definition = definitions[key];
541
+ if (!definition)
542
+ fail(childPath(presetPath, key), `references unknown flow flag: ${key}`);
543
+ validateFlagValue(key, definition, item, childPath(presetPath, key));
544
+ }
545
+ presets[name] = preset;
546
+ }
547
+ const flowsPath = childPath(label, "flows");
548
+ const flowsObject = requireObject(object.flows, flowsPath);
549
+ const flows = {};
550
+ for (const [name, rawFlow] of Object.entries(flowsObject)) {
551
+ const flowPath = childPath(flowsPath, name);
552
+ const flow = requireObject(rawFlow, flowPath);
553
+ const flowDefaults = recordValue(flow.defaults);
554
+ const defaultPresetPath = childPath(flowPath, "default_preset");
555
+ const defaultPreset = requireString(flow.default_preset, defaultPresetPath);
556
+ const supportedPath = childPath(flowPath, "supported");
557
+ const supported = normalizeStringArray(flow.supported, supportedPath);
558
+ const duplicateIndex = supported.findIndex((key, index) => supported.indexOf(key) !== index);
559
+ if (duplicateIndex >= 0)
560
+ fail(childPath(supportedPath, duplicateIndex), `duplicates flow flag: ${supported[duplicateIndex]}`);
561
+ supported.forEach((key, index) => {
562
+ if (!definitions[key])
563
+ fail(childPath(supportedPath, index), `references unknown flow flag: ${key}`);
564
+ });
565
+ if (!presets[defaultPreset])
566
+ fail(defaultPresetPath, `references unknown preset: ${defaultPreset}`);
567
+ const legacyDefault = flowDefaults?.preset;
568
+ if (typeof legacyDefault === "string" && legacyDefault !== defaultPreset) {
569
+ fail(childPath(childPath(flowPath, "defaults"), "preset"), "must match default_preset");
570
+ }
571
+ for (const [presetName, preset] of Object.entries(presets)) {
572
+ for (const key of supported) {
573
+ if (!(key in preset))
574
+ fail(childPath(childPath(presetsPath, presetName), key), `is required by flow: ${name}`);
575
+ }
576
+ }
577
+ flows[name] = {
578
+ default_preset: defaultPreset,
579
+ supported,
580
+ ...(typeof flowDefaults?.preset === "string" ? { defaults: { preset: flowDefaults.preset } } : {})
581
+ };
582
+ }
583
+ const floorsValue = object.mandatory_floors ?? [];
584
+ const floorsPath = childPath(label, "mandatory_floors");
585
+ if (!Array.isArray(floorsValue))
586
+ fail(floorsPath, "must be an array");
587
+ const mandatory_floors = floorsValue.map((rawFloor, index) => {
588
+ const floorPath = childPath(floorsPath, index);
589
+ const floor = requireObject(rawFloor, floorPath);
590
+ const valuesPath = childPath(floorPath, "values");
591
+ const values = normalizeFlagMap(floor.values, valuesPath);
592
+ for (const [key, item] of Object.entries(values)) {
593
+ const definition = definitions[key];
594
+ if (!definition)
595
+ fail(childPath(valuesPath, key), `references unknown flow flag: ${key}`);
596
+ validateFlagValue(key, definition, item, childPath(valuesPath, key));
597
+ }
598
+ return {
599
+ when: requireObject(floor.when, childPath(floorPath, "when")),
600
+ values,
601
+ reason: requireString(floor.reason, childPath(floorPath, "reason"))
602
+ };
603
+ });
604
+ const precedencePath = childPath(label, "source_precedence");
605
+ const precedence = normalizeSourcePrecedence(object.source_precedence, precedencePath);
606
+ const reductionMatrix = recordValue(object.reduction_matrix);
607
+ const consumerMatrix = recordValue(object.consumer_matrix);
608
+ if (reductionMatrix) {
609
+ for (const preset of Object.keys(reductionMatrix)) {
610
+ if (!presets[preset])
611
+ fail(childPath(childPath(label, "reduction_matrix"), preset), `references unknown preset: ${preset}`);
612
+ }
613
+ }
614
+ if (consumerMatrix) {
615
+ for (const key of Object.keys(consumerMatrix)) {
616
+ if (!definitions[key])
617
+ fail(childPath(childPath(label, "consumer_matrix"), key), `references unknown flow flag: ${key}`);
618
+ }
619
+ }
620
+ return {
621
+ source_precedence: precedence,
622
+ definitions,
623
+ presets,
624
+ flows,
625
+ mandatory_floors,
626
+ ...(reductionMatrix ? { reduction_matrix: reductionMatrix } : {}),
627
+ ...(consumerMatrix ? { consumer_matrix: Object.fromEntries(Object.entries(consumerMatrix).map(([key, items]) => [key, normalizeStringArray(items, childPath(childPath(label, "consumer_matrix"), key))])) } : {})
628
+ };
629
+ }
630
+ function normalizeSourcePrecedence(value, label) {
631
+ const supported = ["flow_default", "preset", "task_profile", "protocol_override", "run_override", "escalation", "compatibility_alias"];
632
+ const required = ["flow_default", "preset", "task_profile", "protocol_override", "run_override"];
633
+ const items = normalizeStringArray(value ?? required, label);
634
+ const result = [];
635
+ items.forEach((item, index) => {
636
+ if (!supported.includes(item))
637
+ fail(childPath(label, index), `contains unsupported source kind: ${item}`);
638
+ if (items.indexOf(item) !== index)
639
+ fail(childPath(label, index), `duplicates source kind: ${item}`);
640
+ result.push(item);
641
+ });
642
+ let previous = -1;
643
+ for (const source of required) {
644
+ const index = result.indexOf(source);
645
+ if (index < 0)
646
+ fail(label, `must include source kind: ${source}`);
647
+ if (index < previous)
648
+ fail(childPath(label, index), `must preserve mandatory source order at: ${source}`);
649
+ previous = index;
650
+ }
651
+ return result;
652
+ }
653
+ function normalizeFlagMap(value, label) {
654
+ const object = requireObject(value, label);
655
+ const result = {};
656
+ for (const [key, item] of Object.entries(object)) {
657
+ if (!isFlagScalar(item))
658
+ fail(childPath(label, key), "must be a scalar");
659
+ result[key] = item;
660
+ }
661
+ return result;
662
+ }
663
+ function isFlagScalar(value) {
664
+ return value === null || typeof value === "string" || typeof value === "number" || typeof value === "boolean";
665
+ }
206
666
  function requireObject(value, label) {
207
667
  if (!value || typeof value !== "object" || Array.isArray(value)) {
208
- throw new AppError("validation", `${label} must be an object`, 2);
668
+ fail(label, "must be an object");
209
669
  }
210
670
  return value;
211
671
  }
212
672
  function requireString(value, label) {
213
673
  if (typeof value !== "string" || value.length === 0) {
214
- throw new AppError("validation", `${label} must be a non-empty string`, 2);
674
+ fail(label, "must be a non-empty string");
215
675
  }
216
676
  return value;
217
677
  }
218
678
  function requireNumber(value, label) {
219
679
  if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
220
- throw new AppError("validation", `${label} must be a positive integer`, 2);
680
+ fail(label, "must be a positive integer");
221
681
  }
222
682
  return value;
223
683
  }
@@ -228,6 +688,12 @@ function normalizeKnownStage(value, stages, aliases, label) {
228
688
  }
229
689
  function requireKnownStage(value, stages, label) {
230
690
  if (!Object.prototype.hasOwnProperty.call(stages, value)) {
231
- throw new AppError("validation", `${label} references unknown stage: ${value}`, 2);
691
+ fail(label, `references unknown stage: ${value}`);
232
692
  }
233
693
  }
694
+ function childPath(parent, segment) {
695
+ return `${parent}/${String(segment).replaceAll("~", "~0").replaceAll("/", "~1")}`;
696
+ }
697
+ function fail(errorPath, message) {
698
+ throw new AppError("validation", `${errorPath} ${message}`, 2, { path: errorPath });
699
+ }