okstra 0.122.0 → 0.123.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 (53) hide show
  1. package/README.md +4 -2
  2. package/docs/architecture/storage-model.md +14 -0
  3. package/docs/architecture.md +34 -6
  4. package/docs/cli.md +45 -5
  5. package/docs/for-ai/README.md +2 -2
  6. package/docs/for-ai/skills/okstra-rollup.md +1 -1
  7. package/docs/for-ai/skills/{okstra-schedule.md → okstra-schedule-gen.md} +3 -3
  8. package/docs/project-structure-overview.md +3 -2
  9. package/docs/task-process/implementation-planning.md +33 -9
  10. package/docs/task-process/implementation.md +21 -2
  11. package/package.json +1 -1
  12. package/runtime/BUILD.json +2 -2
  13. package/runtime/bin/lib/okstra/usage.sh +3 -3
  14. package/runtime/prompts/launch.template.md +5 -2
  15. package/runtime/prompts/lead/convergence.md +1 -1
  16. package/runtime/prompts/lead/okstra-lead-contract.md +2 -2
  17. package/runtime/prompts/lead/plan-body-verification.md +29 -0
  18. package/runtime/prompts/lead/report-writer.md +9 -3
  19. package/runtime/prompts/profiles/_common-contract.md +1 -1
  20. package/runtime/prompts/profiles/_implementation-deliverable.md +3 -1
  21. package/runtime/prompts/profiles/_implementation-executor.md +5 -0
  22. package/runtime/prompts/profiles/_implementation-verifier.md +1 -1
  23. package/runtime/prompts/profiles/final-verification.md +2 -0
  24. package/runtime/prompts/profiles/implementation-planning.md +11 -1
  25. package/runtime/prompts/wizard/prompts.ko.json +44 -0
  26. package/runtime/python/okstra_ctl/codex_dispatch.py +23 -1
  27. package/runtime/python/okstra_ctl/design_prep.py +1462 -0
  28. package/runtime/python/okstra_ctl/design_surfaces.py +243 -0
  29. package/runtime/python/okstra_ctl/final_report_schema.py +33 -1
  30. package/runtime/python/okstra_ctl/implementation_stage.py +35 -0
  31. package/runtime/python/okstra_ctl/incremental_carry.py +294 -21
  32. package/runtime/python/okstra_ctl/incremental_scope.py +51 -5
  33. package/runtime/python/okstra_ctl/material.py +1 -1
  34. package/runtime/python/okstra_ctl/model_discovery.py +98 -0
  35. package/runtime/python/okstra_ctl/models.py +8 -3
  36. package/runtime/python/okstra_ctl/render.py +5 -0
  37. package/runtime/python/okstra_ctl/run.py +53 -5
  38. package/runtime/python/okstra_ctl/user_response.py +67 -2
  39. package/runtime/python/okstra_ctl/wizard.py +283 -3
  40. package/runtime/python/okstra_token_usage/report.py +11 -0
  41. package/runtime/schemas/final-report-v1.0.schema.json +336 -0
  42. package/runtime/skills/okstra-inspect/SKILL.md +2 -2
  43. package/runtime/skills/okstra-rollup/SKILL.md +1 -1
  44. package/runtime/skills/{okstra-schedule → okstra-schedule-gen}/SKILL.md +2 -2
  45. package/runtime/skills/okstra-user-response/SKILL.md +8 -6
  46. package/runtime/templates/reports/final-report.template.md +67 -0
  47. package/runtime/templates/reports/i18n/en.json +31 -0
  48. package/runtime/templates/reports/i18n/ko.json +31 -0
  49. package/runtime/validators/validate-run.py +426 -5
  50. package/runtime/validators/validate-schedule.py +4 -4
  51. package/src/cli-registry.mjs +7 -0
  52. package/src/commands/inspect/design-prep.mjs +23 -0
  53. package/src/lib/skill-catalog.mjs +2 -1
@@ -0,0 +1,243 @@
1
+ from __future__ import annotations
2
+
3
+ import fnmatch
4
+ import re
5
+ import unicodedata
6
+ from dataclasses import dataclass
7
+ from typing import Any, Mapping
8
+
9
+
10
+ class DesignSurfaceError(ValueError):
11
+ """The planning structure cannot be mapped to deterministic design surfaces."""
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class SurfaceRule:
16
+ kind: str
17
+ path_tokens: tuple[str, ...]
18
+ path_suffixes: tuple[str, ...]
19
+ path_patterns: tuple[str, ...]
20
+ action_phrases: tuple[str, ...]
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class TriggerEvidence:
25
+ step: int | None
26
+ field: str
27
+ match: str
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class DesignSurfaceTrigger:
32
+ stage: int
33
+ kind: str
34
+ evidence: tuple[TriggerEvidence, ...]
35
+
36
+
37
+ RULES = (
38
+ SurfaceRule(
39
+ "domain-contract",
40
+ ("/domain/",),
41
+ (),
42
+ (),
43
+ ("entity", "value object", "aggregate", "domain error"),
44
+ ),
45
+ SurfaceRule(
46
+ "persistence-schema",
47
+ ("/migrations/", "/orm/", "/repositories/"),
48
+ (".sql",),
49
+ (),
50
+ (
51
+ "create table",
52
+ "alter table",
53
+ "column",
54
+ "index",
55
+ "foreign key",
56
+ "unique constraint",
57
+ ),
58
+ ),
59
+ SurfaceRule(
60
+ "external-interface",
61
+ ("/ports/", "/adapters/", "/clients/", "/gateways/", "/infrastructure/http/"),
62
+ (),
63
+ (),
64
+ ("port", "adapter", "client", "gateway", "external api"),
65
+ ),
66
+ SurfaceRule(
67
+ "transaction-consistency",
68
+ (),
69
+ (),
70
+ (),
71
+ ("transaction", "outbox", "unit of work", "atomic write"),
72
+ ),
73
+ SurfaceRule(
74
+ "transformation-mapping",
75
+ ("/mappers/", "/parsers/", "/normalizers/"),
76
+ (),
77
+ (),
78
+ ("parse", "map", "canonical", "normalize", "transform"),
79
+ ),
80
+ SurfaceRule(
81
+ "lifecycle-state-machine",
82
+ (),
83
+ (),
84
+ (),
85
+ (
86
+ "claim",
87
+ "lease",
88
+ "retry",
89
+ "dead",
90
+ "dead-letter",
91
+ "state transition",
92
+ "state machine",
93
+ ),
94
+ ),
95
+ SurfaceRule(
96
+ "rollout-observability",
97
+ ("/monitoring/", "/deploy/", "/helm/"),
98
+ ("/values.yaml",),
99
+ ("*/values-*.yaml",),
100
+ ("rollout", "liveness", "alert", "feature flag"),
101
+ ),
102
+ )
103
+
104
+
105
+ def _normalise(value: object) -> str:
106
+ return unicodedata.normalize("NFC", str(value or "")).replace("\\", "/").lower()
107
+
108
+
109
+ def _phrase_matches(text: str, phrase: str) -> bool:
110
+ return re.search(rf"(?<![\w-]){re.escape(phrase)}(?![\w-])", text) is not None
111
+
112
+
113
+ def _selected_candidate(planning: Mapping[str, Any]) -> Mapping[str, Any]:
114
+ recommended = _normalise((planning.get("recommendedOption") or {}).get("name"))
115
+ candidates = [
116
+ row
117
+ for row in planning.get("optionCandidates") or []
118
+ if isinstance(row, Mapping)
119
+ ]
120
+ exact = [row for row in candidates if _normalise(row.get("name")) == recommended]
121
+ if len(exact) > 1:
122
+ raise DesignSurfaceError(
123
+ f"recommended option {recommended!r} is missing or ambiguous"
124
+ )
125
+ if len(exact) == 1:
126
+ return exact[0]
127
+ prefixed = [
128
+ row
129
+ for row in candidates
130
+ if _normalise(row.get("name")).startswith(
131
+ (recommended + ":", recommended + " —")
132
+ )
133
+ ]
134
+ if len(prefixed) != 1:
135
+ raise DesignSurfaceError(
136
+ f"recommended option {recommended!r} is missing or ambiguous"
137
+ )
138
+ return prefixed[0]
139
+
140
+
141
+ def expected_prep_plan_item_id(stage: int, kind: str) -> str:
142
+ return f"P-Prep-S{stage}-{kind}"
143
+
144
+
145
+ def _path_matches(rule: SurfaceRule, raw_path: object) -> bool:
146
+ path = "/" + _normalise(raw_path).lstrip("/")
147
+ return (
148
+ any(token in path for token in rule.path_tokens)
149
+ or any(path.endswith(suffix) for suffix in rule.path_suffixes)
150
+ or any(fnmatch.fnmatch(path, pattern) for pattern in rule.path_patterns)
151
+ )
152
+
153
+
154
+ def _matching_rule_evidence(
155
+ *,
156
+ step: int | None,
157
+ field: str,
158
+ value: object,
159
+ ) -> list[tuple[str, TriggerEvidence]]:
160
+ text = _normalise(value)
161
+ matches = []
162
+ for rule in RULES:
163
+ path_hit = field == "files" and _path_matches(rule, text)
164
+ action_hit = field == "action" and any(
165
+ _phrase_matches(text, phrase) for phrase in rule.action_phrases
166
+ )
167
+ if path_hit or action_hit:
168
+ matches.append((rule.kind, TriggerEvidence(step, field, text)))
169
+ return matches
170
+
171
+
172
+ def _stage_rows(planning: Mapping[str, Any]) -> dict[int, Mapping[str, Any]]:
173
+ rows = {
174
+ int(stage["stage"]): stage
175
+ for stage in planning.get("stages") or []
176
+ if isinstance(stage, Mapping) and isinstance(stage.get("stage"), int)
177
+ }
178
+ if not rows:
179
+ raise DesignSurfaceError("implementationPlanning.stages is empty")
180
+ return rows
181
+
182
+
183
+ def _stage_for_selected_path(
184
+ path: str,
185
+ stages: Mapping[int, Mapping[str, Any]],
186
+ ) -> int:
187
+ normalised = _normalise(path)
188
+ matched = []
189
+ for stage_number, stage in stages.items():
190
+ files = [
191
+ _normalise(step.get("files"))
192
+ for step in stage.get("stepwiseExecution") or []
193
+ if isinstance(step, Mapping)
194
+ ]
195
+ path_pattern = rf"(?<![\w./@-]){re.escape(normalised)}(?![\w./@-])"
196
+ if any(re.search(path_pattern, cell) is not None for cell in files):
197
+ matched.append(stage_number)
198
+ if len(matched) != 1:
199
+ raise DesignSurfaceError(
200
+ f"selected option path {path!r} is not mapped to a stage exactly once"
201
+ )
202
+ return matched[0]
203
+
204
+
205
+ def detect_design_surfaces(
206
+ planning: Mapping[str, Any],
207
+ ) -> list[DesignSurfaceTrigger]:
208
+ stages = _stage_rows(planning)
209
+ grouped: dict[tuple[int, str], list[TriggerEvidence]] = {}
210
+ for stage_number, stage in stages.items():
211
+ for step in stage.get("stepwiseExecution") or []:
212
+ if not isinstance(step, Mapping):
213
+ continue
214
+ step_number = (
215
+ int(step.get("step")) if isinstance(step.get("step"), int) else None
216
+ )
217
+ for field in ("files", "action"):
218
+ for kind, evidence in _matching_rule_evidence(
219
+ step=step_number,
220
+ field=field,
221
+ value=step.get(field),
222
+ ):
223
+ grouped.setdefault((stage_number, kind), []).append(evidence)
224
+ selected = _selected_candidate(planning)
225
+ for row in selected.get("fileStructure") or []:
226
+ if not isinstance(row, Mapping):
227
+ continue
228
+ path = str(row.get("path") or "")
229
+ stage_number = _stage_for_selected_path(path, stages)
230
+ for kind, evidence in _matching_rule_evidence(
231
+ step=None,
232
+ field="files",
233
+ value=path,
234
+ ):
235
+ grouped.setdefault((stage_number, kind), []).append(evidence)
236
+ rule_order = {rule.kind: index for index, rule in enumerate(RULES)}
237
+ return [
238
+ DesignSurfaceTrigger(stage, kind, tuple(grouped[(stage, kind)]))
239
+ for stage, kind in sorted(
240
+ grouped,
241
+ key=lambda key: (key[0], rule_order[key[1]]),
242
+ )
243
+ ]
@@ -4,7 +4,7 @@ This is a deliberately narrow JSON Schema implementation that supports
4
4
  exactly the keywords used by ``schemas/final-report-v1.0.schema.json``:
5
5
 
6
6
  type, required, properties, additionalProperties, enum, const, pattern,
7
- minLength, minItems, maxItems, items, minimum, maximum, $ref ($defs),
7
+ minLength, minItems, maxItems, uniqueItems, items, minimum, maximum, $ref ($defs),
8
8
  oneOf, allOf, if/then/else, contains, not
9
9
 
10
10
  We do NOT depend on the ``jsonschema`` PyPI package because its
@@ -62,6 +62,32 @@ def _check_type(value: Any, expected: str) -> bool:
62
62
  return isinstance(value, _TYPE_PYTHON.get(expected, ()))
63
63
 
64
64
 
65
+ def _json_schema_equal(left: Any, right: Any) -> bool:
66
+ if isinstance(left, bool) or isinstance(right, bool):
67
+ return isinstance(left, bool) and isinstance(right, bool) and left == right
68
+ if isinstance(left, (int, float)) or isinstance(right, (int, float)):
69
+ return (
70
+ isinstance(left, (int, float))
71
+ and isinstance(right, (int, float))
72
+ and left == right
73
+ )
74
+ if isinstance(left, list) or isinstance(right, list):
75
+ return (
76
+ isinstance(left, list)
77
+ and isinstance(right, list)
78
+ and len(left) == len(right)
79
+ and all(_json_schema_equal(a, b) for a, b in zip(left, right))
80
+ )
81
+ if isinstance(left, dict) or isinstance(right, dict):
82
+ return (
83
+ isinstance(left, dict)
84
+ and isinstance(right, dict)
85
+ and left.keys() == right.keys()
86
+ and all(_json_schema_equal(left[key], right[key]) for key in left)
87
+ )
88
+ return type(left) is type(right) and left == right
89
+
90
+
65
91
  def _resolve_ref(ref: str, root: dict) -> dict:
66
92
  """Resolve a ``#/$defs/Name`` style reference against ``root``."""
67
93
  if not ref.startswith("#/"):
@@ -165,6 +191,12 @@ class _Validator:
165
191
  max_items = schema.get("maxItems")
166
192
  if max_items is not None and len(instance) > max_items:
167
193
  self._err(path, f"array length {len(instance)} > maxItems {max_items}")
194
+ if schema.get("uniqueItems") is True and any(
195
+ _json_schema_equal(item, previous)
196
+ for index, item in enumerate(instance)
197
+ for previous in instance[:index]
198
+ ):
199
+ self._err(path, "array items are not unique")
168
200
  items = schema.get("items")
169
201
  if items is not None:
170
202
  for i, value in enumerate(instance):
@@ -14,6 +14,8 @@ from pathlib import Path
14
14
  from typing import Any
15
15
 
16
16
  from . import stage_targets
17
+ from .design_prep import DesignPrepDecision, resolve_design_prep
18
+ from .final_report_paths import final_report_data_path
17
19
  from .stage_reconcile import auto_reconcile_best_effort
18
20
 
19
21
 
@@ -32,6 +34,7 @@ class StageRunClaim:
32
34
  worktree_status: str
33
35
  worktree_note: str
34
36
  started_head_commit: str
37
+ design_prep_decision: DesignPrepDecision
35
38
  concurrent_stages: list[int] = field(default_factory=list)
36
39
 
37
40
 
@@ -50,6 +53,20 @@ def _as_implementation_stage_error(exc: Exception) -> ImplementationStageError:
50
53
  return ImplementationStageError(str(exc))
51
54
 
52
55
 
56
+ def _resolve_stage_design_prep(plan_path: Path, stage: int) -> DesignPrepDecision:
57
+ plan_data_path = final_report_data_path(plan_path)
58
+ if plan_data_path.is_file():
59
+ return resolve_design_prep(plan_data_path, stage)
60
+ return DesignPrepDecision(
61
+ outcome="proceed",
62
+ effective_items=(),
63
+ assumptions_to_inject=(),
64
+ warnings=("legacy-unassessed",),
65
+ request_paths=(),
66
+ reason="legacy plan has no design preparation assessment",
67
+ )
68
+
69
+
53
70
  def claim_implementation_stage_run(
54
71
  inp: Any,
55
72
  ctx_stage_map: list[dict[str, Any]],
@@ -87,6 +104,21 @@ def claim_implementation_stage_run(
87
104
  except stage_targets.StageTargetError as exc:
88
105
  raise _as_implementation_stage_error(exc) from exc
89
106
 
107
+ design_prep_decision = _resolve_stage_design_prep(
108
+ Path(inp.approved_plan_path), selected
109
+ )
110
+ if design_prep_decision.outcome == "wait_for_input":
111
+ requests = ", ".join(design_prep_decision.request_paths) or "no request path"
112
+ raise ImplementationStageError(
113
+ f"stage {selected} waits for design input: "
114
+ f"{design_prep_decision.reason}; {requests}"
115
+ )
116
+ if design_prep_decision.outcome == "replan":
117
+ raise ImplementationStageError(
118
+ f"stage {selected} requires implementation-planning rerun: "
119
+ f"{design_prep_decision.reason}"
120
+ )
121
+
90
122
  concurrent_stages = snapshot.concurrent_stage_numbers(
91
123
  selected_stage=selected,
92
124
  )
@@ -101,6 +133,7 @@ def claim_implementation_stage_run(
101
133
  worktree_status=executor_worktree_status,
102
134
  worktree_note="",
103
135
  started_head_commit=head,
136
+ design_prep_decision=design_prep_decision,
104
137
  concurrent_stages=concurrent_stages,
105
138
  )
106
139
  _record_stage_run_claim_started(task_key, plan_run_root, claim)
@@ -174,6 +207,7 @@ def claim_implementation_stage_run(
174
207
  worktree_status=prov.status,
175
208
  worktree_note=prov.note,
176
209
  started_head_commit=prov.base_ref,
210
+ design_prep_decision=design_prep_decision,
177
211
  concurrent_stages=concurrent_stages,
178
212
  )
179
213
  _record_stage_run_claim_started(task_key, plan_run_root, claim)
@@ -216,6 +250,7 @@ def publish_stage_run_claim(
216
250
  "Do NOT recompute from `consumers.jsonl`; the runtime already selected "
217
251
  "and reserved this stage."
218
252
  )
253
+ ctx["DESIGN_PREP_CONTEXT"] = claim.design_prep_decision.as_prompt_markdown()
219
254
  inp.stage = csv
220
255
  print(f"selected stages: {csv}", file=sys.stdout)
221
256