@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.
Files changed (105) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/gsd-advisor-researcher.md +2 -0
  3. package/agents/gsd-ai-researcher.md +2 -0
  4. package/agents/gsd-assumptions-analyzer.md +2 -0
  5. package/agents/gsd-doc-classifier.md +2 -0
  6. package/agents/gsd-doc-synthesizer.md +2 -0
  7. package/agents/gsd-domain-researcher.md +2 -0
  8. package/agents/gsd-eval-auditor.md +6 -9
  9. package/agents/gsd-phase-researcher.md +2 -0
  10. package/agents/gsd-project-researcher.md +2 -0
  11. package/agents/gsd-research-synthesizer.md +2 -0
  12. package/agents/gsd-ui-researcher.md +2 -0
  13. package/agents/gsd-verifier.md +1 -0
  14. package/bin/gsd-mcp-server.js +31 -0
  15. package/bin/install.js +512 -1150
  16. package/commands/gsd/review.md +6 -0
  17. package/gemini-extension.json +1 -1
  18. package/gsd-core/bin/gsd-tools.cjs +127 -2
  19. package/gsd-core/bin/lib/adapter-declarative.cjs +35 -0
  20. package/gsd-core/bin/lib/adapter-imperative.cjs +52 -0
  21. package/gsd-core/bin/lib/assumption-delta.cjs +231 -0
  22. package/gsd-core/bin/lib/capability-lifecycle.cjs +7 -7
  23. package/gsd-core/bin/lib/capability-loader.cjs +18 -0
  24. package/gsd-core/bin/lib/capability-lock.cjs +2 -2
  25. package/gsd-core/bin/lib/capability-registry.cjs +889 -82
  26. package/gsd-core/bin/lib/capability-source.cjs +4 -4
  27. package/gsd-core/bin/lib/capability-validator.cjs +198 -0
  28. package/gsd-core/bin/lib/cli-skew-check.cjs +44 -0
  29. package/gsd-core/bin/lib/command-aliases.cjs +18 -1
  30. package/gsd-core/bin/lib/config.cjs +27 -0
  31. package/gsd-core/bin/lib/decisions.cjs +27 -0
  32. package/gsd-core/bin/lib/embedding-adapter.cjs +27 -0
  33. package/gsd-core/bin/lib/eval-command-router.cjs +21 -0
  34. package/gsd-core/bin/lib/eval.cjs +60 -0
  35. package/gsd-core/bin/lib/external-descriptor-trust.cjs +70 -0
  36. package/gsd-core/bin/lib/frontmatter.cjs +132 -13
  37. package/gsd-core/bin/lib/hook-bus.cjs +81 -0
  38. package/gsd-core/bin/lib/host-integration.cjs +408 -0
  39. package/gsd-core/bin/lib/init.cjs +132 -27
  40. package/gsd-core/bin/lib/install-engine.cjs +755 -0
  41. package/gsd-core/bin/lib/install-profiles.cjs +35 -4
  42. package/gsd-core/bin/lib/installer-migrations.cjs +1 -1
  43. package/gsd-core/bin/lib/io.cjs +1 -0
  44. package/gsd-core/bin/lib/mcp-server.cjs +194 -0
  45. package/gsd-core/bin/lib/milestone.cjs +27 -30
  46. package/gsd-core/bin/lib/model-adapter.cjs +50 -0
  47. package/gsd-core/bin/lib/phase.cjs +57 -74
  48. package/gsd-core/bin/lib/plan-scan.cjs +2 -2
  49. package/gsd-core/bin/lib/planning-workspace.cjs +1 -1
  50. package/gsd-core/bin/lib/probe-core.cjs +91 -1
  51. package/gsd-core/bin/lib/review-reviewer-selection.cjs +129 -13
  52. package/gsd-core/bin/lib/roadmap-upgrade.cjs +3 -2
  53. package/gsd-core/bin/lib/roadmap.cjs +17 -3
  54. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +65 -9
  55. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +54 -4
  56. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +5 -2
  57. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +1 -1
  58. package/gsd-core/bin/lib/runtime-name-policy.cjs +160 -30
  59. package/gsd-core/bin/lib/shell-command-projection.cjs +47 -1
  60. package/gsd-core/bin/lib/stale-bake-guard.cjs +254 -0
  61. package/gsd-core/bin/lib/state-command-router.cjs +4 -0
  62. package/gsd-core/bin/lib/state-io.cjs +55 -0
  63. package/gsd-core/bin/lib/state-transition.cjs +1588 -0
  64. package/gsd-core/bin/lib/state.cjs +340 -689
  65. package/gsd-core/bin/lib/surface.cjs +4 -1
  66. package/gsd-core/bin/lib/uat-predicate.cjs +13 -6
  67. package/gsd-core/bin/lib/verification.cjs +67 -6
  68. package/gsd-core/bin/lib/verify.cjs +8 -1
  69. package/gsd-core/bin/lib/workstream.cjs +4 -4
  70. package/gsd-core/bin/shared/config-defaults.manifest.json +3 -0
  71. package/gsd-core/bin/shared/config-schema.manifest.json +11 -1
  72. package/gsd-core/references/honest-verifier.md +105 -0
  73. package/gsd-core/references/reviewer-instances.md +99 -0
  74. package/gsd-core/references/untrusted-input-boundary.md +13 -0
  75. package/gsd-core/workflows/autonomous.md +61 -54
  76. package/gsd-core/workflows/complete-milestone.md +27 -8
  77. package/gsd-core/workflows/execute-phase.md +1 -1
  78. package/gsd-core/workflows/manager.md +30 -20
  79. package/gsd-core/workflows/new-project.md +78 -12
  80. package/gsd-core/workflows/plan-phase.md +1 -1
  81. package/gsd-core/workflows/profile-user.md +6 -2
  82. package/gsd-core/workflows/progress.md +37 -4
  83. package/gsd-core/workflows/quick.md +3 -1
  84. package/gsd-core/workflows/review.md +26 -0
  85. package/gsd-core/workflows/ship.md +3 -1
  86. package/gsd-core/workflows/spec-phase.md +3 -1
  87. package/gsd-core/workflows/thread.md +4 -4
  88. package/gsd-core/workflows/transition.md +14 -12
  89. package/gsd-core/workflows/ui-review.md +2 -6
  90. package/gsd-core/workflows/verify-phase.md +11 -4
  91. package/gsd-core/workflows/verify-work.md +44 -0
  92. package/hooks/dist/gsd-graphify-update.sh +7 -1
  93. package/hooks/dist/gsd-read-injection-scanner.js +49 -25
  94. package/hooks/gsd-graphify-update.sh +7 -1
  95. package/hooks/gsd-read-injection-scanner.js +49 -25
  96. package/hooks/hooks.json +1 -1
  97. package/package.json +4 -4
  98. package/scripts/check-alias-drift.cjs +5 -0
  99. package/scripts/ci-test-scope.cjs +38 -9
  100. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -1
  101. package/scripts/lint-regression-test-names.allowlist.json +3 -0
  102. package/scripts/lint-test-file-count.allowlist.json +19 -5
  103. package/scripts/mutation-matrix.cjs +45 -3
  104. package/scripts/prompt-injection-scan.sh +13 -0
  105. 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
- // Identity-preservation (additive, lossless round-trip): `reconstructFrontmatter` is a
212
- // deliberately lossy serializer — it cannot faithfully re-emit nested object-list items
213
- // (e.g. must_haves.artifacts / must_haves.prohibitions, whose items are `{ path, provides }`
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
- const yamlStr = reconstructFrontmatter(newObj);
231
- return `---\n${yamlStr}\n---` + content.slice(match[0].length);
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
+ };