wowbagger 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/LICENSE +201 -0
  3. package/README.md +464 -0
  4. package/adapters/claude-code/entrypoint.js +19 -0
  5. package/adapters/claude-code/wowbagger-adapter.json +25 -0
  6. package/adapters/codex/entrypoint.js +11 -0
  7. package/adapters/codex/wowbagger-adapter.json +25 -0
  8. package/adapters/opencode/entrypoint.js +11 -0
  9. package/adapters/opencode/wowbagger-adapter.json +25 -0
  10. package/bin/wowbagger.js +7 -0
  11. package/package.json +51 -0
  12. package/skills/wowbagger/SKILL.md +136 -0
  13. package/src/adapter/approval.js +135 -0
  14. package/src/adapter/bootstrap.js +43 -0
  15. package/src/adapter/context.js +34 -0
  16. package/src/adapter/core-probe.js +231 -0
  17. package/src/adapter/describe.js +383 -0
  18. package/src/adapter/entrypoint-main.js +335 -0
  19. package/src/adapter/entrypoint-path.js +103 -0
  20. package/src/adapter/handoff.js +124 -0
  21. package/src/adapter/instructions.js +106 -0
  22. package/src/adapter/invoke.js +294 -0
  23. package/src/adapter/limits.js +26 -0
  24. package/src/adapter/manifest.js +93 -0
  25. package/src/adapter/messages.js +15 -0
  26. package/src/adapter/paths.js +88 -0
  27. package/src/adapter/process-outcome.js +1116 -0
  28. package/src/adapter/schema-helpers.js +60 -0
  29. package/src/claim-capabilities.js +54 -0
  30. package/src/claim-coordinator.js +85 -0
  31. package/src/claim-journal.js +236 -0
  32. package/src/claim-operations.js +138 -0
  33. package/src/claim-publication.js +739 -0
  34. package/src/claim-request.js +140 -0
  35. package/src/claim-store.js +198 -0
  36. package/src/cli.js +1130 -0
  37. package/src/dependencies.js +3 -0
  38. package/src/git-reconciliation.js +62 -0
  39. package/src/ledger.js +296 -0
  40. package/src/mint.js +32 -0
  41. package/src/mutation.js +1979 -0
  42. package/src/namespace.js +35 -0
  43. package/src/ready.js +85 -0
  44. package/src/request.js +246 -0
  45. package/src/schema-migration.js +300 -0
  46. package/src/validate.js +1208 -0
@@ -0,0 +1,136 @@
1
+ ---
2
+ name: wowbagger
3
+ description: Use when coordinating work through a wowbagger ledger — reading the ready queue, inspecting or filing an item, transitioning one through its lifecycle, taking a work claim, or publishing claimed work. Triggers on "ready queue", "what should I work on", "file a ledger item", "close this item", "claim this work", "publish claimed work", or any mention of a wowbagger ledger. Not for general backlog talk where no wowbagger ledger exists.
4
+ ---
5
+
6
+ # Wowbagger
7
+
8
+ A work ledger that is plain Markdown in Git. Every item is a file; every change
9
+ is a reviewable diff. The core is read-only unless you explicitly ask it to
10
+ publish something.
11
+
12
+ ## Before anything else: check the core
13
+
14
+ This skill does **not** bundle the wowbagger core. It drives an installed one,
15
+ so a version mismatch is detectable rather than silent.
16
+
17
+ ```sh
18
+ wowbagger capabilities --json
19
+ ```
20
+
21
+ Read `contract_version` from the result. **This skill requires
22
+ `contract_version: 2`.**
23
+
24
+ - Command not found → the core is not installed. Tell the user, point them at
25
+ <https://github.com/lstutzman/wowbagger>, and stop. Do not fall back to
26
+ editing ledger files by hand — hand-edits bypass validation and atomic
27
+ publication, which is the whole point of the tool.
28
+ - `contract_version` is anything other than `2` → stop and say so plainly. A
29
+ core reporting `1` predates schema version 2, where `depends_on` records
30
+ declared prerequisites rather than only live blockers; an older or newer core
31
+ may have changed the request or response shape. Do not guess.
32
+
33
+ Run this once per session before the first ledger command, not before every
34
+ command.
35
+
36
+ ## Reading
37
+
38
+ These never modify anything. Prefer them.
39
+
40
+ ```sh
41
+ wowbagger validate --ledger <dir> --json
42
+ wowbagger ready --ledger <dir> --as-of <YYYY-MM-DD> --json
43
+ wowbagger inspect --ledger <dir> --id wb_... --json
44
+ wowbagger capabilities --json
45
+ ```
46
+
47
+ - `ready` is the source of truth for what is workable. Never infer the queue by
48
+ reading files yourself — dependency and parent rules decide it, and a
49
+ directory listing does not.
50
+ - `--as-of` is a real calendar date. Use today's date; do not invent one to make
51
+ an item appear.
52
+ - `validate` returns `{"valid":true,"errors":[]}` on a clean ledger and exits
53
+ nonzero on a broken one. Run it after any change you make.
54
+ - `inspect` returns a lossless byte snapshot plus a SHA-256 revision. That
55
+ revision is what `transition` compares against, so inspect immediately before
56
+ transitioning.
57
+
58
+ ## Writing
59
+
60
+ Every write is an explicit, reviewable Git change. Show the user the command
61
+ before running it.
62
+
63
+ ```sh
64
+ wowbagger create --ledger <dir> --input request.json --json
65
+ wowbagger transition --ledger <dir> --input request.json --json
66
+ ```
67
+
68
+ - `create` publishes only a caller-supplied canonical ID, atomically and
69
+ no-clobber. It will not invent an ID for you.
70
+ - `transition` changes **one** item. If the change would require touching a
71
+ dependent or a child, it refuses. That refusal is correct — make it a
72
+ reviewable multi-file Git change instead of forcing it.
73
+ - See `docs/mutation-contract.md` in the wowbagger repository for the request
74
+ and response shapes.
75
+ - After any write, run `validate` and show the user the resulting diff.
76
+
77
+ ## Work claims are merge-coordinated
78
+
79
+ ```sh
80
+ wowbagger provision --ledger <dir> --json
81
+ wowbagger claim capabilities --ledger <dir> --json
82
+ wowbagger claim read|acquire|renew|release --ledger <dir> --input request.json --json
83
+ wowbagger publish-claimed --ledger <dir> --input request.json --json
84
+ wowbagger claim-verify --ledger <dir> --json
85
+ ```
86
+
87
+ The Git-backed capability reports `mode: "merge-coordinated"` and
88
+ `safe_exclusive_dispatch: false`. Say both facts plainly. An acquire uses
89
+ observed-state compare-and-swap, and a claimed publication checks the active
90
+ owner generation and expected item revision. This protects cooperating
91
+ worktrees that use the protocol.
92
+
93
+ It is not exclusive coordination. Direct filesystem writes, hostile processes,
94
+ other clones, and alternate tools can bypass the protocol. Never present a
95
+ claim as a lock or build a dispatch loop that requires exclusive ownership.
96
+
97
+ Use the claimed write path as one complete loop:
98
+
99
+ 1. Run `provision` once for the ledger. Keep its `ledger_namespace`.
100
+ 2. Run `claim capabilities --ledger <dir> --json`. Stop if the namespace is
101
+ absent or the mode is not `merge-coordinated`.
102
+ 3. Read the current claim record. Acquire with its observed state in
103
+ `expected`; keep the returned `owner_id`, `epoch`, and expiry as the fence.
104
+ 4. Renew before the lease expires if the work continues.
105
+ 5. Inspect the item again. Build the complete desired item bytes.
106
+ 6. Call `publish-claimed` with a unique operation ID, the exact inspected
107
+ revision, the candidate bytes and digest, and the active claim fence. Never
108
+ retry with only the operation ID; retry the complete request.
109
+ 7. Commit the item change, or merge the worker commit into the coordinating
110
+ branch.
111
+ 8. Run `claim-verify` after the commit or merge. It finalizes the Git outcome,
112
+ repairs response-loss cases, and reports later revision drift. Run it again
113
+ before the next fenced operation if the prior outcome was not final.
114
+ 9. Release the claim with its current observed state.
115
+ 10. Run `validate` and show the resulting diff.
116
+
117
+ Legacy `create` refuses an ID with claim history. Legacy `transition` refuses an
118
+ item with an active claim. Do not bypass those refusals. Read
119
+ `docs/work-claim-contract.md` in the wowbagger repository for exact request and
120
+ response envelopes, refusal precedence, and recovery rules.
121
+
122
+ ## Working the unclaimed loop
123
+
124
+ 1. `validate` the ledger.
125
+ 2. `ready --as-of <today>` to see what is actually workable.
126
+ 3. `inspect` the item you intend to take, and read its body — the acceptance
127
+ criteria live there, not in the metadata.
128
+ 4. Do the work.
129
+ 5. `inspect` again for the current revision, then `transition` to close it.
130
+ 6. `validate`, then show the diff.
131
+
132
+ ## Friction is a finding
133
+
134
+ If the tool makes something harder than doing it by hand, that is a defect worth
135
+ recording. File it as a ledger item rather than leaving it in a transcript —
136
+ that is what the ledger is for, and it is how this tool is meant to improve.
@@ -0,0 +1,135 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ import { hasExactMembers } from './schema-helpers.js';
4
+
5
+ const DIGEST = /^sha256:[a-f0-9]{64}$/;
6
+ const NONCE = /^[A-Za-z0-9._-]{16,128}$/;
7
+ const RFC3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/;
8
+ const SAFE_ID = /^[A-Za-z0-9._-]{1,128}$/;
9
+
10
+ function refuse(error_code, detail = {}) {
11
+ return { ok: false, error_code, detail };
12
+ }
13
+
14
+ function canonicalJson(value) {
15
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
16
+ if (value !== null && typeof value === 'object') {
17
+ return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(',')}}`;
18
+ }
19
+ return JSON.stringify(value);
20
+ }
21
+
22
+ function decodeCanonicalBase64(value) {
23
+ if (typeof value !== 'string' || !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) {
24
+ return null;
25
+ }
26
+ const bytes = Buffer.from(value, 'base64');
27
+ return bytes.toString('base64') === value ? bytes : null;
28
+ }
29
+
30
+ function validCanonicalTime(value) {
31
+ const parsed = Date.parse(value);
32
+ return Number.isFinite(parsed) && new Date(parsed).toISOString().replace('.000Z', 'Z') === value;
33
+ }
34
+
35
+ function validInvocationBinding(value) {
36
+ if (!hasExactMembers(value, [
37
+ 'request_id', 'adapter', 'core', 'workspace', 'limits',
38
+ 'instruction_set_digest', 'handoff_digest',
39
+ ]) || !SAFE_ID.test(value.request_id)) return false;
40
+ if (!hasExactMembers(value.adapter, ['id', 'version', 'contract_version'])
41
+ || typeof value.adapter.id !== 'string'
42
+ || typeof value.adapter.version !== 'string'
43
+ || !Number.isSafeInteger(value.adapter.contract_version)) return false;
44
+ if (!hasExactMembers(value.core, [
45
+ 'executable_identity', 'contract_version', 'argv', 'input_base64',
46
+ ])
47
+ || !DIGEST.test(value.core.executable_identity)
48
+ || !Number.isSafeInteger(value.core.contract_version)
49
+ || !Array.isArray(value.core.argv)
50
+ || !value.core.argv.every((argument) => typeof argument === 'string')
51
+ || decodeCanonicalBase64(value.core.input_base64) === null) return false;
52
+ if (!hasExactMembers(value.workspace, ['id', 'root', 'cwd', 'ledger'])
53
+ || !Object.values(value.workspace).every((member) => typeof member === 'string')) return false;
54
+ if (!hasExactMembers(value.limits, [
55
+ 'context_bytes', 'stdout_bytes', 'stderr_bytes', 'timeout_ms',
56
+ ])
57
+ || !Object.values(value.limits).every((member) => Number.isSafeInteger(member) && member >= 0)
58
+ || value.limits.timeout_ms < 1) return false;
59
+ return DIGEST.test(value.instruction_set_digest)
60
+ && (value.handoff_digest === null || DIGEST.test(value.handoff_digest));
61
+ }
62
+
63
+ function approvalSchemaError(value) {
64
+ if (!hasExactMembers(value, [
65
+ 'approval_version', 'source', 'nonce', 'issued_at', 'expires_at', 'invocation_digest',
66
+ ])) return true;
67
+ return value.approval_version !== 1
68
+ || typeof value.source !== 'string'
69
+ || typeof value.nonce !== 'string'
70
+ || !NONCE.test(value.nonce)
71
+ || !DIGEST.test(value.invocation_digest)
72
+ || !RFC3339.test(value.issued_at)
73
+ || !validCanonicalTime(value.issued_at)
74
+ || !RFC3339.test(value.expires_at)
75
+ || !validCanonicalTime(value.expires_at);
76
+ }
77
+
78
+ export function canonicalInvocationDigest(binding) {
79
+ if (!validInvocationBinding(binding)) throw new TypeError('invalid invocation binding');
80
+ const canonical = canonicalJson(binding);
81
+ return {
82
+ canonical,
83
+ digest: `sha256:${createHash('sha256').update(Buffer.from(canonical)).digest('hex')}`,
84
+ };
85
+ }
86
+
87
+ export function verifyTrustedApproval({
88
+ approval,
89
+ binding,
90
+ now,
91
+ redeemedNonces,
92
+ trustedSources = new Set(['consumer']),
93
+ }) {
94
+ if (approvalSchemaError(approval)) return refuse('invalid-approval');
95
+ if (!(trustedSources instanceof Set)
96
+ || trustedSources.size !== 1
97
+ || !trustedSources.has('consumer')
98
+ || approval.source !== 'consumer') {
99
+ return refuse('approval-source-untrusted', { source: approval.source });
100
+ }
101
+ if (!RFC3339.test(now) || !validCanonicalTime(now)) {
102
+ return refuse('invalid-approval-time', { member: 'now' });
103
+ }
104
+ const issued = Date.parse(approval.issued_at);
105
+ const expires = Date.parse(approval.expires_at);
106
+ const current = Date.parse(now);
107
+ if (issued >= expires) return refuse('invalid-approval-time-order');
108
+ if (current < issued) return refuse('approval-not-yet-valid', { issued_at: approval.issued_at });
109
+ if (current >= expires) return refuse('approval-expired', { expires_at: approval.expires_at });
110
+ if (!(redeemedNonces instanceof Set)) return refuse('invalid-approval');
111
+ if (redeemedNonces.has(approval.nonce)) {
112
+ return refuse('approval-replayed', { nonce: approval.nonce });
113
+ }
114
+ let invocationDigest;
115
+ try {
116
+ invocationDigest = canonicalInvocationDigest(binding).digest;
117
+ } catch {
118
+ return refuse('invalid-approval-binding');
119
+ }
120
+ if (invocationDigest !== approval.invocation_digest) return refuse('approval-binding-mismatch');
121
+ redeemedNonces.add(approval.nonce);
122
+ return { ok: true, nonce: approval.nonce };
123
+ }
124
+
125
+ export function verifyMutationAuthority({ command, approval, approvalOptions }) {
126
+ if (command !== 'create' && command !== 'transition' && command !== 'patch') {
127
+ return { ok: true, authority: [] };
128
+ }
129
+ if (approval === null || approval === undefined) {
130
+ return refuse('consumer-approval-required', { command });
131
+ }
132
+ const verified = verifyTrustedApproval({ approval, ...approvalOptions });
133
+ if (!verified.ok) return verified;
134
+ return { ok: true, authority: [`core:${command}`], nonce: verified.nonce };
135
+ }
@@ -0,0 +1,43 @@
1
+ import { normalizeJsonValue, parseJsonRequest } from '../request.js';
2
+
3
+ // The bootstrap wire (contract section 3.3): exactly one strict UTF-8 JSON
4
+ // object in on stdin, then stdin closes; exactly one strict JSON object plus
5
+ // one LF out on stdout. `readBootstrapRequest` collects bounded stream
6
+ // bytes before parsing — `parseJsonRequest` decodes UTF-8 fatally and reports
7
+ // duplicate members and trailing bytes through `issues` rather than
8
+ // throwing, so a non-empty `issues` array is the only acceptable-parse gate.
9
+ export async function readBootstrapRequest(stream, { maxBytes, errorCode = 'invalid-describe-request' } = {}) {
10
+ const chunks = [];
11
+ let byteLength = 0;
12
+ // A stream error must surface as a refusal, never as an uncaught rejection: the
13
+ // entrypoint has to emit exactly one JSON object plus one LF and exit zero on
14
+ // every path (§3.3), including a stdin read failure.
15
+ try {
16
+ for await (const chunk of stream) {
17
+ byteLength += chunk.length;
18
+ if (maxBytes !== undefined && byteLength > maxBytes) {
19
+ return {
20
+ ok: false,
21
+ error_code: errorCode,
22
+ detail: { member: 'request', reason: 'byte-limit-exceeded', limit_bytes: maxBytes },
23
+ };
24
+ }
25
+ chunks.push(chunk);
26
+ }
27
+ } catch {
28
+ return { ok: false, error_code: errorCode, detail: { member: 'request', reason: 'stream-error' } };
29
+ }
30
+ const bytes = Buffer.concat(chunks);
31
+ const parsed = parseJsonRequest(bytes);
32
+ if (parsed.issues.length > 0) {
33
+ return { ok: false, error_code: errorCode, detail: { member: 'request_json' } };
34
+ }
35
+ return { ok: true, request: normalizeJsonValue(parsed.value), bytes };
36
+ }
37
+
38
+ // Writes exactly one JSON object plus one trailing LF, and nothing else.
39
+ export function writeBootstrapResponse(stream, response) {
40
+ return new Promise((resolve, reject) => {
41
+ stream.write(`${JSON.stringify(response)}\n`, (error) => (error ? reject(error) : resolve()));
42
+ });
43
+ }
@@ -0,0 +1,34 @@
1
+ import { validateHandoffCarrier } from './handoff.js';
2
+ import { validateInstructionInput } from './instructions.js';
3
+
4
+ function refuse(error_code, detail = {}) {
5
+ return { ok: false, error_code, detail };
6
+ }
7
+
8
+ export function validateInvokeContext({
9
+ instruction_input: instructionInput,
10
+ handoff_carrier: handoffCarrier,
11
+ context_bytes: contextBytes,
12
+ instruction_limits: instructionLimits,
13
+ handoff_options: handoffOptions,
14
+ }) {
15
+ const instructions = validateInstructionInput(instructionInput, instructionLimits);
16
+ if (!instructions.ok) return instructions;
17
+ const handoff = handoffCarrier === null
18
+ ? { ok: true, byte_length: 0 }
19
+ : validateHandoffCarrier(handoffCarrier, handoffOptions);
20
+ if (!handoff.ok) return handoff;
21
+ if (handoffCarrier !== null
22
+ && handoffCarrier.resume_request.instruction_set_digest !== instructions.instruction_set_digest) {
23
+ return refuse('handoff-instruction-set-mismatch');
24
+ }
25
+ const totalBytes = instructions.total_bytes + handoff.byte_length;
26
+ if (totalBytes > contextBytes) {
27
+ return refuse('context-limit-exceeded', {
28
+ instruction_bytes: instructions.total_bytes,
29
+ handoff_bytes: handoff.byte_length,
30
+ context_bytes: contextBytes,
31
+ });
32
+ }
33
+ return { ok: true, total_bytes: totalBytes, instructions, handoff };
34
+ }
@@ -0,0 +1,231 @@
1
+ import { hasExactMembers } from './schema-helpers.js';
2
+
3
+ // The version 2 core command list, in the fixed advertising order (contract
4
+ // section 3). `describe.js` also needs this order to validate the
5
+ // `core.commands` subset it accepts, so it is exported from here.
6
+ export const CORE_COMMAND_ORDER = Object.freeze([
7
+ 'capabilities', 'create', 'inspect', 'patch', 'ready', 'transition', 'validate',
8
+ ]);
9
+ export const CORE_CONTRACT_VERSION = 2;
10
+
11
+ function refuse(error_code, detail) {
12
+ return { ok: false, error_code, detail };
13
+ }
14
+
15
+ function backendIssue(backend) {
16
+ if (!hasExactMembers(backend, ['name', 'coordination_scope'])
17
+ || backend.name !== 'local-filesystem'
18
+ || backend.coordination_scope !== 'same-working-copy-cooperative-writers') {
19
+ return 'result.backend';
20
+ }
21
+ return null;
22
+ }
23
+
24
+ function inspectOperationIssue(inspect) {
25
+ if (!hasExactMembers(inspect, ['supported', 'write_scope', 'cas_scope'])
26
+ || inspect.supported !== true
27
+ || inspect.write_scope !== 'none'
28
+ || inspect.cas_scope !== 'none') {
29
+ return 'result.operations.inspect';
30
+ }
31
+ return null;
32
+ }
33
+
34
+ function createOperationIssue(create) {
35
+ if (!hasExactMembers(create, [
36
+ 'supported', 'write_scope', 'cas_scope', 'publication_visibility', 'publication_probe',
37
+ ])
38
+ || create.supported !== true
39
+ || create.write_scope !== 'single-item'
40
+ || create.cas_scope !== 'requested-id-lock'
41
+ || create.publication_visibility !== 'atomic-no-clobber-or-fail'
42
+ || create.publication_probe !== 'per-ledger-operation') {
43
+ return 'result.operations.create';
44
+ }
45
+ return null;
46
+ }
47
+
48
+ function transitionOperationIssue(transition) {
49
+ if (!hasExactMembers(transition, ['supported', 'write_scope', 'cas_scope'])
50
+ || transition.supported !== true
51
+ || transition.write_scope !== 'single-item'
52
+ || transition.cas_scope !== 'exact-byte-sha256') {
53
+ return 'result.operations.transition';
54
+ }
55
+ return null;
56
+ }
57
+
58
+ function patchOperationIssue(patch) {
59
+ if (!hasExactMembers(patch, ['supported', 'write_scope', 'cas_scope'])
60
+ || patch.supported !== true
61
+ || patch.write_scope !== 'single-item'
62
+ || patch.cas_scope !== 'exact-byte-sha256') {
63
+ return 'result.operations.patch';
64
+ }
65
+ return null;
66
+ }
67
+
68
+ // Every member except `supported` is a permanent advisory-claims invariant:
69
+ // claims never protect publication, never fence writers, and must never
70
+ // advertise safe exclusive dispatch.
71
+ function workClaimOperationIssue(workClaim) {
72
+ if (!hasExactMembers(workClaim, [
73
+ 'supported', 'api_version', 'mode', 'claim_protected_publication',
74
+ 'fencing_enforced_at', 'safe_exclusive_dispatch',
75
+ ])
76
+ || typeof workClaim.supported !== 'boolean'
77
+ || workClaim.api_version !== 1
78
+ || workClaim.mode !== 'advisory'
79
+ || workClaim.claim_protected_publication !== false
80
+ || workClaim.fencing_enforced_at !== 'none'
81
+ || workClaim.safe_exclusive_dispatch !== false) {
82
+ return 'result.operations.work_claim';
83
+ }
84
+ return null;
85
+ }
86
+
87
+ function operationsIssue(operations) {
88
+ if (!hasExactMembers(operations, ['inspect', 'create', 'transition', 'patch', 'work_claim'])) {
89
+ return 'result.operations';
90
+ }
91
+ return inspectOperationIssue(operations.inspect)
92
+ || createOperationIssue(operations.create)
93
+ || transitionOperationIssue(operations.transition)
94
+ || patchOperationIssue(operations.patch)
95
+ || workClaimOperationIssue(operations.work_claim);
96
+ }
97
+
98
+ function durabilityIssue(durability) {
99
+ if (!hasExactMembers(durability, [
100
+ 'temporary_file_sync', 'directory_sync', 'post_publication_verification', 'power_loss_guarantee',
101
+ ])
102
+ || durability.temporary_file_sync !== 'required-before-publication'
103
+ || durability.directory_sync !== 'best-effort-when-supported'
104
+ || durability.post_publication_verification !== 'exact-bytes-required'
105
+ || durability.power_loss_guarantee !== 'none') {
106
+ return 'result.durability';
107
+ }
108
+ return null;
109
+ }
110
+
111
+ function limitsIssue(limits) {
112
+ if (!hasExactMembers(limits, [
113
+ 'multi_item_atomicity', 'cross_clone_coordination', 'cross_worktree_coordination',
114
+ 'cross_machine_coordination', 'noncooperating_writer_protection', 'automatic_stale_lock_breaking',
115
+ ])
116
+ || limits.multi_item_atomicity !== false
117
+ || limits.cross_clone_coordination !== false
118
+ || limits.cross_worktree_coordination !== false
119
+ || limits.cross_machine_coordination !== false
120
+ || limits.noncooperating_writer_protection !== false
121
+ || limits.automatic_stale_lock_breaking !== false) {
122
+ return 'result.limits';
123
+ }
124
+ return null;
125
+ }
126
+
127
+ function coreProbeSchemaIssue(probe) {
128
+ if (!hasExactMembers(probe, ['ok', 'command', 'contract_version', 'result'])) {
129
+ return 'members';
130
+ }
131
+ if (probe.ok !== true) {
132
+ return 'ok';
133
+ }
134
+ if (probe.command !== 'capabilities') {
135
+ return 'command';
136
+ }
137
+ if (probe.contract_version !== CORE_CONTRACT_VERSION) {
138
+ return 'contract_version';
139
+ }
140
+ const result = probe.result;
141
+ if (!hasExactMembers(result, ['backend', 'operations', 'durability', 'limits'])) {
142
+ return 'result';
143
+ }
144
+ return backendIssue(result.backend)
145
+ || operationsIssue(result.operations)
146
+ || durabilityIssue(result.durability)
147
+ || limitsIssue(result.limits);
148
+ }
149
+
150
+ function sameCommandOrder(commands) {
151
+ return Array.isArray(commands)
152
+ && commands.length === CORE_COMMAND_ORDER.length
153
+ && commands.every((command, index) => command === CORE_COMMAND_ORDER[index]);
154
+ }
155
+
156
+ // verifyCoreProbe(describe, probe) checks the independently-launched core
157
+ // `capabilities --json` probe against an already-validated describe
158
+ // result (contract section 3): the probe must match the exact version 2
159
+ // envelope, its contract version must match the required core contract
160
+ // version, its command list must match the advertised one, and neither
161
+ // optional feature may be elevated beyond what the probe actually supports.
162
+ export function verifyCoreProbe(describe, probe) {
163
+ const schemaIssue = coreProbeSchemaIssue(probe);
164
+ if (schemaIssue) {
165
+ return refuse('core-protocol-error', { member: schemaIssue });
166
+ }
167
+ const required = describe?.core?.required_core_contract_version;
168
+ if (probe.contract_version !== required) {
169
+ return refuse('core-contract-version-mismatch', { required, probed: probe.contract_version });
170
+ }
171
+ if (!sameCommandOrder(describe.core.commands)) {
172
+ return refuse('core-contract-version-mismatch', { member: 'core.commands' });
173
+ }
174
+ if (describe.optional_features?.claims !== probe.result.operations.work_claim.supported) {
175
+ return refuse('core-contract-version-mismatch', { member: 'optional_features.claims' });
176
+ }
177
+ if (describe.optional_features?.policy !== false) {
178
+ return refuse('core-contract-version-mismatch', { member: 'optional_features.policy' });
179
+ }
180
+ return { ok: true };
181
+ }
182
+
183
+ // The engine's own core capability snapshot: this repository's core is a
184
+ // same-process, same-working-copy `local-filesystem` backend. Advisory
185
+ // work-claim visibility is independent of this mutation-coordination scope
186
+ // (contract section 3: "optional_features.claims is derived only from
187
+ // operations.work_claim.supported").
188
+ export function coreCapabilities() {
189
+ return {
190
+ ok: true,
191
+ command: 'capabilities',
192
+ contract_version: CORE_CONTRACT_VERSION,
193
+ result: {
194
+ backend: { name: 'local-filesystem', coordination_scope: 'same-working-copy-cooperative-writers' },
195
+ operations: {
196
+ inspect: { supported: true, write_scope: 'none', cas_scope: 'none' },
197
+ create: {
198
+ supported: true,
199
+ write_scope: 'single-item',
200
+ cas_scope: 'requested-id-lock',
201
+ publication_visibility: 'atomic-no-clobber-or-fail',
202
+ publication_probe: 'per-ledger-operation',
203
+ },
204
+ transition: { supported: true, write_scope: 'single-item', cas_scope: 'exact-byte-sha256' },
205
+ patch: { supported: true, write_scope: 'single-item', cas_scope: 'exact-byte-sha256' },
206
+ work_claim: {
207
+ supported: false,
208
+ api_version: 1,
209
+ mode: 'advisory',
210
+ claim_protected_publication: false,
211
+ fencing_enforced_at: 'none',
212
+ safe_exclusive_dispatch: false,
213
+ },
214
+ },
215
+ durability: {
216
+ temporary_file_sync: 'required-before-publication',
217
+ directory_sync: 'best-effort-when-supported',
218
+ post_publication_verification: 'exact-bytes-required',
219
+ power_loss_guarantee: 'none',
220
+ },
221
+ limits: {
222
+ multi_item_atomicity: false,
223
+ cross_clone_coordination: false,
224
+ cross_worktree_coordination: false,
225
+ cross_machine_coordination: false,
226
+ noncooperating_writer_protection: false,
227
+ automatic_stale_lock_breaking: false,
228
+ },
229
+ },
230
+ };
231
+ }