okstra 0.116.0 → 0.117.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.
@@ -541,6 +541,23 @@ scripts/okstra.sh --resume-clarification --task-key <project-id>:<task-group>:<t
541
541
 
542
542
  `$EDITOR`로 최신 final report가 열리고, 저장 후 같은 phase가 자동으로 `--clarification-response` carry-in 으로 재실행됩니다.
543
543
 
544
+ #### Incremental re-verification (implementation-planning 재실행 한정)
545
+
546
+ `implementation-planning` clarification 재실행은 기본이 **full 재검증**입니다. 다만 답변의 파급이 국소적이고 코드가 그대로라면, lead 가 영향 stage 의 하류 폐포만 재검증하고 나머지 stage 의 plan-item verdict 는 직전 run 에서 carry-forward 합니다. 판정은 결정적 CLI 와 lead 재량으로 나뉩니다.
547
+
548
+ - **C1 (CLI 판정, 결정적)**: 직전 run 의 `state/active-run-context-implementation-planning-<prev-seq>.json` 의 `executorWorktree.baseRef` 와 이번 run 의 base-ref SHA 가 같아야 합니다. 다르면 코드가 바뀐 것이므로 즉시 `full`.
549
+ - **C2 (lead 재량)**: 답변이 건드리는 Stage Map stage 번호 집합. Stage Map 에 없는 번호는 절대 넣지 않고, 매핑에 확신이 없거나 답변이 선택 Option/접근법을 뒤집으면 **빈 집합**을 넘겨 `full` 로 폴백합니다.
550
+ - **폐포 컷오프**: `okstra incremental-scope` 가 `implementationPlanning.stageMap` 의 의존성 그래프에서 영향 stage 의 `downstream_stage_closure` 를 구하고, 그 크기가 전체 stage 의 **과반을 넘으면** `full`. 그 외에는 `{mode, reverify_stages, carry_stages, reason}` JSON 을 출력합니다.
551
+
552
+ ```bash
553
+ okstra incremental-scope --prev-data <prev data.json> --cur-base-sha <sha> --prev-base-sha <sha> --impacted 2,3
554
+ okstra incremental-carry --prev-data <prev data.json> --cur-data <cur data.json> --prev-seq <prev-seq> --out <cur data.json>
555
+ ```
556
+
557
+ `mode == "incremental"` 이면 worker dispatch 는 `reverify_stages` 로만 범위가 좁혀지고(`prompts/profiles/implementation-planning.md` 의 *Cross-verification mode*), 워커는 `carry_stages` 를 재개봉·재판정하지 않습니다. 재실행이 끝나면 `okstra incremental-carry` 가 직전 run 의 plan-item verdict 중 이번 run 에 없는 항목을 `carriedForwardFromSeq` 태그와 함께 병합합니다. 두 run 의 `schemaVersion` 이 다르면 `CarryError` 로 비영 종료하고 full 로 폴백합니다. 재검증 stage 에서 plan item 이 **삭제**돼야 한다는 결론이 나오면 파급이 국소적이지 않다는 신호이므로 역시 full 로 재실행합니다. `verdictCard` / `finalVerdict` 는 절대 carry 되지 않고 매 run 재계산합니다.
558
+
559
+ 판정 결과는 report-writer 가 data.json 의 `implementationPlanning.incrementalDecision` 에 그대로 기록하고, 렌더러가 `### 0.1 Incremental Re-Verification Scope` 감사 블록으로 노출합니다. 절차 원문은 `prompts/launch.template.md` §"Incremental re-verification" 입니다.
560
+
544
561
  ### 6. 필요 시 구현 계획 검토
545
562
 
546
563
  ```bash
@@ -630,8 +647,10 @@ resume 판단 기준:
630
647
  Claude가 작성하는 최종 보고서는 아래 구조를 우선 사용합니다 (brief 의 augmentation 이 더 구체적인 형식을 요구할 때만 그것을 따릅니다).
631
648
 
632
649
  - `## Verdict Card` — **최상단 의무 섹션**. Final Conclusion / Verdict Token / Direction / Approval Required? / Next Step 5 행. Verdict Token / Direction / Next Step 셀은 본문 §2 (실행 현황) 와 §6 (다음 단계) 의 권위 셀과 byte-match 해야 합니다.
650
+ - (선택) `## Reader Summary` — data.json 의 `readerSummary` 가 있을 때만 Verdict Card 바로 아래 렌더되는 5행 표: 결정(`decision`) / 사람이 해야 할 행동(`humanActionRequired`) / 차단 항목(`blockingItems`) / 건너뛰어도 되는 것(`safeToSkip`) / 추천 명령(`recommendedCommand`). 존재하면 5개 필드 전부 필수이며 (schema `required`), raw evidence table 을 반복하지 않고 요약만 담습니다. 없는 과거 data.json 도 그대로 렌더됩니다.
633
651
  - `## 작업 배경과 근거` — **모든 task-type 의무 섹션** (data.json `rationale`). 검토자용 서술로 네 질문에 순서대로 답합니다: 왜 이 작업을 하는가(`motivation`) / 왜 이게 문제인가(`problem`) / 그래서 어떤 작업이 필요한가(`approach`) / 왜 이게 합리적 선택인가(`justification`). 표가 아니라 프로즈이며, 각 필드는 `path:line` · 보고서 ID(`C-001`) · `§5.4` 같은 증거 참조나 명시적 불충분 표시(`근거 불충분`) 중 하나를 반드시 포함해야 합니다 — `validators/validate-run.py` 의 `_validate_rationale_evidence` 가 둘 다 없는 필드를 fail 합니다.
634
652
  - (선택) `## 0. Clarification Response Carried In From Previous Run` — 직전 run 에서 응답이 carry-in 된 경우에만 렌더링. 빈 carry-in 일 때는 헤딩 자체를 출력하지 않습니다.
653
+ - (선택) `### 0.1 Incremental Re-Verification Scope` — data.json 의 `implementationPlanning.incrementalDecision` 이 있을 때만 렌더링. `mode == "incremental"` 이면 `Re-verified stages` / `Carried-forward stages` 두 행이 반드시 포함되며, `validators/validate-run.py` 가 누락을 `contract-violated` 로 차단합니다. carry 된 plan item 은 `carriedForwardFromSeq` 태그로 어느 run 에서 검증됐는지 남깁니다.
635
654
  - `## 1. 문제 또는 검증 대상 요약` — §6.1 Consensus / §6.2 Differences 표 각각 `Source items (worker:item)` 컬럼 보존 (cross-worker traceability).
636
655
  - `## 2. 에이전트별 실행 현황`
637
656
  - `## 3. Cross Verification 결과` — §2.1 Primary Evidence 에 `Source items (worker:item)` + `Source (path:line / log)` 컬럼.
@@ -650,6 +669,8 @@ Claude가 작성하는 최종 보고서는 아래 구조를 우선 사용합니
650
669
  Phase 7 step 1.5 가 final-report MD 한 본을 입력으로 self-contained HTML view 를 결정론적으로 자동 생성합니다.
651
670
 
652
671
  - `reports/final-report-<task-type>-<seq>.html` — 사람 reviewer 용 self-contained HTML. CSS / JS 인라인 임베드 (외부 URL 0), system color 다크모드, sticky header, 인쇄 대응. §1 `C-*` 행의 의사결정 입력 (체크박스 / 셀렉트 / textarea) 을 화면에서 채우고 `Export user response` 버튼으로 사이드카 markdown 을 생성합니다.
672
+ - **Reader Summary dashboard + reader mode**: 최상단에 `readerSummary`(없으면 `verdictCard` fallback) 기반 dashboard 를 렌더하고, `Action` / `Audit` / `Full` 세 reader mode 토글을 제공합니다. 기본은 `Action` — Reader Summary / Verdict Card / Clarification Items / Recommended Next Steps / Follow-up Tasks 만 보이고, Evidence · Cross Verification · Execution Status · Token Usage · Plan Body Verification · Round History 같은 감사용 섹션은 `Audit` / `Full` 에서 펼칩니다.
673
+ - **`C-*` 셀렉트 옵션 순서**: `Expected form` 의 `Recommended:` 답이 항상 **첫 옵션**으로 렌더되고, 이어지는 `Alternatives:` 항목이 `(a)`, `(b)`, ... 순으로 연속 재부여됩니다 (원본 문자 라벨을 그대로 쓰지 않음).
653
674
 
654
675
  진입점:
655
676
 
@@ -711,7 +732,7 @@ Phase 7 step 1.5 가 final-report MD 한 본을 입력으로 self-contained HTML
711
732
  phase 산출물의 출고 가능 여부를 강제하는 진입점:
712
733
 
713
734
  - `validators/validate-workflow.sh` — phase contract 통합 검증.
714
- - `validators/validate-run.py` — run-level final-report 본문 contract (Verdict Card 존재, `rationale` 4개 필드의 증거-앵커 강제(`_validate_rationale_evidence`), deprecated §6.1/§6.2/§5.5.8/§5.5.9 Open Questions 부재, Plan Body Verification gate × Approval 마커 cross-check, Token Usage sentinel/zero 차단, 워커-결과 audit 사이드카 존재).
735
+ - `validators/validate-run.py` — run-level final-report 본문 contract (Verdict Card 존재, `rationale` 4개 필드의 증거-앵커 강제(`_validate_rationale_evidence`), deprecated §6.1/§6.2/§5.5.8/§5.5.9 Open Questions 부재, Plan Body Verification gate × Approval 마커 cross-check, Token Usage sentinel/zero 차단, 워커-결과 audit 사이드카 존재, 증분 재검증 감사 블록 존재(`_check_incremental_audit_block` — data.json 이 `incrementalDecision.mode == "incremental"` 이면 `### 0.1 Incremental Re-Verification Scope` + `Re-verified stages` / `Carried-forward stages` 행 필수)).
715
736
  - `validators/validate-report-views.py` — self-contained HTML view 의 form-control 위치, 외부 URL 부재, stale source digest, Response ID parity(`C-*` ↔ HTML) 검사.
716
737
  - `validators/validate-brief.py` — brief schema (front-matter, `Reporter Confirmations` 섹션 존재, root parent-id self 규칙, slug 컨벤션 등) 강제. `bash validators/validate-brief.sh <brief.md>` 가 thin wrapper.
717
738
 
@@ -211,6 +211,8 @@ Important modules:
211
211
  | `implementation_stage.py` | `implementation` 단일-stage run 오케스트레이션 — Stage Lifecycle Snapshot 읽기 → 가용 Stage Map entry 선택 → 격리 stage worktree provision → 선택 stage 를 run context 로 발행 (`run.py` 에서 추출) |
212
212
  | `stage_targets.py` | Stage readiness/verification 정책 SSOT — Stage Lifecycle Snapshot(`consumers.jsonl` ledger + carry sidecar backfill + active registry reservation)으로 어떤 stage 가 실행 가능한지, 어느 commit 에서 분기하는지, final-verification 이 무엇을 검사하는지 결정. `order_stage_closure` 는 wizard 멀티선택 stage 집합의 의존성 closure 를 Kahn 위상정렬해 `chain-stages` 무인 연쇄 순서를 산출 |
213
213
  | `stage_reconcile.py` | stage prepare 흐름 공용 best-effort git reconciliation (`git_reconcile.auto_reconcile` 위임; advisory — 실패는 stderr 보고만, dependency gate 가 권위 판정 유지) |
214
+ | `incremental_scope.py` | `implementation-planning` clarification 재실행의 증분 재검증 판정 (결정적 순수 함수) — 직전 run data.json 의 `implementationPlanning.stageMap` 에서 의존성 그래프를 읽고, base-ref SHA 불변 + 영향 stage 의 `downstream_stage_closure` 가 전체 stage 의 과반 이하일 때만 `mode="incremental"` 을 반환. CLI: `okstra incremental-scope` |
215
+ | `incremental_carry.py` | 증분 재실행의 carry 병합 — 직전 run 의 plan-item verdict 중 이번 run 이 재검증하지 않은 항목을 `carriedForwardFromSeq` 태그와 함께 현재 data.json 에 병합. `schemaVersion` drift 시 `CarryError` 로 비영 종료해 full 폴백을 유도. CLI: `okstra incremental-carry` |
214
216
  | `run_context.py` | Per-task mutex, run context and run-input persistence; `consumers_mutex` helper for atomic `consumers.jsonl` writes |
215
217
  | `path_hints.py` | Compact path-hint persistence + legacy context hydration — `run-context` / `active-run-context` 를 schemaVersion `2.0` 의 `identity` + `pathHints` compact schema 로 저장하고, host-side reader 가 읽는 순간 기존 flat path key(`RUN_MANIFEST_RELATIVE_PATH`, `TEAM_STATE_PATH` 등)를 메모리에서 hydrate |
216
218
  | `consumers.py` | Append-only `consumers.jsonl` writer + reader — records which `implementation` runs consumed which `implementation-planning` stage |
@@ -291,6 +293,11 @@ Token/cost accounting:
291
293
 
292
294
  `schemas/final-report-v1.0.schema.json` is the final-report data.json contract. The report-writer worker writes `final-report-<task-type>-<seq>.data.json`; the renderer produces Markdown from that JSON.
293
295
 
296
+ Optional (v1.0 하위호환) 최상위 키:
297
+
298
+ - `readerSummary` — 사람이 먼저 읽는 요약 블록. 존재하면 5개 필드(`decision`, `humanActionRequired`, `blockingItems`, `safeToSkip`, `recommendedCommand`)가 모두 필수. 없는 기존 data.json 도 그대로 렌더됩니다(HTML dashboard 는 `verdictCard` 로 폴백).
299
+ - `implementationPlanning.incrementalDecision` — 증분 재검증 판정 (`mode`, `reverifyStages`, `carryStages`, `reason`). `mode == "incremental"` 이면 렌더러가 `### 0.1 Incremental Re-Verification Scope` 감사 블록을 emit 하고 `validators/validate-run.py` 가 그 부재를 차단합니다.
300
+
294
301
  ### 4.9 `validators/`
295
302
 
296
303
  | File | Role |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.116.0",
3
+ "version": "0.117.0",
4
4
  "description": "Multi-agent cross-verification orchestrator runtime + Claude Code skills.",
5
5
  "license": "MIT",
6
6
  "author": "devonshin",
@@ -1,5 +1,5 @@
1
1
  {
2
- "package": "0.116.0",
3
- "builtAt": "2026-07-09T17:28:22.971Z",
2
+ "package": "0.117.0",
3
+ "builtAt": "2026-07-14T11:54:12.876Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -599,6 +599,10 @@ _ALTERNATIVES_CUE = "Alternatives:"
599
599
  _PICK_ONE_ANNOTATION = re.compile(r"\s*\([^()]*택\s*\d+\s*\)\s*$")
600
600
 
601
601
 
602
+ def _lettered_option(letter: str, text: str) -> str:
603
+ return f"({letter}) {text}"
604
+
605
+
602
606
  def _parse_expected_form_options(expected_form: str) -> list[tuple[str, str]]:
603
607
  """Parse the ``Expected form`` contract format
604
608
  (``Recommended: <answer> — <rationale>; Alternatives: <options>``,
@@ -620,17 +624,24 @@ def _parse_expected_form_options(expected_form: str) -> list[tuple[str, str]]:
620
624
  # The rationale follows " — "; the option keeps only the answer part.
621
625
  answer = rec_body.split(" — ", 1)[0].strip(" .,;—-")
622
626
  options: list[tuple[str, str]] = []
627
+ next_letter_idx = 0
623
628
  if answer:
624
- options.append(("recommended", f"추천: {answer}"))
629
+ letter = _ENUM_LETTERS[next_letter_idx]
630
+ options.append(("recommended", _lettered_option(letter, answer)))
631
+ next_letter_idx += 1
625
632
  enum_alts = _parse_enum_options(alt_body)
626
633
  if enum_alts:
627
- options.extend(
628
- (letter, f"({letter}) {text}") for letter, text in enum_alts
629
- )
634
+ for _original_letter, text in enum_alts:
635
+ if next_letter_idx >= len(_ENUM_LETTERS):
636
+ break
637
+ letter = _ENUM_LETTERS[next_letter_idx]
638
+ options.append((letter, _lettered_option(letter, text)))
639
+ next_letter_idx += 1
630
640
  else:
631
641
  alt_text = alt_body.strip(" .,;—-")
632
- if alt_text:
633
- options.append(("alternative", alt_text))
642
+ if alt_text and next_letter_idx < len(_ENUM_LETTERS):
643
+ letter = _ENUM_LETTERS[next_letter_idx]
644
+ options.append((letter, _lettered_option(letter, alt_text)))
634
645
  return options
635
646
 
636
647
 
@@ -651,15 +662,19 @@ def _match_option_value(
651
662
  cv = (current_value or "").strip()
652
663
  if not cv:
653
664
  return None
665
+ normalized_current = _OPTION_LABEL_PREFIX.sub("", cv).strip()
654
666
  bodies = [
655
667
  (value, _OPTION_LABEL_PREFIX.sub("", label).strip())
656
668
  for value, label in opts
657
669
  ]
658
670
  for value, body in bodies:
659
- if body and (cv == body or cv == value):
671
+ if body and (normalized_current == body or cv == body or cv == value):
660
672
  return value
661
673
  for value, body in bodies:
662
- if len(body) >= _MIN_OPTION_PREFIX_LEN and cv.startswith(body):
674
+ if (
675
+ len(body) >= _MIN_OPTION_PREFIX_LEN
676
+ and (normalized_current.startswith(body) or cv.startswith(body))
677
+ ):
663
678
  return value
664
679
  return None
665
680
 
@@ -0,0 +1,107 @@
1
+ ---
2
+ name: okstra-pr-gen
3
+ description: Use when the user wants to register a PR body template or generate a PR description from a branch diff using a stored (or default) template. Manages templates under ~/.okstra/template/pr and drives okstra pr {template,branches,gen}. Trigger words include "okstra pr", "PR 템플릿 등록", "PR 본문 생성", "PR 메시지 만들어", "generate PR", "pr gen", "템플릿 등록".
4
+ ---
5
+
6
+ # okstra-pr-gen
7
+
8
+ Register PR body templates and generate PR descriptions. Templates live in the
9
+ user home at `~/.okstra/template/pr/`. This skill is **global** — it does not
10
+ require `<PROJECT_ROOT>/.okstra/project.json`. PR generation additionally
11
+ requires the current directory to be a git repository.
12
+
13
+ ## Step 0: Check CLI availability
14
+
15
+ Run as a separate Bash tool call with a literal leading token:
16
+
17
+ ```bash
18
+ okstra pr --help
19
+ ```
20
+
21
+ If `okstra` is not on PATH, tell the user:
22
+
23
+ `okstra not installed — run npx okstra@latest install once, then retry this skill.`
24
+
25
+ **Bash invocation rule:** every Bash command begins with the literal token
26
+ `okstra` and passes literal argument values. Do not introduce shell variables,
27
+ `$(...)`, leading `VAR=`, or wrap in `if`/`eval`/`||`/`&&` — that defeats the
28
+ `Bash(okstra:*)` permission match. Paste literal values emitted by a prior call.
29
+
30
+ ## Step 1: Pick the mode (always first)
31
+
32
+ Present a 3-option picker (`AskUserQuestion`):
33
+
34
+ 1. `PR 생성` — generate a PR body from a branch diff.
35
+ 2. `템플릿 등록` — save a new PR body template.
36
+ 3. `직접 입력` — always the last option (okstra picker convention).
37
+
38
+ ## Mode A — PR 생성
39
+
40
+ ### A1. Pick a template
41
+
42
+ ```bash
43
+ okstra pr template list --json
44
+ ```
45
+
46
+ - If `templates` is non-empty, present a picker: 1–2 recommended templates from
47
+ the list, plus `기본 템플릿`(the bundled default), plus `직접 입력` last.
48
+ - If empty, tell the user the bundled default template will be used.
49
+
50
+ Carry the chosen template name as `<template>` (`default` for the bundled one).
51
+
52
+ ### A2. Pick the base branch
53
+
54
+ ```bash
55
+ okstra pr branches --json
56
+ ```
57
+
58
+ Present a 3-option base picker from `recommended` (top entries) plus `직접 입력`
59
+ last. Carry the choice as `<base>`.
60
+
61
+ ### A3. Build the generation bundle and fill the template
62
+
63
+ ```bash
64
+ okstra pr gen --base <base> --template <template> --json
65
+ ```
66
+
67
+ Parse `{base, currentBranch, templateName, template, commits, diffStat}`. Then
68
+ read the full diff honestly — it is the source of truth:
69
+
70
+ ```bash
71
+ git diff <base>...HEAD
72
+ ```
73
+
74
+ For a large diff, read it in sections. Fill the `template` placeholders from the
75
+ diff and commits: describe only what actually changed. Mark checklist boxes
76
+ `[x]` only when the diff supports them (tests touched → tests box, docs touched →
77
+ docs box). If `commits`/`diffStat` are empty, tell the user there is nothing to
78
+ describe and stop. **Never** append AI trailers/footers.
79
+
80
+ ### A4. Output and offer to create the PR
81
+
82
+ Print the filled PR body to chat as a single fenced markdown block. Then ask the
83
+ user whether to open a real PR. Only on an explicit yes:
84
+
85
+ ```bash
86
+ gh pr create --base <base> --title "<title>" --body-file <path-to-body>
87
+ ```
88
+
89
+ Write the body to a temp file first and pass `--body-file`. If `gh` is missing
90
+ or not authenticated (`gh auth status` fails), leave the text in chat and tell
91
+ the user to create the PR manually. Do not push or create the PR without the
92
+ user's explicit confirmation.
93
+
94
+ ## Mode B — 템플릿 등록
95
+
96
+ 1. Ask for a template name (`AskUserQuestion`, free text). It must match
97
+ `^[A-Za-z0-9._-]+$`; re-ask on invalid input.
98
+ 2. Ask for the template body — either pasted text or an absolute file path.
99
+ 3. Save it:
100
+
101
+ ```bash
102
+ okstra pr template add --name <name> --file <abs-path>
103
+ ```
104
+
105
+ or, for pasted text, `--content "<body>"`. Add `--yes` only if the user
106
+ confirmed overwriting an existing template of the same name. Report the
107
+ saved path (`saved: ...`) back to the user.
@@ -329,6 +329,16 @@ export const COMMAND_REGISTRY = [
329
329
  "~/.okstra/memory-book.",
330
330
  ],
331
331
  },
332
+ {
333
+ name: "pr",
334
+ module: "./commands/pr/pr.mjs",
335
+ export: "run",
336
+ category: "introspection",
337
+ summary: [
338
+ "Manage PR body templates and prepare PR generation",
339
+ "bundles under ~/.okstra/template/pr.",
340
+ ],
341
+ },
332
342
  ];
333
343
 
334
344
  export const COMMANDS = new Map(
@@ -0,0 +1,21 @@
1
+ ## **Please check if the PR fulfills these requirements**
2
+ - [ ] Commits have a single intent
3
+ - [ ] Tests for the changes have been added (for bug fixes / features)
4
+ - [ ] I reviewed my own code
5
+ - [ ] I tested the changes (if not, explain why in the "Other information" section)
6
+ - [ ] Docs have been added / updated
7
+
8
+ ## **What kind of change does this PR introduce?** (Bug fix, feature, docs update, ...)
9
+ <pr-kind>
10
+
11
+ ## **What is the current behavior?** (You can also link to an open issue here)
12
+ <current-behavior>
13
+
14
+ ## **What is the new behavior (if this is a feature change)?**
15
+ <new-behavior>
16
+
17
+ ## **Does this PR introduce a breaking change?** (What changes might users need to make in their application due to this PR?)
18
+ <breaking-change>
19
+
20
+ ## **Other information**:
21
+ <other-information>
@@ -0,0 +1,261 @@
1
+ import { promises as fs } from "node:fs";
2
+ import { execFileSync } from "node:child_process";
3
+ import { fileURLToPath } from "node:url";
4
+ import { dirname, join, resolve } from "node:path";
5
+ import { createInterface } from "node:readline/promises";
6
+ import { stdin as input, stdout as output } from "node:process";
7
+ import { resolveOkstraHome } from "../../lib/paths.mjs";
8
+
9
+ const NAME_RE = /^[A-Za-z0-9._-]+$/;
10
+ const DEFAULT_TEMPLATE_PATH = join(
11
+ dirname(fileURLToPath(import.meta.url)),
12
+ "default.md",
13
+ );
14
+
15
+ const USAGE = `okstra pr — PR body templates and PR generation
16
+
17
+ Usage:
18
+ okstra pr template list [--json]
19
+ okstra pr template show <name|default> [--json]
20
+ okstra pr template add --name <name> (--content <text> | --file <path>) [--yes]
21
+ okstra pr template path
22
+ okstra pr branches [--json]
23
+ okstra pr gen --base <ref> [--template <name|default>] [--json]
24
+ `;
25
+
26
+ export function isValidTemplateName(name) {
27
+ return (
28
+ typeof name === "string" &&
29
+ NAME_RE.test(name) &&
30
+ name !== "." &&
31
+ name !== ".."
32
+ );
33
+ }
34
+
35
+ export function templateDir(home) {
36
+ return join(home, "template", "pr");
37
+ }
38
+
39
+ export async function listTemplates(home) {
40
+ let entries;
41
+ try {
42
+ entries = await fs.readdir(templateDir(home));
43
+ } catch (err) {
44
+ if (err.code === "ENOENT") return [];
45
+ throw err;
46
+ }
47
+ return entries
48
+ .filter((f) => f.endsWith(".md"))
49
+ .map((f) => f.slice(0, -3))
50
+ .sort();
51
+ }
52
+
53
+ async function readBundledDefault() {
54
+ return fs.readFile(DEFAULT_TEMPLATE_PATH, "utf8");
55
+ }
56
+
57
+ export async function readTemplate(home, name) {
58
+ if (!name || name === "default") return readBundledDefault();
59
+ if (!isValidTemplateName(name)) throw new Error(`invalid template name: ${name}`);
60
+ try {
61
+ return await fs.readFile(join(templateDir(home), `${name}.md`), "utf8");
62
+ } catch (err) {
63
+ if (err.code === "ENOENT") return readBundledDefault();
64
+ throw err;
65
+ }
66
+ }
67
+
68
+ export async function addTemplate(home, name, content, opts = {}) {
69
+ if (!isValidTemplateName(name)) throw new Error(`invalid template name: ${name}`);
70
+ const dir = templateDir(home);
71
+ await fs.mkdir(dir, { recursive: true });
72
+ const target = join(dir, `${name}.md`);
73
+ if (!opts.force) {
74
+ try {
75
+ await fs.access(target);
76
+ throw new Error(`template exists: ${name} (use --yes to overwrite)`);
77
+ } catch (err) {
78
+ if (err.code !== "ENOENT") throw err;
79
+ }
80
+ }
81
+ await fs.writeFile(target, content, "utf8");
82
+ return target;
83
+ }
84
+
85
+ function takeValue(args, i, flag) {
86
+ const v = args[i + 1];
87
+ if (!v || v.startsWith("--")) throw new Error(`${flag} requires a value`);
88
+ return v;
89
+ }
90
+
91
+ async function readAddContent(opts) {
92
+ if (opts.content !== null) return opts.content;
93
+ if (opts.file !== null) return fs.readFile(resolve(opts.file), "utf8");
94
+ throw new Error("template add requires --content or --file");
95
+ }
96
+
97
+ async function confirm(question) {
98
+ const rl = createInterface({ input, output });
99
+ try {
100
+ const answer = (await rl.question(`${question} [y/N] `)).trim().toLowerCase();
101
+ return answer === "y" || answer === "yes";
102
+ } finally {
103
+ rl.close();
104
+ }
105
+ }
106
+
107
+ async function cmdTemplate(args, home) {
108
+ const sub = args[0];
109
+ const rest = args.slice(1);
110
+ const json = rest.includes("--json");
111
+ if (sub === "list") {
112
+ const names = await listTemplates(home);
113
+ if (json) output.write(JSON.stringify({ templates: names }) + "\n");
114
+ else output.write(names.length ? names.join("\n") + "\n" : "(no templates)\n");
115
+ return 0;
116
+ }
117
+ if (sub === "show") {
118
+ const name = rest.find((a) => !a.startsWith("--"));
119
+ if (!name) throw new Error("template show requires a name");
120
+ const body = await readTemplate(home, name);
121
+ output.write(json ? JSON.stringify({ name, body }) + "\n" : body);
122
+ return 0;
123
+ }
124
+ if (sub === "path") {
125
+ output.write(templateDir(home) + "\n");
126
+ return 0;
127
+ }
128
+ if (sub === "add") {
129
+ const opts = { name: null, content: null, file: null, yes: false };
130
+ for (let i = 0; i < rest.length; i++) {
131
+ const flag = rest[i];
132
+ if (flag === "--name") opts.name = takeValue(rest, i++, flag);
133
+ else if (flag === "--content") opts.content = takeValue(rest, i++, flag);
134
+ else if (flag === "--file") opts.file = takeValue(rest, i++, flag);
135
+ else if (flag === "--yes") opts.yes = true;
136
+ else throw new Error(`unknown flag ${flag}`);
137
+ }
138
+ if (!opts.name) throw new Error("template add requires --name");
139
+ const content = await readAddContent(opts);
140
+ const dir = templateDir(home);
141
+ const target = join(dir, `${opts.name}.md`);
142
+ let force = opts.yes;
143
+ if (!force && isValidTemplateName(opts.name)) {
144
+ let exists = false;
145
+ try {
146
+ await fs.access(target);
147
+ exists = true;
148
+ } catch (err) {
149
+ if (err.code !== "ENOENT") throw err;
150
+ }
151
+ if (exists) {
152
+ if (!process.stdin.isTTY) {
153
+ throw new Error(`template exists: ${opts.name} (use --yes to overwrite)`);
154
+ }
155
+ force = await confirm(`Overwrite existing template ${opts.name}?`);
156
+ if (!force) { output.write("aborted\n"); return 1; }
157
+ }
158
+ }
159
+ const saved = await addTemplate(home, opts.name, content, { force: true });
160
+ output.write(`saved: ${saved}\n`);
161
+ return 0;
162
+ }
163
+ throw new Error(`unknown template subcommand: ${sub ?? "(none)"}`);
164
+ }
165
+
166
+ function tryGit(cwd, args) {
167
+ try {
168
+ return execFileSync("git", args, { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
169
+ } catch {
170
+ return null;
171
+ }
172
+ }
173
+
174
+ export function recommendBranches(cwd) {
175
+ const currentBranch = tryGit(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
176
+ if (currentBranch === null) return { currentBranch: null, recommended: [] };
177
+ const ordered = [];
178
+ const remoteDefault = tryGit(cwd, ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]);
179
+ if (remoteDefault) {
180
+ const bare = remoteDefault.replace(/^origin\//, "");
181
+ if (tryGit(cwd, ["rev-parse", "--verify", "--quiet", `${bare}^{commit}`]) !== null) ordered.push(bare);
182
+ else if (tryGit(cwd, ["rev-parse", "--verify", "--quiet", `${remoteDefault}^{commit}`]) !== null) ordered.push(remoteDefault);
183
+ }
184
+ for (const b of ["main", "master", "develop"]) {
185
+ const local = tryGit(cwd, ["show-ref", "--verify", "--quiet", `refs/heads/${b}`]);
186
+ if (local !== null) ordered.push(b);
187
+ }
188
+ const recent = tryGit(cwd, [
189
+ "for-each-ref", "--sort=-committerdate", "--format=%(refname:short)", "refs/heads",
190
+ ]);
191
+ if (recent) ordered.push(...recent.split("\n").filter(Boolean));
192
+ const recommended = [];
193
+ for (const b of ordered) {
194
+ if (b && b !== currentBranch && !recommended.includes(b)) recommended.push(b);
195
+ }
196
+ return { currentBranch, recommended };
197
+ }
198
+
199
+ function cmdBranches(args, cwd) {
200
+ const json = args.includes("--json");
201
+ const r = recommendBranches(cwd);
202
+ if (json) output.write(JSON.stringify(r) + "\n");
203
+ else output.write((r.recommended.join("\n") || "(no branches)") + "\n");
204
+ return 0;
205
+ }
206
+
207
+ export async function buildGenBundle({ cwd, home, base, templateName }) {
208
+ if (!base) throw new Error("gen requires --base");
209
+ const verified = tryGit(cwd, ["rev-parse", "--verify", "--quiet", `${base}^{commit}`]);
210
+ if (verified === null) throw new Error(`base ref not found: ${base}`);
211
+ const currentBranch = tryGit(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
212
+ const logRaw = tryGit(cwd, [
213
+ "log", "--reverse", "--format=%h%x09%s", `${base}..HEAD`,
214
+ ]) || "";
215
+ const commits = logRaw
216
+ .split("\n")
217
+ .filter(Boolean)
218
+ .map((line) => {
219
+ const tab = line.indexOf("\t");
220
+ return { hash: line.slice(0, tab), subject: line.slice(tab + 1) };
221
+ });
222
+ const diffStat = tryGit(cwd, ["diff", "--stat", `${base}...HEAD`]) || "";
223
+ const template = await readTemplate(home, templateName);
224
+ const resolvedName = templateName && templateName !== "default"
225
+ ? templateName
226
+ : "default";
227
+ return { base, currentBranch, templateName: resolvedName, template, commits, diffStat };
228
+ }
229
+
230
+ async function cmdGen(args, cwd, home) {
231
+ const opts = { base: null, template: "default" };
232
+ for (let i = 0; i < args.length; i++) {
233
+ const flag = args[i];
234
+ if (flag === "--base") opts.base = takeValue(args, i++, flag);
235
+ else if (flag === "--template") opts.template = takeValue(args, i++, flag);
236
+ else if (flag === "--json") continue;
237
+ else throw new Error(`unknown flag ${flag}`);
238
+ }
239
+ const bundle = await buildGenBundle({ cwd, home, base: opts.base, templateName: opts.template });
240
+ output.write(JSON.stringify(bundle) + "\n");
241
+ return 0;
242
+ }
243
+
244
+ export async function run(args) {
245
+ if (args.length === 0 || args.includes("--help")) {
246
+ output.write(USAGE);
247
+ return 0;
248
+ }
249
+ const home = resolveOkstraHome();
250
+ const sub = args[0];
251
+ const rest = args.slice(1);
252
+ try {
253
+ if (sub === "template") return await cmdTemplate(rest, home);
254
+ if (sub === "branches") return cmdBranches(rest, process.cwd());
255
+ if (sub === "gen") return await cmdGen(rest, process.cwd(), home);
256
+ throw new Error(`unknown pr subcommand: ${sub}`);
257
+ } catch (err) {
258
+ process.stderr.write(`error: ${err.message}\n`);
259
+ return 1;
260
+ }
261
+ }
@@ -15,6 +15,7 @@ export const USER_SKILL_NAMES = Object.freeze([
15
15
  "okstra-schedule",
16
16
  "okstra-container-build",
17
17
  "okstra-graphify",
18
+ "okstra-pr-gen",
18
19
  ]);
19
20
 
20
21
  // Names okstra used to install as skills before they were renamed or moved