@opengsd/gsd-core 1.6.0-rc.3 → 1.7.0-rc.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/.claude-plugin/plugin.json +1 -1
- package/agents/gsd-advisor-researcher.md +2 -0
- package/agents/gsd-ai-researcher.md +2 -0
- package/agents/gsd-assumptions-analyzer.md +2 -0
- package/agents/gsd-doc-classifier.md +2 -0
- package/agents/gsd-doc-synthesizer.md +2 -0
- package/agents/gsd-domain-researcher.md +2 -0
- package/agents/gsd-eval-auditor.md +6 -9
- package/agents/gsd-phase-researcher.md +2 -0
- package/agents/gsd-project-researcher.md +2 -0
- package/agents/gsd-research-synthesizer.md +2 -0
- package/agents/gsd-ui-researcher.md +2 -0
- package/agents/gsd-verifier.md +1 -0
- package/bin/gsd-mcp-server.js +31 -0
- package/bin/install.js +512 -1150
- package/commands/gsd/review.md +6 -0
- package/gemini-extension.json +1 -1
- package/gsd-core/bin/gsd-tools.cjs +127 -2
- package/gsd-core/bin/lib/adapter-declarative.cjs +35 -0
- package/gsd-core/bin/lib/adapter-imperative.cjs +52 -0
- package/gsd-core/bin/lib/assumption-delta.cjs +231 -0
- package/gsd-core/bin/lib/capability-lifecycle.cjs +7 -7
- package/gsd-core/bin/lib/capability-loader.cjs +18 -0
- package/gsd-core/bin/lib/capability-lock.cjs +2 -2
- package/gsd-core/bin/lib/capability-registry.cjs +889 -82
- package/gsd-core/bin/lib/capability-source.cjs +4 -4
- package/gsd-core/bin/lib/capability-validator.cjs +198 -0
- package/gsd-core/bin/lib/cli-skew-check.cjs +44 -0
- package/gsd-core/bin/lib/command-aliases.cjs +18 -1
- package/gsd-core/bin/lib/config.cjs +27 -0
- package/gsd-core/bin/lib/decisions.cjs +27 -0
- package/gsd-core/bin/lib/embedding-adapter.cjs +27 -0
- package/gsd-core/bin/lib/eval-command-router.cjs +21 -0
- package/gsd-core/bin/lib/eval.cjs +60 -0
- package/gsd-core/bin/lib/external-descriptor-trust.cjs +70 -0
- package/gsd-core/bin/lib/frontmatter.cjs +132 -13
- package/gsd-core/bin/lib/hook-bus.cjs +81 -0
- package/gsd-core/bin/lib/host-integration.cjs +408 -0
- package/gsd-core/bin/lib/init.cjs +132 -27
- package/gsd-core/bin/lib/install-engine.cjs +755 -0
- package/gsd-core/bin/lib/install-profiles.cjs +35 -4
- package/gsd-core/bin/lib/installer-migrations.cjs +1 -1
- package/gsd-core/bin/lib/io.cjs +1 -0
- package/gsd-core/bin/lib/mcp-server.cjs +194 -0
- package/gsd-core/bin/lib/milestone.cjs +27 -30
- package/gsd-core/bin/lib/model-adapter.cjs +50 -0
- package/gsd-core/bin/lib/phase.cjs +57 -74
- package/gsd-core/bin/lib/plan-scan.cjs +2 -2
- package/gsd-core/bin/lib/planning-workspace.cjs +1 -1
- package/gsd-core/bin/lib/probe-core.cjs +91 -1
- package/gsd-core/bin/lib/review-reviewer-selection.cjs +129 -13
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +3 -2
- package/gsd-core/bin/lib/roadmap.cjs +17 -3
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +65 -9
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +54 -4
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +5 -2
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +1 -1
- package/gsd-core/bin/lib/runtime-name-policy.cjs +160 -30
- package/gsd-core/bin/lib/shell-command-projection.cjs +47 -1
- package/gsd-core/bin/lib/stale-bake-guard.cjs +254 -0
- package/gsd-core/bin/lib/state-command-router.cjs +4 -0
- package/gsd-core/bin/lib/state-io.cjs +55 -0
- package/gsd-core/bin/lib/state-transition.cjs +1588 -0
- package/gsd-core/bin/lib/state.cjs +340 -689
- package/gsd-core/bin/lib/surface.cjs +4 -1
- package/gsd-core/bin/lib/uat-predicate.cjs +13 -6
- package/gsd-core/bin/lib/verification.cjs +67 -6
- package/gsd-core/bin/lib/verify.cjs +8 -1
- package/gsd-core/bin/lib/workstream.cjs +4 -4
- package/gsd-core/bin/shared/config-defaults.manifest.json +3 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +11 -1
- package/gsd-core/references/honest-verifier.md +105 -0
- package/gsd-core/references/reviewer-instances.md +99 -0
- package/gsd-core/references/untrusted-input-boundary.md +13 -0
- package/gsd-core/workflows/autonomous.md +61 -54
- package/gsd-core/workflows/complete-milestone.md +27 -8
- package/gsd-core/workflows/execute-phase.md +1 -1
- package/gsd-core/workflows/manager.md +30 -20
- package/gsd-core/workflows/new-project.md +78 -12
- package/gsd-core/workflows/plan-phase.md +1 -1
- package/gsd-core/workflows/profile-user.md +6 -2
- package/gsd-core/workflows/progress.md +37 -4
- package/gsd-core/workflows/quick.md +3 -1
- package/gsd-core/workflows/review.md +26 -0
- package/gsd-core/workflows/ship.md +3 -1
- package/gsd-core/workflows/spec-phase.md +3 -1
- package/gsd-core/workflows/thread.md +4 -4
- package/gsd-core/workflows/transition.md +14 -12
- package/gsd-core/workflows/ui-review.md +2 -6
- package/gsd-core/workflows/verify-phase.md +11 -4
- package/gsd-core/workflows/verify-work.md +44 -0
- package/hooks/dist/gsd-graphify-update.sh +7 -1
- package/hooks/dist/gsd-read-injection-scanner.js +49 -25
- package/hooks/gsd-graphify-update.sh +7 -1
- package/hooks/gsd-read-injection-scanner.js +49 -25
- package/hooks/hooks.json +1 -1
- package/package.json +4 -4
- package/scripts/check-alias-drift.cjs +5 -0
- package/scripts/ci-test-scope.cjs +38 -9
- package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -1
- package/scripts/lint-regression-test-names.allowlist.json +3 -0
- package/scripts/lint-test-file-count.allowlist.json +19 -5
- package/scripts/mutation-matrix.cjs +45 -3
- package/scripts/prompt-injection-scan.sh +13 -0
- package/scripts/lint-windows-test-portability.cjs +0 -178
|
@@ -205,20 +205,59 @@ function reconstructFrontmatter(obj) {
|
|
|
205
205
|
}
|
|
206
206
|
return lines.join('\n');
|
|
207
207
|
}
|
|
208
|
+
/**
|
|
209
|
+
* Slice a frontmatter YAML body into per-top-level-key raw text segments. Each segment
|
|
210
|
+
* runs from a column-0 `key:` line through the line before the next column-0 key (or the
|
|
211
|
+
* end), capturing all nested indented content. Used by `spliceFrontmatter` for per-key
|
|
212
|
+
* identity preservation (#1572): a structurally-unchanged key keeps its original raw
|
|
213
|
+
* text, so the lossy `reconstructFrontmatter` never touches object-lists the caller did
|
|
214
|
+
* not modify (e.g. must_haves.artifacts / .prohibitions).
|
|
215
|
+
*/
|
|
216
|
+
function sliceTopLevelFrontmatterSegments(yaml) {
|
|
217
|
+
const lines = yaml.split(/\r?\n/);
|
|
218
|
+
const segments = [];
|
|
219
|
+
let current = null;
|
|
220
|
+
for (const line of lines) {
|
|
221
|
+
// A column-0 `key:` (no leading whitespace) starts a new top-level segment.
|
|
222
|
+
if (/^[A-Za-z0-9_-]+:/.test(line)) {
|
|
223
|
+
if (current)
|
|
224
|
+
segments.push({ key: current.key, raw: current.raw.join('\n') });
|
|
225
|
+
const keyName = line.match(/^([A-Za-z0-9_-]+):/)[1];
|
|
226
|
+
current = { key: keyName, raw: [line] };
|
|
227
|
+
}
|
|
228
|
+
else if (current) {
|
|
229
|
+
current.raw.push(line);
|
|
230
|
+
}
|
|
231
|
+
// Stray lines before the first top-level key (rare in frontmatter) are dropped.
|
|
232
|
+
}
|
|
233
|
+
if (current)
|
|
234
|
+
segments.push({ key: current.key, raw: current.raw.join('\n') });
|
|
235
|
+
return segments;
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Regenerate one frontmatter key's serialization, fail-closed if the lossy
|
|
239
|
+
* `reconstructFrontmatter` cannot represent the value (#1572 codex review). Object-list
|
|
240
|
+
* items (e.g. must_haves.artifacts `{path, provides}` maps) serialize as the literal
|
|
241
|
+
* string "[object Object]"; rather than silently emit that and destroy the data, refuse
|
|
242
|
+
* so the caller (cmdFrontmatterSet/Merge) errors out WITHOUT writing — directing the
|
|
243
|
+
* user to edit the file directly. The reported #1572 case (mutating an UNRELATED field)
|
|
244
|
+
* is unaffected: unchanged keys preserve their original raw text and never reach here.
|
|
245
|
+
*/
|
|
246
|
+
function regenerateFrontmatterKey(key, value) {
|
|
247
|
+
const rendered = reconstructFrontmatter({ [key]: value });
|
|
248
|
+
if (/\[object Object\]/.test(rendered)) {
|
|
249
|
+
throw new Error(`frontmatter: cannot faithfully serialize key "${key}" — it contains a nested object-list ` +
|
|
250
|
+
`(e.g. must_haves.artifacts) the frontmatter writer cannot represent, and serializing it would ` +
|
|
251
|
+
`emit "[object Object]". Edit the file directly instead of using frontmatter set/merge.`);
|
|
252
|
+
}
|
|
253
|
+
return rendered;
|
|
254
|
+
}
|
|
208
255
|
function spliceFrontmatter(content, newObj) {
|
|
209
256
|
const match = content.match(/^---\r?\n[\s\S]+?\r?\n---/);
|
|
210
257
|
if (match) {
|
|
211
|
-
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
// / `{ statement, status, … }` maps). When the caller is writing back a value that is
|
|
215
|
-
// STRUCTURALLY UNCHANGED from the original parse (the canonical CRUD round-trip and the
|
|
216
|
-
// #644 prohibition schema round-trip both do this), regenerating from the lossy object would
|
|
217
|
-
// silently mangle those blocks. Detect that case by deep-equality against a re-parse of the
|
|
218
|
-
// original frontmatter and preserve the ORIGINAL raw text verbatim — a true no-op splice.
|
|
219
|
-
// This touches neither the parser (`extractFrontmatter`) nor `parseMustHavesBlock`; it only
|
|
220
|
-
// makes the existing splice faithful when nothing changed. A genuine mutation (different
|
|
221
|
-
// object) still flows through `reconstructFrontmatter` exactly as before.
|
|
258
|
+
const fmBlock = match[0];
|
|
259
|
+
// Whole-document no-op guard: a true no-op returns content verbatim (byte-exact,
|
|
260
|
+
// including any formatting the lossy serializer would normalize).
|
|
222
261
|
try {
|
|
223
262
|
if (frontmatterDeepEqual(extractFrontmatter(content), newObj)) {
|
|
224
263
|
return content;
|
|
@@ -227,10 +266,63 @@ function spliceFrontmatter(content, newObj) {
|
|
|
227
266
|
catch {
|
|
228
267
|
/* fall through to regeneration on any comparison hiccup */
|
|
229
268
|
}
|
|
230
|
-
|
|
231
|
-
|
|
269
|
+
// Per-key identity preservation (#1572). `reconstructFrontmatter` is a deliberately
|
|
270
|
+
// lossy serializer — it cannot faithfully re-emit nested object-list items (e.g.
|
|
271
|
+
// must_haves.artifacts / .prohibitions, whose items are `{ path, provides }` /
|
|
272
|
+
// `{ statement, status }` maps; `extractFrontmatter` flattens those to scalar
|
|
273
|
+
// strings, so a round-trip drops `provides:` and collapses the list to a malformed
|
|
274
|
+
// inline array). For any top-level key whose value is STRUCTURALLY UNCHANGED between
|
|
275
|
+
// the original parse and `newObj`, preserve that key's ORIGINAL raw text verbatim;
|
|
276
|
+
// regenerate only keys that actually changed. This generalizes the whole-document
|
|
277
|
+
// no-op guard above to per-key fidelity, so mutating `wave` no longer destroys an
|
|
278
|
+
// unrelated `must_haves` block. Keys absent from the original (genuinely new) are
|
|
279
|
+
// regenerated and appended; keys absent from `newObj` are preserved (never silently
|
|
280
|
+
// deleted by a set/merge).
|
|
281
|
+
const fmLines = fmBlock.split(/\r?\n/);
|
|
282
|
+
const inner = fmLines.slice(1, -1).join('\n'); // drop the opening `---` and closing `---`
|
|
283
|
+
let originalParsed;
|
|
284
|
+
try {
|
|
285
|
+
originalParsed = extractFrontmatter(fmBlock);
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
originalParsed = {};
|
|
289
|
+
}
|
|
290
|
+
const segments = sliceTopLevelFrontmatterSegments(inner);
|
|
291
|
+
const emitted = [];
|
|
292
|
+
const seen = new Set();
|
|
293
|
+
for (const seg of segments) {
|
|
294
|
+
seen.add(seg.key);
|
|
295
|
+
if (Object.prototype.hasOwnProperty.call(newObj, seg.key)) {
|
|
296
|
+
// Key is in newObj: preserve original raw text if structurally unchanged,
|
|
297
|
+
// otherwise regenerate. The key SET is defined by newObj — keys that were in
|
|
298
|
+
// the original but are absent from newObj are intentionally dropped (the real
|
|
299
|
+
// cmdSet/cmdMerge flow always passes the full merged object, so this only
|
|
300
|
+
// matters for direct unit callers and matches spliceFrontmatter's contract:
|
|
301
|
+
// the result frontmatter IS newObj).
|
|
302
|
+
if (frontmatterDeepEqual(newObj[seg.key], originalParsed[seg.key])) {
|
|
303
|
+
emitted.push(seg.raw); // unchanged → preserve original raw text verbatim
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
emitted.push(regenerateFrontmatterKey(seg.key, newObj[seg.key])); // changed → regenerate (fail-closed on object-lists)
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
// else: key absent from newObj → drop (not emitted).
|
|
310
|
+
}
|
|
311
|
+
// Append genuinely-new keys not present in the original frontmatter.
|
|
312
|
+
for (const k of Object.keys(newObj)) {
|
|
313
|
+
if (!seen.has(k)) {
|
|
314
|
+
emitted.push(regenerateFrontmatterKey(k, newObj[k]));
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
const yamlStr = emitted.join('\n');
|
|
318
|
+
return `---\n${yamlStr}\n---` + content.slice(fmBlock.length);
|
|
232
319
|
}
|
|
320
|
+
// No existing frontmatter — generate from scratch, fail-closed on unrepresentable values.
|
|
233
321
|
const yamlStr = reconstructFrontmatter(newObj);
|
|
322
|
+
if (/\[object Object\]/.test(yamlStr)) {
|
|
323
|
+
throw new Error('frontmatter: cannot faithfully serialize the requested frontmatter — it contains a nested ' +
|
|
324
|
+
'object-list (e.g. must_haves.artifacts) the writer cannot represent. Edit the file directly.');
|
|
325
|
+
}
|
|
234
326
|
return `---\n${yamlStr}\n---\n\n` + content;
|
|
235
327
|
}
|
|
236
328
|
/**
|
|
@@ -434,9 +526,35 @@ function cmdFrontmatterSet(cwd, filePath, field, value, raw) {
|
|
|
434
526
|
}
|
|
435
527
|
fm[field] = parsedValue;
|
|
436
528
|
const newContent = spliceFrontmatter(content, fm);
|
|
529
|
+
// #1660: a no-op set (newContent unchanged) with a dict-valued field means the lossy
|
|
530
|
+
// frontmatter parser made the new value's projection equal the original's — the change
|
|
531
|
+
// did not apply (bites object-list fields like must_haves). Detection lives in the pure
|
|
532
|
+
// exported helper noOpObjectListSetError so the mutation gate (property/unit set) covers
|
|
533
|
+
// it — the cmd path itself is not in that set.
|
|
534
|
+
const noOpErr = noOpObjectListSetError(content, newContent, parsedValue);
|
|
535
|
+
if (noOpErr) {
|
|
536
|
+
output({ error: noOpErr, field }, raw, undefined);
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
437
539
|
(0, shell_command_projection_cjs_1.platformWriteSync)(fullPath, newContent);
|
|
438
540
|
output({ updated: true, field, value: parsedValue }, raw, 'true');
|
|
439
541
|
}
|
|
542
|
+
/**
|
|
543
|
+
* #1660: detect a frontmatter `set` that would be a silent no-op on a dict-valued field.
|
|
544
|
+
* Returns an error message when the splice produced no content change but the new value
|
|
545
|
+
* is a dict (object-list fields like must_haves, whose `{path, provides}` items flatten to
|
|
546
|
+
* scalar strings under extractFrontmatter so a replacement can deep-equal the original's
|
|
547
|
+
* projection), else null. Scalars and scalar arrays round-trip faithfully, so idempotent
|
|
548
|
+
* sets of those are intentionally NOT flagged. Pure and unit-tested directly (the cmd path
|
|
549
|
+
* is not in Stryker's property/unit set, so the detection must be testable in isolation).
|
|
550
|
+
*/
|
|
551
|
+
function noOpObjectListSetError(originalContent, newContent, parsedValue) {
|
|
552
|
+
if (newContent !== originalContent)
|
|
553
|
+
return null;
|
|
554
|
+
if (parsedValue === null || typeof parsedValue !== 'object' || Array.isArray(parsedValue))
|
|
555
|
+
return null;
|
|
556
|
+
return 'frontmatter set had no effect — the supplied value is equivalent to the existing field under the frontmatter parser, which cannot faithfully round-trip object-list fields like must_haves. Edit the file directly.';
|
|
557
|
+
}
|
|
440
558
|
function cmdFrontmatterMerge(cwd, filePath, data, raw) {
|
|
441
559
|
if (!filePath || !data) {
|
|
442
560
|
error('file and data required');
|
|
@@ -489,6 +607,7 @@ module.exports = {
|
|
|
489
607
|
parseFrontmatter: extractFrontmatter,
|
|
490
608
|
reconstructFrontmatter,
|
|
491
609
|
spliceFrontmatter,
|
|
610
|
+
noOpObjectListSetError,
|
|
492
611
|
parseMustHavesBlock,
|
|
493
612
|
FRONTMATTER_SCHEMAS,
|
|
494
613
|
cmdFrontmatterGet,
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hook-bus seam (ADR-1239 Phase C-1, AC4 / #1680).
|
|
3
|
+
*
|
|
4
|
+
* The lifecycle-hook ownership model, selected by the negotiated `hookBus`
|
|
5
|
+
* axis (host-integration.cts):
|
|
6
|
+
*
|
|
7
|
+
* - `engine` — GSD owns the bus internally (in-process pub/sub). Used by
|
|
8
|
+
* hosts that have no event bus (VS Code). Full subscribe + emit.
|
|
9
|
+
* - `host` — the host fires events; GSD subscribes. Handlers register
|
|
10
|
+
* locally for a Phase-5 host binding to dispatch to; `emit` delegates to a
|
|
11
|
+
* host-supplied emitter (fail-closed until bound — GSD does not drive a
|
|
12
|
+
* host-owned bus).
|
|
13
|
+
* - `none` — no bus (Cline-rules). Degrades to rule-text instructions;
|
|
14
|
+
* subscribe/emit are no-ops.
|
|
15
|
+
*
|
|
16
|
+
* Portable event floor — the "claude dialect" all hook-capable hosts share
|
|
17
|
+
* (sourced from src/runtime-hooks-surface.cts). Extended events are negotiated
|
|
18
|
+
* per-host (Phase 5).
|
|
19
|
+
*
|
|
20
|
+
* Minimal seam (per ADR-1239 open wire-shape question): the host-side dispatch
|
|
21
|
+
* wiring lands in Phase 5 (#1682). This slice ships the three ownership modes
|
|
22
|
+
* + the engine pub-sub + the fail-closed contract.
|
|
23
|
+
*/
|
|
24
|
+
'use strict';
|
|
25
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
26
|
+
exports.PORTABLE_EVENT_FLOOR = void 0;
|
|
27
|
+
exports.createHookBus = createHookBus;
|
|
28
|
+
exports.PORTABLE_EVENT_FLOOR = Object.freeze(['SessionStart', 'PreToolUse', 'PostToolUse', 'Stop', 'SessionEnd']);
|
|
29
|
+
function createHookBus({ bus }, options = {}) {
|
|
30
|
+
if (bus !== 'host' && bus !== 'engine' && bus !== 'none') {
|
|
31
|
+
throw new TypeError(`createHookBus: bus must be 'host' | 'engine' | 'none' (got ${JSON.stringify(bus)})`);
|
|
32
|
+
}
|
|
33
|
+
if (bus === 'none') {
|
|
34
|
+
return Object.freeze({
|
|
35
|
+
bus,
|
|
36
|
+
subscribe() { },
|
|
37
|
+
emit() { },
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
if (bus === 'engine') {
|
|
41
|
+
const subs = new Map();
|
|
42
|
+
return Object.freeze({
|
|
43
|
+
bus: 'engine',
|
|
44
|
+
subscribe(event, handler) {
|
|
45
|
+
const list = subs.get(event);
|
|
46
|
+
if (list)
|
|
47
|
+
list.push(handler);
|
|
48
|
+
else
|
|
49
|
+
subs.set(event, [handler]);
|
|
50
|
+
},
|
|
51
|
+
emit(event, payload) {
|
|
52
|
+
const list = subs.get(event);
|
|
53
|
+
if (!list)
|
|
54
|
+
return;
|
|
55
|
+
for (const h of list) {
|
|
56
|
+
// Handler errors are isolated — one throwing handler must not break the bus.
|
|
57
|
+
try {
|
|
58
|
+
h(payload);
|
|
59
|
+
}
|
|
60
|
+
catch { /* swallow; bus stays up */ }
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
// host: GSD subscribes; emits go to the host-supplied emitter (fail-closed until bound).
|
|
66
|
+
const hostEmit = options.hostEmit;
|
|
67
|
+
return Object.freeze({
|
|
68
|
+
bus: 'host',
|
|
69
|
+
subscribe(_event, _handler) {
|
|
70
|
+
// Host owns the bus; GSD's subscriptions are dispatched by a Phase-5 host
|
|
71
|
+
// binding that calls the registered handlers when the host fires events.
|
|
72
|
+
// Stored host-side; locally this is a seam until that binding lands.
|
|
73
|
+
},
|
|
74
|
+
emit(event, payload) {
|
|
75
|
+
if (typeof hostEmit !== 'function') {
|
|
76
|
+
throw new Error("host hook-bus emit: no host emitter bound — the 'host' bus requires a hostEmit primitive (Phase 5 wires the concrete host).");
|
|
77
|
+
}
|
|
78
|
+
hostEmit(event, payload);
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
}
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* Host Integration module — ADR-1239 Phase A.
|
|
4
|
+
*
|
|
5
|
+
* Pure, additive, no-I/O module providing a closed vocabulary for host
|
|
6
|
+
* integration axes, degradation ladder, profile classification, and
|
|
7
|
+
* capability negotiation.
|
|
8
|
+
*
|
|
9
|
+
* The SINGLE source of truth for integration axes and degradation levels.
|
|
10
|
+
* All functions are pure (no side effects, no I/O).
|
|
11
|
+
*
|
|
12
|
+
* Per-CLI sourced axis VALUES (with citations) live in docs/reference/host-integration-capability-matrix.md — every value is documented or explicitly 'undocumented'.
|
|
13
|
+
*/
|
|
14
|
+
// ---------------------------------------------------------------------------
|
|
15
|
+
// Protocol version
|
|
16
|
+
// ---------------------------------------------------------------------------
|
|
17
|
+
const PROTOCOL_VERSION = 1;
|
|
18
|
+
// ---------------------------------------------------------------------------
|
|
19
|
+
// Undocumented sentinel — fail-closed when a host omits CLI docs for an axis
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
/**
|
|
22
|
+
* Sentinel value used when a host descriptor's CLI docs do not state a value
|
|
23
|
+
* for an axis. It VALIDATES (accepted by the validator) but NEVER propagates
|
|
24
|
+
* into effective axes — it fails closed exactly like an unknown/missing value.
|
|
25
|
+
*
|
|
26
|
+
* Do NOT add to HOST_INTEGRATION_AXES (which is the documented vocabulary).
|
|
27
|
+
*/
|
|
28
|
+
const UNDOCUMENTED = 'undocumented';
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Closed vocabulary — axes and interface points
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
const HOST_INTEGRATION_AXES = Object.freeze({
|
|
33
|
+
embeddingMode: Object.freeze(['imperative', 'declarative']),
|
|
34
|
+
commandSurface: Object.freeze(['slash-file', 'slash-programmatic', 'slash-toml', 'palette', 'prose-only']),
|
|
35
|
+
modelMode: Object.freeze(['active', 'passive']),
|
|
36
|
+
hookBus: Object.freeze(['host', 'engine', 'none']),
|
|
37
|
+
stateIO: Object.freeze(['filesystem', 'sandboxed-storage', 'session-log-append']),
|
|
38
|
+
transport: Object.freeze(['mcp', 'native-extension']),
|
|
39
|
+
runtime: Object.freeze(['node', 'bun', 'sandboxed-web', 'python', 'go', 'rust', 'electron', 'other']),
|
|
40
|
+
subagentToolkit: Object.freeze(['full', 'read-only']),
|
|
41
|
+
});
|
|
42
|
+
const INTERFACE_POINTS = Object.freeze(['command', 'dispatch', 'model', 'hooks', 'state', 'artifact']);
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// Profile baselines
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Fail-closed floor: the most restrictive known value per axis, injected when a host omits an axis (degrade-closed, never assume capability).
|
|
47
|
+
const SAFE_DEFAULTS = {
|
|
48
|
+
embeddingMode: 'declarative',
|
|
49
|
+
commandSurface: 'prose-only',
|
|
50
|
+
dispatch: { namedDispatch: false, nested: false, maxDepth: 0, background: false, subagentToolkit: 'read-only', backgroundDispatch: false },
|
|
51
|
+
modelMode: 'passive',
|
|
52
|
+
hookBus: 'none',
|
|
53
|
+
stateIO: 'session-log-append',
|
|
54
|
+
transport: 'mcp',
|
|
55
|
+
runtime: 'node',
|
|
56
|
+
};
|
|
57
|
+
const PROFILE_BASELINES = Object.freeze({
|
|
58
|
+
'programmatic-cli': Object.freeze({
|
|
59
|
+
embeddingMode: 'imperative',
|
|
60
|
+
commandSurface: 'slash-file',
|
|
61
|
+
dispatch: Object.freeze({ namedDispatch: true, nested: true, maxDepth: -1, background: true, subagentToolkit: 'full', backgroundDispatch: true }),
|
|
62
|
+
modelMode: 'passive',
|
|
63
|
+
hookBus: 'host',
|
|
64
|
+
stateIO: 'filesystem',
|
|
65
|
+
transport: 'mcp',
|
|
66
|
+
runtime: 'node',
|
|
67
|
+
}),
|
|
68
|
+
'declarative-cli': Object.freeze({
|
|
69
|
+
embeddingMode: 'declarative',
|
|
70
|
+
commandSurface: 'slash-file',
|
|
71
|
+
dispatch: Object.freeze({ namedDispatch: true, nested: false, maxDepth: 1, background: false, subagentToolkit: 'full', backgroundDispatch: false }),
|
|
72
|
+
modelMode: 'passive',
|
|
73
|
+
hookBus: 'host',
|
|
74
|
+
stateIO: 'filesystem',
|
|
75
|
+
transport: 'mcp',
|
|
76
|
+
runtime: 'node',
|
|
77
|
+
}),
|
|
78
|
+
'ide': Object.freeze({
|
|
79
|
+
embeddingMode: 'imperative',
|
|
80
|
+
commandSurface: 'palette',
|
|
81
|
+
dispatch: Object.freeze({ namedDispatch: true, nested: true, maxDepth: 5, background: true, subagentToolkit: 'full', backgroundDispatch: true }),
|
|
82
|
+
modelMode: 'active',
|
|
83
|
+
hookBus: 'engine',
|
|
84
|
+
stateIO: 'sandboxed-storage',
|
|
85
|
+
transport: 'mcp',
|
|
86
|
+
runtime: 'sandboxed-web',
|
|
87
|
+
}),
|
|
88
|
+
});
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// degradationFor — plain data-table lookup (NOT clever code)
|
|
91
|
+
// ---------------------------------------------------------------------------
|
|
92
|
+
/**
|
|
93
|
+
* Look up the degradation level for a given interface point and partial axes.
|
|
94
|
+
*
|
|
95
|
+
* NEVER throws. Returns { level:'absent', fallback:'...', unknown:true } for
|
|
96
|
+
* any missing or unrecognised axis value.
|
|
97
|
+
*/
|
|
98
|
+
function degradationFor(point, axes) {
|
|
99
|
+
const UNKNOWN = {
|
|
100
|
+
level: 'absent',
|
|
101
|
+
fallback: 'unknown capability — degraded closed',
|
|
102
|
+
unknown: true,
|
|
103
|
+
};
|
|
104
|
+
switch (point) {
|
|
105
|
+
case 'command': {
|
|
106
|
+
const cs = axes.commandSurface;
|
|
107
|
+
if (cs === 'slash-file' || cs === 'slash-programmatic')
|
|
108
|
+
return { level: 'full', fallback: '' };
|
|
109
|
+
if (cs === 'slash-toml' || cs === 'palette')
|
|
110
|
+
return { level: 'degraded', fallback: 'toml/palette surface — limited command routing' };
|
|
111
|
+
if (cs === 'prose-only')
|
|
112
|
+
return { level: 'absent', fallback: 'AGENTS.md prose + skills menu' };
|
|
113
|
+
return UNKNOWN;
|
|
114
|
+
}
|
|
115
|
+
case 'dispatch': {
|
|
116
|
+
const d = axes.dispatch;
|
|
117
|
+
if (!d || typeof d !== 'object')
|
|
118
|
+
return UNKNOWN;
|
|
119
|
+
const disp = d;
|
|
120
|
+
if (disp.namedDispatch !== true || disp.maxDepth === 0) {
|
|
121
|
+
return { level: 'absent', fallback: 'single-agent inline / SDK sub-session' };
|
|
122
|
+
}
|
|
123
|
+
// maxDepth < 0 means unbounded
|
|
124
|
+
const isUnbounded = typeof disp.maxDepth === 'number' && Number.isFinite(disp.maxDepth) && disp.maxDepth < 0;
|
|
125
|
+
const depth = (typeof disp.maxDepth === 'number' && Number.isFinite(disp.maxDepth)) ? disp.maxDepth : 0;
|
|
126
|
+
const isFullDepth = isUnbounded || (disp.nested === true && depth >= 2);
|
|
127
|
+
if (isFullDepth) {
|
|
128
|
+
// Fail-closed: return 'full' ONLY when subagentToolkit is explicitly 'full';
|
|
129
|
+
// any other value (read-only, undocumented, unknown, missing) → degraded.
|
|
130
|
+
if (disp.subagentToolkit === 'full') {
|
|
131
|
+
return { level: 'full', fallback: '' };
|
|
132
|
+
}
|
|
133
|
+
return { level: 'degraded', fallback: 'restricted/undocumented subagent toolkit — limited dispatch surface' };
|
|
134
|
+
}
|
|
135
|
+
// flat (maxDepth===1)
|
|
136
|
+
return { level: 'degraded', fallback: 'flat dispatch — waves run inline' };
|
|
137
|
+
}
|
|
138
|
+
case 'model': {
|
|
139
|
+
const mm = axes.modelMode;
|
|
140
|
+
if (mm === 'active')
|
|
141
|
+
return { level: 'full', fallback: '' };
|
|
142
|
+
if (mm === 'passive')
|
|
143
|
+
return { level: 'degraded', fallback: 'instruction-injection / per-agent model field' };
|
|
144
|
+
return UNKNOWN;
|
|
145
|
+
}
|
|
146
|
+
case 'hooks': {
|
|
147
|
+
const hb = axes.hookBus;
|
|
148
|
+
if (hb === 'host')
|
|
149
|
+
return { level: 'full', fallback: '' };
|
|
150
|
+
if (hb === 'engine')
|
|
151
|
+
return { level: 'degraded', fallback: 'engine-owned bus' };
|
|
152
|
+
if (hb === 'none')
|
|
153
|
+
return { level: 'absent', fallback: 'rule-text instructions' };
|
|
154
|
+
return UNKNOWN;
|
|
155
|
+
}
|
|
156
|
+
case 'state': {
|
|
157
|
+
const si = axes.stateIO;
|
|
158
|
+
if (si === 'filesystem')
|
|
159
|
+
return { level: 'full', fallback: '' };
|
|
160
|
+
if (si === 'sandboxed-storage')
|
|
161
|
+
return { level: 'degraded', fallback: 'sandboxed storage' };
|
|
162
|
+
if (si === 'session-log-append')
|
|
163
|
+
return { level: 'degraded', fallback: 'append-only session log' };
|
|
164
|
+
return UNKNOWN;
|
|
165
|
+
}
|
|
166
|
+
case 'artifact': {
|
|
167
|
+
const cs = axes.commandSurface;
|
|
168
|
+
if (cs === 'slash-file' || cs === 'slash-programmatic')
|
|
169
|
+
return { level: 'full', fallback: '' };
|
|
170
|
+
if (cs === 'slash-toml' || cs === 'prose-only')
|
|
171
|
+
return { level: 'degraded', fallback: 'menu / @-only' };
|
|
172
|
+
if (cs === 'palette')
|
|
173
|
+
return { level: 'absent', fallback: 'palette + chat participant; skills become LM tools' };
|
|
174
|
+
return UNKNOWN;
|
|
175
|
+
}
|
|
176
|
+
default:
|
|
177
|
+
return UNKNOWN;
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
// profileOf
|
|
182
|
+
// ---------------------------------------------------------------------------
|
|
183
|
+
/**
|
|
184
|
+
* Classify a partial set of integration axes into a named profile.
|
|
185
|
+
* Returns null when no profile can be determined.
|
|
186
|
+
*/
|
|
187
|
+
function profileOf(axes) {
|
|
188
|
+
const a = axes;
|
|
189
|
+
if (a.embeddingMode === 'imperative' && a.runtime === 'sandboxed-web')
|
|
190
|
+
return 'ide';
|
|
191
|
+
if (a.embeddingMode === 'imperative')
|
|
192
|
+
return 'programmatic-cli';
|
|
193
|
+
if (a.embeddingMode === 'declarative')
|
|
194
|
+
return 'declarative-cli';
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
const DEFAULT_ENGINE = {
|
|
198
|
+
protocolVersion: PROTOCOL_VERSION,
|
|
199
|
+
axes: {
|
|
200
|
+
embeddingMode: 'imperative',
|
|
201
|
+
commandSurface: 'slash-file',
|
|
202
|
+
dispatch: { namedDispatch: true, nested: true, maxDepth: -1, background: true, subagentToolkit: 'full', backgroundDispatch: true },
|
|
203
|
+
modelMode: 'active',
|
|
204
|
+
hookBus: 'host',
|
|
205
|
+
stateIO: 'filesystem',
|
|
206
|
+
transport: 'mcp',
|
|
207
|
+
runtime: 'node',
|
|
208
|
+
},
|
|
209
|
+
known: HOST_INTEGRATION_AXES,
|
|
210
|
+
};
|
|
211
|
+
// ---------------------------------------------------------------------------
|
|
212
|
+
// negotiateHostCapabilities
|
|
213
|
+
// ---------------------------------------------------------------------------
|
|
214
|
+
/**
|
|
215
|
+
* Negotiate host integration capabilities against an engine.
|
|
216
|
+
*
|
|
217
|
+
* POST-CONDITION: every effective scalar axis value is in engine.known[axis].
|
|
218
|
+
* effective never contains a value the host didn't declare AND the engine
|
|
219
|
+
* cannot drive.
|
|
220
|
+
*
|
|
221
|
+
* NEVER throws. Returns a fresh object each call (mutation-safe).
|
|
222
|
+
*/
|
|
223
|
+
function negotiateHostCapabilities(host, engine = DEFAULT_ENGINE) {
|
|
224
|
+
const warnings = [];
|
|
225
|
+
const h = host;
|
|
226
|
+
// Warn if protocolVersion is present but not a finite number
|
|
227
|
+
if (h.protocolVersion !== undefined && (typeof h.protocolVersion !== 'number' || !Number.isFinite(h.protocolVersion))) {
|
|
228
|
+
warnings.push(`host protocolVersion is not a finite number — using engine version ${engine.protocolVersion}`);
|
|
229
|
+
}
|
|
230
|
+
const hostPV = (typeof h.protocolVersion === 'number' && Number.isFinite(h.protocolVersion)) ? h.protocolVersion : engine.protocolVersion;
|
|
231
|
+
const enginePV = engine.protocolVersion;
|
|
232
|
+
// Warn if host declares a newer protocol version
|
|
233
|
+
if (hostPV > enginePV) {
|
|
234
|
+
warnings.push(`host protocolVersion ${hostPV} newer than engine ${enginePV} — capabilities beyond version ${enginePV} not trusted`);
|
|
235
|
+
}
|
|
236
|
+
// ---------------------------------------------------------------------------
|
|
237
|
+
// Helper: negotiate a single scalar axis
|
|
238
|
+
// ---------------------------------------------------------------------------
|
|
239
|
+
function negotiateScalar(axis) {
|
|
240
|
+
const knownValues = engine.known[axis];
|
|
241
|
+
const hostVal = h[axis];
|
|
242
|
+
const engineVal = engine.axes[axis];
|
|
243
|
+
const safeDefault = SAFE_DEFAULTS[axis];
|
|
244
|
+
if (hostVal === undefined || hostVal === null) {
|
|
245
|
+
// Host did not declare this axis
|
|
246
|
+
warnings.push(`host did not declare '${axis}'`);
|
|
247
|
+
return safeDefault;
|
|
248
|
+
}
|
|
249
|
+
if (hostVal === UNDOCUMENTED) {
|
|
250
|
+
// Host declared the undocumented sentinel — treat as fail-closed (degrade to safe default)
|
|
251
|
+
warnings.push(`host axis '${axis}' is undocumented — degraded closed`);
|
|
252
|
+
return safeDefault;
|
|
253
|
+
}
|
|
254
|
+
if (!knownValues.includes(hostVal)) {
|
|
255
|
+
// Host declared an unknown/future value — NEVER copy into effective
|
|
256
|
+
warnings.push(`host declared unknown '${axis}' value '${String(hostVal)}' — not trusted (host protocolVersion ${hostPV} vs engine ${enginePV})`);
|
|
257
|
+
return safeDefault;
|
|
258
|
+
}
|
|
259
|
+
// Engine capability cap: if the engine can't drive the host's value,
|
|
260
|
+
// use the engine's lesser capability.
|
|
261
|
+
// For modelMode: 'active' > 'passive' — if host wants active but engine
|
|
262
|
+
// is passive, cap to passive.
|
|
263
|
+
if (axis === 'modelMode') {
|
|
264
|
+
if (hostVal === 'active' && engineVal === 'passive')
|
|
265
|
+
return 'passive';
|
|
266
|
+
}
|
|
267
|
+
return hostVal;
|
|
268
|
+
}
|
|
269
|
+
// Negotiate all scalar axes
|
|
270
|
+
const effectiveEmbeddingMode = negotiateScalar('embeddingMode');
|
|
271
|
+
const effectiveCommandSurface = negotiateScalar('commandSurface');
|
|
272
|
+
const effectiveModelMode = negotiateScalar('modelMode');
|
|
273
|
+
const effectiveHookBus = negotiateScalar('hookBus');
|
|
274
|
+
const effectiveStateIO = negotiateScalar('stateIO');
|
|
275
|
+
const effectiveTransport = negotiateScalar('transport');
|
|
276
|
+
const effectiveRuntime = negotiateScalar('runtime');
|
|
277
|
+
// ---------------------------------------------------------------------------
|
|
278
|
+
// Dispatch struct negotiation
|
|
279
|
+
// ---------------------------------------------------------------------------
|
|
280
|
+
const hostDispatch = (typeof h.dispatch === 'object' && h.dispatch !== null)
|
|
281
|
+
? h.dispatch
|
|
282
|
+
: null;
|
|
283
|
+
const engineDispatch = engine.axes.dispatch;
|
|
284
|
+
let effectiveNamedDispatch;
|
|
285
|
+
let effectiveNested;
|
|
286
|
+
let effectiveBackground;
|
|
287
|
+
let effectiveBackgroundDispatch;
|
|
288
|
+
let effectiveSubagentToolkit;
|
|
289
|
+
let effectiveMaxDepth;
|
|
290
|
+
if (hostDispatch === null) {
|
|
291
|
+
// Host didn't declare dispatch at all — fail-closed to most-restrictive values
|
|
292
|
+
warnings.push(`host did not declare 'dispatch'`);
|
|
293
|
+
effectiveNamedDispatch = false;
|
|
294
|
+
effectiveNested = false;
|
|
295
|
+
effectiveBackground = false;
|
|
296
|
+
effectiveBackgroundDispatch = false;
|
|
297
|
+
effectiveSubagentToolkit = 'read-only';
|
|
298
|
+
effectiveMaxDepth = 0;
|
|
299
|
+
}
|
|
300
|
+
else {
|
|
301
|
+
// N1: observability warnings for 'undocumented' sentinel on dispatch fields
|
|
302
|
+
if (hostDispatch.namedDispatch === 'undocumented') {
|
|
303
|
+
warnings.push(`dispatch.namedDispatch is undocumented — degraded closed`);
|
|
304
|
+
}
|
|
305
|
+
if (hostDispatch.nested === 'undocumented') {
|
|
306
|
+
warnings.push(`dispatch.nested is undocumented — degraded closed`);
|
|
307
|
+
}
|
|
308
|
+
if (hostDispatch.background === 'undocumented') {
|
|
309
|
+
warnings.push(`dispatch.background is undocumented — degraded closed`);
|
|
310
|
+
}
|
|
311
|
+
if (hostDispatch.subagentToolkit === 'undocumented') {
|
|
312
|
+
warnings.push(`dispatch.subagentToolkit is undocumented — degraded closed (read-only)`);
|
|
313
|
+
}
|
|
314
|
+
if (hostDispatch.backgroundDispatch === 'undocumented') {
|
|
315
|
+
warnings.push(`dispatch.backgroundDispatch is undocumented — degraded closed`);
|
|
316
|
+
}
|
|
317
|
+
effectiveNamedDispatch = (hostDispatch.namedDispatch === true) && engineDispatch.namedDispatch;
|
|
318
|
+
effectiveNested = (hostDispatch.nested === true) && engineDispatch.nested;
|
|
319
|
+
effectiveBackground = (hostDispatch.background === true) && engineDispatch.background;
|
|
320
|
+
effectiveBackgroundDispatch = (hostDispatch.backgroundDispatch === true) && engineDispatch.backgroundDispatch;
|
|
321
|
+
// subagentToolkit: fail closed to read-only unless explicitly 'full'
|
|
322
|
+
// (an 'undocumented' or 'read-only' value → read-only)
|
|
323
|
+
const hostToolkit = hostDispatch.subagentToolkit === 'full' ? 'full' : 'read-only';
|
|
324
|
+
const engineToolkit = engineDispatch.subagentToolkit === 'read-only' ? 'read-only' : 'full';
|
|
325
|
+
effectiveSubagentToolkit = (hostToolkit === 'read-only' || engineToolkit === 'read-only') ? 'read-only' : 'full';
|
|
326
|
+
// maxDepth: missing/non-number/non-finite → 0 + warning
|
|
327
|
+
let hostMaxDepth;
|
|
328
|
+
if (typeof hostDispatch.maxDepth !== 'number' || !Number.isFinite(hostDispatch.maxDepth)) {
|
|
329
|
+
warnings.push(`host dispatch.maxDepth is missing or not a number — treating as 0`);
|
|
330
|
+
hostMaxDepth = 0;
|
|
331
|
+
}
|
|
332
|
+
else {
|
|
333
|
+
hostMaxDepth = hostDispatch.maxDepth;
|
|
334
|
+
}
|
|
335
|
+
// Treat negative as +Infinity for the min, then if result is +Infinity emit -1
|
|
336
|
+
const hDepthNum = hostMaxDepth < 0 ? Infinity : hostMaxDepth;
|
|
337
|
+
const eDepthNum = engineDispatch.maxDepth < 0 ? Infinity : engineDispatch.maxDepth;
|
|
338
|
+
const minDepth = Math.min(hDepthNum, eDepthNum);
|
|
339
|
+
effectiveMaxDepth = minDepth === Infinity ? -1 : minDepth;
|
|
340
|
+
// If namedDispatch is false, cap maxDepth/nested/background/backgroundDispatch to 0/false/false/false (struct consistency)
|
|
341
|
+
if (!effectiveNamedDispatch) {
|
|
342
|
+
effectiveMaxDepth = 0;
|
|
343
|
+
effectiveNested = false;
|
|
344
|
+
effectiveBackground = false;
|
|
345
|
+
effectiveBackgroundDispatch = false;
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
const effectiveDispatch = {
|
|
349
|
+
namedDispatch: effectiveNamedDispatch,
|
|
350
|
+
nested: effectiveNested,
|
|
351
|
+
maxDepth: effectiveMaxDepth,
|
|
352
|
+
background: effectiveBackground,
|
|
353
|
+
subagentToolkit: effectiveSubagentToolkit,
|
|
354
|
+
backgroundDispatch: effectiveBackgroundDispatch,
|
|
355
|
+
};
|
|
356
|
+
// ---------------------------------------------------------------------------
|
|
357
|
+
// Assemble effective axes
|
|
358
|
+
// ---------------------------------------------------------------------------
|
|
359
|
+
const effective = {
|
|
360
|
+
embeddingMode: effectiveEmbeddingMode,
|
|
361
|
+
commandSurface: effectiveCommandSurface,
|
|
362
|
+
dispatch: effectiveDispatch,
|
|
363
|
+
modelMode: effectiveModelMode,
|
|
364
|
+
hookBus: effectiveHookBus,
|
|
365
|
+
stateIO: effectiveStateIO,
|
|
366
|
+
transport: effectiveTransport,
|
|
367
|
+
runtime: effectiveRuntime,
|
|
368
|
+
};
|
|
369
|
+
// ---------------------------------------------------------------------------
|
|
370
|
+
// Compute points (fresh objects — mutation-safe)
|
|
371
|
+
// ---------------------------------------------------------------------------
|
|
372
|
+
const points = {};
|
|
373
|
+
for (const point of INTERFACE_POINTS) {
|
|
374
|
+
const hostDeg = degradationFor(point, host);
|
|
375
|
+
const effectiveDeg = degradationFor(point, effective);
|
|
376
|
+
points[point] = {
|
|
377
|
+
hostLevel: hostDeg.level,
|
|
378
|
+
effectiveLevel: effectiveDeg.level,
|
|
379
|
+
fallback: effectiveDeg.fallback,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
// protocolVersion: min of host and engine
|
|
383
|
+
const resultProtocolVersion = Math.min(hostPV, enginePV);
|
|
384
|
+
return {
|
|
385
|
+
protocolVersion: resultProtocolVersion,
|
|
386
|
+
effective,
|
|
387
|
+
points,
|
|
388
|
+
warnings: [...warnings], // fresh copy
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
function shouldFlattenDispatch(dispatch) {
|
|
392
|
+
if (!dispatch || typeof dispatch !== 'object')
|
|
393
|
+
return true;
|
|
394
|
+
const canBackground = dispatch.background === true && dispatch.backgroundDispatch === true;
|
|
395
|
+
return !canBackground;
|
|
396
|
+
}
|
|
397
|
+
module.exports = {
|
|
398
|
+
PROTOCOL_VERSION,
|
|
399
|
+
UNDOCUMENTED,
|
|
400
|
+
HOST_INTEGRATION_AXES,
|
|
401
|
+
INTERFACE_POINTS,
|
|
402
|
+
PROFILE_BASELINES,
|
|
403
|
+
DEFAULT_ENGINE,
|
|
404
|
+
degradationFor,
|
|
405
|
+
profileOf,
|
|
406
|
+
negotiateHostCapabilities,
|
|
407
|
+
shouldFlattenDispatch,
|
|
408
|
+
};
|