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.
- package/bin/hs.mjs +85 -25
- package/bin/spexcode.mjs +31 -0
- package/dev-factory/lib/local-firecrawl.mjs +294 -0
- package/dev-factory/lib/seat-context.mjs +20 -4
- package/dev-factory/model-catalog.json +222 -222
- package/dev-factory/spexcrawl/firecrawl-cli.mjs +755 -0
- package/dist/cli/hs-connect.mjs +20 -2
- package/dist/cli/hs-defer.mjs +19 -1
- package/dist/cli/hs-index-rebuild.mjs +19 -1
- package/dist/cli/hs-index-reconcile.mjs +19 -1
- package/dist/cli/hs-init.mjs +2 -2
- package/dist/cli/hs-install.mjs +2 -2
- package/dist/cli/hs-join.mjs +20 -2
- package/dist/cli/hs-next-launch.mjs +89 -10
- package/dist/cli/hs-spawn.mjs +27 -1
- package/dist/cli/hs-vault-pull.mjs +19 -1
- package/dist/cli/hs-vault-push.mjs +19 -1
- package/dist/cli/hs-vault.mjs +21 -7
- package/dist/cli/hs-workspace.mjs +19 -1
- package/dist/cli-templates/customer-vault/specs/entity-model.md +1728 -1728
- package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +464 -464
- package/dist/cli-templates/customer-vault/specs/protocols/destructive-action-checklist.md +122 -122
- package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +46 -46
- package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +530 -530
- package/dist/cli-templates/customer-vault/specs/protocols/spec-summary-authoring.md +83 -83
- package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -28
- package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -62
- package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -29
- package/dist/cli-templates/harness-commands/claude/hs-ground.md +30 -30
- package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -29
- package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -70
- package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -35
- package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -27
- package/dist/cli-templates/harness-commands/claude/hs-res.md +20 -8
- package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -23
- package/dist/cli-templates/harness-commands/claude/hs-view.md +28 -28
- package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -32
- package/dist/cli-templates/harness-commands/claude/prototype.md +91 -91
- package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +22 -22
- package/dist/cli-templates/harness-commands/cursor/hs-capture.md +56 -56
- package/dist/cli-templates/harness-commands/cursor/hs-defer.md +23 -23
- package/dist/cli-templates/harness-commands/cursor/hs-ground.md +24 -24
- package/dist/cli-templates/harness-commands/cursor/hs-next.md +23 -23
- package/dist/cli-templates/harness-commands/cursor/hs-promote.md +64 -64
- package/dist/cli-templates/harness-commands/cursor/hs-push.md +29 -29
- package/dist/cli-templates/harness-commands/cursor/hs-qc.md +21 -21
- package/dist/cli-templates/harness-commands/cursor/hs-res.md +20 -8
- package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +17 -17
- package/dist/cli-templates/harness-commands/cursor/hs-view.md +23 -23
- package/dist/cli-templates/harness-commands/cursor/kill-ports.md +26 -26
- package/dist/cli-templates/harness-commands/cursor/prototype.md +86 -86
- package/docs/copilot-plugin-install.md +7 -0
- package/docs/mcp-client-config.md +6 -0
- package/package.json +7 -2
- package/plugins/heyspex/README.md +6 -0
- package/plugins/heyspex/bin/launch-mcp.mjs +17 -0
- package/plugins/heyspex/hooks/hooks.json +418 -418
- package/scripts/claude-hooks/_intent-classifier-semantic.mjs +2 -2
- package/scripts/hs-companion.mjs +38 -8
- package/scripts/lib/companion-install.mjs +768 -0
- package/scripts/lib/customer-kit.mjs +5 -3
- package/scripts/lib/hs-companion-settings.mjs +1 -0
- package/scripts/lib/hs-source-push.mjs +386 -386
- package/spexharness/doctrine/baseline/SPEXCODE-DOCTRINE.baseline.json +29 -29
- package/spexharness/src/adapters/copilot-models.mjs +179 -0
- package/spexharness/src/adapters/harness-detect.mjs +148 -0
- package/spexharness/src/gates/directive-obligation-gate.decide.mjs +132 -132
- package/spexharness/src/gates/vault-write-tier-guard.decide.mjs +153 -125
- package/spexharness/src/seat-context.mjs +10 -5
- package/spexharness/src/session-bootstrap.mjs +6 -3
- package/vendor/heyspex-mcp/cli.mjs +6 -1
- package/vendor/heyspex-mcp/config-seed.yaml +15 -15
- package/vendor/heyspex-mcp/dev-factory-overlay.mjs +142 -0
- package/vendor/heyspex-mcp/memory-tools.mjs +523 -523
- package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -1708
- package/vendor/heyspex-mcp/procedure-handlers.mjs +327 -327
- package/vendor/heyspex-mcp/procedure-tools.mjs +1209 -1181
- package/vendor/heyspex-mcp/research-tools.mjs +22 -2
- package/vendor/heyspex-mcp/seat-assign-cli.mjs +242 -0
- package/vendor/heyspex-mcp/seat-assignment-rules.mjs +96 -0
- package/vendor/heyspex-mcp/seat-assignments-client.mjs +265 -0
- package/vendor/heyspex-mcp/seat-defaults-cli.mjs +45 -0
- package/vendor/heyspex-mcp/seat-defaults.mjs +232 -0
- package/vendor/heyspex-mcp/seat-identity.mjs +171 -0
- package/vendor/heyspex-mcp/vault-io.mjs +720 -720
|
@@ -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
|
-
|
|
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
|
|
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
|
+
}
|