@guanlin4924/devflow-pi 0.1.13 → 0.1.15

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 (19) hide show
  1. package/package.json +1 -1
  2. package/plugins/devflow-core/extensions/devflow-core.ts +119 -12
  3. package/plugins/devflow-core/skills/workflow-orchestrator/SKILL.md +4 -4
  4. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +47 -15
  5. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/control_io.rb +42 -0
  6. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/init_state.rb +21 -2
  7. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/phase_ownership.rb +154 -0
  8. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/repair_control_plane.rb +35 -26
  9. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/sync_artifacts.rb +18 -7
  10. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/traceability_check.py +160 -0
  11. package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +60 -8
  12. package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +25 -3
  13. package/plugins/devflow-core/skills/workflow-orchestrator/templates/traceability.json +23 -0
  14. package/plugins/devflow-design/skills/workflow-api-contract/SKILL.md +4 -1
  15. package/plugins/devflow-design/skills/workflow-backend-tech-spec/SKILL.md +4 -0
  16. package/plugins/devflow-design/skills/workflow-frontend-tech-spec/SKILL.md +4 -0
  17. package/plugins/devflow-design/skills/workflow-integration-verify/SKILL.md +10 -0
  18. package/plugins/devflow-implementation/skills/workflow-change-plan/SKILL.md +8 -0
  19. package/plugins/devflow-requirements/skills/workflow-requirement-baseline/SKILL.md +9 -0
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.15",
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
  }
@@ -115,14 +115,14 @@ After each step completes, verify the step's gate before advancing:
115
115
  | requirement-wayfinder | 决策地图.meta.json `status=ready_for_baseline` AND `ready_for_baseline=true` | Reject advance to domain-modeling |
116
116
  | domain-modeling | 领域模型记录及 meta 存在且标识当前功能 | Reject advance to requirement-feasibility-review |
117
117
  | requirement-feasibility-review | 可实现性审查.meta.json `status=ready_for_baseline`、`baseline_gate=pass`、`gap_count=0` 且无 critical finding | Reject advance to requirement-baseline |
118
- | requirement-baseline | baseline.md review conclusion is "通过" AND baseline.meta.json `review_status` is `passed` AND blocking counters are all 0 | Reject advance to api-contract |
119
- | api-contract | api-contract.meta.json `review_status` is `passed` AND `pending_contract_count` is 0 | Reject advance to dual tracks |
118
+ | requirement-baseline | baseline.md review conclusion is "通过" AND baseline.meta.json `review_status` / `traceability_status` are `passed` AND blocking counters are all 0 | Reject advance to api-contract |
119
+ | api-contract | api-contract.meta.json `review_status` / `traceability_status` are `passed` AND `pending_contract_count` is 0 | Reject advance to dual tracks |
120
120
  | ui-spec | UI-SPEC.md review conclusion is "通过" | Reject advance to feature-breakdown |
121
121
  | feature-breakdown | 功能点梳理.md review conclusion is "通过" | Reject advance to tech-specs |
122
122
  | api-call-guide | api-call-guide.meta.json `frontend_consumable` and `mock_ready` are true, pending_items is empty | Reject merge gate |
123
123
  | frontend-tech-spec + backend-tech-spec | Both review conclusions are "通过" | Reject integration-verify |
124
- | integration-verify | integration-report.meta.json `merge_gate_status` is `passed` and all mismatch/blocking counters are 0 | Reject advance to change-plan |
125
- | change-plan | 变更清单.md review conclusion is "通过" | Reject advance to execute |
124
+ | integration-verify | integration-report.meta.json `merge_gate_status` / `traceability_status` are `passed` and all mismatch/blocking counters are 0 | Reject advance to change-plan |
125
+ | change-plan | 变更清单.md review conclusion is "通过" AND 变更清单.meta.json traceability_status is `passed` with zero uncovered/orphan counts | Reject advance to execute |
126
126
 
127
127
  If a gate fails, the current step must be re-executed. Do not skip failing gates.
128
128
 
@@ -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