@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.
@@ -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 two normalizations a live Jira write→read round-trip is known
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) and two trailing spaces appended
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). Reuses TASK_LINE from spec-derive.mjs
272
- * rather than a third checkbox regex. */
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 two normalizations a live Jira write→read round-trip is known
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) and two trailing spaces appended
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). Reuses TASK_LINE from spec-derive.mjs
272
- * rather than a third checkbox regex. */
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.
@@ -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 two normalizations a live Jira write→read round-trip is known
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) and two trailing spaces appended
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). Reuses TASK_LINE from spec-derive.mjs
272
- * rather than a third checkbox regex. */
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@praxisflux/gates",
3
- "version": "0.62.0",
3
+ "version": "0.63.1",
4
4
  "description": "praxisflux gate checks as a zero-dependency CLI (spec-bridge, wiki-freshness, course) — status can't exceed proven artifacts",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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 two normalizations a live Jira write→read round-trip is known
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) and two trailing spaces appended
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). Reuses TASK_LINE from spec-derive.mjs
272
- * rather than a third checkbox regex. */
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
+ }