okstra 0.26.0 → 0.27.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 (43) hide show
  1. package/README.kr.md +15 -0
  2. package/README.md +15 -0
  3. package/docs/kr/architecture.md +2 -6
  4. package/docs/kr/cli.md +40 -6
  5. package/docs/kr/performance-improvement-plan-v2.md +23 -0
  6. package/docs/kr/performance-improvement-plan.md +22 -0
  7. package/package.json +1 -1
  8. package/runtime/BUILD.json +2 -2
  9. package/runtime/bin/okstra.sh +0 -1
  10. package/runtime/prompts/profiles/_common-contract.md +25 -1
  11. package/runtime/prompts/profiles/error-analysis.md +12 -0
  12. package/runtime/prompts/profiles/implementation-planning.md +20 -0
  13. package/runtime/prompts/profiles/requirements-discovery.md +20 -0
  14. package/runtime/python/lib/okstra/cli.sh +1 -7
  15. package/runtime/python/lib/okstra/globals.sh +0 -1
  16. package/runtime/python/lib/okstra/usage.sh +1 -4
  17. package/runtime/python/okstra_ctl/render.py +3 -0
  18. package/runtime/python/okstra_ctl/run.py +0 -6
  19. package/runtime/python/okstra_ctl/run_context.py +1 -1
  20. package/runtime/python/okstra_ctl/wizard.py +25 -2
  21. package/runtime/python/okstra_token_usage/blocks.py +5 -1
  22. package/runtime/python/okstra_token_usage/claude.py +16 -1
  23. package/runtime/python/okstra_token_usage/collect.py +17 -3
  24. package/runtime/python/okstra_token_usage/pricing.py +159 -24
  25. package/runtime/skills/okstra-brief/SKILL.md +532 -65
  26. package/runtime/skills/okstra-context-loader/SKILL.md +25 -11
  27. package/runtime/skills/okstra-convergence/SKILL.md +37 -13
  28. package/runtime/skills/okstra-history/SKILL.md +68 -37
  29. package/runtime/skills/okstra-logs/SKILL.md +26 -4
  30. package/runtime/skills/okstra-report-finder/SKILL.md +49 -22
  31. package/runtime/skills/okstra-report-writer/SKILL.md +59 -64
  32. package/runtime/skills/okstra-run/SKILL.md +35 -34
  33. package/runtime/skills/okstra-schedule/SKILL.md +51 -20
  34. package/runtime/skills/okstra-setup/SKILL.md +31 -12
  35. package/runtime/skills/okstra-status/SKILL.md +20 -8
  36. package/runtime/skills/okstra-team-contract/SKILL.md +27 -15
  37. package/runtime/skills/okstra-time-summary/SKILL.md +53 -16
  38. package/runtime/templates/reports/settings.template.json +7 -4
  39. package/runtime/validators/lib/fixtures.sh +10 -2
  40. package/runtime/validators/lib/validate-assets.sh +50 -24
  41. package/runtime/validators/validate-brief.py +385 -0
  42. package/runtime/validators/validate-brief.sh +35 -0
  43. package/runtime/validators/validate-workflow.sh +7 -33
@@ -0,0 +1,385 @@
1
+ #!/usr/bin/env python3
2
+ """Validate brief markdown files produced by the okstra-brief skill.
3
+
4
+ Checks performed per brief file:
5
+
6
+ 1. YAML frontmatter exists on line 1 with required keys.
7
+ 2. brief-id matches the filename stem.
8
+ 3. depth equals the number of `sub/` segments in the path (relative to the
9
+ `briefs/` root).
10
+ 4. Every Open Questions row starts with one of the five signal prefixes
11
+ (general | terminology | intent-check | conversion-block | adr-candidate).
12
+ `adr-candidate:` targets okstra-internal
13
+ `<PROJECT_ROOT>/.project-docs/okstra/decisions/`, not external `docs/adr/`.
14
+ 5. Every Augmentation entry (inline `> augmented: <label>` blockquotes and
15
+ `Augmentation` section bullets) carries one of the four labels
16
+ (evidence-link | format-conversion | terminology-mapping | intent-inference).
17
+ Both documented forms are accepted: `label: ...` and `label — ...`.
18
+ 6. Every `intent-inference` augmentation has a corresponding
19
+ `intent-check:` row in Open Questions (auto-mirroring rule).
20
+ 7. Every `terminology-mapping` augmentation (excluding Step 4.5 outcome
21
+ markers `applied glossary:` / `skipped glossary:`) has a corresponding
22
+ `terminology:` row in Open Questions.
23
+ 8. `parent-id` chain: at depth 0 the value MUST be the literal `self`;
24
+ at depth ≥ 1 it MUST NOT be `self` and MUST differ from the brief's
25
+ own `brief-id`.
26
+ 9. `reporter-confirmations` consistency: when `complete`, every
27
+ `intent-check:` and `conversion-block:` row in Open Questions MUST
28
+ carry a `[CONFIRMED YYYY-MM-DD → RC-N]` marker.
29
+
30
+ Exit code 0 on PASS, 1 on FAIL.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import argparse
36
+ import re
37
+ import sys
38
+ from pathlib import Path
39
+ from typing import Iterable
40
+
41
+ REQUIRED_FRONTMATTER_KEYS = {
42
+ "type",
43
+ "brief-id",
44
+ "parent-id",
45
+ "ticket-id",
46
+ "source-type",
47
+ "task-group",
48
+ "depth",
49
+ "created",
50
+ "generator",
51
+ "reporter-confirmations",
52
+ }
53
+
54
+ OPEN_QUESTIONS_PREFIXES = {
55
+ "general:",
56
+ "terminology:",
57
+ "intent-check:",
58
+ "conversion-block:",
59
+ "adr-candidate:",
60
+ }
61
+
62
+ AUGMENTATION_LABELS = {
63
+ "evidence-link",
64
+ "format-conversion",
65
+ "terminology-mapping",
66
+ "intent-inference",
67
+ }
68
+
69
+ REPORTER_CONFIRMATION_VALUES = {"complete", "partial", "pending", "skipped"}
70
+
71
+
72
+ def parse_frontmatter(text: str) -> tuple[dict[str, str], int]:
73
+ """Return (frontmatter dict, line after closing `---`)."""
74
+ lines = text.splitlines()
75
+ if not lines or lines[0].strip() != "---":
76
+ raise ValueError("missing opening frontmatter delimiter on line 1")
77
+ out: dict[str, str] = {}
78
+ for idx in range(1, len(lines)):
79
+ line = lines[idx]
80
+ if line.strip() == "---":
81
+ return out, idx + 1
82
+ # naive key: value (comments after #)
83
+ bare = line.split("#", 1)[0].strip()
84
+ if not bare:
85
+ continue
86
+ if ":" not in bare:
87
+ raise ValueError(f"frontmatter line without colon: {line!r}")
88
+ key, _, value = bare.partition(":")
89
+ out[key.strip()] = value.strip()
90
+ raise ValueError("missing closing frontmatter delimiter")
91
+
92
+
93
+ def section_body(text: str, heading: str) -> str:
94
+ """Return the body lines between `## <heading>` and the next `## ` heading."""
95
+ pattern = re.compile(
96
+ r"^##\s+" + re.escape(heading) + r"\s*$(.*?)(?=^##\s|\Z)",
97
+ re.MULTILINE | re.DOTALL,
98
+ )
99
+ match = pattern.search(text)
100
+ if not match:
101
+ return ""
102
+ return match.group(1)
103
+
104
+
105
+ def is_placeholder(line: str) -> bool:
106
+ bare = line.strip().lstrip("-").strip()
107
+ return bare in {"_(none)_", "_(none — pending or skipped)_", ""}
108
+
109
+
110
+ def is_template_example(line: str) -> bool:
111
+ """Lines that are template scaffolding (placeholder/example), not real entries."""
112
+ bare = line.strip().lstrip("-").strip()
113
+ return bare.startswith("<") and bare.endswith(">")
114
+
115
+
116
+ def open_questions_rows(text: str) -> list[str]:
117
+ body = section_body(text, "Open Questions")
118
+ rows: list[str] = []
119
+ for line in body.splitlines():
120
+ stripped = line.strip()
121
+ if not stripped.startswith("- "):
122
+ continue
123
+ content = stripped[2:].strip()
124
+ if is_placeholder(content) or is_template_example(content):
125
+ continue
126
+ # strip backticks if the row body is wrapped in `…`
127
+ content = content.strip("`")
128
+ rows.append(content)
129
+ return rows
130
+
131
+
132
+ def augmentation_entries(text: str) -> list[str]:
133
+ """Bullets under the `## Augmentation` section (entries that look like real data)."""
134
+ body = section_body(text, "Augmentation")
135
+ entries: list[str] = []
136
+ for line in body.splitlines():
137
+ stripped = line.strip()
138
+ if not stripped.startswith("- "):
139
+ continue
140
+ content = stripped[2:].strip()
141
+ if is_placeholder(content) or is_template_example(content):
142
+ continue
143
+ # strip backticks
144
+ content = content.strip("`")
145
+ entries.append(content)
146
+ return entries
147
+
148
+
149
+ def inline_augmented_blockquotes(text: str) -> list[str]:
150
+ """Lines starting with `> augmented:`."""
151
+ out: list[str] = []
152
+ for line in text.splitlines():
153
+ stripped = line.strip()
154
+ if stripped.startswith("> augmented:"):
155
+ payload = stripped[len("> augmented:"):].strip()
156
+ if payload.startswith("<") and payload.endswith(">"):
157
+ # template scaffold, e.g. `> augmented: <label> — <interpretation>`
158
+ continue
159
+ out.append(payload)
160
+ return out
161
+
162
+
163
+ def parse_augmentation_label(entry: str) -> tuple[str | None, str]:
164
+ """Return (label, payload) for documented augmentation forms."""
165
+ stripped = entry.strip()
166
+ for label in AUGMENTATION_LABELS:
167
+ if stripped == label:
168
+ return label, ""
169
+ for sep in (":", " — ", " - "):
170
+ prefix = f"{label}{sep}"
171
+ if stripped.startswith(prefix):
172
+ return label, stripped[len(prefix):].strip()
173
+ return None, stripped
174
+
175
+
176
+ def validate_brief(path: Path, briefs_root: Path) -> list[str]:
177
+ text = path.read_text(encoding="utf-8")
178
+ errors: list[str] = []
179
+
180
+ # 1. frontmatter
181
+ try:
182
+ fm, _ = parse_frontmatter(text)
183
+ except ValueError as exc:
184
+ return [f"frontmatter: {exc}"]
185
+
186
+ missing = REQUIRED_FRONTMATTER_KEYS - fm.keys()
187
+ if missing:
188
+ errors.append(f"frontmatter missing keys: {sorted(missing)}")
189
+
190
+ if fm.get("type") != "brief":
191
+ errors.append(f"frontmatter type must be 'brief', got {fm.get('type')!r}")
192
+
193
+ if fm.get("generator") != "okstra-brief":
194
+ errors.append(
195
+ f"frontmatter generator must be 'okstra-brief', got {fm.get('generator')!r}"
196
+ )
197
+
198
+ if fm.get("reporter-confirmations") not in REPORTER_CONFIRMATION_VALUES:
199
+ errors.append(
200
+ "frontmatter reporter-confirmations must be one of "
201
+ f"{sorted(REPORTER_CONFIRMATION_VALUES)}, got "
202
+ f"{fm.get('reporter-confirmations')!r}"
203
+ )
204
+
205
+ # 2. brief-id matches filename stem
206
+ stem = path.stem
207
+ if fm.get("brief-id") and fm["brief-id"] != stem:
208
+ errors.append(
209
+ f"brief-id {fm['brief-id']!r} does not match filename stem {stem!r}"
210
+ )
211
+
212
+ # 3. depth equals path's `sub/` nesting depth
213
+ try:
214
+ rel = path.relative_to(briefs_root)
215
+ except ValueError:
216
+ rel = path
217
+ # path components after the task-group dir: any number of `sub` segments + filename
218
+ parts = list(rel.parts)
219
+ if len(parts) >= 2:
220
+ nested = [p for p in parts[1:-1] if p == "sub"]
221
+ expected_depth = len(nested)
222
+ try:
223
+ actual_depth = int(fm.get("depth", "0"))
224
+ except ValueError:
225
+ actual_depth = -1
226
+ if actual_depth != expected_depth:
227
+ errors.append(
228
+ f"depth mismatch: path has {expected_depth} `sub/` segments, "
229
+ f"frontmatter says depth={fm.get('depth')!r}"
230
+ )
231
+
232
+ # 4. Open Questions prefixes
233
+ oq_rows = open_questions_rows(text)
234
+ intent_check_rows: list[str] = []
235
+ terminology_rows: list[str] = []
236
+ conversion_block_rows: list[str] = []
237
+ for row in oq_rows:
238
+ if not any(row.startswith(prefix) for prefix in OPEN_QUESTIONS_PREFIXES):
239
+ errors.append(f"Open Questions row lacks a known prefix: {row!r}")
240
+ if row.startswith("intent-check:"):
241
+ intent_check_rows.append(row)
242
+ elif row.startswith("terminology:"):
243
+ terminology_rows.append(row)
244
+ elif row.startswith("conversion-block:"):
245
+ conversion_block_rows.append(row)
246
+
247
+ # 5. Augmentation labels
248
+ augmentation_lines: list[str] = []
249
+ augmentation_lines.extend(augmentation_entries(text))
250
+ augmentation_lines.extend(inline_augmented_blockquotes(text))
251
+ intent_inference_count = 0
252
+ terminology_mapping_count = 0
253
+ for entry in augmentation_lines:
254
+ label, payload = parse_augmentation_label(entry)
255
+ if label not in AUGMENTATION_LABELS:
256
+ errors.append(
257
+ f"Augmentation entry lacks a known label: {entry!r} "
258
+ f"(label parsed as {label!r})"
259
+ )
260
+ continue
261
+ if label == "intent-inference":
262
+ intent_inference_count += 1
263
+ elif label == "terminology-mapping":
264
+ # Step 4.5 outcome markers do not need a paired Open Questions row.
265
+ if payload.startswith("applied glossary:") or payload.startswith(
266
+ "skipped glossary:"
267
+ ):
268
+ continue
269
+ terminology_mapping_count += 1
270
+
271
+ # 6. auto-mirroring rule (intent-inference ↔ intent-check:)
272
+ if intent_inference_count > len(intent_check_rows):
273
+ errors.append(
274
+ f"intent-inference augmentations present ({intent_inference_count}) "
275
+ f"but only {len(intent_check_rows)} intent-check: row(s) in Open Questions"
276
+ )
277
+
278
+ # 7. dual-record rule (terminology-mapping ↔ terminology:)
279
+ if terminology_mapping_count > 0 and not terminology_rows:
280
+ errors.append(
281
+ f"terminology-mapping augmentations present ({terminology_mapping_count}) "
282
+ f"but no terminology: row(s) in Open Questions"
283
+ )
284
+
285
+ # 8. parent-id chain
286
+ parent_id = fm.get("parent-id", "")
287
+ brief_id = fm.get("brief-id", "")
288
+ try:
289
+ depth_value = int(fm.get("depth", "0"))
290
+ except ValueError:
291
+ depth_value = -1
292
+ if depth_value == 0:
293
+ if parent_id != "self":
294
+ errors.append(
295
+ f"parent-id for the root (depth 0) brief must be 'self', "
296
+ f"got {parent_id!r}"
297
+ )
298
+ elif depth_value > 0:
299
+ if parent_id == "self":
300
+ errors.append(
301
+ f"parent-id for a descendant (depth {depth_value}) brief must not be 'self'"
302
+ )
303
+ elif parent_id == brief_id:
304
+ errors.append(
305
+ f"parent-id for a descendant brief must differ from its own brief-id "
306
+ f"({brief_id!r})"
307
+ )
308
+
309
+ # 9. reporter-confirmations consistency
310
+ rc_status = fm.get("reporter-confirmations")
311
+ if rc_status == "complete":
312
+ unconfirmed = [
313
+ row
314
+ for row in (intent_check_rows + conversion_block_rows)
315
+ if "[CONFIRMED" not in row
316
+ ]
317
+ if unconfirmed:
318
+ sample = unconfirmed[0]
319
+ errors.append(
320
+ f"reporter-confirmations is 'complete' but {len(unconfirmed)} "
321
+ f"intent-check:/conversion-block: row(s) lack a [CONFIRMED …] "
322
+ f"marker (e.g. {sample!r})"
323
+ )
324
+
325
+ return errors
326
+
327
+
328
+ def find_briefs(root: Path) -> Iterable[Path]:
329
+ yield from root.rglob("*.md")
330
+
331
+
332
+ def main(argv: list[str] | None = None) -> int:
333
+ parser = argparse.ArgumentParser(description=__doc__)
334
+ parser.add_argument(
335
+ "briefs_dir",
336
+ type=Path,
337
+ help="Directory containing brief markdown files (recursed).",
338
+ )
339
+ parser.add_argument(
340
+ "--briefs-root",
341
+ type=Path,
342
+ default=None,
343
+ help=(
344
+ "Root used for depth computation (defaults to briefs_dir). "
345
+ "Usually `<PROJECT_ROOT>/.project-docs/okstra/briefs`."
346
+ ),
347
+ )
348
+ args = parser.parse_args(argv)
349
+
350
+ briefs_dir: Path = args.briefs_dir
351
+ if not briefs_dir.exists():
352
+ print(f"[FAIL] briefs directory not found: {briefs_dir}", file=sys.stderr)
353
+ return 1
354
+
355
+ briefs_root: Path = args.briefs_root or briefs_dir
356
+
357
+ total = 0
358
+ failed_files: list[tuple[Path, list[str]]] = []
359
+ for brief in find_briefs(briefs_dir):
360
+ total += 1
361
+ errors = validate_brief(brief, briefs_root)
362
+ if errors:
363
+ failed_files.append((brief, errors))
364
+
365
+ if total == 0:
366
+ print(f"[PASS] no briefs found under {briefs_dir} (nothing to validate)")
367
+ return 0
368
+
369
+ if not failed_files:
370
+ print(f"[PASS] {total} brief(s) validated under {briefs_dir}")
371
+ return 0
372
+
373
+ for path, errors in failed_files:
374
+ print(f"[FAIL] {path}")
375
+ for err in errors:
376
+ print(f" - {err}")
377
+ print(
378
+ f"[FAIL] {len(failed_files)}/{total} brief(s) failed validation",
379
+ file=sys.stderr,
380
+ )
381
+ return 1
382
+
383
+
384
+ if __name__ == "__main__":
385
+ raise SystemExit(main())
@@ -0,0 +1,35 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Validate brief markdown files produced by okstra-brief.
4
+ #
5
+ # Usage:
6
+ # validators/validate-brief.sh <briefs-dir> [--briefs-root <dir>]
7
+ #
8
+ # Typical invocation (inside a project that has run okstra-setup):
9
+ # validators/validate-brief.sh "$PROJECT_ROOT/.project-docs/okstra/briefs"
10
+ #
11
+ # Thin bash entrypoint — delegates to validate-brief.py for content checks.
12
+
13
+ set -euo pipefail
14
+
15
+ SOURCE_PATH="${BASH_SOURCE[0]}"
16
+ while [[ -L "$SOURCE_PATH" ]]; do
17
+ SOURCE_DIR="$(cd -P "$(dirname "$SOURCE_PATH")" && pwd)"
18
+ SOURCE_PATH="$(readlink "$SOURCE_PATH")"
19
+ [[ "$SOURCE_PATH" != /* ]] && SOURCE_PATH="$SOURCE_DIR/$SOURCE_PATH"
20
+ done
21
+
22
+ SCRIPT_DIR="$(cd -P "$(dirname "$SOURCE_PATH")" && pwd)"
23
+ PYTHON_VALIDATOR="$SCRIPT_DIR/validate-brief.py"
24
+
25
+ if [[ ! -f "$PYTHON_VALIDATOR" ]]; then
26
+ echo "[FAIL] python helper not found: $PYTHON_VALIDATOR" >&2
27
+ exit 1
28
+ fi
29
+
30
+ if [[ $# -lt 1 ]]; then
31
+ echo "usage: $0 <briefs-dir> [--briefs-root <dir>]" >&2
32
+ exit 1
33
+ fi
34
+
35
+ exec python3 "$PYTHON_VALIDATOR" "$@"
@@ -18,7 +18,6 @@ OKSTRA_SCRIPT="$WORKSPACE_ROOT/scripts/okstra.sh"
18
18
  RUN_VALIDATOR_SCRIPT="$WORKSPACE_ROOT/validators/validate-run.py"
19
19
  SOURCE_ASSET_ROOT="$WORKSPACE_ROOT/agents"
20
20
  TASK_TYPE="final-verification"
21
- MARKER="MANUAL-VALIDATION-MARKER"
22
21
  PRIMARY_TASK_GROUP="validation"
23
22
  PRIMARY_TASK_ID="asset-refresh-and-reference-expectations"
24
23
  PRIMARY_BRIEF_FILENAME="validation-brief-primary.md"
@@ -56,7 +55,6 @@ PRIMARY_BRIEF_PATH="$PROJECT_ROOT/$PRIMARY_BRIEF_FILENAME"
56
55
  SECONDARY_BRIEF_PATH="$PROJECT_ROOT/$SECONDARY_BRIEF_FILENAME"
57
56
  DISCOVERY_FILE="$PROJECT_ROOT/$LATEST_TASK_RELATIVE_PATH"
58
57
  CATALOG_FILE="$PROJECT_ROOT/$TASK_CATALOG_RELATIVE_PATH"
59
- MARKER_FILE="$PROJECT_ROOT/.claude/agents/codex-worker.md"
60
58
  PRIMARY_TASK_KEY="$(task_key "$PRIMARY_TASK_GROUP" "$PRIMARY_TASK_ID")"
61
59
  SECONDARY_TASK_KEY="$(task_key "$SECONDARY_TASK_GROUP" "$SECONDARY_TASK_ID")"
62
60
  PRIMARY_REFERENCE_EXPECTATIONS_FILE="$(task_root "$PRIMARY_TASK_GROUP" "$PRIMARY_TASK_ID")/instruction-set/reference-expectations.md"
@@ -155,36 +153,12 @@ if ! validate_task_catalog "$SECONDARY_TASK_KEY" "$PRIMARY_TASK_KEY" "$SECONDARY
155
153
  fi
156
154
  pass "latest-task.json and task-catalog.json now reflect distinct primary and secondary tasks"
157
155
 
158
- step "Verifying that rerun without refresh preserves project-local assets"
159
- require_file "$MARKER_FILE"
160
- printf '\n%s\n' "$MARKER" >>"$MARKER_FILE"
161
- assert_contains "$MARKER_FILE" "$MARKER"
162
- if ! run_okstra "$SECONDARY_TASK_GROUP" "$SECONDARY_TASK_ID" "$SECONDARY_BRIEF_FILENAME"; then
163
- fail "Secondary task rerun without refresh failed"
164
- fi
165
- assert_contains "$MARKER_FILE" "$MARKER"
166
- if ! validate_latest_task_pointer "$SECONDARY_TASK_GROUP" "$SECONDARY_TASK_ID"; then
167
- fail "latest-task.json changed unexpectedly during secondary rerun without refresh"
168
- fi
169
- if ! validate_task_catalog "$SECONDARY_TASK_KEY" "$PRIMARY_TASK_KEY" "$SECONDARY_TASK_KEY"; then
170
- fail "task-catalog.json changed unexpectedly during secondary rerun without refresh"
171
- fi
172
- pass "Rerun without refresh preserved the modified project-local asset and retained both catalog entries"
173
-
174
- step "Verifying that rerun with refresh regenerates project-local assets"
175
- if ! run_okstra "$SECONDARY_TASK_GROUP" "$SECONDARY_TASK_ID" "$SECONDARY_BRIEF_FILENAME" --refresh-assets; then
176
- fail "Secondary task rerun with --refresh-assets failed"
177
- fi
178
- assert_not_contains "$MARKER_FILE" "$MARKER"
179
- if ! validate_seeded_assets match; then
180
- fail "Refreshed project-local okstra assets do not match the source files"
181
- fi
182
- if ! validate_latest_task_pointer "$SECONDARY_TASK_GROUP" "$SECONDARY_TASK_ID"; then
183
- fail "latest-task.json became invalid after refresh"
184
- fi
185
- if ! validate_task_catalog "$SECONDARY_TASK_KEY" "$PRIMARY_TASK_KEY" "$SECONDARY_TASK_KEY"; then
186
- fail "task-catalog.json became invalid after refresh"
187
- fi
188
- pass "Refresh regenerated the mapped project-local okstra assets while preserving both catalog entries"
156
+ # Removed: the historical "rerun without refresh preserves project-local
157
+ # assets" and "rerun with refresh regenerates project-local assets" stanzas.
158
+ # Those tested a contract from when `okstra install` seeded per-project
159
+ # `.claude/` files; install now writes only to `$HOME/.claude` and
160
+ # `$HOME/.okstra`, so the project-local sentinel is no longer a meaningful
161
+ # contract. The `--refresh-assets` flag was removed entirely in a paired
162
+ # commit; users with old scripts should switch to `okstra install --refresh`.
189
163
 
190
164
  print_summary