okstra 0.155.0 → 0.157.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 (36) hide show
  1. package/docs/architecture.md +3 -1
  2. package/docs/for-ai/skills/okstra-schedule-gen.md +5 -4
  3. package/docs/project-structure-overview.md +7 -1
  4. package/package.json +1 -1
  5. package/runtime/BUILD.json +2 -2
  6. package/runtime/prompts/profiles/_common-contract.md +1 -1
  7. package/runtime/python/okstra_ctl/clarification_items.py +3 -3
  8. package/runtime/python/okstra_ctl/render_final_report.py +31 -44
  9. package/runtime/python/okstra_ctl/report_contract.py +15 -0
  10. package/runtime/python/okstra_ctl/report_finalize.py +22 -3
  11. package/runtime/python/okstra_ctl/report_markdown.py +441 -0
  12. package/runtime/python/okstra_ctl/schedule_semantics.py +186 -91
  13. package/runtime/python/okstra_ctl/stage_map.py +203 -1
  14. package/runtime/python/okstra_ctl/wizard.py +1 -11
  15. package/runtime/python/okstra_project/state.py +14 -2
  16. package/runtime/skills/okstra-schedule-gen/SKILL.md +43 -18
  17. package/runtime/templates/reports/final-report-v2.template.md +74 -10
  18. package/runtime/templates/reports/md/macros/sections.md +19 -0
  19. package/runtime/templates/reports/md/tasks/change-impact-analysis.template.md +18 -0
  20. package/runtime/templates/reports/md/tasks/error-analysis.template.md +13 -0
  21. package/runtime/templates/reports/md/tasks/feature-analysis.template.md +13 -0
  22. package/runtime/templates/reports/md/tasks/final-verification.template.md +13 -0
  23. package/runtime/templates/reports/md/tasks/implementation-planning.template.md +15 -0
  24. package/runtime/templates/reports/md/tasks/implementation.template.md +15 -0
  25. package/runtime/templates/reports/md/tasks/improvement-discovery.template.md +10 -0
  26. package/runtime/templates/reports/md/tasks/project-analysis.template.md +15 -0
  27. package/runtime/templates/reports/md/tasks/release-handoff.template.md +13 -0
  28. package/runtime/templates/reports/md/tasks/requirements-discovery.template.md +15 -0
  29. package/runtime/templates/reports/schedule.template.md +166 -63
  30. package/runtime/validators/validate-run.py +19 -6
  31. package/runtime/validators/validate-schedule.py +94 -65
  32. package/src/commands/inspect/stage-map.mjs +6 -1
  33. package/src/commands/inspect/worker-liveness.mjs +15 -3
  34. package/src/commands/lifecycle/install.mjs +69 -4
  35. package/src/commands/lifecycle/uninstall.mjs +21 -35
  36. package/src/lib/install-assets.mjs +37 -0
@@ -5,6 +5,17 @@ const USAGE = `okstra worker-liveness — report whether pending workers are sti
5
5
  Usage:
6
6
  okstra worker-liveness [--team-state <path> --worker <worker-id>]...
7
7
  [--max-idle <seconds>] [--launch-grace <seconds>] [--json]
8
+ okstra worker-liveness --wait [--team-state <path> --worker <worker-id>]...
9
+ [--interval <seconds>] [--timeout <seconds>]
10
+
11
+ --wait polls until every named worker's persisted resultPath lands (exit 0), one
12
+ worker probes unhealthy (exit 1), or --timeout passes (exit 2).
13
+ Defaults: --interval 20, --timeout 2400. Run it as a background
14
+ command and branch on the exit code. Use this instead of writing a
15
+ poll loop: both the "is it done" test (the persisted resultPath, not a
16
+ filename you assemble) and the "is it dead" test live in here, and a
17
+ hand-written loop re-derives them and gets one wrong. A worker row
18
+ without a resultPath is refused rather than waited on by guess.
8
19
 
9
20
  --team-state and --worker select one pending worker. The worker row's
10
21
  livenessMode picks the probe: audit-heartbeat reads the in-process
@@ -15,9 +26,10 @@ Usage:
15
26
  is reused on re-dispatch, so the previous attempt's last heartbeat is
16
27
  not this dispatch's signal.
17
28
 
18
- Output: JSON { ok, checkedAt, probes[], unhealthy[] }. Exit 1 when any worker is
19
- stalled or did not launch, so a poll loop can branch on the exit code. Read-only:
20
- it reports, it never kills or re-dispatches.
29
+ Output: JSON { ok, checkedAt, probes[], unhealthy[] }, plus { outcome, pending[],
30
+ waitedSeconds } under --wait. Exit 1 when any worker is stalled or did not
31
+ launch, so a caller can branch on the exit code. Read-only: it reports, it never
32
+ kills or re-dispatches.
21
33
  `;
22
34
 
23
35
  export async function run(args) {
@@ -8,10 +8,13 @@ import { fileExists } from "../../lib/proc.mjs";
8
8
  import { buildRuntimeManifest, RUNTIMES_MANIFEST_REL } from "../../lib/runtime-manifest.mjs";
9
9
  import { normalizeRuntimeRequest, resolveRuntime } from "../../lib/runtime-resolver.mjs";
10
10
  import { OBSOLETE_SKILL_NAMES, USER_SKILL_NAMES } from "../../lib/skill-catalog.mjs";
11
+ import {
12
+ AGENTS_MANIFEST_REL,
13
+ SKILLS_MANIFEST_REL,
14
+ } from "../../lib/install-assets.mjs";
11
15
  import { renderWorkerAgentDefinitions } from "../../lib/worker-agent-render.mjs";
12
16
 
13
- const SKILLS_MANIFEST_REL = "installed-skills.json";
14
- const AGENTS_MANIFEST_REL = "installed-agents.json";
17
+
15
18
  const USER_HOME = homedir();
16
19
  const CLAUDE_HOME = join(USER_HOME, ".claude");
17
20
  const AGENTS_HOME = join(USER_HOME, ".agents");
@@ -236,15 +239,42 @@ async function writeFileAtomic(target, data, mode) {
236
239
  await fs.rename(tmp, target);
237
240
  }
238
241
 
242
+ // Files the destination has and the payload no longer ships. Left in place they
243
+ // are indistinguishable from current code — Python imports whatever is on disk,
244
+ // so a module dropped in release N+1 keeps being importable after the upgrade,
245
+ // and a rename leaves both spellings live at once.
246
+ async function pruneOrphans(srcRoot, dstRoot, opts) {
247
+ const { dryRun = false } = opts ?? {};
248
+ if (!(await dirExists(dstRoot))) return 0;
249
+ let pruned = 0;
250
+ for await (const dstPath of walkFiles(dstRoot)) {
251
+ const rel = relative(dstRoot, dstPath);
252
+ try {
253
+ await fs.access(join(srcRoot, rel));
254
+ continue;
255
+ } catch {
256
+ // not in the payload → orphan
257
+ }
258
+ if (dryRun) {
259
+ process.stdout.write(`[dry-run] prune ${dstPath}\n`);
260
+ } else {
261
+ await fs.rm(dstPath, { force: true });
262
+ }
263
+ pruned++;
264
+ }
265
+ return pruned;
266
+ }
267
+
239
268
  async function copyTreeIfChanged(srcRoot, dstRoot, opts) {
240
269
  const { refresh = false, dryRun = false, mode } = opts ?? {};
241
270
  let copied = 0;
242
271
  let skipped = 0;
272
+ let pruned = 0;
243
273
  let missingSource = false;
244
274
 
245
275
  if (!(await dirExists(srcRoot))) {
246
276
  missingSource = true;
247
- return { copied, skipped, missingSource };
277
+ return { copied, skipped, pruned, missingSource };
248
278
  }
249
279
 
250
280
  for await (const srcPath of walkFiles(srcRoot)) {
@@ -276,7 +306,8 @@ async function copyTreeIfChanged(srcRoot, dstRoot, opts) {
276
306
  copied++;
277
307
  }
278
308
 
279
- return { copied, skipped, missingSource };
309
+ pruned = await pruneOrphans(srcRoot, dstRoot, { dryRun });
310
+ return { copied, skipped, pruned, missingSource };
280
311
  }
281
312
 
282
313
  async function ensureSymlink(target, linkPath, opts) {
@@ -970,6 +1001,23 @@ function summarise(label, result, target) {
970
1001
  );
971
1002
  }
972
1003
 
1004
+ // `0.156.0` → [0, 156, 0]. Anything else → null, so a caller falls back to the
1005
+ // plain inequality test rather than guessing an ordering.
1006
+ function parseSemver(value) {
1007
+ const match = /^(\d+)\.(\d+)\.(\d+)/.exec(String(value ?? "").trim());
1008
+ return match ? match.slice(1, 4).map(Number) : null;
1009
+ }
1010
+
1011
+ export function installedStampIsNewer(stamp, packageVersion) {
1012
+ const installed = parseSemver(stamp);
1013
+ const running = parseSemver(packageVersion);
1014
+ if (!installed || !running) return false;
1015
+ for (let i = 0; i < 3; i++) {
1016
+ if (installed[i] !== running[i]) return installed[i] > running[i];
1017
+ }
1018
+ return false;
1019
+ }
1020
+
973
1021
  export function parseEnsureInstalledArgs(args) {
974
1022
  const result = {
975
1023
  quiet: false,
@@ -1009,6 +1057,23 @@ export async function runEnsureInstalled(args) {
1009
1057
  return 2;
1010
1058
  }
1011
1059
 
1060
+ // A newer install than the package running this check means an upgrade
1061
+ // arrived by another route — `npx -y okstra@latest install`, or an install
1062
+ // from a checkout — while this command still resolves an older payload.
1063
+ // Reinstalling here would quietly DOWNGRADE the user's runtime, and every
1064
+ // skill preflight calls this, so the downgrade lands on the next command
1065
+ // after the upgrade. Refuse and say so; an explicit `okstra install` is
1066
+ // still honoured, because that one is the user's own decision.
1067
+ if (installedStampIsNewer(paths.version, paths.package)) {
1068
+ process.stderr.write(
1069
+ `okstra: installed runtime ${paths.version} is newer than this package ` +
1070
+ `(${paths.package}) — skipping auto-install so it is not downgraded. ` +
1071
+ `Run 'okstra install' explicitly to force this package's payload, or ` +
1072
+ `update the okstra package itself.\n`,
1073
+ );
1074
+ return 0;
1075
+ }
1076
+
1012
1077
  const reasons = [];
1013
1078
  if (!paths.version) reasons.push("no version stamp");
1014
1079
  else if (paths.version !== paths.package) {
@@ -7,6 +7,12 @@ import {
7
7
  runtimeManifestIncludesClaudeAssets,
8
8
  } from "../../lib/runtime-manifest.mjs";
9
9
  import { OBSOLETE_SKILL_NAMES, USER_SKILL_NAMES } from "../../lib/skill-catalog.mjs";
10
+ import {
11
+ AGENTS_MANIFEST_REL,
12
+ INSTALLED_FILES,
13
+ INSTALLED_TREES,
14
+ SKILLS_MANIFEST_REL,
15
+ } from "../../lib/install-assets.mjs";
10
16
 
11
17
  // Manifest-less uninstall must clean out every okstra skill directory — the
12
18
  // live names AND every former/renamed one. Both sets are the single source of
@@ -34,8 +40,7 @@ const FALLBACK_SKILL_ROOTS = [
34
40
  CLAUDE_SKILLS_DIR,
35
41
  AGENTS_SKILLS_DIR,
36
42
  ];
37
- const SKILLS_MANIFEST_REL = "installed-skills.json";
38
- const AGENTS_MANIFEST_REL = "installed-agents.json";
43
+
39
44
 
40
45
  const USAGE = `okstra uninstall — remove installed runtime and okstra skills
41
46
 
@@ -147,21 +152,16 @@ export async function runUninstall(args) {
147
152
  process.stdout.write(`uninstalling okstra runtime\n`);
148
153
  process.stdout.write(` home: ${paths.home}\n`);
149
154
  }
150
- await removePath(paths.pythonpath, opts);
151
- // lib/validators — installed wholesale by copy mode (runtime/validators).
152
- // Without removing it, `lib` stays non-empty so the rmdir below is skipped and
153
- // a stale validator survives uninstall → reinstall, diverging from the current
154
- // template's section schema (observed: a pre-renumber `## 4.5 Stage Map`
155
- // validator outliving every later install).
156
- await removePath(join(paths.home, "lib", "validators"), opts);
157
- // bin/ tree — install copies the whole runtime/bin payload (entrypoints +
158
- // bin/lib helpers) in copy mode, so a per-name list always drifts behind
159
- // additions (e.g. okstra-inject-report-index.py, okstra-spawn-followups.sh).
160
- // Remove the directory wholesale, matching how install populates it; nothing
161
- // under ~/.okstra/bin is user data.
162
- await removePath(paths.bin, opts);
163
- // Clean now-empty lib parent (best-effort) — pythonpath + validators were the
164
- // only okstra-owned children.
155
+ // Every tree the install writes, read from the list install itself copies
156
+ // from. A per-name list here drifts behind additions the moment one lands on
157
+ // the install side only — which is how a stale validator, a stale schema and
158
+ // a stale prompts tree each survived uninstall→reinstall in turn, one bug
159
+ // report at a time.
160
+ for (const tree of INSTALLED_TREES) {
161
+ await removePath(join(paths.home, ...tree.to), opts);
162
+ }
163
+ // Clean now-empty lib parent (best-effort) — the trees above were the only
164
+ // okstra-owned children.
165
165
  const libDir = join(paths.home, "lib");
166
166
  if (await pathExists(libDir)) {
167
167
  try {
@@ -175,15 +175,6 @@ export async function runUninstall(args) {
175
175
  }
176
176
  }
177
177
 
178
- // schemas/ tree — installed by copy mode (runtime/schemas). Older uninstall
179
- // never removed it, leaving a stale final-report schema behind.
180
- await removePath(join(paths.home, "schemas"), opts);
181
-
182
- // prompts/ tree — installed by copy mode (runtime/prompts): lead contracts
183
- // + coding-preflight resource pack. Remove so an upgrade never serves stale
184
- // operating contracts.
185
- await removePath(join(paths.home, "prompts"), opts);
186
-
187
178
  // Remove the skills we own. Manifest v2 records every target root; legacy
188
179
  // manifests fall back to Claude-only names when the runtime manifest says
189
180
  // Claude assets may have been installed.
@@ -210,20 +201,15 @@ export async function runUninstall(args) {
210
201
  for (const name of agentNames) {
211
202
  await removePath(join(CLAUDE_AGENTS_DIR, name), opts);
212
203
  }
213
- await removePath(join(paths.home, AGENTS_MANIFEST_REL), opts);
214
-
215
- // templates/ tree — installed wholesale by copy mode (runtime/templates),
216
- // including the seeded settings.local.json sidecar. Remove the whole tree so
217
- // an upgrade/reinstall never serves a stale report.css / *.template.md.
218
- await removePath(join(paths.home, "templates"), opts);
219
204
  // Per-project <PROJECT>/.claude/settings.local.json symlinks are NOT removed
220
205
  // here — uninstall is machine-level and does not know which projects opted
221
206
  // in. They will dangle until the user removes them manually or re-runs
222
207
  // okstra install + okstra setup.
223
208
 
224
- await removePath(join(paths.home, "version"), opts);
225
- await removePath(join(paths.home, "dev-link"), opts);
226
- await removePath(join(paths.home, RUNTIMES_MANIFEST_REL), opts);
209
+ // Stamps and manifests, last: the skill/agent removal above reads two of them.
210
+ for (const name of INSTALLED_FILES) {
211
+ await removePath(join(paths.home, name), opts);
212
+ }
227
213
 
228
214
  if (!opts.quiet) {
229
215
  process.stdout.write("done. user data preserved (recent.jsonl, projects/, archive/, ...).\n");
@@ -0,0 +1,37 @@
1
+ // What `okstra install` puts under ~/.okstra, as one list.
2
+ //
3
+ // install and uninstall each used to carry their own copy of this, so an asset
4
+ // added to one and not the other survived every uninstall — `installed-from`
5
+ // sat in real homes for months that way, written by a version that no longer
6
+ // exists and removed by nothing.
7
+ //
8
+ // Skills and agents are NOT here: they install outside ~/.okstra (host skill
9
+ // homes, ~/.claude/agents) and are tracked per-target by their own manifests.
10
+
11
+ // runtime/<from> is mirrored to ~/.okstra/<to...>. Mirrored, not merged: a file
12
+ // the payload no longer ships is removed from the install, because Python
13
+ // imports whatever is on disk and a stale module outlives the release that
14
+ // dropped it.
15
+ export const INSTALLED_TREES = [
16
+ { from: "python", to: ["lib", "python"] },
17
+ { from: "bin", to: ["bin"] },
18
+ { from: "templates", to: ["templates"] },
19
+ { from: "schemas", to: ["schemas"] },
20
+ { from: "validators", to: ["lib", "validators"] },
21
+ { from: "prompts", to: ["prompts"] },
22
+ ];
23
+
24
+ // Single files the install stamps into ~/.okstra. Every one is removed by
25
+ // uninstall; `installed-from` is written by no current code path and is listed
26
+ // so that homes carrying it from an older version still get cleaned.
27
+ export const INSTALLED_FILES = [
28
+ "version",
29
+ "dev-link",
30
+ "installed-from",
31
+ "installed-skills.json",
32
+ "installed-agents.json",
33
+ "installed-runtimes.json",
34
+ ];
35
+
36
+ export const SKILLS_MANIFEST_REL = "installed-skills.json";
37
+ export const AGENTS_MANIFEST_REL = "installed-agents.json";