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
|
@@ -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
|