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
@@ -28,6 +28,7 @@ import { listConfigLeaves } from './config-settings.mjs';
28
28
  // the MCP server's package closure. See dev-factory-bridge.mjs for the full
29
29
  // rationale ([[HS-ZJSP-0JNK-TASK-ship-the-heyspex-mcp-server-as-an]]).
30
30
  import { loadDreamResearch } from './dev-factory-bridge.mjs';
31
+ import { resolveSeatModelForCaller } from './seat-identity.mjs';
31
32
 
32
33
  const RESEARCH_PREFIX = 'agent_behaviors.research.';
33
34
 
@@ -39,12 +40,30 @@ const RESEARCH_PREFIX = 'agent_behaviors.research.';
39
40
  * source — NOT the resource-profile/fallback-chain/capability-gating resolution the research
40
41
  * doc's separate feature will add later.
41
42
  */
42
- export async function executeResolveResearchPlan(vaultRoot) {
43
+ /**
44
+ * The research agent when `default_agent` is not configured: the harness default for the
45
+ * research-synthesis seat ([[HS-J3NY-C86Z-TASK-replace-hard-coded-owner-defaults-on]]) —
46
+ * opus under Claude Code, auto under Copilot, null with no harness (the caller asks). Never an
47
+ * owner default: the owner's DeepSeek routing only answers through the gated owner overlay.
48
+ * @returns {{ agent: string|null, source: string }}
49
+ */
50
+ export function defaultResearchAgent({ env = process.env, resolve = resolveSeatModelForCaller } = {}) {
51
+ try {
52
+ const r = resolve({ seatId: 'research-synthesis', env });
53
+ return { agent: r?.model ?? null, source: r?.source ?? 'no-harness' };
54
+ } catch {
55
+ return { agent: null, source: 'no-harness' };
56
+ }
57
+ }
58
+
59
+ export async function executeResolveResearchPlan(vaultRoot, { env = process.env } = {}) {
43
60
  const leaves = listConfigLeaves(vaultRoot).filter((s) => s.key.startsWith(RESEARCH_PREFIX));
44
61
  const cfg = Object.fromEntries(leaves.map((s) => [s.key.slice(RESEARCH_PREFIX.length), s.value]));
45
62
 
46
63
  const method = cfg.default_method ?? 'ladder';
47
- const defaultAgent = cfg.default_agent ?? 'deepseek';
64
+ const fallbackAgent = cfg.default_agent ? null : defaultResearchAgent({ env });
65
+ const defaultAgent = cfg.default_agent ?? fallbackAgent.agent;
66
+ const defaultAgentSource = cfg.default_agent ? 'config' : fallbackAgent.source;
48
67
  const defaultSource = cfg.default_source ?? 'web-native';
49
68
 
50
69
  let endpoint = null;
@@ -76,6 +95,7 @@ export async function executeResolveResearchPlan(vaultRoot) {
76
95
  ok: true,
77
96
  method,
78
97
  default_agent: defaultAgent,
98
+ default_agent_source: defaultAgentSource,
79
99
  default_source: defaultSource,
80
100
  resource: { source: defaultSource, endpoint },
81
101
  note,
@@ -0,0 +1,242 @@
1
+ #!/usr/bin/env node
2
+ // vendor/heyspex-mcp/seat-assign-cli.mjs — `hs seat list|set|reset|defaults` for customers
3
+ // ([[HS-EUNP-C86Z-TASK-hs-seat-list-set-reset-for-customers-in]], epic
4
+ // [[HS-K9L7-C86Z-EPIC-per-user-per-vault-seat-assignments]]).
5
+ //
6
+ // hs seat list [--harness claude-code|copilot] [--json] [--refresh]
7
+ // hs seat set <seat> <model> [--harness claude-code|copilot]
8
+ // hs seat reset <seat> [--harness claude-code|copilot]
9
+ // hs seat defaults [--harness …] [--json] (read-only harness defaults; seat-defaults-cli)
10
+ //
11
+ // Every choice is the signed-in user's OWN, for the vault this repo is bound to, under one harness
12
+ // (`/api/me/seats`). Strictly per user (owner ruling 2026-10-17). Never reads or writes the
13
+ // owner's dev-factory/seats.json — the source-checkout `hs seat add|update|disable` registry
14
+ // verbs are a different tool.
15
+ //
16
+ // Exit: 0 ok · 1 the console refused or could not be reached · 2 bad arguments / invalid model.
17
+ import { readdirSync, readFileSync } from 'node:fs';
18
+ import os from 'node:os';
19
+
20
+ import { detectHarness, listInstalledHarnessPlugins, normalizeHarness } from '../../spexharness/src/adapters/harness-detect.mjs';
21
+ import { listCopilotModels, validateCopilotModel } from '../../spexharness/src/adapters/copilot-models.mjs';
22
+ import { validateSeatAssignment } from './seat-assignment-rules.mjs';
23
+ import {
24
+ fetchSeatAssignments, invalidateSeatCache, assignmentFor, normalizeAssignments, reportCopilotFallback, SEATS_ENDPOINT,
25
+ } from './seat-assignments-client.mjs';
26
+ import { PRODUCT_SEATS, canonicalSeatId, resolveSeatModel } from './seat-defaults.mjs';
27
+ import { runSeatDefaultsCli } from './seat-defaults-cli.mjs';
28
+
29
+ const SOURCE_LABELS = {
30
+ 'user-assignment': 'you · this vault',
31
+ 'harness-default': 'harness default',
32
+ human: 'a person',
33
+ 'no-harness': 'no harness',
34
+ };
35
+
36
+ export const SEAT_CLI_USAGE = [
37
+ 'Usage:',
38
+ ' hs seat list [--harness claude-code|copilot] [--json] [--refresh] Every seat, its model and where it comes from',
39
+ ' hs seat set <seat> <model> [--harness claude-code|copilot] Use <model> for <seat> (you only, this vault)',
40
+ ' hs seat reset <seat> [--harness claude-code|copilot] Back to the harness default',
41
+ ' hs seat defaults [--harness …] [--json] The harness defaults alone',
42
+ ' hs seat report-fallback <seat> --log <file> Record that Copilot refused a seat model (launchers)',
43
+ '',
44
+ 'Claude Code seats take opus, sonnet, haiku, fable, inherit or a full claude-* id.',
45
+ 'Copilot seats take auto (the default) or a Copilot model id.',
46
+ '',
47
+ ].join('\n');
48
+
49
+ function parseArgs(argv) {
50
+ const out = { positional: [], json: false, refresh: false, harness: undefined, error: null };
51
+ for (let i = 0; i < argv.length; i++) {
52
+ const a = argv[i];
53
+ if (a === '--json') out.json = true;
54
+ else if (a === '--refresh') out.refresh = true;
55
+ else if (a === '--log') { out.log = argv[++i] ?? null; if (!out.log) { out.error = '--log needs a file'; return out; } }
56
+ else if (a === '--harness' || a.startsWith('--harness=')) {
57
+ const v = a.includes('=') ? a.slice(a.indexOf('=') + 1) : argv[++i];
58
+ const h = normalizeHarness(v);
59
+ if (h === undefined || h === null) { out.error = `--harness must be claude-code or copilot (got "${v ?? ''}")`; return out; }
60
+ out.harness = h;
61
+ } else if (a.startsWith('--')) { out.error = `unknown option "${a}"`; return out; }
62
+ else out.positional.push(a);
63
+ }
64
+ return out;
65
+ }
66
+
67
+ function detect(env, forced) {
68
+ if (forced) return { harness: forced, status: 'resolved', source: 'flag:--harness' };
69
+ const home = env.HEYSPEX_SEAT_HOME || env.HEYSPEX_HOME || os.homedir();
70
+ const deps = {
71
+ readFile: (p) => { try { return readFileSync(p, 'utf8'); } catch { return null; } },
72
+ listDir: (p) => { try { return readdirSync(p); } catch { return []; } },
73
+ };
74
+ const installed = listInstalledHarnessPlugins({ home, copilotHome: env.COPILOT_HOME || undefined, deps });
75
+ return detectHarness(env, null, installed);
76
+ }
77
+
78
+ function noHarnessMessage(detected) {
79
+ return detected?.status === 'needs-choice'
80
+ ? `No harness detected and several are installed (${(detected.choices ?? []).join(', ')}): pass --harness claude-code or --harness copilot.`
81
+ : 'No harness detected: pass --harness claude-code or --harness copilot (or set HEYSPEX_HARNESS).';
82
+ }
83
+
84
+ function consoleProblem(res) {
85
+ const b = res?.body && typeof res.body === 'object' ? res.body : {};
86
+ return b.message || b.hint || b.error || res?.error || `HTTP ${res?.status ?? 0}`;
87
+ }
88
+
89
+ /** The rows `hs seat list` prints: every product seat, effective model + source, for one harness. */
90
+ export function buildSeatRows(harness, assignments) {
91
+ const rows = normalizeAssignments(assignments);
92
+ return PRODUCT_SEATS.map((s) => {
93
+ const r = resolveSeatModel({ seat: s.id, harness, assignment: assignmentFor(rows, harness, s.id), overlay: () => null });
94
+ const own = r.source === 'user-assignment' ? rows.find((a) => a.harness === harness && a.seat === s.id) : null;
95
+ const rejectedAt = own?.override_rejected_at ?? null;
96
+ const label = rejectedAt
97
+ ? `you · this vault — REFUSED by ${harness === 'copilot' ? 'Copilot' : harness}, ran on auto`
98
+ : SOURCE_LABELS[r.source] ?? r.source;
99
+ return {
100
+ seat: s.id, title: s.title, tier: s.tier, harness, model: r.model, source: r.source,
101
+ override_rejected: Boolean(rejectedAt), override_rejected_at: rejectedAt, label,
102
+ };
103
+ });
104
+ }
105
+
106
+ /**
107
+ * Validate a model for `hs seat set` under the detected harness. Copilot uses sibling HS-F4UM's
108
+ * validateCopilotModel over listCopilotModels() (`auto` + the installed CLI's `copilot help config`
109
+ * ids, cached per CLI version): any well-formed id is accepted, an unlisted one with a warning.
110
+ */
111
+ function validateForSet({ seat, harness, model, copilotModelList }) {
112
+ if (harness !== 'copilot') return validateSeatAssignment({ seat, harness, model });
113
+ const seatCheck = validateSeatAssignment({ seat, harness, model: 'auto' });
114
+ if (!seatCheck.ok) return seatCheck;
115
+ const c = validateCopilotModel(model, copilotModelList());
116
+ if (!c.ok) return { ok: false, error: 'invalid_model', message: c.error };
117
+ return { ok: true, seat: seatCheck.seat, harness, model: c.model, warnings: c.warning ? [c.warning] : [] };
118
+ }
119
+
120
+ /**
121
+ * @param {string[]} argv arguments after `hs seat`
122
+ * @param {object} [deps]
123
+ * @param {object} [deps.env]
124
+ * @param {{write: Function}} [deps.stdout]
125
+ * @param {{write: Function}} [deps.stderr]
126
+ * @param {Function} [deps.consoleFetch] injectable transport (tests); default is the shared MCP transport
127
+ * @param {string|null} [deps.vaultId] cache key override (tests)
128
+ * @param {string[]|null} [deps.copilotModels] Copilot CLI model list (sibling HS-F4UM); null = unknown
129
+ * @returns {Promise<number>} exit code
130
+ */
131
+ export async function runSeatAssignCli(argv, deps = {}) {
132
+ const env = deps.env ?? process.env;
133
+ const stdout = deps.stdout ?? process.stdout;
134
+ const stderr = deps.stderr ?? process.stderr;
135
+ const consoleFetch = deps.consoleFetch ?? (async (p, o) => (await import('./console-transport.mjs')).consoleFetch(p, o));
136
+ const fetchOpts = { consoleFetch, env, ...(deps.vaultId !== undefined ? { vaultId: deps.vaultId } : {}) };
137
+
138
+ const [sub = 'list', ...rest] = argv;
139
+ if (sub === '--help' || sub === '-h' || sub === 'help') { stdout.write(SEAT_CLI_USAGE); return 0; }
140
+ if (sub === 'defaults') return runSeatDefaultsCli(rest, { env, stdout, stderr });
141
+
142
+ const args = parseArgs(rest);
143
+ if (args.error) { stderr.write(`hs seat ${sub}: ${args.error}\n`); return 2; }
144
+ const detected = detect(env, args.harness);
145
+ const harness = detected.harness ?? null;
146
+
147
+ if (sub === 'list' || sub === 'assignments') {
148
+ if (args.positional.length) { stderr.write(`hs seat ${sub}: unexpected argument "${args.positional[0]}"\n`); return 2; }
149
+ const fetched = harness ? await fetchSeatAssignments({ ...fetchOpts, refresh: args.refresh }) : { source: 'none', assignments: [] };
150
+ const rows = buildSeatRows(harness, fetched.assignments);
151
+ if (args.json) {
152
+ stdout.write(`${JSON.stringify({ harness: detected, vault_id: fetched.vaultId ?? null, assignments_source: fetched.source, error: fetched.error ?? null, seats: rows }, null, 2)}\n`);
153
+ return 0;
154
+ }
155
+ if (!harness) stdout.write(`${noHarnessMessage(detected)}\n`);
156
+ else stdout.write(`Seats for harness: ${harness}\n`);
157
+ if (fetched.error) {
158
+ stdout.write(fetched.source === 'stale-cache'
159
+ ? `(could not reach the console: ${fetched.error} — showing your last saved choices)\n`
160
+ : `(could not read your seat choices: ${fetched.error} — showing harness defaults)\n`);
161
+ }
162
+ const w = Math.max(...rows.map((r) => r.seat.length));
163
+ const mw = Math.max(8, ...rows.map((r) => String(r.model ?? '-').length));
164
+ for (const r of rows) stdout.write(` ${r.seat.padEnd(w)} ${String(r.model ?? '-').padEnd(mw)} ${r.label}\n`);
165
+ const refused = rows.filter((r) => r.override_rejected);
166
+ if (refused.length) {
167
+ stdout.write(
168
+ `\n${refused.length === 1 ? 'One seat\'s' : `${refused.length} seats'`} model was refused by ${harness === 'copilot' ? 'Copilot' : harness} on the last run, `
169
+ + 'so that run used auto instead: ' + refused.map((r) => `${r.seat} (${r.model})`).join(', ') + '.\n'
170
+ + (harness === 'copilot'
171
+ ? 'Your Copilot plan or org policy may not allow named models (Free and Student plans get auto only). '
172
+ : '')
173
+ + 'Pick another model with `hs seat set <seat> <model>`, or go back to the default with `hs seat reset <seat>`.\n',
174
+ );
175
+ }
176
+ return 0;
177
+ }
178
+
179
+ if (sub === 'report-fallback') {
180
+ // `hs seat report-fallback <seat> --log <file>`: a launcher hands over a Copilot seat run's
181
+ // stdout; a heyspex.copilot_model_fallback event marks the user's row override_rejected.
182
+ const logFile = args.log ?? null;
183
+ const seatArg = args.positional[0];
184
+ if (!seatArg || !logFile || args.positional.length !== 1) { stderr.write('hs seat report-fallback: expected <seat> --log <file>\n'); return 2; }
185
+ let logText;
186
+ try { logText = (deps.readFile ?? ((p) => readFileSync(p, 'utf8')))(logFile); } catch (err) {
187
+ stderr.write(`hs seat report-fallback: cannot read ${logFile}: ${err?.message ?? err}\n`); return 2;
188
+ }
189
+ const r = await reportCopilotFallback({ seat: seatArg, logText, consoleFetch, env, vaultId: deps.vaultId });
190
+ if (!r.reported && r.error) { stderr.write(`hs seat report-fallback: the console did not record it — ${r.error}\n`); return 1; }
191
+ stdout.write(r.reported
192
+ ? `Copilot refused ${r.model} for ${canonicalSeatId(seatArg)}; that run used auto. Recorded on your seat.\n`
193
+ : 'No Copilot model fallback in that run.\n');
194
+ return 0;
195
+ }
196
+
197
+ if (sub === 'set' || sub === 'reset') {
198
+ const need = sub === 'set' ? 2 : 1;
199
+ if (args.positional.length !== need) {
200
+ stderr.write(`hs seat ${sub}: expected ${sub === 'set' ? '<seat> <model>' : '<seat>'}\n${SEAT_CLI_USAGE}`);
201
+ return 2;
202
+ }
203
+ if (!harness) { stderr.write(`hs seat ${sub}: ${noHarnessMessage(detected)}\n`); return 2; }
204
+ const [seatArg, modelArg] = args.positional;
205
+ const copilotModelList = () => deps.copilotModelList
206
+ ?? (Array.isArray(deps.copilotModels) ? { models: ['auto', ...deps.copilotModels] } : listCopilotModels({ env }));
207
+ const verdict = sub === 'set'
208
+ ? validateForSet({ seat: seatArg, harness, model: modelArg, copilotModelList })
209
+ : validateSeatAssignment({ seat: seatArg, harness, model: harness === 'copilot' ? 'auto' : 'inherit' });
210
+ if (!verdict.ok) { stderr.write(`hs seat ${sub}: ${verdict.message}\n`); return 2; }
211
+ let res;
212
+ try {
213
+ res = sub === 'set'
214
+ ? await consoleFetch(SEATS_ENDPOINT, { method: 'PUT', body: { harness, seat: verdict.seat, model: verdict.model } })
215
+ : await consoleFetch(`${SEATS_ENDPOINT}?harness=${encodeURIComponent(harness)}&seat=${encodeURIComponent(verdict.seat)}`, { method: 'DELETE' });
216
+ } catch (err) {
217
+ res = { ok: false, status: 0, error: err?.message ?? String(err) };
218
+ }
219
+ if (!res?.ok) { stderr.write(`hs seat ${sub}: the console did not save it — ${consoleProblem(res)}\n`); return 1; }
220
+ const vaultId = res.body?.vault_id ?? deps.vaultId ?? null;
221
+ if (vaultId) invalidateSeatCache(vaultId, { env });
222
+ if (sub === 'set') {
223
+ for (const w of [...verdict.warnings, ...(Array.isArray(res.body?.warnings) ? res.body.warnings : [])].filter((x, i, a) => a.indexOf(x) === i)) {
224
+ stderr.write(`warning: ${w}\n`);
225
+ }
226
+ stdout.write(`${verdict.seat} → ${verdict.model} (you · this vault, ${harness})\n`);
227
+ } else {
228
+ const fallback = resolveSeatModel({ seat: canonicalSeatId(verdict.seat), harness, overlay: () => null });
229
+ stdout.write(`${verdict.seat} → ${fallback.model ?? '-'} (harness default, ${harness})${res.body?.removed === 0 ? ' — it was not set' : ''}\n`);
230
+ }
231
+ return 0;
232
+ }
233
+
234
+ stderr.write(`hs seat: unknown subcommand "${sub}"\n${SEAT_CLI_USAGE}`);
235
+ return 2;
236
+ }
237
+
238
+ /** Subcommands this customer CLI answers (bin/hs.mjs routes them here). */
239
+ export const SEAT_ASSIGN_SUBCOMMANDS = Object.freeze(['list', 'assignments', 'set', 'reset', 'defaults', 'report-fallback', 'help']);
240
+
241
+ const isMain = process.argv[1]?.replaceAll('\\', '/').endsWith('/seat-assign-cli.mjs');
242
+ if (isMain) runSeatAssignCli(process.argv.slice(2)).then((code) => { process.exitCode = code; });
@@ -0,0 +1,96 @@
1
+ // vendor/heyspex-mcp/seat-assignment-rules.mjs
2
+ //
3
+ // The ONE validation rule set for a user's own seat → model assignment
4
+ // ([[HS-K9L7-C86Z-EPIC-per-user-per-vault-seat-assignments]]). Shared by the console route
5
+ // (`/api/me/seats`, spex-console imports this file) and the customer CLI (`hs seat set`), so the
6
+ // two surfaces can never disagree about what a valid assignment is.
7
+ //
8
+ // claude-code → a Claude alias (opus | sonnet | haiku | fable | inherit) or a full `claude-*` id.
9
+ // copilot → `auto`, or a Copilot model id. HeySpex cannot check a user's Copilot
10
+ // entitlement in advance (research HS-RXJO §Copilot defaults), so any id that is
11
+ // not in the supplied Copilot list is ACCEPTED WITH A WARNING; until sibling epic
12
+ // HS-F4UM supplies that list every non-auto id carries the warning.
13
+ //
14
+ // Seats must be PRODUCT roster seats (seat-defaults.mjs PRODUCT_SEATS) and never the human
15
+ // `owner` seat. Pure: no fs, no env, no network.
16
+ import { canonicalSeatId, COPILOT_DEFAULT_MODEL, PRODUCT_SEATS } from './seat-defaults.mjs';
17
+
18
+ /** Harnesses a seat assignment can be keyed by. */
19
+ export const ASSIGNABLE_HARNESSES = Object.freeze(['claude-code', 'copilot']);
20
+
21
+ /** Claude Code model aliases (agent `model:` accepts these, plus a full id). */
22
+ export const CLAUDE_MODEL_ALIASES = Object.freeze(['opus', 'sonnet', 'haiku', 'fable', 'inherit']);
23
+
24
+ /** Longest model id we store. */
25
+ export const MAX_MODEL_ID_LENGTH = 128;
26
+
27
+ const MODEL_ID_SHAPE = /^[A-Za-z0-9][A-Za-z0-9._:/\-[\]]*$/;
28
+ const CLAUDE_FULL_ID = /^claude-[a-z0-9][a-z0-9.-]*(\[1m\])?$/i;
29
+
30
+ const ASSIGNABLE_SEATS = new Set(PRODUCT_SEATS.filter((s) => s.tier !== 'human').map((s) => s.id));
31
+
32
+ /** Product seats a user may assign (every non-human seat), in roster order. */
33
+ export function listAssignableSeats() {
34
+ return PRODUCT_SEATS.filter((s) => ASSIGNABLE_SEATS.has(s.id));
35
+ }
36
+
37
+ /** True when `harness` is one of {@link ASSIGNABLE_HARNESSES}. */
38
+ export function isAssignableHarness(harness) {
39
+ return ASSIGNABLE_HARNESSES.includes(harness);
40
+ }
41
+
42
+ /**
43
+ * Validate one assignment.
44
+ * @param {{ seat: string, harness: string, model: string, copilotModels?: string[]|null }} input
45
+ * @returns {{ ok: true, seat: string, harness: 'claude-code'|'copilot', model: string, warnings: string[] }
46
+ * | { ok: false, error: 'invalid_seat'|'invalid_harness'|'invalid_model', message: string }}
47
+ */
48
+ export function validateSeatAssignment({ seat, harness, model, copilotModels = null } = {}) {
49
+ const seatId = canonicalSeatId(seat);
50
+ if (!seatId || !ASSIGNABLE_SEATS.has(seatId)) {
51
+ const human = seatId === 'owner';
52
+ return {
53
+ ok: false,
54
+ error: 'invalid_seat',
55
+ message: human
56
+ ? 'The owner seat is a person, not a model — it cannot be assigned.'
57
+ : `Unknown seat "${seat ?? ''}". Run \`hs seat list\` for the seat ids.`,
58
+ };
59
+ }
60
+ if (!isAssignableHarness(harness)) {
61
+ return {
62
+ ok: false,
63
+ error: 'invalid_harness',
64
+ message: `harness must be one of ${ASSIGNABLE_HARNESSES.join(', ')} (got "${harness ?? ''}").`,
65
+ };
66
+ }
67
+ const m = typeof model === 'string' ? model.trim() : '';
68
+ if (!m) return { ok: false, error: 'invalid_model', message: 'model must be a non-empty model id.' };
69
+ if (m.length > MAX_MODEL_ID_LENGTH || !MODEL_ID_SHAPE.test(m)) {
70
+ return { ok: false, error: 'invalid_model', message: `"${m}" is not a valid model id.` };
71
+ }
72
+ const warnings = [];
73
+ if (harness === 'claude-code') {
74
+ const alias = m.toLowerCase();
75
+ if (CLAUDE_MODEL_ALIASES.includes(alias)) return { ok: true, seat: seatId, harness, model: alias, warnings };
76
+ if (!CLAUDE_FULL_ID.test(m)) {
77
+ return {
78
+ ok: false,
79
+ error: 'invalid_model',
80
+ message: `Claude Code seats take a Claude model: ${CLAUDE_MODEL_ALIASES.join(', ')} or a full claude-* id (got "${m}").`,
81
+ };
82
+ }
83
+ return { ok: true, seat: seatId, harness, model: m, warnings };
84
+ }
85
+ // copilot
86
+ if (m.toLowerCase() === COPILOT_DEFAULT_MODEL) {
87
+ return { ok: true, seat: seatId, harness, model: COPILOT_DEFAULT_MODEL, warnings };
88
+ }
89
+ const known = Array.isArray(copilotModels) ? copilotModels.filter((x) => typeof x === 'string') : null;
90
+ if (!known || known.length === 0) {
91
+ warnings.push(`HeySpex cannot check "${m}" against your Copilot plan yet; if Copilot refuses it the seat runs on auto.`);
92
+ } else if (!known.includes(m)) {
93
+ warnings.push(`"${m}" is not in your Copilot CLI's model list; if Copilot refuses it the seat runs on auto.`);
94
+ }
95
+ return { ok: true, seat: seatId, harness, model: m, warnings };
96
+ }
@@ -0,0 +1,265 @@
1
+ // vendor/heyspex-mcp/seat-assignments-client.mjs
2
+ //
3
+ // The client half of a user's own seat → model assignments
4
+ // ([[HS-146G-C86Z-TASK-mcp-and-cli-seat-resolve-path-fetches]], epic
5
+ // [[HS-K9L7-C86Z-EPIC-per-user-per-vault-seat-assignments]]). The MCP server and the `hs` CLI
6
+ // fetch the caller's rows from `GET /api/me/seats` (authenticated, vault-scoped by the shared
7
+ // console transport), keep a read-through copy at `~/.heyspex/seats/<vault_id>.json` (never under
8
+ // the vault, never committed), and hand the row for (harness, seat) to resolveSeatModel's USER
9
+ // ASSIGNMENT rung. Offline or refused → the cached copy; no cache → no assignment, so the seat
10
+ // falls back to the harness default. Never reads or writes dev-factory/seats.json.
11
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
12
+ import { dirname, join } from 'node:path';
13
+
14
+ import { heyspexHome } from '../../scripts/lib/heyspex-home.mjs';
15
+ import { detectHarness } from '../../spexharness/src/adapters/harness-detect.mjs';
16
+ import { canonicalSeatId } from './seat-defaults.mjs';
17
+ import { readCurrentAccountId } from './credentials.mjs';
18
+ import { resolveSeatModelForCaller } from './seat-identity.mjs';
19
+
20
+ /** The console route this client talks to. */
21
+ export const SEATS_ENDPOINT = '/api/me/seats';
22
+
23
+ /** A cached copy younger than this is used without a network call. */
24
+ export const SEAT_CACHE_MAX_AGE_MS = 5 * 60 * 1000;
25
+
26
+ const SAFE_VAULT_ID = /^[A-Za-z0-9_.-]{1,128}$/;
27
+
28
+ /**
29
+ * The signed-in account the cache is keyed by: HS_ACCOUNT_ID, else the credentials store's current
30
+ * account under this HEYSPEX_HOME. Null when signed out (the cache then falls back to the vault key).
31
+ */
32
+ export function seatCacheAccountId(env = process.env) {
33
+ const e = typeof env?.HS_ACCOUNT_ID === 'string' ? env.HS_ACCOUNT_ID.trim() : '';
34
+ if (e) return e;
35
+ try {
36
+ return readCurrentAccountId({ homeDir: heyspexHome(env) }) ?? null;
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ /**
43
+ * `~/.heyspex/seats/<account_id>/<vault_id>.json` (signed in) or `~/.heyspex/seats/<vault_id>.json`
44
+ * (signed out) for a vault id, or null for an id unsafe as a file name. Keyed by ACCOUNT as well as
45
+ * vault ([[HS-92PF-C86Z-TASK-fresh-install-seat-acceptance-checks-in]]): two accounts signed in on one
46
+ * machine never read each other's cached choices. `accountId` undefined = resolve it; null = none.
47
+ */
48
+ export function seatCacheFile(vaultId, { env = process.env, accountId } = {}) {
49
+ if (typeof vaultId !== 'string' || !SAFE_VAULT_ID.test(vaultId) || vaultId === '.' || vaultId === '..') return null;
50
+ const acct = accountId === undefined ? seatCacheAccountId(env) : accountId;
51
+ const scoped = typeof acct === 'string' && SAFE_VAULT_ID.test(acct) && acct !== '.' && acct !== '..';
52
+ return scoped
53
+ ? join(heyspexHome(env), '.heyspex', 'seats', acct, `${vaultId}.json`)
54
+ : join(heyspexHome(env), '.heyspex', 'seats', `${vaultId}.json`);
55
+ }
56
+
57
+ /** Keep only well-formed { harness, seat, model } rows. */
58
+ export function normalizeAssignments(rows) {
59
+ if (!Array.isArray(rows)) return [];
60
+ const out = [];
61
+ for (const r of rows) {
62
+ if (!r || typeof r !== 'object') continue;
63
+ const harness = r.harness === 'claude-code' || r.harness === 'copilot' ? r.harness : null;
64
+ const seat = canonicalSeatId(r.seat ?? r.seatId);
65
+ const model = typeof r.model === 'string' ? r.model.trim() : '';
66
+ if (harness && seat && model) {
67
+ out.push({
68
+ harness, seat, model,
69
+ override_rejected_at: r.override_rejected_at ?? r.overrideRejectedAt ?? null,
70
+ updated_at: r.updated_at ?? r.updatedAt ?? null,
71
+ });
72
+ }
73
+ }
74
+ return out;
75
+ }
76
+
77
+ /** The user's model for one (harness, seat), or null. */
78
+ export function assignmentFor(assignments, harness, seat) {
79
+ const id = canonicalSeatId(seat);
80
+ if (!harness || !id) return null;
81
+ const hit = normalizeAssignments(assignments).find((a) => a.harness === harness && a.seat === id);
82
+ return hit ? hit.model : null;
83
+ }
84
+
85
+ function readCache(file, readFile) {
86
+ if (!file) return null;
87
+ try {
88
+ const parsed = JSON.parse(readFile(file));
89
+ if (!parsed || typeof parsed !== 'object') return null;
90
+ return { fetchedAt: Number(parsed.fetched_at) || 0, assignments: normalizeAssignments(parsed.assignments) };
91
+ } catch {
92
+ return null;
93
+ }
94
+ }
95
+
96
+ function writeCache(file, payload, { mkdir, writeFile, rename }) {
97
+ if (!file) return;
98
+ try {
99
+ mkdir(dirname(file), { recursive: true });
100
+ const tmp = `${file}.${process.pid}.tmp`;
101
+ writeFile(tmp, `${JSON.stringify(payload, null, 2)}\n`);
102
+ rename(tmp, file);
103
+ } catch {
104
+ // Best effort: a cache that cannot be written only costs a network call next time.
105
+ }
106
+ }
107
+
108
+ /** The vault id the shared transport would send, or null. */
109
+ function defaultVaultId() {
110
+ return import('./console-fetch.mjs')
111
+ .then((m) => {
112
+ try {
113
+ const h = m.getVaultIdHeader();
114
+ return h?.[m.VAULT_ID_HEADER] ?? null;
115
+ } catch {
116
+ return null;
117
+ }
118
+ })
119
+ .catch(() => null);
120
+ }
121
+
122
+ async function defaultConsoleFetch(path, options) {
123
+ const { consoleFetch } = await import('./console-transport.mjs');
124
+ return consoleFetch(path, options);
125
+ }
126
+
127
+ /**
128
+ * Fetch the caller's assignments for the current vault (cached, offline-tolerant).
129
+ * @param {object} [o]
130
+ * @param {(path: string, options?: object) => Promise<{ok: boolean, status: number, body?: any}>} [o.consoleFetch]
131
+ * @param {string|null} [o.vaultId] cache key; defaults to the transport's resolved vault id
132
+ * @param {boolean} [o.refresh] skip a fresh cache and ask the console
133
+ * @param {number} [o.maxAgeMs]
134
+ * @param {() => number} [o.now]
135
+ * @param {object} [o.env] HEYSPEX_HOME override lives here (tests use fake homes)
136
+ * @param {object} [o.fs] { readFile, writeFile, mkdir, rename } seams
137
+ * @returns {Promise<{ source: 'cache'|'console'|'stale-cache'|'none', vaultId: string|null, assignments: Array<{harness:string, seat:string, model:string}>, error?: string, status?: number, body?: any }>}
138
+ */
139
+ export async function fetchSeatAssignments({
140
+ consoleFetch = defaultConsoleFetch,
141
+ vaultId,
142
+ refresh = false,
143
+ maxAgeMs = SEAT_CACHE_MAX_AGE_MS,
144
+ now = Date.now,
145
+ env = process.env,
146
+ fs = {},
147
+ } = {}) {
148
+ const io = {
149
+ readFile: fs.readFile ?? ((p) => readFileSync(p, 'utf8')),
150
+ writeFile: fs.writeFile ?? ((p, d) => writeFileSync(p, d)),
151
+ mkdir: fs.mkdir ?? ((p, o) => mkdirSync(p, o)),
152
+ rename: fs.rename ?? ((a, b) => renameSync(a, b)),
153
+ };
154
+ const vid = vaultId === undefined ? await defaultVaultId() : vaultId;
155
+ const file = seatCacheFile(vid, { env });
156
+ const cached = readCache(file, io.readFile);
157
+ if (!refresh && cached && now() - cached.fetchedAt < maxAgeMs) {
158
+ return { source: 'cache', vaultId: vid, assignments: cached.assignments };
159
+ }
160
+ let res;
161
+ try {
162
+ res = await consoleFetch(SEATS_ENDPOINT);
163
+ } catch (err) {
164
+ res = { ok: false, status: 0, error: err?.message ?? String(err) };
165
+ }
166
+ if (res?.ok && res.body && typeof res.body === 'object') {
167
+ const assignments = normalizeAssignments(res.body.assignments);
168
+ const servedVault = typeof res.body.vault_id === 'string' ? res.body.vault_id : vid;
169
+ writeCache(seatCacheFile(servedVault, { env }), { vault_id: servedVault, fetched_at: now(), assignments }, io);
170
+ return { source: 'console', vaultId: servedVault, assignments, body: res.body };
171
+ }
172
+ const error = res?.error || res?.body?.error || `HTTP ${res?.status ?? 0}`;
173
+ if (cached) return { source: 'stale-cache', vaultId: vid, assignments: cached.assignments, error, status: res?.status };
174
+ return { source: 'none', vaultId: vid, assignments: [], error, status: res?.status };
175
+ }
176
+
177
+ /** Drop the cached copy so the next read asks the console (after a set/reset). */
178
+ export function invalidateSeatCache(vaultId, { env = process.env, writeFile } = {}) {
179
+ const file = seatCacheFile(vaultId, { env });
180
+ if (!file) return;
181
+ try {
182
+ (writeFile ?? ((p, d) => writeFileSync(p, d)))(file, `${JSON.stringify({ vault_id: vaultId, fetched_at: 0, assignments: [] })}\n`);
183
+ } catch {
184
+ // nothing cached
185
+ }
186
+ }
187
+
188
+ // ── Copilot refused an override (HS-F4UM hand-off) ───────────────────────────
189
+ // spexharness/src/seats/copilot-run.mjs retries a refused `--model <id>` once with `auto` and writes
190
+ // a `heyspex.copilot_model_fallback` JSONL event ({ data: { from, to: 'auto', reason } }) on the
191
+ // run's stdout. The launcher that knows WHICH seat it ran hands that log here, and the caller's own
192
+ // assignment row is marked override_rejected (PATCH /api/me/seats) so `hs seat list` can say so.
193
+
194
+ /** Matches copilot-run.mjs COPILOT_FALLBACK_EVENT (kept literal: no import of the launcher). */
195
+ export const COPILOT_FALLBACK_EVENT_TYPE = 'heyspex.copilot_model_fallback';
196
+
197
+ /** Every fallback event in a run's JSONL stdout: [{ from, to, reason, timestamp }]. */
198
+ export function parseCopilotFallbackEvents(text) {
199
+ const out = [];
200
+ for (const line of String(text ?? '').split(/\r?\n/)) {
201
+ const t = line.trim();
202
+ if (!t.startsWith('{') || !t.includes(COPILOT_FALLBACK_EVENT_TYPE)) continue;
203
+ try {
204
+ const e = JSON.parse(t);
205
+ if (e?.type !== COPILOT_FALLBACK_EVENT_TYPE || typeof e.data?.from !== 'string') continue;
206
+ out.push({ from: e.data.from, to: e.data.to ?? 'auto', reason: e.data.reason ?? null, timestamp: e.timestamp ?? null });
207
+ } catch {
208
+ // not JSON — Copilot's own text
209
+ }
210
+ }
211
+ return out;
212
+ }
213
+
214
+ /**
215
+ * Report a Copilot seat run's fallback to the console: marks the caller's (copilot, seat) row as
216
+ * override_rejected for the refused model. No fallback event in the log → no call. Never throws.
217
+ * @param {{ seat: string, logText?: string, events?: Array<{from: string}>, consoleFetch?: Function, vaultId?: string|null, env?: object }} p
218
+ * @returns {Promise<{ reported: boolean, model?: string, marked?: number, error?: string }>}
219
+ */
220
+ export async function reportCopilotFallback({ seat, logText = '', events, consoleFetch = defaultConsoleFetch, vaultId, env = process.env } = {}) {
221
+ const evs = events ?? parseCopilotFallbackEvents(logText);
222
+ const last = evs.at(-1);
223
+ const seatId = canonicalSeatId(seat);
224
+ if (!last || !seatId) return { reported: false };
225
+ let res;
226
+ try {
227
+ res = await consoleFetch(SEATS_ENDPOINT, {
228
+ method: 'PATCH',
229
+ body: { harness: 'copilot', seat: seatId, model: last.from, override_rejected: true },
230
+ });
231
+ } catch (err) {
232
+ res = { ok: false, status: 0, error: err?.message ?? String(err) };
233
+ }
234
+ if (!res?.ok) return { reported: false, model: last.from, error: res?.body?.error || res?.error || `HTTP ${res?.status ?? 0}` };
235
+ const vid = res.body?.vault_id ?? vaultId ?? null;
236
+ if (vid) invalidateSeatCache(vid, { env });
237
+ return { reported: true, model: last.from, marked: Number(res.body?.marked) || 0 };
238
+ }
239
+
240
+ /**
241
+ * resolveSeatModelForCaller with the caller's own assignment filled in: detect the harness the
242
+ * same way the resolver does, pick the user's row for (harness, seat), and pass it as `assignment`.
243
+ * Synchronous; pass the rows from {@link fetchSeatAssignments}.
244
+ */
245
+ export function resolveSeatModelWithAssignments({ assignments = [], ...opts } = {}) {
246
+ if (opts.assignment) return resolveSeatModelForCaller(opts);
247
+ const detected = detectHarness(opts.env ?? {}, opts.payload ?? null, opts.installedPlugins ?? []);
248
+ const assignment = assignmentFor(assignments, detected.harness, opts.seatId);
249
+ return resolveSeatModelForCaller({ ...opts, assignment });
250
+ }
251
+
252
+ /**
253
+ * The async one-call path for the MCP server / CLI: fetch (or read the cache), then resolve.
254
+ * Never throws for a console problem — it resolves to the harness default instead.
255
+ */
256
+ export async function resolveSeatModelForCallerAsync({ fetchOptions = {}, ...opts } = {}) {
257
+ let fetched;
258
+ try {
259
+ fetched = await fetchSeatAssignments({ env: opts.env ?? process.env, ...fetchOptions });
260
+ } catch {
261
+ fetched = { source: 'none', assignments: [] };
262
+ }
263
+ const resolved = resolveSeatModelWithAssignments({ ...opts, assignments: fetched.assignments });
264
+ return { ...resolved, assignmentsSource: fetched.source };
265
+ }