heyspex 0.2.6 → 0.2.7

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 (85) hide show
  1. package/bin/hs.mjs +85 -25
  2. package/bin/spexcode.mjs +31 -0
  3. package/dev-factory/lib/local-firecrawl.mjs +294 -0
  4. package/dev-factory/lib/seat-context.mjs +20 -4
  5. package/dev-factory/model-catalog.json +222 -222
  6. package/dev-factory/spexcrawl/firecrawl-cli.mjs +755 -0
  7. package/dist/cli/hs-connect.mjs +20 -2
  8. package/dist/cli/hs-defer.mjs +19 -1
  9. package/dist/cli/hs-index-rebuild.mjs +19 -1
  10. package/dist/cli/hs-index-reconcile.mjs +19 -1
  11. package/dist/cli/hs-init.mjs +2 -2
  12. package/dist/cli/hs-install.mjs +2 -2
  13. package/dist/cli/hs-join.mjs +20 -2
  14. package/dist/cli/hs-next-launch.mjs +89 -10
  15. package/dist/cli/hs-spawn.mjs +27 -1
  16. package/dist/cli/hs-vault-pull.mjs +19 -1
  17. package/dist/cli/hs-vault-push.mjs +19 -1
  18. package/dist/cli/hs-vault.mjs +21 -7
  19. package/dist/cli/hs-workspace.mjs +19 -1
  20. package/dist/cli-templates/customer-vault/specs/entity-model.md +1728 -1728
  21. package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +464 -464
  22. package/dist/cli-templates/customer-vault/specs/protocols/destructive-action-checklist.md +122 -122
  23. package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +46 -46
  24. package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +530 -530
  25. package/dist/cli-templates/customer-vault/specs/protocols/spec-summary-authoring.md +83 -83
  26. package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -28
  27. package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -62
  28. package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -29
  29. package/dist/cli-templates/harness-commands/claude/hs-ground.md +30 -30
  30. package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -29
  31. package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -70
  32. package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -35
  33. package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -27
  34. package/dist/cli-templates/harness-commands/claude/hs-res.md +20 -8
  35. package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -23
  36. package/dist/cli-templates/harness-commands/claude/hs-view.md +28 -28
  37. package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -32
  38. package/dist/cli-templates/harness-commands/claude/prototype.md +91 -91
  39. package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +22 -22
  40. package/dist/cli-templates/harness-commands/cursor/hs-capture.md +56 -56
  41. package/dist/cli-templates/harness-commands/cursor/hs-defer.md +23 -23
  42. package/dist/cli-templates/harness-commands/cursor/hs-ground.md +24 -24
  43. package/dist/cli-templates/harness-commands/cursor/hs-next.md +23 -23
  44. package/dist/cli-templates/harness-commands/cursor/hs-promote.md +64 -64
  45. package/dist/cli-templates/harness-commands/cursor/hs-push.md +29 -29
  46. package/dist/cli-templates/harness-commands/cursor/hs-qc.md +21 -21
  47. package/dist/cli-templates/harness-commands/cursor/hs-res.md +20 -8
  48. package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +17 -17
  49. package/dist/cli-templates/harness-commands/cursor/hs-view.md +23 -23
  50. package/dist/cli-templates/harness-commands/cursor/kill-ports.md +26 -26
  51. package/dist/cli-templates/harness-commands/cursor/prototype.md +86 -86
  52. package/docs/copilot-plugin-install.md +7 -0
  53. package/docs/mcp-client-config.md +6 -0
  54. package/package.json +7 -2
  55. package/plugins/heyspex/README.md +6 -0
  56. package/plugins/heyspex/bin/launch-mcp.mjs +17 -0
  57. package/plugins/heyspex/hooks/hooks.json +418 -418
  58. package/scripts/claude-hooks/_intent-classifier-semantic.mjs +2 -2
  59. package/scripts/hs-companion.mjs +38 -8
  60. package/scripts/lib/companion-install.mjs +768 -0
  61. package/scripts/lib/customer-kit.mjs +5 -3
  62. package/scripts/lib/hs-companion-settings.mjs +1 -0
  63. package/scripts/lib/hs-source-push.mjs +386 -386
  64. package/spexharness/doctrine/baseline/SPEXCODE-DOCTRINE.baseline.json +29 -29
  65. package/spexharness/src/adapters/copilot-models.mjs +179 -0
  66. package/spexharness/src/adapters/harness-detect.mjs +148 -0
  67. package/spexharness/src/gates/directive-obligation-gate.decide.mjs +132 -132
  68. package/spexharness/src/gates/vault-write-tier-guard.decide.mjs +153 -125
  69. package/spexharness/src/seat-context.mjs +10 -5
  70. package/spexharness/src/session-bootstrap.mjs +6 -3
  71. package/vendor/heyspex-mcp/cli.mjs +6 -1
  72. package/vendor/heyspex-mcp/config-seed.yaml +15 -15
  73. package/vendor/heyspex-mcp/dev-factory-overlay.mjs +142 -0
  74. package/vendor/heyspex-mcp/memory-tools.mjs +523 -523
  75. package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -1708
  76. package/vendor/heyspex-mcp/procedure-handlers.mjs +327 -327
  77. package/vendor/heyspex-mcp/procedure-tools.mjs +1209 -1181
  78. package/vendor/heyspex-mcp/research-tools.mjs +22 -2
  79. package/vendor/heyspex-mcp/seat-assign-cli.mjs +242 -0
  80. package/vendor/heyspex-mcp/seat-assignment-rules.mjs +96 -0
  81. package/vendor/heyspex-mcp/seat-assignments-client.mjs +265 -0
  82. package/vendor/heyspex-mcp/seat-defaults-cli.mjs +45 -0
  83. package/vendor/heyspex-mcp/seat-defaults.mjs +232 -0
  84. package/vendor/heyspex-mcp/seat-identity.mjs +171 -0
  85. package/vendor/heyspex-mcp/vault-io.mjs +720 -720
@@ -0,0 +1,45 @@
1
+ #!/usr/bin/env node
2
+ // vendor/heyspex-mcp/seat-defaults-cli.mjs — read-only listing of every SpexFleet seat's default
3
+ // model for the harness this shell runs under ([[HS-2K22-C86Z-TASK-plugin-installs-stamp-heyspex-harness]],
4
+ // epic HS-C65J). Thin shell over seat-defaults.mjs + harness-detect.mjs; editing seats is epic HS-K9L7's.
5
+ //
6
+ // node vendor/heyspex-mcp/seat-defaults-cli.mjs [--json] [--harness claude-code|copilot]
7
+ //
8
+ // Exit 0 always for a listing; 2 on a bad argument. Reads the installed-plugin registries under the
9
+ // user's home (or HEYSPEX_SEAT_HOME / COPILOT_HOME when set — tests point these at fake homes).
10
+ import { readFileSync, readdirSync } from 'node:fs';
11
+ import os from 'node:os';
12
+ import { detectHarness, listInstalledHarnessPlugins, normalizeHarness } from '../../spexharness/src/adapters/harness-detect.mjs';
13
+ import { formatSeatDefaults, listSeatDefaults } from './seat-defaults.mjs';
14
+
15
+ export function runSeatDefaultsCli(argv, { env = process.env, stdout = process.stdout, stderr = process.stderr } = {}) {
16
+ let json = false;
17
+ let forced;
18
+ for (let i = 0; i < argv.length; i++) {
19
+ const a = argv[i];
20
+ if (a === '--json') json = true;
21
+ else if (a === '--harness' || a.startsWith('--harness=')) {
22
+ const v = a.includes('=') ? a.slice(a.indexOf('=') + 1) : argv[++i];
23
+ forced = normalizeHarness(v);
24
+ if (forced === undefined) { stderr.write(`seat defaults: --harness must be claude-code, copilot or none (got "${v ?? ''}")\n`); return 2; }
25
+ } else { stderr.write(`seat defaults: unknown argument "${a}"\n`); return 2; }
26
+ }
27
+ const home = env.HEYSPEX_SEAT_HOME || os.homedir();
28
+ const deps = {
29
+ readFile: (p) => { try { return readFileSync(p, 'utf8'); } catch { return null; } },
30
+ listDir: (p) => { try { return readdirSync(p); } catch { return []; } },
31
+ };
32
+ const installed = listInstalledHarnessPlugins({ home, copilotHome: env.COPILOT_HOME || undefined, deps });
33
+ const detected = forced !== undefined
34
+ ? { harness: forced, status: forced ? 'resolved' : 'none', source: 'flag:--harness', detail: forced }
35
+ : detectHarness(env, null, installed);
36
+ if (json) {
37
+ stdout.write(`${JSON.stringify({ harness: detected, installedPlugins: installed, seats: listSeatDefaults(detected.harness) }, null, 2)}\n`);
38
+ } else {
39
+ stdout.write(formatSeatDefaults(detected));
40
+ }
41
+ return 0;
42
+ }
43
+
44
+ const isMain = process.argv[1]?.replaceAll('\\', '/').endsWith('/seat-defaults-cli.mjs');
45
+ if (isMain) process.exitCode = runSeatDefaultsCli(process.argv.slice(2));
@@ -0,0 +1,232 @@
1
+ // vendor/heyspex-mcp/seat-defaults.mjs
2
+ //
3
+ // The CUSTOMER default model for every SpexFleet seat, decided by the harness the installed
4
+ // plugin runs in ([[HS-KQ5E-C86Z-TASK-pure-harness-detection-and-seat-default]] of epic
5
+ // [[HS-C65J-C86Z-EPIC-harness-aware-seat-default-resolver]]; research
6
+ // [[HS-RXJO-C86Z-RES-spexfleet-per-user-seat-assignment-with]]).
7
+ //
8
+ // Owner rulings 2026-10-17 (capture [[HS-5TM7-C86Z-CAP-spexfleet-roles-are-assignable-by-each-u]]):
9
+ // Claude Code → Claude models on every seat, tiered by the seat's job:
10
+ // judgement / planning → opus (COO, director, breakdown, critic, inquisitor, council,
11
+ // research synthesis, architect/engineer/verifier/reviewer/supervisor)
12
+ // execution → sonnet (builder, capture expand)
13
+ // mechanical → haiku (ground rerank, research gather, fit analysis)
14
+ // in-session sub-agent → inherit (follows the user's own main Claude Code session)
15
+ // Copilot → "auto" on every seat.
16
+ // No harness → no default (the caller asks); NEVER an owner default (DeepSeek, routing table).
17
+ //
18
+ // Precedence (resolveSeatModel): explicit per-run override > the user's own assignment (epic
19
+ // HS-K9L7 fills it) > ownerOverlay() (the owner's dev-factory fleet; sibling epic HS-9JMD owns
20
+ // the gate that decides when it may load) > harness default.
21
+ //
22
+ // Pure: no fs, no env reads, no clock. The seat roster below is the PRODUCT roster (the built-in
23
+ // seat ids of the seat registry, epic HS-RJVM), never anyone's seats.json assignments.
24
+
25
+ /** Seat tiers. */
26
+ export const SEAT_TIERS = Object.freeze(['judgement', 'execution', 'mechanical', 'in-session', 'human']);
27
+
28
+ /** The model aliases the Claude Code harness accepts for each tier (aliases track Anthropic's current release). */
29
+ export const CLAUDE_TIER_MODELS = Object.freeze({
30
+ judgement: 'opus',
31
+ execution: 'sonnet',
32
+ mechanical: 'haiku',
33
+ 'in-session': 'inherit',
34
+ human: null,
35
+ });
36
+
37
+ /** Copilot's own automatic model selection — the default for every Copilot seat. */
38
+ export const COPILOT_DEFAULT_MODEL = 'auto';
39
+
40
+ /**
41
+ * The product seat roster: every built-in SpexFleet seat id and its tier. Display titles are for
42
+ * read-only listings only; the id is the contract.
43
+ */
44
+ export const PRODUCT_SEATS = Object.freeze([
45
+ { id: 'owner', tier: 'human', title: 'Owner' },
46
+ { id: 'coo-phase-1', tier: 'judgement', title: 'COO (phase 1)' },
47
+ { id: 'coo-phase-2', tier: 'judgement', title: 'COO (phase 2)' },
48
+ { id: 'director-of-operations', tier: 'judgement', title: 'Director of Operations' },
49
+ { id: 'breakdown', tier: 'judgement', title: 'Breakdown' },
50
+ { id: 'build', tier: 'execution', title: 'Builder' },
51
+ { id: 'critic', tier: 'judgement', title: 'Critic' },
52
+ { id: 'expand', tier: 'execution', title: 'Capture expand' },
53
+ { id: 'research-initiation', tier: 'mechanical', title: 'Research gather' },
54
+ { id: 'research-synthesis', tier: 'judgement', title: 'Research synthesis' },
55
+ { id: 'inquisitor', tier: 'judgement', title: 'Inquisitor' },
56
+ { id: 'fit-analysis', tier: 'mechanical', title: 'Fit analysis' },
57
+ { id: 'ground-rerank', tier: 'mechanical', title: 'Ground rerank' },
58
+ { id: 'software-architect', tier: 'judgement', title: 'Software Architect' },
59
+ { id: 'principal-engineer', tier: 'judgement', title: 'Principal Engineer' },
60
+ { id: 'council-code-reviewer', tier: 'judgement', title: 'Council: code reviewer' },
61
+ { id: 'council-spec-auditor', tier: 'judgement', title: 'Council: spec auditor' },
62
+ { id: 'council-integrator', tier: 'judgement', title: 'Council: integrator' },
63
+ { id: 'subagent-capture-triager', tier: 'in-session', title: 'Sub-agent: capture triager' },
64
+ { id: 'subagent-plan-grounder', tier: 'in-session', title: 'Sub-agent: plan grounder' },
65
+ { id: 'subagent-resolution-triager', tier: 'in-session', title: 'Sub-agent: resolution triager' },
66
+ { id: 'subagent-deployment-engineer', tier: 'in-session', title: 'Sub-agent: deployment engineer' },
67
+ { id: 'subagent-code-reviewer', tier: 'in-session', title: 'Sub-agent: code reviewer' },
68
+ { id: 'subagent-research-synthesizer', tier: 'in-session', title: 'Sub-agent: research synthesizer' },
69
+ { id: 'verifier', tier: 'judgement', title: 'Verifier' },
70
+ { id: 'reviewer', tier: 'judgement', title: 'Reviewer' },
71
+ { id: 'supervisor', tier: 'judgement', title: 'Supervisor' },
72
+ ].map((s) => Object.freeze(s)));
73
+
74
+ const SEAT_BY_ID = new Map(PRODUCT_SEATS.map((s) => [s.id, s]));
75
+
76
+ /** Role names callers use for a seat (HEYSPEX_FLEET_ROLE vocabulary, purposes) → product seat id. */
77
+ export const SEAT_ALIASES = Object.freeze({
78
+ builder: 'build',
79
+ coo: 'coo-phase-1',
80
+ director: 'director-of-operations',
81
+ research: 'research-synthesis',
82
+ 'research-gather': 'research-initiation',
83
+ synthesis: 'research-synthesis',
84
+ rerank: 'ground-rerank',
85
+ 'capture-expand': 'expand',
86
+ fit: 'fit-analysis',
87
+ });
88
+
89
+ /** Normalize a seat id or role name to a product seat id (lower-case, aliases folded). Unknown ids pass through. */
90
+ export function canonicalSeatId(seat) {
91
+ const raw = typeof seat === 'string' ? seat : seat && typeof seat === 'object' ? seat.id : '';
92
+ const id = String(raw ?? '').trim().toLowerCase();
93
+ if (!id) return '';
94
+ return SEAT_ALIASES[id] ?? id;
95
+ }
96
+
97
+ /**
98
+ * The tier of a seat. Product seats use the roster; an unlisted id is classified by shape
99
+ * (council-* → judgement, subagent-* → in-session); any other developer-added seat is execution.
100
+ * @returns {'judgement'|'execution'|'mechanical'|'in-session'|'human'|null} null only for an empty id
101
+ */
102
+ export function seatTier(seat) {
103
+ const id = canonicalSeatId(seat);
104
+ if (!id) return null;
105
+ const known = SEAT_BY_ID.get(id);
106
+ if (known) return known.tier;
107
+ if (id.startsWith('council-')) return 'judgement';
108
+ if (id.startsWith('subagent-')) return 'in-session';
109
+ return 'execution';
110
+ }
111
+
112
+ const HARNESS_NAMES = new Set(['claude-code', 'copilot']);
113
+
114
+ /**
115
+ * @typedef {object} SeatDefault
116
+ * @property {string} seat canonical seat id
117
+ * @property {string|null} tier
118
+ * @property {'claude-code'|'copilot'|null} harness
119
+ * @property {string|null} model null = no default (human seat, or no harness)
120
+ * @property {'harness-default'|'human'|'no-harness'} source
121
+ */
122
+
123
+ /**
124
+ * The harness default model for one seat.
125
+ * @param {string|{id: string}} seat
126
+ * @param {'claude-code'|'copilot'|string|null|undefined} harness
127
+ * @returns {SeatDefault}
128
+ */
129
+ export function defaultModelForSeat(seat, harness) {
130
+ const id = canonicalSeatId(seat);
131
+ const tier = seatTier(id);
132
+ const h = HARNESS_NAMES.has(harness) ? harness : null;
133
+ if (tier === 'human') return Object.freeze({ seat: id, tier, harness: h, model: null, source: 'human' });
134
+ if (!h || !tier) return Object.freeze({ seat: id, tier, harness: h, model: null, source: 'no-harness' });
135
+ const model = h === 'copilot' ? COPILOT_DEFAULT_MODEL : CLAUDE_TIER_MODELS[tier];
136
+ return Object.freeze({ seat: id, tier, harness: h, model, source: 'harness-default' });
137
+ }
138
+
139
+ /** Every product seat's harness default, in roster order. */
140
+ export function listSeatDefaults(harness) {
141
+ return PRODUCT_SEATS.map((s) => ({ ...defaultModelForSeat(s.id, harness), title: s.title }));
142
+ }
143
+
144
+ // ── Owner overlay seam ───────────────────────────────────────────────────────
145
+ // The owner's personal dev-factory fleet (seats.json fill.model, routing table) is an OVERLAY that
146
+ // must only ever load for the owner on a heyspex-app source checkout. Deciding that is sibling epic
147
+ // HS-9JMD's job; this module only provides the slot. The default overlay is "no overlay".
148
+ // A registered overlay receives { seat, harness, ...ctx } and returns a model id string or null.
149
+
150
+ const NO_OVERLAY = () => null;
151
+ let registeredOverlay = NO_OVERLAY;
152
+
153
+ /** Install the owner-overlay resolver (HS-9JMD). Pass null to clear. Returns the previous one. */
154
+ export function registerOwnerOverlay(fn) {
155
+ const prev = registeredOverlay;
156
+ registeredOverlay = typeof fn === 'function' ? fn : NO_OVERLAY;
157
+ return prev;
158
+ }
159
+
160
+ /**
161
+ * Ask the owner overlay for a seat's model. Never throws; anything but a non-empty string is null.
162
+ * @param {{ seat: string, harness?: string|null, [k: string]: unknown }} ctx
163
+ * @param {Function} [overlay] injectable (tests / a caller that carries its own gate)
164
+ * @returns {string|null}
165
+ */
166
+ export function ownerOverlay(ctx = {}, overlay = registeredOverlay) {
167
+ try {
168
+ const m = overlay(ctx);
169
+ return typeof m === 'string' && m.trim() ? m.trim() : null;
170
+ } catch {
171
+ return null;
172
+ }
173
+ }
174
+
175
+ /**
176
+ * @typedef {object} SeatModelResolution
177
+ * @property {string} seat
178
+ * @property {string|null} model
179
+ * @property {'explicit'|'user-assignment'|'owner-overlay'|'harness-default'|'human'|'no-harness'} source
180
+ * @property {'claude-code'|'copilot'|null} harness
181
+ * @property {string|null} tier
182
+ */
183
+
184
+ const nonEmpty = (v) => (typeof v === 'string' && v.trim() ? v.trim() : null);
185
+
186
+ /**
187
+ * The full seat → model chain.
188
+ * @param {object} p
189
+ * @param {string} p.seat
190
+ * @param {'claude-code'|'copilot'|null} [p.harness]
191
+ * @param {string|null} [p.explicit] per-run override (--model, HEYSPEX_SEAT_MODEL_<SEAT>)
192
+ * @param {string|null} [p.assignment] the user's own assignment for this seat (HS-K9L7)
193
+ * @param {Function} [p.overlay] owner-overlay resolver; defaults to the registered one
194
+ * @param {object} [p.overlayContext] extra context handed to the overlay
195
+ * @returns {SeatModelResolution}
196
+ */
197
+ export function resolveSeatModel({ seat, harness = null, explicit = null, assignment = null, overlay, overlayContext = {} } = {}) {
198
+ const base = defaultModelForSeat(seat, harness);
199
+ const out = (model, source) => Object.freeze({ seat: base.seat, model, source, harness: base.harness, tier: base.tier });
200
+ const e = nonEmpty(explicit);
201
+ if (e) return out(e, 'explicit');
202
+ const a = nonEmpty(assignment);
203
+ if (a) return out(a, 'user-assignment');
204
+ const o = ownerOverlay({ ...overlayContext, seat: base.seat, harness: base.harness }, overlay ?? registeredOverlay);
205
+ if (o) return out(o, 'owner-overlay');
206
+ return out(base.model, base.source);
207
+ }
208
+
209
+ /** The env key for a per-run seat override: HEYSPEX_SEAT_MODEL_<SEAT> (seat id upper-cased, '-' → '_'). */
210
+ export function seatModelEnvKey(seat) {
211
+ return `HEYSPEX_SEAT_MODEL_${canonicalSeatId(seat).toUpperCase().replace(/[^A-Z0-9]/g, '_')}`;
212
+ }
213
+
214
+ /**
215
+ * A plain-text table of seat defaults (read-only listing for `hs seat` / smoke proofs).
216
+ * @param {{ harness: string|null, status?: string, choices?: string[] }} detected
217
+ */
218
+ export function formatSeatDefaults(detected) {
219
+ const harness = detected?.harness ?? null;
220
+ const lines = [];
221
+ if (!harness) {
222
+ lines.push(detected?.status === 'needs-choice'
223
+ ? `No harness detected and several are installed (${(detected.choices ?? []).join(', ')}): set HEYSPEX_HARNESS to choose.`
224
+ : 'No harness detected: set HEYSPEX_HARNESS=claude-code or HEYSPEX_HARNESS=copilot.');
225
+ } else {
226
+ lines.push(`Seat defaults for harness: ${harness}`);
227
+ }
228
+ const rows = listSeatDefaults(harness);
229
+ const w = Math.max(...rows.map((r) => r.seat.length));
230
+ for (const r of rows) lines.push(` ${r.seat.padEnd(w)} ${String(r.model ?? '-').padEnd(8)} ${r.source}`);
231
+ return lines.join('\n') + '\n';
232
+ }
@@ -20,6 +20,9 @@
20
20
  import { readFileSync } from "node:fs";
21
21
  import path from "node:path";
22
22
  import { PACKAGE_ROOT } from "./package-root.mjs";
23
+ import { canonicalSeatId, ownerOverlay, registerOwnerOverlay, resolveSeatModel, seatModelEnvKey } from "./seat-defaults.mjs";
24
+ import { devFactoryOverlayVerdict } from "./dev-factory-overlay.mjs";
25
+ import { detectHarness } from "../../spexharness/src/adapters/harness-detect.mjs";
23
26
 
24
27
  /**
25
28
  * Read and parse <vaultRoot>/dev-factory/seats.json.
@@ -126,6 +129,174 @@ export function resolveSeatForCaller({
126
129
  return resolveSeat({ agent, sessionId }, { registry, env });
127
130
  }
128
131
 
132
+ /**
133
+ * True when `packageRoot` is a heyspex-app SOURCE checkout rather than an installed npm package
134
+ * (`…/node_modules/heyspex`). Pure on the path string. The owner's dev-factory overlay only ever
135
+ * applies on a source checkout ([[HS-C65J-C86Z-EPIC-harness-aware-seat-default-resolver]]); the
136
+ * stricter owner-account gate is sibling epic HS-9JMD's, registered through
137
+ * seat-defaults.mjs `registerOwnerOverlay`.
138
+ */
139
+ export function isSourceCheckoutRoot(packageRoot) {
140
+ const p = String(packageRoot ?? "").replaceAll("\\", "/").replace(/\/+$/, "");
141
+ if (!p) return false;
142
+ return !/\/node_modules\/heyspex$/i.test(p);
143
+ }
144
+
145
+ /** The `fill.model` the owner's seats.json assigns `seatId` (aliases folded), or null. Pure. */
146
+ export function seatFillModel(registry, seatId) {
147
+ if (!registry || !Array.isArray(registry.seats)) return null;
148
+ const id = canonicalSeatId(seatId);
149
+ if (!id) return null;
150
+ const seat = registry.seats.find((s) => s && typeof s.id === "string" && canonicalSeatId(s.id) === id);
151
+ const m = seat?.fill?.model;
152
+ return typeof m === "string" && m.trim() ? m.trim() : null;
153
+ }
154
+
155
+ /**
156
+ * Build the owner's dev-factory overlay: seat → `fill.model`, else the first route of the seat's
157
+ * routing purpose (`fill.routingPurpose` / `routing_purpose` / `filledBy.purpose`) through the
158
+ * owner's router `pickRoute`. Pure given its inputs — the caller supplies the registry and the
159
+ * router (never imported here: dev-factory/router.mjs is not in the npm package). Sibling epic
160
+ * HS-9JMD registers the result via seat-defaults.mjs `registerOwnerOverlay` behind its gate.
161
+ * @param {{ registry: object|null, pickRoute?: (purpose: string) => {model?: string}|undefined }} p
162
+ * @returns {(ctx: {seat: string}) => string|null}
163
+ */
164
+ export function createDevFactoryOverlay({ registry, pickRoute } = {}) {
165
+ return ({ seat } = {}) => {
166
+ const direct = seatFillModel(registry, seat);
167
+ if (direct) return direct;
168
+ if (typeof pickRoute !== "function" || !registry || !Array.isArray(registry.seats)) return null;
169
+ const id = canonicalSeatId(seat);
170
+ const row = registry.seats.find((s) => s && typeof s.id === "string" && canonicalSeatId(s.id) === id);
171
+ const purpose = row?.fill?.routingPurpose ?? row?.routing_purpose ?? row?.filledBy?.purpose ?? null;
172
+ if (typeof purpose !== "string" || !purpose) return null;
173
+ try {
174
+ const m = pickRoute(purpose)?.model;
175
+ return typeof m === "string" && m ? m : null;
176
+ } catch {
177
+ return null;
178
+ }
179
+ };
180
+ }
181
+
182
+ /**
183
+ * Which model a seat runs on for this caller — the CUSTOMER default comes from the harness the
184
+ * installed plugin runs in ([[HS-MDZB-C86Z-TASK-wire-harness-seat-defaults-into-seat]]).
185
+ *
186
+ * Chain (seat-defaults.mjs `resolveSeatModel`):
187
+ * 1. explicit: `model`, else env HEYSPEX_SEAT_MODEL_<SEAT>
188
+ * 2. the user's own assignment (`assignment`; epic HS-K9L7 supplies it)
189
+ * 3. owner overlay: a registered overlay (registerDevFactoryOwnerOverlay) wins; otherwise, ONLY
190
+ * when the HS-9JMD gate is open, the vault's dev-factory/seats.json `fill.model`
191
+ * 4. harness default (Claude tiers / Copilot `auto`); no harness → model null, never an owner default
192
+ *
193
+ * @param {object} p
194
+ * @param {string} p.seatId
195
+ * @param {string} [p.vaultRoot] where seats.json is read (only on a source checkout)
196
+ * @param {object} [p.env]
197
+ * @param {unknown} [p.payload] hook payload, when called from a hook
198
+ * @param {string[]} [p.installedPlugins]
199
+ * @param {string|null} [p.model]
200
+ * @param {string|null} [p.assignment]
201
+ * @param {object|null} [p.registry] injected seats.json (tests); read off vaultRoot otherwise
202
+ * @param {string} [p.packageRoot] injected install root (tests); this package's root otherwise
203
+ * @param {Function} [p.pickRouteFn] the owner router pickRoute (owner checkout only; never imported here)
204
+ * @param {boolean} [p.overlayOpen] injected HS-9JMD gate verdict (tests); computed from the root otherwise
205
+ * @returns {{seat: string, model: string|null, source: string, harness: string|null, tier: string|null, harnessDetection: object}}
206
+ */
207
+ export function resolveSeatModelForCaller({
208
+ seatId,
209
+ vaultRoot,
210
+ env = {},
211
+ payload = null,
212
+ installedPlugins = [],
213
+ model = null,
214
+ assignment = null,
215
+ registry,
216
+ packageRoot = PACKAGE_ROOT,
217
+ pickRouteFn,
218
+ overlayOpen,
219
+ } = {}) {
220
+ const detected = detectHarness(env, payload, installedPlugins);
221
+ const explicit = model ?? env?.[seatModelEnvKey(seatId)] ?? null;
222
+ // HS-9JMD gate ([[HS-RA2Q-C86Z-TASK-wire-the-owner-overlay-to-the-dev]]): the inline seats.json
223
+ // fallback opens only where the dev-factory overlay verdict is open — a source checkout AND the
224
+ // owner account AND the switch on. Evaluated lazily, once, and only when no registered overlay
225
+ // answered, so a customer install never even stats the owner files.
226
+ let gate = typeof overlayOpen === "boolean" ? overlayOpen : undefined;
227
+ const isOpen = () => {
228
+ if (gate === undefined) gate = isSourceCheckoutRoot(packageRoot) && ownerOverlayGateOpen({ root: overlayRootFor(vaultRoot, packageRoot), env });
229
+ return gate;
230
+ };
231
+ const overlay = (ctx) => {
232
+ const registered = ownerOverlay(ctx);
233
+ if (registered) return registered;
234
+ if (!isOpen()) return null;
235
+ const reg = registry !== undefined ? registry : vaultRoot ? readSeatsRegistry(vaultRoot) : null;
236
+ return createDevFactoryOverlay({ registry: reg, pickRoute: pickRouteFn })(ctx);
237
+ };
238
+ const r = resolveSeatModel({ seat: seatId, harness: detected.harness, explicit, assignment, overlay });
239
+ return { ...r, harnessDetection: detected };
240
+ }
241
+
242
+ /** The repo root whose dev-factory/ the inline fallback would read (the vault root when it has one). */
243
+ function overlayRootFor(vaultRoot, packageRoot) {
244
+ return vaultRoot || packageRoot || null;
245
+ }
246
+
247
+ /**
248
+ * The HS-9JMD dev-factory overlay verdict for `root` (see dev-factory-overlay.mjs): open only on a
249
+ * heyspex-app source checkout, for the declared owner account, with HEYSPEX_DEV_FACTORY not "0".
250
+ * Never throws.
251
+ */
252
+ export function ownerOverlayGateOpen({ root, env = process.env, accountId } = {}) {
253
+ try {
254
+ return devFactoryOverlayVerdict({ root, env, ...(accountId !== undefined ? { accountId } : {}) }).open === true;
255
+ } catch {
256
+ return false;
257
+ }
258
+ }
259
+
260
+ /**
261
+ * Register the owner's dev-factory fleet as the seat-defaults owner overlay — ONLY when the HS-9JMD
262
+ * gate is open for `root` ([[HS-RA2Q-C86Z-TASK-wire-the-owner-overlay-to-the-dev]]). The router is
263
+ * the owner's `dev-factory/router.mjs` `pickRoute`, loaded from `root` only after the gate opened
264
+ * (it is not in the npm package). A closed gate registers nothing and reads nothing: the seat chain
265
+ * falls through to the harness default.
266
+ *
267
+ * @param {object} [p]
268
+ * @param {string} [p.root] repo root that holds dev-factory/ (default: this package's root)
269
+ * @param {object} [p.env]
270
+ * @param {string|null} [p.accountId] injected signed-in account (tests)
271
+ * @param {Function} [p.pickRoute] injected router (tests); else imported from <root>/dev-factory/router.mjs
272
+ * @param {object|null} [p.registry] injected seats.json (tests); else read from <root>
273
+ * @param {Function} [p.register] injected registerOwnerOverlay (tests)
274
+ * @returns {Promise<{ registered: boolean, reason: string }>}
275
+ */
276
+ export async function registerDevFactoryOwnerOverlay({ root = PACKAGE_ROOT, env = process.env, accountId, pickRoute, registry, register = registerOwnerOverlay } = {}) {
277
+ let verdict;
278
+ try {
279
+ verdict = devFactoryOverlayVerdict({ root, env, ...(accountId !== undefined ? { accountId } : {}) });
280
+ } catch {
281
+ verdict = { open: false, reason: "error" };
282
+ }
283
+ if (!verdict.open) return { registered: false, reason: verdict.reason };
284
+ const reg = registry !== undefined ? registry : readSeatsRegistry(root);
285
+ if (!reg) return { registered: false, reason: "no-seats-registry" };
286
+ let route = pickRoute;
287
+ if (typeof route !== "function") {
288
+ try {
289
+ const { pathToFileURL } = await import("node:url");
290
+ const mod = await import(pathToFileURL(path.join(root, "dev-factory", "router.mjs")).href);
291
+ route = typeof mod.pickRoute === "function" ? (purpose) => mod.pickRoute(purpose) : undefined;
292
+ } catch {
293
+ route = undefined; // no router: fill.model pins still apply
294
+ }
295
+ }
296
+ register(createDevFactoryOverlay({ registry: reg, pickRoute: route }));
297
+ return { registered: true, reason: verdict.reason };
298
+ }
299
+
129
300
  /**
130
301
  * Render a resolved seat as a single-line identity banner for delivery to the
131
302
  * caller — the same seat's OWN mandate, so a Builder worker sees "Builder" and a