heyspex 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/README.md +157 -0
  2. package/bin/hs.mjs +1028 -0
  3. package/bin/postinstall.mjs +88 -0
  4. package/dev-factory/lib/fleet-readiness.mjs +274 -0
  5. package/dev-factory/lib/fleet-state.mjs +250 -0
  6. package/dev-factory/lib/sufficiency-score.mjs +441 -0
  7. package/dist/cli/hs-check.mjs +747 -0
  8. package/dist/cli/hs-claim.mjs +541 -0
  9. package/dist/cli/hs-connect.mjs +3436 -0
  10. package/dist/cli/hs-defer.mjs +8455 -0
  11. package/dist/cli/hs-env.mjs +36845 -0
  12. package/dist/cli/hs-init.mjs +1145 -0
  13. package/dist/cli/hs-install.mjs +1390 -0
  14. package/dist/cli/hs-join.mjs +38553 -0
  15. package/dist/cli/hs-vault-pull.mjs +7782 -0
  16. package/dist/cli/hs-vault.mjs +4166 -0
  17. package/dist/cli/hs-workspace.mjs +2898 -0
  18. package/dist/cli-templates/customer-vault/specs/entity-model.md +1735 -0
  19. package/dist/cli-templates/customer-vault/specs/protocols/canonical-frontmatter-templates.md +531 -0
  20. package/dist/cli-templates/customer-vault/specs/protocols/memory-protocol.md +48 -0
  21. package/dist/cli-templates/customer-vault/specs/protocols/spec-hierarchy.md +548 -0
  22. package/docs/mcp-client-config.md +200 -0
  23. package/package.json +124 -0
  24. package/scripts/check-mcp-freshness.mjs +379 -0
  25. package/scripts/dev-server-status.mjs +722 -0
  26. package/scripts/gateway-skills-lib.mjs +421 -0
  27. package/scripts/lib/console-url-resolver.mjs +152 -0
  28. package/scripts/lib/dev-mode.mjs +369 -0
  29. package/scripts/lib/hs-link.mjs +154 -0
  30. package/scripts/lib/hs-source-commit.mjs +207 -0
  31. package/scripts/lib/hs-source-log.mjs +366 -0
  32. package/scripts/lib/hs-source-preflight.mjs +402 -0
  33. package/scripts/lib/hs-source-push.mjs +307 -0
  34. package/scripts/lib/hs-source-secret-scan.mjs +263 -0
  35. package/scripts/lib/standalone-doctor.mjs +89 -0
  36. package/scripts/vault-id-lib.mjs +173 -0
  37. package/vendor/heyspex-mcp/alignment-gate.mjs +1347 -0
  38. package/vendor/heyspex-mcp/blocker-resolver.mjs +891 -0
  39. package/vendor/heyspex-mcp/breakdown-ladder.mjs +309 -0
  40. package/vendor/heyspex-mcp/breakdown-source.mjs +236 -0
  41. package/vendor/heyspex-mcp/breakdown-writer.mjs +524 -0
  42. package/vendor/heyspex-mcp/brief-observability.mjs +67 -0
  43. package/vendor/heyspex-mcp/canvas-inbox.mjs +130 -0
  44. package/vendor/heyspex-mcp/capture-active-rehydrate.mjs +76 -0
  45. package/vendor/heyspex-mcp/capture-buffer-patches.mjs +147 -0
  46. package/vendor/heyspex-mcp/capture-channel.mjs +577 -0
  47. package/vendor/heyspex-mcp/capture-console-client.mjs +290 -0
  48. package/vendor/heyspex-mcp/capture-focus.mjs +1366 -0
  49. package/vendor/heyspex-mcp/capture-kind-suggest.mjs +121 -0
  50. package/vendor/heyspex-mcp/capture-outbox.mjs +239 -0
  51. package/vendor/heyspex-mcp/capture-stage-gate.mjs +160 -0
  52. package/vendor/heyspex-mcp/cli.mjs +15754 -0
  53. package/vendor/heyspex-mcp/config-settings.mjs +419 -0
  54. package/vendor/heyspex-mcp/console-fetch.mjs +342 -0
  55. package/vendor/heyspex-mcp/defer-spec.mjs +527 -0
  56. package/vendor/heyspex-mcp/deferred-reclaim.mjs +284 -0
  57. package/vendor/heyspex-mcp/design-tools.mjs +449 -0
  58. package/vendor/heyspex-mcp/dev-factory-bridge.mjs +66 -0
  59. package/vendor/heyspex-mcp/diagnose-skeleton.mjs +114 -0
  60. package/vendor/heyspex-mcp/fleet-queue-tools.mjs +241 -0
  61. package/vendor/heyspex-mcp/hooks-engine.mjs +399 -0
  62. package/vendor/heyspex-mcp/login-handoff.mjs +438 -0
  63. package/vendor/heyspex-mcp/mcp-subprocess.mjs +136 -0
  64. package/vendor/heyspex-mcp/mvp-mode.mjs +101 -0
  65. package/vendor/heyspex-mcp/next-resolution.mjs +240 -0
  66. package/vendor/heyspex-mcp/next-review.mjs +548 -0
  67. package/vendor/heyspex-mcp/peer-presence.mjs +430 -0
  68. package/vendor/heyspex-mcp/plan-ground-conflicts.mjs +321 -0
  69. package/vendor/heyspex-mcp/plan-ground.mjs +2001 -0
  70. package/vendor/heyspex-mcp/plan-ingest.mjs +476 -0
  71. package/vendor/heyspex-mcp/plan-push.mjs +1826 -0
  72. package/vendor/heyspex-mcp/plan-review.mjs +1419 -0
  73. package/vendor/heyspex-mcp/preflight-gate.mjs +0 -0
  74. package/vendor/heyspex-mcp/procedure-tools.mjs +970 -0
  75. package/vendor/heyspex-mcp/promote-capture.mjs +1280 -0
  76. package/vendor/heyspex-mcp/promote-diagnose.mjs +187 -0
  77. package/vendor/heyspex-mcp/promote-research-to-diagnose.mjs +338 -0
  78. package/vendor/heyspex-mcp/promote-research.mjs +1553 -0
  79. package/vendor/heyspex-mcp/promotion-helpers.mjs +280 -0
  80. package/vendor/heyspex-mcp/read-path.mjs +138 -0
  81. package/vendor/heyspex-mcp/recipe-tools.mjs +258 -0
  82. package/vendor/heyspex-mcp/regen-tasks.mjs +326 -0
  83. package/vendor/heyspex-mcp/register-spec.mjs +417 -0
  84. package/vendor/heyspex-mcp/research-focus.mjs +476 -0
  85. package/vendor/heyspex-mcp/research-tools.mjs +109 -0
  86. package/vendor/heyspex-mcp/research-type-suggest.mjs +112 -0
  87. package/vendor/heyspex-mcp/resolution-focus.mjs +45 -0
  88. package/vendor/heyspex-mcp/review-focus.mjs +55 -0
  89. package/vendor/heyspex-mcp/review-rollup.mjs +47 -0
  90. package/vendor/heyspex-mcp/run-front.mjs +60 -0
  91. package/vendor/heyspex-mcp/scheduler.mjs +813 -0
  92. package/vendor/heyspex-mcp/seat-identity.mjs +281 -0
  93. package/vendor/heyspex-mcp/settings-tools.mjs +119 -0
  94. package/vendor/heyspex-mcp/spec-id-lib.mjs +790 -0
  95. package/vendor/heyspex-mcp/split-research.mjs +832 -0
  96. package/vendor/heyspex-mcp/submit-for-review.mjs +190 -0
  97. package/vendor/heyspex-mcp/summary-contract.mjs +162 -0
  98. package/vendor/heyspex-mcp/test-helpers.mjs +43 -0
  99. package/vendor/heyspex-mcp/validate-linkage.mjs +574 -0
  100. package/vendor/heyspex-mcp/vault-resolve.mjs +297 -0
  101. package/vendor/heyspex-mcp/vault-settings.mjs +87 -0
  102. package/vendor/heyspex-mcp/verify-reproduction.mjs +314 -0
  103. package/vendor/heyspex-mcp/why-now.mjs +64 -0
  104. package/vendor/heyspex-mcp/yaml-scalar.mjs +34 -0
@@ -0,0 +1,130 @@
1
+ // heyspex_canvas_inbox — agent-pull side of the session-mirror write-back
2
+ // ([[HS-1ROV-EPIC]] stage 5c). An agent that polls calls this tool to claim any
3
+ // pending HeySpex canvas messages for its session. The messages are claimed
4
+ // THROUGH the companion daemon's localhost listener (`POST /outbox/claim`), which
5
+ // dequeues from the daemon's single MirrorOutbox — the same queue the Stop-hook
6
+ // drain and the idle injector pull from. That single ledger is what makes
7
+ // delivery exactly-once across all three channels: a message this tool claims is
8
+ // removed from the outbox, so the Stop drain / idle injection can never also
9
+ // deliver it (and vice-versa).
10
+ //
11
+ // Daemon down / not running / unreachable are NORMAL states (Chrome closed, no
12
+ // companion) — handled gracefully as an empty result with an honest note, never
13
+ // an error.
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import http from 'node:http';
17
+
18
+ const POST_TIMEOUT_MS = 2000;
19
+
20
+ /** Read `mirrorListenerPort` from the companion settings file. 0 = no listener. */
21
+ export function readMirrorListenerPort(settingsPath) {
22
+ try {
23
+ const raw = fs.readFileSync(settingsPath, 'utf8');
24
+ const parsed = JSON.parse(raw);
25
+ const port = parsed?.mirrorListenerPort;
26
+ return Number.isInteger(port) && port > 0 ? port : 0;
27
+ } catch {
28
+ return 0;
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Resolve the companion settings path. Prefers the HEYSPEX_COMPANION_SETTINGS env
34
+ * override; otherwise the in-repo desktop install location
35
+ * (`<vaultRoot>/companion/companion.settings.json`). This is the ONE file
36
+ * that carries `mirrorListenerPort` — NOT `spex-console/companion-settings.json`.
37
+ */
38
+ export function resolveCompanionSettingsPath(vaultRoot) {
39
+ const env = process.env.HEYSPEX_COMPANION_SETTINGS;
40
+ if (env && env.trim().length > 0) return env.trim();
41
+ return path.join(vaultRoot ?? '.', 'companion', 'companion.settings.json');
42
+ }
43
+
44
+ /** Default localhost POST to the companion listener; resolves the body text. */
45
+ function defaultPost(port, urlPath, body) {
46
+ return new Promise((resolve, reject) => {
47
+ const payload = JSON.stringify(body);
48
+ const req = http.request(
49
+ {
50
+ method: 'POST',
51
+ hostname: '127.0.0.1',
52
+ port,
53
+ path: urlPath,
54
+ headers: {
55
+ 'content-type': 'application/json',
56
+ 'content-length': Buffer.byteLength(payload),
57
+ },
58
+ timeout: POST_TIMEOUT_MS,
59
+ },
60
+ (res) => {
61
+ let out = '';
62
+ res.setEncoding('utf8');
63
+ res.on('data', (c) => {
64
+ out += c;
65
+ });
66
+ res.on('end', () => resolve(out));
67
+ res.on('error', reject);
68
+ },
69
+ );
70
+ req.on('error', reject);
71
+ req.on('timeout', () => {
72
+ req.destroy();
73
+ reject(new Error('timeout'));
74
+ });
75
+ req.write(payload);
76
+ req.end();
77
+ });
78
+ }
79
+
80
+ /**
81
+ * Claim pending canvas messages for `sessionId`. `postImpl(port, urlPath, body)`
82
+ * is injectable for tests (returns the response body text). Never throws.
83
+ */
84
+ export async function executeCanvasInbox({ vaultRoot, sessionId, settingsPath, postImpl } = {}) {
85
+ const sid = typeof sessionId === 'string' ? sessionId.trim() : '';
86
+ if (!sid) {
87
+ return {
88
+ ok: false,
89
+ error: 'NO_SESSION',
90
+ message:
91
+ 'A session id is required. Pass session_id, or run inside a Claude Code session ' +
92
+ '(CLAUDE_CODE_SESSION_ID).',
93
+ messages: [],
94
+ };
95
+ }
96
+
97
+ const sp = settingsPath || resolveCompanionSettingsPath(vaultRoot);
98
+ const port = readMirrorListenerPort(sp);
99
+ if (!port) {
100
+ return {
101
+ ok: true,
102
+ messages: [],
103
+ note: 'The HeySpex companion daemon is not running (no listener port); no canvas messages.',
104
+ };
105
+ }
106
+
107
+ const post = postImpl || defaultPost;
108
+ let raw;
109
+ try {
110
+ raw = await post(port, '/outbox/claim', { session_id: sid });
111
+ } catch {
112
+ return {
113
+ ok: true,
114
+ messages: [],
115
+ note: 'The HeySpex companion daemon is unreachable; no canvas messages.',
116
+ };
117
+ }
118
+
119
+ let parsed = null;
120
+ try {
121
+ parsed = JSON.parse(raw);
122
+ } catch {
123
+ parsed = null;
124
+ }
125
+ const messages = Array.isArray(parsed?.messages)
126
+ ? parsed.messages.filter((m) => typeof m === 'string' && m.length > 0)
127
+ : [];
128
+
129
+ return { ok: true, sessionId: sid, delivered: messages.length, messages };
130
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Resolve the content to report for the no-`spec_id` `heyspex_active_capture`
3
+ * path — extracted from cli.mjs so the decision is unit-testable without
4
+ * booting the MCP stdio server (mirrors the `capture-buffer-patches.mjs`
5
+ * factoring).
6
+ *
7
+ * Fixes [[HS-U29N-DIAG-T2DR-heyspex-active-capture-with-no-spec-id]]: the
8
+ * no-`spec_id` path attaches to whichever `capture_sessions` row was most
9
+ * recently active and used to trust its `content_buffer` at face value. A
10
+ * session whose capture is already materialized (`specPath` set) can carry a
11
+ * stale/empty `content_buffer` even though the FILE on disk holds the
12
+ * owner's real content — the DB buffer and the file can diverge (e.g. the
13
+ * file was authored/edited by a path that never PATCHed this session's
14
+ * buffer). Reporting the empty buffer as "this capture is empty" let an
15
+ * agent conclude it was safe to write fresh content, silently overwriting a
16
+ * live capture. This actually happened on 2026-07-13 to `HS-DN7D-CAP` and
17
+ * had to be restored from git.
18
+ *
19
+ * Two rules encoded here:
20
+ * 1. **Never trust an empty buffer when a spec file exists.** `specPath`
21
+ * set means this capture is materialized — a real file exists. If the
22
+ * buffer looks empty, read the file before concluding "empty".
23
+ * 2. **`is_new` is only true for a genuinely fresh capture** — no buffer
24
+ * content AND no materialized file. Every other case (buffer has
25
+ * content, or the file has content) is `is_new: false`, even when
26
+ * rehydration was needed to see that content.
27
+ */
28
+
29
+ /**
30
+ * @param {object} session - the capture_sessions row: { contentBuffer, specPath, id, ... }
31
+ * @param {object} deps
32
+ * @param {(contentBuffer: object) => { core: string, summary: string, notes: string }} deps.normalizeBuffer -
33
+ * normalize a content_buffer (flat or structured shape) into plain text fields.
34
+ * @param {(specPath: string) => ({ body: string } | null)} deps.loadSpec - load a spec file by path; null if unreadable/missing.
35
+ * @param {(body: string) => string} deps.extractCore - extract the `# Core Idea` section from a spec body.
36
+ * @param {(body: string) => string} deps.extractNotes - extract the `## Notes` section from a spec body.
37
+ * @returns {{ contentBuffer: object, is_new: boolean, rehydrated_from_disk: boolean }}
38
+ */
39
+ export function resolveActiveCaptureContent(session, deps) {
40
+ const rawBuffer = (session && session.contentBuffer) || {};
41
+ const norm = deps.normalizeBuffer(rawBuffer);
42
+ const bufferHasContent = Boolean(norm.core || norm.summary || norm.notes);
43
+
44
+ if (bufferHasContent) {
45
+ // Buffer already carries real content — nothing to rehydrate, and this
46
+ // is by definition not a fresh/empty capture.
47
+ return { contentBuffer: rawBuffer, is_new: false, rehydrated_from_disk: false };
48
+ }
49
+
50
+ const specPath = session && session.specPath;
51
+ if (!specPath) {
52
+ // No buffer content and no materialized file → genuinely new/empty.
53
+ return { contentBuffer: rawBuffer, is_new: true, rehydrated_from_disk: false };
54
+ }
55
+
56
+ // Buffer looks empty but the capture is materialized — read the file
57
+ // before trusting "empty". This is the exact clobber precondition from
58
+ // the incident: never skip this check.
59
+ const spec = deps.loadSpec(specPath);
60
+ if (spec) {
61
+ const core = deps.extractCore(spec.body) || '';
62
+ const notes = deps.extractNotes(spec.body) || '';
63
+ if (core || notes) {
64
+ return {
65
+ contentBuffer: { ...rawBuffer, core_idea: core, notes },
66
+ is_new: false,
67
+ rehydrated_from_disk: true,
68
+ };
69
+ }
70
+ }
71
+
72
+ // Materialized but genuinely empty on disk too (or the file is
73
+ // unreadable) — still NOT a fresh capture; a spec file already exists for
74
+ // this id, so a caller must not treat it as safe to write from scratch.
75
+ return { contentBuffer: rawBuffer, is_new: false, rehydrated_from_disk: false };
76
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Build the `content_buffer` PATCH set for `heyspex_update_capture` — extracted
3
+ * from cli.mjs so the shape contract is unit-testable without booting the MCP
4
+ * stdio server (mirrors the console-fetch.mjs / capture-console-client.mjs
5
+ * factoring).
6
+ *
7
+ * Root cause this fixes ([[HS-CAP-BX1J]] empty-body bug, sibling of
8
+ * [[HS-TASK-CPLO]] / [[HS-TASK-MERC]] under [[HS-EPIC-CPMR]]): the tool used to
9
+ * emit flat strings (`user_quotes`/`core_idea`/`concept`/`agent_expansion`)
10
+ * which the console persisted verbatim but every downstream reader
11
+ * (`hasRealContent`, `renderCaptureBody`) rejected — they require the canonical
12
+ * STRUCTURED `CaptureContentBuffer` shapes (see console `types.ts`):
13
+ * - userQuotes: Array<{ at, text }>
14
+ * - coreIdea / summary / details: { at, text, confirmed }
15
+ * so the materialized markdown body rendered empty.
16
+ *
17
+ * Two correctness rules encoded here:
18
+ * 1. **Structured, camelCase paths.** `userQuotes` (array), `coreIdea`,
19
+ * `summary` (renamed from `concept`, HS-EPIC-OED2), and `details` (objects).
20
+ * `agent_expansion` is the `## Expanded Concept` deep-dive section → maps to
21
+ * `details`. `agent_note` has no body section in the v2 shape (Notes is
22
+ * user-verbatim only) → it lands in `chatTurns`, the schema's home for agent
23
+ * coordination.
24
+ * 2. **Append, not overwrite.** The console PATCH route stores top-level keys
25
+ * verbatim (last-write-wins), so multi-beat `user_quote` calls would clobber
26
+ * each other. We read the prior buffer and append, sending the full array.
27
+ *
28
+ * `confirmed: true` is set on every agent-authored section because the MCP write
29
+ * is an explicit commit — the agent only calls this after the conversational
30
+ * confirm in the /hs-capture four-beat protocol.
31
+ */
32
+
33
+ /** Keep only well-formed `{ at, text }` quote entries from a prior buffer. */
34
+ function priorUserQuotes(currentBuffer) {
35
+ const raw = currentBuffer?.userQuotes ?? currentBuffer?.user_quotes;
36
+ if (!Array.isArray(raw)) return [];
37
+ return raw.filter(
38
+ (q) => q && typeof q === 'object' && typeof q.at === 'string' && typeof q.text === 'string',
39
+ );
40
+ }
41
+
42
+ /** Keep only well-formed chat turns from a prior buffer. */
43
+ function priorChatTurns(currentBuffer) {
44
+ const raw = currentBuffer?.chatTurns ?? currentBuffer?.chat_turns;
45
+ if (!Array.isArray(raw)) return [];
46
+ return raw.filter(
47
+ (t) => t && typeof t === 'object' && typeof t.at === 'string' && typeof t.text === 'string',
48
+ );
49
+ }
50
+
51
+ function collapse(value) {
52
+ return String(value).trim().replace(/\n+/g, ' ');
53
+ }
54
+
55
+ /**
56
+ * @param {object} args - { user_quote, agent_note, core_idea, summary (alias: concept), agent_expansion, example, user_stories, kind, done_when, jtbd }
57
+ * @param {object} currentBuffer - the session's existing content_buffer (may be {})
58
+ * @param {string} iso - ISO timestamp to stamp on the new entries
59
+ * @returns {Array<{ path: string, value: unknown }>} canonical structured patches
60
+ */
61
+ export function buildCaptureBufferPatches(args = {}, currentBuffer = {}, iso) {
62
+ const patches = [];
63
+
64
+ if (args.user_quote !== undefined && collapse(args.user_quote)) {
65
+ const next = [...priorUserQuotes(currentBuffer), { at: iso, text: collapse(args.user_quote) }];
66
+ patches.push({ path: 'userQuotes', value: next });
67
+ }
68
+
69
+ if (args.agent_note !== undefined && collapse(args.agent_note)) {
70
+ const next = [
71
+ ...priorChatTurns(currentBuffer),
72
+ { at: iso, who: 'agent', text: collapse(args.agent_note) },
73
+ ];
74
+ patches.push({ path: 'chatTurns', value: next });
75
+ }
76
+
77
+ if (args.core_idea !== undefined && String(args.core_idea).trim()) {
78
+ patches.push({ path: 'coreIdea', value: { at: iso, text: String(args.core_idea).trim(), confirmed: true } });
79
+ }
80
+
81
+ // `summary` is canonical; `concept` is the deprecated alias (HS-EPIC-OED2).
82
+ const summaryArg = args.summary ?? args.concept;
83
+ if (summaryArg !== undefined && String(summaryArg).trim()) {
84
+ patches.push({ path: 'summary', value: { at: iso, text: String(summaryArg).trim(), confirmed: true } });
85
+ }
86
+
87
+ // agent_expansion is the `## Expanded Concept` deep-dive section → buffer.details.
88
+ if (args.agent_expansion !== undefined && String(args.agent_expansion).trim()) {
89
+ patches.push({ path: 'details', value: { at: iso, text: String(args.agent_expansion).trim(), confirmed: true } });
90
+ }
91
+
92
+ // example is the `## Example` section (real or AI-illustrative) → buffer.example.
93
+ if (args.example !== undefined && String(args.example).trim()) {
94
+ patches.push({ path: 'example', value: { at: iso, text: String(args.example).trim(), confirmed: true } });
95
+ }
96
+
97
+ // user_stories is the `## User Stories` section → buffer.userStories. Root cause
98
+ // of [[HS-TXBY-DIAG-W32W-heyspex-update-capture-silently-drops]]: this patch was
99
+ // never emitted, so the tool accepted user_stories, returned success, but the
100
+ // console content_buffer (and therefore the materialized file) never saw it —
101
+ // silent data loss, unlike example which has always had this push.
102
+ if (args.user_stories !== undefined && String(args.user_stories).trim()) {
103
+ patches.push({
104
+ path: 'userStories',
105
+ value: { at: iso, text: String(args.user_stories).trim(), confirmed: true },
106
+ });
107
+ }
108
+
109
+ // kind re-classifies the capture's frontmatter `kind:` (idea/bug-fix/clip/
110
+ // reference/question). Simple scalar, not a timestamped section — mirrors
111
+ // the tool schema's enum contract. Root cause of
112
+ // [[HS-NR43-DIAG-CAIO-promote-sufficiency-gate-demands-kind]]: the tool
113
+ // schema already exposed `kind` but this builder never emitted a patch for
114
+ // it, so the argument was silently dropped and the promote sufficiency gate
115
+ // (which reads frontmatter.kind) was unsatisfiable through the documented
116
+ // tool path.
117
+ if (args.kind !== undefined && String(args.kind).trim()) {
118
+ patches.push({ path: 'kind', value: String(args.kind).trim() });
119
+ }
120
+
121
+ // done_when is the `## Done When` checklist section → buffer.doneWhen, same
122
+ // {at,text,confirmed} shape as the other agent-authored sections (example,
123
+ // user_stories, details) — sibling half of the HS-NR43 fix above.
124
+ if (args.done_when !== undefined && String(args.done_when).trim()) {
125
+ patches.push({
126
+ path: 'doneWhen',
127
+ value: { at: iso, text: String(args.done_when).trim(), confirmed: true },
128
+ });
129
+ }
130
+
131
+ // jtbd is the `## JTBD (job-to-be-done)` section → buffer.jtbd, same
132
+ // {at,text,confirmed} shape as example/userStories/doneWhen. Authored by the
133
+ // /hs-capture enhancement pass; replaces the interim Expanded-Concept-leading
134
+ // -block convention (owner ruling 2026-08-23) —
135
+ // [[HS-TIE7-PR20-TASK-jtbd-job-to-be-done-field-on-captures]]. `jtbd` is
136
+ // explicitly named in `UPDATE_CAPTURE_ACCEPTED_ARGS` (capture-focus.mjs) so it
137
+ // is refused by name at the handler's first hop, never silently dropped here
138
+ // — [[additionalproperties-false-in-an-mcp]].
139
+ if (args.jtbd !== undefined && String(args.jtbd).trim()) {
140
+ patches.push({
141
+ path: 'jtbd',
142
+ value: { at: iso, text: String(args.jtbd).trim(), confirmed: true },
143
+ });
144
+ }
145
+
146
+ return patches;
147
+ }