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,132 +1,132 @@
1
- // spexharness/src/gates/directive-obligation-gate.decide.mjs
2
- //
3
- // Pure decision logic for the directive-obligation-gate ([[HS-20SW-DRSM-DIAG-heyspex-capture-on-idea-and-workflow]]).
4
- // No stdin/stdout/process.exit and no fs: every input arrives as an argument, so the gate module
5
- // (./directive-obligation-gate.mjs) owns all I/O and a test drives these functions directly.
6
-
7
- import { extractShellTargets } from './_shell-write-targets.mjs';
8
- import { isProductCodePath } from '../../../scripts/lib/directive-obligations.mjs';
9
-
10
- const WRITE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'write_file', 'edit_file']);
11
- const SHELL_TOOLS = new Set(['Bash', 'PowerShell', 'Shell']);
12
- const SPAWN_TOOLS = new Set(['Agent', 'Task']);
13
-
14
- /** Sub-agent types that only read / plan / triage: spawning one never builds anything. */
15
- export const READ_ONLY_AGENT_TYPES = new Set([
16
- 'explore',
17
- 'plan',
18
- 'claude-code-guide',
19
- 'capture-triager',
20
- 'plan-grounder',
21
- 'research-synthesizer',
22
- 'resolution-triager',
23
- 'statusline-setup',
24
- ]);
25
-
26
- // Commands that build, install, serve or publish product code. Test runners are deliberately absent.
27
- const BUILD_CMD_RE = new RegExp(
28
- '(?:^|[\\s;&|(])(?:' +
29
- '(?:npm|pnpm|yarn|bun)\\s+(?:run\\s+)?(?:build|dev|start|install|i|add|ci|publish|deploy)\\b' +
30
- '|(?:npx\\s+)?(?:next|vite|webpack|turbo|esbuild)\\s+(?:build|dev)\\b' +
31
- '|dotnet\\s+(?:build|publish|run)\\b' +
32
- '|cargo\\s+(?:build|run|install)\\b' +
33
- '|make\\b' +
34
- '|vercel\\s+(?:deploy|--prod)\\b' +
35
- '|(?:claude|spexcode)\\s+(?:-p|--print)\\b' +
36
- ')',
37
- 'i',
38
- );
39
-
40
- /** Is this a heyspex_* MCP call (any spelling)? Those are never gated. */
41
- export function isHeyspexMcpTool(name) {
42
- return /(?:^|__)heyspex_/.test(String(name ?? ''));
43
- }
44
-
45
- function pathOf(toolInput) {
46
- return toolInput?.file_path ?? toolInput?.notebook_path ?? toolInput?.path ?? toolInput?.filePath ?? '';
47
- }
48
-
49
- /**
50
- * Classify one tool call into the surface this gate cares about.
51
- * @returns {{ kind: 'exempt'|'spawn'|'code_write'|'shell_build'|'shell_write', detail?: string }}
52
- */
53
- export function classifyToolCall({ toolName, toolInput }, { cwd, vaultRoot } = {}) {
54
- const name = String(toolName ?? '');
55
- if (!name || isHeyspexMcpTool(name)) return { kind: 'exempt' };
56
- const input = toolInput ?? {};
57
-
58
- if (SPAWN_TOOLS.has(name)) {
59
- const type = String(input.subagent_type ?? '').trim().toLowerCase();
60
- if (type && READ_ONLY_AGENT_TYPES.has(type)) return { kind: 'exempt' };
61
- return { kind: 'spawn', detail: type || 'general-purpose' };
62
- }
63
-
64
- if (WRITE_TOOLS.has(name)) {
65
- const target = pathOf(input);
66
- return isProductCodePath(target, { cwd, vaultRoot }) ? { kind: 'code_write', detail: String(target) } : { kind: 'exempt' };
67
- }
68
-
69
- if (SHELL_TOOLS.has(name)) {
70
- const command = String(input.command ?? '');
71
- const product = extractShellTargets(command).find((t) => isProductCodePath(t, { cwd, vaultRoot }));
72
- if (product) return { kind: 'shell_write', detail: product };
73
- if (BUILD_CMD_RE.test(command)) return { kind: 'shell_build', detail: command.trim().slice(0, 80) };
74
- return { kind: 'exempt' };
75
- }
76
-
77
- return { kind: 'exempt' };
78
- }
79
-
80
- const HOW_TO_DISCHARGE =
81
- 'Capture the owner\'s words first: call heyspex_update_capture (or heyspex_file_spec / the /hs-capture skill) with their exact ' +
82
- 'message, then retry. Reads, greps, git, test runs, vault and .heyspex writes and every heyspex_* tool stay open meanwhile.';
83
-
84
- /**
85
- * Rule 1: while a capture obligation is outstanding for this session, deny a sub-agent spawn, a
86
- * product-code write and a build/product-write shell command.
87
- * @returns {{ deny: boolean, reason?: string, rule?: string }}
88
- */
89
- export function decideObligation(call, { outstandingCount, cwd, vaultRoot }) {
90
- if (!outstandingCount) return { deny: false };
91
- const c = classifyToolCall(call, { cwd, vaultRoot });
92
- if (c.kind === 'exempt') return { deny: false };
93
- const what =
94
- c.kind === 'spawn' ? `spawn a ${c.detail} sub-agent`
95
- : c.kind === 'code_write' ? `write product code (${c.detail})`
96
- : c.kind === 'shell_write' ? `write product code from the shell (${c.detail})`
97
- : `run a build command (${c.detail})`;
98
- return {
99
- deny: true,
100
- rule: 'directive-obligation',
101
- reason:
102
- `HEYSPEX directive-obligation-gate: the capture_on_idea directive was issued for this session and the capture has not happened, so you may not ${what}. ` +
103
- `${HOW_TO_DISCHARGE} (Break-glass, owner only: HEYSPEX_DIRECTIVE_OBLIGATION_GATE_OFF=1. [[HS-20SW-DRSM-DIAG-heyspex-capture-on-idea-and-workflow]])`,
104
- };
105
- }
106
-
107
- /**
108
- * Rule 2 (claim-before-build): deny a build sub-agent spawn or a product-code write when the session
109
- * has no claimed spec, no on-deck capture and no waiver. Independent of the plan-exit marker and CLAUDE_AGENT.
110
- */
111
- export function decideClaimBeforeBuild(call, { hasRoadToCode, cwd, vaultRoot }) {
112
- if (hasRoadToCode) return { deny: false };
113
- const c = classifyToolCall(call, { cwd, vaultRoot });
114
- if (c.kind !== 'spawn' && c.kind !== 'code_write') return { deny: false };
115
- const what = c.kind === 'spawn' ? `spawn a ${c.detail} sub-agent` : `edit product code (${c.detail})`;
116
- return {
117
- deny: true,
118
- rule: 'claim-before-build',
119
- reason:
120
- `HEYSPEX claim-before-build: this session holds no claimed spec (claim heartbeat confirmed), has no capture on deck and no waiver, so you may not ${what}. ` +
121
- 'Claim the spec you are building (heyspex_claim_spec) or capture the idea (heyspex_update_capture / /hs-capture), then retry. ' +
122
- '(Break-glass, owner only: HEYSPEX_DIRECTIVE_OBLIGATION_GATE_OFF=1. [[HS-20SW-DRSM-DIAG-heyspex-capture-on-idea-and-workflow]])',
123
- };
124
- }
125
-
126
- export function stopReason(count) {
127
- return (
128
- `HEYSPEX directive-obligation-gate: you are about to stop with ${count} capture obligation(s) unmet - the capture_on_idea directive ` +
129
- 'was issued this session and no capture write was seen. Capture the owner\'s words now (heyspex_update_capture with their exact message, ' +
130
- 'or /hs-capture), then finish. This re-drive fires once; it will not block again. [[HS-20SW-DRSM-DIAG-heyspex-capture-on-idea-and-workflow]]'
131
- );
132
- }
1
+ // spexharness/src/gates/directive-obligation-gate.decide.mjs
2
+ //
3
+ // Pure decision logic for the directive-obligation-gate ([[HS-20SW-DRSM-DIAG-heyspex-capture-on-idea-and-workflow]]).
4
+ // No stdin/stdout/process.exit and no fs: every input arrives as an argument, so the gate module
5
+ // (./directive-obligation-gate.mjs) owns all I/O and a test drives these functions directly.
6
+
7
+ import { extractShellTargets } from './_shell-write-targets.mjs';
8
+ import { isProductCodePath } from '../../../scripts/lib/directive-obligations.mjs';
9
+
10
+ const WRITE_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'write_file', 'edit_file']);
11
+ const SHELL_TOOLS = new Set(['Bash', 'PowerShell', 'Shell']);
12
+ const SPAWN_TOOLS = new Set(['Agent', 'Task']);
13
+
14
+ /** Sub-agent types that only read / plan / triage: spawning one never builds anything. */
15
+ export const READ_ONLY_AGENT_TYPES = new Set([
16
+ 'explore',
17
+ 'plan',
18
+ 'claude-code-guide',
19
+ 'capture-triager',
20
+ 'plan-grounder',
21
+ 'research-synthesizer',
22
+ 'resolution-triager',
23
+ 'statusline-setup',
24
+ ]);
25
+
26
+ // Commands that build, install, serve or publish product code. Test runners are deliberately absent.
27
+ const BUILD_CMD_RE = new RegExp(
28
+ '(?:^|[\\s;&|(])(?:' +
29
+ '(?:npm|pnpm|yarn|bun)\\s+(?:run\\s+)?(?:build|dev|start|install|i|add|ci|publish|deploy)\\b' +
30
+ '|(?:npx\\s+)?(?:next|vite|webpack|turbo|esbuild)\\s+(?:build|dev)\\b' +
31
+ '|dotnet\\s+(?:build|publish|run)\\b' +
32
+ '|cargo\\s+(?:build|run|install)\\b' +
33
+ '|make\\b' +
34
+ '|vercel\\s+(?:deploy|--prod)\\b' +
35
+ '|(?:claude|spexcode)\\s+(?:-p|--print)\\b' +
36
+ ')',
37
+ 'i',
38
+ );
39
+
40
+ /** Is this a heyspex_* MCP call (any spelling)? Those are never gated. */
41
+ export function isHeyspexMcpTool(name) {
42
+ return /(?:^|__)heyspex_/.test(String(name ?? ''));
43
+ }
44
+
45
+ function pathOf(toolInput) {
46
+ return toolInput?.file_path ?? toolInput?.notebook_path ?? toolInput?.path ?? toolInput?.filePath ?? '';
47
+ }
48
+
49
+ /**
50
+ * Classify one tool call into the surface this gate cares about.
51
+ * @returns {{ kind: 'exempt'|'spawn'|'code_write'|'shell_build'|'shell_write', detail?: string }}
52
+ */
53
+ export function classifyToolCall({ toolName, toolInput }, { cwd, vaultRoot } = {}) {
54
+ const name = String(toolName ?? '');
55
+ if (!name || isHeyspexMcpTool(name)) return { kind: 'exempt' };
56
+ const input = toolInput ?? {};
57
+
58
+ if (SPAWN_TOOLS.has(name)) {
59
+ const type = String(input.subagent_type ?? '').trim().toLowerCase();
60
+ if (type && READ_ONLY_AGENT_TYPES.has(type)) return { kind: 'exempt' };
61
+ return { kind: 'spawn', detail: type || 'general-purpose' };
62
+ }
63
+
64
+ if (WRITE_TOOLS.has(name)) {
65
+ const target = pathOf(input);
66
+ return isProductCodePath(target, { cwd, vaultRoot }) ? { kind: 'code_write', detail: String(target) } : { kind: 'exempt' };
67
+ }
68
+
69
+ if (SHELL_TOOLS.has(name)) {
70
+ const command = String(input.command ?? '');
71
+ const product = extractShellTargets(command).find((t) => isProductCodePath(t, { cwd, vaultRoot }));
72
+ if (product) return { kind: 'shell_write', detail: product };
73
+ if (BUILD_CMD_RE.test(command)) return { kind: 'shell_build', detail: command.trim().slice(0, 80) };
74
+ return { kind: 'exempt' };
75
+ }
76
+
77
+ return { kind: 'exempt' };
78
+ }
79
+
80
+ const HOW_TO_DISCHARGE =
81
+ 'Capture the owner\'s words first: call heyspex_update_capture (or heyspex_file_spec / the /hs-capture skill) with their exact ' +
82
+ 'message, then retry. Reads, greps, git, test runs, vault and .heyspex writes and every heyspex_* tool stay open meanwhile.';
83
+
84
+ /**
85
+ * Rule 1: while a capture obligation is outstanding for this session, deny a sub-agent spawn, a
86
+ * product-code write and a build/product-write shell command.
87
+ * @returns {{ deny: boolean, reason?: string, rule?: string }}
88
+ */
89
+ export function decideObligation(call, { outstandingCount, cwd, vaultRoot }) {
90
+ if (!outstandingCount) return { deny: false };
91
+ const c = classifyToolCall(call, { cwd, vaultRoot });
92
+ if (c.kind === 'exempt') return { deny: false };
93
+ const what =
94
+ c.kind === 'spawn' ? `spawn a ${c.detail} sub-agent`
95
+ : c.kind === 'code_write' ? `write product code (${c.detail})`
96
+ : c.kind === 'shell_write' ? `write product code from the shell (${c.detail})`
97
+ : `run a build command (${c.detail})`;
98
+ return {
99
+ deny: true,
100
+ rule: 'directive-obligation',
101
+ reason:
102
+ `HEYSPEX directive-obligation-gate: the capture_on_idea directive was issued for this session and the capture has not happened, so you may not ${what}. ` +
103
+ `${HOW_TO_DISCHARGE} (Break-glass, owner only: HEYSPEX_DIRECTIVE_OBLIGATION_GATE_OFF=1. [[HS-20SW-DRSM-DIAG-heyspex-capture-on-idea-and-workflow]])`,
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Rule 2 (claim-before-build): deny a build sub-agent spawn or a product-code write when the session
109
+ * has no claimed spec, no on-deck capture and no waiver. Independent of the plan-exit marker and CLAUDE_AGENT.
110
+ */
111
+ export function decideClaimBeforeBuild(call, { hasRoadToCode, cwd, vaultRoot }) {
112
+ if (hasRoadToCode) return { deny: false };
113
+ const c = classifyToolCall(call, { cwd, vaultRoot });
114
+ if (c.kind !== 'spawn' && c.kind !== 'code_write') return { deny: false };
115
+ const what = c.kind === 'spawn' ? `spawn a ${c.detail} sub-agent` : `edit product code (${c.detail})`;
116
+ return {
117
+ deny: true,
118
+ rule: 'claim-before-build',
119
+ reason:
120
+ `HEYSPEX claim-before-build: this session holds no claimed spec (claim heartbeat confirmed), has no capture on deck and no waiver, so you may not ${what}. ` +
121
+ 'Claim the spec you are building (heyspex_claim_spec) or capture the idea (heyspex_update_capture / /hs-capture), then retry. ' +
122
+ '(Break-glass, owner only: HEYSPEX_DIRECTIVE_OBLIGATION_GATE_OFF=1. [[HS-20SW-DRSM-DIAG-heyspex-capture-on-idea-and-workflow]])',
123
+ };
124
+ }
125
+
126
+ export function stopReason(count) {
127
+ return (
128
+ `HEYSPEX directive-obligation-gate: you are about to stop with ${count} capture obligation(s) unmet - the capture_on_idea directive ` +
129
+ 'was issued this session and no capture write was seen. Capture the owner\'s words now (heyspex_update_capture with their exact message, ' +
130
+ 'or /hs-capture), then finish. This re-drive fires once; it will not block again. [[HS-20SW-DRSM-DIAG-heyspex-capture-on-idea-and-workflow]]'
131
+ );
132
+ }
@@ -1,125 +1,153 @@
1
- // spexharness/src/gates/vault-write-tier-guard.decide.mjs
2
- //
3
- // Pure decision for the vault-write-tier-guard PreToolUse gate (HS-FCNH).
4
- //
5
- // Problem: on a machine in prod / cloud reliance (`hs dev: prod`) the console
6
- // reads Neon, and the vault clone on disk is a MIRROR the console never reads
7
- // back. An agent that hand-edits `<vault>/specs/**/*.md` because a console
8
- // prompt said so writes into a place nobody reads. This gate denies those disk
9
- // writes in cloud write mode and names the cloud-routed tool to use instead.
10
- //
11
- // No process.exit / stdout here. `fs.existsSync` is the only I/O and is
12
- // injectable (`deps.exists`).
13
- //
14
- // call = { toolName, toolInput }
15
- // deps = { vaultRoot, writeMode, env, cwd?, exists?, platform? }
16
-
17
- import fs from 'node:fs';
18
- import path from 'node:path';
19
-
20
- import { extractShellTargets } from './_shell-write-targets.mjs';
21
-
22
- export const KILL_ENV = 'HEYSPEX_VAULT_WRITE_TIER_GUARD_OFF';
23
-
24
- const FILE_TOOLS = new Set(['write', 'edit', 'multiedit', 'notebookedit']);
25
- const SHELL_TOOLS = new Set(['bash', 'powershell']);
26
-
27
- function norm(p, platform) {
28
- const r = String(p);
29
- return platform === 'win32' ? r.toLowerCase() : r;
30
- }
31
-
32
- /** True when `p` resolves to `<vaultRoot>/specs/**\/*.md` outside `.trash/`. */
33
- export function isGuardedVaultPath(p, { vaultRoot, cwd, platform = process.platform }) {
34
- if (!p || typeof p !== 'string') return false;
35
- let abs;
36
- try {
37
- abs = path.resolve(cwd ?? process.cwd(), p);
38
- } catch {
39
- return false;
40
- }
41
- const root = norm(path.resolve(vaultRoot), platform);
42
- const target = norm(abs, platform);
43
- const rel = path.relative(root, target);
44
- if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return false;
45
- const segs = rel.split(/[\\/]+/);
46
- if (segs[0] !== 'specs' || segs.length < 2) return false;
47
- if (segs.includes('.trash')) return false;
48
- return path.extname(target) === '.md';
49
- }
50
-
51
- function editTexts(toolInput) {
52
- const out = [];
53
- for (const k of ['new_string', 'content', 'new_source', 'command']) {
54
- if (typeof toolInput?.[k] === 'string') out.push(toolInput[k]);
55
- }
56
- if (Array.isArray(toolInput?.edits)) {
57
- for (const e of toolInput.edits) if (typeof e?.new_string === 'string') out.push(e.new_string);
58
- }
59
- return out;
60
- }
61
-
62
- function pickTool(toolInput) {
63
- return editTexts(toolInput).some((t) => /^summary:/m.test(t))
64
- ? 'heyspex_author_summary'
65
- : 'heyspex_edit_file';
66
- }
67
-
68
- const ROWS = [
69
- ['summary: field', 'heyspex_author_summary { spec_id, summary } (load via heyspex_load_tools)', 'heyspex_author_summary'],
70
- ['body / other frontmatter', 'heyspex_edit_file { file_path, content } (cloud-routed in this mode)', 'heyspex_edit_file'],
71
- ['status', 'heyspex_submit_for_review / heyspex_claim_spec — never hand-edit status', null],
72
- ['new spec', 'heyspex_file_spec', null],
73
- ];
74
-
75
- export function denyReason(target, tool) {
76
- const rows = ROWS.map(([label, use, t]) => {
77
- const mark = t === tool ? '=> ' : ' ';
78
- return `${mark}${label.padEnd(24)} → ${use}`;
79
- }).join('\n');
80
- return (
81
- `[vault-write-tier-guard] This machine writes the vault to the CLOUD (hs dev: prod). ` +
82
- `${target} is a disk mirror the console never reads back (HS-FCNH).\n${rows}\n` +
83
- `Break-glass for a human: ${KILL_ENV}=1.`
84
- );
85
- }
86
-
87
- /**
88
- * @param {{ toolName: string, toolInput: Record<string, unknown> }} call
89
- * @param {{ vaultRoot?: string|null, writeMode?: string, env?: Record<string,string|undefined>,
90
- * cwd?: string, exists?: (p: string) => boolean, platform?: string }} deps
91
- * @returns {{ allow: boolean, reason?: string, tool?: string }}
92
- */
93
- export function decide(call, deps = {}) {
94
- const env = deps.env ?? process.env;
95
- if (deps.writeMode !== 'cloud') return { allow: true };
96
- if (String(env[KILL_ENV] ?? '') === '1') return { allow: true };
97
- const vaultRoot = deps.vaultRoot;
98
- if (!vaultRoot || typeof vaultRoot !== 'string') return { allow: true };
99
- const exists = deps.exists ?? ((p) => fs.existsSync(p));
100
- if (!exists(path.join(vaultRoot, 'heyspex.yaml'))) return { allow: true };
101
-
102
- const platform = deps.platform ?? process.platform;
103
- const ctx = { vaultRoot, cwd: deps.cwd, platform };
104
- const tool = String(call?.toolName ?? '').toLowerCase();
105
- const input = call?.toolInput ?? {};
106
-
107
- if (FILE_TOOLS.has(tool)) {
108
- const fp = input.file_path ?? input.notebook_path ?? input.filePath;
109
- if (isGuardedVaultPath(fp, ctx)) {
110
- return { allow: false, tool: pickTool(input), reason: denyReason(String(fp), pickTool(input)) };
111
- }
112
- return { allow: true };
113
- }
114
-
115
- if (SHELL_TOOLS.has(tool)) {
116
- const command = String(input.command ?? '');
117
- if (!command) return { allow: true };
118
- for (const t of extractShellTargets(command)) {
119
- if (isGuardedVaultPath(t, ctx)) {
120
- return { allow: false, tool: pickTool(input), reason: denyReason(t, pickTool(input)) };
121
- }
122
- }
123
- }
124
- return { allow: true };
125
- }
1
+ // spexharness/src/gates/vault-write-tier-guard.decide.mjs
2
+ //
3
+ // Pure decision for the vault-write-tier-guard PreToolUse gate (HS-FCNH).
4
+ //
5
+ // Problem: on a machine in prod / cloud reliance (`hs dev: prod`) the console
6
+ // reads Neon, and the vault clone on disk is a MIRROR the console never reads
7
+ // back. An agent that hand-edits `<vault>/specs/**/*.md` because a console
8
+ // prompt said so writes into a place nobody reads. This gate denies those disk
9
+ // writes in cloud write mode and names the cloud-routed tool to use instead.
10
+ //
11
+ // No process.exit / stdout here. `fs.existsSync` is the only I/O and is
12
+ // injectable (`deps.exists`).
13
+ //
14
+ // call = { toolName, toolInput }
15
+ // deps = { vaultRoot, writeMode, env, cwd?, exists?, readFile?, platform? }
16
+
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+
20
+ import { extractShellTargets } from './_shell-write-targets.mjs';
21
+
22
+ export const KILL_ENV = 'HEYSPEX_VAULT_WRITE_TIER_GUARD_OFF';
23
+
24
+ const FILE_TOOLS = new Set(['write', 'edit', 'multiedit', 'notebookedit']);
25
+ const SHELL_TOOLS = new Set(['bash', 'powershell']);
26
+
27
+ function norm(p, platform) {
28
+ const r = String(p);
29
+ return platform === 'win32' ? r.toLowerCase() : r;
30
+ }
31
+
32
+ /** True when `p` resolves to `<vaultRoot>/specs/**\/*.md` outside `.trash/`. */
33
+ export function isGuardedVaultPath(p, { vaultRoot, cwd, platform = process.platform }) {
34
+ if (!p || typeof p !== 'string') return false;
35
+ let abs;
36
+ try {
37
+ abs = path.resolve(cwd ?? process.cwd(), p);
38
+ } catch {
39
+ return false;
40
+ }
41
+ const root = norm(path.resolve(vaultRoot), platform);
42
+ const target = norm(abs, platform);
43
+ const rel = path.relative(root, target);
44
+ if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return false;
45
+ const segs = rel.split(/[\\/]+/);
46
+ if (segs[0] !== 'specs' || segs.length < 2) return false;
47
+ if (segs.includes('.trash')) return false;
48
+ return path.extname(target) === '.md';
49
+ }
50
+
51
+ function editTexts(toolInput) {
52
+ const out = [];
53
+ for (const k of ['new_string', 'content', 'new_source', 'command']) {
54
+ if (typeof toolInput?.[k] === 'string') out.push(toolInput[k]);
55
+ }
56
+ if (Array.isArray(toolInput?.edits)) {
57
+ for (const e of toolInput.edits) if (typeof e?.new_string === 'string') out.push(e.new_string);
58
+ }
59
+ return out;
60
+ }
61
+
62
+ const ID_LINE = /^id:[ \t]*\S/m;
63
+
64
+ /** The frontmatter block of `text`, or '' when it has none. */
65
+ function frontmatterOf(text) {
66
+ const m = /^?---\r?\n([\s\S]*?)\r?\n---/.exec(String(text ?? ''));
67
+ return m ? m[1] : '';
68
+ }
69
+
70
+ /**
71
+ * True when the write touches a cloud-backed spec: the file on disk carries an
72
+ * `id:` frontmatter key, or the incoming text does. An id-less working doc
73
+ * (e.g. `specs/refinement/*.md`) has no cloud row — `heyspex_edit_file` refuses
74
+ * it in cloud mode (CLOUD_MODE_UNSUPPORTED_WRITE) — so disk is its only home and
75
+ * blocking it would leave the agent no way to write it at all (HS-FCY9).
76
+ */
77
+ function touchesCloudSpec(target, toolInput, { cwd, readFile }) {
78
+ if (editTexts(toolInput).some((t) => ID_LINE.test(t))) return true;
79
+ let current;
80
+ try {
81
+ current = readFile(path.resolve(cwd ?? process.cwd(), target));
82
+ } catch {
83
+ return false;
84
+ }
85
+ return ID_LINE.test(frontmatterOf(current));
86
+ }
87
+
88
+ function pickTool(toolInput) {
89
+ return editTexts(toolInput).some((t) => /^summary:/m.test(t))
90
+ ? 'heyspex_author_summary'
91
+ : 'heyspex_edit_file';
92
+ }
93
+
94
+ const ROWS = [
95
+ ['summary: field', 'heyspex_author_summary { spec_id, summary } (load via heyspex_load_tools)', 'heyspex_author_summary'],
96
+ ['body / other frontmatter', 'heyspex_edit_file { file_path, content } (cloud-routed in this mode)', 'heyspex_edit_file'],
97
+ ['status', 'heyspex_submit_for_review / heyspex_claim_spec — never hand-edit status', null],
98
+ ['new spec', 'heyspex_file_spec', null],
99
+ ];
100
+
101
+ export function denyReason(target, tool) {
102
+ const rows = ROWS.map(([label, use, t]) => {
103
+ const mark = t === tool ? '=> ' : ' ';
104
+ return `${mark}${label.padEnd(24)} → ${use}`;
105
+ }).join('\n');
106
+ return (
107
+ `[vault-write-tier-guard] This machine writes the vault to the CLOUD (hs dev: prod). ` +
108
+ `${target} is a disk mirror the console never reads back (HS-FCNH).\n${rows}\n` +
109
+ `Break-glass for a human: ${KILL_ENV}=1.`
110
+ );
111
+ }
112
+
113
+ /**
114
+ * @param {{ toolName: string, toolInput: Record<string, unknown> }} call
115
+ * @param {{ vaultRoot?: string|null, writeMode?: string, env?: Record<string,string|undefined>,
116
+ * cwd?: string, exists?: (p: string) => boolean, platform?: string }} deps
117
+ * @returns {{ allow: boolean, reason?: string, tool?: string }}
118
+ */
119
+ export function decide(call, deps = {}) {
120
+ const env = deps.env ?? process.env;
121
+ if (deps.writeMode !== 'cloud') return { allow: true };
122
+ if (String(env[KILL_ENV] ?? '') === '1') return { allow: true };
123
+ const vaultRoot = deps.vaultRoot;
124
+ if (!vaultRoot || typeof vaultRoot !== 'string') return { allow: true };
125
+ const exists = deps.exists ?? ((p) => fs.existsSync(p));
126
+ if (!exists(path.join(vaultRoot, 'heyspex.yaml'))) return { allow: true };
127
+
128
+ const platform = deps.platform ?? process.platform;
129
+ const ctx = { vaultRoot, cwd: deps.cwd, platform };
130
+ const tool = String(call?.toolName ?? '').toLowerCase();
131
+ const input = call?.toolInput ?? {};
132
+ const readFile = deps.readFile ?? ((p) => fs.readFileSync(p, 'utf8'));
133
+ const guarded = (t) => isGuardedVaultPath(t, ctx) && touchesCloudSpec(t, input, { cwd: deps.cwd, readFile });
134
+
135
+ if (FILE_TOOLS.has(tool)) {
136
+ const fp = input.file_path ?? input.notebook_path ?? input.filePath;
137
+ if (guarded(fp)) {
138
+ return { allow: false, tool: pickTool(input), reason: denyReason(String(fp), pickTool(input)) };
139
+ }
140
+ return { allow: true };
141
+ }
142
+
143
+ if (SHELL_TOOLS.has(tool)) {
144
+ const command = String(input.command ?? '');
145
+ if (!command) return { allow: true };
146
+ for (const t of extractShellTargets(command)) {
147
+ if (guarded(t)) {
148
+ return { allow: false, tool: pickTool(input), reason: denyReason(t, pickTool(input)) };
149
+ }
150
+ }
151
+ }
152
+ return { allow: true };
153
+ }
@@ -20,7 +20,7 @@
20
20
 
21
21
  import { readFileSync, readdirSync } from 'node:fs';
22
22
  import path from 'node:path';
23
- import { buildSeatContext, repoRoot } from '../../dev-factory/lib/seat-context.mjs';
23
+ import { ambientOverlayRoot, buildSeatContext, repoRoot } from '../../dev-factory/lib/seat-context.mjs';
24
24
 
25
25
  export { repoRoot };
26
26
 
@@ -94,16 +94,21 @@ function listSkillIds(dir) {
94
94
  * memories: string[],
95
95
  * }}
96
96
  */
97
- export function loadSeatContext({ root = repoRoot(), seatId = null, vaultRoot = null } = {}) {
98
- const seatLine = buildSeatContext(root);
97
+ export function loadSeatContext({ root: explicitRoot, seatId = null, vaultRoot = null } = {}) {
98
+ // The owner's seats.json is read only from an explicit root or an ambient root where the
99
+ // dev-factory overlay is open ([[HS-R475-C86Z-TASK-load-the-owner-dev-factory-overlay-only]]);
100
+ // skills/procedures still resolve off the plain repo root.
101
+ const root = explicitRoot ?? repoRoot();
102
+ const seatRoot = explicitRoot ?? ambientOverlayRoot();
103
+ const seatLine = seatRoot ? buildSeatContext(seatRoot) : null;
99
104
  const specsRoot = typeof vaultRoot === 'string' && vaultRoot.length > 0 ? vaultRoot : root;
100
105
 
101
106
  let seat = null;
102
- if (seatId) {
107
+ if (seatId && seatRoot) {
103
108
  try {
104
109
  // Reuse the exact seats.json dev-factory/lib/seat-context.mjs already reads — no
105
110
  // second parse-and-guess of the registry shape here.
106
- const seatsPath = path.join(root, 'dev-factory', 'seats.json');
111
+ const seatsPath = path.join(seatRoot, 'dev-factory', 'seats.json');
107
112
  const registry = JSON.parse(readFileSync(seatsPath, 'utf8'));
108
113
  const seats = Array.isArray(registry?.seats) ? registry.seats : [];
109
114
  seat = seats.find((s) => s && s.id === seatId) ?? null;
@@ -148,8 +148,10 @@ function resolveIntent(explicitIntent, seat) {
148
148
  * Default seat-context loader: spexharness/src/seat-context.mjs's loadSeatContext, which is
149
149
  * itself the shared dev-factory builder. Not reimplemented, and called exactly once.
150
150
  */
151
- function defaultSeatContextLoader({ root, seatId, vaultRoot }) {
152
- return loadSeatContext({ root, seatId, vaultRoot });
151
+ function defaultSeatContextLoader({ root, rootExplicit = true, seatId, vaultRoot }) {
152
+ // A defaulted root goes through the dev-factory overlay gate (HS-R475): pass no root so
153
+ // loadSeatContext resolves the ambient one and reads seats.json only where the gate is open.
154
+ return loadSeatContext(rootExplicit ? { root, seatId, vaultRoot } : { seatId, vaultRoot });
153
155
  }
154
156
 
155
157
  /**
@@ -398,6 +400,7 @@ const DEFAULT_LOADERS = Object.freeze({
398
400
  * }}
399
401
  */
400
402
  export function createSessionBootstrap(options = {}) {
403
+ const rootExplicit = options.root !== undefined && options.root !== null;
401
404
  const {
402
405
  root = repoRoot(),
403
406
  vaultRoot = null,
@@ -432,7 +435,7 @@ export function createSessionBootstrap(options = {}) {
432
435
  }
433
436
 
434
437
  async function loadOnce() {
435
- const base = { root, vaultRoot, seatId, sessionId: resolvedSessionId, front, wipLanePath, intentProcedures };
438
+ const base = { root, rootExplicit, vaultRoot, seatId, sessionId: resolvedSessionId, front, wipLanePath, intentProcedures };
436
439
 
437
440
  // Seat context first and once — the other three are derived from it rather than
438
441
  // re-reading seats.json / specs/ per piece. This is the "arrives three ways" fix.
@@ -28,7 +28,7 @@ import { createInterface } from 'node:readline';
28
28
  import { existsSync } from 'node:fs';
29
29
  import { fileURLToPath } from 'node:url';
30
30
 
31
- import { formatSeatBanner } from './seat-identity.mjs';
31
+ import { formatSeatBanner, registerDevFactoryOwnerOverlay } from './seat-identity.mjs';
32
32
  import { seatBlockForSessionSurface } from './seat-block-ledger.mjs';
33
33
  import { appendPeerPresence } from './peer-presence.mjs';
34
34
  import {
@@ -417,6 +417,11 @@ if (!RUNNING_AS_CLI && !INFO_ONLY && process.env.HEYSPEX_DISABLE_STDIO_BOOTSTRAP
417
417
  process.stderr.write?.(`[heyspex-mcp] capture-channel watcher skipped: ${err?.message ?? err}\n`);
418
418
  }
419
419
 
420
+ // [[HS-RA2Q-C86Z-TASK-wire-the-owner-overlay-to-the-dev]]: the owner's dev-factory fleet becomes
421
+ // the seat owner overlay ONLY behind the HS-9JMD gate (heyspex-app source checkout + owner account +
422
+ // HEYSPEX_DEV_FACTORY not 0). A customer install registers nothing and reads no dev-factory file.
423
+ registerDevFactoryOwnerOverlay().catch(() => {});
424
+
420
425
  // [[HS-TASK-UT8H]] — pin stdin to UTF-8 explicitly. readline's default on a
421
426
  // non-TTY stream is utf-8, but pinning protects against runtime changes and
422
427
  // guards against any future host that pipes through a code-page-aware shell.