okstra 0.13.2 → 0.14.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.
@@ -18,7 +18,7 @@
18
18
  - **Single python authority**: 모든 prepare wiring(profile/workers/model 해소, path 계산, 9개 render, central record_start)이 [`okstra_ctl.run.prepare_task_bundle()`](scripts/okstra_ctl/run.py) 한 함수에 모여 있습니다. `okstra.sh` 와 `okstra-run` skill 은 같은 함수를 호출하는 thin caller 이며, 환경 변수로 상태를 전달하지 않습니다 — task 정체성·경로·workflow 상태는 모두 디스크 권위 파일에서 매번 계산됩니다.
19
19
  - **Claude handoff (두 모드)**: (a) `okstra.sh` 가 새 `claude` 프로세스를 띄우는 전통 방식, (b) `okstra-run` skill 이 현재 claude 세션 안에서 prepare 후 lead 역할을 그대로 인계받는 in-session 모드. 둘 다 `prepare_task_bundle` 의 산출물(instruction-set 등)을 그대로 사용합니다.
20
20
  - **Required team contract**: `Claude lead` + `Claude worker` · `Codex worker` · `Gemini worker` · `Report writer worker`의 필수 구성과 Agent Teams 우선 시도를 강제합니다.
21
- - **User-home install + project-local task bundles**: `npx okstra@latest install` 한 명령이 런타임(`~/.okstra/{lib/python, bin}`) + 스킬 마크다운(`~/.claude/skills/<name>/SKILL.md`) 을 모두 깐다. 대상 프로젝트에는 task bundle 과 discovery metadata `.project-docs/okstra/` 아래 저장됩니다 사용자의 `~/.claude/settings.json` 이나 프로젝트 `.claude/settings.local.json` 건드리지 않으며, per-session permission `claude --settings` 런타임 주입합니다. (개발용으로는 `okstra-install.sh` 가 `--link` 모드 symlink 설치를 제공합니다.)
21
+ - **User-home install + project-local task bundles**: `npx okstra@latest install` 한 명령이 런타임(`~/.okstra/{lib/python, bin, templates}`) + 스킬 마크다운(`~/.claude/skills/<name>/SKILL.md`) 을 모두 깐다. 대상 프로젝트에는 task bundle 과 discovery metadata `.project-docs/okstra/` 아래 저장되고, **추가로 `<PROJECT_ROOT>/.claude/settings.local.json` `~/.okstra/templates/settings.local.json` 가리키는 symlink 로 provisioning** 됩니다 (`okstra setup` 또는 `okstra-ctl` prepare idempotent 하게 관리; 기존에 일반 파일이 있었다면 `.bak.<timestamp>` 로 백업 후 교체). 이 symlink 가 host Claude Code 세션에 자동 로드되어 codex/gemini worker wrapper 호출 권한을 부여하므로, 사용자의 글로벌 `~/.claude/settings.json` 은 건드리지 않으며 별도 `--settings` CLI 주입도 필요 없습니다. (개발용으로는 `okstra-install.sh` 가 `--link` 모드 symlink 설치를 제공합니다.)
22
22
  - **Resume and clarification**: `--task-key`, `--resume-clarification`, `--clarification-response`로 같은 task 재개와 lead의 추가 질문 응답 흐름을 지원합니다.
23
23
  - **Optional integrations**: worker error sidecar, token usage / cost accounting을 옵션으로 제공합니다.
24
24
 
@@ -213,7 +213,7 @@ per-process 환경 변수에 task 정체성·경로·workflow 상태를 보관
213
213
  **Mode A — `okstra.sh` 가 새 claude 프로세스를 띄움**
214
214
  - `--render-only`를 사용하면 Claude를 실행하지 않고 instruction-set 만 만든 뒤 종료합니다.
215
215
  - `--render-only`가 없으면 prepare 단계가 Claude session ID 를 선할당하고 current run 의 `sessions/` 아래에 `claude-resume-<task-type>-<seq>.sh` 를 생성합니다.
216
- - 이후 대상 프로젝트 루트에서 resolved `Claude lead` model execution value 로 `claude --model <lead> --session-id "$CLAUDE_SESSION_ID" --settings <runtime-settings> "$PROMPT"` 를 `exec` 합니다.
216
+ - 이후 대상 프로젝트 루트에서 resolved `Claude lead` model execution value 로 `claude --model <lead> --session-id "$CLAUDE_SESSION_ID" "$PROMPT"` 를 `exec` 합니다. (이전 버전의 `--settings <runtime-settings>` 인자는 0.14.0 부터 제거됨 — 권한은 `<PROJECT_ROOT>/.claude/settings.local.json` symlink 가 담당.)
217
217
  - `okstra.sh` 는 handoff 까지만 수행하고, 최종 보고서 저장과 run/task 상태 갱신은 Claude lead 가 이어서 수행합니다.
218
218
 
219
219
  **Mode B — `okstra-run` skill 이 현재 claude 세션 안에서 인계**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.13.2",
3
+ "version": "0.14.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.13.2",
3
- "builtAt": "2026-05-13T02:07:39.150Z",
2
+ "package": "0.14.0",
3
+ "builtAt": "2026-05-13T02:55:11.516Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -152,17 +152,20 @@ if [[ -z "$LAUNCH_JSON" ]]; then
152
152
  fi
153
153
 
154
154
  # Read fields via python (jq not assumed available).
155
- read -r CLAUDE_SESSION_ID LEAD_MODEL_EXECUTION_VALUE PROJECT_ROOT_FROM_PY OKSTRA_RUNTIME_SETTINGS_FILE PROMPT_FILE < <(
155
+ read -r CLAUDE_SESSION_ID LEAD_MODEL_EXECUTION_VALUE PROJECT_ROOT_FROM_PY PROMPT_FILE < <(
156
156
  okstra_py - "$LAUNCH_JSON" <<'PY'
157
157
  import json, sys
158
158
  d = json.loads(sys.argv[1])
159
- print(d["claudeSessionId"], d["leadModelExecutionValue"], d["projectRoot"], d["runtimeSettingsFile"], d["promptFile"])
159
+ print(d["claudeSessionId"], d["leadModelExecutionValue"], d["projectRoot"], d["promptFile"])
160
160
  PY
161
161
  )
162
162
 
163
+ # Note: per-session --settings injection was removed. okstra-ctl prepare
164
+ # provisions <PROJECT_ROOT>/.claude/settings.local.json as a symlink to
165
+ # ~/.okstra/templates/settings.local.json, which Claude Code auto-loads
166
+ # whenever it runs inside that project — no CLI flag required.
163
167
  PROMPT="$(cat "$PROMPT_FILE")"
164
168
  cd "$PROJECT_ROOT_FROM_PY"
165
169
  CLAUDE_COMMAND=(claude --model "$LEAD_MODEL_EXECUTION_VALUE" --session-id "$CLAUDE_SESSION_ID")
166
- [[ -n "$OKSTRA_RUNTIME_SETTINGS_FILE" ]] && CLAUDE_COMMAND+=(--settings "$OKSTRA_RUNTIME_SETTINGS_FILE")
167
170
  CLAUDE_COMMAND+=("$PROMPT")
168
171
  exec "${CLAUDE_COMMAND[@]}"
@@ -23,7 +23,6 @@ import subprocess
23
23
  from dataclasses import dataclass, field
24
24
  from datetime import datetime, timezone
25
25
  from pathlib import Path
26
- from typing import Optional
27
26
 
28
27
  from okstra_project import upsert_project_json
29
28
  from .material import (
@@ -47,8 +46,9 @@ from .render import (
47
46
  )
48
47
  from .run_context import compute_and_write_run_context, write_run_inputs
49
48
  from .seeding import (
49
+ SettingsLinkError,
50
50
  cleanup_obsolete_generated_docs,
51
- render_runtime_settings_file,
51
+ ensure_project_settings_symlink,
52
52
  verify_installation,
53
53
  )
54
54
  from .session import (
@@ -102,7 +102,6 @@ class PrepareInputs:
102
102
  class PrepareOutputs:
103
103
  ctx: dict
104
104
  prompt_text: str
105
- runtime_settings_path: Optional[Path]
106
105
  extras: dict = field(default_factory=dict)
107
106
 
108
107
 
@@ -764,16 +763,26 @@ def prepare_task_bundle(inp: PrepareInputs) -> PrepareOutputs:
764
763
  file=__import__("sys").stderr,
765
764
  )
766
765
 
767
- runtime_settings_path = None
768
766
  if not inp.render_only:
769
- runtime_settings_path = render_runtime_settings_file(
770
- workspace_root=workspace_root, run_dir=Path(ctx["RUN_DIR"]),
771
- )
767
+ try:
768
+ link = ensure_project_settings_symlink(project_root=Path(inp.project_root))
769
+ except SettingsLinkError as exc:
770
+ print(
771
+ f"okstra-settings: failed to provision project settings symlink — "
772
+ f"worker dispatch may be blocked by Claude Code permissions. ({exc})",
773
+ file=__import__("sys").stderr,
774
+ )
775
+ else:
776
+ if link is None:
777
+ print(
778
+ "okstra-settings: ~/.okstra/templates/settings.local.json missing — "
779
+ "re-run 'npx okstra@latest install' (0.14.0+) to provision the symlink target.",
780
+ file=__import__("sys").stderr,
781
+ )
772
782
 
773
783
  return PrepareOutputs(
774
784
  ctx=ctx,
775
785
  prompt_text=prompt_text,
776
- runtime_settings_path=runtime_settings_path,
777
786
  extras={"profile_content": profile_content},
778
787
  )
779
788
 
@@ -919,7 +928,6 @@ def main(argv: list[str]) -> int:
919
928
  "claudeSessionId": ctx["CLAUDE_SESSION_ID"],
920
929
  "leadModelExecutionValue": ctx["LEAD_MODEL_EXECUTION_VALUE"],
921
930
  "projectRoot": ctx["PROJECT_ROOT"],
922
- "runtimeSettingsFile": str(out.runtime_settings_path) if out.runtime_settings_path else "",
923
931
  "promptFile": str(Path(ctx["INSTRUCTION_SET_DIR"]) / "claude-execution-prompt.md"),
924
932
  }
925
933
  print(f"__OKSTRA_LAUNCH__ {json.dumps(machine)}")
@@ -1,13 +1,16 @@
1
- """okstra runtime asset verification + per-run runtime settings.
1
+ """okstra runtime asset verification + project settings provisioning.
2
2
 
3
3
  okstra 가 깔아둔 런타임(`~/.okstra/lib/python`, `~/.okstra/bin`,
4
4
  `~/.okstra/version`) 이 있는지 확인하고, 누락 시 InstallationError 로
5
- surface 한다. 또한 claude 런치 때 사용할 휘발성 settings 파일을 현재 run
6
- 디렉토리에 만든다.
5
+ surface 한다. 또한 대상 프로젝트의 `.claude/settings.local.json`
6
+ `~/.okstra/templates/settings.local.json` 으로 가리키는 symlink 로
7
+ provision 해서, host Claude Code 세션이 같은 프로젝트에서 일하는 동안
8
+ okstra worker wrapper 호출이 자동 허용되도록 한다.
7
9
  """
8
10
  from __future__ import annotations
9
11
 
10
- import shutil
12
+ import os
13
+ import time
11
14
  from pathlib import Path
12
15
  from typing import Optional
13
16
 
@@ -16,6 +19,10 @@ class InstallationError(Exception):
16
19
  """okstra 가 깔아둔 런타임 자산이 누락됨."""
17
20
 
18
21
 
22
+ class SettingsLinkError(Exception):
23
+ """`<project>/.claude/settings.local.json` symlink provisioning 실패."""
24
+
25
+
19
26
  def required_install_paths() -> list[Path]:
20
27
  """okstra install 이 채워야 하는 최소 자산 경로."""
21
28
  okstra_home = Path.home() / ".okstra"
@@ -82,16 +89,90 @@ def cleanup_obsolete_generated_docs(
82
89
  pass
83
90
 
84
91
 
85
- def render_runtime_settings_file(
86
- *, workspace_root: Path, run_dir: Path,
87
- ) -> Optional[Path]:
88
- """`templates/reports/settings.template.json` 을 `<run-dir>/okstra-runtime-settings.json`
89
- 으로 복사한다. 템플릿 부재 시 None 반환(상위에서 `--settings` 인자 skip).
92
+ def _okstra_home() -> Path:
93
+ """`~/.okstra` 절대경로. 테스트에서 `OKSTRA_HOME` 으로 override 가능."""
94
+ override = os.environ.get("OKSTRA_HOME", "").strip()
95
+ if override:
96
+ return Path(override)
97
+ return Path.home() / ".okstra"
98
+
99
+
100
+ def installed_settings_template_path() -> Path:
101
+ """okstra install 이 만들어 둔 settings.local.json template 의 절대경로."""
102
+ return _okstra_home() / "templates" / "settings.local.json"
103
+
104
+
105
+ def ensure_project_settings_symlink(*, project_root: Path) -> Optional[Path]:
106
+ """`<project_root>/.claude/settings.local.json` 을
107
+ `~/.okstra/templates/settings.local.json` 으로 가리키는 symlink 로
108
+ provisioning 한다.
109
+
110
+ Claude Code 가 그 프로젝트에서 host 세션으로 실행될 때 이 파일을
111
+ 자동으로 로드하므로, okstra worker wrapper 호출(`okstra-codex-exec.sh`,
112
+ `okstra-gemini-exec.sh`) 이 별도 `--settings` 인자 없이도 허용된다.
113
+
114
+ 반환값:
115
+ - target Path: symlink 가 새로 생성되었거나 이미 올바른 위치를
116
+ 가리키고 있을 때.
117
+ - None: install 이 아직 settings template 을 깔지 않았을 때
118
+ (구버전 okstra install 등). 상위에서 경고로 흘려보낸다.
119
+
120
+ 상위 호출자는 `SettingsLinkError` 만 처리하면 된다 — symlink target
121
+ 의 dangling 여부, regular 파일 충돌, 사용자가 직접 만든 다른
122
+ symlink 등 의도된 boundary error 만 발생한다.
90
123
  """
91
- template = Path(workspace_root) / "templates" / "reports" / "settings.template.json"
92
- if not template.is_file():
124
+ project_root = Path(project_root)
125
+ template = installed_settings_template_path()
126
+ if not template.exists():
127
+ # install 이 0.13.x 이전 버전이면 templates/ 가 깔리지 않았을 수 있다.
128
+ # 상위에서 안내 메시지로 처리.
93
129
  return None
94
- target = Path(run_dir) / "okstra-runtime-settings.json"
95
- target.parent.mkdir(parents=True, exist_ok=True)
96
- shutil.copyfile(template, target)
130
+
131
+ claude_dir = project_root / ".claude"
132
+ target = claude_dir / "settings.local.json"
133
+ claude_dir.mkdir(parents=True, exist_ok=True)
134
+
135
+ # idempotent: 이미 올바른 target 을 가리키는 symlink 면 no-op.
136
+ if target.is_symlink():
137
+ try:
138
+ current = os.readlink(target)
139
+ except OSError as exc:
140
+ raise SettingsLinkError(
141
+ f"failed to read existing symlink {target}: {exc}"
142
+ ) from exc
143
+ if Path(current) == template or (claude_dir / current).resolve() == template.resolve():
144
+ return target
145
+ # okstra 가 관리하지 않는 다른 symlink 였으면 backup 후 교체.
146
+ _backup_and_replace(target, template)
147
+ return target
148
+
149
+ if target.exists():
150
+ # 일반 파일이 있으면 사용자 작성물일 가능성이 높다 — 손실 방지 backup.
151
+ _backup_and_replace(target, template)
152
+ return target
153
+
154
+ try:
155
+ target.symlink_to(template)
156
+ except OSError as exc:
157
+ raise SettingsLinkError(
158
+ f"failed to create symlink {target} -> {template}: {exc}"
159
+ ) from exc
97
160
  return target
161
+
162
+
163
+ def _backup_and_replace(target: Path, template: Path) -> None:
164
+ """기존 파일/심볼릭링크를 timestamped backup 으로 옮기고 새 symlink 생성."""
165
+ stamp = time.strftime("%Y%m%d-%H%M%S")
166
+ backup = target.with_name(f"{target.name}.bak.{stamp}")
167
+ try:
168
+ target.rename(backup)
169
+ except OSError as exc:
170
+ raise SettingsLinkError(
171
+ f"failed to back up existing {target} to {backup}: {exc}"
172
+ ) from exc
173
+ try:
174
+ target.symlink_to(template)
175
+ except OSError as exc:
176
+ raise SettingsLinkError(
177
+ f"failed to create symlink {target} -> {template} after backup: {exc}"
178
+ ) from exc
@@ -142,6 +142,37 @@ field → built-in default. Only edit when defaults don't cover the
142
142
  project's working files (e.g. additional cache or local-config dirs
143
143
  that must follow the executor into the worktree).
144
144
 
145
+ ## Step 4.6 (automatic): project-local Claude settings symlink
146
+
147
+ `okstra setup` (and `okstra run` on its first invocation per project)
148
+ provisions `<PROJECT_ROOT>/.claude/settings.local.json` as a symlink to
149
+ `~/.okstra/templates/settings.local.json`. The template is installed
150
+ by `okstra install` 0.14.0+ and contains the Bash permission rules
151
+ required for the codex/gemini worker wrappers:
152
+
153
+ - `Bash($HOME/.okstra/bin/okstra-codex-exec.sh:*)`
154
+ - `Bash($HOME/.okstra/bin/okstra-gemini-exec.sh:*)`
155
+
156
+ Claude Code automatically loads `.claude/settings.local.json` whenever
157
+ it operates inside that project, so okstra workers dispatched from
158
+ **any** Claude Code session (host or okstra.sh-spawned) are allowed to
159
+ run their wrapper scripts without further configuration.
160
+
161
+ This replaces the previous per-run `--settings` injection model
162
+ (`<run-dir>/okstra-runtime-settings.json`) and the earlier guidance to
163
+ modify the user's global `~/.claude/settings.json`.
164
+
165
+ If a non-symlink `.claude/settings.local.json` already exists, the
166
+ setup step backs it up to `.claude/settings.local.json.bak.<timestamp>`
167
+ before installing the symlink — surface that to the user so they can
168
+ merge any project-specific rules back into a downstream file (the
169
+ symlinked template is okstra-owned and gets refreshed when okstra
170
+ updates).
171
+
172
+ To opt out (advanced): replace the symlink with a regular file. okstra
173
+ will detect that it is no longer a symlink on its next setup call and
174
+ back it up as `.bak.<timestamp>` rather than overwriting silently.
175
+
145
176
  ## Step 5: Verify
146
177
 
147
178
  ```bash
package/src/install.mjs CHANGED
@@ -10,6 +10,11 @@ const AGENTS_MANIFEST_REL = "installed-agents.json";
10
10
  const CLAUDE_SKILLS_DIR = join(homedir(), ".claude", "skills");
11
11
  const CLAUDE_AGENTS_DIR = join(homedir(), ".claude", "agents");
12
12
 
13
+ // Source template (relative to runtime root in copy mode, or repo root in link mode).
14
+ const SETTINGS_TEMPLATE_SRC_REL = ["templates", "reports", "settings.template.json"];
15
+ // Destination under ~/.okstra/. Project-local .claude/settings.local.json symlinks here.
16
+ const SETTINGS_TEMPLATE_DST_REL = ["templates", "settings.local.json"];
17
+
13
18
  const PYTHON_PACKAGES = ["okstra_project", "okstra_ctl", "okstra_token_usage", "lib"];
14
19
  const BIN_ENTRYPOINTS = [
15
20
  "okstra.sh",
@@ -33,6 +38,7 @@ Usage:
33
38
  Effect (copy mode):
34
39
  ${"$HOME"}/.okstra/lib/python <- runtime/python
35
40
  ${"$HOME"}/.okstra/bin <- runtime/bin
41
+ ${"$HOME"}/.okstra/templates/settings.local.json <- runtime/templates/reports/settings.template.json
36
42
  ${"$HOME"}/.claude/skills/<name> <- runtime/skills/<name> (per skill)
37
43
  ${"$HOME"}/.claude/agents/<worker>.md <- runtime/agents/workers/<worker>.md
38
44
  ${"$HOME"}/.okstra/installed-skills.json <- manifest of installed skills
@@ -42,11 +48,17 @@ Effect (copy mode):
42
48
  Effect (link mode):
43
49
  ${"$HOME"}/.okstra/lib/python/<pkg> -> <repo>/scripts/<pkg> (symlink)
44
50
  ${"$HOME"}/.okstra/bin/<name>.sh -> <repo>/scripts/<name>.sh
51
+ ${"$HOME"}/.okstra/templates/settings.local.json -> <repo>/templates/reports/settings.template.json
45
52
  ${"$HOME"}/.claude/skills/<name> -> <repo>/skills/<name> (symlink dir)
46
53
  ${"$HOME"}/.claude/agents/<worker>.md -> <repo>/agents/workers/<worker>.md
47
54
  ${"$HOME"}/.okstra/dev-link <- <repo> path stamp
48
55
  ${"$HOME"}/.okstra/version <- installed package version stamp
49
56
 
57
+ The settings.local.json file is the symlink target referenced by every
58
+ project-local <project>/.claude/settings.local.json that okstra-setup
59
+ provisions, granting per-project Claude Code permissions for okstra
60
+ worker wrapper scripts without modifying the user's global settings.
61
+
50
62
  Worker agent definitions are installed into ${"$HOME"}/.claude/agents/ so
51
63
  that Claude Code's subagent discovery picks them up; they cannot live
52
64
  inside the package alone because the harness only scans ~/.claude/agents/
@@ -228,6 +240,8 @@ async function installLinkMode(repoPath, paths, opts) {
228
240
  const agentResult = await installAgentsLink(repoAbs, { dryRun, quiet });
229
241
  await writeAgentsManifest(paths.home, agentResult.installed, { dryRun });
230
242
 
243
+ await installSettingsTemplate(repoAbs, paths, { mode: "link", dryRun, quiet });
244
+
231
245
  if (!dryRun) {
232
246
  await writeFileAtomic(join(paths.home, "dev-link"), repoAbs + "\n", 0o644);
233
247
  await writeFileAtomic(join(paths.home, "version"), paths.package + "\n", 0o644);
@@ -376,6 +390,51 @@ async function installAgentsLink(repoAbs, opts) {
376
390
  return { installed: names };
377
391
  }
378
392
 
393
+ async function installSettingsTemplate(srcRoot, paths, opts) {
394
+ const { mode, refresh = false, dryRun = false, quiet = false } = opts;
395
+ const src = join(srcRoot, ...SETTINGS_TEMPLATE_SRC_REL);
396
+ const dst = join(paths.home, ...SETTINGS_TEMPLATE_DST_REL);
397
+
398
+ if (!(await fileExists(src))) {
399
+ if (!quiet) process.stdout.write(` settings template: source missing — skipped (${src})\n`);
400
+ return { installed: false };
401
+ }
402
+
403
+ if (!dryRun) await fs.mkdir(join(dst, ".."), { recursive: true });
404
+
405
+ if (mode === "link") {
406
+ const action = await ensureSymlink(src, dst, { dryRun });
407
+ if (!quiet) process.stdout.write(` settings template: ${action} (${dst} -> ${src})\n`);
408
+ return { installed: action !== "skipped" };
409
+ }
410
+
411
+ // copy mode — hash-skip mirrors copyTreeIfChanged behavior for a single file.
412
+ let needsCopy = refresh;
413
+ if (!needsCopy) {
414
+ try {
415
+ await fs.access(dst);
416
+ const [srcHash, dstHash] = await Promise.all([hashFile(src), hashFile(dst)]);
417
+ needsCopy = srcHash !== dstHash;
418
+ } catch {
419
+ needsCopy = true;
420
+ }
421
+ }
422
+
423
+ if (!needsCopy) {
424
+ if (!quiet) process.stdout.write(` settings template: skipped (hash match)\n`);
425
+ return { installed: false };
426
+ }
427
+
428
+ if (dryRun) {
429
+ process.stdout.write(`[dry-run] copy ${src} -> ${dst}\n`);
430
+ } else {
431
+ const buf = await fs.readFile(src);
432
+ await writeFileAtomic(dst, buf, 0o644);
433
+ }
434
+ if (!quiet) process.stdout.write(` settings template: copied -> ${dst}\n`);
435
+ return { installed: true };
436
+ }
437
+
379
438
  async function installSkillsCopy(runtimeRoot, opts) {
380
439
  const { refresh, dryRun, quiet } = opts;
381
440
  const srcRoot = join(runtimeRoot, "skills");
@@ -507,6 +566,8 @@ export async function runInstall(args) {
507
566
  const agentResult = await installAgentsCopy(runtimeRoot, opts);
508
567
  await writeAgentsManifest(paths.home, agentResult.installed, { dryRun: opts.dryRun });
509
568
 
569
+ await installSettingsTemplate(runtimeRoot, paths, { mode: "copy", ...opts });
570
+
510
571
  if (!opts.dryRun) {
511
572
  await writeFileAtomic(join(paths.home, "version"), paths.package + "\n", 0o644);
512
573
  }
package/src/setup.mjs CHANGED
@@ -2,7 +2,7 @@ import { promises as fs } from "node:fs";
2
2
  import { spawn } from "node:child_process";
3
3
  import { createInterface } from "node:readline";
4
4
  import { homedir } from "node:os";
5
- import { resolve as resolvePath } from "node:path";
5
+ import { join, resolve as resolvePath } from "node:path";
6
6
  import { resolvePaths } from "./paths.mjs";
7
7
 
8
8
  const USAGE = `okstra setup — register the current project with okstra
@@ -283,6 +283,68 @@ export async function run(args) {
283
283
  return 1;
284
284
  }
285
285
 
286
- process.stdout.write(JSON.stringify({ ok: true, ...result, projectJsonPath }, null, 2) + "\n");
286
+ let settingsSymlink = null;
287
+ try {
288
+ settingsSymlink = await ensureProjectSettingsSymlink(projectRoot);
289
+ } catch (err) {
290
+ process.stderr.write(
291
+ `warning: failed to provision .claude/settings.local.json symlink — ` +
292
+ `host Claude Code sessions in this project may need to add wrapper permissions manually. (${err.message})\n`,
293
+ );
294
+ }
295
+
296
+ process.stdout.write(
297
+ JSON.stringify(
298
+ { ok: true, ...result, projectJsonPath, settingsLocalJson: settingsSymlink },
299
+ null,
300
+ 2,
301
+ ) + "\n",
302
+ );
287
303
  return 0;
288
304
  }
305
+
306
+ async function ensureProjectSettingsSymlink(projectRoot) {
307
+ const template = join(homedir(), ".okstra", "templates", "settings.local.json");
308
+ try {
309
+ await fs.access(template);
310
+ } catch {
311
+ return null; // install hasn't provisioned the template yet (pre-0.14 install)
312
+ }
313
+
314
+ const claudeDir = join(projectRoot, ".claude");
315
+ const target = join(claudeDir, "settings.local.json");
316
+ await fs.mkdir(claudeDir, { recursive: true });
317
+
318
+ let existingStat;
319
+ try {
320
+ existingStat = await fs.lstat(target);
321
+ } catch {
322
+ existingStat = null;
323
+ }
324
+
325
+ if (existingStat?.isSymbolicLink()) {
326
+ const current = await fs.readlink(target);
327
+ const resolved = current.startsWith("/") ? current : join(claudeDir, current);
328
+ if (resolved === template) return target;
329
+ await backupAndReplace(target, template);
330
+ return target;
331
+ }
332
+ if (existingStat) {
333
+ await backupAndReplace(target, template);
334
+ return target;
335
+ }
336
+
337
+ await fs.symlink(template, target);
338
+ return target;
339
+ }
340
+
341
+ async function backupAndReplace(target, template) {
342
+ const stamp = new Date()
343
+ .toISOString()
344
+ .replace(/[-:]/g, "")
345
+ .replace(/\..*/, "")
346
+ .replace("T", "-");
347
+ const backup = `${target}.bak.${stamp}`;
348
+ await fs.rename(target, backup);
349
+ await fs.symlink(template, target);
350
+ }
package/src/uninstall.mjs CHANGED
@@ -180,6 +180,21 @@ export async function runUninstall(args) {
180
180
  }
181
181
  await removePath(join(paths.home, AGENTS_MANIFEST_REL), opts);
182
182
 
183
+ await removePath(join(paths.home, "templates", "settings.local.json"), opts);
184
+ // Remove templates/ if now empty.
185
+ const templatesDir = join(paths.home, "templates");
186
+ if (await pathExists(templatesDir)) {
187
+ try {
188
+ const entries = await fs.readdir(templatesDir);
189
+ if (entries.length === 0) {
190
+ if (!opts.dryRun) await fs.rmdir(templatesDir);
191
+ if (!opts.quiet) process.stdout.write(` removed empty: ${templatesDir}\n`);
192
+ }
193
+ } catch {
194
+ /* ignore */
195
+ }
196
+ }
197
+
183
198
  await removePath(join(paths.home, "version"), opts);
184
199
  await removePath(join(paths.home, "dev-link"), opts);
185
200