heyspex 0.2.6 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/bin/hs.mjs +85 -25
  2. package/bin/spexcode.mjs +31 -0
  3. package/dev-factory/lib/local-firecrawl.mjs +294 -0
  4. package/dev-factory/lib/seat-context.mjs +20 -4
  5. package/dev-factory/model-catalog.json +222 -222
  6. package/dev-factory/spexcrawl/firecrawl-cli.mjs +755 -0
  7. package/dist/cli/hs-connect.mjs +20 -2
  8. package/dist/cli/hs-defer.mjs +19 -1
  9. package/dist/cli/hs-index-rebuild.mjs +19 -1
  10. package/dist/cli/hs-index-reconcile.mjs +19 -1
  11. package/dist/cli/hs-init.mjs +2 -2
  12. package/dist/cli/hs-install.mjs +2 -2
  13. package/dist/cli/hs-join.mjs +20 -2
  14. package/dist/cli/hs-next-launch.mjs +89 -10
  15. package/dist/cli/hs-spawn.mjs +27 -1
  16. package/dist/cli/hs-vault-pull.mjs +19 -1
  17. package/dist/cli/hs-vault-push.mjs +19 -1
  18. package/dist/cli/hs-vault.mjs +21 -7
  19. package/dist/cli/hs-workspace.mjs +19 -1
  20. package/dist/cli-templates/customer-vault/specs/entity-model.md +1728 -1728
  21. package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +464 -464
  22. package/dist/cli-templates/customer-vault/specs/protocols/destructive-action-checklist.md +122 -122
  23. package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +46 -46
  24. package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +530 -530
  25. package/dist/cli-templates/customer-vault/specs/protocols/spec-summary-authoring.md +83 -83
  26. package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -28
  27. package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -62
  28. package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -29
  29. package/dist/cli-templates/harness-commands/claude/hs-ground.md +30 -30
  30. package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -29
  31. package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -70
  32. package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -35
  33. package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -27
  34. package/dist/cli-templates/harness-commands/claude/hs-res.md +20 -8
  35. package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -23
  36. package/dist/cli-templates/harness-commands/claude/hs-view.md +28 -28
  37. package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -32
  38. package/dist/cli-templates/harness-commands/claude/prototype.md +91 -91
  39. package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +22 -22
  40. package/dist/cli-templates/harness-commands/cursor/hs-capture.md +56 -56
  41. package/dist/cli-templates/harness-commands/cursor/hs-defer.md +23 -23
  42. package/dist/cli-templates/harness-commands/cursor/hs-ground.md +24 -24
  43. package/dist/cli-templates/harness-commands/cursor/hs-next.md +23 -23
  44. package/dist/cli-templates/harness-commands/cursor/hs-promote.md +64 -64
  45. package/dist/cli-templates/harness-commands/cursor/hs-push.md +29 -29
  46. package/dist/cli-templates/harness-commands/cursor/hs-qc.md +21 -21
  47. package/dist/cli-templates/harness-commands/cursor/hs-res.md +20 -8
  48. package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +17 -17
  49. package/dist/cli-templates/harness-commands/cursor/hs-view.md +23 -23
  50. package/dist/cli-templates/harness-commands/cursor/kill-ports.md +26 -26
  51. package/dist/cli-templates/harness-commands/cursor/prototype.md +86 -86
  52. package/docs/copilot-plugin-install.md +7 -0
  53. package/docs/mcp-client-config.md +6 -0
  54. package/package.json +7 -2
  55. package/plugins/heyspex/README.md +6 -0
  56. package/plugins/heyspex/bin/launch-mcp.mjs +17 -0
  57. package/plugins/heyspex/hooks/hooks.json +418 -418
  58. package/scripts/claude-hooks/_intent-classifier-semantic.mjs +2 -2
  59. package/scripts/hs-companion.mjs +38 -8
  60. package/scripts/lib/companion-install.mjs +768 -0
  61. package/scripts/lib/customer-kit.mjs +5 -3
  62. package/scripts/lib/hs-companion-settings.mjs +1 -0
  63. package/scripts/lib/hs-source-push.mjs +386 -386
  64. package/spexharness/doctrine/baseline/SPEXCODE-DOCTRINE.baseline.json +29 -29
  65. package/spexharness/src/adapters/copilot-models.mjs +179 -0
  66. package/spexharness/src/adapters/harness-detect.mjs +148 -0
  67. package/spexharness/src/gates/directive-obligation-gate.decide.mjs +132 -132
  68. package/spexharness/src/gates/vault-write-tier-guard.decide.mjs +153 -125
  69. package/spexharness/src/seat-context.mjs +10 -5
  70. package/spexharness/src/session-bootstrap.mjs +6 -3
  71. package/vendor/heyspex-mcp/cli.mjs +6 -1
  72. package/vendor/heyspex-mcp/config-seed.yaml +15 -15
  73. package/vendor/heyspex-mcp/dev-factory-overlay.mjs +142 -0
  74. package/vendor/heyspex-mcp/memory-tools.mjs +523 -523
  75. package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -1708
  76. package/vendor/heyspex-mcp/procedure-handlers.mjs +327 -327
  77. package/vendor/heyspex-mcp/procedure-tools.mjs +1209 -1181
  78. package/vendor/heyspex-mcp/research-tools.mjs +22 -2
  79. package/vendor/heyspex-mcp/seat-assign-cli.mjs +242 -0
  80. package/vendor/heyspex-mcp/seat-assignment-rules.mjs +96 -0
  81. package/vendor/heyspex-mcp/seat-assignments-client.mjs +265 -0
  82. package/vendor/heyspex-mcp/seat-defaults-cli.mjs +45 -0
  83. package/vendor/heyspex-mcp/seat-defaults.mjs +232 -0
  84. package/vendor/heyspex-mcp/seat-identity.mjs +171 -0
  85. package/vendor/heyspex-mcp/vault-io.mjs +720 -720
@@ -1,29 +1,29 @@
1
- {
2
- "schemaVersion": 1,
3
- "edition": "baseline",
4
- "learnedAt": "2026-09-11T10:05:22.053Z",
5
- "curatedAt": "2026-10-03",
6
- "_comment": "Baseline edition of SpexCode Doctrine: the foundational layer that ships in the heyspex npm package and is injected into customer sessions (owner ruling 2026-10-03, task HS-A2PO). Every principle is copied verbatim from the learned set (spexharness/doctrine/SPEXCODE-DOCTRINE.json, learnedAt above), with its confidence kept and its evidence ids dropped (they name the owner's own sessions). Selection rule: generic engineering practice only. Never add a rule about the spex-console dev server, Neon, the COO seat, dev-factory, the owner's own machine or preferences, or any heyspex-app path; spexharness/src/doctrine/inject.test.mjs fails if one appears. A customer's own learned layer is built on top of this file, never written into it.",
7
- "sources": {
8
- "curatedFrom": "spexharness/doctrine/SPEXCODE-DOCTRINE.json",
9
- "principleCountInSource": 1481
10
- },
11
- "principles": [
12
- { "text": "Treat a spec as done only after its own gate has run and exact pass/fail counts are reported.", "category": "verification", "confidence": 0.92, "stance": "do", "evidence": [] },
13
- { "text": "Before reporting done, re-check every item in the stated range, not just the one flagged as missed, and explain the cause of any gap.", "category": "verification", "confidence": 0.9, "stance": "do", "evidence": [] },
14
- { "text": "Back every claimed acceptance criterion with something actually run — tests, typecheck, lint, or measured timings — instead of asserting completion.", "category": "verification", "confidence": 0.9, "stance": "do", "evidence": [] },
15
- { "text": "Never claim a build complete or flip status on faith; only after typecheck and the real tests are green.", "category": "verification", "confidence": 0.88, "stance": "avoid", "evidence": [] },
16
- { "text": "When an exit test is unmet or a regression is found, say so explicitly in the artefact instead of papering over it or closing on a number you set out to beat and did not.", "category": "verification", "confidence": 0.85, "stance": "do", "evidence": [] },
17
- { "text": "Report exactly what was not done, could not be done, or failed, and name the blocker, rather than glossing over an untested branch or a red acceptance criterion.", "category": "verification", "confidence": 0.85, "stance": "do", "evidence": [] },
18
- { "text": "Touch only the files in scope; when an out-of-scope edit is unavoidable, keep it minimal and flag it explicitly.", "category": "session-hygiene", "confidence": 0.88, "stance": "do", "evidence": [] },
19
- { "text": "When a design question is open, research it with the designated tools instead of guessing an answer or a number.", "category": "research-grounding", "confidence": 0.88, "stance": "do", "evidence": [] },
20
- { "text": "Confirming an outline or stub is not doing the work; fill every section with real sourced content before reporting research done.", "category": "research-grounding", "confidence": 0.85, "stance": "avoid", "evidence": [] },
21
- { "text": "Irreversible or transactional writes are two-phase: present a proposal and take a real human confirmation turn before executing, never in the same turn the proposal is authored.", "category": "safety-permissions", "confidence": 0.85, "stance": "do", "evidence": [] },
22
- { "text": "Run at the most restrictive permission mode until the folder is trusted, and never auto-approve a block-severity gate.", "category": "safety-permissions", "confidence": 0.85, "stance": "do", "evidence": [] },
23
- { "text": "Disclose known out-of-scope failures, deliberate deviations, and residual risk in the final report rather than burying them.", "category": "communication", "confidence": 0.85, "stance": "do", "evidence": [] },
24
- { "text": "End every close-out or status report with an explicit per-item outcome word (fixed, partly fixed, not fixed, not started), stated before the detail, so a found bug is never left ambiguous.", "category": "communication", "confidence": 0.85, "stance": "do", "evidence": [] },
25
- { "text": "Session titles stay short and descriptive in plain words, never raw spec/task ids, and remain sticky through follow-ups and sub-steps rather than changing at each turn.", "category": "naming", "confidence": 0.85, "stance": "do", "evidence": [] },
26
- { "text": "Parallelise independent workstreams by spawning sub-agents instead of serialising everything in the main thread.", "category": "delegation", "confidence": 0.84, "stance": "do", "evidence": [] }
27
- ],
28
- "conflicts": []
29
- }
1
+ {
2
+ "schemaVersion": 1,
3
+ "edition": "baseline",
4
+ "learnedAt": "2026-09-11T10:05:22.053Z",
5
+ "curatedAt": "2026-10-03",
6
+ "_comment": "Baseline edition of SpexCode Doctrine: the foundational layer that ships in the heyspex npm package and is injected into customer sessions (owner ruling 2026-10-03, task HS-A2PO). Every principle is copied verbatim from the learned set (spexharness/doctrine/SPEXCODE-DOCTRINE.json, learnedAt above), with its confidence kept and its evidence ids dropped (they name the owner's own sessions). Selection rule: generic engineering practice only. Never add a rule about the spex-console dev server, Neon, the COO seat, dev-factory, the owner's own machine or preferences, or any heyspex-app path; spexharness/src/doctrine/inject.test.mjs fails if one appears. A customer's own learned layer is built on top of this file, never written into it.",
7
+ "sources": {
8
+ "curatedFrom": "spexharness/doctrine/SPEXCODE-DOCTRINE.json",
9
+ "principleCountInSource": 1481
10
+ },
11
+ "principles": [
12
+ { "text": "Treat a spec as done only after its own gate has run and exact pass/fail counts are reported.", "category": "verification", "confidence": 0.92, "stance": "do", "evidence": [] },
13
+ { "text": "Before reporting done, re-check every item in the stated range, not just the one flagged as missed, and explain the cause of any gap.", "category": "verification", "confidence": 0.9, "stance": "do", "evidence": [] },
14
+ { "text": "Back every claimed acceptance criterion with something actually run — tests, typecheck, lint, or measured timings — instead of asserting completion.", "category": "verification", "confidence": 0.9, "stance": "do", "evidence": [] },
15
+ { "text": "Never claim a build complete or flip status on faith; only after typecheck and the real tests are green.", "category": "verification", "confidence": 0.88, "stance": "avoid", "evidence": [] },
16
+ { "text": "When an exit test is unmet or a regression is found, say so explicitly in the artefact instead of papering over it or closing on a number you set out to beat and did not.", "category": "verification", "confidence": 0.85, "stance": "do", "evidence": [] },
17
+ { "text": "Report exactly what was not done, could not be done, or failed, and name the blocker, rather than glossing over an untested branch or a red acceptance criterion.", "category": "verification", "confidence": 0.85, "stance": "do", "evidence": [] },
18
+ { "text": "Touch only the files in scope; when an out-of-scope edit is unavoidable, keep it minimal and flag it explicitly.", "category": "session-hygiene", "confidence": 0.88, "stance": "do", "evidence": [] },
19
+ { "text": "When a design question is open, research it with the designated tools instead of guessing an answer or a number.", "category": "research-grounding", "confidence": 0.88, "stance": "do", "evidence": [] },
20
+ { "text": "Confirming an outline or stub is not doing the work; fill every section with real sourced content before reporting research done.", "category": "research-grounding", "confidence": 0.85, "stance": "avoid", "evidence": [] },
21
+ { "text": "Irreversible or transactional writes are two-phase: present a proposal and take a real human confirmation turn before executing, never in the same turn the proposal is authored.", "category": "safety-permissions", "confidence": 0.85, "stance": "do", "evidence": [] },
22
+ { "text": "Run at the most restrictive permission mode until the folder is trusted, and never auto-approve a block-severity gate.", "category": "safety-permissions", "confidence": 0.85, "stance": "do", "evidence": [] },
23
+ { "text": "Disclose known out-of-scope failures, deliberate deviations, and residual risk in the final report rather than burying them.", "category": "communication", "confidence": 0.85, "stance": "do", "evidence": [] },
24
+ { "text": "End every close-out or status report with an explicit per-item outcome word (fixed, partly fixed, not fixed, not started), stated before the detail, so a found bug is never left ambiguous.", "category": "communication", "confidence": 0.85, "stance": "do", "evidence": [] },
25
+ { "text": "Session titles stay short and descriptive in plain words, never raw spec/task ids, and remain sticky through follow-ups and sub-steps rather than changing at each turn.", "category": "naming", "confidence": 0.85, "stance": "do", "evidence": [] },
26
+ { "text": "Parallelise independent workstreams by spawning sub-agents instead of serialising everything in the main thread.", "category": "delegation", "confidence": 0.84, "stance": "do", "evidence": [] }
27
+ ],
28
+ "conflicts": []
29
+ }
@@ -0,0 +1,179 @@
1
+ // spexharness/src/adapters/copilot-models.mjs
2
+ //
3
+ // Which models can a Copilot seat be pointed at? ([[HS-OUIT-C86Z-TASK-copilot-model-list-from-the-copilot-cli]]
4
+ // of epic [[HS-F4UM-C86Z-EPIC-copilot-model-list-and-auto-pass-through]]; design: research
5
+ // [[HS-RXJO-C86Z-RES-spexfleet-per-user-seat-assignment-with]] §Copilot defaults.)
6
+ //
7
+ // Owner ruling: under Copilot every seat defaults to `auto`; a seat may be overridden to any model the
8
+ // Copilot harness offers. There is NO supported per-user entitlement listing (github/copilot-cli#700),
9
+ // so the list here is the installed CLI's SUPPORTED-model enum, printed by `copilot help config`
10
+ // under the `model` key - suggestions, not a promise. Any well-formed typed id is accepted too: the
11
+ // Copilot CLI itself is the final check at launch, and a rejected override falls back to `auto` for
12
+ // that run (spexharness/src/seats/copilot-run.mjs).
13
+ //
14
+ // The parsed list is cached per CLI version under <HEYSPEX_HOME>/.heyspex/cache/copilot-models.json
15
+ // (never under the vault, never in ~/.copilot). Everything impure is injectable so tests never run a
16
+ // real Copilot.
17
+ //
18
+ // node spexharness/src/adapters/copilot-models.mjs [--refresh] [--json]
19
+
20
+ import { spawnSync } from 'node:child_process';
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import { resolveCopilotCli } from '../../../scripts/lib/copilot-cli.mjs';
24
+ import { heyspexHome } from '../../../scripts/lib/heyspex-home.mjs';
25
+
26
+ /** Copilot's automatic model selection: always offered, always first, the default for every seat. */
27
+ export const COPILOT_AUTO = 'auto';
28
+
29
+ /** The cache file name under <home>/.heyspex/cache/. */
30
+ export const COPILOT_MODELS_CACHE_FILE = 'copilot-models.json';
31
+
32
+ /** Where the per-version cache lives. */
33
+ export function copilotModelsCachePath(env = process.env) {
34
+ return path.join(heyspexHome(env), '.heyspex', 'cache', COPILOT_MODELS_CACHE_FILE);
35
+ }
36
+
37
+ /**
38
+ * Pull the `model` enum out of `copilot help config` text. The block looks like
39
+ * `model`: AI model to use for Copilot CLI; ...
40
+ * - "claude-sonnet-5.5"
41
+ * - "gpt-5.4"
42
+ * and ends at the first line that is not a `- "id"` row (a blank line or the next `key`). The format
43
+ * is not a stable contract (a user in copilot-cli#700 saw an earlier list vanish), so anything
44
+ * unexpected yields [] rather than a guess.
45
+ * @param {string} text
46
+ * @returns {string[]}
47
+ */
48
+ export function parseCopilotModelEnum(text) {
49
+ const lines = String(text ?? '').split(/\r?\n/);
50
+ const start = lines.findIndex((l) => /^\s*`model`\s*:/.test(l));
51
+ if (start < 0) return [];
52
+ const out = [];
53
+ for (let i = start + 1; i < lines.length; i++) {
54
+ const m = /^\s*-\s*"([^"]+)"\s*$/.exec(lines[i]);
55
+ if (!m) break;
56
+ const id = m[1].trim();
57
+ if (id && id !== COPILOT_AUTO && !out.includes(id)) out.push(id);
58
+ }
59
+ return out;
60
+ }
61
+
62
+ /** `GitHub Copilot CLI 1.0.100-1.` → `1.0.100-1`; null when no version is found. */
63
+ export function parseCopilotVersion(text) {
64
+ const m = /(\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?)/.exec(String(text ?? ''));
65
+ return m ? m[1].replace(/\.$/, '') : null;
66
+ }
67
+
68
+ /** Run the CLI once (Windows npm shims need a shell). Returns { status, stdout } or null on spawn failure. */
69
+ function defaultRun(cliPath, args) {
70
+ const shim = /\.(cmd|bat)$/i.test(cliPath);
71
+ const q = (a) => (/[\s"]/.test(a) ? `"${a.replace(/"/g, '\\"')}"` : a);
72
+ try {
73
+ const opts = { encoding: 'utf8', timeout: 30000, windowsHide: true };
74
+ // A shim goes through the shell as ONE pre-quoted command line (args + shell:true is deprecated, DEP0190).
75
+ const r = shim ? spawnSync([cliPath, ...args].map(q).join(' '), { ...opts, shell: true }) : spawnSync(cliPath, args, opts);
76
+ if (r.error) return null;
77
+ return { status: r.status, stdout: `${r.stdout ?? ''}${r.stderr ?? ''}` };
78
+ } catch { return null; }
79
+ }
80
+
81
+ function readCache(file, readFile) {
82
+ try { const j = JSON.parse(readFile(file)); return j && typeof j === 'object' && j.versions && typeof j.versions === 'object' ? j : { versions: {} }; } catch { return { versions: {} }; }
83
+ }
84
+
85
+ /**
86
+ * @typedef {object} CopilotModelList
87
+ * @property {string[]} models `auto` first, then the CLI's supported ids (in the CLI's order)
88
+ * @property {'copilot help config'|'cache'|'none'} source
89
+ * @property {string|null} cliVersion
90
+ * @property {string|null} cliPath
91
+ * @property {string|null} cachedAt ISO time the list was read from the CLI
92
+ * @property {string} note the entitlement caveat, for display
93
+ */
94
+
95
+ /** Shown wherever the list is shown: the list is what the CLI supports, not what this user may use. */
96
+ export const COPILOT_MODELS_NOTE =
97
+ 'Models the installed Copilot CLI supports (`copilot help config`), not a per-user entitlement list - plan and org policy decide what actually runs. Any typed id is accepted; Copilot is the final check and a rejected override falls back to auto for that run.';
98
+
99
+ /**
100
+ * List the models a Copilot seat can be set to. Never throws: no CLI → `['auto']`, source `none`.
101
+ * @param {{ refresh?: boolean, env?: Record<string,string|undefined>, cli?: {path: string, version?: string}|null,
102
+ * run?: (cliPath: string, args: string[]) => ({status: number|null, stdout: string}|null),
103
+ * cachePath?: string, readFile?: (p: string) => string, writeFile?: (p: string, s: string) => void,
104
+ * now?: () => Date }} [opts]
105
+ * @returns {CopilotModelList}
106
+ */
107
+ export function listCopilotModels(opts = {}) {
108
+ const env = opts.env ?? process.env;
109
+ const run = opts.run ?? defaultRun;
110
+ const now = opts.now ?? (() => new Date());
111
+ const cachePath = opts.cachePath ?? copilotModelsCachePath(env);
112
+ const readFile = opts.readFile ?? ((p) => fs.readFileSync(p, 'utf8'));
113
+ const writeFile = opts.writeFile ?? ((p, s) => { fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, s); });
114
+ const cli = opts.cli !== undefined ? opts.cli : resolveCopilotCli({ env });
115
+ const result = (models, source, cliVersion, cachedAt) => Object.freeze({
116
+ models: Object.freeze([COPILOT_AUTO, ...models]), source, cliVersion, cliPath: cli?.path ?? null, cachedAt, note: COPILOT_MODELS_NOTE,
117
+ });
118
+ if (!cli?.path) return result([], 'none', null, null);
119
+
120
+ // The version keys the cache: the child-process marker when we are inside Copilot, else `copilot --version`.
121
+ let version = (typeof env.COPILOT_CLI_BINARY_VERSION === 'string' && env.COPILOT_CLI_BINARY_VERSION.trim()) || cli.version || null;
122
+ if (!version) { const v = run(cli.path, ['--version']); version = v ? parseCopilotVersion(v.stdout) : null; }
123
+ const key = version ?? 'unknown';
124
+
125
+ const cache = readCache(cachePath, readFile);
126
+ const hit = cache.versions[key];
127
+ if (!opts.refresh && version && hit && Array.isArray(hit.models) && hit.models.length) {
128
+ return result(hit.models, 'cache', version, hit.cachedAt ?? null);
129
+ }
130
+
131
+ const r = run(cli.path, ['help', 'config']);
132
+ const models = r ? parseCopilotModelEnum(r.stdout) : [];
133
+ if (!models.length) {
134
+ // The CLI answered with no parsable enum: an older cached list for this version beats nothing.
135
+ if (hit?.models?.length) return result(hit.models, 'cache', version, hit.cachedAt ?? null);
136
+ return result([], 'none', version, null);
137
+ }
138
+ const cachedAt = now().toISOString();
139
+ if (version) {
140
+ cache.versions[key] = { models, cachedAt, cliPath: cli.path };
141
+ try { writeFile(cachePath, `${JSON.stringify(cache, null, 2)}\n`); } catch { /* the cache is an optimisation only */ }
142
+ }
143
+ return result(models, 'copilot help config', version, cachedAt);
144
+ }
145
+
146
+ /** A model id Copilot could plausibly take: non-empty, no whitespace or quotes, bounded length. */
147
+ const WELL_FORMED = /^[A-Za-z0-9][A-Za-z0-9._:/@+-]{0,127}$/;
148
+
149
+ /**
150
+ * Validate a typed Copilot model id for a seat (`hs seat set <seat> <model> --harness copilot`).
151
+ * Unknown-but-well-formed ids are ACCEPTED with a warning: the list is not an entitlement list, and
152
+ * Copilot's own refusal (with the auto fallback) is the final check.
153
+ * @param {string} id
154
+ * @param {{ models?: readonly string[] }|CopilotModelList} [list] default: listCopilotModels()
155
+ * @returns {{ ok: boolean, model: string|null, known: boolean, warning?: string, error?: string }}
156
+ */
157
+ export function validateCopilotModel(id, list) {
158
+ const model = String(id ?? '').trim();
159
+ if (!model) return { ok: false, model: null, known: false, error: 'a Copilot model id is required (use "auto" to let Copilot pick)' };
160
+ if (!WELL_FORMED.test(model)) return { ok: false, model: null, known: false, error: `"${model}" is not a valid Copilot model id (letters, digits and . _ : / @ + - only, no spaces)` };
161
+ if (model === COPILOT_AUTO) return { ok: true, model, known: true };
162
+ const models = (list ?? listCopilotModels()).models ?? [];
163
+ if (models.includes(model)) return { ok: true, model, known: true };
164
+ return {
165
+ ok: true, model, known: false,
166
+ warning: `"${model}" is not in the installed Copilot CLI's model list${models.length > 1 ? '' : ' (no list could be read)'}; it is kept as typed - Copilot decides at launch, and a rejected model falls back to auto for that run`,
167
+ };
168
+ }
169
+
170
+ const isMain = process.argv[1]?.replaceAll('\\', '/').endsWith('/copilot-models.mjs');
171
+ if (isMain) {
172
+ const list = listCopilotModels({ refresh: process.argv.includes('--refresh') });
173
+ if (process.argv.includes('--json')) console.log(JSON.stringify(list, null, 2));
174
+ else {
175
+ console.log(`Copilot models (${list.source}${list.cliVersion ? `, CLI ${list.cliVersion}` : ''}):`);
176
+ for (const m of list.models) console.log(` ${m}${m === COPILOT_AUTO ? ' (default for every seat)' : ''}`);
177
+ console.log(`\n${list.note}`);
178
+ }
179
+ }
@@ -0,0 +1,148 @@
1
+ // spexharness/src/adapters/harness-detect.mjs
2
+ //
3
+ // Which coding harness is this HeySpex process running under, for SEAT DEFAULTS?
4
+ // ([[HS-KQ5E-C86Z-TASK-pure-harness-detection-and-seat-default]] of epic
5
+ // [[HS-C65J-C86Z-EPIC-harness-aware-seat-default-resolver]]; design: research
6
+ // [[HS-RXJO-C86Z-RES-spexfleet-per-user-seat-assignment-with]] §Harness detection; owner rulings 2026-10-17.)
7
+ //
8
+ // The sibling ./detect-harness.mjs answers a narrower question (which output dialect a HOOK must
9
+ // speak) and always returns a string. This module answers "whose models are the seat defaults",
10
+ // so it must also say WHERE the answer came from and refuse to guess when it cannot tell:
11
+ //
12
+ // 1. env.HEYSPEX_HARNESS (claude-code | copilot | none) — every plugin/kit install stamps it
13
+ // 2. hook payload shape (the existing detectHarness) — when a payload is present
14
+ // 3. positive env markers — Copilot: COPILOT_AGENT_SESSION_ID,
15
+ // COPILOT_CLI, COPILOT_PLUGIN_ROOT; Claude Code: CLAUDECODE, CLAUDE_CODE_SESSION_ID.
16
+ // Both present → copilot (a Claude Code shell launching Copilot is the documented nesting;
17
+ // the reverse is not). CLAUDE_PLUGIN_ROOT is NEVER a marker: Copilot expands it too.
18
+ // 4. installed plugins — exactly one → that harness;
19
+ // several → status 'needs-choice' (the caller asks); none → status 'none'.
20
+ //
21
+ // Pure: env, payload and the installed-plugin list come in; nothing is read here. The one impure
22
+ // helper, listInstalledHarnessPlugins(), takes injected fs deps and an explicit home so tests use
23
+ // fake homes and never touch a real ~/.claude or ~/.copilot.
24
+
25
+ import path from 'node:path';
26
+ import { detectHarness as detectHookHarness } from './detect-harness.mjs';
27
+
28
+ /** The harnesses that carry their own seat defaults. */
29
+ export const SEAT_HARNESSES = Object.freeze(['claude-code', 'copilot']);
30
+
31
+ /** Env markers that positively identify Copilot CLI (observed in its child processes, 2026-10-17 probe). */
32
+ export const COPILOT_ENV_MARKERS = Object.freeze(['COPILOT_AGENT_SESSION_ID', 'COPILOT_CLI', 'COPILOT_PLUGIN_ROOT']);
33
+
34
+ /** Env markers that positively identify Claude Code. CLAUDE_PLUGIN_ROOT is deliberately absent. */
35
+ export const CLAUDE_ENV_MARKERS = Object.freeze(['CLAUDECODE', 'CLAUDE_CODE_SESSION_ID']);
36
+
37
+ /** Accepted spellings of an explicit HEYSPEX_HARNESS value. */
38
+ const EXPLICIT = Object.freeze({
39
+ 'claude-code': 'claude-code',
40
+ claude: 'claude-code',
41
+ claudecode: 'claude-code',
42
+ copilot: 'copilot',
43
+ 'github-copilot': 'copilot',
44
+ none: null,
45
+ });
46
+
47
+ /** Normalize a harness name to 'claude-code' | 'copilot', or undefined when it is not one. */
48
+ export function normalizeHarness(value) {
49
+ const key = String(value ?? '').trim().toLowerCase();
50
+ if (!key || !Object.prototype.hasOwnProperty.call(EXPLICIT, key)) return undefined;
51
+ return EXPLICIT[key];
52
+ }
53
+
54
+ const present = (env, k) => typeof env?.[k] === 'string' && env[k].trim() !== '';
55
+
56
+ /**
57
+ * @typedef {object} HarnessResult
58
+ * @property {'claude-code'|'copilot'|null} harness
59
+ * @property {'resolved'|'needs-choice'|'none'} status
60
+ * @property {'env:HEYSPEX_HARNESS'|'hook-payload'|'env-marker'|'installed-plugin'|'none'} source
61
+ * @property {string|null} detail which marker / plugin decided it (null when nothing did)
62
+ * @property {string[]} [choices] set only for status 'needs-choice'
63
+ */
64
+
65
+ /**
66
+ * @param {Record<string, string|undefined>} [env]
67
+ * @param {unknown} [payload] parsed hook stdin, when this runs inside a hook
68
+ * @param {string[]} [installedPlugins] harnesses the heyspex plugin is installed for
69
+ * @returns {HarnessResult}
70
+ */
71
+ export function detectHarness(env = {}, payload = null, installedPlugins = []) {
72
+ const safeEnv = env && typeof env === 'object' ? env : {};
73
+
74
+ // 1. Explicit stamp. `none` is an explicit "no harness" (plain terminal), not a fall-through.
75
+ if (present(safeEnv, 'HEYSPEX_HARNESS')) {
76
+ const h = normalizeHarness(safeEnv.HEYSPEX_HARNESS);
77
+ if (h !== undefined) {
78
+ return Object.freeze({ harness: h, status: h ? 'resolved' : 'none', source: 'env:HEYSPEX_HARNESS', detail: String(safeEnv.HEYSPEX_HARNESS).trim() });
79
+ }
80
+ }
81
+
82
+ // 2. Hook payload shape. The hook detector reads HEYSPEX_HARNESS itself; pass a stripped env so a
83
+ // junk value (rejected above) cannot leak back in through it.
84
+ if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
85
+ const fromPayload = detectHookHarness(payload, {});
86
+ if (fromPayload === 'claude-code' || fromPayload === 'copilot') {
87
+ return Object.freeze({ harness: fromPayload, status: 'resolved', source: 'hook-payload', detail: fromPayload });
88
+ }
89
+ }
90
+
91
+ // 3. Positive env markers, Copilot first (nesting rule).
92
+ const copilotMarker = COPILOT_ENV_MARKERS.find((k) => present(safeEnv, k));
93
+ if (copilotMarker) return Object.freeze({ harness: 'copilot', status: 'resolved', source: 'env-marker', detail: copilotMarker });
94
+ const claudeMarker = CLAUDE_ENV_MARKERS.find((k) => present(safeEnv, k));
95
+ if (claudeMarker) return Object.freeze({ harness: 'claude-code', status: 'resolved', source: 'env-marker', detail: claudeMarker });
96
+
97
+ // 4. Installed plugins.
98
+ const installed = [...new Set((Array.isArray(installedPlugins) ? installedPlugins : []).map(normalizeHarness).filter(Boolean))].sort();
99
+ if (installed.length === 1) {
100
+ return Object.freeze({ harness: installed[0], status: 'resolved', source: 'installed-plugin', detail: installed[0] });
101
+ }
102
+ if (installed.length > 1) {
103
+ return Object.freeze({ harness: null, status: 'needs-choice', source: 'installed-plugin', detail: null, choices: Object.freeze(installed) });
104
+ }
105
+ return Object.freeze({ harness: null, status: 'none', source: 'none', detail: null });
106
+ }
107
+
108
+ /**
109
+ * The value a launcher should stamp into HEYSPEX_HARNESS for its child, or null when it cannot tell
110
+ * from env alone (then it stamps nothing and the child falls back to its own detection).
111
+ * @param {Record<string, string|undefined>} [env]
112
+ * @returns {'claude-code'|'copilot'|null}
113
+ */
114
+ export function harnessStampFor(env = {}) {
115
+ const r = detectHarness(env, null, []);
116
+ return r.status === 'resolved' ? r.harness : null;
117
+ }
118
+
119
+ /**
120
+ * Which harnesses the heyspex plugin is installed for. Best effort, never throws.
121
+ * Evidence: ~/.claude/plugins/installed_plugins.json with a "heyspex@…" key → claude-code;
122
+ * a heyspex entry under <copilotHome>/installed-plugins or <copilotHome>/plugins (one level of
123
+ * <marketplace>/<plugin> nesting) → copilot.
124
+ * @param {{ home: string, copilotHome?: string, deps: { readFile: (p: string) => string|null, listDir: (p: string) => string[] } }} opts
125
+ * @returns {string[]}
126
+ */
127
+ export function listInstalledHarnessPlugins({ home, copilotHome, deps }) {
128
+ const out = [];
129
+ if (!home || !deps) return out;
130
+ const read = (p) => { try { return deps.readFile(p); } catch { return null; } };
131
+ const list = (p) => { try { const r = deps.listDir(p); return Array.isArray(r) ? r : []; } catch { return []; } };
132
+ try {
133
+ const raw = read(path.join(home, '.claude', 'plugins', 'installed_plugins.json'));
134
+ if (raw) {
135
+ const j = JSON.parse(raw);
136
+ const keys = Object.keys(j?.plugins ?? j ?? {});
137
+ if (keys.some((k) => /^heyspex(@|$)/i.test(k))) out.push('claude-code');
138
+ }
139
+ } catch { /* unreadable registry is no evidence */ }
140
+ const cHome = copilotHome || path.join(home, '.copilot');
141
+ const isOurs = (n) => /^heyspex(@|$|-)/i.test(String(n));
142
+ for (const sub of ['installed-plugins', 'plugins']) {
143
+ const dir = path.join(cHome, sub);
144
+ const names = list(dir);
145
+ if (names.some(isOurs) || names.some((n) => list(path.join(dir, n)).some(isOurs))) { out.push('copilot'); break; }
146
+ }
147
+ return out;
148
+ }