okstra 0.15.0 → 0.17.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.
- package/bin/okstra +13 -0
- package/package.json +1 -1
- package/runtime/BUILD.json +2 -2
- package/runtime/prompts/profiles/final-verification.md +1 -0
- package/runtime/prompts/profiles/implementation.md +37 -1
- package/runtime/python/okstra_ctl/__init__.py +8 -0
- package/runtime/python/okstra_ctl/qa_commands.py +165 -0
- package/runtime/python/okstra_ctl/run.py +17 -0
- package/runtime/skills/okstra-run/SKILL.md +43 -74
- package/runtime/skills/okstra-setup/SKILL.md +53 -17
- package/runtime/templates/reports/settings.template.json +1 -0
- package/src/_python-helper.mjs +42 -0
- package/src/plan-validate.mjs +68 -0
- package/src/render-bundle.mjs +60 -0
- package/src/task-list.mjs +86 -0
- package/src/task-show.mjs +120 -0
- package/src/worktree-lookup.mjs +91 -0
package/bin/okstra
CHANGED
|
@@ -9,6 +9,11 @@ const COMMANDS = new Map([
|
|
|
9
9
|
["doctor", () => import("../src/doctor.mjs").then((m) => m.run)],
|
|
10
10
|
["setup", () => import("../src/setup.mjs").then((m) => m.run)],
|
|
11
11
|
["check-project", () => import("../src/check-project.mjs").then((m) => m.run)],
|
|
12
|
+
["task-list", () => import("../src/task-list.mjs").then((m) => m.run)],
|
|
13
|
+
["task-show", () => import("../src/task-show.mjs").then((m) => m.run)],
|
|
14
|
+
["worktree-lookup", () => import("../src/worktree-lookup.mjs").then((m) => m.run)],
|
|
15
|
+
["plan-validate", () => import("../src/plan-validate.mjs").then((m) => m.run)],
|
|
16
|
+
["render-bundle", () => import("../src/render-bundle.mjs").then((m) => m.run)],
|
|
12
17
|
]);
|
|
13
18
|
|
|
14
19
|
const USAGE = `okstra — multi-agent cross-verification orchestrator for Claude Code
|
|
@@ -37,6 +42,14 @@ Admin commands:
|
|
|
37
42
|
check-project Verify the current project has been registered with setup
|
|
38
43
|
paths Print runtime paths (workspace/agents/pythonpath/bin/home/version)
|
|
39
44
|
|
|
45
|
+
Introspection commands (JSON output, used by skills to avoid python heredocs):
|
|
46
|
+
task-list List tasks registered in the current project
|
|
47
|
+
task-show Summarize a task's manifest + workflow phase state
|
|
48
|
+
worktree-lookup Look up registered worktree for a task-key
|
|
49
|
+
plan-validate Check an approved-plan file for the approval marker
|
|
50
|
+
render-bundle Preview prepare_task_bundle() output (forwards to
|
|
51
|
+
python3 -m okstra_ctl.run --render-only)
|
|
52
|
+
|
|
40
53
|
Global options:
|
|
41
54
|
--version Print okstra version and exit
|
|
42
55
|
--help Print this help
|
package/package.json
CHANGED
package/runtime/BUILD.json
CHANGED
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
- **Residual Risk block** (under section 4): risks that are not blockers but should be tracked, each with mitigation owner and a trigger that would escalate them to a blocker.
|
|
25
25
|
- **Validation Evidence**: for every requirement in the originating plan or task brief, cite the artifact (commit SHA, test output, log line, MCP SELECT result) that demonstrates coverage. Paraphrased "verified" claims without an artifact are rejected.
|
|
26
26
|
- **Read-only command log**: any pre-existing test/validation command executed during this run MUST be listed with its exact command line and exit code. No mutating commands may appear here.
|
|
27
|
+
- **Two-tier command lookup (shared with `implementation`):** when this phase performs its own independent re-validation, the command source is exactly the same two tiers `implementation` verifiers use — Tier 1 is the originating task brief / approved plan's `validation` set, Tier 2 is `<PROJECT_ROOT>/.project-docs/okstra/project.json` under `qaCommands`. Auto-detecting tools from manifest files is forbidden; missing tiers are recorded as `qa-command not configured: <category>` and do NOT trigger a guess. The `cmd` deny-list (`--fix`, `--write`, ` -w`, ` -u`, `--snapshot-update`, `INSTA_UPDATE=<not-no>`, `cargo update`, `npm install` without `ci`, etc.) is enforced identically. NOTE: runtime fail-fast validation (`okstra_ctl.qa_commands.validate_qa_commands`) only fires at `--task-type implementation` run-prep, so this phase MUST self-check each `qaCommands` entry against the deny-list before executing it — if a denied token is present, skip the command and record it as a `Read-only command log` line `qa-command rejected (denied token: <token>): <label>`.
|
|
27
28
|
- **Routing recommendation**: brief note on the next safe phase (`done`, `error-analysis`, `implementation-planning`) tied to the verdict and blocker list.
|
|
28
29
|
- Clarification request policy (phase-specific addendum — shared policy is in `_common-contract.md`):
|
|
29
30
|
- populate section 5 only when a blocker hinges on information only the user can supply (deployment intent, intended target environment, business-rule interpretation)
|
|
@@ -17,6 +17,27 @@
|
|
|
17
17
|
- Team contract (phase-specific overrides — `Claude worker` is replaced by `Executor` + verifier set in this phase):
|
|
18
18
|
- **Executor role:** the `Executor` (bound above) is the **only worker permitted to use Edit / Write / state-mutating Bash commands** on project files. All other workers run read-only. When the executor provider is `codex` or `gemini`, the actual file mutation happens inside the executor CLI's own auto-edit mode (e.g. `codex exec --sandbox workspace-write`, gemini's equivalent) — not through Claude-side Edit/Write tools — but the safety rules in this profile still apply identically.
|
|
19
19
|
- **Verifier roles:** the verifier slots are `Claude verifier` and `Codex verifier`, plus `Gemini verifier` **only when `gemini` is in the resolved `--workers` roster**. Every verifier in the resolved roster is dispatched regardless of which provider holds the executor role; the executor's own provider is run *separately* as a verifier (a fresh CLI session with no shared context) so that no verdict is produced from the same session that wrote the diff. Verifiers MUST NOT call Edit, Write, or any Bash command that mutates files outside the run's artifact directories. If a verifier wants a fix, it records the recommendation in its worker result; it does not apply the fix itself.
|
|
20
|
+
- **Verifier QA duties (independent re-run mandate):** every verifier acts as a QA gate, not just a diff reviewer. Trusting the executor's reported evidence is forbidden — verifiers MUST reproduce it themselves from the same worktree path the executor used.
|
|
21
|
+
- **Two-tier command lookup (NO auto-detection):** verifier obtains the QA command set from exactly two declared sources, in order — there is **no fallback to guessing tools from manifest files**.
|
|
22
|
+
1. **Tier 1 — plan validation set (task-specific):** every command listed under the approved plan's `validation` block (pre / mid / post).
|
|
23
|
+
2. **Tier 2 — project baseline (`project.json.qaCommands`):** the project's standing QA baseline declared in `<PROJECT_ROOT>/.project-docs/okstra/project.json` under the `qaCommands` key. Schema (each category is an array of `{ "label", "cmd", "language"? }` objects):
|
|
24
|
+
```json
|
|
25
|
+
{
|
|
26
|
+
"qaCommands": {
|
|
27
|
+
"lint": [{ "label": "cargo clippy", "cmd": "cargo clippy --all-targets -- -D warnings", "language": "rust" }],
|
|
28
|
+
"format": [{ "label": "cargo fmt", "cmd": "cargo fmt --check", "language": "rust" }],
|
|
29
|
+
"typecheck": [{ "label": "tsc", "cmd": "pnpm exec tsc --noEmit", "language": "ts" }],
|
|
30
|
+
"test": [{ "label": "cargo test", "cmd": "cargo test --workspace --locked", "language": "rust" }]
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
```
|
|
34
|
+
`language` is optional; when present, verifier MAY skip categories whose `language` is not represented in this run's diff (recorded as `qa-command skipped: <label> (language=<x> not in diff)`). Absent `language` means "always run".
|
|
35
|
+
- **Execution rule:** Tier 1 commands run verbatim first. Then every Tier 2 entry runs once. Each command runs in the worktree cwd, and is recorded in the worker result with its exact command line, exit code, and the tail of stdout/stderr. Substituting or paraphrasing a Tier 1 command is forbidden (see Forbidden actions).
|
|
36
|
+
- **Missing-tier handling:** if a tier is empty or absent, verifier records the single line `qa-command not configured: <category>` per missing category (`lint` / `format` / `typecheck` / `test`) in the worker result and proceeds — silent omission is a contract violation. Verifier MUST NOT auto-detect or invent a command in this case; the user/operator must declare it in `project.json.qaCommands` or in the plan.
|
|
37
|
+
- **`cmd` field deny-list (Tier 2 validation):** the runtime AND the verifier MUST reject any `cmd` containing tokens that imply mutation: `--fix`, `--write`, ` -w` (gofmt write), ` -u` (jest snapshot update), `--update-snapshots`, `--snapshot-update`, `--update-goldens`, `INSTA_UPDATE=` (with any value other than `no`), `cargo insta accept`, `npm install` (without `ci`), `cargo update`, `pip install -U`, `pnpm add`, `bun add`. Encountering a denied token aborts the verifier run with `contract-violated` and the operator is asked to re-declare the command in check-only form.
|
|
38
|
+
- **Discrepancy rule:** if the verifier's re-run result differs from what the executor reported (a passing test fails on re-run, a clean lint surfaces warnings, an exit code mismatches), the verifier MUST issue verdict `FAIL` with the divergence cited. `Claude lead` MUST NOT silently prefer the executor's evidence over a verifier's reproduced result during synthesis; if it overrides, it MUST cite a concrete reproduction-time reason (flaky-test commit-cited, environment delta documented) — handwaving is not allowed.
|
|
39
|
+
- **Read-only command log (per verifier):** the worker result MUST contain a `Read-only command log` block listing every command executed during the verifier run with its exact invocation and exit code, in execution order. No mutating command may appear in this block. This log is copied into the final report's verifier result section verbatim.
|
|
40
|
+
- **Verifier evidence is independent of executor evidence:** the final report keeps both — executor's `Validation evidence` AND each verifier's `Read-only command log` — so reviewers can compare them line-by-line.
|
|
20
41
|
- Session isolation — not model-variant divergence — is the primary self-review safeguard: each verifier is a separate CLI invocation with its own context window, so reusing the same model variant for executor and same-provider verifier is acceptable. Different model variants (e.g. executor=opus / Claude verifier=sonnet) remain recommended when available.
|
|
21
42
|
- Phase-specific model defaults override the shared defaults: `Claude verifier`=`sonnet`, `Codex verifier`=`gpt-5.5`, `Gemini verifier`=`auto` (only when present in the roster). The `Executor`'s model is taken from the provider-specific worker model corresponding to `--executor`: claude→`--claude-model` (default `sonnet`, override to `opus` recommended when this run's executor is claude), codex→`--codex-model` (default `gpt-5.5`), gemini→`--gemini-model` (default `auto`).
|
|
22
43
|
- **All-verifier-failure policy**: if every verifier present in the resolved roster (`Claude verifier`, `Codex verifier`, and `Gemini verifier` when opted in) ends with a non-result terminal status (`timeout`, `error`, `not-run`) — i.e. zero independent verdicts were produced — the run MUST end with status `blocked` and route to a follow-up `error-analysis` run. `Claude lead` MUST NOT substitute its own verdict in place of the missing verifier outputs; synthesis requires at least one independent verifier's verdict. If one or more verifiers fail but at least one returns a verdict, the run proceeds with the surviving verdict(s) and the final report MUST explicitly notate which verifiers were unavailable, with the captured error / timeout evidence per failed verifier.
|
|
@@ -79,6 +100,14 @@
|
|
|
79
100
|
- dispatching parallel sub-agents beyond the required worker roster
|
|
80
101
|
- silent scope expansion — adding files, dependencies, or features that the approved plan did not list, without recording an `Out-of-plan edits` justification
|
|
81
102
|
- leaving placeholders such as TBD / TODO / "implement later" / "handle edge cases" in committed code
|
|
103
|
+
- **(verifier-specific)** running lint / formatter auto-fix modes during a verifier's re-run — `eslint --fix`, `prettier --write`, `ruff check --fix`, `rustfmt` (writes by default; verifiers MUST use `cargo fmt --check` or `rustfmt --check`), `gofmt -w`, `black .` (use `black --check`), `isort .` (use `isort --check-only`), or any equivalent rewrite mode
|
|
104
|
+
- **(verifier-specific)** updating snapshots / golden fixtures during verification — `jest -u` / `--updateSnapshot`, `pytest --snapshot-update`, `INSTA_UPDATE=*` (any value other than `no`), `cargo insta accept`, `--update-goldens`, or any equivalent "make the test agree with current output" flag
|
|
105
|
+
- **(verifier-specific)** masking test failure with selection or shell tricks during re-run — `-k <expr>` / `--ignore` / `--deselect` to skip subsets, trailing `|| true`, `set +e` followed by a manually softened comparison, redirecting non-zero exit to success. The plan's listed test command MUST run in full
|
|
106
|
+
- **(verifier-specific)** substituting the plan's validation commands — verifier MUST run the plan's pre/mid/post validation commands verbatim; replacing them with paraphrased or "equivalent" commands is forbidden. Adding supplementary check-only lint/type-check is allowed and is logged separately in the verifier's Read-only command log
|
|
107
|
+
- **(verifier-specific)** mutating lockfiles or dependency manifests — `npm install <pkg>`, `npm install` (without lockfile freeze; use `npm ci`), `pnpm add`, `bun add`, `cargo add`, `cargo update`, `pip install -U`, or any dependency install that is not lockfile-frozen (`--locked` / `--frozen-lockfile` / `npm ci` / `pip install --require-hashes`)
|
|
108
|
+
- **(verifier-specific)** git state mutations — `git add`, `git commit`, `git stash`, `git checkout -- <file>`, `git restore`, `git reset`, `git rebase`, `git merge`, branch creation/deletion, tag creation. Only read-only git queries (`git status`, `git diff`, `git log`, `git show`, `git rev-parse`, `git blame`) are permitted for verifiers
|
|
109
|
+
- **(verifier-specific)** running integration / end-to-end tests that produce non-local side effects (DB writes against a non-local datastore, external API writes, docker compose against a non-isolated environment) unless that exact command is listed in the approved plan's validation set
|
|
110
|
+
- **(verifier-specific)** redirecting tool caches or output to paths outside the worktree — e.g. setting `CARGO_TARGET_DIR`, `PYTEST_CACHE_DIR`, `NODE_OPTIONS=--require=<external>`, or any env var that causes the verifier's command to write outside the worktree's normal build artifact paths
|
|
82
111
|
- Required deliverable shape (final report, in addition to the standard sections):
|
|
83
112
|
- **Plan link & approval evidence**: path to the approved `final-report.md` and the exact quoted approval marker
|
|
84
113
|
- **Commit list**: each commit's SHA (or short SHA), message, and the plan step it satisfies
|
|
@@ -86,7 +115,14 @@
|
|
|
86
115
|
- **Out-of-plan edits block**: every file edited that was not in the approved plan's file list, with rationale (empty block is acceptable and preferred)
|
|
87
116
|
- **Validation evidence**: actual command output (stdout/stderr) for every `pre / mid / post` validation command from the plan. Truncated output is acceptable but the command line and exit code MUST be exact. No paraphrasing of test results.
|
|
88
117
|
- **TDD evidence (when applicable)**: for steps that should be TDD-ordered, show the failing-test output BEFORE the implementation commit and the passing-test output AFTER, with commit SHAs framing the transition.
|
|
89
|
-
- **Verifier results**: a section per verifier present in the resolved roster (`Claude verifier`, `Codex verifier`, and `Gemini verifier` when opted in) containing
|
|
118
|
+
- **Verifier results**: a section per verifier present in the resolved roster (`Claude verifier`, `Codex verifier`, and `Gemini verifier` when opted in) containing:
|
|
119
|
+
- their independent verdict (PASS / CONCERNS / FAIL),
|
|
120
|
+
- cited diff snippets supporting the verdict,
|
|
121
|
+
- the verifier's `Read-only command log` (every command they ran with exact invocation and exit code, in execution order — copied verbatim from the worker result),
|
|
122
|
+
- **independent validation re-run results** — per plan-validation command: command line, exit code, and tail of output captured by the verifier (not the executor); any divergence from the executor's reported result MUST be called out as a `Discrepancy` line citing both sides,
|
|
123
|
+
- **style / lint / type-check results** — each check-only tool the verifier ran, its exit code, and the count of new findings attributable to lines this run introduced. When no tool is configured for a touched language, record the single line `no lint/style tool configured for <language>`,
|
|
124
|
+
- any fix recommendations the verifier declined to apply.
|
|
125
|
+
`Claude lead` synthesises a unified verdict but MUST preserve dissent — do not collapse opinions into one paragraph. If any verifier issued `FAIL` on a `Discrepancy` line, the synthesised verdict MUST be `FAIL` unless lead cites a concrete reproduction-time reason (committed flaky-test record, documented environment delta) for overriding.
|
|
90
126
|
- **Rollback verification**: confirmation that the plan's rollback path is still valid after the changes. Strength of verification depends on the change category:
|
|
91
127
|
- **Pure code changes** (no persisted state, no infra mutation): a reachable revert SHA is sufficient. Record the exact `git revert <SHA>` command that would undo the change, and confirm `git rev-parse <SHA>` resolves.
|
|
92
128
|
- **Feature-flag-gated changes**: confirm the off-switch path was exercised in this run's validation evidence (i.e. one of the validation commands ran with the flag off and succeeded). A plan that ships a flag without exercising the off-path does NOT satisfy this requirement.
|
|
@@ -28,6 +28,14 @@ from .project_meta import (
|
|
|
28
28
|
load_project_meta,
|
|
29
29
|
upsert_project_meta,
|
|
30
30
|
)
|
|
31
|
+
from .qa_commands import (
|
|
32
|
+
ALLOWED_CATEGORIES,
|
|
33
|
+
QaCommandsError,
|
|
34
|
+
find_denied_tokens,
|
|
35
|
+
format_errors,
|
|
36
|
+
validate_qa_cmd,
|
|
37
|
+
validate_qa_commands,
|
|
38
|
+
)
|
|
31
39
|
from .index import (
|
|
32
40
|
_replace_or_append_active_row,
|
|
33
41
|
_replace_or_append_project_row,
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""`project.json.qaCommands` 검증 헬퍼.
|
|
2
|
+
|
|
3
|
+
implementation phase 의 verifier QA gate 는 plan 의 `validation` 셋(Tier 1) 과
|
|
4
|
+
project-wide baseline 인 `qaCommands`(Tier 2) 를 함께 실행한다. Tier 2 는
|
|
5
|
+
사용자가 `project.json` 에 직접 선언하며, mutation 을 유발하는 토큰이 포함된
|
|
6
|
+
명령을 미리 차단해야 verifier 가 read-only 계약을 깨지 않는다.
|
|
7
|
+
|
|
8
|
+
본 모듈은 두 가지 책임을 갖는다.
|
|
9
|
+
|
|
10
|
+
1. `cmd` 문자열에서 mutation 유발 토큰을 검출 (`validate_qa_cmd`).
|
|
11
|
+
2. `qaCommands` 블록 전체를 순회하며 모든 위반을 모은다 (`validate_qa_commands`).
|
|
12
|
+
|
|
13
|
+
본 모듈은 런타임 검증에만 사용된다. 런타임 외 (verifier 가 실제 실행 단계에서
|
|
14
|
+
self-enforce 하는 측면) 의 계약은 `prompts/profiles/implementation.md` 의
|
|
15
|
+
"Two-tier command lookup" 단락에 명문화돼 있다.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import re
|
|
20
|
+
from typing import Iterable
|
|
21
|
+
|
|
22
|
+
# 카테고리 화이트리스트. 알 수 없는 카테고리는 오타 가능성이 높으므로 거부.
|
|
23
|
+
ALLOWED_CATEGORIES: tuple[str, ...] = ("lint", "format", "typecheck", "test")
|
|
24
|
+
|
|
25
|
+
# Mutation 을 유발하거나 lockfile 을 갱신하는 토큰. 각 토큰은 `cmd` 문자열을
|
|
26
|
+
# 공백으로 단순 분해한 결과 또는 부분 일치 패턴(prefix/suffix sensitive) 로 검출한다.
|
|
27
|
+
# 새로운 도구를 추가할 때마다 한 줄씩 늘려가는 것이 정상 — 정규식 흑마법 금지.
|
|
28
|
+
_DENIED_LITERAL_TOKENS: tuple[str, ...] = (
|
|
29
|
+
"--fix",
|
|
30
|
+
"--write",
|
|
31
|
+
"-w", # gofmt -w, prettier -w
|
|
32
|
+
"-u", # jest -u
|
|
33
|
+
"--updateSnapshot",
|
|
34
|
+
"--update-snapshot",
|
|
35
|
+
"--snapshot-update",
|
|
36
|
+
"--update-goldens",
|
|
37
|
+
"--update-golden",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# 공백 분해로는 잡기 어려운 패턴 (substring 검사로 잡는다).
|
|
41
|
+
_DENIED_SUBSTRINGS: tuple[str, ...] = (
|
|
42
|
+
"cargo insta accept",
|
|
43
|
+
"cargo update",
|
|
44
|
+
"pip install -U",
|
|
45
|
+
"pip install --upgrade",
|
|
46
|
+
"pnpm add",
|
|
47
|
+
"bun add",
|
|
48
|
+
"cargo add",
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _has_npm_install_without_ci(cmd: str) -> bool:
|
|
53
|
+
"""`npm install` 은 lockfile mutation 위험이라 거부, `npm ci` 는 허용.
|
|
54
|
+
|
|
55
|
+
부분 문자열 매칭에서 `npm install` 이 잡히면, 그 뒤에 오는 토큰 시퀀스가
|
|
56
|
+
`npm ci` 의 변종이 아닌 한 항상 거부.
|
|
57
|
+
"""
|
|
58
|
+
# 단순화: 정확히 `npm install` (또는 `npm i`) 가 등장하는지 검사. `ci` 는 별개
|
|
59
|
+
# 서브커맨드라 `npm ci` 는 이 정규식에 걸리지 않는다.
|
|
60
|
+
return re.search(r"\bnpm\s+(install|i)\b", cmd) is not None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _has_insta_update_set(cmd: str) -> bool:
|
|
64
|
+
"""`INSTA_UPDATE=<value>` 에서 value 가 `no` 가 아닌 경우 거부."""
|
|
65
|
+
match = re.search(r"\bINSTA_UPDATE=([A-Za-z0-9_-]+)", cmd)
|
|
66
|
+
if match is None:
|
|
67
|
+
return False
|
|
68
|
+
return match.group(1).lower() != "no"
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def find_denied_tokens(cmd: str) -> list[str]:
|
|
72
|
+
"""`cmd` 안에 포함된 모든 denied 토큰 목록을 반환. 비어 있으면 안전."""
|
|
73
|
+
if not isinstance(cmd, str):
|
|
74
|
+
return ["<not-a-string>"]
|
|
75
|
+
found: list[str] = []
|
|
76
|
+
tokens = cmd.split()
|
|
77
|
+
for tok in _DENIED_LITERAL_TOKENS:
|
|
78
|
+
if tok in tokens:
|
|
79
|
+
found.append(tok)
|
|
80
|
+
for sub in _DENIED_SUBSTRINGS:
|
|
81
|
+
if sub in cmd:
|
|
82
|
+
found.append(sub)
|
|
83
|
+
if _has_npm_install_without_ci(cmd):
|
|
84
|
+
found.append("npm install (use 'npm ci' instead)")
|
|
85
|
+
if _has_insta_update_set(cmd):
|
|
86
|
+
found.append("INSTA_UPDATE=<not-no>")
|
|
87
|
+
return found
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class QaCommandsError(ValueError):
|
|
91
|
+
"""`qaCommands` 블록이 계약을 어긴 경우 발생."""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def validate_qa_cmd(cmd: str, *, label: str = "<unnamed>", category: str = "<uncategorised>") -> None:
|
|
95
|
+
"""단일 `cmd` 문자열을 검사. 위반이 있으면 `QaCommandsError`.
|
|
96
|
+
|
|
97
|
+
`label` / `category` 는 에러 메시지를 사람이 읽을 수 있게 하는 데만 쓰인다.
|
|
98
|
+
"""
|
|
99
|
+
denied = find_denied_tokens(cmd)
|
|
100
|
+
if denied:
|
|
101
|
+
joined = ", ".join(denied)
|
|
102
|
+
raise QaCommandsError(
|
|
103
|
+
f"qaCommands.{category}[{label!r}] contains mutation token(s): {joined}. "
|
|
104
|
+
f"Re-declare in check-only form."
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def validate_qa_commands(qa: object) -> list[str]:
|
|
109
|
+
"""`qaCommands` 블록 전체를 검증. 위반 메시지 리스트를 반환 (비면 안전).
|
|
110
|
+
|
|
111
|
+
런타임이 fail-fast 하려면 반환값이 비어있지 않을 때 `PrepareError` 로 승격.
|
|
112
|
+
여기서는 raise 하지 않고 메시지를 모아서 호출자가 일괄 보고할 수 있게 한다.
|
|
113
|
+
"""
|
|
114
|
+
errors: list[str] = []
|
|
115
|
+
if qa is None:
|
|
116
|
+
return errors # 옵션 필드 — 미선언은 합법.
|
|
117
|
+
if not isinstance(qa, dict):
|
|
118
|
+
return [f"qaCommands must be an object, got {type(qa).__name__}"]
|
|
119
|
+
for category, entries in qa.items():
|
|
120
|
+
if category not in ALLOWED_CATEGORIES:
|
|
121
|
+
errors.append(
|
|
122
|
+
f"qaCommands.{category}: unknown category "
|
|
123
|
+
f"(allowed: {', '.join(ALLOWED_CATEGORIES)})"
|
|
124
|
+
)
|
|
125
|
+
continue
|
|
126
|
+
if not isinstance(entries, list):
|
|
127
|
+
errors.append(
|
|
128
|
+
f"qaCommands.{category} must be an array, got {type(entries).__name__}"
|
|
129
|
+
)
|
|
130
|
+
continue
|
|
131
|
+
for idx, entry in enumerate(entries):
|
|
132
|
+
if not isinstance(entry, dict):
|
|
133
|
+
errors.append(
|
|
134
|
+
f"qaCommands.{category}[{idx}] must be an object, got {type(entry).__name__}"
|
|
135
|
+
)
|
|
136
|
+
continue
|
|
137
|
+
label = entry.get("label")
|
|
138
|
+
cmd = entry.get("cmd")
|
|
139
|
+
if not isinstance(label, str) or not label.strip():
|
|
140
|
+
errors.append(
|
|
141
|
+
f"qaCommands.{category}[{idx}].label must be a non-empty string"
|
|
142
|
+
)
|
|
143
|
+
if not isinstance(cmd, str) or not cmd.strip():
|
|
144
|
+
errors.append(
|
|
145
|
+
f"qaCommands.{category}[{idx}].cmd must be a non-empty string"
|
|
146
|
+
)
|
|
147
|
+
continue
|
|
148
|
+
denied = find_denied_tokens(cmd)
|
|
149
|
+
if denied:
|
|
150
|
+
pretty_label = label if isinstance(label, str) else f"index {idx}"
|
|
151
|
+
errors.append(
|
|
152
|
+
f"qaCommands.{category}[{pretty_label!r}] contains mutation token(s): "
|
|
153
|
+
f"{', '.join(denied)}. Re-declare in check-only form."
|
|
154
|
+
)
|
|
155
|
+
return errors
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def format_errors(errors: Iterable[str]) -> str:
|
|
159
|
+
"""`PrepareError` 등에 그대로 박을 수 있는 멀티라인 문자열."""
|
|
160
|
+
lines = list(errors)
|
|
161
|
+
if not lines:
|
|
162
|
+
return ""
|
|
163
|
+
head = "qaCommands validation failed:"
|
|
164
|
+
body = "\n".join(f" - {line}" for line in lines)
|
|
165
|
+
return f"{head}\n{body}"
|
|
@@ -25,6 +25,7 @@ from datetime import datetime, timezone
|
|
|
25
25
|
from pathlib import Path
|
|
26
26
|
|
|
27
27
|
from okstra_project import upsert_project_json
|
|
28
|
+
from .qa_commands import format_errors as _format_qa_errors, validate_qa_commands
|
|
28
29
|
from .material import (
|
|
29
30
|
build_analysis_material,
|
|
30
31
|
related_tasks_bullets,
|
|
@@ -456,6 +457,22 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
|
|
|
456
457
|
# is preserved by the `: {exc}` suffix and the `raise ... from exc`.
|
|
457
458
|
raise PrepareError(f"project.json upsert failed for {project_root}: {exc}") from exc
|
|
458
459
|
|
|
460
|
+
# `qaCommands` 는 implementation phase verifier 의 QA gate baseline 으로만
|
|
461
|
+
# 쓰이므로 검증도 implementation 진입 시에만 수행한다. 다른 task-type 에서는
|
|
462
|
+
# 잘못된 선언이 있어도 동작에 영향이 없어 fail-fast 할 이유가 없다.
|
|
463
|
+
if inp.task_type == "implementation":
|
|
464
|
+
project_json_path = Path(project_root) / ".project-docs" / "okstra" / "project.json"
|
|
465
|
+
if project_json_path.is_file():
|
|
466
|
+
try:
|
|
467
|
+
project_meta = json.loads(project_json_path.read_text())
|
|
468
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
469
|
+
raise PrepareError(
|
|
470
|
+
f"project.json read failed at {project_json_path}: {exc}"
|
|
471
|
+
) from exc
|
|
472
|
+
qa_errors = validate_qa_commands(project_meta.get("qaCommands"))
|
|
473
|
+
if qa_errors:
|
|
474
|
+
raise PrepareError(_format_qa_errors(qa_errors))
|
|
475
|
+
|
|
459
476
|
# ---- workers resolution ----
|
|
460
477
|
# release-handoff is intentionally single-lead (no worker dispatch, no
|
|
461
478
|
# TeamCreate, no convergence). The profile has no `- Required workers:`
|
|
@@ -75,35 +75,23 @@ After Step 0 the following are guaranteed:
|
|
|
75
75
|
|
|
76
76
|
## Step 1: Resolve PROJECT_ROOT and projectId
|
|
77
77
|
|
|
78
|
+
Prefer `$OKSTRA_PROJECT_INFO` from Step 0 — it already carries `{ok, projectRoot, projectJsonPath, projectId}`. Only re-resolve when that JSON's `ok` is false (cwd outside an okstra project):
|
|
79
|
+
|
|
78
80
|
```bash
|
|
79
|
-
|
|
80
|
-
import sys, json
|
|
81
|
-
from okstra_project import resolve_project_root, ResolverError
|
|
82
|
-
try:
|
|
83
|
-
pr = resolve_project_root(explicit_root="", cwd=".")
|
|
84
|
-
except ResolverError as e:
|
|
85
|
-
print(f"FAIL\t{e}"); raise SystemExit(0)
|
|
86
|
-
print(f"OK\t{pr}")
|
|
87
|
-
PY
|
|
81
|
+
okstra check-project --cwd "$(pwd)"
|
|
88
82
|
```
|
|
89
83
|
|
|
90
|
-
- If `
|
|
91
|
-
- If `
|
|
84
|
+
- If `ok: true`: read `projectRoot` and `projectId` from the JSON.
|
|
85
|
+
- If `ok: false`: ask the user (`AskUserQuestion`, free text) for an absolute project-root path; rerun with `okstra check-project --cwd <their input>`.
|
|
92
86
|
|
|
93
87
|
## Step 2: Choose task — existing vs new
|
|
94
88
|
|
|
95
89
|
```bash
|
|
96
|
-
|
|
97
|
-
import json, sys
|
|
98
|
-
from pathlib import Path
|
|
99
|
-
from okstra_project import list_project_tasks, read_latest_task
|
|
100
|
-
pr = Path(sys.argv[1])
|
|
101
|
-
tasks = list_project_tasks(pr)
|
|
102
|
-
latest = read_latest_task(pr)
|
|
103
|
-
print(json.dumps({'tasks': tasks, 'latest': latest}))
|
|
104
|
-
" "$PROJECT_ROOT"
|
|
90
|
+
okstra task-list --project "$PROJECT_ROOT"
|
|
105
91
|
```
|
|
106
92
|
|
|
93
|
+
Output is JSON `{ok, projectRoot, tasks: [...], latest: {...}|null}`.
|
|
94
|
+
|
|
107
95
|
Use `AskUserQuestion`:
|
|
108
96
|
|
|
109
97
|
- **Label**: "Which task?"
|
|
@@ -151,21 +139,12 @@ silently inherits an unrelated branch you happen to be checked out on.
|
|
|
151
139
|
First, decide whether to ask:
|
|
152
140
|
|
|
153
141
|
```bash
|
|
154
|
-
|
|
155
|
-
import sys
|
|
156
|
-
from pathlib import Path
|
|
157
|
-
sys.path.insert(0, "$OKSTRA_PYTHONPATH".split(":")[0])
|
|
158
|
-
from okstra_ctl import worktree_registry
|
|
159
|
-
from okstra_ctl.ids import _safe_fs_segment
|
|
160
|
-
entry = worktree_registry.lookup(
|
|
161
|
-
_safe_fs_segment("<project-id>"),
|
|
162
|
-
_safe_fs_segment("<task-group>"),
|
|
163
|
-
_safe_fs_segment("<task-id>"),
|
|
164
|
-
)
|
|
165
|
-
print("REUSE" if (entry and entry.status == "active") else "ASK")
|
|
166
|
-
PY
|
|
142
|
+
okstra worktree-lookup "<project-id>" "<task-group>" "<task-id>"
|
|
167
143
|
```
|
|
168
144
|
|
|
145
|
+
Output JSON: `{ok: true, entry: null}` means no active worktree → **ASK**. A
|
|
146
|
+
non-null `entry` with `status: "active"` → **REUSE**.
|
|
147
|
+
|
|
169
148
|
- `REUSE` → the registered worktree is reused; set `base_ref=""` and skip the
|
|
170
149
|
question (the registered base is authoritative).
|
|
171
150
|
- `ASK` → this is the first phase for this task-key. Continue.
|
|
@@ -235,7 +214,7 @@ For prompts whose target worker is NOT in the resolved workers list (after overr
|
|
|
235
214
|
|
|
236
215
|
## Step 6.5: Confirm selections before rendering
|
|
237
216
|
|
|
238
|
-
Before
|
|
217
|
+
Before invoking `okstra render-bundle`, echo the resolved selections back to the user in a compact block so they can verify what will be passed. Show the **effective** values, not the raw input — i.e. when the user left a field blank, display `default` (and where known, the actual default such as `opus` / `sonnet`). Example for an `implementation` run:
|
|
239
218
|
|
|
240
219
|
```
|
|
241
220
|
선택 확인:
|
|
@@ -255,50 +234,40 @@ Before calling `prepare_task_bundle`, echo the resolved selections back to the u
|
|
|
255
234
|
|
|
256
235
|
Then `AskUserQuestion`: `"이대로 진행할까요?"` with options `Proceed` / `Edit`. On `Edit`, return to the relevant Step 6 sub-prompt.
|
|
257
236
|
|
|
258
|
-
## Step 7: Call `
|
|
237
|
+
## Step 7: Call `okstra render-bundle`
|
|
259
238
|
|
|
260
|
-
This is the single
|
|
239
|
+
This is the single command that materializes the entire task bundle. The
|
|
240
|
+
subcommand auto-supplies `--workspace-root` (from `okstra paths --field
|
|
241
|
+
workspace`) and forces `--render-only`, so the current claude session itself
|
|
242
|
+
takes over as lead — no new claude is spawned.
|
|
261
243
|
|
|
262
244
|
```bash
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
task_type="<task-type>",
|
|
280
|
-
brief_path=brief_abs,
|
|
281
|
-
directive="<directive or empty>",
|
|
282
|
-
workers_override="<comma-separated worker list, or empty for profile default; MUST be empty for implementation>",
|
|
283
|
-
lead_model="...", claude_model="...", codex_model="...",
|
|
284
|
-
gemini_model="...", report_writer_model="...",
|
|
285
|
-
related_tasks_raw="...",
|
|
286
|
-
executor="<claude|codex|gemini or empty>", # implementation only; empty → default (claude / OKSTRA_DEFAULT_EXECUTOR)
|
|
287
|
-
base_ref="<chosen-ref-from-step-4.6 or empty when reusing existing worktree>",
|
|
288
|
-
approved_plan_path="<approved-plan-or-empty>",
|
|
289
|
-
clarification_response_path=str(clarification_abs) if clarification_abs else "",
|
|
290
|
-
render_only=True,
|
|
291
|
-
))
|
|
292
|
-
|
|
293
|
-
# Print key paths so the next step can read them.
|
|
294
|
-
ctx = out.ctx
|
|
295
|
-
print("TASK_ROOT", ctx["TASK_ROOT"])
|
|
296
|
-
print("INSTRUCTION_SET_DIR", ctx["INSTRUCTION_SET_DIR"])
|
|
297
|
-
print("LEAD_PROMPT", str(Path(ctx["INSTRUCTION_SET_DIR"]) / "claude-execution-prompt.md"))
|
|
298
|
-
PY
|
|
245
|
+
okstra render-bundle \
|
|
246
|
+
--project-root "<project-root>" \
|
|
247
|
+
--project-id "<project-id>" \
|
|
248
|
+
--task-group "<task-group>" \
|
|
249
|
+
--task-id "<task-id>" \
|
|
250
|
+
--task-type "<task-type>" \
|
|
251
|
+
--task-brief "<brief-path-from-user>" \
|
|
252
|
+
--executor "<claude|codex|gemini or empty for default>" \
|
|
253
|
+
--approved-plan "<approved-plan-or-empty>" \
|
|
254
|
+
--base-ref "<chosen-ref-from-step-4.6 or empty when reusing existing worktree>" \
|
|
255
|
+
--workers "<comma-separated worker list, or empty for profile default; MUST be empty for implementation>" \
|
|
256
|
+
--directive "<directive or empty>" \
|
|
257
|
+
--lead-model "..." --claude-model "..." --codex-model "..." \
|
|
258
|
+
--gemini-model "..." --report-writer-model "..." \
|
|
259
|
+
--related-tasks "..." \
|
|
260
|
+
--clarification-response "<clarification-or-empty>"
|
|
299
261
|
```
|
|
300
262
|
|
|
301
|
-
|
|
263
|
+
Stdout prints `okstra task root:`, `okstra instruction-set:`, and the full
|
|
264
|
+
rendered lead-prompt text (because `--render-only` is on). Parse the labelled
|
|
265
|
+
lines to get `TASK_ROOT`, `INSTRUCTION_SET_DIR`, and from there the
|
|
266
|
+
`claude-execution-prompt.md` path used by Step 8.
|
|
267
|
+
|
|
268
|
+
The python function underneath is mutex-protected (`~/.okstra/.locks/<task-key>.lock`),
|
|
269
|
+
writes `run-context-*.json` + `run-inputs-*.json` + all manifests + discovery
|
|
270
|
+
files, and registers the run in `~/.okstra/recent.jsonl` with status `prepared`.
|
|
302
271
|
|
|
303
272
|
## Step 8: Take over as Claude lead
|
|
304
273
|
|
|
@@ -327,7 +296,7 @@ Inform the user with one short line:
|
|
|
327
296
|
|---|---|---|
|
|
328
297
|
| `okstra runtime missing: ...` | First run on this machine, or stale install | `npx okstra@latest install` once, retry. |
|
|
329
298
|
| `OKSTRA_PYTHONPATH unbound` / `ModuleNotFoundError: okstra_project` | Step 0 was skipped or env vars dropped | Re-run Step 0; never invoke python without exporting `PYTHONPATH=$OKSTRA_PYTHONPATH`. |
|
|
330
|
-
| `task root not found for <key>` | catalog entry stale or task-key typo | Re-run Step 2
|
|
299
|
+
| `task root not found for <key>` | catalog entry stale or task-key typo | Re-run Step 2 (`okstra task-list`) and show available keys |
|
|
331
300
|
| `PROJECT_ROOT 를 해석할 수 없습니다` | cwd outside okstra project, no git toplevel | Ask user for absolute path |
|
|
332
301
|
| `approved plan has no recognised user-approval marker` | `implementation` without proper approval | Ask user to add `APPROVED` to the plan, or pick a different task-type |
|
|
333
302
|
| `task brief not found` | brief-path doesn't resolve relative to cwd or project-root | Re-ask Step 5 |
|
|
@@ -72,19 +72,12 @@ them from the env vars.
|
|
|
72
72
|
## Step 3: Resolve PROJECT_ROOT
|
|
73
73
|
|
|
74
74
|
```bash
|
|
75
|
-
|
|
76
|
-
from okstra_project import resolve_project_root, ResolverError
|
|
77
|
-
try:
|
|
78
|
-
pr = resolve_project_root(explicit_root="", cwd=".")
|
|
79
|
-
print(f"OK\t{pr}")
|
|
80
|
-
except ResolverError as e:
|
|
81
|
-
print(f"FAIL\t{e}")
|
|
82
|
-
PY
|
|
75
|
+
okstra check-project --cwd "$(pwd)"
|
|
83
76
|
```
|
|
84
77
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
78
|
+
The JSON includes `projectRoot` on success. On failure (`ok: false`,
|
|
79
|
+
`stage: "resolve"`) ask the user (`AskUserQuestion`, free text) for an
|
|
80
|
+
absolute project root and rerun with `--cwd <their answer>`.
|
|
88
81
|
|
|
89
82
|
## Step 4: Inspect or create `project.json`
|
|
90
83
|
|
|
@@ -108,12 +101,7 @@ If the file does NOT exist, ask via `AskUserQuestion`:
|
|
|
108
101
|
Then create the file:
|
|
109
102
|
|
|
110
103
|
```bash
|
|
111
|
-
|
|
112
|
-
from pathlib import Path
|
|
113
|
-
from okstra_project import upsert_project_json
|
|
114
|
-
result = upsert_project_json(Path("$PROJECT_ROOT"), "$PROJECT_ID")
|
|
115
|
-
print(result)
|
|
116
|
-
PY
|
|
104
|
+
okstra setup --yes --project-root "$PROJECT_ROOT" --project-id "$PROJECT_ID"
|
|
117
105
|
```
|
|
118
106
|
|
|
119
107
|
## Step 4.5 (optional): customise worktree sync dirs
|
|
@@ -142,6 +130,54 @@ field → built-in default. Only edit when defaults don't cover the
|
|
|
142
130
|
project's working files (e.g. additional cache or local-config dirs
|
|
143
131
|
that must follow the executor into the worktree).
|
|
144
132
|
|
|
133
|
+
## Step 4.7 (optional but recommended): declare project QA commands
|
|
134
|
+
|
|
135
|
+
`implementation`-phase verifiers run an independent QA gate over the
|
|
136
|
+
executor's diff and need a project-wide baseline of check-only
|
|
137
|
+
lint / format / typecheck / test commands. okstra does NOT auto-detect
|
|
138
|
+
tooling from manifest files — declare the commands explicitly in
|
|
139
|
+
`project.json` under `qaCommands`. Skipping this declaration is
|
|
140
|
+
allowed but the verifier will then only run the plan's per-task
|
|
141
|
+
`validation` set, with `qa-command not configured: <category>`
|
|
142
|
+
recorded per missing category in the final report.
|
|
143
|
+
|
|
144
|
+
Each category is an array of `{ "label", "cmd", "language"? }`
|
|
145
|
+
objects. `language` is optional; when present the verifier MAY skip
|
|
146
|
+
commands whose language is not represented in this run's diff.
|
|
147
|
+
|
|
148
|
+
```json
|
|
149
|
+
{
|
|
150
|
+
"projectId": "...",
|
|
151
|
+
"projectRoot": "...",
|
|
152
|
+
"qaCommands": {
|
|
153
|
+
"lint": [{ "label": "cargo clippy", "cmd": "cargo clippy --all-targets -- -D warnings", "language": "rust" }],
|
|
154
|
+
"format": [{ "label": "cargo fmt", "cmd": "cargo fmt --check", "language": "rust" }],
|
|
155
|
+
"typecheck": [{ "label": "tsc", "cmd": "pnpm exec tsc --noEmit", "language": "ts" }],
|
|
156
|
+
"test": [{ "label": "cargo test", "cmd": "cargo test --workspace --locked", "language": "rust" }]
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
**`cmd` deny-list (mutation guard):** the verifier rejects any `cmd`
|
|
162
|
+
containing tokens that imply mutation — declare commands in their
|
|
163
|
+
check-only form only. Denied tokens include:
|
|
164
|
+
|
|
165
|
+
- `--fix` (eslint, ruff), `--write` (prettier), ` -w` (gofmt),
|
|
166
|
+
- ` -u` / `--updateSnapshot` / `--snapshot-update` / `--update-goldens`,
|
|
167
|
+
- `INSTA_UPDATE=` with any value other than `no`,
|
|
168
|
+
- `cargo insta accept`,
|
|
169
|
+
- `npm install` (use `npm ci`), `cargo update`, `pip install -U`,
|
|
170
|
+
- `pnpm add`, `bun add`, `cargo add`.
|
|
171
|
+
|
|
172
|
+
Encountering a denied token aborts the verifier with status
|
|
173
|
+
`contract-violated`; re-declare the command in check-only form to
|
|
174
|
+
recover (e.g. swap `prettier --write` → `prettier --check`).
|
|
175
|
+
|
|
176
|
+
The field is preserved across the runtime's auto-upserts of
|
|
177
|
+
`project.json` — only `projectId`, `projectRoot`, `createdAt`,
|
|
178
|
+
`updatedAt` are runtime-owned, so manual edits to `qaCommands`
|
|
179
|
+
survive every subsequent `okstra setup` / `okstra run` invocation.
|
|
180
|
+
|
|
145
181
|
## Step 4.6 (automatic): project-local Claude settings symlink
|
|
146
182
|
|
|
147
183
|
`okstra setup` (and `okstra run` on its first invocation per project)
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { resolvePaths } from "./paths.mjs";
|
|
3
|
+
|
|
4
|
+
export async function runPythonSnippet({ script, args = [], extraEnv = {} }) {
|
|
5
|
+
const paths = await resolvePaths();
|
|
6
|
+
return new Promise((resolve) => {
|
|
7
|
+
const child = spawn("python3", ["-c", script, ...args], {
|
|
8
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
9
|
+
env: { ...process.env, PYTHONPATH: paths.pythonpath, ...extraEnv },
|
|
10
|
+
});
|
|
11
|
+
let stdout = "";
|
|
12
|
+
let stderr = "";
|
|
13
|
+
child.stdout.on("data", (b) => (stdout += b.toString()));
|
|
14
|
+
child.stderr.on("data", (b) => (stderr += b.toString()));
|
|
15
|
+
child.on("error", (err) => resolve({ code: -1, stdout, stderr: err.message }));
|
|
16
|
+
child.on("close", (code) => resolve({ code, stdout, stderr }));
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export async function runPythonModule({ module, args = [], extraEnv = {}, stdio = "inherit-stdout" }) {
|
|
21
|
+
const paths = await resolvePaths();
|
|
22
|
+
return new Promise((resolve) => {
|
|
23
|
+
const child = spawn("python3", ["-m", module, ...args], {
|
|
24
|
+
stdio: stdio === "capture" ? ["ignore", "pipe", "pipe"] : ["ignore", "inherit", "inherit"],
|
|
25
|
+
env: { ...process.env, PYTHONPATH: paths.pythonpath, ...extraEnv },
|
|
26
|
+
});
|
|
27
|
+
let stdout = "";
|
|
28
|
+
let stderr = "";
|
|
29
|
+
if (stdio === "capture") {
|
|
30
|
+
child.stdout.on("data", (b) => (stdout += b.toString()));
|
|
31
|
+
child.stderr.on("data", (b) => (stderr += b.toString()));
|
|
32
|
+
}
|
|
33
|
+
child.on("error", (err) => resolve({ code: -1, stdout, stderr: err.message }));
|
|
34
|
+
child.on("close", (code) => resolve({ code, stdout, stderr }));
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function emitJsonError({ stage, reason, extra = {} }) {
|
|
39
|
+
process.stdout.write(
|
|
40
|
+
JSON.stringify({ ok: false, stage, reason, ...extra }, null, 2) + "\n",
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { runPythonSnippet, emitJsonError } from "./_python-helper.mjs";
|
|
2
|
+
|
|
3
|
+
const USAGE = `okstra plan-validate — verify an approved-plan file has a recognised approval marker
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
okstra plan-validate <plan-path>
|
|
7
|
+
|
|
8
|
+
Output: JSON { ok: true, planPath } on success.
|
|
9
|
+
On failure: { ok: false, reason } with non-zero exit code.
|
|
10
|
+
|
|
11
|
+
Replaces the \`from okstra_ctl.run import _validate_approved_plan\` heredoc
|
|
12
|
+
pattern. Use this in flows that need to confirm a plan was approved
|
|
13
|
+
without invoking the full prepare_task_bundle pipeline.
|
|
14
|
+
`;
|
|
15
|
+
|
|
16
|
+
function parseArgs(args) {
|
|
17
|
+
const positional = args.filter((a) => !a.startsWith("--"));
|
|
18
|
+
const unknown = args.filter((a) => a.startsWith("--"));
|
|
19
|
+
if (unknown.length) throw new Error(`unknown argument '${unknown[0]}'`);
|
|
20
|
+
if (positional.length !== 1) {
|
|
21
|
+
throw new Error("expected exactly one positional argument: <plan-path>");
|
|
22
|
+
}
|
|
23
|
+
return { planPath: positional[0] };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const SCRIPT = `
|
|
27
|
+
import json, sys
|
|
28
|
+
from okstra_ctl.run import _validate_approved_plan, PrepareError
|
|
29
|
+
|
|
30
|
+
path = sys.argv[1]
|
|
31
|
+
try:
|
|
32
|
+
_validate_approved_plan(path)
|
|
33
|
+
except PrepareError as e:
|
|
34
|
+
print(json.dumps({"ok": False, "stage": "validation", "reason": str(e), "planPath": path}))
|
|
35
|
+
sys.exit(1)
|
|
36
|
+
|
|
37
|
+
print(json.dumps({"ok": True, "planPath": path}))
|
|
38
|
+
`;
|
|
39
|
+
|
|
40
|
+
export async function run(args) {
|
|
41
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
42
|
+
process.stdout.write(USAGE);
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
let opts;
|
|
46
|
+
try {
|
|
47
|
+
opts = parseArgs(args);
|
|
48
|
+
} catch (err) {
|
|
49
|
+
process.stderr.write(`error: ${err.message}\n\n${USAGE}`);
|
|
50
|
+
return 2;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const result = await runPythonSnippet({
|
|
54
|
+
script: SCRIPT,
|
|
55
|
+
args: [opts.planPath],
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
if (result.code !== 0 && !result.stdout.trim()) {
|
|
59
|
+
emitJsonError({
|
|
60
|
+
stage: "python",
|
|
61
|
+
reason: `python invocation failed: ${result.stderr.trim() || "no output"}`,
|
|
62
|
+
});
|
|
63
|
+
return 1;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
process.stdout.write(result.stdout);
|
|
67
|
+
return result.code === 0 ? 0 : result.code;
|
|
68
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { runPythonModule } from "./_python-helper.mjs";
|
|
2
|
+
import { resolvePaths } from "./paths.mjs";
|
|
3
|
+
|
|
4
|
+
const USAGE = `okstra render-bundle — preview the task bundle without launching claude
|
|
5
|
+
|
|
6
|
+
This is a thin shim over \`python3 -m okstra_ctl.run\` that forces
|
|
7
|
+
\`--render-only\`. Use it to preview where prepare_task_bundle() would
|
|
8
|
+
materialize files (INSTRUCTION_SET_DIR, RUN_DIR, lead prompt path,
|
|
9
|
+
final report template path, etc.) for a given task/phase, without
|
|
10
|
+
actually creating the run-state or spawning the launcher.
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
okstra render-bundle --project-root <dir> --project-id <id> \\
|
|
14
|
+
--task-group <tg> --task-id <tid> --task-type <type> \\
|
|
15
|
+
--task-brief <path-or-rel> [--executor <name>] \\
|
|
16
|
+
[--approved-plan <path>] [--workers <list>] [--directive <text>] \\
|
|
17
|
+
[--lead-model <m>] [--claude-model <m>] [--codex-model <m>] \\
|
|
18
|
+
[--gemini-model <m>] [--report-writer-model <m>] \\
|
|
19
|
+
[--related-tasks <list>] [--base-ref <ref>] \\
|
|
20
|
+
[--clarification-response <path>] [--work-category <cat>]
|
|
21
|
+
|
|
22
|
+
All flags pass through unchanged to \`python3 -m okstra_ctl.run\`. The
|
|
23
|
+
shim auto-supplies \`--workspace-root\` (from \`okstra paths --field workspace\`)
|
|
24
|
+
and \`--render-only\`, so callers do not need to set those.
|
|
25
|
+
|
|
26
|
+
Replaces the long \`from okstra_ctl.run import PrepareInputs,
|
|
27
|
+
prepare_task_bundle\` heredoc pattern.
|
|
28
|
+
`;
|
|
29
|
+
|
|
30
|
+
export async function run(args) {
|
|
31
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
32
|
+
process.stdout.write(USAGE);
|
|
33
|
+
return 0;
|
|
34
|
+
}
|
|
35
|
+
const paths = await resolvePaths();
|
|
36
|
+
|
|
37
|
+
// Callers should not pass these; we own them.
|
|
38
|
+
const forbidden = ["--workspace-root", "--render-only"];
|
|
39
|
+
for (const f of forbidden) {
|
|
40
|
+
if (args.includes(f)) {
|
|
41
|
+
process.stderr.write(
|
|
42
|
+
`error: ${f} is set by 'okstra render-bundle' itself — remove it from your args\n`,
|
|
43
|
+
);
|
|
44
|
+
return 2;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const finalArgs = [
|
|
49
|
+
"--workspace-root", paths.workspace,
|
|
50
|
+
"--render-only",
|
|
51
|
+
...args,
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
const result = await runPythonModule({
|
|
55
|
+
module: "okstra_ctl.run",
|
|
56
|
+
args: finalArgs,
|
|
57
|
+
stdio: "inherit-stdout",
|
|
58
|
+
});
|
|
59
|
+
return result.code ?? 0;
|
|
60
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { runPythonSnippet, emitJsonError } from "./_python-helper.mjs";
|
|
2
|
+
|
|
3
|
+
const USAGE = `okstra task-list — list okstra tasks registered in this project
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
okstra task-list Resolve PROJECT_ROOT from cwd, list tasks
|
|
7
|
+
okstra task-list --cwd <dir> Start resolution from <dir> instead of cwd
|
|
8
|
+
okstra task-list --project <dir> Use <dir> directly as PROJECT_ROOT
|
|
9
|
+
|
|
10
|
+
Output: JSON { ok, projectRoot, tasks: [...], latest: {...}|null }.
|
|
11
|
+
|
|
12
|
+
Replaces the common pattern of loading okstra_project and calling
|
|
13
|
+
list_project_tasks + read_latest_task from a python heredoc.
|
|
14
|
+
`;
|
|
15
|
+
|
|
16
|
+
function parseArgs(args) {
|
|
17
|
+
const opts = { cwd: process.cwd(), projectRoot: "" };
|
|
18
|
+
for (let i = 0; i < args.length; i++) {
|
|
19
|
+
const a = args[i];
|
|
20
|
+
if (a === "--cwd") {
|
|
21
|
+
opts.cwd = args[++i];
|
|
22
|
+
if (!opts.cwd) throw new Error("--cwd requires a path");
|
|
23
|
+
} else if (a === "--project") {
|
|
24
|
+
opts.projectRoot = args[++i];
|
|
25
|
+
if (!opts.projectRoot) throw new Error("--project requires a path");
|
|
26
|
+
} else {
|
|
27
|
+
throw new Error(`unknown argument '${a}'`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return opts;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const SCRIPT = `
|
|
34
|
+
import json, sys
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from okstra_project import (
|
|
37
|
+
list_project_tasks, read_latest_task,
|
|
38
|
+
resolve_project_root, ResolverError,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
explicit = sys.argv[1]
|
|
42
|
+
cwd = sys.argv[2]
|
|
43
|
+
try:
|
|
44
|
+
pr = resolve_project_root(explicit_root=explicit, cwd=cwd)
|
|
45
|
+
except ResolverError as e:
|
|
46
|
+
print(json.dumps({"ok": False, "stage": "resolve", "reason": str(e)}))
|
|
47
|
+
sys.exit(2)
|
|
48
|
+
|
|
49
|
+
pr_path = Path(pr)
|
|
50
|
+
print(json.dumps({
|
|
51
|
+
"ok": True,
|
|
52
|
+
"projectRoot": str(pr_path),
|
|
53
|
+
"tasks": list_project_tasks(pr_path),
|
|
54
|
+
"latest": read_latest_task(pr_path),
|
|
55
|
+
}, default=str))
|
|
56
|
+
`;
|
|
57
|
+
|
|
58
|
+
export async function run(args) {
|
|
59
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
60
|
+
process.stdout.write(USAGE);
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
63
|
+
let opts;
|
|
64
|
+
try {
|
|
65
|
+
opts = parseArgs(args);
|
|
66
|
+
} catch (err) {
|
|
67
|
+
process.stderr.write(`error: ${err.message}\n\n${USAGE}`);
|
|
68
|
+
return 2;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const result = await runPythonSnippet({
|
|
72
|
+
script: SCRIPT,
|
|
73
|
+
args: [opts.projectRoot, opts.cwd],
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
if (result.code !== 0 && !result.stdout.trim()) {
|
|
77
|
+
emitJsonError({
|
|
78
|
+
stage: "python",
|
|
79
|
+
reason: `python invocation failed: ${result.stderr.trim() || "no output"}`,
|
|
80
|
+
});
|
|
81
|
+
return 1;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
process.stdout.write(result.stdout);
|
|
85
|
+
return result.code === 0 ? 0 : result.code;
|
|
86
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { runPythonSnippet, emitJsonError } from "./_python-helper.mjs";
|
|
2
|
+
|
|
3
|
+
const USAGE = `okstra task-show — summarize a task's manifest and workflow state
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
okstra task-show <task-key> task-key is project-id:task-group:task-id
|
|
7
|
+
okstra task-show <task-key> --cwd <dir> Resolve PROJECT_ROOT from <dir>
|
|
8
|
+
okstra task-show <task-key> --project <dir> Use <dir> directly as PROJECT_ROOT
|
|
9
|
+
|
|
10
|
+
Output: JSON with taskKey, taskType, taskRoot, brief path, workflow phases,
|
|
11
|
+
artifacts, modelAssignments, latestRunPath. Replaces the
|
|
12
|
+
\`cat task-manifest.json | python3 -c ...\` summarization pattern.
|
|
13
|
+
`;
|
|
14
|
+
|
|
15
|
+
function parseArgs(args) {
|
|
16
|
+
const opts = { cwd: process.cwd(), projectRoot: "", taskKey: "" };
|
|
17
|
+
const positional = [];
|
|
18
|
+
for (let i = 0; i < args.length; i++) {
|
|
19
|
+
const a = args[i];
|
|
20
|
+
if (a === "--cwd") {
|
|
21
|
+
opts.cwd = args[++i];
|
|
22
|
+
if (!opts.cwd) throw new Error("--cwd requires a path");
|
|
23
|
+
} else if (a === "--project") {
|
|
24
|
+
opts.projectRoot = args[++i];
|
|
25
|
+
if (!opts.projectRoot) throw new Error("--project requires a path");
|
|
26
|
+
} else if (a.startsWith("--")) {
|
|
27
|
+
throw new Error(`unknown argument '${a}'`);
|
|
28
|
+
} else {
|
|
29
|
+
positional.push(a);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (positional.length !== 1) {
|
|
33
|
+
throw new Error("expected exactly one positional argument: <task-key>");
|
|
34
|
+
}
|
|
35
|
+
opts.taskKey = positional[0];
|
|
36
|
+
return opts;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const SCRIPT = `
|
|
40
|
+
import json, sys
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
from okstra_project import (
|
|
43
|
+
resolve_project_root, find_task_root, read_task_manifest,
|
|
44
|
+
ResolverError,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
explicit = sys.argv[1]
|
|
48
|
+
cwd = sys.argv[2]
|
|
49
|
+
task_key = sys.argv[3]
|
|
50
|
+
|
|
51
|
+
try:
|
|
52
|
+
pr = resolve_project_root(explicit_root=explicit, cwd=cwd)
|
|
53
|
+
except ResolverError as e:
|
|
54
|
+
print(json.dumps({"ok": False, "stage": "resolve", "reason": str(e)}))
|
|
55
|
+
sys.exit(2)
|
|
56
|
+
|
|
57
|
+
pr_path = Path(pr)
|
|
58
|
+
task_root = find_task_root(pr_path, task_key)
|
|
59
|
+
if task_root is None:
|
|
60
|
+
print(json.dumps({"ok": False, "stage": "task_root_missing", "reason": f"no task root for {task_key}"}))
|
|
61
|
+
sys.exit(1)
|
|
62
|
+
|
|
63
|
+
manifest = read_task_manifest(task_root)
|
|
64
|
+
if manifest is None:
|
|
65
|
+
print(json.dumps({"ok": False, "stage": "manifest_missing", "reason": f"task-manifest.json missing in {task_root}"}))
|
|
66
|
+
sys.exit(1)
|
|
67
|
+
|
|
68
|
+
wf = manifest.get("workflow", {}) or {}
|
|
69
|
+
out = {
|
|
70
|
+
"ok": True,
|
|
71
|
+
"projectRoot": str(pr_path),
|
|
72
|
+
"taskKey": manifest.get("taskKey"),
|
|
73
|
+
"taskType": manifest.get("taskType"),
|
|
74
|
+
"taskRoot": str(task_root),
|
|
75
|
+
"taskBriefPath": manifest.get("taskBriefPath"),
|
|
76
|
+
"workflow": {
|
|
77
|
+
"currentPhase": wf.get("currentPhase"),
|
|
78
|
+
"currentPhaseState": wf.get("currentPhaseState"),
|
|
79
|
+
"lastCompletedPhase": wf.get("lastCompletedPhase"),
|
|
80
|
+
"nextRecommendedPhase": wf.get("nextRecommendedPhase"),
|
|
81
|
+
"routingStatus": wf.get("routingStatus"),
|
|
82
|
+
"phaseStates": wf.get("phaseStates"),
|
|
83
|
+
},
|
|
84
|
+
"resultContract": manifest.get("resultContract"),
|
|
85
|
+
"artifacts": manifest.get("artifacts"),
|
|
86
|
+
"modelAssignments": manifest.get("modelAssignments"),
|
|
87
|
+
"latestRunPath": manifest.get("latestRunPath"),
|
|
88
|
+
}
|
|
89
|
+
print(json.dumps(out, ensure_ascii=False, default=str, indent=2))
|
|
90
|
+
`;
|
|
91
|
+
|
|
92
|
+
export async function run(args) {
|
|
93
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
94
|
+
process.stdout.write(USAGE);
|
|
95
|
+
return 0;
|
|
96
|
+
}
|
|
97
|
+
let opts;
|
|
98
|
+
try {
|
|
99
|
+
opts = parseArgs(args);
|
|
100
|
+
} catch (err) {
|
|
101
|
+
process.stderr.write(`error: ${err.message}\n\n${USAGE}`);
|
|
102
|
+
return 2;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const result = await runPythonSnippet({
|
|
106
|
+
script: SCRIPT,
|
|
107
|
+
args: [opts.projectRoot, opts.cwd, opts.taskKey],
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
if (result.code !== 0 && !result.stdout.trim()) {
|
|
111
|
+
emitJsonError({
|
|
112
|
+
stage: "python",
|
|
113
|
+
reason: `python invocation failed: ${result.stderr.trim() || "no output"}`,
|
|
114
|
+
});
|
|
115
|
+
return 1;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
process.stdout.write(result.stdout);
|
|
119
|
+
return result.code === 0 ? 0 : result.code;
|
|
120
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { runPythonSnippet, emitJsonError } from "./_python-helper.mjs";
|
|
2
|
+
|
|
3
|
+
const USAGE = `okstra worktree-lookup — look up registered worktree for a task-key
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
okstra worktree-lookup <project-id> <task-group> <task-id>
|
|
7
|
+
|
|
8
|
+
The three identifiers are slugified by the runtime before lookup, so
|
|
9
|
+
case/whitespace variants are tolerated.
|
|
10
|
+
|
|
11
|
+
Output: JSON with the registry entry (worktreePath, branch, baseRef,
|
|
12
|
+
status, lastPhase, createdAt). Emits {"ok": true, "entry": null} when
|
|
13
|
+
the task-key has no active worktree.
|
|
14
|
+
|
|
15
|
+
Replaces the okstra_ctl.worktree_registry.lookup() heredoc pattern.
|
|
16
|
+
`;
|
|
17
|
+
|
|
18
|
+
function parseArgs(args) {
|
|
19
|
+
const positional = args.filter((a) => !a.startsWith("--"));
|
|
20
|
+
const unknown = args.filter((a) => a.startsWith("--"));
|
|
21
|
+
if (unknown.length) throw new Error(`unknown argument '${unknown[0]}'`);
|
|
22
|
+
if (positional.length !== 3) {
|
|
23
|
+
throw new Error("expected exactly three positional arguments: <project-id> <task-group> <task-id>");
|
|
24
|
+
}
|
|
25
|
+
return { projectId: positional[0], taskGroup: positional[1], taskId: positional[2] };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const SCRIPT = `
|
|
29
|
+
import json, sys
|
|
30
|
+
from dataclasses import asdict
|
|
31
|
+
from okstra_ctl import worktree_registry
|
|
32
|
+
from okstra_ctl.ids import _safe_fs_segment
|
|
33
|
+
|
|
34
|
+
pid, tg, tid = sys.argv[1], sys.argv[2], sys.argv[3]
|
|
35
|
+
entry = worktree_registry.lookup(
|
|
36
|
+
_safe_fs_segment(pid),
|
|
37
|
+
_safe_fs_segment(tg),
|
|
38
|
+
_safe_fs_segment(tid),
|
|
39
|
+
)
|
|
40
|
+
if entry is None:
|
|
41
|
+
print(json.dumps({"ok": True, "entry": None}))
|
|
42
|
+
else:
|
|
43
|
+
d = asdict(entry)
|
|
44
|
+
# rename for camelCase API symmetry
|
|
45
|
+
out = {
|
|
46
|
+
"ok": True,
|
|
47
|
+
"entry": {
|
|
48
|
+
"taskKey": d.get("task_key"),
|
|
49
|
+
"projectId": d.get("project_id"),
|
|
50
|
+
"taskGroup": d.get("task_group"),
|
|
51
|
+
"taskId": d.get("task_id"),
|
|
52
|
+
"worktreePath": d.get("worktree_path"),
|
|
53
|
+
"branch": d.get("branch"),
|
|
54
|
+
"baseRef": d.get("base_ref"),
|
|
55
|
+
"createdAt": d.get("created_at"),
|
|
56
|
+
"lastPhase": d.get("last_phase"),
|
|
57
|
+
"status": d.get("status"),
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
print(json.dumps(out, ensure_ascii=False, default=str, indent=2))
|
|
61
|
+
`;
|
|
62
|
+
|
|
63
|
+
export async function run(args) {
|
|
64
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
65
|
+
process.stdout.write(USAGE);
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
let opts;
|
|
69
|
+
try {
|
|
70
|
+
opts = parseArgs(args);
|
|
71
|
+
} catch (err) {
|
|
72
|
+
process.stderr.write(`error: ${err.message}\n\n${USAGE}`);
|
|
73
|
+
return 2;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const result = await runPythonSnippet({
|
|
77
|
+
script: SCRIPT,
|
|
78
|
+
args: [opts.projectId, opts.taskGroup, opts.taskId],
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
if (result.code !== 0 && !result.stdout.trim()) {
|
|
82
|
+
emitJsonError({
|
|
83
|
+
stage: "python",
|
|
84
|
+
reason: `python invocation failed: ${result.stderr.trim() || "no output"}`,
|
|
85
|
+
});
|
|
86
|
+
return 1;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
process.stdout.write(result.stdout);
|
|
90
|
+
return result.code === 0 ? 0 : result.code;
|
|
91
|
+
}
|