heyspex 0.1.0
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/README.md +157 -0
- package/bin/hs.mjs +1028 -0
- package/bin/postinstall.mjs +88 -0
- package/dev-factory/lib/fleet-readiness.mjs +274 -0
- package/dev-factory/lib/fleet-state.mjs +250 -0
- package/dev-factory/lib/sufficiency-score.mjs +441 -0
- package/dist/cli/hs-check.mjs +747 -0
- package/dist/cli/hs-claim.mjs +541 -0
- package/dist/cli/hs-connect.mjs +3436 -0
- package/dist/cli/hs-defer.mjs +8455 -0
- package/dist/cli/hs-env.mjs +36845 -0
- package/dist/cli/hs-init.mjs +1145 -0
- package/dist/cli/hs-install.mjs +1390 -0
- package/dist/cli/hs-join.mjs +38553 -0
- package/dist/cli/hs-vault-pull.mjs +7782 -0
- package/dist/cli/hs-vault.mjs +4166 -0
- package/dist/cli/hs-workspace.mjs +2898 -0
- package/dist/cli-templates/customer-vault/specs/entity-model.md +1735 -0
- package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +531 -0
- package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +48 -0
- package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +548 -0
- package/docs/mcp-client-config.md +200 -0
- package/package.json +124 -0
- package/scripts/check-mcp-freshness.mjs +379 -0
- package/scripts/dev-server-status.mjs +722 -0
- package/scripts/gateway-skills-lib.mjs +421 -0
- package/scripts/lib/console-url-resolver.mjs +152 -0
- package/scripts/lib/dev-mode.mjs +369 -0
- package/scripts/lib/hs-link.mjs +154 -0
- package/scripts/lib/hs-source-commit.mjs +207 -0
- package/scripts/lib/hs-source-log.mjs +366 -0
- package/scripts/lib/hs-source-preflight.mjs +402 -0
- package/scripts/lib/hs-source-push.mjs +307 -0
- package/scripts/lib/hs-source-secret-scan.mjs +263 -0
- package/scripts/lib/standalone-doctor.mjs +89 -0
- package/scripts/vault-id-lib.mjs +173 -0
- package/vendor/heyspex-mcp/alignment-gate.mjs +1347 -0
- package/vendor/heyspex-mcp/blocker-resolver.mjs +891 -0
- package/vendor/heyspex-mcp/breakdown-ladder.mjs +309 -0
- package/vendor/heyspex-mcp/breakdown-source.mjs +236 -0
- package/vendor/heyspex-mcp/breakdown-writer.mjs +524 -0
- package/vendor/heyspex-mcp/brief-observability.mjs +67 -0
- package/vendor/heyspex-mcp/canvas-inbox.mjs +130 -0
- package/vendor/heyspex-mcp/capture-active-rehydrate.mjs +76 -0
- package/vendor/heyspex-mcp/capture-buffer-patches.mjs +147 -0
- package/vendor/heyspex-mcp/capture-channel.mjs +577 -0
- package/vendor/heyspex-mcp/capture-console-client.mjs +290 -0
- package/vendor/heyspex-mcp/capture-focus.mjs +1366 -0
- package/vendor/heyspex-mcp/capture-kind-suggest.mjs +121 -0
- package/vendor/heyspex-mcp/capture-outbox.mjs +239 -0
- package/vendor/heyspex-mcp/capture-stage-gate.mjs +160 -0
- package/vendor/heyspex-mcp/cli.mjs +15754 -0
- package/vendor/heyspex-mcp/config-settings.mjs +419 -0
- package/vendor/heyspex-mcp/console-fetch.mjs +342 -0
- package/vendor/heyspex-mcp/defer-spec.mjs +527 -0
- package/vendor/heyspex-mcp/deferred-reclaim.mjs +284 -0
- package/vendor/heyspex-mcp/design-tools.mjs +449 -0
- package/vendor/heyspex-mcp/dev-factory-bridge.mjs +66 -0
- package/vendor/heyspex-mcp/diagnose-skeleton.mjs +114 -0
- package/vendor/heyspex-mcp/fleet-queue-tools.mjs +241 -0
- package/vendor/heyspex-mcp/hooks-engine.mjs +399 -0
- package/vendor/heyspex-mcp/login-handoff.mjs +438 -0
- package/vendor/heyspex-mcp/mcp-subprocess.mjs +136 -0
- package/vendor/heyspex-mcp/mvp-mode.mjs +101 -0
- package/vendor/heyspex-mcp/next-resolution.mjs +240 -0
- package/vendor/heyspex-mcp/next-review.mjs +548 -0
- package/vendor/heyspex-mcp/peer-presence.mjs +430 -0
- package/vendor/heyspex-mcp/plan-ground-conflicts.mjs +321 -0
- package/vendor/heyspex-mcp/plan-ground.mjs +2001 -0
- package/vendor/heyspex-mcp/plan-ingest.mjs +476 -0
- package/vendor/heyspex-mcp/plan-push.mjs +1826 -0
- package/vendor/heyspex-mcp/plan-review.mjs +1419 -0
- package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
- package/vendor/heyspex-mcp/procedure-tools.mjs +970 -0
- package/vendor/heyspex-mcp/promote-capture.mjs +1280 -0
- package/vendor/heyspex-mcp/promote-diagnose.mjs +187 -0
- package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +338 -0
- package/vendor/heyspex-mcp/promote-research.mjs +1553 -0
- package/vendor/heyspex-mcp/promotion-helpers.mjs +280 -0
- package/vendor/heyspex-mcp/read-path.mjs +138 -0
- package/vendor/heyspex-mcp/recipe-tools.mjs +258 -0
- package/vendor/heyspex-mcp/regen-tasks.mjs +326 -0
- package/vendor/heyspex-mcp/register-spec.mjs +417 -0
- package/vendor/heyspex-mcp/research-focus.mjs +476 -0
- package/vendor/heyspex-mcp/research-tools.mjs +109 -0
- package/vendor/heyspex-mcp/research-type-suggest.mjs +112 -0
- package/vendor/heyspex-mcp/resolution-focus.mjs +45 -0
- package/vendor/heyspex-mcp/review-focus.mjs +55 -0
- package/vendor/heyspex-mcp/review-rollup.mjs +47 -0
- package/vendor/heyspex-mcp/run-front.mjs +60 -0
- package/vendor/heyspex-mcp/scheduler.mjs +813 -0
- package/vendor/heyspex-mcp/seat-identity.mjs +281 -0
- package/vendor/heyspex-mcp/settings-tools.mjs +119 -0
- package/vendor/heyspex-mcp/spec-id-lib.mjs +790 -0
- package/vendor/heyspex-mcp/split-research.mjs +832 -0
- package/vendor/heyspex-mcp/submit-for-review.mjs +190 -0
- package/vendor/heyspex-mcp/summary-contract.mjs +162 -0
- package/vendor/heyspex-mcp/test-helpers.mjs +43 -0
- package/vendor/heyspex-mcp/validate-linkage.mjs +574 -0
- package/vendor/heyspex-mcp/vault-resolve.mjs +297 -0
- package/vendor/heyspex-mcp/vault-settings.mjs +87 -0
- package/vendor/heyspex-mcp/verify-reproduction.mjs +314 -0
- package/vendor/heyspex-mcp/why-now.mjs +64 -0
- package/vendor/heyspex-mcp/yaml-scalar.mjs +34 -0
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Dev-factory seat identity resolution for the HeySpex MCP server.
|
|
3
|
+
//
|
|
4
|
+
// The MCP server is the one point EVERY caller passes through (Claude Code,
|
|
5
|
+
// Cursor, or a spawned fleet worker). This module resolves which dev-factory
|
|
6
|
+
// seat a caller holds so the server can surface that seat's mandate on connect —
|
|
7
|
+
// moving seat identity from Claude-Code-only (the SessionStart hook) to
|
|
8
|
+
// universal. Owning epic: [[HS-CXO0-EPIC-seat-identity-surfaced-to-every-caller-f]].
|
|
9
|
+
//
|
|
10
|
+
// Source of truth for seats: dev-factory/seats.json (read off the MCP vaultRoot).
|
|
11
|
+
// Fail-silent contract (mirror scripts/claude-hooks/inject-coo-seat.mjs): a
|
|
12
|
+
// missing/corrupt registry, or an unknown caller, resolves to null — inject
|
|
13
|
+
// nothing, never throw.
|
|
14
|
+
//
|
|
15
|
+
// The deterministic gate for this module is its own `--self-test` (run via
|
|
16
|
+
// `node dev-factory/controller.mjs --self-test-gate`). The assertions below ARE
|
|
17
|
+
// the acceptance criteria made executable — the Builder seat implements the
|
|
18
|
+
// three function bodies until every case passes.
|
|
19
|
+
|
|
20
|
+
import { readFileSync } from "node:fs";
|
|
21
|
+
import path from "node:path";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Read and parse <vaultRoot>/dev-factory/seats.json.
|
|
26
|
+
* @returns {{settings?: object, seats?: Array}|null} parsed registry, or null on
|
|
27
|
+
* missing file / parse error (fail-silent — never throws).
|
|
28
|
+
*/
|
|
29
|
+
export function readSeatsRegistry(vaultRoot) {
|
|
30
|
+
try {
|
|
31
|
+
const seatsPath = path.join(vaultRoot, "dev-factory", "seats.json");
|
|
32
|
+
const raw = readFileSync(seatsPath, "utf8");
|
|
33
|
+
return JSON.parse(raw);
|
|
34
|
+
} catch (err) {
|
|
35
|
+
// Fail-silent as per brief.
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Resolve the active dev-factory seat for a caller. Pure — no filesystem.
|
|
42
|
+
* @param {{agent?: string, sessionId?: string}} identity
|
|
43
|
+
* @param {{registry?: object, env?: object}} opts registry from readSeatsRegistry; env bag
|
|
44
|
+
* @returns {object|null} the matching ACTIVE seat object augmented with a
|
|
45
|
+
* `resolvedVia` string, or null when nothing matches / seat inactive / no registry.
|
|
46
|
+
*
|
|
47
|
+
* Precedence (first match wins):
|
|
48
|
+
* 1. env.HEYSPEX_SEAT — exact seat id (case-insensitive) → resolvedVia "env:HEYSPEX_SEAT"
|
|
49
|
+
* 2. env.HEYSPEX_FLEET_ROLE — builder|build→build, critic→critic,
|
|
50
|
+
* breakdown→breakdown, expand→expand,
|
|
51
|
+
* else the value as a seat id → resolvedVia "env:HEYSPEX_FLEET_ROLE"
|
|
52
|
+
* 3. env.HEYSPEX_COO_WINDOW — truthy ("1"/"true") → seat id "coo" → resolvedVia "env:HEYSPEX_COO_WINDOW"
|
|
53
|
+
* 4. agent starts with "Claude"/"Cursor" → seat id "coo" → resolvedVia "agent-default"
|
|
54
|
+
* otherwise → null
|
|
55
|
+
* A matched seat is only returned when seat.active === true; else null.
|
|
56
|
+
*/
|
|
57
|
+
export function resolveSeat(identity = {}, opts = {}) {
|
|
58
|
+
const { agent = "", sessionId = "" } = identity;
|
|
59
|
+
const { registry, env = {} } = opts;
|
|
60
|
+
|
|
61
|
+
if (!registry || !Array.isArray(registry.seats)) return null;
|
|
62
|
+
|
|
63
|
+
// 1. env.HEYSPEX_SEAT — exact seat id (case-insensitive)
|
|
64
|
+
if (env.HEYSPEX_SEAT) {
|
|
65
|
+
const seatId = env.HEYSPEX_SEAT.toLowerCase();
|
|
66
|
+
const seat = registry.seats.find((s) => s.id.toLowerCase() === seatId);
|
|
67
|
+
if (seat && seat.active)
|
|
68
|
+
return { ...seat, resolvedVia: "env:HEYSPEX_SEAT" };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 2. env.HEYSPEX_FLEET_ROLE
|
|
72
|
+
if (env.HEYSPEX_FLEET_ROLE) {
|
|
73
|
+
const role = env.HEYSPEX_FLEET_ROLE.toLowerCase();
|
|
74
|
+
let seatId;
|
|
75
|
+
switch (role) {
|
|
76
|
+
case "builder":
|
|
77
|
+
seatId = "build";
|
|
78
|
+
break;
|
|
79
|
+
case "critic":
|
|
80
|
+
seatId = "critic";
|
|
81
|
+
break;
|
|
82
|
+
case "breakdown":
|
|
83
|
+
seatId = "breakdown";
|
|
84
|
+
break;
|
|
85
|
+
case "expand":
|
|
86
|
+
seatId = "expand";
|
|
87
|
+
break;
|
|
88
|
+
default:
|
|
89
|
+
seatId = role; // treat as literal seat id
|
|
90
|
+
}
|
|
91
|
+
const seat = registry.seats.find((s) => s.id === seatId);
|
|
92
|
+
if (seat && seat.active)
|
|
93
|
+
return { ...seat, resolvedVia: "env:HEYSPEX_FLEET_ROLE" };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 3. env.HEYSPEX_COO_WINDOW — truthy → "coo"
|
|
97
|
+
if (
|
|
98
|
+
env.HEYSPEX_COO_WINDOW &&
|
|
99
|
+
["1", "true"].includes(env.HEYSPEX_COO_WINDOW)
|
|
100
|
+
) {
|
|
101
|
+
const seat = registry.seats.find((s) => s.id === "coo");
|
|
102
|
+
if (seat && seat.active)
|
|
103
|
+
return { ...seat, resolvedVia: "env:HEYSPEX_COO_WINDOW" };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// 4. agent default
|
|
107
|
+
if (agent.startsWith("Claude") || agent.startsWith("Cursor")) {
|
|
108
|
+
const seat = registry.seats.find((s) => s.id === "coo");
|
|
109
|
+
if (seat && seat.active) return { ...seat, resolvedVia: "agent-default" };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Convenience: read the registry off vaultRoot then resolve the seat.
|
|
117
|
+
* @returns {object|null}
|
|
118
|
+
*/
|
|
119
|
+
export function resolveSeatForCaller({
|
|
120
|
+
agent,
|
|
121
|
+
sessionId,
|
|
122
|
+
vaultRoot,
|
|
123
|
+
env = {},
|
|
124
|
+
} = {}) {
|
|
125
|
+
const registry = readSeatsRegistry(vaultRoot);
|
|
126
|
+
return resolveSeat({ agent, sessionId }, { registry, env });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Render a resolved seat as a single-line identity banner for delivery to the
|
|
131
|
+
* caller — the same seat's OWN mandate, so a Builder worker sees "Builder" and a
|
|
132
|
+
* COO session sees "COO" (no cross-contamination). Pure; safe on any input.
|
|
133
|
+
* @param {object|null} seat a seat object (from resolveSeat), or null.
|
|
134
|
+
* @returns {string} `DEV-FACTORY SEAT: <title> — <mandate>`, or "" when nothing
|
|
135
|
+
* resolves / the seat lacks a title (caller injects nothing).
|
|
136
|
+
*/
|
|
137
|
+
export function formatSeatBanner(seat) {
|
|
138
|
+
if (!seat || typeof seat !== "object" || typeof seat.title !== "string")
|
|
139
|
+
return "";
|
|
140
|
+
const mandate = typeof seat.mandate === "string" ? seat.mandate : "";
|
|
141
|
+
const suffix = mandate ? ` — ${mandate}` : "";
|
|
142
|
+
return `DEV-FACTORY SEAT: ${seat.title}${suffix}`;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
146
|
+
// Deterministic gate — `node vendor/heyspex-mcp/seat-identity.mjs --self-test`
|
|
147
|
+
// These assertions are the acceptance criteria. Do NOT weaken them.
|
|
148
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
149
|
+
|
|
150
|
+
const FIXTURE = {
|
|
151
|
+
settings: { cooMode: true },
|
|
152
|
+
seats: [
|
|
153
|
+
{ id: "coo", title: "COO", active: true, mandate: "coo mandate" },
|
|
154
|
+
{ id: "build", title: "Builder", active: true, mandate: "builder mandate" },
|
|
155
|
+
{ id: "critic", title: "Critic", active: true, mandate: "critic mandate" },
|
|
156
|
+
{
|
|
157
|
+
id: "software-architect",
|
|
158
|
+
title: "Software Architect",
|
|
159
|
+
active: false,
|
|
160
|
+
mandate: "staged",
|
|
161
|
+
},
|
|
162
|
+
],
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
function selfTest() {
|
|
166
|
+
const failures = [];
|
|
167
|
+
const check = (cond, label) => {
|
|
168
|
+
if (!cond) failures.push(label);
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
// Repo root is two dirs up from this file (vendor/heyspex-mcp/ → repo root).
|
|
172
|
+
const repoRoot = path.resolve(
|
|
173
|
+
path.dirname(fileURLToPath(import.meta.url)),
|
|
174
|
+
"..",
|
|
175
|
+
"..",
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
// 1. Real registry smoke-read.
|
|
179
|
+
const real = readSeatsRegistry(repoRoot);
|
|
180
|
+
check(
|
|
181
|
+
real && Array.isArray(real.seats) && real.seats.length > 0,
|
|
182
|
+
"readSeatsRegistry reads real seats.json",
|
|
183
|
+
);
|
|
184
|
+
|
|
185
|
+
// 2. Missing registry → null (fail-silent).
|
|
186
|
+
check(
|
|
187
|
+
readSeatsRegistry(path.join(repoRoot, "no", "such", "dir")) === null,
|
|
188
|
+
"readSeatsRegistry missing → null",
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
// 3. Claude Code session, empty env → coo via agent-default.
|
|
192
|
+
const s3 = resolveSeat(
|
|
193
|
+
{ agent: "Claude Code", sessionId: "s1" },
|
|
194
|
+
{ registry: FIXTURE, env: {} },
|
|
195
|
+
);
|
|
196
|
+
check(
|
|
197
|
+
s3 && s3.id === "coo" && s3.resolvedVia === "agent-default",
|
|
198
|
+
"Claude Code + empty env → coo (agent-default)",
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
// 4. Fleet builder role → build, NOT coo (the core acceptance point).
|
|
202
|
+
const s4 = resolveSeat(
|
|
203
|
+
{ agent: "", sessionId: "w1" },
|
|
204
|
+
{ registry: FIXTURE, env: { HEYSPEX_FLEET_ROLE: "builder" } },
|
|
205
|
+
);
|
|
206
|
+
check(
|
|
207
|
+
s4 && s4.id === "build",
|
|
208
|
+
"HEYSPEX_FLEET_ROLE=builder → build (not coo)",
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
// 5. COO window env → coo.
|
|
212
|
+
const s5 = resolveSeat(
|
|
213
|
+
{ agent: "", sessionId: "" },
|
|
214
|
+
{ registry: FIXTURE, env: { HEYSPEX_COO_WINDOW: "1" } },
|
|
215
|
+
);
|
|
216
|
+
check(
|
|
217
|
+
s5 && s5.id === "coo" && s5.resolvedVia === "env:HEYSPEX_COO_WINDOW",
|
|
218
|
+
"HEYSPEX_COO_WINDOW=1 → coo",
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
// 6. HEYSPEX_SEAT override beats agent-default.
|
|
222
|
+
const s6 = resolveSeat(
|
|
223
|
+
{ agent: "Claude Code", sessionId: "s" },
|
|
224
|
+
{ registry: FIXTURE, env: { HEYSPEX_SEAT: "critic" } },
|
|
225
|
+
);
|
|
226
|
+
check(
|
|
227
|
+
s6 && s6.id === "critic" && s6.resolvedVia === "env:HEYSPEX_SEAT",
|
|
228
|
+
"HEYSPEX_SEAT=critic overrides agent-default",
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
// 7. Unknown external caller → null.
|
|
232
|
+
const s7 = resolveSeat(
|
|
233
|
+
{ agent: "External agent", sessionId: "x" },
|
|
234
|
+
{ registry: FIXTURE, env: {} },
|
|
235
|
+
);
|
|
236
|
+
check(s7 === null, "unknown external agent + empty env → null");
|
|
237
|
+
|
|
238
|
+
// 8. Inactive seat via HEYSPEX_SEAT → null (only active seats inject).
|
|
239
|
+
const s8 = resolveSeat(
|
|
240
|
+
{ agent: "", sessionId: "" },
|
|
241
|
+
{ registry: FIXTURE, env: { HEYSPEX_SEAT: "software-architect" } },
|
|
242
|
+
);
|
|
243
|
+
check(s8 === null, "inactive seat (software-architect) → null");
|
|
244
|
+
|
|
245
|
+
// 9. Null registry → null.
|
|
246
|
+
check(
|
|
247
|
+
resolveSeat({ agent: "Claude Code" }, { registry: null, env: {} }) === null,
|
|
248
|
+
"null registry → null",
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
// 10. formatSeatBanner renders the seat's OWN title + mandate (Builder ≠ COO).
|
|
252
|
+
const builderSeat = FIXTURE.seats.find((s) => s.id === "build");
|
|
253
|
+
check(
|
|
254
|
+
formatSeatBanner(builderSeat) === "DEV-FACTORY SEAT: Builder — builder mandate",
|
|
255
|
+
"formatSeatBanner renders the seat's own title + mandate",
|
|
256
|
+
);
|
|
257
|
+
|
|
258
|
+
// 11. formatSeatBanner on null / titleless → "" (inject nothing).
|
|
259
|
+
check(
|
|
260
|
+
formatSeatBanner(null) === "" && formatSeatBanner({ mandate: "x" }) === "",
|
|
261
|
+
"formatSeatBanner(null / titleless) → empty string",
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
if (failures.length > 0) {
|
|
265
|
+
console.error(`seat-identity --self-test: ${failures.length} FAILED:`);
|
|
266
|
+
for (const f of failures) console.error(` ✗ ${f}`);
|
|
267
|
+
process.exit(1);
|
|
268
|
+
}
|
|
269
|
+
console.log("seat-identity --self-test: all 11 cases passed");
|
|
270
|
+
process.exit(0);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// isMain guard (mirrors dev-factory/lib/run-events.mjs): without it, any script that
|
|
274
|
+
// imports this module while itself being run with --self-test (e.g. `node
|
|
275
|
+
// coo-dispatcher.mjs --self-test` after HS-OJ5J wired seat resolution into the
|
|
276
|
+
// dispatcher) would have ITS OWN self-test hijacked by this module's side effect on
|
|
277
|
+
// import, since `process.argv` is shared across the whole process.
|
|
278
|
+
const isMain = process.argv[1]?.replaceAll("\\", "/").endsWith("/seat-identity.mjs");
|
|
279
|
+
if (isMain && process.argv.includes("--self-test")) {
|
|
280
|
+
selfTest();
|
|
281
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HeySpex settings MCP tools — the agent face over the settings core
|
|
3
|
+
* (config-settings.mjs), mounted in cli.mjs as:
|
|
4
|
+
* - heyspex_settings_list (read — open)
|
|
5
|
+
* - heyspex_settings_get (read — open)
|
|
6
|
+
* - heyspex_settings_set (WRITE — permission-gated two-step confirm)
|
|
7
|
+
*
|
|
8
|
+
* WRITE-PERMISSION GUARDRAIL (owner directive 2026-07-11, documented in
|
|
9
|
+
* specs/protocols/agent-settings-protocol.md): a settings write is a protected
|
|
10
|
+
* class. `heyspex_settings_set` without `confirm_token` never writes — it records
|
|
11
|
+
* the proposed change and returns `confirmation_required` plus a token; the agent
|
|
12
|
+
* must show the user the exact change (key, current → proposed) and obtain the
|
|
13
|
+
* user's explicit approval IN THEIR OWN WORDS before calling again with the token.
|
|
14
|
+
* This holds regardless of `agent_behaviors.ask_forgiveness_not_permission` — that
|
|
15
|
+
* switch governs autonomous behaviors, never its own controls. A PreToolUse gate
|
|
16
|
+
* (scripts/claude-hooks/settings-write-gate.mjs) additionally routes the confirm
|
|
17
|
+
* call through the harness permission prompt in Claude Code.
|
|
18
|
+
*
|
|
19
|
+
* Pattern source: procedure-tools.mjs (pure-logic execute* functions; cli.mjs wraps).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
listConfigLeaves,
|
|
24
|
+
getConfigLeaf,
|
|
25
|
+
requestSettingsWrite,
|
|
26
|
+
confirmSettingsWrite,
|
|
27
|
+
validateSettingKey,
|
|
28
|
+
CONFIG_REL,
|
|
29
|
+
} from './config-settings.mjs';
|
|
30
|
+
|
|
31
|
+
const MCP_FACE = 'mcp';
|
|
32
|
+
|
|
33
|
+
export function executeSettingsList(vaultRoot, args = {}) {
|
|
34
|
+
const prefix = typeof args.prefix === 'string' ? args.prefix.trim() : '';
|
|
35
|
+
let settings = listConfigLeaves(vaultRoot);
|
|
36
|
+
if (prefix) {
|
|
37
|
+
settings = settings.filter((s) => s.key === prefix || s.key.startsWith(prefix + '.'));
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
ok: true,
|
|
41
|
+
config_file: CONFIG_REL.replace(/\\/g, '/'),
|
|
42
|
+
count: settings.length,
|
|
43
|
+
settings: settings.map(({ key, value }) => ({ key, value })),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function executeSettingsGet(vaultRoot, args = {}) {
|
|
48
|
+
const key = typeof args.key === 'string' ? args.key.trim() : '';
|
|
49
|
+
const check = validateSettingKey(key);
|
|
50
|
+
if (!check.ok) return { ok: false, error: 'invalid_key', message: check.reason };
|
|
51
|
+
const leaf = getConfigLeaf(vaultRoot, key);
|
|
52
|
+
return {
|
|
53
|
+
ok: true,
|
|
54
|
+
key,
|
|
55
|
+
found: leaf.found,
|
|
56
|
+
value: leaf.found ? leaf.value : null,
|
|
57
|
+
...(leaf.found ? {} : { note: 'Key absent from .heyspex/config.yaml — consumers fall back to their documented default.' }),
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Coerce the `value` argument (which may arrive as a JSON scalar or a string). */
|
|
62
|
+
function coerceValue(raw) {
|
|
63
|
+
if (raw === null || typeof raw === 'boolean' || typeof raw === 'number') return raw;
|
|
64
|
+
if (typeof raw !== 'string') return undefined;
|
|
65
|
+
const v = raw.trim();
|
|
66
|
+
if (v === 'null') return null;
|
|
67
|
+
if (v === 'true') return true;
|
|
68
|
+
if (v === 'false') return false;
|
|
69
|
+
if (/^-?\d+(\.\d+)?$/.test(v)) return Number(v);
|
|
70
|
+
return raw;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function executeSettingsSet(vaultRoot, args = {}) {
|
|
74
|
+
const actor = typeof args.actor === 'string' && args.actor.trim() ? args.actor.trim() : 'agent (MCP)';
|
|
75
|
+
|
|
76
|
+
// Step 2 — confirm an already-reviewed change.
|
|
77
|
+
if (typeof args.confirm_token === 'string' && args.confirm_token.trim()) {
|
|
78
|
+
const result = confirmSettingsWrite(vaultRoot, {
|
|
79
|
+
token: args.confirm_token.trim(),
|
|
80
|
+
actor,
|
|
81
|
+
face: MCP_FACE,
|
|
82
|
+
});
|
|
83
|
+
if (!result.ok) return result;
|
|
84
|
+
return {
|
|
85
|
+
ok: true,
|
|
86
|
+
status: 'applied',
|
|
87
|
+
key: result.key,
|
|
88
|
+
previous: result.previous,
|
|
89
|
+
value: result.value,
|
|
90
|
+
created: result.created,
|
|
91
|
+
note: 'Setting applied and audited to .heyspex/settings-history.jsonl.',
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Step 1 — record the proposed change; nothing is written yet.
|
|
96
|
+
const key = typeof args.key === 'string' ? args.key.trim() : '';
|
|
97
|
+
const value = coerceValue(args.value);
|
|
98
|
+
if (value === undefined) {
|
|
99
|
+
return { ok: false, error: 'invalid_value', message: 'value must be a scalar (string, number, boolean, or null)' };
|
|
100
|
+
}
|
|
101
|
+
const req = requestSettingsWrite(vaultRoot, { key, value, actor, face: MCP_FACE });
|
|
102
|
+
if (!req.ok) return req;
|
|
103
|
+
return {
|
|
104
|
+
ok: true,
|
|
105
|
+
status: 'confirmation_required',
|
|
106
|
+
key: req.key,
|
|
107
|
+
current_value: req.current_found ? req.current_value : null,
|
|
108
|
+
current_found: req.current_found,
|
|
109
|
+
proposed_value: req.value,
|
|
110
|
+
confirm_token: req.token,
|
|
111
|
+
expires_at: req.expires_at,
|
|
112
|
+
instructions:
|
|
113
|
+
'NOTHING WAS WRITTEN. Settings writes require EXPLICIT user permission (this is independent of ' +
|
|
114
|
+
'ask_forgiveness_not_permission — that switch never governs its own controls). Show the user the exact ' +
|
|
115
|
+
`change — "${req.key}: ${JSON.stringify(req.current_found ? req.current_value : null)} → ${JSON.stringify(req.value)}" — ` +
|
|
116
|
+
'and ask for approval. ONLY after the user explicitly approves, call heyspex_settings_set again with ' +
|
|
117
|
+
'confirm_token to apply. If the user declines, drop it — do not retry or edit .heyspex/config.yaml directly.',
|
|
118
|
+
};
|
|
119
|
+
}
|