heyspex 0.2.0 → 0.2.1

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 (145) hide show
  1. package/.claude-plugin/marketplace.json +14 -0
  2. package/bin/hs.mjs +163 -11
  3. package/dist/cli/hs-check.mjs +88 -46
  4. package/dist/cli/hs-claim.mjs +84 -39
  5. package/dist/cli/hs-connect.mjs +583 -129
  6. package/dist/cli/hs-defer.mjs +251 -30
  7. package/dist/cli/hs-dev.mjs +20 -8
  8. package/dist/cli/hs-env.mjs +62 -34
  9. package/dist/cli/hs-index-rebuild.mjs +288 -83
  10. package/dist/cli/hs-index-reconcile.mjs +397 -111
  11. package/dist/cli/hs-init.mjs +2143 -91
  12. package/dist/cli/hs-install.mjs +628 -106
  13. package/dist/cli/hs-join.mjs +686 -236
  14. package/dist/cli/hs-vault-pull.mjs +3189 -179
  15. package/dist/cli/hs-vault-push.mjs +6433 -0
  16. package/dist/cli/hs-vault.mjs +396 -178
  17. package/dist/cli/hs-workspace.mjs +289 -151
  18. package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -28
  19. package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -62
  20. package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -29
  21. package/dist/cli-templates/harness-commands/claude/hs-ground.md +30 -28
  22. package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -29
  23. package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -70
  24. package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -35
  25. package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -27
  26. package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -23
  27. package/dist/cli-templates/harness-commands/claude/hs-review.md +23 -23
  28. package/dist/cli-templates/harness-commands/claude/hs-view.md +28 -26
  29. package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -32
  30. package/dist/cli-templates/harness-commands/claude/prototype.md +91 -91
  31. package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +22 -22
  32. package/dist/cli-templates/harness-commands/cursor/hs-capture.md +56 -56
  33. package/dist/cli-templates/harness-commands/cursor/hs-defer.md +23 -23
  34. package/dist/cli-templates/harness-commands/cursor/hs-ground.md +24 -22
  35. package/dist/cli-templates/harness-commands/cursor/hs-next.md +23 -23
  36. package/dist/cli-templates/harness-commands/cursor/hs-promote.md +64 -64
  37. package/dist/cli-templates/harness-commands/cursor/hs-push.md +29 -29
  38. package/dist/cli-templates/harness-commands/cursor/hs-qc.md +21 -21
  39. package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +17 -17
  40. package/dist/cli-templates/harness-commands/cursor/hs-review.md +17 -17
  41. package/dist/cli-templates/harness-commands/cursor/hs-view.md +24 -22
  42. package/dist/cli-templates/harness-commands/cursor/kill-ports.md +26 -26
  43. package/dist/cli-templates/harness-commands/cursor/prototype.md +86 -86
  44. package/docs/copilot-plugin-install.md +162 -0
  45. package/docs/driving-heyspex-from-copilot.md +165 -0
  46. package/package.json +20 -2
  47. package/plugins/heyspex/.claude-plugin/plugin.json +12 -12
  48. package/plugins/heyspex/.cursor-plugin/plugin.json +15 -15
  49. package/plugins/heyspex/README.md +309 -0
  50. package/plugins/heyspex/bin/run-hook.mjs +20 -2
  51. package/plugins/heyspex/hooks/cursor-hooks.json +81 -0
  52. package/plugins/heyspex/hooks/hooks.json +94 -37
  53. package/scripts/claude-hooks/_intent-classifier-router.mjs +456 -410
  54. package/scripts/claude-hooks/_intent-classifier-semantic.mjs +5 -0
  55. package/scripts/claude-hooks/_run.mjs +316 -23
  56. package/scripts/claude-hooks/capture-execution-gate.mjs +51 -0
  57. package/scripts/claude-hooks/claim-liveness-shared.mjs +2 -0
  58. package/scripts/claude-hooks/diagnose-capture-reminder.mjs +11 -0
  59. package/scripts/claude-hooks/directive-obligation-gate.mjs +11 -0
  60. package/scripts/claude-hooks/inject-communication-style.mjs +20 -0
  61. package/scripts/claude-hooks/intent-classifier-trigger.mjs +279 -247
  62. package/scripts/claude-hooks/session-end.mjs +2 -0
  63. package/scripts/claude-hooks/vault-mirror-catchup.mjs +11 -0
  64. package/scripts/claude-hooks/vault-write-tier-guard.mjs +11 -0
  65. package/scripts/claude-hooks/wip-checkpoint.mjs +5 -1
  66. package/scripts/cursor-hooks/manifest.mjs +46 -0
  67. package/scripts/heyspex-mode.mjs +20 -0
  68. package/scripts/hs-mode.mjs +1 -1
  69. package/scripts/hs-view.mjs +180 -17
  70. package/scripts/install-claude-code-hooks.mjs +3 -1
  71. package/scripts/lib/console-url-resolver.mjs +3 -2
  72. package/scripts/lib/customer-kit.mjs +327 -10
  73. package/scripts/lib/dev-mode.mjs +7 -6
  74. package/scripts/lib/directive-obligations.mjs +349 -0
  75. package/scripts/lib/hook-dispositions.mjs +210 -0
  76. package/scripts/lib/hook-vault-header.mjs +30 -0
  77. package/scripts/lib/hs-source-branch.mjs +196 -0
  78. package/scripts/lib/hs-source-log.mjs +2 -1
  79. package/scripts/lib/hs-source-preflight.mjs +18 -4
  80. package/scripts/lib/hs-source-push.mjs +386 -356
  81. package/scripts/lib/window-placement.mjs +109 -0
  82. package/spexharness/src/adapters/claude-code.mjs +11 -3
  83. package/spexharness/src/adapters/copilot.mjs +236 -0
  84. package/spexharness/src/adapters/detect-harness.mjs +34 -0
  85. package/spexharness/src/gates/_shell-write-targets.mjs +77 -0
  86. package/spexharness/src/gates/deep-research-cost-gate.mjs +4 -3
  87. package/spexharness/src/gates/diagnose-capture-reminder.mjs +112 -0
  88. package/spexharness/src/gates/directive-obligation-gate.decide.mjs +132 -0
  89. package/spexharness/src/gates/directive-obligation-gate.mjs +183 -0
  90. package/spexharness/src/gates/vault-mirror-catchup.mjs +124 -0
  91. package/spexharness/src/gates/vault-write-tier-guard.decide.mjs +125 -0
  92. package/spexharness/src/gates/vault-write-tier-guard.mjs +42 -0
  93. package/spexharness/src/intent-classifier.mjs +4 -2
  94. package/spexharness/src/intent-directive.mjs +626 -541
  95. package/spexharness/src/rule-table.mjs +31 -1
  96. package/vendor/heyspex-mcp/account-switch.mjs +6 -5
  97. package/vendor/heyspex-mcp/capture-buffer-patches.mjs +31 -3
  98. package/vendor/heyspex-mcp/capture-console-client.mjs +37 -2
  99. package/vendor/heyspex-mcp/capture-focus.mjs +26 -2
  100. package/vendor/heyspex-mcp/capture-outbox.mjs +10 -4
  101. package/vendor/heyspex-mcp/capture-sources.mjs +195 -0
  102. package/vendor/heyspex-mcp/capture-tools.mjs +56 -11
  103. package/vendor/heyspex-mcp/claim-tools.mjs +25 -2
  104. package/vendor/heyspex-mcp/cli-verbs.mjs +19 -21
  105. package/vendor/heyspex-mcp/cli.mjs +7 -1
  106. package/vendor/heyspex-mcp/close-branch-report.mjs +102 -0
  107. package/vendor/heyspex-mcp/cloud-vault-writes.mjs +197 -7
  108. package/vendor/heyspex-mcp/console-fetch.mjs +192 -28
  109. package/vendor/heyspex-mcp/console-transport.mjs +7 -1
  110. package/vendor/heyspex-mcp/credentials.mjs +9 -8
  111. package/vendor/heyspex-mcp/dev-tools.mjs +7 -0
  112. package/vendor/heyspex-mcp/filing-tools.mjs +35 -0
  113. package/vendor/heyspex-mcp/fleet-queue-tools.mjs +2 -1
  114. package/vendor/heyspex-mcp/folder-trust-store.mjs +2 -1
  115. package/vendor/heyspex-mcp/http-transport.mjs +2 -0
  116. package/vendor/heyspex-mcp/list-tools.mjs +1 -1
  117. package/vendor/heyspex-mcp/mcp-config.mjs +14 -1
  118. package/vendor/heyspex-mcp/memory-tools.mjs +523 -523
  119. package/vendor/heyspex-mcp/obligation-backstop.mjs +103 -0
  120. package/vendor/heyspex-mcp/occupied-keys.mjs +1 -0
  121. package/vendor/heyspex-mcp/on-deck-brief.mjs +19 -2
  122. package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -1708
  123. package/vendor/heyspex-mcp/plan-push.mjs +3 -2
  124. package/vendor/heyspex-mcp/preflight-gate.mjs +1 -0
  125. package/vendor/heyspex-mcp/procedure-handlers.mjs +327 -325
  126. package/vendor/heyspex-mcp/procedure-tools.mjs +1181 -1159
  127. package/vendor/heyspex-mcp/promote-capture.mjs +2 -0
  128. package/vendor/heyspex-mcp/promote-research.mjs +6 -0
  129. package/vendor/heyspex-mcp/read-path.mjs +162 -6
  130. package/vendor/heyspex-mcp/request-context.mjs +25 -1
  131. package/vendor/heyspex-mcp/research-handlers.mjs +6 -4
  132. package/vendor/heyspex-mcp/roadmap-default.mjs +70 -0
  133. package/vendor/heyspex-mcp/session-owner.mjs +3 -2
  134. package/vendor/heyspex-mcp/session-tools.mjs +10 -0
  135. package/vendor/heyspex-mcp/spec-edit-tools.mjs +8 -1
  136. package/vendor/heyspex-mcp/split-research.mjs +3 -0
  137. package/vendor/heyspex-mcp/submit-for-review.mjs +18 -1
  138. package/vendor/heyspex-mcp/summary-write-tools.mjs +5 -1
  139. package/vendor/heyspex-mcp/test-helpers.mjs +18 -3
  140. package/vendor/heyspex-mcp/tool-catalog.mjs +7 -1
  141. package/vendor/heyspex-mcp/tool-dispatch.mjs +18 -0
  142. package/vendor/heyspex-mcp/vault-io.mjs +720 -724
  143. package/vendor/heyspex-mcp/vault-resolve.mjs +197 -9
  144. package/vendor/heyspex-mcp/whoami.mjs +2 -1
  145. package/vendor/heyspex-mcp/write-outbox.mjs +512 -0
@@ -120,7 +120,15 @@ export const BUILT_IN_RULES = Object.freeze(
120
120
  "\\b(?:please\\s+)?(?:also\\s+)?(?:add|build|create|implement)\\s+(?:this|that|it)\\s+(?:to|in|into|for)\\b",
121
121
  "\\bshould\\s+we\\s+(?:create|build|make|add|have|offer|support|implement|write)\\b",
122
122
  "\\bshould\\s+(?:be|become)\\s+(?:a|an)\\s+(?:\\w+[\\s-]+){0,3}?(?:command|feature|setting|option|tool|button|verb|surface|skill|hook|gate)\\b",
123
- "\\bfor\\s+all\\s+future\\s+users\\b"
123
+ "\\bfor\\s+all\\s+future\\s+users\\b",
124
+ "\\bshould(?:n'?t|\\s+not)?\\s+(?:we|it|this|that|there|they|you)\\s+(?:be|have|also|always|never|just|only)?\\s*(?:\\w+\\s+){0,2}?(?:write|writing|sync|syncing|default|defaulting|use|using|store|storing|read|reading|run|running|do|doing|go|going|work|working|happen|happening|fire|firing|capture|capturing|land|landing|show|showing|open|opening|load|loading|live|living)\\b",
125
+ "\\bshould\\s+have\\s+(?:\\w+\\s+){0,2}?(?:immediately|automatically|already|just|also)?\\s*\\w+ed\\b",
126
+ "\\bwhy\\s+(?:did|does|do|is|isn'?t|didn'?t|doesn'?t|wasn'?t|aren'?t|can'?t|won'?t|hasn'?t|haven'?t)\\s+(?:it|this|that|the|we|there|they|these|those)\\b",
127
+ "\\b(?:it|this|that|there|the\\s+\\w+(?:\\s+\\w+)?)\\s+(?:needs?|ought)\\s+to\\s+(?:be|have|get|also|always)\\b",
128
+ "\\bfix\\s+all\\s+(?:of\\s+)?(?:this|that|these|those|it|them)\\b",
129
+ "\\b(?:significant|major|serious|real|big|total|complete|repeated|constant|ongoing)\\s+(?:breakdowns?|failures?|gaps?|problems?|regressions?|holes?)\\s+(?:with|in|around|across)\\b",
130
+ "\\b\\w+s?\\s+(?:aren'?t|isn'?t|are\\s+not|is\\s+not|never|don'?t\\s+get|doesn'?t\\s+get|stopped)\\s+(?:being\\s+|getting\\s+)?\\w+(?:ed|ing)\\b",
131
+ "\\bthere\\s+should\\s+be\\s+(?:a|an|some)\\b"
124
132
  ],
125
133
  "inject": "This message contains a RAW IDEA / suggestion worth capturing — capture it even if it is ALSO a work order, question, or bug report (both can be true). Per the owner's standing wish, the system CAPTURES ideas autonomously — the owner built the skills and cannot remember them all, so do NOT wait to be told to run /hs-capture.\n\n{{CONFIRM_MODE}}\n\nFIRST, pick the TRACK — never whether to capture. Skipping capture is the USER's call alone (they say \"discard\", \"skip\", or \"no, just do it\"); you never self-exempt, and \"this is actually a command / work order / bug report\" is NOT a reason to skip (owner ruling 2026-07-12):\n - CAPTURE CONTINUATION (check this FIRST — owner ruling 2026-07-12): a capture from THIS conversation is sitting at the promote/iterate choice, and this prose follows on from that idea (more detail, a refinement, a related want — even when phrased as a work order like \"please add …\", and even though the user never says \"iterate\"). That prose IS the iterate move on the SAME capture: call `heyspex_update_capture` with `spec_id` = that capture's exact id (NEVER `heyspex_active_capture({})`, which can mint a fresh capture), append the verbatim `user_quote`, re-ground into `agent_expansion`, and re-present promote/iterate. Do NOT create a new capture; do NOT implement anything.\n - ACTIONABLE (also a command, work order, or bug report to fix now, where the work is something OTHER than building the captured idea itself): announce in ONE line — Capturing this as an idea per your standing capture flow — say \"discard\" if you'd rather I skip it. — run capture steps 1-3 below, then IMMEDIATELY proceed with the requested work. The capture never blocks, replaces, or delays the task; skip step 4's promote/iterate ask and just do the work (the capture stays parked for later). HARD LIMIT (owner ruling 2026-07-12; bypass retired 2026-08-11): when the requested work IS the captured idea (build/add/implement the thing you just captured), this track does NOT apply — a captured idea is never implemented straight from a capture. Its only road to code is promote-to-research, then `/hs-promote <research-id>` + `/hs-next <resulting-id> [front]` — milestone/epic/task filing is never skipped; front mode (handheld/assisted/autonomous) controls supervision, not whether that filing happens (research-capture-protocol §\"From research to code — always through milestone/epic/task (no bypass)\").\n - PURE IDEA (nothing to execute right now): run the full capture flow, steps 1-4.\n Sole exception (no capture): a bare continuation carrying NO new idea content — \"yes\", \"proceed\", a direct answer to a question you just asked.\nDEFER OPTION (owner ruling 2026-07-12): when filing the capture in-line would get in the way of operations — the capture endpoint is failing/buffering, the message is a time-critical work order, or the user is mid-operation — you may DEFER the capture write to session close-out instead of doing it now. Announce it in the same one line (\"deferring capture to close-out\"), keep the user's VERBATIM words safe in session state, and at closing procedures append those exact quotes to the related capture doc that already exists (explicit spec_id) — or file the deferred new capture — alongside memory filing (session-close-protocol step 7). Defer ≠ skip: the words MUST land in the vault at close; skipping capture remains the USER's call alone.\nCapture flow:\n 1. OPEN — call `heyspex_active_capture({})` (call `heyspex_load_tools` first if the capture tools aren't loaded). It attaches to the on-deck capture or auto-creates a fresh HS-CAP-*.\n 2. CAPTURE (beat 1) — call `heyspex_update_capture` with `user_quote` = the user's VERBATIM words; if Core Idea is empty, also pass `core_idea` + `summary` in the same call. Never alter the user's words.\n 3. GROUND = EXPAND (beat 2) — do peer-level grounding as a technical co-founder and write it to `agent_expansion` in the same pass, automatically (no confirm).\n 4. Then present exactly two moves: promote to research (the only real exit) or iterate. Follow the /hs-capture beats exactly; promote is always confirm-required.\n\nIf the user declines / says discard: STOP the flow and leave the capture as an unpromoted raw note (they can delete it later). Do not promote, do not file epics/tasks/plans.\n\nHard rules (capture-guardrails): user words stay verbatim; promote-to-research (or promote-to-diagnose for `kind: bug-fix`) is the ONLY exit — never offer to file epics/tasks/plans; never commit or push.\n(Mute: HEYSPEX_CAPTURE_TRIGGER_OFF=1, or set agent_behaviors.capture_on_idea.enabled: false in .heyspex/config.yaml.)\n",
126
134
  "naming": null
@@ -250,6 +258,28 @@ export const BUILT_IN_RULES = Object.freeze(
250
258
  "inject": "Destructive git activity on this working tree is WITNESSED independently of every\nother gate (protected-shared-actions-gate): git state is snapshotted at session start\nto a session-scoped file under `.heyspex/git-reflog-audit/`, and diffed at session\nclose. Any growth in `git stash list`, and any HEAD reflog entry newer than the\nsnapshot whose action is reset / checkout / rebase, is named in one warn row in\n`.heyspex/guardrail-history.jsonl`. Ordinary commits are not flagged. This never\nblocks anything — it exists so an action that slipped past every other control still\nleaves a record that does not depend on the acting agent's honesty. If the witness\nfires, say so in the hand-off rather than letting it go unmentioned.\n",
251
259
  "naming": null
252
260
  },
261
+ {
262
+ "rule": "handoff-intent",
263
+ "sourceProtocol": "handoff-intent-protocol",
264
+ "severity": "prime",
265
+ "surfaces": [
266
+ "userpromptsubmit"
267
+ ],
268
+ "configKey": "agent_behaviors.handoff_intent.enabled",
269
+ "ignoreInjectedContent": true,
270
+ "patterns": [
271
+ "\\bhand[\\s-]?off\\s+(?:prompt|doc|document|notes?|message|brief|context|summary|instructions?)\\b",
272
+ "\\bhand[\\s-]?off\\b[^.?!]{0,40}\\b(?:next|new|another)\\s+(?:agent|session|window|claude)\\b",
273
+ "\\bhand\\s+(?:this|it|things?|the\\s+work)\\s+off\\b",
274
+ "\\b(?:for|to)\\s+(?:the\\s+)?next\\s+(?:agent|session)\\b",
275
+ "\\bresume\\s+(?:prompt|context|instructions?)\\b",
276
+ "\\bnext[\\s-]session\\s+(?:prompt|notes?|handoff|hand-off)\\b",
277
+ "\\bwhat\\s+(?:should|does|do)\\s+the\\s+next\\s+(?:agent|session)\\b",
278
+ "\\bpick\\s+(?:this|it)\\s+up\\s+(?:in|with)\\s+a\\s+(?:new|fresh|another)\\s+(?:session|window|agent)\\b"
279
+ ],
280
+ "inject": "HANDOFF REQUEST - HeySpex has an owner-locked hand-off contract; never write a freehand hand-off prompt or doc. Run the /hs-handoff CREATE flow now (.claude/commands/hs-handoff.md): 1) close out first - heyspex_record_wip per worked epic, change-log rows / HS-REV at the review grain, heyspex_release_spec on open claims, hs export-events (skip what is already current); 2) `hs handoff record --new-code --spec <next-target> --phase \"<label>\" --link <worked ids> --blocker \"<open items, one line>\"` - the code is MINTED, never invented; 3) hand the human BOTH `/hs-handoff <CODE>` and `cd C:\\dev-projects\\heyspex-specs && claude \"/hs-next <next-spec-id>\"`; 4) the session is not over - the human decides. (Mute: HEYSPEX_HANDOFF_INTENT_OFF=1 or agent_behaviors.handoff_intent.enabled: false.)\n",
281
+ "naming": null
282
+ },
253
283
  {
254
284
  "rule": "id-display-shape-gate",
255
285
  "sourceProtocol": "communication-style-protocol",
@@ -20,6 +20,7 @@
20
20
  //
21
21
  // Plain Node ESM, zero dependencies. Never logs token material.
22
22
 
23
+ import { heyspexHome } from '../../scripts/lib/heyspex-home.mjs';
23
24
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
24
25
  import { homedir } from 'node:os';
25
26
  import { dirname, join } from 'node:path';
@@ -27,7 +28,7 @@ import { dirname, join } from 'node:path';
27
28
  import { assertNotRealHomeUnderTest, removeAccount, setCurrentAccount } from './credentials.mjs';
28
29
 
29
30
  /** `~/.heyspex/registry.json` for `homeDir`. */
30
- export function registryFilePath(homeDir = homedir()) {
31
+ export function registryFilePath(homeDir = heyspexHome()) {
31
32
  return join(homeDir, '.heyspex', 'registry.json');
32
33
  }
33
34
 
@@ -36,7 +37,7 @@ export function registryFilePath(homeDir = homedir()) {
36
37
  * @param {{ homeDir?: string, accountId: string | null }} input
37
38
  * @returns {{ status: 'updated' | 'unchanged' | 'absent' | 'v1' | 'unreadable', path: string }}
38
39
  */
39
- export function setRegistryCurrentAccount({ homeDir = homedir(), accountId }) {
40
+ export function setRegistryCurrentAccount({ homeDir = heyspexHome(), accountId }) {
40
41
  const path = registryFilePath(homeDir);
41
42
  if (!existsSync(path)) return { status: 'absent', path };
42
43
  let json;
@@ -64,7 +65,7 @@ export function setRegistryCurrentAccount({ homeDir = homedir(), accountId }) {
64
65
  * the registry is touched only after the credentials half succeeded.
65
66
  * @param {{ homeDir?: string, accountId: string }} input
66
67
  */
67
- export function switchCurrentAccount({ homeDir = homedir(), accountId }) {
68
+ export function switchCurrentAccount({ homeDir = heyspexHome(), accountId }) {
68
69
  const credentialsPath = setCurrentAccount({ homeDir, accountId });
69
70
  const registry = setRegistryCurrentAccount({ homeDir, accountId });
70
71
  return { accountId, credentialsPath, registry };
@@ -76,7 +77,7 @@ export function switchCurrentAccount({ homeDir = homedir(), accountId }) {
76
77
  * account leaves the registry alone.
77
78
  * @param {{ homeDir?: string, accountId?: string | null }} [input]
78
79
  */
79
- export function signOutAccount({ homeDir = homedir(), accountId = null } = {}) {
80
+ export function signOutAccount({ homeDir = heyspexHome(), accountId = null } = {}) {
80
81
  const result = removeAccount({ homeDir, accountId });
81
82
  const registry =
82
83
  result.removed && result.wasCurrent
@@ -90,6 +91,6 @@ export function signOutAccount({ homeDir = homedir(), accountId = null } = {}) {
90
91
  * store, carry the registry's pointer along with it.
91
92
  * @param {{ homeDir?: string, accountId: string }} input
92
93
  */
93
- export function followCurrentAccountInRegistry({ homeDir = homedir(), accountId }) {
94
+ export function followCurrentAccountInRegistry({ homeDir = heyspexHome(), accountId }) {
94
95
  return setRegistryCurrentAccount({ homeDir, accountId });
95
96
  }
@@ -36,6 +36,8 @@
36
36
  * confirm in the /hs-capture four-beat protocol.
37
37
  */
38
38
 
39
+ import { buildSourcesPatches, normalizeCaptureSources } from './capture-sources.mjs';
40
+
39
41
  /** Keep only well-formed `{ at, text }` quote entries from a prior buffer. */
40
42
  function priorUserQuotes(currentBuffer) {
41
43
  const raw = currentBuffer?.userQuotes ?? currentBuffer?.user_quotes;
@@ -86,6 +88,17 @@ function collapse(value) {
86
88
  return String(value).trim().replace(/\n+/g, ' ');
87
89
  }
88
90
 
91
+ /**
92
+ * A user's quote is stored STRICTLY VERBATIM ([[HS-TGHU-ST07-TASK-store-and-render-capture-quotes]],
93
+ * owner 2026-10-10: "always verbatim words in notes"): no trim, no newline
94
+ * flattening, no whitespace collapsing. Only CRLF/CR are normalized to LF.
95
+ * A whitespace-only quote is dropped (nothing was said).
96
+ */
97
+ function verbatimQuote(value) {
98
+ const raw = String(value).replace(/\r\n?/g, '\n');
99
+ return raw.trim() ? raw : '';
100
+ }
101
+
89
102
  /**
90
103
  * @param {object} args - { user_quote, agent_note, core_idea, summary (alias: concept), agent_expansion, example, user_stories, kind, done_when, jtbd }
91
104
  * @param {object} currentBuffer - the session's existing content_buffer (may be {})
@@ -95,9 +108,17 @@ function collapse(value) {
95
108
  export function buildCaptureBufferPatches(args = {}, currentBuffer = {}, iso) {
96
109
  const patches = [];
97
110
 
98
- if (args.user_quote !== undefined && collapse(args.user_quote)) {
99
- const next = [...priorUserQuotes(currentBuffer), { at: iso, text: collapse(args.user_quote) }];
100
- patches.push({ path: 'userQuotes', value: next });
111
+ if (args.user_quote !== undefined && verbatimQuote(args.user_quote)) {
112
+ const entry = { at: iso, text: verbatimQuote(args.user_quote) };
113
+ // op 'append' + items: a current console appends under its row lock (no
114
+ // stale read-modify-write); an older console ignores both and stores the
115
+ // full prior-plus-new value.
116
+ patches.push({
117
+ path: 'userQuotes',
118
+ value: [...priorUserQuotes(currentBuffer), entry],
119
+ op: 'append',
120
+ items: [entry],
121
+ });
101
122
  }
102
123
 
103
124
  if (args.agent_note !== undefined && collapse(args.agent_note)) {
@@ -184,5 +205,12 @@ export function buildCaptureBufferPatches(args = {}, currentBuffer = {}, iso) {
184
205
  });
185
206
  }
186
207
 
208
+ // sources: links + original files saved with the capture (HS-9K12). The
209
+ // handler pre-validates via normalizeCaptureSources; an invalid bag emits nothing.
210
+ if (args.sources !== undefined) {
211
+ const normalized = normalizeCaptureSources(args.sources, iso);
212
+ if (normalized.ok) patches.push(...buildSourcesPatches(normalized, currentBuffer));
213
+ }
214
+
187
215
  return patches;
188
216
  }
@@ -173,9 +173,14 @@ export function describeCaptureWriteFailure(failure) {
173
173
  `The console answered — it is not down. This write was NOT saved and will NOT retry automatically; fix the request and send it again.`,
174
174
  };
175
175
  }
176
+ // [[HS-K27H]] decided a 5xx was NOT stashed because the OLD code reported
177
+ // server errors as "can't reach the server". The sin was that false
178
+ // connectivity claim, not the buffering: degrade-don't-die buffers the user's
179
+ // words on a 5xx too, but the message says truthfully that the console
180
+ // ANSWERED with an error (see respondToCaptureWriteFailure).
176
181
  return {
177
182
  kind: "server",
178
- shouldStash: false,
183
+ shouldStash: true,
179
184
  error: CAPTURE_ERROR_FAILED,
180
185
  http_status: status,
181
186
  server_error_code: serverCode,
@@ -198,7 +203,7 @@ export function describeCaptureWriteFailure(failure) {
198
203
  * (AC4) — the drain path replays every OUTBOX_KINDS field in canonical
199
204
  * structured shape.
200
205
  * - 4xx → `status: "refused"` + the server's error/message verbatim (AC2/AC5).
201
- * - 5xx → `status: "failed"` + the server's message verbatim (AC3/AC5).
206
+ * - 5xx → stash too, `status: "failed_buffered"`, `buffered: true`, truthful "console answered <status>" message (not "unreachable"); `status: "failed"` only when there was nothing to stash.
202
207
  * - sign-in (`authProblem` on a status-0 result, or an answered 401) →
203
208
  * `status: "blocked"`, `buffered: false`, `action` = the sign-in command in
204
209
  * the form this machine can run (`hs login`, or `node "<abs>/bin/hs.mjs" login`),
@@ -206,6 +211,17 @@ export function describeCaptureWriteFailure(failure) {
206
211
  * that says plainly the words were NOT saved ([[HS-9R6S-N8VP-DIAG]]).
207
212
  */
208
213
  export function respondToCaptureWriteFailure(vaultRoot, args, failure) {
214
+ const payload = respondToCaptureWriteFailureInner(vaultRoot, args, failure);
215
+ // sources (original files / links) are NOT stashed offline: say so honestly
216
+ // instead of letting the "your words are saved" message imply they were.
217
+ if (Array.isArray(args?.sources) && args.sources.length > 0) {
218
+ payload.sources_not_saved = true;
219
+ payload.message = `${payload.message} NOTE: the sources you attached were NOT saved offline. Re-send them with heyspex_update_capture once the console is reachable.`;
220
+ }
221
+ return payload;
222
+ }
223
+
224
+ function respondToCaptureWriteFailureInner(vaultRoot, args, failure) {
209
225
  const d = describeCaptureWriteFailure(failure);
210
226
  if (d.kind === "transport") {
211
227
  const stashed = stashCaptureArgs(vaultRoot, args || {});
@@ -240,6 +256,25 @@ export function respondToCaptureWriteFailure(vaultRoot, args, failure) {
240
256
  message: `This capture was not filed, and your words were not buffered. ${d.message}`,
241
257
  };
242
258
  }
259
+ if (d.kind === "server") {
260
+ const stashed = stashCaptureArgs(vaultRoot, args || {});
261
+ if (stashed.length > 0) {
262
+ return {
263
+ ok: false,
264
+ status: "failed_buffered",
265
+ error: d.error,
266
+ http_status: d.http_status,
267
+ server_error: d.server_error_code,
268
+ console_error: d.server_message ?? d.server_error_code,
269
+ buffered: true,
270
+ source: "offline-buffer",
271
+ message:
272
+ `The console answered ${d.http_status} applying the write; your words are buffered locally in .heyspex/capture-outbox and will be replayed on the next successful capture call.`,
273
+ stashed: stashed.map((s) => ({ kind: s.kind })),
274
+ pending_sync: outboxCount(vaultRoot),
275
+ };
276
+ }
277
+ }
243
278
  return {
244
279
  ok: false,
245
280
  status: d.kind === "client" ? "refused" : "failed",
@@ -851,10 +851,23 @@ export function validateSummaryLength(summary) {
851
851
  /** @deprecated renamed to validateSummaryLength (HS-EPIC-OED2). */
852
852
  export const validateConceptLength = validateSummaryLength;
853
853
 
854
+ /**
855
+ * Strictly verbatim note entry (HS-TGHU). MUST match the console's
856
+ * formatUserNoteEntry (capture-vault.ts): a plain one-line quote keeps the
857
+ * legacy one-line form; anything with newlines or edge whitespace renders as a
858
+ * per-line " > " blockquote. Only CRLF/CR become LF.
859
+ */
854
860
  export function formatUserNoteEntry(iso, role, text) {
855
- const clean = text.trim().replace(/\n+/g, ' ');
861
+ const raw = String(text).replace(/\r\n?/g, '\n');
856
862
  const label = role === 'user' ? 'you' : role;
857
- return `- **${iso} · ${label}:** "${clean}"`;
863
+ if (!raw.includes('\n') && raw === raw.trim()) {
864
+ return `- **${iso} · ${label}:** "${raw}"`;
865
+ }
866
+ const quoted = raw
867
+ .split('\n')
868
+ .map((line) => (line === '' ? ' >' : ` > ${line}`))
869
+ .join('\n');
870
+ return `- **${iso} · ${label}:**\n${quoted}`;
858
871
  }
859
872
 
860
873
  export function appendCaptureNote(existingNotes, entry) {
@@ -1295,6 +1308,7 @@ export const UPDATE_CAPTURE_ACCEPTED_ARGS = [
1295
1308
  'kind',
1296
1309
  'done_when',
1297
1310
  'jtbd',
1311
+ 'sources',
1298
1312
  ];
1299
1313
 
1300
1314
  /**
@@ -1397,6 +1411,16 @@ export function executeUpdateCapture(vaultRoot, parseFrontmatter, args = {}, dep
1397
1411
  * for every rejection.
1398
1412
  */
1399
1413
  function computeCaptureUpdate(frontmatter, body, args = {}) {
1414
+ // sources (links / original files) live in the capture session's content_buffer
1415
+ // (console API path). This spec-file path has nowhere to put them, so refuse BY
1416
+ // NAME rather than silently dropping them (additionalproperties-false-in-an-mcp).
1417
+ if (Array.isArray(args.sources) && args.sources.length > 0) {
1418
+ return {
1419
+ error: 'SOURCES_UNSUPPORTED_ON_SPEC_FILE_PATH',
1420
+ message:
1421
+ 'heyspex_update_capture `sources` is only supported through the console capture-session path. NOTHING WAS WRITTEN. Retry against a reachable console.',
1422
+ };
1423
+ }
1400
1424
  // `summary` is the canonical input; `concept` is the deprecated alias (HS-EPIC-OED2).
1401
1425
  const summaryArg = args.summary ?? args.concept;
1402
1426
 
@@ -89,7 +89,9 @@ export function stashCaptureArgs(vaultRoot, args = {}) {
89
89
  for (const kind of OUTBOX_KINDS) {
90
90
  const raw = args[kind];
91
91
  if (raw !== undefined && String(raw).trim()) {
92
- entries.push({ kind, text: String(raw).trim() });
92
+ // user_quote is stashed verbatim (no trim) so an offline replay keeps the
93
+ // exact words (HS-TGHU).
94
+ entries.push({ kind, text: kind === 'user_quote' ? String(raw) : String(raw).trim() });
93
95
  }
94
96
  }
95
97
  if (entries.length === 0) return [];
@@ -122,7 +124,10 @@ export function listOutbox(vaultRoot) {
122
124
  const kind = m ? m[1] : 'user_quote';
123
125
  let text = '';
124
126
  try {
125
- text = readFileSync(join(dir, f), 'utf8').replace(/\n+$/, '');
127
+ // stash writes text + one newline: strip exactly that one so a verbatim
128
+ // quote's own trailing whitespace survives the round trip.
129
+ text = readFileSync(join(dir, f), 'utf8').replace(/\n$/, '');
130
+ if (kind !== 'user_quote') text = text.replace(/\n+$/, '');
126
131
  } catch {
127
132
  /* skip unreadable */
128
133
  }
@@ -235,8 +240,9 @@ export function quarantineCount(vaultRoot) {
235
240
  * @returns {{path: string, value: unknown} | null}
236
241
  */
237
242
  export function outboxEntryToPatch(entry, iso, currentBuffer = {}) {
238
- const text = String(entry.text ?? '').trim();
239
- if (!text) return null;
243
+ const text =
244
+ entry.kind === 'user_quote' ? String(entry.text ?? '') : String(entry.text ?? '').trim();
245
+ if (!text.trim()) return null;
240
246
  if (!OUTBOX_KINDS.includes(entry.kind)) return null;
241
247
  const patches = buildCaptureBufferPatches({ [entry.kind]: text }, currentBuffer, iso);
242
248
  return patches[0] ?? null;
@@ -0,0 +1,195 @@
1
+ /**
2
+ * `sources` argument of `heyspex_update_capture`: links, original text
3
+ * documents and binary files saved WITH a capture
4
+ * ([[HS-9K12-ST07-TASK-heyspex-update-capture-sources-argument]], epic
5
+ * [[HS-WF23-ST07-EPIC-verbatim-words-and-source-files]]; owner 2026-10-10:
6
+ * "files/original docs etc saved as source").
7
+ *
8
+ * Entry forms (exactly one content key per entry):
9
+ * { url, title? } -> link source (`## Source Material`, grain link)
10
+ * { name, text, mime?, origin? } -> original TEXT document, stored byte-for-byte
11
+ * { name, content_base64, mime?, origin? } -> binary file
12
+ * { path, name?, mime? } -> the MCP reads the file at `path` itself (so the
13
+ * agent never has to pass the content through its context)
14
+ *
15
+ * Caps mirror the console (`spex-console/src/lib/capture-source-files.ts`),
16
+ * which re-validates server-side: 256 KiB per file, 1 MiB total, 20 files.
17
+ * Oversize / malformed entries are REFUSED by name; nothing is truncated.
18
+ */
19
+ import { createHash } from 'node:crypto';
20
+ import { readFileSync, statSync } from 'node:fs';
21
+ import { basename } from 'node:path';
22
+
23
+ export const SOURCE_FILE_MAX_BYTES = 256 * 1024;
24
+ export const SOURCE_FILES_MAX_TOTAL_BYTES = 1024 * 1024;
25
+ export const SOURCE_FILES_MAX_COUNT = 20;
26
+ const BASE64_RE = /^[A-Za-z0-9+/]*={0,2}$/;
27
+
28
+ /** Reduce a name to one safe path segment (mirrors the console). */
29
+ export function sanitizeSourceFileName(raw) {
30
+ const segs = String(raw).split(/[\\/]/);
31
+ // eslint-disable-next-line no-control-regex
32
+ return (segs[segs.length - 1] ?? '').replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 200);
33
+ }
34
+
35
+ function defaultMime(name, encoding) {
36
+ if (encoding === 'base64') return 'application/octet-stream';
37
+ return /\.(md|markdown)$/i.test(name) ? 'text/markdown' : 'text/plain';
38
+ }
39
+
40
+ function fileFromBuffer(buf, { name, mime, origin, iso, encoding, content }) {
41
+ if (buf.length === 0) return { error: `source "${name}" is empty` };
42
+ if (buf.length > SOURCE_FILE_MAX_BYTES) {
43
+ return {
44
+ error: `source "${name}" is ${buf.length} bytes; the inline source-file cap is ${SOURCE_FILE_MAX_BYTES} bytes. Attach a smaller file or link it by url.`,
45
+ };
46
+ }
47
+ return {
48
+ file: {
49
+ name,
50
+ mime: mime || defaultMime(name, encoding),
51
+ bytes: buf.length,
52
+ sha256: createHash('sha256').update(buf).digest('hex'),
53
+ at: iso,
54
+ encoding,
55
+ content,
56
+ ...(origin ? { origin } : {}),
57
+ },
58
+ };
59
+ }
60
+
61
+ /** Decode bytes as UTF-8 text only when that is lossless; otherwise treat as binary. */
62
+ function bufferToStored(buf) {
63
+ if (!buf.includes(0)) {
64
+ const text = buf.toString('utf8');
65
+ if (Buffer.from(text, 'utf8').equals(buf)) return { encoding: 'utf8', content: text };
66
+ }
67
+ return { encoding: 'base64', content: buf.toString('base64') };
68
+ }
69
+
70
+ /**
71
+ * Validate + normalize the `sources` argument.
72
+ * @returns {{ ok: true, links: object[], files: object[] } | { ok: false, error: object }}
73
+ */
74
+ export function normalizeCaptureSources(sources, iso, deps = {}) {
75
+ const read = deps.readFile ?? ((p) => readFileSync(p));
76
+ const size = deps.fileSize ?? ((p) => statSync(p).size);
77
+ const refuse = (message, index) => ({
78
+ ok: false,
79
+ error: {
80
+ error: 'INVALID_SOURCES',
81
+ message: `${message} NOTHING WAS WRITTEN.`,
82
+ field: `sources[${index}]`,
83
+ },
84
+ });
85
+ if (!Array.isArray(sources)) return refuse('sources must be an array.', 0);
86
+ if (sources.length > SOURCE_FILES_MAX_COUNT) {
87
+ return refuse(`sources cannot exceed ${SOURCE_FILES_MAX_COUNT} entries.`, 0);
88
+ }
89
+ const links = [];
90
+ const files = [];
91
+ let total = 0;
92
+ for (let i = 0; i < sources.length; i += 1) {
93
+ const s = sources[i];
94
+ if (!s || typeof s !== 'object') return refuse(`sources[${i}] must be an object.`, i);
95
+ if (typeof s.url === 'string' && s.url.trim()) {
96
+ if (!/^https?:\/\//i.test(s.url.trim())) {
97
+ return refuse(`sources[${i}].url must be an http(s) URL.`, i);
98
+ }
99
+ links.push({
100
+ url: s.url.trim(),
101
+ ...(typeof s.title === 'string' && s.title.trim() ? { title: s.title.trim() } : {}),
102
+ added_at: iso,
103
+ grain: 'link',
104
+ });
105
+ continue;
106
+ }
107
+ let name = typeof s.name === 'string' ? sanitizeSourceFileName(s.name) : '';
108
+ const mime = typeof s.mime === 'string' && s.mime.trim() ? s.mime.trim().slice(0, 100) : '';
109
+ const origin =
110
+ typeof s.origin === 'string' && s.origin.trim() ? s.origin.trim().slice(0, 500) : '';
111
+ let r;
112
+ if (typeof s.path === 'string' && s.path.trim()) {
113
+ const p = s.path.trim();
114
+ let sz;
115
+ try {
116
+ sz = size(p);
117
+ } catch (e) {
118
+ return refuse(`sources[${i}].path could not be read (${e.code || e.message}).`, i);
119
+ }
120
+ if (sz > SOURCE_FILE_MAX_BYTES) {
121
+ return refuse(
122
+ `sources[${i}] (${basename(p)}) is ${sz} bytes; the inline source-file cap is ${SOURCE_FILE_MAX_BYTES} bytes.`,
123
+ i,
124
+ );
125
+ }
126
+ let buf;
127
+ try {
128
+ buf = read(p);
129
+ } catch (e) {
130
+ return refuse(`sources[${i}].path could not be read (${e.code || e.message}).`, i);
131
+ }
132
+ name = name || sanitizeSourceFileName(basename(p));
133
+ if (!name) return refuse(`sources[${i}] needs a name.`, i);
134
+ r = fileFromBuffer(buf, { name, mime, origin: origin || p, iso, ...bufferToStored(buf) });
135
+ } else if (typeof s.text === 'string') {
136
+ if (!name) return refuse(`sources[${i}].name is required for a text document.`, i);
137
+ r = fileFromBuffer(Buffer.from(s.text, 'utf8'), {
138
+ name,
139
+ mime,
140
+ origin,
141
+ iso,
142
+ encoding: 'utf8',
143
+ content: s.text,
144
+ });
145
+ } else if (typeof s.content_base64 === 'string') {
146
+ if (!name) return refuse(`sources[${i}].name is required for a binary file.`, i);
147
+ const b64 = s.content_base64.replace(/\s+/g, '');
148
+ if (b64.length % 4 !== 0 || !BASE64_RE.test(b64)) {
149
+ return refuse(`sources[${i}].content_base64 is not valid base64.`, i);
150
+ }
151
+ r = fileFromBuffer(Buffer.from(b64, 'base64'), {
152
+ name,
153
+ mime,
154
+ origin,
155
+ iso,
156
+ encoding: 'base64',
157
+ content: b64,
158
+ });
159
+ } else {
160
+ return refuse(`sources[${i}] needs one of url, path, text (+name) or content_base64 (+name).`, i);
161
+ }
162
+ if (r.error) return refuse(r.error, i);
163
+ total += r.file.bytes;
164
+ files.push(r.file);
165
+ }
166
+ if (total > SOURCE_FILES_MAX_TOTAL_BYTES) {
167
+ return refuse(
168
+ `sources total ${total} bytes exceeds the ${SOURCE_FILES_MAX_TOTAL_BYTES} byte per-capture cap.`,
169
+ 0,
170
+ );
171
+ }
172
+ return { ok: true, links, files };
173
+ }
174
+
175
+ /** Patches for validated sources: prior-plus-new full array (old consoles) + append hint (new consoles). */
176
+ export function buildSourcesPatches(normalized, currentBuffer = {}) {
177
+ const patches = [];
178
+ if (normalized.files.length > 0) {
179
+ const prior = Array.isArray(currentBuffer?.sourceFiles) ? currentBuffer.sourceFiles : [];
180
+ const fresh = normalized.files.filter(
181
+ (f) => !prior.some((p) => p && p.name === f.name && p.sha256 === f.sha256),
182
+ );
183
+ if (fresh.length > 0) {
184
+ patches.push({ path: 'sourceFiles', value: [...prior, ...fresh], op: 'append', items: fresh });
185
+ }
186
+ }
187
+ if (normalized.links.length > 0) {
188
+ const prior = Array.isArray(currentBuffer?.sources) ? currentBuffer.sources : [];
189
+ const fresh = normalized.links.filter((l) => !prior.some((p) => p && p.url === l.url));
190
+ if (fresh.length > 0) {
191
+ patches.push({ path: 'sources', value: [...prior, ...fresh], op: 'append', items: fresh });
192
+ }
193
+ }
194
+ return patches;
195
+ }
@@ -8,18 +8,19 @@
8
8
  // ─────────────────────────────────────────────────────────────────────────────
9
9
 
10
10
  import { parseSpecId, shortLookupIdOf } from './spec-id-lib.mjs';
11
- import { readViaSelector, resolveReadTier, resolveVaultWriteMode } from './read-path.mjs';
11
+ import { consoleReadErrorFrom, readViaSelector, resolveReadTier, resolveVaultWriteMode } from './read-path.mjs';
12
12
  import { consoleFetch } from './console-transport.mjs';
13
13
  import { join } from 'node:path';
14
14
  import { existsSync, readFileSync, readdirSync } from 'node:fs';
15
15
  import { findSpecFile, loadSpec, parseFrontmatter } from './vault-io.mjs';
16
16
  import { collectOccupiedIdsFromSpecs } from '../../scripts/vault-id-lib.mjs';
17
- import { RECENT_PROMOTION_LINEAGE_MS, computeCaptureSufficiency, extractCaptureCoreFromBody, extractCaptureNotesFromBody, findCaptureSpecFile, loadCaptureFocusOffline, resolveActiveCapture, updateCaptureArgsVerdict, writeCaptureFocus } from './capture-focus.mjs';
17
+ import { RECENT_PROMOTION_LINEAGE_MS, buildCaptureBody, computeCaptureSufficiency, extractCaptureCoreFromBody, extractCaptureNotesFromBody, findCaptureSpecFile, loadCaptureFocusOffline, resolveActiveCapture, updateCaptureArgsVerdict, writeCaptureFocus } from './capture-focus.mjs';
18
18
  import { consoleErrorCode } from './console-fetch.mjs';
19
19
  import { executePromoteCapture, executePromoteCaptureToDiagnose } from './promote-capture.mjs';
20
20
  import { detectAgentIdentity, effectiveConsoleUrl } from './mcp-config.mjs';
21
21
  import { makeCaptureConsoleClient, respondToCaptureWriteFailure } from './capture-console-client.mjs';
22
22
  import { buildCaptureBufferPatches } from './capture-buffer-patches.mjs';
23
+ import { normalizeCaptureSources } from './capture-sources.mjs';
23
24
  import { resolveActiveCaptureContent } from './capture-active-rehydrate.mjs';
24
25
 
25
26
  /**
@@ -574,19 +575,51 @@ export function captureSpecIdFromPath(specPath) {
574
575
  * @param {string|null|undefined} specPath
575
576
  * @returns {{ sufficiency: object|null, sufficiency_reason?: string }}
576
577
  */
577
- function sufficiencyForSpecPath(vaultRoot, specPath) {
578
+ export function sufficiencyForSpecPath(vaultRoot, specPath, session = null) {
579
+ // Cloud/database path ([[HS-WF23]]): the session row carries the content the console
580
+ // holds, but the spec file is not on this machine (cloud materialization) or does not exist
581
+ // yet. Score the body rebuilt from the session's content_buffer rather than report
582
+ // `spec_unreadable` for a capture that has real content.
583
+ const fromBuffer = () => {
584
+ const body = bodyFromContentBuffer(session?.contentBuffer);
585
+ if (body === null) return null;
586
+ try {
587
+ return { sufficiency: computeCaptureSufficiency({ kind: session?.contentBuffer?.kind }, body) };
588
+ } catch {
589
+ return { sufficiency: null, sufficiency_reason: 'scorer_error' };
590
+ }
591
+ };
578
592
  if (!specPath) {
579
- return { sufficiency: null, sufficiency_reason: 'not_yet_materialized' };
593
+ return fromBuffer() ?? { sufficiency: null, sufficiency_reason: 'not_yet_materialized' };
580
594
  }
581
595
  try {
582
596
  const spec = loadSpec(vaultRoot, specPath);
583
- if (!spec) return { sufficiency: null, sufficiency_reason: 'spec_unreadable' };
597
+ if (!spec) return fromBuffer() ?? { sufficiency: null, sufficiency_reason: 'spec_unreadable' };
584
598
  return { sufficiency: computeCaptureSufficiency(spec.frontmatter, spec.body) };
585
599
  } catch {
586
- return { sufficiency: null, sufficiency_reason: 'scorer_error' };
600
+ return fromBuffer() ?? { sufficiency: null, sufficiency_reason: 'scorer_error' };
587
601
  }
588
602
  }
589
603
 
604
+ /**
605
+ * Rebuild the capture body from a session content_buffer (structured `{at,text}` or flat
606
+ * shapes), byte-compatible with what materialization writes. `null` when the buffer holds no
607
+ * authored content (nothing to score — keep the honest null verdict).
608
+ * @param {object|null|undefined} cb
609
+ * @returns {string|null}
610
+ */
611
+ export function bodyFromContentBuffer(cb) {
612
+ if (!cb || typeof cb !== 'object') return null;
613
+ const { core, summary, notes } = normalizeCaptureBuffer(cb);
614
+ const example = bufferText(cb.example);
615
+ const userStories = bufferText(cb.userStories ?? cb.user_stories);
616
+ const doneWhen = bufferText(cb.doneWhen ?? cb.done_when);
617
+ const jtbd = bufferText(cb.jtbd);
618
+ const details = bufferText(cb.details ?? cb.agent_expansion);
619
+ if (![core, summary, notes, example, userStories, doneWhen, jtbd, details].some(Boolean)) return null;
620
+ return buildCaptureBody(core, notes, summary, details, '', example, userStories, doneWhen, jtbd);
621
+ }
622
+
590
623
  /**
591
624
  * [[HS-TASK-MJJK]] — rewired to query sessions API first.
592
625
  * 1. If spec_id is provided, resolve that exact capture DB-first
@@ -628,7 +661,7 @@ export async function handleActiveCapture(vaultRoot, args) {
628
661
  source: rehydrated.rehydrated_from_disk ? 'database+disk-rehydrated' : 'database',
629
662
  is_new: rehydrated.is_new,
630
663
  session,
631
- ...sufficiencyForSpecPath(vaultRoot, session.specPath),
664
+ ...sufficiencyForSpecPath(vaultRoot, session.specPath, session),
632
665
  };
633
666
  if (!rehydrated.is_new) {
634
667
  payload.guidance =
@@ -767,7 +800,7 @@ export async function handleActiveCapture(vaultRoot, args) {
767
800
  is_new: rehydrated.is_new,
768
801
  session,
769
802
  synced_from_offline_buffer: drain.drained || undefined,
770
- ...sufficiencyForSpecPath(vaultRoot, session.specPath),
803
+ ...sufficiencyForSpecPath(vaultRoot, session.specPath, session),
771
804
  };
772
805
  if (!rehydrated.is_new) {
773
806
  payload.guidance =
@@ -823,6 +856,17 @@ export async function handleUpdateCapture(vaultRoot, args) {
823
856
  return { content: [{ type: 'text', text: JSON.stringify(argsVerdict.error, null, 2) }] };
824
857
  }
825
858
 
859
+ // sources (links / original files): validate BEFORE anything is written so an
860
+ // oversize or malformed entry is refused by name with nothing half-saved.
861
+ // The normalized result is not reused: buildCaptureBufferPatches re-derives
862
+ // it with the same timestamp it stamps on every other field.
863
+ if (args.sources !== undefined) {
864
+ const srcVerdict = normalizeCaptureSources(args.sources, new Date().toISOString());
865
+ if (!srcVerdict.ok) {
866
+ return { content: [{ type: 'text', text: JSON.stringify(srcVerdict.error, null, 2) }] };
867
+ }
868
+ }
869
+
826
870
  const identity = detectAgentIdentity();
827
871
 
828
872
  // Resolve the session to update — online-only, no file-mode fallback.
@@ -918,7 +962,7 @@ export async function handleUpdateCapture(vaultRoot, args) {
918
962
  session: patchResult.session,
919
963
  materialization: patchResult.materialization || null,
920
964
  message: 'Capture session updated via API. Materialization handled server-side.',
921
- ...sufficiencyForSpecPath(vaultRoot, patchResult.session?.specPath),
965
+ ...sufficiencyForSpecPath(vaultRoot, patchResult.session?.specPath, patchResult.session),
922
966
  };
923
967
 
924
968
  // Agent-originated captures never auto-open a browser window (companion /
@@ -1163,6 +1207,7 @@ export function collectOccupiedCaptureIds(vaultRoot) {
1163
1207
  */
1164
1208
  export async function collectOccupiedCaptureIdsViaTier(vaultRoot, { tier, consoleUrl }) {
1165
1209
  return readViaSelector({
1210
+ allowDiskFallback: false,
1166
1211
  tier,
1167
1212
  diskRead: () => collectOccupiedCaptureIds(vaultRoot),
1168
1213
  neonRead: async () => {
@@ -1171,10 +1216,10 @@ export async function collectOccupiedCaptureIdsViaTier(vaultRoot, { tier, consol
1171
1216
  consoleFetch('/api/vault/occupied-ids?bucket=ideas'),
1172
1217
  ]);
1173
1218
  if (!capturesRes.ok) {
1174
- throw new Error(`GET /api/vault/occupied-ids?bucket=captures returned ${capturesRes.status}: ${capturesRes.error || ''}`);
1219
+ throw consoleReadErrorFrom(capturesRes, `GET /api/vault/occupied-ids?bucket=captures returned ${capturesRes.status}: ${capturesRes.error || ''}`, null);
1175
1220
  }
1176
1221
  if (!ideasRes.ok) {
1177
- throw new Error(`GET /api/vault/occupied-ids?bucket=ideas returned ${ideasRes.status}: ${ideasRes.error || ''}`);
1222
+ throw consoleReadErrorFrom(ideasRes, `GET /api/vault/occupied-ids?bucket=ideas returned ${ideasRes.status}: ${ideasRes.error || ''}`, null);
1178
1223
  }
1179
1224
  return new Set([
1180
1225
  ...(Array.isArray(capturesRes.body?.ids) ? capturesRes.body.ids : []),