okstra 0.172.0 → 0.174.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 (123) hide show
  1. package/README.md +8 -6
  2. package/docs/architecture/storage-model.md +24 -3
  3. package/docs/architecture.md +21 -35
  4. package/docs/cli.md +39 -7
  5. package/docs/container.md +1 -1
  6. package/docs/contributor-change-matrix.md +1 -1
  7. package/docs/performance-improvement-plan-v2.md +6 -5
  8. package/docs/project-structure-overview.md +33 -25
  9. package/docs/task-process/README.md +6 -4
  10. package/docs/task-process/error-analysis.md +2 -2
  11. package/docs/task-process/final-verification.md +2 -2
  12. package/docs/task-process/implementation-option-selection.md +70 -0
  13. package/docs/task-process/implementation-planning.md +24 -16
  14. package/docs/task-process/requirements-discovery.md +2 -2
  15. package/package.json +1 -1
  16. package/runtime/BUILD.json +2 -2
  17. package/runtime/agents/workers/claude-worker.md +1 -1
  18. package/runtime/agents/workers/report-writer-worker.md +30 -6
  19. package/runtime/bin/lib/okstra/cli.sh +5 -1
  20. package/runtime/bin/lib/okstra/globals.sh +2 -1
  21. package/runtime/bin/lib/okstra/usage.sh +3 -0
  22. package/runtime/bin/okstra-provider-exec.py +29 -12
  23. package/runtime/bin/okstra-trace-cleanup.sh +58 -129
  24. package/runtime/bin/okstra.sh +2 -0
  25. package/runtime/prompts/duties/direction-selection-worker.md +44 -0
  26. package/runtime/prompts/duties/planning-worker.md +12 -4
  27. package/runtime/prompts/lead/adapters/cmux.md +2 -0
  28. package/runtime/prompts/lead/context-loader.md +1 -1
  29. package/runtime/prompts/lead/convergence.md +5 -5
  30. package/runtime/prompts/lead/okstra-lead-contract.md +7 -6
  31. package/runtime/prompts/lead/plan-body-verification.md +23 -6
  32. package/runtime/prompts/lead/report-writer.md +33 -11
  33. package/runtime/prompts/profiles/_common-contract.md +3 -3
  34. package/runtime/prompts/profiles/_implementation-deliverable.md +2 -2
  35. package/runtime/prompts/profiles/_implementation-executor.md +2 -0
  36. package/runtime/prompts/profiles/_implementation-verifier.md +2 -2
  37. package/runtime/prompts/profiles/error-analysis.md +4 -4
  38. package/runtime/prompts/profiles/final-verification.md +3 -3
  39. package/runtime/prompts/profiles/forbidden-actions.json +7 -0
  40. package/runtime/prompts/profiles/implementation-option-selection.md +35 -0
  41. package/runtime/prompts/profiles/implementation-planning.md +61 -46
  42. package/runtime/prompts/profiles/implementation.md +4 -2
  43. package/runtime/prompts/profiles/improvement-discovery.md +1 -1
  44. package/runtime/prompts/profiles/release-handoff.md +1 -1
  45. package/runtime/prompts/profiles/requirements-discovery.md +3 -3
  46. package/runtime/prompts/wizard/prompts.ko.json +9 -1
  47. package/runtime/python/okstra_ctl/adapters/dispatch/__init__.py +1 -6
  48. package/runtime/python/okstra_ctl/adapters/hosts/external/relay.md +4 -4
  49. package/runtime/python/okstra_ctl/adapters/providers/claude/adapter.py +5 -0
  50. package/runtime/python/okstra_ctl/agent_invocation.py +1 -0
  51. package/runtime/python/okstra_ctl/analysis_packet.py +6 -0
  52. package/runtime/python/okstra_ctl/conformance.py +68 -0
  53. package/runtime/python/okstra_ctl/dispatch_core.py +89 -39
  54. package/runtime/python/okstra_ctl/dispatch_state.py +142 -14
  55. package/runtime/python/okstra_ctl/doctor.py +2 -2
  56. package/runtime/python/okstra_ctl/domain/worker_exec.py +5 -0
  57. package/runtime/python/okstra_ctl/exact_coverage.py +128 -0
  58. package/runtime/python/okstra_ctl/final_report_schema.py +5 -4
  59. package/runtime/python/okstra_ctl/fix_cycles.py +3 -1
  60. package/runtime/python/okstra_ctl/implementation_direction.py +836 -0
  61. package/runtime/python/okstra_ctl/implementation_options.py +479 -0
  62. package/runtime/python/okstra_ctl/pane_reclaim.py +13 -22
  63. package/runtime/python/okstra_ctl/plan_items.py +51 -3
  64. package/runtime/python/okstra_ctl/render.py +1 -0
  65. package/runtime/python/okstra_ctl/render_final_report.py +16 -19
  66. package/runtime/python/okstra_ctl/report_contract.py +45 -14
  67. package/runtime/python/okstra_ctl/report_finalize.py +68 -9
  68. package/runtime/python/okstra_ctl/report_html/render.py +4 -2
  69. package/runtime/python/okstra_ctl/report_html/router.py +4 -0
  70. package/runtime/python/okstra_ctl/report_html/view_models/implementation_option_selection.py +32 -0
  71. package/runtime/python/okstra_ctl/report_html/view_models/implementation_planning.py +25 -10
  72. package/runtime/python/okstra_ctl/report_views.py +148 -12
  73. package/runtime/python/okstra_ctl/run.py +393 -4
  74. package/runtime/python/okstra_ctl/schema_excerpt.py +1 -1
  75. package/runtime/python/okstra_ctl/scope_provenance.py +16 -10
  76. package/runtime/python/okstra_ctl/session.py +69 -12
  77. package/runtime/python/okstra_ctl/team.py +51 -25
  78. package/runtime/python/okstra_ctl/tmux.py +19 -149
  79. package/runtime/python/okstra_ctl/user_response.py +75 -0
  80. package/runtime/python/okstra_ctl/wizard.py +144 -0
  81. package/runtime/python/okstra_ctl/worker_prompt_policy.py +2 -0
  82. package/runtime/python/okstra_ctl/worker_request.py +2 -0
  83. package/runtime/python/okstra_ctl/workflow.py +29 -7
  84. package/runtime/python/okstra_ctl/worktree.py +69 -3
  85. package/runtime/python/okstra_token_usage/cli.py +1 -1
  86. package/runtime/python/okstra_token_usage/collect.py +66 -6
  87. package/runtime/schemas/final-report-v2.0.schema.json +1428 -137
  88. package/runtime/skills/okstra-setup/references/project-config.md +11 -0
  89. package/runtime/templates/reports/final-report-v2.template.md +4 -0
  90. package/runtime/templates/reports/final-verification-input.template.md +1 -1
  91. package/runtime/templates/reports/html/base.template.html +3 -2
  92. package/runtime/templates/reports/html/i18n/en.json +21 -1
  93. package/runtime/templates/reports/html/i18n/ko.json +21 -1
  94. package/runtime/templates/reports/html/macros/forms.html +21 -2
  95. package/runtime/templates/reports/html/tasks/implementation-option-selection.template.html +49 -0
  96. package/runtime/templates/reports/html/tasks/implementation-planning.template.html +36 -2
  97. package/runtime/templates/reports/i18n/en.json +13 -0
  98. package/runtime/templates/reports/implementation-input.template.md +4 -2
  99. package/runtime/templates/reports/implementation-planning-input.template.md +18 -4
  100. package/runtime/templates/reports/improvement-discovery-input.template.md +1 -1
  101. package/runtime/templates/reports/md/tasks/implementation-option-selection.template.md +13 -0
  102. package/runtime/templates/reports/md/tasks/implementation-planning.template.md +17 -0
  103. package/runtime/templates/reports/report.js +111 -4
  104. package/runtime/templates/reports/settings.template.json +0 -24
  105. package/runtime/templates/reports/task-brief.template.md +9 -3
  106. package/runtime/templates/reports/user-response.template.md +25 -4
  107. package/runtime/templates/worker-prompt-preamble.md +8 -0
  108. package/runtime/validators/lib/fixtures.sh +49 -17
  109. package/runtime/validators/validate-implementation-plan-stages.py +169 -4
  110. package/runtime/validators/validate-report-views.py +2 -2
  111. package/runtime/validators/validate-run.py +149 -498
  112. package/runtime/validators/validate_improvement_report.py +5 -1
  113. package/runtime/validators/validate_session_conformance.py +1 -1
  114. package/src/cli-registry.mjs +8 -1
  115. package/src/commands/execute/codex-run.mjs +1 -0
  116. package/src/commands/execute/render-bundle.mjs +1 -0
  117. package/src/commands/execute/team.mjs +3 -3
  118. package/src/commands/execute/worktree-status.mjs +109 -0
  119. package/src/commands/lifecycle/install.mjs +0 -2
  120. package/src/commands/report/finalize.mjs +13 -6
  121. package/runtime/bin/okstra-subagent-reclaim.sh +0 -26
  122. package/runtime/schemas/final-report-v1.0.schema.json +0 -6366
  123. package/runtime/templates/reports/final-report.template.md +0 -1258
@@ -44,6 +44,28 @@
44
44
  return String(s == null ? "" : s).replace(/^\s+|\s+$/g, "");
45
45
  }
46
46
 
47
+ var DIRECTION_IDENTITY_WHITESPACE = [
48
+ 0x0009, 0x000A, 0x000B, 0x000C, 0x000D,
49
+ 0x001C, 0x001D, 0x001E, 0x001F, 0x0020, 0x0085, 0x00A0, 0x1680,
50
+ 0x2000, 0x2001, 0x2002, 0x2003, 0x2004, 0x2005, 0x2006, 0x2007,
51
+ 0x2008, 0x2009, 0x200A, 0x2028, 0x2029, 0x202F, 0x205F, 0x3000,
52
+ 0xFEFF,
53
+ ];
54
+
55
+ function trimDirectionIdentity(value) {
56
+ var start = 0;
57
+ var end = value.length;
58
+ while (
59
+ start < end &&
60
+ DIRECTION_IDENTITY_WHITESPACE.indexOf(value.charCodeAt(start)) !== -1
61
+ ) start += 1;
62
+ while (
63
+ end > start &&
64
+ DIRECTION_IDENTITY_WHITESPACE.indexOf(value.charCodeAt(end - 1)) !== -1
65
+ ) end -= 1;
66
+ return value.slice(start, end);
67
+ }
68
+
47
69
  // Read the user-supplied value and decision effect from one clarification
48
70
  // row. Returns null when the row has no usable input.
49
71
  function readRowInput(row) {
@@ -181,6 +203,58 @@
181
203
  });
182
204
  }
183
205
 
206
+ function validateDirectionSelection(selection) {
207
+ if (!selection) return null;
208
+ var optionId = String(selection.optionId == null ? "" : selection.optionId);
209
+ var optionName = String(selection.optionName == null ? "" : selection.optionName);
210
+ var normalizedId = trimDirectionIdentity(optionId);
211
+ var normalizedName = trimDirectionIdentity(optionName);
212
+ if (
213
+ !normalizedId ||
214
+ !normalizedName ||
215
+ /[\r\n]/.test(optionId) ||
216
+ /[\r\n]/.test(optionName)
217
+ ) {
218
+ throw new Error(
219
+ "DIRECTION SELECTION Option-ID and Option-Name must be " +
220
+ "non-empty single-line values"
221
+ );
222
+ }
223
+ if (selection.confirmed !== true) {
224
+ throw new Error("DIRECTION SELECTION requires Confirmed: true");
225
+ }
226
+ return {
227
+ optionId: normalizedId,
228
+ optionName: normalizedName,
229
+ confirmed: selection.confirmed,
230
+ selectionNote: selection.selectionNote,
231
+ constraints: selection.constraints,
232
+ };
233
+ }
234
+
235
+ function collectDirectionSelection() {
236
+ var controls = document.querySelectorAll(
237
+ 'input[name="direction-selection-option"]'
238
+ );
239
+ if (!controls.length) return null;
240
+ var selected = document.querySelector(
241
+ 'input[name="direction-selection-option"]:checked'
242
+ );
243
+ if (!selected) {
244
+ throw new Error("Select an implementation direction");
245
+ }
246
+ var confirmed = document.getElementById("direction-selection-confirmed");
247
+ var note = document.getElementById("direction-selection-note");
248
+ var constraints = document.getElementById("direction-selection-constraints");
249
+ return validateDirectionSelection({
250
+ optionId: selected.value,
251
+ optionName: selected.getAttribute("data-option-name") || "",
252
+ confirmed: !!(confirmed && confirmed.checked),
253
+ selectionNote: note ? trimMultiline(note.value) : "",
254
+ constraints: constraints ? trimMultiline(constraints.value) : "",
255
+ });
256
+ }
257
+
184
258
  // Toggle the visibility of the "기타" companion input next to each
185
259
  // select whose current value is "__other__". Wired at bind() time and
186
260
  // also called once for the initial state.
@@ -233,13 +307,34 @@
233
307
  return chunk;
234
308
  }
235
309
 
236
- function buildUserResponseMarkdown(runMeta, entries, createdAt, planDecision, analysisReview) {
310
+ function serialiseDirectionSelection(selection) {
311
+ selection = validateDirectionSelection(selection);
312
+ return (
313
+ "\n## DIRECTION SELECTION\n" +
314
+ "- Status: selected\n" +
315
+ "- Option-ID: " + selection.optionId + "\n" +
316
+ "- Option-Name: " + selection.optionName + "\n" +
317
+ "- Confirmed: true\n" +
318
+ quotedReviewField("Selection-Note", selection.selectionNote) +
319
+ quotedReviewField("Constraints", selection.constraints)
320
+ );
321
+ }
322
+
323
+ function buildUserResponseMarkdown(
324
+ runMeta, entries, createdAt, planDecision, analysisReview, directionSelection
325
+ ) {
237
326
  var head =
238
327
  "---\n" +
239
328
  "task-key: " + (runMeta["task-key"] || "") + "\n" +
240
329
  "task-type: " + (runMeta["task-type"] || "") + "\n" +
241
330
  "seq: " + (runMeta["seq"] || "") + "\n" +
242
331
  "source-report: " + (runMeta["source-report"] || "") + "\n" +
332
+ (runMeta["source-data"]
333
+ ? "source-data: " + runMeta["source-data"] + "\n"
334
+ : "") +
335
+ (runMeta["source-data-sha256"]
336
+ ? "source-data-sha256: " + runMeta["source-data-sha256"] + "\n"
337
+ : "") +
243
338
  "created-by: user\n" +
244
339
  "created-at: " + createdAt + "\n" +
245
340
  "---\n" +
@@ -249,6 +344,7 @@
249
344
  entries = entries || [];
250
345
  var hasPlanDecision = !!planDecision;
251
346
  var hasAnalysisReview = !!analysisReview;
347
+ var hasDirectionSelection = !!directionSelection;
252
348
  var chunks = "";
253
349
  for (var i = 0; i < entries.length; i++) {
254
350
  var e = entries[i];
@@ -265,7 +361,12 @@
265
361
  }
266
362
  chunks += chunk;
267
363
  }
268
- if (entries.length === 0 && !hasPlanDecision && !hasAnalysisReview) {
364
+ if (
365
+ entries.length === 0 &&
366
+ !hasPlanDecision &&
367
+ !hasAnalysisReview &&
368
+ !hasDirectionSelection
369
+ ) {
269
370
  chunks += "\n_(No user responses recorded.)_\n";
270
371
  }
271
372
  if (hasPlanDecision) {
@@ -274,6 +375,9 @@
274
375
  if (hasAnalysisReview) {
275
376
  chunks += serialiseAnalysisReview(analysisReview);
276
377
  }
378
+ if (hasDirectionSelection) {
379
+ chunks += serialiseDirectionSelection(directionSelection);
380
+ }
277
381
  return head + chunks;
278
382
  }
279
383
 
@@ -308,22 +412,24 @@
308
412
  var out = document.getElementById("user-response-output");
309
413
  var planDecision;
310
414
  var analysisReview;
415
+ var directionSelection;
311
416
  try {
312
417
  planDecision = collectPlanDecision();
313
418
  analysisReview = collectAnalysisReview();
419
+ directionSelection = collectDirectionSelection();
314
420
  } catch (e) {
315
421
  showOutput(out, e.message || String(e));
316
422
  showDismiss(true);
317
423
  return "";
318
424
  }
319
425
  var md = buildUserResponseMarkdown(
320
- runMeta, entries, isoNowUtc(), planDecision, analysisReview
426
+ runMeta, entries, isoNowUtc(), planDecision, analysisReview, directionSelection
321
427
  );
322
428
  showOutput(out, md);
323
429
  showDismiss(true);
324
430
  // Nothing answered yet — show the empty serialisation as feedback but
325
431
  // don't download a useless file.
326
- if (entries.length > 0 || planDecision || analysisReview) {
432
+ if (entries.length > 0 || planDecision || analysisReview || directionSelection) {
327
433
  downloadUserResponse(md, runMeta);
328
434
  }
329
435
  return md;
@@ -455,6 +561,7 @@
455
561
  collectEntries: collectEntries,
456
562
  collectPlanDecision: collectPlanDecision,
457
563
  collectAnalysisReview: collectAnalysisReview,
564
+ collectDirectionSelection: collectDirectionSelection,
458
565
  exportUserResponse: exportUserResponse,
459
566
  setReaderMode: setReaderMode,
460
567
  };
@@ -52,36 +52,12 @@
52
52
  "SessionEnd": [
53
53
  {
54
54
  "hooks": [
55
- {
56
- "type": "command",
57
- "command": "$HOME/.okstra/bin/okstra-trace-cleanup.sh --reap"
58
- },
59
55
  {
60
56
  "type": "command",
61
57
  "command": "$HOME/.okstra/bin/okstra-team-reconcile.sh --session-end"
62
58
  }
63
59
  ]
64
60
  }
65
- ],
66
- "SubagentStop": [
67
- {
68
- "hooks": [
69
- {
70
- "type": "command",
71
- "command": "$HOME/.okstra/bin/okstra-subagent-reclaim.sh"
72
- }
73
- ]
74
- }
75
- ],
76
- "TaskCompleted": [
77
- {
78
- "hooks": [
79
- {
80
- "type": "command",
81
- "command": "$HOME/.okstra/bin/okstra-subagent-reclaim.sh"
82
- }
83
- ]
84
- }
85
61
  ]
86
62
  }
87
63
  }
@@ -60,9 +60,14 @@ taskType: "{{FM_TASK_TYPE}}"
60
60
  - What is the visible symptom?
61
61
  - What are the expected vs actual results?
62
62
  - What are the current hypotheses and missing evidence?
63
+ - If `Task Type` is `implementation-option-selection`:
64
+ - Is this a comparison among possible directions or validation of one preselected direction?
65
+ - Which stable `EB-NNN`, `PB-NNN`, and `EO-NNN` requirements form the coverage denominator?
66
+ - Which mechanism, architecture boundary, preservation rule, or implementation constraint must each direction address?
63
67
  - If `Task Type` is `implementation-planning`:
64
- - What implementation options are under consideration?
65
- - What trade-offs, dependencies, or migrations matter?
68
+ - Which validated selection report and `selectedDirectionRef` authorise this new plan?
69
+ - Which mechanism, architecture boundary, invariants, and user constraints must the plan preserve?
70
+ - Which dependencies or migrations matter while realizing that one direction?
66
71
  - What validation and rollback approach is expected?
67
72
  - If `Task Type` is `implementation`:
68
73
  - Which approved `implementation-planning` final report authorises this run, and is its frontmatter `approved: true` cited verbatim?
@@ -141,6 +146,7 @@ taskType: "{{FM_TASK_TYPE}}"
141
146
  - Allowed and forbidden actions for each task type are listed in `Lifecycle Phase Boundaries` of the okstra skill (`prompts/lead/okstra-lead-contract.md`). The lead and every worker stay inside that boundary.
142
147
  - "proceed to the next step" or any equivalent user phrase is interpreted as "complete the remaining outputs of the current phase," never as "start the next lifecycle phase." The next phase begins only via a fresh okstra invocation with the new `--task-type`.
143
148
  - For `implementation-planning` specifically: produce a plan document with the sections listed in `okstra-implementation-planning-input.template.md` `## Required Plan Deliverable`. Do not edit project source code, run builds/migrations/deployments, or write artifacts outside the run's own directories.
149
+ - For `implementation-option-selection` specifically: compare or validate implementation directions without editing source, running tests/builds, or writing exact file lists, stage maps, and test commands. Only directions with 100% requirement coverage and 100% scope precision may be displayed.
144
150
  - For `implementation` specifically: edits are bounded by the approved plan's file list (the `--approved-plan` reference). The run MUST refuse to start if the approved plan path is missing or its frontmatter `approved` field is not `true`. `git push`, publish, deploy, real migrations, and any third-party write API call remain forbidden; only local `git add`/`git commit` are allowed. Verifier roles stay read-only — they record fix recommendations rather than applying edits — and acceptance verdicts belong to `final-verification`, not this phase.
145
151
 
146
152
  ## Available MCP Servers
@@ -157,7 +163,7 @@ How to invoke (worker-by-worker):
157
163
 
158
164
  Usage policy:
159
165
 
160
- - **Allowed phases**: `requirements-discovery`, `error-analysis`, `implementation-planning`, `final-verification`. Use only when local schema/data evidence improves the answer. Always cite the server, table, and the SELECT used as evidence in worker output.
166
+ - **Allowed phases**: `requirements-discovery`, `error-analysis`, `implementation-option-selection`, `implementation-planning`, `final-verification`. Use only when local schema/data evidence improves the answer. Always cite the server, table, and the SELECT used as evidence in worker output.
161
167
  - **`implementation` phase**: read-only MCP queries are permitted as cross-checks; MCP MUST NOT be used as a write path even if a write tool becomes available — schema/data mutations belong in the codebase migration files reviewed by humans.
162
168
  - **CLI-wrapper workers**: can use these MCP servers only if their provider CLI configuration mirrors the same servers. If not configured, the worker should record `MCP not available in this CLI` in `Missing Information or Assumptions` rather than guessing.
163
169
  - **Forbidden**: connecting to non-listed databases, running anything that mutates state (server is read-only — flagged write attempts are a contract violation), persisting query results outside the run's own artifact directories.
@@ -9,9 +9,11 @@ This file defines the standard format of the markdown produced by the **Export u
9
9
 
10
10
  ```yaml
11
11
  task-key: <task-group>/<task-id>
12
- task-type: <requirements-discovery | error-analysis | implementation-planning | implementation | final-verification | release-handoff | project-analysis | feature-analysis | change-impact-analysis>
12
+ task-type: <requirements-discovery | error-analysis | implementation-option-selection | implementation-planning | implementation | final-verification | release-handoff | improvement-discovery | project-analysis | feature-analysis | change-impact-analysis>
13
13
  seq: <3-digit zero-padded run sequence>
14
14
  source-report: <project-relative path to the final-report .md the HTML was derived from>
15
+ source-data: <project-relative path to the final-report data.json; omit for legacy reports without one>
16
+ source-data-sha256: <SHA-256 of the exact source-data bytes; omit when source-data is omitted>
15
17
  created-by: user
16
18
  created-at: <ISO 8601 UTC timestamp>
17
19
  ```
@@ -72,15 +74,16 @@ When you pick a verdict in the Plan Decision widget and Export, the following bl
72
74
  ```markdown
73
75
  ## PLAN DECISION
74
76
  - Status: <approved | revision-requested | rejected>
75
- - Implementation-Option: <the name exactly as in Option Candidates>
77
+ - Implementation-Option: <legacy plans only: the name exactly as in Option Candidates>
76
78
  - Reason:
77
79
  > <one quoted line per input line>
78
80
  ```
79
81
 
80
82
  - `revision-requested` and `rejected` require a reason: a plan sent back without one leaves the next run guessing at what to change, so Export refuses to serialise it.
81
- - `Implementation-Option:` is emitted only for `approved`, and only when you moved off the recommended default — otherwise implementation falls back to the plan's Recommended Option.
83
+ - A `planningContract: selected-direction` plan never emits `Implementation-Option:`. The direction was confirmed before planning, and this block decides only whether the detailed plan is approved, revised, or rejected.
84
+ - A legacy candidate plan emits `Implementation-Option:` only for `approved`, and only when you moved off the recommended default. Otherwise implementation falls back to the legacy plan's Recommended Option.
82
85
  - `Reason:` is optional for `approved`, and the line is omitted when empty.
83
- - The consumer of an `approved` block is the approve-confirm step of the implementation start wizard (`scripts/okstra_ctl/wizard.py`). After user confirmation the wizard applies it through the existing `--approve` / `--implementation-option` path; the sidecar itself does not bypass approval-gate validation. A non-approved status is ignored there — that step only ever looks for an approval.
86
+ - The consumer of an `approved` block is the approve-confirm step of the implementation start wizard (`scripts/okstra_ctl/wizard.py`). After user confirmation the wizard applies `--approve`; it applies `--implementation-option` only for a legacy plan. The sidecar itself does not bypass approval-gate validation. A non-approved status is ignored there — that step only ever looks for an approval.
84
87
  - The parser is `parse_plan_decision` in `scripts/okstra_ctl/user_response.py`, and it accepts only the lowercase statuses that are byte-identical to the producer output (hand-edited values such as `Approved` are rejected fail-closed).
85
88
  - `--resume-clarification` attaches the sidecar verbatim, so the PLAN DECISION block reaches the next planning run — which is how a rejection and its reason get acted on.
86
89
 
@@ -102,6 +105,24 @@ The Analysis Review control appends one decision block. `revision-requested` and
102
105
 
103
106
  The block is the review decision's sole storage location. Export never changes the source final-report markdown. A later analysis rerun receives the sidecar through `--clarification-response` and records one `analysisReviewResolution` row for each imported affected ID.
104
107
 
108
+ ## DIRECTION SELECTION block (implementation-option-selection comparison only)
109
+
110
+ Selecting one ranked direction and checking `Confirmed` appends this block. `Selection-Note` and `Constraints` keep their field lines when empty, but do not emit empty quote lines.
111
+
112
+ ```markdown
113
+ ## DIRECTION SELECTION
114
+ - Status: selected
115
+ - Option-ID: IO-002
116
+ - Option-Name: Adapter boundary
117
+ - Confirmed: true
118
+ - Selection-Note:
119
+ > Use the existing port.
120
+ - Constraints:
121
+ > Keep PB-001 unchanged.
122
+ ```
123
+
124
+ The parser accepts exactly one block, lowercase `selected`, and lowercase `true`. A `preselected-validation` report displays its confirmed upstream direction read-only and does not export a new selection block.
125
+
105
126
  ## Compatibility Rules
106
127
 
107
128
  - If `Kind` has an unknown value, the form renders with a `<textarea>` fallback, and the received `Kind` string is preserved as-is during serialization.
@@ -61,6 +61,14 @@ Every analysis result starts with YAML frontmatter containing the task identity
61
61
 
62
62
  Every item has a worker-local ID and file:line evidence where code evidence exists. Sections 1–5 are the common core: feasibility, requirement interpretation, hidden assumptions, alternatives, and execution risk. Section 6 is the only legal home for specialization and is not consensus input.
63
63
 
64
+ ### Selected-direction planning ownership
65
+
66
+ For `implementation-planning` with `selected-direction.json`, sections 1–5 read that snapshot and the original requirements, then assess only its realization into files, interfaces, stages, validation, rollback, bidirectional requirement links, and `direction-invalidated` evidence. Direction selection remains upstream of this prompt.
67
+
68
+ ### Legacy candidate-comparison ownership
69
+
70
+ For an implementation-planning compatibility rerun without the snapshot, sections 1–5 retain candidate comparison, trade-off, recommendation, and `P-Opt-*` evidence.
71
+
64
72
  ### Ticket Tagging
65
73
 
66
74
  For `requirements-discovery`, `error-analysis`, and `implementation-planning`, tag every section 1–5 item with its related ticket. Use `Issue / Ticket`, fall back to Task ID, then `unknown`; comma-separate multiple tickets.
@@ -428,7 +428,7 @@ if WORKSPACE_ROOT:
428
428
  task_type = str(task_manifest.get("taskType", ""))
429
429
  sample_path = (
430
430
  Path(WORKSPACE_ROOT)
431
- / "tests" / "fixtures" / "final-report-data"
431
+ / "tests" / "fixtures" / "final-report-data-v2"
432
432
  / f"{task_type}-001.data.json"
433
433
  )
434
434
  if sample_path.is_file():
@@ -439,6 +439,25 @@ if WORKSPACE_ROOT:
439
439
  sample["frontmatter"]["projectId"] = str(task_manifest.get("projectId", ""))
440
440
  sample["header"]["taskKey"] = str(task_manifest.get("taskKey", ""))
441
441
  sample["header"]["taskType"] = task_type
442
+ # The shipped fixture carries its own roster; this run's contract names
443
+ # a different one, and the validator compares the report's agent rows
444
+ # against that contract. Restate the rows under the contract's names so
445
+ # the fixture exercises the check instead of tripping over it.
446
+ _required = (
447
+ (run_manifest.get("teamContract") or {}).get("requiredAgentStatusEntries")
448
+ or (task_manifest.get("resultContract") or {}).get(
449
+ "requiredAgentStatusEntries"
450
+ )
451
+ or []
452
+ )
453
+ _rows = sample.get("executionStatus") or []
454
+ if _required and _rows:
455
+ # `agent` is the runtime (a schema enum); `role` carries the label the
456
+ # validator looks for in the rendered markdown.
457
+ sample["executionStatus"] = [
458
+ {**_rows[min(i, len(_rows) - 1)], "role": role}
459
+ for i, role in enumerate(_required)
460
+ ]
442
461
  name = report_path.name
443
462
  data_path = (
444
463
  report_path.with_name(name[:-3] + ".data.json")
@@ -452,23 +471,36 @@ if WORKSPACE_ROOT:
452
471
 
453
472
  import sys as _sys
454
473
  _sys.path.insert(0, str(Path(WORKSPACE_ROOT) / "scripts"))
455
- try:
456
- from okstra_ctl.report_views import RunMeta, render_html_view
457
- css = (Path(WORKSPACE_ROOT) / "templates" / "reports" / "report.css").read_text(encoding="utf-8")
458
- js = (Path(WORKSPACE_ROOT) / "templates" / "reports" / "report.js").read_text(encoding="utf-8")
459
- render_html_view(
460
- report_path,
461
- run_meta=RunMeta(
462
- task_key=str(task_manifest.get("taskKey", "validation/fixture")),
463
- task_type=str(task_manifest.get("taskType", "validation")),
464
- seq="001",
465
- source_report=report_path.name,
466
- ),
467
- css=css,
468
- js=js,
474
+ # The markdown seeded above this block predates the data.json contract, so
475
+ # re-render it from the data.json the fixture just wrote. Otherwise the pair
476
+ # disagrees and the validator's AI-handoff heading scan fails on a fixture
477
+ # that never claimed to be hand-authored.
478
+ if sample_path.is_file():
479
+ try:
480
+ from okstra_ctl.render_final_report import render_to_file
481
+
482
+ render_to_file(data_path, report_path)
483
+ except Exception as exc: # pragma: no cover — fixture path only
484
+ raise SystemExit(f"failed to render final report in fixture: {exc}")
485
+ # Go through the same CLI the run uses, so the fixture picks the schema's
486
+ # own view (v2 task template) rather than a second copy of that choice.
487
+ import subprocess as _subprocess
488
+ _views = _subprocess.run(
489
+ [
490
+ _sys.executable,
491
+ str(Path(WORKSPACE_ROOT) / "scripts" / "okstra-render-report-views.py"),
492
+ str(report_path),
493
+ "--task-key", str(task_manifest.get("taskKey", "validation/fixture")),
494
+ "--task-type", str(task_manifest.get("taskType", "validation")),
495
+ "--seq", "001",
496
+ ],
497
+ capture_output=True,
498
+ text=True,
499
+ )
500
+ if _views.returncode != 0:
501
+ raise SystemExit(
502
+ f"failed to render report views in fixture: {_views.stderr or _views.stdout}"
469
503
  )
470
- except Exception as exc: # pragma: no cover — fixture path only
471
- raise SystemExit(f"failed to render report views in fixture: {exc}")
472
504
 
473
505
  if final_status_path.exists():
474
506
  final_status_path.unlink()
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env python3
2
- """S1–S11 checks for the Stage Map structure of an approved
2
+ """S1–S13 checks for the Stage Map structure of an approved
3
3
  implementation-planning final-report.md. Run from prepare_task_bundle
4
4
  of `implementation` task or standalone."""
5
5
 
@@ -8,6 +8,7 @@ from __future__ import annotations
8
8
  import argparse
9
9
  import re
10
10
  import sys
11
+ from collections import Counter
11
12
  from dataclasses import dataclass
12
13
  from pathlib import Path
13
14
  from typing import List, Tuple
@@ -42,9 +43,26 @@ EXIT_CONTRACT_HEADING = re.compile(r"^###\s+Stage Exit Contract\b", re.M)
42
43
  PATH_TOKEN = re.compile(r"(?:[\w.@-]+/)+[\w.@-]+")
43
44
 
44
45
 
46
+ # S12 — a step command that reads an okstra artifact back out of a git object
47
+ # (`git cat-file -e <rev>:.okstra/...`, `git show <rev>:.okstra/...`). `.okstra/**`
48
+ # is never committed: `_implementation-executor.md` forbids `git add -f` and makes
49
+ # a staged ignored path abort the commit, and `_implementation-verifier.md` reports
50
+ # a committed `.okstra` path as a branch defect. A verification step built on such
51
+ # a read can never pass, whatever the stage does.
52
+ GIT_OBJECT_OKSTRA_READ = re.compile(
53
+ r"\bgit\b[^&|;]*?\b(?:cat-file|show|ls-tree|archive|grep)\b[^&|;]*?"
54
+ r"(?<![\w./@'\"-])[\w./@{}~^-]+:\.okstra/"
55
+ )
56
+ # S13 — a clean-worktree assertion built on a bare `git status`. okstra provisions
57
+ # `.okstra`, the configured sync entries, and (for implementation) a nested stage
58
+ # worktree into every task worktree, so a bare status is never empty there.
59
+ BARE_GIT_STATUS = re.compile(r"\bgit\b[^&|;]*?\bstatus\b[^&|;]*?--(?:porcelain|short)\b")
60
+ CLEAN_GATE_COMMAND = "okstra worktree-status --check-clean"
61
+
62
+
45
63
  @dataclass
46
64
  class ValidationError:
47
- code: str # S1..S11
65
+ code: str # S1..S13
48
66
  stage: int # 0 = global
49
67
  message: str
50
68
 
@@ -91,10 +109,14 @@ def _slice_stage_section(text: str, stage_number: int) -> str:
91
109
  return text[start: start + nxt.start()] if nxt else text[start:]
92
110
 
93
111
 
112
+ STEP_COMMAND_CELL = 3
113
+
114
+
94
115
  def _effective_step_rows(section: str) -> List[List[str]]:
95
116
  """Effective (non header/divider/comment) rows of the `### Stepwise
96
117
  Execution Order` table, each as a list of stripped cells. Columns are
97
- `step | action | files | command | expected`, so action is index 1."""
118
+ `step | action | files | command | outcome | expected`, so action is
119
+ index 1, command index 3, outcome index 4."""
98
120
  m = re.search(r"^###\s+Stepwise Execution Order\b", section, re.M)
99
121
  if not m:
100
122
  return []
@@ -266,6 +288,40 @@ def _check_red_green_steps(section: str, stage_number: int) -> List[ValidationEr
266
288
  return errs
267
289
 
268
290
 
291
+ def _check_step_command(command: str, stage_number: int) -> List[ValidationError]:
292
+ """S12 / S13 over one step's `command` cell.
293
+
294
+ The command cell is what actually closes a step, so it has to be runnable
295
+ inside the worktree layout okstra provisions. Both rules reject a command
296
+ that can never pass there, regardless of what the stage implements.
297
+ """
298
+ errs: List[ValidationError] = []
299
+ if GIT_OBJECT_OKSTRA_READ.search(command):
300
+ errs.append(ValidationError("S12", stage_number,
301
+ "S12: step command reads an `.okstra/` path out of a git object — "
302
+ "`.okstra/**` is gitignored and never committed, so the read can "
303
+ "never resolve. Pass the artifact forward through the stage carry "
304
+ "sidecar / verifier result, or read it from the working tree"))
305
+ if BARE_GIT_STATUS.search(command):
306
+ errs.append(ValidationError("S13", stage_number,
307
+ "S13: step command asserts a clean worktree with a bare `git status` — "
308
+ "okstra provisions `.okstra`, the synced entries, and any nested stage "
309
+ f"worktree there, so it is never empty. Use `{CLEAN_GATE_COMMAND}`"))
310
+ return errs
311
+
312
+
313
+ def _check_markdown_step_commands(
314
+ text: str, stages: List[StageMapStage]
315
+ ) -> List[ValidationError]:
316
+ """S12 / S13 over the rendered `### Stepwise Execution Order` rows."""
317
+ errs: List[ValidationError] = []
318
+ for s in stages:
319
+ for row in _effective_step_rows(_slice_stage_section(text, s.stage_number)):
320
+ if len(row) > STEP_COMMAND_CELL:
321
+ errs.extend(_check_step_command(row[STEP_COMMAND_CELL], s.stage_number))
322
+ return errs
323
+
324
+
269
325
  def _check_conformance_declaration(
270
326
  text: str, stages: List[StageMapStage]
271
327
  ) -> List[ValidationError]:
@@ -382,6 +438,7 @@ def collect_validation_errors(text: str) -> List[ValidationError]:
382
438
  if stages:
383
439
  errors.extend(_check_each_stage_section(text, stages))
384
440
  errors.extend(_check_slice_tdd(text, stages))
441
+ errors.extend(_check_markdown_step_commands(text, stages))
385
442
  errors.extend(_check_conformance_declaration(text, stages))
386
443
  errors.extend(_check_depends_on(stages))
387
444
  errors.extend(_check_parallel_safety(text, stages))
@@ -472,6 +529,99 @@ def _check_data_step_counts(
472
529
  return errs
473
530
 
474
531
 
532
+ def _check_data_stage_identities(
533
+ raw_stage_map: List[dict], stages: List[dict]
534
+ ) -> List[ValidationError]:
535
+ """Reject ambiguous schema-v2 identities before building stage indexes."""
536
+ errs: List[ValidationError] = []
537
+ stage_map_numbers = [
538
+ row["stage"]
539
+ for row in raw_stage_map
540
+ if isinstance(row, dict) and isinstance(row.get("stage"), int)
541
+ ]
542
+ stage_numbers = [
543
+ row["stage"]
544
+ for row in stages
545
+ if isinstance(row, dict) and isinstance(row.get("stage"), int)
546
+ ]
547
+ duplicate_stage_map = {
548
+ number for number, count in Counter(stage_map_numbers).items() if count > 1
549
+ }
550
+ duplicate_stages = {
551
+ number for number, count in Counter(stage_numbers).items() if count > 1
552
+ }
553
+ errs.extend(
554
+ ValidationError("S3", number, f"stageMap duplicate stage {number}")
555
+ for number in sorted(duplicate_stage_map)
556
+ )
557
+ errs.extend(
558
+ ValidationError("S3", number, f"stages[] duplicate stage {number}")
559
+ for number in sorted(duplicate_stages)
560
+ )
561
+
562
+ stage_map_set = set(stage_map_numbers)
563
+ stage_set = set(stage_numbers)
564
+ errs.extend(
565
+ ValidationError(
566
+ "S3",
567
+ number,
568
+ f"stageMap is missing stages[] stage {number}",
569
+ )
570
+ for number in sorted(stage_set - stage_map_set)
571
+ )
572
+ errs.extend(
573
+ ValidationError(
574
+ "S3",
575
+ number,
576
+ f"stages[] is missing stageMap stage {number}",
577
+ )
578
+ for number in sorted(stage_map_set - stage_set)
579
+ )
580
+
581
+ stage_map_by_number = {
582
+ row["stage"]: row
583
+ for row in raw_stage_map
584
+ if isinstance(row, dict)
585
+ and isinstance(row.get("stage"), int)
586
+ and row["stage"] not in duplicate_stage_map
587
+ }
588
+ stages_by_number = {
589
+ row["stage"]: row
590
+ for row in stages
591
+ if isinstance(row, dict)
592
+ and isinstance(row.get("stage"), int)
593
+ and row["stage"] not in duplicate_stages
594
+ }
595
+ errs.extend(
596
+ ValidationError(
597
+ "S3",
598
+ number,
599
+ f"stageMap and stages[] stage {number} title must match",
600
+ )
601
+ for number in sorted(stage_map_by_number.keys() & stages_by_number.keys())
602
+ if stage_map_by_number[number].get("title")
603
+ != stages_by_number[number].get("title")
604
+ )
605
+
606
+ for stage in stages:
607
+ number = stage.get("stage")
608
+ step_numbers = [
609
+ step["step"]
610
+ for step in stage.get("stepwiseExecution") or []
611
+ if isinstance(step, dict) and isinstance(step.get("step"), int)
612
+ ]
613
+ errs.extend(
614
+ ValidationError(
615
+ "S4",
616
+ number if isinstance(number, int) else 0,
617
+ f"stages[] stage {number} step {step} is duplicate",
618
+ )
619
+ for step, count in sorted(Counter(step_numbers).items())
620
+ if count > 1
621
+ )
622
+ return errs
623
+
624
+
475
625
  def collect_data_validation_errors(planning: dict) -> List[ValidationError]:
476
626
  """The S-checks that schema v2 cannot express, over `implementationPlanning`.
477
627
 
@@ -482,11 +632,22 @@ def collect_data_validation_errors(planning: dict) -> List[ValidationError]:
482
632
  covers presence and cardinality; this covers the relationships between
483
633
  fields, which is what a JSON Schema has no way to say.
484
634
  """
485
- stage_map, errors = _data_stage_metas(planning.get("stageMap") or [])
635
+ if (
636
+ planning.get("planningContract") == "selected-direction"
637
+ and planning.get("outcome") == "direction-invalidated"
638
+ ):
639
+ return []
640
+
641
+ raw_stage_map = planning.get("stageMap") or []
642
+ stage_map, errors = _data_stage_metas(raw_stage_map)
486
643
  stages = [s for s in (planning.get("stages") or []) if isinstance(s, dict)]
487
644
  if not stage_map and not stages:
488
645
  return errors
489
646
 
647
+ identity_errors = _check_data_stage_identities(raw_stage_map, stages)
648
+ errors.extend(identity_errors)
649
+ if identity_errors:
650
+ return errors
490
651
  errors.extend(_check_data_step_counts(stage_map, stages))
491
652
  errors.extend(_check_depends_on(stage_map))
492
653
  errors.extend(_report_shared_parallel_files({
@@ -500,6 +661,10 @@ def collect_data_validation_errors(planning: dict) -> List[ValidationError]:
500
661
  }))
501
662
  for stage in stages:
502
663
  errors.extend(_check_data_slice_tdd(stage))
664
+ number = stage.get("stage") if isinstance(stage.get("stage"), int) else 0
665
+ for step in stage.get("stepwiseExecution") or []:
666
+ if isinstance(step, dict):
667
+ errors.extend(_check_step_command(str(step.get("command") or ""), number))
503
668
  return errors
504
669
 
505
670