@awebai/oats 0.29.3 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -6
- package/bin/oats.mjs +203 -54
- package/capabilities/oats-aweb/bin/oats-aweb.mjs +538 -204
- package/capabilities/oats-aweb/injects/aweb.md +1 -1
- package/capabilities/oats-aweb/lib/binding-wire.mjs +31 -22
- package/capabilities/oats-aweb/oats.json +5 -12
- package/capabilities/oats-aweb/skills/VENDORED.md +4 -4
- package/capabilities/oats-aweb/skills/aweb-team-membership/SKILL.md +1 -1
- package/capabilities/oats-aweb/skills/oats-aweb/SKILL.md +83 -13
- package/capabilities/oats-code-review/injects/reviewer.md +26 -0
- package/capabilities/oats-code-review/oats.json +16 -0
- package/capabilities/oats-code-review/skills/adversarial-review/SKILL.md +66 -0
- package/capabilities/oats-code-review/skills/review-dev-docs/SKILL.md +30 -0
- package/capabilities/oats-code-review/skills/security-review/SKILL.md +56 -0
- package/capabilities/oats-code-review/skills/simplification-review/SKILL.md +34 -0
- package/capabilities/oats-developer/injects/developer.md +38 -0
- package/capabilities/oats-developer/oats.json +17 -0
- package/capabilities/oats-developer/skills/execution-strategy/SKILL.md +43 -0
- package/capabilities/oats-developer/skills/maintain-dev-docs/SKILL.md +47 -0
- package/capabilities/oats-developer/skills/run-the-review-loop/SKILL.md +65 -0
- package/capabilities/oats-developer/skills/understand-the-spec/SKILL.md +37 -0
- package/capabilities/oats-developer/skills/worktrees/SKILL.md +36 -0
- package/capabilities/oats-engineering-expert/injects/expert.md +37 -0
- package/capabilities/oats-engineering-expert/oats.json +17 -0
- package/capabilities/oats-engineering-expert/skills/coordinate-developers/SKILL.md +37 -0
- package/capabilities/oats-engineering-expert/skills/coordinate-experts/SKILL.md +52 -0
- package/capabilities/oats-engineering-expert/skills/land-your-prs/SKILL.md +50 -0
- package/capabilities/oats-engineering-expert/skills/plan-and-spec/SKILL.md +53 -0
- package/capabilities/oats-engineering-expert/skills/verify-developer-work/SKILL.md +49 -0
- package/capabilities/oats-okf/bin/oats-okf.mjs +16 -9
- package/capabilities/oats-okf/lib/binding-wire.mjs +47 -15
- package/capabilities/oats-okf/lib/config.mjs +2 -1
- package/capabilities/oats-okf/lib/consult.mjs +26 -4
- package/capabilities/oats-okf/lib/harvest-switch.mjs +16 -3
- package/capabilities/oats-okf/lib/inspection.mjs +26 -7
- package/capabilities/oats-okf/lib/io.mjs +9 -1
- package/capabilities/oats-okf/lib/sources.mjs +34 -3
- package/capabilities/oats-okf/lib/stores.mjs +8 -6
- package/capabilities/oats-okf/lib/worker.mjs +7 -17
- package/capabilities/oats-okf/oats.json +6 -3
- package/capabilities/oats-okf-harvest/bin/okf-harvest.mjs +2 -2
- package/capabilities/oats-okf-harvest/oats.json +3 -3
- package/capabilities/oats-okf-harvest/skills/knowledge-harvest/SKILL.md +6 -6
- package/capabilities/oats-okf-maintenance/bin/okf-maintenance.mjs +37 -16
- package/capabilities/oats-okf-maintenance/injects/maintainer.md +1 -1
- package/capabilities/oats-okf-maintenance/lib/provenance.mjs +6 -1
- package/capabilities/oats-okf-maintenance/oats.json +2 -2
- package/capabilities/oats-okf-maintenance/skills/knowledge-review/SKILL.md +17 -2
- package/capabilities/oats-okf-maintenance/skills/okf-trigger-setup/SKILL.md +11 -23
- package/capabilities/oats-workspace-experts/injects/oats-experts.md +26 -0
- package/capabilities/oats-workspace-experts/oats.json +9 -0
- package/docs/capabilities.md +160 -171
- package/docs/capability-manifest.schema.json +7 -10
- package/docs/configuration.md +213 -64
- package/docs/design/2026-09-16-knowledge-capability-contract.md +36 -50
- package/docs/design/2026-09-23-workspace-module-contracts.md +377 -544
- package/docs/design/2026-09-26-okf-knowledge-operations.md +132 -357
- package/docs/design/2026-09-27-team-model-v2.md +116 -0
- package/docs/design/2026-09-28-automations-trust.md +38 -0
- package/docs/design/2026-09-28-soul-launch-preference.md +63 -0
- package/docs/design/HISTORY.md +65 -0
- package/docs/design/README.md +23 -54
- package/docs/desktop-cli-api.md +1787 -1777
- package/docs/desktop.md +30 -91
- package/docs/execution-targets.md +146 -292
- package/docs/first-team.md +31 -17
- package/docs/implementation.md +76 -288
- package/docs/integrations.md +118 -320
- package/docs/knowledge-capability-authoring.md +25 -52
- package/docs/knowledge-reference/acceptance.md +3 -3
- package/docs/knowledge-reference/adoption.md +1 -1
- package/docs/knowledge-reference/harvester.md +2 -2
- package/docs/knowledge-reference/package-craft.md +3 -3
- package/docs/knowledge-reference/provider-mapping.md +3 -6
- package/docs/knowledge-reference/reader-capture.md +3 -3
- package/docs/knowledge-theory.md +62 -166
- package/docs/knowledge.md +225 -404
- package/docs/layers.md +42 -97
- package/docs/oats-local.schema.json +58 -5
- package/docs/oats-membership.schema.json +1 -8
- package/docs/oats-package.schema.json +5 -5
- package/docs/oats-workspace.schema.json +8 -22
- package/docs/official-catalog.md +25 -28
- package/docs/packages.md +45 -63
- package/docs/plans/0.30-close-out.md +61 -0
- package/docs/release-lane.md +77 -0
- package/docs/release-notes/oats-framework-v1.1.3.md +10 -8
- package/docs/release-notes/v0.19.0.md +48 -147
- package/docs/release-notes/v0.19.1.md +2 -3
- package/docs/release-notes/v0.19.3.md +2 -15
- package/docs/release-notes/v0.20.0.md +0 -15
- package/docs/release-notes/v0.22.0.md +71 -138
- package/docs/release-notes/v0.22.1.md +42 -90
- package/docs/release-notes/v0.22.10.md +1 -1
- package/docs/release-notes/v0.22.11.md +1 -47
- package/docs/release-notes/v0.22.12.md +4 -13
- package/docs/release-notes/v0.22.13.md +1 -42
- package/docs/release-notes/v0.22.14.md +3 -11
- package/docs/release-notes/v0.22.15.md +1 -46
- package/docs/release-notes/v0.22.16.md +6 -8
- package/docs/release-notes/v0.22.18.md +1 -99
- package/docs/release-notes/v0.22.19.md +3 -14
- package/docs/release-notes/v0.22.2.md +6 -15
- package/docs/release-notes/v0.22.3.md +0 -1
- package/docs/release-notes/v0.22.4.md +1 -14
- package/docs/release-notes/v0.22.5.md +2 -12
- package/docs/release-notes/v0.22.6.md +0 -3
- package/docs/release-notes/v0.23.0.md +9 -25
- package/docs/release-notes/v0.23.1.md +9 -25
- package/docs/release-notes/v0.23.2.md +2 -4
- package/docs/release-notes/v0.24.0.md +56 -97
- package/docs/release-notes/v0.24.1.md +7 -11
- package/docs/release-notes/v0.24.10.md +34 -45
- package/docs/release-notes/v0.24.11.md +12 -20
- package/docs/release-notes/v0.24.12.md +35 -48
- package/docs/release-notes/v0.24.13.md +34 -41
- package/docs/release-notes/v0.24.2.md +9 -13
- package/docs/release-notes/v0.24.3.md +7 -11
- package/docs/release-notes/v0.24.4.md +6 -6
- package/docs/release-notes/v0.24.5.md +6 -10
- package/docs/release-notes/v0.24.6.md +2 -5
- package/docs/release-notes/v0.24.7.md +46 -75
- package/docs/release-notes/v0.24.8.md +58 -96
- package/docs/release-notes/v0.24.9.md +38 -54
- package/docs/release-notes/v0.25.0.md +59 -76
- package/docs/release-notes/v0.25.1.md +57 -81
- package/docs/release-notes/v0.25.2.md +51 -70
- package/docs/release-notes/v0.25.3.md +11 -13
- package/docs/release-notes/v0.25.4.md +9 -13
- package/docs/release-notes/v0.25.5.md +3 -5
- package/docs/release-notes/v0.25.6.md +20 -29
- package/docs/release-notes/v0.25.7.md +5 -7
- package/docs/release-notes/v0.25.8.md +26 -39
- package/docs/release-notes/v0.26.0.md +175 -646
- package/docs/release-notes/v0.27.0.md +4 -5
- package/docs/release-notes/v0.27.1.md +4 -6
- package/docs/release-notes/v0.27.2.md +1 -1
- package/docs/release-notes/v0.28.0.md +57 -124
- package/docs/release-notes/v0.29.0.md +89 -208
- package/docs/release-notes/v0.29.1.md +1 -1
- package/docs/release-notes/v0.29.2.md +3 -4
- package/docs/release-notes/v0.29.4.md +90 -0
- package/docs/release-notes/v0.30.0.md +205 -0
- package/docs/schedules.md +280 -349
- package/docs/servers.md +99 -117
- package/docs/soul.schema.json +2 -9
- package/docs/souls-and-instances.md +145 -158
- package/docs/workspaces.md +132 -215
- package/lib/automations.mjs +28 -6
- package/lib/core.mjs +226 -74
- package/lib/instance-events.mjs +1 -1
- package/lib/instance-inspect.mjs +109 -34
- package/lib/instance-lifecycle.mjs +14 -1
- package/lib/instance-resolution.mjs +26 -27
- package/lib/launch-preference.mjs +87 -0
- package/lib/materialize.mjs +3 -3
- package/lib/packages.mjs +2 -5
- package/lib/resolve.mjs +29 -87
- package/lib/schedule.mjs +32 -18
- package/lib/teams-verbs.mjs +195 -0
- package/lib/teams.mjs +190 -0
- package/lib/triggers.mjs +53 -17
- package/lib/workspace.mjs +54 -147
- package/package-catalog.json +9 -15
- package/package.json +1 -1
- package/skills/oats-getting-started/SKILL.md +25 -13
- package/capabilities/oats-review/injects/review.md +0 -69
- package/capabilities/oats-review/oats.json +0 -10
- package/capabilities/oats-review/skills/code-review/SKILL.md +0 -44
- package/capabilities/oats-review/skills/security-review/SKILL.md +0 -59
- package/docs/conventions.md +0 -90
- package/docs/design/2026-09-07-architecture-reassessment.md +0 -131
- package/docs/design/2026-09-07-desktop-souls-capabilities.md +0 -50
- package/docs/design/2026-09-07-mobile-agent-management-proposal.md +0 -228
- package/docs/design/2026-09-08-expert-assisted-deployment-proposal.md +0 -558
- package/docs/design/2026-09-13-knowledge-and-memory-direction.md +0 -744
- package/docs/design/2026-09-13-knowledge-implementation.md +0 -127
- package/docs/design/2026-09-13-knowledge-location-contract.md +0 -340
- package/docs/design/2026-09-14-artifact-retention-contract.md +0 -190
- package/docs/design/2026-09-14-portable-souls-and-git-workspaces.md +0 -708
- package/docs/design/2026-09-14-portable-souls-contract-amendments.md +0 -85
- package/docs/design/2026-09-14-portable-souls-explainer.md +0 -750
- package/docs/design/2026-09-15-captured-dispatch.md +0 -127
- package/docs/design/2026-09-15-captured-resolution-records.md +0 -143
- package/docs/design/2026-09-15-package-preparation.md +0 -100
- package/docs/design/2026-09-15-portable-data-contract.md +0 -121
- package/docs/design/2026-09-15-portable-declarations.md +0 -189
- package/docs/design/2026-09-15-portable-souls-handoff.md +0 -150
- package/docs/design/2026-09-15-portable-souls-implementation.md +0 -417
- package/docs/design/2026-09-15-selection-lock-and-approval.md +0 -122
- package/docs/design/2026-09-15-source-observation.md +0 -119
- package/docs/design/2026-09-16-captured-admission.md +0 -77
- package/docs/design/2026-09-16-captured-helper-dispatch.md +0 -105
- package/docs/design/2026-09-16-captured-launch-inputs.md +0 -42
- package/docs/design/2026-09-16-command-profile-preparation.md +0 -86
- package/docs/design/2026-09-16-fresh-install-first-rollout.md +0 -47
- package/docs/design/2026-09-16-fresh-operator-walkthrough.md +0 -282
- package/docs/design/2026-09-16-messaging-capability-contract.md +0 -59
- package/docs/design/2026-09-16-portable-migration-evidence.md +0 -158
- package/docs/design/2026-09-16-portable-onboarding.md +0 -179
- package/docs/design/2026-09-16-prepare-request-transport.md +0 -26
- package/docs/design/2026-09-16-provider-binding-codecs.md +0 -98
- package/docs/design/2026-09-16-provider-binding-wire.md +0 -274
- package/docs/design/2026-09-17-capability-helper-input-contract.md +0 -95
- package/docs/design/2026-09-17-captured-backend-parity.md +0 -53
- package/docs/design/2026-09-17-captured-native-start.md +0 -58
- package/docs/design/2026-09-17-portable-boundary-hookup.md +0 -19
- package/docs/design/2026-09-17-portable-boundary-resources.md +0 -52
- package/docs/design/2026-09-17-public-captured-start.md +0 -108
- package/docs/design/2026-09-17-public-prepare-request.md +0 -90
- package/docs/design/2026-09-18-captured-pi-host.md +0 -205
- package/docs/design/2026-09-18-first-cut-release-checklist.md +0 -131
- package/docs/design/2026-09-18-herdr-protocol-compatibility.md +0 -60
- package/docs/design/2026-09-20-redesign-program-board.md +0 -142
- package/docs/design/2026-09-20-workspace-and-portable-adoption-plan.md +0 -289
- package/docs/design/2026-09-20-workspace-onboarding-public.md +0 -207
- package/docs/design/2026-09-22-desktop-parity-seams.md +0 -58
- package/docs/design/2026-09-23-simplified-workspace-model.md +0 -711
- package/docs/design/2026-09-23-workspace-v2-implementation-plan.md +0 -65
- package/docs/design/2026-09-24-desktop-phase-f-boundary.md +0 -242
- package/docs/design/2026-09-24-phase-d-plan.md +0 -305
- package/docs/design/2026-09-25-teams-contract.md +0 -258
- package/docs/design/2026-09-26-desktop-design-brief-architecture.md +0 -241
- package/docs/design/desktop-ux-plan.md +0 -362
- package/docs/design/launch-configurations.md +0 -168
- package/docs/design/okf-mirror-provenance.md +0 -105
- package/docs/design/operations-contract.md +0 -141
- package/docs/oats-member.schema.json +0 -38
- package/skills/integration-authoring/SKILL.md +0 -84
- package/skills/oats-support/SKILL.md +0 -79
- package/skills/skill-craft/SKILL.md +0 -109
- package/skills/soul-craft/SKILL.md +0 -116
package/lib/resolve.mjs
CHANGED
|
@@ -25,13 +25,12 @@
|
|
|
25
25
|
*
|
|
26
26
|
* Composition order (soul wins; `off` removes):
|
|
27
27
|
* workspace.defaults.{knowledge,messaging,tasks} (slot defaults; a soul `none` drops them)
|
|
28
|
-
* ⊕ workspace.defaults.capabilities ⊕
|
|
29
|
-
* soul's
|
|
30
|
-
* entries → E_TEAM_CONFLICT naming both (teams contract 2026-09-25, decision 2).
|
|
28
|
+
* ⊕ workspace.defaults.capabilities ⊕ soul.capabilities. (Team labels compose nothing: 0.30 removed
|
|
29
|
+
* defaults.byTeam, so a soul's composition is the same for every person and machine.)
|
|
31
30
|
*
|
|
32
31
|
* Slot `none` (contract §3, post-0.25.0 rule): a soul's `<slot>: none` EMPTIES the slot — it drops the
|
|
33
32
|
* workspace's `defaults.<slot>` AND any capability of that layer the workspace defaults contributed
|
|
34
|
-
* (`defaults.capabilities
|
|
33
|
+
* (`defaults.capabilities`). A layer-bearing capability the SOUL ITSELF declares
|
|
35
34
|
* next to `none` is contradictory and stays E_SLOT_CONFLICT { reason: "none" } (spell `<cap>: off` to
|
|
36
35
|
* remove a default explicitly; drop the soul's own line to fill the slot).
|
|
37
36
|
*
|
|
@@ -45,7 +44,7 @@
|
|
|
45
44
|
* everything, while a preview can say WHAT changed since the previous instance (declarations | payload | both).
|
|
46
45
|
*
|
|
47
46
|
* Payloads (decision 14), later wins on scalars/arrays, objects deep-merge:
|
|
48
|
-
* workspace.messaging (messaging slot only
|
|
47
|
+
* workspace.messaging (messaging slot only)
|
|
49
48
|
* ⊕ soul.<slot> ⊕ local.settings[cap] ⊕ spawn.providers[cap]
|
|
50
49
|
*
|
|
51
50
|
* This module shells out to nothing. Remote access is injected (`remote`, default
|
|
@@ -58,6 +57,7 @@ import { readFileSync } from "node:fs";
|
|
|
58
57
|
import { posix } from "node:path";
|
|
59
58
|
import { oatsError as baseOatsError } from "./errors.mjs";
|
|
60
59
|
import * as defaultRemote from "./remote.mjs";
|
|
60
|
+
import { BY_TEAM_REMOVED, reportRows, soulKeyOf, soulTeams, teamModel } from "./teams.mjs";
|
|
61
61
|
import { bindRemote, packageProviding, readPackageManifests, validateLock } from "./packages.mjs";
|
|
62
62
|
import { settingValueProblems } from "./capability-contract.mjs";
|
|
63
63
|
|
|
@@ -150,14 +150,13 @@ export function payloadOrigins(layers) {
|
|
|
150
150
|
return origins;
|
|
151
151
|
}
|
|
152
152
|
|
|
153
|
-
/** `byTeam`
|
|
154
|
-
*
|
|
155
|
-
*
|
|
156
|
-
|
|
157
|
-
const RESERVED_KEY = "byTeam";
|
|
153
|
+
/** `byTeam` was removed in 0.30 (team model v2): in any payload layer — the workspace's messaging, a soul's
|
|
154
|
+
* slot payload, local.settings[cap], spawn.providers[cap] — it would reach the provider verbatim as a dead
|
|
155
|
+
* key; refused at the layer's top level with E_WORKSPACE_SCHEMA reason "removed-key" and the path named. */
|
|
156
|
+
const REMOVED_KEY = "byTeam";
|
|
158
157
|
function assertNoReservedKey(value, path) {
|
|
159
|
-
if (isObject(value) && Object.hasOwn(value,
|
|
160
|
-
throw fail("E_WORKSPACE_SCHEMA", `${path}/${
|
|
158
|
+
if (isObject(value) && Object.hasOwn(value, REMOVED_KEY)) {
|
|
159
|
+
throw fail("E_WORKSPACE_SCHEMA", `${path}/${REMOVED_KEY}: ${BY_TEAM_REMOVED}`, { path: `${path}/${REMOVED_KEY}`, key: REMOVED_KEY, reason: "removed-key" });
|
|
161
160
|
}
|
|
162
161
|
}
|
|
163
162
|
|
|
@@ -333,46 +332,16 @@ const choiceOf = (value, path, via) => {
|
|
|
333
332
|
throw fail("E_WORKSPACE_SCHEMA", `${path} must be { from: <location> } or "off", got ${show(value)}`, { path, value });
|
|
334
333
|
};
|
|
335
334
|
|
|
336
|
-
/** A soul's team labels, primary first: `team` is a label or a list of distinct labels (teams
|
|
337
|
-
* contract 2026-09-25, decision 1). A discovery SoulEntry carries them as `labels`; an entry
|
|
338
|
-
* without them (a hand-built one) has its `team` as the only label. */
|
|
339
|
-
export function teamLabelsOf(soulEntry) {
|
|
340
|
-
if (Array.isArray(soulEntry?.labels)) return soulEntry.labels.filter((l) => typeof l === "string");
|
|
341
|
-
return typeof soulEntry?.team === "string" ? [soulEntry.team] : [];
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
/**
|
|
345
|
-
* The eligible teams of a soul (teams contract, decision 3): one entry per label, in soul order,
|
|
346
|
-
* `{ label, team, mapped, payload }`. `payload` is workspace.messaging's base ⊕ byTeam[label] when the
|
|
347
|
-
* workspace maps the label, else the base alone with `mapped: false`; `team` is the payload's team id
|
|
348
|
-
* when mapped (null otherwise). Kernel-owned and delivered BESIDE a provider's settings, never inside
|
|
349
|
-
* them; joining any of them is the messaging provider's explicit act. No label → [] (the workspace's default team only).
|
|
350
|
-
* Pure: resolveSoul validates every carried label's entry (reserved byTeam, hostOnly keys) first.
|
|
351
|
-
*/
|
|
352
|
-
export function teamsOf(workspace, labels) {
|
|
353
|
-
const messaging = isObject(workspace?.messaging) ? workspace.messaging : {};
|
|
354
|
-
const { byTeam, ...base } = messaging;
|
|
355
|
-
return (labels || []).map((label) => {
|
|
356
|
-
const mapped = isObject(byTeam) && Object.hasOwn(byTeam, label) && isObject(byTeam[label]);
|
|
357
|
-
const payload = mapped ? mergePayload(base, byTeam[label]) : mergePayload(base);
|
|
358
|
-
return { label, team: mapped && typeof payload.team === "string" ? payload.team : null, mapped, payload };
|
|
359
|
-
});
|
|
360
|
-
}
|
|
361
|
-
|
|
362
335
|
/**
|
|
363
336
|
* The ordered capability map of a soul BEFORE any lookup:
|
|
364
|
-
* slot defaults (dropped where the soul says `none`) ⊕ defaults.capabilities ⊕
|
|
365
|
-
* each label in order ⊕ soul.capabilities
|
|
337
|
+
* slot defaults (dropped where the soul says `none`) ⊕ defaults.capabilities ⊕ soul.capabilities
|
|
366
338
|
* → [{ name, from, via }] sorted by name; `off` removes the entry from every lower layer.
|
|
367
|
-
* `via` is one of "defaults.<slot>" | "defaults.capabilities" | "
|
|
368
|
-
* Two labels that give one capability different entries (`off` vs a location, or two locations) →
|
|
369
|
-
* E_TEAM_CONFLICT { capability, labels: [a, b] }; identical entries are not a conflict, and a capability
|
|
370
|
-
* the soul names itself is not one either (the soul's entry wins over both).
|
|
339
|
+
* `via` is one of "defaults.<slot>" | "defaults.capabilities" | "soul".
|
|
371
340
|
* `offs` (optional, feature desktop-facts): receives what the soul turned off — { name, reason: "off",
|
|
372
341
|
* overrides } for each capability its own `off` removed from a lower layer, and { name, reason: "slot-none",
|
|
373
342
|
* slot, overrides: "workspace" } for a slot default its `<slot>: none` dropped (`overrides`: fromOfVia vocabulary).
|
|
374
343
|
*/
|
|
375
|
-
export function composeCapabilities(workspace, soulDefinition, {
|
|
344
|
+
export function composeCapabilities(workspace, soulDefinition, { offs = null } = {}) {
|
|
376
345
|
const map = new Map();
|
|
377
346
|
const apply = (entries, via, path) => {
|
|
378
347
|
for (const [name, value] of Object.entries(entries || {})) {
|
|
@@ -392,22 +361,6 @@ export function composeCapabilities(workspace, soulDefinition, { team = null, la
|
|
|
392
361
|
apply(d, `defaults.${slot}`, `/defaults/${slot}`);
|
|
393
362
|
}
|
|
394
363
|
apply(defaults.capabilities, "defaults.capabilities", "/defaults/capabilities");
|
|
395
|
-
const byLabel = new Map(); // capability -> { label, choice } of the first label that set it
|
|
396
|
-
// The soul's own entry wins over every label, so a capability the soul names settles the conflict.
|
|
397
|
-
const soulNames = new Set(Object.keys(isObject(soulDefinition?.capabilities) ? soulDefinition.capabilities : {}));
|
|
398
|
-
for (const label of labels) {
|
|
399
|
-
if (!isObject(defaults.byTeam) || !Object.hasOwn(defaults.byTeam, label) || !isObject(defaults.byTeam[label])) continue;
|
|
400
|
-
const entries = defaults.byTeam[label].capabilities, path = `/defaults/byTeam/${label}/capabilities`;
|
|
401
|
-
for (const [name, value] of Object.entries(entries || {})) {
|
|
402
|
-
const choice = choiceOf(value, `${path}/${name}`, `defaults.byTeam.${label}`);
|
|
403
|
-
const seen = byLabel.get(name);
|
|
404
|
-
if (seen && !soulNames.has(name) && canonicalJson(seen.choice) !== canonicalJson(choice)) {
|
|
405
|
-
throw fail("E_TEAM_CONFLICT", `${name}: team labels ${show(seen.label)} and ${show(label)} give it different entries (${canonicalJson(seen.choice)} vs ${canonicalJson(choice)}) in defaults.byTeam — make them agree, or name ${name} in the soul`, { capability: name, labels: [seen.label, label], entries: [seen.choice, choice], paths: [`/defaults/byTeam/${seen.label}/capabilities/${name}`, `${path}/${name}`] });
|
|
406
|
-
}
|
|
407
|
-
if (!seen) byLabel.set(name, { label, choice });
|
|
408
|
-
}
|
|
409
|
-
apply(entries, `defaults.byTeam.${label}`, path);
|
|
410
|
-
}
|
|
411
364
|
apply(soulDefinition?.capabilities, "soul", "/capabilities");
|
|
412
365
|
return [...map.values()].sort((a, b) => byCodepoint(a.name, b.name));
|
|
413
366
|
}
|
|
@@ -599,17 +552,18 @@ export async function resolveSoul(discovery, soulEntry, { local = null, lock = n
|
|
|
599
552
|
const workspace = isObject(discovery?.workspace) ? discovery.workspace : null;
|
|
600
553
|
assertSoulDiscovered(discovery, soulEntry);
|
|
601
554
|
const definition = isObject(soulEntry.definition) ? soulEntry.definition : {};
|
|
602
|
-
const
|
|
603
|
-
|
|
604
|
-
|
|
555
|
+
const soul = { name: soulEntry.name, repoKey: soulEntry.repoKey, commit: soulEntry.commit ?? null, path: soulEntry.path ?? null };
|
|
556
|
+
// Team model v2: the soul's teams HERE (the committed shared teams + oats-local.yaml). An unknown
|
|
557
|
+
// label (E_TEAM_UNKNOWN) or an ineligible souls.default (E_TEAM_NOT_ELIGIBLE) refuses the soul.
|
|
558
|
+
const soulTeamsHere = soulTeams(teamModel(discovery?.standalone === true ? null : workspace, local, { workspaceKey: discovery?.key ?? null }), soulKeyOf(soulEntry));
|
|
605
559
|
|
|
606
560
|
// Standalone: the soul's own repo only, workspace defaults unknown (decision 10).
|
|
607
561
|
// What the soul turned off (feature desktop-facts): its own `off` over a lower layer, and a `<slot>: none`
|
|
608
562
|
// that dropped a workspace default below. Provenance, like slotsFrom.
|
|
609
563
|
const offs = [];
|
|
610
564
|
const declared = discovery?.standalone === true
|
|
611
|
-
? composeCapabilities(null, { ...definition, capabilities: soulEntry.capabilities ?? definition.capabilities }, {
|
|
612
|
-
: composeCapabilities(workspace, definition, {
|
|
565
|
+
? composeCapabilities(null, { ...definition, capabilities: soulEntry.capabilities ?? definition.capabilities }, { offs })
|
|
566
|
+
: composeCapabilities(workspace, definition, { offs });
|
|
613
567
|
const turnedOff = offs;
|
|
614
568
|
|
|
615
569
|
const modules = [];
|
|
@@ -727,21 +681,11 @@ export async function resolveSoul(discovery, soulEntry, { local = null, lock = n
|
|
|
727
681
|
// provider receives one merged payload without provenance and cannot enforce this itself.
|
|
728
682
|
const hostOnly = hostOnlyKeys(m.manifest);
|
|
729
683
|
const committed = (payload, path, origin) => { assertNoHostOnlyKey(payload, path, hostOnly, m.name); add(payload, origin); };
|
|
684
|
+
// The provider's settings are base ⊕ soul ⊕ host ⊕ spawn. Teams are not settings: they reach the
|
|
685
|
+
// provider beside them (OATS_DEFAULT_TEAM*, OATS_TEAMS).
|
|
730
686
|
if (m.layer === "messaging" && isObject(workspace?.messaging)) {
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
// label's base ⊕ byTeam[label] is delivered only in its `teams` entry (teamsOf → OATS_TEAMS),
|
|
734
|
-
// so settings.team is the default team a host, soul or spawn set. `byTeam` never reaches the
|
|
735
|
-
// provider, and the primary team's own payload may still not nest one.
|
|
736
|
-
const { byTeam, ...base } = workspace.messaging;
|
|
737
|
-
committed(base, "/messaging", { kind: "workspace", at: "oats-workspace.yaml#/messaging" });
|
|
738
|
-
// Every label's entry still reaches the provider, in OATS_TEAMS (teamsOf), so each one the soul
|
|
739
|
-
// carries — the primary's and every other — may nest no byTeam and carry no hostOnly key.
|
|
740
|
-
for (const label of labels) {
|
|
741
|
-
if (!isObject(byTeam) || !Object.hasOwn(byTeam, label) || !isObject(byTeam[label])) continue;
|
|
742
|
-
assertNoReservedKey(byTeam[label], `/messaging/byTeam/${label}`);
|
|
743
|
-
assertNoHostOnlyKey(byTeam[label], `/messaging/byTeam/${label}`, hostOnly, m.name);
|
|
744
|
-
}
|
|
687
|
+
assertNoReservedKey(workspace.messaging, "/messaging");
|
|
688
|
+
committed(workspace.messaging, "/messaging", { kind: "workspace", at: "oats-workspace.yaml#/messaging" });
|
|
745
689
|
}
|
|
746
690
|
if (m.layer && isObject(definition[m.layer])) { assertNoReservedKey(definition[m.layer], `/${m.layer}`); committed(definition[m.layer], `/${m.layer}`, { kind: "soul", at: `soul.yaml#/${m.layer}` }); }
|
|
747
691
|
if (Object.hasOwn(settings, m.name) && isObject(settings[m.name])) { assertNoReservedKey(settings[m.name], `/settings/${m.name}`); add(settings[m.name], { kind: "host", at: `oats-local.yaml#/settings/${m.name}` }); } // the host layer: hostOnly keys are legal here
|
|
@@ -805,23 +749,21 @@ export async function resolveSoul(discovery, soulEntry, { local = null, lock = n
|
|
|
805
749
|
const payloadRevision = revisionOf(payloads);
|
|
806
750
|
const revision = revisionOf({ declRevision, payloadRevision });
|
|
807
751
|
// payloadOrigins is provenance only: it is derived from the same inputs the two revisions already
|
|
808
|
-
// bind, so it does not enter either fingerprint. `teams` (
|
|
809
|
-
//
|
|
810
|
-
// single-label soul's revision is what it was.
|
|
752
|
+
// bind, so it does not enter either fingerprint. `teams` / `defaultTeam` (team model v2) are
|
|
753
|
+
// deployment-local messaging state, not composition: they stay out of both fingerprints too.
|
|
811
754
|
// `slotsFrom` (where each filled slot's capability came from) is provenance, like payloadOrigins: outside
|
|
812
755
|
// both fingerprints, so the same capability reached another way is not a composition change. So are
|
|
813
756
|
// `capabilitiesFrom` (the same per composed capability) and `turnedOff` (feature desktop-facts).
|
|
814
|
-
const teams =
|
|
757
|
+
const teams = reportRows(soulTeamsHere.teams), defaultTeam = soulTeamsHere.defaultTeam;
|
|
815
758
|
const capabilitiesFrom = Object.fromEntries(modules.map((m) => [m.name, fromOfVia(viaOf.get(m.name))]));
|
|
816
759
|
turnedOff.sort((a, b) => byCodepoint(a.name, b.name));
|
|
817
|
-
return deepFreeze({ ...decl, payloads, payloadOrigins: origins, teams, slotsFrom, capabilitiesFrom, turnedOff, declRevision, payloadRevision, revision });
|
|
760
|
+
return deepFreeze({ ...decl, payloads, payloadOrigins: origins, teams, defaultTeam, slotsFrom, capabilitiesFrom, turnedOff, declRevision, payloadRevision, revision });
|
|
818
761
|
}
|
|
819
762
|
|
|
820
763
|
/** Where a composed capability came from, as the Desktop names it (`layers.<layer>.from`): the soul's own
|
|
821
|
-
* entry → "soul"; defaults.<slot> or defaults.capabilities → "workspace"
|
|
764
|
+
* entry → "soul"; defaults.<slot> or defaults.capabilities → "workspace". */
|
|
822
765
|
export function fromOfVia(via) {
|
|
823
766
|
if (via === "soul") return "soul";
|
|
824
|
-
if (typeof via === "string" && via.startsWith("defaults.byTeam.")) return `team:${via.slice("defaults.byTeam.".length)}`;
|
|
825
767
|
if (typeof via === "string" && via.startsWith("defaults.")) return "workspace";
|
|
826
768
|
return null;
|
|
827
769
|
}
|
package/lib/schedule.mjs
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* succeeded. A launch whose side effects cannot be confirmed stays
|
|
22
22
|
* `unknown` with its slot held until `reconcile` proves what happened. */
|
|
23
23
|
import { spawnSync } from "node:child_process";
|
|
24
|
-
import { closeSync, constants as fsConstants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
24
|
+
import { closeSync, constants as fsConstants, existsSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, readSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
25
25
|
import { createHash } from "node:crypto";
|
|
26
26
|
import { homedir } from "node:os";
|
|
27
27
|
import { basename, dirname, isAbsolute, join, resolve, sep } from "node:path";
|
|
@@ -31,7 +31,7 @@ import { loadLocal } from "./workspace.mjs";
|
|
|
31
31
|
import { noteRuntimeName } from "./deprecation.mjs";
|
|
32
32
|
import { tickTriggers } from "./triggers.mjs";
|
|
33
33
|
import { resolveMemberClone } from "./instance-resolution.mjs";
|
|
34
|
-
import { AUTOMATION_ID_RE, automationContext, automationError, baseRow, localEntry, localId, soulOriginOf, splitId } from "./automations.mjs";
|
|
34
|
+
import { AUTOMATION_ID_RE, MODEL_RE, automationContext, automationError, baseRow, localEntry, localId, soulOriginOf, splitId } from "./automations.mjs";
|
|
35
35
|
import { RESERVED_LAUNCH_ENV, MAX_INSTANCE_NAME, findAgent, findInstanceHomes, inspectInstanceSession, inputInstanceSession, startInstanceSession, retirePendingMarkerPath } from "./core.mjs";
|
|
36
36
|
|
|
37
37
|
export const SCHEDULE_FILE = "oats-schedules.json";
|
|
@@ -208,6 +208,12 @@ export function readRegistry() {
|
|
|
208
208
|
if (!Array.isArray(reg.workspaces)) reg.workspaces = [];
|
|
209
209
|
if (!Number.isInteger(reg.maxConcurrent) || reg.maxConcurrent < 1) reg.maxConcurrent = 1;
|
|
210
210
|
if (!Number.isInteger(reg.tickIntervalSec) || reg.tickIntervalSec < 60) reg.tickIntervalSec = 60;
|
|
211
|
+
// The host cap on trigger-spawned live instances (re-review B #5): absent = unbounded (each
|
|
212
|
+
// trigger's own concurrency.max still applies). Separate from maxConcurrent: a running scheduled
|
|
213
|
+
// job never holds a trigger, and trigger homes never hold a schedule. Hand-edited, so refused loudly.
|
|
214
|
+
if (reg.triggersMaxConcurrent !== undefined && !(Number.isSafeInteger(reg.triggersMaxConcurrent) && reg.triggersMaxConcurrent >= 1)) {
|
|
215
|
+
throw scheduleError("E_SCHEDULE_INVALID", `${join(hostScheduleDir(), "registry.json")}: triggersMaxConcurrent must be a positive integer, or absent for no host cap (got ${JSON.stringify(reg.triggersMaxConcurrent)})`, { field: "triggersMaxConcurrent" });
|
|
216
|
+
}
|
|
211
217
|
return reg;
|
|
212
218
|
}
|
|
213
219
|
export function writeRegistry(reg) { writeJson(join(hostScheduleDir(), "registry.json"), reg); }
|
|
@@ -300,14 +306,14 @@ export function validateDefinition(ws, def, { checkAgent = true } = {}) {
|
|
|
300
306
|
validateCron(def.cron, def.tz);
|
|
301
307
|
const out = { id, enabled, cron: def.cron.trim(), tz: def.tz.trim(), kind: def.kind };
|
|
302
308
|
if (def.kind === "spawn") {
|
|
303
|
-
if (typeof def.agent !== "string" || !def.agent.trim()) throw scheduleError("E_SCHEDULE_INVALID", "agent: soul name required", { field: "agent" });
|
|
309
|
+
if (typeof def.agent !== "string" || !def.agent.trim() || def.agent.trim().startsWith("-")) throw scheduleError("E_SCHEDULE_INVALID", "agent: soul name required (not an option)", { field: "agent" });
|
|
304
310
|
out.agent = def.agent.trim();
|
|
305
311
|
if (def.agentsRoot !== undefined) {
|
|
306
312
|
const known = scopeRoots(ws);
|
|
307
313
|
if (typeof def.agentsRoot !== "string" || !isAbsolute(def.agentsRoot) || !inside(ws, def.agentsRoot) || !known.includes(resolve(def.agentsRoot))) throw scheduleError("E_SCHEDULE_INVALID", `agentsRoot: must be one of this scope's agents roots (${known.join(", ") || "none"})`, { field: "agentsRoot" });
|
|
308
314
|
out.agentsRoot = resolve(def.agentsRoot);
|
|
309
315
|
}
|
|
310
|
-
if (def.repo !== undefined) { if (typeof def.repo !== "string" || !def.repo.trim()) throw scheduleError("E_SCHEDULE_INVALID", "repo: the work repository path, as oats spawn --repo", { field: "repo" }); out.repo = def.repo; }
|
|
316
|
+
if (def.repo !== undefined) { if (typeof def.repo !== "string" || !def.repo.trim() || def.repo.startsWith("-")) throw scheduleError("E_SCHEDULE_INVALID", "repo: the work repository path, as oats spawn --repo", { field: "repo" }); out.repo = def.repo; }
|
|
311
317
|
if (def.backend !== undefined) { if (!BACKENDS.has(def.backend)) throw scheduleError("E_SCHEDULE_INVALID", "backend: tmux or herdr", { field: "backend" }); out.backend = def.backend; }
|
|
312
318
|
if (def.purpose !== undefined) { if (typeof def.purpose !== "string" || !/^[a-z0-9-]{1,40}$/.test(def.purpose)) throw scheduleError("E_SCHEDULE_INVALID", "purpose: lowercase letters, digits and dashes", { field: "purpose" }); out.purpose = def.purpose; }
|
|
313
319
|
// A run spawns <agent>-<purpose|id>-<YYYYMMDDHHMM>; instance names are at most
|
|
@@ -323,7 +329,7 @@ export function validateDefinition(ws, def, { checkAgent = true } = {}) {
|
|
|
323
329
|
}
|
|
324
330
|
if (def.launchConfig !== undefined) { if (typeof def.launchConfig !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/.test(def.launchConfig)) throw scheduleError("E_SCHEDULE_INVALID", "launchConfig: the name of a launch configuration on the running host (oats-local.yaml launch-configs)", { field: "launchConfig" }); out.launchConfig = def.launchConfig; }
|
|
325
331
|
if (def.harness !== undefined) { if (!HARNESSES.has(def.harness)) throw scheduleError("E_SCHEDULE_INVALID", "harness: pi, claude or codex", { field: "harness" }); out.harness = def.harness; }
|
|
326
|
-
if (def.model !== undefined) { if (typeof def.model !== "string") throw scheduleError("E_SCHEDULE_INVALID", "model:
|
|
332
|
+
if (def.model !== undefined) { if (typeof def.model !== "string" || !MODEL_RE.test(def.model)) throw scheduleError("E_SCHEDULE_INVALID", "model: a model id: a letter or digit, then letters, digits and . _ : / @ + - [ ] (at most 128 characters)", { field: "model" }); out.model = def.model; }
|
|
327
333
|
if (def.yolo !== undefined) { if (typeof def.yolo !== "boolean") throw scheduleError("E_SCHEDULE_INVALID", "yolo: boolean", { field: "yolo" }); out.yolo = def.yolo; }
|
|
328
334
|
if (def.wake !== undefined) {
|
|
329
335
|
const w = def.wake;
|
|
@@ -402,6 +408,10 @@ export function validateWorkspaceSchedule(dep, a) {
|
|
|
402
408
|
if (spec.kind === "command") {
|
|
403
409
|
if (d.cwd !== undefined && (typeof d.cwd !== "string" || isAbsolute(d.cwd))) throw scheduleError("E_SCHEDULE_INVALID", "cwd: a directory relative to the deployment (a workspace schedule never names a machine path)", { field: "cwd" });
|
|
404
410
|
spec.cwd = resolve(dep, d.cwd ?? ".");
|
|
411
|
+
// Inside the deployment, `..` and symlinks included (re-review B #4): Git content never picks a
|
|
412
|
+
// directory elsewhere on the host.
|
|
413
|
+
const real = (p) => { try { return realpathSync(p); } catch { return resolve(p); } };
|
|
414
|
+
if (!inside(real(dep), real(spec.cwd))) throw scheduleError("E_SCHEDULE_INVALID", `cwd: ${JSON.stringify(d.cwd)} leaves the deployment; a workspace command's cwd is a directory inside it`, { field: "cwd" });
|
|
405
415
|
}
|
|
406
416
|
const out = validateDefinition(dep, spec, { checkAgent: false });
|
|
407
417
|
delete out.enabled;
|
|
@@ -497,7 +507,7 @@ export function childEnv() {
|
|
|
497
507
|
"OATS_DEPLOYMENT", "OATS_RESOLUTION", "OATS_CAPABILITY", "OATS_LAYER", "OATS_LEVEL", "OATS_META", "OATS_OPERATION",
|
|
498
508
|
"OATS_REPO", "OATS_BRANCH", "OATS_WORK", "OATS_KIND", "OATS_TASK",
|
|
499
509
|
"OATS_HARNESS", "OATS_PREVIOUS_HARNESS", "OATS_RUNTIME", "OATS_PREVIOUS_RUNTIME", "OATS_RETIRE_INTENT",
|
|
500
|
-
"OATS_TEAM_NAME", "OATS_TEAM_ID", "
|
|
510
|
+
"OATS_TEAM_NAME", "OATS_TEAM_SCOPE", "OATS_TEAM_ID", "OATS_TEAM_LABEL", "OATS_TEAM_LABELS", "OATS_DEFAULT_TEAM", "OATS_DEFAULT_TEAM_ID", "OATS_DEFAULT_TEAM_FROM", "OATS_TEAMS", "OATS_TEAMS_SOURCE", "OATS_TRIGGER_EVENT_FILE",
|
|
501
511
|
]) delete env[key];
|
|
502
512
|
return env;
|
|
503
513
|
}
|
|
@@ -521,19 +531,22 @@ export function workspaceSpawnContext(ws) {
|
|
|
521
531
|
* `io.oatsBin`, `io.commandTimeoutMs` and `io.noLaunch` are the test seams. */
|
|
522
532
|
function spawnViaCli(ws, root, agent, opts, context, io) {
|
|
523
533
|
if (context.error) throw scheduleError(context.error.code, `this deployment realizes a workspace (${context.dir}) but its oats-local.yaml cannot be read: ${context.error.message}`);
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
if (
|
|
527
|
-
|
|
528
|
-
if (opts.
|
|
529
|
-
if (opts.
|
|
534
|
+
// Every value travels as ONE `--flag=value` token, so no value can be read as a flag of its own;
|
|
535
|
+
// the soul is the one positional, and an option-shaped one is refused.
|
|
536
|
+
if (String(agent.name).startsWith("-")) throw scheduleError("E_SCHEDULE_INVALID", `agent: ${JSON.stringify(agent.name)} is not a soul name`, { field: "agent" });
|
|
537
|
+
const argv = ["spawn", agent.name, `--dir=${context.dir}`, ...(opts.discover ? [] : [`--agents-root=${root}`]), `--purpose=${opts.purpose}`, "--json"];
|
|
538
|
+
if (opts.launchConfig) argv.push(`--launch-config=${opts.launchConfig}`);
|
|
539
|
+
if (opts.harness) argv.push(`--harness=${opts.harness}`);
|
|
540
|
+
if (opts.model) argv.push(`--model=${opts.model}`);
|
|
541
|
+
if (opts.backend) argv.push(`--backend=${opts.backend}`);
|
|
542
|
+
if (opts.repo) argv.push(`--repo=${opts.repo}`);
|
|
530
543
|
if (opts.yolo === true) argv.push("--yolo"); else if (opts.yolo === false) argv.push("--no-yolo");
|
|
531
544
|
if (opts.launch === false) argv.push("--no-launch");
|
|
532
545
|
// The task is multi-line text of arbitrary size: it travels as a private file, never in argv.
|
|
533
546
|
mkdirSync(join(stateDir(ws), "tasks"), { recursive: true });
|
|
534
547
|
const taskFile = join(stateDir(ws), "tasks", `${opts.purpose}-${process.pid}.md`);
|
|
535
548
|
writeFileSync(taskFile, opts.task, { mode: 0o600 });
|
|
536
|
-
argv.push(
|
|
549
|
+
argv.push(`--task-file=${taskFile}`);
|
|
537
550
|
let r;
|
|
538
551
|
try { r = spawnSync(process.execPath, [io?.oatsBin || OATS_BIN, ...argv], { cwd: context.dir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: io?.commandTimeoutMs || COMMAND_TIMEOUT_MS, killSignal: "SIGTERM", maxBuffer: 16 * 1024 * 1024, env: childEnv() }); }
|
|
539
552
|
finally { try { rmSync(taskFile, { force: true }); } catch { /* best effort */ } }
|
|
@@ -866,7 +879,7 @@ export function tickHost({ now = new Date(), io, dryRun = false } = {}) {
|
|
|
866
879
|
}
|
|
867
880
|
// Triggers: each scope's event-driven spawns, polled at their own interval (lib/triggers.mjs).
|
|
868
881
|
for (const ws of wsList) {
|
|
869
|
-
try { considered.push(...tickTriggers(ws, { now, io, dryRun, ctx: contexts.get(ws) })); }
|
|
882
|
+
try { considered.push(...tickTriggers(ws, { now, io, dryRun, ctx: contexts.get(ws), reg, wsList })); }
|
|
870
883
|
catch (e) { considered.push({ workspace: ws, action: "error", error: `triggers: ${e.message}` }); }
|
|
871
884
|
}
|
|
872
885
|
if (!dryRun) writeHostState({ ...readHostState(), lastTick: now.toISOString(), minute: minuteKey(minuteStart(now)) });
|
|
@@ -978,10 +991,11 @@ export function testSchedule(ws, qid, io, { now = new Date(), ctx = null } = {})
|
|
|
978
991
|
const { root, discover } = resolveScheduledAgent(ws, def);
|
|
979
992
|
const context = workspaceSpawnContext(ws);
|
|
980
993
|
if (context.error) throw scheduleError(context.error.code, context.error.message);
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
if (def.
|
|
984
|
-
if (def.
|
|
994
|
+
if (String(def.agent).startsWith("-")) throw scheduleError("E_SCHEDULE_INVALID", `agent: ${JSON.stringify(def.agent)} is not a soul name`, { field: "agent" });
|
|
995
|
+
const argv = ["spawn", def.agent, `--dir=${context.dir}`, ...(discover ? [] : [`--agents-root=${root}`]), "--preview", "--json"];
|
|
996
|
+
if (def.launchConfig) argv.push(`--launch-config=${def.launchConfig}`);
|
|
997
|
+
if (def.harness) argv.push(`--harness=${def.harness}`);
|
|
998
|
+
if (def.model) argv.push(`--model=${def.model}`);
|
|
985
999
|
const r = spawnSync(process.execPath, [io?.oatsBin || OATS_BIN, ...argv], { cwd: context.dir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: io?.commandTimeoutMs || COMMAND_TIMEOUT_MS, killSignal: "SIGTERM", maxBuffer: 16 * 1024 * 1024, env: childEnv() });
|
|
986
1000
|
const envelope = parseEnvelopeText(r.stdout);
|
|
987
1001
|
if (!envelope || typeof envelope.ok !== "boolean") throw scheduleError("E_SPAWN_FAILED", `the spawn preview answered no valid envelope (exit ${r.status ?? r.signal})`);
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The team model v2 verbs (docs/desktop-cli-api.md, `oats teams`, `oats soul teams`): CONFIG ONLY — they
|
|
3
|
+
* never call a provider. Each reads the deployment's oats-local.yaml and the committed shared teams, and a
|
|
4
|
+
* mutation rewrites oats-local.yaml in place (comments and every other key kept), after validating the
|
|
5
|
+
* result: the local schema, and no label reference the write would leave unknown or ineligible.
|
|
6
|
+
*/
|
|
7
|
+
import { readFileSync } from "node:fs";
|
|
8
|
+
import YAML from "yaml";
|
|
9
|
+
import { parseConfigData } from "./config-data.mjs";
|
|
10
|
+
import { oatsError } from "./errors.mjs";
|
|
11
|
+
import { validateLocal } from "./workspace.mjs";
|
|
12
|
+
import { LOCAL_FILE, TEAM_ID_RE, pointerKey, soulTeams, teamModel, teamProblems, teamReferences } from "./teams.mjs";
|
|
13
|
+
import { writeFileAtomic } from "./packages.mjs";
|
|
14
|
+
|
|
15
|
+
export const TEAMS_API = 1, SOUL_TEAMS_API = 1;
|
|
16
|
+
const LABEL_RE = /^[a-z0-9][a-z0-9._-]*$/;
|
|
17
|
+
const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
|
|
18
|
+
const byCodepoint = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
19
|
+
function fail(code, message, details) { const e = oatsError(code, message, details); e.details = details; return e; }
|
|
20
|
+
|
|
21
|
+
/** Whether a messaging layer is active in this deployment: the workspace fills the messaging slot. */
|
|
22
|
+
const messagingActive = (workspace) => isObject(workspace?.defaults?.messaging);
|
|
23
|
+
|
|
24
|
+
/** `oats teams --json`. `ctx`: { deployment, local, workspace (the committed file, or null), workspaceKey }. */
|
|
25
|
+
export function teamsDocument(ctx) {
|
|
26
|
+
const model = teamModel(ctx.workspace, ctx.local, { workspaceKey: ctx.workspaceKey });
|
|
27
|
+
const teams = [...model.labels.values()].sort((a, b) => byCodepoint(a.label, b.label))
|
|
28
|
+
.map((d) => ({ label: d.label, team: d.team, description: d.description, from: d.from, default: d.label === model.defaultTeam, at: d.at }));
|
|
29
|
+
return {
|
|
30
|
+
teamsApi: TEAMS_API, deployment: ctx.deployment, defaultTeam: model.defaultTeam, teams,
|
|
31
|
+
souls: { teams: structuredClone(model.souls.teams), default: structuredClone(model.souls.default) },
|
|
32
|
+
problems: teamProblems(model, { messaging: messagingActive(ctx.workspace) }),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** `oats soul teams <soul>|'*' --json`: `key` is the soul's key ("*" for every soul), `soul` its name. */
|
|
37
|
+
export function soulTeamsDocument(ctx, { soul, key }) {
|
|
38
|
+
const model = teamModel(ctx.workspace, ctx.local, { workspaceKey: ctx.workspaceKey });
|
|
39
|
+
const t = soulTeams(model, key);
|
|
40
|
+
const list = (v) => (Array.isArray(v) ? [...v] : []);
|
|
41
|
+
return {
|
|
42
|
+
soulTeamsApi: SOUL_TEAMS_API, soul, key, defaultTeam: t.defaultTeam, teams: t.teams,
|
|
43
|
+
local: { teams: list(model.souls.teams[key]), default: key === "*" ? null : model.souls.default[key] ?? null },
|
|
44
|
+
all: list(model.souls.teams["*"]),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/* ───────────────────────────── writing oats-local.yaml ─────────────────── */
|
|
49
|
+
|
|
50
|
+
const clone = (v) => (v === undefined ? undefined : structuredClone(v));
|
|
51
|
+
/** The four team keys of a local value, as plain data. */
|
|
52
|
+
const teamState = (local) => ({
|
|
53
|
+
teams: clone(isObject(local?.teams) ? local.teams : {}),
|
|
54
|
+
defaultTeam: typeof local?.defaultTeam === "string" ? local.defaultTeam : null,
|
|
55
|
+
soulsTeams: clone(isObject(local?.souls?.teams) ? local.souls.teams : {}),
|
|
56
|
+
soulsDefault: clone(isObject(local?.souls?.default) ? local.souls.default : {}),
|
|
57
|
+
});
|
|
58
|
+
const modelOf = (ctx, local) => teamModel(ctx.workspace, local, { workspaceKey: ctx.workspaceKey });
|
|
59
|
+
/** The refusals a state carries (unknown / ineligible references), keyed so a write can tell new ones. */
|
|
60
|
+
const refusals = (ctx, local) => new Map(teamProblems(modelOf(ctx, local))
|
|
61
|
+
.filter((p) => p.code === "E_TEAM_UNKNOWN" || p.code === "E_TEAM_NOT_ELIGIBLE").map((p) => [`${p.code} ${p.at}`, p]));
|
|
62
|
+
const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
|
|
63
|
+
const readLocal = (ctx) => {
|
|
64
|
+
const text = readFileSync(ctx.localPath, "utf8");
|
|
65
|
+
const doc = YAML.parseDocument(text, { keepSourceTokens: true });
|
|
66
|
+
if (doc.errors?.length) throw fail("E_WORKSPACE_SCHEMA", `${ctx.localPath}: ${doc.errors[0].message}`, { path: ctx.localPath });
|
|
67
|
+
return { text, doc, local: parseConfigData(text, { origin: { kind: "local", path: ctx.localPath } }).value };
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Edit `doc` from `before` to `next` SURGICALLY: only the entries that change are touched, so comments and
|
|
72
|
+
* styles on everything else (inline comments on sibling entries, flow sequences) are kept. A map or list
|
|
73
|
+
* left empty is removed, and so is an empty `souls`.
|
|
74
|
+
*/
|
|
75
|
+
function applyState(doc, before, next) {
|
|
76
|
+
const map = (path, from, to, entry) => {
|
|
77
|
+
for (const k of Object.keys(from)) if (!Object.hasOwn(to, k)) doc.deleteIn([...path, k]);
|
|
78
|
+
for (const [k, v] of Object.entries(to)) if (!same(from[k], v)) entry([...path, k], from[k], v);
|
|
79
|
+
const node = doc.getIn(path);
|
|
80
|
+
if (YAML.isMap(node) && node.items.length === 0) doc.deleteIn(path);
|
|
81
|
+
};
|
|
82
|
+
// A team definition, key by key (its own comments stay on untouched keys).
|
|
83
|
+
map(["teams"], before.teams, next.teams, (path, was, now) => {
|
|
84
|
+
if (!isObject(was) || !YAML.isMap(doc.getIn(path))) { doc.setIn(path, now); return; }
|
|
85
|
+
for (const k of Object.keys(was)) if (!Object.hasOwn(now, k)) doc.deleteIn([...path, k]);
|
|
86
|
+
for (const [k, v] of Object.entries(now)) if (!same(was[k], v)) doc.setIn([...path, k], v);
|
|
87
|
+
});
|
|
88
|
+
if (next.defaultTeam !== before.defaultTeam) { if (next.defaultTeam === null) doc.deleteIn(["defaultTeam"]); else doc.setIn(["defaultTeam"], next.defaultTeam); }
|
|
89
|
+
// A soul's list, item by item (its style — flow or block — and the other items' comments stay).
|
|
90
|
+
map(["souls", "teams"], before.soulsTeams, next.soulsTeams, (path, was, now) => {
|
|
91
|
+
const seq = doc.getIn(path, true);
|
|
92
|
+
if (!Array.isArray(was) || !YAML.isSeq(seq)) { doc.setIn(path, now); return; }
|
|
93
|
+
seq.items = seq.items.filter((item) => now.includes(YAML.isScalar(item) ? item.value : item));
|
|
94
|
+
for (const label of now) if (!was.includes(label)) seq.items.push(doc.createNode(label));
|
|
95
|
+
});
|
|
96
|
+
map(["souls", "default"], before.soulsDefault, next.soulsDefault, (path, _was, now) => doc.setIn(path, now));
|
|
97
|
+
const souls = doc.get("souls");
|
|
98
|
+
if (YAML.isMap(souls) && souls.items.length === 0) doc.delete("souls");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Rewrite oats-local.yaml. `mutate(state, model)` edits the plain `state` (teamState) and may throw a
|
|
103
|
+
* refusal; both come from the file AS IT IS NOW (re-read here, never the command's startup snapshot), so
|
|
104
|
+
* two verbs running together cannot drop each other's change: the write is compare-and-swap — when the
|
|
105
|
+
* file changed after this read, the edit is redone on the new content (a few times, then E_LOCAL_CHANGED).
|
|
106
|
+
* `check(local)` may refuse the result before anything is written. → { changed, local } (the value now).
|
|
107
|
+
*/
|
|
108
|
+
function editLocal(ctx, mutate, { check } = {}) {
|
|
109
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
110
|
+
const { text, doc, local: current } = readLocal(ctx);
|
|
111
|
+
const before = teamState(current);
|
|
112
|
+
const next = teamState(current);
|
|
113
|
+
mutate(next, modelOf(ctx, current));
|
|
114
|
+
if (same(next, before)) return { changed: false, local: current };
|
|
115
|
+
applyState(doc, before, next);
|
|
116
|
+
const out = doc.toString();
|
|
117
|
+
const local = parseConfigData(out, { origin: { kind: "local", path: ctx.localPath } }).value;
|
|
118
|
+
const problems = validateLocal(local);
|
|
119
|
+
if (problems.length) throw fail("E_WORKSPACE_SCHEMA", `the rewritten oats-local.yaml would be invalid (${problems.map((p) => `${p.path || "/"}: ${p.message}`).join("; ")}); nothing was written`, { problems });
|
|
120
|
+
const had = refusals(ctx, current);
|
|
121
|
+
for (const [k, p] of refusals(ctx, local)) if (!had.has(k)) throw fail(p.code, `${p.message}; nothing was written`, Object.fromEntries(Object.entries(p).filter(([key]) => !["code", "severity", "message", "fix"].includes(key))));
|
|
122
|
+
if (check) check(local);
|
|
123
|
+
if (readFileSync(ctx.localPath, "utf8") !== text) continue; // changed under us: redo on the new content
|
|
124
|
+
writeFileAtomic(ctx.localPath, out);
|
|
125
|
+
return { changed: true, local };
|
|
126
|
+
}
|
|
127
|
+
throw fail("E_LOCAL_CHANGED", `${ctx.localPath} kept changing while this edit ran; nothing was written — run it again`, { path: ctx.localPath });
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const assertLabel = (label) => {
|
|
131
|
+
if (typeof label !== "string" || !LABEL_RE.test(label)) throw fail("E_BAD_ARGS", `${JSON.stringify(label)} is not a team label (lowercase letters, digits, and . _ - after the first)`, { label });
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/** `oats teams add <label> --team <id> [--description <d>]` → { changed, local }. */
|
|
135
|
+
export function teamsAdd(ctx, label, { team, description } = {}) {
|
|
136
|
+
assertLabel(label);
|
|
137
|
+
if (typeof team !== "string" || !team.trim()) throw fail("E_BAD_ARGS", "oats teams add needs --team <provider team id>", { label });
|
|
138
|
+
if (!TEAM_ID_RE.test(team.trim())) throw fail("E_BAD_ARGS", `${JSON.stringify(team)} is not a team id: it must match ${TEAM_ID_RE.source} (a letter or digit first; no whitespace or control characters; at most 256 characters)`, { label, team });
|
|
139
|
+
return editLocal(ctx, (s, model) => {
|
|
140
|
+
if (model.labels.has(label)) {
|
|
141
|
+
const from = model.labels.get(label).from;
|
|
142
|
+
throw fail("E_TEAM_EXISTS", `team ${label} is already declared (${from === "shared" ? "shared, in oats-workspace.yaml" : "local, in oats-local.yaml"})${from === "local" ? ": remove it first (`oats teams remove`) to redefine it" : ""}`, { label, from });
|
|
143
|
+
}
|
|
144
|
+
s.teams[label] = { team: team.trim(), ...(typeof description === "string" && description ? { description } : {}) };
|
|
145
|
+
// The first team added becomes the deployment's default.
|
|
146
|
+
if (s.defaultTeam === null) s.defaultTeam = label;
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** `oats teams remove <label>`: a LOCAL team nothing references (no cascade). */
|
|
151
|
+
export function teamsRemove(ctx, label) {
|
|
152
|
+
return editLocal(ctx, (s, model) => {
|
|
153
|
+
if (!model.local.has(label)) {
|
|
154
|
+
if (model.shared.has(label)) throw fail("E_TEAM_SHARED", `team ${label} is shared (declared in oats-workspace.yaml): it is edited by a PR to that file, never removed here`, { label, at: model.shared.get(label).at });
|
|
155
|
+
throw fail("E_TEAM_UNKNOWN", `team ${JSON.stringify(label)} is not declared`, { label });
|
|
156
|
+
}
|
|
157
|
+
// A label also declared in the committed file still resolves after the local copy goes (the
|
|
158
|
+
// collision's fix): its references stay valid, so only a purely local label must be unreferenced.
|
|
159
|
+
const usedBy = model.shared.has(label) ? [] : teamReferences(model, label);
|
|
160
|
+
if (usedBy.length) throw fail("E_TEAM_IN_USE", `team ${label} is still referenced (${usedBy.join(", ")}): remove the references first (\`oats teams default\`, \`oats soul teams … --remove ${label}\`)`, { label, usedBy });
|
|
161
|
+
delete s.teams[label];
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** `oats teams default <label>`: a label of either file. */
|
|
166
|
+
export function teamsDefault(ctx, label) {
|
|
167
|
+
return editLocal(ctx, (s, model) => {
|
|
168
|
+
if (!model.labels.has(label)) throw fail("E_TEAM_UNKNOWN", `team ${JSON.stringify(label)} is not declared: \`oats teams add\` it first`, { label });
|
|
169
|
+
s.defaultTeam = label;
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* `oats soul teams <soul>|'*' [--add a,b] [--remove a,b] [--default <l> | --clear-default]`.
|
|
175
|
+
* `key` is the soul's key ("*" for every soul). → { changed, local }.
|
|
176
|
+
*/
|
|
177
|
+
export function soulTeamsEdit(ctx, key, { add = [], remove = [], setDefault = null, clearDefault = false } = {}) {
|
|
178
|
+
if (key === "*" && (setDefault !== null || clearDefault)) throw fail("E_BAD_ARGS", "a default is per soul: --default / --clear-default need a soul, not '*' (the deployment's default is `oats teams default`)", { soul: key });
|
|
179
|
+
if (setDefault !== null && clearDefault) throw fail("E_BAD_ARGS", "choose --default <label> or --clear-default, not both");
|
|
180
|
+
// --default must name one of the soul's teams after the write.
|
|
181
|
+
const check = setDefault === null ? undefined : (local) => {
|
|
182
|
+
try { soulTeams(modelOf(ctx, local), key); }
|
|
183
|
+
catch (e) { if (e.code === "E_TEAM_NOT_ELIGIBLE") throw fail("E_TEAM_NOT_ELIGIBLE", `${e.message}; nothing was written`, { soul: key, label: setDefault, at: `${LOCAL_FILE}#/souls/default/${pointerKey(key)}` }); throw e; }
|
|
184
|
+
};
|
|
185
|
+
return editLocal(ctx, (s, model) => {
|
|
186
|
+
for (const label of [...add, ...remove, ...(setDefault !== null ? [setDefault] : [])]) {
|
|
187
|
+
if (!model.labels.has(label)) throw fail("E_TEAM_UNKNOWN", `team ${JSON.stringify(label)} is not declared: \`oats teams add\` it first`, { label });
|
|
188
|
+
}
|
|
189
|
+
const list = Array.isArray(s.soulsTeams[key]) ? s.soulsTeams[key] : [];
|
|
190
|
+
const next = [...list.filter((l) => !remove.includes(l)), ...add.filter((l) => !list.includes(l) && !remove.includes(l))].filter((l, i, a) => a.indexOf(l) === i);
|
|
191
|
+
if (next.length) s.soulsTeams[key] = next; else delete s.soulsTeams[key];
|
|
192
|
+
if (clearDefault) delete s.soulsDefault[key];
|
|
193
|
+
if (setDefault !== null) s.soulsDefault[key] = setDefault;
|
|
194
|
+
}, { check });
|
|
195
|
+
}
|