@aiwg/cli 2026.8.17 → 2026.8.19

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 (50) hide show
  1. package/bin/aiwg.mjs +24 -2
  2. package/dist/src/a2a/agent-card.js +4 -1
  3. package/dist/src/a2a/client.js +148 -68
  4. package/dist/src/a2a/codecs.js +480 -0
  5. package/dist/src/a2a/events.js +226 -0
  6. package/dist/src/a2a/hitl-driver.js +8 -6
  7. package/dist/src/a2a/hitl.js +2 -1
  8. package/dist/src/a2a/http.js +85 -5
  9. package/dist/src/a2a/protocol.js +136 -0
  10. package/dist/src/a2a/types.js +4 -14
  11. package/dist/src/a2a/webhook.js +101 -4
  12. package/dist/src/artifacts/index-builder.js +63 -12
  13. package/dist/src/artifacts/index-files.js +26 -5
  14. package/dist/src/artifacts/query-engine.js +67 -67
  15. package/dist/src/artifacts/stats.js +6 -2
  16. package/dist/src/artifacts/types.js +1 -1
  17. package/dist/src/audit/operator-decision.js +15 -1
  18. package/dist/src/channel/manager.mjs +89 -17
  19. package/dist/src/cli/handlers/index.js +3 -1
  20. package/dist/src/cli/handlers/installation.js +79 -0
  21. package/dist/src/cli/handlers/refresh.js +6 -2
  22. package/dist/src/cli/handlers/runtime-info.js +9 -1
  23. package/dist/src/cli/handlers/serve.js +107 -4
  24. package/dist/src/cli/handlers/session.js +12 -26
  25. package/dist/src/cli/handlers/use.js +19 -4
  26. package/dist/src/cli/handlers/utilities.js +4 -0
  27. package/dist/src/cli/handlers/version.js +4 -0
  28. package/dist/src/config/user-config-dir.mjs +29 -0
  29. package/dist/src/config/user-config.js +4 -22
  30. package/dist/src/extensions/commands/definitions.js +20 -1
  31. package/dist/src/features/catalog.js +2 -1
  32. package/dist/src/flow/graph-metadata.js +56 -0
  33. package/dist/src/installation/manager-command.mjs +31 -0
  34. package/dist/src/installation/manager.mjs +264 -0
  35. package/dist/src/serve/a2a-terminal-observer.js +28 -5
  36. package/dist/src/serve/dispatch-router.js +32 -4
  37. package/dist/src/serve/executor-registry.js +29 -0
  38. package/dist/src/serve/mission-conductor.js +15 -1
  39. package/dist/src/serve/stack-adapters.js +2 -1
  40. package/dist/src/serve/telemetry.js +5 -1
  41. package/dist/src/smiths/context-pipeline/claude-hook.js +8 -5
  42. package/dist/src/smiths/context-pipeline/line-endings.js +12 -0
  43. package/dist/src/smiths/context-pipeline/managed-hook.js +8 -5
  44. package/dist/src/smiths/context-pipeline/workspace-context.js +3 -1
  45. package/dist/src/update/checker.mjs +16 -15
  46. package/dist/src/update/notifier.mjs +8 -3
  47. package/dist/src/update/service.mjs +51 -5
  48. package/package.json +1 -1
  49. package/tools/agents/deploy-agents.mjs +28 -8
  50. package/tools/agents/providers/base.mjs +5 -3
@@ -15,6 +15,7 @@
15
15
  //
16
16
  // @issue #1256
17
17
  import { createHmac, timingSafeEqual } from 'node:crypto';
18
+ import { decodeStreamResponse, A2AEventReconciler } from './events.js';
18
19
  /** Header name (case-insensitive). */
19
20
  export const SIGNATURE_HEADER = 'x-aiwg-signature';
20
21
  /** Five minutes — RFC 8941 timestamp tolerance. */
@@ -111,6 +112,7 @@ function constantTimeHexEqual(a, b) {
111
112
  export class IdempotencyCache {
112
113
  capacity;
113
114
  seen = new Set();
115
+ pending = new Set();
114
116
  order = [];
115
117
  constructor(capacity = DEFAULT_IDEMPOTENCY_CAPACITY) {
116
118
  this.capacity = Math.max(16, capacity);
@@ -119,6 +121,23 @@ export class IdempotencyCache {
119
121
  markFresh(id) {
120
122
  if (this.seen.has(id))
121
123
  return false;
124
+ this.commit(id);
125
+ return true;
126
+ }
127
+ /** Reserve an event before parsing/routing so concurrent deliveries cannot race. */
128
+ begin(id) {
129
+ if (this.seen.has(id))
130
+ return 'duplicate';
131
+ if (this.pending.has(id))
132
+ return 'pending';
133
+ this.pending.add(id);
134
+ return 'fresh';
135
+ }
136
+ /** Mark a successfully routed reservation as completed. */
137
+ commit(id) {
138
+ this.pending.delete(id);
139
+ if (this.seen.has(id))
140
+ return;
122
141
  this.seen.add(id);
123
142
  this.order.push(id);
124
143
  while (this.order.length > this.capacity) {
@@ -126,7 +145,10 @@ export class IdempotencyCache {
126
145
  if (evicted !== undefined)
127
146
  this.seen.delete(evicted);
128
147
  }
129
- return true;
148
+ }
149
+ /** Release a failed reservation so a later retry can be processed. */
150
+ release(id) {
151
+ this.pending.delete(id);
130
152
  }
131
153
  size() {
132
154
  return this.seen.size;
@@ -134,15 +156,35 @@ export class IdempotencyCache {
134
156
  }
135
157
  export class PushSecretRegistry {
136
158
  entries = new Map();
159
+ reconcilers = new Map();
137
160
  register(entry) {
161
+ if (entry.protocolVersion === '1.0' && !entry.taskId) {
162
+ throw new Error('A2A 1.0 push config registration requires taskId ownership scope');
163
+ }
138
164
  this.entries.set(entry.configId, entry);
165
+ if (entry.taskId) {
166
+ this.reconcilers.set(entry.configId, new A2AEventReconciler({
167
+ taskId: entry.taskId,
168
+ ...(entry.contextId ? { contextId: entry.contextId } : {}),
169
+ }));
170
+ }
139
171
  }
140
172
  lookup(configId) {
141
173
  return this.entries.get(configId) ?? null;
142
174
  }
143
175
  unregister(configId) {
176
+ this.reconcilers.delete(configId);
144
177
  return this.entries.delete(configId);
145
178
  }
179
+ reconcile(configId, event) {
180
+ const entry = this.entries.get(configId);
181
+ const eventOwner = ownerOf(event);
182
+ if (entry?.taskOwner && eventOwner && entry.taskOwner !== eventOwner) {
183
+ throw new Error(`A2A event belongs to owner ${eventOwner}, expected ${entry.taskOwner}`);
184
+ }
185
+ const reconciler = this.reconcilers.get(configId);
186
+ return reconciler ? reconciler.accept(event) : event;
187
+ }
146
188
  /** Test/debug helper. */
147
189
  size() {
148
190
  return this.entries.size;
@@ -193,40 +235,83 @@ export async function handleWebhook(configId, body, signature, eventId, opts) {
193
235
  // Idempotency check — duplicate event-ids are accepted with 200 but
194
236
  // not re-routed. The executor's retry logic depends on a 2xx response
195
237
  // to mark delivery complete; failing here would cause infinite retry.
196
- const fresh = opts.idempotency.markFresh(eventId);
197
- if (!fresh) {
238
+ const entryForScope = opts.registry.lookup(configId);
239
+ const protocolVersion = entryForScope?.protocolVersion ?? '0.3';
240
+ const scopedEventId = [
241
+ configId,
242
+ protocolVersion,
243
+ entryForScope?.taskOwner ?? '',
244
+ entryForScope?.taskId ?? '',
245
+ eventId,
246
+ ].join('|');
247
+ const reservation = opts.idempotency.begin(scopedEventId);
248
+ if (reservation === 'duplicate') {
198
249
  return { status: 200, body: { ok: true, deduped: true } };
199
250
  }
251
+ if (reservation === 'pending') {
252
+ return {
253
+ status: 409,
254
+ body: errorBody('aiwg.webhook_event_in_progress', 'a concurrent delivery is still being processed'),
255
+ };
256
+ }
200
257
  // Route the verified payload. Errors thrown here become 500 so the
201
258
  // executor will retry — pick the abstraction carefully on the
202
259
  // mission-state side.
203
260
  const entry = opts.registry.lookup(configId);
204
261
  if (!entry) {
205
262
  // Edge case: secret was unregistered between verify and route.
263
+ opts.idempotency.release(scopedEventId);
206
264
  return {
207
265
  status: 404,
208
266
  body: errorBody('aiwg.webhook_secret_unknown', `configId='${configId}' no longer registered`),
209
267
  };
210
268
  }
269
+ if (protocolVersion === '1.0' && opts.contentType?.split(';')[0]?.trim().toLowerCase() !== 'application/a2a+json') {
270
+ opts.idempotency.release(scopedEventId);
271
+ return {
272
+ status: 415,
273
+ body: errorBody('aiwg.webhook_content_type_invalid', 'A2A 1.0 push requires application/a2a+json'),
274
+ };
275
+ }
211
276
  let parsed;
212
277
  try {
213
278
  parsed = JSON.parse(body.toString('utf8'));
214
279
  }
215
280
  catch (e) {
281
+ opts.idempotency.release(scopedEventId);
216
282
  return {
217
283
  status: 400,
218
284
  body: errorBody('aiwg.webhook_body_not_json', e.message),
219
285
  };
220
286
  }
287
+ let event;
221
288
  try {
222
- await opts.route(entry, parsed);
289
+ event = decodeStreamResponse(protocolVersion, parsed, { eventId });
290
+ const accepted = opts.registry.reconcile(configId, event);
291
+ if (!accepted) {
292
+ opts.idempotency.commit(scopedEventId);
293
+ return { status: 200, body: { ok: true, deduped: true } };
294
+ }
295
+ event = accepted;
223
296
  }
224
297
  catch (e) {
298
+ opts.idempotency.release(scopedEventId);
299
+ return {
300
+ status: 400,
301
+ body: errorBody('aiwg.webhook_event_invalid', e.message),
302
+ };
303
+ }
304
+ try {
305
+ await opts.route(entry, event);
306
+ }
307
+ catch (e) {
308
+ opts.idempotency.release(scopedEventId);
225
309
  return {
226
310
  status: 500,
227
311
  body: errorBody('aiwg.webhook_route_failed', e.message),
228
312
  };
229
313
  }
314
+ opts.idempotency.commit(scopedEventId);
230
315
  return { status: 200, body: { ok: true } };
231
316
  }
232
317
  function errorBody(code, detail) {
@@ -237,4 +322,16 @@ function errorBody(code, detail) {
237
322
  detail,
238
323
  };
239
324
  }
325
+ function ownerOf(event) {
326
+ const metadata = event.type === 'task'
327
+ ? event.task.metadata
328
+ : event.type === 'message'
329
+ ? event.message.metadata
330
+ : event.metadata;
331
+ const owner = metadata?.['task_owner']
332
+ ?? metadata?.['taskOwner']
333
+ ?? metadata?.['tenant_id']
334
+ ?? metadata?.['tenantId'];
335
+ return typeof owner === 'string' && owner ? owner : undefined;
336
+ }
240
337
  //# sourceMappingURL=webhook.js.map
@@ -2,7 +2,7 @@
2
2
  * Artifact Index Builder
3
3
  *
4
4
  * Scans .aiwg/ directories, extracts metadata from artifact frontmatter,
5
- * computes checksums, extracts @-mention dependencies, and builds a
5
+ * computes checksums, extracts @-mention and Markdown-link dependencies, and builds a
6
6
  * structured index at .aiwg/.index/.
7
7
  *
8
8
  * @implements #415
@@ -68,6 +68,57 @@ export function extractMentions(content) {
68
68
  }
69
69
  return Array.from(mentions);
70
70
  }
71
+ /**
72
+ * Extract relative Markdown links that may resolve to graph-local artifacts.
73
+ *
74
+ * External URLs, absolute paths, and anchor-only links are intentionally absent
75
+ * from the accepted pattern. Resolution still happens later against indexed
76
+ * nodes, so a parsed link outside the active graph cannot create an edge.
77
+ */
78
+ export function extractMarkdownLinks(content) {
79
+ const links = new Set();
80
+ const pattern = /(!?)\[[^\]]+\]\((\.\/?[^)#\s]+)(?:#[^)]+)?\)/g;
81
+ let match;
82
+ while ((match = pattern.exec(content)) !== null) {
83
+ if (match[1] === '!')
84
+ continue;
85
+ links.add(match[2]);
86
+ }
87
+ return Array.from(links);
88
+ }
89
+ function resolveMarkdownLinkDependency(cwd, sourcePath, rawLink, entries, graph) {
90
+ const target = rawLink.split('#')[0]?.trim();
91
+ if (!target)
92
+ return null;
93
+ const sourceFullPath = absoluteEntryPath(cwd, sourcePath, graph);
94
+ const targetFullPath = path.resolve(path.dirname(sourceFullPath), target);
95
+ let stat;
96
+ try {
97
+ stat = fs.statSync(targetFullPath);
98
+ }
99
+ catch {
100
+ return null;
101
+ }
102
+ if (!stat.isFile())
103
+ return null;
104
+ const indexedPath = indexPathFor(cwd, targetFullPath, graph);
105
+ return entries[indexedPath] ? indexedPath : null;
106
+ }
107
+ function addDependencyEdge(depGraph, entries, sourcePath, targetPath, type) {
108
+ if (sourcePath === targetPath)
109
+ return false;
110
+ if (!depGraph[sourcePath])
111
+ depGraph[sourcePath] = { upstream: [], downstream: [] };
112
+ if (!depGraph[targetPath])
113
+ depGraph[targetPath] = { upstream: [], downstream: [] };
114
+ if (depGraph[sourcePath].upstream.some(edge => edge.path === targetPath))
115
+ return false;
116
+ depGraph[sourcePath].upstream.push({ path: targetPath, type });
117
+ depGraph[targetPath].downstream.push({ path: sourcePath, type });
118
+ if (entries[targetPath])
119
+ entries[targetPath].dependents.push(sourcePath);
120
+ return true;
121
+ }
71
122
  /**
72
123
  * Extract title from content (first # heading or frontmatter title)
73
124
  */
@@ -846,6 +897,7 @@ export async function buildIndex(cwd, options = {}) {
846
897
  const tags = flow ? flow.tags : (Array.isArray(data.tags) ? data.tags.map(String) : []);
847
898
  const summary = flow?.description ?? schemaDoc?.capability ?? runbook?.capability ?? extractSummary(data, body);
848
899
  const dependencies = extractMentions(content);
900
+ const markdownLinks = extractMarkdownLinks(content);
849
901
  // Discovery metadata (#1214, #1540, #1792) — meaningful for operational
850
902
  // AIWG artifact kinds. Kept undefined on document types so the index file
851
903
  // stays small for the common case.
@@ -879,6 +931,7 @@ export async function buildIndex(cwd, options = {}) {
879
931
  checksum,
880
932
  summary,
881
933
  dependencies,
934
+ ...(markdownLinks.length > 0 ? { markdownLinks } : {}),
882
935
  dependents: [], // Computed after all entries are processed
883
936
  ...(name ? { name } : {}),
884
937
  ...(triggers && triggers.length > 0 ? { triggers } : {}),
@@ -915,6 +968,7 @@ export async function buildIndex(cwd, options = {}) {
915
968
  }
916
969
  }
917
970
  // Build dependency graph and compute dependents
971
+ let markdownLinkEdgeCount = 0;
918
972
  for (const entry of Object.values(entries)) {
919
973
  if (!depGraph[entry.path]) {
920
974
  depGraph[entry.path] = { upstream: [], downstream: [] };
@@ -923,17 +977,13 @@ export async function buildIndex(cwd, options = {}) {
923
977
  // Normalize: check if referenced path exists in the index
924
978
  const normalizedDep = Object.keys(entries).find(p => p === dep || p.endsWith(dep));
925
979
  if (normalizedDep && normalizedDep !== entry.path) {
926
- const upEdge = { path: normalizedDep, type: 'depends-on' };
927
- depGraph[entry.path].upstream.push(upEdge);
928
- if (!depGraph[normalizedDep]) {
929
- depGraph[normalizedDep] = { upstream: [], downstream: [] };
930
- }
931
- const downEdge = { path: entry.path, type: 'depends-on' };
932
- depGraph[normalizedDep].downstream.push(downEdge);
933
- // Also update the dependents field on the target entry
934
- if (entries[normalizedDep]) {
935
- entries[normalizedDep].dependents.push(entry.path);
936
- }
980
+ addDependencyEdge(depGraph, entries, entry.path, normalizedDep, 'depends-on');
981
+ }
982
+ }
983
+ for (const link of entry.markdownLinks ?? []) {
984
+ const normalizedDep = resolveMarkdownLinkDependency(cwd, entry.path, link, entries, graph);
985
+ if (normalizedDep && addDependencyEdge(depGraph, entries, entry.path, normalizedDep, 'markdown-link')) {
986
+ markdownLinkEdgeCount++;
937
987
  }
938
988
  }
939
989
  }
@@ -1069,6 +1119,7 @@ export async function buildIndex(cwd, options = {}) {
1069
1119
  tagDistribution: tagDist,
1070
1120
  graphMetrics: {
1071
1121
  totalEdges,
1122
+ markdownLinkEdges: markdownLinkEdgeCount,
1072
1123
  ...(citationMetrics ? {
1073
1124
  canonicalEdges: citationMetrics.canonicalEdges,
1074
1125
  outgoingDeclarations: citationMetrics.outgoingDeclarations,
@@ -31,22 +31,40 @@ export function indexPathFor(cwd, fullPath, graph) {
31
31
  return toPosixPath(relative);
32
32
  return fullPath;
33
33
  }
34
- /** Recursively find indexable files, excluding hidden directories such as .index. */
35
34
  export function findArtifactFiles(dir, extensions = DEFAULT_INDEX_EXTENSIONS) {
35
+ return walkArtifactFiles(dir, extensions, new Set());
36
+ }
37
+ /** Recursively find indexable files, excluding hidden directories such as .index. */
38
+ function walkArtifactFiles(dir, extensions, seenRealDirs) {
36
39
  const results = [];
37
40
  if (!fs.existsSync(dir))
38
41
  return results;
42
+ let realDir;
43
+ try {
44
+ realDir = fs.realpathSync(dir);
45
+ }
46
+ catch {
47
+ return results;
48
+ }
49
+ if (seenRealDirs.has(realDir))
50
+ return results;
51
+ seenRealDirs.add(realDir);
39
52
  const entries = fs.readdirSync(dir, { withFileTypes: true });
40
53
  for (const entry of entries) {
41
54
  const fullPath = path.join(dir, entry.name);
42
- if (entry.isSymbolicLink() && !fs.existsSync(fullPath))
55
+ let stat;
56
+ try {
57
+ stat = fs.statSync(fullPath);
58
+ }
59
+ catch {
43
60
  continue;
44
- if (entry.isDirectory()) {
61
+ }
62
+ if (stat.isDirectory()) {
45
63
  if (entry.name.startsWith('.'))
46
64
  continue;
47
- results.push(...findArtifactFiles(fullPath, extensions));
65
+ results.push(...walkArtifactFiles(fullPath, extensions, seenRealDirs));
48
66
  }
49
- else if (extensions.some(extension => entry.name.endsWith(extension))) {
67
+ else if (stat.isFile() && extensions.some(extension => entry.name.endsWith(extension))) {
50
68
  results.push(fullPath);
51
69
  }
52
70
  }
@@ -55,6 +73,9 @@ export function findArtifactFiles(dir, extensions = DEFAULT_INDEX_EXTENSIONS) {
55
73
  /** Return the exact current source-file set used by a standard graph build. */
56
74
  export async function collectGraphIndexFiles(cwd, graph) {
57
75
  const config = graph ? GRAPH_CONFIGS[graph] : undefined;
76
+ if (graph && !config) {
77
+ throw new Error(`Unknown graph: ${graph}`);
78
+ }
58
79
  const scanDirs = config
59
80
  ? config.scanDirs.map(directory => resolveGraphScanDir(cwd, directory))
60
81
  : [resolveProjectAiwgDir(cwd)];
@@ -181,7 +181,7 @@ const SCORE_STOPWORDS = new Set([
181
181
  'with', 'into', 'from', 'is', 'are', 'be', 'i', 'we', 'my',
182
182
  // pronouns / determiners / fillers
183
183
  'it', 'you', 'me', 'us', 'your', 'our', 'this', 'that', 'these', 'those',
184
- 'there', 'here', 'some', 'any', 'all', 'also', 'please', 'about',
184
+ 'there', 'here', 'some', 'any', 'all', 'also', 'please', 'about', 'project',
185
185
  // question words
186
186
  'how', 'what', 'which', 'where', 'when', 'who', 'why',
187
187
  // asking / request verbs ("find a skill that handles …")
@@ -191,6 +191,7 @@ const SCORE_STOPWORDS = new Set([
191
191
  // AIWG meta-type nouns — zero discriminating signal in a discover query
192
192
  'aiwg', 'skill', 'skills', 'agent', 'agents', 'command', 'commands',
193
193
  'rule', 'rules', 'schema', 'schemas', 'flow', 'flows', 'workflow', 'workflows',
194
+ 'template', 'templates',
194
195
  ]);
195
196
  /**
196
197
  * Tokenize a query phrase into lowercased keywords for multi-word
@@ -200,8 +201,18 @@ const SCORE_STOPWORDS = new Set([
200
201
  function tokenize(text) {
201
202
  return text
202
203
  .toLowerCase()
203
- .split(/[^a-z0-9-]+/)
204
- .filter(t => t.length > 1 && !SCORE_STOPWORDS.has(t));
204
+ .split(/[^a-z0-9]+/)
205
+ .filter(t => t.length > 1 && !SCORE_STOPWORDS.has(t))
206
+ .map(token => token.length > 4 && token.endsWith('s') && !token.endsWith('ss')
207
+ ? token.slice(0, -1)
208
+ : token);
209
+ }
210
+ function matchedFieldTokens(queryTokens, field) {
211
+ const fieldTokens = new Set(tokenize(field));
212
+ return queryTokens.filter(token => fieldTokens.has(token));
213
+ }
214
+ function fieldContainsQuery(field, queryTokens) {
215
+ return containsTokenSequence(tokenize(field), queryTokens);
205
216
  }
206
217
  /**
207
218
  * Score a metadata entry against a keyword query.
@@ -297,6 +308,7 @@ function scoreEntryDetailed(entry, text, opts = {}) {
297
308
  const personaIdentitySuppressed = diagnoseFacetActivations(text).some((activation) => activation.facet === 'persona-identity' && activation.status === 'suppressed');
298
309
  let score = 0;
299
310
  const matches = [];
311
+ const creditedTokens = new Set();
300
312
  const finish = (uncappedScore = score, cap = 1) => ({
301
313
  score: Math.min(uncappedScore, cap),
302
314
  diagnostic: {
@@ -311,6 +323,17 @@ function scoreEntryDetailed(entry, text, opts = {}) {
311
323
  score += contribution;
312
324
  matches.push({ ...match, contribution });
313
325
  };
326
+ const addTokenMatch = (contributionPerToken, hits, match) => {
327
+ const newlyMatched = hits.filter(token => !creditedTokens.has(token));
328
+ if (newlyMatched.length === 0)
329
+ return;
330
+ newlyMatched.forEach(token => creditedTokens.add(token));
331
+ addMatch(contributionPerToken * newlyMatched.length, {
332
+ ...match,
333
+ matched_tokens: newlyMatched,
334
+ query_token_coverage: tokens.length > 0 ? newlyMatched.length / tokens.length : 0,
335
+ });
336
+ };
314
337
  // Exact-name floor (#1233) — if the query (normalized) exactly matches
315
338
  // the entry's canonical name, this is the artifact the user is asking
316
339
  // for and it must surface at the top regardless of how cluttered the
@@ -422,14 +445,12 @@ function scoreEntryDetailed(entry, text, opts = {}) {
422
445
  });
423
446
  }
424
447
  else if (useMultiToken) {
425
- const hits = tokens.filter(t => trigger.includes(t));
448
+ const hits = matchedFieldTokens(tokens, trigger);
426
449
  if (overlapOK(hits.length)) {
427
- addMatch(0.06 * 4 * (hits.length / tokens.length), {
450
+ addTokenMatch(0.1 * 4, hits, {
428
451
  field: 'trigger',
429
452
  match: 'token-overlap',
430
453
  value: trigger,
431
- matched_tokens: hits,
432
- query_token_coverage: hits.length / tokens.length,
433
454
  });
434
455
  }
435
456
  }
@@ -437,7 +458,7 @@ function scoreEntryDetailed(entry, text, opts = {}) {
437
458
  }
438
459
  // Capability description (2x weight) — full phrase first, then tokens
439
460
  if (capabilityLower) {
440
- if (capabilityLower.includes(lower)) {
461
+ if (fieldContainsQuery(capabilityLower, tokens)) {
441
462
  addMatch(0.2 * 2, {
442
463
  field: 'capability',
443
464
  match: 'contained-phrase',
@@ -445,20 +466,18 @@ function scoreEntryDetailed(entry, text, opts = {}) {
445
466
  });
446
467
  }
447
468
  else if (useMultiToken) {
448
- const hits = tokens.filter(t => capabilityLower.includes(t));
469
+ const hits = matchedFieldTokens(tokens, capabilityLower);
449
470
  if (overlapOK(hits.length)) {
450
- addMatch(0.1 * 2 * (hits.length / tokens.length), {
471
+ addTokenMatch(0.1 * 2, hits, {
451
472
  field: 'capability',
452
473
  match: 'token-overlap',
453
474
  value: entry.capability,
454
- matched_tokens: hits,
455
- query_token_coverage: hits.length / tokens.length,
456
475
  });
457
476
  }
458
477
  }
459
478
  }
460
479
  // Title (3x weight)
461
- if (titleLower.includes(lower)) {
480
+ if (fieldContainsQuery(titleLower, tokens)) {
462
481
  addMatch(0.3 * 3, {
463
482
  field: 'title',
464
483
  match: titleLower === lower ? 'exact' : 'contained-phrase',
@@ -469,93 +488,83 @@ function scoreEntryDetailed(entry, text, opts = {}) {
469
488
  }
470
489
  }
471
490
  else if (useMultiToken) {
472
- const hits = tokens.filter(t => titleLower.includes(t));
491
+ const hits = matchedFieldTokens(tokens, titleLower);
473
492
  if (overlapOK(hits.length)) {
474
- addMatch(0.08 * 3 * (hits.length / tokens.length), {
493
+ addTokenMatch(0.08 * 3, hits, {
475
494
  field: 'title',
476
495
  match: 'token-overlap',
477
496
  value: entry.title,
478
- matched_tokens: hits,
479
- query_token_coverage: hits.length / tokens.length,
480
497
  });
481
498
  }
482
499
  }
483
500
  // Tags (2x weight)
484
501
  for (const tag of tagsLower) {
485
- if (tag.includes(lower)) {
502
+ if (fieldContainsQuery(tag, tokens)) {
486
503
  addMatch(0.2 * 2, { field: 'tag', match: 'contained-phrase', value: tag });
487
504
  }
488
505
  else if (useMultiToken) {
489
- const hits = tokens.filter(t => tag.includes(t));
506
+ const hits = matchedFieldTokens(tokens, tag);
490
507
  if (overlapOK(hits.length)) {
491
- addMatch(0.05 * 2 * (hits.length / tokens.length), {
508
+ addTokenMatch(0.05 * 2, hits, {
492
509
  field: 'tag',
493
510
  match: 'token-overlap',
494
511
  value: tag,
495
- matched_tokens: hits,
496
- query_token_coverage: hits.length / tokens.length,
497
512
  });
498
513
  }
499
514
  }
500
515
  }
501
516
  // Structure-aware language terms (1.5x weight). These are deliberately
502
517
  // below declared triggers/capabilities but above generic body summaries.
503
- if (searchTermsLower.includes(lower)) {
518
+ if (fieldContainsQuery(searchTermsLower, tokens)) {
504
519
  addMatch(0.18 * 1.5, { field: 'search_terms', match: 'contained-phrase' });
505
520
  }
506
521
  else if (useMultiToken) {
507
- const hits = tokens.filter(t => searchTermsLower.includes(t));
522
+ const hits = matchedFieldTokens(tokens, searchTermsLower);
508
523
  if (overlapOK(hits.length)) {
509
- addMatch(0.06 * 1.5 * (hits.length / tokens.length), {
524
+ addTokenMatch(0.06 * 1.5, hits, {
510
525
  field: 'search_terms',
511
526
  match: 'token-overlap',
512
- matched_tokens: hits,
513
- query_token_coverage: hits.length / tokens.length,
514
527
  });
515
528
  }
516
529
  }
517
530
  // Exact declarative kind and physical source classification are compact,
518
531
  // useful routing signals (e.g. FlowPlaybook vs OpsInventory; runbook that
519
532
  // originated under templates/).
520
- if (kindLower.includes(lower)) {
533
+ if (fieldContainsQuery(kindLower, tokens)) {
521
534
  addMatch(0.15, { field: 'kind', match: 'contained-phrase', value: entry.kind });
522
535
  }
523
- if (sourceTypeLower.includes(lower)) {
536
+ if (fieldContainsQuery(sourceTypeLower, tokens)) {
524
537
  addMatch(0.08, { field: 'source_type', match: 'contained-phrase', value: entry.sourceType });
525
538
  }
526
539
  // Summary (1x weight)
527
- if (summaryLower.includes(lower)) {
540
+ if (fieldContainsQuery(summaryLower, tokens)) {
528
541
  addMatch(0.15, { field: 'summary', match: 'contained-phrase' });
529
542
  }
530
543
  else if (useMultiToken) {
531
- const hits = tokens.filter(t => summaryLower.includes(t));
544
+ const hits = matchedFieldTokens(tokens, summaryLower);
532
545
  if (overlapOK(hits.length)) {
533
- addMatch(0.04 * (hits.length / tokens.length), {
546
+ addTokenMatch(0.04, hits, {
534
547
  field: 'summary',
535
548
  match: 'token-overlap',
536
- matched_tokens: hits,
537
- query_token_coverage: hits.length / tokens.length,
538
549
  });
539
550
  }
540
551
  }
541
552
  // Path (0.5x weight)
542
- if (pathLower.includes(lower)) {
553
+ if (fieldContainsQuery(pathLower, tokens)) {
543
554
  addMatch(0.1, { field: 'path', match: 'contained-phrase', value: entry.path });
544
555
  }
545
556
  else if (useMultiToken) {
546
- const hits = tokens.filter(t => pathLower.includes(t));
557
+ const hits = matchedFieldTokens(tokens, pathLower);
547
558
  if (overlapOK(hits.length)) {
548
- addMatch(0.03 * (hits.length / tokens.length), {
559
+ addTokenMatch(0.03, hits, {
549
560
  field: 'path',
550
561
  match: 'token-overlap',
551
562
  value: entry.path,
552
- matched_tokens: hits,
553
- query_token_coverage: hits.length / tokens.length,
554
563
  });
555
564
  }
556
565
  }
557
566
  // Type (0.5x weight)
558
- if (typeLower.includes(lower)) {
567
+ if (fieldContainsQuery(typeLower, tokens)) {
559
568
  addMatch(0.1, { field: 'type', match: 'contained-phrase', value: entry.type });
560
569
  }
561
570
  return finish();
@@ -1078,36 +1087,27 @@ export async function discoverCapability(cwd, params) {
1078
1087
  // lexical ranking so canonical domain phrases rank their owning capability
1079
1088
  // top-K instead of being out-scored by artifacts that merely mention the
1080
1089
  // word. Facet activation can also rescue an otherwise-empty strict pass.
1081
- let scored = dedupeDiscoverResults(await applyFacetFusion(strictScored, candidates, params.phrase)).slice(0, limit);
1082
- // #1561 verbose-query fallback. A wordy full-sentence query
1083
- // ("find me a skill that handles intake forms") dilutes the token hit ratio
1084
- // below the strict ceil(n/2) overlap gate and returns nothing, training
1085
- // agents to conclude "no skill exists" the exact decline-without-search
1086
- // failure the skill-discovery rule guards against. When the strict pass
1087
- // dead-ends, re-score with a relaxed (single-hit) overlap so the meaningful
1088
- // tokens still surface ranked candidates rather than an empty set.
1089
- let relaxed = false;
1090
- if (scored.length === 0) {
1091
- // Floor the relaxed pass so a single incidental path/summary token hit
1092
- // (~0.006–0.008) doesn't surface as noise. Capability/title/trigger field
1093
- // hits land ~0.04+, so this keeps meaningful matches while dropping junk —
1094
- // if nothing clears the floor, we fall through to the no-match hint, which
1095
- // is more honest than surfacing a 0.01 path match.
1096
- const RELAXED_MIN_SCORE = 0.02;
1097
- const relaxedFull = candidates
1098
- .map(entry => {
1099
- const detailed = scoreEntryDetailed(entry, params.phrase, { relaxOverlap: true });
1090
+ // #154 a strict result anywhere in the corpus must not suppress relevant
1091
+ // partial matches for a natural-language query. Score the relaxed pass on
1092
+ // matched terms (unmatched terms do not divide the score), apply a noise
1093
+ // floor, and union it with strict matches before ranking. Word-boundary
1094
+ // token matching keeps this from resurrecting substring noise such as UX in
1095
+ // Linux.
1096
+ const RELAXED_MIN_SCORE = 0.02;
1097
+ const strictPaths = new Set(strictScored.map(result => result.entry.path));
1098
+ const combinedByPath = new Map(strictScored.map(result => [result.entry.path, result]));
1099
+ for (const entry of candidates) {
1100
+ const detailed = scoreEntryDetailed(entry, params.phrase, { relaxOverlap: true });
1101
+ if (detailed.score < RELAXED_MIN_SCORE)
1102
+ continue;
1103
+ const existing = combinedByPath.get(entry.path);
1104
+ if (!existing || detailed.score > existing.score) {
1105
+ combinedByPath.set(entry.path, { entry, score: detailed.score });
1100
1106
  lexicalDiagnostics.set(entry.path, detailed.diagnostic);
1101
- return { entry, score: detailed.score };
1102
- })
1103
- .filter(r => r.score >= RELAXED_MIN_SCORE)
1104
- .sort(compareDiscoverResults);
1105
- const relaxedScored = dedupeDiscoverResults(await applyFacetFusion(relaxedFull, candidates, params.phrase)).slice(0, limit);
1106
- if (relaxedScored.length > 0) {
1107
- scored = relaxedScored;
1108
- relaxed = true;
1109
1107
  }
1110
1108
  }
1109
+ const scored = dedupeDiscoverResults(await applyFacetFusion(Array.from(combinedByPath.values()).sort(compareDiscoverResults), candidates, params.phrase)).slice(0, limit);
1110
+ const relaxed = scored.some(result => !strictPaths.has(result.entry.path));
1111
1111
  const queryTimeMs = Date.now() - startTime;
1112
1112
  /**
1113
1113
  * Resolve a stored framework-graph path to an absolute AIWG_ROOT