@awebai/oats 0.34.1 → 0.34.3

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.
package/bin/oats.mjs CHANGED
@@ -30,7 +30,7 @@ import {
30
30
  LAYERS, OATS_VERSION, manifestOperations, upgradeHomeMeta,
31
31
  capabilityManifests, capabilityTrust, capabilityExecutablePath,
32
32
  officialPackageCatalog, officialCatalogFile, officialCapabilityAliases, resolvedFromHome, resolvedFromPrepared, teamEnv, isWorkspaceHome, preWorkspaceHome, isCapturedHome, capturedHomeRefusal, composeInstanceAgentsMd, parseYamlNested, withConfigFile,
33
- findInstanceHome, findInstanceHomes, enclosingInstanceHome, logicalCwd, workspaceOf, stopInstanceSession, ensureRoot, findRoot, findAgent, findAgentAt, legacyLocalAgents, legacyCapturedHomes, listAgents, listInstances, servedIdentityLine, spawnInstanceAsync, instanceSoulDir, recordedKernelBin, launchConfigsAt, launchReportFor, explicitInstanceName, retireInstance, inspectInstanceSession, inputInstanceSession, attachInstanceSession, startInstanceSession, defaultRepo, RELATIONS, validateLaunchConfig, validateLaunchConfigDefaults, renderLaunchRecipe, describeLaunchCommand, redactLaunchRecipe, LAUNCH_HARNESSES, planLaunch, redactLaunchCommand, restartInstanceSession,
33
+ findInstanceHome, findInstanceHomes, enclosingInstanceHome, logicalCwd, readableInstanceHomes, workspaceOf, stopInstanceSession, ensureRoot, findRoot, findAgent, findAgentAt, legacyLocalAgents, legacyCapturedHomes, listAgents, listInstances, servedIdentityLine, spawnInstanceAsync, instanceSoulDir, recordedKernelBin, launchConfigsAt, launchReportFor, explicitInstanceName, retireInstance, inspectInstanceSession, inputInstanceSession, attachInstanceSession, startInstanceSession, defaultRepo, RELATIONS, validateLaunchConfig, validateLaunchConfigDefaults, renderLaunchRecipe, describeLaunchCommand, redactLaunchRecipe, withSafeTaskPrompt, LAUNCH_HARNESSES, planLaunch, redactLaunchCommand, restartInstanceSession,
34
34
  } from "../lib/core.mjs";
35
35
  import {
36
36
  writeFileAtomic, LOCK_FILE, readLock, readLockIfPresent, writeLock, resolvePackages, memoizedRemote,
@@ -583,7 +583,8 @@ function legacyLayoutProblems(root) {
583
583
  }
584
584
  async function doctorWorkspaceJson(ctx, soulName, ws) {
585
585
  const composition = await doctorComposition(ctx, soulName, ws, (code, msg, details) => jsonFail(code, msg, details));
586
- const problems = legacyLayoutProblems(join(dirname(ws.local.path), "agents"));
586
+ const agentsRoot = join(dirname(ws.local.path), "agents");
587
+ const problems = [...legacyLayoutProblems(agentsRoot), readableInstanceHomes(agentsRoot)].filter(Boolean);
587
588
  return {
588
589
  schemaVersion: 1, workspaceApi: 2, context: ctx,
589
590
  workspace: { file: ws.local.path, ref: ws.local.workspace },
@@ -622,7 +623,8 @@ async function doctor(dir) {
622
623
  doctorVersionSkew();
623
624
  const composition = await doctorComposition(ctx, soulName, ws, (code, msg) => die(`${msg} [${code}]`));
624
625
  printDoctorWorkspace(ws);
625
- for (const p of legacyLayoutProblems(join(dirname(ws.local.path), "agents"))) console.log(`\n! ${p.code}: ${p.message}`);
626
+ const agentsRoot = join(dirname(ws.local.path), "agents");
627
+ for (const p of [...legacyLayoutProblems(agentsRoot), readableInstanceHomes(agentsRoot)].filter(Boolean)) console.log(`\n! ${p.code}: ${p.message}`);
626
628
  if (soulName) {
627
629
  const information = operationalKnowledgeNote(composition, soulName);
628
630
  if (information) console.log(`\nINFO: ${information}`);
@@ -769,11 +771,11 @@ function launchPreview(bail) {
769
771
  instance = meta.instance || basename(home);
770
772
  if (!(meta.launch && typeof meta.launch === "object") && !selectionGiven) {
771
773
  // A home that predates recipes, asked nothing: its frozen command is
772
- // described as is. Under a selection the planner refuses it
773
- // (E_LAUNCH_LEGACY: re-spawn it from the deployment).
774
- let d;
775
- try { d = describeLaunchCommand(meta.command); } catch (e) { bail(e.code || "E_LAUNCH_COMMAND_UNSUPPORTED", e.message); }
776
- jsonOk({ context, selected, selection: { source: "frozen-command", launchConfig: null, harness: null, model: null, yolo: null }, harness: meta.harness, model: meta.model || null, modelSource: meta.model ? "recorded" : "native default", yolo: meta.yolo ?? null, launchConfig: null, launchConfigSource: null, launchConfigDefault: false, executable: { path: d.executable, declared: null, resolvedFrom: "recorded" }, argv: d.argv, environment: d.environment, command: redactLaunchCommand(meta.command), prompt: { kind: "task-file", file: "TASK.md" }, hooks: null, preflight: [{ check: "recipe", ok: true, detail: "frozen command; a selection is refused (E_LAUNCH_LEGACY): re-spawn it" }], ok: true });
774
+ // described as a start runs it (with its harness's safe task prompt). Under a
775
+ // selection the planner refuses it (E_LAUNCH_LEGACY: re-spawn it from the deployment).
776
+ let d, frozen;
777
+ try { frozen = withSafeTaskPrompt(meta.command, meta.harness); d = describeLaunchCommand(frozen); } catch (e) { bail(e.code || "E_LAUNCH_COMMAND_UNSUPPORTED", e.message); }
778
+ jsonOk({ context, selected, selection: { source: "frozen-command", launchConfig: null, harness: null, model: null, yolo: null }, harness: meta.harness, model: meta.model || null, modelSource: meta.model ? "recorded" : "native default", yolo: meta.yolo ?? null, launchConfig: null, launchConfigSource: null, launchConfigDefault: false, executable: { path: d.executable, declared: null, resolvedFrom: "recorded" }, argv: d.argv, environment: d.environment, command: redactLaunchCommand(frozen), prompt: { kind: "task-file", file: "TASK.md" }, hooks: null, preflight: [{ check: "recipe", ok: true, detail: "frozen command; a selection is refused (E_LAUNCH_LEGACY): re-spawn it" }], ok: true });
777
779
  return;
778
780
  }
779
781
  const agentsRoot = agentsRootOfHome(home);
@@ -2838,14 +2840,20 @@ async function capabilityCommand() {
2838
2840
  if (!hit) return NOT_DISPATCHED;
2839
2841
  // The same team/workspace facts a spawn hook receives (lead decision c3-7).
2840
2842
  const teamCtx = teamEnv(resolvedFromPrepared(hit.prepared, hit.deployment));
2841
- // No home, so no recorded soul: the soul's per-commit copy is OATS_SOUL when a spawn
2842
- // already fetched exactly this commit; otherwise the command gets none (never ambient).
2843
- // The agent directory is the soul entry's own (a package soul's is `<package>--<soul>`), never
2844
- // the bare name, which a same-named member soul's copy may occupy (as instance-inspect does).
2845
- const { agentDirOf } = await import("../lib/instance-resolution.mjs");
2846
- const entry = hit.prepared?.soulEntry;
2847
- const cachedSoul = entry?.commit ? join(hit.deployment, "agents", agentDirOf(entry), "souls", String(entry.commit).slice(0, 12)) : null;
2848
- return runManifestCommand({ capability: hit.module.name, ...hit.manifest }, { settings: hit.settings, origins: hit.resolution?.payloadOrigins?.[hit.module.name] }, teamCtx, hit.ensureTree, cachedSoul && existsSync(join(cachedSoul, "soul.yaml")) ? realpathSync(cachedSoul) : undefined);
2843
+ // No home, so no recorded soul: OATS_SOUL is the soul's source at the resolved commit, read
2844
+ // as a spawn preview reads it (the per-commit copy a spawn left under the agents root, else a
2845
+ // temporary fetch removed when the command ends). Read only when the command runs (after its
2846
+ // --help). A soul that cannot be read refuses the command: it never runs with OATS_SOUL unset.
2847
+ const soul = async () => {
2848
+ const { previewWorkspaceSoul } = await import("../lib/instance-resolution.mjs");
2849
+ const entry = hit.prepared?.soulEntry;
2850
+ try { const p = await previewWorkspaceSoul(hit.prepared, join(hit.deployment, "agents")); return { dir: p.soulDir, cleanup: p.cleanup }; }
2851
+ catch (e) {
2852
+ throw Object.assign(new Error(`oats ${cmd}: cannot read soul ${flag("soul")} at ${String(entry?.commit ?? "?").slice(0, 12)}, so the command would run without OATS_SOUL; nothing was run: ${e.message}`),
2853
+ { code: typeof e?.code === "string" && e.code.startsWith("E_") ? e.code : "E_REMOTE_UNREADABLE", details: { soul: flag("soul"), repoKey: entry?.repoKey ?? null, commit: entry?.commit ?? null } });
2854
+ }
2855
+ };
2856
+ return runManifestCommand({ capability: hit.module.name, ...hit.manifest }, { settings: hit.settings, origins: hit.resolution?.payloadOrigins?.[hit.module.name] }, teamCtx, hit.ensureTree, soul);
2849
2857
  }
2850
2858
 
2851
2859
  async function dispatch() {
@@ -2917,14 +2925,14 @@ async function capabilityCommand() {
2917
2925
  const live = await liveTeams(instanceHome, homeMeta.meta, { remoteOptions: remoteOptionsFromEnv() });
2918
2926
  teamCtx = homeTeamCtx(live);
2919
2927
  }
2920
- return runManifestCommand(m, { settings: capSettings[m.capability] || {}, origins: capOrigins[m.capability] }, teamCtx, () => m._dir, soulDir);
2928
+ return runManifestCommand(m, { settings: capSettings[m.capability] || {}, origins: capOrigins[m.capability] }, teamCtx, () => m._dir, () => ({ dir: soulDir, cleanup: () => {} }));
2921
2929
  }
2922
2930
 
2923
2931
  /** Help / unknown-command / spec validation / exec — shared by every context.
2924
2932
  * `m` is the manifest (with `capability`; `_dir` may be absent until `ensureDir`
2925
2933
  * resolves the directory holding the executable — the operator branch fetches
2926
2934
  * the module tree only when a command is actually going to run). */
2927
- async function runManifestCommand(m, { settings, origins }, teamCtx, ensureDir, soulDir) {
2935
+ async function runManifestCommand(m, { settings, origins }, teamCtx, ensureDir, soul) {
2928
2936
  const sub = args[1];
2929
2937
  const cmds = Object.keys(m.commands);
2930
2938
  // `oats <ns> --help` and `oats <ns> <cmd> --help` answer from the manifest
@@ -2958,6 +2966,11 @@ async function capabilityCommand() {
2958
2966
  try { abs = capabilityExecutablePath(withDir, script); }
2959
2967
  catch (e) { bail("E_CAPABILITY_BROKEN", e.message); }
2960
2968
  if (!abs) bail("E_CAPABILITY_BROKEN", `${cmd} ${sub}: script not found (${join(dir, script)})`);
2969
+ // The soul the command acts for → { dir, cleanup }: a home's recorded soul directory, or (operator
2970
+ // dispatch) the soul read at its resolved commit, whose temporary copy goes when this process exits.
2971
+ let soulDir;
2972
+ try { const read = await soul(); soulDir = read.dir; process.once("exit", read.cleanup); }
2973
+ catch (e) { if (typeof e?.code === "string" && e.code.startsWith("E_")) bail(e.code, e.message, e.details); throw e; }
2961
2974
  // OATS_SOUL is the recorded soul or nothing: an ambient value inherited from the
2962
2975
  // invoking process names some other soul (a coordinator's own), never this one.
2963
2976
  const { OATS_SOUL: _ambientSoul, ...inherited } = process.env;
@@ -104,7 +104,8 @@ A self-contained package has an `oats.json`:
104
104
  permanent external residue. It is marked `.oats-rollback-incomplete.json`, so
105
105
  `oats status` reports it as retained state rather than a live instance, and
106
106
  `oats retire <instance>` retries the cleanup — re-running the retire hooks and
107
- the rollback-owned Git steps, and verifying both. A retry that still cannot
107
+ the worktree removal, verifying both, and verifying (never deleting) the
108
+ branch: a branch is deleted only with `--delete-branch`. A retry that still cannot
108
109
  finish keeps the home again, names what is outstanding, and exits nonzero.
109
110
  - The **escape hatch is `oats retire <instance> --force`**, for a home OATS cannot
110
111
  identify at all: no `instance.json` and no **usable** cleanup descriptor. Usable
@@ -533,7 +534,12 @@ passed as arguments; no shell is involved.
533
534
  - `OATS_SOUL`, the soul directory: a home's recorded one, or for a soul
534
535
  (`readiness --soul`, `inspect --soul`) its copy at the resolved commit,
535
536
  which the kernel materialises first as a spawn would (0.30; a copy that
536
- cannot be made fails readiness under `installed`, producer `soul copy`);
537
+ cannot be made fails readiness under `installed`, producer `soul copy`).
538
+ An operator command (`oats <namespace> … --soul <name>` from the
539
+ deployment) gets the soul's source at the resolved commit too: the copy a
540
+ spawn left under the agents root, else a temporary copy removed when the
541
+ command ends. A soul that cannot be read refuses the command; it never
542
+ runs without `OATS_SOUL`;
537
543
  - for a home, `OATS_INSTANCE` and `OATS_INSTANCE_HOME`.
538
544
  - `OATS_TEAM_SCOPE` is the deployment directory; `OATS_TEAM_NAME` is always
539
545
  empty.
@@ -723,7 +723,7 @@ Read-only (it writes no lock):
723
723
  "souls":["rm"],"capabilities":["nw-house-style"],"publishes":null,"url":"https://github.com/nw/agents/tree/66566512…",
724
724
  "membershipFile":{"path":"oats-membership.yaml","url":"https://github.com/nw/agents/blob/66566512…/oats-membership.yaml"}}],
725
725
  "packages":[{"id":"oats.okf","version":"3.0.0","source":"catalog:oats.okf","commit":"ab897841…","integrity":"sha256-bada35…",
726
- "capabilities":["oats.okf"],"souls":[],"latest":{"version":"4.0.5","ref":"v4.0.5"}}],
726
+ "capabilities":["oats.okf"],"souls":[],"latest":{"version":"4.0.7","ref":"v4.0.7"}}],
727
727
  "declaredPackages":["oats.framework","oats.okf"],"unsynced":["oats.framework"],"stale":[],
728
728
  "external":[{"source":"git:github.com/oss/experts@3c606e09…","soul":"security-reviewer"}],
729
729
  "problems":[],"warnings":[],
@@ -782,8 +782,8 @@ packages' capabilities and souls, sorted by name, then origin. Both carry
782
782
  "defaultTeam":{"label":"mine","team":"mine:ana.aweb.ai","from":"deployment"},
783
783
  "private":false,"path":"souls/writer","work":"directory","description":"Drafts campaigns.","harness":"pi","model":null,"harnessFrom":"kernel-default",
784
784
  "file":{"path":"souls/writer/soul.yaml","url":null},"spawnable":true,"problem":null},
785
- {"name":"knowledge-maintainer","qualifiedName":"oats.okf/knowledge-maintainer","origin":"package oats.okf v4.0.5","kind":"package","package":"oats.okf",
786
- "version":"4.0.5","repoKey":"github.com/awebai/oats-okf","commit":"26d8216f…","teams":null,"defaultTeam":null,"private":false,
785
+ {"name":"knowledge-maintainer","qualifiedName":"oats.okf/knowledge-maintainer","origin":"package oats.okf v4.0.7","kind":"package","package":"oats.okf",
786
+ "version":"4.0.7","repoKey":"github.com/awebai/oats-okf","commit":"e460b29a…","teams":null,"defaultTeam":null,"private":false,
787
787
  "path":"oats-package/souls/knowledge-maintainer","work":"directory","description":"Reviews harvested knowledge.","harness":"pi","model":null,
788
788
  "harnessFrom":"kernel-default","file":{"path":"oats-package/souls/knowledge-maintainer/soul.yaml","url":null},
789
789
  "spawnable":false,"problem":{"code":"E_TEAM_UNKNOWN","message":"team \"reviewers\" is not declared (oats-local.yaml#/souls/teams/…)"}}],
@@ -872,8 +872,8 @@ nothing reads a working clone.
872
872
  **The show:**
873
873
 
874
874
  ```json
875
- {"capabilityShowApi":1,"name":"oats.okf","kind":"package","repoKey":"github.com/awebai/oats-okf","package":"oats.okf","version":"4.0.5",
876
- "commit":"26d8216f…","path":"oats-package/capabilities/oats-okf",
875
+ {"capabilityShowApi":1,"name":"oats.okf","kind":"package","repoKey":"github.com/awebai/oats-okf","package":"oats.okf","version":"4.0.7",
876
+ "commit":"e460b29a…","path":"oats-package/capabilities/oats-okf",
877
877
  "inject":{"path":"injects/okf.md","bytes":2422,"text":"## Knowledge: OKF\n\nYou have two kinds of knowledge. …","binary":false,"truncated":false},
878
878
  "skills":[{"name":"okf-consultation","path":"skills/okf-consultation","description":"Consulting your soul's knowledge with the `oats okf` CLI: …",
879
879
  "files":[{"path":"skills/okf-consultation/SKILL.md","bytes":6947},{"path":"skills/okf-consultation/references/consult.md","bytes":4465}],
@@ -899,7 +899,7 @@ nothing reads a working clone.
899
899
  **The `--file` answer:**
900
900
 
901
901
  ```json
902
- {"capabilityShowApi":1,"name":"oats.okf","kind":"package","commit":"26d8216f…",
902
+ {"capabilityShowApi":1,"name":"oats.okf","kind":"package","commit":"e460b29a…",
903
903
  "file":{"path":"skills/okf-instance-knowledge/SKILL.md","bytes":4787,"text":"---\nname: okf-instance-knowledge\n…","binary":false,"truncated":false}}
904
904
  ```
905
905
 
@@ -2028,7 +2028,11 @@ A first retire prints the **raw receipt**, not an envelope:
2028
2028
  - `--discard-worktree` removes the worktree. `--delete-branch` deletes the
2029
2029
  worktree's verified branch (re-verified at deletion time) and implies
2030
2030
  discarding; a mismatch deletes nothing and reports
2031
- `branchDeletionSkipped`.
2031
+ `branchDeletionSkipped`. Without `--delete-branch` no retire deletes a
2032
+ branch, a retried or `--force`d quarantine included. A failed spawn's
2033
+ quarantine that still owes the branch the spawn created stays incomplete
2034
+ (`git branch <b>: kept; the failed spawn created it; pass --delete-branch to
2035
+ delete it`).
2032
2036
  - `workRecovery` (or `workRecoveries[]`): `{path, classes, bytes, outputs?,
2033
2037
  repoCopy?}`; `outputs: {paths: [{path, bytes}], bytes}` names what was
2034
2038
  copied beyond tracked state, largest first.
@@ -13,8 +13,8 @@ home. To run them on another machine, see [servers.md](servers.md).
13
13
  | Harness | `--harness` | Launched as |
14
14
  |---|---|---|
15
15
  | pi | `pi` (the default) | `pi --append-system-prompt <home>/AGENTS.md --approve --name <instance> [--model m] @TASK.md` |
16
- | Claude Code | `claude` | `claude [--model m] -- "$(cat TASK.md)"` |
17
- | Codex | `codex` | `codex --cd <home> [--model m] -- "$(cat TASK.md)"` |
16
+ | Claude Code | `claude` | `claude [--model m] -- '@TASK.md'` |
17
+ | Codex | `codex` | `codex --cd <home> [--model m] -- 'Read TASK.md in this directory first: it is your briefing and your task.'` |
18
18
 
19
19
  Each harness starts in the instance home with its own native settings,
20
20
  authentication and skill discovery. The command line also carries the
@@ -166,6 +166,18 @@ and `restart`, opens a new harness conversation on `TASK.md`. The instance
166
166
  resumes its work from its own state files, as its knowledge capability
167
167
  prescribes.
168
168
 
169
+ The task's text never travels on a command line, where any local user could
170
+ read it in the process list:
171
+
172
+ - pi and Claude Code get `@TASK.md`, which each harness reads as the file.
173
+ - Codex gets a fixed pointer to the file and reads it with a tool.
174
+ - A home whose recorded command still hands over `"$(cat TASK.md)"` starts
175
+ with its harness's safe prompt instead, and the command is saved that way.
176
+
177
+ The home is created `0700` and `TASK.md` `0600`. `oats doctor` reports an
178
+ instance home that other users can read (`home-readable`), with the exact
179
+ `chmod`; it never changes a home's mode itself.
180
+
169
181
  ## Permissions (yolo)
170
182
 
171
183
  Yolo is chosen per launch: `--yolo` / `--no-yolo` on `oats spawn`,
@@ -225,7 +225,7 @@ scaling by call count.
225
225
  | `npm test` | every suite under `test/` (`node --test`), through `scripts/run-tests.mjs` |
226
226
  | `npm run check` | syntax of every shipped file |
227
227
  | `npm run check:pi` | the pi adapter's TypeScript |
228
- | `npm run validate` | the JSON schemas, the example manifests and configs, and every local link and anchor in the public docs |
228
+ | `npm run validate` | the JSON schemas, the example manifests and configs, every local link and anchor in the public docs, the repository's own `oats-workspace.yaml` and `package-catalog.json` read by the kernel's readers, and no unresolved merge-conflict marker in a tracked text file (`scripts/check-workspace-files.mjs`) |
229
229
  | `npm run pack:check` | an `npm pack` dry run of both packages: nothing missing, nothing leaked |
230
230
  | `npm run smoke:tarball` | installs the packed tarballs outside the checkout and exercises them |
231
231
 
@@ -40,8 +40,8 @@ arrives from.
40
40
  ```yaml
41
41
  # oats-workspace.yaml: one default per slot, for every soul
42
42
  packages:
43
- oats.okf: v4.0.5
44
- oats.aweb: v1.17.5
43
+ oats.okf: v4.0.7
44
+ oats.aweb: v1.17.7
45
45
  oats.linear: v1.0.1
46
46
  oats.jira: v1.0.1
47
47
  defaults:
package/docs/knowledge.md CHANGED
@@ -26,7 +26,7 @@ The workspace pins the package and fills the slot for every soul by default:
26
26
  ```yaml
27
27
  # oats-workspace.yaml (excerpt)
28
28
  packages:
29
- oats.okf: v4.0.5
29
+ oats.okf: v4.0.7
30
30
  defaults:
31
31
  knowledge: { oats.okf: { from: package } }
32
32
  stores:
@@ -225,7 +225,8 @@ identical copies in both role capabilities.
225
225
  `--soul <name>` (`E_BAD_ARGS` without it). Inside an instance home, a
226
226
  `--soul` naming another soul is refused (`E_HOME_MISMATCH`). The kernel resolves the
227
227
  soul as a spawn would, fetches its module at the locked commit into
228
- `<deployment>/.oats/modules/` and runs it with the soul's merged settings.
228
+ `<deployment>/.oats/modules/` and runs it with the soul's merged settings
229
+ and `OATS_SOUL`, the soul's source at that commit.
229
230
  An unlocked package is `E_PACKAGE_MISSING` until `oats sync`.
230
231
 
231
232
  ### Consult
@@ -10,8 +10,8 @@ or workspace membership alone does not make a package official.
10
10
  | package | release | capabilities | package souls |
11
11
  |---|---|---|---|
12
12
  | `oats.framework` | `oats-framework/v1.4.1` (this repository) | `oats.core`, `oats.setup`, `oats.knowledge-theory` | `knowledge-theory-expert` |
13
- | `oats.okf` | `v4.0.5` | `oats.okf` (knowledge), `oats.okf-harvest`, `oats.okf-maintenance` | `knowledge-harvester`, `knowledge-maintainer` |
14
- | `oats.aweb` | `v1.17.5` | `oats.aweb` (messaging) | |
13
+ | `oats.okf` | `v4.0.7` | `oats.okf` (knowledge), `oats.okf-harvest`, `oats.okf-maintenance` | `knowledge-harvester`, `knowledge-maintainer` |
14
+ | `oats.aweb` | `v1.17.7` | `oats.aweb` (messaging) | |
15
15
  | `oats.engineering` | `v1.5.0` | `oats.engineering-expert`, `oats.developer`, `oats.code-review` | `code-reviewer` |
16
16
  | `oats.authoring` | `v1.0.3` | `oats.authoring` | |
17
17
  | `oats.jira` | `v1.0.1` | `oats.jira` (tasks) | |
@@ -27,7 +27,7 @@ no lock and adds nothing to an existing workspace.
27
27
  ## Find and use packages
28
28
 
29
29
  - A workspace pins an official package by **bare version** in its
30
- `packages:` map (`oats.okf: v4.0.5`); `oats sync` resolves it through the
30
+ `packages:` map (`oats.okf: v4.0.7`); `oats sync` resolves it through the
31
31
  catalog to an exact commit, fetches it, verifies its integrity and locks it.
32
32
  A package outside the catalog is written `git:<repo>@<ref>`. Pinning does
33
33
  not join a team or adopt the publisher's workspace. See
package/docs/packages.md CHANGED
@@ -44,14 +44,14 @@ whole organisation:
44
44
 
45
45
  ```yaml
46
46
  packages:
47
- oats.okf: v4.0.5 # bare version → the official catalog
47
+ oats.okf: v4.0.7 # bare version → the official catalog
48
48
  acme.tools: git:github.com/acme/tools@v0.4.0 # direct ref: git:<repo>@<tag or full OID>
49
49
  ```
50
50
 
51
- - **Bare version** (`v4.0.5`, `4.0.5`, `1.0.0-rc.1`): the id is looked up in
51
+ - **Bare version** (`v4.0.7`, `4.0.7`, `1.0.0-rc.1`): the id is looked up in
52
52
  the official catalog — `package-catalog.json` in the `oats` repo, or the file
53
53
  named by `OATS_PACKAGE_CATALOG` — which supplies the repo url, the tag
54
- convention (`v4.0.5` or `oats-framework/v1.4.1`) and the payload path. An id
54
+ convention (`v4.0.7` or `oats-framework/v1.4.1`) and the payload path. An id
55
55
  the catalog does not know is `E_PACKAGE_MISSING` ("use `git:<repo>@<ref>` for
56
56
  a package outside the catalog"). The catalog is the reviewed official list
57
57
  ([official-catalog.md](official-catalog.md)) and the only way a
@@ -75,8 +75,8 @@ members:
75
75
  - git:github.com/acme/platform
76
76
  packages:
77
77
  oats.framework: v1.4.1
78
- oats.okf: v4.0.5
79
- oats.aweb: v1.17.5
78
+ oats.okf: v4.0.7
79
+ oats.aweb: v1.17.7
80
80
  teams:
81
81
  platform: { team: "platform:acme.aweb.ai", description: Platform engineering }
82
82
  defaults:
@@ -105,7 +105,7 @@ decision recorded in the lock.
105
105
  $ oats sync
106
106
  workspace acme (github.com/acme/agents @ 3f2a9c1e)
107
107
  members agents ✓↔ (@ 3f2a9c1e) platform ✓↔ (@ 77c0a1b2) billing ✗ (no-backlink)
108
- packages acme.tools 0.4.0 ✓ (@ 47f4b816) oats.okf 4.0.5 ✓ (@ 26d8216f)
108
+ packages acme.tools 0.4.0 ✓ (@ 47f4b816) oats.okf 4.0.7 ✓ (@ e460b29a)
109
109
  changed acme.tools — → 0.4.0 (@ 47f4b816)
110
110
  souls 9 discovered (6 members, 1 external, 2 package, 0 disabled here) · 0 private capabilities
111
111
  teams platform (shared) · this deployment's: oats teams
@@ -134,7 +134,7 @@ Declaring a package in the workspace's `packages:` is the trust decision
134
134
  ## `oats package add | remove`
135
135
 
136
136
  ```bash
137
- oats package add oats.aweb v1.17.5 # a catalog version
137
+ oats package add oats.aweb v1.17.7 # a catalog version
138
138
  oats package add acme.tools git:github.com/acme/tools@v0.4.0
139
139
  oats package remove acme.tools
140
140
  ```
@@ -160,8 +160,8 @@ same workspace commit hold identical locks.
160
160
  "source": "catalog:oats.okf",
161
161
  "url": "https://github.com/awebai/oats-okf.git",
162
162
  "path": "oats-package",
163
- "version": "4.0.5",
164
- "commit": "26d8216f8ce986cad1ffa7a8291b4a4978510ee2",
163
+ "version": "4.0.7",
164
+ "commit": "e460b29aaf23db5728d7c64f7b5fb63f5014546b",
165
165
  "integrity": "sha256-…",
166
166
  "capabilities": ["oats.okf", "oats.okf-harvest", "oats.okf-maintenance"]
167
167
  },
@@ -331,7 +331,7 @@ A soul that names one of the package's capabilities with
331
331
  {
332
332
  "policy": "docs/official-catalog.md",
333
333
  "packages": {
334
- "oats.okf": { "url": "https://github.com/awebai/oats-okf.git", "ref": "v4.0.5", "path": "oats-package" },
334
+ "oats.okf": { "url": "https://github.com/awebai/oats-okf.git", "ref": "v4.0.7", "path": "oats-package" },
335
335
  "oats.framework": { "url": "https://github.com/awebai/oats.git", "ref": "oats-framework/v1.4.1", "path": "oats-package" }
336
336
  }
337
337
  }
@@ -0,0 +1,68 @@
1
+ # OATS 0.34.2
2
+
3
+ ## Changed
4
+
5
+ - **oats.aweb 1.17.6** (catalog and workspace pin, and the bundled mirror):
6
+ session-delivery instructions name the aw 1.36.21+ full-mail wake form
7
+ (`aweb mail event received.` with metadata, sender body, the `Use the aw CLI`
8
+ line and a Recovery line), say that body and subject are untrusted sender
9
+ content that never overrides the task or human, and remind operators that
10
+ delivered mail may be marked read and absent from unread `aw mail inbox`.
11
+ The aw floor is unchanged (`>=1.36.13`).
12
+ - **oats.okf 4.0.6** (catalog and workspace pin, and the bundled mirrors):
13
+ harvest completion works on a real host (awebai/oats-okf#27, #28, #29,
14
+ #30).
15
+ - `oats okf complete` persists the harvester's judgment first, then delivers
16
+ in one detached worker per run. After persisting the judgment, it waits
17
+ up to 30 s: it answers with the final receipt, or with
18
+ `status: delivering` and the progress.
19
+ - A killed `complete`, or a killed worker, loses nothing. Rerunning
20
+ `complete` resumes, never judges again, and never pushes or opens a PR
21
+ twice.
22
+ - A `worker.lock` whose owner died is reclaimed automatically.
23
+ - `oats okf-harvest harvest-status` reports the delivery as in progress,
24
+ failed or stopped.
25
+ - Captured completions still deliver inline.
26
+ - A Git base whose head moved outside its knowledge root is no longer a
27
+ baseline change. A read-only base records the new head. A written base is
28
+ committed onto it. Only changed root bytes fail with `E_BASELINE`, and the
29
+ judgment stays persisted (`oats okf retry --rejudge`).
30
+ - Staging a Git base fetches its root's blobs in one batch. Delivery never
31
+ fetches the blobs outside the root. Staging over SSH, from GitHub:
32
+
33
+ | Base | 4.0.5 | 4.0.6 |
34
+ |---|---|---|
35
+ | oats-knowledge | 237 s | 9.9 s |
36
+ | aweb | 41 s | 9.1 s |
37
+
38
+ - `oats okf harvest-status` reports `unknown`, with the reason, when it
39
+ cannot read a soul's opt-out.
40
+
41
+ ## Fixed
42
+
43
+ - **An operator command with `--soul` always gets the soul**
44
+ (awebai/oats#423). `oats <namespace> … --soul <name>` from the deployment
45
+ passed `OATS_SOUL` only when a spawn had already fetched that soul at the
46
+ resolved commit, so `oats okf harvest-status --soul <s>` reported harvest
47
+ "off" for a soul no spawn had fetched yet. The soul is now read at the
48
+ resolved commit as a spawn preview reads it: the copy a spawn left, else a
49
+ temporary copy removed when the command ends. A soul that cannot be read
50
+ refuses the command; it never runs without `OATS_SOUL`.
51
+
52
+ - **A task's text no longer appears in the process list** (awebai/oats#427).
53
+ Claude Code and Codex instances were launched with the whole `TASK.md` as a
54
+ command-line argument, which any local user can read. Now:
55
+ - Claude Code gets `@TASK.md` (it reads the file).
56
+ - Codex gets a fixed instruction to read `TASK.md`.
57
+ - A home recorded the old way starts with the safe prompt, and its saved
58
+ command is updated.
59
+ - New instance homes are created `0700` and `TASK.md` `0600`.
60
+ - `oats doctor` reports a home other users can read (`home-readable`) with
61
+ the exact `chmod`. Existing homes are not changed: run that `chmod`.
62
+
63
+ - **`npm run validate` catches a broken workspace file** (a development
64
+ check). It reads the repository's own `oats-workspace.yaml` and
65
+ `package-catalog.json` with the kernel's readers, and fails on an unresolved
66
+ merge-conflict marker in any tracked text file, naming the file and the
67
+ line. A conflicted `oats-workspace.yaml` had passed check, validate, pack
68
+ and smoke. The kernel's YAML and catalog errors now name the line too.
@@ -0,0 +1,48 @@
1
+ # OATS 0.34.3
2
+
3
+ ## Changed
4
+
5
+ - **oats.okf 4.0.7** (catalog and workspace pin, and the bundled mirrors):
6
+ `oats okf complete` records acceptance of an amended and merged harvest PR
7
+ (awebai/oats-okf#32). After the knowledge-maintainer's `amend+merge`, the
8
+ after-merge `complete --run <id>` failed with `E_BASELINE`, and the receipt
9
+ stayed `delivered`. Now:
10
+ - a merged PR is settled by its merge before any baseline check, and the
11
+ receipt records `mergeCommit`;
12
+ - when the PR was merged at a head the maintainer amended, an `okf-review`
13
+ verdict (`merge` or `amend+merge`) must name that head and the PR URL. It
14
+ must come from a repository member, or from the account that merged the
15
+ PR (which covers a maintainer on a GitHub App token);
16
+ - without such a verdict, `complete` fails with `E_PR` and says what to do.
17
+ Merged inputs are never rejudged.
18
+
19
+ - **oats.aweb 1.17.7** (catalog and workspace pin, and the bundled mirror):
20
+ native retire retries are idempotent after a successful default-workspace
21
+ self-delete. The provider records a local completion marker, so if another
22
+ retire hook keeps the home and the kernel retries, oats.aweb does not call
23
+ `aw workspace delete` again with the already-revoked certificate.
24
+
25
+ ## Fixed
26
+
27
+ - **Retire no longer deletes a branch you did not ask it to delete**
28
+ (awebai/oats#436, data safety). When a retire hook reported incomplete
29
+ cleanup, the home was quarantined, and the retry (or `oats retire --force`)
30
+ ran `git branch -D` on the instance's branch even without
31
+ `--delete-branch`, taking any unpushed commits with it. Now:
32
+ - only `--delete-branch` deletes a branch, the verified one;
33
+ - a quarantine and its retry never do;
34
+ - a failed spawn deletes its own new branch only while the branch's tip is
35
+ still where the spawn created it (`git update-ref -d` with that commit),
36
+ and otherwise keeps it and says so.
37
+
38
+ - **A capture that is killed no longer blocks every later capture**
39
+ (awebai/oats#437). A capture pass killed mid-pass (a hook timeout, or
40
+ okf's 60 s bound) left the record root's capture lock behind, and every
41
+ later `oats capture` skipped. Retires that needed a final capture then
42
+ failed. Now:
43
+ - the next pass reclaims a lock whose recorded owner is dead on this host,
44
+ under a guard, and says so on stderr;
45
+ - a live or unknown owner, an initializing lock and another host's lock
46
+ are never touched;
47
+ - locks written by earlier kernels, which do not record the host, count as
48
+ this host's, so a stale lock already on disk clears on the next pass.
@@ -129,7 +129,7 @@ full copy** of every capability the soul resolved to:
129
129
  .oats/modules/<capability>/ # the whole capability: oats.json, bin/, injects/, skills/ (hooks run from here)
130
130
  .oats/bin/oats → <kernel>/bin/oats.mjs # the kernel that last launched this home: first on the harness's PATH
131
131
  work/ # worktree, checkout symlink, attached tree, or private directory
132
- TASK.md # briefing and task
132
+ TASK.md # briefing and task (0600; the home itself is 0700)
133
133
  instance.json # provenance (below); `soulDir` = the soul directory hooks get as OATS_SOUL
134
134
  STATE.md, log.md, notes/ # optional, from the knowledge capability
135
135
  ```
@@ -152,8 +152,8 @@ composed skills and instructions, a spawn records:
152
152
  "commit": "3f2a9c1e…", "digest": "sha256-…", "materializedAt": "2026-09-24T10:12:44.118Z"
153
153
  },
154
154
  "oats.okf": {
155
- "from": { "kind": "package", "package": "oats.okf", "version": "4.0.5", "commit": "26d8216f…", "integrity": "sha256-…", "repoKey": "github.com/awebai/oats-okf" },
156
- "commit": "26d8216f…", "digest": "sha256-…", "materializedAt": "2026-09-24T10:12:44.201Z"
155
+ "from": { "kind": "package", "package": "oats.okf", "version": "4.0.7", "commit": "e460b29a…", "integrity": "sha256-…", "repoKey": "github.com/awebai/oats-okf" },
156
+ "commit": "e460b29a…", "digest": "sha256-…", "materializedAt": "2026-09-24T10:12:44.201Z"
157
157
  }
158
158
  },
159
159
  "providers": {
@@ -432,6 +432,12 @@ retained home (plus the usual quarantine marker when hooks reported incomplete
432
432
  cleanup), shows in `oats status` and the Desktop as a failed deferred
433
433
  retirement, and is retried and cleared with `oats retire <instance>`.
434
434
 
435
+ Retire never deletes a branch unless you pass `--delete-branch`, and then
436
+ only the verified branch: not on a quarantine, its retry or `--force`. A
437
+ spawn that fails deletes the branch it created only while the branch's tip
438
+ is still where the spawn created it. If something was committed there, the
439
+ branch is kept and the failure says so.
440
+
435
441
  ## Work modes
436
442
 
437
443
  A work mode decides what `./work` points at and what discipline the agent must
@@ -55,7 +55,7 @@ members: # repo refs, NO @revision (E_WORKSPAC
55
55
 
56
56
  packages: # the ONLY versioned things
57
57
  oats.framework: v1.4.1 # bare version → resolves through the official catalog
58
- oats.okf: v4.0.5
58
+ oats.okf: v4.0.7
59
59
  acme.tools: git:github.com/acme/tools@v0.4.0 # outside the catalog → git:<repo>@<tag|OID>; still a package
60
60
 
61
61
  teams: # SHARED teams: the same provider team for everyone
@@ -103,6 +103,8 @@ export function canonicalJson(value, options) {
103
103
  return chunks.join("");
104
104
  }
105
105
 
106
+ /** The 1-based line of character `offset` in `text`: where a reader's error points. */
107
+ export function lineAt(text, offset) { return text.slice(0, offset).split("\n").length; }
106
108
  /** JSON.parse alone cannot detect duplicate decoded keys. This small JSON
107
109
  * decoder preserves that check and budgets; YAML must use it for JSON input,
108
110
  * not maintain another permissive JSON reader. Objects have null prototypes. */
@@ -114,7 +116,7 @@ export function parseStrictJson(input, options) {
114
116
  }
115
117
  const text = decodeUtf8(data);
116
118
  let at = 0, entries = 0;
117
- const bad = () => { throw oatsError("invalid-declaration", `invalid JSON at character ${at}`); };
119
+ const bad = () => { throw oatsError("invalid-declaration", `invalid JSON at character ${at}`, { offset: at }); };
118
120
  const space = () => { while (at < text.length && /[\x20\t\r\n]/.test(text[at])) at++; };
119
121
  const string = () => {
120
122
  const start = at++;
@@ -3,7 +3,7 @@
3
3
  * Legacy core readers are unchanged until the explicit consumer migration. */
4
4
  import { Composer, CST, Lexer, Parser, isAlias, isMap, isScalar, isSeq } from "yaml";
5
5
  import { bytesIntegrity } from "./digest.mjs";
6
- import { byteView, canonicalJson, dataLimits, decodeUtf8, parseStrictJson } from "./canonical-json.mjs";
6
+ import { byteView, canonicalJson, dataLimits, decodeUtf8, lineAt, parseStrictJson } from "./canonical-json.mjs";
7
7
  import { oatsError } from "./errors.mjs";
8
8
 
9
9
  const pointerKey = (key) => key.replace(/~/g, "~0").replace(/\//g, "~1");
@@ -71,7 +71,9 @@ export function parseConfigData(input, { format = "auto", origin = null, limits:
71
71
  }
72
72
  if (document.errors.length || document.warnings.length || document.directives.yaml.version !== "1.2") {
73
73
  const issue = document.errors[0] ?? document.warnings[0];
74
- throw oatsError("invalid-declaration", `invalid portable YAML${issue ? ` (${issue.code})` : " version"}`);
74
+ const offset = issue?.pos?.[0];
75
+ const line = Number.isInteger(offset) ? lineAt(source, offset) : undefined;
76
+ throw oatsError("invalid-declaration", `invalid portable YAML${issue ? ` (${issue.code}${line ? ` at line ${line}` : ""})` : " version"}`);
75
77
  }
76
78
  const decode = (node, pointer, depth) => {
77
79
  mark(pointer, depth, node);
package/lib/core.mjs CHANGED
@@ -63,7 +63,7 @@ import { validateBindingInterface } from "./provider-binding.mjs";
63
63
  /** Retirement/rollback tree fingerprint (exported for scope tests: kernel-field neutrality is opt-in per instance home). */
64
64
  export { fingerprintTree };
65
65
 
66
- import { canonicalJson, parseStrictJson } from "./canonical-json.mjs";
66
+ import { canonicalJson, lineAt, parseStrictJson } from "./canonical-json.mjs";
67
67
  import { readPortableBytes } from "./bounded-read.mjs";
68
68
  import { copyTreeSafe } from "./tree-copy.mjs";
69
69
  /** The package and capability id grammar (namespaced, lowercase): an id names a
@@ -89,6 +89,10 @@ function capabilityAgentDirs(root) {
89
89
  return entries.filter((e) => e.isDirectory() && !e.name.startsWith(".") && !RESERVED.has(e.name) && !existsSync(join(root, e.name, "soul", "soul.yaml")))
90
90
  .map((e) => ({ name: e.name, dir: join(root, e.name) }));
91
91
  }
92
+ /** The names of the non-hidden directories in `d` ([] when it cannot be read). */
93
+ function subdirs(d) {
94
+ try { return readdirSync(d, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name); } catch { return []; }
95
+ }
92
96
  /** What OATS 0.25 left under <scope>/local-agents/ (and the nested <root>/local-agents|
93
97
  * tmp-agents) for the agents root `root`: one `legacy-local-agents` problem naming the
94
98
  * instance homes there, or null. Names only — nothing there is read, spawned into or
@@ -97,7 +101,6 @@ export function legacyLocalAgents(root) {
97
101
  if (!root) return null;
98
102
  const bases = [join(dirname(root), LEGACY_LOCAL_AGENTS_DIR), join(root, LEGACY_LOCAL_AGENTS_DIR), join(root, "tmp-agents")];
99
103
  const dirs = [], instances = [];
100
- const subdirs = (d) => { try { return readdirSync(d, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name); } catch { return []; } };
101
104
  for (const base of bases) {
102
105
  let st; try { st = lstatSync(base); } catch { continue; }
103
106
  if (!st.isDirectory()) continue;
@@ -109,12 +112,32 @@ export function legacyLocalAgents(root) {
109
112
  return { code: "legacy-local-agents", dirs, instances,
110
113
  message: `${instances.length} instance home${instances.length === 1 ? "" : "s"} under local-agents/ ${instances.length === 1 ? "is" : "are"} from OATS 0.25 and ${instances.length === 1 ? "is" : "are"} not managed by this kernel; retire ${instances.length === 1 ? "it" : "them"} with the 0.25 kernel or delete the directory once ${instances.length === 1 ? "it is" : "they are"} stopped${instances.length ? ` (${instances.join(", ")})` : ""}` };
111
114
  }
115
+ /** The instance homes under the agents root `root` (a directory <root>/<agent>/instances/<name>
116
+ * whose instance.json records that name) that other users on the machine can read or enter (any
117
+ * group or other permission bit): one `home-readable` problem naming them, with the exact chmod,
118
+ * or null. A home holds identity keys, TASK.md and transcripts; spawn creates it 0700. Only
119
+ * reported: the operator decides. */
120
+ export function readableInstanceHomes(root) {
121
+ if (!root) return null;
122
+ const homes = [];
123
+ for (const agent of subdirs(root)) for (const name of subdirs(join(root, agent, "instances"))) {
124
+ const home = join(root, agent, "instances", name);
125
+ try {
126
+ if (JSON.parse(readFileSync(join(home, "instance.json"), "utf8"))?.instance !== name) continue;
127
+ if (lstatSync(home).mode & 0o077) homes.push(home);
128
+ } catch { /* no readable instance.json, or gone meanwhile: not a home */ }
129
+ }
130
+ if (!homes.length) return null;
131
+ homes.sort();
132
+ const fix = `chmod 700 ${homes.map(shq).join(" ")}`;
133
+ return { code: "home-readable", homes, fix,
134
+ message: `${homes.length} instance home${homes.length === 1 ? " can" : "s can"} be read by other users on this machine (${homes.length === 1 ? "it holds" : "they hold"} identity keys, TASK.md and transcripts); fix: ${fix}` };
135
+ }
112
136
  /** The captured homes (the 0.24–0.25 captured/portable path, removed in 0.26) under the
113
137
  * agents root `root`: one `legacy-captured-home` problem naming them, or null. They have
114
138
  * no 0.26 runtime (start, inspect and in-home commands refuse them); retire still works. */
115
139
  export function legacyCapturedHomes(root) {
116
140
  if (!root) return null;
117
- const subdirs = (d) => { try { return readdirSync(d, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith(".")).map((e) => e.name); } catch { return []; } };
118
141
  const homes = [];
119
142
  for (const agent of subdirs(root)) for (const inst of subdirs(join(root, agent, "instances"))) {
120
143
  const home = join(root, agent, "instances", inst);
@@ -883,11 +906,21 @@ function readCatalogFile() {
883
906
  // catalog belongs to the kernel install.
884
907
  const override = !!process.env.OATS_PACKAGE_CATALOG;
885
908
  if (!existsSync(file)) { if (override) recordLocalInput(file, null); return empty; }
886
- let doc;
887
909
  const text = readFileSync(file, "utf8");
888
910
  if (override) recordLocalInput(file, text);
911
+ return parsePackageCatalog(text, file);
912
+ }
913
+ /** A package catalog's text, parsed and checked as the kernel reads it (named `file` in errors):
914
+ * { packages, capabilities, file }, both maps null-prototype; invalid-source when it is broken. */
915
+ export function parsePackageCatalog(text, file) {
916
+ let doc;
889
917
  try { doc = JSON.parse(text); }
890
- catch (e) { throw oatsError("invalid-source", `broken package catalog ${file}: ${e.message}`); }
918
+ catch (e) {
919
+ // JSON.parse does not say where; the kernel's strict reader (same grammar) locates the error.
920
+ let line;
921
+ try { parseStrictJson(text, { maxBytes: 64 * 1024 * 1024 }); } catch (s) { if (Number.isInteger(s.provenance?.offset)) line = lineAt(text, s.provenance.offset); }
922
+ throw oatsError("invalid-source", `broken package catalog ${file}${line ? ` (line ${line})` : ""}: ${e.message}`);
923
+ }
891
924
  if (!doc || typeof doc !== "object" || Array.isArray(doc)) throw oatsError("invalid-source", `broken package catalog ${file}: root must be a JSON object`);
892
925
  const out = { packages: Object.create(null), capabilities: Object.create(null), file };
893
926
  const packages = doc.packages;
@@ -2082,6 +2115,12 @@ export function upgradeHomeMeta(meta, home) {
2082
2115
  return { ...rest, ...(runtime !== undefined || meta.harness !== undefined ? { harness: meta.harness ?? runtime } : {}), ...(meta.launch !== undefined ? { launch: upgradeLaunchRecipe(meta.launch) } : {}) };
2083
2116
  }
2084
2117
  const LAUNCH_PROMPT = { kind: "task-file", file: "TASK.md" };
2118
+ /** The fixed prompt a codex launch starts with: it names the task file, never its text (argv is
2119
+ * readable by every local user). Codex reads the file with a tool. */
2120
+ export const CODEX_TASK_PROMPT = "Read TASK.md in this directory first: it is your briefing and your task.";
2121
+ /** The prompt each harness gets, so the task's text never travels in argv: Claude Code inlines an
2122
+ * `@file` mention; codex is pointed at the file. pi takes `@TASK.md` among its own arguments. */
2123
+ const TASK_PROMPT = Object.freeze({ claude: "@TASK.md", codex: CODEX_TASK_PROMPT });
2085
2124
 
2086
2125
  /** The executable a launch uses: a configuration's declared one (a bare name
2087
2126
  * on PATH; a path against the deployment directory when relative) or the
@@ -2362,7 +2401,7 @@ export function renderLaunchRecipe(recipe, { home, instance, redact = false, tru
2362
2401
  }
2363
2402
  let cmdline;
2364
2403
  if (harness === "claude") {
2365
- cmdline = `${shq(executable)}${yolo ? " --dangerously-skip-permissions" : ""}${model ? ` --model ${shq(model)}` : ""}${tail} -- "$(cat TASK.md)"`;
2404
+ cmdline = `${shq(executable)}${yolo ? " --dangerously-skip-permissions" : ""}${model ? ` --model ${shq(model)}` : ""}${tail} -- ${shq(TASK_PROMPT.claude)}`;
2366
2405
  } else if (harness === "codex") {
2367
2406
  const codexTrust = `projects={${JSON.stringify(realPathOrNearest(home))}={trust_level="trusted"}}`;
2368
2407
  // Codex may run tool commands outside the session's process (its shared app-server daemon),
@@ -2370,7 +2409,7 @@ export function renderLaunchRecipe(recipe, { home, instance, redact = false, tru
2370
2409
  // never goes on argv, and PATH is the execution's (CODEX_TOOL_PATH: the shim first).
2371
2410
  const toolEnvArgs = env.filter((e) => !e.reference && e.name !== "PATH")
2372
2411
  .map((e) => ` -c ${shq(`shell_environment_policy.set.${e.name}=${JSON.stringify(e.value)}`)}`).join("");
2373
- cmdline = `${shq(executable)} --cd ${shq(home)} -c check_for_update_on_startup=false${yolo ? " --yolo" : ""}${yolo || trustHome ? ` -c ${shq(codexTrust)}` : ""}${toolEnvArgs}${model ? ` --model ${shq(model)}` : ""}${tail} -- "$(cat TASK.md)"`;
2412
+ cmdline = `${shq(executable)} --cd ${shq(home)} -c check_for_update_on_startup=false${yolo ? " --yolo" : ""}${yolo || trustHome ? ` -c ${shq(codexTrust)}` : ""}${toolEnvArgs}${model ? ` --model ${shq(model)}` : ""}${tail} -- ${shq(TASK_PROMPT.codex)}`;
2374
2413
  } else {
2375
2414
  // Decision 13: pi starts NORMALLY — its own skill discovery (~/.pi/agent/skills,
2376
2415
  // .agents/skills up the tree, so the instance's copied capability skills are
@@ -2387,7 +2426,7 @@ export function renderLaunchRecipe(recipe, { home, instance, redact = false, tru
2387
2426
  * with the home's kernel shim first on PATH (every launch runs through here). */
2388
2427
  function nativeRecordCommand(command, home, harness) {
2389
2428
  const { tokens, binary } = parseLaunchCommand(command);
2390
- const args = tokens.slice(binary + 1).filter(t => t.kind !== "prompt").map(t => t.value ?? t.text);
2429
+ const args = tokens.slice(binary + 1).map(t => t.value ?? t.text);
2391
2430
  const id = prepareNativeStart(home, harness);
2392
2431
  const recorder = join(PKG_ROOT, "packages", "record", "bin", "record-native-start.mjs");
2393
2432
  const inner = `${shq(process.execPath)} ${shq(recorder)} ${shq(home)} ${shq(id)} ${shq(harness)} ${shq(JSON.stringify(args))} && exec ${executionArgv(tokens, binary, harness).join(" ")}`;
@@ -3102,7 +3141,8 @@ function* spawnBody(root, agent, o = {}) {
3102
3141
  // apply of the same decision, or anything else) got here first, and this one
3103
3142
  // has touched nothing.
3104
3143
  mkdirSync(dirname(home), { recursive: true });
3105
- try { mkdirSync(home); }
3144
+ // 0700: the home holds the instance's identity keys, TASK.md and transcripts.
3145
+ try { mkdirSync(home, { mode: 0o700 }); }
3106
3146
  catch (e) {
3107
3147
  if (e?.code === "EEXIST") throw Object.assign(oatsError("E_PLACEMENT_TAKEN", `${instance} already exists at ${home} (a concurrent spawn won the placement); nothing was created by this call`), { instance, home });
3108
3148
  throw e;
@@ -3355,11 +3395,7 @@ function* spawnBody(root, agent, o = {}) {
3355
3395
  const list = run(["git", "-C", repoAbs, "worktree", "list", "--porcelain", "-z"]);
3356
3396
  if (!list.ok) incomplete.push(`git worktree ${wt}: could not verify removal (${list.err || "worktree list failed"})`);
3357
3397
  else incomplete.push(`git worktree ${wt}: could not verify removal (canonical path unavailable after add)`);
3358
- const del = run(["git", "-C", repoAbs, "branch", "-D", branch]);
3359
- if (!del.ok) incomplete.push(`git branch ${branch}: deletion failed (${del.err || `exit ${del.status}`})`);
3360
- const ref = run(["git", "-C", repoAbs, "rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]);
3361
- if (ref.ok) incomplete.push(`git branch ${branch}: still exists`);
3362
- else if (ref.status !== 1 || ref.err) incomplete.push(`git branch ${branch}: could not verify deletion (${ref.err || `exit ${ref.status}`})`);
3398
+ incomplete.push(...deleteBranchAsCreated(run, repoAbs, branch, plannedBase.oid));
3363
3399
  }
3364
3400
  try { rmSync(home, { recursive: true, force: true }); } catch (e2) { incomplete.push(`instance home ${home}: ${e2.message}`); }
3365
3401
  const note = incomplete.length ? ` — rollback INCOMPLETE — clean up manually: ${incomplete.join("; ")}` : "";
@@ -3536,10 +3572,8 @@ function* spawnBody(root, agent, o = {}) {
3536
3572
  if (worktreeCanonical && registered.includes(worktreeCanonical)) { incomplete.push(`git worktree ${worktreeCanonical}: still registered`); outstandingGit.add("worktree"); }
3537
3573
  }
3538
3574
  if (branch) {
3539
- probe(["git", "-C", repoAbs, "branch", "-D", branch]);
3540
- const brProbe = probe(["git", "-C", repoAbs, "rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]);
3541
- if (brProbe.ok) { incomplete.push(`git branch ${branch}: still exists`); outstandingGit.add("branch"); }
3542
- else if (brProbe.status !== 1 || brProbe.err) { incomplete.push(`git branch ${branch}: could not verify deletion (${brProbe.err || `rev-parse exit ${brProbe.status}`})`); outstandingGit.add("branch"); }
3575
+ const branchDebt = deleteBranchAsCreated(probe, repoAbs, branch, plannedBase.oid);
3576
+ if (branchDebt.length) { incomplete.push(...branchDebt); outstandingGit.add("branch"); }
3543
3577
  }
3544
3578
  }
3545
3579
  // Any attempted spawn hook may have created state before a later hook (or
@@ -3606,7 +3640,7 @@ You are instance "${instance}" of agent "${agent.name}".
3606
3640
  - Home: ${home}
3607
3641
  - Work tree: ./work — ${workDesc}
3608
3642
  - Do all repository work inside ./work. Read ./work/AGENTS.md or ./work/CLAUDE.md first if present.${briefLines}${harness === "codex" ? "\n## Harness notification delivery\n\nNative Codex has no built-in messaging channel. Follow the explicit delivery briefing for this instance from your messaging capability, if present; it may arrange notification through this terminal. Shared channel instructions alone do not establish that delivery is configured. Without an instance delivery briefing, check your messaging capability's inbox and pending commands at task boundaries or when the operator asks; do not assume messages will wake this session.\n" : ""}
3609
- ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spawn time — await instructions.\n"}`);
3643
+ ${task.trim() ? `\n## Task\n\n${task.trim()}\n` : "\nNo task was provided at spawn time — await instructions.\n"}`, { mode: 0o600 }); // human text: the owner's only
3610
3644
 
3611
3645
  // Launch command. Spawn IS session start: the recipe is persisted in
3612
3646
  // instance.json beside its rendering, which is executed in the instance's
@@ -4074,6 +4108,17 @@ function sessionDirectoryGuard(home) {
4074
4108
  return check;
4075
4109
  }
4076
4110
 
4111
+ /** A failed spawn's compare-and-delete of the branch it created at `oid`: `git update-ref -d` refuses
4112
+ * atomically if the tip moved (something was committed there), and the branch is then kept. `run`
4113
+ * answers { ok, out, status, err }. → what is still owed, as messages ([] when the branch is gone). */
4114
+ function deleteBranchAsCreated(run, repoAbs, branch, oid) {
4115
+ const del = run(["git", "-C", repoAbs, "update-ref", "-d", `refs/heads/${branch}`, oid]);
4116
+ const ref = run(["git", "-C", repoAbs, "rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]);
4117
+ if (ref.ok && ref.out.trim() !== oid) return [`git branch ${branch}: kept; its tip moved from ${oid.slice(0, 12)}, where this spawn created it`];
4118
+ if (ref.ok) return [`git branch ${branch}: still exists${del.ok ? "" : ` (deletion failed: ${del.err || `exit ${del.status}`})`}`];
4119
+ if (ref.status !== 1 || ref.err) return [`git branch ${branch}: could not verify deletion (${ref.err || `exit ${ref.status}`})`];
4120
+ return [];
4121
+ }
4077
4122
  /** Retain the home and its cleanup receipt when spawn compensation or retirement
4078
4123
  * cannot finish. Keeping the original credentials makes cleanup retryable. */
4079
4124
  function quarantineInstanceHome({ home, instance, agent, soulDir, soulId, incomplete, failed, outstandingHooks, outstandingGit, repoAbs, work, branch, resolvedCfg, hookMeta, compensationMeta, launched, tmux, recordRetirementBaseline = false, reason, directoryPreservation = false, directoryHome = realPathOrNearest(home) }) {
@@ -4564,8 +4609,18 @@ export function inputInstanceSession(home, text) {
4564
4609
 
4565
4610
  // ---------------------------------------------------------------- session start
4566
4611
 
4567
- /** The exact prompt token spawn renders for claude and codex launches. */
4612
+ /** The prompt token of a recorded claude or codex command that hands the harness the task's own
4613
+ * text in argv, where any local user can read it: `"$(cat TASK.md)"`. It is parsed, and replaced
4614
+ * when the home starts (withSafeTaskPrompt). */
4568
4615
  const LAUNCH_PROMPT_TOKEN = '"$(cat TASK.md)"';
4616
+ /** A recorded command with its harness's safe task prompt: a `"$(cat TASK.md)"` token becomes
4617
+ * TASK_PROMPT[harness]. Applied when a home starts from its recorded command, which is saved so. */
4618
+ export function withSafeTaskPrompt(command, harness) {
4619
+ const { tokens } = parseLaunchCommand(command);
4620
+ if (!tokens.some((t) => t.kind === "prompt")) return command;
4621
+ if (!Object.hasOwn(TASK_PROMPT, harness)) throw oatsError("E_LAUNCH_COMMAND_UNSUPPORTED", `a ${harness} command never carried the task as "$(cat TASK.md)"; inspect the saved command in instance.json`);
4622
+ return renderLaunchCommand(tokens.map((t) => t.kind === "prompt" ? { kind: "word", value: TASK_PROMPT[harness], quoted: true, text: shq(TASK_PROMPT[harness]) } : t));
4623
+ }
4569
4624
 
4570
4625
  /** Tokenize a persisted OATS launch command. The grammar is exactly what
4571
4626
  * spawn renders: space-separated tokens that are env assignments NAME='v',
@@ -5004,6 +5059,8 @@ export function startInstanceSession(home, o = {}) {
5004
5059
  command = withLaunchModel(command, resolved);
5005
5060
  model = resolved; explicitModelFrom = "start";
5006
5061
  } else parseLaunchCommand(command);
5062
+ // A recorded command starts, and is saved, with its harness's safe task prompt.
5063
+ if (!launchPlan) command = withSafeTaskPrompt(command, harness);
5007
5064
  // References recorded for this home must resolve on this host on every
5008
5065
  // start path, and the source variables go to the pane, not the command.
5009
5066
  const recipeForEnv = launchPlan?.recipe || (meta.launch && typeof meta.launch === "object" ? meta.launch : null);
@@ -5098,7 +5155,7 @@ export function startInstanceSession(home, o = {}) {
5098
5155
  }
5099
5156
  target = { backend: "tmux", session, window, socket: resolve(socket) };
5100
5157
  // Keep launch evidence until the command exits or the target disappears.
5101
- // A transient child (for example cat TASK.md) is not proof that startup
5158
+ // A transient child (for example the native-start recorder) is not proof that startup
5102
5159
  // has finished. A later start reconciles the receipt without a watcher.
5103
5160
  try { return { ...record(meta, { id, target, model, command, startedAt, reused, ...planExtra, ...(stopReceipt ? { stop: stopReceipt } : {}) }, false), warnings }; }
5104
5161
  catch (e) {
@@ -5886,7 +5943,6 @@ export function retireInstance(root, name, o = {}) {
5886
5943
  // Otherwise the home — and the credentials in it — must survive again, or the
5887
5944
  // retry becomes the deletion the quarantine was preventing.
5888
5945
  let stillIncomplete;
5889
- let quarantineBranchDeleted = false;
5890
5946
  // Ordinary path: quarantine instead of deleting, using the SAME writer the
5891
5947
  // spawn rollback uses. Two copies of this logic is how a previous divergence
5892
5948
  // happened (see quarantineInstanceHome), so there is still exactly one.
@@ -5911,9 +5967,8 @@ export function retireInstance(root, name, o = {}) {
5911
5967
  if (quarantine) {
5912
5968
  const failures = (hookResults?.failures || []).map((f) => `retire hook ${f.capability}: ${f.message}`);
5913
5969
  // The quarantine may exist BECAUSE Git cleanup failed, so a retry has to
5914
- // redo those steps and verify them — not just rerun hooks. The branch is
5915
- // rollback-owned (spawn created it), so it is deleted here without needing
5916
- // the normal-retire --delete-branch flag, and any failure keeps the home.
5970
+ // redo those steps and verify them — not just rerun hooks. A branch is never
5971
+ // deleted here: only --delete-branch deletes one (the verified branch, above).
5917
5972
  if (meta.work === "worktree" && meta.repo) {
5918
5973
  const gitProbe = (argv) => {
5919
5974
  try { return { ok: true, out: execFileSync(argv[0], argv.slice(1), { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }) }; }
@@ -5928,14 +5983,25 @@ export function retireInstance(root, name, o = {}) {
5928
5983
  const registered = wtProbe.out.split("\0").filter((f) => f.startsWith("worktree ")).map((f) => f.slice("worktree ".length));
5929
5984
  if (registered.includes(wtCanonical)) failures.push(`git worktree ${wtCanonical}: still registered`);
5930
5985
  }
5931
- if (meta.branch) {
5932
- gitProbe(["git", "-C", meta.repo, "branch", "-D", meta.branch]);
5933
- const br = gitProbe(["git", "-C", meta.repo, "rev-parse", "--verify", "--quiet", `refs/heads/${meta.branch}`]);
5934
- if (br.ok) failures.push(`git branch ${meta.branch}: still exists`);
5935
- else if (br.status !== 1 || br.err) failures.push(`git branch ${meta.branch}: could not verify deletion (${br.err || `rev-parse exit ${br.status}`})`);
5936
- // Verified gone: the result must say so, or --json misreports the very
5937
- // cleanup this path just performed.
5938
- else quarantineBranchDeleted = true;
5986
+ // The branch is a debt only when the failed spawn's rollback still owes its deletion, or when the
5987
+ // operator asked for it (--delete-branch); then it must be verified gone, and a branch kept is said.
5988
+ // --delete-branch: the branch the documented path deleted (the verified one) must be gone. A failed
5989
+ // spawn's quarantine that owes its recorded branch keeps it unless that branch was the one deleted.
5990
+ const verify = (branch) => {
5991
+ const br = gitProbe(["git", "-C", meta.repo, "rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]);
5992
+ if (br.ok) return "exists";
5993
+ if (br.status !== 1 || br.err) { failures.push(`git branch ${branch}: could not verify whether it still exists (${br.err || `rev-parse exit ${br.status}`})`); return "unknown"; }
5994
+ return "gone";
5995
+ };
5996
+ const deleted = retention?.branchDeleted;
5997
+ if (deleted && verify(deleted) === "exists") failures.push(`git branch ${deleted}: still exists`);
5998
+ const owesBranch = (quarantine.cleanup.outstanding?.git || []).includes("branch");
5999
+ if (owesBranch && meta.branch && meta.branch !== deleted && verify(meta.branch) === "exists") {
6000
+ // Without a home left to retry from (--force), or when --delete-branch verified another branch, the
6001
+ // recorded branch is the operator's to delete by hand.
6002
+ failures.push(o.force || o.deleteBranch
6003
+ ? `git branch ${meta.branch}: kept; the failed spawn created it; delete it with git branch -D ${meta.branch} if unwanted`
6004
+ : `git branch ${meta.branch}: kept; the failed spawn created it; pass --delete-branch to delete it`);
5939
6005
  }
5940
6006
  }
5941
6007
  for (const [capId, m] of Object.entries(hookResults?.meta || {})) {
@@ -5987,7 +6053,7 @@ export function retireInstance(root, name, o = {}) {
5987
6053
  rmSync(deferredRetireResultPath(found.home).replace(/\.json$/, ".log"), { force: true });
5988
6054
  }
5989
6055
 
5990
- const result = { retired: name, agent: found.agent.name, workRecovery, workRecoveries: workRecoveries.length > 1 ? workRecoveries : undefined, retention, worktreeRemoved: isWorktree && retention?.worktree !== "retained", branchDeleted: !!(retention?.branchDeleted) || quarantineBranchDeleted, removedDir: !o.keepDir && (!stillIncomplete || forced), rollbackIncomplete: forced ? undefined : stillIncomplete, forcedIncomplete: forced ? stillIncomplete : undefined, retainedHome: stillIncomplete && !forced ? found.home : undefined, relinked: relinked.length ? relinked : undefined, capabilityMeta: hookResults?.meta, warnings: (() => {
6056
+ const result = { retired: name, agent: found.agent.name, workRecovery, workRecoveries: workRecoveries.length > 1 ? workRecoveries : undefined, retention, worktreeRemoved: isWorktree && retention?.worktree !== "retained", branchDeleted: !!(retention?.branchDeleted), removedDir: !o.keepDir && (!stillIncomplete || forced), rollbackIncomplete: forced ? undefined : stillIncomplete, forcedIncomplete: forced ? stillIncomplete : undefined, retainedHome: stillIncomplete && !forced ? found.home : undefined, relinked: relinked.length ? relinked : undefined, capabilityMeta: hookResults?.meta, warnings: (() => {
5991
6057
  const w = [...(hookResults?.warnings || [])];
5992
6058
  if (isCapturedHome(meta) && !quarantine) {
5993
6059
  // A captured home retires through the workspace path; its captured retire hooks do not
package/lib/workspace.mjs CHANGED
@@ -334,8 +334,9 @@ function schemaHint(kind, value) {
334
334
  }
335
335
  return "";
336
336
  }
337
- /** Parse + validate a declaration file → { value } | { problems }. Never throws. */
338
- function readDeclaration(kind, bytes, origin, validateOptions) {
337
+ /** Parse + validate a declaration file (`kind` workspace | membership | soul | local) → { value } |
338
+ * { problems }. Never throws. The reader `oats sync` uses, exported for the repository's validate. */
339
+ export function readDeclaration(kind, bytes, origin, validateOptions) {
339
340
  const decoded = decodeDocument(bytes, origin);
340
341
  if (decoded.problems) return decoded;
341
342
  const problems = FILE_KINDS[kind].validate(decoded.value, validateOptions);
@@ -3,12 +3,12 @@
3
3
  "packages": {
4
4
  "oats.okf": {
5
5
  "url": "https://github.com/awebai/oats-okf.git",
6
- "ref": "v4.0.5",
6
+ "ref": "v4.0.7",
7
7
  "path": "oats-package"
8
8
  },
9
9
  "oats.aweb": {
10
10
  "url": "https://github.com/awebai/oats-aweb.git",
11
- "ref": "v1.17.5",
11
+ "ref": "v1.17.7",
12
12
  "path": "oats-package"
13
13
  },
14
14
  "oats.jira": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@awebai/oats",
3
- "version": "0.34.1",
3
+ "version": "0.34.3",
4
4
  "description": "OATS (Open Agent Team Specification) — durable souls, disposable instances, targetable capability packages, and the runtime-neutral oats CLI/kernel.",
5
5
  "keywords": [
6
6
  "agents",
@@ -197,6 +197,27 @@ Journal writes fsync; note that on macOS `fsync(2)` does not guarantee media
197
197
  durability (that would need `F_FULLFSYNC`, which Node's fs API does not
198
198
  expose) — the guarantee is OS-crash-level, not power-loss-level.
199
199
 
200
+ **One capture pass at a time.** A pass takes the record root's
201
+ `.capture.lock` directory, whose `owner.json` records the pid, a nonce, the
202
+ start time and the host. A pass that finds the lock held skips (the next
203
+ pass catches up). A pass that is killed (a hook or caller timeout) runs no
204
+ cleanup, so the next pass reclaims a lock whose recorded owner is dead on
205
+ this host:
206
+
207
+ - Reclaimers are serialized by a guard, `.capture.lock.reclaim`, taken by
208
+ exclusive create. Under it the record is read again and removed only if it
209
+ still belongs to that dead owner.
210
+ - A live or unknowable owner, an owner-less (initializing) lock and another
211
+ host's lock are never touched.
212
+ - A guard left by a reclaimer that died is never removed. It is named, with
213
+ the exact recovery.
214
+ - Records that name no host predate host recording and live under this
215
+ user's home, so they count as this host's: a dead owner's lock is
216
+ reclaimed too. On a home shared across machines (NFS, or a synced
217
+ directory), such a record may belong to another host, whose pid means
218
+ nothing here; check that no capture runs on the other machines before the
219
+ first pass after upgrading.
220
+
200
221
  ## Upgrading
201
222
 
202
223
  The derived index self-heals across schema changes by wiping and
@@ -183,6 +183,9 @@ function withCaptureLock(fn) {
183
183
  let lock;
184
184
  try {
185
185
  lock = acquireCaptureLock(root);
186
+ // Said by the pass that removed it, never quiet, whether or not it then took the lock: a pass that
187
+ // died holding the lock is worth knowing about.
188
+ if (lock.reclaimed) console.error(`capture: reclaimed ${lock.path} from pid ${lock.reclaimed.pid}, which died (started ${lock.reclaimed.startedAt || "?"})`);
186
189
  } catch (err) {
187
190
  if (err.lockCleanup) {
188
191
  const c = err.lockCleanup;
@@ -4,18 +4,33 @@
4
4
  // and the next pass catches up, since reconciliation is idempotent.
5
5
  //
6
6
  // The lock is a DIRECTORY: mkdir is atomic and a directory is never
7
- // observable half-created. The owner record (pid, start time) is written
8
- // inside it after the mkdir. Nothing here ever steals a lock: any existing
9
- // lock, live, dead, unknowable or still initializing, refuses the pass and
10
- // names the holder and the operator recovery. A stale lock after a killed
11
- // pass is removed by the operator once the pid is verified gone; the
12
- // message says exactly that. (A reclaim protocol was reviewed and rejected:
13
- // rename is not compare-and-swap, and stealing from a stalled live
14
- // initializer under memory pressure is the failure we are preventing.)
15
- import { closeSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
7
+ // observable half-created. The owner record (pid, nonce, start time, host)
8
+ // is written inside it after the mkdir. A live, unknowable or still
9
+ // initializing (owner-less) lock refuses the pass and names the holder and
10
+ // the operator recovery; it is never stolen.
11
+ //
12
+ // A lock whose recorded owner is DEAD on this host is reclaimed (a capture
13
+ // killed mid-pass by a hook or caller timeout runs no finally). Records
14
+ // without host predate host recording and live under this user's home, so
15
+ // they count as this host's (RECLAIM_HOSTLESS_RECORDS). Reclaimers
16
+ // are serialized by a guard, `<lock>.reclaim` (exclusive create): under it
17
+ // the owner record is read again and the lock removed only while it is still
18
+ // that dead owner's, so a reclaimer cannot remove a lock a live pass took
19
+ // meanwhile. A guard whose holder died is never removed (that would race
20
+ // exactly as removing the lock does); it is named with its recovery.
21
+ // Acquire never waits: it reclaims once or skips, and the next pass catches up.
22
+ // There is no signal handler: a pass is synchronous, so a JS handler would
23
+ // only run after the whole pass (turning a caller's timeout kill into a full
24
+ // pass), and SIGKILL cannot be handled; the reclaim is the recovery.
25
+ import { closeSync, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
16
26
  import { randomBytes } from "node:crypto";
27
+ import { hostname } from "node:os";
17
28
  import { join } from "node:path";
18
29
 
30
+ /** Whether an owner record that names no host is reclaimed when its pid is dead here: yes. Records without
31
+ * host predate host recording and live under this user's home, so they are this host's. */
32
+ export const RECLAIM_HOSTLESS_RECORDS = true;
33
+
19
34
  export function captureLockPath(root) { return join(root, ".capture.lock"); }
20
35
 
21
36
  /** "alive" | "dead" | "unknown" for an owner pid ("unknown" = exists but not signalable). */
@@ -24,8 +39,38 @@ export function holderLiveness(pid) {
24
39
  try { process.kill(pid, 0); return "alive"; } catch (e) { return e.code === "EPERM" ? "unknown" : "dead"; }
25
40
  }
26
41
 
27
- function readOwner(dir) {
28
- try { return JSON.parse(readFileSync(join(dir, "owner.json"), "utf8")); } catch { return undefined; }
42
+ const readJson = (path) => { try { return JSON.parse(readFileSync(path, "utf8")); } catch { return undefined; } };
43
+ const readOwner = (dir) => readJson(join(dir, "owner.json"));
44
+
45
+ /** Whether `owner` is a record of this host whose process is dead: the only lock this module reclaims. */
46
+ function deadHere(owner, { host, liveness, reclaimHostless }) {
47
+ if (!owner || !Number.isInteger(owner.pid)) return false;
48
+ const here = owner.host === undefined ? reclaimHostless : owner.host === host;
49
+ return here && liveness(owner.pid) === "dead";
50
+ }
51
+
52
+ /** Remove the lock `dir` held by the dead `owner`, serialized by the guard `<dir>.reclaim`. → { removed }
53
+ * (true only when THIS call removed it; otherwise it is left for the next pass), or { abandoned: { guard,
54
+ * pid } } when a reclaimer died holding the guard. */
55
+ function reclaimDeadLock(dir, owner, me, opts) {
56
+ const guard = `${dir}.reclaim`;
57
+ try { writeFileSync(guard, JSON.stringify(me), { flag: "wx", mode: 0o600 }); }
58
+ catch (e) {
59
+ if (e.code !== "EEXIST") return { removed: false };
60
+ const g = readJson(guard);
61
+ return g && deadHere(g, opts) ? { abandoned: { guard, pid: g.pid } } : { removed: false };
62
+ }
63
+ const same = (o) => o && o.pid === owner.pid && o.nonce === owner.nonce && o.startedAt === owner.startedAt;
64
+ let removed = false;
65
+ try {
66
+ const now = readOwner(dir);
67
+ if (same(now) && deadHere(now, opts)) {
68
+ rmSync(dir, { recursive: true, force: true });
69
+ removed = !existsSync(dir) || !same(readOwner(dir));
70
+ }
71
+ } catch { /* the next pass */ }
72
+ finally { if (readJson(guard)?.nonce === me.nonce) { try { unlinkSync(guard); } catch { /* gone */ } } }
73
+ return { removed };
29
74
  }
30
75
 
31
76
  /** Single-quote shell escaping: safe to paste whatever the path contains. */
@@ -40,9 +85,13 @@ export function recoveryInstruction(dir, owner, liveness) {
40
85
  return `${dir} is held by ${who}; if that process is gone (ps -p ${owner.pid}), remove the lock with: ${remove} and rerun`;
41
86
  }
42
87
 
43
- /** Try to take the root's capture lock. Returns { path, release } when
44
- * taken, or { path, held: { pid, startedAt, liveness, recovery } } when any
45
- * lock exists. Never removes a lock it did not create.
88
+ /** Try to take the root's capture lock. Returns { path, release, reclaimed? }
89
+ * when taken, or { path, held: { pid, startedAt, liveness, recovery, guard? },
90
+ * reclaimed? } when a lock is held. A lock it did not create is removed only
91
+ * when its recorded owner is dead on this host, under the reclaim guard (the
92
+ * header); `reclaimed: { pid, startedAt }` says THIS call removed it (whether
93
+ * or not it then won the lock), and `held.guard` names a guard a dead
94
+ * reclaimer left. Every other lock is left alone.
46
95
  *
47
96
  * Two failure points are reported rather than left behind. If the owner
48
97
  * record cannot be written after THIS call created the directory (a full
@@ -59,22 +108,39 @@ export function recoveryInstruction(dir, owner, liveness) {
59
108
  * The owner record carries a per-acquisition nonce, so a release kept from
60
109
  * an earlier acquisition cannot erase a later one by the same pid (an
61
110
  * operator recovery followed by a new pass in the same long-lived process).
62
- * That is ownership checking; no lock is ever reclaimed.
63
111
  *
64
112
  * `io` exists for fault injection in tests only. */
65
- export function acquireCaptureLock(root, { now = Date.now, pid = process.pid, liveness = holderLiveness, io = {} } = {}) {
113
+ export function acquireCaptureLock(root, { now = Date.now, pid = process.pid, liveness = holderLiveness, host = hostname(), reclaimHostless = RECLAIM_HOSTLESS_RECORDS, io = {} } = {}) {
66
114
  const fs = { writeFileSync, rmSync, openSync, closeSync, lstatSync, ...io };
67
115
  const dir = captureLockPath(root);
116
+ const nonce = randomBytes(8).toString("hex");
68
117
  mkdirSync(root, { recursive: true }); // the store creates the root lazily; the lock may come first
69
- try {
70
- mkdirSync(dir);
71
- } catch (e) {
72
- if (e.code !== "EEXIST") throw e;
73
- const owner = readOwner(dir);
74
- const live = owner ? (owner.pid === pid ? "alive" : liveness(owner.pid)) : "unknown";
75
- return { path: dir, held: { pid: owner?.pid, startedAt: owner?.startedAt, liveness: live, recovery: recoveryInstruction(dir, owner, live) } };
118
+ let reclaimed;
119
+ for (let attempt = 0; ; attempt++) {
120
+ try { mkdirSync(dir); break; }
121
+ catch (e) {
122
+ if (e.code !== "EEXIST") throw e;
123
+ const owner = readOwner(dir);
124
+ const opts = { host, liveness, reclaimHostless };
125
+ if (attempt === 0 && owner?.pid !== pid && deadHere(owner, opts)) {
126
+ const r = reclaimDeadLock(dir, owner, { pid, nonce, host }, opts);
127
+ if (r.abandoned) {
128
+ const { guard, pid: reclaimer } = r.abandoned;
129
+ return { path: dir, held: { pid: owner.pid, startedAt: owner.startedAt, liveness: "dead", guard,
130
+ recovery: `${guard} was left by pid ${reclaimer}, which died while reclaiming ${dir}; once no capture process is running (pgrep -f capture.mjs), remove both with: rm -- ${shellQuote(guard)}; rm -r -- ${shellQuote(dir)} and rerun` } };
131
+ }
132
+ // Gone, whoever removed it (another reclaimer may have): try the lock once more.
133
+ if (r.removed) reclaimed = { pid: owner.pid, startedAt: owner.startedAt };
134
+ if (r.removed || !existsSync(dir)) continue;
135
+ }
136
+ const now = readOwner(dir);
137
+ // Gone between the mkdir and this read (a release or a reclaim): try once more. A directory that
138
+ // exists without a record is initializing or mid-removal, and is reported so.
139
+ if (!now && attempt === 0 && !existsSync(dir)) continue;
140
+ const live = now ? (now.pid === pid ? "alive" : liveness(now.pid)) : "unknown";
141
+ return { path: dir, ...(reclaimed ? { reclaimed } : {}), held: { pid: now?.pid, startedAt: now?.startedAt, liveness: live, recovery: recoveryInstruction(dir, now, live) } };
142
+ }
76
143
  }
77
- const nonce = randomBytes(8).toString("hex");
78
144
  let directoryFd, identity;
79
145
  try {
80
146
  // Keep the directory alive until initialization or its cleanup finishes.
@@ -82,7 +148,7 @@ export function acquireCaptureLock(root, { now = Date.now, pid = process.pid, li
82
148
  // a record-less replacement look like the directory we created.
83
149
  directoryFd = fs.openSync(dir, "r");
84
150
  identity = fstatSync(directoryFd);
85
- fs.writeFileSync(join(dir, "owner.json"), JSON.stringify({ pid, nonce, startedAt: new Date(now()).toISOString() }));
151
+ fs.writeFileSync(join(dir, "owner.json"), JSON.stringify({ pid, nonce, startedAt: new Date(now()).toISOString(), host }));
86
152
  } catch (err) {
87
153
  // Ownership was proven by the mkdir, not by the moment of cleanup: the
88
154
  // directory is removed only if it is still ours (same inode) and holds
@@ -109,6 +175,7 @@ export function acquireCaptureLock(root, { now = Date.now, pid = process.pid, li
109
175
  }
110
176
  return {
111
177
  path: dir,
178
+ ...(reclaimed ? { reclaimed } : {}),
112
179
  release: () => {
113
180
  if (!existsSync(dir)) return { released: false, reason: "gone" };
114
181
  const cur = readOwner(dir);
@@ -61,7 +61,7 @@ members:
61
61
  - git:github.com/acme/platform
62
62
  packages:
63
63
  oats.framework: v1.4.1 # bare versions resolve through the official catalog
64
- oats.okf: v4.0.5
64
+ oats.okf: v4.0.7
65
65
  defaults:
66
66
  capabilities: { oats.core: { from: package } }
67
67
  knowledge: { oats.okf: { from: package } }