@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 +31 -18
- package/docs/capabilities.md +8 -2
- package/docs/desktop-cli-api.md +11 -7
- package/docs/execution-targets.md +14 -2
- package/docs/implementation.md +1 -1
- package/docs/integrations.md +2 -2
- package/docs/knowledge.md +3 -2
- package/docs/official-catalog.md +3 -3
- package/docs/packages.md +10 -10
- package/docs/release-notes/v0.34.2.md +68 -0
- package/docs/release-notes/v0.34.3.md +48 -0
- package/docs/souls-and-instances.md +9 -3
- package/docs/workspaces.md +1 -1
- package/lib/canonical-json.mjs +3 -1
- package/lib/config-data.mjs +4 -2
- package/lib/core.mjs +100 -34
- package/lib/workspace.mjs +3 -2
- package/package-catalog.json +2 -2
- package/package.json +1 -1
- package/packages/record/README.md +21 -0
- package/packages/record/bin/capture.mjs +3 -0
- package/packages/record/lib/capture-lock.mjs +92 -25
- package/skills/oats-getting-started/SKILL.md +1 -1
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
|
|
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
|
-
|
|
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
|
|
773
|
-
// (E_LAUNCH_LEGACY: re-spawn it from the deployment).
|
|
774
|
-
let d;
|
|
775
|
-
try {
|
|
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(
|
|
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
|
|
2842
|
-
//
|
|
2843
|
-
//
|
|
2844
|
-
//
|
|
2845
|
-
const
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
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,
|
|
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;
|
package/docs/capabilities.md
CHANGED
|
@@ -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
|
|
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.
|
package/docs/desktop-cli-api.md
CHANGED
|
@@ -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.
|
|
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.
|
|
786
|
-
"version":"4.0.
|
|
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.
|
|
876
|
-
"commit":"
|
|
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":"
|
|
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] --
|
|
17
|
-
| Codex | `codex` | `codex --cd <home> [--model m] --
|
|
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`,
|
package/docs/implementation.md
CHANGED
|
@@ -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,
|
|
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
|
|
package/docs/integrations.md
CHANGED
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.
|
|
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
|
package/docs/official-catalog.md
CHANGED
|
@@ -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.
|
|
14
|
-
| `oats.aweb` | `v1.17.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
79
|
-
oats.aweb: v1.17.
|
|
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.
|
|
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.
|
|
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.
|
|
164
|
-
"commit": "
|
|
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.
|
|
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.
|
|
156
|
-
"commit": "
|
|
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
|
package/docs/workspaces.md
CHANGED
|
@@ -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.
|
|
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
|
package/lib/canonical-json.mjs
CHANGED
|
@@ -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++;
|
package/lib/config-data.mjs
CHANGED
|
@@ -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
|
-
|
|
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) {
|
|
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} --
|
|
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} --
|
|
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).
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3540
|
-
|
|
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
|
|
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
|
|
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.
|
|
5915
|
-
//
|
|
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
|
-
|
|
5932
|
-
|
|
5933
|
-
|
|
5934
|
-
|
|
5935
|
-
|
|
5936
|
-
|
|
5937
|
-
|
|
5938
|
-
|
|
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)
|
|
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
|
|
338
|
-
|
|
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);
|
package/package-catalog.json
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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)
|
|
8
|
-
// inside it after the mkdir.
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
|
|
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
|
-
|
|
28
|
-
|
|
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 }
|
|
44
|
-
* taken, or { path, held: { pid, startedAt, liveness, recovery
|
|
45
|
-
*
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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.
|
|
64
|
+
oats.okf: v4.0.7
|
|
65
65
|
defaults:
|
|
66
66
|
capabilities: { oats.core: { from: package } }
|
|
67
67
|
knowledge: { oats.okf: { from: package } }
|