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.
- package/.claude-plugin/marketplace.json +14 -0
- package/bin/hs.mjs +163 -11
- package/dist/cli/hs-check.mjs +88 -46
- package/dist/cli/hs-claim.mjs +84 -39
- package/dist/cli/hs-connect.mjs +583 -129
- package/dist/cli/hs-defer.mjs +251 -30
- package/dist/cli/hs-dev.mjs +20 -8
- package/dist/cli/hs-env.mjs +62 -34
- package/dist/cli/hs-index-rebuild.mjs +288 -83
- package/dist/cli/hs-index-reconcile.mjs +397 -111
- package/dist/cli/hs-init.mjs +2143 -91
- package/dist/cli/hs-install.mjs +628 -106
- package/dist/cli/hs-join.mjs +686 -236
- package/dist/cli/hs-vault-pull.mjs +3189 -179
- package/dist/cli/hs-vault-push.mjs +6433 -0
- package/dist/cli/hs-vault.mjs +396 -178
- package/dist/cli/hs-workspace.mjs +289 -151
- package/dist/cli-templates/harness-commands/claude/hs-backfill.md +28 -28
- package/dist/cli-templates/harness-commands/claude/hs-capture.md +62 -62
- package/dist/cli-templates/harness-commands/claude/hs-defer.md +29 -29
- package/dist/cli-templates/harness-commands/claude/hs-ground.md +30 -28
- package/dist/cli-templates/harness-commands/claude/hs-next.md +29 -29
- package/dist/cli-templates/harness-commands/claude/hs-promote.md +70 -70
- package/dist/cli-templates/harness-commands/claude/hs-push.md +35 -35
- package/dist/cli-templates/harness-commands/claude/hs-qc.md +27 -27
- package/dist/cli-templates/harness-commands/claude/hs-resolve.md +23 -23
- package/dist/cli-templates/harness-commands/claude/hs-review.md +23 -23
- package/dist/cli-templates/harness-commands/claude/hs-view.md +28 -26
- package/dist/cli-templates/harness-commands/claude/kill-ports.md +32 -32
- package/dist/cli-templates/harness-commands/claude/prototype.md +91 -91
- package/dist/cli-templates/harness-commands/cursor/hs-backfill.md +22 -22
- package/dist/cli-templates/harness-commands/cursor/hs-capture.md +56 -56
- package/dist/cli-templates/harness-commands/cursor/hs-defer.md +23 -23
- package/dist/cli-templates/harness-commands/cursor/hs-ground.md +24 -22
- package/dist/cli-templates/harness-commands/cursor/hs-next.md +23 -23
- package/dist/cli-templates/harness-commands/cursor/hs-promote.md +64 -64
- package/dist/cli-templates/harness-commands/cursor/hs-push.md +29 -29
- package/dist/cli-templates/harness-commands/cursor/hs-qc.md +21 -21
- package/dist/cli-templates/harness-commands/cursor/hs-resolve.md +17 -17
- package/dist/cli-templates/harness-commands/cursor/hs-review.md +17 -17
- package/dist/cli-templates/harness-commands/cursor/hs-view.md +24 -22
- package/dist/cli-templates/harness-commands/cursor/kill-ports.md +26 -26
- package/dist/cli-templates/harness-commands/cursor/prototype.md +86 -86
- package/docs/copilot-plugin-install.md +162 -0
- package/docs/driving-heyspex-from-copilot.md +165 -0
- package/package.json +20 -2
- package/plugins/heyspex/.claude-plugin/plugin.json +12 -12
- package/plugins/heyspex/.cursor-plugin/plugin.json +15 -15
- package/plugins/heyspex/README.md +309 -0
- package/plugins/heyspex/bin/run-hook.mjs +20 -2
- package/plugins/heyspex/hooks/cursor-hooks.json +81 -0
- package/plugins/heyspex/hooks/hooks.json +94 -37
- package/scripts/claude-hooks/_intent-classifier-router.mjs +456 -410
- package/scripts/claude-hooks/_intent-classifier-semantic.mjs +5 -0
- package/scripts/claude-hooks/_run.mjs +316 -23
- package/scripts/claude-hooks/capture-execution-gate.mjs +51 -0
- package/scripts/claude-hooks/claim-liveness-shared.mjs +2 -0
- package/scripts/claude-hooks/diagnose-capture-reminder.mjs +11 -0
- package/scripts/claude-hooks/directive-obligation-gate.mjs +11 -0
- package/scripts/claude-hooks/inject-communication-style.mjs +20 -0
- package/scripts/claude-hooks/intent-classifier-trigger.mjs +279 -247
- package/scripts/claude-hooks/session-end.mjs +2 -0
- package/scripts/claude-hooks/vault-mirror-catchup.mjs +11 -0
- package/scripts/claude-hooks/vault-write-tier-guard.mjs +11 -0
- package/scripts/claude-hooks/wip-checkpoint.mjs +5 -1
- package/scripts/cursor-hooks/manifest.mjs +46 -0
- package/scripts/heyspex-mode.mjs +20 -0
- package/scripts/hs-mode.mjs +1 -1
- package/scripts/hs-view.mjs +180 -17
- package/scripts/install-claude-code-hooks.mjs +3 -1
- package/scripts/lib/console-url-resolver.mjs +3 -2
- package/scripts/lib/customer-kit.mjs +327 -10
- package/scripts/lib/dev-mode.mjs +7 -6
- package/scripts/lib/directive-obligations.mjs +349 -0
- package/scripts/lib/hook-dispositions.mjs +210 -0
- package/scripts/lib/hook-vault-header.mjs +30 -0
- package/scripts/lib/hs-source-branch.mjs +196 -0
- package/scripts/lib/hs-source-log.mjs +2 -1
- package/scripts/lib/hs-source-preflight.mjs +18 -4
- package/scripts/lib/hs-source-push.mjs +386 -356
- package/scripts/lib/window-placement.mjs +109 -0
- package/spexharness/src/adapters/claude-code.mjs +11 -3
- package/spexharness/src/adapters/copilot.mjs +236 -0
- package/spexharness/src/adapters/detect-harness.mjs +34 -0
- package/spexharness/src/gates/_shell-write-targets.mjs +77 -0
- package/spexharness/src/gates/deep-research-cost-gate.mjs +4 -3
- package/spexharness/src/gates/diagnose-capture-reminder.mjs +112 -0
- package/spexharness/src/gates/directive-obligation-gate.decide.mjs +132 -0
- package/spexharness/src/gates/directive-obligation-gate.mjs +183 -0
- package/spexharness/src/gates/vault-mirror-catchup.mjs +124 -0
- package/spexharness/src/gates/vault-write-tier-guard.decide.mjs +125 -0
- package/spexharness/src/gates/vault-write-tier-guard.mjs +42 -0
- package/spexharness/src/intent-classifier.mjs +4 -2
- package/spexharness/src/intent-directive.mjs +626 -541
- package/spexharness/src/rule-table.mjs +31 -1
- package/vendor/heyspex-mcp/account-switch.mjs +6 -5
- package/vendor/heyspex-mcp/capture-buffer-patches.mjs +31 -3
- package/vendor/heyspex-mcp/capture-console-client.mjs +37 -2
- package/vendor/heyspex-mcp/capture-focus.mjs +26 -2
- package/vendor/heyspex-mcp/capture-outbox.mjs +10 -4
- package/vendor/heyspex-mcp/capture-sources.mjs +195 -0
- package/vendor/heyspex-mcp/capture-tools.mjs +56 -11
- package/vendor/heyspex-mcp/claim-tools.mjs +25 -2
- package/vendor/heyspex-mcp/cli-verbs.mjs +19 -21
- package/vendor/heyspex-mcp/cli.mjs +7 -1
- package/vendor/heyspex-mcp/close-branch-report.mjs +102 -0
- package/vendor/heyspex-mcp/cloud-vault-writes.mjs +197 -7
- package/vendor/heyspex-mcp/console-fetch.mjs +192 -28
- package/vendor/heyspex-mcp/console-transport.mjs +7 -1
- package/vendor/heyspex-mcp/credentials.mjs +9 -8
- package/vendor/heyspex-mcp/dev-tools.mjs +7 -0
- package/vendor/heyspex-mcp/filing-tools.mjs +35 -0
- package/vendor/heyspex-mcp/fleet-queue-tools.mjs +2 -1
- package/vendor/heyspex-mcp/folder-trust-store.mjs +2 -1
- package/vendor/heyspex-mcp/http-transport.mjs +2 -0
- package/vendor/heyspex-mcp/list-tools.mjs +1 -1
- package/vendor/heyspex-mcp/mcp-config.mjs +14 -1
- package/vendor/heyspex-mcp/memory-tools.mjs +523 -523
- package/vendor/heyspex-mcp/obligation-backstop.mjs +103 -0
- package/vendor/heyspex-mcp/occupied-keys.mjs +1 -0
- package/vendor/heyspex-mcp/on-deck-brief.mjs +19 -2
- package/vendor/heyspex-mcp/on-deck-scheduler.mjs +1708 -1708
- package/vendor/heyspex-mcp/plan-push.mjs +3 -2
- package/vendor/heyspex-mcp/preflight-gate.mjs +1 -0
- package/vendor/heyspex-mcp/procedure-handlers.mjs +327 -325
- package/vendor/heyspex-mcp/procedure-tools.mjs +1181 -1159
- package/vendor/heyspex-mcp/promote-capture.mjs +2 -0
- package/vendor/heyspex-mcp/promote-research.mjs +6 -0
- package/vendor/heyspex-mcp/read-path.mjs +162 -6
- package/vendor/heyspex-mcp/request-context.mjs +25 -1
- package/vendor/heyspex-mcp/research-handlers.mjs +6 -4
- package/vendor/heyspex-mcp/roadmap-default.mjs +70 -0
- package/vendor/heyspex-mcp/session-owner.mjs +3 -2
- package/vendor/heyspex-mcp/session-tools.mjs +10 -0
- package/vendor/heyspex-mcp/spec-edit-tools.mjs +8 -1
- package/vendor/heyspex-mcp/split-research.mjs +3 -0
- package/vendor/heyspex-mcp/submit-for-review.mjs +18 -1
- package/vendor/heyspex-mcp/summary-write-tools.mjs +5 -1
- package/vendor/heyspex-mcp/test-helpers.mjs +18 -3
- package/vendor/heyspex-mcp/tool-catalog.mjs +7 -1
- package/vendor/heyspex-mcp/tool-dispatch.mjs +18 -0
- package/vendor/heyspex-mcp/vault-io.mjs +720 -724
- package/vendor/heyspex-mcp/vault-resolve.mjs +197 -9
- package/vendor/heyspex-mcp/whoami.mjs +2 -1
- 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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 &&
|
|
99
|
-
const
|
|
100
|
-
|
|
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:
|
|
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: "
|
|
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
|
|
861
|
+
const raw = String(text).replace(/\r\n?/g, '\n');
|
|
856
862
|
const label = role === 'user' ? 'you' : role;
|
|
857
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
239
|
-
|
|
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
|
|
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
|
|
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 : []),
|