@guanlin4924/devflow-pi 0.1.25 → 0.1.27
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 +306 -36
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/advance_state.rb +38 -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/inspect_state.rb +22 -0
- 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/platform-extension.ts +41 -0
- package/src/runtime.ts +40 -2
- package/templates/omp/AGENTS.md +2 -0
- package/templates/omp/config.yml +3 -0
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.27
|
|
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
|
[
|
|
@@ -2184,9 +2403,11 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
2184
2403
|
label: "Devflow Transition",
|
|
2185
2404
|
description: "Apply one validated delivery-state transition. This is the only supported STATE.md mutation path.",
|
|
2186
2405
|
parameters: z.object({
|
|
2187
|
-
action: z.enum(["complete-step", "confirm-checkpoint", "checkpoint-decision", "enter-failure-branch"]),
|
|
2406
|
+
action: z.enum(["complete-step", "confirm-checkpoint", "checkpoint-decision", "enter-failure-branch", "cancel-feature"]),
|
|
2188
2407
|
step: z.string().optional(),
|
|
2189
2408
|
decision: z.string().optional(),
|
|
2409
|
+
reason: z.string().optional(),
|
|
2410
|
+
confirm: z.literal(true).optional(),
|
|
2190
2411
|
state: z.string().optional(),
|
|
2191
2412
|
expectedRevision: z.number().int().min(0),
|
|
2192
2413
|
}),
|
|
@@ -2219,6 +2440,19 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
2219
2440
|
return renderResult({ ok: false, exitCode: 2, stdout: "", stderr: "decision is required" });
|
|
2220
2441
|
}
|
|
2221
2442
|
args.push("--checkpoint-decision", params.decision);
|
|
2443
|
+
} else if (params.action === "cancel-feature") {
|
|
2444
|
+
const inspected = await inspectWorkflow(ctx.cwd);
|
|
2445
|
+
const snapshot = inspected.snapshot;
|
|
2446
|
+
const feature = typeof snapshot?.current_feature === "string" ? snapshot.current_feature : "当前需求";
|
|
2447
|
+
const revision = typeof snapshot?.revision === "number" ? snapshot.revision : undefined;
|
|
2448
|
+
if (!inspected.result.ok || revision === undefined) return renderResult(inspected.result, { action: params.action });
|
|
2449
|
+
if (!params.reason?.trim()) return renderResult({ ok: false, exitCode: 2, stdout: "", stderr: "reason is required" });
|
|
2450
|
+
const confirmed = await ctx.ui.confirm(
|
|
2451
|
+
"确认弃用当前需求",
|
|
2452
|
+
`需求:${feature}\n原因:${params.reason.trim()}\n\n将结束当前需求、释放项目占用并保留现有工件;不会把未完成步骤标记为完成。继续吗?`,
|
|
2453
|
+
);
|
|
2454
|
+
if (!confirmed) return renderResult({ ok: false, exitCode: 1, stdout: "", stderr: "User declined feature cancellation." });
|
|
2455
|
+
args.push("--cancel", "--cancel-reason", params.reason.trim());
|
|
2222
2456
|
} else {
|
|
2223
2457
|
args.push("--enter-failure-branch");
|
|
2224
2458
|
}
|
|
@@ -2382,6 +2616,42 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
2382
2616
|
ctx.ui.notify("控制面 registry 已修复;未推进工作流。请审查当前步骤产物后明确回复“批准执行”或提出修改意见。", "info");
|
|
2383
2617
|
return;
|
|
2384
2618
|
}
|
|
2619
|
+
if (trimmed === "abandon" || trimmed.startsWith("abandon ") || trimmed === "cancel" || trimmed.startsWith("cancel ")) {
|
|
2620
|
+
const inspected = await inspectWorkflow(ctx.cwd);
|
|
2621
|
+
const snapshot = inspected.snapshot;
|
|
2622
|
+
const revision = typeof snapshot?.revision === "number" ? snapshot.revision : undefined;
|
|
2623
|
+
const feature = typeof snapshot?.current_feature === "string" ? snapshot.current_feature : undefined;
|
|
2624
|
+
const status = typeof snapshot?.status === "string" ? snapshot.status : undefined;
|
|
2625
|
+
if (!inspected.result.ok || revision === undefined || !feature) {
|
|
2626
|
+
ctx.ui.notify(`无法读取当前需求状态:${inspected.result.stderr || inspected.result.stdout}`, "error");
|
|
2627
|
+
return;
|
|
2628
|
+
}
|
|
2629
|
+
if (!status || ["completed", "cancelled", "failed"].includes(status)) {
|
|
2630
|
+
ctx.ui.notify(`当前没有可弃用的进行中需求(status=${status || "unknown"})。`, "info");
|
|
2631
|
+
return;
|
|
2632
|
+
}
|
|
2633
|
+
const reason = trimmed.split(/\s+/, 2)[1]?.trim() || "用户主动终止当前需求";
|
|
2634
|
+
const confirmed = await ctx.ui.confirm(
|
|
2635
|
+
"确认弃用当前需求",
|
|
2636
|
+
`需求:${feature}\n原因:${reason}\n\n将结束当前需求、释放项目占用并保留现有工件;不会把未完成步骤标记为完成。继续吗?`,
|
|
2637
|
+
);
|
|
2638
|
+
if (!confirmed) {
|
|
2639
|
+
ctx.ui.notify("已取消弃用操作;状态保持不变。", "info");
|
|
2640
|
+
return;
|
|
2641
|
+
}
|
|
2642
|
+
const result = await runRuby(ctx.cwd, "workflow-orchestrator/scripts/advance_state.rb", [
|
|
2643
|
+
"--write", "--json", "--cancel", "--cancel-reason", reason, "--expected-revision", String(revision),
|
|
2644
|
+
]);
|
|
2645
|
+
if (!result.ok) {
|
|
2646
|
+
ctx.ui.notify(`弃用当前需求失败:${result.stderr || result.stdout}`, "error");
|
|
2647
|
+
return;
|
|
2648
|
+
}
|
|
2649
|
+
activeContract = undefined;
|
|
2650
|
+
contractAgentCalls = 0;
|
|
2651
|
+
setControlState(ctx, initialControlState());
|
|
2652
|
+
ctx.ui.notify(`需求“${feature}”已弃用;现有工件已保留。现在可以创建新需求。`, "info");
|
|
2653
|
+
return;
|
|
2654
|
+
}
|
|
2385
2655
|
if (trimmed === "project-init") {
|
|
2386
2656
|
const initialization = await collectProjectInitialization(ctx);
|
|
2387
2657
|
if (!initialization) return;
|
|
@@ -2453,7 +2723,7 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
2453
2723
|
}
|
|
2454
2724
|
const existing = await inspectWorkflow(ctx.cwd);
|
|
2455
2725
|
const existingFeature = existing.snapshot?.current_feature;
|
|
2456
|
-
const previousWorkflowComplete = existing.snapshot?.action
|
|
2726
|
+
const previousWorkflowComplete = ["workflow_complete", "workflow_cancelled"].includes(existing.snapshot?.action as string);
|
|
2457
2727
|
if (
|
|
2458
2728
|
!previousWorkflowComplete &&
|
|
2459
2729
|
typeof existingFeature === "string" &&
|
|
@@ -2534,7 +2804,7 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
2534
2804
|
return;
|
|
2535
2805
|
}
|
|
2536
2806
|
ctx.ui.notify(
|
|
2537
|
-
"Usage: /devflow doctor | status | explain-state | recover | project-init | start <feature>|<phase> | resume",
|
|
2807
|
+
"Usage: /devflow doctor | status | explain-state | recover | abandon [reason] | project-init | start <feature>|<phase> | resume",
|
|
2538
2808
|
"warning",
|
|
2539
2809
|
);
|
|
2540
2810
|
},
|
|
@@ -21,6 +21,8 @@ options = {
|
|
|
21
21
|
checkpoint_decision: nil,
|
|
22
22
|
enter_failure_branch: false,
|
|
23
23
|
exit_failure_branch: false,
|
|
24
|
+
cancel: false,
|
|
25
|
+
cancel_reason: nil,
|
|
24
26
|
expected_revision: nil,
|
|
25
27
|
json: false
|
|
26
28
|
}
|
|
@@ -34,6 +36,8 @@ OptionParser.new do |opts|
|
|
|
34
36
|
opts.on("--checkpoint-decision VALUE", "Set checkpoint decision, e.g. approved, rejected, needs_fix") { |value| options[:checkpoint_decision] = value }
|
|
35
37
|
opts.on("--enter-failure-branch", "Transition into the configured failure branch for the current step") { options[:enter_failure_branch] = true }
|
|
36
38
|
opts.on("--exit-failure-branch", "Return from a completed failure branch to its configured source flow") { options[:exit_failure_branch] = true }
|
|
39
|
+
opts.on("--cancel", "Cancel the active feature without marking pending steps completed") { options[:cancel] = true }
|
|
40
|
+
opts.on("--cancel-reason REASON", "Record why the active feature was cancelled") { |reason| options[:cancel_reason] = reason }
|
|
37
41
|
opts.on("--expected-revision REVISION", Integer, "Reject a stale write unless STATE.md has this revision") { |value| options[:expected_revision] = value }
|
|
38
42
|
opts.on("--json", "Print JSON output") { options[:json] = true }
|
|
39
43
|
end.parse!
|
|
@@ -504,7 +508,15 @@ result =
|
|
|
504
508
|
next_intent_requested = false
|
|
505
509
|
current_revision = Integer(state.fetch("revision", 0))
|
|
506
510
|
|
|
507
|
-
if
|
|
511
|
+
if state.dig("rollover", "status") && %w[prepared claimed].include?(state.dig("rollover", "status"))
|
|
512
|
+
result = fail_result(
|
|
513
|
+
"session_rollover_in_progress",
|
|
514
|
+
"rollover" => state["rollover"],
|
|
515
|
+
"message" => "Workflow transitions are frozen until the controlled session handoff is consumed or explicitly recovered."
|
|
516
|
+
)
|
|
517
|
+
end
|
|
518
|
+
|
|
519
|
+
if result.nil? && !options[:expected_revision].nil? && current_revision != options[:expected_revision]
|
|
508
520
|
result = fail_result(
|
|
509
521
|
"stale_revision",
|
|
510
522
|
"expected_revision" => options[:expected_revision],
|
|
@@ -519,6 +531,31 @@ result =
|
|
|
519
531
|
action_description = "Checkpoint decision set to: #{options[:checkpoint_decision]}"
|
|
520
532
|
end
|
|
521
533
|
|
|
534
|
+
if result.nil? && options[:cancel]
|
|
535
|
+
if %w[completed cancelled failed].include?(state["status"])
|
|
536
|
+
result = fail_result("feature_not_active", "status" => state["status"])
|
|
537
|
+
elsif options[:cancel_reason].to_s.strip.empty?
|
|
538
|
+
result = fail_result("cancel_reason_required")
|
|
539
|
+
else
|
|
540
|
+
state["status"] = "cancelled"
|
|
541
|
+
state["cancel_reason"] = options[:cancel_reason].to_s.strip
|
|
542
|
+
state["cancelled_at"] = Date.today.to_s
|
|
543
|
+
# A cancelled feature is terminal. Preserve completed steps and the
|
|
544
|
+
# previous step for history, but clear current/pending work so the
|
|
545
|
+
# inspector can expose a terminal state and ownership can be released.
|
|
546
|
+
state["cancelled_step"] = state["current_step"]
|
|
547
|
+
state["current_step"] = nil
|
|
548
|
+
state["pending_steps"] = []
|
|
549
|
+
state.delete("checkpoint")
|
|
550
|
+
state.delete("checkpoint_label")
|
|
551
|
+
state.delete("checkpoint_decision")
|
|
552
|
+
state.delete("rollover")
|
|
553
|
+
changed = true
|
|
554
|
+
actions << "feature_cancelled"
|
|
555
|
+
action_description = "Feature cancelled: #{state["current_feature"]}. Reason: #{state["cancel_reason"]}."
|
|
556
|
+
end
|
|
557
|
+
end
|
|
558
|
+
|
|
522
559
|
if result.nil? && options[:complete_step]
|
|
523
560
|
step_id = options[:complete_step]
|
|
524
561
|
step = step_by_id[step_id]
|
|
@@ -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] },
|
|
@@ -473,6 +473,28 @@ result =
|
|
|
473
473
|
action: "workflow_complete",
|
|
474
474
|
reason: "completed_status_with_no_pending_steps"
|
|
475
475
|
}
|
|
476
|
+
elsif pending_ids.empty? && %w[cancelled failed].include?(state["status"])
|
|
477
|
+
{
|
|
478
|
+
current_flow: state["current_flow"],
|
|
479
|
+
current_feature: state["current_feature"],
|
|
480
|
+
current_phase: state["current_phase"],
|
|
481
|
+
status: state["status"],
|
|
482
|
+
cancel_reason: state["cancel_reason"],
|
|
483
|
+
cancelled_step: state["cancelled_step"],
|
|
484
|
+
completed_steps: completed_ids,
|
|
485
|
+
pending_steps: pending_ids,
|
|
486
|
+
current_step: state["current_step"] && step_payload(step_by_id[state["current_step"]], state, root, artifact_by_path),
|
|
487
|
+
next_step: nil,
|
|
488
|
+
workflow_config: workflow_path,
|
|
489
|
+
artifact_registry: artifact_registry_summary(root, artifact_by_path).merge(
|
|
490
|
+
"path" => artifact_registry_path,
|
|
491
|
+
"error" => artifact_registry_error,
|
|
492
|
+
"recovery" => artifact_registry_error ? "Call devflow_artifact with action=sync." : nil,
|
|
493
|
+
"current_outputs_registered" => 0
|
|
494
|
+
),
|
|
495
|
+
action: state["status"] == "cancelled" ? "workflow_cancelled" : "workflow_failed",
|
|
496
|
+
reason: "terminal_status_with_no_pending_steps"
|
|
497
|
+
}
|
|
476
498
|
else
|
|
477
499
|
fail_result("unknown_current_step", current_step: state["current_step"])
|
|
478
500
|
end
|
|
@@ -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
|
@@ -10,6 +10,8 @@ export interface PlatformExtensionOptions {
|
|
|
10
10
|
sessionRoot: string;
|
|
11
11
|
ompSessionRoot: string;
|
|
12
12
|
projectId: string;
|
|
13
|
+
/** Read the persisted role assignment so optional roles can fail closed. */
|
|
14
|
+
getConfiguredRole?: (role: string) => string | undefined;
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
type UsageFormat = "table" | "json" | "html";
|
|
@@ -55,6 +57,45 @@ export function createPlatformExtension(options: PlatformExtensionOptions) {
|
|
|
55
57
|
return function devflowPlatform(pi: ExtensionAPI) {
|
|
56
58
|
const { z } = pi.zod;
|
|
57
59
|
pi.setLabel("Devflow Platform Runtime");
|
|
60
|
+
let modelBeforeVisionTurn: Parameters<ExtensionAPI["setModel"]>[0] | undefined;
|
|
61
|
+
let visionTurnActive = false;
|
|
62
|
+
|
|
63
|
+
pi.on("input", async (event, ctx) => {
|
|
64
|
+
if (event.source !== "interactive" || !event.images?.length) return;
|
|
65
|
+
|
|
66
|
+
const configuredVision = options.getConfiguredRole?.("vision")?.trim();
|
|
67
|
+
if (!configuredVision) {
|
|
68
|
+
ctx.ui.notify("检测到图片,但尚未配置 VISION 模型。请先在 /model 中配置 VISION,再重新提交。", "warning");
|
|
69
|
+
if (ctx.hasUI) await ctx.ui.input("VISION 未配置", "请先在 /model 中配置 VISION,完成后重新提交本次图片");
|
|
70
|
+
return { handled: true };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const visionModel = ctx.models.resolve("@vision");
|
|
74
|
+
if (!visionModel || !visionModel.input.includes("image")) {
|
|
75
|
+
ctx.ui.notify(`VISION 配置无法解析为支持图片输入的模型:${configuredVision}`, "error");
|
|
76
|
+
if (ctx.hasUI) await ctx.ui.input("VISION 配置无效", "请选择支持图片输入的模型后重新提交");
|
|
77
|
+
return { handled: true };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
modelBeforeVisionTurn = ctx.models.current();
|
|
81
|
+
if (!(await pi.setModel(visionModel))) {
|
|
82
|
+
modelBeforeVisionTurn = undefined;
|
|
83
|
+
ctx.ui.notify(`无法切换到 VISION 模型:${visionModel.provider}/${visionModel.id}`, "error");
|
|
84
|
+
return { handled: true };
|
|
85
|
+
}
|
|
86
|
+
visionTurnActive = true;
|
|
87
|
+
ctx.ui.notify(`本回合已切换到 VISION:${visionModel.provider}/${visionModel.id}`, "info");
|
|
88
|
+
return;
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
pi.on("turn_end", async (_event, ctx) => {
|
|
92
|
+
if (!visionTurnActive || !modelBeforeVisionTurn) return;
|
|
93
|
+
const previousModel = modelBeforeVisionTurn;
|
|
94
|
+
modelBeforeVisionTurn = undefined;
|
|
95
|
+
visionTurnActive = false;
|
|
96
|
+
await pi.setModel(previousModel);
|
|
97
|
+
ctx.ui.notify(`图片回合结束,已恢复模型:${previousModel.provider}/${previousModel.id}`, "info");
|
|
98
|
+
});
|
|
58
99
|
const usageParameters = z.object({
|
|
59
100
|
source: z.enum(["project", "omp"]).default("project"),
|
|
60
101
|
session: z.string().optional(),
|
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";
|
|
@@ -37,7 +37,9 @@ const MANAGED_SETTINGS: Record<string, unknown> = {
|
|
|
37
37
|
// The interactive session is the high-capability Planner. Low-cost agents
|
|
38
38
|
// are dispatched only through explicit Devflow steps, one at a time by
|
|
39
39
|
// default, so background work cannot silently widen a user's request.
|
|
40
|
-
|
|
40
|
+
// Keep ordinary interactive turns on the user's DEFAULT role. In particular,
|
|
41
|
+
// do not inherit SLOW just because a thinking model is configured.
|
|
42
|
+
"defaultThinkingLevel": "low",
|
|
41
43
|
"task.batch": false,
|
|
42
44
|
"task.maxConcurrency": 1,
|
|
43
45
|
"task.maxRecursionDepth": 1,
|
|
@@ -61,6 +63,10 @@ const MANAGED_SETTINGS: Record<string, unknown> = {
|
|
|
61
63
|
"lsp.enabled": true,
|
|
62
64
|
"lsp.lazy": true,
|
|
63
65
|
"lsp.diagnosticsOnWrite": true,
|
|
66
|
+
// Devflow owns cross-session handoff through STATE.rollover. OMP's LLM-made
|
|
67
|
+
// handoff would create a second, unauditable source of continuation facts.
|
|
68
|
+
"compaction.strategy": "context-full",
|
|
69
|
+
"compaction.handoffSaveToDisk": false,
|
|
64
70
|
};
|
|
65
71
|
|
|
66
72
|
export async function createEmbeddedRuntime(project: ProjectContext, options: RuntimeOptions = {}) {
|
|
@@ -136,6 +142,7 @@ export async function createEmbeddedRuntime(project: ProjectContext, options: Ru
|
|
|
136
142
|
sessionRoot: sessionDir,
|
|
137
143
|
ompSessionRoot: path.join(sharedAgentDir, "sessions"),
|
|
138
144
|
projectId: project.manifest.projectId,
|
|
145
|
+
getConfiguredRole: role => settings.getModelRole(role),
|
|
139
146
|
}),
|
|
140
147
|
],
|
|
141
148
|
hasUI: options.hasUI ?? true,
|
|
@@ -173,8 +180,39 @@ export async function runInteractiveRuntime(project: ProjectContext, options: Ru
|
|
|
173
180
|
mode.showStatus(`Devflow project ${project.manifest.name} · ${project.manifest.projectId.slice(0, 8)}`);
|
|
174
181
|
if (runtime.modelFallbackMessage) mode.showWarning(runtime.modelFallbackMessage);
|
|
175
182
|
if (options.initialPrompt) await runtime.session.prompt(options.initialPrompt);
|
|
183
|
+
// Extensions cannot create a session from lifecycle hooks. The interactive
|
|
184
|
+
// loop, however, owns AgentSession and reaches this point only after the
|
|
185
|
+
// submitted turn has settled. A request is merely a wake-up signal: its
|
|
186
|
+
// authority remains STATE.rollover, which the extension verifies and claims
|
|
187
|
+
// during session_switch before it starts the next workflow turn.
|
|
188
|
+
const consumeControlledRollover = async (): Promise<void> => {
|
|
189
|
+
const requestPath = path.join(project.root, ".planning", "rollover-request.json");
|
|
190
|
+
let request: { schema_version?: number; rollover_id?: string; source_session_id?: string };
|
|
191
|
+
try {
|
|
192
|
+
request = JSON.parse(await readFile(requestPath, "utf8")) as typeof request;
|
|
193
|
+
} catch {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
if (
|
|
197
|
+
request.schema_version !== 1 ||
|
|
198
|
+
typeof request.rollover_id !== "string" ||
|
|
199
|
+
!/^ROL-[0-9a-f-]+$/i.test(request.rollover_id) ||
|
|
200
|
+
request.source_session_id !== runtime.session.sessionManager.getSessionId()
|
|
201
|
+
) {
|
|
202
|
+
await rm(requestPath, { force: true });
|
|
203
|
+
mode.showWarning("已忽略无效或陈旧的 Devflow 会话轮换请求;STATE 未被修改。");
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
const switched = await runtime.session.newSession();
|
|
207
|
+
if (!switched) {
|
|
208
|
+
mode.showWarning("Devflow 会话轮换被运行时取消;当前会话保持不变。请检查控制面状态后重试。");
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
await rm(requestPath, { force: true });
|
|
212
|
+
};
|
|
176
213
|
while (true) {
|
|
177
214
|
const input = await mode.getUserInput();
|
|
178
215
|
await omp.submitInteractiveInput(mode, runtime.session, input);
|
|
216
|
+
await consumeControlledRollover();
|
|
179
217
|
}
|
|
180
218
|
}
|
package/templates/omp/AGENTS.md
CHANGED
|
@@ -8,6 +8,8 @@ This repository uses an OMP-native, project-scoped software delivery runtime.
|
|
|
8
8
|
- Inspect workflow state with `devflow_state`; do not infer state from chat history.
|
|
9
9
|
- Initialize delivery with `devflow_bootstrap`.
|
|
10
10
|
- Mutate checkpoints and steps only with `devflow_transition`.
|
|
11
|
+
- To abandon an active feature, use the runtime-confirmed `devflow_transition`
|
|
12
|
+
`cancel-feature` operation or `/devflow abandon`; never edit control files.
|
|
11
13
|
- Never directly edit `.planning/STATE.md`, `.planning/.next-intent`, `.planning/workflows/`, or `.planning/artifacts/index.yaml`.
|
|
12
14
|
- The parent session is the only workflow-state writer. Task agents return evidence or patches and never advance state.
|
|
13
15
|
|
package/templates/omp/config.yml
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
# Devflow owns this project-local OMP profile. Model ids stay in the user's
|
|
2
2
|
# global config; only role storage and development policy are project-scoped.
|
|
3
3
|
modelRoleStorage: project
|
|
4
|
+
# Ordinary turns stay on DEFAULT and use only low automatic thinking. Agent
|
|
5
|
+
# frontmatter may still opt into medium/high for explicitly routed work.
|
|
6
|
+
defaultThinkingLevel: low
|
|
4
7
|
|
|
5
8
|
# Do not import competing editor/harness configuration into this dedicated Pi.
|
|
6
9
|
# These are discovery-source ids, not model-provider ids.
|