@orchestraworks/cli 0.5.8 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/README.md +1 -1
  2. package/dist/cli/api/approval.js +3 -3
  3. package/dist/cli/commands/outpost/local.js +3 -3
  4. package/dist/cli/commands/outpost/machine-names.d.ts +17 -0
  5. package/dist/cli/commands/outpost/machine-names.js +22 -0
  6. package/dist/cli/commands/outpost/up.js +7 -5
  7. package/dist/cli/commands/worker/enroll.d.ts +4 -3
  8. package/dist/cli/commands/worker/enroll.js +4 -3
  9. package/dist/cli/core/auth.d.ts +11 -1
  10. package/dist/cli/core/auth.js +21 -4
  11. package/dist/cli/core/session-body.d.ts +1 -1
  12. package/dist/cli/core/session-body.js +1 -1
  13. package/dist/cli/firstRun.js +2 -2
  14. package/dist/cli/main.js +4 -3
  15. package/dist/constructs/environment.d.ts +24 -11
  16. package/dist/constructs/environment.js +76 -12
  17. package/dist/constructs/secret.d.ts +17 -7
  18. package/dist/constructs/secret.js +24 -16
  19. package/dist/core/labels.d.ts +32 -6
  20. package/dist/core/labels.js +70 -8
  21. package/dist/engine/diff.js +38 -11
  22. package/dist/engine/gateway-key-reconcile.d.ts +1 -2
  23. package/dist/engine/gateway-key-reconcile.js +19 -15
  24. package/dist/engine/pipeline-reconcile.js +2 -2
  25. package/dist/engine/pipeline-workflow-reconcile.js +15 -9
  26. package/dist/engine/reconcile.js +48 -15
  27. package/dist/engine/refusals.d.ts +0 -5
  28. package/dist/engine/refusals.js +1 -5
  29. package/dist/engine/secrets-apply.d.ts +10 -14
  30. package/dist/engine/secrets-apply.js +12 -17
  31. package/dist/engine/workflow-reconcile.d.ts +1 -1
  32. package/dist/generated/cli-commands.json +170 -34
  33. package/dist/generated/providers.d.ts +40 -0
  34. package/dist/generated/providers.js +30 -0
  35. package/docs/REFERENCE.md +9 -7
  36. package/node_modules/@orchestraworks/client/dist/esm/models/ApiErrorDetail.d.ts +1 -1
  37. package/node_modules/@orchestraworks/client/dist/esm/models/CreateEnvironmentRequest.d.ts +7 -0
  38. package/node_modules/@orchestraworks/client/dist/esm/models/CreateEnvironmentRequest.js +3 -0
  39. package/node_modules/@orchestraworks/client/dist/esm/models/CreateSecretRequest.d.ts +4 -3
  40. package/node_modules/@orchestraworks/client/dist/esm/models/Environment.d.ts +7 -0
  41. package/node_modules/@orchestraworks/client/dist/esm/models/Environment.js +3 -0
  42. package/node_modules/@orchestraworks/client/dist/esm/models/EnvironmentCredential.d.ts +38 -0
  43. package/node_modules/@orchestraworks/client/dist/esm/models/EnvironmentCredential.js +45 -0
  44. package/node_modules/@orchestraworks/client/dist/esm/models/EnvironmentProvisionSpec.d.ts +1 -1
  45. package/node_modules/@orchestraworks/client/dist/esm/models/EnvironmentProvisionSpecRequest.d.ts +1 -1
  46. package/node_modules/@orchestraworks/client/dist/esm/models/EnvironmentSnapshot.d.ts +1 -1
  47. package/node_modules/@orchestraworks/client/dist/esm/models/InjectionRecipe.d.ts +6 -0
  48. package/node_modules/@orchestraworks/client/dist/esm/models/InjectionRecipe.js +2 -0
  49. package/node_modules/@orchestraworks/client/dist/esm/models/OAuthTokenRequest.d.ts +1 -1
  50. package/node_modules/@orchestraworks/client/dist/esm/models/Secret.d.ts +1 -7
  51. package/node_modules/@orchestraworks/client/dist/esm/models/Secret.js +0 -4
  52. package/node_modules/@orchestraworks/client/dist/esm/models/UpdateEnvironmentRequest.d.ts +7 -0
  53. package/node_modules/@orchestraworks/client/dist/esm/models/UpdateEnvironmentRequest.js +3 -0
  54. package/node_modules/@orchestraworks/client/dist/esm/models/UpdateSecretRequest.d.ts +4 -3
  55. package/node_modules/@orchestraworks/client/dist/esm/models/index.d.ts +1 -0
  56. package/node_modules/@orchestraworks/client/dist/esm/models/index.js +1 -0
  57. package/node_modules/@orchestraworks/client/dist/models/ApiErrorDetail.d.ts +1 -1
  58. package/node_modules/@orchestraworks/client/dist/models/CreateEnvironmentRequest.d.ts +7 -0
  59. package/node_modules/@orchestraworks/client/dist/models/CreateEnvironmentRequest.js +3 -0
  60. package/node_modules/@orchestraworks/client/dist/models/CreateSecretRequest.d.ts +4 -3
  61. package/node_modules/@orchestraworks/client/dist/models/Environment.d.ts +7 -0
  62. package/node_modules/@orchestraworks/client/dist/models/Environment.js +3 -0
  63. package/node_modules/@orchestraworks/client/dist/models/EnvironmentCredential.d.ts +38 -0
  64. package/node_modules/@orchestraworks/client/dist/models/EnvironmentCredential.js +52 -0
  65. package/node_modules/@orchestraworks/client/dist/models/EnvironmentProvisionSpec.d.ts +1 -1
  66. package/node_modules/@orchestraworks/client/dist/models/EnvironmentProvisionSpecRequest.d.ts +1 -1
  67. package/node_modules/@orchestraworks/client/dist/models/EnvironmentSnapshot.d.ts +1 -1
  68. package/node_modules/@orchestraworks/client/dist/models/InjectionRecipe.d.ts +6 -0
  69. package/node_modules/@orchestraworks/client/dist/models/InjectionRecipe.js +2 -0
  70. package/node_modules/@orchestraworks/client/dist/models/OAuthTokenRequest.d.ts +1 -1
  71. package/node_modules/@orchestraworks/client/dist/models/Secret.d.ts +1 -7
  72. package/node_modules/@orchestraworks/client/dist/models/Secret.js +0 -4
  73. package/node_modules/@orchestraworks/client/dist/models/UpdateEnvironmentRequest.d.ts +7 -0
  74. package/node_modules/@orchestraworks/client/dist/models/UpdateEnvironmentRequest.js +3 -0
  75. package/node_modules/@orchestraworks/client/dist/models/UpdateSecretRequest.d.ts +4 -3
  76. package/node_modules/@orchestraworks/client/dist/models/index.d.ts +1 -0
  77. package/node_modules/@orchestraworks/client/dist/models/index.js +1 -0
  78. package/node_modules/@orchestraworks/client/package.json +1 -1
  79. package/package.json +4 -4
  80. package/skills/orchestra/SKILL.md +132 -18
  81. package/skills/orchestra-fdk/references/authoring.md +511 -20
  82. package/skills/orchestra-fdk/references/environment.md +49 -12
  83. package/skills/orchestra-fdk/references/integrations/code-repo.md +26 -5
  84. package/skills/orchestra-fdk/references/phases/1-discover.md +1 -1
  85. package/skills/orchestra-fdk/references/phases/2-plan.md +1 -1
  86. package/skills/orchestra-fdk/references/troubleshooting.md +3 -2
  87. package/templates/bug-fix/main.ts +7 -4
  88. package/templates/engineering-team/lib/environment.ts +6 -3
  89. package/templates/engineering-team/package.json +1 -1
  90. package/templates/todo-factory/main.ts +7 -4
  91. package/templates/todo-factory/package.json +1 -1
@@ -68,8 +68,10 @@ const factory = new Factory({ name: "acme-support" });
68
68
  new Secret(factory, "openai-api-key", {
69
69
  name: "OpenAI API key",
70
70
  value: SecretValue.fromEnv("OPENAI_API_KEY_VALUE"), // read from env at apply — never in the plan
71
- hosts: ["api.openai.com"], // where the edge presents it
72
- injection: { headers: [{ name: "Authorization", value: "Bearer ${secret}" }] }, // how
71
+ injection: {
72
+ headers: [{ name: "Authorization", value: "Bearer ${secret}" }], // how the edge presents it
73
+ hosts: ["api.openai.com"], // where
74
+ },
73
75
  });
74
76
 
75
77
  const runtime = new Environment(factory, "runtime", {
@@ -554,26 +556,29 @@ token the platform mints. The **Claude sign-in recipe** is the one to copy when
554
556
  on a person's Claude subscription token rather than an API key:
555
557
 
556
558
  ```ts
557
- new Secret(factory, "claude-sign-in", {
559
+ const claudeSignIn = new Secret(factory, "claude-sign-in", {
558
560
  value: SecretValue.fromEnv("ORCHESTRA_FDK_CLAUDE_CODE_OAUTH_TOKEN"),
559
- hosts: ["api.anthropic.com"],
560
561
  injection: {
561
562
  headers: [{ name: "authorization", value: "Bearer ${secret}" }],
562
563
  removeHeaders: ["x-api-key"], // stripped before injection
564
+ hosts: ["api.anthropic.com"],
563
565
  },
564
566
  });
565
567
 
566
568
  // …in the Environment's props:
567
- env: { CLAUDE_CODE_OAUTH_TOKEN: "software-factory-egress-placeholder", ANTHROPIC_API_KEY: "" },
569
+ env: { CLAUDE_CODE_OAUTH_TOKEN: claudeSignIn, ANTHROPIC_API_KEY: "" },
568
570
  egress: EgressPolicy.allowAll()
569
571
  .allow("claude-sign-in", { hosts: ["api.anthropic.com"], priority: 300 }),
570
572
  ```
571
573
 
572
574
  It injects `authorization` and nothing else. **Never inject an `anthropic-beta` header**: an injected
573
- one replaces the harness's own list of beta flags, and every call then answers 400. The placeholder
574
- `CLAUDE_CODE_OAUTH_TOKEN` makes the harness sign in the subscription way and send its own list, and the
575
- edge swaps the real token in. The empty `ANTHROPIC_API_KEY` covers the runtime image's placeholder key,
576
- which the harness would otherwise send instead.
575
+ one replaces the harness's own list of beta flags, and every call then answers 400. A Secret as an `env`
576
+ value is written as its reference, `orchestra:secret/<id>`, with the id resolved at apply. In
577
+ `CLAUDE_CODE_OAUTH_TOKEN` it makes the harness sign in the subscription way and send its own list, and the
578
+ edge reads the reference where the token goes and swaps the real token in. The edge adds a credential only
579
+ to a request that names one, or that an environment default covers (`references/environment.md`, "Naming a
580
+ credential"). The empty `ANTHROPIC_API_KEY` covers the runtime image's placeholder key, which the harness
581
+ would otherwise send instead.
577
582
 
578
583
  - Sources: `SecretValue.fromEnv(NAME)`, `SecretValue.fromCommandLine(label?)` and
579
584
  `SecretValue.fromGatewayKey(key)`, plus the derived recipes `basicAuth`, `registryCredential` and
@@ -613,7 +618,7 @@ which the harness would otherwise send instead.
613
618
  `GatewayKey` you declare in the same factory — `new GatewayKey(factory, "model-gateway-key", props)`,
614
619
  whose only props are `slug`, `displayName`, `monthlyCreditCap` and `labels` — so apply mints the
615
620
  key and writes the token it shows *exactly once* straight into this Secret, which the edge then
616
- adds on its `hosts` like any other. It is legal only as the whole top-level value of exactly one Secret:
621
+ adds on its recipe's `hosts` like any other. It is legal only as the whole top-level value of exactly one Secret:
617
622
  nesting it inside another recipe, or a key with zero or two Secrets, is a synth error, and there
618
623
  is no importer because an existing key's token is unrecoverable. Rotation is new-slug-only.
619
624
  - A **`ModelProviderKey`** is the other credential you can declare: your organization's own key for
@@ -648,11 +653,12 @@ which the harness would otherwise send instead.
648
653
  suppresses it and none can supply it either, so a headless apply of a `fromCommandLine`
649
654
  declaration refuses at *every* level and tells you to run from a terminal or change the source —
650
655
  it never recommends a policy lever that could not clear it.
651
- - Presenting a secret on the network: the `Secret` itself declares `hosts` (where: an exact host,
652
- `*.suffix`, or `*`) and an `injection` recipe (how: the headers it sets, reading `${secret}`, or
653
- `${secret.<field>}` for a `SecretValue.fields` value). Egress rules carry no credential, so a rule
654
- that passes `credential` or `credentials` is a synth error; a Secret with no `hosts` is presented
655
- nowhere.
656
+ - Presenting a secret on the network: the `Secret` itself declares an `injection` recipe — how: the
657
+ headers it sets, reading `${secret}`, or `${secret.<field>}` for a `SecretValue.fields` value; and
658
+ where: its `hosts`, each an exact host, `*.suffix`, or `*`. Egress rules carry no credential, so a rule
659
+ that passes `credential` or `credentials` is a synth error. A Secret with no `injection` is stored
660
+ only, and one whose recipe has no `hosts` is presented nowhere. The top-level `hosts` prop is
661
+ deprecated: it is moved into the recipe for one release.
656
662
 
657
663
  ## How the agents reach a model
658
664
 
@@ -668,9 +674,9 @@ on their own computer; run from your shell, its browser step hangs. Never read o
668
674
  At apply, the shell exports it from that file into the variable the Secret reads. Declare the Claude
669
675
  sign-in recipe (§ Secrets are recipes, never values): a `Secret`, an egress rule that allows
670
676
  `api.anthropic.com` and sets `Authorization: Bearer ${secret}` with `x-api-key` removed, and, in the
671
- environment's `env`, `CLAUDE_CODE_OAUTH_TOKEN: "software-factory-egress-placeholder"` and an empty
672
- `ANTHROPIC_API_KEY`. Turn `enableModelGateway` off for the agent. The machine holds only the
673
- placeholder; the edge adds the token. Copying the renewing credentials file instead is the fallback,
677
+ environment's `env`, `CLAUDE_CODE_OAUTH_TOKEN` set to that Secret and an empty `ANTHROPIC_API_KEY`.
678
+ Turn `enableModelGateway` off for the agent. The machine holds only the Secret's reference; the edge
679
+ adds the token. Copying the renewing credentials file instead is the fallback,
674
680
  and follows the ChatGPT sign-in steps below.
675
681
  - **Codex: a personal access token, where the workspace allows one.** A ChatGPT Business or Enterprise
676
682
  member creates it at chatgpt.com/admin/access-tokens, once the workspace owner allows access tokens,
@@ -762,7 +768,7 @@ An environment's `egress` is a policy you build up from one of **two starting po
762
768
  - **`EgressPolicy.allowAll()`** allows every host no rule refuses.
763
769
 
764
770
  Rules fold onto either one the same way: a rule only opens or closes the network, a credential still goes
765
- only to the hosts its own `hosts` cover, and a deny still refuses.
771
+ only to the hosts its own recipe's `hosts` cover, and a deny still refuses.
766
772
 
767
773
  Most of what an agent needs to reach arrives ready-made as a **bundle** — a list of rules folded onto
768
774
  the policy by one `applyPreset(...)` call — so **reach for the bundles first** and hand-write allow/deny
@@ -955,6 +961,491 @@ new Trigger(factory, "bug-intake", {
955
961
  each to what you declared. A revert made in the app shows in `orchestra fdk diff` as the rules apply
956
962
  would write back.
957
963
 
964
+ ## Check gates
965
+
966
+ A *gate* is a stage's `requires` list. It keeps an item out of the stage until its checks say so. A
967
+ *check* is a named key on an item. Each writer has one vote on it: `PASS`, `FAIL` or `SKIP`, with an
968
+ optional summary and evidence links. The orchestra skill's section 10, "Checks and gates", explains the
969
+ model. This section is the setup.
970
+
971
+ **Set up a gate in four steps.**
972
+
973
+ 1. **Pick a key**, such as `ci`, `review` or `approval`. Nothing declares it ahead.
974
+ 2. **Write the gate.** Add conditions over the key to the stage's `requires`. A condition is
975
+ `{ field, op, value }`, and a count takes a number value: `{ field: "checks.approval.passUsers", op:
976
+ "gte", value: 2 }`. A stage reads `checks.<key>.result`, the counts `.pass`, `.fail`, `.passUsers` and
977
+ `.passAgents`, and `.writer.isAssignee`, which describes the latest vote only.
978
+ 3. **Grant the key to each writer.** The action is `work-items:setWorkItemCheck` on the resource
979
+ `work-items/*/checks/<key>`, one key per grant. Only owner and admin hold it by default. For an agent,
980
+ `pipeline.allowCheck(agent, "<key>")` declares the grant, and apply prints the statement for an
981
+ administrator to attach. Apply writes no IAM.
982
+ 4. **Make something vote.** A judge agent that a rule's `evaluate` asks, a CI job or a person runs
983
+ `orchestra work-items set-check <item> <key> --result PASS|FAIL|SKIP --summary "…"`. `<item>` is the
984
+ item's id or its slug, such as `feat-12`. Evidence links go in a JSON file passed with
985
+ `--body @vote.json`. A rule can also vote with `setCheck`, or clear every vote with `clearCheck`; the
986
+ person who applies that rule must hold the key.
987
+
988
+ Before you rely on a gate, read the orchestra skill's section 11, "What a judge must know", and the
989
+ reopen rule in its section 10. The examples follow those rules: a rule runs `evaluate` on entering the
990
+ stage before the gated one, the judge votes with `orchestra work-items set-check`, and an `inStageFor`
991
+ rule asks again while the check is absent.
992
+
993
+ The examples build on the `factory` app and the import line of § The construct model. Example 3 runs on its
994
+ `runtime` environment, and Examples 1 and 5 declare their own. Each example imports only the names that
995
+ section and the earlier examples do not import, so all of them paste into one `main.ts`.
996
+
997
+ ### Example 1: a model judge on an OpenAI model
998
+
999
+ A `codex` agent judges the `review` key through the model gateway. Its environment, `codexRuntime`, differs
1000
+ from `runtime` in two places:
1001
+
1002
+ - **`env` sets `INITIAL_AGENT_MODE: "agent-full-access"`.** Codex opens each session with a command
1003
+ sandbox that has no network. Without this mode, the judge cannot reach the platform to vote. The
1004
+ egress policy still decides which hosts the judge may reach.
1005
+ - **The egress policy carries the gateway rule**, `routeModelsViaGateway`, with the gateway key's secret.
1006
+ An agent whose model settings turn on the model gateway needs it.
1007
+
1008
+ ```ts
1009
+ import {
1010
+ GatewayKey, Pipeline, Workflow, check, clearCheck, entered, evaluate, inStageFor, move, rule,
1011
+ } from "@orchestraworks/cli";
1012
+
1013
+ const gatewayKey = new Secret(factory, "model-gateway-key", {
1014
+ value: SecretValue.fromGatewayKey(new GatewayKey(factory, "model-gateway")),
1015
+ injection: {
1016
+ headers: [{ name: "Authorization", value: "Bearer ${secret}" }],
1017
+ removeHeaders: ["x-api-key"],
1018
+ hosts: ["models.orchestraworks.ai"],
1019
+ },
1020
+ });
1021
+
1022
+ const codexRuntime = new Environment(factory, "codex-runtime", {
1023
+ name: "Codex judge environment",
1024
+ image: Image.fromTag("acme/support-agent:1.0.0"),
1025
+ platform: { os: "linux", arch: "amd64" },
1026
+ resources: { cpu: "2", memory: "4G" },
1027
+ env: { INITIAL_AGENT_MODE: "agent-full-access" },
1028
+ egress: EgressPolicy.denyAll()
1029
+ .applyPreset(egressPreset.softwareDevelopment())
1030
+ .routeModelsViaGateway({ key: gatewayKey }),
1031
+ });
1032
+
1033
+ const reviewer = new Agent(factory, "reviewer", {
1034
+ environment: codexRuntime,
1035
+ harness: "codex",
1036
+ modelSettings: { model: "gpt-6.1-sol", enableModelGateway: true },
1037
+ instructions: `You judge work items. You never change the code or the item.
1038
+
1039
+ When a turn asks you to set a check:
1040
+ 1. Read the item: orchestra work-items get <item id>
1041
+ 2. Decide against the criterion the turn gives.
1042
+ 3. Record the decision. Only this command counts; your reply is not read:
1043
+ orchestra work-items set-check <item id> <key> --result PASS --summary "<one line: why>"
1044
+ Use --result FAIL when the work does not meet the criterion. Use the item id and key the turn names.`,
1045
+ });
1046
+
1047
+ const features = new Pipeline(factory, "features", {
1048
+ itemSlugPrefix: "feat",
1049
+ stages: [
1050
+ { slug: "building", category: "IN_PROGRESS" },
1051
+ { slug: "review", category: "IN_PROGRESS" },
1052
+ { slug: "done", category: "DONE", requires: [{ field: "checks.review.result", op: "is", value: "PASS" }] },
1053
+ ],
1054
+ }).allowCheck(reviewer, "review");
1055
+
1056
+ const askReviewer = evaluate({
1057
+ agent: reviewer,
1058
+ check: "review",
1059
+ criterion: "The change does what the item asks, its tests pass, and it adds no unrelated change.",
1060
+ });
1061
+
1062
+ new Workflow(factory, "features-review", {
1063
+ pipeline: features,
1064
+ description: "Asks the reviewer agent to judge each feature in Review and moves the item on its vote.",
1065
+ rules: [
1066
+ rule({
1067
+ name: "Clear the old vote and ask the reviewer on entering Review",
1068
+ when: entered("review"),
1069
+ then: [clearCheck("review"), askReviewer],
1070
+ }),
1071
+ rule({
1072
+ name: "Ask the reviewer again after an hour with no vote",
1073
+ when: inStageFor("review", "1h"),
1074
+ if: { field: "checks.review.result", op: "isAbsent" },
1075
+ then: [askReviewer],
1076
+ }),
1077
+ rule({ name: "Move a passed review to Done", when: check("review").is("PASS"), then: [move("done")] }),
1078
+ rule({ name: "Send a failed review back to Building", when: check("review").is("FAIL"), then: [move("building")] }),
1079
+ ],
1080
+ });
1081
+ ```
1082
+
1083
+ The first rule clears the vote of an earlier stay, so the second rule sees an absent check. A rule that
1084
+ writes or clears a check needs its author to hold the key, so the person who applies this workflow holds
1085
+ `review`, as owner and admin do. An `inStageFor` rule fires once in each stay in the stage. For more
1086
+ tries, add a second rule with a longer duration.
1087
+
1088
+ **Attach the judge's grant before its first vote.** `allowCheck` only prints the policy statement, and a
1089
+ judge without it gets a 403. An administrator attaches it with the policy and membership commands of
1090
+ Example 2: write the policy for the `review` key, and take the membership id from the `membershipId` field
1091
+ that `orchestra agents get <agent id>` answers. The agent's membership already holds the policies the
1092
+ platform attached at its create, so keep them in `attach.json`.
1093
+
1094
+ ### Example 2: a CI result from GitHub Actions
1095
+
1096
+ The CI job runs the tests and votes on the `ci` key as a service account. The FDK neither creates a
1097
+ service account nor writes a policy, so the setup is four commands, run once by an administrator:
1098
+
1099
+ ```bash
1100
+ orchestra service-accounts create --name github-ci # note its id, svc_…, and its membershipId
1101
+ orchestra policies create --body @ci-check-policy.json # note its id, pol_…
1102
+ orchestra organization-memberships update <membership id> --body @attach.json
1103
+ orchestra api-keys create --principal-type SERVICE_ACCOUNT --principal-id <svc id> --name github-ci \
1104
+ --output-file github-ci-key.json
1105
+ ```
1106
+
1107
+ `ci-check-policy.json`:
1108
+
1109
+ ```json
1110
+ {
1111
+ "name": "Write the ci check",
1112
+ "document": {
1113
+ "statements": [
1114
+ { "effect": "allow", "actions": ["work-items:setWorkItemCheck"], "resources": ["work-items/*/checks/ci"] }
1115
+ ]
1116
+ }
1117
+ }
1118
+ ```
1119
+
1120
+ `attach.json` replaces the membership's whole list of directly attached policies. Read the list first with
1121
+ `orchestra organization-memberships get <membership id>` and keep each entry in it:
1122
+
1123
+ ```json
1124
+ { "policyAssignments": [{ "policyId": "<pol id>", "allTeams": true }] }
1125
+ ```
1126
+
1127
+ The platform shows a key's token once, in the answer to its create, and stores only its digest. The CLI
1128
+ masks the token on the screen, so the last command writes the whole answer to `github-ci-key.json`. Store
1129
+ its `token` field as the repository secret `CHECKS_CI_KEY`, then delete the file. The gate:
1130
+
1131
+ ```ts
1132
+ { slug: "merged", category: "DONE", requires: [{ field: "checks.ci.result", op: "is", value: "PASS" }] }
1133
+ ```
1134
+
1135
+ The job must know the item. This example uses a team convention: the branch name starts with the item's
1136
+ slug, such as `feat-12-export-csv`. Use whatever convention your team keeps, such as a line in the pull
1137
+ request body.
1138
+
1139
+ ```yaml
1140
+ name: tests
1141
+ on: pull_request
1142
+ jobs:
1143
+ test:
1144
+ runs-on: ubuntu-latest
1145
+ steps:
1146
+ - uses: actions/checkout@v4
1147
+ - id: test
1148
+ run: npm ci && npm test
1149
+ - name: Vote on the work item's ci check
1150
+ if: always()
1151
+ env:
1152
+ ORCHESTRA_BASE_URL: https://api.orchestraworks.ai
1153
+ ORCHESTRA_API_TOKEN: ${{ secrets.CHECKS_CI_KEY }}
1154
+ HEAD_REF: ${{ github.head_ref }}
1155
+ TEST_OUTCOME: ${{ steps.test.outcome }}
1156
+ run: |
1157
+ item=$(printf '%s' "$HEAD_REF" | grep -oE '^feat-[0-9]+' || true)
1158
+ if [ -z "$item" ]; then echo "The branch name names no work item"; exit 0; fi
1159
+ result=FAIL
1160
+ if [ "$TEST_OUTCOME" = success ]; then result=PASS; fi
1161
+ run_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
1162
+ printf '{"evidence":["%s"]}' "$run_url" > vote.json
1163
+ npx --yes @orchestraworks/cli work-items set-check "$item" ci \
1164
+ --result "$result" --summary "Tests: $TEST_OUTCOME" --body @vote.json
1165
+ ```
1166
+
1167
+ Each run replaces the account's earlier vote, so a fix pushed after a `FAIL` turns the check to `PASS`.
1168
+ `ORCHESTRA_BASE_URL` is required: the CLI never sends a token to an address it did not read from you.
1169
+
1170
+ A GitHub event cannot write this check without the CI change. The GitHub connection delivers only
1171
+ `issues`, `issue_comment` and `pull_request` events, and none of them carries a test result.
1172
+
1173
+ ### Example 3: a script judge
1174
+
1175
+ A `script` agent's `instructions` are a Bash program, so it judges a fixed rule with no model. Each turn's
1176
+ text reaches the program on standard input, and this program reads none of it. `evaluate` runs the judge in a
1177
+ session on that one item (the orchestra skill's section 11). So the program lists the items of its own
1178
+ session, `ORCHESTRA_SESSION_ID`, decides, and votes with `orchestra`. With no item or more than one, it stops
1179
+ with no vote. The machine image ships `orchestra` and `jq`. `orchestra` sends the agent's own token on each
1180
+ platform call, so the program holds no credential. The command also gets `PLATFORM_BASE_URL` and
1181
+ `ORCHESTRA_ORGANIZATION_ID`.
1182
+
1183
+ ```ts
1184
+ import { changed } from "@orchestraworks/cli";
1185
+
1186
+ const notesJudge = new Agent(factory, "notes-judge", {
1187
+ environment: runtime,
1188
+ harness: "script",
1189
+ instructions: `set -euo pipefail
1190
+ items=$(orchestra work-items list --session "$ORCHESTRA_SESSION_ID" --output json)
1191
+ if [ "$(printf '%s' "$items" | jq '.data | length')" != 1 ]; then
1192
+ echo "This session is not on exactly one work item." >&2
1193
+ exit 1
1194
+ fi
1195
+ id=$(printf '%s' "$items" | jq -r '.data[0].id')
1196
+ description=$(printf '%s' "$items" | jq -r '.data[0].description // ""')
1197
+ if printf '%s' "$description" | grep -q '^## Release notes'; then
1198
+ orchestra work-items set-check "$id" notes --result PASS --summary "The description has a Release notes section."
1199
+ else
1200
+ orchestra work-items set-check "$id" notes --result FAIL --summary "Add a '## Release notes' section to the description."
1201
+ fi`,
1202
+ });
1203
+
1204
+ const releases = new Pipeline(factory, "releases", {
1205
+ itemSlugPrefix: "rel",
1206
+ stages: [
1207
+ { slug: "drafting", category: "TODO" },
1208
+ { slug: "review", category: "IN_PROGRESS", requires: [{ field: "checks.notes.result", op: "is", value: "PASS" }] },
1209
+ { slug: "released", category: "DONE" },
1210
+ ],
1211
+ }).allowCheck(notesJudge, "notes");
1212
+
1213
+ const askNotesJudge = evaluate({
1214
+ agent: notesJudge,
1215
+ check: "notes",
1216
+ criterion: "The description has a Release notes section.",
1217
+ });
1218
+
1219
+ new Workflow(factory, "releases-notes", {
1220
+ pipeline: releases,
1221
+ description: "Asks the notes judge to check each release's description while it is drafted.",
1222
+ rules: [
1223
+ rule({ name: "Ask the notes judge on entering Drafting", when: entered("drafting"), then: [askNotesJudge] }),
1224
+ rule({
1225
+ name: "Ask the notes judge again after ten minutes with no vote",
1226
+ when: inStageFor("drafting", "10m"),
1227
+ if: { field: "checks.notes.result", op: "isAbsent" },
1228
+ then: [askNotesJudge],
1229
+ }),
1230
+ rule({ name: "Ask the notes judge again on a recheck label", when: changed("labels.recheck"), then: [askNotesJudge] }),
1231
+ ],
1232
+ });
1233
+ ```
1234
+
1235
+ - **The exit code sets the session outcome, not the check.** Exit 0 after any vote, `FAIL` included.
1236
+ Exit non-zero only when the program could not vote; that turn fails, and the `inStageFor` rule asks
1237
+ again.
1238
+ - **The judge does not see later edits.** A `FAIL` stays until a new turn asks again. So the last rule
1239
+ asks again when a person sets or changes the `recheck` label after a fix.
1240
+ - **A turn runs the whole program again**, so it must be safe to run twice. A second vote replaces the
1241
+ first, so this program is.
1242
+ - **Attach the `notes` grant** the way Example 1 attaches its judge's grant.
1243
+ - **The program reads only the description.** The item's JSON also holds the checks' summaries, and the
1244
+ `FAIL` summary names the section, so a search of the whole JSON would find it there.
1245
+
1246
+ ### Example 4: approval by two people
1247
+
1248
+ The `approval` key is granted to people through a role. The gate asks for two people's `PASS`, no `FAIL`,
1249
+ and a latest voter who does not hold the item.
1250
+
1251
+ 1. Create the policy with `orchestra policies create --body @approval-policy.json`, the same shape as in
1252
+ example 2 with the resource `work-items/*/checks/approval`. The FDK names a policy by id; it does not
1253
+ create one.
1254
+ 2. Declare the role in code:
1255
+
1256
+ ```ts
1257
+ import { Policy, Role } from "@orchestraworks/cli";
1258
+
1259
+ const approver = new Role(factory, "approver", {
1260
+ name: "Approver",
1261
+ policies: [Policy.fromId(factory, "pol_…")], // the policy from step 1
1262
+ });
1263
+ ```
1264
+
1265
+ 3. Give each approver the role at the pipeline's team, or at all teams:
1266
+ `orchestra team-memberships create --membership-id <person's membership id> --team-id <team id>
1267
+ --role-id <role id>`. A member holds one role per scope, so a person who already holds a role at that
1268
+ scope needs one custom role that lists both sets of policies. The FDK declares team memberships for
1269
+ agents and service accounts only, not for people.
1270
+ 4. Write the gate:
1271
+
1272
+ ```ts
1273
+ {
1274
+ slug: "released",
1275
+ category: "DONE",
1276
+ requires: [
1277
+ { field: "checks.approval.passUsers", op: "gte", value: 2 },
1278
+ { field: "checks.approval.fail", op: "is", value: 0 },
1279
+ { field: "checks.approval.writer.isAssignee", op: "is", value: false },
1280
+ ],
1281
+ }
1282
+ ```
1283
+
1284
+ An approver votes with `orchestra work-items set-check rel-7 approval --result PASS --summary "…"`.
1285
+ `passUsers` counts people only, so an agent's vote does not count toward the two. One `FAIL` holds the item
1286
+ until that person votes again.
1287
+
1288
+ **The limit of the self-approval guard.** `.writer.isAssignee` reads the latest vote only. If the assignee
1289
+ votes first and a second person votes last, the guard passes. With two approvers, it cannot prove that
1290
+ neither of them holds the item. Keep the assignee out of the approver role when that matters.
1291
+
1292
+ ### Example 5: a decision model judge (Jev)
1293
+
1294
+ <!-- Sources, on 74243daf6c: https://docs.typesafe.ai/api.md (the endpoint, the noul and choice questions and
1295
+ answers, a choice's criteria map, back off on 429 and 529); platform/src/main/kotlin/ai/livingcomputers/
1296
+ softwarefactory/integration/typesafe/TypeSafeDecisionClient.kt (that shape as encoded; a choice answer needs a
1297
+ string choice and a number confidence; one attempt) and platform/src/test/resources/typesafe/
1298
+ decision-transcript.json (every field name); platform/…/config/DecisionsProperties.kt:31 (a pinned version, never
1299
+ jev-latest: a new version changes the confidences); docs/architecture/work-plane.md:490-498, 562-564 (POST
1300
+ /decisions writes nothing; the platform's calls, on its key); platform/…/workplane/service/
1301
+ WorkItemSessionService.kt:332-392 (a judge's session is linked to one item); platform/…/common/api/Page.kt:11-16
1302
+ (rows under `data`); cli/src/constructs/environment.ts:717-722 and docs/architecture/egress.md:919-921, 1364-1371
1303
+ (an environment credential is added to a request to its hosts that names none, whatever the agent);
1304
+ cli/src/engine/reconcile.ts:368 and secrets-apply.ts:254-257 (apply reads the variable from its own environment);
1305
+ docs/architecture/commercial-plans-and-credits.md:268, 432 (the individual plan refuses a Secret create);
1306
+ cli/src/constructs/rules.ts:149-152 (entered fires on every entry); agents/runtime/image/Dockerfile:43 (curl, jq);
1307
+ cli/src/constructs/pipeline.ts:31-35 (`next` lists the stage slugs a move may go to);
1308
+ docs/architecture/work-plane.md:650 (a rule's move is judged against `next`); platform/…/workplane/model/
1309
+ WorkItem.kt:344 (a create with no stage lands in the first stage); platform/…/workplane/service/
1310
+ WorkplaneWorkItemService.kt:372 (a move to the item's own stage does nothing); execve(2) (one argument holds at
1311
+ most 128 KiB on Linux) and curl(1) `--data-binary` (`@-` reads the body from standard input as it is). -->
1312
+
1313
+ A *decision model* answers by picking, not by writing: one option from a fixed list, or the probability of yes.
1314
+ TypeSafe's Jev is one. Here a `script` agent asks Jev if a request is ready to build. Jev picks `PASS` or `FAIL`
1315
+ with a confidence, and the agent votes on `ready`.
1316
+
1317
+ - **Bring your own TypeSafe key.** The platform calls Jev only to read the text typed into the console's
1318
+ "+ Create" box, with its own key. That call cannot write a check, and no agent gets that key. Each call this
1319
+ judge makes bills your TypeSafe account.
1320
+ - **Set `TYPESAFE_API_KEY` in the shell that runs `orchestra fdk apply`.** Apply reads the key from that variable
1321
+ and writes it to the Secret. On the `individual` plan, apply refuses a Secret: see the orchestra skill's
1322
+ section 6, "Secret".
1323
+ - **The program never holds the key.** The Secret's recipe sets `Authorization: Bearer` on `api.typesafe.ai`. The
1324
+ environment lists the Secret in `credentials`. So the edge adds the key to each request that any program on
1325
+ this environment's machines sends to `api.typesafe.ai`, and no program holds it. Use this environment for the
1326
+ judge only.
1327
+ - **The request and the answer follow TypeSafe's published reference**, https://docs.typesafe.ai/api.md. The
1328
+ request is `{model, state, questions}`. The program asks one `choice` question, whose `criteria` map each
1329
+ option to a description: here `PASS` and `FAIL`, each described from the criterion. The answer gives the
1330
+ picked option, its confidence and each option's probability. For example: `{"answers": {"ready": {"type":
1331
+ "choice", "choice": "PASS", "confidence": 0.88, "probabilities": {"PASS": 0.94, "FAIL": 0.06}}}}`. The image
1332
+ ships `curl` beside `orchestra` and `jq`.
1333
+
1334
+ ```ts
1335
+ const typesafeKey = new Secret(factory, "typesafe-api-key", {
1336
+ value: SecretValue.fromEnv("TYPESAFE_API_KEY"),
1337
+ injection: {
1338
+ headers: [{ name: "Authorization", value: "Bearer ${secret}" }],
1339
+ hosts: ["api.typesafe.ai"],
1340
+ },
1341
+ });
1342
+
1343
+ const decisionRuntime = new Environment(factory, "decision-runtime", {
1344
+ name: "Decision model judge environment",
1345
+ image: Image.fromTag("acme/support-agent:1.0.0"),
1346
+ platform: { os: "linux", arch: "amd64" },
1347
+ resources: { cpu: "2", memory: "4G" },
1348
+ credentials: [{ credential: typesafeKey }],
1349
+ egress: EgressPolicy.denyAll()
1350
+ .applyPreset(egressPreset.softwareDevelopment())
1351
+ .allow("typesafe-api", { hosts: ["api.typesafe.ai"], priority: 300 }),
1352
+ });
1353
+
1354
+ const readyCriterion = "The description says what to build and how to tell that it is done.";
1355
+ const minConfidence = 0.8;
1356
+ const unsureResult: "PASS" | "FAIL" = "FAIL";
1357
+
1358
+ const readyJudge = new Agent(factory, "ready-judge", {
1359
+ environment: decisionRuntime,
1360
+ harness: "script",
1361
+ instructions: `set -euo pipefail
1362
+ items=$(orchestra work-items list --session "$ORCHESTRA_SESSION_ID" --output json)
1363
+ if [ "$(printf '%s' "$items" | jq '.data | length')" != 1 ]; then
1364
+ echo "This session is not on exactly one work item." >&2
1365
+ exit 1
1366
+ fi
1367
+ id=$(printf '%s' "$items" | jq -r '.data[0].id')
1368
+ body=$(printf '%s' "$items" | jq --arg criterion '${readyCriterion}' '{
1369
+ model: "jev-1.13.0",
1370
+ state: (.data[0] | {title, description: (.description // "")}),
1371
+ questions: {ready: {
1372
+ type: "choice",
1373
+ instructions: ("Is this true of the work item? " + $criterion),
1374
+ criteria: {PASS: ("True: " + $criterion), FAIL: ("Not true: " + $criterion)}
1375
+ }}
1376
+ }')
1377
+ answer=$(printf '%s' "$body" |
1378
+ curl -sS --fail --max-time 30 -H 'content-type: application/json' --data-binary @- https://api.typesafe.ai/v1/systemone)
1379
+ choice=$(printf '%s' "$answer" | jq -er '.answers.ready.choice | select(. == "PASS" or . == "FAIL")')
1380
+ confidence=$(printf '%s' "$answer" | jq -e '.answers.ready.confidence | numbers')
1381
+ if jq -en --argjson c "$confidence" '$c >= ${minConfidence}' > /dev/null; then
1382
+ result=$choice
1383
+ summary="The decision model picked $choice with confidence $confidence."
1384
+ else
1385
+ result=${unsureResult}
1386
+ summary="The decision model was unsure: it picked $choice with confidence $confidence."
1387
+ fi
1388
+ orchestra work-items set-check "$id" ready --result "$result" --summary "$summary"`,
1389
+ });
1390
+
1391
+ const requests = new Pipeline(factory, "requests", {
1392
+ itemSlugPrefix: "req",
1393
+ stages: [
1394
+ { slug: "drafting", category: "TODO", next: ["inbox"] },
1395
+ { slug: "inbox", category: "TODO" },
1396
+ { slug: "building", category: "IN_PROGRESS", requires: [{ field: "checks.ready.result", op: "is", value: "PASS" }] },
1397
+ { slug: "done", category: "DONE" },
1398
+ ],
1399
+ }).allowCheck(readyJudge, "ready");
1400
+
1401
+ const askReadyJudge = evaluate({ agent: readyJudge, check: "ready", criterion: readyCriterion });
1402
+
1403
+ new Workflow(factory, "requests-ready", {
1404
+ pipeline: requests,
1405
+ description: "Asks the decision model judge if each request in Inbox is ready, and sends it back on a FAIL.",
1406
+ rules: [
1407
+ rule({
1408
+ name: "Clear the old vote and ask the ready judge on entering Inbox",
1409
+ when: entered("inbox"),
1410
+ then: [clearCheck("ready"), askReadyJudge],
1411
+ }),
1412
+ rule({
1413
+ name: "Ask the ready judge again after ten minutes with no vote",
1414
+ when: inStageFor("inbox", "10m"),
1415
+ if: { field: "checks.ready.result", op: "isAbsent" },
1416
+ then: [askReadyJudge],
1417
+ }),
1418
+ rule({ name: "Send a request that is not ready back to Drafting", when: check("ready").is("FAIL"), then: [move("drafting")] }),
1419
+ ],
1420
+ });
1421
+ ```
1422
+
1423
+ - **The rules work as Example 1's**, with Drafting and Inbox in place of Building and Review. By default, a new
1424
+ request lands in Drafting, the first stage. Move it to Inbox to ask the judge. No rule moves a passed
1425
+ request: a person moves it to Building.
1426
+ - **A PASS holds until the request next enters Inbox.** A request in Drafting can move only to Inbox, so it
1427
+ reaches Building only through Inbox. Each entry into Inbox clears the vote and asks again. After a fix or an
1428
+ edit, move the request back to Inbox to ask again. A move to the stage the request is in does nothing. So
1429
+ from Inbox, move it to Drafting first.
1430
+ - **The program reads nothing from the turn.** It finds the item as Example 3 does, and the key and the
1431
+ criterion are fixed in it. So no text from the turn reaches the question. The item's title and description
1432
+ go in `state`. A long description can be longer than the limit for one command-line argument. So the
1433
+ program gives the item's JSON and the TypeSafe body to `jq` and `curl` on standard input. The cost is one
1434
+ judge for each criterion. One constant gives the criterion to the rule and to the program. Keep single
1435
+ quotes out of it, because the program quotes it in them.
1436
+ - **The vote.** At a confidence of `minConfidence` or more, the vote is Jev's pick, `answers.ready.choice`. Below
1437
+ it, the vote is `unsureResult`. The summary gives the pick and the confidence, and says when Jev was unsure.
1438
+ - **`FAIL` as the unsure result fails closed.** An unsure answer keeps the gate shut, and the `FAIL` rule sends
1439
+ the request back to Drafting, as in Example 1. Set `unsureResult` to `PASS` only where a wrong pass costs
1440
+ little.
1441
+ - **Pin the model version**, `jev-1.13.0` here. Never use `jev-latest`: a new version changes the confidences,
1442
+ and `minConfidence` then means something else. Choose `minConfidence` from Jev's answers on your own items.
1443
+ - **One call, no retry.** TypeSafe asks a caller to back off on 429 and 529. On an HTTP error or no answer in
1444
+ 30 seconds, the program exits non-zero with no vote. It does the same when `choice` is not exactly `PASS` or
1445
+ `FAIL`, or `confidence` is not a number. The `inStageFor` rule then asks again, as in Example 1.
1446
+ - **Attach the `ready` grant** the way Example 1 attaches its judge's grant.
1447
+ - **Example 3's warnings on the exit code and on running twice hold here too.**
1448
+
958
1449
  ## The six design principles
959
1450
 
960
1451
  These decide the design questions authoring throws up. When something feels like it needs new API
@@ -1079,7 +1570,7 @@ orchestra gateway-keys create --slug ci-smoke --output-file key.json
1079
1570
  export, a CI secret) suppresses the only prompt you would have seen, the command says so in one
1080
1571
  line on **stderr**, naming the lever — stdout stays the response document.
1081
1572
  - **Writing to a factory-managed resource warns.** `apply` stamps what it manages with an
1082
- `sfdk.managedBy` labels key naming the owning factory, and a write to a stamped resource says so:
1573
+ `orchestra.managedBy` labels key naming the owning factory, and a write to a stamped resource says so:
1083
1574
  the next `apply` reverts it. It is a warning, not a refusal — the fallback has to stay usable in an
1084
1575
  emergency — and it is the CLI telling you this belongs in `main.ts`.
1085
1576