okstra 0.156.0 → 0.157.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 (35) hide show
  1. package/docs/architecture.md +3 -1
  2. package/docs/for-ai/skills/okstra-schedule-gen.md +5 -4
  3. package/docs/project-structure-overview.md +7 -1
  4. package/package.json +1 -1
  5. package/runtime/BUILD.json +2 -2
  6. package/runtime/prompts/profiles/_common-contract.md +1 -1
  7. package/runtime/python/okstra_ctl/clarification_items.py +3 -3
  8. package/runtime/python/okstra_ctl/render_final_report.py +31 -44
  9. package/runtime/python/okstra_ctl/report_contract.py +15 -0
  10. package/runtime/python/okstra_ctl/report_markdown.py +441 -0
  11. package/runtime/python/okstra_ctl/schedule_semantics.py +186 -91
  12. package/runtime/python/okstra_ctl/stage_map.py +111 -6
  13. package/runtime/python/okstra_ctl/wizard.py +1 -11
  14. package/runtime/python/okstra_project/state.py +14 -2
  15. package/runtime/skills/okstra-schedule-gen/SKILL.md +43 -18
  16. package/runtime/templates/reports/final-report-v2.template.md +74 -10
  17. package/runtime/templates/reports/md/macros/sections.md +19 -0
  18. package/runtime/templates/reports/md/tasks/change-impact-analysis.template.md +18 -0
  19. package/runtime/templates/reports/md/tasks/error-analysis.template.md +13 -0
  20. package/runtime/templates/reports/md/tasks/feature-analysis.template.md +13 -0
  21. package/runtime/templates/reports/md/tasks/final-verification.template.md +13 -0
  22. package/runtime/templates/reports/md/tasks/implementation-planning.template.md +15 -0
  23. package/runtime/templates/reports/md/tasks/implementation.template.md +15 -0
  24. package/runtime/templates/reports/md/tasks/improvement-discovery.template.md +10 -0
  25. package/runtime/templates/reports/md/tasks/project-analysis.template.md +15 -0
  26. package/runtime/templates/reports/md/tasks/release-handoff.template.md +13 -0
  27. package/runtime/templates/reports/md/tasks/requirements-discovery.template.md +15 -0
  28. package/runtime/templates/reports/schedule.template.md +166 -63
  29. package/runtime/validators/validate-run.py +19 -6
  30. package/runtime/validators/validate-schedule.py +94 -65
  31. package/src/commands/inspect/stage-map.mjs +6 -1
  32. package/src/commands/inspect/worker-liveness.mjs +15 -3
  33. package/src/commands/lifecycle/install.mjs +69 -4
  34. package/src/commands/lifecycle/uninstall.mjs +21 -35
  35. package/src/lib/install-assets.mjs +37 -0
@@ -0,0 +1,441 @@
1
+ """Schema-ordered Markdown for the AI handoff final-report body.
2
+
3
+ `data.json` is the report's single source of truth; this module renders its
4
+ subtrees as Markdown a reading agent can skim — headings for structure, tables
5
+ for uniform row sets, prose for narrative fields.
6
+
7
+ Property order comes from the schema, which is authored in reading order (a
8
+ decision draft reads `context -> decision -> consequences`). Serialising the
9
+ same subtree with `json.dumps(sort_keys=True)` would put `alternativesConsidered`
10
+ first and `decision` last, which is why order is taken from the schema rather
11
+ than from the mapping.
12
+
13
+ Order is a hint, never a gate: a key the schema does not mention still renders,
14
+ it just sorts after the ones the schema names. Nothing in `data.json` is
15
+ dropped except `userNarrative`, which belongs to the human HTML.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import re
20
+ from typing import Any, Iterable, Sequence
21
+
22
+ from okstra_ctl.md_table import to_cell_text
23
+
24
+
25
+ # Markdown has no `#######`; deeper structures degrade to a bold label so the
26
+ # heading tree stays parseable instead of emitting an invalid level.
27
+ MAX_HEADING_LEVEL = 6
28
+ # Past these, a row set reads better as one block per row than as a table that
29
+ # no longer aligns: 8 columns is roughly a terminal width, and a cell longer
30
+ # than 160 characters is prose that a `<br>`-folded cell would bury.
31
+ TABLE_MAX_COLUMNS = 8
32
+ TABLE_MAX_CELL = 160
33
+ # A scalar this long stops being a field value and starts being a paragraph.
34
+ INLINE_MAX_CHARS = 140
35
+
36
+ EMPTY_MARKER = "_none_"
37
+
38
+ # The human HTML owns this field; the AI Markdown carries the structured facts
39
+ # it was written from, so repeating it here would duplicate the whole report.
40
+ HUMAN_ONLY_KEYS = frozenset({"userNarrative"})
41
+
42
+ _WORD_BOUNDARY_RE = re.compile(r"(?<=[a-z0-9])(?=[A-Z])")
43
+ # A bare token (path, id, enum value, command) reads better fenced; prose does
44
+ # not. Whitespace is the discriminator, so this pattern deliberately has none.
45
+ _TOKEN_RE = re.compile(r"^[\w./:#@+-]+$")
46
+
47
+ _ACRONYMS = {
48
+ "adr": "ADR",
49
+ "api": "API",
50
+ "cli": "CLI",
51
+ "ci": "CI",
52
+ "id": "ID",
53
+ "ids": "IDs",
54
+ "pr": "PR",
55
+ "sql": "SQL",
56
+ "url": "URL",
57
+ "usd": "USD",
58
+ }
59
+
60
+ # Row identity for block-form lists. A row heading reads `E-001 — <what it is>`,
61
+ # so the identifier and the human name are looked up separately: an id alone
62
+ # ("1") does not say what the block holds, and a name alone loses the handle
63
+ # every cross-reference in the report cites.
64
+ _ROW_IDENT_KEYS = ("id", "rowId", "number", "stage", "stageNumber", "key")
65
+ # Naming fields only. A prose field like `summary` is deliberately absent: it
66
+ # would be truncated into the heading and then printed again in full below it.
67
+ _ROW_NAME_KEYS = (
68
+ "title",
69
+ "name",
70
+ "slug",
71
+ "label",
72
+ # `role` outranks `agent` because several rows of one execution audit share
73
+ # an agent ("Claude Code") and only the role tells them apart in a heading.
74
+ "role",
75
+ "agent",
76
+ "worker",
77
+ "command",
78
+ "path",
79
+ )
80
+ # Long enough to identify the row, short enough to stay one heading line.
81
+ ROW_NAME_MAX_CHARS = 80
82
+ # A short token list (`P-001, P-002, …`) reads as one line; past this width an
83
+ # item is content that deserves its own bullet.
84
+ INLINE_ITEM_MAX_CHARS = 32
85
+
86
+
87
+ def humanise(key: str) -> str:
88
+ """`stageMap` -> `Stage Map`, keeping known acronyms upper-case."""
89
+ spaced = _WORD_BOUNDARY_RE.sub(" ", str(key)).replace("_", " ").replace("-", " ")
90
+ words = spaced.split()
91
+ if not words:
92
+ return str(key)
93
+ return " ".join(
94
+ _ACRONYMS.get(word.lower(), word[:1].upper() + word[1:]) for word in words
95
+ )
96
+
97
+
98
+ class SchemaIndex:
99
+ """Property-order lookup over the final-report schema.
100
+
101
+ Resolves `$ref` and flattens `allOf` / `oneOf` / `anyOf` branches into one
102
+ ordered key list, because the schema uses all three to compose task blocks
103
+ and a reader needs a single order regardless of which branch matched.
104
+ """
105
+
106
+ def __init__(self, schema: Any) -> None:
107
+ self._defs = schema.get("$defs", {}) if isinstance(schema, dict) else {}
108
+
109
+ def resolve(self, node: Any) -> dict:
110
+ seen: set[str] = set()
111
+ while isinstance(node, dict) and "$ref" in node:
112
+ ref = str(node["$ref"])
113
+ if ref in seen:
114
+ return {}
115
+ seen.add(ref)
116
+ node = self._defs.get(ref.rsplit("/", 1)[-1], {})
117
+ return node if isinstance(node, dict) else {}
118
+
119
+ def _branches(self, node: Any) -> list[dict]:
120
+ resolved = self.resolve(node)
121
+ branches = [resolved]
122
+ for keyword in ("allOf", "oneOf", "anyOf"):
123
+ for branch in resolved.get(keyword) or ():
124
+ branches.append(self.resolve(branch))
125
+ return branches
126
+
127
+ def key_order(self, node: Any) -> list[str]:
128
+ order: list[str] = []
129
+ for branch in self._branches(node):
130
+ for key in branch.get("properties") or ():
131
+ if key not in order:
132
+ order.append(key)
133
+ return order
134
+
135
+ def child(self, node: Any, key: str) -> dict:
136
+ for branch in self._branches(node):
137
+ candidate = (branch.get("properties") or {}).get(key)
138
+ if candidate is not None:
139
+ return self.resolve(candidate)
140
+ return {}
141
+
142
+ def item(self, node: Any) -> dict:
143
+ for branch in self._branches(node):
144
+ if "items" in branch:
145
+ return self.resolve(branch["items"])
146
+ return {}
147
+
148
+
149
+ def _is_scalar(value: Any) -> bool:
150
+ return value is None or isinstance(value, (str, int, float, bool))
151
+
152
+
153
+ def _is_empty(value: Any) -> bool:
154
+ if value is None:
155
+ return True
156
+ if isinstance(value, (list, dict, str)):
157
+ return len(value) == 0
158
+ return False
159
+
160
+
161
+ def _is_inline(value: Any) -> bool:
162
+ """True when the value fits on one `- **Label**: value` line."""
163
+ if not _is_scalar(value):
164
+ return False
165
+ if isinstance(value, str):
166
+ return "\n" not in value and len(value) <= INLINE_MAX_CHARS
167
+ return True
168
+
169
+
170
+ def scalar_text(value: Any) -> str:
171
+ if value is None:
172
+ return EMPTY_MARKER
173
+ if isinstance(value, bool):
174
+ return f"`{str(value).lower()}`"
175
+ if isinstance(value, (int, float)):
176
+ return f"`{value}`"
177
+ text = str(value).strip()
178
+ if not text:
179
+ return EMPTY_MARKER
180
+ return f"`{text}`" if _TOKEN_RE.match(text) else text
181
+
182
+
183
+ def heading(level: int, text: str) -> str:
184
+ if level <= MAX_HEADING_LEVEL:
185
+ return f"{'#' * level} {text}"
186
+ return f"**{text}**"
187
+
188
+
189
+ def _join_blocks(blocks: Iterable[str]) -> str:
190
+ return "\n\n".join(block for block in blocks if block and block.strip())
191
+
192
+
193
+ def _visible_keys(
194
+ value: dict, node: Any, index: SchemaIndex, skip: Iterable[str] = ()
195
+ ) -> list[str]:
196
+ hidden = HUMAN_ONLY_KEYS | set(skip)
197
+ known = [key for key in index.key_order(node) if key in value]
198
+ rest = [key for key in value if key not in known]
199
+ return [key for key in (*known, *rest) if key not in hidden]
200
+
201
+
202
+ def _first_present(
203
+ row: dict, keys: Iterable[str], limit: int | None = None
204
+ ) -> tuple[str, str]:
205
+ """The first key in *keys* carrying a usable scalar, as ``(key, text)``."""
206
+ for key in keys:
207
+ candidate = row.get(key)
208
+ if candidate is None or not _is_scalar(candidate):
209
+ continue
210
+ text = " ".join(str(candidate).split())
211
+ if not text:
212
+ continue
213
+ if limit is not None and len(text) > limit:
214
+ text = text[: limit - 1].rstrip() + "…"
215
+ return key, text
216
+ return "", ""
217
+
218
+
219
+ def _row_label(row: Any, position: int) -> tuple[str, set[str]]:
220
+ """Heading text for a block-form row, and the keys it already shows.
221
+
222
+ The caller drops those keys from the body: repeating `Stage: 1` under a
223
+ `#### 1 — …` heading costs a line and tells the reader nothing new.
224
+ """
225
+ if not isinstance(row, dict):
226
+ return f"Item {position}", set()
227
+ ident_key, ident = _first_present(row, _ROW_IDENT_KEYS)
228
+ name_key, name = _first_present(row, _ROW_NAME_KEYS, limit=ROW_NAME_MAX_CHARS)
229
+ label = " — ".join(part for part in (ident, name) if part)
230
+ if not label:
231
+ return f"Item {position}", set()
232
+ consumed = {key for key in (ident_key, name_key) if key}
233
+ # A truncated name still needs its full text in the body.
234
+ if name and row.get(name_key) is not None and name.endswith("…"):
235
+ consumed.discard(name_key)
236
+ return label, consumed
237
+
238
+
239
+ def _table_columns(
240
+ rows: Sequence[dict], node: Any, index: SchemaIndex
241
+ ) -> list[str] | None:
242
+ """Column order if *rows* render as a table, else ``None``.
243
+
244
+ A table has to stay aligned to beat block form, so every cell must be a
245
+ short scalar. One prose field is enough to send the whole set to blocks.
246
+ """
247
+ if len(rows) < 2:
248
+ return None
249
+ columns: list[str] = []
250
+ for key in index.key_order(index.item(node)):
251
+ if key not in HUMAN_ONLY_KEYS and any(key in row for row in rows):
252
+ columns.append(key)
253
+ for row in rows:
254
+ for key in row:
255
+ if key not in columns and key not in HUMAN_ONLY_KEYS:
256
+ columns.append(key)
257
+ if not columns or len(columns) > TABLE_MAX_COLUMNS:
258
+ return None
259
+ for row in rows:
260
+ for key in columns:
261
+ cell = row.get(key)
262
+ if not _is_scalar(cell) or len(to_cell_text(cell)) > TABLE_MAX_CELL:
263
+ return None
264
+ return columns
265
+
266
+
267
+ def _render_table(rows: Sequence[dict], columns: Sequence[str]) -> str:
268
+ header = "| " + " | ".join(humanise(column) for column in columns) + " |"
269
+ rule = "|" + "|".join(" --- " for _ in columns) + "|"
270
+ body = [
271
+ "| " + " | ".join(to_cell_text(row.get(column)) for column in columns) + " |"
272
+ for row in rows
273
+ ]
274
+ return "\n".join([header, rule, *body])
275
+
276
+
277
+ def _inline_sequence(value: Sequence[Any]) -> str | None:
278
+ """One comma-joined line if every item is a short token, else ``None``."""
279
+ parts: list[str] = []
280
+ for item in value:
281
+ if item is None or not _is_scalar(item):
282
+ return None
283
+ text = str(item).strip()
284
+ if not text or len(text) > INLINE_ITEM_MAX_CHARS or not _TOKEN_RE.match(text):
285
+ return None
286
+ parts.append(scalar_text(item))
287
+ return ", ".join(parts) if parts else None
288
+
289
+
290
+ def _inline_field(value: Any) -> str | None:
291
+ """The one-line form of a mapping field, or ``None`` if it needs a section."""
292
+ if _is_empty(value) and not isinstance(value, (int, float)):
293
+ return EMPTY_MARKER
294
+ if _is_inline(value):
295
+ return scalar_text(value)
296
+ if isinstance(value, list):
297
+ return _inline_sequence(value)
298
+ return None
299
+
300
+
301
+ def _render_sequence(value: Sequence[Any], node: Any, index: SchemaIndex, level: int) -> str:
302
+ if all(_is_inline(item) for item in value):
303
+ return _inline_sequence(value) or "\n".join(
304
+ f"- {scalar_text(item)}" for item in value
305
+ )
306
+ if all(isinstance(item, dict) for item in value):
307
+ columns = _table_columns(value, node, index)
308
+ if columns:
309
+ return _render_table(value, columns)
310
+ item_node = index.item(node)
311
+ blocks: list[str] = []
312
+ for position, item in enumerate(value, start=1):
313
+ label, consumed = _row_label(item, position)
314
+ blocks.append(heading(level, label))
315
+ if isinstance(item, dict):
316
+ blocks.append(
317
+ _render_mapping(item, item_node, index, level + 1, skip=consumed)
318
+ )
319
+ else:
320
+ blocks.append(
321
+ render_value(item, node=item_node, index=index, level=level + 1)
322
+ )
323
+ return _join_blocks(blocks)
324
+
325
+
326
+ def _render_mapping(
327
+ value: dict,
328
+ node: Any,
329
+ index: SchemaIndex,
330
+ level: int,
331
+ skip: Iterable[str] = (),
332
+ ) -> str:
333
+ blocks: list[str] = []
334
+ bullets: list[str] = []
335
+ for key in _visible_keys(value, node, index, skip):
336
+ child = value[key]
337
+ label = humanise(key)
338
+ inline = _inline_field(child)
339
+ if inline is not None:
340
+ bullets.append(f"- **{label}**: {inline}")
341
+ continue
342
+ if bullets:
343
+ blocks.append("\n".join(bullets))
344
+ bullets = []
345
+ blocks.append(heading(level, label))
346
+ blocks.append(
347
+ render_value(child, node=index.child(node, key), index=index, level=level + 1)
348
+ )
349
+ if bullets:
350
+ blocks.append("\n".join(bullets))
351
+ return _join_blocks(blocks)
352
+
353
+
354
+ def render_value(value: Any, *, node: Any, index: SchemaIndex, level: int) -> str:
355
+ """Render one `data.json` subtree as a Markdown block."""
356
+ if _is_empty(value) and not isinstance(value, (int, float)):
357
+ return EMPTY_MARKER
358
+ if _is_scalar(value):
359
+ return scalar_text(value)
360
+ if isinstance(value, list):
361
+ return _render_sequence(value, node, index, level)
362
+ if isinstance(value, dict):
363
+ return _render_mapping(value, node, index, level)
364
+ return scalar_text(value)
365
+
366
+
367
+ class ReportSections:
368
+ """Template-facing view of `data.json` addressed by dotted path.
369
+
370
+ Templates name the sections they want in the order a reading agent should
371
+ meet them; `rest()` then sweeps whatever the template did not name, so a
372
+ field added to the schema reaches the Markdown without a template edit.
373
+ """
374
+
375
+ def __init__(self, data: dict, schema: Any) -> None:
376
+ self._data = data
377
+ self._index = SchemaIndex(schema)
378
+ self._schema = schema
379
+ self._rendered: set[str] = set()
380
+
381
+ def _walk(self, path: str) -> tuple[Any, Any, bool]:
382
+ value: Any = self._data
383
+ node: Any = self._schema
384
+ for part in path.split("."):
385
+ if not isinstance(value, dict) or part not in value:
386
+ return None, {}, False
387
+ node = self._index.child(node, part)
388
+ value = value[part]
389
+ return value, node, True
390
+
391
+ def has(self, path: str) -> bool:
392
+ value, _, found = self._walk(path)
393
+ return found and not _is_empty(value)
394
+
395
+ def section(self, path: str, level: int = 3) -> str:
396
+ self._rendered.add(path)
397
+ value, node, found = self._walk(path)
398
+ if not found:
399
+ return EMPTY_MARKER
400
+ return render_value(value, node=node, index=self._index, level=level)
401
+
402
+ def _is_claimed(self, path: str) -> bool:
403
+ """True once *path* — or anything under it — has been rendered.
404
+
405
+ A template that renders `implementationPlanning.stageMap` has claimed
406
+ part of `implementationPlanning`; a later `rest("")` sweep must not
407
+ emit the whole block a second time.
408
+ """
409
+ return any(
410
+ rendered == path or rendered.startswith(f"{path}.")
411
+ for rendered in self._rendered
412
+ )
413
+
414
+ def rest(self, prefix: str, level: int = 3) -> str:
415
+ """Every child of *prefix* no `section()` call has already rendered."""
416
+ container, node, found = (
417
+ self._walk(prefix) if prefix else (self._data, self._schema, True)
418
+ )
419
+ if not found or not isinstance(container, dict):
420
+ return ""
421
+ blocks: list[str] = []
422
+ for key in _visible_keys(container, node, self._index):
423
+ path = f"{prefix}.{key}" if prefix else key
424
+ if self._is_claimed(path) or _is_empty(container[key]):
425
+ continue
426
+ self._rendered.add(path)
427
+ blocks.append(heading(level, humanise(key)))
428
+ blocks.append(
429
+ render_value(
430
+ container[key],
431
+ node=self._index.child(node, key),
432
+ index=self._index,
433
+ level=level + 1,
434
+ )
435
+ )
436
+ return _join_blocks(blocks)
437
+
438
+ def mark_rendered(self, *paths: str) -> str:
439
+ """Claim paths the spine renders by hand so `rest()` skips them."""
440
+ self._rendered.update(paths)
441
+ return ""