@guanlin4924/devflow-pi 0.1.25 → 0.1.26
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/README.md +7 -1
- package/package.json +1 -1
- package/plugins/devflow-core/extensions/devflow-core.ts +252 -33
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +9 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/control_io.rb +4 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +1 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/session_rollover.rb +159 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +36 -0
- package/plugins/devflow-quality/skills/workflow-acceptance-test-design/SKILL.md +8 -0
- package/src/constants.ts +1 -1
- package/src/runtime.ts +36 -1
package/README.md
CHANGED
|
@@ -11,7 +11,7 @@ Devflow Pi 是一个面向项目研发交付的独立 OMP 产品入口,内置
|
|
|
11
11
|
安装与启动:
|
|
12
12
|
|
|
13
13
|
```bash
|
|
14
|
-
npm install -g @guanlin4924/devflow-pi@0.1.
|
|
14
|
+
npm install -g @guanlin4924/devflow-pi@0.1.26
|
|
15
15
|
cd /path/to/your-project
|
|
16
16
|
devflow-pi init
|
|
17
17
|
devflow-pi doctor
|
|
@@ -115,6 +115,12 @@ bash .skills/install.sh --apply --force-config
|
|
|
115
115
|
|
|
116
116
|
`/devflow start` 会先进入安全的需求梳理:可澄清并只读检查,不写入项目。用户随后直接补充需求,原文会被带入入口分诊并启动当前步骤。每个步骤结束后,系统展示产物和证据;用户明确回复“批准执行”才会推进并运行下一步,回复修改意见或“拒绝执行”则保持原步骤。普通需求描述绝不会自动确认 checkpoint。`/devflow explain-state` 发现控制面和制品 registry 漂移时会给出只读恢复计划;用户审查后可用 `/devflow recover` 确认仅修复可由文件系统证明的 registry 字段,它绝不推进步骤或自动通过 checkpoint。恢复已有流程使用 `/devflow resume`,它同样只恢复状态而不自动执行。
|
|
117
117
|
|
|
118
|
+
### 窗口操作指引与干净会话
|
|
119
|
+
|
|
120
|
+
Devflow 会在输入框上方显示与当前状态对应的“Devflow 操作指引”:空闲时给出初始化、创建功能和状态查看入口;需求梳理时提示直接补充需求;节点审查时提示回复“批准执行”或直接描述修改项;阻塞时只展示解释状态和安全恢复入口。
|
|
121
|
+
|
|
122
|
+
正式节点经用户批准后,Devflow 会把已校验的状态和产物引用固化为内容校验的 handoff,再自动创建干净 Session 执行下一节点。交接期间工作流冻结;只有新 Session 成功认领并消费同一份 handoff 后才解除冻结。Devflow 不使用 OMP 的模型摘要 handoff,因此 `.planning/STATE.md` 仍是唯一流程事实源。
|
|
123
|
+
|
|
118
124
|
### 需求验收测试与 TDD 证据链
|
|
119
125
|
|
|
120
126
|
新建功能在 `integration-verify` 后会先生成独立的 `TC-*` 验收测试设计,再生成变更清单:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { existsSync, lstatSync, readFileSync, readdirSync, realpathSync } from "node:fs";
|
|
3
|
-
import { mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { mkdir, rename, writeFile } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import type { ExtensionAPI } from "@oh-my-pi/pi-coding-agent";
|
|
@@ -29,6 +29,7 @@ type ActiveExecutionContract = {
|
|
|
29
29
|
step: string;
|
|
30
30
|
stateRevision?: number;
|
|
31
31
|
graphRevision?: number;
|
|
32
|
+
workflowFingerprint?: string;
|
|
32
33
|
allowedPaths: string[];
|
|
33
34
|
allowedAgents: string[];
|
|
34
35
|
allowedTools: string[];
|
|
@@ -41,6 +42,30 @@ type ActiveExecutionContract = {
|
|
|
41
42
|
onUnknown: "block_and_escalate";
|
|
42
43
|
};
|
|
43
44
|
|
|
45
|
+
function contractFromRecord(record: Record<string, unknown>): ActiveExecutionContract | null {
|
|
46
|
+
const strings = (key: string): string[] =>
|
|
47
|
+
Array.isArray(record[key]) ? record[key].filter((item): item is string => typeof item === "string") : [];
|
|
48
|
+
const contract: ActiveExecutionContract = {
|
|
49
|
+
contractId: String(record.contract_id ?? ""),
|
|
50
|
+
feature: String(record.feature ?? ""),
|
|
51
|
+
step: String(record.step ?? ""),
|
|
52
|
+
stateRevision: typeof record.state_revision === "number" ? record.state_revision : undefined,
|
|
53
|
+
graphRevision: typeof record.graph_revision === "number" ? record.graph_revision : undefined,
|
|
54
|
+
workflowFingerprint: typeof record.workflow_fingerprint === "string" ? record.workflow_fingerprint : undefined,
|
|
55
|
+
allowedPaths: strings("allowed_paths"),
|
|
56
|
+
allowedAgents: strings("allowed_agents"),
|
|
57
|
+
allowedTools: strings("allowed_tools"),
|
|
58
|
+
checkerIds: strings("checker_ids"),
|
|
59
|
+
toolBudget: typeof record.tool_budget === "number" ? record.tool_budget : 0,
|
|
60
|
+
frozenHash: String(record.frozen_hash ?? ""),
|
|
61
|
+
acceptance: strings("acceptance"),
|
|
62
|
+
allowedGraphDelta: strings("allowed_graph_delta"),
|
|
63
|
+
onUnknown: "block_and_escalate",
|
|
64
|
+
};
|
|
65
|
+
if (!contract.contractId || !contract.frozenHash) return null;
|
|
66
|
+
return contract;
|
|
67
|
+
}
|
|
68
|
+
|
|
44
69
|
const READ_ONLY_TOOLS = new Set([
|
|
45
70
|
"read",
|
|
46
71
|
"grep",
|
|
@@ -56,7 +81,6 @@ const READ_ONLY_TOOLS = new Set([
|
|
|
56
81
|
"devflow_usage_stats",
|
|
57
82
|
"usage_stats",
|
|
58
83
|
]);
|
|
59
|
-
|
|
60
84
|
const REQUIREMENT_DRAFT_TOOLS = new Set([...READ_ONLY_TOOLS, "ask"]);
|
|
61
85
|
// A Todo update changes only the client-visible task list, never the project
|
|
62
86
|
// or Devflow state. Permit it during planning so a harmless checklist cannot
|
|
@@ -414,6 +438,7 @@ const REQUIRED_SCRIPTS = [
|
|
|
414
438
|
"workflow-orchestrator/scripts/phase_ownership.rb",
|
|
415
439
|
"workflow-orchestrator/scripts/inspect_state.rb",
|
|
416
440
|
"workflow-orchestrator/scripts/advance_state.rb",
|
|
441
|
+
"workflow-orchestrator/scripts/session_rollover.rb",
|
|
417
442
|
"workflow-orchestrator/scripts/sync_artifacts.rb",
|
|
418
443
|
"workflow-orchestrator/scripts/requirement_graph.rb",
|
|
419
444
|
"workflow-orchestrator/scripts/acceptance_test_suite.rb",
|
|
@@ -840,9 +865,35 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
840
865
|
let activeContract: ActiveExecutionContract | undefined;
|
|
841
866
|
let contractAgentCalls = 0;
|
|
842
867
|
|
|
843
|
-
function
|
|
868
|
+
function controlGuide(state: SessionControlState): string[] {
|
|
869
|
+
const title = "Devflow 操作指引";
|
|
870
|
+
if (state.intent === "project-init" && state.phase === "planning") {
|
|
871
|
+
return [title, "初始化范围待确认:按界面完成选择;批准写入请回复“批准执行”。"];
|
|
872
|
+
}
|
|
873
|
+
if (state.intent === "requirement-draft" && state.phase === "planning") {
|
|
874
|
+
return [title, "需求梳理:直接补充需求正文、目标、边界和验收结果;此时不会推进实现。"];
|
|
875
|
+
}
|
|
876
|
+
if (state.phase === "waiting-user" || state.phase === "awaiting-approval") {
|
|
877
|
+
return [title, "当前节点等待审查:批准请回复“批准执行”;需修改请直接描述修改项。", "状态说明:/devflow explain-state"];
|
|
878
|
+
}
|
|
879
|
+
if (state.phase === "executing") {
|
|
880
|
+
return [title, `正在处理:${state.label}`, "请等待当前步骤输出;完成后会提示你审查。"];
|
|
881
|
+
}
|
|
882
|
+
if (state.phase === "blocked") {
|
|
883
|
+
return [title, "当前步骤被阻塞:使用 /devflow explain-state 查看证据与可恢复路径。", "仅在系统给出安全恢复计划后使用 /devflow recover。"];
|
|
884
|
+
}
|
|
885
|
+
return [title, "初始化项目:/devflow project-init", "创建功能:/devflow start <功能名> 查看状态:/devflow explain-state"];
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
function setControlState(ctx: {
|
|
889
|
+
ui: {
|
|
890
|
+
setStatus(key: string, text: string | undefined): void;
|
|
891
|
+
setWidget?(key: string, content: string[] | undefined, options?: { placement?: "aboveEditor" | "belowEditor" }): void;
|
|
892
|
+
};
|
|
893
|
+
}, state: SessionControlState) {
|
|
844
894
|
controlState = state;
|
|
845
895
|
ctx.ui.setStatus("devflow-control", statusText(state));
|
|
896
|
+
ctx.ui.setWidget?.("devflow-operation-guide", controlGuide(state), { placement: "aboveEditor" });
|
|
846
897
|
}
|
|
847
898
|
|
|
848
899
|
function normalizeContractSegment(value: string, label: string): string {
|
|
@@ -862,11 +913,57 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
862
913
|
const stateRevision = typeof state?.revision === "number" ? state.revision : undefined;
|
|
863
914
|
const graphResult = parseJsonResult(await runRuby(cwd, "workflow-orchestrator/scripts/requirement_graph.rb", ["inspect", "--feature", contract.feature, "--json"]));
|
|
864
915
|
const graph = graphResult?.graph;
|
|
865
|
-
const graphRevision =
|
|
866
|
-
|
|
916
|
+
const graphRevision =
|
|
917
|
+
graph && typeof graph === "object" && typeof (graph as Record<string, unknown>).revision === "number"
|
|
918
|
+
? (graph as Record<string, number>).revision
|
|
919
|
+
: undefined;
|
|
867
920
|
return stateRevision === contract.stateRevision && graphRevision === contract.graphRevision;
|
|
868
921
|
}
|
|
869
922
|
|
|
923
|
+
async function restoreActiveContract(cwd: string): Promise<ActiveExecutionContract | undefined> {
|
|
924
|
+
try {
|
|
925
|
+
const state = parseJsonResult(await runRuby(cwd, "workflow-orchestrator/scripts/inspect_state.rb", ["--json"]));
|
|
926
|
+
const feature = typeof state?.current_feature === "string" ? state.current_feature : undefined;
|
|
927
|
+
const rawStep = state?.current_step;
|
|
928
|
+
const step =
|
|
929
|
+
typeof rawStep === "string"
|
|
930
|
+
? rawStep
|
|
931
|
+
: rawStep && typeof rawStep === "object" && typeof (rawStep as { id?: unknown }).id === "string"
|
|
932
|
+
? (rawStep as { id: string }).id
|
|
933
|
+
: undefined;
|
|
934
|
+
if (!feature || !step) return undefined;
|
|
935
|
+
const contractsDir = path.join(cwd, ".planning", "contracts", feature);
|
|
936
|
+
if (!existsSync(contractsDir)) return undefined;
|
|
937
|
+
const candidates = readdirSync(contractsDir)
|
|
938
|
+
.filter(name => name.startsWith("EC-") && name.endsWith(".json"))
|
|
939
|
+
.map(name => path.join(contractsDir, name))
|
|
940
|
+
.sort((a, b) => lstatSync(b).mtimeMs - lstatSync(a).mtimeMs);
|
|
941
|
+
for (const candidate of candidates) {
|
|
942
|
+
let parsed: Record<string, unknown>;
|
|
943
|
+
try {
|
|
944
|
+
parsed = JSON.parse(readFileSync(candidate, "utf8")) as Record<string, unknown>;
|
|
945
|
+
} catch {
|
|
946
|
+
continue;
|
|
947
|
+
}
|
|
948
|
+
const contract = contractFromRecord(parsed);
|
|
949
|
+
if (!contract || contract.feature !== feature || contract.step !== step) continue;
|
|
950
|
+
const inspected = parseJsonResult(await runRuby(cwd, "workflow-orchestrator/scripts/execution_contract.rb", ["inspect", "--feature", feature, "--step", step, "--json"]));
|
|
951
|
+
const currentRaw = inspected?.contract;
|
|
952
|
+
const current = currentRaw && typeof currentRaw === "object" ? contractFromRecord(currentRaw as Record<string, unknown>) : null;
|
|
953
|
+
if (!current) continue;
|
|
954
|
+
if (current.contractId !== contract.contractId) continue;
|
|
955
|
+
if (current.frozenHash !== contract.frozenHash) continue;
|
|
956
|
+
if (current.workflowFingerprint !== contract.workflowFingerprint) continue;
|
|
957
|
+
if (!(await contractIsFresh(cwd, contract))) continue;
|
|
958
|
+
contract.contractPath = candidate;
|
|
959
|
+
return contract;
|
|
960
|
+
}
|
|
961
|
+
} catch {
|
|
962
|
+
// 恢复失败不阻塞会话启动;对应步骤执行时会提示重新合成合同。
|
|
963
|
+
}
|
|
964
|
+
return undefined;
|
|
965
|
+
}
|
|
966
|
+
|
|
870
967
|
async function collectProjectInitialization(ctx: {
|
|
871
968
|
cwd: string;
|
|
872
969
|
ui: {
|
|
@@ -1190,6 +1287,32 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
1190
1287
|
|
|
1191
1288
|
let activeWorkflowTurn = false;
|
|
1192
1289
|
let pendingProjectInitialization: ProjectInitializationRequest | undefined;
|
|
1290
|
+
let pendingRollover: { snapshot: Record<string, unknown>; preparedRevision: number; rolloverId: string } | undefined;
|
|
1291
|
+
|
|
1292
|
+
async function enqueueControlledRolloverRequest(cwd: string, rolloverId: string, sourceSessionId: string) {
|
|
1293
|
+
const requestPath = path.join(cwd, ".planning", "rollover-request.json");
|
|
1294
|
+
const temporary = `${requestPath}.${randomUUID()}.tmp`;
|
|
1295
|
+
await writeFile(temporary, JSON.stringify({ schema_version: 1, rollover_id: rolloverId, source_session_id: sourceSessionId }) + "\n", "utf8");
|
|
1296
|
+
await rename(temporary, requestPath);
|
|
1297
|
+
}
|
|
1298
|
+
|
|
1299
|
+
async function requestControlledRollover(ctx: { cwd: string; sessionManager: { getSessionId(): string } }, snapshot: Record<string, unknown>) {
|
|
1300
|
+
const revision = typeof snapshot.revision === "number" ? snapshot.revision : undefined;
|
|
1301
|
+
if (revision === undefined) throw new Error("Cannot prepare session rollover without a STATE revision.");
|
|
1302
|
+
const prepared = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/session_rollover.rb", [
|
|
1303
|
+
"prepare", "--write", "--json", "--expected-revision", String(revision),
|
|
1304
|
+
"--source-session-id", ctx.sessionManager.getSessionId(), "--reason", "workflow_step_boundary",
|
|
1305
|
+
]);
|
|
1306
|
+
const payload = parseJsonResult(prepared);
|
|
1307
|
+
if (!prepared.ok || !payload || typeof payload.revision !== "number") {
|
|
1308
|
+
throw new Error(prepared.stderr || prepared.stdout || "rollover prepare failed");
|
|
1309
|
+
}
|
|
1310
|
+
const rollover = payload.rollover as Record<string, unknown> | undefined;
|
|
1311
|
+
const rolloverId = typeof rollover?.id === "string" ? rollover.id : undefined;
|
|
1312
|
+
if (!rolloverId) throw new Error("rollover prepare returned no rollover id");
|
|
1313
|
+
await enqueueControlledRolloverRequest(ctx.cwd, rolloverId, ctx.sessionManager.getSessionId());
|
|
1314
|
+
pendingRollover = { snapshot, preparedRevision: payload.revision, rolloverId };
|
|
1315
|
+
}
|
|
1193
1316
|
|
|
1194
1317
|
async function triggerWorkflow(snapshot: Record<string, unknown>, source: "start" | "approval", requirementDraft?: string) {
|
|
1195
1318
|
activeWorkflowTurn = true;
|
|
@@ -1211,7 +1334,71 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
1211
1334
|
return;
|
|
1212
1335
|
}
|
|
1213
1336
|
ctx.ui.setStatus("devflow", "Devflow ready");
|
|
1337
|
+
const restored = await restoreActiveContract(ctx.cwd);
|
|
1338
|
+
if (restored) {
|
|
1339
|
+
activeContract = restored;
|
|
1340
|
+
contractAgentCalls = 0;
|
|
1341
|
+
ctx.ui.notify(`已恢复执行合同 ${restored.contractId}(${restored.feature}/${restored.step});步骤推进后自动失效。`, "info");
|
|
1342
|
+
}
|
|
1214
1343
|
setControlState(ctx, initialControlState());
|
|
1344
|
+
// Recovery never searches the handoff directory. STATE.rollover is the
|
|
1345
|
+
// sole authority: only the original source session may resume a prepared
|
|
1346
|
+
// transition, and only the already-recorded target may consume a claim.
|
|
1347
|
+
const rolloverResult = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/session_rollover.rb", ["inspect", "--json"]);
|
|
1348
|
+
const rolloverState = parseJsonResult(rolloverResult);
|
|
1349
|
+
const rollover = rolloverState?.rollover as Record<string, unknown> | undefined;
|
|
1350
|
+
const currentSessionId = ctx.sessionManager.getSessionId();
|
|
1351
|
+
if (rolloverState && typeof rolloverState.revision === "number" && rollover?.status === "prepared" && rollover.source_session_id === currentSessionId && typeof rollover.id === "string") {
|
|
1352
|
+
const workflow = await inspectWorkflow(ctx.cwd);
|
|
1353
|
+
if (workflow.result.ok && workflow.snapshot) {
|
|
1354
|
+
pendingRollover = { snapshot: workflow.snapshot, preparedRevision: rolloverState.revision, rolloverId: rollover.id };
|
|
1355
|
+
await enqueueControlledRolloverRequest(ctx.cwd, rollover.id, currentSessionId);
|
|
1356
|
+
ctx.ui.notify("已恢复待完成的受控会话交接;当前回合结束后将继续切换。", "info");
|
|
1357
|
+
}
|
|
1358
|
+
} else if (rolloverState && typeof rolloverState.revision === "number" && rollover?.status === "claimed" && rollover.target_session_id === currentSessionId) {
|
|
1359
|
+
const consumed = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/session_rollover.rb", [
|
|
1360
|
+
"consume", "--write", "--json", "--expected-revision", String(rolloverState.revision), "--target-session-id", currentSessionId,
|
|
1361
|
+
]);
|
|
1362
|
+
const after = await inspectWorkflow(ctx.cwd);
|
|
1363
|
+
if (consumed.ok && after.result.ok && after.snapshot) {
|
|
1364
|
+
setControlState(ctx, executingState("恢复已认领的干净会话"));
|
|
1365
|
+
await triggerWorkflow(after.snapshot, "approval");
|
|
1366
|
+
} else if (!consumed.ok) {
|
|
1367
|
+
ctx.ui.notify(`已认领的会话交接无法恢复;工作流保持冻结:${consumed.stderr || consumed.stdout}`, "error");
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
});
|
|
1371
|
+
|
|
1372
|
+
pi.on("session_before_switch", async (event) => {
|
|
1373
|
+
// A controlled rollover must always create a fresh session. Other session
|
|
1374
|
+
// navigation remains OMP-owned and never consumes a Devflow handoff.
|
|
1375
|
+
if (pendingRollover && event.reason !== "new") return { cancel: true };
|
|
1376
|
+
});
|
|
1377
|
+
|
|
1378
|
+
pi.on("session_switch", async (event, ctx) => {
|
|
1379
|
+
if (!pendingRollover || event.reason !== "new") return;
|
|
1380
|
+
const pending = pendingRollover;
|
|
1381
|
+
const targetSessionId = ctx.sessionManager.getSessionId();
|
|
1382
|
+
const claimed = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/session_rollover.rb", [
|
|
1383
|
+
"claim", "--write", "--json", "--expected-revision", String(pending.preparedRevision), "--target-session-id", targetSessionId,
|
|
1384
|
+
]);
|
|
1385
|
+
const claimedPayload = parseJsonResult(claimed);
|
|
1386
|
+
if (!claimed.ok || !claimedPayload || typeof claimedPayload.revision !== "number") {
|
|
1387
|
+
ctx.ui.notify(`Devflow 会话交接认领失败;工作流保持冻结:${claimed.stderr || claimed.stdout}`, "error");
|
|
1388
|
+
return;
|
|
1389
|
+
}
|
|
1390
|
+
const consumed = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/session_rollover.rb", [
|
|
1391
|
+
"consume", "--write", "--json", "--expected-revision", String(claimedPayload.revision), "--target-session-id", targetSessionId,
|
|
1392
|
+
]);
|
|
1393
|
+
if (!consumed.ok) {
|
|
1394
|
+
ctx.ui.notify(`Devflow 会话交接消费失败;工作流保持冻结:${consumed.stderr || consumed.stdout}`, "error");
|
|
1395
|
+
return;
|
|
1396
|
+
}
|
|
1397
|
+
pendingRollover = undefined;
|
|
1398
|
+
activeContract = undefined;
|
|
1399
|
+
contractAgentCalls = 0;
|
|
1400
|
+
setControlState(ctx, executingState("在干净会话中执行下一工作流步骤"));
|
|
1401
|
+
await triggerWorkflow(pending.snapshot, "approval");
|
|
1215
1402
|
});
|
|
1216
1403
|
|
|
1217
1404
|
pi.on("before_agent_start", async event => ({
|
|
@@ -1277,7 +1464,10 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
1277
1464
|
return { handled: true };
|
|
1278
1465
|
}
|
|
1279
1466
|
const result = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/advance_state.rb", [
|
|
1280
|
-
"--write",
|
|
1467
|
+
"--write",
|
|
1468
|
+
"--json",
|
|
1469
|
+
"--expected-revision",
|
|
1470
|
+
String(revision),
|
|
1281
1471
|
...(checkpoint ? ["--confirm-checkpoint"] : ["--complete-step", stepId!]),
|
|
1282
1472
|
]);
|
|
1283
1473
|
if (!result.ok) {
|
|
@@ -1286,13 +1476,21 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
1286
1476
|
return { handled: true };
|
|
1287
1477
|
}
|
|
1288
1478
|
const after = await inspectWorkflow(ctx.cwd);
|
|
1479
|
+
activeContract = undefined;
|
|
1480
|
+
contractAgentCalls = 0;
|
|
1289
1481
|
if (!after.result.ok || !after.snapshot || after.snapshot.action === "workflow_complete") {
|
|
1290
1482
|
setControlState(ctx, initialControlState());
|
|
1291
1483
|
ctx.ui.notify(after.snapshot?.action === "workflow_complete" ? "工作流已完成。" : "已推进,但读取下一步骤失败。", "info");
|
|
1292
1484
|
return { handled: true };
|
|
1293
1485
|
}
|
|
1294
|
-
setControlState(ctx,
|
|
1295
|
-
|
|
1486
|
+
setControlState(ctx, waitingUserState("正在准备干净会话以执行下一工作流步骤"));
|
|
1487
|
+
try {
|
|
1488
|
+
await requestControlledRollover(ctx, after.snapshot);
|
|
1489
|
+
ctx.ui.notify("当前节点的证据已固定;本回合结束后将自动切换到干净会话并继续下一节点。", "info");
|
|
1490
|
+
} catch (error) {
|
|
1491
|
+
setControlState(ctx, { intent: "workflow-execute", phase: "blocked", label: "会话轮换准备失败" });
|
|
1492
|
+
ctx.ui.notify(`未开始下一节点:会话轮换准备失败:${String(error)}`, "error");
|
|
1493
|
+
}
|
|
1296
1494
|
return { handled: true };
|
|
1297
1495
|
}
|
|
1298
1496
|
}
|
|
@@ -1538,9 +1736,11 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
1538
1736
|
});
|
|
1539
1737
|
|
|
1540
1738
|
pi.registerTool({
|
|
1739
|
+
|
|
1541
1740
|
name: "devflow_requirement_graph",
|
|
1542
1741
|
label: "Devflow Requirement Graph",
|
|
1543
|
-
description: "Submit bounded requirement-graph candidates or inspect
|
|
1742
|
+
description: "Submit bounded requirement-graph candidates or inspect and validate the Core-owned canonical graph.",
|
|
1743
|
+
|
|
1544
1744
|
parameters: z.object({
|
|
1545
1745
|
action: z.enum(["propose", "inspect", "validate", "context", "confirm"]),
|
|
1546
1746
|
feature: z.string().min(1),
|
|
@@ -1552,7 +1752,7 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
1552
1752
|
read: z.boolean().optional(),
|
|
1553
1753
|
delta: z.object({
|
|
1554
1754
|
op: z.enum(["add", "link", "update"]),
|
|
1555
|
-
kind: z.enum(["source", "code", "term", "object", "behavior", "requirement", "rule", "boundary", "slice", "risk", "decision", "acceptance", "api", "workload_shape", "workload_constraint", "test_case"]).optional(),
|
|
1755
|
+
kind: z.enum(["source", "code", "term", "object", "behavior", "requirement", "rule", "boundary", "slice", "risk", "decision", "acceptance", "api", "workload_shape", "workload_constraint", "metric", "read_model", "test_case"]).optional(),
|
|
1556
1756
|
id: z.string().min(1).optional(),
|
|
1557
1757
|
data: z.record(z.string(), z.unknown()).optional(),
|
|
1558
1758
|
from: z.string().min(1).optional(),
|
|
@@ -1596,23 +1796,49 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
1596
1796
|
const node = Array.isArray(inspected?.graph?.nodes)
|
|
1597
1797
|
? inspected.graph.nodes.find((candidate: unknown) => typeof candidate === "object" && candidate !== null && (candidate as { id?: unknown }).id === params.nodeId) as { kind?: unknown; data?: unknown } | undefined
|
|
1598
1798
|
: undefined;
|
|
1599
|
-
const architectureDecision =
|
|
1600
|
-
|
|
1601
|
-
|
|
1799
|
+
const architectureDecision =
|
|
1800
|
+
node?.kind === "decision" && typeof node?.data === "object" && node.data !== null && (node.data as { decision_type?: unknown }).decision_type === "architecture_option_comparison";
|
|
1801
|
+
const architectureRisk =
|
|
1802
|
+
node?.kind === "risk" && typeof node?.data === "object" && node.data !== null && (node.data as { risk_type?: unknown }).risk_type === "architecture_option_risk";
|
|
1803
|
+
const metricNode = node?.kind === "metric";
|
|
1804
|
+
const readModelNode = node?.kind === "read_model";
|
|
1805
|
+
if (node?.kind === "workload_shape" || node?.kind === "workload_constraint" || metricNode || readModelNode || architectureDecision || architectureRisk) {
|
|
1602
1806
|
const preview = JSON.stringify(node.data ?? {}, null, 2);
|
|
1603
|
-
const decisionDialog = architectureDecision;
|
|
1604
|
-
const riskDialog = architectureRisk;
|
|
1605
1807
|
const confirmed = await ctx.ui.confirm(
|
|
1606
|
-
|
|
1808
|
+
architectureDecision
|
|
1809
|
+
? "确认架构方案取舍"
|
|
1810
|
+
: architectureRisk
|
|
1811
|
+
? "确认架构风险处理"
|
|
1812
|
+
: metricNode
|
|
1813
|
+
? "确认业务指标定义"
|
|
1814
|
+
: readModelNode
|
|
1815
|
+
? "确认读模型定义"
|
|
1816
|
+
: "确认工作负载事实或缺口",
|
|
1607
1817
|
[
|
|
1608
1818
|
`Feature:${params.feature}`,
|
|
1609
1819
|
`节点:${params.nodeId}`,
|
|
1610
|
-
`类型:${
|
|
1611
|
-
|
|
1820
|
+
`类型:${
|
|
1821
|
+
architectureDecision
|
|
1822
|
+
? "架构方案比较"
|
|
1823
|
+
: architectureRisk
|
|
1824
|
+
? "架构风险处理"
|
|
1825
|
+
: metricNode
|
|
1826
|
+
? "业务指标"
|
|
1827
|
+
: readModelNode
|
|
1828
|
+
? "读模型"
|
|
1829
|
+
: node?.kind === "workload_shape"
|
|
1830
|
+
? "工作负载形态"
|
|
1831
|
+
: "工作负载约束"
|
|
1832
|
+
}`,
|
|
1833
|
+
architectureDecision
|
|
1612
1834
|
? "确认的含义是:你已核对候选方案、证据与取舍,并接受其中 recommendation_option_id 作为本次架构选择。"
|
|
1613
|
-
:
|
|
1614
|
-
? "
|
|
1615
|
-
|
|
1835
|
+
: architectureRisk
|
|
1836
|
+
? "确认的含义是:你已核对风险陈述、缓解策略与剩余风险,并接受该风险登记结果。"
|
|
1837
|
+
: metricNode
|
|
1838
|
+
? "确认的含义是:你已核对该业务指标的口径、粒度、包含/排除规则与归属;它代表可追踪的业务事实,不只是文本标签。"
|
|
1839
|
+
: readModelNode
|
|
1840
|
+
? "确认的含义是:你已核对该读模型的投影语义、消费边界与查询责任;它代表 Core 认可的读侧契约。"
|
|
1841
|
+
: "确认的含义仅是:该事实或缺口、来源与责任归属已被你核对;它不代表容量已经满足,也不会替你选择实现方案。",
|
|
1616
1842
|
"待确认内容:",
|
|
1617
1843
|
preview.length > 8_000 ? `${preview.slice(0, 8_000)}\n…(内容过长,已截断)` : preview,
|
|
1618
1844
|
].join("\n\n"),
|
|
@@ -2029,19 +2255,12 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
2029
2255
|
const payload = parseJsonResult(inspected);
|
|
2030
2256
|
const raw = payload?.contract;
|
|
2031
2257
|
if (!controlPlaneSucceeded(inspected) || !raw || typeof raw !== "object") return renderResult(inspected, { action: input.action });
|
|
2032
|
-
const
|
|
2033
|
-
|
|
2034
|
-
const contract: ActiveExecutionContract = {
|
|
2035
|
-
contractId: String(record.contract_id ?? ""), feature: String(record.feature ?? ""), step: String(record.step ?? ""),
|
|
2036
|
-
stateRevision: typeof record.state_revision === "number" ? record.state_revision : undefined,
|
|
2037
|
-
graphRevision: typeof record.graph_revision === "number" ? record.graph_revision : undefined,
|
|
2038
|
-
allowedPaths: strings("allowed_paths"), allowedAgents: strings("allowed_agents"), allowedTools: strings("allowed_tools"), checkerIds: strings("checker_ids"),
|
|
2039
|
-
toolBudget: typeof record.tool_budget === "number" ? record.tool_budget : 0,
|
|
2040
|
-
frozenHash: String(record.frozen_hash ?? ""), acceptance: strings("acceptance"), allowedGraphDelta: strings("allowed_graph_delta"), onUnknown: "block_and_escalate",
|
|
2041
|
-
};
|
|
2042
|
-
if (!contract.contractId || !contract.frozenHash) return renderResult({ ok: false, exitCode: 1, stdout: "", stderr: "Core returned an invalid execution contract." });
|
|
2258
|
+
const contract = contractFromRecord(raw as Record<string, unknown>);
|
|
2259
|
+
if (!contract) return renderResult({ ok: false, exitCode: 1, stdout: "", stderr: "Core returned an invalid execution contract." });
|
|
2043
2260
|
if (input.action === "inspect") return { content: [{ type: "text" as const, text: JSON.stringify(contract, null, 2) }], details: { contract } };
|
|
2044
|
-
if (
|
|
2261
|
+
if (!["planning", "waiting-user", "awaiting-approval"].includes(controlState.phase)) {
|
|
2262
|
+
return renderResult({ ok: false, exitCode: 2, stdout: "", stderr: "Contracts may only be activated from the planning phase or while the step waits for user review." });
|
|
2263
|
+
}
|
|
2045
2264
|
const confirmed = await ctx.ui.confirm(
|
|
2046
2265
|
"激活 Devflow 执行合同",
|
|
2047
2266
|
[
|
|
@@ -504,7 +504,15 @@ result =
|
|
|
504
504
|
next_intent_requested = false
|
|
505
505
|
current_revision = Integer(state.fetch("revision", 0))
|
|
506
506
|
|
|
507
|
-
if
|
|
507
|
+
if state.dig("rollover", "status") && %w[prepared claimed].include?(state.dig("rollover", "status"))
|
|
508
|
+
result = fail_result(
|
|
509
|
+
"session_rollover_in_progress",
|
|
510
|
+
"rollover" => state["rollover"],
|
|
511
|
+
"message" => "Workflow transitions are frozen until the controlled session handoff is consumed or explicitly recovered."
|
|
512
|
+
)
|
|
513
|
+
end
|
|
514
|
+
|
|
515
|
+
if result.nil? && !options[:expected_revision].nil? && current_revision != options[:expected_revision]
|
|
508
516
|
result = fail_result(
|
|
509
517
|
"stale_revision",
|
|
510
518
|
"expected_revision" => options[:expected_revision],
|
|
@@ -31,6 +31,10 @@ module DevflowControlIO
|
|
|
31
31
|
when :workflow then normalized.match?(%r{\A\.planning/workflows/[A-Za-z0-9][A-Za-z0-9_-]*\.yaml\z})
|
|
32
32
|
when :registry then normalized == ".planning/artifacts/index.yaml"
|
|
33
33
|
when :artifact then normalized.start_with?(".planning/") || normalized.start_with?(".context/")
|
|
34
|
+
# Feature names are user-facing and may be Unicode. `cleanpath`
|
|
35
|
+
# and the parent-traversal check above own containment; this
|
|
36
|
+
# matcher only fixes the handoff namespace and immutable name.
|
|
37
|
+
when :handoff then normalized.match?(%r{\A\.planning/handoffs/[^/]+/ROL-[0-9a-f-]+\.json\z})
|
|
34
38
|
else false
|
|
35
39
|
end
|
|
36
40
|
raise InvalidControlPath, "invalid #{kind} control path" unless allowed
|
|
@@ -13,7 +13,7 @@ require_relative "requirement_graph"
|
|
|
13
13
|
POLICY = {
|
|
14
14
|
"requirement-baseline" => { "checkers" => %w[requirement-graph-check slice-independence-check acceptance-coverage-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[requirement rule boundary slice acceptance] },
|
|
15
15
|
"api-contract" => { "checkers" => %w[requirement-graph-check contract-coverage-check api-test-schema-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[api] },
|
|
16
|
-
"architecture-feasibility" => { "checkers" => %w[requirement-graph-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[workload_shape workload_constraint decision risk] },
|
|
16
|
+
"architecture-feasibility" => { "checkers" => %w[requirement-graph-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[workload_shape workload_constraint decision risk metric read_model] },
|
|
17
17
|
"integration-verify" => { "checkers" => %w[requirement-graph-check slice-independence-check contract-coverage-check change-impact-check], "agents" => ["verification-runner"], "budget" => 1, "allowed_graph_delta" => %w[requirement risk decision] },
|
|
18
18
|
"acceptance-test-design" => { "checkers" => %w[requirement-graph-check contract-coverage-check acceptance-coverage-check api-test-matrix-check test-coverage-check test-obligation-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[test_case] },
|
|
19
19
|
"change-plan" => { "checkers" => %w[requirement-graph-check slice-independence-check contract-coverage-check api-test-matrix-check test-coverage-check test-obligation-check test-task-binding-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[acceptance] },
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Controlled, content-addressed session handoff state. This script is the only
|
|
5
|
+
# writer for STATE.rollover; callers must hold no assumptions about a
|
|
6
|
+
# filesystem "latest" handoff. STATE is the authority and immutable handoff
|
|
7
|
+
# JSON is the evidence it points to.
|
|
8
|
+
|
|
9
|
+
require "digest"
|
|
10
|
+
require "json"
|
|
11
|
+
require "optparse"
|
|
12
|
+
require "securerandom"
|
|
13
|
+
require "time"
|
|
14
|
+
require "yaml"
|
|
15
|
+
require_relative "control_io"
|
|
16
|
+
|
|
17
|
+
options = { json: false, write: false }
|
|
18
|
+
parser = OptionParser.new do |opts|
|
|
19
|
+
opts.banner = "Usage: session_rollover.rb prepare|claim|consume|inspect [options]"
|
|
20
|
+
opts.on("--write", "Apply the requested transition") { options[:write] = true }
|
|
21
|
+
opts.on("--json", "Print JSON") { options[:json] = true }
|
|
22
|
+
opts.on("--expected-revision N", Integer, "STATE revision compare-and-swap") { |v| options[:expected_revision] = v }
|
|
23
|
+
opts.on("--source-session-id ID", "Source OMP session id") { |v| options[:source_session_id] = v }
|
|
24
|
+
opts.on("--target-session-id ID", "Target OMP session id") { |v| options[:target_session_id] = v }
|
|
25
|
+
opts.on("--reason TEXT", "Auditable rollover reason") { |v| options[:reason] = v }
|
|
26
|
+
end
|
|
27
|
+
action = ARGV.shift
|
|
28
|
+
parser.parse!
|
|
29
|
+
|
|
30
|
+
def fail_result(reason, details = {})
|
|
31
|
+
{ "ok" => false, "reason" => reason }.merge(details)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def parse_state(path)
|
|
35
|
+
raw = File.read(path)
|
|
36
|
+
match = raw.match(/\A---\n(.*?)\n---\n/m)
|
|
37
|
+
raise "STATE.md is missing YAML front matter" unless match
|
|
38
|
+
[YAML.safe_load(match[1], permitted_classes: [], aliases: false), raw]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def write_state(path, state, previous)
|
|
42
|
+
body = previous.sub(/\A---\n.*?\n---\n/m, "")
|
|
43
|
+
front = state.to_yaml.sub(/\A---\n/, "")
|
|
44
|
+
DevflowControlIO.atomic_write(path, "---\n#{front}---\n#{body}")
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def valid_session_id?(value)
|
|
48
|
+
value.is_a?(String) && value.match?(/\A[A-Za-z0-9][A-Za-z0-9._:-]{0,255}\z/)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def iso_now
|
|
52
|
+
Time.now.utc.iso8601(3)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
root = Dir.pwd
|
|
56
|
+
state_path, = DevflowControlIO.resolve_control_path(root, ".planning/STATE.md", :state)
|
|
57
|
+
result = catch(:transition_done) do
|
|
58
|
+
lock = DevflowControlIO.acquire_project_lock(root)
|
|
59
|
+
begin
|
|
60
|
+
unless File.exist?(state_path)
|
|
61
|
+
result = fail_result("missing_state")
|
|
62
|
+
throw :transition_done, result
|
|
63
|
+
end
|
|
64
|
+
state, raw = parse_state(state_path)
|
|
65
|
+
revision = Integer(state.fetch("revision", 0))
|
|
66
|
+
if !options[:expected_revision].nil? && revision != options[:expected_revision]
|
|
67
|
+
result = fail_result("stale_revision", "expected_revision" => options[:expected_revision], "actual_revision" => revision)
|
|
68
|
+
throw :transition_done, result
|
|
69
|
+
end
|
|
70
|
+
rollover = state["rollover"]
|
|
71
|
+
|
|
72
|
+
case action
|
|
73
|
+
when "inspect"
|
|
74
|
+
result = { "ok" => true, "revision" => revision, "rollover" => rollover }
|
|
75
|
+
when "prepare"
|
|
76
|
+
unless valid_session_id?(options[:source_session_id])
|
|
77
|
+
result = fail_result("invalid_source_session_id")
|
|
78
|
+
throw :transition_done, result
|
|
79
|
+
end
|
|
80
|
+
if rollover && %w[prepared claimed].include?(rollover["status"])
|
|
81
|
+
result = fail_result("rollover_in_progress", "rollover" => rollover)
|
|
82
|
+
throw :transition_done, result
|
|
83
|
+
end
|
|
84
|
+
rollover_id = "ROL-#{SecureRandom.uuid}"
|
|
85
|
+
relative_path = ".planning/handoffs/#{state.fetch("current_feature")}/#{rollover_id}.json"
|
|
86
|
+
absolute_path, = DevflowControlIO.resolve_control_path(root, relative_path, :handoff)
|
|
87
|
+
payload = {
|
|
88
|
+
"schema_version" => 1,
|
|
89
|
+
"rollover_id" => rollover_id,
|
|
90
|
+
"created_at" => iso_now,
|
|
91
|
+
"reason" => options[:reason].to_s.empty? ? "workflow_step_boundary" : options[:reason],
|
|
92
|
+
"source_session_id" => options[:source_session_id],
|
|
93
|
+
"state" => {
|
|
94
|
+
"revision" => revision,
|
|
95
|
+
"current_flow" => state["current_flow"], "current_feature" => state["current_feature"],
|
|
96
|
+
"current_phase" => state["current_phase"], "current_step" => state["current_step"],
|
|
97
|
+
"status" => state["status"], "completed_steps" => state["completed_steps"], "pending_steps" => state["pending_steps"]
|
|
98
|
+
},
|
|
99
|
+
"references" => {
|
|
100
|
+
"state_path" => ".planning/STATE.md",
|
|
101
|
+
"workflow_path" => state["workflow_config"],
|
|
102
|
+
"artifact_registry" => ".planning/artifacts/index.yaml"
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
serialized = JSON.generate(payload) + "\n"
|
|
106
|
+
digest = Digest::SHA256.hexdigest(serialized)
|
|
107
|
+
if options[:write]
|
|
108
|
+
DevflowControlIO.atomic_create(absolute_path, serialized)
|
|
109
|
+
state["rollover"] = {
|
|
110
|
+
"id" => rollover_id, "status" => "prepared", "handoff_path" => relative_path,
|
|
111
|
+
"handoff_sha256" => digest, "source_session_id" => options[:source_session_id],
|
|
112
|
+
"target_session_id" => nil, "expected_state_revision" => revision,
|
|
113
|
+
"prepared_at" => iso_now, "claimed_at" => nil
|
|
114
|
+
}
|
|
115
|
+
state["revision"] = revision + 1
|
|
116
|
+
write_state(state_path, state, raw)
|
|
117
|
+
end
|
|
118
|
+
result = { "ok" => true, "written" => options[:write], "rollover" => options[:write] ? state["rollover"] : payload.merge("handoff_sha256" => digest), "revision" => options[:write] ? revision + 1 : revision }
|
|
119
|
+
when "claim"
|
|
120
|
+
unless rollover.is_a?(Hash) && rollover["status"] == "prepared"
|
|
121
|
+
result = fail_result("rollover_not_prepared", "rollover" => rollover)
|
|
122
|
+
throw :transition_done, result
|
|
123
|
+
end
|
|
124
|
+
unless valid_session_id?(options[:target_session_id])
|
|
125
|
+
result = fail_result("invalid_target_session_id")
|
|
126
|
+
throw :transition_done, result
|
|
127
|
+
end
|
|
128
|
+
path, = DevflowControlIO.resolve_control_path(root, rollover["handoff_path"], :handoff)
|
|
129
|
+
unless File.exist?(path) && Digest::SHA256.hexdigest(File.binread(path)) == rollover["handoff_sha256"]
|
|
130
|
+
result = fail_result("handoff_integrity_failure")
|
|
131
|
+
throw :transition_done, result
|
|
132
|
+
end
|
|
133
|
+
if options[:write]
|
|
134
|
+
rollover["status"] = "claimed"; rollover["target_session_id"] = options[:target_session_id]; rollover["claimed_at"] = iso_now
|
|
135
|
+
state["rollover"] = rollover; state["revision"] = revision + 1; write_state(state_path, state, raw)
|
|
136
|
+
end
|
|
137
|
+
result = { "ok" => true, "written" => options[:write], "rollover" => rollover, "revision" => options[:write] ? revision + 1 : revision }
|
|
138
|
+
when "consume"
|
|
139
|
+
unless rollover.is_a?(Hash) && rollover["status"] == "claimed" && valid_session_id?(options[:target_session_id]) && rollover["target_session_id"] == options[:target_session_id]
|
|
140
|
+
result = fail_result("rollover_not_owned_by_target", "rollover" => rollover)
|
|
141
|
+
throw :transition_done, result
|
|
142
|
+
end
|
|
143
|
+
if options[:write]
|
|
144
|
+
rollover["status"] = "consumed"; rollover["consumed_at"] = iso_now
|
|
145
|
+
state["rollover"] = rollover; state["revision"] = revision + 1; write_state(state_path, state, raw)
|
|
146
|
+
end
|
|
147
|
+
result = { "ok" => true, "written" => options[:write], "rollover" => rollover, "revision" => options[:write] ? revision + 1 : revision }
|
|
148
|
+
else
|
|
149
|
+
result = fail_result("unknown_action")
|
|
150
|
+
end
|
|
151
|
+
rescue DevflowControlIO::InvalidControlPath, Psych::Exception, SystemCallError, RuntimeError => error
|
|
152
|
+
result = fail_result("rollover_transition_failed", "message" => error.message)
|
|
153
|
+
ensure
|
|
154
|
+
DevflowControlIO.release_lock(lock)
|
|
155
|
+
end
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
puts JSON.pretty_generate(result)
|
|
159
|
+
exit(result["ok"] ? 0 : 1)
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
# frozen_string_literal: true
|
|
3
3
|
|
|
4
4
|
require "json"
|
|
5
|
+
require "digest"
|
|
5
6
|
require "optparse"
|
|
6
7
|
require "yaml"
|
|
7
8
|
require_relative "control_io"
|
|
@@ -42,6 +43,7 @@ module WorkflowStateValidator
|
|
|
42
43
|
REQUIRED_STATE_FIELDS.each do |field|
|
|
43
44
|
issues << issue("error", "missing_state_field", "STATE.md is missing required field: #{field}", field: field) unless state.key?(field)
|
|
44
45
|
end
|
|
46
|
+
validate_rollover(issues, root, state)
|
|
45
47
|
return result(false, state_path, nil, nil, issues) if issues.any? { |entry| entry["severity"] == "error" }
|
|
46
48
|
|
|
47
49
|
ownership = PhaseOwnership.validate_state(root: root, state: state, owner_override: phase_owner_override)
|
|
@@ -122,6 +124,40 @@ module WorkflowStateValidator
|
|
|
122
124
|
nil
|
|
123
125
|
end
|
|
124
126
|
|
|
127
|
+
def validate_rollover(issues, root, state)
|
|
128
|
+
rollover = state["rollover"]
|
|
129
|
+
return if rollover.nil?
|
|
130
|
+
unless rollover.is_a?(Hash)
|
|
131
|
+
issues << issue("error", "invalid_rollover", "STATE.rollover must be an object")
|
|
132
|
+
return
|
|
133
|
+
end
|
|
134
|
+
status = rollover["status"]
|
|
135
|
+
unless %w[prepared claimed consumed failed].include?(status)
|
|
136
|
+
issues << issue("error", "invalid_rollover_status", "STATE.rollover.status is invalid", status: status)
|
|
137
|
+
return
|
|
138
|
+
end
|
|
139
|
+
%w[id handoff_path handoff_sha256 source_session_id prepared_at].each do |field|
|
|
140
|
+
issues << issue("error", "missing_rollover_field", "STATE.rollover is missing #{field}", field: field) unless rollover[field].is_a?(String) && !rollover[field].empty?
|
|
141
|
+
end
|
|
142
|
+
return if issues.any? { |entry| entry["severity"] == "error" }
|
|
143
|
+
begin
|
|
144
|
+
handoff_path, = DevflowControlIO.resolve_control_path(root, rollover["handoff_path"], :handoff)
|
|
145
|
+
if %w[prepared claimed consumed].include?(status) && !File.exist?(handoff_path)
|
|
146
|
+
issues << issue("error", "missing_rollover_handoff", "STATE.rollover references a missing immutable handoff", path: rollover["handoff_path"])
|
|
147
|
+
elsif File.exist?(handoff_path) && Digest::SHA256.hexdigest(File.binread(handoff_path)) != rollover["handoff_sha256"]
|
|
148
|
+
issues << issue("error", "rollover_handoff_hash_mismatch", "STATE.rollover handoff hash does not match immutable payload", path: rollover["handoff_path"])
|
|
149
|
+
end
|
|
150
|
+
rescue DevflowControlIO::InvalidControlPath => error
|
|
151
|
+
issues << issue("error", "invalid_rollover_handoff_path", error.message)
|
|
152
|
+
end
|
|
153
|
+
if rollover["handoff_sha256"].to_s !~ /\A[0-9a-f]{64}\z/
|
|
154
|
+
issues << issue("error", "invalid_rollover_handoff_hash", "STATE.rollover.handoff_sha256 must be SHA-256 hex")
|
|
155
|
+
end
|
|
156
|
+
if status == "claimed" && (rollover["target_session_id"].to_s.empty? || rollover["claimed_at"].to_s.empty?)
|
|
157
|
+
issues << issue("error", "incomplete_rollover_claim", "Claimed rollover requires target_session_id and claimed_at")
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
|
|
125
161
|
def load_yaml(path, issues, code)
|
|
126
162
|
unless File.exist?(path)
|
|
127
163
|
issues << issue("error", "missing_#{code}", "YAML file does not exist", path: path)
|
|
@@ -7,6 +7,14 @@ description: "Use after integration verification and before change planning to d
|
|
|
7
7
|
|
|
8
8
|
在代码执行前,基于已确认的需求、契约和架构约束提出独立的 `TC-*` 验收测试候选。测试意图必须独立于实现:禁止读取 `SUMMARY.md`、实现源码、git diff 或 agent 交付自述来决定测试范围。
|
|
9
9
|
|
|
10
|
+
## 执行合同
|
|
11
|
+
|
|
12
|
+
本步骤的图谱写入受 ExecutionContract v2 约束:`devflow_requirement_graph(action="propose", kind="test_case")` 与 `devflow_api_test_matrix(action="import")` 仅在 active contract 的 step 为 `acceptance-test-design` 时放行。步骤开始且无 active contract 时:
|
|
13
|
+
|
|
14
|
+
1. 先调用 `devflow_contract(action="status")` 检查当前会话是否有 active contract;
|
|
15
|
+
2. 未激活时调用 `devflow_contract(action="activate", feature="<feature>", step="acceptance-test-design")` 合成并激活(planning / waiting-user / awaiting-approval 阶段均可,Core 校验 STATE 与图谱 revision 后由用户确认);
|
|
16
|
+
3. 会话中断后 contract 由 runtime 在 `session_start` 时从 `.planning/contracts/{feature}/EC-*.json` 自动恢复;恢复时校验 state/graph revision,推进后自动失效,需重新合成。
|
|
17
|
+
|
|
10
18
|
## 输入
|
|
11
19
|
|
|
12
20
|
按顺序读取:
|
package/src/constants.ts
CHANGED
package/src/runtime.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { mkdir } from "node:fs/promises";
|
|
1
|
+
import { mkdir, readFile, rm } from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
|
|
4
4
|
import { OMP_RUNTIME_VERSION, PRODUCT_NAME, PRODUCT_VERSION } from "./constants.ts";
|
|
@@ -61,6 +61,10 @@ const MANAGED_SETTINGS: Record<string, unknown> = {
|
|
|
61
61
|
"lsp.enabled": true,
|
|
62
62
|
"lsp.lazy": true,
|
|
63
63
|
"lsp.diagnosticsOnWrite": true,
|
|
64
|
+
// Devflow owns cross-session handoff through STATE.rollover. OMP's LLM-made
|
|
65
|
+
// handoff would create a second, unauditable source of continuation facts.
|
|
66
|
+
"compaction.strategy": "context-full",
|
|
67
|
+
"compaction.handoffSaveToDisk": false,
|
|
64
68
|
};
|
|
65
69
|
|
|
66
70
|
export async function createEmbeddedRuntime(project: ProjectContext, options: RuntimeOptions = {}) {
|
|
@@ -173,8 +177,39 @@ export async function runInteractiveRuntime(project: ProjectContext, options: Ru
|
|
|
173
177
|
mode.showStatus(`Devflow project ${project.manifest.name} · ${project.manifest.projectId.slice(0, 8)}`);
|
|
174
178
|
if (runtime.modelFallbackMessage) mode.showWarning(runtime.modelFallbackMessage);
|
|
175
179
|
if (options.initialPrompt) await runtime.session.prompt(options.initialPrompt);
|
|
180
|
+
// Extensions cannot create a session from lifecycle hooks. The interactive
|
|
181
|
+
// loop, however, owns AgentSession and reaches this point only after the
|
|
182
|
+
// submitted turn has settled. A request is merely a wake-up signal: its
|
|
183
|
+
// authority remains STATE.rollover, which the extension verifies and claims
|
|
184
|
+
// during session_switch before it starts the next workflow turn.
|
|
185
|
+
const consumeControlledRollover = async (): Promise<void> => {
|
|
186
|
+
const requestPath = path.join(project.root, ".planning", "rollover-request.json");
|
|
187
|
+
let request: { schema_version?: number; rollover_id?: string; source_session_id?: string };
|
|
188
|
+
try {
|
|
189
|
+
request = JSON.parse(await readFile(requestPath, "utf8")) as typeof request;
|
|
190
|
+
} catch {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (
|
|
194
|
+
request.schema_version !== 1 ||
|
|
195
|
+
typeof request.rollover_id !== "string" ||
|
|
196
|
+
!/^ROL-[0-9a-f-]+$/i.test(request.rollover_id) ||
|
|
197
|
+
request.source_session_id !== runtime.session.sessionManager.getSessionId()
|
|
198
|
+
) {
|
|
199
|
+
await rm(requestPath, { force: true });
|
|
200
|
+
mode.showWarning("已忽略无效或陈旧的 Devflow 会话轮换请求;STATE 未被修改。");
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const switched = await runtime.session.newSession();
|
|
204
|
+
if (!switched) {
|
|
205
|
+
mode.showWarning("Devflow 会话轮换被运行时取消;当前会话保持不变。请检查控制面状态后重试。");
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
await rm(requestPath, { force: true });
|
|
209
|
+
};
|
|
176
210
|
while (true) {
|
|
177
211
|
const input = await mode.getUserInput();
|
|
178
212
|
await omp.submitInteractiveInput(mode, runtime.session, input);
|
|
213
|
+
await consumeControlledRollover();
|
|
179
214
|
}
|
|
180
215
|
}
|