okstra 0.55.0 → 0.56.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 (29) hide show
  1. package/bin/okstra +24 -7
  2. package/docs/project-structure-overview.md +0 -1
  3. package/docs/superpowers/plans/2026-05-25-okstra-project-root-rename.md +0 -1
  4. package/docs/superpowers/plans/2026-06-07-stage-conformance-qa-phase2.md +275 -0
  5. package/docs/superpowers/plans/2026-06-07-stage-conformance-qa-phase3.md +282 -0
  6. package/docs/superpowers/plans/2026-06-07-stage-conformance-qa-phase4a.md +147 -0
  7. package/docs/superpowers/plans/2026-06-07-stage-conformance-qa-phase4b.md +262 -0
  8. package/docs/superpowers/plans/2026-06-07-stage-conformance-qa-phase4c.md +184 -0
  9. package/docs/superpowers/plans/2026-06-07-stage-conformance-qa-phase4d.md +88 -0
  10. package/docs/superpowers/plans/2026-06-07-stage-conformance-qa-phase4e.md +250 -0
  11. package/docs/superpowers/plans/2026-06-07-stage-conformance-qa.md +409 -0
  12. package/docs/superpowers/specs/2026-06-07-stage-conformance-qa-design.md +169 -0
  13. package/package.json +1 -1
  14. package/runtime/BUILD.json +2 -2
  15. package/runtime/bin/lib/okstra/cli.sh +5 -1
  16. package/runtime/bin/lib/okstra/usage.sh +5 -0
  17. package/runtime/bin/okstra.sh +1 -0
  18. package/runtime/prompts/profiles/_implementation-verifier.md +23 -2
  19. package/runtime/prompts/profiles/final-verification.md +1 -0
  20. package/runtime/prompts/profiles/implementation-planning.md +4 -0
  21. package/runtime/python/okstra_ctl/conformance.py +270 -0
  22. package/runtime/python/okstra_ctl/paths.py +2 -0
  23. package/runtime/python/okstra_ctl/run.py +29 -0
  24. package/runtime/skills/okstra-run/SKILL.md +12 -0
  25. package/runtime/skills/okstra-setup/SKILL.md +35 -0
  26. package/runtime/validators/validate-implementation-plan-stages.py +28 -3
  27. package/runtime/validators/validate-run.py +96 -0
  28. package/src/okstra-dirs.mjs +1 -1
  29. package/src/migrate.mjs +0 -146
@@ -30,8 +30,17 @@ except ImportError: # pragma: no cover — runtime guarantees this import
30
30
  load_schema = None # type: ignore[assignment]
31
31
  schema_validate = None # type: ignore[assignment]
32
32
 
33
+ from okstra_project import project_json_path # noqa: E402
33
34
  from okstra_project.dirs import tasks_root as _okstra_tasks_root # noqa: E402
34
35
 
36
+ from okstra_ctl.conformance import ( # noqa: E402
37
+ detect_surfaces,
38
+ evaluate_conformance,
39
+ manifest_required_surfaces,
40
+ qa_result_from_dict,
41
+ validate_conformance_manifest,
42
+ )
43
+
35
44
  TERMINAL_STATUSES = {"completed", "timeout", "error", "not-run"}
36
45
  ATTEMPTED_STATUSES = {"completed", "timeout", "error"}
37
46
 
@@ -700,6 +709,84 @@ _DEPRECATED_FINAL_REPORT_PATTERNS: tuple[tuple[re.Pattern, str], ...] = (
700
709
  )
701
710
 
702
711
 
712
+ def _load_conformance_results(qa_dir: Path, manifest: dict) -> dict:
713
+ """매니페스트 각 entry 에 대응하는 `result-<stageKey>.json` 을 로드.
714
+ 파일 부재/파손은 키를 비워 둬 미실행(None→BLOCKING)으로 흐르게 한다."""
715
+ results: dict = {}
716
+ for entry in manifest.get("entries", []):
717
+ key = entry.get("stageKey") if isinstance(entry, dict) else None
718
+ if not isinstance(key, str) or not key:
719
+ continue
720
+ sidecar = qa_dir / f"result-{key}.json"
721
+ if not sidecar.is_file():
722
+ continue
723
+ try:
724
+ results[key] = qa_result_from_dict(json.loads(sidecar.read_text()))
725
+ except (OSError, json.JSONDecodeError):
726
+ results[key] = qa_result_from_dict(None) # → MISSING → BLOCKING
727
+ return results
728
+
729
+
730
+ _DIFF_SUMMARY_RE = re.compile(r"^###\s+5\.7\.3\b.*?(?=^###\s|\Z)", re.MULTILINE | re.DOTALL)
731
+ _DIFF_ROW_PATH_RE = re.compile(r"^\|\s*`([^`]+)`\s*\|", re.MULTILINE)
732
+
733
+
734
+ def _parse_diff_summary_files(content: str) -> list[str]:
735
+ """implementation 리포트 §5.7.3 Diff Summary 표의 첫 셀(백틱 경로)들을 추출."""
736
+ section = _DIFF_SUMMARY_RE.search(content)
737
+ if section is None:
738
+ return []
739
+ return _DIFF_ROW_PATH_RE.findall(section.group(0))
740
+
741
+
742
+ def _validate_conformance(report_path: Path, failures: list[str],
743
+ surface_patterns: object = None) -> None:
744
+ """Tier 3 conformance 게이트(implementation / final-verification).
745
+
746
+ `<task_root>/qa/conformance-manifest.json` 이 없으면 inert(선언된 conformance
747
+ 가 없다는 뜻 — 선언을 강제하는 것은 planning 계약(Phase 4)의 몫). 매니페스트가
748
+ 있으면 결과 사이드카와 함께 evaluate_conformance 로 판정하고 BLOCKING verdict
749
+ 를 run 검증 실패로 승격한다. WAIVED(conditional)/EXEMPT 는 통과시킨다.
750
+ """
751
+ # conformance 산출물은 task-level(<task_root>/qa)에 있어 planning/
752
+ # implementation/final-verification 가 공유한다. report_path 는
753
+ # task_root/runs/<task-type>/reports/final-report.md 이므로 task_root 는
754
+ # run_dir(=report_path.parent.parent)에서 두 단계 위.
755
+ run_dir = report_path.parent.parent
756
+ qa_dir = run_dir.parent.parent / "qa"
757
+ manifest_path = qa_dir / "conformance-manifest.json"
758
+ if not manifest_path.is_file():
759
+ return
760
+ try:
761
+ manifest = json.loads(manifest_path.read_text())
762
+ except (OSError, json.JSONDecodeError) as exc:
763
+ failures.append(f"conformance manifest unreadable at {manifest_path}: {exc}")
764
+ return
765
+ schema_errors = validate_conformance_manifest(manifest)
766
+ if schema_errors:
767
+ failures.extend(f"conformance manifest: {e}" for e in schema_errors)
768
+ return
769
+ results = _load_conformance_results(qa_dir, manifest)
770
+ for verdict in evaluate_conformance(manifest, results):
771
+ if not verdict.ok:
772
+ failures.append(
773
+ f"conformance gate BLOCKING for stage {verdict.stage_key}: "
774
+ f"{verdict.message}. Run the stage's conformance script (or declare "
775
+ f"an exemption / user waiver) — see "
776
+ f"docs/superpowers/specs/2026-06-07-stage-conformance-qa-design.md."
777
+ )
778
+ changed_files = _parse_diff_summary_files(report_path.read_text(encoding="utf-8"))
779
+ if changed_files:
780
+ uncovered = detect_surfaces(changed_files, surface_patterns) - manifest_required_surfaces(manifest)
781
+ if uncovered:
782
+ failures.append(
783
+ "conformance gate BLOCKING: implementation diff touches undeclared "
784
+ f"surface(s) {sorted(uncovered)} — no stage declares `requires` for "
785
+ "them. Declare a conformance entry (requires=[...]) for the touching "
786
+ "stage, or an explicit exemption. (silent mock-green 방지 — DEV-9184)"
787
+ )
788
+
789
+
703
790
  def validate_report(
704
791
  report_path: Path, required_agent_status_entries: list[str], failures: list[str]
705
792
  ) -> None:
@@ -1917,6 +2004,15 @@ def main() -> int:
1917
2004
  validate_phase_boundary(task_type, report_path, failures)
1918
2005
  if task_type:
1919
2006
  validate_worker_results_audit(report_path, task_type, failures)
2007
+ if task_type in ("implementation", "final-verification"):
2008
+ _sp = None
2009
+ _pj = project_json_path(project_root)
2010
+ if _pj.is_file():
2011
+ try:
2012
+ _sp = (json.loads(_pj.read_text()).get("qaEnv") or {}).get("surfacePatterns")
2013
+ except (OSError, json.JSONDecodeError):
2014
+ _sp = None
2015
+ _validate_conformance(report_path, failures, surface_patterns=_sp)
1920
2016
  if task_type == "improvement-discovery":
1921
2017
  brief_relative = str(task_manifest.get("taskBriefPath") or "").strip()
1922
2018
  brief_path = (
@@ -15,7 +15,7 @@ export const OKSTRA_DIR = ".okstra";
15
15
  // Pre-v0.37 layout. `okstra migrate` reads this to find unmigrated projects.
16
16
  // 코드 path 빌드에는 절대 사용 금지 — `OKSTRA_DIR` 만. 오로지 migration
17
17
  // 탐지/안내 메시지 용도.
18
- export const LEGACY_OKSTRA_DIR = ".project-docs/okstra";
18
+ export const LEGACY_OKSTRA_DIR = "./okstra";
19
19
 
20
20
  export function okstraRoot(projectRoot) {
21
21
  return join(projectRoot, OKSTRA_DIR);
package/src/migrate.mjs DELETED
@@ -1,146 +0,0 @@
1
- import { buildPythonpath, resolvePaths } from "./paths.mjs";
2
- import { LEGACY_OKSTRA_DIR, OKSTRA_DIR } from "./okstra-dirs.mjs";
3
- import { runProcess } from "./_proc.mjs";
4
-
5
- const USAGE = `okstra migrate — move project artifacts from ${LEGACY_OKSTRA_DIR}/ to ${OKSTRA_DIR}/
6
-
7
- Run this once per project that was set up before v0.37. The migrator moves
8
- the okstra-managed directory tree, rewrites the import line in your
9
- project's CLAUDE.md, updates .gitignore, and refreshes okstra-home
10
- registries (~/.okstra/{recent,active}.jsonl, ~/.okstra/worktrees/registry.json)
11
- so the existing run history keeps resolving to the new path.
12
-
13
- Usage:
14
- okstra migrate Dry-run by default. Prints the plan as JSON.
15
- okstra migrate --apply Execute the move and ancillary updates.
16
- okstra migrate --cwd <dir> Search starting point (default: cwd).
17
- okstra migrate --quiet Suppress stdout on success; exit code reports.
18
-
19
- Exit codes:
20
- 0 plan prepared (dry-run) or migration applied successfully
21
- 1 pre-condition rejected (e.g. ${OKSTRA_DIR}/ already exists, or no
22
- ${LEGACY_OKSTRA_DIR}/ to migrate); reason printed on stderr
23
- 2 internal error (python invocation failed, etc.)
24
-
25
- The command is idempotent in spirit: re-running after a successful apply
26
- exits 1 with "nothing to migrate". Safe to wire into CI as a one-shot
27
- post-upgrade step.
28
- `;
29
-
30
- function parseArgs(args) {
31
- const opts = { cwd: process.cwd(), apply: false, quiet: false };
32
- for (let i = 0; i < args.length; i++) {
33
- const a = args[i];
34
- if (a === "--apply") opts.apply = true;
35
- else if (a === "--quiet" || a === "-q") opts.quiet = true;
36
- else if (a === "--cwd") {
37
- const next = args[i + 1];
38
- if (!next || next.startsWith("--")) throw new Error("--cwd requires a path");
39
- opts.cwd = next;
40
- i++;
41
- } else {
42
- throw new Error(`unknown argument '${a}'`);
43
- }
44
- }
45
- return opts;
46
- }
47
-
48
- function emit(opts, payload) {
49
- if (opts.quiet) return;
50
- process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
51
- }
52
-
53
- export async function run(args) {
54
- if (args.includes("--help") || args.includes("-h")) {
55
- process.stdout.write(USAGE);
56
- return 0;
57
- }
58
-
59
- let opts;
60
- try {
61
- opts = parseArgs(args);
62
- } catch (err) {
63
- process.stderr.write(`error: ${err.message}\n\n${USAGE}`);
64
- return 2;
65
- }
66
-
67
- const paths = await resolvePaths();
68
-
69
- // We resolve project root via the python resolver — the same one used by
70
- // every other okstra command — to keep "what project this is" consistent
71
- // across CLI surfaces. The resolver knows three lookup strategies; the
72
- // migrator only needs whichever ancestor it returns.
73
- const py = await runProcess(
74
- "python3",
75
- [
76
- "-c",
77
- [
78
- "import json, sys",
79
- "from pathlib import Path",
80
- "from okstra_project.resolver import resolve_project_root, ResolverError",
81
- "from okstra_ctl.migrate import (",
82
- " prepare_migration_plan, apply_migration_plan, MigrationRefused,",
83
- ")",
84
- "cwd, apply = sys.argv[1], sys.argv[2] == 'apply'",
85
- "try:",
86
- " # Resolver requires .okstra/project.json by default; for migrate",
87
- " # we fall back to the cwd directly because the legacy layout has",
88
- " # no .okstra/project.json yet.",
89
- " try:",
90
- " pr = resolve_project_root(explicit_root='', cwd=cwd)",
91
- " except ResolverError:",
92
- " pr = Path(cwd).resolve()",
93
- " plan = prepare_migration_plan(pr)",
94
- " if apply:",
95
- " result = apply_migration_plan(plan, dry_run=False)",
96
- " else:",
97
- " result = apply_migration_plan(plan, dry_run=True)",
98
- " print('OK')",
99
- " print(json.dumps({'plan': plan.to_dict(), 'result': result.to_dict()}, ensure_ascii=False))",
100
- "except MigrationRefused as e:",
101
- " print('REFUSED')",
102
- " print(str(e))",
103
- " sys.exit(1)",
104
- ].join("\n"),
105
- opts.cwd,
106
- opts.apply ? "apply" : "dry",
107
- ],
108
- { PYTHONPATH: buildPythonpath(paths) },
109
- );
110
-
111
- if (py.code === 1) {
112
- // Refused — pre-condition fail. Stderr message already on second line.
113
- const reason = py.stdout.split("\n").slice(1).join("\n").trim() || py.stderr.trim();
114
- if (!opts.quiet) process.stderr.write(`migrate refused: ${reason}\n`);
115
- return 1;
116
- }
117
- if (py.code !== 0) {
118
- process.stderr.write(`error: python invocation failed: ${py.stderr.trim() || py.stdout.trim()}\n`);
119
- return 2;
120
- }
121
-
122
- const lines = py.stdout.split("\n");
123
- if (lines[0]?.trim() !== "OK") {
124
- process.stderr.write(`error: unexpected python output: ${py.stdout}\n`);
125
- return 2;
126
- }
127
- let payload;
128
- try {
129
- payload = JSON.parse(lines.slice(1).join("\n"));
130
- } catch (err) {
131
- process.stderr.write(`error: failed to parse python output: ${err.message}\n`);
132
- return 2;
133
- }
134
-
135
- emit(opts, {
136
- ok: true,
137
- mode: opts.apply ? "apply" : "dry-run",
138
- ...payload,
139
- });
140
- if (!opts.apply && !opts.quiet) {
141
- process.stderr.write(
142
- "\nNo changes written. Re-run with --apply to perform the migration.\n",
143
- );
144
- }
145
- return 0;
146
- }