@qvac/harness 0.2.7 → 0.2.8

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 (149) hide show
  1. package/.claude/skills/pr-review/SKILL.md +237 -40
  2. package/dist/harness.bundle +10 -9
  3. package/dist-lib/index.js +58 -58
  4. package/dist-lib/lib/audio-transcode-bare.js +2 -2
  5. package/dist-lib/lib/capabilities/completion.js +30 -30
  6. package/dist-lib/lib/capabilities/download.js +2 -2
  7. package/dist-lib/lib/capabilities/speech-text.js +1 -1
  8. package/dist-lib/lib/capabilities/tool-prompt-log.js +1 -1
  9. package/dist-lib/lib/capabilities/tool-selection.js +2 -2
  10. package/dist-lib/lib/capabilities/tool-turns.js +1 -1
  11. package/dist-lib/lib/capabilities/usage-breakdown.js +1 -1
  12. package/dist-lib/lib/capabilities/voice.js +3 -3
  13. package/dist-lib/lib/connect.js +11 -11
  14. package/dist-lib/lib/context-window.js +1 -1
  15. package/dist-lib/lib/docx/index.js +3 -3
  16. package/dist-lib/lib/hardware/capability-probe.js +3 -3
  17. package/dist-lib/lib/hardware/gpu-probe-addon.js +2 -2
  18. package/dist-lib/lib/hardware/gpu-probe-entry.js +1 -1
  19. package/dist-lib/lib/helpers/markup-stream-filter.js +1 -1
  20. package/dist-lib/lib/helpers/run-outcome.js +1 -1
  21. package/dist-lib/lib/helpers/spawn-path.d.ts +1 -1
  22. package/dist-lib/lib/helpers/thinking.js +2 -2
  23. package/dist-lib/lib/helpers/tool-intent.js +1 -1
  24. package/dist-lib/lib/helpers/tool-label.js +3 -3
  25. package/dist-lib/lib/hooks/built-in/arg-repair.d.ts +1 -1
  26. package/dist-lib/lib/hooks/built-in/arg-repair.js +1 -1
  27. package/dist-lib/lib/hooks/built-in/egress.d.ts +1 -1
  28. package/dist-lib/lib/hooks/built-in/egress.js +2 -2
  29. package/dist-lib/lib/hooks/built-in/index.js +3 -3
  30. package/dist-lib/lib/hooks/built-in/skill-catalog.d.ts +1 -1
  31. package/dist-lib/lib/hooks/pipeline.js +4 -4
  32. package/dist-lib/lib/hooks/registry.js +1 -1
  33. package/dist-lib/lib/hooks/run.js +1 -1
  34. package/dist-lib/lib/models/model-catalog.js +4 -4
  35. package/dist-lib/lib/models/model-delete.js +1 -1
  36. package/dist-lib/lib/models/model-handle.js +1 -1
  37. package/dist-lib/lib/models/model-list.js +1 -1
  38. package/dist-lib/lib/models/model-pool.js +8 -8
  39. package/dist-lib/lib/models/model-source.js +1 -1
  40. package/dist-lib/lib/models/music-catalog.d.ts +1 -1
  41. package/dist-lib/lib/no-redirect-fetch-bare.js +3 -3
  42. package/dist-lib/lib/no-redirect-fetch-impl.js +1 -1
  43. package/dist-lib/lib/no-redirect-fetch-node.js +2 -2
  44. package/dist-lib/lib/resolution-detector.js +1 -1
  45. package/dist-lib/lib/sdk-loader.js +2 -2
  46. package/dist-lib/lib/sdk-storage.js +1 -1
  47. package/dist-lib/lib/serve.js +10 -10
  48. package/dist-lib/lib/spawn.js +3 -3
  49. package/dist-lib/lib/staging.js +1 -1
  50. package/dist-lib/lib/tools/catalog.js +1 -1
  51. package/dist-lib/lib/tools/create-documents.js +2 -2
  52. package/dist-lib/lib/tools/edit-documents.js +2 -2
  53. package/dist-lib/lib/tools/edit-image.js +2 -2
  54. package/dist-lib/lib/tools/exec/adapters/python/attachments.js +2 -2
  55. package/dist-lib/lib/tools/exec/adapters/python/create-effect.js +1 -1
  56. package/dist-lib/lib/tools/exec/adapters/python/loader.js +1 -1
  57. package/dist-lib/lib/tools/exec/adapters/python/memfs.js +2 -2
  58. package/dist-lib/lib/tools/exec/adapters/python/pyodide.js +6 -6
  59. package/dist-lib/lib/tools/exec/adapters/python/python-adapter.js +3 -3
  60. package/dist-lib/lib/tools/exec/adapters/python/run-session.js +5 -5
  61. package/dist-lib/lib/tools/exec/adapters/python/wheels.js +1 -1
  62. package/dist-lib/lib/tools/exec/adapters/python/worker/client.js +3 -3
  63. package/dist-lib/lib/tools/exec/adapters/python/worker/entry.js +4 -4
  64. package/dist-lib/lib/tools/exec/adapters/shell/shell-adapter.js +1 -1
  65. package/dist-lib/lib/tools/exec/obsidian/cli-process.js +2 -2
  66. package/dist-lib/lib/tools/exec/obsidian/cli.js +4 -4
  67. package/dist-lib/lib/tools/exec/obsidian/command.js +1 -1
  68. package/dist-lib/lib/tools/exec/obsidian/route.js +7 -7
  69. package/dist-lib/lib/tools/exec/obsidian/vault-create.js +2 -2
  70. package/dist-lib/lib/tools/exec/obsidian/vault-fs.js +6 -6
  71. package/dist-lib/lib/tools/exec/obsidian/vault-paths.js +2 -2
  72. package/dist-lib/lib/tools/exec/obsidian/vault-read.js +1 -1
  73. package/dist-lib/lib/tools/exec/obsidian/vault-write.js +2 -2
  74. package/dist-lib/lib/tools/exec/runner.js +1 -1
  75. package/dist-lib/lib/tools/exec.js +13 -13
  76. package/dist-lib/lib/tools/file-glob.js +3 -3
  77. package/dist-lib/lib/tools/fs/local.js +1 -1
  78. package/dist-lib/lib/tools/fs/remote.js +1 -1
  79. package/dist-lib/lib/tools/fs/walk.js +2 -2
  80. package/dist-lib/lib/tools/generate-image.js +4 -4
  81. package/dist-lib/lib/tools/generate-music.js +6 -6
  82. package/dist-lib/lib/tools/grep.js +6 -6
  83. package/dist-lib/lib/tools/http-request/truncate-response.js +1 -1
  84. package/dist-lib/lib/tools/http-request.js +9 -9
  85. package/dist-lib/lib/tools/mcp/utils.js +1 -1
  86. package/dist-lib/lib/tools/mcp-call.js +8 -8
  87. package/dist-lib/lib/tools/operations/builders.d.ts +1 -1
  88. package/dist-lib/lib/tools/operations/builders.js +1 -1
  89. package/dist-lib/lib/tools/operations/compile.js +1 -1
  90. package/dist-lib/lib/tools/operations/manifest.d.ts +16 -16
  91. package/dist-lib/lib/tools/operations/manifest.js +1 -1
  92. package/dist-lib/lib/tools/outbound-guard.js +1 -1
  93. package/dist-lib/lib/tools/rag-search.js +1 -1
  94. package/dist-lib/lib/tools/read-files.js +3 -3
  95. package/dist-lib/lib/tools/skill.js +8 -8
  96. package/dist-lib/lib/tools/skills/auth-resolver.js +2 -2
  97. package/dist-lib/lib/tools/skills/bin-check.js +1 -1
  98. package/dist-lib/lib/tools/skills/bundled-dir.js +2 -2
  99. package/dist-lib/lib/tools/skills/catalog.js +10 -10
  100. package/dist-lib/lib/tools/skills/cli-schema-check.js +1 -1
  101. package/dist-lib/lib/tools/skills/cli-schema-load.js +1 -1
  102. package/dist-lib/lib/tools/skills/cli-schema.js +4 -4
  103. package/dist-lib/lib/tools/skills/credential-check.js +3 -3
  104. package/dist-lib/lib/tools/skills/credential-grants.js +1 -1
  105. package/dist-lib/lib/tools/skills/credential-health.js +2 -2
  106. package/dist-lib/lib/tools/skills/frontmatter-parser.js +1 -1
  107. package/dist-lib/lib/tools/skills/index.js +5 -5
  108. package/dist-lib/lib/tools/skills/mention.js +1 -1
  109. package/dist-lib/lib/tools/skills/overlay-crud.js +1 -1
  110. package/dist-lib/lib/tools/skills/setup-routes.js +1 -1
  111. package/dist-lib/lib/tools/skills/skill-settings.js +1 -1
  112. package/dist-lib/lib/tools/skills/skill-state-constants.d.ts +8 -8
  113. package/dist-lib/lib/tools/skills/skill-state.js +9 -9
  114. package/dist-lib/lib/tools/toolsets.d.ts +1 -1
  115. package/dist-lib/lib/tools/url-guard.js +2 -2
  116. package/dist-lib/lib/tools/weather-lookup/place.d.ts +11 -0
  117. package/dist-lib/lib/tools/weather-lookup/place.js +152 -0
  118. package/dist-lib/lib/tools/weather-lookup/report.d.ts +37 -0
  119. package/dist-lib/lib/tools/weather-lookup/report.js +56 -0
  120. package/dist-lib/lib/tools/weather-lookup.d.ts +8 -0
  121. package/dist-lib/lib/tools/weather-lookup.js +110 -0
  122. package/dist-lib/lib/tools/web-fetch.js +10 -10
  123. package/dist-lib/lib/tools/web-search.js +2 -2
  124. package/dist-lib/lib/transport.js +1 -1
  125. package/dist-lib/lib/validating-lookup.js +1 -1
  126. package/dist-lib/lib/web-fetch/embedded-state.js +1 -1
  127. package/dist-lib/lib/web-fetch/extract.js +1 -1
  128. package/dist-lib/lib/web-fetch/images.js +1 -1
  129. package/dist-lib/lib/web-fetch/video.js +1 -1
  130. package/dist-lib/lib/web-search/engine-registry.js +7 -7
  131. package/dist-lib/lib/web-search/engines/aol.js +3 -3
  132. package/dist-lib/lib/web-search/engines/bing.js +4 -4
  133. package/dist-lib/lib/web-search/engines/brave.js +2 -2
  134. package/dist-lib/lib/web-search/engines/duckduckgo.js +3 -3
  135. package/dist-lib/lib/web-search/engines/google.js +4 -4
  136. package/dist-lib/lib/web-search/engines/wikipedia.js +2 -2
  137. package/dist-lib/lib/web-search/engines/yahoo.js +3 -3
  138. package/dist-lib/lib/web-search/fetch-html.js +1 -1
  139. package/dist-lib/lib/web-search/merge-results.js +1 -1
  140. package/dist-lib/lib/web-search/orchestrator.js +3 -3
  141. package/dist-lib/schema/baseline.js +1 -1
  142. package/dist-lib/schema/build.js +3 -3
  143. package/dist-lib/schema/release-tags.d.ts +2 -1
  144. package/dist-lib/schema/release-tags.js +9 -7
  145. package/docs/decisions/2026-09-10-skill-wording-is-measured-offline.md +63 -0
  146. package/docs/decisions/2026-09-16-a-lookup-reports-the-place-it-resolved.md +70 -0
  147. package/docs/decisions/INDEX.md +17 -15
  148. package/package.json +16 -3
  149. package/worker/harness-sidecar.mjs +2 -0
@@ -0,0 +1,56 @@
1
+ import { describePlace } from './place.js';
2
+ const MAX_DAYS = 3;
3
+ const DAY_LABELS = ['today', 'tomorrow'];
4
+ const RANGE = '\u2013';
5
+ const NOON = 4;
6
+ function firstValue(values) {
7
+ return values?.[0]?.value?.trim() ?? '';
8
+ }
9
+ export function placeOf(json) {
10
+ const area = json.nearest_area?.[0];
11
+ if (area === undefined)
12
+ return null;
13
+ const place = {
14
+ area: firstValue(area.areaName),
15
+ region: firstValue(area.region),
16
+ country: firstValue(area.country)
17
+ };
18
+ return place.area || place.region || place.country ? place : null;
19
+ }
20
+ export function renderReport(json, place, asked, verified) {
21
+ const lines = [headline(place, asked, verified)];
22
+ const now = json.current_condition?.[0];
23
+ if (now !== undefined)
24
+ lines.push(currentLine(now));
25
+ const days = json.weather?.slice(0, MAX_DAYS) ?? [];
26
+ for (const [index, day] of days.entries())
27
+ lines.push(dailyLine(day, index));
28
+ return lines.join('\n');
29
+ }
30
+ function headline(place, asked, verified) {
31
+ const resolved = describePlace(place);
32
+ if (verified)
33
+ return resolved;
34
+ return `${resolved} (not "${asked}" — this is where it resolved)`;
35
+ }
36
+ function currentLine(now) {
37
+ const parts = [`Now: ${now.temp_C ?? '?'}°C / ${now.temp_F ?? '?'}°F`];
38
+ const desc = firstValue(now.weatherDesc);
39
+ if (desc)
40
+ parts.push(desc);
41
+ if (now.FeelsLikeC !== undefined)
42
+ parts.push(`feels like ${now.FeelsLikeC}°C`);
43
+ if (now.humidity !== undefined)
44
+ parts.push(`humidity ${now.humidity}%`);
45
+ if (now.windspeedKmph !== undefined)
46
+ parts.push(`wind ${now.windspeedKmph} km/h`);
47
+ return parts.join(', ');
48
+ }
49
+ function dailyLine(day, index) {
50
+ const label = DAY_LABELS[index];
51
+ const date = `${day.date ?? '?'}${label === undefined ? '' : ` (${label})`}`;
52
+ const celsius = `${day.mintempC ?? '?'}${RANGE}${day.maxtempC ?? '?'}°C`;
53
+ const fahrenheit = `${day.mintempF ?? '?'}${RANGE}${day.maxtempF ?? '?'}°F`;
54
+ const desc = firstValue(day.hourly?.[NOON]?.weatherDesc);
55
+ return `${date}: ${celsius} / ${fahrenheit}${desc ? `, ${desc}` : ''}`;
56
+ }
@@ -0,0 +1,8 @@
1
+ import type { ToolDefinition } from '../capabilities/completion.ts';
2
+ import type { FetchFn } from './http-request.ts';
3
+ import { type HostResolver } from './url-guard.ts';
4
+ export interface WeatherLookupToolOptions {
5
+ fetch?: FetchFn;
6
+ resolveHost?: HostResolver;
7
+ }
8
+ export declare function createWeatherLookupTool({ fetch: fetchImpl, resolveHost }?: WeatherLookupToolOptions): ToolDefinition;
@@ -0,0 +1,110 @@
1
+ import defaultFetch from '#no-redirect-fetch';
2
+ import { AbortController } from 'bare-abort-controller';
3
+ import { lookupHost } from '../dns-lookup.js';
4
+ import { isRedirectStatus } from './http-request/utils.js';
5
+ import { resolveOutboundUrl } from './url-guard.js';
6
+ import { checkQualifier, cityMatches, describePlace, qualifierOf } from './weather-lookup/place.js';
7
+ import { placeOf, renderReport } from './weather-lookup/report.js';
8
+ const WEATHER_HOST = 'wttr.in';
9
+ const LOOKUP_TIMEOUT_MS = 15_000;
10
+ const MAX_BODY_BYTES = 400_000;
11
+ export function createWeatherLookupTool({ fetch: fetchImpl, resolveHost = lookupHost } = {}) {
12
+ return {
13
+ name: 'weather_lookup',
14
+ toolset: 'http-request',
15
+ skillGated: true,
16
+ autoApprove: true,
17
+ description: 'Look up the current weather and a three-day forecast for a place, via wttr.in. ' +
18
+ 'Returns the place the service resolved on its first line, then the conditions now and ' +
19
+ 'one line per day. Use it for any question about weather, temperature or forecast.',
20
+ parameters: {
21
+ type: 'object',
22
+ properties: {
23
+ location: {
24
+ type: 'string',
25
+ description: 'The place exactly as the user wrote it, including any country or state: ' +
26
+ '"Nassau, Bahamas", "Paris, Texas", "London"'
27
+ }
28
+ },
29
+ required: ['location']
30
+ },
31
+ effect: () => 'read',
32
+ resource: () => ({ kind: 'host', label: WEATHER_HOST }),
33
+ async execute(args, ctx) {
34
+ const raw = args.location;
35
+ if (typeof raw !== 'string' || raw.trim().length === 0) {
36
+ return { error: 'location must be a non-empty string' };
37
+ }
38
+ const location = raw.trim();
39
+ if (ctx?.signal?.aborted)
40
+ return { error: 'weather_lookup cancelled' };
41
+ const url = new URL(`https://${WEATHER_HOST}/${encodeURIComponent(location)}?format=j1`);
42
+ const fetch = fetchImpl ?? defaultFetch;
43
+ const controller = new AbortController();
44
+ const timeout = setTimeout(() => controller.abort(undefined), LOOKUP_TIMEOUT_MS);
45
+ const onCallerAbort = () => controller.abort(undefined);
46
+ ctx?.signal?.addEventListener('abort', onCallerAbort);
47
+ try {
48
+ const check = await resolveOutboundUrl(url, resolveHost, ctx?.signal);
49
+ if (check.blocked)
50
+ return { error: check.blocked };
51
+ const response = await fetch(url, {
52
+ method: 'GET',
53
+ headers: { Accept: 'application/json' },
54
+ signal: controller.signal,
55
+ redirect: 'manual',
56
+ validatedAddresses: check.addresses,
57
+ maxBytes: MAX_BODY_BYTES
58
+ });
59
+ if (isRedirectStatus(response.status)) {
60
+ return { error: `${WEATHER_HOST} redirected the lookup for "${location}"` };
61
+ }
62
+ const body = await response.text();
63
+ if (/location not found/i.test(body))
64
+ return { content: notFound(location) };
65
+ if (response.status !== 200) {
66
+ return { error: unavailable(`it answered HTTP ${response.status}`) };
67
+ }
68
+ let json;
69
+ try {
70
+ json = JSON.parse(body);
71
+ }
72
+ catch {
73
+ return { error: unavailable('it did not return a forecast') };
74
+ }
75
+ const place = placeOf(json);
76
+ if (place === null)
77
+ return { content: notFound(location) };
78
+ const qualifier = qualifierOf(location);
79
+ const verdict = qualifier === null ? null : checkQualifier(qualifier, place);
80
+ const named = cityMatches(location, place);
81
+ if (verdict === 'contradiction' && !named) {
82
+ return {
83
+ content: `No such place: "${location}". ${WEATHER_HOST} resolved it to ` +
84
+ `${describePlace(place)}, which is somewhere else.`
85
+ };
86
+ }
87
+ const verified = qualifier === null ? named : verdict === 'match';
88
+ return { content: renderReport(json, place, location, verified) };
89
+ }
90
+ catch (err) {
91
+ if (ctx?.signal?.aborted)
92
+ return { error: 'weather_lookup cancelled' };
93
+ if (controller.signal.aborted) {
94
+ return { error: `weather_lookup timed out after ${LOOKUP_TIMEOUT_MS} ms` };
95
+ }
96
+ return { error: unavailable(err instanceof Error ? err.message : String(err)) };
97
+ }
98
+ finally {
99
+ clearTimeout(timeout);
100
+ ctx?.signal?.removeEventListener('abort', onCallerAbort);
101
+ }
102
+ }
103
+ };
104
+ }
105
+ function unavailable(reason) {
106
+ return `The weather service could not be reached: ${reason}.`;
107
+ }
108
+ function notFound(location) {
109
+ return `No such place: "${location}". ${WEATHER_HOST} does not know it.`;
110
+ }
@@ -1,15 +1,15 @@
1
1
  import defaultFetch from '#no-redirect-fetch';
2
2
  import { AbortController } from 'bare-abort-controller';
3
- import { lookupHost } from "../dns-lookup.js";
4
- import { AbortedError } from "../helpers/race-abort.js";
5
- import { extractEmbeddedState } from "../web-fetch/embedded-state.js";
6
- import { extractPageText, looksLikeHtml } from "../web-fetch/extract.js";
7
- import { imageFileName, pageImageUrls } from "../web-fetch/images.js";
8
- import { pageVideoRef } from "../web-fetch/video.js";
9
- import { MAX_HTML_CHARS } from "../web-search/fetch-html.js";
10
- import { BROWSER_UA } from "../web-search/user-agents.js";
11
- import { isRedirectStatus } from "./http-request/utils.js";
12
- import { resolveOutboundUrl } from "./url-guard.js";
3
+ import { lookupHost } from '../dns-lookup.js';
4
+ import { AbortedError } from '../helpers/race-abort.js';
5
+ import { extractEmbeddedState } from '../web-fetch/embedded-state.js';
6
+ import { extractPageText, looksLikeHtml } from '../web-fetch/extract.js';
7
+ import { imageFileName, pageImageUrls } from '../web-fetch/images.js';
8
+ import { pageVideoRef } from '../web-fetch/video.js';
9
+ import { MAX_HTML_CHARS } from '../web-search/fetch-html.js';
10
+ import { BROWSER_UA } from '../web-search/user-agents.js';
11
+ import { isRedirectStatus } from './http-request/utils.js';
12
+ import { resolveOutboundUrl } from './url-guard.js';
13
13
  const SMALL_CONTEXT_TOKENS = 12_288;
14
14
  const SMALL_CONTEXT_CHARS = 6_000;
15
15
  const LARGE_CONTEXT_CHARS = 16_000;
@@ -1,6 +1,6 @@
1
1
  import defaultFetch from '#fetch';
2
- import { formatResultsForPrompt } from "../web-search/format-results.js";
3
- import { searchWeb } from "../web-search/orchestrator.js";
2
+ import { formatResultsForPrompt } from '../web-search/format-results.js';
3
+ import { searchWeb } from '../web-search/orchestrator.js';
4
4
  const nodeFetch = async (url, init) => {
5
5
  const res = await defaultFetch(url, init);
6
6
  return { ok: res.ok, status: res.status, text: () => res.text() };
@@ -1,6 +1,6 @@
1
1
  import c from 'compact-encoding';
2
2
  import { Duplex } from 'streamx';
3
- export { settleOnClose } from "./rpc-session.js";
3
+ export { settleOnClose } from './rpc-session.js';
4
4
  export function clientSessions(pipe) {
5
5
  let current = session(pipe);
6
6
  pipe.on('data', (chunk) => current.push(chunk));
@@ -1,4 +1,4 @@
1
- import { isPrivateOrBlocked } from "./tools/private-ip.js";
1
+ import { isPrivateOrBlocked } from './tools/private-ip.js';
2
2
  export class BlockedAddressError extends Error {
3
3
  code = 'EBLOCKEDADDR';
4
4
  constructor(hostname, address) {
@@ -1,4 +1,4 @@
1
- import { decodeHtmlEntities } from "../web-search/html-helpers.js";
1
+ import { decodeHtmlEntities } from '../web-search/html-helpers.js';
2
2
  const LDJSON_SCRIPT = /<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi;
3
3
  const JSON_SCRIPT = /<script[^>]*type=["']application\/json["'][^>]*>([\s\S]*?)<\/script>/gi;
4
4
  const INLINE_SCRIPT = /<script\b(?![^>]*\bsrc=)(?![^>]*type=["']application\/(?:ld\+)?json["'])[^>]*>([\s\S]*?)<\/script>/gi;
@@ -1,4 +1,4 @@
1
- import { decodeHtmlEntities, stripHtmlTags } from "../web-search/html-helpers.js";
1
+ import { decodeHtmlEntities, stripHtmlTags } from '../web-search/html-helpers.js';
2
2
  const NOISE_BLOCKS = [
3
3
  'script',
4
4
  'style',
@@ -1,4 +1,4 @@
1
- import { decodeHtmlEntities } from "../web-search/html-helpers.js";
1
+ import { decodeHtmlEntities } from '../web-search/html-helpers.js';
2
2
  const META_TAG = /<meta\s[^>]*>/gi;
3
3
  const IMAGE_ATTR = /(?:property|name)=["'](?:og:image(?::secure_url|:url)?|twitter:image)["']/i;
4
4
  const META_CONTENT = /content=["']([^"']+)["']/i;
@@ -1,4 +1,4 @@
1
- import { decodeHtmlEntities } from "../web-search/html-helpers.js";
1
+ import { decodeHtmlEntities } from '../web-search/html-helpers.js';
2
2
  const META_TAG = /<meta\s[^>]*>/gi;
3
3
  const VIDEO_ATTR = /(?:property|name)=["']og:video(?::secure_url|:url)?["']/i;
4
4
  const META_CONTENT = /content=["']([^"']+)["']/i;
@@ -1,10 +1,10 @@
1
- import { searchAol } from "./engines/aol.js";
2
- import { searchBing } from "./engines/bing.js";
3
- import { searchBrave } from "./engines/brave.js";
4
- import { searchDuckDuckGo } from "./engines/duckduckgo.js";
5
- import { searchGoogle } from "./engines/google.js";
6
- import { searchWikipedia } from "./engines/wikipedia.js";
7
- import { searchYahoo } from "./engines/yahoo.js";
1
+ import { searchAol } from './engines/aol.js';
2
+ import { searchBing } from './engines/bing.js';
3
+ import { searchBrave } from './engines/brave.js';
4
+ import { searchDuckDuckGo } from './engines/duckduckgo.js';
5
+ import { searchGoogle } from './engines/google.js';
6
+ import { searchWikipedia } from './engines/wikipedia.js';
7
+ import { searchYahoo } from './engines/yahoo.js';
8
8
  export const DEFAULT_MAX_RESULTS = 8;
9
9
  export const ENGINES = [
10
10
  { name: 'duckduckgo', fn: searchDuckDuckGo },
@@ -1,6 +1,6 @@
1
- import { fetchHtml } from "../fetch-html.js";
2
- import { clean } from "../html-helpers.js";
3
- import { urlMatchesHost } from "../url-helpers.js";
1
+ import { fetchHtml } from '../fetch-html.js';
2
+ import { clean } from '../html-helpers.js';
3
+ import { urlMatchesHost } from '../url-helpers.js';
4
4
  function decodeRuUrl(encoded) {
5
5
  try {
6
6
  const href = decodeURIComponent(encoded);
@@ -1,7 +1,7 @@
1
- import { fetchHtml } from "../fetch-html.js";
2
- import { clean } from "../html-helpers.js";
3
- import { urlMatchesHost } from "../url-helpers.js";
4
- import { SAFARI_UA } from "../user-agents.js";
1
+ import { fetchHtml } from '../fetch-html.js';
2
+ import { clean } from '../html-helpers.js';
3
+ import { urlMatchesHost } from '../url-helpers.js';
4
+ import { SAFARI_UA } from '../user-agents.js';
5
5
  function isOrganicBingUrl(url) {
6
6
  return url.startsWith('http') && !urlMatchesHost(url, 'bing.com') && !url.includes('msockid=');
7
7
  }
@@ -1,5 +1,5 @@
1
- import { fetchHtml } from "../fetch-html.js";
2
- import { clean } from "../html-helpers.js";
1
+ import { fetchHtml } from '../fetch-html.js';
2
+ import { clean } from '../html-helpers.js';
3
3
  function parseSnippetBlock(inner) {
4
4
  const urlMatch = inner.match(/<a[^>]*href="(https?:\/\/(?!imgs\.search\.brave)[^"]*)"[^>]*>/i);
5
5
  if (!urlMatch) {
@@ -1,6 +1,6 @@
1
- import { fetchHtml } from "../fetch-html.js";
2
- import { clean } from "../html-helpers.js";
3
- import { urlMatchesHost } from "../url-helpers.js";
1
+ import { fetchHtml } from '../fetch-html.js';
2
+ import { clean } from '../html-helpers.js';
3
+ import { urlMatchesHost } from '../url-helpers.js';
4
4
  function extractDdgUrl(raw) {
5
5
  if (raw.includes('uddg=')) {
6
6
  const m = raw.match(/uddg=([^&]+)/);
@@ -1,7 +1,7 @@
1
- import { fetchHtml } from "../fetch-html.js";
2
- import { clean } from "../html-helpers.js";
3
- import { urlMatchesHost } from "../url-helpers.js";
4
- import { GOOGLE_GSA_UA } from "../user-agents.js";
1
+ import { fetchHtml } from '../fetch-html.js';
2
+ import { clean } from '../html-helpers.js';
3
+ import { urlMatchesHost } from '../url-helpers.js';
4
+ import { GOOGLE_GSA_UA } from '../user-agents.js';
5
5
  function resolveGoogleUrl(rawUrl) {
6
6
  if (rawUrl.startsWith('/url?q=')) {
7
7
  return decodeURIComponent(rawUrl.slice(7).split('&')[0] || '');
@@ -1,5 +1,5 @@
1
- import { fetchJson } from "../fetch-html.js";
2
- import { clean } from "../html-helpers.js";
1
+ import { fetchJson } from '../fetch-html.js';
2
+ import { clean } from '../html-helpers.js';
3
3
  function extractSearchItems(data) {
4
4
  if (typeof data !== 'object' || data === null) {
5
5
  return [];
@@ -1,6 +1,6 @@
1
- import { fetchHtml } from "../fetch-html.js";
2
- import { clean } from "../html-helpers.js";
3
- import { urlMatchesHost } from "../url-helpers.js";
1
+ import { fetchHtml } from '../fetch-html.js';
2
+ import { clean } from '../html-helpers.js';
3
+ import { urlMatchesHost } from '../url-helpers.js';
4
4
  function decodeRuUrl(encoded) {
5
5
  try {
6
6
  const href = decodeURIComponent(encoded);
@@ -1,5 +1,5 @@
1
1
  import { AbortController } from 'bare-abort-controller';
2
- import { BROWSER_UA } from "./user-agents.js";
2
+ import { BROWSER_UA } from './user-agents.js';
3
3
  export const ENGINE_TIMEOUT_MS = 8000;
4
4
  export const MAX_HTML_CHARS = 2_000_000;
5
5
  export class TimeoutError extends Error {
@@ -1,4 +1,4 @@
1
- import { normalizeUrl } from "./url-normalize.js";
1
+ import { normalizeUrl } from './url-normalize.js';
2
2
  const WEAK_RESULT_PENALTY = 0.2;
3
3
  function scoreFor(r) {
4
4
  const base = 1 / r.position;
@@ -1,6 +1,6 @@
1
- import { DEFAULT_MAX_RESULTS, ENGINES } from "./engine-registry.js";
2
- import { TimeoutError } from "./fetch-html.js";
3
- import { mergeResults } from "./merge-results.js";
1
+ import { DEFAULT_MAX_RESULTS, ENGINES } from './engine-registry.js';
2
+ import { TimeoutError } from './fetch-html.js';
3
+ import { mergeResults } from './merge-results.js';
4
4
  export function classifyEngineError(reason) {
5
5
  return reason instanceof TimeoutError ? 'timeout' : 'error';
6
6
  }
@@ -2,7 +2,7 @@ import fs from 'bare-fs';
2
2
  import path from 'bare-path';
3
3
  import process from 'bare-process';
4
4
  import { spawnSync } from 'bare-subprocess';
5
- import { previousReleaseTags } from "./release-tags.js";
5
+ import { previousReleaseTags } from './release-tags.js';
6
6
  export function rewindSpec(root, dirs, { carries, keep }) {
7
7
  if (process.env.SCHEMA_NO_BASELINE)
8
8
  return [];
@@ -3,9 +3,9 @@ import { fileURLToPath } from 'bare-url';
3
3
  import HRPCBuilder from 'hrpc';
4
4
  import Hyperschema from 'hyperschema';
5
5
  import HyperschemaTS from 'hyperschema-ts';
6
- import { resetHrpcIds, rewindSpec, unstageSpec } from "./baseline.js";
7
- import { writeBind, writeClient } from "./generate.js";
8
- import { registerHarnessApi, registerHarnessTypes } from "./harness.js";
6
+ import { resetHrpcIds, rewindSpec, unstageSpec } from './baseline.js';
7
+ import { writeBind, writeClient } from './generate.js';
8
+ import { registerHarnessApi, registerHarnessTypes } from './harness.js';
9
9
  const dir = path.dirname(fileURLToPath(import.meta.url));
10
10
  const ROOT = path.join(dir, '..');
11
11
  const SPEC_DIR = path.join(dir, '..', 'spec');
@@ -1,4 +1,5 @@
1
- export declare function currentReleaseLine(root: string): string;
1
+ import { Version } from 'bare-semver';
2
+ export declare function currentReleaseVersion(root: string): Version;
2
3
  export declare function previousReleaseTags(root: string, { count, carries }: {
3
4
  count?: number;
4
5
  carries: string;
@@ -2,24 +2,26 @@ import fs from 'fs';
2
2
  import path from 'path';
3
3
  import { spawnSync } from 'child_process';
4
4
  import { Version } from 'bare-semver';
5
- export function currentReleaseLine(root) {
5
+ export function currentReleaseVersion(root) {
6
6
  const file = path.join(root, 'package.json');
7
7
  const { version } = JSON.parse(fs.readFileSync(file, 'utf8'));
8
- const parsed = /^(\d+)\.(\d+)/.exec(version);
9
- if (!parsed)
10
- throw new Error(`no release line in package version ${version}`);
11
- return `${parsed[1]}.${parsed[2]}`;
8
+ try {
9
+ return Version.parse(version);
10
+ }
11
+ catch {
12
+ throw new Error(`no release version in package version ${version}`);
13
+ }
12
14
  }
13
15
  const TAG_PREFIX = 'v';
14
16
  export function previousReleaseTags(root, { count = 3, carries }) {
15
- const current = currentReleaseLine(root);
17
+ const current = currentReleaseVersion(root);
16
18
  const listed = spawnSync('git', ['tag', '-l', `${TAG_PREFIX}*`], { cwd: root });
17
19
  if (listed.status !== 0)
18
20
  throw new Error(`git tag -l failed: ${listed.stderr ?? ''}`);
19
21
  const newestPerLine = new Map();
20
22
  for (const line of (listed.stdout?.toString() ?? '').split('\n')) {
21
23
  const parsed = parseTag(line.trim());
22
- if (!parsed || parsed.line === current)
24
+ if (!parsed || Version.compare(parsed.version, current) > 0)
23
25
  continue;
24
26
  const winner = newestPerLine.get(parsed.line);
25
27
  if (!winner || Version.compare(parsed.version, winner.version) > 0) {
@@ -0,0 +1,63 @@
1
+ ---
2
+ id: ADR-2026-09-10-skill-wording-is-measured-offline
3
+ status: active
4
+ introduced_by: PR-2160
5
+ supersedes: []
6
+ ---
7
+
8
+ # A skill's wording is measured on the real small model before it ships
9
+
10
+ ## Decision
11
+
12
+ A change to a bundled skill's `SKILL.md` that is meant to change model behaviour is measured before
13
+ it lands: the candidate body and the current one run through `examples/skill-eval` on
14
+ the real local 2B model, against a corpus of user-worded prompts, with the skill's tools answered
15
+ from recorded fixtures and every reply judged by deterministic rules. The winning variant records
16
+ its scores in the skill's front matter (`version:` plus a comment the model never sees).
17
+
18
+ The eval drives `Harness.completion` in-process, swaps variants through the skills overlay
19
+ (`writeSkillFile` / `deleteOverlaySkill`), and never uses a second model as a judge. Baseline is
20
+ the bundled `@qvac/skills` tree, or any checkout named by `--skills-dir`.
21
+
22
+ ## Context
23
+
24
+ The default mobile model is a 2B. It copies examples it is shown and ignores rules it cannot copy,
25
+ and a wording that reads better to a human routinely scores worse on it. The weather skill's Nassau
26
+ defect (QVAC-24701) was one line of guidance; nine candidate rewrites were tried and seven of them
27
+ fixed the ticket prompt while regressing plain-city prompts through wrong URL suffixes. Only a
28
+ measured comparison separated them, and only categories, not totals, showed the trade.
29
+
30
+ Judging with a larger model was rejected: its verdicts vary run to run, cost a second inference per
31
+ sample, and hide which rule failed. String laws over the call the model made and the reply it
32
+ gave are reproducible and name the failure.
33
+
34
+ Live network was rejected for the loop itself: wttr.in's answers change hourly and the source
35
+ keys its format on the User-Agent. Recorded fixtures keep runs comparable; `--record` refreshes them
36
+ deliberately, with the product's User-Agent.
37
+
38
+ ## Alternatives Considered
39
+
40
+ - A Playwright smoke that sends one prompt through the desktop UI. It proves the wiring, not the
41
+ wording: one sample, no categories, no variant swap, and the UI adds minutes per run.
42
+ - Tuning by hand in the TUI. Two repeats of a 130-run corpus already swing by about three runs;
43
+ a handful of manual tries cannot see a five-point change.
44
+
45
+ ## Consequences
46
+
47
+ - A skill PR that changes model-facing text states its before/after category table, from this eval
48
+ or an equivalent pack, and keeps the score history in the front matter.
49
+ - A new skill that needs measuring adds a pack (corpus, fake tools, judge) under
50
+ `examples/skill-eval/packs/`; the runner, routes, gap accounting and report are shared.
51
+ - Differences under about five runs per 130 are noise at two repeats. Three repeats decide.
52
+ - The eval is not CI: it needs the model weights and about four seconds per run. It is run by hand
53
+ and its output lives under the gitignored `output/`.
54
+ - It lives here rather than in `@qvac/skills` because a pack is harness code: `world()` builds the
55
+ tools the model is offered. Here it needs no dependency the repo does not already have, and
56
+ `--variant` and `--skills-dir` already reach a skill body in another checkout.
57
+ - Revisit if the SDK exposes a sampler seed (paired repeats would tighten the noise band) or if a
58
+ judge model becomes cheap and stable enough to grade open-ended replies.
59
+
60
+ ## When Agents Should Read This
61
+
62
+ Read this when changing the paths, workflows, runtime boundaries, or platform
63
+ behavior listed for this ADR in `docs/decisions/INDEX.md`.
@@ -0,0 +1,70 @@
1
+ ---
2
+ id: ADR-2026-09-16-a-lookup-reports-the-place-it-resolved
3
+ status: active
4
+ introduced_by: PR-52
5
+ supersedes: []
6
+ ---
7
+
8
+ # A provider that can silently resolve the wrong thing gets a typed tool, not a skill rule
9
+
10
+ ## Decision
11
+
12
+ When a third-party lookup can answer `200` for a resource the user did not ask for, the harness owns
13
+ the check in a typed tool rather than leaving it to skill prose. `weather_lookup` is the first: it
14
+ asks wttr.in for the structured `format=j1` body, compares the place the geocoder resolved against
15
+ the country or state the request named, refuses the answer when they contradict, and puts the
16
+ resolved place on the first line of every report it does return.
17
+
18
+ A skill keeps the judgement that is genuinely the model's: whether to call at all, and what location
19
+ to send. The tool keeps the part that is arithmetic.
20
+
21
+ ## Context
22
+
23
+ QVAC-24701 shipped a tuned `weather` skill and still failed QA. "What's the weather like in
24
+ Xyzzyville, Atlantis?" produced a correct call, `200`, and the body
25
+ `Xyzzyville, Atlantis: 🌦 +86°F`, which the model relayed. wttr.in echoes the request string back as
26
+ the label and geocodes it fuzzily, so its answer for a place that does not exist is the weather of
27
+ whatever it landed on — Atlantis, Florida. The same mechanism means a bare `Rome` currently answers
28
+ with Lomé, in Togo.
29
+
30
+ No wording can catch that. The skill already tells the model to report a failure on a non-200 or a
31
+ `location not found` body; here the status and the body both look right, and the only evidence to the
32
+ contrary is `nearest_area` inside a 40 KB JSON document the agent's context cannot hold and the
33
+ response truncator would clip. Deciding whether "Atlantis" agrees with "Florida, United States of
34
+ America" is string comparison with an alias table, which is the work a 2B is worst at and code is
35
+ best at. Measured behaviour agrees: "ask before acting" rules top out near 30 % on that model in any
36
+ wording.
37
+
38
+ Only the qualifier is verified, never the city. The geocoder answers with the nearest populated area,
39
+ which for a large city is routinely a neighbourhood that does not carry the city's name — São Paulo
40
+ resolves to Liberdade, Sydney to Kirribilli, Tbilisi to Okrokana. Nine of thirty large cities probed
41
+ would have been rejected by a city check. The region and country are reliable, and they are exactly
42
+ what a wrong resolution gets wrong.
43
+
44
+ ## Alternatives Considered
45
+
46
+ - An `operations.json` entry, the existing declarative path for provider calls. Every compiled
47
+ operation is a write: approval-gated, and its only response shaping is `pick` over top-level keys.
48
+ It cannot validate or refuse, and it would put an approval dialog in front of a weather question.
49
+ - Keeping `http_request` and asking the skill for `format=j1`. The body does not fit the context, the
50
+ truncator clips it, and the comparison still lands on the model.
51
+ - A wttr.in-specific hook that issues a second `j1` request and appends the resolved place to the
52
+ `http_request` result. Hidden coupling, two requests per answer, and the model still has to read it.
53
+
54
+ ## Consequences
55
+
56
+ - The tool is `skillGated`, so its schema costs nothing until the weather skill body is in context,
57
+ and `autoApprove`, so the SDK does not inject a second `intent` argument into a one-argument read.
58
+ It names the `http-request` toolset, so the per-agent switch still turns it off.
59
+ - A skill that grants `weather_lookup` requires a harness that has it. The bundled `weather` skill
60
+ keeps working on older harnesses only while it still grants `http_request`.
61
+ - The alias table is data and will need names added. A qualifier it does not know falls back to a
62
+ plain substring match against the region and country, which is the behaviour it had before.
63
+ - Adding a tool for every provider is not the intent. The trigger is specific: a provider that can
64
+ answer successfully about the wrong resource, where the evidence exists but not in a form the model
65
+ can be asked to check.
66
+
67
+ ## When Agents Should Read This
68
+
69
+ Read this when changing the paths, workflows, runtime boundaries, or platform
70
+ behavior listed for this ADR in `docs/decisions/INDEX.md`.