okstra 0.50.0 → 0.51.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 (57) hide show
  1. package/README.kr.md +8 -7
  2. package/README.md +8 -7
  3. package/bin/okstra +2 -0
  4. package/docs/kr/architecture.md +15 -16
  5. package/docs/kr/cli.md +5 -5
  6. package/docs/project-structure-overview.md +10 -6
  7. package/package.json +1 -1
  8. package/runtime/BUILD.json +2 -2
  9. package/runtime/agents/SKILL.md +15 -11
  10. package/runtime/agents/workers/claude-worker.md +3 -3
  11. package/runtime/agents/workers/codex-worker.md +2 -2
  12. package/runtime/agents/workers/gemini-worker.md +2 -2
  13. package/runtime/bin/lib/okstra/cli.sh +8 -1
  14. package/runtime/bin/lib/okstra/globals.sh +3 -0
  15. package/runtime/bin/lib/okstra/interactive.sh +14 -12
  16. package/runtime/bin/lib/okstra/usage.sh +6 -0
  17. package/runtime/bin/okstra-team-reconcile.sh +28 -0
  18. package/runtime/bin/okstra.sh +2 -0
  19. package/runtime/prompts/launch.template.md +3 -1
  20. package/runtime/prompts/profiles/_common-contract.md +4 -4
  21. package/runtime/prompts/profiles/_implementation-executor.md +2 -2
  22. package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
  23. package/runtime/prompts/profiles/implementation-planning.md +1 -0
  24. package/runtime/prompts/profiles/implementation.md +1 -1
  25. package/runtime/python/okstra_ctl/analysis_packet.py +259 -0
  26. package/runtime/python/okstra_ctl/context_cost.py +308 -0
  27. package/runtime/python/okstra_ctl/migrate.py +2 -12
  28. package/runtime/python/okstra_ctl/paths.py +22 -0
  29. package/runtime/python/okstra_ctl/render.py +284 -125
  30. package/runtime/python/okstra_ctl/render_final_report.py +31 -0
  31. package/runtime/python/okstra_ctl/run.py +507 -245
  32. package/runtime/python/okstra_ctl/sequence.py +2 -5
  33. package/runtime/python/okstra_ctl/team_reconcile.py +131 -0
  34. package/runtime/python/okstra_ctl/wizard.py +129 -133
  35. package/runtime/python/okstra_ctl/worktree.py +13 -5
  36. package/runtime/schemas/final-report-v1.0.schema.json +4 -0
  37. package/runtime/skills/okstra-coding-preflight/SKILL.md +69 -0
  38. package/runtime/skills/okstra-coding-preflight/architecture/hexagonal.md +116 -0
  39. package/runtime/skills/okstra-coding-preflight/clean-code.md +254 -0
  40. package/runtime/skills/okstra-coding-preflight/languages/java.md +64 -0
  41. package/runtime/skills/okstra-coding-preflight/languages/javascript-typescript.md +87 -0
  42. package/runtime/skills/okstra-coding-preflight/languages/kotlin.md +69 -0
  43. package/runtime/skills/okstra-coding-preflight/languages/nodejs.md +66 -0
  44. package/runtime/skills/okstra-coding-preflight/languages/python.md +179 -0
  45. package/runtime/skills/okstra-coding-preflight/languages/rust.md +105 -0
  46. package/runtime/skills/okstra-coding-preflight/languages/sql.md +68 -0
  47. package/runtime/skills/okstra-context-loader/SKILL.md +12 -6
  48. package/runtime/skills/okstra-inspect/SKILL.md +100 -1
  49. package/runtime/skills/okstra-report-writer/SKILL.md +5 -1
  50. package/runtime/skills/okstra-run/SKILL.md +1 -1
  51. package/runtime/skills/okstra-team-contract/SKILL.md +7 -4
  52. package/runtime/templates/reports/final-report.template.md +1 -0
  53. package/runtime/templates/worker-prompt-preamble.md +3 -3
  54. package/src/_python-helper.mjs +3 -3
  55. package/src/context-cost.mjs +27 -0
  56. package/src/install.mjs +1 -0
  57. package/src/uninstall.mjs +1 -0
@@ -142,6 +142,13 @@ while [[ $# -gt 0 ]]; do
142
142
  APPROVE_PLAN_ACK="true"
143
143
  shift
144
144
  ;;
145
+ --implementation-option)
146
+ # 유저가 implementation-planning final-report 에서 고른 Option Candidate
147
+ # 이름. 런타임이 approved-plan frontmatter 의 implementation-option 라인을
148
+ # 이 값으로 채운다. 빈 값이면 implementation 이 Recommended Option 으로 폴백.
149
+ IMPLEMENTATION_OPTION="$(require_option_value --implementation-option "${2-}")"
150
+ shift 2
151
+ ;;
145
152
  --no-plan-verification)
146
153
  # implementation-planning 의 Phase 6 plan-body verification 라운드를
147
154
  # 끈다. 기본값은 활성화. 비활성 시 final-report 상단의 User Approval
@@ -178,7 +185,7 @@ while [[ $# -gt 0 ]]; do
178
185
  printf ' hint: did you mean --task-id?\n' >&2
179
186
  ;;
180
187
  esac
181
- printf ' valid options: --render-only --resume-clarification --yes --workers --lead-model --claude-model --codex-model --gemini-model --report-writer-model --related-tasks --task-type --project-id --project-root --task-group --task-id --task-brief --directive --clarification-response --approved-plan --approve --no-plan-verification -h|--help\n' >&2
188
+ printf ' valid options: --render-only --resume-clarification --yes --workers --lead-model --claude-model --codex-model --gemini-model --report-writer-model --related-tasks --task-type --project-id --project-root --task-group --task-id --task-brief --directive --clarification-response --approved-plan --approve --implementation-option --no-plan-verification -h|--help\n' >&2
182
189
  usage
183
190
  exit 1
184
191
  ;;
@@ -39,6 +39,9 @@ DIRECTIVE=""
39
39
  CLARIFICATION_RESPONSE_PATH=""
40
40
  APPROVED_PLAN_PATH=""
41
41
  APPROVE_PLAN_ACK="false"
42
+ # implementation 전용: 유저가 고른 Option Candidate 이름. 빈 값이면 implementation
43
+ # 이 plan 의 Recommended Option 으로 폴백한다. --implementation-option 으로 설정.
44
+ IMPLEMENTATION_OPTION=""
42
45
  # Phase 6 plan-body verification toggle. Default "true" (round runs).
43
46
  # Flipped to "false" by --no-plan-verification on the CLI.
44
47
  PLAN_VERIFICATION_ENABLED="true"
@@ -59,23 +59,25 @@ resolve_task_root_for_shortcut() {
59
59
  local task_id="$4"
60
60
 
61
61
  local resolved=""
62
- resolved="$(python3 - "$project_root" "$project_id" "$task_group" "$task_id" <<'PY'
63
- import json, os, re, sys
62
+ resolved="$(python3 - "$WORKSPACE_ROOT/scripts" "$project_root" "$project_id" "$task_group" "$task_id" <<'PY'
63
+ import json, os, sys
64
64
  from pathlib import Path
65
65
 
66
- project_root = Path(sys.argv[1])
67
- project_id = sys.argv[2]
68
- task_group = sys.argv[3]
69
- task_id = sys.argv[4]
66
+ # task root 의 slug 경로 구성은 okstra_ctl.paths.task_dir(SSOT) 에 위임한다.
67
+ # 과거 이 heredoc 은 slugify 와 `.okstra/tasks/<slug>/<slug>` 구조를 자체
68
+ # 재구현해 규칙 변경 시 silent drift 위험이 있었다. project-resolver.sh 와
69
+ # 동일하게 $WORKSPACE_ROOT/scripts 를 sys.path 에 올려 패키지를 import 한다.
70
+ sys.path.insert(0, sys.argv[1])
71
+ from okstra_ctl.paths import task_dir
72
+
73
+ project_root = Path(sys.argv[2])
74
+ project_id = sys.argv[3]
75
+ task_group = sys.argv[4]
76
+ task_id = sys.argv[5]
70
77
 
71
78
  requested_key = f"{project_id}:{task_group}:{task_id}"
72
79
  requested_key_ci = requested_key.lower()
73
80
 
74
- def slugify(value: str) -> str:
75
- value = value.lower()
76
- value = re.sub(r"[^a-z0-9]+", "-", value).strip("-")
77
- return value
78
-
79
81
  candidates = []
80
82
 
81
83
  catalog_path = project_root / ".okstra" / "discovery" / "task-catalog.json"
@@ -111,7 +113,7 @@ if catalog_path.is_file():
111
113
  sys.exit(0)
112
114
  candidates.append(str(abs_path))
113
115
 
114
- slug_path = project_root / ".okstra" / "tasks" / slugify(task_group) / slugify(task_id)
116
+ slug_path = task_dir(project_root, task_group, task_id)
115
117
  if slug_path.is_dir():
116
118
  print(f"OK\t{slug_path}")
117
119
  sys.exit(0)
@@ -46,6 +46,12 @@ optional arguments:
46
46
  \`- [ ] Approved\` to \`- [x] Approved\` and appends an approval audit line
47
47
  (timestamp + "CLI --approve"). Use this for scripted/CI flows or when you want a
48
48
  single command to both approve and launch the next phase.
49
+ --implementation-option <name>
50
+ Name of the Option Candidate the user chose from the implementation-planning
51
+ final-report. Only meaningful together with --approved-plan and
52
+ --task-type=implementation. The runtime fills the approved-plan frontmatter
53
+ \`implementation-option:\` line with <name>. When omitted, the implementation run
54
+ falls back to the plan's \`Recommended Option\`.
49
55
  --no-plan-verification
50
56
  Disable the Phase 6 plan-body verification round that runs after the report-writer
51
57
  authors the implementation-planning draft. Default: enabled. Only meaningful with
@@ -0,0 +1,28 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # okstra-team-reconcile.sh — flip dead-pane stale-active team members to
4
+ # inactive so the lead's `TeamDelete()` can disband the team in one shot.
5
+ #
6
+ # A Claude Code team member clears its own `isActive` flag in
7
+ # `~/.claude/teams/<team>/config.json` when its `Agent()` dispatch returns. A
8
+ # member whose tmux pane died WITHOUT that flip stays `isActive: true`, and
9
+ # `TeamDelete` then refuses the whole team ("active members remain") — an error
10
+ # no re-sent `shutdown_request` can clear, since the addressee is already gone.
11
+ # This reconciles exactly that case; it never touches a live-pane member, the
12
+ # lead, or a member with no recorded pane (those are left for graceful
13
+ # shutdown). It no-ops when tmux is unavailable or nothing is stale.
14
+ #
15
+ # Usage: okstra-team-reconcile.sh [--list] <team-name>
16
+ # --list report what WOULD be deactivated; do not write (alias --dry-run).
17
+ #
18
+ # Failures are non-fatal to the run — teardown must never block on this.
19
+ set -u
20
+
21
+ _dir="$(cd -P "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
22
+ # Logic lives in okstra_ctl.team_reconcile. In the repo layout the package is a
23
+ # bin-sibling (scripts/okstra_ctl); in the installed layout it is under
24
+ # $OKSTRA_HOME/lib/python. Put both on PYTHONPATH so either resolves.
25
+ home="${OKSTRA_HOME:-$HOME/.okstra}"
26
+ export PYTHONPATH="${_dir}:${home}/lib/python${PYTHONPATH:+:$PYTHONPATH}"
27
+
28
+ exec python3 -c 'import sys; from okstra_ctl.team_reconcile import main; sys.exit(main(sys.argv[1:]))' "$@"
@@ -80,6 +80,7 @@ okstra execution summary:
80
80
  executor (implementation only): ${EXECUTOR_OVERRIDE:-default(claude)}
81
81
  approved plan: ${APPROVED_PLAN_PATH:-None}
82
82
  approve ack (CLI 승인 의사): ${APPROVE_PLAN_ACK}
83
+ implementation option (선택 옵션): ${IMPLEMENTATION_OPTION:-None(fallback to Recommended Option)}
83
84
  related tasks: ${RELATED_TASKS_RAW:-None}
84
85
  CONFIRM_EOF
85
86
  printf 'Continue? [y/yes]: ' >&2
@@ -117,6 +118,7 @@ PY_ARGS=(
117
118
  [[ -n "${RELATED_TASKS_RAW-}" ]] && PY_ARGS+=(--related-tasks "$RELATED_TASKS_RAW")
118
119
  [[ -n "${APPROVED_PLAN_PATH-}" ]] && PY_ARGS+=(--approved-plan "$APPROVED_PLAN_PATH")
119
120
  [[ "$APPROVE_PLAN_ACK" == "true" ]] && PY_ARGS+=(--approve)
121
+ [[ -n "${IMPLEMENTATION_OPTION-}" ]] && PY_ARGS+=(--implementation-option "$IMPLEMENTATION_OPTION")
120
122
  [[ -n "${CLARIFICATION_RESPONSE_PATH-}" ]] && PY_ARGS+=(--clarification-response "$CLARIFICATION_RESPONSE_PATH")
121
123
  [[ -n "${WORK_CATEGORY-}" ]] && PY_ARGS+=(--work-category "$WORK_CATEGORY")
122
124
  [[ -n "${BASE_REF-}" ]] && PY_ARGS+=(--base-ref "$BASE_REF")
@@ -39,6 +39,8 @@ Emit one `PROGRESS: <phase-id> <verb-phrase>` line as plain user-facing text at
39
39
 
40
40
  - Task manifest: `{{TASK_MANIFEST_RELATIVE_PATH}}`
41
41
  - Run manifest: `{{RUN_MANIFEST_RELATIVE_PATH}}`
42
+ - Active run context: `{{ACTIVE_RUN_CONTEXT_RELATIVE_PATH}}`
43
+ - Analysis packet: `{{ANALYSIS_PACKET_RELATIVE_PATH}}`
42
44
 
43
45
  ## Session
44
46
 
@@ -82,7 +84,7 @@ Emit one `PROGRESS: <phase-id> <verb-phrase>` line as plain user-facing text at
82
84
  ## Available MCP Servers
83
85
 
84
86
  {{AVAILABLE_MCP_SERVERS}}
85
- - The full usage policy and per-phase rules live in the task brief's `## Available MCP Servers` section. Read them there before dispatching workers and inject only the one-line pointer below into each worker prompt (the brief is already in every worker's [Required reading], so verbatim copy is redundant): `**MCP servers:** follow the task brief's "## Available MCP Servers" section (already in your Required reading).`
87
+ - The full usage policy and per-phase rules live in the analysis packet's `Available MCP Servers` extract. Inject only the one-line pointer below into each analysis-worker prompt: `**MCP servers:** follow the analysis packet's "Available MCP Servers" section (already in your Required reading).`
86
88
  - **Invocation rule (forward to every worker prompt)**: MCP tools are addressed by their tool name through the host's tool interface — **never via `Bash`**. Claude-side workers call the tool directly (e.g. `mcp__<server>__<tool>`). Codex/Gemini workers call through their CLI's own MCP transport (e.g. `codex mcp call ...`). Running the tool name as a shell command is a contract violation and will always fail regardless of permission grants.
87
89
  - Codex worker and Gemini worker run external CLIs; they can only use these MCP servers if their own CLI configs mirror them. If not, instruct the worker to record `MCP not available in this CLI` in its `Missing Information or Assumptions` block rather than guessing or shell-falling-back.
88
90
  - MCP queries are evidence-grade. Cite server, table, and the SELECT used in worker output. MCP must NOT be used as a write path in any phase, including `implementation`.
@@ -39,11 +39,11 @@ profile document.
39
39
  - Run-end team teardown (shared — runs AFTER Phase 7 persistence/token collection, BEFORE the pane disposition step below):
40
40
  - The lead created the worker team in Phase 3 (`TeamCreate(team_name: "okstra-<task-key>")`). Worker teammates are NOT reclaimed on their own — without an explicit teardown they linger in the FleetView roster across this and later runs in the session. The lead MUST release them once the run's work is done.
41
41
  - This step is **automatic and silent** — NO user prompt (workers are idle sessions that have already delivered their results; there is nothing for the user to preserve). It runs only when team-state's `teamCreate.status == "ok"` (Teams mode was actually used); in the no-`team_name` fallback there is no team to delete, so silent-skip.
42
+ - Why a reconcile step exists: each worker clears its own `isActive` flag in `~/.claude/teams/<team>/config.json` when its `Agent()` dispatch returns, so by Phase 7 every worker is normally already inactive and `TeamDelete()` succeeds immediately. The one failure mode is a worker whose tmux pane died WITHOUT clearing the flag (e.g. killed mid-turn): it stays `isActive: true`, and `TeamDelete` then refuses the entire team with an "active members" error that no amount of re-sending `shutdown_request` can clear — the addressee is already gone. `okstra-team-reconcile.sh` deterministically flips exactly those dead-pane stale-active members to inactive (never a live-pane member, never the lead).
42
43
  - Sequence (token-usage collection MUST already be complete — `TeamDelete` removes `~/.claude/teams/<team>/` + `~/.claude/tasks/<team>/` but NOT the `~/.claude/projects/` jsonls Phase 7 reads, yet the read MUST precede teardown):
43
- 1. Read `~/.claude/teams/okstra-<task-key>/config.json` and, for every `members` entry whose name is not the lead, `SendMessage(to: <name>, message: { type: "shutdown_request" })` to terminate it gracefully.
44
- 2. These workers already delivered their results and terminated when their `Agent()` dispatch returned (the lead's completion evidence is the returned output + the existing result/final-report file, not a teardown ack) a terminated session emits NO shutdown confirmation. Treat `shutdown_request` as best-effort (fire-and-forget); the lead MUST NOT block waiting for acks from addressed teammates. Proceed immediately to step 3.
45
- 3. Call `TeamDelete()` the single synchronization point for teardown. If it errors with an active-members message, one teammate is genuinely still shutting down: wait briefly, retry `TeamDelete()` once, then proceed regardless of the result. NEVER loop or re-send `shutdown_request`; teardown must never block run completion once the work and final report already exist.
46
- - Report it in one short line (e.g. `worker 6명 종료 + 팀 해제`) and proceed. Emit `PROGRESS: phase-7-teardown disbanding team` immediately before step 1.
44
+ 1. Run `$HOME/.okstra/bin/okstra-team-reconcile.sh "okstra-<task-key>"` exactly once. It flips dead-pane stale-active members to inactive, and no-ops when tmux is unavailable or nothing is stale. Do NOT loop it.
45
+ 2. Call `TeamDelete()` the single synchronization point for teardown. If it STILL errors with an active-members message, one worker pane is genuinely still live (rare at Phase 7, since every `Agent()` dispatch has already returned): send that one member a structured `SendMessage(to: <name>, message: { type: "shutdown_request" })` — the `message` MUST be the object literal shown, NEVER a JSON string stuffed into a text field (a stringified payload is delivered as a plain message and the shutdown protocol never fires) — wait briefly, then retry `TeamDelete()` once and proceed regardless of the result. NEVER loop, never use `TaskStop` (teammates are not background tasks — `TaskStop` 404s on a member address), and never let teardown block run completion once the work and final report already exist.
46
+ - Report it in one short line (e.g. `stale 멤버 1명 정리 + 해제`, or just `worker 해제` when nothing was stale) and proceed. Emit `PROGRESS: phase-7-teardown disbanding team` immediately before step 1.
47
47
  - Phase wrap-up — okstra pane disposition (shared, MUST be the *last* step before returning control to the user):
48
48
  - At run end the only residual okstra panes are the LAST phase's (e.g. the `report-writer-worker` agent pane and any codex/gemini trace pane). `okstra-trace-cleanup.sh --list --run-dir "<RUN_DIR>"` returns one tab-separated `<pane_id>\t<pane_title>` line per residual okstra pane (worker-agent + trace) for this run.
49
49
  - When `<RUN_DIR>/state/lead-pane.id` is non-empty, after the final-report file has been written and the routing recommendation has been issued, the lead MUST run `$HOME/.okstra/bin/okstra-trace-cleanup.sh --list --run-dir "<RUN_DIR>"` exactly once. The output lists every residual okstra pane (worker-agent + trace) for this run, never the lead's own pane.
@@ -19,9 +19,9 @@ until Phase 5 ends, then drop from active context for Phase 6/7.
19
19
  ## Pre-implementation context exploration (executor before first edit)
20
20
 
21
21
  - **Coding-conventions preflight (BLOCKING — runs before the first `Edit` / `Write`, and binds the TDD loop below):** load the applicable coding conventions for every language the diff will touch, then state in ONE line which conventions apply (e.g. `Applying TS + hexagonal overlay; domain at src/domains/*/domain/`). Lint/test green is necessary but NOT sufficient — self-mocked tests, interaction-only assertions, and untruthful names all pass a green pipeline; this gate is what keeps them out of the diff.
22
- - **Language-specific rules load per situation — never inline them here.** Detect each touched file's language (extension / project manifest) and load the matching reference from the project's coding-conventions skill: `coding-preflight`, when installed, routes `languages/<lang>.md` (mock/spy API, idioms, test framework) + `clean-code.md` + any `architecture/*` overlay. For a ports-and-adapters / NestJS-hex layout (`domain/` + `ports/` + `adapters/`, `*.port.*`), load the hexagonal overlay too. This per-language split is the skill's job — the executor does not carry a multi-language block in context.
22
+ - **Language-specific rules load per situation — never inline them here.** Detect each touched file's language (extension / project manifest) and load the matching reference by reading okstra's installed coding-conventions files directly at `~/.claude/skills/okstra-coding-preflight/` (placed there by `okstra install`): read `languages/<lang>.md` (mock/spy API, idioms, test framework) + `clean-code.md` + any `architecture/*` overlay via the Read tool by absolute path. The skill is `user-invocable: false`, so do NOT rely on Skill-tool auto-invocation — read the files directly. For a ports-and-adapters / NestJS-hex layout (`domain/` + `ports/` + `adapters/`, `*.port.*`), load the hexagonal overlay too. This per-language split is the skill's job — the executor does not carry a multi-language block in context.
23
23
  - **Language-agnostic principles that ALWAYS bind (the TDD loop below MUST satisfy them):** (1) no self-mocking of the SUT — stub/spy only injected collaborators, never the subject's own methods; (2) behavioral assertions on outcomes (return value, state, persisted rows, events, boundary calls) — never `toHaveBeenCalled*` on an internal helper as the only/primary assertion; (3) truthful names — a `get*` / `find*` that writes/inserts, or a name encoding the caller's use-case (`*ForInit`) or hiding a domain rule (`findValid*`), is a defect; (4) single-purpose functions ≤50 effective lines, plain-English readability.
24
- - **Graceful degradation (end-user, or codex / gemini executor runtimes where no coding-conventions skill is reachable):** do NOT skip the gate — apply the agnostic principles above plus the project's own `CLAUDE.md` / `CONTRIBUTING` / formatter+lint config, and record `coding-conventions: skill-unavailable → applied <project rules + agnostic principles>` in the final report. Never claim a skill read that did not happen.
24
+ - **Graceful degradation (codex / gemini executor runtimes, or any runtime where the `~/.claude/skills/okstra-coding-preflight/` files are absent or unreadable):** do NOT skip the gate — apply the agnostic principles above plus the project's own `CLAUDE.md` / `CONTRIBUTING` / formatter+lint config, and record `coding-conventions: skill-unavailable → applied <project rules + agnostic principles>` in the final report. Never claim a skill read that did not happen.
25
25
  - **Mandatory TDD loop**: BEFORE the first `Edit` or `Write` call, the executor MUST apply a red-green-refactor loop for every code change in this run. This is required; skipping it is a `contract-violated` outcome. This governs HOW each step is executed (failing test first → minimal implementation → refactor); it does not override the approved plan's WHAT/file scope.
26
26
  - Order of operations per plan step: (1) write/extend the test that captures the step's acceptance criterion and confirm it fails for the right reason, (2) commit the failing test (`test(<scope>): ...`), (3) implement the minimum change to make it pass, (4) commit the implementation (`feat|fix(<scope>): ...`), (5) refactor without changing behaviour and commit separately if any cleanup is made (`refactor(<scope>): ...`). The failing-then-passing transition between steps (2) and (4) is the `TDD evidence` required by the final report.
27
27
  - Doc-only / config-only / pure-rename steps that have no observable runtime behaviour are exempt from the failing-test requirement, but the executor MUST cite the exemption per step in the final report (`TDD exemption: <reason>`).
@@ -66,7 +66,7 @@ The final report keeps both — executor's `Validation evidence` AND each verifi
66
66
  Re-running commands proves the diff *builds and passes*; it does NOT prove the diff is *well-designed*. Lint/test green is necessary but not sufficient — self-mocked tests, interaction-only assertions, and untruthful names all survive a green pipeline. This gate is the filter for exactly those defects, so the executor's design errors are caught here instead of in post-merge PR review. It is a real gate, not a checklist: it enumerates the full diff and a blocking hit forces `FAIL`.
67
67
 
68
68
  - **Scope (no silent sampling).** Enumerate every changed source/test file via `git diff --name-only <base>...HEAD` and review each one. Skipping a changed file silently is a `contract-violated` outcome. If a file's language has no reference and is not covered by the agnostic checks below, record `design-review skipped: <file> (language=<x> no reference)` — never pass it silently.
69
- - **Load the same conventions the executor used, per language.** For each touched language load the coding-conventions reference (`coding-preflight` `languages/<lang>.md` + `clean-code.md` + the hexagonal overlay when the layout matches); degrade to the agnostic checks below when no skill is reachable. The verifier does NOT inline language rules — it loads them per situation, identical to the executor preflight.
69
+ - **Load the same conventions the executor used, per language.** For each touched language load the coding-conventions reference by reading `~/.claude/skills/okstra-coding-preflight/languages/<lang>.md` + `clean-code.md` + the `architecture/hexagonal.md` overlay when the layout matches; degrade to the agnostic checks below when those files are not readable. The verifier does NOT inline language rules — it loads them per situation, identical to the executor preflight.
70
70
  - **Blocking checks (any hit → verdict `FAIL`, cited `path:line` + rule name, recommended fix recorded — the verifier does NOT apply it):**
71
71
  - **Self-mocking:** a test for `Foo` stubs/spies a method on the `Foo` instance under test (`jest.spyOn(sut, ...)`, `spyOn(FooService.prototype, ...)` in `foo.*.spec.*`, `vi.mocked(sut)` + stub). Mocking injected collaborators is fine.
72
72
  - **Interaction-only assertion:** a test whose only/primary assertion is `toHaveBeenCalled*` / `toHaveBeenCalledTimes` on an internal helper or a non-side-effecting collaborator, with no assertion on the returned value / resulting state / persisted row / emitted event.
@@ -76,6 +76,7 @@
76
76
  - validation checklist (pre / mid / post) — each item is an exact command or observable outcome
77
77
  - rollback strategy — exact revert path (commits, flags, migrations) and the signal that triggers rollback
78
78
  - the YAML frontmatter MUST include the line `approved: false` (report-writer always emits the unflipped value). The user authorises the next `implementation` run by flipping it to `approved: true` (manual edit or `--approve` CLI). Do NOT recreate any `User Approval Request` body block — the validator fails reports that contain one (see `validators/validate-run.py` deprecated patterns).
79
+ - the YAML frontmatter MUST include the line `implementation-option:` directly under `approved:` (report-writer always emits it with an **empty value**). The user selects which Option Candidate the next `implementation` run executes by filling this line with that option's name (manual edit or `--implementation-option <name>` CLI). When left empty, the `implementation` run falls back to the `Recommended Option`.
79
80
  - **the frontmatter `approved: false` line is rendered unconditionally; if the plan-body verification gate (§5.5.9) returns `blocked-by-disagreement` or `aborted-non-result`, the writer MUST keep `approved: false` and the validator refuses any report that ships with `approved: true` under such a gate result.**
80
81
  - every ambiguity flagged during pre-planning that the user must resolve before approval registered as a `Blocks=approval` row in the `## 1. Clarification Items` table (do NOT create a separate `Open Questions` block under `4.5.x` — the unified table is the single home)
81
82
  - **§5.5.9 Plan Body Verification (BLOCKING).** After report-writer finishes the draft, the lead MUST run a worker peer-review round on the consolidated plan body (sections 4.5.1 – 4.5.7) and populate `### 5.5.9 Plan Body Verification` in the final report. The round protocol, plan-item ID scheme (`P-Opt-*` / `P-Step-*` / `P-Dep-*` / `P-Val-*` / `P-Rb-*`), verdict semantics, gate-result classification, and dissent log format are defined in `skills/okstra-convergence/SKILL.md` "Plan-body verification mode". The four gate-result values are `passed`, `passed-with-dissent`, `blocked-by-disagreement`, `aborted-non-result`. When the gate would have been `blocked-by-disagreement` or `aborted-non-result`, the lead MUST NOT silently flip it to one of the passing values to "unblock" the run — that is a contract violation. When `convergence.adversarial=true` (the default for this phase), this round uses the adversarial posture — verifiers confirm cited paths/commands and the burden of proof is on the plan — but the gate threshold stays `majority-disagree` (see that skill's §"Adversarial plan-body posture").
@@ -19,7 +19,7 @@
19
19
  - the run brief MUST cite `--approved-plan <path>` pointing to a `final-report.md` produced by a prior `implementation-planning` run located under `runs/implementation-planning/.../reports/final-report.md`
20
20
  - that file's YAML frontmatter MUST carry `approved: true`. report-writer emits `approved: false` by default; the user flips it to `true` to authorise this run. Free-form approvals such as "lgtm" / "go ahead" / paraphrased confirmations are NOT accepted; re-edit the plan file's frontmatter to `approved: true` before invoking implementation, or pass `--approve` so the CLI flips it on the user's behalf (`okstra_ctl.run._apply_cli_approval`).
21
21
  - The `--approve` flag is meaningful ONLY with `--task-type implementation` and `--approved-plan <path>`; any other use raises `PrepareError`. Idempotent — re-running with `approved: true` already set appends an audit line but does NOT re-toggle.
22
- - the file's `Recommended option` and its bite-sized step list become the authoritative scope for this run; deviations must be justified in the final report and routed back to a new `implementation-planning` run rather than silently expanded.
22
+ - the authoritative scope for this run is the Option Candidate named by the YAML frontmatter `implementation-option:` field. **If `implementation-option:` is empty, fall back to the plan's `Recommended Option`** (this is a soft fallback, not a hard block). The chosen option's bite-sized step list becomes the authoritative scope; deviations must be justified in the final report and routed back to a new `implementation-planning` run rather than silently expanded. If the chosen option name does not match any heading under `Option Candidates`, record it as a deviation.
23
23
  - Task worktree (provisioned by `okstra-ctl` at the first phase's run-prep time, reused for every subsequent phase of this task-key):
24
24
  - Status: `{{EXECUTOR_WORKTREE_STATUS}}` (one of: `created` | `reused` | `skipped-in-worktree` | `skipped-not-git`)
25
25
  - Working tree path: `{{EXECUTOR_WORKTREE_PATH}}` — when status is `created` or `reused`, this is the task's `git worktree` rooted at `~/.okstra/worktrees/<project>/<task-group>/<task-id>/`. When skipped, this is the caller's `project_root`.
@@ -0,0 +1,259 @@
1
+ """Build the compact analysis-worker input packet for a task run."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from pathlib import Path
6
+
7
+
8
+ BRIEF_SECTIONS = (
9
+ "Identity",
10
+ "Request Summary",
11
+ "Current Context",
12
+ "Evidence and Source Materials",
13
+ "Task-Type Focus",
14
+ "Constraints and Risks",
15
+ "Out of Scope",
16
+ "Configuration References and Expected Values",
17
+ "Deployment Manifests and Expected Values",
18
+ "Questions for Workers",
19
+ "Expected Outputs",
20
+ "Task Continuity Notes",
21
+ "Available MCP Servers",
22
+ )
23
+ PROFILE_SECTIONS = (
24
+ "Primary focus areas",
25
+ "Expected output emphasis",
26
+ "Non-goals",
27
+ "Clarification request policy",
28
+ )
29
+ CLARIFICATION_SECTIONS = (
30
+ "Clarification Items",
31
+ "Clarification Response Carried In From Previous Run",
32
+ )
33
+ _FRONTMATTER_RE = re.compile(r"\A---\n(?P<body>.*?)\n---\n", re.DOTALL)
34
+
35
+
36
+ def build_analysis_packet(
37
+ *,
38
+ task_key: str,
39
+ task_type: str,
40
+ task_brief_path: Path,
41
+ analysis_profile_path: Path,
42
+ reference_expectations_path: Path,
43
+ clarification_response_path: Path | None,
44
+ directive: str,
45
+ instruction_set_relative_path: str,
46
+ ) -> str:
47
+ """Return the primary compact input for Claude/Codex/Gemini analysers."""
48
+ brief_text = task_brief_path.read_text(encoding="utf-8")
49
+ profile_text = analysis_profile_path.read_text(encoding="utf-8")
50
+ reference_text = _read_optional(reference_expectations_path)
51
+ clarification_text = (
52
+ _read_optional(clarification_response_path)
53
+ if clarification_response_path else ""
54
+ )
55
+ parts = [_packet_frontmatter(brief_text, task_key)]
56
+ parts.extend(
57
+ _intro_block(
58
+ task_key,
59
+ instruction_set_relative_path,
60
+ bool(clarification_response_path),
61
+ )
62
+ )
63
+ parts.extend(_brief_block(brief_text))
64
+ parts.extend(_profile_block(task_type, profile_text))
65
+ parts.extend(_reference_block(reference_text))
66
+ parts.extend(_clarification_block(clarification_text))
67
+ parts.extend(_directive_block(directive))
68
+ return "\n".join(part.rstrip() for part in parts).rstrip() + "\n"
69
+
70
+
71
+ def _packet_frontmatter(brief_text: str, task_key: str) -> str:
72
+ frontmatter = _extract_frontmatter(brief_text)
73
+ if frontmatter:
74
+ return (
75
+ f"---\n{frontmatter}\n"
76
+ 'packetVersion: "1.0"\n'
77
+ "packetRole: analysis-worker-primary\n---"
78
+ )
79
+ return (
80
+ "---\n"
81
+ f'title: OKSTRA Analysis Packet - {task_key}\n'
82
+ 'packetVersion: "1.0"\n'
83
+ "packetRole: analysis-worker-primary\n"
84
+ "---"
85
+ )
86
+
87
+
88
+ def _intro_block(
89
+ task_key: str,
90
+ instruction_set: str,
91
+ has_clarification: bool,
92
+ ) -> list[str]:
93
+ lines = [
94
+ f"# OKSTRA Analysis Packet - {task_key}",
95
+ "",
96
+ "## Packet Role",
97
+ "",
98
+ "- This packet is the primary required reading for analysis workers.",
99
+ "- It extracts task-specific material from the source files listed below.",
100
+ "- Read source files only for evidence verification or missing detail.",
101
+ "",
102
+ "## Source Files",
103
+ "",
104
+ f"- Task brief: `{instruction_set}/task-brief.md`",
105
+ f"- Analysis profile: `{instruction_set}/analysis-profile.md`",
106
+ f"- Analysis material: `{instruction_set}/analysis-material.md`",
107
+ f"- Reference expectations: `{instruction_set}/reference-expectations.md`",
108
+ ]
109
+ if has_clarification:
110
+ lines.append(
111
+ f"- Clarification response: `{instruction_set}/clarification-response.md`"
112
+ )
113
+ return lines
114
+
115
+
116
+ def _brief_block(brief_text: str) -> list[str]:
117
+ return [
118
+ "",
119
+ "## Task-Specific Brief Extract",
120
+ "",
121
+ _extract_sections(brief_text, BRIEF_SECTIONS),
122
+ ]
123
+
124
+
125
+ def _profile_block(task_type: str, profile_text: str) -> list[str]:
126
+ profile_sections = _extract_sections(profile_text, PROFILE_SECTIONS)
127
+ profile_bullets = _extract_bullet_sections(profile_text, PROFILE_SECTIONS)
128
+ profile_focus = "\n\n".join(
129
+ part for part in (profile_sections, profile_bullets)
130
+ if part and not part.startswith("- No matching")
131
+ )
132
+ return [
133
+ "",
134
+ "## Phase Focus Extract",
135
+ "",
136
+ f"- Task Type: `{task_type}`",
137
+ "",
138
+ _extract_profile_prelude(profile_text),
139
+ "",
140
+ profile_focus or "- No matching source sections were available.",
141
+ ]
142
+
143
+
144
+ def _reference_block(reference_text: str) -> list[str]:
145
+ body = reference_text.strip() or "- No reference expectations content was available."
146
+ return ["", "## Reference Expectations", "", body]
147
+
148
+
149
+ def _clarification_block(clarification_text: str) -> list[str]:
150
+ if not clarification_text.strip():
151
+ return []
152
+ return [
153
+ "",
154
+ "## Clarification Carry-In Extract",
155
+ "",
156
+ _extract_sections(clarification_text, CLARIFICATION_SECTIONS),
157
+ ]
158
+
159
+
160
+ def _directive_block(directive: str) -> list[str]:
161
+ if not directive:
162
+ return []
163
+ return ["", "## Directive", "", directive.strip()]
164
+
165
+
166
+ def _extract_frontmatter(text: str) -> str:
167
+ match = _FRONTMATTER_RE.match(text)
168
+ return match.group("body").rstrip() if match else ""
169
+
170
+
171
+ def _extract_profile_prelude(text: str) -> str:
172
+ lines = []
173
+ for line in text.splitlines():
174
+ if line.startswith(("{{INCLUDE:", "## ", "<!--", "- Team contract")):
175
+ break
176
+ if _top_level_bullet_heading(line) in PROFILE_SECTIONS:
177
+ break
178
+ if line.strip():
179
+ lines.append(line)
180
+ return "\n".join(lines).strip() or "- No profile prelude was available."
181
+
182
+
183
+ def _extract_sections(text: str, headings: tuple[str, ...]) -> str:
184
+ sections = _section_map(text)
185
+ out = []
186
+ for heading in headings:
187
+ body = sections.get(heading, "").strip()
188
+ if body:
189
+ out.append(f"### {heading}\n\n{body}")
190
+ return "\n\n".join(out) or "- No matching source sections were available."
191
+
192
+
193
+ def _extract_bullet_sections(text: str, headings: tuple[str, ...]) -> str:
194
+ heading_set = set(headings)
195
+ captured: list[tuple[str, list[str]]] = []
196
+ current_heading = ""
197
+ current_lines: list[str] = []
198
+
199
+ def flush_current() -> None:
200
+ nonlocal current_heading, current_lines
201
+ if current_heading and current_lines:
202
+ captured.append((current_heading, current_lines))
203
+ current_heading = ""
204
+ current_lines = []
205
+
206
+ for line in _strip_frontmatter(text).splitlines():
207
+ bullet_heading = _top_level_bullet_heading(line)
208
+ if bullet_heading:
209
+ if bullet_heading in heading_set:
210
+ flush_current()
211
+ current_heading = bullet_heading
212
+ current_lines = [line]
213
+ continue
214
+ flush_current()
215
+ continue
216
+ if current_heading:
217
+ if line.startswith(" ") or not line.strip():
218
+ current_lines.append(line)
219
+ else:
220
+ flush_current()
221
+ flush_current()
222
+
223
+ out = []
224
+ for heading, lines in captured:
225
+ body = "\n".join(lines).strip()
226
+ if body:
227
+ out.append(f"### {heading}\n\n{body}")
228
+ return "\n\n".join(out)
229
+
230
+
231
+ def _top_level_bullet_heading(line: str) -> str:
232
+ if not line.startswith("- ") or ":" not in line:
233
+ return ""
234
+ label = line[2:].split(":", 1)[0].strip().strip("*")
235
+ return label
236
+
237
+
238
+ def _section_map(text: str) -> dict[str, str]:
239
+ result: dict[str, list[str]] = {}
240
+ current = ""
241
+ for line in _strip_frontmatter(text).splitlines():
242
+ if line.startswith("## "):
243
+ current = line[3:].strip()
244
+ result.setdefault(current, [])
245
+ continue
246
+ if current:
247
+ result[current].append(line)
248
+ return {key: "\n".join(lines).strip() for key, lines in result.items()}
249
+
250
+
251
+ def _strip_frontmatter(text: str) -> str:
252
+ match = _FRONTMATTER_RE.match(text)
253
+ return text[match.end():] if match else text
254
+
255
+
256
+ def _read_optional(path: Path | None) -> str:
257
+ if path and path.is_file():
258
+ return path.read_text(encoding="utf-8")
259
+ return ""