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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/CHANGELOG.md +55 -0
  2. package/README.md +74 -5
  3. package/dist/build-info.json +5 -5
  4. package/dist/cli/help.js +116 -18
  5. package/dist/cli/run-cli.js +361 -29
  6. package/dist/schemas/compatibility.schema.json +81 -2
  7. package/dist/schemas/engine-manifest.schema.json +61 -0
  8. package/dist/schemas/flow-guidance.schema.json +17 -0
  9. package/dist/schemas/global-dashboard-data.schema.json +60 -2
  10. package/dist/schemas/mb-upgrade-migration-report.schema.json +93 -0
  11. package/dist/schemas/project-dashboard-data.schema.json +26 -2
  12. package/dist/schemas/project-summary.schema.json +73 -0
  13. package/dist/schemas/protocol-dashboard-data.schema.json +25 -2
  14. package/dist/schemas/status-report.schema.json +4 -2
  15. package/dist/services/branch-context.js +254 -0
  16. package/dist/services/cleanup.js +31 -0
  17. package/dist/services/cli-operation-classifier.js +104 -0
  18. package/dist/services/compatibility-preflight.js +127 -0
  19. package/dist/services/config.js +6 -0
  20. package/dist/services/dashboard-targets.js +95 -0
  21. package/dist/services/dashboard.js +376 -59
  22. package/dist/services/engines.js +532 -0
  23. package/dist/services/flow-guidance.js +8 -1
  24. package/dist/services/hooks.js +1 -1
  25. package/dist/services/lanes.js +333 -1
  26. package/dist/services/merge-queue.js +310 -15
  27. package/dist/services/merge-worker.js +44 -3
  28. package/dist/services/migrations.js +231 -0
  29. package/dist/services/project-summary.js +122 -0
  30. package/dist/services/projects.js +41 -6
  31. package/dist/services/protocol-lifecycle.js +144 -0
  32. package/dist/services/protocols.js +34 -7
  33. package/dist/services/sessions.js +21 -4
  34. package/dist/services/status.js +10 -0
  35. package/dist/services/version-status.js +39 -15
  36. package/dist/storage/database.js +25 -0
  37. package/dist/storage/paths.js +12 -0
  38. package/package.json +3 -2
@@ -0,0 +1,532 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { spawn } from "node:child_process";
5
+ import { getCliBuildInfo } from "./build-info.js";
6
+ import { AppError } from "../shared/errors.js";
7
+ import { ensureDir, engineStoreRoot, engineVersionRoot, resolveProjectRoot } from "../storage/paths.js";
8
+ import { classifyCliOperation } from "./cli-operation-classifier.js";
9
+ export const engineManifestSchemaId = "dd-flow/engine-manifest@1";
10
+ const routerNativeFamilies = new Set(["engine", "version", "schema"]);
11
+ const sourceFile = fileURLToPath(import.meta.url);
12
+ export function isEngineMode(env) {
13
+ return env.DD_FLOW_ENGINE_MODE === "1";
14
+ }
15
+ export function isRouterNativeCommand(args) {
16
+ const first = args[0];
17
+ if (!first || first === "--version")
18
+ return true;
19
+ if (first === "--help" || first === "-h")
20
+ return true;
21
+ if (args.includes("--help") || args.includes("-h"))
22
+ return true;
23
+ return routerNativeFamilies.has(first);
24
+ }
25
+ export function installCurrentEngine(context, input = {}) {
26
+ const build = getCliBuildInfo();
27
+ const packageRoot = findPackageRoot(path.dirname(sourceFile));
28
+ if (!packageRoot) {
29
+ throw new AppError("engine_install_failed", "Cannot locate package root for current dd-flow engine", 1);
30
+ }
31
+ const target = engineVersionRoot(context.ddFlowHome, build.package_name, build.version);
32
+ const manifestPath = path.join(target, "engine.json");
33
+ const existing = readManifest(manifestPath);
34
+ if (existing && !input.force && manifestHealthy(existing)) {
35
+ return {
36
+ ok: true,
37
+ action: "engine_install",
38
+ changed: false,
39
+ engine: manifestSummary(existing),
40
+ path: target
41
+ };
42
+ }
43
+ const lockDir = `${target}.lock`;
44
+ acquireInstallLock(lockDir);
45
+ try {
46
+ const afterLock = readManifest(manifestPath);
47
+ if (afterLock && !input.force && manifestHealthy(afterLock)) {
48
+ return {
49
+ ok: true,
50
+ action: "engine_install",
51
+ changed: false,
52
+ engine: manifestSummary(afterLock),
53
+ path: target
54
+ };
55
+ }
56
+ const parent = path.dirname(target);
57
+ ensureDir(parent);
58
+ const tmp = path.join(parent, `.${path.basename(target)}.tmp-${process.pid}-${Date.now()}`);
59
+ fs.rmSync(tmp, { recursive: true, force: true });
60
+ ensureDir(tmp);
61
+ copyPackageSnapshot(packageRoot, tmp);
62
+ const manifest = buildManifest(context, build.package_name, build.version, packageRoot, target, tmp);
63
+ fs.writeFileSync(path.join(tmp, "engine.json"), `${JSON.stringify(manifest, null, 2)}\n`);
64
+ fs.rmSync(target, { recursive: true, force: true });
65
+ fs.renameSync(tmp, target);
66
+ const installed = requireManifest(path.join(target, "engine.json"));
67
+ return {
68
+ ok: true,
69
+ action: "engine_install",
70
+ changed: true,
71
+ engine: manifestSummary(installed),
72
+ path: target
73
+ };
74
+ }
75
+ finally {
76
+ fs.rmSync(lockDir, { recursive: true, force: true });
77
+ }
78
+ }
79
+ export function listEngines(context) {
80
+ const root = engineStoreRoot(context.ddFlowHome);
81
+ const engines = readInstalledManifests(root).map(manifestSummary);
82
+ return { ok: true, schema_id: "dd-flow/engine-list@1", engine_store: root, engines };
83
+ }
84
+ export function engineInfo(context, input) {
85
+ const build = getCliBuildInfo();
86
+ const packageName = input.packageName ?? build.package_name;
87
+ const version = input.version ?? build.version;
88
+ const manifest = requireManifest(path.join(engineVersionRoot(context.ddFlowHome, packageName, version), "engine.json"));
89
+ return { ok: true, schema_id: "dd-flow/engine-info@1", engine: manifest };
90
+ }
91
+ export function resolveEngine(context, input = {}) {
92
+ installCurrentEngine(context);
93
+ const selection = selectEngine(context, input);
94
+ if (selection.status === "missing") {
95
+ return { ok: false, error: missingEngineError(selection), selection, exit_code: 1 };
96
+ }
97
+ return { ok: true, schema_id: "dd-flow/engine-resolve@1", selection };
98
+ }
99
+ export function doctorEngines(context, input = {}) {
100
+ installCurrentEngine(context);
101
+ const root = engineStoreRoot(context.ddFlowHome);
102
+ const installed = readInstalledManifests(root);
103
+ const checks = installed.map((manifest) => ({
104
+ engine: manifestSummary(manifest),
105
+ status: manifestHealthy(manifest) ? "ok" : "corrupt",
106
+ diagnostics: manifestDiagnostics(manifest)
107
+ }));
108
+ const selection = selectEngine(context, input);
109
+ return {
110
+ ok: checks.every((check) => check.status === "ok") && selection.status !== "missing",
111
+ schema_id: "dd-flow/engine-doctor@1",
112
+ engine_store: root,
113
+ checks,
114
+ selection,
115
+ exit_code: checks.every((check) => check.status === "ok") && selection.status !== "missing" ? 0 : 1
116
+ };
117
+ }
118
+ export function routeArgsThroughEngine(context, args, io, stdin, env) {
119
+ if (isEngineMode(env) || isRouterNativeCommand(args))
120
+ return Promise.resolve(null);
121
+ installCurrentEngine(context);
122
+ const selection = selectEngine(context, { projectRoot: projectRootFromArgs(args) });
123
+ if (selection.status === "missing") {
124
+ const classification = classifyCliOperation(args, env);
125
+ if (classification.mode === "read_only_diagnostics" || classification.mode === "mb_upgrade") {
126
+ return Promise.resolve(null);
127
+ }
128
+ throw new AppError("missing_engine", "Required dd-flow engine is not installed", 1, missingEngineDetails(selection, classification));
129
+ }
130
+ if (!selection.selected)
131
+ return Promise.resolve(null);
132
+ const current = getCliBuildInfo();
133
+ if (selection.selected.package_name === current.package_name && selection.selected.package_version === current.version) {
134
+ return Promise.resolve({ routed: true, exitCode: 0, inProcess: true });
135
+ }
136
+ const entrypoint = resolveManifestEntrypoint(selection.selected);
137
+ return spawnEngine(selection.selected, entrypoint, args, io, stdin, env, current.version);
138
+ }
139
+ export function engineRoutingMetadata(env) {
140
+ if (env.DD_FLOW_ENGINE_MODE !== "1")
141
+ return null;
142
+ return {
143
+ mode: "engine",
144
+ engine_home: env.DD_FLOW_ENGINE_HOME ?? null,
145
+ routed_from: env.DD_FLOW_ROUTED_FROM ?? null
146
+ };
147
+ }
148
+ export function selectEngine(context, input) {
149
+ const compatibility = readCompatibilityForProject(input.projectRoot);
150
+ const build = getCliBuildInfo();
151
+ const packageName = stringValue(compatibility?.engine?.package_name) ?? build.package_name;
152
+ const requiredRange = stringValue(compatibility?.engine?.version_range) ?? `>=${build.version} <9999.0.0`;
153
+ const recommended = stringValue(compatibility?.engine?.recommended_version) ?? build.version;
154
+ const installHintTemplate = stringValue(compatibility?.engine?.old_engine_install_hint) ?? `npx ${packageName}@<version> engine install`;
155
+ const manifests = readInstalledManifests(engineStoreRoot(context.ddFlowHome))
156
+ .filter((manifest) => manifest.package_name === packageName)
157
+ .filter((manifest) => satisfiesRange(manifest.package_version, requiredRange))
158
+ .filter((manifest) => manifestHealthy(manifest))
159
+ .sort((a, b) => compareSemverStrings(b.package_version, a.package_version));
160
+ const selected = manifests[0] ?? null;
161
+ return {
162
+ status: selected ? "selected" : "missing",
163
+ package_name: packageName,
164
+ required_range: requiredRange,
165
+ recommended_version: recommended,
166
+ selected,
167
+ install_hint: selected ? null : installHintTemplate.replace("<version>", recommended),
168
+ project_root: input.projectRoot ? path.resolve(input.projectRoot) : null,
169
+ memory_bank_version: compatibility?.memory_bank_version ?? null,
170
+ diagnostics: selected ? [] : [`No installed engine satisfies ${packageName} ${requiredRange}`]
171
+ };
172
+ }
173
+ function readCompatibilityForProject(projectRoot) {
174
+ let resolvedProjectRoot = null;
175
+ if (projectRoot) {
176
+ try {
177
+ resolvedProjectRoot = resolveProjectRoot(projectRoot);
178
+ }
179
+ catch {
180
+ return null;
181
+ }
182
+ }
183
+ const roots = resolvedProjectRoot ? [path.join(resolvedProjectRoot, ".memory-bank", "dd-flow", "compatibility.json")] : [];
184
+ for (const file of roots) {
185
+ try {
186
+ return JSON.parse(fs.readFileSync(file, "utf8"));
187
+ }
188
+ catch {
189
+ // Best effort; caller falls back to current package.
190
+ }
191
+ }
192
+ return null;
193
+ }
194
+ function projectRootFromArgs(args) {
195
+ for (let index = 0; index < args.length; index += 1) {
196
+ const arg = args[index];
197
+ if ((arg === "--project-root" || arg === "--root") && args[index + 1])
198
+ return args[index + 1];
199
+ if (arg?.startsWith("--project-root="))
200
+ return arg.slice("--project-root=".length);
201
+ if (arg?.startsWith("--root="))
202
+ return arg.slice("--root=".length);
203
+ }
204
+ return undefined;
205
+ }
206
+ function buildManifest(context, packageName, version, packageRoot, snapshotRoot, checksumRoot) {
207
+ return {
208
+ schema_id: engineManifestSchemaId,
209
+ package_name: packageName,
210
+ package_version: version,
211
+ engine_id: "dd-flow-engine",
212
+ engine_version: version,
213
+ installed_at: context.now(),
214
+ install_source: inferInstallSource(packageRoot, context.ddFlowHome),
215
+ package_root: snapshotRoot,
216
+ snapshot_root: snapshotRoot,
217
+ entrypoint: path.join("dist", "cli.js"),
218
+ supports_memorybank: [`>=0.0.0 <9999.0.0`],
219
+ supports_contracts: {
220
+ compatibility: ["dd-flow/compatibility@1"],
221
+ version: ["dd-flow/version-report@1"],
222
+ status: ["dd-flow/status-report@1"]
223
+ },
224
+ integrity: {
225
+ source: "package_snapshot",
226
+ checksum: fileListChecksum(checksumRoot),
227
+ mode: "file_list"
228
+ }
229
+ };
230
+ }
231
+ function copyPackageSnapshot(packageRoot, target) {
232
+ for (const item of ["dist", "package.json", "README.md", "CHANGELOG.md"]) {
233
+ const from = path.join(packageRoot, item);
234
+ if (!fs.existsSync(from))
235
+ continue;
236
+ const to = path.join(target, item);
237
+ const stat = fs.statSync(from);
238
+ if (stat.isDirectory())
239
+ copyDir(from, to);
240
+ else {
241
+ ensureDir(path.dirname(to));
242
+ fs.copyFileSync(from, to);
243
+ }
244
+ }
245
+ }
246
+ function inferInstallSource(packageRoot, ddFlowHome) {
247
+ const normalized = path.resolve(packageRoot);
248
+ if (normalized.includes(`${path.sep}_npx${path.sep}`) || normalized.includes(`${path.sep}.npm${path.sep}_npx${path.sep}`)) {
249
+ return "npx_package";
250
+ }
251
+ if (isInside(path.resolve(ddFlowHome), normalized))
252
+ return "bundled_package";
253
+ if (hasAncestorFile(normalized, ".git"))
254
+ return "local_development";
255
+ return "bundled_package";
256
+ }
257
+ function hasAncestorFile(start, name) {
258
+ let current = path.resolve(start);
259
+ while (true) {
260
+ if (fs.existsSync(path.join(current, name)))
261
+ return true;
262
+ const parent = path.dirname(current);
263
+ if (parent === current)
264
+ return false;
265
+ current = parent;
266
+ }
267
+ }
268
+ function copyDir(from, to) {
269
+ ensureDir(to);
270
+ for (const entry of fs.readdirSync(from, { withFileTypes: true })) {
271
+ const source = path.join(from, entry.name);
272
+ const target = path.join(to, entry.name);
273
+ if (entry.isDirectory())
274
+ copyDir(source, target);
275
+ else if (entry.isFile())
276
+ fs.copyFileSync(source, target);
277
+ }
278
+ }
279
+ function acquireInstallLock(lockDir) {
280
+ ensureDir(path.dirname(lockDir));
281
+ try {
282
+ fs.mkdirSync(lockDir, { recursive: false });
283
+ }
284
+ catch {
285
+ const stat = fs.existsSync(lockDir) ? fs.statSync(lockDir) : null;
286
+ if (stat && Date.now() - stat.mtimeMs > 120_000) {
287
+ fs.rmSync(lockDir, { recursive: true, force: true });
288
+ fs.mkdirSync(lockDir, { recursive: false });
289
+ return;
290
+ }
291
+ throw new AppError("engine_install_locked", "Another dd-flow engine install is in progress", 1, { lock_dir: lockDir });
292
+ }
293
+ }
294
+ function readInstalledManifests(root) {
295
+ if (!fs.existsSync(root))
296
+ return [];
297
+ const manifests = [];
298
+ for (const packageDir of fs.readdirSync(root)) {
299
+ const packageRoot = path.join(root, packageDir);
300
+ if (!fs.statSync(packageRoot).isDirectory())
301
+ continue;
302
+ for (const versionDir of fs.readdirSync(packageRoot)) {
303
+ const manifest = readManifest(path.join(packageRoot, versionDir, "engine.json"));
304
+ if (manifest)
305
+ manifests.push(manifest);
306
+ }
307
+ }
308
+ return manifests;
309
+ }
310
+ function requireManifest(file) {
311
+ const manifest = readManifest(file);
312
+ if (!manifest)
313
+ throw new AppError("engine_not_found", `Engine manifest not found: ${file}`, 1, { manifest: file });
314
+ return manifest;
315
+ }
316
+ function readManifest(file) {
317
+ try {
318
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
319
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
320
+ return null;
321
+ const record = parsed;
322
+ if (record.schema_id !== engineManifestSchemaId)
323
+ return null;
324
+ return record;
325
+ }
326
+ catch {
327
+ return null;
328
+ }
329
+ }
330
+ function manifestHealthy(manifest) {
331
+ return manifestDiagnostics(manifest).length === 0;
332
+ }
333
+ function manifestDiagnostics(manifest) {
334
+ const diagnostics = [];
335
+ if (manifest.schema_id !== engineManifestSchemaId)
336
+ diagnostics.push("schema_id_mismatch");
337
+ if (!manifest.package_name || !manifest.package_version)
338
+ diagnostics.push("package_identity_missing");
339
+ const snapshotRoot = path.resolve(manifest.snapshot_root || manifest.package_root || "");
340
+ if (!snapshotRoot || !fs.existsSync(snapshotRoot))
341
+ diagnostics.push("snapshot_root_missing");
342
+ const entrypoint = safeResolveManifestEntrypoint(manifest);
343
+ if (!entrypoint)
344
+ diagnostics.push("entrypoint_outside_snapshot");
345
+ else if (!fs.existsSync(entrypoint))
346
+ diagnostics.push("entrypoint_missing");
347
+ return diagnostics;
348
+ }
349
+ function manifestSummary(manifest) {
350
+ return {
351
+ schema_id: manifest.schema_id,
352
+ package_name: manifest.package_name,
353
+ package_version: manifest.package_version,
354
+ engine_version: manifest.engine_version,
355
+ install_source: manifest.install_source,
356
+ entrypoint: manifest.entrypoint,
357
+ snapshot_root: manifest.snapshot_root,
358
+ resolved_entrypoint: safeResolveManifestEntrypoint(manifest),
359
+ installed_at: manifest.installed_at,
360
+ healthy: manifestHealthy(manifest),
361
+ diagnostics: manifestDiagnostics(manifest)
362
+ };
363
+ }
364
+ function resolveManifestEntrypoint(manifest) {
365
+ const resolved = safeResolveManifestEntrypoint(manifest);
366
+ if (!resolved) {
367
+ throw new AppError("unhealthy_engine", "Selected dd-flow engine entrypoint escapes its snapshot", 1, {
368
+ engine: manifestSummary(manifest)
369
+ });
370
+ }
371
+ return resolved;
372
+ }
373
+ function safeResolveManifestEntrypoint(manifest) {
374
+ const snapshotRoot = path.resolve(manifest.snapshot_root || manifest.package_root);
375
+ const candidate = path.isAbsolute(manifest.entrypoint)
376
+ ? path.resolve(manifest.entrypoint)
377
+ : path.resolve(snapshotRoot, manifest.entrypoint);
378
+ if (!isInside(snapshotRoot, candidate))
379
+ return null;
380
+ return candidate;
381
+ }
382
+ function isInside(root, candidate) {
383
+ const relative = path.relative(root, candidate);
384
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative));
385
+ }
386
+ function spawnEngine(manifest, entrypoint, args, io, stdin, env, routedFromVersion) {
387
+ return new Promise((resolve, reject) => {
388
+ const child = spawn(process.execPath, [entrypoint, ...args], {
389
+ env: {
390
+ ...process.env,
391
+ ...env,
392
+ DD_FLOW_ENGINE_MODE: "1",
393
+ DD_FLOW_ENGINE_HOME: manifest.snapshot_root,
394
+ DD_FLOW_ROUTED_FROM: routedFromVersion
395
+ },
396
+ stdio: ["pipe", "pipe", "pipe"]
397
+ });
398
+ const forwardSignal = (signal) => {
399
+ if (!child.killed)
400
+ child.kill(signal);
401
+ };
402
+ process.once("SIGINT", forwardSignal);
403
+ process.once("SIGTERM", forwardSignal);
404
+ child.stdout.on("data", (chunk) => io.stdout.write(chunk));
405
+ child.stderr.on("data", (chunk) => io.stderr.write(chunk));
406
+ child.on("error", (error) => {
407
+ process.off("SIGINT", forwardSignal);
408
+ process.off("SIGTERM", forwardSignal);
409
+ reject(new AppError("engine_dispatch_failed", `Failed to execute selected dd-flow engine: ${error.message}`, 1, {
410
+ engine: manifestSummary(manifest),
411
+ cause: String(error)
412
+ }));
413
+ });
414
+ child.on("close", (code, signal) => {
415
+ process.off("SIGINT", forwardSignal);
416
+ process.off("SIGTERM", forwardSignal);
417
+ if (signal)
418
+ resolve({ routed: true, exitCode: 128 + signalNumber(signal), inProcess: false });
419
+ else
420
+ resolve({ routed: true, exitCode: code ?? 1, inProcess: false });
421
+ });
422
+ if (stdin)
423
+ stdin.pipe(child.stdin);
424
+ else
425
+ child.stdin.end();
426
+ });
427
+ }
428
+ function signalNumber(signal) {
429
+ if (signal === "SIGINT")
430
+ return 2;
431
+ if (signal === "SIGTERM")
432
+ return 15;
433
+ return 1;
434
+ }
435
+ function missingEngineError(selection, classification = classifyCliOperation(["unknown"])) {
436
+ return {
437
+ code: "missing_engine",
438
+ message: `No installed dd-flow engine satisfies ${selection.package_name} ${selection.required_range ?? ""}`.trim(),
439
+ details: missingEngineDetails(selection, classification)
440
+ };
441
+ }
442
+ function missingEngineDetails(selection, classification) {
443
+ return {
444
+ package_name: selection.package_name,
445
+ required_range: selection.required_range,
446
+ recommended_version: selection.recommended_version,
447
+ install_hint: selection.install_hint,
448
+ project_root: selection.project_root,
449
+ memory_bank_version: selection.memory_bank_version,
450
+ diagnostics: selection.diagnostics,
451
+ compatibility: missingEngineCompatibilityReport(selection, classification)
452
+ };
453
+ }
454
+ function missingEngineCompatibilityReport(selection, classification) {
455
+ const build = getCliBuildInfo();
456
+ return {
457
+ verdict: "incompatible",
458
+ memorybank_version: selection.memory_bank_version,
459
+ router_version: build.version,
460
+ cli_version: build.version,
461
+ engine_version: null,
462
+ engine_resolution: selection.status,
463
+ required_engine_range: selection.required_range,
464
+ recommended_engine_version: selection.recommended_version,
465
+ allowed_modes: ["read_only_diagnostics", "mb_upgrade"],
466
+ operation_mode: classification.mode,
467
+ blocked_operation: classification.mode === "normal_write" ? classification.operation : null,
468
+ install_hint: selection.install_hint,
469
+ project_root: selection.project_root,
470
+ diagnostics: selection.diagnostics
471
+ };
472
+ }
473
+ function findPackageRoot(start) {
474
+ let current = path.resolve(start);
475
+ while (true) {
476
+ if (fs.existsSync(path.join(current, "package.json")))
477
+ return current;
478
+ const parent = path.dirname(current);
479
+ if (parent === current)
480
+ return null;
481
+ current = parent;
482
+ }
483
+ }
484
+ function fileListChecksum(root) {
485
+ const files = [];
486
+ collectFiles(root, root, files);
487
+ return Buffer.from(files.sort().join("\n")).toString("base64url");
488
+ }
489
+ function collectFiles(root, current, files) {
490
+ for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
491
+ if (entry.name === "engine.json")
492
+ continue;
493
+ const full = path.join(current, entry.name);
494
+ if (entry.isDirectory())
495
+ collectFiles(root, full, files);
496
+ else if (entry.isFile())
497
+ files.push(path.relative(root, full));
498
+ }
499
+ }
500
+ function stringValue(value) {
501
+ return typeof value === "string" && value.length > 0 ? value : null;
502
+ }
503
+ function satisfiesRange(version, range) {
504
+ const parts = range.split(/\s+/).filter(Boolean);
505
+ return parts.every((part) => {
506
+ if (part.startsWith(">="))
507
+ return compareSemverStrings(version, part.slice(2)) >= 0;
508
+ if (part.startsWith(">"))
509
+ return compareSemverStrings(version, part.slice(1)) > 0;
510
+ if (part.startsWith("<="))
511
+ return compareSemverStrings(version, part.slice(2)) <= 0;
512
+ if (part.startsWith("<"))
513
+ return compareSemverStrings(version, part.slice(1)) < 0;
514
+ return version === part;
515
+ });
516
+ }
517
+ function compareSemverStrings(left, right) {
518
+ const a = parseSemver(left);
519
+ const b = parseSemver(right);
520
+ if (!a || !b)
521
+ return left.localeCompare(right);
522
+ for (const index of [0, 1, 2]) {
523
+ const diff = a[index] - b[index];
524
+ if (diff !== 0)
525
+ return diff;
526
+ }
527
+ return 0;
528
+ }
529
+ function parseSemver(value) {
530
+ const match = value.match(/^(\d+)\.(\d+)\.(\d+)/);
531
+ return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
532
+ }
@@ -1,10 +1,13 @@
1
1
  import path from "node:path";
2
2
  import { defaultFlowContract, flowContractForState } from "../domain/flow-contract.js";
3
+ import { normalizeProtocolLifecycle } from "./protocol-lifecycle.js";
3
4
  export function buildProtocolFlowGuidance(input) {
4
5
  const contract = flowContractForState(input.state);
6
+ const lifecycle = normalizeProtocolLifecycle({ state: input.state, queueStatus: input.queueStatus ?? null, flowContract: contract });
5
7
  const stageChain = latestRunStageChain(input.latestRun);
6
8
  return buildFlowGuidance({
7
9
  currentStage: input.state.stage,
10
+ lifecycle,
8
11
  contract,
9
12
  stageChain,
10
13
  runId: stringValue(input.latestRun?.id),
@@ -17,6 +20,7 @@ export function buildRunFlowGuidance(input) {
17
20
  const currentStage = input.protocolStage ?? inferStageFromRun(input.stageRuns);
18
21
  return buildFlowGuidance({
19
22
  currentStage,
23
+ lifecycle: normalizeProtocolLifecycle({ rawStage: currentStage, rawStatus: "running", flowContract: contract }),
20
24
  contract,
21
25
  stageChain: chain,
22
26
  runId: input.runId ?? null,
@@ -25,9 +29,11 @@ export function buildRunFlowGuidance(input) {
25
29
  });
26
30
  }
27
31
  export function buildStaticFlowGuidance(input) {
32
+ const contract = input.contract ?? defaultFlowContract;
28
33
  return buildFlowGuidance({
29
34
  currentStage: input.stage,
30
- contract: input.contract ?? defaultFlowContract,
35
+ lifecycle: normalizeProtocolLifecycle({ rawStage: input.stage, rawStatus: input.status ?? "running", queueStatus: input.queueStatus ?? null, flowContract: contract }),
36
+ contract,
31
37
  stageChain: [],
32
38
  runId: null,
33
39
  queueStatus: input.queueStatus ?? null
@@ -148,6 +154,7 @@ function buildFlowGuidance(input) {
148
154
  function guidance(input, value) {
149
155
  return {
150
156
  current_stage: input.currentStage,
157
+ ...(input.lifecycle ? { lifecycle: input.lifecycle } : {}),
151
158
  allowed_next_stages: input.contract.transitions[input.currentStage] ?? [],
152
159
  recommended_next_action: value.action,
153
160
  recommended_prompt: value.prompt,
@@ -801,7 +801,7 @@ function mergeQueueMutation(command) {
801
801
  return match?.[1] ?? null;
802
802
  }
803
803
  function mergeLaneLockMutation(command) {
804
- return /\bdd-flow\s+lane\s+lock\s+(acquire|heartbeat|release|wait)\b/.test(command) && /(?:--lane(?:\s+|=)(?:"merge"|'merge'|merge)\b)/.test(command);
804
+ return /\bdd-flow\s+lane\s+lock\s+(acquire|heartbeat|release|wait|wait-acquire)\b/.test(command) && /(?:--lane(?:\s+|=)(?:"merge"|'merge'|merge)\b)/.test(command);
805
805
  }
806
806
  function selfWorktreeRemovalTarget(command, cwd) {
807
807
  if (!/\bgit\s+worktree\s+remove\b/.test(command)) {