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.
Files changed (104) hide show
  1. package/README.md +157 -0
  2. package/bin/hs.mjs +1028 -0
  3. package/bin/postinstall.mjs +88 -0
  4. package/dev-factory/lib/fleet-readiness.mjs +274 -0
  5. package/dev-factory/lib/fleet-state.mjs +250 -0
  6. package/dev-factory/lib/sufficiency-score.mjs +441 -0
  7. package/dist/cli/hs-check.mjs +747 -0
  8. package/dist/cli/hs-claim.mjs +541 -0
  9. package/dist/cli/hs-connect.mjs +3436 -0
  10. package/dist/cli/hs-defer.mjs +8455 -0
  11. package/dist/cli/hs-env.mjs +36845 -0
  12. package/dist/cli/hs-init.mjs +1145 -0
  13. package/dist/cli/hs-install.mjs +1390 -0
  14. package/dist/cli/hs-join.mjs +38553 -0
  15. package/dist/cli/hs-vault-pull.mjs +7782 -0
  16. package/dist/cli/hs-vault.mjs +4166 -0
  17. package/dist/cli/hs-workspace.mjs +2898 -0
  18. package/dist/cli-templates/customer-vault/specs/entity-model.md +1735 -0
  19. package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +531 -0
  20. package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +48 -0
  21. package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +548 -0
  22. package/docs/mcp-client-config.md +200 -0
  23. package/package.json +124 -0
  24. package/scripts/check-mcp-freshness.mjs +379 -0
  25. package/scripts/dev-server-status.mjs +722 -0
  26. package/scripts/gateway-skills-lib.mjs +421 -0
  27. package/scripts/lib/console-url-resolver.mjs +152 -0
  28. package/scripts/lib/dev-mode.mjs +369 -0
  29. package/scripts/lib/hs-link.mjs +154 -0
  30. package/scripts/lib/hs-source-commit.mjs +207 -0
  31. package/scripts/lib/hs-source-log.mjs +366 -0
  32. package/scripts/lib/hs-source-preflight.mjs +402 -0
  33. package/scripts/lib/hs-source-push.mjs +307 -0
  34. package/scripts/lib/hs-source-secret-scan.mjs +263 -0
  35. package/scripts/lib/standalone-doctor.mjs +89 -0
  36. package/scripts/vault-id-lib.mjs +173 -0
  37. package/vendor/heyspex-mcp/alignment-gate.mjs +1347 -0
  38. package/vendor/heyspex-mcp/blocker-resolver.mjs +891 -0
  39. package/vendor/heyspex-mcp/breakdown-ladder.mjs +309 -0
  40. package/vendor/heyspex-mcp/breakdown-source.mjs +236 -0
  41. package/vendor/heyspex-mcp/breakdown-writer.mjs +524 -0
  42. package/vendor/heyspex-mcp/brief-observability.mjs +67 -0
  43. package/vendor/heyspex-mcp/canvas-inbox.mjs +130 -0
  44. package/vendor/heyspex-mcp/capture-active-rehydrate.mjs +76 -0
  45. package/vendor/heyspex-mcp/capture-buffer-patches.mjs +147 -0
  46. package/vendor/heyspex-mcp/capture-channel.mjs +577 -0
  47. package/vendor/heyspex-mcp/capture-console-client.mjs +290 -0
  48. package/vendor/heyspex-mcp/capture-focus.mjs +1366 -0
  49. package/vendor/heyspex-mcp/capture-kind-suggest.mjs +121 -0
  50. package/vendor/heyspex-mcp/capture-outbox.mjs +239 -0
  51. package/vendor/heyspex-mcp/capture-stage-gate.mjs +160 -0
  52. package/vendor/heyspex-mcp/cli.mjs +15754 -0
  53. package/vendor/heyspex-mcp/config-settings.mjs +419 -0
  54. package/vendor/heyspex-mcp/console-fetch.mjs +342 -0
  55. package/vendor/heyspex-mcp/defer-spec.mjs +527 -0
  56. package/vendor/heyspex-mcp/deferred-reclaim.mjs +284 -0
  57. package/vendor/heyspex-mcp/design-tools.mjs +449 -0
  58. package/vendor/heyspex-mcp/dev-factory-bridge.mjs +66 -0
  59. package/vendor/heyspex-mcp/diagnose-skeleton.mjs +114 -0
  60. package/vendor/heyspex-mcp/fleet-queue-tools.mjs +241 -0
  61. package/vendor/heyspex-mcp/hooks-engine.mjs +399 -0
  62. package/vendor/heyspex-mcp/login-handoff.mjs +438 -0
  63. package/vendor/heyspex-mcp/mcp-subprocess.mjs +136 -0
  64. package/vendor/heyspex-mcp/mvp-mode.mjs +101 -0
  65. package/vendor/heyspex-mcp/next-resolution.mjs +240 -0
  66. package/vendor/heyspex-mcp/next-review.mjs +548 -0
  67. package/vendor/heyspex-mcp/peer-presence.mjs +430 -0
  68. package/vendor/heyspex-mcp/plan-ground-conflicts.mjs +321 -0
  69. package/vendor/heyspex-mcp/plan-ground.mjs +2001 -0
  70. package/vendor/heyspex-mcp/plan-ingest.mjs +476 -0
  71. package/vendor/heyspex-mcp/plan-push.mjs +1826 -0
  72. package/vendor/heyspex-mcp/plan-review.mjs +1419 -0
  73. package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
  74. package/vendor/heyspex-mcp/procedure-tools.mjs +970 -0
  75. package/vendor/heyspex-mcp/promote-capture.mjs +1280 -0
  76. package/vendor/heyspex-mcp/promote-diagnose.mjs +187 -0
  77. package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +338 -0
  78. package/vendor/heyspex-mcp/promote-research.mjs +1553 -0
  79. package/vendor/heyspex-mcp/promotion-helpers.mjs +280 -0
  80. package/vendor/heyspex-mcp/read-path.mjs +138 -0
  81. package/vendor/heyspex-mcp/recipe-tools.mjs +258 -0
  82. package/vendor/heyspex-mcp/regen-tasks.mjs +326 -0
  83. package/vendor/heyspex-mcp/register-spec.mjs +417 -0
  84. package/vendor/heyspex-mcp/research-focus.mjs +476 -0
  85. package/vendor/heyspex-mcp/research-tools.mjs +109 -0
  86. package/vendor/heyspex-mcp/research-type-suggest.mjs +112 -0
  87. package/vendor/heyspex-mcp/resolution-focus.mjs +45 -0
  88. package/vendor/heyspex-mcp/review-focus.mjs +55 -0
  89. package/vendor/heyspex-mcp/review-rollup.mjs +47 -0
  90. package/vendor/heyspex-mcp/run-front.mjs +60 -0
  91. package/vendor/heyspex-mcp/scheduler.mjs +813 -0
  92. package/vendor/heyspex-mcp/seat-identity.mjs +281 -0
  93. package/vendor/heyspex-mcp/settings-tools.mjs +119 -0
  94. package/vendor/heyspex-mcp/spec-id-lib.mjs +790 -0
  95. package/vendor/heyspex-mcp/split-research.mjs +832 -0
  96. package/vendor/heyspex-mcp/submit-for-review.mjs +190 -0
  97. package/vendor/heyspex-mcp/summary-contract.mjs +162 -0
  98. package/vendor/heyspex-mcp/test-helpers.mjs +43 -0
  99. package/vendor/heyspex-mcp/validate-linkage.mjs +574 -0
  100. package/vendor/heyspex-mcp/vault-resolve.mjs +297 -0
  101. package/vendor/heyspex-mcp/vault-settings.mjs +87 -0
  102. package/vendor/heyspex-mcp/verify-reproduction.mjs +314 -0
  103. package/vendor/heyspex-mcp/why-now.mjs +64 -0
  104. package/vendor/heyspex-mcp/yaml-scalar.mjs +34 -0
@@ -0,0 +1,419 @@
1
+ /**
2
+ * HeySpex settings core — the ONE module that reads and writes scalar values in
3
+ * `.heyspex/config.yaml` by dotted key (e.g. `agent_behaviors.capture_on_idea.enabled`).
4
+ *
5
+ * Three-faces principle ([[capability-three-faces-no-per-capability-slash]]): this is
6
+ * the core; the faces delegate to it —
7
+ * - `hs settings list|get|set` (CLI face, scripts/hs-settings.mjs)
8
+ * - `heyspex_settings_list|get|set` (MCP face, settings-tools.mjs → cli.mjs)
9
+ * - `GET/PUT /api/agent-behaviors` (console face, spex-console lib delegates here)
10
+ * The read-only hook loader (`scripts/claude-hooks/_guardrail-loader.mjs`) keeps its
11
+ * own tiny reader on purpose — hooks predate this module and must stay standalone —
12
+ * but both use the identical indent-scoped scan so the semantics match.
13
+ *
14
+ * WRITE-PERMISSION GUARDRAIL (owner directive 2026-07-11): settings WRITES are a
15
+ * protected class. Reads are open; no agent or MCP/agentic process may change a
16
+ * setting without EXPLICIT user permission — regardless of the global
17
+ * `ask_forgiveness_not_permission` switch (that switch governs behaviors, not its
18
+ * own controls). This module supplies the two-step confirm store
19
+ * (`requestSettingsWrite` → `confirmSettingsWrite`) that the MCP face uses, and the
20
+ * audit trail (`.heyspex/settings-history.jsonl`) every face appends to. The
21
+ * PreToolUse gate (`scripts/claude-hooks/settings-write-gate.mjs`) enforces the same
22
+ * rule on direct file edits. Documented in specs/protocols/agent-settings-protocol.md.
23
+ *
24
+ * DEPENDENCY-FREE: Node stdlib only (no js-yaml — same rationale as yaml-scalar.mjs).
25
+ * Comment lines and unrelated content in config.yaml are preserved byte-for-byte;
26
+ * writes are surgical single-line rewrites/inserts.
27
+ */
28
+
29
+ import fs from 'node:fs';
30
+ import path from 'node:path';
31
+ import crypto from 'node:crypto';
32
+
33
+ import { yamlScalar } from './yaml-scalar.mjs';
34
+
35
+ export const CONFIG_REL = path.join('.heyspex', 'config.yaml');
36
+ export const SETTINGS_AUDIT_REL = path.join('.heyspex', 'settings-history.jsonl');
37
+ export const PENDING_WRITE_REL = path.join('.heyspex', 'settings-write-pending.json');
38
+
39
+ /** How long a requested-but-unconfirmed write stays valid. */
40
+ export const PENDING_WRITE_TTL_MS = 10 * 60 * 1000;
41
+
42
+ const KEY_RE = /^[A-Za-z0-9_]+(\.[A-Za-z0-9_]+){0,5}$/;
43
+
44
+ export function configPath(repoRoot, options = {}) {
45
+ return options.configPath || path.join(repoRoot, CONFIG_REL);
46
+ }
47
+
48
+ /** Validate a dotted settings key. Returns { ok } or { ok:false, reason }. */
49
+ export function validateSettingKey(key) {
50
+ if (typeof key !== 'string' || !key.trim()) {
51
+ return { ok: false, reason: 'key must be a non-empty string' };
52
+ }
53
+ if (!KEY_RE.test(key.trim())) {
54
+ return {
55
+ ok: false,
56
+ reason:
57
+ 'key must be dotted segments of letters/digits/underscores, at most 6 levels (e.g. agent_behaviors.capture_on_idea.enabled)',
58
+ };
59
+ }
60
+ return { ok: true };
61
+ }
62
+
63
+ /**
64
+ * Parse a raw YAML scalar string into a JS value (boolean/number/null/string).
65
+ * Quoted strings are unquoted; anything else stays a string.
66
+ */
67
+ export function parseScalar(raw) {
68
+ if (raw === undefined || raw === null) return null;
69
+ const v = String(raw).trim();
70
+ if (v === '' || v === 'null' || v === '~') return null;
71
+ const low = v.toLowerCase();
72
+ if (low === 'true' || low === 'yes') return true;
73
+ if (low === 'false' || low === 'no') return false;
74
+ if (/^-?\d+(\.\d+)?$/.test(v)) return Number(v);
75
+ const m = v.match(/^"(.*)"$/) || v.match(/^'(.*)'$/);
76
+ if (m) return m[1];
77
+ return v;
78
+ }
79
+
80
+ /** Serialize a JS scalar for a YAML `key: value` line. */
81
+ export function serializeScalar(value) {
82
+ if (value === null || value === undefined) return 'null';
83
+ if (typeof value === 'boolean' || typeof value === 'number') return String(value);
84
+ return yamlScalar(value);
85
+ }
86
+
87
+ function readRaw(repoRoot, options = {}) {
88
+ try {
89
+ return fs.readFileSync(configPath(repoRoot, options), 'utf8');
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Walk config.yaml lines maintaining an indent-scoped key path (the same scan the
97
+ * hook loader uses). Calls `visit({ pathHere, key, indent, rawValue, lineIdx })` for
98
+ * every `key:` line. Never throws.
99
+ */
100
+ function scanLines(lines, visit) {
101
+ const stack = []; // { key, indent }
102
+ for (let i = 0; i < lines.length; i++) {
103
+ const line = lines[i];
104
+ const trimmed = line.trim();
105
+ if (!trimmed || trimmed.startsWith('#')) continue;
106
+ const m = trimmed.match(/^([A-Za-z0-9_]+)\s*:(.*)$/);
107
+ if (!m) continue;
108
+ const indent = line.length - line.trimStart().length;
109
+ while (stack.length && stack[stack.length - 1].indent >= indent) stack.pop();
110
+ stack.push({ key: m[1], indent });
111
+ const stop = visit({
112
+ pathHere: stack.map((f) => f.key),
113
+ key: m[1],
114
+ indent,
115
+ rawValue: stripTrailingComment(m[2]),
116
+ lineIdx: i,
117
+ });
118
+ if (stop === true) return;
119
+ }
120
+ }
121
+
122
+ /** Drop a trailing ` # comment` from a scalar value (quote-aware enough for our file). */
123
+ function stripTrailingComment(raw) {
124
+ const v = String(raw ?? '').trim();
125
+ if (!v) return v;
126
+ if (v.startsWith('"') || v.startsWith("'")) return v; // keep quoted values intact
127
+ const hash = v.indexOf(' #');
128
+ return hash === -1 ? v : v.slice(0, hash).trim();
129
+ }
130
+
131
+ /**
132
+ * All scalar leaves as [{ key, value, raw }], dotted-key sorted by file order.
133
+ * A `key:` line with an empty value followed by deeper lines is a block, not a leaf.
134
+ */
135
+ export function listConfigLeaves(repoRoot, options = {}) {
136
+ const raw = readRaw(repoRoot, options);
137
+ if (raw === null) return [];
138
+ const lines = raw.split(/\r?\n/);
139
+ const entries = [];
140
+ scanLines(lines, ({ pathHere, rawValue, lineIdx, indent }) => {
141
+ if (rawValue !== '') {
142
+ entries.push({ key: pathHere.join('.'), raw: rawValue, value: parseScalar(rawValue) });
143
+ return;
144
+ }
145
+ // Empty value: block header unless nothing deeper follows (then it's a null leaf).
146
+ for (let j = lineIdx + 1; j < lines.length; j++) {
147
+ const t = lines[j].trim();
148
+ if (!t || t.startsWith('#')) continue;
149
+ const childIndent = lines[j].length - lines[j].trimStart().length;
150
+ if (childIndent > indent) return; // block header — skip
151
+ break;
152
+ }
153
+ entries.push({ key: pathHere.join('.'), raw: '', value: null });
154
+ });
155
+ return entries;
156
+ }
157
+
158
+ /**
159
+ * Read one scalar leaf. Returns { key, found, raw, value }.
160
+ * `found:false` when the path is absent or the config file is unreadable.
161
+ */
162
+ export function getConfigLeaf(repoRoot, key, options = {}) {
163
+ const check = validateSettingKey(key);
164
+ if (!check.ok) return { key, found: false, raw: null, value: null, error: check.reason };
165
+ const raw = readRaw(repoRoot, options);
166
+ if (raw === null) return { key, found: false, raw: null, value: null };
167
+ const segments = key.trim().split('.');
168
+ let hit = null;
169
+ scanLines(raw.split(/\r?\n/), ({ pathHere, rawValue }) => {
170
+ if (pathHere.length === segments.length && pathHere.every((k, i) => k === segments[i])) {
171
+ hit = rawValue;
172
+ return true;
173
+ }
174
+ return false;
175
+ });
176
+ if (hit === null) return { key, found: false, raw: null, value: null };
177
+ return { key, found: true, raw: hit, value: parseScalar(hit) };
178
+ }
179
+
180
+ /** Append one row to the settings audit log. Fail-safe: never throws. */
181
+ export function appendSettingsAudit(repoRoot, row) {
182
+ try {
183
+ const full = {
184
+ timestamp: new Date().toISOString(),
185
+ event_kind: 'settings.write',
186
+ actor: 'unknown',
187
+ face: 'unknown',
188
+ ...row,
189
+ };
190
+ fs.appendFileSync(path.join(repoRoot, SETTINGS_AUDIT_REL), JSON.stringify(full) + '\n');
191
+ return true;
192
+ } catch {
193
+ return false;
194
+ }
195
+ }
196
+
197
+ /**
198
+ * Surgically set one scalar leaf. Rewrites the existing `key:` line in place, or
199
+ * inserts the missing key (and any missing intermediate blocks) at the correct
200
+ * indent, after the last line of the deepest existing ancestor's block. Preserves
201
+ * every other line (comments included) byte-for-byte.
202
+ *
203
+ * Returns { ok, key, previous, value, created } or { ok:false, error }.
204
+ * Appends a `settings.write` audit row on success (meta: { actor, face }).
205
+ */
206
+ export function setConfigLeaf(repoRoot, key, value, meta = {}, options = {}) {
207
+ const check = validateSettingKey(key);
208
+ if (!check.ok) return { ok: false, error: 'invalid_key', message: check.reason };
209
+ if (value !== null && !['string', 'number', 'boolean'].includes(typeof value)) {
210
+ return { ok: false, error: 'invalid_value', message: 'value must be a scalar (string, number, boolean, or null)' };
211
+ }
212
+
213
+ const file = configPath(repoRoot, options);
214
+ const raw = readRaw(repoRoot, options);
215
+ if (raw === null) {
216
+ return { ok: false, error: 'config_unreadable', message: `${file} not found or unreadable` };
217
+ }
218
+
219
+ const eol = raw.includes('\r\n') ? '\r\n' : '\n';
220
+ const lines = raw.split(/\r?\n/);
221
+ const segments = key.trim().split('.');
222
+
223
+ // Locate the target line, the deepest existing ancestor, and (for insertion) the
224
+ // end of that ancestor's block.
225
+ let targetLineIdx = -1;
226
+ let targetRaw = null;
227
+ let ancestorDepth = 0; // how many leading segments already exist as a chain
228
+ let ancestorLineIdx = -1;
229
+ let ancestorIndent = -1;
230
+ scanLines(lines, ({ pathHere, rawValue, lineIdx, indent }) => {
231
+ const depth = pathHere.length;
232
+ if (depth <= segments.length && pathHere.every((k, i) => k === segments[i])) {
233
+ if (depth === segments.length) {
234
+ targetLineIdx = lineIdx;
235
+ targetRaw = rawValue;
236
+ return true;
237
+ }
238
+ if (depth > ancestorDepth) {
239
+ ancestorDepth = depth;
240
+ ancestorLineIdx = lineIdx;
241
+ ancestorIndent = indent;
242
+ }
243
+ }
244
+ return false;
245
+ });
246
+
247
+ const serialized = serializeScalar(value);
248
+
249
+ if (targetLineIdx >= 0) {
250
+ // The target exists. Refuse to overwrite a block header (a key with children).
251
+ if (targetRaw === '') {
252
+ for (let j = targetLineIdx + 1; j < lines.length; j++) {
253
+ const t = lines[j].trim();
254
+ if (!t || t.startsWith('#')) continue;
255
+ const childIndent = lines[j].length - lines[j].trimStart().length;
256
+ const targetIndent = lines[targetLineIdx].length - lines[targetLineIdx].trimStart().length;
257
+ if (childIndent > targetIndent) {
258
+ return {
259
+ ok: false,
260
+ error: 'not_scalar',
261
+ message: `"${key}" is a block with nested keys, not a scalar setting — set one of its leaves instead`,
262
+ };
263
+ }
264
+ break;
265
+ }
266
+ }
267
+ const line = lines[targetLineIdx];
268
+ const colonIdx = line.indexOf(':');
269
+ // Preserve any trailing comment on the same line.
270
+ const after = line.slice(colonIdx + 1);
271
+ const commentMatch = after.match(/\s#\s.*$/);
272
+ const trailingComment = commentMatch && !stripTrailingComment(after).startsWith('"') ? commentMatch[0] : '';
273
+ lines[targetLineIdx] = `${line.slice(0, colonIdx + 1)} ${serialized}${trailingComment}`;
274
+ const previous = parseScalar(targetRaw);
275
+ try {
276
+ fs.writeFileSync(file, lines.join(eol), 'utf8');
277
+ } catch (err) {
278
+ return { ok: false, error: 'write_failed', message: String(err?.message ?? err) };
279
+ }
280
+ appendSettingsAudit(repoRoot, {
281
+ key,
282
+ previous,
283
+ value,
284
+ actor: meta.actor ?? 'unknown',
285
+ face: meta.face ?? 'unknown',
286
+ });
287
+ return { ok: true, key, previous, value, created: false };
288
+ }
289
+
290
+ // Insert. Build the missing chain below the deepest existing ancestor.
291
+ // Indent convention in this file: 2 spaces per nesting level.
292
+ let insertAfter;
293
+ if (ancestorLineIdx >= 0) {
294
+ // Find the last line inside the ancestor's block (skip trailing blank/comment runs).
295
+ insertAfter = ancestorLineIdx;
296
+ for (let j = ancestorLineIdx + 1; j < lines.length; j++) {
297
+ const t = lines[j].trim();
298
+ if (!t || t.startsWith('#')) continue;
299
+ const lineIndent = lines[j].length - lines[j].trimStart().length;
300
+ if (lineIndent <= ancestorIndent) break;
301
+ insertAfter = j;
302
+ }
303
+ } else {
304
+ // No ancestor at all — append at end of file (after trimming trailing blanks).
305
+ insertAfter = lines.length - 1;
306
+ while (insertAfter > 0 && lines[insertAfter].trim() === '') insertAfter--;
307
+ }
308
+
309
+ const newLines = [];
310
+ for (let depth = ancestorDepth; depth < segments.length; depth++) {
311
+ const indent = ' '.repeat(2 * depth);
312
+ const isLeaf = depth === segments.length - 1;
313
+ newLines.push(`${indent}${segments[depth]}:${isLeaf ? ` ${serialized}` : ''}`);
314
+ }
315
+ lines.splice(insertAfter + 1, 0, ...newLines);
316
+ try {
317
+ fs.writeFileSync(file, lines.join(eol), 'utf8');
318
+ } catch (err) {
319
+ return { ok: false, error: 'write_failed', message: String(err?.message ?? err) };
320
+ }
321
+ appendSettingsAudit(repoRoot, {
322
+ key,
323
+ previous: null,
324
+ value,
325
+ actor: meta.actor ?? 'unknown',
326
+ face: meta.face ?? 'unknown',
327
+ });
328
+ return { ok: true, key, previous: null, value, created: true };
329
+ }
330
+
331
+ // ── two-step confirm store (the MCP write-permission handshake) ─────────────
332
+ //
333
+ // An agentic settings write is a two-call flow:
334
+ // 1. requestSettingsWrite → records the proposed change and returns a token; the
335
+ // caller MUST show the user the exact change and get explicit approval.
336
+ // 2. confirmSettingsWrite(token) → applies the change only while the token is
337
+ // valid (10-minute expiry, single pending slot — a new request replaces it).
338
+ // The token proves call-two refers to the exact reviewed change; the PreToolUse
339
+ // gate additionally routes the confirm call through the harness permission prompt.
340
+
341
+ function pendingPath(repoRoot) {
342
+ return path.join(repoRoot, PENDING_WRITE_REL);
343
+ }
344
+
345
+ export function peekPendingWrite(repoRoot) {
346
+ try {
347
+ const raw = fs.readFileSync(pendingPath(repoRoot), 'utf8');
348
+ const parsed = JSON.parse(raw);
349
+ if (!parsed || typeof parsed !== 'object' || !parsed.token) return null;
350
+ if (Date.parse(parsed.expires_at) < Date.now()) return null; // expired
351
+ return parsed;
352
+ } catch {
353
+ return null;
354
+ }
355
+ }
356
+
357
+ export function requestSettingsWrite(repoRoot, { key, value, actor, face } = {}, options = {}) {
358
+ const check = validateSettingKey(key);
359
+ if (!check.ok) return { ok: false, error: 'invalid_key', message: check.reason };
360
+ if (value !== null && !['string', 'number', 'boolean'].includes(typeof value)) {
361
+ return { ok: false, error: 'invalid_value', message: 'value must be a scalar (string, number, boolean, or null)' };
362
+ }
363
+ const current = getConfigLeaf(repoRoot, key, options);
364
+ const token = crypto.randomBytes(6).toString('hex');
365
+ const record = {
366
+ token,
367
+ key,
368
+ value,
369
+ current_value: current.found ? current.value : null,
370
+ current_found: current.found,
371
+ requested_by: actor ?? 'unknown',
372
+ face: face ?? 'unknown',
373
+ requested_at: new Date().toISOString(),
374
+ expires_at: new Date(Date.now() + PENDING_WRITE_TTL_MS).toISOString(),
375
+ };
376
+ try {
377
+ fs.writeFileSync(pendingPath(repoRoot), JSON.stringify(record, null, 2) + '\n', 'utf8');
378
+ } catch (err) {
379
+ return { ok: false, error: 'pending_write_failed', message: String(err?.message ?? err) };
380
+ }
381
+ appendSettingsAudit(repoRoot, {
382
+ event_kind: 'settings.write_requested',
383
+ key,
384
+ previous: record.current_value,
385
+ value,
386
+ actor: actor ?? 'unknown',
387
+ face: face ?? 'unknown',
388
+ });
389
+ return { ok: true, ...record };
390
+ }
391
+
392
+ export function confirmSettingsWrite(repoRoot, { token, actor, face } = {}, options = {}) {
393
+ if (typeof token !== 'string' || !token.trim()) {
394
+ return { ok: false, error: 'missing_token', message: 'confirm_token is required to apply a settings write' };
395
+ }
396
+ const pending = peekPendingWrite(repoRoot);
397
+ if (!pending) {
398
+ return {
399
+ ok: false,
400
+ error: 'no_pending_write',
401
+ message: 'No valid pending settings write (never requested, already applied, or expired after 10 minutes). Request again.',
402
+ };
403
+ }
404
+ if (pending.token !== token.trim()) {
405
+ return { ok: false, error: 'token_mismatch', message: 'confirm_token does not match the pending settings write' };
406
+ }
407
+ const result = setConfigLeaf(repoRoot, pending.key, pending.value, {
408
+ actor: actor ?? pending.requested_by,
409
+ face: face ?? pending.face,
410
+ }, options);
411
+ if (result.ok) {
412
+ try {
413
+ fs.unlinkSync(pendingPath(repoRoot));
414
+ } catch {
415
+ /* already gone — fine */
416
+ }
417
+ }
418
+ return result;
419
+ }