okstra 0.1.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 (106) hide show
  1. package/README.md +36 -0
  2. package/bin/okstra +62 -0
  3. package/package.json +30 -0
  4. package/runtime/.gitkeep +0 -0
  5. package/runtime/BUILD.json +5 -0
  6. package/runtime/agents/SKILL.md +243 -0
  7. package/runtime/agents/TODO.md +168 -0
  8. package/runtime/agents/workers/claude-worker.md +106 -0
  9. package/runtime/agents/workers/codex-worker.md +179 -0
  10. package/runtime/agents/workers/gemini-worker.md +179 -0
  11. package/runtime/agents/workers/report-writer-worker.md +116 -0
  12. package/runtime/bin/okstra-central.sh +152 -0
  13. package/runtime/bin/okstra-codex-exec.sh +53 -0
  14. package/runtime/bin/okstra-error-log.py +295 -0
  15. package/runtime/bin/okstra-gemini-exec.sh +55 -0
  16. package/runtime/bin/okstra-token-usage.py +46 -0
  17. package/runtime/bin/okstra.sh +162 -0
  18. package/runtime/prompts/launch.template.md +52 -0
  19. package/runtime/prompts/profiles/error-analysis.md +43 -0
  20. package/runtime/prompts/profiles/final-verification.md +37 -0
  21. package/runtime/prompts/profiles/implementation-planning.md +85 -0
  22. package/runtime/prompts/profiles/implementation.md +71 -0
  23. package/runtime/prompts/profiles/requirements-discovery.md +43 -0
  24. package/runtime/python/lib/okstra/cli.sh +227 -0
  25. package/runtime/python/lib/okstra/globals.sh +157 -0
  26. package/runtime/python/lib/okstra/interactive.sh +411 -0
  27. package/runtime/python/lib/okstra/project-resolver.sh +57 -0
  28. package/runtime/python/lib/okstra/usage.sh +98 -0
  29. package/runtime/python/lib/okstra-ctl/cmd-batch.sh +59 -0
  30. package/runtime/python/lib/okstra-ctl/cmd-list.sh +35 -0
  31. package/runtime/python/lib/okstra-ctl/cmd-open.sh +36 -0
  32. package/runtime/python/lib/okstra-ctl/cmd-projects.sh +26 -0
  33. package/runtime/python/lib/okstra-ctl/cmd-reconcile.sh +27 -0
  34. package/runtime/python/lib/okstra-ctl/cmd-reindex.sh +38 -0
  35. package/runtime/python/lib/okstra-ctl/cmd-rerun.sh +326 -0
  36. package/runtime/python/lib/okstra-ctl/cmd-show.sh +27 -0
  37. package/runtime/python/lib/okstra-ctl/cmd-tail.sh +76 -0
  38. package/runtime/python/lib/okstra-ctl/main.sh +41 -0
  39. package/runtime/python/lib/okstra-ctl/prepare.sh +29 -0
  40. package/runtime/python/lib/okstra-ctl/usage.sh +23 -0
  41. package/runtime/python/okstra_ctl/__init__.py +125 -0
  42. package/runtime/python/okstra_ctl/backfill.py +253 -0
  43. package/runtime/python/okstra_ctl/batch.py +62 -0
  44. package/runtime/python/okstra_ctl/ids.py +84 -0
  45. package/runtime/python/okstra_ctl/index.py +216 -0
  46. package/runtime/python/okstra_ctl/invocation.py +49 -0
  47. package/runtime/python/okstra_ctl/jsonl.py +84 -0
  48. package/runtime/python/okstra_ctl/listing.py +156 -0
  49. package/runtime/python/okstra_ctl/locks.py +42 -0
  50. package/runtime/python/okstra_ctl/material.py +62 -0
  51. package/runtime/python/okstra_ctl/models.py +63 -0
  52. package/runtime/python/okstra_ctl/path_resolve.py +40 -0
  53. package/runtime/python/okstra_ctl/paths.py +251 -0
  54. package/runtime/python/okstra_ctl/project_meta.py +51 -0
  55. package/runtime/python/okstra_ctl/reconcile.py +166 -0
  56. package/runtime/python/okstra_ctl/render.py +1065 -0
  57. package/runtime/python/okstra_ctl/resolver.py +54 -0
  58. package/runtime/python/okstra_ctl/run.py +674 -0
  59. package/runtime/python/okstra_ctl/run_context.py +166 -0
  60. package/runtime/python/okstra_ctl/seeding.py +97 -0
  61. package/runtime/python/okstra_ctl/sequence.py +53 -0
  62. package/runtime/python/okstra_ctl/session.py +33 -0
  63. package/runtime/python/okstra_ctl/tmux.py +27 -0
  64. package/runtime/python/okstra_ctl/workers.py +64 -0
  65. package/runtime/python/okstra_ctl/workflow.py +182 -0
  66. package/runtime/python/okstra_project/__init__.py +41 -0
  67. package/runtime/python/okstra_project/resolver.py +126 -0
  68. package/runtime/python/okstra_project/state.py +170 -0
  69. package/runtime/python/okstra_token_usage/__init__.py +26 -0
  70. package/runtime/python/okstra_token_usage/blocks.py +62 -0
  71. package/runtime/python/okstra_token_usage/claude.py +97 -0
  72. package/runtime/python/okstra_token_usage/cli.py +84 -0
  73. package/runtime/python/okstra_token_usage/codex.py +80 -0
  74. package/runtime/python/okstra_token_usage/collect.py +161 -0
  75. package/runtime/python/okstra_token_usage/gemini.py +77 -0
  76. package/runtime/python/okstra_token_usage/jsonl_io.py +18 -0
  77. package/runtime/python/okstra_token_usage/paths.py +22 -0
  78. package/runtime/python/okstra_token_usage/pricing.py +71 -0
  79. package/runtime/python/okstra_token_usage/report.py +64 -0
  80. package/runtime/templates/prd/brief.template.md +273 -0
  81. package/runtime/templates/project-docs/task-index.template.md +65 -0
  82. package/runtime/templates/reports/error-analysis-input.template.md +80 -0
  83. package/runtime/templates/reports/final-report.template.md +167 -0
  84. package/runtime/templates/reports/final-verification-input.template.md +67 -0
  85. package/runtime/templates/reports/implementation-input.template.md +81 -0
  86. package/runtime/templates/reports/implementation-planning-input.template.md +93 -0
  87. package/runtime/templates/reports/quick-input.template.md +64 -0
  88. package/runtime/templates/reports/schedule.template.md +168 -0
  89. package/runtime/templates/reports/settings.template.json +101 -0
  90. package/runtime/templates/reports/task-brief.template.md +165 -0
  91. package/runtime/validators/lib/common.sh +44 -0
  92. package/runtime/validators/lib/fixtures.sh +322 -0
  93. package/runtime/validators/lib/paths.sh +44 -0
  94. package/runtime/validators/lib/runners.sh +140 -0
  95. package/runtime/validators/lib/summary.sh +15 -0
  96. package/runtime/validators/lib/validate-assets.sh +44 -0
  97. package/runtime/validators/lib/validate-prompt-metadata.sh +267 -0
  98. package/runtime/validators/lib/validate-tasks.sh +335 -0
  99. package/runtime/validators/validate-run.py +568 -0
  100. package/runtime/validators/validate-schedule.py +665 -0
  101. package/runtime/validators/validate-workflow.sh +190 -0
  102. package/src/doctor.mjs +127 -0
  103. package/src/install.mjs +355 -0
  104. package/src/paths.mjs +132 -0
  105. package/src/uninstall.mjs +122 -0
  106. package/src/version.mjs +20 -0
@@ -0,0 +1,36 @@
1
+ # shellcheck shell=bash
2
+
3
+ _okstra_ctl_open() {
4
+ if [[ $# -lt 1 ]]; then
5
+ printf 'open: missing <runId-or-prefix>\n' >&2; exit 2
6
+ fi
7
+ local resolved
8
+ resolved="$(OKSTRA_HOME_RESOLVED="$(okstra_central_home)" \
9
+ OKSTRA_CTL_LIB_DIR="$SCRIPT_DIR" \
10
+ OK_QUERY="$1" python3 - <<'PY'
11
+ import os, sys
12
+ sys.path.insert(0, os.environ["OKSTRA_CTL_LIB_DIR"])
13
+ from pathlib import Path
14
+ from okstra_ctl import resolve_run_id, find_row_by_run_id, absolute_final_report_path, ResolveError
15
+ home = Path(os.environ["OKSTRA_HOME_RESOLVED"])
16
+ try:
17
+ rid = resolve_run_id(home, os.environ["OK_QUERY"])
18
+ except ResolveError as e:
19
+ print(f"open: {e}", file=sys.stderr); sys.exit(2)
20
+ row = find_row_by_run_id(home, rid)
21
+ abs_path = absolute_final_report_path(row) if row else None
22
+ if abs_path is None or not abs_path.is_file():
23
+ print(f"open: final report not found", file=sys.stderr); sys.exit(2)
24
+ print(str(abs_path))
25
+ PY
26
+ )"
27
+ if [[ -z "$resolved" ]]; then exit 2; fi
28
+ # $EDITOR 가 설정되어 있으면 그것으로 열고, 없으면 경로만 출력한다.
29
+ # EDITOR 는 보통 'code -w' 처럼 인자를 포함할 수 있어 단어 분리해
30
+ # exec 해야 한다(따옴표 안에 두면 단일 명령어로 잘못 해석됨).
31
+ if [[ -n "${EDITOR:-}" ]]; then
32
+ # shellcheck disable=SC2086
33
+ exec ${EDITOR} "$resolved"
34
+ fi
35
+ printf '%s\n' "$resolved"
36
+ }
@@ -0,0 +1,26 @@
1
+ # shellcheck shell=bash
2
+
3
+ _okstra_ctl_projects() {
4
+ local sort_by="recent" only_active="false"
5
+ while [[ $# -gt 0 ]]; do
6
+ case "$1" in
7
+ --sort) sort_by="$2"; shift 2 ;;
8
+ --active) only_active="true"; shift ;;
9
+ *) printf 'projects: unknown option: %s\n' "$1" >&2; exit 2 ;;
10
+ esac
11
+ done
12
+ OKSTRA_HOME_RESOLVED="$(okstra_central_home)" \
13
+ OKSTRA_CTL_LIB_DIR="$SCRIPT_DIR" \
14
+ OKSTRA_SORT_BY="$sort_by" \
15
+ OKSTRA_ONLY_ACTIVE="$only_active" \
16
+ python3 - <<'PY'
17
+ import os, sys
18
+ sys.path.insert(0, os.environ["OKSTRA_CTL_LIB_DIR"])
19
+ from pathlib import Path
20
+ from okstra_ctl import list_projects, format_projects_table
21
+ items = list_projects(Path(os.environ["OKSTRA_HOME_RESOLVED"]),
22
+ only_active=os.environ["OKSTRA_ONLY_ACTIVE"] == "true",
23
+ sort_by=os.environ["OKSTRA_SORT_BY"])
24
+ print(format_projects_table(items), end="")
25
+ PY
26
+ }
@@ -0,0 +1,27 @@
1
+ # shellcheck shell=bash
2
+
3
+ _okstra_ctl_reconcile() {
4
+ # `okstra-ctl reconcile [--project <id|all>]`. dispatcher 가 이미 호출한
5
+ # _okstra_ctl_prepare 의 reconcile 은 unscoped 이므로, 여기서는 사용자
6
+ # --project 필터를 적용해 한 번 더 호출한다.
7
+ local project="all"
8
+ while [[ $# -gt 0 ]]; do
9
+ case "$1" in
10
+ --project) project="$2"; shift 2 ;;
11
+ *) printf 'reconcile: unknown option: %s\n' "$1" >&2; exit 2 ;;
12
+ esac
13
+ done
14
+ OKSTRA_HOME_RESOLVED="$(okstra_central_home)" \
15
+ OKSTRA_CTL_LIB_DIR="$SCRIPT_DIR" \
16
+ OK_PROJECT="$project" \
17
+ python3 - <<'PY'
18
+ import os, sys
19
+ sys.path.insert(0, os.environ["OKSTRA_CTL_LIB_DIR"])
20
+ from pathlib import Path
21
+ from okstra_ctl import reconcile_active
22
+ target = os.environ["OK_PROJECT"]
23
+ reconcile_active(Path(os.environ["OKSTRA_HOME_RESOLVED"]),
24
+ project=None if target == "all" else target)
25
+ PY
26
+ printf 'reconcile complete\n'
27
+ }
@@ -0,0 +1,38 @@
1
+ # shellcheck shell=bash
2
+
3
+ _okstra_ctl_reindex() {
4
+ local project="all"
5
+ while [[ $# -gt 0 ]]; do
6
+ case "$1" in
7
+ --project) project="$2"; shift 2 ;;
8
+ *) printf 'reindex: unknown option: %s\n' "$1" >&2; exit 2 ;;
9
+ esac
10
+ done
11
+ OKSTRA_HOME_RESOLVED="$(okstra_central_home)" \
12
+ OKSTRA_CTL_LIB_DIR="$SCRIPT_DIR" \
13
+ OK_PROJECT="$project" \
14
+ python3 - <<'PY'
15
+ import os, sys
16
+ sys.path.insert(0, os.environ["OKSTRA_CTL_LIB_DIR"])
17
+ from pathlib import Path
18
+ from okstra_ctl import (discover_project_roots, backfill_project,
19
+ mark_backfilled, central_lock)
20
+ home = Path(os.environ["OKSTRA_HOME_RESOLVED"])
21
+ target = os.environ["OK_PROJECT"]
22
+ total = 0
23
+ # 중앙 락으로 보호 — record_start/reconcile 와 같은 lock 위에서 직렬화하여
24
+ # projects/<id>/meta.json 의 read-modify-write 가 손실되지 않게 한다.
25
+ with central_lock(home):
26
+ # 신규 모델: discover_project_roots 는 ~/.okstra/projects/*/meta.json 을
27
+ # 스캔한다(과거 examples/projects/*.conf.sh 모델 폐기).
28
+ for pid, root in discover_project_roots(home):
29
+ if target != "all" and pid != target:
30
+ continue
31
+ n = backfill_project(home, pid, Path(root))
32
+ print(f"{pid}: {n} new run(s) backfilled")
33
+ total += n
34
+ if target == "all":
35
+ mark_backfilled(home)
36
+ print(f"total: {total}")
37
+ PY
38
+ }
@@ -0,0 +1,326 @@
1
+ # shellcheck shell=bash
2
+
3
+ _okstra_ctl_rerun() {
4
+ local yes="false" dry_run="false" use_filter="false"
5
+ local project="all" task_group="all" status="all" since=""
6
+ local project_set="false" task_group_set="false"
7
+ local max_spawn="${OKSTRA_CTL_MAX_SPAWN:-10}"
8
+ local from_stdin="false" use_last="false"
9
+ local -a explicit=()
10
+ local ignore_brief_drift="false"
11
+ while [[ $# -gt 0 ]]; do
12
+ case "$1" in
13
+ --yes) yes="true"; shift ;;
14
+ --dry-run) dry_run="true"; shift ;;
15
+ --filter) use_filter="true"; shift ;;
16
+ --project) project="$2"; project_set="true"; shift 2 ;;
17
+ --task-group) task_group="$2"; task_group_set="true"; shift 2 ;;
18
+ --status) status="$2"; shift 2 ;;
19
+ --since) since="$2"; shift 2 ;;
20
+ --max-spawn) max_spawn="$2"; shift 2 ;;
21
+ --ignore-brief-drift) ignore_brief_drift="true"; shift ;;
22
+ last) use_last="true"; shift ;;
23
+ -) from_stdin="true"; shift ;;
24
+ *) explicit+=("$1"); shift ;;
25
+ esac
26
+ done
27
+ # tmux 부재 검사는 실제 spawn 이 일어나는 경우에만. --dry-run 은 CI/최소 환경에서도
28
+ # 안전 미리보기를 제공해야 하므로 우회한다.
29
+ if [[ "$dry_run" != "true" ]] && ! command -v tmux >/dev/null 2>&1; then
30
+ printf 'rerun: tmux is required but not found\n' >&2; exit 2
31
+ fi
32
+ # 'last' selector 는 부작용 있는 명령이므로 모호한 기본값을 거부한다.
33
+ # 사용자는 --project / --task-group 을 반드시 명시해야 한다 ('all' 와일드카드 허용).
34
+ if [[ "$use_last" == "true" ]]; then
35
+ if [[ "$project_set" != "true" || "$task_group_set" != "true" ]]; then
36
+ printf 'rerun last: --project 와 --task-group 을 반드시 지정해 주십시오 (all 와일드카드 명시 가능)\n' >&2
37
+ exit 2
38
+ fi
39
+ fi
40
+
41
+ # stdin selector('-') 사용 시 호출자의 파이프 데이터를 미리 읽어 env 로 전달.
42
+ # python3 - <<'PY' 는 stdin 을 heredoc 으로 사용하므로 자식 프로세스 안에서는
43
+ # sys.stdin 이 EOF 가 되어 caller 의 piped runId 들을 읽을 수 없다.
44
+ local stdin_payload=""
45
+ if [[ "$from_stdin" == "true" ]]; then
46
+ stdin_payload="$(cat)"
47
+ fi
48
+
49
+ OKSTRA_HOME_RESOLVED="$(okstra_central_home)" \
50
+ OKSTRA_CTL_LIB_DIR="$SCRIPT_DIR" \
51
+ OKSTRA_SCRIPT="$SCRIPT_DIR/okstra.sh" \
52
+ OK_YES="$yes" OK_DRY="$dry_run" OK_FILTER="$use_filter" \
53
+ OK_PROJECT="$project" OK_TG="$task_group" OK_STATUS="$status" \
54
+ OK_SINCE="$since" OK_MAX="$max_spawn" OK_LAST="$use_last" \
55
+ OK_STDIN="$from_stdin" OK_STDIN_DATA="$stdin_payload" \
56
+ OK_IGNORE_BRIEF_DRIFT="$ignore_brief_drift" \
57
+ OK_EXPLICIT_JSON="$(printf '%s\n' "${explicit[@]:-}" | python3 -c 'import json,sys; print(json.dumps([l.strip() for l in sys.stdin if l.strip()]))')" \
58
+ python3 - <<'PY'
59
+ import fcntl, hashlib, json, os, subprocess, sys, time
60
+ sys.path.insert(0, os.environ["OKSTRA_CTL_LIB_DIR"])
61
+ from pathlib import Path
62
+ from okstra_ctl import (
63
+ expand_selectors, find_row_by_run_id, load_invocation,
64
+ predict_next_run_seq, build_run_id, build_tmux_command,
65
+ run_id_to_session_name, task_lock_filename, make_batch_id,
66
+ write_batch_meta, ResolveError, central_lock,
67
+ reserve_run_in_active, remove_reservation,
68
+ slugify_task_segment,
69
+ )
70
+
71
+ home = Path(os.environ["OKSTRA_HOME_RESOLVED"])
72
+ explicit = json.loads(os.environ["OK_EXPLICIT_JSON"])
73
+ use_filter = os.environ["OK_FILTER"] == "true"
74
+ use_last = os.environ["OK_LAST"] == "true"
75
+ from_stdin = os.environ["OK_STDIN"] == "true"
76
+ max_spawn = int(os.environ["OK_MAX"])
77
+ yes = os.environ["OK_YES"] == "true"
78
+ dry = os.environ["OK_DRY"] == "true"
79
+
80
+ try:
81
+ targets = expand_selectors(
82
+ home, explicit=explicit, use_filter=use_filter,
83
+ project=os.environ["OK_PROJECT"], task_group=os.environ["OK_TG"],
84
+ status=os.environ["OK_STATUS"], since=os.environ["OK_SINCE"],
85
+ last=use_last, from_stdin=from_stdin,
86
+ stdin_data=os.environ.get("OK_STDIN_DATA", ""),
87
+ )
88
+ except ResolveError as e:
89
+ print(f"rerun: {e}", file=sys.stderr); sys.exit(2)
90
+
91
+ if not targets:
92
+ print("rerun: no targets selected", file=sys.stderr); sys.exit(2)
93
+ if len(targets) > max_spawn:
94
+ print(f"매칭 결과 {len(targets)}건은 임계({max_spawn})를 초과합니다. "
95
+ f"--max-spawn {len(targets)} 로 재실행하거나 선택자를 좁혀 주십시오.",
96
+ file=sys.stderr); sys.exit(2)
97
+ if len(targets) > 1 and not yes and not dry:
98
+ print("rerun targets:")
99
+ for t in targets: print(f" - {t}")
100
+ print("재실행하려면 --yes 를 추가해 주십시오.", file=sys.stderr); sys.exit(2)
101
+
102
+ ignore_brief_drift = os.environ.get("OK_IGNORE_BRIEF_DRIFT") == "true"
103
+
104
+ # 현재 okstra-ctl 프로세스의 OKSTRA_HOME / 명시적 ctl-only override 만 base 에
105
+ # 둔다. 각 target 의 inv["envOverrides"](원본 run 시점에 캡처된 환경변수) 를
106
+ # 로드 시점에 layer 한다.
107
+ extra_env_base = {"OKSTRA_HOME": str(home)}
108
+ for key in ("OKSTRA_CTL_SKIP_RECONCILE", "OKSTRA_CTL_SKIP_BACKFILL"):
109
+ if os.environ.get(key):
110
+ extra_env_base[key] = os.environ[key]
111
+
112
+
113
+ def _brief_sha256(brief_path):
114
+ try:
115
+ with open(brief_path, "rb") as f:
116
+ return hashlib.sha256(f.read()).hexdigest()
117
+ except OSError:
118
+ return None
119
+
120
+
121
+ def _brief_path_from_argv(argv, cwd, project_root):
122
+ """invocation argv 에서 --task-brief 값을 찾아 절대 경로화한다.
123
+ okstra.sh resolve_brief_path 의 lookup 순서(cwd-relative 우선,
124
+ PROJECT_ROOT fallback) 를 동일하게 mirror 한다 — 그렇지 않으면 사용자가
125
+ 프로젝트 밖에서 project-relative --task-brief 로 실행한 run 의 drift
126
+ 검사가 'brief missing' 으로 잘못 skip 된다.
127
+ """
128
+ for i, tok in enumerate(argv):
129
+ if tok == "--task-brief" and i + 1 < len(argv):
130
+ raw = argv[i + 1]
131
+ p = Path(raw)
132
+ if p.is_absolute():
133
+ return p if p.is_file() else None
134
+ cwd_rel = Path(cwd) / p if cwd else None
135
+ if cwd_rel and cwd_rel.is_file():
136
+ return cwd_rel
137
+ root_rel = Path(project_root) / p if project_root else None
138
+ if root_rel and root_rel.is_file():
139
+ return root_rel
140
+ return None
141
+ return None
142
+
143
+
144
+ batch_id = make_batch_id()
145
+ items = []
146
+ spawned = skipped = 0
147
+ # 같은 (project, group, task_id, task_type) 에 속한 다중 rerun 이 같은 batch 내에서
148
+ # 같은 seq 를 받지 않도록 메모리상 reservation 추적. tmux spawn 후 detached okstra 가
149
+ # 디스크에 manifest/report 를 쓰기 전에 락이 풀리므로 filesystem-only 예측은 충돌한다.
150
+ batch_reserved = {}
151
+ for original in targets:
152
+ row = find_row_by_run_id(home, original)
153
+ inv = (load_invocation(home, row["projectId"], row["taskGroup"],
154
+ row["taskId"], row["taskType"], row["runSeq"])
155
+ if row else None)
156
+ if inv is None or inv.get("backfilled") or not Path(row["projectRoot"]).is_dir():
157
+ items.append({"originalRunId": original, "newRunId": None,
158
+ "newRunSeq": None, "sessionName": None,
159
+ "status": "skipped", "spawnedAt": None,
160
+ "skipReason": "no invocation / backfilled / project root missing"})
161
+ skipped += 1
162
+ continue
163
+ # brief drift 검사: 원래 run 시점에 캡처한 briefSha256 과 현재 brief 파일의
164
+ # 해시가 다르면, --ignore-brief-drift 가 없는 한 skip 하고 사유를 기록한다.
165
+ # 그렇지 않으면 batch/--yes 재실행이 다른 입력으로 silently 분석을 수행한다.
166
+ recorded_sha = inv.get("briefSha256") or ""
167
+ if recorded_sha and not ignore_brief_drift:
168
+ brief_abs = _brief_path_from_argv(inv.get("argv", []),
169
+ inv.get("cwd", ""),
170
+ row["projectRoot"])
171
+ current_sha = _brief_sha256(brief_abs) if brief_abs else None
172
+ if current_sha is None:
173
+ items.append({"originalRunId": original, "newRunId": None,
174
+ "newRunSeq": None, "sessionName": None,
175
+ "status": "skipped", "spawnedAt": None,
176
+ "skipReason": "brief missing for drift check"})
177
+ skipped += 1
178
+ continue
179
+ if current_sha != recorded_sha:
180
+ items.append({"originalRunId": original, "newRunId": None,
181
+ "newRunSeq": None, "sessionName": None,
182
+ "status": "skipped", "spawnedAt": None,
183
+ "skipReason": "brief drift (use --ignore-brief-drift to override)"})
184
+ skipped += 1
185
+ continue
186
+ lock = home / ".locks" / task_lock_filename(
187
+ row["projectId"], row["taskGroup"], row["taskId"], row["taskType"])
188
+ lock.parent.mkdir(parents=True, exist_ok=True); lock.touch()
189
+ with lock.open("r+") as lf:
190
+ fcntl.flock(lf.fileno(), fcntl.LOCK_EX)
191
+ rkey = (row["projectId"], row["taskGroup"], row["taskId"], row["taskType"])
192
+ used = batch_reserved.setdefault(rkey, set())
193
+ # 중앙 인덱스(active.jsonl) 와 manifest 디렉터리까지 함께 보고 다음 seq 를 계산.
194
+ # 다른 okstra-ctl 프로세스가 이미 예약했거나 in-flight 인 run 의 seq 도 회피된다.
195
+ with central_lock(home):
196
+ next_seq = predict_next_run_seq(
197
+ Path(row["projectRoot"]),
198
+ row["taskGroup"], row["taskId"], row["taskType"],
199
+ home=home, project_id=row["projectId"])
200
+ while next_seq in used:
201
+ next_seq += 1
202
+ used.add(next_seq)
203
+ new_run_id = build_run_id(row["projectId"], row["taskGroup"],
204
+ row["taskId"], row["taskType"], next_seq)
205
+ session = run_id_to_session_name(new_run_id)
206
+ # dry-run 이 아닌 경우 reservation 을 중앙 인덱스에 영속한다.
207
+ # 후속 okstra-ctl 호출이 즉시 이 seq 를 회피할 수 있게 된다.
208
+ if not dry:
209
+ # rerun 시 새 run 의 RUN_DIR 은 같은 task-type 디렉터리, FINAL_REPORT 는 새 seq.
210
+ # okstra.sh 는 디렉터리 segment 를 slugify_value 로 정규화하므로
211
+ # (공백/대문자/`/` 등이 포함된 raw taskGroup/taskId 그대로 쓰면
212
+ # 실제 디스크 경로와 어긋나 tail -F 가 영구 wait 하거나 startup
213
+ # 실패 시 row 가 잘못된 경로를 가리킨다) 같은 슬러그로 예측 경로를
214
+ # 만든다. 정상 spawn 시 record_start 가 RUN_DIR_RELATIVE_PATH 로
215
+ # 정확 값을 다시 update 한다.
216
+ slug_group = slugify_task_segment(row["taskGroup"])
217
+ slug_task = slugify_task_segment(row["taskId"])
218
+ slug_type = slugify_task_segment(row["taskType"])
219
+ base_run = (".project-docs/okstra/tasks/"
220
+ f"{slug_group}/{slug_task}/runs/{slug_type}")
221
+ final_rel = (f"{base_run}/reports/"
222
+ f"final-report-{slug_type}-{next_seq:03d}.md")
223
+ # 예약 시점에 final status 경로도 best-effort 로 박아 둔다.
224
+ # RUN_STATUS_SEQ 는 RUN_MANIFESTS_SEQ 와 별개 카운터지만,
225
+ # 정상 run 에선 두 카운터가 함께 advance 한다(render-only/
226
+ # 실패 prep 만 manifest 만 advance). 추후 record_start 가
227
+ # 정확한 finalStatusRel 로 row 를 update 하므로 여기서 값을
228
+ # 비워 두면 spawn 직후 tail 이 status dir 부재로 race-fail
229
+ # 한다 — 기대 경로를 미리 채워 tail -F 가 등장 대기하게 한다.
230
+ final_status_rel = (f"{base_run}/status/"
231
+ f"final-{slug_type}-{next_seq:03d}.status")
232
+ reserve_run_in_active(
233
+ home, project_id=row["projectId"],
234
+ project_root=row["projectRoot"],
235
+ task_group=row["taskGroup"], task_id=row["taskId"],
236
+ task_type=row["taskType"], run_seq=next_seq,
237
+ when=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
238
+ run_dir_rel=base_run, final_report_rel=final_rel,
239
+ final_status_rel=final_status_rel,
240
+ )
241
+ # 원본 invocation 시점에 캡처된 envOverrides 를 base 에 layer 한다.
242
+ # 신규 모델은 PROJECT_ROOT 를 project.json 에서 자기 해석하므로
243
+ # invocation-specific 환경변수 override 가 사실상 사라졌지만, 호환을
244
+ # 위해 envOverrides 가 dict 형태로 남아있을 수 있다. base(현재 ctl
245
+ # 환경) 키와 충돌하면 base 가 우선해 사용자의 OKSTRA_HOME 등이 보존된다.
246
+ target_env = {}
247
+ for k, v in (inv.get("envOverrides") or {}).items():
248
+ if isinstance(v, str):
249
+ target_env[k] = v
250
+ target_env.update(extra_env_base)
251
+ cmd = build_tmux_command(session_name=session, cwd=inv["cwd"],
252
+ run_seq=next_seq, argv=inv["argv"],
253
+ okstra_script=os.environ["OKSTRA_SCRIPT"],
254
+ extra_env=target_env)
255
+ if dry:
256
+ items.append({"originalRunId": original, "newRunId": new_run_id,
257
+ "newRunSeq": next_seq, "sessionName": session,
258
+ "status": "dry-run", "spawnedAt": None,
259
+ "skipReason": None})
260
+ continue
261
+ # 핵심 모델: tmux 세션을 detached 로 띄우고 즉시 다음 대상으로 넘어간다.
262
+ rc = subprocess.run(cmd, capture_output=True, text=True)
263
+ if rc.returncode != 0:
264
+ # spawn 실패 시 reservation 을 정리한다(중앙 인덱스 누수 방지).
265
+ with central_lock(home):
266
+ remove_reservation(
267
+ home, project_id=row["projectId"],
268
+ task_group=row["taskGroup"], task_id=row["taskId"],
269
+ task_type=row["taskType"], run_seq=next_seq,
270
+ )
271
+ items.append({"originalRunId": original, "newRunId": new_run_id,
272
+ "newRunSeq": next_seq, "sessionName": session,
273
+ "status": "skipped", "spawnedAt": None,
274
+ "skipReason": f"tmux: {rc.stderr.strip()}"})
275
+ skipped += 1
276
+ continue
277
+ items.append({"originalRunId": original, "newRunId": new_run_id,
278
+ "newRunSeq": next_seq, "sessionName": session,
279
+ "status": "spawned",
280
+ "spawnedAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
281
+ "skipReason": None})
282
+ spawned += 1
283
+
284
+ selector_raw = (
285
+ explicit
286
+ + (["--filter", "--project", os.environ["OK_PROJECT"],
287
+ "--task-group", os.environ["OK_TG"],
288
+ "--status", os.environ["OK_STATUS"], "--since", os.environ["OK_SINCE"]]
289
+ if use_filter else [])
290
+ + (["last"] if use_last else [])
291
+ + (["-"] if from_stdin else [])
292
+ )
293
+ write_batch_meta(home, batch_id, {
294
+ "batchId": batch_id,
295
+ "createdAt": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
296
+ "selectorRaw": selector_raw, "maxSpawn": max_spawn,
297
+ "items": items,
298
+ "summary": {"total": len(targets), "spawned": spawned,
299
+ "skipped": skipped, "rejected": 0},
300
+ })
301
+
302
+ dry_run_count = sum(1 for it in items if it["status"] == "dry-run")
303
+ if dry_run_count:
304
+ print(f"batch {batch_id} dry-run: {dry_run_count} skipped: {skipped} rejected: 0")
305
+ else:
306
+ print(f"batch {batch_id} spawned: {spawned} skipped: {skipped} rejected: 0")
307
+ print()
308
+ header = ("RUN-ID", "SESSION-NAME", "ATTACH")
309
+ rows = []
310
+ # dry-run 도 미리보기 대상이다 — preview 의 본래 목적이 spawn 없이 target/
311
+ # reservation 을 보여주는 것이므로 spawned 와 동일하게 row 에 포함한다.
312
+ for it in items:
313
+ if it["status"] in ("spawned", "dry-run"):
314
+ attach = (f"tmux attach -t {it['sessionName']}"
315
+ if it["status"] == "spawned" else "(dry-run)")
316
+ rows.append((it["newRunId"] or "-", it["sessionName"] or "-", attach))
317
+ if rows:
318
+ table = [header] + rows
319
+ widths = [max(len(t[i]) for t in table) for i in range(3)]
320
+ for t in table:
321
+ print(" ".join(c.ljust(widths[i]) for i, c in enumerate(t)))
322
+ print()
323
+ print(f"monitor batch : okstra-ctl batch status {batch_id}")
324
+ print( "list sessions : tmux ls | grep ^okstra-")
325
+ PY
326
+ }
@@ -0,0 +1,27 @@
1
+ # shellcheck shell=bash
2
+
3
+ _okstra_ctl_show() {
4
+ if [[ $# -lt 1 ]]; then
5
+ printf 'show: missing <runId-or-prefix>\n' >&2; exit 2
6
+ fi
7
+ OKSTRA_HOME_RESOLVED="$(okstra_central_home)" \
8
+ OKSTRA_CTL_LIB_DIR="$SCRIPT_DIR" \
9
+ OK_QUERY="$1" \
10
+ python3 - <<'PY'
11
+ import os, sys
12
+ sys.path.insert(0, os.environ["OKSTRA_CTL_LIB_DIR"])
13
+ from pathlib import Path
14
+ from okstra_ctl import resolve_run_id, find_row_by_run_id, format_show, ResolveError
15
+ home = Path(os.environ["OKSTRA_HOME_RESOLVED"])
16
+ try:
17
+ rid = resolve_run_id(home, os.environ["OK_QUERY"])
18
+ except ResolveError as e:
19
+ print(f"show: {e}", file=sys.stderr)
20
+ for c in e.candidates: print(f" - {c}", file=sys.stderr)
21
+ sys.exit(2)
22
+ row = find_row_by_run_id(home, rid)
23
+ if row is None:
24
+ print(f"show: row not found for {rid}", file=sys.stderr); sys.exit(2)
25
+ print(format_show(row), end="")
26
+ PY
27
+ }
@@ -0,0 +1,76 @@
1
+ # shellcheck shell=bash
2
+
3
+ _okstra_ctl_tail() {
4
+ if [[ $# -lt 1 ]]; then
5
+ printf 'tail: missing <runId-or-prefix> | active\n' >&2; exit 2
6
+ fi
7
+ if [[ "$1" == "active" ]]; then
8
+ OKSTRA_HOME_RESOLVED="$(okstra_central_home)" \
9
+ OKSTRA_CTL_LIB_DIR="$SCRIPT_DIR" \
10
+ python3 - <<'PY'
11
+ import os, sys
12
+ sys.path.insert(0, os.environ["OKSTRA_CTL_LIB_DIR"])
13
+ from pathlib import Path
14
+ from okstra_ctl import read_jsonl, format_runs_table
15
+ # tail active 는 active.jsonl 자체를 보여줘야 한다 — running 외에도
16
+ # in-progress(backfill), reserving(rerun 예약), 그리고 어떤 비-터미널
17
+ # 상태든 active 에 들어있는 것은 사용자가 모니터링해야 할 대상이다.
18
+ rows = sorted(read_jsonl(Path(os.environ["OKSTRA_HOME_RESOLVED"]) / "active.jsonl"),
19
+ key=lambda r: r.get("startedAt", ""), reverse=True)
20
+ print(format_runs_table(rows), end="")
21
+ PY
22
+ return 0
23
+ fi
24
+ # 정확한 status 파일 경로는 row 의 finalStatusRel(RUN_STATUS_SEQ 기반,
25
+ # record_start 시점에 박힘) 에서 가져온다. RUN_STATUS_SEQ 와
26
+ # RUN_MANIFESTS_SEQ 는 별개 카운터이므로 row["runSeq"] 로 추정하면
27
+ # render-only 가 manifest seq 만 advance 한 직후의 실행에서 어긋난다.
28
+ # finalStatusRel 이 없을 때만 (구식 row) runDirRel/taskType/runSeq 로
29
+ # fallback 하고, 그래도 없으면 expected status 파일이 아직 만들어지지
30
+ # 않았어도 tail -F 로 wait 한다(`tail -F` 는 by-name 으로 파일 등장을
31
+ # 감지한다).
32
+ local resolved
33
+ resolved="$(OKSTRA_HOME_RESOLVED="$(okstra_central_home)" \
34
+ OKSTRA_CTL_LIB_DIR="$SCRIPT_DIR" \
35
+ OK_QUERY="$1" python3 -c '
36
+ import os, sys
37
+ sys.path.insert(0, os.environ["OKSTRA_CTL_LIB_DIR"])
38
+ from pathlib import Path
39
+ from okstra_ctl import resolve_run_id, find_row_by_run_id
40
+ home = Path(os.environ["OKSTRA_HOME_RESOLVED"])
41
+ rid = resolve_run_id(home, os.environ["OK_QUERY"])
42
+ row = find_row_by_run_id(home, rid)
43
+ project_root = Path(row["projectRoot"])
44
+ status_rel = row.get("finalStatusRel") or ""
45
+ status_abs = str(project_root / status_rel) if status_rel else ""
46
+ print(project_root / row["runDirRel"])
47
+ print(row.get("taskType", ""))
48
+ print(int(row.get("runSeq", 0)))
49
+ print(status_abs)
50
+ ')"
51
+ local rid task_type seq status_abs
52
+ rid="$(printf '%s\n' "$resolved" | sed -n '1p')"
53
+ task_type="$(printf '%s\n' "$resolved" | sed -n '2p')"
54
+ seq="$(printf '%s\n' "$resolved" | sed -n '3p')"
55
+ status_abs="$(printf '%s\n' "$resolved" | sed -n '4p')"
56
+ if [[ -n "$status_abs" ]]; then
57
+ # row 가 정식 경로를 알고 있으면 그것만 tail. 아직 파일이 없어도
58
+ # `tail -F` 가 등장을 기다리므로, active 진입 직후 호출도 정상.
59
+ exec tail -F "$status_abs"
60
+ fi
61
+ # 구식 row(이전 버전에서 작성되어 finalStatusRel 누락) — runSeq 로 glob.
62
+ if [[ ! -d "$rid/status" ]]; then
63
+ printf 'tail: status dir missing: %s/status\n' "$rid" >&2; exit 2
64
+ fi
65
+ local seq_pad
66
+ seq_pad="$(printf '%03d' "$seq")"
67
+ shopt -s nullglob
68
+ local matches=("$rid/status/"*"-${task_type}-${seq_pad}".*)
69
+ shopt -u nullglob
70
+ if [[ ${#matches[@]} -eq 0 ]]; then
71
+ # 매니페스트 카운터가 status 카운터를 앞섰을 수 있으므로 정확
72
+ # 매칭이 없어도 가장 그럴듯한 expected 경로로 wait 한다.
73
+ exec tail -F "$rid/status/final-${task_type}-${seq_pad}.status"
74
+ fi
75
+ exec tail -F "${matches[@]}"
76
+ }
@@ -0,0 +1,41 @@
1
+ # shellcheck shell=bash
2
+
3
+ main() {
4
+ if [[ $# -eq 0 ]]; then
5
+ usage
6
+ exit 2
7
+ fi
8
+ case "$1" in
9
+ -h|--help)
10
+ cat <<'HELP'
11
+ okstra-ctl: okstra Control Center CLI.
12
+
13
+ Subcommands: projects, list, show, open, tail, rerun, batch, reconcile, reindex.
14
+ HELP
15
+ usage
16
+ exit 0
17
+ ;;
18
+ projects|list|show|open|tail|rerun|batch|reconcile|reindex)
19
+ local sub="$1"; shift
20
+ # reindex 는 사용자가 명시적으로 지정한 --project 필터를 가지므로,
21
+ # 이 경로에서 자동(unqualified) backfill 을 추가로 돌리면 사용자
22
+ # scope 을 무시하고 모든 프로젝트를 스캔하게 된다. reindex 자체가
23
+ # backfill 을 수행하므로 prepare 의 자동 backfill 단계는 건너뛴다.
24
+ if [[ "$sub" == "reindex" ]]; then
25
+ OKSTRA_CTL_SKIP_BACKFILL=1 _okstra_ctl_prepare
26
+ elif [[ "$sub" == "reconcile" ]]; then
27
+ # 사용자 --project 필터를 보존하기 위해 prepare 의 unscoped
28
+ # reconcile 단계를 건너뛴다(서브커맨드 본체가 스코프 reconcile 수행).
29
+ OKSTRA_CTL_SKIP_RECONCILE=1 _okstra_ctl_prepare
30
+ else
31
+ _okstra_ctl_prepare
32
+ fi
33
+ "_okstra_ctl_${sub}" "$@"
34
+ ;;
35
+ *)
36
+ printf 'unknown subcommand: %s\n' "$1" >&2
37
+ usage
38
+ exit 2
39
+ ;;
40
+ esac
41
+ }
@@ -0,0 +1,29 @@
1
+ # shellcheck shell=bash
2
+
3
+ _okstra_ctl_prepare() {
4
+ okstra_central_bootstrap
5
+ local home; home="$(okstra_central_home)"
6
+ if [[ "${OKSTRA_CTL_SKIP_BACKFILL:-0}" != "1" ]]; then
7
+ if ! OKSTRA_STATE_PATH="$home/state.json" python3 -c '
8
+ import json, os, sys
9
+ from pathlib import Path
10
+ state = Path(os.environ["OKSTRA_STATE_PATH"])
11
+ data = json.loads(state.read_text()) if state.is_file() else {}
12
+ sys.exit(0 if data.get("backfilledAt") else 1)
13
+ '; then
14
+ _okstra_ctl_reindex >/dev/null 2>&1 || true
15
+ fi
16
+ fi
17
+ if [[ "${OKSTRA_CTL_SKIP_RECONCILE:-0}" == "1" ]]; then
18
+ return 0
19
+ fi
20
+ OKSTRA_HOME_RESOLVED="$home" \
21
+ OKSTRA_CTL_LIB_DIR="$SCRIPT_DIR" \
22
+ python3 - <<'PY'
23
+ import os, sys
24
+ sys.path.insert(0, os.environ["OKSTRA_CTL_LIB_DIR"])
25
+ from pathlib import Path
26
+ from okstra_ctl import reconcile_active
27
+ reconcile_active(Path(os.environ["OKSTRA_HOME_RESOLVED"]))
28
+ PY
29
+ }
@@ -0,0 +1,23 @@
1
+ # shellcheck shell=bash
2
+
3
+ usage() {
4
+ cat >&2 <<'USAGE'
5
+ usage:
6
+ okstra-ctl projects [--sort recent|name|count] [--active]
7
+ okstra-ctl list [--project <id|all>] [--task-group <name|all>]
8
+ [--status running|completed|failed|all]
9
+ [--since <duration|date>] [--limit N]
10
+ okstra-ctl show <taskId-or-prefix>
11
+ okstra-ctl open <taskId-or-prefix>
12
+ okstra-ctl tail <taskId-or-prefix> | active
13
+ okstra-ctl rerun <selector> [<selector> ...]
14
+ [--filter --project <id|all> --task-group <name|all>
15
+ --status ... --since ...]
16
+ [--max-spawn N] [--dry-run] [--yes]
17
+ okstra-ctl batch list
18
+ okstra-ctl batch status <batch-id>
19
+ okstra-ctl reconcile [--project <id|all>]
20
+ okstra-ctl reindex [--project <id|all>]
21
+ okstra-ctl --help
22
+ USAGE
23
+ }