@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 +1 -1
- package/plugins/devflow-core/extensions/devflow-core.ts +119 -12
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +47 -15
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/control_io.rb +42 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/init_state.rb +21 -2
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/phase_ownership.rb +154 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/repair_control_plane.rb +35 -26
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/sync_artifacts.rb +18 -7
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +33 -8
package/package.json
CHANGED
|
@@ -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 (/^(?:批准|确认|同意|通过)(
|
|
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).
|
|
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
|
|
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
|
-
|
|
1444
|
+
phase,
|
|
1363
1445
|
"--flow",
|
|
1364
1446
|
params.flow,
|
|
1365
1447
|
"--write",
|
|
1366
1448
|
"--json",
|
|
1367
1449
|
...force,
|
|
1368
1450
|
]);
|
|
1369
|
-
if (!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
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
55
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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,
|