heyspex 0.2.5 → 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 (110) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/bin/hs.mjs +349 -38
  3. package/bin/spexcode.mjs +31 -0
  4. package/dev-factory/lib/dispatch-guard.mjs +75 -0
  5. package/dev-factory/lib/local-firecrawl.mjs +294 -0
  6. package/dev-factory/lib/runner-path.mjs +51 -0
  7. package/dev-factory/lib/seat-context.mjs +20 -4
  8. package/dev-factory/model-catalog.json +222 -222
  9. package/dev-factory/spexcrawl/firecrawl-cli.mjs +755 -0
  10. package/dev-factory/spexcrawl/spexcrawl-cli.mjs +734 -0
  11. package/dist/cli/hs-connect.mjs +40 -3
  12. package/dist/cli/hs-defer.mjs +3817 -3741
  13. package/dist/cli/hs-events.mjs +1296 -0
  14. package/dist/cli/hs-export-events.mjs +1252 -0
  15. package/dist/cli/hs-handoff.mjs +2576 -0
  16. package/dist/cli/hs-index-rebuild.mjs +228 -37
  17. package/dist/cli/hs-index-reconcile.mjs +231 -40
  18. package/dist/cli/hs-init.mjs +22 -3
  19. package/dist/cli/hs-install.mjs +22 -3
  20. package/dist/cli/hs-join.mjs +40 -3
  21. package/dist/cli/hs-next-launch.mjs +2777 -0
  22. package/dist/cli/hs-pin.mjs +1336 -0
  23. package/dist/cli/hs-prereq.mjs +1380 -0
  24. package/dist/cli/hs-revalidate.mjs +3257 -0
  25. package/dist/cli/hs-spawn.mjs +12403 -0
  26. package/dist/cli/hs-test.mjs +1046 -0
  27. package/dist/cli/hs-trash.mjs +362 -0
  28. package/dist/cli/hs-trust.mjs +197 -0
  29. package/dist/cli/hs-vault-pull.mjs +233 -42
  30. package/dist/cli/hs-vault-push.mjs +231 -40
  31. package/dist/cli/hs-vault.mjs +21 -7
  32. package/dist/cli/hs-workspace.mjs +19 -1
  33. package/dist/cli-templates/customer-vault/specs/entity-model.md +1728 -1728
  34. package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +464 -464
  35. package/dist/cli-templates/customer-vault/specs/protocols/destructive-action-checklist.md +122 -122
  36. package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +46 -46
  37. package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +530 -530
  38. package/dist/cli-templates/customer-vault/specs/protocols/spec-summary-authoring.md +83 -83
  39. package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -28
  40. package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -62
  41. package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -29
  42. package/dist/cli-templates/harness-commands/claude/hs-ground.md +30 -30
  43. package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -29
  44. package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -70
  45. package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -35
  46. package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -27
  47. package/dist/cli-templates/harness-commands/claude/hs-res.md +83 -63
  48. package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -23
  49. package/dist/cli-templates/harness-commands/claude/hs-view.md +28 -28
  50. package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -32
  51. package/dist/cli-templates/harness-commands/claude/prototype.md +91 -91
  52. package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +22 -22
  53. package/dist/cli-templates/harness-commands/cursor/hs-capture.md +56 -56
  54. package/dist/cli-templates/harness-commands/cursor/hs-defer.md +23 -23
  55. package/dist/cli-templates/harness-commands/cursor/hs-ground.md +24 -24
  56. package/dist/cli-templates/harness-commands/cursor/hs-next.md +23 -23
  57. package/dist/cli-templates/harness-commands/cursor/hs-promote.md +64 -64
  58. package/dist/cli-templates/harness-commands/cursor/hs-push.md +29 -29
  59. package/dist/cli-templates/harness-commands/cursor/hs-qc.md +21 -21
  60. package/dist/cli-templates/harness-commands/cursor/hs-res.md +78 -58
  61. package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +17 -17
  62. package/dist/cli-templates/harness-commands/cursor/hs-view.md +23 -23
  63. package/dist/cli-templates/harness-commands/cursor/kill-ports.md +26 -26
  64. package/dist/cli-templates/harness-commands/cursor/prototype.md +86 -86
  65. package/docs/copilot-plugin-install.md +9 -2
  66. package/docs/mcp-client-config.md +6 -0
  67. package/package.json +266 -257
  68. package/plugins/heyspex/.claude-plugin/plugin.json +1 -1
  69. package/plugins/heyspex/.cursor-plugin/plugin.json +1 -1
  70. package/plugins/heyspex/README.md +6 -0
  71. package/plugins/heyspex/bin/launch-mcp.mjs +17 -0
  72. package/plugins/heyspex/hooks/hooks.json +418 -418
  73. package/scripts/claude-hooks/_intent-classifier-semantic.mjs +2 -2
  74. package/scripts/claude-hooks/wip-checkpoint.mjs +17 -2
  75. package/scripts/claude-statusline/statusline-command.mjs +287 -27
  76. package/scripts/hs-companion.mjs +41 -3
  77. package/scripts/install-claude-code-hooks.mjs +104 -3
  78. package/scripts/install-copilot-hooks.mjs +21 -1
  79. package/scripts/install-cursor-hooks.mjs +4 -3
  80. package/scripts/lib/capture-execution-guard.mjs +221 -0
  81. package/scripts/lib/companion-install.mjs +768 -0
  82. package/scripts/lib/customer-kit.mjs +53 -3
  83. package/scripts/lib/hs-companion-settings.mjs +26 -0
  84. package/scripts/lib/hs-source-push.mjs +386 -386
  85. package/scripts/record-grounding.mjs +38 -0
  86. package/spexharness/doctrine/baseline/SPEXCODE-DOCTRINE.baseline.json +29 -29
  87. package/spexharness/src/adapters/copilot-models.mjs +179 -0
  88. package/spexharness/src/adapters/harness-detect.mjs +148 -0
  89. package/spexharness/src/gates/directive-obligation-gate.decide.mjs +132 -132
  90. package/spexharness/src/gates/vault-write-tier-guard.decide.mjs +153 -125
  91. package/spexharness/src/seat-context.mjs +10 -5
  92. package/spexharness/src/session-bootstrap.mjs +6 -3
  93. package/vendor/heyspex-mcp/cli.mjs +6 -1
  94. package/vendor/heyspex-mcp/config-seed.yaml +15 -15
  95. package/vendor/heyspex-mcp/dev-factory-overlay.mjs +142 -0
  96. package/vendor/heyspex-mcp/http-transport.mjs +1 -1
  97. package/vendor/heyspex-mcp/memory-tools.mjs +523 -523
  98. package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -1708
  99. package/vendor/heyspex-mcp/procedure-handlers.mjs +327 -327
  100. package/vendor/heyspex-mcp/procedure-tools.mjs +1209 -1181
  101. package/vendor/heyspex-mcp/research-tools.mjs +22 -2
  102. package/vendor/heyspex-mcp/seat-assign-cli.mjs +242 -0
  103. package/vendor/heyspex-mcp/seat-assignment-rules.mjs +96 -0
  104. package/vendor/heyspex-mcp/seat-assignments-client.mjs +265 -0
  105. package/vendor/heyspex-mcp/seat-defaults-cli.mjs +45 -0
  106. package/vendor/heyspex-mcp/seat-defaults.mjs +232 -0
  107. package/vendor/heyspex-mcp/seat-identity.mjs +171 -0
  108. package/vendor/heyspex-mcp/test-helpers.mjs +37 -23
  109. package/vendor/heyspex-mcp/vault-io.mjs +720 -720
  110. package/bin/postinstall.mjs +0 -88
@@ -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
@@ -3,9 +3,9 @@
3
3
  */
4
4
 
5
5
  import { randomUUID } from 'node:crypto';
6
- import { cpSync, existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
6
+ import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
7
7
  import { tmpdir } from 'node:os';
8
- import { join } from 'node:path';
8
+ import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
9
9
 
10
10
  import { resetActiveConfigCache } from './vault-resolve.mjs';
11
11
 
@@ -134,35 +134,49 @@ export function installScopedConsoleEnv({
134
134
  * part of `scripts/`) also imports `vendor/heyspex-mcp/vault-resolve.mjs` by a
135
135
  * relative path OUTSIDE `scripts/` — that file must be mirrored too, or the
136
136
  * copied validator dangles with MODULE_NOT_FOUND the moment it computes its
137
- * own default root. `vault-resolve.mjs` itself has no further local-file
138
- * imports (node builtins only), so this is a single extra copy, not a chain.
137
+ * own default root, and that file in turn imports `vault-binding.mjs` →
138
+ * `usable-dir-env.mjs` (a chain, not a single copy).
139
139
  *
140
- * Single source of truth: the next dependency the validator grows is a one-line
141
- * add here, not a silent breakage in every round-trip test.
140
+ * The mirror is DERIVED, not hand-listed: a hardcoded list rotted three times
141
+ * as modules grew imports (last miss: `usable-dir-env.mjs`, HS-PNBC). We walk
142
+ * relative import specifiers transitively from the validator and copy every
143
+ * reached file that lives outside `scripts/` (which is copied wholesale).
142
144
  *
143
145
  * @param {string} repoRoot Absolute path to the real repo root.
144
146
  * @param {string} root Absolute path to the temp vault being assembled.
145
147
  */
146
148
  export function mirrorValidatorDeps(repoRoot, root) {
147
149
  cpSync(join(repoRoot, 'scripts'), join(root, 'scripts'), { recursive: true });
148
-
149
- mkdirSync(join(root, 'vendor', 'heyspex-mcp'), { recursive: true });
150
- // vault-resolve.mjs imports vault-binding.mjs (the repo binding, HS-TYJA).
151
- for (const file of ['vault-resolve.mjs', 'vault-binding.mjs']) {
152
- const rel = join('vendor', 'heyspex-mcp', file);
153
- cpSync(join(repoRoot, rel), join(root, rel));
150
+ for (const abs of localImportClosure(join(repoRoot, 'scripts', 'validate-vault-ids.mjs'))) {
151
+ const rel = relative(repoRoot, abs);
152
+ if (rel.startsWith('..') || isAbsolute(rel)) continue;
153
+ if (rel.split(sep)[0] === 'scripts') continue; // already copied wholesale
154
+ mkdirSync(dirname(join(root, rel)), { recursive: true });
155
+ cpSync(abs, join(root, rel));
154
156
  }
157
+ }
158
+
159
+ // Relative specifiers in `from '…'`, side-effect `import '…'`, and literal `import('…')`.
160
+ const LOCAL_IMPORT_RE = /(?:\bfrom\s*|\bimport\s*\(?\s*)(['"])(\.{1,2}\/[^'"]+)\1/g;
155
161
 
156
- const indexerRel = join('spex-console', 'src', 'lib', 'indexer');
157
- mkdirSync(join(root, indexerRel), { recursive: true });
158
- for (const file of [
159
- 'vault-rules.mjs',
160
- 'vault-naming-rules.generated.mjs',
161
- // validate-vault-ids.mjs imports handoff-envelope.mjs (split/defer lineage rule).
162
- 'handoff-envelope.mjs',
163
- ]) {
164
- const rel = join(indexerRel, file);
165
- const src = join(repoRoot, rel);
166
- if (existsSync(src)) cpSync(src, join(root, rel));
162
+ /**
163
+ * Every local file transitively reachable from `entry` via relative import
164
+ * specifiers (bare/builtin specifiers ignored). A missing target is skipped so
165
+ * the copied script fails loudly with MODULE_NOT_FOUND instead of here.
166
+ *
167
+ * @param {string} entry Absolute path to the entry module.
168
+ * @returns {Set<string>} Absolute paths, entry included.
169
+ */
170
+ export function localImportClosure(entry) {
171
+ const seen = new Set();
172
+ const stack = [entry];
173
+ while (stack.length) {
174
+ const file = stack.pop();
175
+ if (seen.has(file) || !existsSync(file)) continue;
176
+ seen.add(file);
177
+ for (const m of readFileSync(file, 'utf8').matchAll(LOCAL_IMPORT_RE)) {
178
+ stack.push(resolve(dirname(file), m[2]));
179
+ }
167
180
  }
181
+ return seen;
168
182
  }