@dotdrelle/wiki-manager 0.9.3 → 0.10.4

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 CHANGED
@@ -527,13 +527,17 @@ The shared `docker-compose.yml` starts one workspace stack:
527
527
 
528
528
  | Service | Role | Port variable |
529
529
  | --- | --- | --- |
530
- | `serve` | Wiki web UI and browser chat | `WIKI_SERVE_PORT` |
531
- | `mcp-http` | llm-wiki MCP endpoint | `WIKI_MCP_PORT` |
532
- | `production-mcp` | Production job MCP endpoint | `PRODUCTION_MCP_PORT` |
530
+ | `serve` | Wiki web UI and browser chat, container port `3000` | `WIKI_SERVE_PORT` |
531
+ | `mcp-http` | llm-wiki MCP endpoint, container port `3333` | `WIKI_MCP_PORT` |
532
+ | `production-mcp` | Production job MCP endpoint, container port `8080` | `PRODUCTION_MCP_PORT` |
533
533
 
534
534
  Use `wiki-workspace` whenever possible so Compose receives the right project
535
535
  name, env file, ports, and volume mounts.
536
536
 
537
+ Runtime split: the host manager/runtime uses Node.js 22+ for `node:sqlite`; the
538
+ interactive OpenTUI shell uses Bun 1.2+; workspace Docker services run from the
539
+ published images and do not depend on host `node_modules`.
540
+
537
541
  ```bash
538
542
  wiki-workspace list
539
543
  wiki-workspace agents up
@@ -799,12 +803,23 @@ Files matching `docker-compose*.local.yml` are ignored by Git.
799
803
  ```bash
800
804
  pnpm install
801
805
  pnpm start
806
+ pnpm run check-versions
802
807
  pnpm run check
803
808
  ```
804
809
 
805
- When bumping the package version, update both `package.json` and the Streamable
806
- HTTP MCP `clientInfo.version` in `src/core/mcp.js`. They are kept explicit so
807
- remote MCP server logs show the manager build that initiated the handshake.
810
+ When bumping a coordinated release, keep `llm-wiki`, `llm-wiki-manager`, Python
811
+ agent `_AGENT_VERSION` values, MCP `clientInfo.version` / server versions, Git
812
+ tags, and Docker image tags aligned. Run:
813
+
814
+ ```bash
815
+ pnpm run check-versions
816
+ CHECK_GIT_TAG=1 pnpm run check-versions # pre-release tag check
817
+ CHECK_DOCKER_IMAGES=1 pnpm run check-versions # after local image build
818
+ ```
819
+
820
+ `build-and-push.sh` synchronizes the coordinated version, runs
821
+ `pnpm run check-versions`, builds images tagged with that version, and can push
822
+ the matching `latest` tags.
808
823
 
809
824
  `pnpm run check` verifies the CLI version, help output, and limited `--once` mode.
810
825
  For headless changes, also test a controlled error path, for example:
@@ -21,6 +21,7 @@
21
21
  # MAILERSEND_API_KEY
22
22
  # MAILERSEND_FROM_EMAIL
23
23
  # MAILERSEND_FROM_NAME
24
+ # MAILERSEND_CA_CERT — optional CA bundle path inside the container
24
25
  #
25
26
  # Set these variables in a .env file at the directory where you run wiki-workspace,
26
27
  # or export them in your shell before running wiki-workspace agents up.
@@ -90,7 +91,11 @@ services:
90
91
  - MAILERSEND_FROM_NAME=${MAILERSEND_FROM_NAME:-Mailer Agent}
91
92
  - MAILERSEND_USER_AGENT=${MAILERSEND_USER_AGENT:-curl/8.7.1}
92
93
  - MAILERSEND_VERIFY_SSL=${MAILERSEND_VERIFY_SSL:-true}
94
+ - MAILERSEND_CA_CERT=${MAILERSEND_CA_CERT:-}
93
95
  - MAILER_REQUIRE_CONFIRMATION=${MAILER_REQUIRE_CONFIRMATION:-true}
94
96
  - MAILER_DRY_RUN=${MAILER_DRY_RUN:-false}
95
97
  - MCP_AUTH_TOKEN=${MAILER_MCP_AUTH_TOKEN:-}
98
+ volumes:
99
+ # Optional: mount a CA bundle and set MAILERSEND_CA_CERT to its container path.
100
+ #- ${AGENTS_DATA_DIR:-./.agents-data}/certs:/certs:ro
96
101
  restart: unless-stopped
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotdrelle/wiki-manager",
3
- "version": "0.9.3",
3
+ "version": "0.10.4",
4
4
  "description": "Agentic shell and orchestration cockpit for llm-wiki workspaces.",
5
5
  "license": "PolyForm-Noncommercial-1.0.0",
6
6
  "author": "dotrelle",
@@ -11,7 +11,10 @@
11
11
  },
12
12
  "scripts": {
13
13
  "start": "bun ./bin/wiki-manager.js",
14
- "test": "node --test src/agent/graph.test.js src/core/activity.test.js src/core/agentEvents.test.js src/core/agentLoop.test.js src/core/plan.test.js src/core/mcp.test.js src/core/documentIntake.test.js src/core/dockerCompose.test.js src/core/wikiWorkspace.test.js src/core/wikirc.test.js src/core/modelFetch.test.js src/core/startupCheck.test.js src/core/queueStore.test.js src/commands/slash.test.js src/shell/repl.test.js src/runtime/store.test.js src/runtime/server.test.js src/runtime/supervisor.test.js src/runtime/runner.test.js src/runtime/auth.test.js",
14
+ "test": "node --test src/agent/graph.test.js src/contracts/schemas.test.js src/core/activity.test.js src/core/agentEvents.test.js src/core/workflow.test.js src/core/planPatch.test.js src/core/agentLoop.test.js src/core/plan.test.js src/core/mcp.test.js src/core/documentIntake.test.js src/core/dockerCompose.test.js src/core/wikiWorkspace.test.js src/core/wikirc.test.js src/core/modelFetch.test.js src/core/startupCheck.test.js src/core/queueStore.test.js src/commands/slash.test.js src/shell/repl.test.js src/runtime/store.test.js src/runtime/server.test.js src/runtime/supervisor.test.js src/runtime/runner.test.js src/runtime/auth.test.js",
15
+ "check-versions": "node scripts/check-versions.js",
16
+ "prepack": "node scripts/check-versions.js",
17
+ "prepublishOnly": "node scripts/check-versions.js",
15
18
  "check": "bun ./bin/wiki-manager.js --version && bun ./bin/wiki-manager.js --help && bun ./bin/wiki-manager.js --once \"verifie le mode agent\""
16
19
  },
17
20
  "engines": {
@@ -66,8 +66,26 @@ const WIKI_PLAN_SET_TOOL = {
66
66
  properties: {
67
67
  steps: {
68
68
  type: 'array',
69
- items: { type: 'string' },
70
- description: 'Ordered step descriptions, e.g. ["CME export", "Production ingest", "Build", "Polish", "Email report"].',
69
+ items: {
70
+ anyOf: [
71
+ { type: 'string' },
72
+ {
73
+ type: 'object',
74
+ additionalProperties: false,
75
+ properties: {
76
+ id: { type: 'string' },
77
+ description: { type: 'string' },
78
+ status: { type: 'string', enum: ['pending', 'queued', 'running', 'waiting', 'pending_approval', 'done', 'failed', 'cancelled', 'stalled', 'added_during_run'] },
79
+ dependsOn: { type: 'array', items: { type: 'string' } },
80
+ executor: { type: ['string', 'null'] },
81
+ executorQuery: { type: ['object', 'null'], additionalProperties: true },
82
+ outputRefs: { type: 'array', items: { type: 'string' } },
83
+ },
84
+ required: ['description'],
85
+ },
86
+ ],
87
+ },
88
+ description: 'Ordered steps. Backward-compatible strings are accepted; structured steps may include id, dependsOn, executor, executorQuery, outputRefs.',
71
89
  },
72
90
  },
73
91
  required: ['steps'],
@@ -344,11 +362,7 @@ function handleWikiTool(session, tool, args) {
344
362
  if (tool === 'plan_set') {
345
363
  const steps = Array.isArray(args.steps) ? args.steps : [];
346
364
  emitAgentEvent(session, 'plan_set', 'tool', {
347
- steps: steps.map((description, i) => ({
348
- step: i + 1,
349
- description: String(description),
350
- status: 'pending',
351
- })),
365
+ steps: steps.map((raw, i) => normalizeDeclaredPlanStep(raw, i, session)),
352
366
  });
353
367
  return `Plan registered: ${steps.length} step${steps.length !== 1 ? 's' : ''}.`;
354
368
  }
@@ -364,6 +378,51 @@ function handleWikiTool(session, tool, args) {
364
378
  return `Unknown wiki tool: ${tool}`;
365
379
  }
366
380
 
381
+ function normalizeDeclaredPlanStep(raw, index, session) {
382
+ const item = raw && typeof raw === 'object' && !Array.isArray(raw)
383
+ ? raw
384
+ : { description: String(raw) };
385
+ const description = String(item.description ?? item.label ?? item.name ?? item.id ?? `Step ${index + 1}`);
386
+ return {
387
+ step: Number(item.step ?? index + 1),
388
+ id: item.id ? String(item.id) : slugStepId(description, index),
389
+ description,
390
+ status: item.status ?? 'pending',
391
+ dependsOn: Array.isArray(item.dependsOn) ? item.dependsOn.map(String) : [],
392
+ executor: item.executor ?? selectExecutorForStep(description, session),
393
+ executorQuery: item.executorQuery ?? null,
394
+ outputRefs: Array.isArray(item.outputRefs) ? item.outputRefs.map(String) : [],
395
+ };
396
+ }
397
+
398
+ function slugStepId(description, index) {
399
+ const slug = String(description)
400
+ .toLowerCase()
401
+ .normalize('NFD')
402
+ .replace(/[\u0300-\u036f]/g, '')
403
+ .replace(/[^a-z0-9]+/g, '-')
404
+ .replace(/^-+|-+$/g, '')
405
+ .slice(0, 48);
406
+ return slug || `task-${index + 1}`;
407
+ }
408
+
409
+ function selectExecutorForStep(description, session) {
410
+ const text = String(description ?? '').toLowerCase();
411
+ let fallback = null;
412
+ for (const [serverName, value] of Object.entries(session.mcp ?? {})) {
413
+ if (value.status !== 'connected') continue;
414
+ for (const tool of value.tools ?? []) {
415
+ const executor = `${serverName}.${tool.name}`;
416
+ fallback ??= executor;
417
+ const haystack = `${serverName} ${tool.name} ${tool.description ?? ''}`.toLowerCase();
418
+ if (text.split(/[^a-z0-9]+/).filter((token) => token.length >= 4).some((token) => haystack.includes(token))) {
419
+ return executor;
420
+ }
421
+ }
422
+ }
423
+ return fallback;
424
+ }
425
+
367
426
  export function buildAgentSystemPrompt(state) {
368
427
  const workspace = state.session.workspace ?? 'no workspace selected';
369
428
  const wikirc = state.session.wikirc?.profile ?? 'no profile loaded';
@@ -401,8 +460,8 @@ export function buildAgentSystemPrompt(state) {
401
460
  '',
402
461
  'Task startup:',
403
462
  ' 1. If the next MCP tool returns _activity.plan.steps, call that tool directly; the shell will create the visible plan from the returned activity.',
404
- ' 2. If the tool cannot declare its own plan, call wiki__plan_set(steps=["Step description", ...]) before executing the first step.',
405
- ' Multi-tool example: wiki__plan_set(steps=["CME export", "Production pipeline", "Email report"])',
463
+ ' 2. If the tool cannot declare its own plan, call wiki__plan_set before executing the first step. Prefer structured steps: {id, description, dependsOn, executor, executorQuery, outputRefs}; a legacy list of strings is still accepted.',
464
+ ' Multi-tool example: wiki__plan_set(steps=[{id:"cme-export",description:"CME export",dependsOn:[],executor:"cme.cme_export_run",outputRefs:["raw/untracked"]},{id:"production",description:"Production pipeline",dependsOn:["cme-export"],executor:"production.production_start_job",outputRefs:["deliverables"]}])',
406
465
  ' 3. Immediately execute the first step using the appropriate MCP tool. Do not start step 2 in the same turn unless one async pipeline tool owns and declares the whole sequence.',
407
466
  ' For synchronous steps (result is immediate, no _activity polling), call wiki__plan_done(step=1) after confirming success.',
408
467
  ' For async MCP jobs (returns _activity with poll), the orchestrator tracks completion automatically.',
@@ -143,3 +143,67 @@ test('agent graph waits for tool-level approval configured on endpoint', async (
143
143
  }
144
144
  });
145
145
 
146
+ test('agent graph accepts structured wiki plan steps and selects MCP executors', async () => {
147
+ let calls = 0;
148
+ const session = sessionBase({
149
+ mcp: {
150
+ cme: {
151
+ status: 'connected',
152
+ url: 'http://127.0.0.1:3001/mcp/',
153
+ tools: [{
154
+ name: 'cme_export_run',
155
+ description: 'Export CME pages',
156
+ inputSchema: { type: 'object', properties: {} },
157
+ }],
158
+ },
159
+ production: {
160
+ status: 'connected',
161
+ url: 'http://127.0.0.1:3000/mcp/',
162
+ tools: [{
163
+ name: 'production_start_job',
164
+ description: 'Start production job',
165
+ inputSchema: { type: 'object', properties: { type: { type: 'string' } } },
166
+ }],
167
+ },
168
+ },
169
+ llm: {
170
+ async completeWithTools() {
171
+ calls += 1;
172
+ if (calls === 1) {
173
+ return {
174
+ content: null,
175
+ message: { role: 'assistant', content: null },
176
+ tool_calls: [{
177
+ id: 'plan-call',
178
+ type: 'function',
179
+ function: {
180
+ name: 'wiki__plan_set',
181
+ arguments: JSON.stringify({
182
+ steps: [
183
+ { id: 'cme-export', description: 'Export CME pages', outputRefs: ['raw/untracked'] },
184
+ { id: 'build', description: 'Run production build', dependsOn: ['cme-export'] },
185
+ ],
186
+ }),
187
+ },
188
+ }],
189
+ };
190
+ }
191
+ return {
192
+ content: 'Plan ready.',
193
+ message: { role: 'assistant', content: 'Plan ready.' },
194
+ tool_calls: null,
195
+ };
196
+ },
197
+ },
198
+ });
199
+
200
+ const agent = createAgentGraph();
201
+ const result = await agent.invoke({ input: 'Plan export then build', session });
202
+
203
+ assert.equal(result.response, 'Plan ready.');
204
+ assert.deepEqual(session.headlessPlan.map((step) => step.id), ['cme-export', 'build']);
205
+ assert.equal(session.headlessPlan[0].executor, 'cme.cme_export_run');
206
+ assert.equal(session.headlessPlan[1].executor, 'production.production_start_job');
207
+ assert.deepEqual(session.headlessPlan[1].dependsOn, ['cme-export']);
208
+ assert.deepEqual(session.headlessPlan[0].outputRefs, ['raw/untracked']);
209
+ });
@@ -163,7 +163,7 @@ async function runHeadlessAgenticLoop(agent, session, initialInput, log, { timeo
163
163
  console.log(response);
164
164
  },
165
165
  onPlanExtracted: ({ steps }) => {
166
- log.push(`agentic-loop: plan extracted from text (${steps.length} steps, fallback)`);
166
+ log.push(`agentic-loop: plan extracted from text (${steps.length} steps, deprecated fallback)`);
167
167
  },
168
168
  onPlanAlreadySet: ({ steps }) => {
169
169
  log.push(`agentic-loop: plan set via tool (${steps.length} steps)`);
@@ -615,8 +615,8 @@ ${helpPair('/workspace list', 'Workspaces', '/new <n> [path]', 'New workspace')}
615
615
  ${helpPair('/use <workspace>', 'Use workspace', '/status', 'Session status')}
616
616
  ${helpPair('/config list', 'Config profiles', '/config use <n>', 'Use config')}
617
617
  ${helpPair('/config edit <n>', 'Edit config', '/workspace delete <n>', 'Delete workspace')}
618
- ${helpPair('/services', 'Services', '/start [service|agents]', 'Start service(s)')}
619
- ${helpPair('/stop [service|agents]', 'Stop service(s)', '/logs <service>', 'Service logs')}
618
+ ${helpPair('/services', 'Services', '/start [all|service|agents]', 'Start service(s)')}
619
+ ${helpPair('/stop [all|service|agents]', 'Stop service(s)', '/logs <service>', 'Service logs')}
620
620
  ${helpPair('/skills', 'List skills', '/skills show <n>', 'Show skill')}
621
621
  ${helpPair('/skills run <n>', 'Run skill guide', '/skills edit <n>', 'Edit skill')}
622
622
  ${helpPair('/mcp status', 'MCP status', '/mcp endpoints', 'MCP endpoints')}
@@ -810,6 +810,10 @@ export async function handleSlashCommand(line, context) {
810
810
  }
811
811
  }
812
812
  case 'start': {
813
+ // 'all' already resolves correctly through serviceAliases() (DEFAULT_SERVICE_ALIASES.all
814
+ // = COMPOSE_SERVICES, overridable via docker-compose.yml's service-aliases.all.targets) —
815
+ // do not remap it to undefined, that bypasses any custom "all" target list and always
816
+ // falls back to the hardcoded COMPOSE_SERVICES constant instead.
813
817
  const service = args[1];
814
818
  if (service === 'agents') return runAgentCommand(startAgents, 'start');
815
819
  try {
@@ -0,0 +1,16 @@
1
+ # Runtime Contracts
2
+
3
+ Versioned JSON Schema-like contracts live in `schemas.js`.
4
+
5
+ Current schema version: `1`.
6
+
7
+ Validated boundaries:
8
+
9
+ - `_activity` after normalization in `core/activity.js`
10
+ - `AgentRunEvent` creation and dispatch in `core/agentEvents.js`
11
+ - structured plan and plan patch normalization
12
+ - runtime `/run` and `/control` request payloads
13
+
14
+ Validation is enabled when `WIKI_MANAGER_VALIDATE_CONTRACTS=1`, `CI=true`, or
15
+ `NODE_ENV` is set to a non-production value. Schemas tolerate additional fields
16
+ so agents can extend payloads without breaking older consumers.
@@ -0,0 +1,302 @@
1
+ const STATUS_VALUES = [
2
+ 'pending',
3
+ 'queued',
4
+ 'running',
5
+ 'waiting',
6
+ 'pending_approval',
7
+ 'done',
8
+ 'failed',
9
+ 'cancelled',
10
+ 'canceled',
11
+ 'stalled',
12
+ 'added_during_run',
13
+ 'error',
14
+ 'complete',
15
+ 'completed',
16
+ 'success',
17
+ ];
18
+
19
+ const nullableString = { type: ['string', 'null'] };
20
+ const nullableObject = { type: ['object', 'null'], additionalProperties: true };
21
+ const outputReferenceSchema = {
22
+ $id: 'https://dotdrelle.dev/wiki-manager/contracts/output-reference/v1',
23
+ title: 'OutputReference',
24
+ schemaVersion: '1',
25
+ oneOf: [
26
+ { type: 'string' },
27
+ {
28
+ type: 'object',
29
+ required: ['type', 'ref'],
30
+ additionalProperties: true,
31
+ properties: {
32
+ type: { type: 'string' },
33
+ ref: { type: 'string' },
34
+ label: nullableString,
35
+ workspace: nullableString,
36
+ },
37
+ },
38
+ ],
39
+ };
40
+
41
+ const planStepSchema = {
42
+ type: 'object',
43
+ required: ['description', 'status', 'dependsOn', 'outputRefs'],
44
+ additionalProperties: true,
45
+ properties: {
46
+ step: { type: 'number' },
47
+ id: nullableString,
48
+ description: { type: 'string' },
49
+ status: { type: 'string' },
50
+ dependsOn: { type: 'array', items: { type: 'string' } },
51
+ executor: nullableString,
52
+ executorQuery: nullableObject,
53
+ outputRefs: { type: 'array', items: outputReferenceSchema },
54
+ },
55
+ };
56
+
57
+ const patchTaskSchema = {
58
+ type: 'object',
59
+ required: ['id', 'description'],
60
+ additionalProperties: true,
61
+ properties: {
62
+ id: { type: 'string' },
63
+ description: { type: 'string' },
64
+ status: { type: 'string' },
65
+ dependsOn: { type: 'array', items: { type: 'string' } },
66
+ executor: nullableString,
67
+ executorQuery: nullableObject,
68
+ outputRefs: { type: 'array', items: outputReferenceSchema },
69
+ },
70
+ };
71
+
72
+ export const contractSchemas = {
73
+ activity: {
74
+ $id: 'https://dotdrelle.dev/wiki-manager/contracts/activity/v1',
75
+ title: '_activity',
76
+ schemaVersion: '1',
77
+ type: 'object',
78
+ required: ['schemaVersion', 'id', 'source', 'kind', 'label', 'status', 'progress', 'poll', 'outputRefs'],
79
+ additionalProperties: true,
80
+ properties: {
81
+ schemaVersion: { const: '1' },
82
+ id: { type: 'string' },
83
+ source: { type: 'string' },
84
+ kind: { type: 'string' },
85
+ label: { type: 'string' },
86
+ status: { type: 'string' },
87
+ progress: {
88
+ type: 'object',
89
+ additionalProperties: true,
90
+ properties: {
91
+ percent: { type: 'number', minimum: 0, maximum: 100 },
92
+ stepId: { type: 'string' },
93
+ parentActivityKey: { type: 'string' },
94
+ detail: { type: 'string' },
95
+ },
96
+ },
97
+ poll: nullableObject,
98
+ outputRefs: { type: 'array', items: outputReferenceSchema },
99
+ },
100
+ },
101
+ agentRunEvent: {
102
+ $id: 'https://dotdrelle.dev/wiki-manager/contracts/agent-run-event/v1',
103
+ title: 'AgentRunEvent',
104
+ schemaVersion: '1',
105
+ type: 'object',
106
+ required: ['id', 'ts', 'type', 'origin', 'payload'],
107
+ additionalProperties: true,
108
+ properties: {
109
+ id: { type: 'string' },
110
+ ts: { type: 'string' },
111
+ type: { type: 'string' },
112
+ origin: { type: 'string' },
113
+ runId: nullableString,
114
+ turnId: nullableString,
115
+ taskId: nullableString,
116
+ workspace: nullableString,
117
+ payload: { type: 'object', additionalProperties: true },
118
+ },
119
+ },
120
+ runRequest: {
121
+ $id: 'https://dotdrelle.dev/wiki-manager/contracts/run-request/v1',
122
+ title: 'RuntimeRunRequest',
123
+ schemaVersion: '1',
124
+ type: 'object',
125
+ required: ['input'],
126
+ additionalProperties: true,
127
+ properties: {
128
+ input: { type: 'string', minLength: 1 },
129
+ prompt: { type: 'string' },
130
+ workspace: nullableString,
131
+ runId: nullableString,
132
+ turnId: nullableString,
133
+ },
134
+ },
135
+ controlMessage: {
136
+ $id: 'https://dotdrelle.dev/wiki-manager/contracts/control-message/v1',
137
+ title: 'RuntimeControlMessage',
138
+ schemaVersion: '1',
139
+ type: 'object',
140
+ required: ['action'],
141
+ additionalProperties: true,
142
+ properties: {
143
+ action: { type: 'string', enum: ['status', 'explain', 'message', 'enqueue', 'approve_patch', 'reject_patch'] },
144
+ input: { type: 'string' },
145
+ message: { type: 'string' },
146
+ prompt: { type: 'string' },
147
+ request: { type: 'string' },
148
+ intent: { type: 'string', enum: ['observe', 'converse', 'mutate', 'enqueue'] },
149
+ workspace: nullableString,
150
+ patchId: { type: 'string' },
151
+ id: { type: 'string' },
152
+ reason: { type: 'string' },
153
+ },
154
+ },
155
+ plan: {
156
+ $id: 'https://dotdrelle.dev/wiki-manager/contracts/plan/v1',
157
+ title: 'StructuredPlan',
158
+ schemaVersion: '1',
159
+ type: 'array',
160
+ items: planStepSchema,
161
+ },
162
+ planPatch: {
163
+ $id: 'https://dotdrelle.dev/wiki-manager/contracts/plan-patch/v1',
164
+ title: 'PlanPatch',
165
+ schemaVersion: '1',
166
+ type: 'object',
167
+ required: ['basePlanRevision', 'operations'],
168
+ additionalProperties: true,
169
+ properties: {
170
+ id: nullableString,
171
+ targetRunId: nullableString,
172
+ basePlanRevision: { type: 'number', minimum: 0 },
173
+ reason: nullableString,
174
+ operations: {
175
+ type: 'array',
176
+ items: {
177
+ type: 'object',
178
+ required: ['op'],
179
+ additionalProperties: true,
180
+ properties: {
181
+ op: { type: 'string', enum: ['add_task', 'add_dependency', 'remove_dependency', 'cancel_task', 'replace_executor', 'request_approval'] },
182
+ task: patchTaskSchema,
183
+ taskId: { type: 'string' },
184
+ targetTaskId: { type: 'string' },
185
+ dependencyId: { type: 'string' },
186
+ dependsOn: { type: 'string' },
187
+ executor: nullableString,
188
+ executorQuery: nullableObject,
189
+ reason: { type: 'string' },
190
+ },
191
+ },
192
+ },
193
+ },
194
+ },
195
+ outputReference: outputReferenceSchema,
196
+ };
197
+
198
+ export function validateContract(name, value) {
199
+ const schema = contractSchemas[name];
200
+ if (!schema) {
201
+ throw new Error(`Unknown contract schema: ${name}`);
202
+ }
203
+ const errors = [];
204
+ validateSchema(schema, value, name, errors);
205
+ return {
206
+ ok: errors.length === 0,
207
+ errors,
208
+ };
209
+ }
210
+
211
+ export function assertContract(name, value) {
212
+ const result = validateContract(name, value);
213
+ if (!result.ok) {
214
+ throw new Error(`Contract ${name} invalid: ${result.errors.join('; ')}`);
215
+ }
216
+ return value;
217
+ }
218
+
219
+ export function validateContractInDev(name, value) {
220
+ if (!contractValidationEnabled()) return value;
221
+ return assertContract(name, value);
222
+ }
223
+
224
+ export function contractValidationEnabled() {
225
+ return process.env.WIKI_MANAGER_VALIDATE_CONTRACTS === '1'
226
+ || process.env.CI === 'true'
227
+ || (process.env.NODE_ENV && process.env.NODE_ENV !== 'production');
228
+ }
229
+
230
+ function validateSchema(schema, value, path, errors) {
231
+ if (schema.oneOf) {
232
+ const matches = schema.oneOf.filter((candidate) => validateCandidate(candidate, value, path));
233
+ if (matches.length !== 1) errors.push(`${path} must match exactly one schema`);
234
+ return;
235
+ }
236
+ if (schema.anyOf) {
237
+ const matches = schema.anyOf.filter((candidate) => validateCandidate(candidate, value, path));
238
+ if (matches.length < 1) errors.push(`${path} must match at least one schema`);
239
+ return;
240
+ }
241
+ if (schema.const !== undefined && value !== schema.const) {
242
+ errors.push(`${path} must equal ${JSON.stringify(schema.const)}`);
243
+ return;
244
+ }
245
+ if (schema.enum && !schema.enum.includes(value)) {
246
+ errors.push(`${path} must be one of ${schema.enum.join(', ')}`);
247
+ return;
248
+ }
249
+ if (schema.type && !typeMatches(schema.type, value)) {
250
+ errors.push(`${path} must be ${formatType(schema.type)}`);
251
+ return;
252
+ }
253
+ if (typeof value === 'string' && schema.minLength != null && value.length < schema.minLength) {
254
+ errors.push(`${path} must have length >= ${schema.minLength}`);
255
+ }
256
+ if (typeof value === 'number') {
257
+ if (schema.minimum != null && value < schema.minimum) errors.push(`${path} must be >= ${schema.minimum}`);
258
+ if (schema.maximum != null && value > schema.maximum) errors.push(`${path} must be <= ${schema.maximum}`);
259
+ }
260
+ if (Array.isArray(value)) {
261
+ value.forEach((item, index) => validateSchema(schema.items ?? {}, item, `${path}[${index}]`, errors));
262
+ return;
263
+ }
264
+ if (value && typeof value === 'object') {
265
+ for (const key of schema.required ?? []) {
266
+ if (!Object.hasOwn(value, key)) errors.push(`${path}.${key} is required`);
267
+ }
268
+ for (const [key, childSchema] of Object.entries(schema.properties ?? {})) {
269
+ if (Object.hasOwn(value, key)) validateSchema(childSchema, value[key], `${path}.${key}`, errors);
270
+ }
271
+ if (schema.additionalProperties === false) {
272
+ const allowed = new Set(Object.keys(schema.properties ?? {}));
273
+ for (const key of Object.keys(value)) {
274
+ if (!allowed.has(key)) errors.push(`${path}.${key} is not allowed`);
275
+ }
276
+ }
277
+ }
278
+ }
279
+
280
+ function validateCandidate(schema, value, path) {
281
+ const errors = [];
282
+ validateSchema(schema, value, path, errors);
283
+ return errors.length === 0;
284
+ }
285
+
286
+ function typeMatches(type, value) {
287
+ const types = Array.isArray(type) ? type : [type];
288
+ return types.some((candidate) => {
289
+ if (candidate === 'array') return Array.isArray(value);
290
+ if (candidate === 'null') return value === null;
291
+ if (candidate === 'integer') return Number.isInteger(value);
292
+ if (candidate === 'number') return typeof value === 'number' && Number.isFinite(value);
293
+ if (candidate === 'object') return value !== null && typeof value === 'object' && !Array.isArray(value);
294
+ return typeof value === candidate;
295
+ });
296
+ }
297
+
298
+ function formatType(type) {
299
+ return Array.isArray(type) ? type.join('|') : type;
300
+ }
301
+
302
+ export { STATUS_VALUES };