@davesheffer/hunch 1.10.8 → 1.12.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.
package/dist/cli/index.js CHANGED
@@ -57,7 +57,7 @@ import { blockingInScope, vetoInScope, proposedEditLines } from "../core/hookpol
57
57
  import { isHumanConfirmed } from "../core/strictgate.js";
58
58
  import { appendEvent, readEvents } from "../core/events.js";
59
59
  import { computeStats, formatStats } from "../core/stats.js";
60
- import { injectionMode } from "../core/hookcache.js";
60
+ import { injectionMode, resetSessionInjections } from "../core/hookcache.js";
61
61
  import { contextHookOutput, denyHookOutput, hookProvider, normalizeHookEvent, stopHookOutput } from "../core/agenthook.js";
62
62
  import { PIPELINE_LOOP, UNVERIFIED_NAG, loadPipelineState, onCommand, onEdit, onPrompt, onSkill, pipelineEnabled, savePipelineState, stopVerdict, } from "../core/pipeline.js";
63
63
  import { draftDuplicateOf, isAcceptedDuplicateAnchor } from "../core/dupdetect.js";
@@ -3797,7 +3797,53 @@ program
3797
3797
  emitContext(provider, "UserPromptSubmit", text);
3798
3798
  return;
3799
3799
  }
3800
+ if (evt.hook_event_name === "PreCompact") {
3801
+ // Compaction is about to summarize injected grounding out of the agent's
3802
+ // context while the dedup map still says "delivered". Reset it so every
3803
+ // post-compact injection is full again. Emit nothing — this event has no
3804
+ // context channel worth spending.
3805
+ resetSessionInjections(evt.session_id);
3806
+ return;
3807
+ }
3808
+ if (evt.hook_event_name === "SubagentStart") {
3809
+ // A delegated agent starts with NONE of the parent session's grounding:
3810
+ // session orientation never fired inside it and only per-edit PreToolUse
3811
+ // follows it in — so read-only agents (Explore/Plan) could work fully
3812
+ // blind. Give it the invariants that must survive delegation. Public
3813
+ // store only; once per agent type per session.
3814
+ const s = new HunchStore(paths);
3815
+ try {
3816
+ const sevRank = { blocking: 0, warning: 1, advisory: 2 };
3817
+ const constraints = s.advisoryRecs("constraints")
3818
+ .filter((c) => c.status === "active")
3819
+ .sort((a, b) => sevRank[a.severity] - sevRank[b.severity]);
3820
+ if (!constraints.length)
3821
+ return;
3822
+ const L = [`🧠 Hunch — delegated agent grounding: ${constraints.length} invariant(s) in force in this repo.`];
3823
+ for (const c of constraints.slice(0, 8)) {
3824
+ const flat = c.statement.replace(/\s+/g, " ").trim();
3825
+ const claim = flat.length > 140 ? `${flat.slice(0, 139).trimEnd()}…` : flat;
3826
+ L.push(`- [${c.severity}] ${claim}${c.scope.length ? ` (scope: ${c.scope.slice(0, 3).join(", ")})` : ""}`);
3827
+ }
3828
+ if (constraints.length > 8)
3829
+ L.push(`…and ${constraints.length - 8} more — hunch_check_constraints(scope) for your files.`);
3830
+ L.push("Before editing: hunch_check_constraints(scope) · hunch_why(target). Orient: hunch_context(task).");
3831
+ // No dedup here: the hook event carries the PARENT session id, but each
3832
+ // spawned agent is a fresh empty context — deduping would ground the
3833
+ // first Explore and silently starve every later one.
3834
+ emitContext(provider, "SubagentStart", L.join("\n"));
3835
+ }
3836
+ finally {
3837
+ s.close();
3838
+ }
3839
+ return;
3840
+ }
3800
3841
  if (evt.hook_event_name === "SessionStart") {
3842
+ // A compact-resume means everything injected so far was just summarized
3843
+ // away — the dedup map must forget it delivered anything, or the rest of
3844
+ // the session gets delta one-liners against grounding that is gone.
3845
+ if (evt.source === "compact")
3846
+ resetSessionInjections(evt.session_id);
3801
3847
  // Orientation at the moment it matters: what just happened + what's next,
3802
3848
  // straight from the graph — the agent sits down already knowing where it
3803
3849
  // is instead of pulling (or worse, grepping) for it. Cheap reads only
@@ -77,6 +77,8 @@ function eventName(value, provider) {
77
77
  posttooluse: "PostToolUse",
78
78
  userpromptsubmit: "UserPromptSubmit",
79
79
  sessionstart: "SessionStart",
80
+ subagentstart: "SubagentStart",
81
+ precompact: "PreCompact",
80
82
  stop: "Stop",
81
83
  };
82
84
  if (map[name])
@@ -155,6 +157,8 @@ export function normalizeHookEvent(raw, provider) {
155
157
  tool_name: hunchToolName(stringAt(input, "tool_name", "toolName"), toolInput ?? {}),
156
158
  tool_input: toolInput,
157
159
  prompt: stringAt(input, "prompt", "user_prompt", "userPrompt"),
160
+ source: stringAt(input, "source"),
161
+ agent_type: stringAt(input, "agent_type", "agentType", "subagent_type", "subagentType"),
158
162
  };
159
163
  }
160
164
  /** Provider-aware hook output. Context output is intentionally omitted for
@@ -88,6 +88,19 @@ export function buildCorrectionConstraint(input, now) {
88
88
  let severity = input.severity ?? "warning";
89
89
  if (severity === "blocking" && repoWide && !input.applies_to_all)
90
90
  severity = "warning";
91
+ // AUTHORSHIP TIER. This function hardcoded provenance human_confirmed @1, and
92
+ // isStrictBlocker treats human_confirmed + blocking as a DENY — so an un-interviewed
93
+ // agent call could mint a repo-wide deny carrying a signature nobody gave. That made
94
+ // the highest-authority write path the least gated one, and strictly worse than
95
+ // hunch_record_decision, which only ever produced advisory memory and is now tiered.
96
+ //
97
+ // The token sets the TIER, never whether the write lands. An un-vouched correction is
98
+ // still recorded immediately and still held against every assistant at edit time and
99
+ // in CI — Never Twice keeps its promise. What waits for a countersign is only the
100
+ // authority to DENY.
101
+ const vouched = input.vouched !== false;
102
+ if (!vouched && severity === "blocking")
103
+ severity = "warning";
91
104
  return {
92
105
  id: constraintId(rule),
93
106
  type: input.type ?? "correctness",
@@ -101,13 +114,21 @@ export function buildCorrectionConstraint(input, now) {
101
114
  // rule that goes stale. Validated against the repo's real deps when supplied → never mints a
102
115
  // never-firing rule for a non-dependency. null when nothing derivable → falls back to scope.
103
116
  forbids: deriveForbids(rule, input.knownDeps),
104
- rationale: input.rationale ?? "Captured from a human correction of the agent (Never Twice).",
117
+ rationale: input.rationale ?? (vouched
118
+ ? "Captured from a human correction of the agent (Never Twice)."
119
+ : "Recorded by the agent as a correction, WITHOUT a capture interview — advisory testimony until a human countersigns it via /capture (Never Twice)."),
105
120
  source_decision: input.source_decision ?? null,
106
121
  violations: [],
107
122
  status: "active",
108
123
  valid_from: now,
109
124
  valid_to: null,
110
- provenance: { source: "human_confirmed", confidence: 1, evidence: [], last_verified: now },
125
+ // The signature is EARNED, not assumed. isStrictBlocker treats human_confirmed as
126
+ // authority to deny, so stamping it on an un-interviewed write forges the one thing
127
+ // the strict gate trusts. agent_recorded is the same tier hunch_record_decision uses
128
+ // for an un-token'd write — advisory, real, and honest about who wrote it.
129
+ provenance: vouched
130
+ ? { source: "human_confirmed", confidence: 1, evidence: [], last_verified: now }
131
+ : { source: "agent_recorded", confidence: 0.75, evidence: [], last_verified: now },
111
132
  };
112
133
  }
113
134
  //# sourceMappingURL=correction.js.map
@@ -1,12 +1,45 @@
1
1
  import { currentForTopic, rejectedForTopic } from "./topics.js";
2
2
  const MARKER = /<!--\s*hunch:topic\s+([A-Za-z0-9._/-]+)(?:\s+(dec_[A-Za-z0-9]+))?\s*-->/g;
3
- /** Parse every hunch:topic marker out of a markdown document. */
3
+ /** Character ranges covered by fenced code blocks (``` or ~~~), so a
4
+ * documentation EXAMPLE of a marker never registers as a live anchor.
5
+ * CommonMark-lite: a fence of N chars (≤3 leading spaces) closes only on a
6
+ * line of ≥N of the same char and nothing else; an unclosed fence runs to
7
+ * EOF; a backtick fence's info string may not itself contain a backtick. */
8
+ function fencedRanges(text) {
9
+ const ranges = [];
10
+ let open = null;
11
+ let offset = 0;
12
+ for (const line of text.split("\n")) {
13
+ const m = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
14
+ if (m) {
15
+ const ch = m[1][0];
16
+ if (!open) {
17
+ if (!(ch === "`" && m[2].includes("`")))
18
+ open = { ch, len: m[1].length, start: offset };
19
+ }
20
+ else if (ch === open.ch && m[1].length >= open.len && m[2].trim() === "") {
21
+ ranges.push([open.start, offset + line.length]);
22
+ open = null;
23
+ }
24
+ }
25
+ offset += line.length + 1;
26
+ }
27
+ if (open)
28
+ ranges.push([open.start, text.length]);
29
+ return ranges;
30
+ }
31
+ /** Parse every hunch:topic marker out of a markdown document. Markers inside
32
+ * fenced code blocks are examples, not declarations, and are skipped. */
4
33
  export function parseDocAnchors(text) {
5
34
  const out = [];
35
+ const fences = fencedRanges(text);
6
36
  MARKER.lastIndex = 0;
7
37
  let m;
8
38
  while ((m = MARKER.exec(text))) {
9
- out.push({ topic: m[1], pin: m[2] ?? null, line: text.slice(0, m.index).split("\n").length });
39
+ const at = m.index;
40
+ if (fences.some(([s, e]) => at >= s && at <= e))
41
+ continue;
42
+ out.push({ topic: m[1], pin: m[2] ?? null, line: text.slice(0, at).split("\n").length });
10
43
  }
11
44
  return out;
12
45
  }
@@ -55,6 +55,22 @@ export function injectionMode(sessionId, key, content) {
55
55
  return "full"; // grounded beats deduped, always
56
56
  }
57
57
  }
58
+ /** Forget everything injected into a session. Compaction summarizes injected
59
+ * grounding out of the agent's context while the dedup map still says
60
+ * "delivered" — so on PreCompact / SessionStart[source=compact] the map must
61
+ * reset, or post-compact edits get delta one-liners against grounding the
62
+ * agent no longer has. Never throws (same posture as injectionMode). */
63
+ export function resetSessionInjections(sessionId) {
64
+ try {
65
+ if (!sessionId)
66
+ return;
67
+ const file = join(tmpdir(), "hunch-hookcache", `${sessionId.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 80)}.json`);
68
+ rmSync(file, { force: true });
69
+ }
70
+ catch {
71
+ /* unwritable tmpdir — next injectionMode call falls back to "full" anyway */
72
+ }
73
+ }
58
74
  /** Drop session caches from long-gone sessions (best effort, bounded dir). */
59
75
  function sweep(dir) {
60
76
  try {
@@ -5,7 +5,7 @@ const clip = (s, n = 90) => (s.length > n ? s.slice(0, n - 1).trimEnd() + "…"
5
5
  function validRel(p) {
6
6
  return !!p && !p.startsWith("/") && !/^[A-Za-z]:[\\/]/.test(p) && !p.split(/[\\/]/).includes("..");
7
7
  }
8
- function checkPath(claim, rel, wantExists, env) {
8
+ function checkPath(claim, rel, wantExists, env, under) {
9
9
  if (!validRel(rel)) {
10
10
  return { claim, holds: false, reason: `unevaluable: path must be repo-relative ("${rel}") — fix the premise record` };
11
11
  }
@@ -15,9 +15,26 @@ function checkPath(claim, rel, wantExists, env) {
15
15
  ? { claim, holds: true, reason: `"${rel}" still exists` }
16
16
  : { claim, holds: false, reason: `"${rel}" no longer exists` };
17
17
  }
18
+ // NEGATIVE probe. A missing path proves nothing on its own: a deleted subtree, a
19
+ // renamed directory and a typo all read as "absent". The `under` anchor is what makes
20
+ // the answer mean something — when the ancestor is gone, the question no longer has a
21
+ // subject, so the premise is UNEVALUABLE rather than quietly satisfied. That is the
22
+ // decay this closes: it is what rotted five decisions pointing into vscode-extension/
23
+ // after that tree was cut.
24
+ if (under !== undefined) {
25
+ if (!validRel(under)) {
26
+ return { claim, holds: false, reason: `unevaluable: under must be repo-relative ("${under}") — fix the premise record` };
27
+ }
28
+ if (!env.exists(under)) {
29
+ return { claim, holds: false, reason: `unevaluable: the anchor "${under}" no longer exists, so "${rel}" being absent proves nothing — re-anchor or supersede` };
30
+ }
31
+ }
18
32
  return exists
19
33
  ? { claim, holds: false, reason: `"${rel}" now exists` }
20
- : { claim, holds: true, reason: `"${rel}" still absent` };
34
+ // The residual risk is stated, not hidden: a MISTYPED path is also absent, and it
35
+ // stays absent forever — the harm is a premise that silently never fires, which no
36
+ // existence probe can detect. Saying so is what keeps "holds" honest.
37
+ : { claim, holds: true, reason: `"${rel}" still absent${under ? ` under "${under}"` : ""} (a mistyped path also reads as absent — confirm it on re-attest)` };
21
38
  }
22
39
  function checkOne(p, env) {
23
40
  // The clock is an INJECTED input, so it is an unevaluable case like any other.
@@ -30,7 +47,7 @@ function checkOne(p, env) {
30
47
  return { claim: p.claim, holds: false, reason: `unevaluable: caller supplied a non-ISO clock ("${env.now}")` };
31
48
  }
32
49
  if (p.path_absent !== undefined)
33
- return checkPath(p.claim, p.path_absent, false, env);
50
+ return checkPath(p.claim, p.path_absent, false, env, p.under);
34
51
  if (p.path_exists !== undefined)
35
52
  return checkPath(p.claim, p.path_exists, true, env);
36
53
  if (p.review_by !== undefined) {
@@ -115,12 +115,29 @@ export const ConformancePredicateSchema = z.object({
115
115
  // (the human renews, supersedes, or retires — same ethos as topic anchors).
116
116
  export const PremiseSchema = z.object({
117
117
  claim: z.string().min(1).describe("the human-readable reason this decision rests on"),
118
- path_absent: z.string().optional().describe("premise holds while this repo-relative path does NOT exist"),
118
+ path_absent: z.string().optional().describe("premise holds while this repo-relative path does NOT exist. Requires `under`. PREFER path_exists where you can: a negative probe cannot tell 'verified absent' from 'wrong path', so it fails OPEN, while path_exists fails closed."),
119
+ under: z.string().optional().describe("required with path_absent: an EXISTING repo-relative ancestor of it. When this anchor disappears (a directory deleted or moved), the premise reads unevaluable instead of silently 'still absent'."),
119
120
  path_exists: z.string().optional().describe("premise holds while this repo-relative path exists"),
120
121
  review_by: z.string().optional().describe("dated attestation: premise holds until this ISO date, then needs re-attesting"),
121
122
  attested: z.string().optional().describe("ISO date a human last attested the claim (informational)"),
122
123
  }).refine((p) => [p.path_absent, p.path_exists, p.review_by].filter((x) => x !== undefined).length <= 1, {
123
124
  message: "a premise carries at most one check (path_absent | path_exists | review_by)",
125
+ }).refine((p) => p.path_absent === undefined || (typeof p.under === "string" && p.under.trim().length > 0), {
126
+ message: "path_absent requires `under`: an existing ancestor path. Without an anchor, a deleted or renamed subtree reads as 'still absent' forever. Prefer path_exists where you can — it fails closed.",
127
+ path: ["under"],
128
+ }).refine((p) => {
129
+ if (p.path_absent === undefined || typeof p.under !== "string")
130
+ return true;
131
+ const norm = (s) => s.replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/+$/, "");
132
+ const target = norm(p.path_absent);
133
+ const anchor = norm(p.under);
134
+ // Must be a real ANCESTOR, not an arbitrary existing path: `under` is what makes
135
+ // "absent" meaningful ("nothing named gateway UNDER src"). An unrelated anchor
136
+ // would prove the premise still evaluable while telling you nothing about it.
137
+ return anchor !== "" && target !== anchor && target.startsWith(`${anchor}/`);
138
+ }, {
139
+ message: "`under` must be a proper ancestor of `path_absent` (e.g. path_absent 'src/gateway' with under 'src')",
140
+ path: ["under"],
124
141
  });
125
142
  export const DecisionSchema = z.object({
126
143
  id: z.string().describe("dec_*"),
@@ -156,6 +156,18 @@ export function installClaudeHooks(root, hookCmd) {
156
156
  ...keep(json.hooks.SessionStart),
157
157
  { hooks: [{ type: "command", command: hookCmd }] },
158
158
  ];
159
+ // Delegated agents start with no session grounding (orientation never fired
160
+ // inside them); compaction summarizes injected grounding away while the dedup
161
+ // map still says "delivered". These two events keep delivery alive across the
162
+ // whole session lifecycle, not just its first context window.
163
+ json.hooks.SubagentStart = [
164
+ ...keep(json.hooks.SubagentStart),
165
+ { hooks: [{ type: "command", command: hookCmd }] },
166
+ ];
167
+ json.hooks.PreCompact = [
168
+ ...keep(json.hooks.PreCompact),
169
+ { hooks: [{ type: "command", command: hookCmd }] },
170
+ ];
159
171
  // Verification pipeline (core/pipeline.ts): PostToolUse records observable
160
172
  // facts (edits, verify commands); Stop refuses to end a turn with unverified
161
173
  // product edits at firm/strict firmness. Delivery is enforced, not hoped for.
@@ -739,7 +739,8 @@ export function buildServerWithRootControl(initialRoot) {
739
739
  // PremiseSchema in src/core/types.ts.
740
740
  premises: z.array(z.object({
741
741
  claim: z.string().min(1).describe("the human-readable reason this decision rests on"),
742
- path_absent: z.string().optional().describe("premise holds while this repo-relative path does NOT exist"),
742
+ path_absent: z.string().optional().describe("premise holds while this repo-relative path does NOT exist. REQUIRES `under`. Prefer path_exists where you can — a negative probe fails OPEN, a positive one fails closed."),
743
+ under: z.string().optional().describe("required with path_absent: an EXISTING repo-relative ANCESTOR of it (path_absent 'src/gateway' -> under 'src'). When the anchor disappears the premise reads unevaluable instead of silently 'still absent'."),
743
744
  path_exists: z.string().optional().describe("premise holds while this repo-relative path exists"),
744
745
  review_by: z.string().optional().describe("dated attestation: premise holds until this ISO date, then needs re-attesting"),
745
746
  attested: z.string().optional().describe("ISO date a human last attested the claim (informational)"),
@@ -941,6 +942,7 @@ export function buildServerWithRootControl(initialRoot) {
941
942
  rationale: z.string().optional().describe("Why it must hold."),
942
943
  source_decision: z.string().optional().describe("id of a decision this correction derives from."),
943
944
  private: z.boolean().optional().describe("write into the PRIVATE overlay store (HUNCH_PRIVATE_DIR) instead of the committed repo — a sensitive rule enforced locally (pre-edit hook + local check) but never exposed in a public PR comment. Errors if no private store is configured."),
945
+ capture_token: z.string().optional().describe("token from hunch_capture_decision. The rule is recorded and enforced either way — the token only decides whether it may DENY: without one it lands as advisory testimony capped at severity 'warning'."),
944
946
  },
945
947
  }, async (input) => {
946
948
  try {
@@ -950,7 +952,12 @@ export function buildServerWithRootControl(initialRoot) {
950
952
  // paths (edit-tool payloads and MCP roots are absolute) and every consumer matches
951
953
  // repo-relative — without this the rule would be blocking-but-inert and would leak
952
954
  // the local filesystem path into the committed graph.
953
- const rec = buildCorrectionConstraint({ ...input, knownDeps: knownRepoDeps(root), root }, new Date().toISOString());
955
+ // Same authorship tier as hunch_record_decision: a consumed token mints the
956
+ // signature, an un-token'd write is testimony. Here the stakes are HIGHER — a
957
+ // blocking constraint DENIES edits, so an un-vouched write is capped at
958
+ // "warning" rather than being refused. Never Twice still lands immediately.
959
+ const vouched = consumeCaptureToken(input.capture_token);
960
+ const rec = buildCorrectionConstraint({ ...input, knownDeps: knownRepoDeps(root), root, vouched }, new Date().toISOString());
954
961
  // Private corrections go to the overlay (enforced locally via the merged read,
955
962
  // never rendered into the public CI comment, which is public-only by construction).
956
963
  const home = store.captureHome(!!input.private);
@@ -983,7 +990,14 @@ export function buildServerWithRootControl(initialRoot) {
983
990
  // The Constraint itself is the durable retry queue. Normal `hunch index`
984
991
  // and post-commit sync rescan it; no in-process timer can be lost on exit.
985
992
  const reviewNote = "\n\nREVIEW PENDING: After the fix is committed, run hunch index; an installed post-commit hook retries this automatically on the fixing commit. Only the supported static ESM import-declaration package projection is eligible, and it remains activation-blocked; the immediate guard is already durable.";
986
- return ok(`${existing ? "Updated" : "Recorded"} ${rec.severity} constraint ${rec.id}: "${rec.statement}" (scope: ${rec.scope.join(", ")}).${where} It now ${enforce}.${reviewNote}`);
993
+ // Say plainly which tier this landed in. A silent downgrade would be its own
994
+ // dishonesty: the caller asked for "blocking" and must be told it is not.
995
+ const tierNote = vouched
996
+ ? ""
997
+ : `
998
+
999
+ ⚠ Recorded WITHOUT a capture interview — this rule is agent_recorded TESTIMONY${input.severity === "blocking" ? ' and was capped from "blocking" to "warning"' : ""}. It IS enforced: the pre-edit hook and CI surface it on every matching edit from now on. What it cannot do is DENY an edit — only a rule a human countersigned may block. Countersign it by re-recording through hunch_capture_decision → hunch_record_correction(capture_token).`;
1000
+ return ok(`${existing ? "Updated" : "Recorded"} ${rec.severity} constraint ${rec.id}: "${rec.statement}" (scope: ${rec.scope.join(", ")}).${where} It now ${enforce}.${reviewNote}${tierNote}`);
987
1001
  }
988
1002
  catch (e) {
989
1003
  return err(`Failed to record correction: ${e.message}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davesheffer/hunch",
3
- "version": "1.10.8",
3
+ "version": "1.12.0",
4
4
  "mcpName": "io.github.davesheffer/hunch",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Dave Sheffer <dave.sheffer1@gmail.com>",
package/server.json CHANGED
@@ -1,19 +1,19 @@
1
1
  {
2
- "$schema": "https://static.modelcontextprotocol.io/schemas/2025-09-29/server.schema.json",
2
+ "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
3
  "name": "io.github.davesheffer/hunch",
4
- "description": "Engineering memory for AI-assisted codebases: decisions, rejected alternatives, bug lineage, and deterministic architectural gates, served git-natively over MCP.",
4
+ "description": "Engineering memory for AI-assisted codebases: decisions, bug lineage and invariants, over MCP.",
5
5
  "repository": {
6
6
  "url": "https://github.com/davesheffer/hunch",
7
7
  "source": "github"
8
8
  },
9
9
  "websiteUrl": "https://hunch-pi.vercel.app",
10
- "version": "1.10.8",
10
+ "version": "1.12.0",
11
11
  "packages": [
12
12
  {
13
13
  "registryType": "npm",
14
14
  "registryBaseUrl": "https://registry.npmjs.org",
15
15
  "identifier": "@davesheffer/hunch",
16
- "version": "1.10.8",
16
+ "version": "1.12.0",
17
17
  "runtimeHint": "npx",
18
18
  "packageArguments": [
19
19
  {