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,190 @@
1
+ #!/usr/bin/env bash
2
+
3
+ set -euo pipefail
4
+
5
+ SOURCE_PATH="${BASH_SOURCE[0]}"
6
+ while [[ -L "$SOURCE_PATH" ]]; do
7
+ SOURCE_DIR="$(cd -P "$(dirname "$SOURCE_PATH")" && pwd)"
8
+ SOURCE_PATH="$(readlink "$SOURCE_PATH")"
9
+ [[ "$SOURCE_PATH" != /* ]] && SOURCE_PATH="$SOURCE_DIR/$SOURCE_PATH"
10
+ done
11
+
12
+ SCRIPT_DIR="$(cd -P "$(dirname "$SOURCE_PATH")" && pwd)"
13
+ WORKSPACE_ROOT="$(cd -P "$SCRIPT_DIR/.." && pwd)"
14
+ PROJECT_ID="okstra-validation"
15
+ PROJECT_ROOT="${OKSTRA_VALIDATION_PROJECT_ROOT:-/tmp/okstra-validate.workflow}"
16
+ WORKSPACE_APP_PATH="$PROJECT_ROOT"
17
+ OKSTRA_SCRIPT="$WORKSPACE_ROOT/scripts/okstra.sh"
18
+ RUN_VALIDATOR_SCRIPT="$WORKSPACE_ROOT/validators/validate-run.py"
19
+ SOURCE_ASSET_ROOT="$WORKSPACE_ROOT/agents"
20
+ TASK_TYPE="final-verification"
21
+ MARKER="MANUAL-VALIDATION-MARKER"
22
+ PRIMARY_TASK_GROUP="validation"
23
+ PRIMARY_TASK_ID="asset-refresh-and-reference-expectations"
24
+ PRIMARY_BRIEF_FILENAME="validation-brief-primary.md"
25
+ SECONDARY_TASK_GROUP="discovery"
26
+ SECONDARY_TASK_ID="task-catalog"
27
+ SECONDARY_BRIEF_FILENAME="validation-brief-secondary.md"
28
+ LATEST_TASK_RELATIVE_PATH=".project-docs/okstra/discovery/latest-task.json"
29
+ TASK_CATALOG_RELATIVE_PATH=".project-docs/okstra/discovery/task-catalog.json"
30
+
31
+ # shellcheck source=lib/common.sh
32
+ source "$SCRIPT_DIR/lib/common.sh"
33
+ # shellcheck source=lib/paths.sh
34
+ source "$SCRIPT_DIR/lib/paths.sh"
35
+ # shellcheck source=lib/fixtures.sh
36
+ source "$SCRIPT_DIR/lib/fixtures.sh"
37
+ # shellcheck source=lib/runners.sh
38
+ source "$SCRIPT_DIR/lib/runners.sh"
39
+ # shellcheck source=lib/validate-assets.sh
40
+ source "$SCRIPT_DIR/lib/validate-assets.sh"
41
+ # shellcheck source=lib/validate-prompt-metadata.sh
42
+ source "$SCRIPT_DIR/lib/validate-prompt-metadata.sh"
43
+ # shellcheck source=lib/validate-tasks.sh
44
+ source "$SCRIPT_DIR/lib/validate-tasks.sh"
45
+ # shellcheck source=lib/summary.sh
46
+ source "$SCRIPT_DIR/lib/summary.sh"
47
+
48
+ trap 'on_error "$LINENO"' ERR
49
+
50
+ require_file "$OKSTRA_SCRIPT"
51
+ require_file "$RUN_VALIDATOR_SCRIPT"
52
+
53
+ validate_project_root_safety
54
+
55
+ PRIMARY_BRIEF_PATH="$PROJECT_ROOT/$PRIMARY_BRIEF_FILENAME"
56
+ SECONDARY_BRIEF_PATH="$PROJECT_ROOT/$SECONDARY_BRIEF_FILENAME"
57
+ DISCOVERY_FILE="$PROJECT_ROOT/$LATEST_TASK_RELATIVE_PATH"
58
+ CATALOG_FILE="$PROJECT_ROOT/$TASK_CATALOG_RELATIVE_PATH"
59
+ MARKER_FILE="$PROJECT_ROOT/.claude/agents/codex-worker.md"
60
+ PRIMARY_TASK_KEY="$(task_key "$PRIMARY_TASK_GROUP" "$PRIMARY_TASK_ID")"
61
+ SECONDARY_TASK_KEY="$(task_key "$SECONDARY_TASK_GROUP" "$SECONDARY_TASK_ID")"
62
+ PRIMARY_REFERENCE_EXPECTATIONS_FILE="$(task_root "$PRIMARY_TASK_GROUP" "$PRIMARY_TASK_ID")/instruction-set/reference-expectations.md"
63
+ SECONDARY_REFERENCE_EXPECTATIONS_FILE="$(task_root "$SECONDARY_TASK_GROUP" "$SECONDARY_TASK_ID")/instruction-set/reference-expectations.md"
64
+
65
+ step "Resetting the validation root"
66
+ reset_validation_root
67
+ pass "Validation root reset: $PROJECT_ROOT"
68
+
69
+ step "Writing validation briefs for multiple tasks"
70
+ write_validation_brief \
71
+ "$PRIMARY_BRIEF_PATH" \
72
+ "Primary validation brief for reference expectations" \
73
+ "$PRIMARY_TASK_GROUP" \
74
+ "$PRIMARY_TASK_ID" \
75
+ "verify the initial task bundle and discovery pointer"
76
+ write_validation_brief \
77
+ "$SECONDARY_BRIEF_PATH" \
78
+ "Secondary validation brief for discovery catalog" \
79
+ "$SECONDARY_TASK_GROUP" \
80
+ "$SECONDARY_TASK_ID" \
81
+ "verify task catalog retention across multiple prepared tasks"
82
+ require_file "$PRIMARY_BRIEF_PATH"
83
+ require_file "$SECONDARY_BRIEF_PATH"
84
+ pass "Validation briefs created for primary and secondary tasks"
85
+
86
+ step "Running the initial render-only okstra validation for the primary task"
87
+ if ! run_okstra "$PRIMARY_TASK_GROUP" "$PRIMARY_TASK_ID" "$PRIMARY_BRIEF_FILENAME"; then
88
+ fail "Initial okstra render-only validation for the primary task failed"
89
+ fi
90
+ pass "Primary task render-only validation completed"
91
+
92
+ step "Checking seeded assets and primary task discovery artifacts"
93
+ if ! validate_seeded_assets match; then
94
+ fail "Seeded project-local okstra assets are missing or do not match the source files"
95
+ fi
96
+ if ! validate_reference_expectations "$PRIMARY_BRIEF_PATH" "$PRIMARY_REFERENCE_EXPECTATIONS_FILE" "$PRIMARY_TASK_KEY"; then
97
+ fail "Primary reference-expectations.md does not preserve the expected brief content"
98
+ fi
99
+ if ! validate_task_artifacts "$PRIMARY_TASK_GROUP" "$PRIMARY_TASK_ID"; then
100
+ fail "Primary task manifest or run manifest validation failed"
101
+ fi
102
+ if ! validate_worker_prompt_metadata "$PRIMARY_TASK_GROUP" "$PRIMARY_TASK_ID"; then
103
+ fail "Primary worker prompt metadata validation failed"
104
+ fi
105
+ if ! validate_latest_task_pointer "$PRIMARY_TASK_GROUP" "$PRIMARY_TASK_ID"; then
106
+ fail "latest-task.json did not point to the primary task after the first run"
107
+ fi
108
+ if ! validate_task_catalog "$PRIMARY_TASK_KEY" "$PRIMARY_TASK_KEY"; then
109
+ fail "task-catalog.json did not preserve the expected primary task entry"
110
+ fi
111
+ pass "Primary task discovery artifacts are valid"
112
+
113
+ step "Preparing validator fixture artifacts for the primary task"
114
+ if ! prepare_run_validator_fixture "$PRIMARY_TASK_GROUP" "$PRIMARY_TASK_ID" codex; then
115
+ fail "Failed to prepare validator fixture artifacts for the primary task"
116
+ fi
117
+ pass "Primary task validator fixture prepared with an intentionally missing Codex prompt history file"
118
+
119
+ step "Ensuring the run validator rejects attempted workers without prompt history"
120
+ if ! run_validator_expectation "$PRIMARY_TASK_GROUP" "$PRIMARY_TASK_ID" failed "Codex worker with status \`timeout\` is missing worker prompt history file"; then
121
+ fail "Run validator did not reject the missing worker prompt history artifact"
122
+ fi
123
+ pass "Run validator rejected the missing worker prompt history artifact as expected"
124
+
125
+ step "Restoring the missing worker prompt history file and rerunning the validator"
126
+ if ! write_worker_prompt_history_fixture "$PRIMARY_TASK_GROUP" "$PRIMARY_TASK_ID" codex; then
127
+ fail "Failed to write the missing worker prompt history fixture"
128
+ fi
129
+ if ! run_validator_expectation "$PRIMARY_TASK_GROUP" "$PRIMARY_TASK_ID" passed; then
130
+ fail "Run validator did not pass after restoring the worker prompt history artifact"
131
+ fi
132
+ pass "Run validator passed after the missing worker prompt history artifact was restored"
133
+
134
+ step "Running a second render-only okstra validation for the secondary task"
135
+ if ! run_okstra "$SECONDARY_TASK_GROUP" "$SECONDARY_TASK_ID" "$SECONDARY_BRIEF_FILENAME"; then
136
+ fail "Render-only validation for the secondary task failed"
137
+ fi
138
+ pass "Secondary task render-only validation completed"
139
+
140
+ step "Checking that latest-task.json moves while task-catalog.json retains both tasks"
141
+ if ! validate_reference_expectations "$SECONDARY_BRIEF_PATH" "$SECONDARY_REFERENCE_EXPECTATIONS_FILE" "$SECONDARY_TASK_KEY"; then
142
+ fail "Secondary reference-expectations.md does not preserve the expected brief content"
143
+ fi
144
+ if ! validate_task_artifacts "$SECONDARY_TASK_GROUP" "$SECONDARY_TASK_ID"; then
145
+ fail "Secondary task manifest or run manifest validation failed"
146
+ fi
147
+ if ! validate_worker_prompt_metadata "$SECONDARY_TASK_GROUP" "$SECONDARY_TASK_ID"; then
148
+ fail "Secondary worker prompt metadata validation failed"
149
+ fi
150
+ if ! validate_latest_task_pointer "$SECONDARY_TASK_GROUP" "$SECONDARY_TASK_ID"; then
151
+ fail "latest-task.json did not point to the secondary task after the second run"
152
+ fi
153
+ if ! validate_task_catalog "$SECONDARY_TASK_KEY" "$PRIMARY_TASK_KEY" "$SECONDARY_TASK_KEY"; then
154
+ fail "task-catalog.json did not preserve both prepared tasks"
155
+ fi
156
+ pass "latest-task.json and task-catalog.json now reflect distinct primary and secondary tasks"
157
+
158
+ step "Verifying that rerun without refresh preserves project-local assets"
159
+ require_file "$MARKER_FILE"
160
+ printf '\n%s\n' "$MARKER" >>"$MARKER_FILE"
161
+ assert_contains "$MARKER_FILE" "$MARKER"
162
+ if ! run_okstra "$SECONDARY_TASK_GROUP" "$SECONDARY_TASK_ID" "$SECONDARY_BRIEF_FILENAME"; then
163
+ fail "Secondary task rerun without refresh failed"
164
+ fi
165
+ assert_contains "$MARKER_FILE" "$MARKER"
166
+ if ! validate_latest_task_pointer "$SECONDARY_TASK_GROUP" "$SECONDARY_TASK_ID"; then
167
+ fail "latest-task.json changed unexpectedly during secondary rerun without refresh"
168
+ fi
169
+ if ! validate_task_catalog "$SECONDARY_TASK_KEY" "$PRIMARY_TASK_KEY" "$SECONDARY_TASK_KEY"; then
170
+ fail "task-catalog.json changed unexpectedly during secondary rerun without refresh"
171
+ fi
172
+ pass "Rerun without refresh preserved the modified project-local asset and retained both catalog entries"
173
+
174
+ step "Verifying that rerun with refresh regenerates project-local assets"
175
+ if ! run_okstra "$SECONDARY_TASK_GROUP" "$SECONDARY_TASK_ID" "$SECONDARY_BRIEF_FILENAME" --refresh-assets; then
176
+ fail "Secondary task rerun with --refresh-assets failed"
177
+ fi
178
+ assert_not_contains "$MARKER_FILE" "$MARKER"
179
+ if ! validate_seeded_assets match; then
180
+ fail "Refreshed project-local okstra assets do not match the source files"
181
+ fi
182
+ if ! validate_latest_task_pointer "$SECONDARY_TASK_GROUP" "$SECONDARY_TASK_ID"; then
183
+ fail "latest-task.json became invalid after refresh"
184
+ fi
185
+ if ! validate_task_catalog "$SECONDARY_TASK_KEY" "$PRIMARY_TASK_KEY" "$SECONDARY_TASK_KEY"; then
186
+ fail "task-catalog.json became invalid after refresh"
187
+ fi
188
+ pass "Refresh regenerated the mapped project-local okstra assets while preserving both catalog entries"
189
+
190
+ print_summary
package/src/doctor.mjs ADDED
@@ -0,0 +1,127 @@
1
+ import { promises as fs } from "node:fs";
2
+ import { spawn } from "node:child_process";
3
+ import { join } from "node:path";
4
+ import { resolvePaths } from "./paths.mjs";
5
+
6
+ const USAGE = `okstra doctor — diagnose the installed runtime
7
+
8
+ Usage:
9
+ okstra doctor Run all checks and print a summary
10
+ okstra doctor --json Machine-readable result
11
+
12
+ Checks:
13
+ - python3 binary and version (>= 3.10)
14
+ - okstra_project module importable from $HOME/.okstra/lib/python
15
+ - okstra_ctl module importable
16
+ - bash entrypoints present and executable in $HOME/.okstra/bin
17
+ - agents/ directory exists inside the okstra package
18
+ - version stamp matches package version
19
+ `;
20
+
21
+ async function pathExists(p) {
22
+ try {
23
+ await fs.access(p);
24
+ return true;
25
+ } catch {
26
+ return false;
27
+ }
28
+ }
29
+
30
+ function runProcess(cmd, args, opts = {}) {
31
+ return new Promise((resolve) => {
32
+ const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"], ...opts });
33
+ let stdout = "";
34
+ let stderr = "";
35
+ child.stdout.on("data", (b) => (stdout += b.toString()));
36
+ child.stderr.on("data", (b) => (stderr += b.toString()));
37
+ child.on("error", (err) => resolve({ code: -1, stdout, stderr: err.message }));
38
+ child.on("close", (code) => resolve({ code, stdout, stderr }));
39
+ });
40
+ }
41
+
42
+ async function checkPython3() {
43
+ const r = await runProcess("python3", ["--version"]);
44
+ if (r.code !== 0) return { ok: false, detail: `python3 not found: ${r.stderr.trim() || "missing binary"}` };
45
+ const out = (r.stdout + r.stderr).trim();
46
+ const m = out.match(/Python\s+(\d+)\.(\d+)/);
47
+ if (!m) return { ok: false, detail: `unparsable python version: ${out}` };
48
+ const [_, maj, min] = m.map(Number);
49
+ if (maj < 3 || (maj === 3 && min < 10)) {
50
+ return { ok: false, detail: `python ${maj}.${min} is too old (need 3.10+)` };
51
+ }
52
+ return { ok: true, detail: out };
53
+ }
54
+
55
+ async function checkPythonImport(moduleName, pythonpath) {
56
+ const r = await runProcess("python3", ["-c", `import ${moduleName}; print(${moduleName}.__name__)`], {
57
+ env: { ...process.env, PYTHONPATH: pythonpath },
58
+ });
59
+ if (r.code !== 0) {
60
+ return { ok: false, detail: `import ${moduleName} failed: ${r.stderr.trim().split("\n").pop()}` };
61
+ }
62
+ return { ok: true, detail: `${moduleName} importable` };
63
+ }
64
+
65
+ async function checkBashEntry(binDir, name) {
66
+ const p = join(binDir, name);
67
+ if (!(await pathExists(p))) return { ok: false, detail: `missing ${p}` };
68
+ try {
69
+ const st = await fs.stat(p);
70
+ if (!(st.mode & 0o111)) return { ok: false, detail: `${name} not executable` };
71
+ } catch (err) {
72
+ return { ok: false, detail: `stat ${name} failed: ${err.message}` };
73
+ }
74
+ return { ok: true, detail: `${name} OK` };
75
+ }
76
+
77
+ async function check(name, fn) {
78
+ try {
79
+ const result = await fn();
80
+ return { name, ...result };
81
+ } catch (err) {
82
+ return { name, ok: false, detail: `unexpected error: ${err.message}` };
83
+ }
84
+ }
85
+
86
+ export async function run(args) {
87
+ if (args.includes("--help") || args.includes("-h")) {
88
+ process.stdout.write(USAGE);
89
+ return 0;
90
+ }
91
+ const jsonMode = args.includes("--json");
92
+ const paths = await resolvePaths();
93
+
94
+ const results = [
95
+ await check("python3", checkPython3),
96
+ await check("okstra_project import", () => checkPythonImport("okstra_project", paths.pythonpath)),
97
+ await check("okstra_ctl import", () => checkPythonImport("okstra_ctl", paths.pythonpath)),
98
+ await check("agents dir", async () =>
99
+ (await pathExists(paths.agents))
100
+ ? { ok: true, detail: paths.agents }
101
+ : { ok: false, detail: `not found: ${paths.agents}` },
102
+ ),
103
+ await check("bin: okstra.sh", () => checkBashEntry(paths.bin, "okstra.sh")),
104
+ await check("bin: okstra-codex-exec.sh", () => checkBashEntry(paths.bin, "okstra-codex-exec.sh")),
105
+ await check("bin: okstra-gemini-exec.sh", () => checkBashEntry(paths.bin, "okstra-gemini-exec.sh")),
106
+ await check("bin: okstra-central.sh", () => checkBashEntry(paths.bin, "okstra-central.sh")),
107
+ await check("version stamp", async () =>
108
+ paths.version === paths.package
109
+ ? { ok: true, detail: paths.version }
110
+ : { ok: false, detail: `stamp=${paths.version || "(none)"} package=${paths.package}` },
111
+ ),
112
+ ];
113
+
114
+ const allOk = results.every((r) => r.ok);
115
+
116
+ if (jsonMode) {
117
+ process.stdout.write(`${JSON.stringify({ ok: allOk, paths, checks: results }, null, 2)}\n`);
118
+ return allOk ? 0 : 1;
119
+ }
120
+
121
+ for (const r of results) {
122
+ const mark = r.ok ? "OK " : "FAIL";
123
+ process.stdout.write(` [${mark}] ${r.name}: ${r.detail}\n`);
124
+ }
125
+ process.stdout.write(`\nresult: ${allOk ? "OK" : "FAIL"}\n`);
126
+ return allOk ? 0 : 1;
127
+ }
@@ -0,0 +1,355 @@
1
+ import { promises as fs } from "node:fs";
2
+ import { createHash } from "node:crypto";
3
+ import { join, relative, resolve as resolveAbs } from "node:path";
4
+ import { getPackageRoot } from "./version.mjs";
5
+ import { resolvePaths } from "./paths.mjs";
6
+
7
+ const PYTHON_PACKAGES = ["okstra_project", "okstra_ctl", "okstra_token_usage", "lib"];
8
+ const BIN_ENTRYPOINTS = [
9
+ "okstra.sh",
10
+ "okstra-codex-exec.sh",
11
+ "okstra-gemini-exec.sh",
12
+ "okstra-central.sh",
13
+ "okstra-token-usage.py",
14
+ "okstra-error-log.py",
15
+ ];
16
+
17
+ const INSTALL_USAGE = `okstra install — install runtime into ~/.okstra
18
+
19
+ Usage:
20
+ okstra install Install/update runtime (copy mode)
21
+ okstra install --link <p> Dev only: symlink ~/.okstra/{lib/python,bin}
22
+ to the source repo at <p>. agents/ resolves
23
+ to <p>/agents. Not advertised to end users.
24
+ okstra install --dry-run Print the plan without touching disk
25
+ okstra install --refresh Re-copy even files that match by hash
26
+
27
+ Effect (copy mode):
28
+ ${"$HOME"}/.okstra/lib/python <- packages/okstra/runtime/python
29
+ ${"$HOME"}/.okstra/bin <- packages/okstra/runtime/bin
30
+ ${"$HOME"}/.okstra/version <- installed package version stamp
31
+
32
+ Effect (link mode):
33
+ ${"$HOME"}/.okstra/lib/python/<pkg> -> <repo>/scripts/<pkg> (symlink)
34
+ ${"$HOME"}/.okstra/bin/<name>.sh -> <repo>/scripts/<name>.sh
35
+ ${"$HOME"}/.okstra/dev-link <- <repo> path stamp
36
+ ${"$HOME"}/.okstra/version <- installed package version stamp
37
+
38
+ agents/ is NOT copied — it stays inside the package (copy mode) or is
39
+ resolved to <repo>/agents (link mode) via 'okstra paths --field agents'.
40
+ `;
41
+
42
+ const ENSURE_USAGE = `okstra ensure-installed — idempotent install check
43
+
44
+ Usage:
45
+ okstra ensure-installed Verify install; reinstall if version/files drift
46
+ okstra ensure-installed -q Same, but suppress success output
47
+
48
+ Skills call this on every run. Returns 0 quickly when the install is fresh.
49
+ `;
50
+
51
+ async function hashFile(path) {
52
+ const buf = await fs.readFile(path);
53
+ return createHash("sha256").update(buf).digest("hex");
54
+ }
55
+
56
+ async function dirExists(path) {
57
+ try {
58
+ const st = await fs.stat(path);
59
+ return st.isDirectory();
60
+ } catch {
61
+ return false;
62
+ }
63
+ }
64
+
65
+ async function* walkFiles(root) {
66
+ let entries;
67
+ try {
68
+ entries = await fs.readdir(root, { withFileTypes: true });
69
+ } catch (err) {
70
+ if (err.code === "ENOENT") return;
71
+ throw err;
72
+ }
73
+ for (const ent of entries) {
74
+ if (ent.name === "__pycache__" || ent.name === ".DS_Store") continue;
75
+ if (ent.name.endsWith(".pyc")) continue;
76
+ const abs = join(root, ent.name);
77
+ if (ent.isDirectory()) {
78
+ yield* walkFiles(abs);
79
+ } else if (ent.isFile()) {
80
+ yield abs;
81
+ }
82
+ }
83
+ }
84
+
85
+ async function writeFileAtomic(target, data, mode) {
86
+ await fs.mkdir(join(target, ".."), { recursive: true });
87
+ const tmp = `${target}.tmp.${process.pid}.${Date.now()}`;
88
+ await fs.writeFile(tmp, data, { mode });
89
+ await fs.rename(tmp, target);
90
+ }
91
+
92
+ async function copyTreeIfChanged(srcRoot, dstRoot, opts) {
93
+ const { refresh = false, dryRun = false, mode } = opts ?? {};
94
+ let copied = 0;
95
+ let skipped = 0;
96
+ let missingSource = false;
97
+
98
+ if (!(await dirExists(srcRoot))) {
99
+ missingSource = true;
100
+ return { copied, skipped, missingSource };
101
+ }
102
+
103
+ for await (const srcPath of walkFiles(srcRoot)) {
104
+ const rel = relative(srcRoot, srcPath);
105
+ const dstPath = join(dstRoot, rel);
106
+
107
+ let needsCopy = refresh;
108
+ if (!needsCopy) {
109
+ try {
110
+ await fs.access(dstPath);
111
+ const [srcHash, dstHash] = await Promise.all([hashFile(srcPath), hashFile(dstPath)]);
112
+ needsCopy = srcHash !== dstHash;
113
+ } catch {
114
+ needsCopy = true;
115
+ }
116
+ }
117
+
118
+ if (!needsCopy) {
119
+ skipped++;
120
+ continue;
121
+ }
122
+
123
+ if (dryRun) {
124
+ process.stdout.write(`[dry-run] copy ${srcPath} -> ${dstPath}\n`);
125
+ } else {
126
+ const buf = await fs.readFile(srcPath);
127
+ await writeFileAtomic(dstPath, buf, mode);
128
+ }
129
+ copied++;
130
+ }
131
+
132
+ return { copied, skipped, missingSource };
133
+ }
134
+
135
+ async function ensureSymlink(target, linkPath, opts) {
136
+ const { dryRun = false } = opts ?? {};
137
+ try {
138
+ const current = await fs.readlink(linkPath);
139
+ if (current === target) return "skipped";
140
+ } catch (err) {
141
+ if (err.code !== "ENOENT" && err.code !== "EINVAL") throw err;
142
+ }
143
+ if (dryRun) {
144
+ process.stdout.write(`[dry-run] symlink ${linkPath} -> ${target}\n`);
145
+ return "linked";
146
+ }
147
+ await fs.mkdir(join(linkPath, ".."), { recursive: true });
148
+ try {
149
+ await fs.unlink(linkPath);
150
+ } catch (err) {
151
+ if (err.code !== "ENOENT") {
152
+ // Existing directory or regular file — refuse to clobber automatically.
153
+ throw new Error(
154
+ `refuse to replace ${linkPath} (not a symlink). Remove it manually and retry, or run 'okstra uninstall' first.`,
155
+ );
156
+ }
157
+ }
158
+ await fs.symlink(target, linkPath);
159
+ return "linked";
160
+ }
161
+
162
+ async function installLinkMode(repoPath, paths, opts) {
163
+ const { dryRun, quiet } = opts;
164
+ const repoAbs = resolveAbs(repoPath);
165
+
166
+ if (!(await dirExists(repoAbs))) {
167
+ throw new Error(`repo path not found: ${repoAbs}`);
168
+ }
169
+ const scriptsDir = join(repoAbs, "scripts");
170
+ if (!(await dirExists(scriptsDir))) {
171
+ throw new Error(`<repo>/scripts not found: ${scriptsDir}`);
172
+ }
173
+ const agentsDir = join(repoAbs, "agents");
174
+ if (!(await dirExists(agentsDir))) {
175
+ throw new Error(`<repo>/agents not found: ${agentsDir}`);
176
+ }
177
+
178
+ if (!quiet) {
179
+ process.stdout.write(`installing okstra runtime in LINK mode (package ${paths.package})\n`);
180
+ process.stdout.write(` repo: ${repoAbs}\n`);
181
+ process.stdout.write(` home: ${paths.home}\n`);
182
+ }
183
+
184
+ if (!dryRun) {
185
+ await fs.mkdir(paths.pythonpath, { recursive: true });
186
+ await fs.mkdir(paths.bin, { recursive: true });
187
+ }
188
+
189
+ for (const pkg of PYTHON_PACKAGES) {
190
+ const src = join(scriptsDir, pkg);
191
+ const dst = join(paths.pythonpath, pkg);
192
+ if (!(await dirExists(src))) {
193
+ if (!quiet) process.stdout.write(` python/${pkg}: missing in repo — skipped\n`);
194
+ continue;
195
+ }
196
+ const action = await ensureSymlink(src, dst, { dryRun });
197
+ if (!quiet) process.stdout.write(` python/${pkg}: ${action}\n`);
198
+ }
199
+
200
+ for (const name of BIN_ENTRYPOINTS) {
201
+ const src = join(scriptsDir, name);
202
+ const dst = join(paths.bin, name);
203
+ if (!(await fileExists(src))) {
204
+ if (!quiet) process.stdout.write(` bin/${name}: missing in repo — skipped\n`);
205
+ continue;
206
+ }
207
+ const action = await ensureSymlink(src, dst, { dryRun });
208
+ if (!quiet) process.stdout.write(` bin/${name}: ${action}\n`);
209
+ }
210
+
211
+ if (!dryRun) {
212
+ await writeFileAtomic(join(paths.home, "dev-link"), repoAbs + "\n", 0o644);
213
+ await writeFileAtomic(join(paths.home, "version"), paths.package + "\n", 0o644);
214
+ }
215
+ if (!quiet) {
216
+ process.stdout.write(` dev-link stamp: ${repoAbs}\n`);
217
+ process.stdout.write(` version stamp: ${paths.package}\n`);
218
+ process.stdout.write("done.\n");
219
+ }
220
+ return 0;
221
+ }
222
+
223
+ async function fileExists(p) {
224
+ try {
225
+ await fs.access(p);
226
+ return true;
227
+ } catch {
228
+ return false;
229
+ }
230
+ }
231
+
232
+ function parseInstallArgs(args) {
233
+ const result = {
234
+ dryRun: false,
235
+ refresh: false,
236
+ quiet: false,
237
+ linkRepo: null,
238
+ };
239
+ for (let i = 0; i < args.length; i++) {
240
+ const a = args[i];
241
+ if (a === "--dry-run") result.dryRun = true;
242
+ else if (a === "--refresh") result.refresh = true;
243
+ else if (a === "-q" || a === "--quiet") result.quiet = true;
244
+ else if (a === "--link") {
245
+ const next = args[i + 1];
246
+ if (!next || next.startsWith("--")) {
247
+ throw new Error("--link requires a repo path");
248
+ }
249
+ result.linkRepo = next;
250
+ i++;
251
+ } else {
252
+ throw new Error(`unknown argument '${a}'`);
253
+ }
254
+ }
255
+ return result;
256
+ }
257
+
258
+ export async function runInstall(args) {
259
+ if (args.includes("--help") || args.includes("-h")) {
260
+ process.stdout.write(INSTALL_USAGE);
261
+ return 0;
262
+ }
263
+
264
+ const opts = parseInstallArgs(args);
265
+ const pkgRoot = getPackageRoot();
266
+ const runtimeRoot = join(pkgRoot, "runtime");
267
+ const paths = await resolvePaths();
268
+
269
+ if (!opts.dryRun) {
270
+ await fs.mkdir(paths.home, { recursive: true });
271
+ }
272
+
273
+ if (opts.linkRepo) {
274
+ return await installLinkMode(opts.linkRepo, paths, opts);
275
+ }
276
+
277
+ // copy mode — if a prior dev-link is present, refuse rather than silently overwriting.
278
+ if (paths["dev-link"]) {
279
+ throw new Error(
280
+ `~/.okstra/dev-link is set to ${paths["dev-link"]}. Run 'okstra uninstall' first, then 'okstra install' in copy mode.`,
281
+ );
282
+ }
283
+
284
+ if (!opts.quiet) {
285
+ process.stdout.write(`installing okstra runtime (package ${paths.package})\n`);
286
+ process.stdout.write(` source: ${runtimeRoot}\n`);
287
+ process.stdout.write(` home: ${paths.home}\n`);
288
+ }
289
+
290
+ const pythonResult = await copyTreeIfChanged(
291
+ join(runtimeRoot, "python"),
292
+ paths.pythonpath,
293
+ { refresh: opts.refresh, dryRun: opts.dryRun, mode: 0o644 },
294
+ );
295
+ const binResult = await copyTreeIfChanged(
296
+ join(runtimeRoot, "bin"),
297
+ paths.bin,
298
+ { refresh: opts.refresh, dryRun: opts.dryRun, mode: 0o755 },
299
+ );
300
+
301
+ if (!opts.quiet) {
302
+ summarise("python", pythonResult, paths.pythonpath);
303
+ summarise("bin", binResult, paths.bin);
304
+ }
305
+
306
+ if (pythonResult.missingSource && binResult.missingSource) {
307
+ process.stderr.write(
308
+ "warning: runtime/{python,bin} are both empty. Runtime sync (build step) has not been performed.\n",
309
+ );
310
+ }
311
+
312
+ if (!opts.dryRun) {
313
+ await writeFileAtomic(join(paths.home, "version"), paths.package + "\n", 0o644);
314
+ }
315
+ if (!opts.quiet) {
316
+ process.stdout.write(` version stamp: ${paths.package}\n`);
317
+ process.stdout.write("done.\n");
318
+ }
319
+ return 0;
320
+ }
321
+
322
+ function summarise(label, result, target) {
323
+ if (result.missingSource) {
324
+ process.stdout.write(` ${label}: source directory missing — skipped\n`);
325
+ return;
326
+ }
327
+ process.stdout.write(
328
+ ` ${label}: copied=${result.copied} skipped=${result.skipped} -> ${target}\n`,
329
+ );
330
+ }
331
+
332
+ export async function runEnsureInstalled(args) {
333
+ if (args.includes("--help") || args.includes("-h")) {
334
+ process.stdout.write(ENSURE_USAGE);
335
+ return 0;
336
+ }
337
+ const quiet = args.includes("-q") || args.includes("--quiet");
338
+ const paths = await resolvePaths();
339
+
340
+ const reasons = [];
341
+ if (!paths.version) reasons.push("no version stamp");
342
+ else if (paths.version !== paths.package) {
343
+ reasons.push(`version drift (stamp=${paths.version}, package=${paths.package})`);
344
+ }
345
+ if (!(await dirExists(paths.pythonpath))) reasons.push(`missing ${paths.pythonpath}`);
346
+ if (!(await dirExists(paths.agents))) reasons.push(`missing agents dir ${paths.agents}`);
347
+
348
+ if (reasons.length === 0) {
349
+ if (!quiet) process.stdout.write(`okstra runtime OK (package ${paths.package})\n`);
350
+ return 0;
351
+ }
352
+
353
+ process.stderr.write(`okstra runtime needs install: ${reasons.join("; ")}\n`);
354
+ return await runInstall(quiet ? ["-q"] : []);
355
+ }