@praxisflux/gates 0.62.0 → 0.63.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.
- package/codebase-to-course/lib/README.md +4 -0
- package/codebase-to-course/lib/board-mirror.mjs +77 -5
- package/codebase-to-course/lib/structured-offload.mjs +140 -0
- package/grounding-wiki/lib/README.md +4 -0
- package/grounding-wiki/lib/board-mirror.mjs +77 -5
- package/grounding-wiki/lib/structured-offload.mjs +140 -0
- package/lib/README.md +4 -0
- package/lib/board-mirror.mjs +77 -5
- package/lib/structured-offload.mjs +140 -0
- package/package.json +1 -1
- package/spec-bridge/lib/README.md +4 -0
- package/spec-bridge/lib/board-mirror.mjs +77 -5
- package/spec-bridge/lib/structured-offload.mjs +140 -0
|
@@ -7,6 +7,10 @@ Planned modules (**TASK-1.2**): `project-root` · `gate-runner` (Stop-hook harne
|
|
|
7
7
|
· `selfcontained` (HTML verifier) · `lifecycle` (status-cannot-exceed-proven-artifacts) ·
|
|
8
8
|
`installer` · `dates` · `template`.
|
|
9
9
|
|
|
10
|
+
Also shipped: `structured-offload` — schema-validated, fail-soft calls to a local
|
|
11
|
+
Ollama/OpenAI-compatible endpoint, config-driven (`.claude/structured-offload.json`),
|
|
12
|
+
opt-in and absent-by-default. See `docs/wiki/chassis.md`.
|
|
13
|
+
|
|
10
14
|
Also shipped here: `handoff-protocol.md` — a stamped copy of the canonical
|
|
11
15
|
`docs/handoff-protocol.md` (re-stamped by `scripts/sync-shared.mjs`), so skills can reference
|
|
12
16
|
the protocol as `${CLAUDE_PLUGIN_ROOT}/lib/handoff-protocol.md` from an installed plugin.
|
|
@@ -264,12 +264,23 @@ export function isPausedLink(link) {
|
|
|
264
264
|
* Indexes are POSITIONAL (1-based) within the block — a position, not an identity. A reordered
|
|
265
265
|
* block renumbers.
|
|
266
266
|
*
|
|
267
|
-
* `parseSpecPhasesBlock` tolerates
|
|
268
|
-
* to introduce on EVERY read (same findings file): a blank line inserted immediately after
|
|
269
|
-
* BEGIN (skipped here — a blank line is never a checkbox line)
|
|
267
|
+
* `parseSpecPhasesBlock` tolerates THREE normalizations a live Jira write→read round-trip is
|
|
268
|
+
* known to introduce on EVERY read (same findings file): a blank line inserted immediately after
|
|
269
|
+
* BEGIN (skipped here — a blank line is never a checkbox line), two trailing spaces appended
|
|
270
270
|
* to the LAST checkbox line (needs no extra handling: TASK_LINE's own `(\S.*?)\s*$` already
|
|
271
|
-
* strips trailing whitespace from the captured text)
|
|
272
|
-
*
|
|
271
|
+
* strips trailing whitespace from the captured text), and two trailing spaces appended to the
|
|
272
|
+
* END MARKER LINE itself (verified live 2026-09-10, spec 056 phase 2 — the slice on
|
|
273
|
+
* indexOf(END) leaves them outside every item, so they never reach the parse). Reuses TASK_LINE
|
|
274
|
+
* from spec-derive.mjs rather than a third checkbox regex.
|
|
275
|
+
*
|
|
276
|
+
* WHY THE RENDERER RIGHT-TRIMS (spec 056 phase 3, verified live): that END-marker whitespace
|
|
277
|
+
* COMPOUNDS if echoed back. Read 2 spaces, write them back unchanged, and the next read returns
|
|
278
|
+
* 4 — then 6, and so on, because Jira appends its own on top of whatever it is given. Phase 1
|
|
279
|
+
* recorded the cycle as "idempotent, not degrading", which held for the checkbox lines it
|
|
280
|
+
* measured but NOT for the END marker. `renderSpecPhasesBlock` emits a clean marker with no
|
|
281
|
+
* trailing whitespace, which re-normalizes every cycle back to a constant 2 (confirmed by a
|
|
282
|
+
* live write of a clean marker returning exactly 2). Splice the rendered block between the
|
|
283
|
+
* existing markers rather than preserving the read bytes, and the growth cannot start. */
|
|
273
284
|
|
|
274
285
|
const SPEC_PHASES_BEGIN = "<!-- spec-phases BEGIN -->";
|
|
275
286
|
const SPEC_PHASES_END = "<!-- spec-phases END -->";
|
|
@@ -402,6 +413,12 @@ export function projectBacklog(root) {
|
|
|
402
413
|
* `jira` by adding one key here; no `if (provider === "...")` branch belongs anywhere. */
|
|
403
414
|
export const providers = {
|
|
404
415
|
backlog: { requiresSync: false, project: projectBacklog },
|
|
416
|
+
// spec 056 R1. `project: null` is not an omission — it is the TYPE-LEVEL statement that this
|
|
417
|
+
// provider cannot be projected by `node` alone (it needs MCP, hence a skill). Registering it
|
|
418
|
+
// is what activates spec 052 R5's `--check` behavior and spec 053 R3/R4's staleness and
|
|
419
|
+
// missing-mirror findings for a Jira host. No `if (provider === "jira")` branch belongs
|
|
420
|
+
// anywhere: the shape of this entry carries the distinction.
|
|
421
|
+
jira: { requiresSync: true, project: null },
|
|
405
422
|
};
|
|
406
423
|
|
|
407
424
|
/* ── `.board.json` config schema (spec 054) — a SEPARATE, smaller table from `providers`
|
|
@@ -462,10 +479,65 @@ export function validateBoardConfig(config) {
|
|
|
462
479
|
if (!sub[field]) problems.push(`${name}.${field}: required for provider "${name}"`);
|
|
463
480
|
if (sub.statusMap !== undefined && (typeof sub.statusMap !== "object" || Array.isArray(sub.statusMap) || sub.statusMap === null))
|
|
464
481
|
problems.push(`${name}.statusMap: expected object, got ${Array.isArray(sub.statusMap) ? "array" : typeof sub.statusMap}`);
|
|
482
|
+
if (sub.statusReadMap !== undefined && (typeof sub.statusReadMap !== "object" || Array.isArray(sub.statusReadMap) || sub.statusReadMap === null))
|
|
483
|
+
problems.push(`${name}.statusReadMap: expected object, got ${Array.isArray(sub.statusReadMap) ? "array" : typeof sub.statusReadMap}`);
|
|
484
|
+
// The injectivity rule differs BY DIRECTION and that asymmetry is the whole point (spec 056
|
|
485
|
+
// Phase 2 / the 2026-09-10 operator ruling). `statusMap` is bridge -> site: it names the one
|
|
486
|
+
// canonical WRITE target per bridge status, so two bridge statuses sharing a site status
|
|
487
|
+
// makes the reverse read ambiguous and is an ERROR. `statusReadMap` is site -> bridge and is
|
|
488
|
+
// many-to-one BY DESIGN (a 15-status workflow collapsing onto 3), so it is deliberately
|
|
489
|
+
// exempt. Silently picking a winner would make verdicts depend on key order.
|
|
490
|
+
if (sub.statusMap && typeof sub.statusMap === "object" && !Array.isArray(sub.statusMap)) {
|
|
491
|
+
const seen = new Map();
|
|
492
|
+
for (const [bridge, site] of Object.entries(sub.statusMap)) {
|
|
493
|
+
if (typeof site !== "string") continue;
|
|
494
|
+
const prior = seen.get(site);
|
|
495
|
+
if (prior !== undefined)
|
|
496
|
+
problems.push(`${name}.statusMap: non-injective — "${prior}" and "${bridge}" both map to site status "${site}"; the reverse read would be ambiguous`);
|
|
497
|
+
else seen.set(site, bridge);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
465
500
|
}
|
|
466
501
|
return problems;
|
|
467
502
|
}
|
|
468
503
|
|
|
504
|
+
/* ── status mapping, both directions (spec 054 R1 + spec 056 Phase 2's ratified amendment) ──
|
|
505
|
+
*
|
|
506
|
+
* Two fields, because the two directions have genuinely different shapes and one field cannot
|
|
507
|
+
* honestly carry both:
|
|
508
|
+
*
|
|
509
|
+
* bridge status ──statusMap──▶ site status (WRITE: injective, one canonical target)
|
|
510
|
+
* site status ──statusReadMap──▶ bridge status (READ: many-to-one BY DESIGN)
|
|
511
|
+
*
|
|
512
|
+
* The live workflow that forced this had FIFTEEN statuses against the bridge's three
|
|
513
|
+
* (`bridge.mjs`'s RANK: to do / in progress / done). Collapsing 15 onto 3 is inherently
|
|
514
|
+
* many-to-one, while the write direction must pick exactly one target per bridge status.
|
|
515
|
+
* See specs/056-jira-provider/findings/phase-2-operator-rulings.md, ruling 2.
|
|
516
|
+
*
|
|
517
|
+
* Both directions FALL THROUGH UNCHANGED on a miss — spec 054 R1's stated rule, kept here so a
|
|
518
|
+
* host with no map at all behaves exactly as it did before either field existed. */
|
|
519
|
+
|
|
520
|
+
/** Map a bridge status to the site's workflow status for a WRITE. Falls through unchanged. */
|
|
521
|
+
export function toSiteStatus(bridgeStatus, config = {}) {
|
|
522
|
+
return config?.statusMap?.[bridgeStatus] ?? bridgeStatus;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** Map a site workflow status back to the bridge's vocabulary for a READ. Prefers the explicit
|
|
526
|
+
* many-to-one `statusReadMap`; with none, inverts `statusMap` (so a host that predates
|
|
527
|
+
* `statusReadMap` keeps its exact prior behavior). Falls through unchanged on a miss — the
|
|
528
|
+
* caller's `verdict()` then reports "unknown" for a status outside the vocabulary rather than
|
|
529
|
+
* this function guessing one. */
|
|
530
|
+
export function toBridgeStatus(siteStatus, config = {}) {
|
|
531
|
+
const read = config?.statusReadMap;
|
|
532
|
+
if (read && typeof read === "object" && !Array.isArray(read) && read[siteStatus] !== undefined)
|
|
533
|
+
return read[siteStatus];
|
|
534
|
+
const write = config?.statusMap;
|
|
535
|
+
if (write && typeof write === "object" && !Array.isArray(write)) {
|
|
536
|
+
for (const [bridge, site] of Object.entries(write)) if (site === siteStatus) return bridge;
|
|
537
|
+
}
|
|
538
|
+
return siteStatus;
|
|
539
|
+
}
|
|
540
|
+
|
|
469
541
|
/** Run git argv `args` in `cwd`. Never throws — a git failure is data, not an exception,
|
|
470
542
|
* matching grounding-wiki/gates/repin-window.mjs's `git()` helper shape (spawnSync, argv
|
|
471
543
|
* array so there is no shell, utf8 encoding). Returns `{ status, out }`; `status` is `null`
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// structured-offload.mjs — schema-validated calls to a local model, fail-soft always.
|
|
2
|
+
//
|
|
3
|
+
// `offload({ prompt, schema, config, timeoutMs })` asks a local Ollama or
|
|
4
|
+
// OpenAI-compatible endpoint to answer `prompt`, constrained to `schema` at the backend
|
|
5
|
+
// (Ollama `format`, OpenAI-compatible `response_format: json_schema`) so the model is
|
|
6
|
+
// structurally prevented from returning prose. The result is validated again on this
|
|
7
|
+
// side against the same schema. NEVER throws: every failure path — unset config,
|
|
8
|
+
// timeout, connection refused, non-2xx, invalid JSON, schema mismatch — resolves to
|
|
9
|
+
// `{ ok: false, reason, residue }` and the caller does the work in-session, exactly as
|
|
10
|
+
// if this module did not exist.
|
|
11
|
+
//
|
|
12
|
+
// `config` is loaded by `loadConfig(root)` from `<root>/.claude/structured-offload.json`
|
|
13
|
+
// (absent/unreadable/malformed -> null -> `reason: 'unconfigured'`), kept separate from
|
|
14
|
+
// `offload` so tests can hand it a stub-server config directly without touching disk.
|
|
15
|
+
//
|
|
16
|
+
// Config shape: `{ endpoint, api: 'ollama'|'openai', model, timeoutMs?, residuePath? }`.
|
|
17
|
+
//
|
|
18
|
+
// Schema checker (minimal subset — extend only when a consumer needs more):
|
|
19
|
+
// - `type`: 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean'
|
|
20
|
+
// - `required`: array of property names that must exist on an object value
|
|
21
|
+
// - `properties`: per-key sub-schemas, checked recursively
|
|
22
|
+
// - `enum`: value must be one of the listed members
|
|
23
|
+
// - `items`: sub-schema every array element must satisfy
|
|
24
|
+
// ponytail: full JSON Schema (oneOf/anyOf/patternProperties/formats/…) is not
|
|
25
|
+
// implemented; the checked subset is what closed-enum/path-lookup callers need.
|
|
26
|
+
//
|
|
27
|
+
// `residue`: `{ backend, model, outcome: 'validated'|'fallback', reason?, ms }`,
|
|
28
|
+
// returned on every call and appended as a JSON line to `config.residuePath` when set.
|
|
29
|
+
|
|
30
|
+
import { readFileSync, appendFileSync } from "node:fs";
|
|
31
|
+
import { join } from "node:path";
|
|
32
|
+
|
|
33
|
+
/** Load and validate the config file, or null on any absence/parse/shape failure. */
|
|
34
|
+
export function loadConfig(root) {
|
|
35
|
+
try {
|
|
36
|
+
const cfg = JSON.parse(readFileSync(join(root, ".claude", "structured-offload.json"), "utf8"));
|
|
37
|
+
if (!cfg || typeof cfg !== "object") return null;
|
|
38
|
+
if (!cfg.endpoint || !["ollama", "openai"].includes(cfg.api) || !cfg.model) return null;
|
|
39
|
+
return cfg;
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isTimeoutError(err) {
|
|
46
|
+
return err?.name === "TimeoutError" || err?.name === "AbortError";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function buildRequest(config, prompt, schema) {
|
|
50
|
+
const messages = [{ role: "user", content: prompt }];
|
|
51
|
+
if (config.api === "ollama") {
|
|
52
|
+
return { path: "/api/chat", body: { model: config.model, messages, format: schema, stream: false } };
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
path: "/v1/chat/completions",
|
|
56
|
+
body: {
|
|
57
|
+
model: config.model,
|
|
58
|
+
messages,
|
|
59
|
+
response_format: { type: "json_schema", json_schema: { name: "offload_response", strict: true, schema } },
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function extractContent(api, data) {
|
|
65
|
+
return api === "ollama" ? data?.message?.content : data?.choices?.[0]?.message?.content;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The minimal schema subset documented in the module header. */
|
|
69
|
+
export function validateSchema(value, schema) {
|
|
70
|
+
if (!schema) return true;
|
|
71
|
+
if (schema.enum) return schema.enum.includes(value);
|
|
72
|
+
switch (schema.type) {
|
|
73
|
+
case "object": {
|
|
74
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
75
|
+
for (const key of schema.required || []) if (!(key in value)) return false;
|
|
76
|
+
if (schema.properties) {
|
|
77
|
+
for (const [key, sub] of Object.entries(schema.properties)) {
|
|
78
|
+
if (key in value && !validateSchema(value[key], sub)) return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
case "array":
|
|
84
|
+
return Array.isArray(value) && (!schema.items || value.every((v) => validateSchema(v, schema.items)));
|
|
85
|
+
case "string":
|
|
86
|
+
return typeof value === "string";
|
|
87
|
+
case "number":
|
|
88
|
+
return typeof value === "number";
|
|
89
|
+
case "integer":
|
|
90
|
+
return Number.isInteger(value);
|
|
91
|
+
case "boolean":
|
|
92
|
+
return typeof value === "boolean";
|
|
93
|
+
default:
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Async, never-throwing: see module header for the full contract. */
|
|
99
|
+
export async function offload({ prompt, schema, config, timeoutMs } = {}) {
|
|
100
|
+
const start = Date.now();
|
|
101
|
+
const backend = config?.api;
|
|
102
|
+
const model = config?.model;
|
|
103
|
+
|
|
104
|
+
function finish(result) {
|
|
105
|
+
const residue = { backend, model, outcome: result.ok ? "validated" : "fallback", ms: Date.now() - start };
|
|
106
|
+
if (!result.ok) residue.reason = result.reason;
|
|
107
|
+
if (config?.residuePath) {
|
|
108
|
+
try { appendFileSync(config.residuePath, JSON.stringify(residue) + "\n"); } catch { /* residue is best-effort */ }
|
|
109
|
+
}
|
|
110
|
+
return { ...result, residue };
|
|
111
|
+
}
|
|
112
|
+
const fail = (reason) => finish({ ok: false, reason });
|
|
113
|
+
|
|
114
|
+
if (!config || !config.endpoint || !["ollama", "openai"].includes(config.api) || !config.model) {
|
|
115
|
+
return fail("unconfigured");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const { path, body } = buildRequest(config, prompt, schema);
|
|
119
|
+
let res;
|
|
120
|
+
try {
|
|
121
|
+
res = await fetch(new URL(path, config.endpoint), {
|
|
122
|
+
method: "POST",
|
|
123
|
+
headers: { "content-type": "application/json" },
|
|
124
|
+
body: JSON.stringify(body),
|
|
125
|
+
signal: AbortSignal.timeout(timeoutMs ?? config.timeoutMs ?? 30000),
|
|
126
|
+
});
|
|
127
|
+
} catch (err) {
|
|
128
|
+
return fail(isTimeoutError(err) ? "timeout" : "refused");
|
|
129
|
+
}
|
|
130
|
+
if (!res.ok) return fail("http-error");
|
|
131
|
+
|
|
132
|
+
let data;
|
|
133
|
+
try { data = await res.json(); } catch { return fail("invalid-json"); }
|
|
134
|
+
|
|
135
|
+
let value;
|
|
136
|
+
try { value = JSON.parse(extractContent(config.api, data)); } catch { return fail("invalid-json"); }
|
|
137
|
+
|
|
138
|
+
if (!validateSchema(value, schema)) return fail("schema-mismatch");
|
|
139
|
+
return finish({ ok: true, value });
|
|
140
|
+
}
|
|
@@ -7,6 +7,10 @@ Planned modules (**TASK-1.2**): `project-root` · `gate-runner` (Stop-hook harne
|
|
|
7
7
|
· `selfcontained` (HTML verifier) · `lifecycle` (status-cannot-exceed-proven-artifacts) ·
|
|
8
8
|
`installer` · `dates` · `template`.
|
|
9
9
|
|
|
10
|
+
Also shipped: `structured-offload` — schema-validated, fail-soft calls to a local
|
|
11
|
+
Ollama/OpenAI-compatible endpoint, config-driven (`.claude/structured-offload.json`),
|
|
12
|
+
opt-in and absent-by-default. See `docs/wiki/chassis.md`.
|
|
13
|
+
|
|
10
14
|
Also shipped here: `handoff-protocol.md` — a stamped copy of the canonical
|
|
11
15
|
`docs/handoff-protocol.md` (re-stamped by `scripts/sync-shared.mjs`), so skills can reference
|
|
12
16
|
the protocol as `${CLAUDE_PLUGIN_ROOT}/lib/handoff-protocol.md` from an installed plugin.
|
|
@@ -264,12 +264,23 @@ export function isPausedLink(link) {
|
|
|
264
264
|
* Indexes are POSITIONAL (1-based) within the block — a position, not an identity. A reordered
|
|
265
265
|
* block renumbers.
|
|
266
266
|
*
|
|
267
|
-
* `parseSpecPhasesBlock` tolerates
|
|
268
|
-
* to introduce on EVERY read (same findings file): a blank line inserted immediately after
|
|
269
|
-
* BEGIN (skipped here — a blank line is never a checkbox line)
|
|
267
|
+
* `parseSpecPhasesBlock` tolerates THREE normalizations a live Jira write→read round-trip is
|
|
268
|
+
* known to introduce on EVERY read (same findings file): a blank line inserted immediately after
|
|
269
|
+
* BEGIN (skipped here — a blank line is never a checkbox line), two trailing spaces appended
|
|
270
270
|
* to the LAST checkbox line (needs no extra handling: TASK_LINE's own `(\S.*?)\s*$` already
|
|
271
|
-
* strips trailing whitespace from the captured text)
|
|
272
|
-
*
|
|
271
|
+
* strips trailing whitespace from the captured text), and two trailing spaces appended to the
|
|
272
|
+
* END MARKER LINE itself (verified live 2026-09-10, spec 056 phase 2 — the slice on
|
|
273
|
+
* indexOf(END) leaves them outside every item, so they never reach the parse). Reuses TASK_LINE
|
|
274
|
+
* from spec-derive.mjs rather than a third checkbox regex.
|
|
275
|
+
*
|
|
276
|
+
* WHY THE RENDERER RIGHT-TRIMS (spec 056 phase 3, verified live): that END-marker whitespace
|
|
277
|
+
* COMPOUNDS if echoed back. Read 2 spaces, write them back unchanged, and the next read returns
|
|
278
|
+
* 4 — then 6, and so on, because Jira appends its own on top of whatever it is given. Phase 1
|
|
279
|
+
* recorded the cycle as "idempotent, not degrading", which held for the checkbox lines it
|
|
280
|
+
* measured but NOT for the END marker. `renderSpecPhasesBlock` emits a clean marker with no
|
|
281
|
+
* trailing whitespace, which re-normalizes every cycle back to a constant 2 (confirmed by a
|
|
282
|
+
* live write of a clean marker returning exactly 2). Splice the rendered block between the
|
|
283
|
+
* existing markers rather than preserving the read bytes, and the growth cannot start. */
|
|
273
284
|
|
|
274
285
|
const SPEC_PHASES_BEGIN = "<!-- spec-phases BEGIN -->";
|
|
275
286
|
const SPEC_PHASES_END = "<!-- spec-phases END -->";
|
|
@@ -402,6 +413,12 @@ export function projectBacklog(root) {
|
|
|
402
413
|
* `jira` by adding one key here; no `if (provider === "...")` branch belongs anywhere. */
|
|
403
414
|
export const providers = {
|
|
404
415
|
backlog: { requiresSync: false, project: projectBacklog },
|
|
416
|
+
// spec 056 R1. `project: null` is not an omission — it is the TYPE-LEVEL statement that this
|
|
417
|
+
// provider cannot be projected by `node` alone (it needs MCP, hence a skill). Registering it
|
|
418
|
+
// is what activates spec 052 R5's `--check` behavior and spec 053 R3/R4's staleness and
|
|
419
|
+
// missing-mirror findings for a Jira host. No `if (provider === "jira")` branch belongs
|
|
420
|
+
// anywhere: the shape of this entry carries the distinction.
|
|
421
|
+
jira: { requiresSync: true, project: null },
|
|
405
422
|
};
|
|
406
423
|
|
|
407
424
|
/* ── `.board.json` config schema (spec 054) — a SEPARATE, smaller table from `providers`
|
|
@@ -462,10 +479,65 @@ export function validateBoardConfig(config) {
|
|
|
462
479
|
if (!sub[field]) problems.push(`${name}.${field}: required for provider "${name}"`);
|
|
463
480
|
if (sub.statusMap !== undefined && (typeof sub.statusMap !== "object" || Array.isArray(sub.statusMap) || sub.statusMap === null))
|
|
464
481
|
problems.push(`${name}.statusMap: expected object, got ${Array.isArray(sub.statusMap) ? "array" : typeof sub.statusMap}`);
|
|
482
|
+
if (sub.statusReadMap !== undefined && (typeof sub.statusReadMap !== "object" || Array.isArray(sub.statusReadMap) || sub.statusReadMap === null))
|
|
483
|
+
problems.push(`${name}.statusReadMap: expected object, got ${Array.isArray(sub.statusReadMap) ? "array" : typeof sub.statusReadMap}`);
|
|
484
|
+
// The injectivity rule differs BY DIRECTION and that asymmetry is the whole point (spec 056
|
|
485
|
+
// Phase 2 / the 2026-09-10 operator ruling). `statusMap` is bridge -> site: it names the one
|
|
486
|
+
// canonical WRITE target per bridge status, so two bridge statuses sharing a site status
|
|
487
|
+
// makes the reverse read ambiguous and is an ERROR. `statusReadMap` is site -> bridge and is
|
|
488
|
+
// many-to-one BY DESIGN (a 15-status workflow collapsing onto 3), so it is deliberately
|
|
489
|
+
// exempt. Silently picking a winner would make verdicts depend on key order.
|
|
490
|
+
if (sub.statusMap && typeof sub.statusMap === "object" && !Array.isArray(sub.statusMap)) {
|
|
491
|
+
const seen = new Map();
|
|
492
|
+
for (const [bridge, site] of Object.entries(sub.statusMap)) {
|
|
493
|
+
if (typeof site !== "string") continue;
|
|
494
|
+
const prior = seen.get(site);
|
|
495
|
+
if (prior !== undefined)
|
|
496
|
+
problems.push(`${name}.statusMap: non-injective — "${prior}" and "${bridge}" both map to site status "${site}"; the reverse read would be ambiguous`);
|
|
497
|
+
else seen.set(site, bridge);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
465
500
|
}
|
|
466
501
|
return problems;
|
|
467
502
|
}
|
|
468
503
|
|
|
504
|
+
/* ── status mapping, both directions (spec 054 R1 + spec 056 Phase 2's ratified amendment) ──
|
|
505
|
+
*
|
|
506
|
+
* Two fields, because the two directions have genuinely different shapes and one field cannot
|
|
507
|
+
* honestly carry both:
|
|
508
|
+
*
|
|
509
|
+
* bridge status ──statusMap──▶ site status (WRITE: injective, one canonical target)
|
|
510
|
+
* site status ──statusReadMap──▶ bridge status (READ: many-to-one BY DESIGN)
|
|
511
|
+
*
|
|
512
|
+
* The live workflow that forced this had FIFTEEN statuses against the bridge's three
|
|
513
|
+
* (`bridge.mjs`'s RANK: to do / in progress / done). Collapsing 15 onto 3 is inherently
|
|
514
|
+
* many-to-one, while the write direction must pick exactly one target per bridge status.
|
|
515
|
+
* See specs/056-jira-provider/findings/phase-2-operator-rulings.md, ruling 2.
|
|
516
|
+
*
|
|
517
|
+
* Both directions FALL THROUGH UNCHANGED on a miss — spec 054 R1's stated rule, kept here so a
|
|
518
|
+
* host with no map at all behaves exactly as it did before either field existed. */
|
|
519
|
+
|
|
520
|
+
/** Map a bridge status to the site's workflow status for a WRITE. Falls through unchanged. */
|
|
521
|
+
export function toSiteStatus(bridgeStatus, config = {}) {
|
|
522
|
+
return config?.statusMap?.[bridgeStatus] ?? bridgeStatus;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** Map a site workflow status back to the bridge's vocabulary for a READ. Prefers the explicit
|
|
526
|
+
* many-to-one `statusReadMap`; with none, inverts `statusMap` (so a host that predates
|
|
527
|
+
* `statusReadMap` keeps its exact prior behavior). Falls through unchanged on a miss — the
|
|
528
|
+
* caller's `verdict()` then reports "unknown" for a status outside the vocabulary rather than
|
|
529
|
+
* this function guessing one. */
|
|
530
|
+
export function toBridgeStatus(siteStatus, config = {}) {
|
|
531
|
+
const read = config?.statusReadMap;
|
|
532
|
+
if (read && typeof read === "object" && !Array.isArray(read) && read[siteStatus] !== undefined)
|
|
533
|
+
return read[siteStatus];
|
|
534
|
+
const write = config?.statusMap;
|
|
535
|
+
if (write && typeof write === "object" && !Array.isArray(write)) {
|
|
536
|
+
for (const [bridge, site] of Object.entries(write)) if (site === siteStatus) return bridge;
|
|
537
|
+
}
|
|
538
|
+
return siteStatus;
|
|
539
|
+
}
|
|
540
|
+
|
|
469
541
|
/** Run git argv `args` in `cwd`. Never throws — a git failure is data, not an exception,
|
|
470
542
|
* matching grounding-wiki/gates/repin-window.mjs's `git()` helper shape (spawnSync, argv
|
|
471
543
|
* array so there is no shell, utf8 encoding). Returns `{ status, out }`; `status` is `null`
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// structured-offload.mjs — schema-validated calls to a local model, fail-soft always.
|
|
2
|
+
//
|
|
3
|
+
// `offload({ prompt, schema, config, timeoutMs })` asks a local Ollama or
|
|
4
|
+
// OpenAI-compatible endpoint to answer `prompt`, constrained to `schema` at the backend
|
|
5
|
+
// (Ollama `format`, OpenAI-compatible `response_format: json_schema`) so the model is
|
|
6
|
+
// structurally prevented from returning prose. The result is validated again on this
|
|
7
|
+
// side against the same schema. NEVER throws: every failure path — unset config,
|
|
8
|
+
// timeout, connection refused, non-2xx, invalid JSON, schema mismatch — resolves to
|
|
9
|
+
// `{ ok: false, reason, residue }` and the caller does the work in-session, exactly as
|
|
10
|
+
// if this module did not exist.
|
|
11
|
+
//
|
|
12
|
+
// `config` is loaded by `loadConfig(root)` from `<root>/.claude/structured-offload.json`
|
|
13
|
+
// (absent/unreadable/malformed -> null -> `reason: 'unconfigured'`), kept separate from
|
|
14
|
+
// `offload` so tests can hand it a stub-server config directly without touching disk.
|
|
15
|
+
//
|
|
16
|
+
// Config shape: `{ endpoint, api: 'ollama'|'openai', model, timeoutMs?, residuePath? }`.
|
|
17
|
+
//
|
|
18
|
+
// Schema checker (minimal subset — extend only when a consumer needs more):
|
|
19
|
+
// - `type`: 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean'
|
|
20
|
+
// - `required`: array of property names that must exist on an object value
|
|
21
|
+
// - `properties`: per-key sub-schemas, checked recursively
|
|
22
|
+
// - `enum`: value must be one of the listed members
|
|
23
|
+
// - `items`: sub-schema every array element must satisfy
|
|
24
|
+
// ponytail: full JSON Schema (oneOf/anyOf/patternProperties/formats/…) is not
|
|
25
|
+
// implemented; the checked subset is what closed-enum/path-lookup callers need.
|
|
26
|
+
//
|
|
27
|
+
// `residue`: `{ backend, model, outcome: 'validated'|'fallback', reason?, ms }`,
|
|
28
|
+
// returned on every call and appended as a JSON line to `config.residuePath` when set.
|
|
29
|
+
|
|
30
|
+
import { readFileSync, appendFileSync } from "node:fs";
|
|
31
|
+
import { join } from "node:path";
|
|
32
|
+
|
|
33
|
+
/** Load and validate the config file, or null on any absence/parse/shape failure. */
|
|
34
|
+
export function loadConfig(root) {
|
|
35
|
+
try {
|
|
36
|
+
const cfg = JSON.parse(readFileSync(join(root, ".claude", "structured-offload.json"), "utf8"));
|
|
37
|
+
if (!cfg || typeof cfg !== "object") return null;
|
|
38
|
+
if (!cfg.endpoint || !["ollama", "openai"].includes(cfg.api) || !cfg.model) return null;
|
|
39
|
+
return cfg;
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isTimeoutError(err) {
|
|
46
|
+
return err?.name === "TimeoutError" || err?.name === "AbortError";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function buildRequest(config, prompt, schema) {
|
|
50
|
+
const messages = [{ role: "user", content: prompt }];
|
|
51
|
+
if (config.api === "ollama") {
|
|
52
|
+
return { path: "/api/chat", body: { model: config.model, messages, format: schema, stream: false } };
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
path: "/v1/chat/completions",
|
|
56
|
+
body: {
|
|
57
|
+
model: config.model,
|
|
58
|
+
messages,
|
|
59
|
+
response_format: { type: "json_schema", json_schema: { name: "offload_response", strict: true, schema } },
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function extractContent(api, data) {
|
|
65
|
+
return api === "ollama" ? data?.message?.content : data?.choices?.[0]?.message?.content;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The minimal schema subset documented in the module header. */
|
|
69
|
+
export function validateSchema(value, schema) {
|
|
70
|
+
if (!schema) return true;
|
|
71
|
+
if (schema.enum) return schema.enum.includes(value);
|
|
72
|
+
switch (schema.type) {
|
|
73
|
+
case "object": {
|
|
74
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
75
|
+
for (const key of schema.required || []) if (!(key in value)) return false;
|
|
76
|
+
if (schema.properties) {
|
|
77
|
+
for (const [key, sub] of Object.entries(schema.properties)) {
|
|
78
|
+
if (key in value && !validateSchema(value[key], sub)) return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
case "array":
|
|
84
|
+
return Array.isArray(value) && (!schema.items || value.every((v) => validateSchema(v, schema.items)));
|
|
85
|
+
case "string":
|
|
86
|
+
return typeof value === "string";
|
|
87
|
+
case "number":
|
|
88
|
+
return typeof value === "number";
|
|
89
|
+
case "integer":
|
|
90
|
+
return Number.isInteger(value);
|
|
91
|
+
case "boolean":
|
|
92
|
+
return typeof value === "boolean";
|
|
93
|
+
default:
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Async, never-throwing: see module header for the full contract. */
|
|
99
|
+
export async function offload({ prompt, schema, config, timeoutMs } = {}) {
|
|
100
|
+
const start = Date.now();
|
|
101
|
+
const backend = config?.api;
|
|
102
|
+
const model = config?.model;
|
|
103
|
+
|
|
104
|
+
function finish(result) {
|
|
105
|
+
const residue = { backend, model, outcome: result.ok ? "validated" : "fallback", ms: Date.now() - start };
|
|
106
|
+
if (!result.ok) residue.reason = result.reason;
|
|
107
|
+
if (config?.residuePath) {
|
|
108
|
+
try { appendFileSync(config.residuePath, JSON.stringify(residue) + "\n"); } catch { /* residue is best-effort */ }
|
|
109
|
+
}
|
|
110
|
+
return { ...result, residue };
|
|
111
|
+
}
|
|
112
|
+
const fail = (reason) => finish({ ok: false, reason });
|
|
113
|
+
|
|
114
|
+
if (!config || !config.endpoint || !["ollama", "openai"].includes(config.api) || !config.model) {
|
|
115
|
+
return fail("unconfigured");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const { path, body } = buildRequest(config, prompt, schema);
|
|
119
|
+
let res;
|
|
120
|
+
try {
|
|
121
|
+
res = await fetch(new URL(path, config.endpoint), {
|
|
122
|
+
method: "POST",
|
|
123
|
+
headers: { "content-type": "application/json" },
|
|
124
|
+
body: JSON.stringify(body),
|
|
125
|
+
signal: AbortSignal.timeout(timeoutMs ?? config.timeoutMs ?? 30000),
|
|
126
|
+
});
|
|
127
|
+
} catch (err) {
|
|
128
|
+
return fail(isTimeoutError(err) ? "timeout" : "refused");
|
|
129
|
+
}
|
|
130
|
+
if (!res.ok) return fail("http-error");
|
|
131
|
+
|
|
132
|
+
let data;
|
|
133
|
+
try { data = await res.json(); } catch { return fail("invalid-json"); }
|
|
134
|
+
|
|
135
|
+
let value;
|
|
136
|
+
try { value = JSON.parse(extractContent(config.api, data)); } catch { return fail("invalid-json"); }
|
|
137
|
+
|
|
138
|
+
if (!validateSchema(value, schema)) return fail("schema-mismatch");
|
|
139
|
+
return finish({ ok: true, value });
|
|
140
|
+
}
|
package/lib/README.md
CHANGED
|
@@ -7,6 +7,10 @@ Planned modules (**TASK-1.2**): `project-root` · `gate-runner` (Stop-hook harne
|
|
|
7
7
|
· `selfcontained` (HTML verifier) · `lifecycle` (status-cannot-exceed-proven-artifacts) ·
|
|
8
8
|
`installer` · `dates` · `template`.
|
|
9
9
|
|
|
10
|
+
Also shipped: `structured-offload` — schema-validated, fail-soft calls to a local
|
|
11
|
+
Ollama/OpenAI-compatible endpoint, config-driven (`.claude/structured-offload.json`),
|
|
12
|
+
opt-in and absent-by-default. See `docs/wiki/chassis.md`.
|
|
13
|
+
|
|
10
14
|
Also shipped here: `handoff-protocol.md` — a stamped copy of the canonical
|
|
11
15
|
`docs/handoff-protocol.md` (re-stamped by `scripts/sync-shared.mjs`), so skills can reference
|
|
12
16
|
the protocol as `${CLAUDE_PLUGIN_ROOT}/lib/handoff-protocol.md` from an installed plugin.
|
package/lib/board-mirror.mjs
CHANGED
|
@@ -264,12 +264,23 @@ export function isPausedLink(link) {
|
|
|
264
264
|
* Indexes are POSITIONAL (1-based) within the block — a position, not an identity. A reordered
|
|
265
265
|
* block renumbers.
|
|
266
266
|
*
|
|
267
|
-
* `parseSpecPhasesBlock` tolerates
|
|
268
|
-
* to introduce on EVERY read (same findings file): a blank line inserted immediately after
|
|
269
|
-
* BEGIN (skipped here — a blank line is never a checkbox line)
|
|
267
|
+
* `parseSpecPhasesBlock` tolerates THREE normalizations a live Jira write→read round-trip is
|
|
268
|
+
* known to introduce on EVERY read (same findings file): a blank line inserted immediately after
|
|
269
|
+
* BEGIN (skipped here — a blank line is never a checkbox line), two trailing spaces appended
|
|
270
270
|
* to the LAST checkbox line (needs no extra handling: TASK_LINE's own `(\S.*?)\s*$` already
|
|
271
|
-
* strips trailing whitespace from the captured text)
|
|
272
|
-
*
|
|
271
|
+
* strips trailing whitespace from the captured text), and two trailing spaces appended to the
|
|
272
|
+
* END MARKER LINE itself (verified live 2026-09-10, spec 056 phase 2 — the slice on
|
|
273
|
+
* indexOf(END) leaves them outside every item, so they never reach the parse). Reuses TASK_LINE
|
|
274
|
+
* from spec-derive.mjs rather than a third checkbox regex.
|
|
275
|
+
*
|
|
276
|
+
* WHY THE RENDERER RIGHT-TRIMS (spec 056 phase 3, verified live): that END-marker whitespace
|
|
277
|
+
* COMPOUNDS if echoed back. Read 2 spaces, write them back unchanged, and the next read returns
|
|
278
|
+
* 4 — then 6, and so on, because Jira appends its own on top of whatever it is given. Phase 1
|
|
279
|
+
* recorded the cycle as "idempotent, not degrading", which held for the checkbox lines it
|
|
280
|
+
* measured but NOT for the END marker. `renderSpecPhasesBlock` emits a clean marker with no
|
|
281
|
+
* trailing whitespace, which re-normalizes every cycle back to a constant 2 (confirmed by a
|
|
282
|
+
* live write of a clean marker returning exactly 2). Splice the rendered block between the
|
|
283
|
+
* existing markers rather than preserving the read bytes, and the growth cannot start. */
|
|
273
284
|
|
|
274
285
|
const SPEC_PHASES_BEGIN = "<!-- spec-phases BEGIN -->";
|
|
275
286
|
const SPEC_PHASES_END = "<!-- spec-phases END -->";
|
|
@@ -402,6 +413,12 @@ export function projectBacklog(root) {
|
|
|
402
413
|
* `jira` by adding one key here; no `if (provider === "...")` branch belongs anywhere. */
|
|
403
414
|
export const providers = {
|
|
404
415
|
backlog: { requiresSync: false, project: projectBacklog },
|
|
416
|
+
// spec 056 R1. `project: null` is not an omission — it is the TYPE-LEVEL statement that this
|
|
417
|
+
// provider cannot be projected by `node` alone (it needs MCP, hence a skill). Registering it
|
|
418
|
+
// is what activates spec 052 R5's `--check` behavior and spec 053 R3/R4's staleness and
|
|
419
|
+
// missing-mirror findings for a Jira host. No `if (provider === "jira")` branch belongs
|
|
420
|
+
// anywhere: the shape of this entry carries the distinction.
|
|
421
|
+
jira: { requiresSync: true, project: null },
|
|
405
422
|
};
|
|
406
423
|
|
|
407
424
|
/* ── `.board.json` config schema (spec 054) — a SEPARATE, smaller table from `providers`
|
|
@@ -462,10 +479,65 @@ export function validateBoardConfig(config) {
|
|
|
462
479
|
if (!sub[field]) problems.push(`${name}.${field}: required for provider "${name}"`);
|
|
463
480
|
if (sub.statusMap !== undefined && (typeof sub.statusMap !== "object" || Array.isArray(sub.statusMap) || sub.statusMap === null))
|
|
464
481
|
problems.push(`${name}.statusMap: expected object, got ${Array.isArray(sub.statusMap) ? "array" : typeof sub.statusMap}`);
|
|
482
|
+
if (sub.statusReadMap !== undefined && (typeof sub.statusReadMap !== "object" || Array.isArray(sub.statusReadMap) || sub.statusReadMap === null))
|
|
483
|
+
problems.push(`${name}.statusReadMap: expected object, got ${Array.isArray(sub.statusReadMap) ? "array" : typeof sub.statusReadMap}`);
|
|
484
|
+
// The injectivity rule differs BY DIRECTION and that asymmetry is the whole point (spec 056
|
|
485
|
+
// Phase 2 / the 2026-09-10 operator ruling). `statusMap` is bridge -> site: it names the one
|
|
486
|
+
// canonical WRITE target per bridge status, so two bridge statuses sharing a site status
|
|
487
|
+
// makes the reverse read ambiguous and is an ERROR. `statusReadMap` is site -> bridge and is
|
|
488
|
+
// many-to-one BY DESIGN (a 15-status workflow collapsing onto 3), so it is deliberately
|
|
489
|
+
// exempt. Silently picking a winner would make verdicts depend on key order.
|
|
490
|
+
if (sub.statusMap && typeof sub.statusMap === "object" && !Array.isArray(sub.statusMap)) {
|
|
491
|
+
const seen = new Map();
|
|
492
|
+
for (const [bridge, site] of Object.entries(sub.statusMap)) {
|
|
493
|
+
if (typeof site !== "string") continue;
|
|
494
|
+
const prior = seen.get(site);
|
|
495
|
+
if (prior !== undefined)
|
|
496
|
+
problems.push(`${name}.statusMap: non-injective — "${prior}" and "${bridge}" both map to site status "${site}"; the reverse read would be ambiguous`);
|
|
497
|
+
else seen.set(site, bridge);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
465
500
|
}
|
|
466
501
|
return problems;
|
|
467
502
|
}
|
|
468
503
|
|
|
504
|
+
/* ── status mapping, both directions (spec 054 R1 + spec 056 Phase 2's ratified amendment) ──
|
|
505
|
+
*
|
|
506
|
+
* Two fields, because the two directions have genuinely different shapes and one field cannot
|
|
507
|
+
* honestly carry both:
|
|
508
|
+
*
|
|
509
|
+
* bridge status ──statusMap──▶ site status (WRITE: injective, one canonical target)
|
|
510
|
+
* site status ──statusReadMap──▶ bridge status (READ: many-to-one BY DESIGN)
|
|
511
|
+
*
|
|
512
|
+
* The live workflow that forced this had FIFTEEN statuses against the bridge's three
|
|
513
|
+
* (`bridge.mjs`'s RANK: to do / in progress / done). Collapsing 15 onto 3 is inherently
|
|
514
|
+
* many-to-one, while the write direction must pick exactly one target per bridge status.
|
|
515
|
+
* See specs/056-jira-provider/findings/phase-2-operator-rulings.md, ruling 2.
|
|
516
|
+
*
|
|
517
|
+
* Both directions FALL THROUGH UNCHANGED on a miss — spec 054 R1's stated rule, kept here so a
|
|
518
|
+
* host with no map at all behaves exactly as it did before either field existed. */
|
|
519
|
+
|
|
520
|
+
/** Map a bridge status to the site's workflow status for a WRITE. Falls through unchanged. */
|
|
521
|
+
export function toSiteStatus(bridgeStatus, config = {}) {
|
|
522
|
+
return config?.statusMap?.[bridgeStatus] ?? bridgeStatus;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** Map a site workflow status back to the bridge's vocabulary for a READ. Prefers the explicit
|
|
526
|
+
* many-to-one `statusReadMap`; with none, inverts `statusMap` (so a host that predates
|
|
527
|
+
* `statusReadMap` keeps its exact prior behavior). Falls through unchanged on a miss — the
|
|
528
|
+
* caller's `verdict()` then reports "unknown" for a status outside the vocabulary rather than
|
|
529
|
+
* this function guessing one. */
|
|
530
|
+
export function toBridgeStatus(siteStatus, config = {}) {
|
|
531
|
+
const read = config?.statusReadMap;
|
|
532
|
+
if (read && typeof read === "object" && !Array.isArray(read) && read[siteStatus] !== undefined)
|
|
533
|
+
return read[siteStatus];
|
|
534
|
+
const write = config?.statusMap;
|
|
535
|
+
if (write && typeof write === "object" && !Array.isArray(write)) {
|
|
536
|
+
for (const [bridge, site] of Object.entries(write)) if (site === siteStatus) return bridge;
|
|
537
|
+
}
|
|
538
|
+
return siteStatus;
|
|
539
|
+
}
|
|
540
|
+
|
|
469
541
|
/** Run git argv `args` in `cwd`. Never throws — a git failure is data, not an exception,
|
|
470
542
|
* matching grounding-wiki/gates/repin-window.mjs's `git()` helper shape (spawnSync, argv
|
|
471
543
|
* array so there is no shell, utf8 encoding). Returns `{ status, out }`; `status` is `null`
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// structured-offload.mjs — schema-validated calls to a local model, fail-soft always.
|
|
2
|
+
//
|
|
3
|
+
// `offload({ prompt, schema, config, timeoutMs })` asks a local Ollama or
|
|
4
|
+
// OpenAI-compatible endpoint to answer `prompt`, constrained to `schema` at the backend
|
|
5
|
+
// (Ollama `format`, OpenAI-compatible `response_format: json_schema`) so the model is
|
|
6
|
+
// structurally prevented from returning prose. The result is validated again on this
|
|
7
|
+
// side against the same schema. NEVER throws: every failure path — unset config,
|
|
8
|
+
// timeout, connection refused, non-2xx, invalid JSON, schema mismatch — resolves to
|
|
9
|
+
// `{ ok: false, reason, residue }` and the caller does the work in-session, exactly as
|
|
10
|
+
// if this module did not exist.
|
|
11
|
+
//
|
|
12
|
+
// `config` is loaded by `loadConfig(root)` from `<root>/.claude/structured-offload.json`
|
|
13
|
+
// (absent/unreadable/malformed -> null -> `reason: 'unconfigured'`), kept separate from
|
|
14
|
+
// `offload` so tests can hand it a stub-server config directly without touching disk.
|
|
15
|
+
//
|
|
16
|
+
// Config shape: `{ endpoint, api: 'ollama'|'openai', model, timeoutMs?, residuePath? }`.
|
|
17
|
+
//
|
|
18
|
+
// Schema checker (minimal subset — extend only when a consumer needs more):
|
|
19
|
+
// - `type`: 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean'
|
|
20
|
+
// - `required`: array of property names that must exist on an object value
|
|
21
|
+
// - `properties`: per-key sub-schemas, checked recursively
|
|
22
|
+
// - `enum`: value must be one of the listed members
|
|
23
|
+
// - `items`: sub-schema every array element must satisfy
|
|
24
|
+
// ponytail: full JSON Schema (oneOf/anyOf/patternProperties/formats/…) is not
|
|
25
|
+
// implemented; the checked subset is what closed-enum/path-lookup callers need.
|
|
26
|
+
//
|
|
27
|
+
// `residue`: `{ backend, model, outcome: 'validated'|'fallback', reason?, ms }`,
|
|
28
|
+
// returned on every call and appended as a JSON line to `config.residuePath` when set.
|
|
29
|
+
|
|
30
|
+
import { readFileSync, appendFileSync } from "node:fs";
|
|
31
|
+
import { join } from "node:path";
|
|
32
|
+
|
|
33
|
+
/** Load and validate the config file, or null on any absence/parse/shape failure. */
|
|
34
|
+
export function loadConfig(root) {
|
|
35
|
+
try {
|
|
36
|
+
const cfg = JSON.parse(readFileSync(join(root, ".claude", "structured-offload.json"), "utf8"));
|
|
37
|
+
if (!cfg || typeof cfg !== "object") return null;
|
|
38
|
+
if (!cfg.endpoint || !["ollama", "openai"].includes(cfg.api) || !cfg.model) return null;
|
|
39
|
+
return cfg;
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isTimeoutError(err) {
|
|
46
|
+
return err?.name === "TimeoutError" || err?.name === "AbortError";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function buildRequest(config, prompt, schema) {
|
|
50
|
+
const messages = [{ role: "user", content: prompt }];
|
|
51
|
+
if (config.api === "ollama") {
|
|
52
|
+
return { path: "/api/chat", body: { model: config.model, messages, format: schema, stream: false } };
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
path: "/v1/chat/completions",
|
|
56
|
+
body: {
|
|
57
|
+
model: config.model,
|
|
58
|
+
messages,
|
|
59
|
+
response_format: { type: "json_schema", json_schema: { name: "offload_response", strict: true, schema } },
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function extractContent(api, data) {
|
|
65
|
+
return api === "ollama" ? data?.message?.content : data?.choices?.[0]?.message?.content;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The minimal schema subset documented in the module header. */
|
|
69
|
+
export function validateSchema(value, schema) {
|
|
70
|
+
if (!schema) return true;
|
|
71
|
+
if (schema.enum) return schema.enum.includes(value);
|
|
72
|
+
switch (schema.type) {
|
|
73
|
+
case "object": {
|
|
74
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
75
|
+
for (const key of schema.required || []) if (!(key in value)) return false;
|
|
76
|
+
if (schema.properties) {
|
|
77
|
+
for (const [key, sub] of Object.entries(schema.properties)) {
|
|
78
|
+
if (key in value && !validateSchema(value[key], sub)) return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
case "array":
|
|
84
|
+
return Array.isArray(value) && (!schema.items || value.every((v) => validateSchema(v, schema.items)));
|
|
85
|
+
case "string":
|
|
86
|
+
return typeof value === "string";
|
|
87
|
+
case "number":
|
|
88
|
+
return typeof value === "number";
|
|
89
|
+
case "integer":
|
|
90
|
+
return Number.isInteger(value);
|
|
91
|
+
case "boolean":
|
|
92
|
+
return typeof value === "boolean";
|
|
93
|
+
default:
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Async, never-throwing: see module header for the full contract. */
|
|
99
|
+
export async function offload({ prompt, schema, config, timeoutMs } = {}) {
|
|
100
|
+
const start = Date.now();
|
|
101
|
+
const backend = config?.api;
|
|
102
|
+
const model = config?.model;
|
|
103
|
+
|
|
104
|
+
function finish(result) {
|
|
105
|
+
const residue = { backend, model, outcome: result.ok ? "validated" : "fallback", ms: Date.now() - start };
|
|
106
|
+
if (!result.ok) residue.reason = result.reason;
|
|
107
|
+
if (config?.residuePath) {
|
|
108
|
+
try { appendFileSync(config.residuePath, JSON.stringify(residue) + "\n"); } catch { /* residue is best-effort */ }
|
|
109
|
+
}
|
|
110
|
+
return { ...result, residue };
|
|
111
|
+
}
|
|
112
|
+
const fail = (reason) => finish({ ok: false, reason });
|
|
113
|
+
|
|
114
|
+
if (!config || !config.endpoint || !["ollama", "openai"].includes(config.api) || !config.model) {
|
|
115
|
+
return fail("unconfigured");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const { path, body } = buildRequest(config, prompt, schema);
|
|
119
|
+
let res;
|
|
120
|
+
try {
|
|
121
|
+
res = await fetch(new URL(path, config.endpoint), {
|
|
122
|
+
method: "POST",
|
|
123
|
+
headers: { "content-type": "application/json" },
|
|
124
|
+
body: JSON.stringify(body),
|
|
125
|
+
signal: AbortSignal.timeout(timeoutMs ?? config.timeoutMs ?? 30000),
|
|
126
|
+
});
|
|
127
|
+
} catch (err) {
|
|
128
|
+
return fail(isTimeoutError(err) ? "timeout" : "refused");
|
|
129
|
+
}
|
|
130
|
+
if (!res.ok) return fail("http-error");
|
|
131
|
+
|
|
132
|
+
let data;
|
|
133
|
+
try { data = await res.json(); } catch { return fail("invalid-json"); }
|
|
134
|
+
|
|
135
|
+
let value;
|
|
136
|
+
try { value = JSON.parse(extractContent(config.api, data)); } catch { return fail("invalid-json"); }
|
|
137
|
+
|
|
138
|
+
if (!validateSchema(value, schema)) return fail("schema-mismatch");
|
|
139
|
+
return finish({ ok: true, value });
|
|
140
|
+
}
|
package/package.json
CHANGED
|
@@ -7,6 +7,10 @@ Planned modules (**TASK-1.2**): `project-root` · `gate-runner` (Stop-hook harne
|
|
|
7
7
|
· `selfcontained` (HTML verifier) · `lifecycle` (status-cannot-exceed-proven-artifacts) ·
|
|
8
8
|
`installer` · `dates` · `template`.
|
|
9
9
|
|
|
10
|
+
Also shipped: `structured-offload` — schema-validated, fail-soft calls to a local
|
|
11
|
+
Ollama/OpenAI-compatible endpoint, config-driven (`.claude/structured-offload.json`),
|
|
12
|
+
opt-in and absent-by-default. See `docs/wiki/chassis.md`.
|
|
13
|
+
|
|
10
14
|
Also shipped here: `handoff-protocol.md` — a stamped copy of the canonical
|
|
11
15
|
`docs/handoff-protocol.md` (re-stamped by `scripts/sync-shared.mjs`), so skills can reference
|
|
12
16
|
the protocol as `${CLAUDE_PLUGIN_ROOT}/lib/handoff-protocol.md` from an installed plugin.
|
|
@@ -264,12 +264,23 @@ export function isPausedLink(link) {
|
|
|
264
264
|
* Indexes are POSITIONAL (1-based) within the block — a position, not an identity. A reordered
|
|
265
265
|
* block renumbers.
|
|
266
266
|
*
|
|
267
|
-
* `parseSpecPhasesBlock` tolerates
|
|
268
|
-
* to introduce on EVERY read (same findings file): a blank line inserted immediately after
|
|
269
|
-
* BEGIN (skipped here — a blank line is never a checkbox line)
|
|
267
|
+
* `parseSpecPhasesBlock` tolerates THREE normalizations a live Jira write→read round-trip is
|
|
268
|
+
* known to introduce on EVERY read (same findings file): a blank line inserted immediately after
|
|
269
|
+
* BEGIN (skipped here — a blank line is never a checkbox line), two trailing spaces appended
|
|
270
270
|
* to the LAST checkbox line (needs no extra handling: TASK_LINE's own `(\S.*?)\s*$` already
|
|
271
|
-
* strips trailing whitespace from the captured text)
|
|
272
|
-
*
|
|
271
|
+
* strips trailing whitespace from the captured text), and two trailing spaces appended to the
|
|
272
|
+
* END MARKER LINE itself (verified live 2026-09-10, spec 056 phase 2 — the slice on
|
|
273
|
+
* indexOf(END) leaves them outside every item, so they never reach the parse). Reuses TASK_LINE
|
|
274
|
+
* from spec-derive.mjs rather than a third checkbox regex.
|
|
275
|
+
*
|
|
276
|
+
* WHY THE RENDERER RIGHT-TRIMS (spec 056 phase 3, verified live): that END-marker whitespace
|
|
277
|
+
* COMPOUNDS if echoed back. Read 2 spaces, write them back unchanged, and the next read returns
|
|
278
|
+
* 4 — then 6, and so on, because Jira appends its own on top of whatever it is given. Phase 1
|
|
279
|
+
* recorded the cycle as "idempotent, not degrading", which held for the checkbox lines it
|
|
280
|
+
* measured but NOT for the END marker. `renderSpecPhasesBlock` emits a clean marker with no
|
|
281
|
+
* trailing whitespace, which re-normalizes every cycle back to a constant 2 (confirmed by a
|
|
282
|
+
* live write of a clean marker returning exactly 2). Splice the rendered block between the
|
|
283
|
+
* existing markers rather than preserving the read bytes, and the growth cannot start. */
|
|
273
284
|
|
|
274
285
|
const SPEC_PHASES_BEGIN = "<!-- spec-phases BEGIN -->";
|
|
275
286
|
const SPEC_PHASES_END = "<!-- spec-phases END -->";
|
|
@@ -402,6 +413,12 @@ export function projectBacklog(root) {
|
|
|
402
413
|
* `jira` by adding one key here; no `if (provider === "...")` branch belongs anywhere. */
|
|
403
414
|
export const providers = {
|
|
404
415
|
backlog: { requiresSync: false, project: projectBacklog },
|
|
416
|
+
// spec 056 R1. `project: null` is not an omission — it is the TYPE-LEVEL statement that this
|
|
417
|
+
// provider cannot be projected by `node` alone (it needs MCP, hence a skill). Registering it
|
|
418
|
+
// is what activates spec 052 R5's `--check` behavior and spec 053 R3/R4's staleness and
|
|
419
|
+
// missing-mirror findings for a Jira host. No `if (provider === "jira")` branch belongs
|
|
420
|
+
// anywhere: the shape of this entry carries the distinction.
|
|
421
|
+
jira: { requiresSync: true, project: null },
|
|
405
422
|
};
|
|
406
423
|
|
|
407
424
|
/* ── `.board.json` config schema (spec 054) — a SEPARATE, smaller table from `providers`
|
|
@@ -462,10 +479,65 @@ export function validateBoardConfig(config) {
|
|
|
462
479
|
if (!sub[field]) problems.push(`${name}.${field}: required for provider "${name}"`);
|
|
463
480
|
if (sub.statusMap !== undefined && (typeof sub.statusMap !== "object" || Array.isArray(sub.statusMap) || sub.statusMap === null))
|
|
464
481
|
problems.push(`${name}.statusMap: expected object, got ${Array.isArray(sub.statusMap) ? "array" : typeof sub.statusMap}`);
|
|
482
|
+
if (sub.statusReadMap !== undefined && (typeof sub.statusReadMap !== "object" || Array.isArray(sub.statusReadMap) || sub.statusReadMap === null))
|
|
483
|
+
problems.push(`${name}.statusReadMap: expected object, got ${Array.isArray(sub.statusReadMap) ? "array" : typeof sub.statusReadMap}`);
|
|
484
|
+
// The injectivity rule differs BY DIRECTION and that asymmetry is the whole point (spec 056
|
|
485
|
+
// Phase 2 / the 2026-09-10 operator ruling). `statusMap` is bridge -> site: it names the one
|
|
486
|
+
// canonical WRITE target per bridge status, so two bridge statuses sharing a site status
|
|
487
|
+
// makes the reverse read ambiguous and is an ERROR. `statusReadMap` is site -> bridge and is
|
|
488
|
+
// many-to-one BY DESIGN (a 15-status workflow collapsing onto 3), so it is deliberately
|
|
489
|
+
// exempt. Silently picking a winner would make verdicts depend on key order.
|
|
490
|
+
if (sub.statusMap && typeof sub.statusMap === "object" && !Array.isArray(sub.statusMap)) {
|
|
491
|
+
const seen = new Map();
|
|
492
|
+
for (const [bridge, site] of Object.entries(sub.statusMap)) {
|
|
493
|
+
if (typeof site !== "string") continue;
|
|
494
|
+
const prior = seen.get(site);
|
|
495
|
+
if (prior !== undefined)
|
|
496
|
+
problems.push(`${name}.statusMap: non-injective — "${prior}" and "${bridge}" both map to site status "${site}"; the reverse read would be ambiguous`);
|
|
497
|
+
else seen.set(site, bridge);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
465
500
|
}
|
|
466
501
|
return problems;
|
|
467
502
|
}
|
|
468
503
|
|
|
504
|
+
/* ── status mapping, both directions (spec 054 R1 + spec 056 Phase 2's ratified amendment) ──
|
|
505
|
+
*
|
|
506
|
+
* Two fields, because the two directions have genuinely different shapes and one field cannot
|
|
507
|
+
* honestly carry both:
|
|
508
|
+
*
|
|
509
|
+
* bridge status ──statusMap──▶ site status (WRITE: injective, one canonical target)
|
|
510
|
+
* site status ──statusReadMap──▶ bridge status (READ: many-to-one BY DESIGN)
|
|
511
|
+
*
|
|
512
|
+
* The live workflow that forced this had FIFTEEN statuses against the bridge's three
|
|
513
|
+
* (`bridge.mjs`'s RANK: to do / in progress / done). Collapsing 15 onto 3 is inherently
|
|
514
|
+
* many-to-one, while the write direction must pick exactly one target per bridge status.
|
|
515
|
+
* See specs/056-jira-provider/findings/phase-2-operator-rulings.md, ruling 2.
|
|
516
|
+
*
|
|
517
|
+
* Both directions FALL THROUGH UNCHANGED on a miss — spec 054 R1's stated rule, kept here so a
|
|
518
|
+
* host with no map at all behaves exactly as it did before either field existed. */
|
|
519
|
+
|
|
520
|
+
/** Map a bridge status to the site's workflow status for a WRITE. Falls through unchanged. */
|
|
521
|
+
export function toSiteStatus(bridgeStatus, config = {}) {
|
|
522
|
+
return config?.statusMap?.[bridgeStatus] ?? bridgeStatus;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/** Map a site workflow status back to the bridge's vocabulary for a READ. Prefers the explicit
|
|
526
|
+
* many-to-one `statusReadMap`; with none, inverts `statusMap` (so a host that predates
|
|
527
|
+
* `statusReadMap` keeps its exact prior behavior). Falls through unchanged on a miss — the
|
|
528
|
+
* caller's `verdict()` then reports "unknown" for a status outside the vocabulary rather than
|
|
529
|
+
* this function guessing one. */
|
|
530
|
+
export function toBridgeStatus(siteStatus, config = {}) {
|
|
531
|
+
const read = config?.statusReadMap;
|
|
532
|
+
if (read && typeof read === "object" && !Array.isArray(read) && read[siteStatus] !== undefined)
|
|
533
|
+
return read[siteStatus];
|
|
534
|
+
const write = config?.statusMap;
|
|
535
|
+
if (write && typeof write === "object" && !Array.isArray(write)) {
|
|
536
|
+
for (const [bridge, site] of Object.entries(write)) if (site === siteStatus) return bridge;
|
|
537
|
+
}
|
|
538
|
+
return siteStatus;
|
|
539
|
+
}
|
|
540
|
+
|
|
469
541
|
/** Run git argv `args` in `cwd`. Never throws — a git failure is data, not an exception,
|
|
470
542
|
* matching grounding-wiki/gates/repin-window.mjs's `git()` helper shape (spawnSync, argv
|
|
471
543
|
* array so there is no shell, utf8 encoding). Returns `{ status, out }`; `status` is `null`
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// structured-offload.mjs — schema-validated calls to a local model, fail-soft always.
|
|
2
|
+
//
|
|
3
|
+
// `offload({ prompt, schema, config, timeoutMs })` asks a local Ollama or
|
|
4
|
+
// OpenAI-compatible endpoint to answer `prompt`, constrained to `schema` at the backend
|
|
5
|
+
// (Ollama `format`, OpenAI-compatible `response_format: json_schema`) so the model is
|
|
6
|
+
// structurally prevented from returning prose. The result is validated again on this
|
|
7
|
+
// side against the same schema. NEVER throws: every failure path — unset config,
|
|
8
|
+
// timeout, connection refused, non-2xx, invalid JSON, schema mismatch — resolves to
|
|
9
|
+
// `{ ok: false, reason, residue }` and the caller does the work in-session, exactly as
|
|
10
|
+
// if this module did not exist.
|
|
11
|
+
//
|
|
12
|
+
// `config` is loaded by `loadConfig(root)` from `<root>/.claude/structured-offload.json`
|
|
13
|
+
// (absent/unreadable/malformed -> null -> `reason: 'unconfigured'`), kept separate from
|
|
14
|
+
// `offload` so tests can hand it a stub-server config directly without touching disk.
|
|
15
|
+
//
|
|
16
|
+
// Config shape: `{ endpoint, api: 'ollama'|'openai', model, timeoutMs?, residuePath? }`.
|
|
17
|
+
//
|
|
18
|
+
// Schema checker (minimal subset — extend only when a consumer needs more):
|
|
19
|
+
// - `type`: 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean'
|
|
20
|
+
// - `required`: array of property names that must exist on an object value
|
|
21
|
+
// - `properties`: per-key sub-schemas, checked recursively
|
|
22
|
+
// - `enum`: value must be one of the listed members
|
|
23
|
+
// - `items`: sub-schema every array element must satisfy
|
|
24
|
+
// ponytail: full JSON Schema (oneOf/anyOf/patternProperties/formats/…) is not
|
|
25
|
+
// implemented; the checked subset is what closed-enum/path-lookup callers need.
|
|
26
|
+
//
|
|
27
|
+
// `residue`: `{ backend, model, outcome: 'validated'|'fallback', reason?, ms }`,
|
|
28
|
+
// returned on every call and appended as a JSON line to `config.residuePath` when set.
|
|
29
|
+
|
|
30
|
+
import { readFileSync, appendFileSync } from "node:fs";
|
|
31
|
+
import { join } from "node:path";
|
|
32
|
+
|
|
33
|
+
/** Load and validate the config file, or null on any absence/parse/shape failure. */
|
|
34
|
+
export function loadConfig(root) {
|
|
35
|
+
try {
|
|
36
|
+
const cfg = JSON.parse(readFileSync(join(root, ".claude", "structured-offload.json"), "utf8"));
|
|
37
|
+
if (!cfg || typeof cfg !== "object") return null;
|
|
38
|
+
if (!cfg.endpoint || !["ollama", "openai"].includes(cfg.api) || !cfg.model) return null;
|
|
39
|
+
return cfg;
|
|
40
|
+
} catch {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isTimeoutError(err) {
|
|
46
|
+
return err?.name === "TimeoutError" || err?.name === "AbortError";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function buildRequest(config, prompt, schema) {
|
|
50
|
+
const messages = [{ role: "user", content: prompt }];
|
|
51
|
+
if (config.api === "ollama") {
|
|
52
|
+
return { path: "/api/chat", body: { model: config.model, messages, format: schema, stream: false } };
|
|
53
|
+
}
|
|
54
|
+
return {
|
|
55
|
+
path: "/v1/chat/completions",
|
|
56
|
+
body: {
|
|
57
|
+
model: config.model,
|
|
58
|
+
messages,
|
|
59
|
+
response_format: { type: "json_schema", json_schema: { name: "offload_response", strict: true, schema } },
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function extractContent(api, data) {
|
|
65
|
+
return api === "ollama" ? data?.message?.content : data?.choices?.[0]?.message?.content;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The minimal schema subset documented in the module header. */
|
|
69
|
+
export function validateSchema(value, schema) {
|
|
70
|
+
if (!schema) return true;
|
|
71
|
+
if (schema.enum) return schema.enum.includes(value);
|
|
72
|
+
switch (schema.type) {
|
|
73
|
+
case "object": {
|
|
74
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
75
|
+
for (const key of schema.required || []) if (!(key in value)) return false;
|
|
76
|
+
if (schema.properties) {
|
|
77
|
+
for (const [key, sub] of Object.entries(schema.properties)) {
|
|
78
|
+
if (key in value && !validateSchema(value[key], sub)) return false;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
case "array":
|
|
84
|
+
return Array.isArray(value) && (!schema.items || value.every((v) => validateSchema(v, schema.items)));
|
|
85
|
+
case "string":
|
|
86
|
+
return typeof value === "string";
|
|
87
|
+
case "number":
|
|
88
|
+
return typeof value === "number";
|
|
89
|
+
case "integer":
|
|
90
|
+
return Number.isInteger(value);
|
|
91
|
+
case "boolean":
|
|
92
|
+
return typeof value === "boolean";
|
|
93
|
+
default:
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Async, never-throwing: see module header for the full contract. */
|
|
99
|
+
export async function offload({ prompt, schema, config, timeoutMs } = {}) {
|
|
100
|
+
const start = Date.now();
|
|
101
|
+
const backend = config?.api;
|
|
102
|
+
const model = config?.model;
|
|
103
|
+
|
|
104
|
+
function finish(result) {
|
|
105
|
+
const residue = { backend, model, outcome: result.ok ? "validated" : "fallback", ms: Date.now() - start };
|
|
106
|
+
if (!result.ok) residue.reason = result.reason;
|
|
107
|
+
if (config?.residuePath) {
|
|
108
|
+
try { appendFileSync(config.residuePath, JSON.stringify(residue) + "\n"); } catch { /* residue is best-effort */ }
|
|
109
|
+
}
|
|
110
|
+
return { ...result, residue };
|
|
111
|
+
}
|
|
112
|
+
const fail = (reason) => finish({ ok: false, reason });
|
|
113
|
+
|
|
114
|
+
if (!config || !config.endpoint || !["ollama", "openai"].includes(config.api) || !config.model) {
|
|
115
|
+
return fail("unconfigured");
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const { path, body } = buildRequest(config, prompt, schema);
|
|
119
|
+
let res;
|
|
120
|
+
try {
|
|
121
|
+
res = await fetch(new URL(path, config.endpoint), {
|
|
122
|
+
method: "POST",
|
|
123
|
+
headers: { "content-type": "application/json" },
|
|
124
|
+
body: JSON.stringify(body),
|
|
125
|
+
signal: AbortSignal.timeout(timeoutMs ?? config.timeoutMs ?? 30000),
|
|
126
|
+
});
|
|
127
|
+
} catch (err) {
|
|
128
|
+
return fail(isTimeoutError(err) ? "timeout" : "refused");
|
|
129
|
+
}
|
|
130
|
+
if (!res.ok) return fail("http-error");
|
|
131
|
+
|
|
132
|
+
let data;
|
|
133
|
+
try { data = await res.json(); } catch { return fail("invalid-json"); }
|
|
134
|
+
|
|
135
|
+
let value;
|
|
136
|
+
try { value = JSON.parse(extractContent(config.api, data)); } catch { return fail("invalid-json"); }
|
|
137
|
+
|
|
138
|
+
if (!validateSchema(value, schema)) return fail("schema-mismatch");
|
|
139
|
+
return finish({ ok: true, value });
|
|
140
|
+
}
|