diff --git a/docs/evidence/g02-intent-js-closed-loop-20261112/07-standard-golden-diff.md b/docs/evidence/g02-intent-js-closed-loop-20261112/07-standard-golden-diff.md new file mode 100644 index 0000000..c5e6a63 --- /dev/null +++ b/docs/evidence/g02-intent-js-closed-loop-20261112/07-standard-golden-diff.md @@ -0,0 +1,17 @@ +# G02 工作流与现有 Standard golden 的可解释差异 + +对照对象:仓库已冻结示例 `examples/editorial.workflow.js`(及其 `editorial.compiled.mjs` / `editorial.mmd`),它是 Standard 八类节点的回归 golden,由 `tests/runtime/golden.test.mjs` 维护。本文件只解释差异,不更新该 golden,也不把 editorial 的能力记为 Interactive 已实现。 + +| 维度 | editorial golden | G02 `feature-review.workflow.js` | 说明 | +|---|---|---|---| +| 产品目的 | 编辑审批:起草、并行审阅、循环修复、人工批准、发布 | 功能变更评审:入口门禁、需求分析、技术设计、独立评审、终态 | 都是“分析/评审/结论”类闭环,但业务对象不同 | +| 节点种类 | Action、Agent、Parallel、Condition、Loop、Verification、Human、Terminal | Condition、Agent、Terminal | G02 必须同时通过 Interactive P0-A 功能门禁,因此不放入尚未实现可恢复路径的节点 | +| 条件路由 | `styleOk && factOk` 决定进入批准或质量循环 | `materialsReady` 决定是否进入分析;`reviewApproved` 决定成功或失败终态 | 都由编译后的 JS 调用 `evalWhen` / Interactive `advance()` 求值,而不是由模型选择下一节点 | +| 重试 | 起草 Agent `retries: 1`;另有 Loop 质量预算 | 仅评审 Agent `retries: 1`;分析/设计 `retries: 0` | G02 用 Schema 校验失败演示一次可控重试,不引入 Loop | +| 并行 | `reviews` 并行 style/fact,`all_succeeded` | 无 | Parallel 属于 Standard 已实现、Interactive 未实现(P1/P2) | +| 人工/动作 | Human 批准/拒绝;Action 准备、修复、修订、发布 | 无 | 这些是 Standard 已实现语义;Interactive 要到 P0-B。G02 不把它们算进 Interactive 验收 | +| 编译器 | 同一套 Standard 静态分析器与 `flowkit compile` | 同一分析器;另外再走 `compileInteractive` 生成独立的 `.interactive.mjs` | 没有第二套 DSL 解析器 | +| 执行入口 | Standard `run()` | 同时演示 Standard `run()` 与 Interactive Coordinator/Trusted Host | Interactive 产物不能用 Standard `run()` 加载,反之亦然 | +| Agent 输出来源 | 示例适配器按 Skill 词汇生成固定对象,不调用真实模型 | 成功/拒绝路径的 Agent 正文由 Grok 在看到 Pending 后撰写;非法评审是脚本负例 | 证据等级不同,见夹具说明 | + +结论:G02 有意使用 P0-A 可执行子集来验证“意图 → DSL → 真实 JS Program → 程序决定分支”的产品闭环;完整八类节点的 Standard 行为仍以 editorial golden 为准,不在本阶段扩展 Interactive。 diff --git a/docs/evidence/g02-intent-js-closed-loop-20261112/HASHES.sha256 b/docs/evidence/g02-intent-js-closed-loop-20261112/HASHES.sha256 new file mode 100644 index 0000000..9fdb43b --- /dev/null +++ b/docs/evidence/g02-intent-js-closed-loop-20261112/HASHES.sha256 @@ -0,0 +1,38 @@ +8df70cd1af7dd9b0b5ea2c2143815c8aca862696f580fa46b21f726c021ec592 examples/g02/feature-review.workflow.js +5c2f8a1e4ce0faeeb2d95176bcd3b5c70dd270e1096f20d1bc2b69360f995ed5 examples/g02/feature-review.compiled.mjs +8d811766fd1d58456ce4e58cc2a2f04c4d1e36dd683f86496c2df946cb15eded examples/g02/feature-review.interactive.mjs +82be98702a59ae251265a0eae9bbe4a3e60654d88bb16f96ac24f5f93166aa6b examples/g02/feature-review.mmd +26653dd276028a77660f9268c22c2c1a023bbc8352d0f69a0d7159e6b36f5b08 examples/g02/skills/analyze.md +c14f956000b3bf9e3786af50a85c01d2b282b087f4cc4606ccffa47616ae0ba9 examples/g02/skills/design.md +464ddaea7cce6f7d2479db0b3fa91d36fd75e28a346661b19c5b29d70decf5ed examples/g02/skills/review.md +9ea9606d0aca8dbb15478fbdccf511cd2ac9672cd51cf4aa1f190f2d390a0acb examples/g02/README.md +d47177cd5bacd17727c10da4efe32ab07758762a8d909b47c58c7ebbae4aaf67 examples/g02/run-interactive.mjs +dc533fb324204cbddeb62a5046fd18779e3a4137095ca4b0aadc8bd74a57e56d tests/fixtures/g02/turns-review-false.json +3aec13cf3fc8f851350656a879aa391e33cd33defe1c73b6552107174e311637 tests/fixtures/g02/turns-success-retry.json +d4380d813435a1249ebc2515b82ba00842ffab73bbbf03b41cb2b118f0d48119 tests/interactive/g02-product-closed-loop.test.mjs +aa4712816a41cd509c7d00227b253b187526e32b130c1ce6df79444435808054 docs/evidence/g02-intent-js-closed-loop-20261112/README.md +acccfc9b1782863557884ce4b8263e47d8130bbae8bfa918567009a2a6a4333b docs/evidence/g02-intent-js-closed-loop-20261112/07-standard-golden-diff.md +8f62bacd12654004b5b75c98b5279281001b99cb6a029a641b412319478fe45e docs/evidence/g02-intent-js-closed-loop-20261112/cli/00-timestamp.txt +19eaf43821a7660ec323a87c8457bf74823beb296c39f5e01aa8a683aa50f061 docs/evidence/g02-intent-js-closed-loop-20261112/cli/attempt-01-check.exit.txt +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 docs/evidence/g02-intent-js-closed-loop-20261112/cli/attempt-01-check.stderr.txt +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 docs/evidence/g02-intent-js-closed-loop-20261112/cli/attempt-01-check.stdout.txt +19eaf43821a7660ec323a87c8457bf74823beb296c39f5e01aa8a683aa50f061 docs/evidence/g02-intent-js-closed-loop-20261112/cli/attempt-01-compile.exit.txt +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 docs/evidence/g02-intent-js-closed-loop-20261112/cli/attempt-01-compile.stderr.txt +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 docs/evidence/g02-intent-js-closed-loop-20261112/cli/attempt-01-compile.stdout.txt +95e4d85ba0a5f006130163dbcd634f16334ca086ecb9c123e3d38f5291510951 docs/evidence/g02-intent-js-closed-loop-20261112/cli/attempt-01-interactive-compile.json +1017181cab7dee17344997bf532bdd82e4589c930e8aba27ec2734fe2409fcee docs/evidence/g02-intent-js-closed-loop-20261112/execution/00-env.txt +d83a6c9a84da539a1cba34f1d9e77c7df4d862dbef8bc096b85702b49d7f0364 docs/evidence/g02-intent-js-closed-loop-20261112/execution/interactive-run.exit.txt +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 docs/evidence/g02-intent-js-closed-loop-20261112/execution/interactive-run.stderr.txt +56e24cf17a253f42a96289540473b9839b5ed69f2c5ace49b6102f10e7eda3ee docs/evidence/g02-intent-js-closed-loop-20261112/execution/interactive-run.stdout.json +e01a5b5252b8a0bffd0b9e9d7e80311beeae9e4d9eeea0faabcdf8a3c3bf249e docs/evidence/g02-intent-js-closed-loop-20261112/execution/local-g02-tests.exit.txt +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 docs/evidence/g02-intent-js-closed-loop-20261112/execution/local-g02-tests.stderr.txt +46ba14286185325effe1e611f53750ac8ee7be01ab81fd40e0ddf79cfeec211b docs/evidence/g02-intent-js-closed-loop-20261112/execution/local-g02-tests.stdout.txt +9cdac5e8dac099f5d7ddcc978954fe17e9419d6c329fb6b8896ba51e991948a3 docs/evidence/g02-intent-js-closed-loop-20261112/execution/local-npm-test.summary.txt +05e2066203713f7c9f74d74b965ab2b12dc57d2c27843728cfabd5edae29875c docs/evidence/g02-intent-js-closed-loop-20261112/execution/r02-pending-counts-measured.json +b16a74729a277c11d5e28daf8b2691f248250d8b4221cc7129fb659ee306d56c docs/evidence/g02-intent-js-closed-loop-20261112/execution/standard-run.exit.txt +e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 docs/evidence/g02-intent-js-closed-loop-20261112/execution/standard-run.stderr.txt +e0785102cc8fd7a28f4bb6a851581a84371e27638c035267d8896f04a4932e7c docs/evidence/g02-intent-js-closed-loop-20261112/execution/standard-run.stdout.json +be60fe6a557e18f6b34e1360b536f137c9711a5ab69a5afd0eb093636eef17c4 docs/evidence/g02-intent-js-closed-loop-20261112/dsl-generation/00-prompt.md +76ddeea02e9ef200a170c0768f044c7d8f2d07a8b3b13c0f22f75abfe9226dd4 docs/evidence/g02-intent-js-closed-loop-20261112/dsl-generation/00-user-intent.md +ef2b7b97991a24ea539d55a2c071bbeaba520956a4567ecbc5455a1d85798d0e docs/evidence/g02-intent-js-closed-loop-20261112/dsl-generation/attempt-01-notes.md +8df70cd1af7dd9b0b5ea2c2143815c8aca862696f580fa46b21f726c021ec592 docs/evidence/g02-intent-js-closed-loop-20261112/dsl-generation/attempt-01-raw.workflow.js diff --git a/docs/evidence/g02-intent-js-closed-loop-20261112/README.md b/docs/evidence/g02-intent-js-closed-loop-20261112/README.md new file mode 100644 index 0000000..b8b2bdf --- /dev/null +++ b/docs/evidence/g02-intent-js-closed-loop-20261112/README.md @@ -0,0 +1,15 @@ +# G02 证据目录(2026-11-12,Asia/Shanghai) + +本目录保存 G02「真实用户意图 → Workflow DSL → 可执行 JavaScript → Standard / Interactive P0-A 执行」的原始输入、编译日志和本地复现输出。它不是新的需求或技术设计,也不构成独立复审 PASS。 + +| 路径 | 内容 | +|---|---| +| `dsl-generation/` | Grok 生成 `.workflow.js` 时的完整输入、第一次原始输出,以及“一次通过、无第二次尝试”的说明 | +| `cli/` | 官方 `flowkit compile` / `flowkit check` 与 `compileInteractive` 的原始输出、退出码和 Program 身份 | +| `execution/` | Standard `run()`、Interactive 夹具重放、本地 `node --test` 的原始日志 | +| `07-standard-golden-diff.md` | 与 `examples/editorial.workflow.js` golden 的可解释差异 | +| `HASHES.sha256` | 上述文件及示例/夹具的 SHA-256 | + +证据分级:编译与 JS/SQLite 执行为 P;`source=grok-model` 为 M 层结果消费(6 次消费 / 5 份独立正文 / 1 次复用并重新绑定 Pending 身份,不是 6 次独立模型推理);`source=scripted-invalid` 是脚本负例。`pendingAtEnd` 只描述终态帧;`publishedPendingCount` 来自实际执行 route。本环境用 Node 和 FlowKit CLI 执行 JavaScript,**不得**标为 N-PASS 或原生安全 Host 来源认证。GitHub CI 7/7 只证明仓库回归,不证明原生 Grok 工具或跨用户 OS 隔离。 + +正式审查请同时使用 GitHub PR、精确 HEAD、本目录与交接包中的 `STAGE-REVIEW-REPORT.md`。 diff --git a/docs/evidence/g02-intent-js-closed-loop-20261112/cli/00-timestamp.txt b/docs/evidence/g02-intent-js-closed-loop-20261112/cli/00-timestamp.txt new file mode 100644 index 0000000..35e69c0 --- /dev/null +++ b/docs/evidence/g02-intent-js-closed-loop-20261112/cli/00-timestamp.txt @@ -0,0 +1,4 @@ +2026-11-12 17:10:07 CST +node: v24.22.1 +cwd: /workspace/flowkit +HEAD: 7e5960fc570f0cd91a5ff8e9fe6029345932442f diff --git a/docs/evidence/g02-intent-js-closed-loop-20261112/cli/attempt-01-check.exit.txt b/docs/evidence/g02-intent-js-closed-loop-20261112/cli/attempt-01-check.exit.txt new file mode 100644 index 0000000..9200c2a --- /dev/null +++ b/docs/evidence/g02-intent-js-closed-loop-20261112/cli/attempt-01-check.exit.txt @@ -0,0 +1 @@ +exit=0 diff --git a/docs/evidence/g02-intent-js-closed-loop-20261112/cli/attempt-01-check.stderr.txt b/docs/evidence/g02-intent-js-closed-loop-20261112/cli/attempt-01-check.stderr.txt new file mode 100644 index 0000000..e69de29 diff --git a/docs/evidence/g02-intent-js-closed-loop-20261112/cli/attempt-01-check.stdout.txt b/docs/evidence/g02-intent-js-closed-loop-20261112/cli/attempt-01-check.stdout.txt new file mode 100644 index 0000000..e69de29 diff --git a/docs/evidence/g02-intent-js-closed-loop-20261112/cli/attempt-01-compile.exit.txt b/docs/evidence/g02-intent-js-closed-loop-20261112/cli/attempt-01-compile.exit.txt new file mode 100644 index 0000000..9200c2a --- /dev/null +++ b/docs/evidence/g02-intent-js-closed-loop-20261112/cli/attempt-01-compile.exit.txt @@ -0,0 +1 @@ +exit=0 diff --git a/docs/evidence/g02-intent-js-closed-loop-20261112/cli/attempt-01-compile.stderr.txt b/docs/evidence/g02-intent-js-closed-loop-20261112/cli/attempt-01-compile.stderr.txt new file mode 100644 index 0000000..e69de29 diff --git a/docs/evidence/g02-intent-js-closed-loop-20261112/cli/attempt-01-compile.stdout.txt b/docs/evidence/g02-intent-js-closed-loop-20261112/cli/attempt-01-compile.stdout.txt new file mode 100644 index 0000000..e69de29 diff --git a/docs/evidence/g02-intent-js-closed-loop-20261112/cli/attempt-01-interactive-compile.json b/docs/evidence/g02-intent-js-closed-loop-20261112/cli/attempt-01-interactive-compile.json new file mode 100644 index 0000000..3a83060 --- /dev/null +++ b/docs/evidence/g02-intent-js-closed-loop-20261112/cli/attempt-01-interactive-compile.json @@ -0,0 +1,19 @@ +{ + "timestamp": "2026-11-12T09:10:12.013Z", + "diagnostics": [], + "artifactPath": "/workspace/flowkit/examples/g02/feature-review.interactive.mjs", + "programIdentity": { + "workflowId": "g02-feature-review", + "workflowVersion": "1.0.0", + "sourceHash": "sha256:8df70cd1af7dd9b0b5ea2c2143815c8aca862696f580fa46b21f726c021ec592", + "compilerVersion": "1.0.0", + "programAbiVersion": 1, + "semanticsVersion": "interactive-state-machine-v1", + "checkpointSchemaVersion": 1, + "canonicalizationVersion": "jcs-rfc8785-v1", + "implementedStage": "P0-A", + "artifactHash": "sha256:8d811766fd1d58456ce4e58cc2a2f04c4d1e36dd683f86496c2df946cb15eded" + }, + "codeBytes": 15015, + "codeSha256": "8d811766fd1d58456ce4e58cc2a2f04c4d1e36dd683f86496c2df946cb15eded" +} diff --git a/docs/evidence/g02-intent-js-closed-loop-20261112/dsl-generation/00-prompt.md b/docs/evidence/g02-intent-js-closed-loop-20261112/dsl-generation/00-prompt.md new file mode 100644 index 0000000..bbf5020 --- /dev/null +++ b/docs/evidence/g02-intent-js-closed-loop-20261112/dsl-generation/00-prompt.md @@ -0,0 +1,25 @@ +# G02 DSL 生成:Grok Build 原始模型输入 + +记录时间:2026-11-12,Asia/Shanghai。 +角色:Grok Bot / Grok Build,作为真实模型阅读用户意图,并按 FlowKit 现有 DSL 生成 `.workflow.js`。 +本文件是生成尝试使用的完整输入。输出见同目录 `attempt-NN-raw.workflow.js`;若编译失败,后续尝试会完整保留,不会覆盖本输入或更早输出。 + +## 用户意图 + +见仓库文件 `examples/g02/user-intent.md`(生成时已完整阅读,内容与该文件一致)。 + +## 已阅读的现有 DSL 约束(摘自仓库,不是新语法) + +- 从 `@devcodex-labs/flowkit/dsl` 导入 `defineWorkflow` 以及节点构造器。Agent 步骤使用 `node()`,并提供 `agent` 或 `skill`;条件使用 `condition()`;终态使用 `terminal()`。 +- `export default defineWorkflow({ id, version, name, nodes })`。 +- `id` 必须是 kebab-case,最多 64 字符;`version` 遵循 SemVer 2.0。 +- Agent 节点需要 `name`、`agent` 或 `skill`、对象类型的 `output` schema、`next`。`retries` 为 0–10 的整数;缺省为 2。`inputs` 声明从 State 读取的键,缺失键进入 `missing`,不得被合成 null。 +- 条件节点 `branches` 为 1–20 项,最后一项的 `when` 必须是 `'true'`。`when` 是针对 State 的表达式字符串,不能包含换行或反斜杠。 +- 终端节点 `status` 只能是 `succeeded` 或 `failed`。 +- 工作流文件名必须以 `.workflow.js` 或 `.workflow.ts` 结尾。 +- Interactive P0-A 只允许 Condition / Terminal / 基本 Agent(无 Agent verify/checkStep,无 Human/Action/Loop/Parallel)。本任务的 DSL 必须能通过 `compileInteractive(..., { featureStage: 'P0-A' })`,因此不放入 P0-A 未实现的节点。 +- 参考已有示例:`examples/interactive/c01-live-chatgpt.workflow.js`、`examples/editorial.workflow.js` 的构造方式。不要复制 C01 的业务文本,本任务是独立的功能变更评审闭环。 + +## 生成要求 + +只输出一份完整的、可保存为 `feature-review.workflow.js` 的 ESM 源码。不要输出 Mermaid、JSON 节点计划或伪代码来代替 DSL。节点后继必须写在 DSL 里,让编译后的 JS Program 做路由。 diff --git a/docs/evidence/g02-intent-js-closed-loop-20261112/dsl-generation/00-user-intent.md b/docs/evidence/g02-intent-js-closed-loop-20261112/dsl-generation/00-user-intent.md new file mode 100644 index 0000000..097d14d --- /dev/null +++ b/docs/evidence/g02-intent-js-closed-loop-20261112/dsl-generation/00-user-intent.md @@ -0,0 +1,27 @@ +# G02 用户意图(真实任务原文) + +记录时间:2026-11-12,Asia/Shanghai。 +本文件是 Grok Build 在生成本阶段 `.workflow.js` 时实际阅读的用户任务原文,未事后改写需求。 + +## 任务名称 + +内部「功能变更评审」可执行闭环。 + +## 任务正文 + +请为 FlowKit 内部维护团队设计一个可重复执行的「功能变更评审」工作流,用来处理维护者提交的功能请求。要求如下: + +1. 入口先检查材料是否齐全(状态字段 `materialsReady`)。若材料不齐全,流程必须直接失败结束,不得调用任何 Agent。 +2. 若材料齐全,按固定顺序执行三个 Agent 步骤,模型不得自行选择下一个节点: + - 需求分析:阅读功能请求 `request`,输出 `requirementSummary`(字符串)。 + - 技术设计:基于上一节点的需求分析,输出 `designSummary`(字符串)。 + - 独立评审:同时阅读需求摘要和技术设计摘要,输出 `reviewApproved`(布尔)和 `reviewNotes`(字符串)。评审结果必须符合约定的 JSON 对象 schema;非法输出允许重试一次。 +3. 评审通过则成功结束;评审不通过则失败结束,并给出可核查终态。 +4. 流程推进、条件分支、重试与后继节点必须由 FlowKit 编译出的 JavaScript Program 决定,而不是由模型在回答里指定下一个节点。 +5. 只使用 FlowKit 现有 DSL 语法,不要发明第二套解析器、模型网关或独立 FlowKit Server。Interactive 验收仅使用已实现的 P0-A 子集:Condition、Terminal、基本 Agent。Standard 可用同一份 DSL 展示已实现的 Agent/Condition/Terminal 语义;Loop/Action/Human/Parallel 不是本任务的必要步骤,对照现有 editorial golden 说明差异即可。 + +## 本次实际提交的功能请求(作为工作流输入 `request`) + +为 FlowKit 维护者增加一条只读的工作流运行摘要查看能力:在不修改现有 Standard Root 六个 API、不引入独立 FlowKit Server 的前提下,允许受信 Host 读取一次运行的终态状态(succeeded/failed/errored)、terminal 节点和公开投影,便于排障。不在本阶段实现 adminTerminate,也不改变 Interactive P0-A 的节点范围。 + +说明:上述功能请求只是本工作流要评审的业务对象,G02 阶段本身并不实现该能力。 diff --git a/docs/evidence/g02-intent-js-closed-loop-20261112/dsl-generation/attempt-01-notes.md b/docs/evidence/g02-intent-js-closed-loop-20261112/dsl-generation/attempt-01-notes.md new file mode 100644 index 0000000..3e079c8 --- /dev/null +++ b/docs/evidence/g02-intent-js-closed-loop-20261112/dsl-generation/attempt-01-notes.md @@ -0,0 +1,7 @@ +# DSL 生成尝试记录 + +- 尝试次数:1。第一次输出已通过官方 `flowkit compile` 与 `flowkit check`(exit code 0,无诊断),以及 `compileInteractive({ featureStage: 'P0-A', skillCheck: true })`。因此没有 attempt-02。 +- 原始输入:`00-prompt.md`、`00-user-intent.md` +- 原始输出(生成后、编译前原样保留):`attempt-01-raw.workflow.js` +- 采纳结果:该文件字节与 `examples/g02/feature-review.workflow.js` 一致,SHA-256 `8df70cd1af7dd9b0b5ea2c2143815c8aca862696f580fa46b21f726c021ec592` +- 编译命令与退出码:见 `../cli/` diff --git a/docs/evidence/g02-intent-js-closed-loop-20261112/dsl-generation/attempt-01-raw.workflow.js b/docs/evidence/g02-intent-js-closed-loop-20261112/dsl-generation/attempt-01-raw.workflow.js new file mode 100644 index 0000000..ebccf4a --- /dev/null +++ b/docs/evidence/g02-intent-js-closed-loop-20261112/dsl-generation/attempt-01-raw.workflow.js @@ -0,0 +1,67 @@ +import { defineWorkflow, condition, node, terminal } from '@devcodex-labs/flowkit/dsl'; + +export default defineWorkflow({ + id: 'g02-feature-review', + version: '1.0.0', + name: '功能变更评审闭环', + nodes: [ + condition('gate', { + name: '检查材料是否齐全', + branches: [ + { when: 'materialsReady', next: 'analyze' }, + { when: 'true', next: 'rejected' }, + ], + }), + node('analyze', { + name: '需求分析', + agent: 'analyze', + inputs: ['request'], + output: { + type: 'object', + properties: { requirementSummary: { type: 'string' } }, + required: ['requirementSummary'], + additionalProperties: false, + }, + retries: 0, + next: 'design', + }), + node('design', { + name: '技术设计', + agent: 'design', + inputs: ['requirementSummary'], + output: { + type: 'object', + properties: { designSummary: { type: 'string' } }, + required: ['designSummary'], + additionalProperties: false, + }, + retries: 0, + next: 'review', + }), + node('review', { + name: '独立评审', + agent: 'review', + inputs: ['requirementSummary', 'designSummary'], + output: { + type: 'object', + properties: { + reviewApproved: { type: 'boolean' }, + reviewNotes: { type: 'string' }, + }, + required: ['reviewApproved', 'reviewNotes'], + additionalProperties: false, + }, + retries: 1, + next: 'decide', + }), + condition('decide', { + name: '根据评审结论分流', + branches: [ + { when: 'reviewApproved', next: 'accepted' }, + { when: 'true', next: 'rejected' }, + ], + }), + terminal('accepted', { name: '评审通过', status: 'succeeded' }), + terminal('rejected', { name: '评审未通过或材料不齐', status: 'failed' }), + ], +}); diff --git a/docs/evidence/g02-intent-js-closed-loop-20261112/execution/00-env.txt b/docs/evidence/g02-intent-js-closed-loop-20261112/execution/00-env.txt new file mode 100644 index 0000000..88f6b28 --- /dev/null +++ b/docs/evidence/g02-intent-js-closed-loop-20261112/execution/00-env.txt @@ -0,0 +1,4 @@ +2026-11-12 17:56:18 CST +node v24.22.1 +HEAD 689e0bd97127bf32116bdfa37f88b8e013f3adb2 (pre-commit working tree for G02-R01/R02) +note=R01/R02 evidence refresh; pending counts measured from run-interactive.mjs route, not hardcoded diff --git a/docs/evidence/g02-intent-js-closed-loop-20261112/execution/interactive-run.exit.txt b/docs/evidence/g02-intent-js-closed-loop-20261112/execution/interactive-run.exit.txt new file mode 100644 index 0000000..09461c5 --- /dev/null +++ b/docs/evidence/g02-intent-js-closed-loop-20261112/execution/interactive-run.exit.txt @@ -0,0 +1 @@ +interactive_exit=0 diff --git a/docs/evidence/g02-intent-js-closed-loop-20261112/execution/interactive-run.stderr.txt b/docs/evidence/g02-intent-js-closed-loop-20261112/execution/interactive-run.stderr.txt new file mode 100644 index 0000000..e69de29 diff --git a/docs/evidence/g02-intent-js-closed-loop-20261112/execution/interactive-run.stdout.json b/docs/evidence/g02-intent-js-closed-loop-20261112/execution/interactive-run.stdout.json new file mode 100644 index 0000000..2a1a26a --- /dev/null +++ b/docs/evidence/g02-intent-js-closed-loop-20261112/execution/interactive-run.stdout.json @@ -0,0 +1,105 @@ +[ + { + "layer": "interactive-p0a", + "scenario": "success-with-scripted-retry", + "status": "succeeded", + "terminal": { + "nodeId": "accepted", + "status": "succeeded" + }, + "reviewApproved": true, + "pendingAtEnd": false, + "publishedPendingCount": 4, + "route": [ + { + "source": "grok-model", + "nodeId": "analyze", + "attempt": 1, + "requestId": "req:cd869d313e933b356b99d9229e8ac9be161eca22e0d4b2c11a53091f58f868a7", + "statusAfter": "waiting", + "next": "design" + }, + { + "source": "grok-model", + "nodeId": "design", + "attempt": 1, + "requestId": "req:d76207d128012416efb5dc4b1ade578be4ac0e1802031fe8f92b215734407de3", + "statusAfter": "waiting", + "next": "review" + }, + { + "source": "scripted-invalid", + "nodeId": "review", + "attempt": 1, + "requestId": "req:d2d5fe090a4aeef9500ee9a624690a23f17d887d3c84a90e36538d29cb775133", + "statusAfter": "waiting", + "next": "review" + }, + { + "source": "grok-model", + "nodeId": "review", + "attempt": 2, + "requestId": "req:6cc88145ba67ad65081a9a78a741f8cbf26b35e9d7b4b8017ca095d59f0ea363", + "statusAfter": "succeeded", + "next": "accepted" + } + ], + "programHash": "sha256:8d811766fd1d58456ce4e58cc2a2f04c4d1e36dd683f86496c2df946cb15eded", + "evidenceNote": "Interactive Program + SQLite + local test receipts via Node. Not N-PASS. Not native secure Host provenance." + }, + { + "layer": "interactive-p0a", + "scenario": "gate-false", + "status": "failed", + "terminal": { + "nodeId": "rejected", + "status": "failed" + }, + "reviewApproved": null, + "pendingAtEnd": false, + "publishedPendingCount": 0, + "route": [], + "programHash": "sha256:8d811766fd1d58456ce4e58cc2a2f04c4d1e36dd683f86496c2df946cb15eded", + "evidenceNote": "Interactive Program + SQLite + local test receipts via Node. Not N-PASS. Not native secure Host provenance." + }, + { + "layer": "interactive-p0a", + "scenario": "review-approved-false", + "status": "failed", + "terminal": { + "nodeId": "rejected", + "status": "failed" + }, + "reviewApproved": false, + "pendingAtEnd": false, + "publishedPendingCount": 3, + "route": [ + { + "source": "grok-model", + "nodeId": "analyze", + "attempt": 1, + "requestId": "req:8c18ec5e3534510274b4ee71d0f02da7c12b00f627bc44a1109c9d3ad20997d0", + "statusAfter": "waiting", + "next": "design" + }, + { + "source": "grok-model", + "nodeId": "design", + "attempt": 1, + "requestId": "req:2d76c8634f8c31f8a6161f42241f1f2b69db3728bd674f0373c3333d2507cf9a", + "statusAfter": "waiting", + "next": "review" + }, + { + "source": "grok-model", + "nodeId": "review", + "attempt": 1, + "requestId": "req:aade8c66498bab1b076a8e3d7a9f37e5d7ff2d830fc95fd856592007707f18ba", + "statusAfter": "failed", + "next": "rejected" + } + ], + "programHash": "sha256:8d811766fd1d58456ce4e58cc2a2f04c4d1e36dd683f86496c2df946cb15eded", + "evidenceNote": "Interactive Program + SQLite + local test receipts via Node. Not N-PASS. Not native secure Host provenance." + } +] diff --git a/docs/evidence/g02-intent-js-closed-loop-20261112/execution/local-g02-tests.exit.txt b/docs/evidence/g02-intent-js-closed-loop-20261112/execution/local-g02-tests.exit.txt new file mode 100644 index 0000000..3cb3283 --- /dev/null +++ b/docs/evidence/g02-intent-js-closed-loop-20261112/execution/local-g02-tests.exit.txt @@ -0,0 +1 @@ +g02_test_exit=0 diff --git a/docs/evidence/g02-intent-js-closed-loop-20261112/execution/local-g02-tests.stderr.txt b/docs/evidence/g02-intent-js-closed-loop-20261112/execution/local-g02-tests.stderr.txt new file mode 100644 index 0000000..e69de29 diff --git a/docs/evidence/g02-intent-js-closed-loop-20261112/execution/local-g02-tests.stdout.txt b/docs/evidence/g02-intent-js-closed-loop-20261112/execution/local-g02-tests.stdout.txt new file mode 100644 index 0000000..865b128 --- /dev/null +++ b/docs/evidence/g02-intent-js-closed-loop-20261112/execution/local-g02-tests.stdout.txt @@ -0,0 +1,16 @@ +✔ G02 Standard compile/check matches checked-in JS artifact and Program is real JavaScript (26.08371ms) +✔ G02 Interactive compile matches checked-in Program bytes and identity hash (4.175926ms) +✔ G02 Standard run covers success, gate-false, review-false and scripted retry; Program chooses branches (8.2015ms) +✔ G02 Interactive success path binds Grok results, retries scripted invalid review, Program routes to accepted (113.391383ms) +✔ G02 Interactive reviewApproved=false is routed by the Program to rejected (67.452899ms) +✔ G02 Interactive materialsReady=false never publishes a Pending; Program goes to rejected (19.250238ms) +✔ G02 fixtures record six grok-model uses, five distinct bodies, and one rebound reuse (0.668977ms) +✔ G02 workflow is a P0-A subset and differs from the editorial Standard golden in node kinds (0.416418ms) +ℹ tests 8 +ℹ suites 0 +ℹ pass 8 +ℹ fail 0 +ℹ cancelled 0 +ℹ skipped 0 +ℹ todo 0 +ℹ duration_ms 312.650933 diff --git a/docs/evidence/g02-intent-js-closed-loop-20261112/execution/local-npm-test.summary.txt b/docs/evidence/g02-intent-js-closed-loop-20261112/execution/local-npm-test.summary.txt new file mode 100644 index 0000000..2aa73b1 --- /dev/null +++ b/docs/evidence/g02-intent-js-closed-loop-20261112/execution/local-npm-test.summary.txt @@ -0,0 +1,9 @@ +2026-11-12 17:56:30 CST +command: npm test +exit: 0 +tests: 395 +pass: 395 +fail: 0 +node: v24.22.1 +OS: Linux (local Grok Build box; not GitHub CI) +note: includes G02-R01/R02 updates; one added accounting test (8 G02 tests / 395 total) diff --git a/docs/evidence/g02-intent-js-closed-loop-20261112/execution/r02-pending-counts-measured.json b/docs/evidence/g02-intent-js-closed-loop-20261112/execution/r02-pending-counts-measured.json new file mode 100644 index 0000000..031a972 --- /dev/null +++ b/docs/evidence/g02-intent-js-closed-loop-20261112/execution/r02-pending-counts-measured.json @@ -0,0 +1,32 @@ +[ + { + "scenario": "success-with-scripted-retry", + "status": "succeeded", + "terminal": { + "nodeId": "accepted", + "status": "succeeded" + }, + "pendingAtEnd": false, + "publishedPendingCount": 4 + }, + { + "scenario": "gate-false", + "status": "failed", + "terminal": { + "nodeId": "rejected", + "status": "failed" + }, + "pendingAtEnd": false, + "publishedPendingCount": 0 + }, + { + "scenario": "review-approved-false", + "status": "failed", + "terminal": { + "nodeId": "rejected", + "status": "failed" + }, + "pendingAtEnd": false, + "publishedPendingCount": 3 + } +] diff --git a/docs/evidence/g02-intent-js-closed-loop-20261112/execution/standard-run.exit.txt b/docs/evidence/g02-intent-js-closed-loop-20261112/execution/standard-run.exit.txt new file mode 100644 index 0000000..4ad3d92 --- /dev/null +++ b/docs/evidence/g02-intent-js-closed-loop-20261112/execution/standard-run.exit.txt @@ -0,0 +1 @@ +standard_exit=0 diff --git a/docs/evidence/g02-intent-js-closed-loop-20261112/execution/standard-run.stderr.txt b/docs/evidence/g02-intent-js-closed-loop-20261112/execution/standard-run.stderr.txt new file mode 100644 index 0000000..e69de29 diff --git a/docs/evidence/g02-intent-js-closed-loop-20261112/execution/standard-run.stdout.json b/docs/evidence/g02-intent-js-closed-loop-20261112/execution/standard-run.stdout.json new file mode 100644 index 0000000..0502ea5 --- /dev/null +++ b/docs/evidence/g02-intent-js-closed-loop-20261112/execution/standard-run.stdout.json @@ -0,0 +1,90 @@ +[ + { + "layer": "standard", + "scenario": "success-with-retry", + "status": "succeeded", + "terminal": "accepted", + "reviewApproved": true, + "agentCalls": [ + { + "scenario": "success-with-retry", + "id": "analyze", + "attempt": 1, + "keys": [ + "requirementSummary" + ] + }, + { + "scenario": "success-with-retry", + "id": "design", + "attempt": 1, + "keys": [ + "designSummary" + ] + }, + { + "scenario": "success-with-retry", + "id": "review", + "attempt": 1, + "keys": [ + "comment", + "ok" + ] + }, + { + "scenario": "success-with-retry", + "id": "review", + "attempt": 2, + "keys": [ + "reviewApproved", + "reviewNotes" + ] + } + ], + "evidenceNote": "Standard run() via Node. Not native secure Host provenance. Not N-PASS." + }, + { + "layer": "standard", + "scenario": "gate-false", + "status": "failed", + "terminal": "rejected", + "reviewApproved": null, + "agentCalls": [], + "evidenceNote": "Standard run() via Node. Not native secure Host provenance. Not N-PASS." + }, + { + "layer": "standard", + "scenario": "review-false", + "status": "failed", + "terminal": "rejected", + "reviewApproved": false, + "agentCalls": [ + { + "scenario": "review-false", + "id": "analyze", + "attempt": 1, + "keys": [ + "requirementSummary" + ] + }, + { + "scenario": "review-false", + "id": "design", + "attempt": 1, + "keys": [ + "designSummary" + ] + }, + { + "scenario": "review-false", + "id": "review", + "attempt": 1, + "keys": [ + "reviewApproved", + "reviewNotes" + ] + } + ], + "evidenceNote": "Standard run() via Node. Not native secure Host provenance. Not N-PASS." + } +] diff --git a/examples/README.md b/examples/README.md index bd4a09f..972a9fc 100644 --- a/examples/README.md +++ b/examples/README.md @@ -101,3 +101,8 @@ dialect, actual model execution, business actions and an approval service. See [host integration](../docs/host-integration.md) and [troubleshooting](../docs/troubleshooting.md). 示例目录不随 npm 包分发。v1.0.0 包包含 `dist`、`README.md`、`LICENSE` 和包元数据;用户可从仓库或文档站获取完整教程文件。 + +## G02 产品闭环验证(Interactive P0-A 子集) + +[g02/](g02/) 保存一次真实用户意图到可执行 JavaScript 的验证材料:Grok Build 按现有 DSL 生成 `feature-review.workflow.js`,再用官方 `compile`/`check` 与 `compileInteractive` 生成并运行 Program。Standard 层只演示该 DSL 已有的 Agent/Condition/Terminal;完整八类节点仍以本目录的 editorial golden 为准。Interactive 层只验收 P0-A 子集与 Pending/恢复,不能把 Node 执行或本地测试签名写成原生安全 Host 来源认证。操作说明见该目录 README。 + diff --git a/examples/g02/README.md b/examples/g02/README.md new file mode 100644 index 0000000..bd4fb7e --- /dev/null +++ b/examples/g02/README.md @@ -0,0 +1,37 @@ +# G02 — 真实用户意图 → JS 工作流执行产品闭环 + +本目录是 G02 产品闭环验证材料,不是新的 DSL、解析器或模型网关。 + +- 用户意图原文:[`user-intent.md`](user-intent.md) +- 由 Grok Build 根据现有 DSL 生成的工作流:[`feature-review.workflow.js`](feature-review.workflow.js) +- Skill 文本:[`skills/`](skills/) +- Standard 编译产物(由官方 `flowkit compile` 生成):[`feature-review.compiled.mjs`](feature-review.compiled.mjs)、可视化 [`feature-review.mmd`](feature-review.mmd)(Mermaid 只是图,不能代替 JS 执行) +- Interactive 编译产物(由 `compileInteractive` 生成):[`feature-review.interactive.mjs`](feature-review.interactive.mjs) +- 操作脚本:[`run-standard.mjs`](run-standard.mjs)、[`run-interactive.mjs`](run-interactive.mjs) +- 可重复测试:[`../../tests/interactive/g02-product-closed-loop.test.mjs`](../../tests/interactive/g02-product-closed-loop.test.mjs) +- 模型回执夹具:[`../../tests/fixtures/g02/`](../../tests/fixtures/g02/) +- 证据目录:[`../../docs/evidence/g02-intent-js-closed-loop-20261112/`](../../docs/evidence/g02-intent-js-closed-loop-20261112/) + +## 分层说明 + +| 层 | 本目录实际验证的内容 | 不得声称 | +|---|---|---| +| Standard | 同一份 DSL 经官方 `compile`/`check`/`run()` 执行 Agent/Condition/Terminal,含成功、入口条件为 false、评审拒绝、非法输出重试 | 不得把这些结果写成 Interactive 已支持 Loop/Action/Human/Parallel | +| Interactive P0-A | `compileInteractive`、真实 JS Program、Pending、受信 Host 回执、SQLite 关闭后重开恢复 | 不得把 Node/Shell 执行或本地测试签名标为 N-PASS 或原生安全 Host 来源认证 | +| 现有 Standard golden | 与 [`../editorial.workflow.js`](../editorial.workflow.js) 对照,说明八类节点差异 | 不得把 editorial 的 Loop/Action/Human/Parallel 记为本次 Interactive 已实现 | + +## 从仓库复现 + +需要 Node >=22.13.0,在仓库根目录: + +```sh +npm ci +npm run build +node dist/cli.mjs compile examples/g02/feature-review.workflow.js --skills-root examples/g02/skills +node dist/cli.mjs check examples/g02/feature-review.workflow.js --skills-root examples/g02/skills +node examples/g02/run-standard.mjs +node examples/g02/run-interactive.mjs +node --test tests/interactive/g02-product-closed-loop.test.mjs +``` + +`run-*.mjs` 从源码树导入现有 SDK,不创建第二套编译器。Interactive 脚本会在临时 SQLite 中重放已绑定的夹具,并输出 `pendingAtEnd`(终态帧是否仍有 Pending)以及从本次 `route` 实测的 `publishedPendingCount`。夹具中 `source=scripted-invalid` 是故意非法的脚本负例;`source=grok-model` 共消费 6 次,对应 5 份独立撰写正文、1 次复用(重新绑定到新的 Pending 身份),不能计为 6 次独立模型推理。 diff --git a/examples/g02/feature-review.compiled.mjs b/examples/g02/feature-review.compiled.mjs new file mode 100644 index 0000000..4834582 --- /dev/null +++ b/examples/g02/feature-review.compiled.mjs @@ -0,0 +1,117 @@ +// @generated by flowkit compiler — DO NOT EDIT +// workflow: g02-feature-review@1.0.0(功能变更评审闭环) +// source: feature-review.workflow.js +// sourceHash: sha256:8df70cd1af7dd9b0b5ea2c2143815c8aca862696f580fa46b21f726c021ec592 · bodyHash: sha256:6115bc998e045fd9fa269d815830de3f5e4ab08af7862fd6b24cac3e84efdd9c +// compiler: flowkit@1.0.0 · options: trace=off +// evalWhen reads own ctx.state paths; strict booleans and short circuit; ! has lower precedence than comparison. +// runAgent receives one step; schema -> checkStep -> verify; own-key commit only after success; retries share reasons and control signals are rethrown. +// State is supplied by the host. +// Source and body hashes are mandatory. +// This module contains native control flow. +export async function workflow(ctx) { + const runtime = ctx.runtime; + const runAgent = (...args) => runtime.runAgent(...args); + const validateResult = (...args) => runtime.validateResult(...args); + const loadSkill = (...args) => runtime.loadSkill === undefined ? ctx.skillLoader.loadSkill(...args) : runtime.loadSkill(...args); + const loadSkillByAgent = (...args) => runtime.loadSkillByAgent === undefined ? ctx.skillLoader.loadSkillByAgent(...args) : runtime.loadSkillByAgent(...args); + const evalWhen = (...args) => runtime.evalWhen(...args); + const isControlSignal = (...args) => runtime.isControlSignal(...args); + const writeOwn = (target, key, value) => Object.defineProperty(target, key, + { value, enumerable: true, writable: true, configurable: true }); + const checkStep = runtime.checkStep && ((...args) => runtime.checkStep(...args)); + const verifyStep = undefined; + let stepNo = 0; + // Diagnostic text must not change retry behavior or replace the original rejection cause. + const rejectionText = (error) => { + try { return String(error?.message ?? error); } + catch { return ""; } + }; + const mergeOwn = (target, source) => { for (const key of Object.keys(source)) writeOwn(target, key, source[key]); return target; }; + const runStep = async (s) => { + // Allocate before any await: parallel declarations keep deterministic overview numbers. + const step = ++stepNo; + const instruction = await s.skill(); + const input = {}; + const missing = []; + for (const key of s.inputs) { + writeOwn(input, key, Object.hasOwn(ctx.state, key) ? ctx.state[key] : undefined); + if (!Object.hasOwn(ctx.state, key)) missing.push(key); + } + let reasons = []; + let threw = false; + let cause; + for (let attempt = 1; attempt <= s.retries + 1; attempt++) { + let result; + try { + result = await runAgent({ id: s.id, name: s.name, instruction, input, missing, resultSchema: s.output, + attempt, reasons, ...(s.overview ? { overview: { step } } : {}), signal: ctx.signal, runId: ctx.runId }); + threw = false; + } catch (error) { + if (isControlSignal(error)) throw error; + threw = true; + cause = error; + reasons = [`runAgent threw: ${rejectionText(error)}`]; + continue; + } + reasons = validateResult(s.output, result); + if (reasons.length === 0 && checkStep) { + const why = await checkStep({ id: s.id, name: s.name, attempt }, result, ctx); + if (typeof why === "string") reasons = [why]; + else if (why != null) throw Object.assign(new Error(`FK_R002 ${s.name} checkStep returned an invalid value`), { code: "FK_R002" }); + } + if (reasons.length === 0 && s.verify != null) { + const candidate = mergeOwn(mergeOwn({}, ctx.state), result); + if (!(await verifyStep(s.id, s.verify, s.name, { ...ctx, state: candidate }))) reasons = [`verify ${s.verify} failed`]; + } + if (reasons.length === 0) { mergeOwn(ctx.state, result); return; } + } + throw Object.assign(new Error(`FK_R004 ${s.name}: ${reasons.join("; ")}`, threw ? { cause } : undefined), { code: "FK_R004", reasons }); + }; + let n0_gateReach = false; + let n1_analyzeReach = false; + let n2_designReach = false; + let n3_reviewReach = false; + let n4_decideReach = false; + let n5_acceptedReach = false; + let n6_rejectedReach = false; + n0_gateReach = true; + if (n0_gateReach) { + // [gate] 检查材料是否齐全 · condition + if (evalWhen(ctx, "materialsReady")) { + n1_analyzeReach = true; + } else { + n6_rejectedReach = true; + } + } + if (n1_analyzeReach) { + // [analyze] 需求分析 · agent: analyze -> examples/g02/skills/analyze.md; inputs: request; results: requirementSummary; attempts: 1 + await runStep({ id: "analyze", name: "需求分析", skill: () => loadSkillByAgent("analyze"), inputs: ["request"], output: {"type": "object", "properties": {"requirementSummary": {"type": "string"}}, "required": ["requirementSummary"], "additionalProperties": false}, retries: 0, verify: null, overview: false }); + n2_designReach = true; + } + if (n2_designReach) { + // [design] 技术设计 · agent: design -> examples/g02/skills/design.md; inputs: requirementSummary; results: designSummary; attempts: 1 + await runStep({ id: "design", name: "技术设计", skill: () => loadSkillByAgent("design"), inputs: ["requirementSummary"], output: {"type": "object", "properties": {"designSummary": {"type": "string"}}, "required": ["designSummary"], "additionalProperties": false}, retries: 0, verify: null, overview: false }); + n3_reviewReach = true; + } + if (n3_reviewReach) { + // [review] 独立评审 · agent: review -> examples/g02/skills/review.md; inputs: requirementSummary, designSummary; results: reviewApproved, reviewNotes; attempts: 2 + await runStep({ id: "review", name: "独立评审", skill: () => loadSkillByAgent("review"), inputs: ["requirementSummary", "designSummary"], output: {"type": "object", "properties": {"reviewApproved": {"type": "boolean"}, "reviewNotes": {"type": "string"}}, "required": ["reviewApproved", "reviewNotes"], "additionalProperties": false}, retries: 1, verify: null, overview: false }); + n4_decideReach = true; + } + if (n4_decideReach) { + // [decide] 根据评审结论分流 · condition + if (evalWhen(ctx, "reviewApproved")) { + n5_acceptedReach = true; + } else { + n6_rejectedReach = true; + } + } + if (n5_acceptedReach) { + // [accepted] 评审通过 · terminal + return { status: "succeeded", terminal: "accepted" }; + } + if (n6_rejectedReach) { + // [rejected] 评审未通过或材料不齐 · terminal + return { status: "failed", terminal: "rejected" }; + } +} diff --git a/examples/g02/feature-review.interactive.mjs b/examples/g02/feature-review.interactive.mjs new file mode 100644 index 0000000..f4226a4 --- /dev/null +++ b/examples/g02/feature-review.interactive.mjs @@ -0,0 +1,143 @@ +// @generated by FlowKit Interactive — P0-A / Program ABI V1 +// This trusted compiler output MUST be loaded from verified immutable bytes. +function requireBoolean(value) { + if (typeof value !== 'boolean') throw Object.assign(new Error('When operand and final result must be boolean'), { code: 'FK_R002' }); + return value; +} +function evaluateWhenAst(ast, state) { + const stack = [{ node: ast, stage: 0 }]; + let result; + const finish = value => { result = value; stack.pop(); }; + while (stack.length) { + const frame = stack.at(-1); + const node = frame.node; + if (node.kind === 'literal') finish(node.value); + else if (node.kind === 'path') { + let value = state; + for (const key of node.parts) value = value !== null && typeof value === 'object' && Object.hasOwn(value, key) ? value[key] : undefined; + finish(value); + } else if (node.kind === 'not') { + if (frame.stage === 0) { frame.stage = 1; stack.push({ node: node.operand, stage: 0 }); } + else finish(!requireBoolean(result)); + } else if (['and', 'or', 'compare'].includes(node.kind)) { + if (frame.stage === 0) { frame.stage = 1; stack.push({ node: node.left, stage: 0 }); } + else if (frame.stage === 1) { + frame.left = node.kind === 'compare' ? result : requireBoolean(result); + if (node.kind === 'and' && !frame.left || node.kind === 'or' && frame.left) finish(frame.left); + else { frame.stage = 2; stack.push({ node: node.right, stage: 0 }); } + } else if (node.kind !== 'compare') finish(requireBoolean(result)); + else { + const left = frame.left; + const right = result; + if (node.operator === '===') finish(left === right); + else if (node.operator === '!==') finish(left !== right); + else { + if (!(typeof left === 'number' && typeof right === 'number' || typeof left === 'string' && typeof right === 'string')) { + throw Object.assign(new Error('When ordering requires two numbers or two strings'), { code: 'FK_R002' }); + } + if (node.operator === '>') finish(left > right); + else if (node.operator === '>=') finish(left >= right); + else if (node.operator === '<') finish(left < right); + else if (node.operator === '<=') finish(left <= right); + else throw new TypeError('Unknown when comparison'); + } + } + } else throw new TypeError('Unknown when AST node'); + } + return requireBoolean(result); +} +function freezeData(value) { + if (value && typeof value === 'object') { + for (const key of Object.keys(value)) freezeData(value[key]); + Object.freeze(value); + } + return value; +} +export const interactiveProgram = freezeData({"artifactFormat":"FlowkitInteractiveProgramV1","identity":{"workflowId":"g02-feature-review","workflowVersion":"1.0.0","sourceHash":"sha256:8df70cd1af7dd9b0b5ea2c2143815c8aca862696f580fa46b21f726c021ec592","compilerVersion":"1.0.0","programAbiVersion":1,"semanticsVersion":"interactive-state-machine-v1","checkpointSchemaVersion":1,"canonicalizationVersion":"jcs-rfc8785-v1","implementedStage":"P0-A"}}); +export const programTable = freezeData({"schemaVersion":1,"entryBlockId":"gate::enter","blocks":[{"id":"gate::enter","nodeId":"gate","phase":"enter","legalTargets":["gate::route"],"eventKinds":["internal-step","deterministic-failure"],"proposalKinds":["move","raise-error"]},{"id":"gate::route","nodeId":"gate","phase":"route","legalTargets":["analyze::enter","rejected::enter"],"eventKinds":["internal-step","deterministic-failure"],"proposalKinds":["move","raise-error"]},{"id":"analyze::enter","nodeId":"analyze","phase":"enter","legalTargets":["analyze::prepare"],"eventKinds":["internal-step","deterministic-failure"],"proposalKinds":["move","raise-error"]},{"id":"analyze::prepare","nodeId":"analyze","phase":"prepare","legalTargets":["analyze::dispatch-ready"],"eventKinds":["internal-step","pending-prepared","deterministic-failure"],"proposalKinds":["prepare-pending","raise-error"]},{"id":"analyze::dispatch-ready","nodeId":"analyze","phase":"dispatch-ready","legalTargets":["analyze::awaiting"],"eventKinds":["pending-prepared","deterministic-failure"],"proposalKinds":["publish-pending","raise-error"]},{"id":"analyze::awaiting","nodeId":"analyze","phase":"awaiting","legalTargets":["analyze::validate"],"eventKinds":[],"proposalKinds":[],"acceptTarget":"analyze::validate"},{"id":"analyze::validate","nodeId":"analyze","phase":"validate","legalTargets":["analyze::prepare","analyze::commit"],"eventKinds":["agent-result","agent-call-failed","validation-passed","validation-rejected","deterministic-failure"],"proposalKinds":["retry-agent","commit-candidate","raise-error"]},{"id":"analyze::commit","nodeId":"analyze","phase":"commit","legalTargets":["analyze::route"],"eventKinds":["internal-step","deterministic-failure"],"proposalKinds":["commit-state","raise-error"]},{"id":"analyze::route","nodeId":"analyze","phase":"route","legalTargets":["design::enter"],"eventKinds":["internal-step","deterministic-failure"],"proposalKinds":["move","raise-error"]},{"id":"design::enter","nodeId":"design","phase":"enter","legalTargets":["design::prepare"],"eventKinds":["internal-step","deterministic-failure"],"proposalKinds":["move","raise-error"]},{"id":"design::prepare","nodeId":"design","phase":"prepare","legalTargets":["design::dispatch-ready"],"eventKinds":["internal-step","pending-prepared","deterministic-failure"],"proposalKinds":["prepare-pending","raise-error"]},{"id":"design::dispatch-ready","nodeId":"design","phase":"dispatch-ready","legalTargets":["design::awaiting"],"eventKinds":["pending-prepared","deterministic-failure"],"proposalKinds":["publish-pending","raise-error"]},{"id":"design::awaiting","nodeId":"design","phase":"awaiting","legalTargets":["design::validate"],"eventKinds":[],"proposalKinds":[],"acceptTarget":"design::validate"},{"id":"design::validate","nodeId":"design","phase":"validate","legalTargets":["design::prepare","design::commit"],"eventKinds":["agent-result","agent-call-failed","validation-passed","validation-rejected","deterministic-failure"],"proposalKinds":["retry-agent","commit-candidate","raise-error"]},{"id":"design::commit","nodeId":"design","phase":"commit","legalTargets":["design::route"],"eventKinds":["internal-step","deterministic-failure"],"proposalKinds":["commit-state","raise-error"]},{"id":"design::route","nodeId":"design","phase":"route","legalTargets":["review::enter"],"eventKinds":["internal-step","deterministic-failure"],"proposalKinds":["move","raise-error"]},{"id":"review::enter","nodeId":"review","phase":"enter","legalTargets":["review::prepare"],"eventKinds":["internal-step","deterministic-failure"],"proposalKinds":["move","raise-error"]},{"id":"review::prepare","nodeId":"review","phase":"prepare","legalTargets":["review::dispatch-ready"],"eventKinds":["internal-step","pending-prepared","deterministic-failure"],"proposalKinds":["prepare-pending","raise-error"]},{"id":"review::dispatch-ready","nodeId":"review","phase":"dispatch-ready","legalTargets":["review::awaiting"],"eventKinds":["pending-prepared","deterministic-failure"],"proposalKinds":["publish-pending","raise-error"]},{"id":"review::awaiting","nodeId":"review","phase":"awaiting","legalTargets":["review::validate"],"eventKinds":[],"proposalKinds":[],"acceptTarget":"review::validate"},{"id":"review::validate","nodeId":"review","phase":"validate","legalTargets":["review::prepare","review::commit"],"eventKinds":["agent-result","agent-call-failed","validation-passed","validation-rejected","deterministic-failure"],"proposalKinds":["retry-agent","commit-candidate","raise-error"]},{"id":"review::commit","nodeId":"review","phase":"commit","legalTargets":["review::route"],"eventKinds":["internal-step","deterministic-failure"],"proposalKinds":["commit-state","raise-error"]},{"id":"review::route","nodeId":"review","phase":"route","legalTargets":["decide::enter"],"eventKinds":["internal-step","deterministic-failure"],"proposalKinds":["move","raise-error"]},{"id":"decide::enter","nodeId":"decide","phase":"enter","legalTargets":["decide::route"],"eventKinds":["internal-step","deterministic-failure"],"proposalKinds":["move","raise-error"]},{"id":"decide::route","nodeId":"decide","phase":"route","legalTargets":["accepted::enter","rejected::enter"],"eventKinds":["internal-step","deterministic-failure"],"proposalKinds":["move","raise-error"]},{"id":"accepted::enter","nodeId":"accepted","phase":"enter","legalTargets":["accepted::route"],"eventKinds":["internal-step","deterministic-failure"],"proposalKinds":["move","raise-error"]},{"id":"accepted::route","nodeId":"accepted","phase":"route","legalTargets":[],"eventKinds":["internal-step","deterministic-failure"],"proposalKinds":["terminal","raise-error"]},{"id":"rejected::enter","nodeId":"rejected","phase":"enter","legalTargets":["rejected::route"],"eventKinds":["internal-step","deterministic-failure"],"proposalKinds":["move","raise-error"]},{"id":"rejected::route","nodeId":"rejected","phase":"route","legalTargets":[],"eventKinds":["internal-step","deterministic-failure"],"proposalKinds":["terminal","raise-error"]}],"nodes":{"gate":{"kind":"condition","metadata":{"name":"检查材料是否齐全","branches":[{"when":"materialsReady","ast":{"kind":"path","parts":["materialsReady"],"column":1},"next":"analyze"},{"when":"true","ast":{"kind":"literal","value":true,"column":1},"next":"rejected"}]}},"analyze":{"kind":"agent","metadata":{"name":"需求分析","next":"design","agent":"analyze","inputs":["request"],"output":{"type":"object","properties":{"requirementSummary":{"type":"string"}},"required":["requirementSummary"],"additionalProperties":false},"retries":0,"overview":false}},"design":{"kind":"agent","metadata":{"name":"技术设计","next":"review","agent":"design","inputs":["requirementSummary"],"output":{"type":"object","properties":{"designSummary":{"type":"string"}},"required":["designSummary"],"additionalProperties":false},"retries":0,"overview":false}},"review":{"kind":"agent","metadata":{"name":"独立评审","next":"decide","agent":"review","inputs":["requirementSummary","designSummary"],"output":{"type":"object","properties":{"reviewApproved":{"type":"boolean"},"reviewNotes":{"type":"string"}},"required":["reviewApproved","reviewNotes"],"additionalProperties":false},"retries":1,"overview":false}},"decide":{"kind":"condition","metadata":{"name":"根据评审结论分流","branches":[{"when":"reviewApproved","ast":{"kind":"path","parts":["reviewApproved"],"column":1},"next":"accepted"},{"when":"true","ast":{"kind":"literal","value":true,"column":1},"next":"rejected"}]}},"accepted":{"kind":"terminal","metadata":{"name":"评审通过","status":"succeeded"}},"rejected":{"kind":"terminal","metadata":{"name":"评审未通过或材料不齐","status":"failed"}}},"requiredBindings":[{"id":"runtime.validateResult","version":"1","mode":"pure-local","kind":"agent-validation"},{"id":"runtime.skillResolver","version":"1","mode":"trusted-read","kind":"skill"}]}); +export function advance(view, event) { + if (!view || !event || event.schemaVersion !== 1 || view.blockId !== event.forBlockId) { + throw new TypeError('Event does not target the current checkpoint block'); + } + const blockId = view.blockId; + const phase = view.phase; + const block = programTable.blocks.find(item => item.id === blockId); + if (!block || block.phase !== phase || !block.eventKinds.includes(event.kind)) { + throw new TypeError('Uncertified event or invalid stage'); + } + const nodeId = block.nodeId; + const record = programTable.nodes[nodeId]; + const none = { kind: 'none' }; + const proposal = (kind, toBlockId, stackEffect = none, mergeIntent = none) => + ({ kind, fromBlockId: blockId, nodeId, toBlockId, stackEffect, mergeIntent }); + const fail = code => ({ + kind: 'raise-error', fromBlockId: blockId, nodeId, + causeCode: code || 'FK_R002', stackEffect: none, mergeIntent: none, + }); + if (event.kind === 'deterministic-failure') return fail(event.error?.code); + const to = (node, name) => node + '::' + name; + if (phase === 'enter') { + if (record.kind === 'agent') { + if (!Number.isSafeInteger(event.activation) || event.activation < 1) { + throw new TypeError('Missing certified Agent activation'); + } + const frame = { + kind: 'agent', nodeId, activation: event.activation, + attempt: 1, phase: 'prepare', reasons: [], overviewStep: event.activation, + }; + return proposal('move', to(nodeId, 'prepare'), {kind: 'push', frame}); + } + return proposal('move', to(nodeId, 'route')); + } + if (record.kind === 'condition' && phase === 'route') { + try { + for (const branch of record.metadata.branches) { + if (evaluateWhenAst(branch.ast, view.state)) { + return proposal('move', to(branch.next, 'enter')); + } + } + } catch (error) { + if (error?.code === 'FK_R002') return fail('FK_R002'); + throw error; // trusted Coordinator classifies unexpected program failures + } + return fail('FK_R002'); + } + if (record.kind === 'terminal' && phase === 'route') { + return { + kind: 'terminal', fromBlockId: blockId, nodeId, + terminal: {status: record.metadata.status, nodeId}, + stackEffect: none, mergeIntent: none, + }; + } + if (record.kind !== 'agent') throw new TypeError('Unsupported P0-A block'); + const frame = view.controlStack[view.controlStack.length - 1]; + if (!frame || frame.kind !== 'agent' || frame.nodeId !== nodeId) { + throw new TypeError('Missing certified Agent control frame'); + } + if (phase === 'prepare') return proposal('prepare-pending', to(nodeId, 'dispatch-ready')); + if (phase === 'dispatch-ready') return proposal('publish-pending', to(nodeId, 'awaiting')); + if (phase === 'validate') { + if (event.kind === 'validation-passed') { + if (view.candidate?.kind !== 'agent') throw new TypeError('Verified Agent candidate not committed'); + return proposal('commit-candidate', to(nodeId, 'commit')); + } + if (event.kind === 'validation-rejected' || event.kind === 'agent-call-failed') { + const attempts = record.metadata.retries + 1; + if (frame.attempt >= attempts) return fail('FK_R004'); + return proposal('retry-agent', to(nodeId, 'prepare'), + {kind: 'update', expectedKind: 'agent', expectedNodeId: nodeId, + field: 'attempt', value: frame.attempt + 1}); + } + throw new TypeError('Agent validation event is not ready for Program advancement'); + } + if (phase === 'commit') { + if (view.candidate?.kind !== 'agent') throw new TypeError('Missing committed Agent candidate'); + return proposal('commit-state', to(nodeId, 'route'), none, + {kind:'commit-candidate', requestId:view.candidate.requestId}); + } + if (phase === 'route') { + return proposal('move', to(record.metadata.next, 'enter'), + {kind:'pop', expectedKind:'agent', expectedNodeId:nodeId}); + } + throw new TypeError('Illegal Program advancement at awaiting/unknown phase'); +} diff --git a/examples/g02/feature-review.mmd b/examples/g02/feature-review.mmd new file mode 100644 index 0000000..7f30cde --- /dev/null +++ b/examples/g02/feature-review.mmd @@ -0,0 +1,15 @@ +flowchart TD + Gate{"检查材料是否齐全"} + Analyze["需求分析"] + Design["技术设计"] + Review["独立评审"] + Decide{"根据评审结论分流"} + Accepted("评审通过") + Rejected("评审未通过或材料不齐") + Gate -- "materialsReady" --> Analyze + Gate -- "true" --> Rejected + Analyze --> Design + Design --> Review + Review --> Decide + Decide -- "reviewApproved" --> Accepted + Decide -- "true" --> Rejected diff --git a/examples/g02/feature-review.workflow.js b/examples/g02/feature-review.workflow.js new file mode 100644 index 0000000..ebccf4a --- /dev/null +++ b/examples/g02/feature-review.workflow.js @@ -0,0 +1,67 @@ +import { defineWorkflow, condition, node, terminal } from '@devcodex-labs/flowkit/dsl'; + +export default defineWorkflow({ + id: 'g02-feature-review', + version: '1.0.0', + name: '功能变更评审闭环', + nodes: [ + condition('gate', { + name: '检查材料是否齐全', + branches: [ + { when: 'materialsReady', next: 'analyze' }, + { when: 'true', next: 'rejected' }, + ], + }), + node('analyze', { + name: '需求分析', + agent: 'analyze', + inputs: ['request'], + output: { + type: 'object', + properties: { requirementSummary: { type: 'string' } }, + required: ['requirementSummary'], + additionalProperties: false, + }, + retries: 0, + next: 'design', + }), + node('design', { + name: '技术设计', + agent: 'design', + inputs: ['requirementSummary'], + output: { + type: 'object', + properties: { designSummary: { type: 'string' } }, + required: ['designSummary'], + additionalProperties: false, + }, + retries: 0, + next: 'review', + }), + node('review', { + name: '独立评审', + agent: 'review', + inputs: ['requirementSummary', 'designSummary'], + output: { + type: 'object', + properties: { + reviewApproved: { type: 'boolean' }, + reviewNotes: { type: 'string' }, + }, + required: ['reviewApproved', 'reviewNotes'], + additionalProperties: false, + }, + retries: 1, + next: 'decide', + }), + condition('decide', { + name: '根据评审结论分流', + branches: [ + { when: 'reviewApproved', next: 'accepted' }, + { when: 'true', next: 'rejected' }, + ], + }), + terminal('accepted', { name: '评审通过', status: 'succeeded' }), + terminal('rejected', { name: '评审未通过或材料不齐', status: 'failed' }), + ], +}); diff --git a/examples/g02/run-interactive.mjs b/examples/g02/run-interactive.mjs new file mode 100644 index 0000000..cf0604e --- /dev/null +++ b/examples/g02/run-interactive.mjs @@ -0,0 +1,152 @@ +/** + * Maintainer helper: replay G02 Interactive fixtures through the real + * compileInteractive Program, SQLite Store and Trusted Host. + * Local test signatures and Node execution are not native Host provenance. + */ +import {readFile, mkdtemp, rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import {generateKeyPairSync, sign} from 'node:crypto'; +import {compileInteractive} from '../../src/interactive/compile.mjs'; +import {createSqliteCheckpointStore} from '../../src/interactive/sqlite-store.mjs'; +import {createTrustedInteractiveHost} from '../../src/interactive/trusted-host.mjs'; +import {createAgentReceiptStatement, encodeAgentReceiptStatement} + from '../../src/interactive/host-attestation.mjs'; +import {canonicalBytes, sha256Bytes} from '../../src/interactive/canonical.mjs'; + +const repoRoot = fileURLToPath(new URL('../../', import.meta.url)); +const workflow = fileURLToPath(new URL('./feature-review.workflow.js', import.meta.url)); +const success = JSON.parse(await readFile(new URL( + '../../tests/fixtures/g02/turns-success-retry.json', import.meta.url), 'utf8')); +const rejected = JSON.parse(await readFile(new URL( + '../../tests/fixtures/g02/turns-review-false.json', import.meta.url), 'utf8')); +const skillsRoot = path.join(repoRoot, 'examples/g02/skills'); +const NOW = 1794500000000; + +function validate(schema, output) { + if (!output || typeof output !== 'object' || Array.isArray(output)) return ['Agent result must be object']; + const props = schema.properties ?? {}; + const required = schema.required ?? Object.keys(props); + for (const key of required) if (!Object.hasOwn(output, key)) return ['missing ' + key]; + if (schema.additionalProperties === false) { + for (const key of Object.keys(output)) if (!Object.hasOwn(props, key)) return ['unknown ' + key]; + } + for (const [key, spec] of Object.entries(props)) { + if (!Object.hasOwn(output, key)) continue; + if (typeof output[key] !== spec.type) return [key + ' must be ' + spec.type]; + if (spec.type === 'string' && output[key].length < 80) return [key + ' must be at least 80 characters']; + } + return []; +} + +const skills = { + analyze: await readFile(new URL('./skills/analyze.md', import.meta.url), 'utf8'), + design: await readFile(new URL('./skills/design.md', import.meta.url), 'utf8'), + review: await readFile(new URL('./skills/review.md', import.meta.url), 'utf8'), +}; + +const compiled = await compileInteractive(workflow, { + output: 'memory', featureStage: 'P0-A', skillsRoot, skillCheck: true, projectRoot: repoRoot, +}); +if (compiled.diagnostics.some((d) => d.severity === 'error')) { + throw new Error(JSON.stringify(compiled.diagnostics)); +} + +async function replay(fixture, materialsReady = true) { + const dir = await mkdtemp(path.join(tmpdir(), 'g02-run-')); + const file = path.join(dir, 'run.sqlite'); + const keys = generateKeyPairSync('ed25519'); + let store; + const route = []; + try { + const hostFor = (s) => createTrustedInteractiveHost({ + store: s, + runtime: { + skillResolver: async ({agent}) => skills[agent], + validateResult: validate, + }, + policy: { + deploymentId: 'g02-feature-review-20261112', + bindingVersions: {skill: 'g02-v1', validator: 'g02-v1'}, + }, + trustedPublicKeys: {'g02-host': keys.publicKey}, + trustedArtifacts: [compiled.programIdentity.artifactHash], + allowedInputKeys: ['request', 'requirementSummary', 'designSummary'], + disclosurePolicyId: 'g02-public-inputs', + authorizeDisclosure: async ({task}) => + ['analyze', 'design', 'review'].includes(task?.nodeId) && task?.runId === fixture.runId, + now: () => NOW, + }); + store = createSqliteCheckpointStore(file, {namespace: 'g02-feature-review'}); + let host = hostFor(store); + let frame = await host.start({ + programBytes: Buffer.from(compiled.code, 'utf8'), + programIdentity: compiled.programIdentity, + runId: fixture.runId, startKey: fixture.startKey, + state: {materialsReady, request: fixture.request, hostSecret: 'CANARY'}, + }); + for (let index = 0; index < (fixture.turns ?? []).length; index++) { + const pending = await host.getPublicAgentTask({runId: fixture.runId}); + const recorded = fixture.turns[index]; + const taskDigest = sha256Bytes(canonicalBytes(pending)); + if (recorded.requestId !== pending.requestId || recorded.taskDigest !== taskDigest) { + throw new Error('fixture does not bind to live Pending at ' + index); + } + const receipt = { + runId: fixture.runId, requestId: pending.requestId, nodeId: pending.nodeId, + attempt: pending.attempt, kind: 'agent-result', result: recorded.result, + }; + const statement = createAgentReceiptStatement({ + run: store.load(fixture.runId), receipt, keyId: 'g02-host', + proofId: 'g02-replay-' + fixture.scenario + '-' + index, + issuedAt: NOW - 1000, expiresAt: NOW + 30000, + }); + const attestation = { + statement, + signature: sign(null, encodeAgentReceiptStatement(statement), keys.privateKey).toString('base64url'), + }; + store.close(); + store = createSqliteCheckpointStore(file, {namespace: 'g02-feature-review'}); + host = hostFor(store); + frame = await host.accept({receipt, attestation}); + route.push({ + source: recorded.source, nodeId: pending.nodeId, attempt: pending.attempt, + requestId: pending.requestId, statusAfter: frame.status, + next: frame.pending?.nodeId ?? frame.terminal?.nodeId ?? null, + }); + } + return { + layer: 'interactive-p0a', + scenario: fixture.scenario ?? (materialsReady ? 'unknown' : 'gate-false'), + status: frame.status, + terminal: frame.terminal ?? null, + reviewApproved: frame.state?.reviewApproved ?? null, + // pendingAtEnd is the terminal frame only. publishedPendingCount is + // measured from Pendings actually observed on this run's route. + pendingAtEnd: (frame.pending ?? null) != null, + publishedPendingCount: route.length, + route, + programHash: compiled.programIdentity.artifactHash, + evidenceNote: 'Interactive Program + SQLite + local test receipts via Node. Not N-PASS. Not native secure Host provenance.', + }; + } finally { + store?.close(); + await rm(dir, {recursive: true, force: true}); + } +} + +const gate = { + scenario: 'gate-false', + runId: 'g02-gate-false-20261112', + startKey: 'g02-gate-false-start', + request: success.request, + turns: [], +}; + +const output = [ + await replay(success), + await replay(gate, false), + await replay(rejected), +]; +console.log(JSON.stringify(output, null, 2)); diff --git a/examples/g02/run-standard.mjs b/examples/g02/run-standard.mjs new file mode 100644 index 0000000..79284ea --- /dev/null +++ b/examples/g02/run-standard.mjs @@ -0,0 +1,99 @@ +/** + * Maintainer helper: execute the G02 workflow through Standard run(). + * This is not a native secure Host and does not call a live model API. + * Agent payloads are the recorded Grok / scripted fixtures. + */ +import {run, isControlSignal, evalWhen} from '../../src/index.mjs'; +import {readFile} from 'node:fs/promises'; +import {fileURLToPath} from 'node:url'; +import path from 'node:path'; + +const repoRoot = fileURLToPath(new URL('../../', import.meta.url)); +const workflow = fileURLToPath(new URL('./feature-review.workflow.js', import.meta.url)); +const success = JSON.parse(await readFile(new URL( + '../../tests/fixtures/g02/turns-success-retry.json', import.meta.url), 'utf8')); +const rejected = JSON.parse(await readFile(new URL( + '../../tests/fixtures/g02/turns-review-false.json', import.meta.url), 'utf8')); +const request = success.request; +const skillsRoot = path.join(repoRoot, 'examples/g02/skills'); + +function validate(schema, output) { + if (!output || typeof output !== 'object' || Array.isArray(output)) return ['Agent result must be object']; + const props = schema.properties ?? {}; + const required = schema.required ?? Object.keys(props); + for (const key of required) if (!Object.hasOwn(output, key)) return ['missing ' + key]; + if (schema.additionalProperties === false) { + for (const key of Object.keys(output)) if (!Object.hasOwn(props, key)) return ['unknown ' + key]; + } + for (const [key, spec] of Object.entries(props)) { + if (!Object.hasOwn(output, key)) continue; + if (typeof output[key] !== spec.type) return [key + ' must be ' + spec.type]; + if (spec.type === 'string' && output[key].length < 80) return [key + ' must be at least 80 characters']; + } + return []; +} + +function runtime(plan, label) { + const calls = []; + return { + calls, + isControlSignal, + evalWhen, + validateResult: validate, + async runAgent(step) { + const result = plan(step); + calls.push({scenario: label, id: step.id, attempt: step.attempt, keys: Object.keys(result)}); + return result; + }, + }; +} + +const results = []; +const successRuntime = runtime((step) => { + if (step.id === 'analyze') return success.turns[0].result; + if (step.id === 'design') return success.turns[1].result; + if (step.id === 'review' && step.attempt === 1) return success.turns[2].result; + if (step.id === 'review' && step.attempt === 2) return success.turns[3].result; + throw new Error('unexpected ' + step.id); +}, 'success-with-retry'); +results.push({ + layer: 'standard', + scenario: 'success-with-retry', + result: await run(workflow, successRuntime, {materialsReady: true, request, hostSecret: 'CANARY'}, + {skillsRoot, projectRoot: repoRoot}), + calls: successRuntime.calls, +}); + +const gateRuntime = runtime(() => { throw new Error('gate-false must not call runAgent'); }, 'gate-false'); +results.push({ + layer: 'standard', + scenario: 'gate-false', + result: await run(workflow, gateRuntime, {materialsReady: false, request, hostSecret: 'CANARY'}, + {skillsRoot, projectRoot: repoRoot}), + calls: gateRuntime.calls, +}); + +const rejectRuntime = runtime((step) => { + if (step.id === 'analyze') return rejected.turns[0].result; + if (step.id === 'design') return rejected.turns[1].result; + if (step.id === 'review') return rejected.turns[2].result; + throw new Error('unexpected ' + step.id); +}, 'review-false'); +results.push({ + layer: 'standard', + scenario: 'review-false', + result: await run(workflow, rejectRuntime, {materialsReady: true, request, hostSecret: 'CANARY'}, + {skillsRoot, projectRoot: repoRoot}), + calls: rejectRuntime.calls, +}); + +const summary = results.map((item) => ({ + layer: item.layer, + scenario: item.scenario, + status: item.result.status, + terminal: item.result.terminal, + reviewApproved: item.result.state.reviewApproved ?? null, + agentCalls: item.calls, + evidenceNote: 'Standard run() via Node. Not native secure Host provenance. Not N-PASS.', +})); +console.log(JSON.stringify(summary, null, 2)); diff --git a/examples/g02/skills/analyze.md b/examples/g02/skills/analyze.md new file mode 100644 index 0000000..de4fbb5 --- /dev/null +++ b/examples/g02/skills/analyze.md @@ -0,0 +1,8 @@ +# 需求分析 Agent + +你是 FlowKit 内部「功能变更评审」工作流中的需求分析步骤。 + +根据公开输入中的功能请求 `request`,返回一个 JSON 对象,且只包含一个字段: +- `requirementSummary`:用中文写出严谨、可操作的需求摘要,说明目标、约束、非目标和可验收结果,不少于 80 个汉字。 + +禁止输出 Host 私钥、SQLite 路径、完整 State、未授权内部信息,也禁止指定下一个工作流节点。下一节点由 FlowKit 编译后的 JavaScript Program 决定。 diff --git a/examples/g02/skills/design.md b/examples/g02/skills/design.md new file mode 100644 index 0000000..61bba40 --- /dev/null +++ b/examples/g02/skills/design.md @@ -0,0 +1,8 @@ +# 技术设计 Agent + +你是 FlowKit 内部「功能变更评审」工作流中的技术设计步骤。 + +根据公开输入中的 `requirementSummary`,返回一个 JSON 对象,且只包含一个字段: +- `designSummary`:用中文给出精简的技术设计要点,分清 FlowKit Core、受信 Host 与可选 Adapter 的职责,不少于 80 个汉字。 + +禁止修改程序控制流,禁止指定下一个节点,禁止把未实现的 Interactive 能力写成已交付。下一节点由 FlowKit Program 决定。 diff --git a/examples/g02/skills/review.md b/examples/g02/skills/review.md new file mode 100644 index 0000000..ddaffb4 --- /dev/null +++ b/examples/g02/skills/review.md @@ -0,0 +1,9 @@ +# 独立评审 Agent + +你是 FlowKit 内部「功能变更评审」工作流中的独立评审步骤。 + +根据公开输入中的 `requirementSummary` 和 `designSummary`,返回一个 JSON 对象,且只包含以下字段: +- `reviewApproved`:布尔值。仅在需求与设计一致、约束未被突破、验收路径清楚时为 true。 +- `reviewNotes`:中文评审说明,不少于 80 个汉字,必须依据实际上游摘要,不得凭空宣称安全宿主或原生工具已通过。 + +禁止指定下一个节点,禁止输出 Host 机密或完整 State。程序将根据 `reviewApproved` 自行选择终态。 diff --git a/examples/g02/user-intent.md b/examples/g02/user-intent.md new file mode 100644 index 0000000..097d14d --- /dev/null +++ b/examples/g02/user-intent.md @@ -0,0 +1,27 @@ +# G02 用户意图(真实任务原文) + +记录时间:2026-11-12,Asia/Shanghai。 +本文件是 Grok Build 在生成本阶段 `.workflow.js` 时实际阅读的用户任务原文,未事后改写需求。 + +## 任务名称 + +内部「功能变更评审」可执行闭环。 + +## 任务正文 + +请为 FlowKit 内部维护团队设计一个可重复执行的「功能变更评审」工作流,用来处理维护者提交的功能请求。要求如下: + +1. 入口先检查材料是否齐全(状态字段 `materialsReady`)。若材料不齐全,流程必须直接失败结束,不得调用任何 Agent。 +2. 若材料齐全,按固定顺序执行三个 Agent 步骤,模型不得自行选择下一个节点: + - 需求分析:阅读功能请求 `request`,输出 `requirementSummary`(字符串)。 + - 技术设计:基于上一节点的需求分析,输出 `designSummary`(字符串)。 + - 独立评审:同时阅读需求摘要和技术设计摘要,输出 `reviewApproved`(布尔)和 `reviewNotes`(字符串)。评审结果必须符合约定的 JSON 对象 schema;非法输出允许重试一次。 +3. 评审通过则成功结束;评审不通过则失败结束,并给出可核查终态。 +4. 流程推进、条件分支、重试与后继节点必须由 FlowKit 编译出的 JavaScript Program 决定,而不是由模型在回答里指定下一个节点。 +5. 只使用 FlowKit 现有 DSL 语法,不要发明第二套解析器、模型网关或独立 FlowKit Server。Interactive 验收仅使用已实现的 P0-A 子集:Condition、Terminal、基本 Agent。Standard 可用同一份 DSL 展示已实现的 Agent/Condition/Terminal 语义;Loop/Action/Human/Parallel 不是本任务的必要步骤,对照现有 editorial golden 说明差异即可。 + +## 本次实际提交的功能请求(作为工作流输入 `request`) + +为 FlowKit 维护者增加一条只读的工作流运行摘要查看能力:在不修改现有 Standard Root 六个 API、不引入独立 FlowKit Server 的前提下,允许受信 Host 读取一次运行的终态状态(succeeded/failed/errored)、terminal 节点和公开投影,便于排障。不在本阶段实现 adminTerminate,也不改变 Interactive P0-A 的节点范围。 + +说明:上述功能请求只是本工作流要评审的业务对象,G02 阶段本身并不实现该能力。 diff --git a/tests/fixtures/g02/turns-review-false.json b/tests/fixtures/g02/turns-review-false.json new file mode 100644 index 0000000..ed5a283 --- /dev/null +++ b/tests/fixtures/g02/turns-review-false.json @@ -0,0 +1,75 @@ +{ + "stageId": "G02", + "model": "Grok Bot / Grok Build", + "evidenceTier": "M", + "providerVerified": false, + "nativeJsTool": false, + "nativeHostProvenance": false, + "note": "两份 G02 夹具合计:source=grok-model 的结果消费 6 次,对应 5 份独立撰写正文、1 次复用(评审拒绝路径的 analyze 复用成功路径同一 requirementSummary,并重新绑定本轮独立的 requestId/taskDigest,不构成第六次独立模型推理)。source=scripted-invalid 是故意非法的脚本负例,不是模型输出。本地 Trusted Host 使用测试用 Ed25519 密钥签名回执,这不是供应商来源证明,也不得标为 N-PASS。本环境通过 Node/FlowKit CLI 执行 JavaScript,不能声称已通过原生安全 Host 来源认证。", + "request": "为 FlowKit 维护者增加一条只读的工作流运行摘要查看能力:在不修改现有 Standard Root 六个 API、不引入独立 FlowKit Server 的前提下,允许受信 Host 读取一次运行的终态状态(succeeded/failed/errored)、terminal 节点和公开投影,便于排障。不在本阶段实现 adminTerminate,也不改变 Interactive P0-A 的节点范围。", + "programArtifactHash": "sha256:8d811766fd1d58456ce4e58cc2a2f04c4d1e36dd683f86496c2df946cb15eded", + "sourceHash": "sha256:8df70cd1af7dd9b0b5ea2c2143815c8aca862696f580fa46b21f726c021ec592", + "workflowId": "g02-feature-review", + "namespace": "g02-feature-review", + "scenario": "review-approved-false", + "runId": "g02-review-false-20261112", + "startKey": "g02-review-false-start", + "materialsReady": true, + "expectedTerminal": { + "nodeId": "rejected", + "status": "failed" + }, + "turns": [ + { + "index": 0, + "nodeId": "analyze", + "attempt": 1, + "requestId": "req:8c18ec5e3534510274b4ee71d0f02da7c12b00f627bc44a1109c9d3ad20997d0", + "taskDigest": "sha256:6a244424828420965de95f851a7b08697778d246f57ab9a57ae0d03c7e8decbd", + "source": "grok-model", + "authoredAt": "2026-11-12T17:14:50+08:00", + "note": "复用且重新绑定:在查阅本轮新 Pending 的公开 input/instruction 后,确认与成功路径同一功能请求,复用此前由 Grok 撰写的需求分析正文(originalAuthoredAt 为首次撰写时间),并绑定到本轮独立的 requestId/taskDigest。这是第 6 次结果消费,不是一次新的模型推理。", + "result": { + "requirementSummary": "本需求的目标是为受信 Host 提供一条只读的工作流运行摘要查询能力,便于排障时确认一次运行的终态。摘要仅应返回 succeeded、failed 或 errored 等运行状态、terminal 节点标识,以及已批准的公开投影,不得暴露完整私有 State 或 Host 机密。明确约束包括:不得修改现有 Standard Root 的六个 API,不得为此引入独立 FlowKit Server,不得在本阶段实现 adminTerminate,也不得扩大 Interactive P0-A 已实现节点范围。非目标包括通用产物存储、模型网关和跨租户管理控制台。可验收结果是:在现有 Interactive 契约下,受信 Host 能对指定 runId 读取上述只读摘要,未授权主体不能获得额外字段,且该能力不改变 Program 路由或 Standard 兼容性。" + }, + "originalAuthoredAt": "2026-11-12T17:11:00+08:00", + "reusedAt": "2026-11-12T17:14:50+08:00", + "reusedFrom": { + "scenario": "success-with-scripted-retry", + "nodeId": "analyze", + "requestId": "req:cd869d313e933b356b99d9229e8ac9be161eca22e0d4b2c11a53091f58f868a7", + "taskDigest": "sha256:cc148ff129cd73677a5d7a8fc8afd641664dcafa0f39fecf2864b85fea7d8237" + } + }, + { + "index": 1, + "nodeId": "design", + "attempt": 1, + "requestId": "req:2d76c8634f8c31f8a6161f42241f1f2b69db3728bd674f0373c3333d2507cf9a", + "taskDigest": "sha256:7d32125cec204e43475e524fed1d538b5b168d5f68169d7a9ce5fe26f37e0987", + "source": "grok-model", + "authoredAt": "2026-11-12T17:15:40+08:00", + "note": "本轮故意给出一份越界设计,供后续独立评审拒绝。这是模型针对本 Pending 撰写的真实输出,不是脚本假数据。", + "result": { + "designSummary": "为了让维护者更方便排障,建议直接在 Standard Root 上新增第七个公开函数 runSummary(),并同时建立一个独立的 FlowKit Server,对所有运行提供 HTTP 查询和简单的管理终止入口。Core 可以把完整终态 State 一并返回给调用方,以便尽快定位问题。模型也可以在摘要接口里附带建议的下一节点,减少 Host 编排成本。该方案实现快,但会扩大根 API、引入独立服务,并把私有 State 和管理终止一并打包上线。" + } + }, + { + "index": 2, + "nodeId": "review", + "attempt": 1, + "requestId": "req:aade8c66498bab1b076a8e3d7a9f37e5d7ff2d830fc95fd856592007707f18ba", + "taskDigest": "sha256:0bbf5b56a3c469e588ab2f4178695db78463836f2e25167e41a94c1f58b389bd", + "source": "grok-model", + "authoredAt": "2026-11-12T17:16:30+08:00", + "result": { + "reviewApproved": false, + "reviewNotes": "评审不通过。需求摘要明确禁止修改 Standard Root 六个 API、禁止引入独立 FlowKit Server、禁止本阶段实现 adminTerminate,并要求摘要只包含终态状态、terminal 节点和公开投影,不得返回完整私有 State。当前设计却建议新增第七个根函数 runSummary()、建立独立 FlowKit Server、把完整终态 State 返回给调用方,还允许模型在接口中附带建议的下一节点,这些都直接突破了已声明约束,也会削弱 Program 对路由的唯一决定权。因此不能批准该设计。本结论只给出 reviewApproved=false 与评审说明,不指定工作流的下一个节点。" + } + } + ], + "modelResponseUses": 6, + "distinctAuthoredBodies": 5, + "reusedBodies": 1, + "scriptedInvalidUses": 1 +} diff --git a/tests/fixtures/g02/turns-success-retry.json b/tests/fixtures/g02/turns-success-retry.json new file mode 100644 index 0000000..a03bbcf --- /dev/null +++ b/tests/fixtures/g02/turns-success-retry.json @@ -0,0 +1,79 @@ +{ + "stageId": "G02", + "model": "Grok Bot / Grok Build", + "evidenceTier": "M", + "providerVerified": false, + "nativeJsTool": false, + "nativeHostProvenance": false, + "note": "两份 G02 夹具合计:source=grok-model 的结果消费 6 次,对应 5 份独立撰写正文、1 次复用(评审拒绝路径的 analyze 复用成功路径同一 requirementSummary,并重新绑定本轮独立的 requestId/taskDigest,不构成第六次独立模型推理)。source=scripted-invalid 是故意非法的脚本负例,不是模型输出。本地 Trusted Host 使用测试用 Ed25519 密钥签名回执,这不是供应商来源证明,也不得标为 N-PASS。本环境通过 Node/FlowKit CLI 执行 JavaScript,不能声称已通过原生安全 Host 来源认证。", + "request": "为 FlowKit 维护者增加一条只读的工作流运行摘要查看能力:在不修改现有 Standard Root 六个 API、不引入独立 FlowKit Server 的前提下,允许受信 Host 读取一次运行的终态状态(succeeded/failed/errored)、terminal 节点和公开投影,便于排障。不在本阶段实现 adminTerminate,也不改变 Interactive P0-A 的节点范围。", + "programArtifactHash": "sha256:8d811766fd1d58456ce4e58cc2a2f04c4d1e36dd683f86496c2df946cb15eded", + "sourceHash": "sha256:8df70cd1af7dd9b0b5ea2c2143815c8aca862696f580fa46b21f726c021ec592", + "workflowId": "g02-feature-review", + "namespace": "g02-feature-review", + "scenario": "success-with-scripted-retry", + "runId": "g02-success-retry-20261112", + "startKey": "g02-success-retry-start", + "materialsReady": true, + "expectedTerminal": { + "nodeId": "accepted", + "status": "succeeded" + }, + "turns": [ + { + "index": 0, + "nodeId": "analyze", + "attempt": 1, + "requestId": "req:cd869d313e933b356b99d9229e8ac9be161eca22e0d4b2c11a53091f58f868a7", + "taskDigest": "sha256:cc148ff129cd73677a5d7a8fc8afd641664dcafa0f39fecf2864b85fea7d8237", + "source": "grok-model", + "authoredAt": "2026-11-12T17:11:00+08:00", + "result": { + "requirementSummary": "本需求的目标是为受信 Host 提供一条只读的工作流运行摘要查询能力,便于排障时确认一次运行的终态。摘要仅应返回 succeeded、failed 或 errored 等运行状态、terminal 节点标识,以及已批准的公开投影,不得暴露完整私有 State 或 Host 机密。明确约束包括:不得修改现有 Standard Root 的六个 API,不得为此引入独立 FlowKit Server,不得在本阶段实现 adminTerminate,也不得扩大 Interactive P0-A 已实现节点范围。非目标包括通用产物存储、模型网关和跨租户管理控制台。可验收结果是:在现有 Interactive 契约下,受信 Host 能对指定 runId 读取上述只读摘要,未授权主体不能获得额外字段,且该能力不改变 Program 路由或 Standard 兼容性。" + } + }, + { + "index": 1, + "nodeId": "design", + "attempt": 1, + "requestId": "req:d76207d128012416efb5dc4b1ade578be4ac0e1802031fe8f92b215734407de3", + "taskDigest": "sha256:3a88c90088ca520ef9e3dfb9912d4d352c0c5410eaf13a34fb7eb510fb36bcd5", + "source": "grok-model", + "authoredAt": "2026-11-12T17:12:30+08:00", + "result": { + "designSummary": "建议在现有 `@devcodex-labs/flowkit/interactive` 子路径上,由受信 Host 调用已有 Coordinator 或 Trusted Host 的只读查询接口来获得运行摘要,而不是新增 Standard Root API,也不建立独立 FlowKit Server。FlowKit Core 继续只负责 Program 路由、Checkpoint 与合法事件校验,摘要内容应从已提交的 Checkpoint 或公开投影组装,仅包含运行状态、terminal 节点和已授权的公开字段。完整 State、私钥、SQLite 路径不得进入公开投影。adminTerminate 仍按未决缺口单独处理,不与本只读查询捆绑交付。可选 OpenAPI Adapter 若日后暴露该查询,必须停留在 Host 层并复用同一公开投影策略。验收应证明:同一 Program Hash 下的终态摘要可在进程重启后从 Store 重读,且模型无法通过该回答改写 PC 或后继节点。" + } + }, + { + "index": 2, + "nodeId": "review", + "attempt": 1, + "requestId": "req:d2d5fe090a4aeef9500ee9a624690a23f17d887d3c84a90e36538d29cb775133", + "taskDigest": "sha256:503a23e6e656f6665aaae76ad668eb4fb8b4345655d776f8d7acd42462d868f7", + "source": "scripted-invalid", + "authoredAt": "2026-11-12T17:13:20+08:00", + "note": "脚本化负例:故意提交不符合 resultSchema 的对象,用于验证非法 Agent 输出触发重试。不是模型输出。", + "result": { + "comment": "this is not a valid review schema", + "ok": 1 + } + }, + { + "index": 3, + "nodeId": "review", + "attempt": 2, + "requestId": "req:6cc88145ba67ad65081a9a78a741f8cbf26b35e9d7b4b8017ca095d59f0ea363", + "taskDigest": "sha256:4c7ba81821e86f8008a7377a74e152a8587bc21343b650d3c0dbda9c4e3c9d65", + "source": "grok-model", + "authoredAt": "2026-11-12T17:14:10+08:00", + "result": { + "reviewApproved": true, + "reviewNotes": "独立评审认为需求与设计一致:目标被准确收敛为受信 Host 下的只读运行摘要,范围包含终态状态、terminal 节点和公开投影,并明确排除完整私有 State。设计选择复用 interactive 子路径上已有 Coordinator 或 Trusted Host 的只读查询,而不是修改 Standard Root 六个 API,也没有引入独立 FlowKit Server,符合约束。adminTerminate 被正确排除在本能力之外,OpenAPI 若出现也只作为 Host 层可选适配。验收路径要求同一 Program Hash 下终态摘要可在 Store 重启后重读,且模型不能改写路由,这是可执行的。因此批准该方案继续,但不把本批准解释为安全宿主、原生工具或 P0-A 全量验收已经通过。" + } + } + ], + "modelResponseUses": 6, + "distinctAuthoredBodies": 5, + "reusedBodies": 1, + "scriptedInvalidUses": 1 +} diff --git a/tests/interactive/g02-product-closed-loop.test.mjs b/tests/interactive/g02-product-closed-loop.test.mjs new file mode 100644 index 0000000..74e89f7 --- /dev/null +++ b/tests/interactive/g02-product-closed-loop.test.mjs @@ -0,0 +1,438 @@ +/** + * G02 product closed loop: a Grok-generated Workflow DSL is compiled by the + * real FlowKit compiler into JavaScript, then executed on two layers. + * + * Standard layer: official compile/check/run() of Agent/Condition/Terminal. + * Interactive P0-A layer: compileInteractive, immutable JS Program, published + * Pending, Trusted Host receipts, SQLite close/reopen recovery. + * + * Model evidence is M-level. source=grok-model records result consumption + * bound to an exact Pending (6 uses, 5 distinct authored bodies, 1 reused + * body rebound to a new requestId/taskDigest). source=scripted-invalid is a + * deliberate fixture, not a model turn. Local Ed25519 receipts and Node/CLI + * execution are not native secure Host provenance and must not be graded N-PASS. + */ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {readFile, mkdtemp, rm} from 'node:fs/promises'; +import {fileURLToPath} from 'node:url'; +import {tmpdir} from 'node:os'; +import path from 'node:path'; +import {createHash} from 'node:crypto'; +import {generateKeyPairSync, sign} from 'node:crypto'; +import {compile, check, run, isControlSignal, evalWhen} from '../../src/index.mjs'; +import {compileInteractive} from '../../src/interactive/compile.mjs'; +import {createSqliteCheckpointStore} from '../../src/interactive/sqlite-store.mjs'; +import {createTrustedInteractiveHost} from '../../src/interactive/trusted-host.mjs'; +import {createAgentReceiptStatement, encodeAgentReceiptStatement} + from '../../src/interactive/host-attestation.mjs'; +import {canonicalBytes, sha256Bytes} from '../../src/interactive/canonical.mjs'; + +const repoRoot = fileURLToPath(new URL('../../', import.meta.url)); +const workflowFile = fileURLToPath(new URL( + '../../examples/g02/feature-review.workflow.js', import.meta.url)); +const compiledFile = fileURLToPath(new URL( + '../../examples/g02/feature-review.compiled.mjs', import.meta.url)); +const interactiveFile = fileURLToPath(new URL( + '../../examples/g02/feature-review.interactive.mjs', import.meta.url)); +const mermaidFile = fileURLToPath(new URL( + '../../examples/g02/feature-review.mmd', import.meta.url)); +const editorialFile = fileURLToPath(new URL( + '../../examples/editorial.workflow.js', import.meta.url)); +const skillsRoot = 'examples/g02/skills'; +const successFixtureFile = fileURLToPath(new URL( + '../fixtures/g02/turns-success-retry.json', import.meta.url)); +const rejectFixtureFile = fileURLToPath(new URL( + '../fixtures/g02/turns-review-false.json', import.meta.url)); +const REQUEST = '为 FlowKit 维护者增加一条只读的工作流运行摘要查看能力:在不修改现有 Standard Root 六个 API、不引入独立 FlowKit Server 的前提下,允许受信 Host 读取一次运行的终态状态(succeeded/failed/errored)、terminal 节点和公开投影,便于排障。不在本阶段实现 adminTerminate,也不改变 Interactive P0-A 的节点范围。'; +const NOW = 1794500000000; +const NAMESPACE = 'g02-feature-review'; +const POLICY = { + deploymentId: 'g02-feature-review-20261112', + bindingVersions: {skill: 'g02-v1', validator: 'g02-v1'}, +}; +const compileOptions = {skillsRoot, projectRoot: repoRoot, mermaid: true}; + +function sha256(buf) { + return createHash('sha256').update(buf).digest('hex'); +} + +export function validateG02Result(schema, output) { + if (!output || typeof output !== 'object' || Array.isArray(output)) { + return ['Agent result must be object']; + } + const props = schema.properties ?? {}; + const required = schema.required ?? Object.keys(props); + for (const key of required) { + if (!Object.hasOwn(output, key)) return ['missing ' + key]; + } + if (schema.additionalProperties === false) { + for (const key of Object.keys(output)) { + if (!Object.hasOwn(props, key)) return ['unknown ' + key]; + } + } + for (const [key, spec] of Object.entries(props)) { + if (!Object.hasOwn(output, key)) continue; + if (typeof output[key] !== spec.type) return [key + ' must be ' + spec.type]; + if (spec.type === 'string' && output[key].length < 80) { + return [key + ' must be at least 80 characters']; + } + } + return []; +} + +function assertNoControlChoice(result) { + const text = JSON.stringify(result); + assert.equal(Object.hasOwn(result, 'next'), false); + assert.equal(Object.hasOwn(result, 'nextNode'), false); + assert.equal(Object.hasOwn(result, 'toBlockId'), false); + assert.equal(text.includes('"accepted"') && Object.hasOwn(result, 'next'), false); + assert.match(text, /./); +} + +async function loadSkills() { + const names = ['analyze', 'design', 'review']; + const skills = {}; + for (const name of names) { + skills[name] = await readFile(new URL( + '../../examples/g02/skills/' + name + '.md', import.meta.url), 'utf8'); + } + return skills; +} + +test('G02 Standard compile/check matches checked-in JS artifact and Program is real JavaScript', async () => { + const compiled = await compile(workflowFile, compileOptions); + assert.equal(compiled.diagnostics.some(d => d.severity === 'error'), false, + JSON.stringify(compiled.diagnostics)); + assert.match(compiled.code, /export async function workflow\(ctx\)/); + assert.match(compiled.code, /evalWhen\(ctx, "materialsReady"\)/); + assert.match(compiled.code, /evalWhen\(ctx, "reviewApproved"\)/); + assert.match(compiled.code, /runStep\(/); + assert.equal(compiled.code.includes('Mermaid'), false); + const existing = await readFile(compiledFile); + assert.equal(sha256(existing), sha256(Buffer.from(compiled.code, 'utf8'))); + const mermaid = await readFile(mermaidFile, 'utf8'); + assert.ok(mermaid.includes('flowchart')); + const diagnostics = await check(workflowFile, compileOptions); + assert.equal(diagnostics.some(d => d.severity === 'error'), false, + JSON.stringify(diagnostics)); +}); + +test('G02 Interactive compile matches checked-in Program bytes and identity hash', async () => { + const compiled = await compileInteractive(workflowFile, { + output: 'memory', featureStage: 'P0-A', skillsRoot, skillCheck: true, + projectRoot: repoRoot, + }); + assert.equal(compiled.diagnostics.length, 0, JSON.stringify(compiled.diagnostics)); + assert.match(compiled.code, /export const interactiveProgram/); + assert.match(compiled.code, /export function advance\(/); + assert.equal(compiled.programIdentity.workflowId, 'g02-feature-review'); + assert.equal(compiled.programIdentity.implementedStage, 'P0-A'); + assert.equal(compiled.programIdentity.artifactHash, + 'sha256:' + sha256(Buffer.from(compiled.code, 'utf8'))); + const existing = await readFile(interactiveFile); + assert.equal(sha256(existing), sha256(Buffer.from(compiled.code, 'utf8'))); + assert.equal(compiled.programIdentity.artifactHash, + 'sha256:8d811766fd1d58456ce4e58cc2a2f04c4d1e36dd683f86496c2df946cb15eded'); +}); + +test('G02 Standard run covers success, gate-false, review-false and scripted retry; Program chooses branches', async () => { + const success = JSON.parse(await readFile(successFixtureFile, 'utf8')); + const rejected = JSON.parse(await readFile(rejectFixtureFile, 'utf8')); + const payload = { + analyze: success.turns[0].result, + designSuccess: success.turns[1].result, + reviewInvalid: success.turns[2].result, + reviewApproved: success.turns[3].result, + designRejected: rejected.turns[1].result, + reviewRejected: rejected.turns[2].result, + }; + assert.equal(success.turns[2].source, 'scripted-invalid'); + assert.equal(success.turns[0].source, 'grok-model'); + assert.equal(success.turns[3].source, 'grok-model'); + assert.equal(rejected.turns[2].source, 'grok-model'); + assert.equal(rejected.turns[2].result.reviewApproved, false); + + function runtimeFor(plan) { + const calls = []; + return { + calls, + isControlSignal, + evalWhen, + validateResult: validateG02Result, + async runAgent(step) { + calls.push({id: step.id, attempt: step.attempt, missing: step.missing}); + assert.equal(Object.hasOwn(step, 'state'), false); + const result = plan({id: step.id, attempt: step.attempt}); + assertNoControlChoice(result); + return result; + }, + }; + } + + const successRuntime = runtimeFor(({id, attempt}) => { + if (id === 'analyze') return payload.analyze; + if (id === 'design') return payload.designSuccess; + if (id === 'review' && attempt === 1) return payload.reviewInvalid; + if (id === 'review' && attempt === 2) return payload.reviewApproved; + throw new Error('unexpected Standard agent call ' + id + ' #' + attempt); + }); + const successResult = await run(workflowFile, successRuntime, { + materialsReady: true, request: REQUEST, hostSecret: 'CANARY', + }, {skillsRoot, projectRoot: repoRoot}); + assert.equal(successResult.status, 'succeeded'); + assert.equal(successResult.terminal, 'accepted'); + assert.equal(successResult.state.reviewApproved, true); + assert.equal(successResult.state.hostSecret, 'CANARY'); + assert.deepEqual(successRuntime.calls.map(c => [c.id, c.attempt]), + [['analyze', 1], ['design', 1], ['review', 1], ['review', 2]]); + + const gateRuntime = runtimeFor(() => { + throw new Error('gate-false must not call runAgent'); + }); + const gateResult = await run(workflowFile, gateRuntime, { + materialsReady: false, request: REQUEST, hostSecret: 'CANARY', + }, {skillsRoot, projectRoot: repoRoot}); + assert.equal(gateResult.status, 'failed'); + assert.equal(gateResult.terminal, 'rejected'); + assert.equal(gateRuntime.calls.length, 0); + assert.equal(gateResult.state.reviewApproved, undefined); + + const rejectRuntime = runtimeFor(({id, attempt}) => { + if (id === 'analyze') return payload.analyze; + if (id === 'design') return payload.designRejected; + if (id === 'review' && attempt === 1) return payload.reviewRejected; + throw new Error('unexpected reject-path call ' + id + ' #' + attempt); + }); + const rejectResult = await run(workflowFile, rejectRuntime, { + materialsReady: true, request: REQUEST, hostSecret: 'CANARY', + }, {skillsRoot, projectRoot: repoRoot}); + assert.equal(rejectResult.status, 'failed'); + assert.equal(rejectResult.terminal, 'rejected'); + assert.equal(rejectResult.state.reviewApproved, false); + assert.deepEqual(rejectRuntime.calls.map(c => [c.id, c.attempt]), + [['analyze', 1], ['design', 1], ['review', 1]]); +}); + +async function runInteractiveScenario(fixture, {materialsReady = true} = {}) { + const compiled = await compileInteractive(workflowFile, { + output: 'memory', featureStage: 'P0-A', skillsRoot, skillCheck: true, + projectRoot: repoRoot, + }); + assert.equal(compiled.diagnostics.length, 0); + const skills = await loadSkills(); + const dir = await mkdtemp(path.join(tmpdir(), 'g02-loop-')); + const file = path.join(dir, 'run.sqlite'); + const keys = generateKeyPairSync('ed25519'); + const route = []; + let store; + try { + const hostFor = (s) => createTrustedInteractiveHost({ + store: s, + runtime: { + skillResolver: async ({agent}) => { + assert.equal(typeof skills[agent], 'string'); + return skills[agent]; + }, + validateResult: validateG02Result, + }, + policy: POLICY, + trustedPublicKeys: {'g02-host': keys.publicKey}, + trustedArtifacts: [compiled.programIdentity.artifactHash], + allowedInputKeys: ['request', 'requirementSummary', 'designSummary'], + disclosurePolicyId: 'g02-public-inputs', + authorizeDisclosure: async ({task}) => + ['analyze', 'design', 'review'].includes(task?.nodeId) + && task?.runId === fixture.runId, + now: () => NOW, + }); + store = createSqliteCheckpointStore(file, {namespace: NAMESPACE}); + let host = hostFor(store); + let frame = await host.start({ + programBytes: Buffer.from(compiled.code, 'utf8'), + programIdentity: compiled.programIdentity, + runId: fixture.runId, + startKey: fixture.startKey, + state: {materialsReady, request: REQUEST, hostSecret: 'CANARY'}, + }); + const applied = []; + for (let index = 0; index < fixture.turns.length; index++) { + assert.equal(frame.status, 'waiting'); + const pending = await host.getPublicAgentTask({runId: fixture.runId}); + assert.ok(pending); + assert.equal(JSON.stringify(pending).includes('CANARY'), false); + assert.equal(Object.hasOwn(pending, 'hostSecret'), false); + assert.equal(Object.hasOwn(pending, 'state'), false); + const taskDigest = sha256Bytes(canonicalBytes(pending)); + const recorded = fixture.turns[index]; + assert.equal(recorded.nodeId, pending.nodeId); + assert.equal(recorded.attempt, pending.attempt); + assert.equal(recorded.requestId, pending.requestId); + assert.equal(recorded.taskDigest, taskDigest); + if (recorded.source === 'grok-model') { + assert.equal(validateG02Result(pending.resultSchema, recorded.result).length, 0); + assertNoControlChoice(recorded.result); + } else { + assert.equal(recorded.source, 'scripted-invalid'); + assert.ok(validateG02Result(pending.resultSchema, recorded.result).length > 0); + } + const receipt = { + runId: fixture.runId, + requestId: pending.requestId, + nodeId: pending.nodeId, + attempt: pending.attempt, + kind: 'agent-result', + result: recorded.result, + }; + const statement = createAgentReceiptStatement({ + run: store.load(fixture.runId), receipt, keyId: 'g02-host', + proofId: 'g02-' + fixture.scenario + '-turn-' + index, + issuedAt: NOW - 1000, expiresAt: NOW + 30000, + }); + const attestation = { + statement, + signature: sign(null, encodeAgentReceiptStatement(statement), keys.privateKey) + .toString('base64url'), + }; + store.close(); + store = createSqliteCheckpointStore(file, {namespace: NAMESPACE}); + host = hostFor(store); + const recovered = await host.load({runId: fixture.runId}); + assert.equal(recovered.status, 'waiting'); + assert.equal((await host.getPublicAgentTask({runId: fixture.runId})).requestId, + pending.requestId); + frame = await host.accept({receipt, attestation}); + route.push({ + nodeId: pending.nodeId, + attempt: pending.attempt, + source: recorded.source, + statusAfter: frame.status, + next: frame.pending?.nodeId ?? frame.terminal?.nodeId, + }); + applied.push(frame); + } + return {frame, route, store, compiled}; + } finally { + store?.close(); + await rm(dir, {recursive: true, force: true}); + } +} + +test('G02 Interactive success path binds Grok results, retries scripted invalid review, Program routes to accepted', async () => { + const fixture = JSON.parse(await readFile(successFixtureFile, 'utf8')); + const {frame, route} = await runInteractiveScenario(fixture); + assert.equal(frame.status, 'succeeded'); + assert.equal(frame.terminal.nodeId, 'accepted'); + assert.equal(frame.terminal.status, 'succeeded'); + assert.equal(frame.state.hostSecret, 'CANARY'); + assert.equal(frame.state.reviewApproved, true); + assert.deepEqual(route.map(r => [r.nodeId, r.attempt, r.source, r.next]), [ + ['analyze', 1, 'grok-model', 'design'], + ['design', 1, 'grok-model', 'review'], + ['review', 1, 'scripted-invalid', 'review'], + ['review', 2, 'grok-model', 'accepted'], + ]); + assert.equal(route.length, 4); + assert.equal(frame.pending ?? null, null); + assert.equal(Object.hasOwn(fixture.turns[3].result, 'next'), false); +}); + +test('G02 Interactive reviewApproved=false is routed by the Program to rejected', async () => { + const fixture = JSON.parse(await readFile(rejectFixtureFile, 'utf8')); + const {frame, route} = await runInteractiveScenario(fixture); + assert.equal(frame.status, 'failed'); + assert.equal(frame.terminal.nodeId, 'rejected'); + assert.equal(frame.state.reviewApproved, false); + assert.equal(fixture.turns.at(-1).result.reviewApproved, false); + assert.equal(Object.hasOwn(fixture.turns.at(-1).result, 'next'), false); + assert.equal(route.at(-1).next, 'rejected'); + assert.equal(route.length, 3); + assert.equal(frame.pending ?? null, null); +}); + +test('G02 Interactive materialsReady=false never publishes a Pending; Program goes to rejected', async () => { + const compiled = await compileInteractive(workflowFile, { + output: 'memory', featureStage: 'P0-A', skillsRoot, skillCheck: true, + projectRoot: repoRoot, + }); + const dir = await mkdtemp(path.join(tmpdir(), 'g02-gate-')); + const file = path.join(dir, 'run.sqlite'); + const keys = generateKeyPairSync('ed25519'); + const skills = await loadSkills(); + let store; + try { + store = createSqliteCheckpointStore(file, {namespace: NAMESPACE}); + const host = createTrustedInteractiveHost({ + store, + runtime: { + skillResolver: async ({agent}) => skills[agent], + validateResult: validateG02Result, + }, + policy: POLICY, + trustedPublicKeys: {'g02-host': keys.publicKey}, + trustedArtifacts: [compiled.programIdentity.artifactHash], + allowedInputKeys: ['request', 'requirementSummary', 'designSummary'], + disclosurePolicyId: 'g02-public-inputs', + authorizeDisclosure: async () => false, + now: () => NOW, + }); + const frame = await host.start({ + programBytes: Buffer.from(compiled.code, 'utf8'), + programIdentity: compiled.programIdentity, + runId: 'g02-gate-false-20261112', + startKey: 'g02-gate-false-start', + state: {materialsReady: false, request: REQUEST, hostSecret: 'CANARY'}, + }); + assert.equal(frame.status, 'failed'); + assert.equal(frame.terminal.nodeId, 'rejected'); + assert.equal(frame.pending ?? null, null); + assert.equal(await host.getPublicAgentTask({runId: 'g02-gate-false-20261112'}), null); + assert.equal(frame.state.hostSecret, 'CANARY'); + assert.equal(Object.hasOwn(frame.state, 'requirementSummary'), false); + } finally { + store?.close(); + await rm(dir, {recursive: true, force: true}); + } +}); + + +test('G02 fixtures record six grok-model uses, five distinct bodies, and one rebound reuse', async () => { + const success = JSON.parse(await readFile(successFixtureFile, 'utf8')); + const rejected = JSON.parse(await readFile(rejectFixtureFile, 'utf8')); + const all = [...success.turns, ...rejected.turns]; + assert.equal(all.length, 7); + const grok = all.filter((turn) => turn.source === 'grok-model'); + const scripted = all.filter((turn) => turn.source === 'scripted-invalid'); + assert.equal(grok.length, 6); + assert.equal(scripted.length, 1); + const bodyKey = (result) => JSON.stringify(result); + const distinct = new Set(grok.map((turn) => bodyKey(turn.result))); + assert.equal(distinct.size, 5); + const analyzeSuccess = success.turns[0]; + const analyzeReuse = rejected.turns[0]; + assert.equal(analyzeSuccess.nodeId, 'analyze'); + assert.equal(analyzeReuse.nodeId, 'analyze'); + assert.deepEqual(analyzeReuse.result, analyzeSuccess.result); + assert.notEqual(analyzeReuse.requestId, analyzeSuccess.requestId); + assert.notEqual(analyzeReuse.taskDigest, analyzeSuccess.taskDigest); + assert.equal(analyzeReuse.originalAuthoredAt, analyzeSuccess.authoredAt); + assert.equal(analyzeReuse.reusedAt, analyzeReuse.authoredAt); + assert.equal(analyzeReuse.reusedFrom.requestId, analyzeSuccess.requestId); + assert.equal(success.modelResponseUses, 6); + assert.equal(success.distinctAuthoredBodies, 5); + assert.equal(success.reusedBodies, 1); + assert.equal(rejected.modelResponseUses, 6); + assert.equal(rejected.distinctAuthoredBodies, 5); + assert.equal(rejected.reusedBodies, 1); +}); + +test('G02 workflow is a P0-A subset and differs from the editorial Standard golden in node kinds', async () => { + const g02 = await readFile(workflowFile, 'utf8'); + const editorial = await readFile(editorialFile, 'utf8'); + for (const banned of ["action(", "parallel(", "loop(", "human(", "verification("]) { + assert.equal(g02.includes(banned), false, 'G02 DSL unexpectedly contains ' + banned); + } + for (const required of ["action(", "parallel(", "loop(", "human(", "verification(", "condition(", "node(", "terminal("]) { + assert.equal(editorial.includes(required), true, 'editorial golden missing ' + required); + } +});