@guanlin4924/devflow-pi 0.1.13 → 0.1.14

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@guanlin4924/devflow-pi",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
4
4
  "type": "module",
5
5
  "description": "Devflow 专项研发 Pi,基于固定版本 OMP Runtime 组装研发状态机、代码智能、设计、实现与质量门禁。",
6
6
  "license": "UNLICENSED",
@@ -1,4 +1,4 @@
1
- import { randomUUID } from "node:crypto";
1
+ import { createHash, randomUUID } from "node:crypto";
2
2
  import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from "node:fs";
3
3
  import { mkdir, rename, writeFile } from "node:fs/promises";
4
4
  import path from "node:path";
@@ -42,6 +42,10 @@ const READ_ONLY_TOOLS = new Set([
42
42
  ]);
43
43
 
44
44
  const REQUIREMENT_DRAFT_TOOLS = new Set([...READ_ONLY_TOOLS, "ask"]);
45
+ // A Todo update changes only the client-visible task list, never the project
46
+ // or Devflow state. Permit it during planning so a harmless checklist cannot
47
+ // produce a misleading "blocked" warning during initialization.
48
+ const PLANNING_INTERNAL_TOOLS = new Set(["todo"]);
45
49
 
46
50
  const PLANNING_AGENTS = new Set(["project-scout", "requirements-auditor", "contract-auditor"]);
47
51
 
@@ -93,6 +97,7 @@ export function statusText(state: SessionControlState): string {
93
97
 
94
98
  export function isPlanningToolAllowed(toolName: string, taskAgents: string[] = []): boolean {
95
99
  const normalized = toolName.toLowerCase();
100
+ if (PLANNING_INTERNAL_TOOLS.has(normalized)) return true;
96
101
  if (READ_ONLY_TOOLS.has(normalized)) return true;
97
102
  if (normalized !== "task") return false;
98
103
  return taskAgents.length > 0 && taskAgents.every(agent => PLANNING_AGENTS.has(agent));
@@ -105,6 +110,7 @@ export function isReadOnlyShellCommand(command: string): boolean {
105
110
 
106
111
  export function isRequirementDraftToolAllowed(toolName: string, input: unknown, taskAgents: string[] = []): boolean {
107
112
  const normalized = toolName.toLowerCase();
113
+ if (PLANNING_INTERNAL_TOOLS.has(normalized)) return true;
108
114
  if (["bash", "shell", "exec_command"].includes(normalized)) {
109
115
  const record = input && typeof input === "object" ? input as Record<string, unknown> : {};
110
116
  return isReadOnlyShellCommand(String(record.command ?? record.cmd ?? ""));
@@ -113,11 +119,21 @@ export function isRequirementDraftToolAllowed(toolName: string, input: unknown,
113
119
  return normalized === "task" && taskAgents.length > 0 && taskAgents.every(agent => PLANNING_AGENTS.has(agent));
114
120
  }
115
121
 
122
+ /** `xd://devflow_contract` is an in-memory OMP control resource, not a
123
+ * project file. The planner must be able to read and activate it while the
124
+ * filesystem remains read-only; otherwise initialization deadlocks before it
125
+ * can create a bounded execution contract. */
126
+ export function isInternalDevflowContractResource(toolName: string, input: unknown): boolean {
127
+ if (!["read", "write"].includes(toolName.toLowerCase()) || !input || typeof input !== "object") return false;
128
+ const record = input as Record<string, unknown>;
129
+ return [record.path, record.uri, record.resource].some(value => value === "xd://devflow_contract");
130
+ }
131
+
116
132
  export type NaturalControlAction = "approve" | "reject";
117
133
 
118
134
  export function classifyNaturalControlAction(input: string): NaturalControlAction | null {
119
135
  const text = input.trim();
120
- if (/^(?:批准|确认|同意|通过)(?:执行|继续|推进|当前(?:方案|步骤|计划))?[。!!]?$/.test(text)) return "approve";
136
+ if (/^(?:批准|确认|同意|通过)(?:\s*(?:(?:full|完整|全量)?\s*初始化|执行|继续|推进|当前(?:方案|步骤|计划)))?[。!!]?$/i.test(text)) return "approve";
121
137
  if (/^(?:拒绝|不同意|暂不|需要修改|退回)(?:执行|继续|推进|当前(?:方案|步骤|计划))?[。!!]?$/.test(text)) return "reject";
122
138
  return null;
123
139
  }
@@ -256,6 +272,7 @@ const REQUIRED_AGENT_NAMES = new Set(Object.keys(REQUIRED_AGENTS));
256
272
  const REQUIRED_SCRIPTS = [
257
273
  "workflow-orchestrator/scripts/init_workflow.rb",
258
274
  "workflow-orchestrator/scripts/init_state.rb",
275
+ "workflow-orchestrator/scripts/phase_ownership.rb",
259
276
  "workflow-orchestrator/scripts/inspect_state.rb",
260
277
  "workflow-orchestrator/scripts/advance_state.rb",
261
278
  "workflow-orchestrator/scripts/sync_artifacts.rb",
@@ -263,6 +280,17 @@ const REQUIRED_SCRIPTS = [
263
280
  "workflow-context-sync/scripts/context_sync_cursor.py",
264
281
  ];
265
282
 
283
+ function defaultPhaseId(feature: string): string {
284
+ // STATE.md is project-global, so a generic name such as "delivery" is not
285
+ // a safe default. Keep the generated id filesystem-safe and deterministic
286
+ // without requiring a user to learn a phase-naming command.
287
+ return `feature-${createHash("sha256").update(feature, "utf8").digest("hex").slice(0, 12)}`;
288
+ }
289
+
290
+ function validPhaseId(phase: string): boolean {
291
+ return /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(phase);
292
+ }
293
+
266
294
  function resolvePluginRoot(): string {
267
295
  const moduleDirectory = path.dirname(fileURLToPath(import.meta.url));
268
296
  return path.resolve(process.env.DEVFLOW_PLUGIN_ROOT || path.resolve(moduleDirectory, "../.."));
@@ -490,6 +518,13 @@ function parseJsonResult(result: ProcessResult): Record<string, unknown> | null
490
518
  }
491
519
  }
492
520
 
521
+ /** Ruby control-plane scripts report expected validation failures as JSON with
522
+ * `ok: false` and intentionally keep a zero process exit code. Never mistake
523
+ * that structured failure for a successful state mutation. */
524
+ function controlPlaneSucceeded(result: ProcessResult): boolean {
525
+ return result.ok && parseJsonResult(result)?.ok === true;
526
+ }
527
+
493
528
  function compactStep(value: unknown): Record<string, unknown> | null {
494
529
  if (!value || typeof value !== "object") return null;
495
530
  const step = value as Record<string, unknown>;
@@ -542,6 +577,17 @@ function workflowDirective(snapshot: Record<string, unknown>, source: "start" |
542
577
  ].join("\n");
543
578
  }
544
579
 
580
+ function projectInitializationDirective(summary: string): string {
581
+ return [
582
+ "<devflow-project-initialization-directive>",
583
+ "用户已批准完整项目初始化。现在处于执行模式;不要再创建 Todo、不要读取或写入 xd://devflow_contract。",
584
+ "执行 skill://workflow-project-context,按已确认的项目边界建立 .planning/PROJECT.md、.planning/navigation/INDEX.md、.context/spec/index.md,并按用户选择建立 full 代码索引。",
585
+ "只写项目初始化所需的 .planning/ 与 .context/ 控制产物;不得改动业务源码、依赖或配置。完成后运行项目初始化校验,报告生成文件和验证结果并停止,等待用户审查。",
586
+ summary,
587
+ "</devflow-project-initialization-directive>",
588
+ ].join("\n");
589
+ }
590
+
545
591
  function explainWorkflowSnapshot(snapshot: Record<string, unknown> | null): string {
546
592
  if (!snapshot) return "Devflow 当前没有可解释的工作流状态。可使用 /devflow start <feature> 创建新 Feature。";
547
593
  const step = compactStep(snapshot.next_step) ?? compactStep(snapshot.current_step);
@@ -881,6 +927,7 @@ export default function devflowCore(pi: ExtensionAPI) {
881
927
  }
882
928
 
883
929
  let activeWorkflowTurn = false;
930
+ let pendingProjectInitialization: string | undefined;
884
931
 
885
932
  async function triggerWorkflow(snapshot: Record<string, unknown>, source: "start" | "approval", requirementDraft?: string) {
886
933
  activeWorkflowTurn = true;
@@ -933,6 +980,21 @@ export default function devflowCore(pi: ExtensionAPI) {
933
980
  await triggerWorkflow(inspected.snapshot, "start", requirementDraft);
934
981
  return { handled: true };
935
982
  }
983
+ if (controlState.intent === "project-init" && controlState.phase === "planning") {
984
+ if (controlAction === "approve") {
985
+ if (!pendingProjectInitialization) {
986
+ ctx.ui.notify("项目初始化缺少已确认的边界信息,请重新发起初始化。", "error");
987
+ return { handled: true };
988
+ }
989
+ setControlState(ctx, executingState("执行已批准的项目初始化"));
990
+ activeWorkflowTurn = true;
991
+ return { text: projectInitializationDirective(pendingProjectInitialization), handled: false };
992
+ }
993
+ if (controlAction === "reject") {
994
+ ctx.ui.notify("已保留初始化规划,未创建任何项目控制文件。请直接说明需要调整的初始化范围。", "info");
995
+ return { handled: true };
996
+ }
997
+ }
936
998
  if (controlState.phase === "awaiting-approval" || controlState.phase === "waiting-user") {
937
999
  if (controlAction === "reject") {
938
1000
  ctx.ui.notify("已保留当前步骤和产物。请直接说明需要修改的内容;未执行任何推进。", "info");
@@ -985,6 +1047,7 @@ export default function devflowCore(pi: ExtensionAPI) {
985
1047
  if (intent === "project-init") {
986
1048
  const summary = await collectProjectInitialization(ctx);
987
1049
  if (!summary) return { handled: true };
1050
+ pendingProjectInitialization = summary;
988
1051
  const state = planningState("project-init");
989
1052
  setControlState(ctx, state);
990
1053
  return {
@@ -1051,6 +1114,7 @@ export default function devflowCore(pi: ExtensionAPI) {
1051
1114
  } else if (
1052
1115
  controlState.phase === "planning" &&
1053
1116
  toolName !== "devflow_contract" &&
1117
+ !isInternalDevflowContractResource(toolName, event.input) &&
1054
1118
  !(toolName === "devflow_evidence" && String((event.input as Record<string, unknown> | undefined)?.action) !== "create") &&
1055
1119
  !(controlState.intent === "requirement-draft"
1056
1120
  ? isRequirementDraftToolAllowed(toolName, event.input)
@@ -1342,35 +1406,54 @@ export default function devflowCore(pi: ExtensionAPI) {
1342
1406
  description: "Initialize workflow definitions, STATE.md, and the artifact registry for a project feature.",
1343
1407
  parameters: z.object({
1344
1408
  feature: z.string().min(1),
1345
- phase: z.string().min(1).default("delivery"),
1409
+ phase: z.string().min(1).optional(),
1346
1410
  flow: z.string().min(1).default("feature-development"),
1347
1411
  force: z.boolean().default(false),
1348
1412
  }),
1349
1413
  approval: "write",
1350
1414
  async execute(_id, params, _signal, _onUpdate, ctx) {
1415
+ const phase = params.phase?.trim() || defaultPhaseId(params.feature);
1416
+ if (!validPhaseId(phase)) {
1417
+ return {
1418
+ content: [{ type: "text" as const, text: "Phase ID 必须是 1-64 位字母、数字、下划线或连字符,且不得包含路径分隔符。" }],
1419
+ isError: true,
1420
+ };
1421
+ }
1351
1422
  const force = params.force ? ["--force"] : [];
1423
+ const ownership = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/phase_ownership.rb", [
1424
+ "claim",
1425
+ "--feature",
1426
+ params.feature,
1427
+ "--phase",
1428
+ phase,
1429
+ "--flow",
1430
+ params.flow,
1431
+ "--json",
1432
+ ]);
1433
+ if (!controlPlaneSucceeded(ownership)) return renderResult(ownership, { stage: "phase-ownership" });
1352
1434
  const workflow = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/init_workflow.rb", [
1353
1435
  "--write",
1354
1436
  "--json",
1355
1437
  ...force,
1356
1438
  ]);
1357
- if (!workflow.ok) return renderResult(workflow, { stage: "workflow" });
1439
+ if (!controlPlaneSucceeded(workflow)) return renderResult(workflow, { stage: "workflow" });
1358
1440
  const state = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/init_state.rb", [
1359
1441
  "--feature",
1360
1442
  params.feature,
1361
1443
  "--phase",
1362
- params.phase,
1444
+ phase,
1363
1445
  "--flow",
1364
1446
  params.flow,
1365
1447
  "--write",
1366
1448
  "--json",
1367
1449
  ...force,
1368
1450
  ]);
1369
- if (!state.ok) return renderResult(state, { stage: "state" });
1451
+ if (!controlPlaneSucceeded(state)) return renderResult(state, { stage: "state" });
1370
1452
  const artifacts = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/sync_artifacts.rb", [
1371
1453
  "--write",
1372
1454
  "--json",
1373
1455
  ]);
1456
+ if (!controlPlaneSucceeded(artifacts)) return renderResult(artifacts, { stage: "artifacts" });
1374
1457
  return renderResult(artifacts, {
1375
1458
  stage: "complete",
1376
1459
  workflow: workflow.stdout,
@@ -1455,7 +1538,8 @@ export default function devflowCore(pi: ExtensionAPI) {
1455
1538
  approval: "write",
1456
1539
  async execute(_id, params, _signal, _onUpdate, ctx) {
1457
1540
  const args = ["--write", "--confirm", "--expected-revision", String(params.expectedRevision), "--json"];
1458
- if (params.state) args.push("--state", params.state);
1541
+ const state = safeControlPath(ctx.cwd, params.state, "state");
1542
+ if (state) args.push("--state", state);
1459
1543
  return renderResult(
1460
1544
  await runRuby(ctx.cwd, "workflow-orchestrator/scripts/repair_control_plane.rb", args),
1461
1545
  { action: "repair-control-plane" },
@@ -1573,6 +1657,7 @@ export default function devflowCore(pi: ExtensionAPI) {
1573
1657
  if (trimmed === "project-init") {
1574
1658
  const summary = await collectProjectInitialization(ctx);
1575
1659
  if (!summary) return;
1660
+ pendingProjectInitialization = summary;
1576
1661
  setControlState(ctx, planningState("project-init"));
1577
1662
  await pi.sendUserMessage(
1578
1663
  [
@@ -1617,11 +1702,16 @@ export default function devflowCore(pi: ExtensionAPI) {
1617
1702
  return;
1618
1703
  }
1619
1704
  if (trimmed.startsWith("start ")) {
1620
- const [feature, phase = "delivery"] = trimmed.slice(6).split("|", 2).map(value => value.trim());
1705
+ const [feature, requestedPhase] = trimmed.slice(6).split("|", 2).map(value => value.trim());
1621
1706
  if (!feature) {
1622
1707
  ctx.ui.notify("Usage: /devflow start <feature>|<phase>", "error");
1623
1708
  return;
1624
1709
  }
1710
+ const phase = requestedPhase || defaultPhaseId(feature);
1711
+ if (!validPhaseId(phase)) {
1712
+ ctx.ui.notify("Phase ID 必须是 1-64 位字母、数字、下划线或连字符,且不得包含路径分隔符。", "error");
1713
+ return;
1714
+ }
1625
1715
  const report = await runDoctor(ctx);
1626
1716
  if (!report.ok) {
1627
1717
  ctx.ui.notify(formatDoctorReport(report).slice(0, 10000), "error");
@@ -1636,16 +1726,33 @@ export default function devflowCore(pi: ExtensionAPI) {
1636
1726
  existingFeature !== feature
1637
1727
  ) {
1638
1728
  ctx.ui.notify(
1639
- `An unfinished Devflow state belongs to feature ${existingFeature}. Use /devflow resume and complete that workflow before starting ${feature}.`,
1729
+ `当前仍有未完成需求:${existingFeature}。请在对话中继续处理当前步骤,完成后再创建新需求:${feature}。`,
1640
1730
  "error",
1641
1731
  );
1642
1732
  return;
1643
1733
  }
1734
+ // This dry-run happens before init_workflow.rb, so a rejected feature
1735
+ // can never overwrite generated workflow files belonging to an active
1736
+ // owner or a legacy phase directory that has no trustworthy owner.
1737
+ const ownership = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/phase_ownership.rb", [
1738
+ "claim",
1739
+ "--feature",
1740
+ feature,
1741
+ "--phase",
1742
+ phase,
1743
+ "--flow",
1744
+ "feature-development",
1745
+ "--json",
1746
+ ]);
1747
+ if (!controlPlaneSucceeded(ownership)) {
1748
+ ctx.ui.notify((ownership.stderr || ownership.stdout).slice(0, 6000), "error");
1749
+ return;
1750
+ }
1644
1751
  const workflow = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/init_workflow.rb", [
1645
1752
  "--write",
1646
1753
  "--json",
1647
1754
  ]);
1648
- if (!workflow.ok) {
1755
+ if (!controlPlaneSucceeded(workflow)) {
1649
1756
  ctx.ui.notify(workflow.stderr || workflow.stdout, "error");
1650
1757
  return;
1651
1758
  }
@@ -1660,7 +1767,7 @@ export default function devflowCore(pi: ExtensionAPI) {
1660
1767
  "--json",
1661
1768
  ...(previousWorkflowComplete ? ["--force"] : []),
1662
1769
  ]);
1663
- if (!state.ok) {
1770
+ if (!controlPlaneSucceeded(state)) {
1664
1771
  ctx.ui.notify((state.stderr || state.stdout).slice(0, 6000), "error");
1665
1772
  return;
1666
1773
  }
@@ -1668,7 +1775,7 @@ export default function devflowCore(pi: ExtensionAPI) {
1668
1775
  "--write",
1669
1776
  "--json",
1670
1777
  ]);
1671
- if (!artifacts.ok) {
1778
+ if (!controlPlaneSucceeded(artifacts)) {
1672
1779
  ctx.ui.notify((artifacts.stderr || artifacts.stdout).slice(0, 6000), "error");
1673
1780
  return;
1674
1781
  }
@@ -8,6 +8,7 @@ require "optparse"
8
8
  require "yaml"
9
9
  require_relative "control_io"
10
10
  require_relative "validate_state"
11
+ require_relative "phase_ownership"
11
12
 
12
13
  options = {
13
14
  state: nil,
@@ -35,7 +36,7 @@ OptionParser.new do |opts|
35
36
  end.parse!
36
37
 
37
38
  root = Dir.pwd
38
- state_path = options[:state] ? File.expand_path(options[:state], root) : File.join(root, ".planning", "STATE.md")
39
+ state_path, = DevflowControlIO.resolve_control_path(root, options[:state] || ".planning/STATE.md", :state)
39
40
 
40
41
  def front_matter(markdown)
41
42
  match = markdown.match(/\A---\n(.*?)\n---\n/m)
@@ -51,8 +52,9 @@ def workflow_path_for(root, state)
51
52
  end
52
53
 
53
54
  def load_workflow(root, state)
54
- path = workflow_path_for(root, state)
55
- [path, load_yaml_file(File.join(root, path))]
55
+ source = workflow_path_for(root, state)
56
+ path, relative = DevflowControlIO.resolve_control_path(root, source, :workflow)
57
+ [relative, load_yaml_file(path)]
56
58
  end
57
59
 
58
60
  def interpolate_path(path, state)
@@ -65,7 +67,7 @@ def load_artifact_registry(root, workflow)
65
67
  registry_path = workflow.dig("artifact_registry", "source")
66
68
  return {} unless registry_path
67
69
 
68
- abs = File.join(root, registry_path)
70
+ abs, = DevflowControlIO.resolve_control_path(root, registry_path, :registry)
69
71
  return {} unless File.exist?(abs)
70
72
 
71
73
  registry = load_yaml_file(abs)
@@ -79,7 +81,7 @@ def collect_outputs(workflow)
79
81
  end
80
82
 
81
83
  def content_validity(root, path, checks)
82
- abs = File.join(root, path)
84
+ abs, = DevflowControlIO.resolve_control_path(root, path, :artifact)
83
85
  return { "valid" => false, "issues" => ["file_missing"] } unless File.exist?(abs)
84
86
 
85
87
  issues = []
@@ -102,7 +104,7 @@ def build_artifact_registry(root, workflow, state)
102
104
  registry_path = workflow.dig("artifact_registry", "source")
103
105
  return [nil, nil] unless registry_path
104
106
 
105
- abs = File.join(root, registry_path)
107
+ abs, = DevflowControlIO.resolve_control_path(root, registry_path, :registry)
106
108
  existing_by_path = if File.exist?(abs)
107
109
  Array(load_yaml_file(abs)["artifacts"]).to_h { |artifact| [artifact.fetch("path"), artifact] }
108
110
  else
@@ -127,7 +129,8 @@ def build_artifact_registry(root, workflow, state)
127
129
  end
128
130
 
129
131
  validity = content_validity(root, path, content_checks)
130
- file_exists = File.exist?(File.join(root, path))
132
+ artifact_path, = DevflowControlIO.resolve_control_path(root, path, :artifact)
133
+ file_exists = File.exist?(artifact_path)
131
134
  status = if preservable_statuses.include?(existing["status"])
132
135
  existing["status"]
133
136
  else
@@ -169,7 +172,7 @@ def sync_artifact_registry(root, workflow, state, registry: nil)
169
172
  return nil unless registry_path
170
173
 
171
174
  registry ||= build_artifact_registry(root, workflow, state).last
172
- abs = File.join(root, registry_path)
175
+ abs, = DevflowControlIO.resolve_control_path(root, registry_path, :registry)
173
176
  DevflowControlIO.atomic_write(abs, registry.to_yaml)
174
177
  registry_path
175
178
  end
@@ -348,7 +351,7 @@ def apply_return_to!(root, state, branch_workflow)
348
351
  source_workflow_config = state["source_workflow_config"]
349
352
  return nil unless return_to && source_flow && source_workflow_config
350
353
 
351
- main_workflow_path = File.join(root, source_workflow_config)
354
+ main_workflow_path, = DevflowControlIO.resolve_control_path(root, source_workflow_config, :workflow)
352
355
  return nil unless File.exist?(main_workflow_path)
353
356
 
354
357
  main_workflow = load_yaml_file(main_workflow_path)
@@ -512,7 +515,8 @@ result =
512
515
  result = fail_result("missing_failure_branch", "current_step" => state["current_step"])
513
516
  else
514
517
  branch_path = branch.fetch("workflow_config")
515
- branch_workflow = load_yaml_file(File.join(root, branch_path))
518
+ branch_workflow_path, branch_path = DevflowControlIO.resolve_control_path(root, branch_path, :workflow)
519
+ branch_workflow = load_yaml_file(branch_workflow_path)
516
520
  transition = branch_workflow.dig("state_contract", "suggested_transition") || {}
517
521
  state["source_flow"] = state["current_flow"]
518
522
  state["source_workflow_config"] = workflow_path
@@ -553,6 +557,13 @@ result =
553
557
  state["revision"] = current_revision + 1
554
558
  workflow_path, workflow = load_workflow(root, state)
555
559
  end
560
+ prospective_owner = if changed
561
+ {
562
+ "phase" => state["current_phase"], "feature" => state["current_feature"],
563
+ "flow" => state["current_flow"], "state_path" => ".planning/STATE.md",
564
+ "scope" => "global", "status" => "active"
565
+ }
566
+ end
556
567
  # Build the registry from the candidate state before validating it. A
557
568
  # step's output is normally written before this transition, so the
558
569
  # persisted registry may still describe the previous state revision.
@@ -564,16 +575,37 @@ result =
564
575
  state_override: state,
565
576
  artifact_registry_override: candidate_registry,
566
577
  strict_artifact_status: false,
567
- strict_registry_revision: false
578
+ strict_registry_revision: false,
579
+ phase_owner_override: prospective_owner
568
580
  )
569
581
 
570
582
  if changed && !validation["ok"]
571
583
  result = fail_result("state_validation_failed", "validation" => validation)
572
584
  elsif changed && options[:write]
573
- sync_artifact_registry(root, workflow, state, registry: candidate_registry)
574
- artifact_by_path = load_artifact_registry(root, workflow)
575
- write_state_file(root, state_path, state, workflow, artifact_by_path, action_description)
576
- next_intent_written = write_next_intent(root, workflow) if next_intent_requested
585
+ ownership = PhaseOwnership.claim(
586
+ root: root, feature: state["current_feature"], phase: state["current_phase"],
587
+ flow: state["current_flow"], write: true
588
+ )
589
+ if !ownership["ok"]
590
+ result = fail_result("phase_ownership_claim_failed", "ownership" => ownership)
591
+ else
592
+ sync_artifact_registry(root, workflow, state, registry: candidate_registry)
593
+ artifact_by_path = load_artifact_registry(root, workflow)
594
+ write_state_file(root, state_path, state, workflow, artifact_by_path, action_description)
595
+ # STATE.md is project-global. Once its top-level workflow is
596
+ # terminal, retain the historical owner but release its active
597
+ # reservation so the next feature can claim a distinct phase.
598
+ if %w[completed failed cancelled].include?(state["status"]) && Array(state["pending_steps"]).empty? && state["source_flow"].nil?
599
+ completed_owner = PhaseOwnership.complete(
600
+ root: root, feature: state["current_feature"], phase: state["current_phase"],
601
+ flow: state["current_flow"], write: true
602
+ )
603
+ unless completed_owner["ok"]
604
+ result = fail_result("phase_ownership_completion_failed", "ownership" => completed_owner)
605
+ end
606
+ end
607
+ next_intent_written = write_next_intent(root, workflow) if next_intent_requested
608
+ end
577
609
  end
578
610
 
579
611
  result ||= {
@@ -2,14 +2,56 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "fileutils"
5
+ require "pathname"
5
6
  require "securerandom"
6
7
 
7
8
  # Shared durability primitives for every writer in the Devflow control plane.
8
9
  # The project lock serializes state, workflow, registry, and next-intent updates;
9
10
  # atomic rename prevents readers from observing partially written documents.
10
11
  module DevflowControlIO
12
+ class InvalidControlPath < StandardError; end
13
+
11
14
  module_function
12
15
 
16
+ # Control-plane files are not general-purpose CLI file arguments. Keeping
17
+ # their location fixed prevents a crafted STATE/workflow/registry reference
18
+ # from turning recovery or synchronization into an out-of-project writer.
19
+ def resolve_control_path(root, raw, kind)
20
+ root_path = File.realpath(root)
21
+ value = raw.to_s
22
+ raise InvalidControlPath, "#{kind} path is required" if value.empty?
23
+ raise InvalidControlPath, "#{kind} path must be project-relative" if Pathname.new(value).absolute?
24
+ raise InvalidControlPath, "#{kind} path contains control characters" if value.match?(/[\x00-\x1F\x7F]/)
25
+
26
+ normalized = Pathname.new(value).cleanpath.to_s.tr("\\", "/")
27
+ raise InvalidControlPath, "#{kind} path cannot traverse parent directories" if normalized == ".." || normalized.start_with?("../")
28
+ allowed = case kind
29
+ when :state then normalized == ".planning/STATE.md"
30
+ when :workflow then normalized.match?(%r{\A\.planning/workflows/[A-Za-z0-9][A-Za-z0-9_-]*\.yaml\z})
31
+ when :registry then normalized == ".planning/artifacts/index.yaml"
32
+ when :artifact then normalized.start_with?(".planning/") || normalized.start_with?(".context/")
33
+ else false
34
+ end
35
+ raise InvalidControlPath, "invalid #{kind} control path" unless allowed
36
+
37
+ absolute = File.expand_path(normalized, root_path)
38
+ raise InvalidControlPath, "#{kind} path escapes project root" unless absolute.start_with?("#{root_path}#{File::SEPARATOR}")
39
+ # Existing symlinks in any parent component are equally unsafe: a write
40
+ # through one could escape after lexical normalization.
41
+ cursor = root_path
42
+ normalized.split("/").each do |segment|
43
+ cursor = File.join(cursor, segment)
44
+ next unless File.exist?(cursor) || File.symlink?(cursor)
45
+ next unless File.symlink?(cursor)
46
+
47
+ resolved = File.realpath(cursor)
48
+ unless resolved == root_path || resolved.start_with?("#{root_path}#{File::SEPARATOR}")
49
+ raise InvalidControlPath, "#{kind} path escapes project through a symlink"
50
+ end
51
+ end
52
+ [absolute, normalized]
53
+ end
54
+
13
55
  def acquire_project_lock(root)
14
56
  planning_dir = File.join(root, ".planning")
15
57
  FileUtils.mkdir_p(planning_dir)
@@ -2,11 +2,13 @@
2
2
  # frozen_string_literal: true
3
3
 
4
4
  require "date"
5
+ require "digest"
5
6
  require "fileutils"
6
7
  require "json"
7
8
  require "optparse"
8
9
  require "yaml"
9
10
  require_relative "control_io"
11
+ require_relative "phase_ownership"
10
12
 
11
13
  options = {
12
14
  feature: nil,
@@ -36,7 +38,12 @@ OptionParser.new do |opts|
36
38
  end.parse!
37
39
 
38
40
  root = Dir.pwd
39
- state_path = File.expand_path(options[:state], root)
41
+ begin
42
+ state_path, = DevflowControlIO.resolve_control_path(root, options[:state], :state)
43
+ rescue DevflowControlIO::InvalidControlPath => error
44
+ puts JSON.pretty_generate({ "ok" => false, "reason" => "invalid_control_path", "message" => error.message })
45
+ exit 0
46
+ end
40
47
 
41
48
  def front_matter(markdown)
42
49
  match = markdown.match(/\A---\n(.*?)\n---\n/m)
@@ -44,7 +51,13 @@ def front_matter(markdown)
44
51
  end
45
52
 
46
53
  feature = options[:feature] || File.basename(root)
47
- phase = options[:phase] || "01-#{feature}"
54
+ # CLI callers get the same safe invariant as the extension entrypoint: phase
55
+ # identifiers never derive from a user-facing feature label or a shared name.
56
+ phase = options[:phase] || "feature-#{Digest::SHA256.hexdigest(feature)[0, 12]}"
57
+ unless PhaseOwnership.valid_slug?(phase)
58
+ puts JSON.pretty_generate({ "ok" => false, "reason" => "invalid_phase", "message" => "phase must be a safe non-empty slug" })
59
+ exit 0
60
+ end
48
61
  workflow_config = ".planning/workflows/#{options[:flow]}.yaml"
49
62
  workflow_path = File.expand_path(workflow_config, root)
50
63
  fallback_pending_steps = [
@@ -104,6 +117,11 @@ state["active_track"] = options[:track] if %w[A B].include?(options[:track].to_s
104
117
  project_lock = DevflowControlIO.acquire_project_lock(root) if options[:write]
105
118
 
106
119
  begin
120
+ ownership = PhaseOwnership.claim(root: root, feature: feature, phase: phase, flow: options[:flow], write: options[:write])
121
+ unless ownership["ok"]
122
+ puts JSON.pretty_generate(ownership)
123
+ exit 0
124
+ end
107
125
  existing = File.exist?(state_path) ? File.read(state_path) : nil
108
126
  existing_front = existing && front_matter(existing)
109
127
  action =
@@ -129,6 +147,7 @@ result = {
129
147
  "written" => options[:write] && action != "skip_existing_front_matter",
130
148
  "path" => options[:state],
131
149
  "action" => action,
150
+ "phase_ownership" => ownership,
132
151
  "state" => state
133
152
  }
134
153
  ensure
@@ -0,0 +1,154 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # The control plane has one STATE.md, so phases must be claimed serially. This
5
+ # registry deliberately lives beside STATE.md instead of inside a phase folder:
6
+ # it remains visible even when a caller has selected the wrong phase directory.
7
+ require "date"
8
+ require "json"
9
+ require "optparse"
10
+ require "yaml"
11
+ require_relative "control_io"
12
+
13
+ module PhaseOwnership
14
+ REGISTRY_PATH = ".planning/phase-ownership.yaml"
15
+ PHASE_SLUG = /\A[A-Za-z0-9][A-Za-z0-9_-]*\z/
16
+ module_function
17
+
18
+ def valid_slug?(value)
19
+ value.is_a?(String) && value.match?(PHASE_SLUG)
20
+ end
21
+
22
+ # Feature labels are user-facing names and may be Unicode. They are never
23
+ # used as a registry filename here, but reject separators/control characters
24
+ # so a later caller cannot reinterpret them as a path.
25
+ def valid_feature?(value)
26
+ value.is_a?(String) && !value.strip.empty? && !value.match?(/[\\\/\x00-\x1F\x7F]/)
27
+ end
28
+
29
+ def registry_path(root)
30
+ File.join(root, REGISTRY_PATH)
31
+ end
32
+
33
+ def load_registry(root)
34
+ path = registry_path(root)
35
+ return [nil, nil] unless File.exist?(path)
36
+
37
+ registry = YAML.safe_load(File.read(path), permitted_classes: [], aliases: false)
38
+ return [nil, "invalid_phase_ownership_registry"] unless registry.is_a?(Hash) && registry["schema_version"] == 1 && registry["owners"].is_a?(Hash)
39
+
40
+ [registry, nil]
41
+ rescue Psych::Exception
42
+ [nil, "invalid_phase_ownership_registry"]
43
+ end
44
+
45
+ def valid_owner?(owner, phase: nil)
46
+ owner.is_a?(Hash) && valid_slug?(owner["phase"]) && (phase.nil? || owner["phase"] == phase) &&
47
+ valid_feature?(owner["feature"]) && owner["flow"].is_a?(String) && !owner["flow"].empty? &&
48
+ owner["state_path"] == ".planning/STATE.md" && %w[active completed].include?(owner["status"])
49
+ end
50
+
51
+ def nonempty_phase_dirs(root)
52
+ Dir.glob(File.join(root, ".planning", "phases", "*")).select do |path|
53
+ File.directory?(path) && !Dir.children(path).empty?
54
+ end.map { |path| File.basename(path) }
55
+ end
56
+
57
+ def claim(root:, feature:, phase:, flow:, write:)
58
+ return failure("invalid_phase", "phase must be a safe non-empty slug") unless valid_slug?(phase)
59
+ return failure("invalid_feature", "feature must be non-empty and cannot contain path separators or control characters") unless valid_feature?(feature)
60
+ return failure("invalid_flow", "flow is required") unless flow.is_a?(String) && !flow.empty?
61
+
62
+ registry, error = load_registry(root)
63
+ return failure(error, "phase ownership registry is malformed") if error
64
+ registry ||= { "schema_version" => 1, "owners" => {} }
65
+ owners = registry["owners"]
66
+
67
+ nonempty_phase_dirs(root).each do |phase_dir|
68
+ owner = owners[phase_dir]
69
+ next if valid_owner?(owner, phase: phase_dir)
70
+
71
+ return failure("legacy_phase_directory_without_owner", "non-empty phase directory has no valid ownership record", phase: phase_dir)
72
+ end
73
+
74
+ owners.each do |owner_phase, owner|
75
+ return failure("invalid_phase_owner", "phase ownership record is invalid", phase: owner_phase) unless valid_owner?(owner, phase: owner_phase)
76
+ next unless owner["status"] == "active"
77
+ next if owner["phase"] == phase && owner["feature"] == feature
78
+
79
+ return failure("active_phase_owned_by_another_feature", "an active phase already owns the global state", owner: owner)
80
+ end
81
+
82
+ current = owners[phase]
83
+ if current && current["feature"] != feature
84
+ return failure("phase_owned_by_another_feature", "requested phase is already owned by another feature", owner: current)
85
+ end
86
+
87
+ owner = current || { "phase" => phase, "feature" => feature, "flow" => flow, "state_path" => ".planning/STATE.md", "scope" => "global", "status" => "active" }
88
+ owner = owner.merge("phase" => phase, "feature" => feature, "flow" => flow, "state_path" => ".planning/STATE.md", "scope" => "global", "status" => "active")
89
+ registry["owners"][phase] = owner
90
+ registry["updated_at"] = Date.today.to_s
91
+ DevflowControlIO.atomic_write(registry_path(root), registry.to_yaml) if write
92
+ { "ok" => true, "written" => write, "action" => current ? "idempotent_claim" : "claim", "owner" => owner, "path" => REGISTRY_PATH }
93
+ end
94
+
95
+ def complete(root:, feature:, phase:, flow:, write:)
96
+ registry, error = load_registry(root)
97
+ return failure(error || "missing_phase_owner", "phase ownership record is required") unless registry
98
+ owner = registry["owners"][phase]
99
+ unless valid_owner?(owner, phase: phase) && owner["feature"] == feature && owner["flow"] == flow
100
+ return failure("phase_owner_mismatch", "cannot complete a phase owned by another feature")
101
+ end
102
+ owner["status"] = "completed"
103
+ registry["updated_at"] = Date.today.to_s
104
+ DevflowControlIO.atomic_write(registry_path(root), registry.to_yaml) if write
105
+ { "ok" => true, "written" => write, "action" => "complete", "owner" => owner, "path" => REGISTRY_PATH }
106
+ end
107
+
108
+ def validate_state(root:, state:, owner_override: nil)
109
+ registry, error = load_registry(root)
110
+ return failure(error || "missing_phase_owner", "STATE.md has no phase ownership record") unless registry
111
+ phase = state["current_phase"]
112
+ feature = state["current_feature"]
113
+ flow = state["current_flow"]
114
+ return failure("invalid_phase", "STATE.md current_phase must be a safe non-empty slug") unless valid_slug?(phase)
115
+ owner = owner_override || registry["owners"][phase]
116
+ return failure("missing_phase_owner", "STATE.md phase has no ownership record", phase: phase) unless valid_owner?(owner, phase: phase)
117
+ unless owner["feature"] == feature && owner["flow"] == flow && owner["state_path"] == ".planning/STATE.md"
118
+ return failure("phase_owner_mismatch", "STATE.md does not match phase ownership record", owner: owner)
119
+ end
120
+ conflicting = registry["owners"].values.find { |entry| valid_owner?(entry) && entry["status"] == "active" && entry["phase"] != phase }
121
+ return failure("active_phase_ownership_conflict", "another active phase owns the global state", owner: conflicting) if conflicting
122
+ { "ok" => true, "owner" => owner, "path" => REGISTRY_PATH }
123
+ end
124
+
125
+ def failure(reason, message, extra = {})
126
+ { "ok" => false, "reason" => reason, "message" => message }.merge(extra)
127
+ end
128
+ end
129
+
130
+ if __FILE__ == $PROGRAM_NAME
131
+ options = { feature: nil, phase: nil, flow: "feature-development", write: false, json: false }
132
+ command = ARGV.shift
133
+ OptionParser.new do |opts|
134
+ opts.banner = "Usage: phase_ownership.rb claim|inspect|complete --feature NAME --phase ID --flow ID [--write] [--json]"
135
+ opts.on("--feature NAME") { |value| options[:feature] = value }
136
+ opts.on("--phase ID") { |value| options[:phase] = value }
137
+ opts.on("--flow ID") { |value| options[:flow] = value }
138
+ opts.on("--write") { options[:write] = true }
139
+ opts.on("--json") { options[:json] = true }
140
+ end.parse!
141
+ root = Dir.pwd
142
+ lock = options[:write] ? DevflowControlIO.acquire_project_lock(root) : nil
143
+ begin
144
+ result = case command
145
+ when "claim" then PhaseOwnership.claim(root: root, feature: options[:feature], phase: options[:phase], flow: options[:flow], write: options[:write])
146
+ when "complete" then PhaseOwnership.complete(root: root, feature: options[:feature], phase: options[:phase], flow: options[:flow], write: options[:write])
147
+ when "inspect" then PhaseOwnership.validate_state(root: root, state: { "current_feature" => options[:feature], "current_phase" => options[:phase], "current_flow" => options[:flow] })
148
+ else PhaseOwnership.failure("invalid_command", "command must be claim, inspect, or complete")
149
+ end
150
+ ensure
151
+ DevflowControlIO.release_lock(lock)
152
+ end
153
+ puts JSON.pretty_generate(result)
154
+ end
@@ -7,6 +7,7 @@ require "optparse"
7
7
  require "yaml"
8
8
  require_relative "control_io"
9
9
  require_relative "validate_state"
10
+ require_relative "phase_ownership"
10
11
 
11
12
  options = { state: ".planning/STATE.md", expected_revision: nil, confirm: false, write: false, json: false }
12
13
  OptionParser.new do |opts|
@@ -31,7 +32,7 @@ def build_registry(root, workflow, state)
31
32
  artifacts = Array(workflow["steps"]).flat_map do |step|
32
33
  Array(step["outputs"]).map do |output|
33
34
  path = interpolate_path(output.fetch("path"), state)
34
- abs = File.join(root, path)
35
+ abs, = DevflowControlIO.resolve_control_path(root, path, :artifact)
35
36
  exists = File.exist?(abs)
36
37
  issues = []
37
38
  issues << "file_missing" unless exists
@@ -53,43 +54,51 @@ def build_registry(root, workflow, state)
53
54
  end
54
55
 
55
56
  root = Dir.pwd
56
- state_path = File.expand_path(options[:state], root)
57
57
  result = nil
58
58
  lock = options[:write] ? DevflowControlIO.acquire_project_lock(root) : nil
59
59
  begin
60
+ state_path, = DevflowControlIO.resolve_control_path(root, options[:state], :state)
60
61
  unless File.exist?(state_path)
61
62
  result = { "ok" => false, "classification" => "unsafe", "reason" => "missing_state_file" }
62
63
  else
63
64
  state = YAML.safe_load(front_matter(File.read(state_path)), permitted_classes: [], aliases: false)
64
- workflow_path = File.expand_path(state.fetch("workflow_config"), root)
65
- workflow = YAML.safe_load(File.read(workflow_path), permitted_classes: [], aliases: false)
66
- registry_source = workflow.dig("artifact_registry", "source")
67
- if !registry_source.is_a?(String) || registry_source.empty?
68
- result = { "ok" => false, "classification" => "unsafe", "reason" => "missing_artifact_registry_config", "plan" => { "state_transition" => "none", "registry_write" => false } }
65
+ ownership = PhaseOwnership.validate_state(root: root, state: state)
66
+ unless ownership["ok"]
67
+ result = { "ok" => false, "classification" => "unsafe", "reason" => ownership["reason"], "issues" => [ownership], "plan" => { "state_transition" => "none", "registry_write" => false } }
69
68
  else
70
- candidate = build_registry(root, workflow, state)
71
- validation = WorkflowStateValidator.validate(root: root, state_path: state_path, state_override: state, workflow_override: workflow, artifact_registry_override: candidate, strict_artifact_status: true, strict_registry_revision: true)
72
- current = Array(workflow["steps"]).find { |step| step["id"] == state["current_step"] }
73
- missing_current_outputs = Array(current && current["outputs"]).map do |output|
74
- next unless output["required"]
75
- path = interpolate_path(output.fetch("path"), state)
76
- path unless File.exist?(File.join(root, path))
77
- end.compact
78
- if missing_current_outputs.any?
79
- result = { "ok" => false, "classification" => "unsafe", "reason" => "missing_required_output", "missing_outputs" => missing_current_outputs, "plan" => { "state_transition" => "none", "registry_write" => false } }
80
- elsif !validation["ok"]
81
- result = { "ok" => false, "classification" => "unsafe", "reason" => "ambiguous_or_invalid_state", "issues" => validation["errors"], "plan" => { "state_transition" => "none", "registry_write" => false } }
82
- elsif !options[:confirm] || !options[:write]
83
- result = { "ok" => false, "classification" => "safe_repairable", "reason" => "confirmation_required", "revision" => state.fetch("revision", 0), "plan" => { "state_transition" => "none", "registry_write" => true, "registry" => candidate } }
84
- elsif options[:expected_revision] != state.fetch("revision", 0)
85
- result = { "ok" => false, "classification" => "unsafe", "reason" => "stale_revision", "expected_revision" => options[:expected_revision], "actual_revision" => state.fetch("revision", 0) }
69
+ workflow_path, = DevflowControlIO.resolve_control_path(root, state.fetch("workflow_config"), :workflow)
70
+ workflow = YAML.safe_load(File.read(workflow_path), permitted_classes: [], aliases: false)
71
+ registry_source = workflow.dig("artifact_registry", "source")
72
+ if !registry_source.is_a?(String) || registry_source.empty?
73
+ result = { "ok" => false, "classification" => "unsafe", "reason" => "missing_artifact_registry_config", "plan" => { "state_transition" => "none", "registry_write" => false } }
86
74
  else
87
- registry_path = File.join(root, registry_source)
88
- DevflowControlIO.atomic_write(registry_path, candidate.to_yaml)
89
- result = { "ok" => true, "classification" => "safe_repairable", "written" => true, "revision" => state.fetch("revision", 0), "plan" => { "state_transition" => "none", "registry_write" => true } }
75
+ candidate = build_registry(root, workflow, state)
76
+ validation = WorkflowStateValidator.validate(root: root, state_path: state_path, state_override: state, workflow_override: workflow, artifact_registry_override: candidate, strict_artifact_status: true, strict_registry_revision: true)
77
+ current = Array(workflow["steps"]).find { |step| step["id"] == state["current_step"] }
78
+ missing_current_outputs = Array(current && current["outputs"]).map do |output|
79
+ next unless output["required"]
80
+ path = interpolate_path(output.fetch("path"), state)
81
+ output_path, = DevflowControlIO.resolve_control_path(root, path, :artifact)
82
+ path unless File.exist?(output_path)
83
+ end.compact
84
+ if missing_current_outputs.any?
85
+ result = { "ok" => false, "classification" => "unsafe", "reason" => "missing_required_output", "missing_outputs" => missing_current_outputs, "plan" => { "state_transition" => "none", "registry_write" => false } }
86
+ elsif !validation["ok"]
87
+ result = { "ok" => false, "classification" => "unsafe", "reason" => "ambiguous_or_invalid_state", "issues" => validation["errors"], "plan" => { "state_transition" => "none", "registry_write" => false } }
88
+ elsif !options[:confirm] || !options[:write]
89
+ result = { "ok" => false, "classification" => "safe_repairable", "reason" => "confirmation_required", "revision" => state.fetch("revision", 0), "plan" => { "state_transition" => "none", "registry_write" => true, "registry" => candidate } }
90
+ elsif options[:expected_revision] != state.fetch("revision", 0)
91
+ result = { "ok" => false, "classification" => "unsafe", "reason" => "stale_revision", "expected_revision" => options[:expected_revision], "actual_revision" => state.fetch("revision", 0) }
92
+ else
93
+ registry_path, = DevflowControlIO.resolve_control_path(root, registry_source, :registry)
94
+ DevflowControlIO.atomic_write(registry_path, candidate.to_yaml)
95
+ result = { "ok" => true, "classification" => "safe_repairable", "written" => true, "revision" => state.fetch("revision", 0), "plan" => { "state_transition" => "none", "registry_write" => true } }
96
+ end
90
97
  end
91
98
  end
92
99
  end
100
+ rescue DevflowControlIO::InvalidControlPath => error
101
+ result = { "ok" => false, "classification" => "unsafe", "reason" => "invalid_control_path", "message" => error.message, "plan" => { "state_transition" => "none", "registry_write" => false } }
93
102
  ensure
94
103
  DevflowControlIO.release_lock(lock)
95
104
  end
@@ -7,6 +7,7 @@ require "json"
7
7
  require "optparse"
8
8
  require "yaml"
9
9
  require_relative "control_io"
10
+ require_relative "phase_ownership"
10
11
 
11
12
  options = {
12
13
  state: ".planning/STATE.md",
@@ -63,11 +64,18 @@ end
63
64
  project_lock = DevflowControlIO.acquire_project_lock(root) if options[:write]
64
65
 
65
66
  begin
66
- state = load_state(File.join(root, options[:state]))
67
- workflow_path = options[:workflow] || state["workflow_config"] || ".planning/workflows/#{state.fetch("current_flow")}.yaml"
68
- workflow = load_yaml_file(File.join(root, workflow_path))
69
- registry_path = options[:output] || workflow.dig("artifact_registry", "source") || ".planning/artifacts/index.yaml"
70
- existing_registry_path = File.join(root, registry_path)
67
+ state_path, = DevflowControlIO.resolve_control_path(root, options[:state], :state)
68
+ state = load_state(state_path)
69
+ ownership = PhaseOwnership.validate_state(root: root, state: state)
70
+ unless ownership["ok"]
71
+ puts JSON.pretty_generate(ownership.merge("written" => false))
72
+ exit 0
73
+ end
74
+ workflow_source = options[:workflow] || state["workflow_config"] || ".planning/workflows/#{state.fetch("current_flow")}.yaml"
75
+ workflow_path, = DevflowControlIO.resolve_control_path(root, workflow_source, :workflow)
76
+ workflow = load_yaml_file(workflow_path)
77
+ registry_source = options[:output] || workflow.dig("artifact_registry", "source") || ".planning/artifacts/index.yaml"
78
+ existing_registry_path, registry_path = DevflowControlIO.resolve_control_path(root, registry_source, :registry)
71
79
  existing_by_path = {}
72
80
  if options[:preserve] && File.exist?(existing_registry_path)
73
81
  existing = load_yaml_file(existing_registry_path)
@@ -80,7 +88,7 @@ content_checks = workflow.dig("artifact_registry", "content_checks") || {}
80
88
  preservable_statuses = %w[approved completed passed pending_rerun stale failed]
81
89
 
82
90
  def content_validity(root, path, checks)
83
- abs = File.join(root, path)
91
+ abs, = DevflowControlIO.resolve_control_path(root, path, :artifact)
84
92
  return { "valid" => false, "issues" => ["file_missing"] } unless File.exist?(abs)
85
93
 
86
94
  issues = []
@@ -114,7 +122,8 @@ artifacts = collect_outputs(workflow).map do |step, output|
114
122
  end
115
123
 
116
124
  validity = content_validity(root, path, content_checks)
117
- file_exists = File.exist?(File.join(root, path))
125
+ artifact_path, = DevflowControlIO.resolve_control_path(root, path, :artifact)
126
+ file_exists = File.exist?(artifact_path)
118
127
  status = if options[:preserve] && preservable_statuses.include?(existing["status"])
119
128
  existing["status"]
120
129
  else
@@ -161,6 +170,8 @@ result = {
161
170
  "missing_count" => artifacts.count { |artifact| !artifact["exists"] },
162
171
  "registry" => registry
163
172
  }
173
+ rescue DevflowControlIO::InvalidControlPath => error
174
+ result = { "ok" => false, "written" => false, "reason" => "invalid_control_path", "message" => error.message }
164
175
  ensure
165
176
  DevflowControlIO.release_lock(project_lock)
166
177
  end
@@ -4,6 +4,8 @@
4
4
  require "json"
5
5
  require "optparse"
6
6
  require "yaml"
7
+ require_relative "control_io"
8
+ require_relative "phase_ownership"
7
9
 
8
10
  module WorkflowStateValidator
9
11
  REQUIRED_STATE_FIELDS = %w[
@@ -18,12 +20,18 @@ module WorkflowStateValidator
18
20
 
19
21
  TERMINAL_ARTIFACT_STATUSES = %w[approved completed passed].freeze
20
22
  NON_TERMINAL_ARTIFACT_STATUSES = %w[planned generated pending_rerun stale failed].freeze
23
+ TERMINAL_WORKFLOW_STATUSES = %w[completed failed cancelled].freeze
21
24
 
22
25
  module_function
23
26
 
24
- def validate(root:, state_path:, state_override: nil, workflow_override: nil, artifact_registry_override: nil, strict_artifact_status: true, strict_registry_revision: true)
27
+ def validate(root:, state_path:, state_override: nil, workflow_override: nil, artifact_registry_override: nil, strict_artifact_status: true, strict_registry_revision: true, phase_owner_override: nil)
25
28
  root = File.expand_path(root)
26
- state_path = File.expand_path(state_path, root)
29
+ canonical_state_path, = DevflowControlIO.resolve_control_path(root, ".planning/STATE.md", :state)
30
+ unless File.expand_path(state_path, root) == canonical_state_path
31
+ issues = [issue("error", "invalid_control_path", "STATE.md path must be .planning/STATE.md")]
32
+ return result(false, state_path, nil, nil, issues)
33
+ end
34
+ state_path = canonical_state_path
27
35
  issues = []
28
36
 
29
37
  state = state_override || load_state(state_path, issues)
@@ -34,8 +42,14 @@ module WorkflowStateValidator
34
42
  end
35
43
  return result(false, state_path, nil, nil, issues) if issues.any? { |entry| entry["severity"] == "error" }
36
44
 
45
+ ownership = PhaseOwnership.validate_state(root: root, state: state, owner_override: phase_owner_override)
46
+ unless ownership["ok"]
47
+ issues << issue("error", ownership["reason"], ownership["message"], ownership.reject { |key, _| %w[ok reason message].include?(key) })
48
+ return result(false, state_path, nil, nil, issues)
49
+ end
50
+
37
51
  workflow_path = state["workflow_config"] || File.join(".planning", "workflows", "#{state.fetch("current_flow")}.yaml")
38
- abs_workflow_path = File.expand_path(workflow_path, root)
52
+ abs_workflow_path, workflow_path = DevflowControlIO.resolve_control_path(root, workflow_path, :workflow)
39
53
  workflow = workflow_override || load_yaml(abs_workflow_path, issues, "workflow_config")
40
54
  return result(false, state_path, workflow_path, nil, issues) unless workflow
41
55
 
@@ -103,7 +117,7 @@ module WorkflowStateValidator
103
117
 
104
118
  registry = registry_override
105
119
  unless registry
106
- abs_registry_path = File.expand_path(registry_path, root)
120
+ abs_registry_path, = DevflowControlIO.resolve_control_path(root, registry_path, :registry)
107
121
  unless File.exist?(abs_registry_path)
108
122
  issues << issue("error", "missing_artifact_registry", "Artifact registry does not exist", path: registry_path)
109
123
  return {}
@@ -121,7 +135,7 @@ module WorkflowStateValidator
121
135
  )
122
136
  end
123
137
  Array(registry["artifacts"]).to_h { |artifact| [artifact.fetch("path"), artifact] }
124
- rescue ArgumentError, TypeError, Psych::SyntaxError => e
138
+ rescue ArgumentError, TypeError, Psych::SyntaxError, DevflowControlIO::InvalidControlPath => e
125
139
  issues << issue("error", "invalid_artifact_registry", e.message, path: registry_path)
126
140
  {}
127
141
  end
@@ -145,8 +159,10 @@ module WorkflowStateValidator
145
159
  unknown.each do |step_id|
146
160
  issues << issue("error", "unknown_tracked_step", "Tracked step is not defined in the active workflow", step: step_id)
147
161
  end
148
- missing.each do |step_id|
149
- issues << issue("error", "missing_tracked_step", "Workflow step is missing from both completed_steps and pending_steps", step: step_id)
162
+ unless %w[failed cancelled].include?(state["status"])
163
+ missing.each do |step_id|
164
+ issues << issue("error", "missing_tracked_step", "Workflow step is missing from both completed_steps and pending_steps", step: step_id)
165
+ end
150
166
  end
151
167
 
152
168
  current_step = state["current_step"]
@@ -162,6 +178,10 @@ module WorkflowStateValidator
162
178
  missing_completed.each do |step_id|
163
179
  issues << issue("error", "completed_status_missing_step", "Workflow status is completed but a workflow step is not completed", step: step_id)
164
180
  end
181
+ elsif %w[failed cancelled].include?(state["status"])
182
+ if pending_ids.any?
183
+ issues << issue("error", "terminal_with_pending_steps", "Terminal workflow status must not retain pending steps", status: state["status"])
184
+ end
165
185
  elsif pending_ids.empty?
166
186
  issues << issue("error", "non_completed_without_pending_steps", "Workflow has no pending steps but status is not completed")
167
187
  end
@@ -589,7 +609,12 @@ if __FILE__ == $PROGRAM_NAME
589
609
  end.parse!
590
610
 
591
611
  root = Dir.pwd
592
- state_path = File.expand_path(options[:state], root)
612
+ begin
613
+ state_path, = DevflowControlIO.resolve_control_path(root, options[:state], :state)
614
+ rescue DevflowControlIO::InvalidControlPath => error
615
+ puts JSON.pretty_generate({ "ok" => false, "errors" => [{ "code" => "invalid_control_path", "message" => error.message }] })
616
+ exit 1
617
+ end
593
618
  validation = WorkflowStateValidator.validate(
594
619
  root: root,
595
620
  state_path: state_path,