@aiwg/cli 2026.9.6 → 2026.9.9
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/src/artifacts/index-builder.js +43 -1
- package/dist/src/artifacts/query-engine.js +7 -0
- package/dist/src/cli/handlers/help.js +7 -1
- package/dist/src/cli/handlers/installation.js +106 -2
- package/dist/src/cli/handlers/mc.js +100 -37
- package/dist/src/cli/handlers/ralph.js +14 -4
- package/dist/src/cli/handlers/refresh.js +359 -31
- package/dist/src/cli/handlers/repo-access.js +155 -4
- package/dist/src/cli/handlers/runtime-info.js +3 -0
- package/dist/src/cli/handlers/serve.js +21 -3
- package/dist/src/cli/handlers/setup.js +5 -5
- package/dist/src/cli/handlers/steward.js +30 -1
- package/dist/src/cli/handlers/use.js +123 -12
- package/dist/src/cli/handlers/utilities.js +26 -10
- package/dist/src/cli/handlers/version.js +40 -14
- package/dist/src/cli/handlers/workspace-context.js +8 -0
- package/dist/src/cli/services/deployment-verification.js +156 -7
- package/dist/src/cli/watch-service.js +47 -4
- package/dist/src/config/aiwg-config.js +95 -3
- package/dist/src/config/cli.js +16 -1
- package/dist/src/config/gitignore.js +5 -0
- package/dist/src/config/project-artifacts-health.mjs +15 -2
- package/dist/src/cost/fleet-report.js +19 -5
- package/dist/src/extensions/claude-hooks-installer.js +22 -6
- package/dist/src/extensions/project-local-doctor.js +40 -2
- package/dist/src/extensions/project-quickref.js +4 -0
- package/dist/src/installation/manager.mjs +38 -3
- package/dist/src/lint/runner.js +138 -0
- package/dist/src/mcp/helpers.mjs +56 -22
- package/dist/src/mcp/registry.js +32 -22
- package/dist/src/mcp/registry.mjs +31 -26
- package/dist/src/mcp/toml-editor.mjs +117 -0
- package/dist/src/mcp/tools/orchestration.mjs +7 -7
- package/dist/src/mcp/tools/subsystems.mjs +7 -7
- package/dist/src/memory/context-pack.js +5 -1
- package/dist/src/plugin/skill-command-translator.js +70 -1
- package/dist/src/serve/a2a-terminal-observer.js +19 -1
- package/dist/src/serve/mission-hitl.js +91 -0
- package/dist/src/sessions/import-lease.js +5 -1
- package/dist/src/smiths/context-pipeline/workspace-context.js +132 -6
- package/dist/src/testing/fixtures/test-data-factory.js +3 -3
- package/dist/src/writing/pattern-library.js +29 -6
- package/package.json +2 -1
- package/tools/agents/deploy-agents.mjs +91 -5
- package/tools/agents/providers/base.mjs +162 -6
|
@@ -179,13 +179,27 @@ function requestSignal(parent) {
|
|
|
179
179
|
}
|
|
180
180
|
async function openRouterGet(endpoint, key, options) {
|
|
181
181
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
182
|
+
let response;
|
|
183
|
+
try {
|
|
184
|
+
response = await fetchImpl(`${options.apiBaseUrl ?? OPENROUTER_API}${endpoint}`, {
|
|
185
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
186
|
+
signal: requestSignal(options.signal),
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
// Transport diagnostics can embed Authorization headers or request bodies.
|
|
191
|
+
throw new Error('OpenRouter request could not complete. Check connectivity, timeout, or cancellation.');
|
|
192
|
+
}
|
|
186
193
|
if (!response.ok)
|
|
187
194
|
throw new Error(`OpenRouter request failed with status ${response.status}.`);
|
|
188
|
-
|
|
195
|
+
let payload;
|
|
196
|
+
try {
|
|
197
|
+
payload = await response.json();
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
// Decoder exceptions can quote arbitrary response content.
|
|
201
|
+
throw new Error('OpenRouter returned unreadable JSON. Check the API response format.');
|
|
202
|
+
}
|
|
189
203
|
if (!payload || typeof payload !== 'object' || !payload.data)
|
|
190
204
|
throw new Error('OpenRouter returned an invalid response.');
|
|
191
205
|
return payload.data;
|
|
@@ -68,11 +68,17 @@ function normalizeHooks(raw) {
|
|
|
68
68
|
return [{}, false];
|
|
69
69
|
}
|
|
70
70
|
/**
|
|
71
|
-
* Detect whether the existing settings carries the AIWG signature
|
|
72
|
-
*
|
|
71
|
+
* Detect whether the existing settings carries the AIWG signature — either a hook
|
|
72
|
+
* entry tagged `_aiwg_managed: true`, or the top-level `aiwg` stamp written when
|
|
73
|
+
* AIWG creates the file itself (tools/agents/providers/claude.mjs). Without the
|
|
74
|
+
* latter, a greenfield deploy backs up the settings.json it created seconds
|
|
75
|
+
* earlier and reports it as protected operator content (#2542). Accepts both the
|
|
73
76
|
* current object form and the legacy array form.
|
|
74
77
|
*/
|
|
75
78
|
function hasAiwgMarker(settings) {
|
|
79
|
+
const stamp = settings.aiwg;
|
|
80
|
+
if (stamp && typeof stamp === 'object')
|
|
81
|
+
return true;
|
|
76
82
|
const hooksField = settings.hooks;
|
|
77
83
|
if (!hooksField)
|
|
78
84
|
return false;
|
|
@@ -167,7 +173,7 @@ export async function installAiwgHooks(opts) {
|
|
|
167
173
|
const backup = `${result.settingsPath}.bak.${new Date().toISOString().replace(/[:.]/g, '-')}`;
|
|
168
174
|
await fs.copyFile(result.settingsPath, backup);
|
|
169
175
|
result.backupPath = backup;
|
|
170
|
-
result.warnings.push(`Backed up
|
|
176
|
+
result.warnings.push(`Backed up operator-authored settings.json to ${backup}`);
|
|
171
177
|
}
|
|
172
178
|
}
|
|
173
179
|
catch (err) {
|
|
@@ -202,18 +208,28 @@ export async function installAiwgHooks(opts) {
|
|
|
202
208
|
// those files no longer exist on disk after refresh, causing
|
|
203
209
|
// `MODULE_NOT_FOUND` at `node:internal/modules/cjs/loader` on every
|
|
204
210
|
// hook invocation. (Fixes regression report on Claude Code 2.1.157.)
|
|
211
|
+
// Match by `_aiwg_id` first, then by the script path. An entry invoking an
|
|
212
|
+
// AIWG-owned hook script without the managed tag is AIWG residue from an
|
|
213
|
+
// earlier install; appending a managed entry beside it registers the hook
|
|
214
|
+
// twice and runs it twice per event. Adopt it instead. (#2543)
|
|
205
215
|
let updated = false;
|
|
206
216
|
for (const group of groups) {
|
|
207
217
|
if (!Array.isArray(group.hooks))
|
|
208
218
|
continue;
|
|
209
219
|
for (const h of group.hooks) {
|
|
210
|
-
|
|
220
|
+
const sameId = h._aiwg_id === hookId;
|
|
221
|
+
const sameScript = typeof h.command === 'string' && h.command.includes(script);
|
|
222
|
+
if (!sameId && !sameScript)
|
|
211
223
|
continue;
|
|
212
|
-
|
|
224
|
+
const adopting = !sameId && sameScript;
|
|
225
|
+
if (h.command !== command || h.type !== 'command' || h._aiwg_managed !== true || h._aiwg_id !== hookId) {
|
|
213
226
|
h.type = 'command';
|
|
214
227
|
h.command = command;
|
|
215
228
|
h._aiwg_managed = true;
|
|
216
|
-
|
|
229
|
+
h._aiwg_id = hookId;
|
|
230
|
+
result.warnings.push(adopting
|
|
231
|
+
? `Adopted pre-existing untagged ${event} → ${hookId} entry instead of registering a duplicate`
|
|
232
|
+
: `Refreshed stale ${event} → ${hookId} command path`);
|
|
217
233
|
}
|
|
218
234
|
updated = true;
|
|
219
235
|
}
|
|
@@ -67,7 +67,7 @@ export async function buildProjectLocalDoctorSection(opts) {
|
|
|
67
67
|
}
|
|
68
68
|
// No project-local content → no section at all
|
|
69
69
|
if (discovery.isEmpty && discovery.errors.length === 0 && !quickrefAudit.exists && quickrefErrors.length === 0) {
|
|
70
|
-
return { output: '', validationErrors: 0, denylistViolations: 0, driftCount: 0, hasFailures: false };
|
|
70
|
+
return { output: '', validationErrors: 0, denylistViolations: 0, driftCount: 0, undeployedCount: 0, hasFailures: false };
|
|
71
71
|
}
|
|
72
72
|
const lines = ['', '── Project-local artifacts ────────────────────────────────────'];
|
|
73
73
|
// Counts
|
|
@@ -108,12 +108,17 @@ export async function buildProjectLocalDoctorSection(opts) {
|
|
|
108
108
|
lines.push('');
|
|
109
109
|
// Shadows + denylist
|
|
110
110
|
let denylistViolations = 0;
|
|
111
|
+
// Bundles the resolver deliberately refused are already reported below as
|
|
112
|
+
// denylist violations; the undeployed check must not double-count them.
|
|
113
|
+
const refusedBundleIds = new Set();
|
|
111
114
|
if (discovery.bundles.length > 0) {
|
|
112
115
|
try {
|
|
113
116
|
const upstream = await buildUpstreamRegistry({ frameworkRoot });
|
|
114
117
|
const shadowResult = await resolveShadows(discovery.bundles, upstream);
|
|
115
118
|
const refusals = shadowResult.resolutions.filter(r => r.verdict === 'refuse-unsafe' || r.verdict === 'refuse-phantom' || r.verdict === 'refuse-duplicate');
|
|
116
119
|
denylistViolations = refusals.length;
|
|
120
|
+
for (const refusal of refusals)
|
|
121
|
+
refusedBundleIds.add(refusal.bundleId);
|
|
117
122
|
if (!quiet) {
|
|
118
123
|
const informational = shadowResult.shadows.filter(s => s.verdict === 'deploy-with-warning' || s.verdict === 'deploy-acknowledged');
|
|
119
124
|
if (informational.length > 0) {
|
|
@@ -188,6 +193,37 @@ export async function buildProjectLocalDoctorSection(opts) {
|
|
|
188
193
|
}
|
|
189
194
|
lines.push('');
|
|
190
195
|
}
|
|
196
|
+
// Undeployed bundles (#2503).
|
|
197
|
+
//
|
|
198
|
+
// A bundle whose deploy aborted (a bad support-asset reference, a failed CLI
|
|
199
|
+
// contribution) leaves no `installed` entry, so every check above — manifest
|
|
200
|
+
// validation, drift — silently skips it and reports a clean bill of health
|
|
201
|
+
// for a bundle that is not actually available. The only prior signal was a
|
|
202
|
+
// WARN line in `aiwg use` output, long scrolled away by the time anyone
|
|
203
|
+
// wonders where the skill went.
|
|
204
|
+
const undeployed = config
|
|
205
|
+
? discovery.bundles.filter((bundle) => {
|
|
206
|
+
if (refusedBundleIds.has(bundle.id))
|
|
207
|
+
return false;
|
|
208
|
+
const entry = config.installed[bundle.id];
|
|
209
|
+
return !entry || entry.source !== 'project-local';
|
|
210
|
+
})
|
|
211
|
+
: [];
|
|
212
|
+
if (undeployed.length > 0) {
|
|
213
|
+
lines.push(` Deployment: ✗ ${undeployed.length} discovered bundle${undeployed.length === 1 ? '' : 's'} not deployed`);
|
|
214
|
+
for (const bundle of undeployed.slice(0, 5)) {
|
|
215
|
+
lines.push(` ✗ ${bundle.type}/${bundle.id} (${bundle.localPath}) — no deployment recorded`);
|
|
216
|
+
}
|
|
217
|
+
if (undeployed.length > 5)
|
|
218
|
+
lines.push(` + ${undeployed.length - 5} more`);
|
|
219
|
+
lines.push(` Run \`aiwg use ${undeployed[0].id}\` and read the output — a deploy that`);
|
|
220
|
+
lines.push(' fails reports the reason there.');
|
|
221
|
+
lines.push('');
|
|
222
|
+
}
|
|
223
|
+
else if (!quiet && config && discovery.bundles.length > 0) {
|
|
224
|
+
lines.push(' Deployment: ✓ all discovered bundles deployed');
|
|
225
|
+
lines.push('');
|
|
226
|
+
}
|
|
191
227
|
// Provider deployment matrix
|
|
192
228
|
if (!quiet && config) {
|
|
193
229
|
const projectLocalEntries = Object.entries(config.installed).filter(([, e]) => e.source === 'project-local');
|
|
@@ -252,12 +288,14 @@ export async function buildProjectLocalDoctorSection(opts) {
|
|
|
252
288
|
lines.push('');
|
|
253
289
|
}
|
|
254
290
|
}
|
|
255
|
-
const hasFailures = validationErrors > 0 || denylistViolations > 0 || driftCount > 0
|
|
291
|
+
const hasFailures = validationErrors > 0 || denylistViolations > 0 || driftCount > 0
|
|
292
|
+
|| gitignoredCount > 0 || undeployed.length > 0;
|
|
256
293
|
return {
|
|
257
294
|
output: lines.join('\n'),
|
|
258
295
|
validationErrors,
|
|
259
296
|
denylistViolations,
|
|
260
297
|
driftCount,
|
|
298
|
+
undeployedCount: undeployed.length,
|
|
261
299
|
hasFailures,
|
|
262
300
|
};
|
|
263
301
|
}
|
|
@@ -258,6 +258,10 @@ export function renderProjectQuickref(definition) {
|
|
|
258
258
|
'---',
|
|
259
259
|
`name: ${skillName}`,
|
|
260
260
|
`description: ${JSON.stringify(`Project-specific orientation for ${definition.project.name}`)}`,
|
|
261
|
+
// AIWG generates and deploys this skill, so it must declare AIWG ownership.
|
|
262
|
+
// Without it the collision scan treats every redeploy of AIWG's own artifact
|
|
263
|
+
// as an unowned overwrite and warns permanently (#2504).
|
|
264
|
+
'namespace: aiwg',
|
|
261
265
|
'kernel: true',
|
|
262
266
|
'platforms: [all]',
|
|
263
267
|
'---',
|
|
@@ -176,13 +176,44 @@ export function inspectInstallation(options = {}) {
|
|
|
176
176
|
const actualRoot = canonicalPath(options.actualRoot);
|
|
177
177
|
const actualMethod = options.actualMethod ?? inferInstallationMethod(actualRoot);
|
|
178
178
|
const identity = options.identity ?? loadInstallationIdentity({ ...options, actualRoot });
|
|
179
|
-
if (!identity)
|
|
179
|
+
if (!identity) {
|
|
180
|
+
return {
|
|
181
|
+
state: 'unrecorded',
|
|
182
|
+
identity: null,
|
|
183
|
+
actualRoot,
|
|
184
|
+
actualMethod,
|
|
185
|
+
frameworkRoot: actualRoot,
|
|
186
|
+
launcher: null,
|
|
187
|
+
drift: ['installation identity is not recorded'],
|
|
188
|
+
};
|
|
189
|
+
}
|
|
180
190
|
|
|
181
191
|
const drift = [];
|
|
182
192
|
const canonicalRoot = canonicalPath(identity.root);
|
|
193
|
+
|
|
194
|
+
// Edge/customize mode deliberately separates the *launcher* (the executable
|
|
195
|
+
// that ran, typically an npm-global install) from the *framework root* (the
|
|
196
|
+
// local clone named by `edgePath`). Comparing the launcher's package root
|
|
197
|
+
// against the canonical root then reports the supported configuration as
|
|
198
|
+
// drift, and does so only for the commands that happen to run from the
|
|
199
|
+
// npm-global copy — `aiwg doctor`, loaded through the redirect, saw the
|
|
200
|
+
// clone and reported aligned for the same workspace (#2505).
|
|
201
|
+
//
|
|
202
|
+
// The redirect is only trusted when the identity actually declares it: the
|
|
203
|
+
// channel is `edge` and `edgePath` resolves to the canonical root.
|
|
204
|
+
const edgePath = identity.edgePath ? canonicalPath(identity.edgePath) : null;
|
|
205
|
+
const rootsDiffer = canonicalRoot !== actualRoot;
|
|
206
|
+
const launcherRedirect = identity.channel === 'edge' && edgePath !== null && edgePath === canonicalRoot && rootsDiffer;
|
|
207
|
+
const launcher = launcherRedirect ? { root: actualRoot, method: actualMethod } : null;
|
|
208
|
+
// The framework root is what AIWG actually reads its corpus from, and it is
|
|
209
|
+
// the single value that drives `state`.
|
|
210
|
+
const frameworkRoot = launcherRedirect ? canonicalRoot : actualRoot;
|
|
211
|
+
|
|
183
212
|
if (!existsSync(canonicalRoot)) drift.push(`canonical root does not exist: ${canonicalRoot}`);
|
|
184
|
-
if (
|
|
185
|
-
if (identity.method !== actualMethod
|
|
213
|
+
if (rootsDiffer && !launcherRedirect) drift.push(`actual root ${actualRoot} differs from canonical root ${canonicalRoot}`);
|
|
214
|
+
if (identity.method !== actualMethod && !launcherRedirect) {
|
|
215
|
+
drift.push(`actual method ${actualMethod} differs from canonical method ${identity.method}`);
|
|
216
|
+
}
|
|
186
217
|
if (identity.method !== 'web' && !identity.managerExecutable) {
|
|
187
218
|
drift.push(`canonical ${identity.method} installation has no recorded manager executable`);
|
|
188
219
|
}
|
|
@@ -214,6 +245,10 @@ export function inspectInstallation(options = {}) {
|
|
|
214
245
|
canonicalRoot,
|
|
215
246
|
actualRoot,
|
|
216
247
|
actualMethod,
|
|
248
|
+
/** Where the corpus is read from. Equals actualRoot unless a launcher redirect applies. */
|
|
249
|
+
frameworkRoot,
|
|
250
|
+
/** Non-null only in edge/customize mode: the executable's own package root. */
|
|
251
|
+
launcher,
|
|
217
252
|
drift,
|
|
218
253
|
managerProbe,
|
|
219
254
|
};
|
package/dist/src/lint/runner.js
CHANGED
|
@@ -29,6 +29,100 @@ function parseFrontmatter(content) {
|
|
|
29
29
|
return result;
|
|
30
30
|
}
|
|
31
31
|
const DEFAULT_REFERENCE_PATTERN = '\\bREF-\\d{3,}\\b';
|
|
32
|
+
/**
|
|
33
|
+
* Verification targets — things the inducting agent could have checked.
|
|
34
|
+
*
|
|
35
|
+
* Requiring one of these is what separates "the agent skipped a cheap check"
|
|
36
|
+
* from "the paper's own claim is unverified". Only the first is a provenance
|
|
37
|
+
* gap; the second is legitimate analysis, and #2523 explicitly does not ask
|
|
38
|
+
* agents to stop declaring limitations. Validated against a 2,544-reference
|
|
39
|
+
* corpus, where grammar-only matching made ~45% of hits paper-claim prose
|
|
40
|
+
* ("scaling behavior above 7B is unverified", "Not confirmed (34% vs 51%)").
|
|
41
|
+
*/
|
|
42
|
+
/**
|
|
43
|
+
* Split a markdown line into clauses.
|
|
44
|
+
*
|
|
45
|
+
* Corpus prose keeps whole paragraphs, bullet bodies and changelog table rows on
|
|
46
|
+
* a single line, so "same line" is far too coarse a scope for relating an
|
|
47
|
+
* unperformed action to its target. Sentence and cell boundaries are the unit
|
|
48
|
+
* that actually corresponds to one statement.
|
|
49
|
+
*/
|
|
50
|
+
function splitClauses(line) {
|
|
51
|
+
return line
|
|
52
|
+
.split(/(?<=[.;:!?])\s+|\s+\u2014\s+|\s+--\s+|\|/g)
|
|
53
|
+
.map((c) => c.trim())
|
|
54
|
+
.filter(Boolean);
|
|
55
|
+
}
|
|
56
|
+
export const DEFAULT_VERIFICATION_TARGETS = [
|
|
57
|
+
'openreview',
|
|
58
|
+
'(?:acl )?anthology',
|
|
59
|
+
'proceedings',
|
|
60
|
+
'camera[- ]ready',
|
|
61
|
+
'published version',
|
|
62
|
+
'\\bPMLR\\b',
|
|
63
|
+
'\\bDBLP\\b',
|
|
64
|
+
'\\bOpenAlex\\b',
|
|
65
|
+
'semantic scholar',
|
|
66
|
+
'\\bpubpeer\\b',
|
|
67
|
+
'\\bunpaywall\\b',
|
|
68
|
+
'retraction|correction notice|expression of concern',
|
|
69
|
+
'citation (?:census|count)',
|
|
70
|
+
'influential citation',
|
|
71
|
+
'\\bPDF\\b',
|
|
72
|
+
'full[- ]text',
|
|
73
|
+
'\\be-?print\\b',
|
|
74
|
+
'(?:code|project|dataset|repository|repo)\\s+(?:page|url|link|release|availability)',
|
|
75
|
+
'\\bvenue\\b',
|
|
76
|
+
'\\bacceptance\\b',
|
|
77
|
+
'source[_ ]type',
|
|
78
|
+
];
|
|
79
|
+
/**
|
|
80
|
+
* Phrases that assert a check was not performed. Drawn from real induction
|
|
81
|
+
* output — each of these has appeared in a corpus reference doc (#2523).
|
|
82
|
+
* Only counted when a verification target appears on the same line.
|
|
83
|
+
*/
|
|
84
|
+
export const DEFAULT_UNCERTAINTY_PATTERNS = [
|
|
85
|
+
'(?:was|were|is|are) not (?:retrieved|run|performed|attempted|fetched|queried|checked|acquired|probed|verified|confirmed)',
|
|
86
|
+
'not (?:retrieved|run|performed|attempted|fetched|queried|checked|acquired|probed|verified|confirmed)\\b',
|
|
87
|
+
// Requires the action to be stated as unperformed. Without the trailing verb
|
|
88
|
+
// this matched bare "no search" in unrelated prose and scope statements like
|
|
89
|
+
// "no exhaustive census is claimed", neither of which is a skipped check.
|
|
90
|
+
'no (?:\\w+[ -]){0,4}(?:quer(?:y|ies)|search|census|fetch|check|lookup|probe)(?:es|s)?\\s+(?:was |were )?(?:performed|run|attempted|made|conducted|executed)',
|
|
91
|
+
'\\b(?:is|remains) unverified\\b',
|
|
92
|
+
'\\bunconfirmed\\b',
|
|
93
|
+
'rests on .{0,60} rather than an independent',
|
|
94
|
+
];
|
|
95
|
+
export const DEFAULT_OBSTACLE_PATTERNS = [
|
|
96
|
+
'\\bHTTP\\s?[45]\\d{2}\\b',
|
|
97
|
+
'\\b(?:401|403|404|429|451|503)\\b',
|
|
98
|
+
'paywall',
|
|
99
|
+
'closed[- ]access',
|
|
100
|
+
'requires? (?:a )?(?:credential|token|API key|subscription|login|account)',
|
|
101
|
+
'\\b(?:HF_TOKEN|API[_ ]KEY)\\b',
|
|
102
|
+
'rate[- ]limit',
|
|
103
|
+
'anti[- ]bot',
|
|
104
|
+
'cloudflare',
|
|
105
|
+
'captcha',
|
|
106
|
+
'endpoint unknown',
|
|
107
|
+
'no (?:known )?endpoint',
|
|
108
|
+
// Obstacle vocabulary observed in a real corpus: these are named obstacles,
|
|
109
|
+
// so the statement is already a real outcome rather than a silent skip.
|
|
110
|
+
'proof[- ]of[- ]work',
|
|
111
|
+
'challenge (?:artifact|page|response)',
|
|
112
|
+
'returned challenge',
|
|
113
|
+
'\\bgated\\b',
|
|
114
|
+
'did not render',
|
|
115
|
+
'green OA',
|
|
116
|
+
// Explicit scope declarations: saying what is deliberately not claimed is an
|
|
117
|
+
// outcome, unlike omitting the check and not saying so.
|
|
118
|
+
'is not (?:claimed|asserted)',
|
|
119
|
+
'NOT (?:recorded|asserted) as',
|
|
120
|
+
'evidence boundary',
|
|
121
|
+
'completion_evidence',
|
|
122
|
+
'status:\\s*(?:incomplete|blocked)',
|
|
123
|
+
'\\b(?:queried|fetched|checked|probed|confirmed|resolved)\\s+(?:on\\s+)?\\d{4}-\\d{2}-\\d{2}',
|
|
124
|
+
'\\bdeferred\\b.{0,40}\\b(?:because|since|due to)\\b',
|
|
125
|
+
];
|
|
32
126
|
/**
|
|
33
127
|
* Build a target-wide artifact ID index once per lint run.
|
|
34
128
|
*
|
|
@@ -136,6 +230,50 @@ function runCheck(check, content, frontmatter, filePath, targetDir, allFiles, re
|
|
|
136
230
|
}
|
|
137
231
|
break;
|
|
138
232
|
}
|
|
233
|
+
case 'unregistered-uncertainty': {
|
|
234
|
+
// An uncertainty written only into prose is invisible to the verification
|
|
235
|
+
// contract: it was never a declared check, so it never surfaces as
|
|
236
|
+
// `incomplete` and the "never report skipped verification as success" rule
|
|
237
|
+
// is never violated. Flag the bare form; accept it once a specific
|
|
238
|
+
// obstacle is named or an outcome is recorded (#2523).
|
|
239
|
+
const uncertainty = (check.uncertaintyPatterns ?? DEFAULT_UNCERTAINTY_PATTERNS)
|
|
240
|
+
.map((p) => new RegExp(p, 'i'));
|
|
241
|
+
const targets = (check.verificationTargets ?? DEFAULT_VERIFICATION_TARGETS)
|
|
242
|
+
.map((p) => new RegExp(p, 'i'));
|
|
243
|
+
const obstacle = (check.obstaclePatterns ?? DEFAULT_OBSTACLE_PATTERNS)
|
|
244
|
+
.map((p) => new RegExp(p, 'i'));
|
|
245
|
+
const within = check.obstacleWithinLines ?? 2;
|
|
246
|
+
const lines = content.split('\n');
|
|
247
|
+
for (let i = 0; i < lines.length; i++) {
|
|
248
|
+
// Both conditions must hold in the SAME CLAUSE: an unperformed action
|
|
249
|
+
// AND something the agent could have acted on. Without the target, the
|
|
250
|
+
// match is as likely to be the paper's own limitation, which must stay
|
|
251
|
+
// untouched. Without clause scoping, a long markdown line relates two
|
|
252
|
+
// unrelated clauses — real corpus prose puts whole paragraphs and
|
|
253
|
+
// changelog tables on one line, which produced most false positives.
|
|
254
|
+
const clause = splitClauses(lines[i]).find((c) => uncertainty.some((re) => re.test(c)) && targets.some((re) => re.test(c)));
|
|
255
|
+
if (!clause)
|
|
256
|
+
continue;
|
|
257
|
+
// Look in the matching line and the following `within` lines: the
|
|
258
|
+
// obstacle normally sits in the same sentence or the next one.
|
|
259
|
+
const window = lines.slice(i, i + within + 1).join(' ');
|
|
260
|
+
if (obstacle.some((re) => re.test(window)))
|
|
261
|
+
continue;
|
|
262
|
+
diagnostics.push({
|
|
263
|
+
ruleId: '',
|
|
264
|
+
ruleName: '',
|
|
265
|
+
// Left unset so the rule's declared severity applies (see runRule).
|
|
266
|
+
// This is a prose heuristic, so the shipped rule declares `warn`; an
|
|
267
|
+
// operator can raise it to error once a corpus is clean.
|
|
268
|
+
severity: undefined,
|
|
269
|
+
file: filePath,
|
|
270
|
+
line: i + 1,
|
|
271
|
+
message: `Uncertainty stated without a named obstacle or recorded outcome: ${clause.trim().slice(0, 160)}`,
|
|
272
|
+
fix: 'Resolve it if it costs about one request against a known endpoint, or name the specific obstacle (HTTP status, credential required, rate limited, paywalled) so it lands as incomplete/blocked rather than narrative.',
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
139
277
|
case 'pattern-match': {
|
|
140
278
|
if (!check.pattern)
|
|
141
279
|
break;
|
package/dist/src/mcp/helpers.mjs
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import { spawn } from 'node:child_process';
|
|
9
|
+
import { StringDecoder } from 'node:string_decoder';
|
|
9
10
|
import fs from 'node:fs/promises';
|
|
10
11
|
import path from 'node:path';
|
|
11
12
|
|
|
@@ -72,7 +73,7 @@ export async function resolveProjectAiwgDir(projectDir) {
|
|
|
72
73
|
*/
|
|
73
74
|
export async function findProjectRoot(startDir = process.cwd()) {
|
|
74
75
|
let currentDir = startDir;
|
|
75
|
-
while (
|
|
76
|
+
while (true) {
|
|
76
77
|
const aiwgPath = path.join(currentDir, '.aiwg');
|
|
77
78
|
const pointerPath = path.join(currentDir, PROJECT_AIWG_LOCATION_FILE);
|
|
78
79
|
try {
|
|
@@ -87,7 +88,10 @@ export async function findProjectRoot(startDir = process.cwd()) {
|
|
|
87
88
|
} catch {
|
|
88
89
|
// continue up
|
|
89
90
|
}
|
|
90
|
-
|
|
91
|
+
// Inspect the root candidate too, then stop instead of revisiting it.
|
|
92
|
+
const parentDir = path.dirname(currentDir);
|
|
93
|
+
if (parentDir === currentDir) break;
|
|
94
|
+
currentDir = parentDir;
|
|
91
95
|
}
|
|
92
96
|
throw new Error('No .aiwg directory or .aiwg-location pointer found. Run from an AIWG project or `aiwg new` first.');
|
|
93
97
|
}
|
|
@@ -167,33 +171,56 @@ export function runAiwgCli(args, { cwd, env, timeoutMs = 120_000, input } = {})
|
|
|
167
171
|
});
|
|
168
172
|
let stdout = '';
|
|
169
173
|
let stderr = '';
|
|
170
|
-
|
|
174
|
+
const stdoutDecoder = new StringDecoder('utf8');
|
|
175
|
+
const stderrDecoder = new StringDecoder('utf8');
|
|
176
|
+
let settled = false;
|
|
177
|
+
let killTimer;
|
|
178
|
+
const rejectAndTerminate = (err) => {
|
|
179
|
+
if (settled) return;
|
|
180
|
+
settled = true;
|
|
181
|
+
clearTimeout(timer);
|
|
182
|
+
// Settlement must not depend on a cooperative close event. Give the
|
|
183
|
+
// owned child one second to exit gracefully, then escalate cleanup.
|
|
184
|
+
reject(err);
|
|
185
|
+
killTimer = setTimeout(() => {
|
|
186
|
+
try { proc.kill('SIGKILL'); } catch { /* child may already have exited */ }
|
|
187
|
+
}, 1000);
|
|
188
|
+
killTimer.unref?.();
|
|
189
|
+
// Install cleanup first: kill() can synchronously trigger close in an adapter.
|
|
190
|
+
try { proc.kill('SIGTERM'); } catch { /* retain the original failure */ }
|
|
191
|
+
};
|
|
171
192
|
const timer = setTimeout(() => {
|
|
172
|
-
|
|
173
|
-
proc.kill('SIGTERM');
|
|
193
|
+
rejectAndTerminate(new Error(`aiwg ${args[0] || ''} timed out after ${timeoutMs}ms`));
|
|
174
194
|
}, timeoutMs);
|
|
175
195
|
|
|
176
|
-
proc.stdout.on('data', (chunk) => { stdout += chunk; });
|
|
177
|
-
proc.stderr.on('data', (chunk) => { stderr += chunk; });
|
|
196
|
+
proc.stdout.on('data', (chunk) => { stdout += stdoutDecoder.write(chunk); });
|
|
197
|
+
proc.stderr.on('data', (chunk) => { stderr += stderrDecoder.write(chunk); });
|
|
178
198
|
|
|
179
199
|
proc.on('close', (code) => {
|
|
180
200
|
clearTimeout(timer);
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
201
|
+
clearTimeout(killTimer);
|
|
202
|
+
if (settled) return;
|
|
203
|
+
settled = true;
|
|
204
|
+
stdout += stdoutDecoder.end();
|
|
205
|
+
stderr += stderrDecoder.end();
|
|
185
206
|
resolve({ stdout, stderr, code: code ?? -1 });
|
|
186
207
|
});
|
|
187
208
|
proc.on('error', (err) => {
|
|
188
209
|
clearTimeout(timer);
|
|
210
|
+
// A late error must not cancel cleanup of a failed, still-live child.
|
|
211
|
+
if (settled) return;
|
|
212
|
+
settled = true;
|
|
189
213
|
reject(err);
|
|
190
214
|
});
|
|
191
215
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
proc.stdin.
|
|
216
|
+
// Pipe errors are emitted on stdin, not on the ChildProcess. Register
|
|
217
|
+
// before writing so synchronous adapter events and late EPIPE are handled.
|
|
218
|
+
proc.stdin.on('error', rejectAndTerminate);
|
|
219
|
+
try {
|
|
220
|
+
if (input !== undefined) proc.stdin.write(input);
|
|
221
|
+
if (!settled) proc.stdin.end();
|
|
222
|
+
} catch (err) {
|
|
223
|
+
rejectAndTerminate(err);
|
|
197
224
|
}
|
|
198
225
|
});
|
|
199
226
|
}
|
|
@@ -244,13 +271,20 @@ export async function loadCommandAllowList() {
|
|
|
244
271
|
}
|
|
245
272
|
}
|
|
246
273
|
if (!text) {
|
|
247
|
-
//
|
|
274
|
+
// Installed packages need not contain TypeScript sources. Ask the CLI for
|
|
275
|
+
// its versioned canonical registry; human help is incomplete and contains examples.
|
|
248
276
|
try {
|
|
249
|
-
const { stdout } = await runAiwgCli(['help'], { timeoutMs: 30_000 });
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
277
|
+
const { stdout, code } = await runAiwgCli(['help', '--json'], { timeoutMs: 30_000 });
|
|
278
|
+
if (code !== 0) throw new Error('Command registry subprocess failed');
|
|
279
|
+
const registry = JSON.parse(stdout);
|
|
280
|
+
const ids = registry?.commandIds;
|
|
281
|
+
if (registry?.schema !== 'aiwg.command-registry.v1'
|
|
282
|
+
|| !Array.isArray(ids) || ids.length === 0
|
|
283
|
+
|| ids.some(id => typeof id !== 'string' || !/^[a-z][a-z0-9-]*$/.test(id))
|
|
284
|
+
|| new Set(ids).size !== ids.length) {
|
|
285
|
+
throw new Error('Invalid command registry response');
|
|
286
|
+
}
|
|
287
|
+
_commandIds = new Set(ids);
|
|
254
288
|
return _commandIds;
|
|
255
289
|
} catch (e) {
|
|
256
290
|
_commandIds = new Set();
|
package/dist/src/mcp/registry.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { manageOmpMcp } from './omp-config.mjs';
|
|
2
|
+
import { replaceServer } from './toml-editor.mjs';
|
|
2
3
|
import { resolveOmpPaths } from '../providers/omp-paths.mjs';
|
|
3
4
|
/**
|
|
4
5
|
* MCP Server Registry
|
|
@@ -246,18 +247,30 @@ function buildServerConfig(server, provider) {
|
|
|
246
247
|
/**
|
|
247
248
|
* Build a TOML section for a server (Codex/OpenAI provider).
|
|
248
249
|
*/
|
|
250
|
+
function tomlString(value) {
|
|
251
|
+
if (typeof value !== 'string' || [...value].some(char => {
|
|
252
|
+
const point = char.codePointAt(0);
|
|
253
|
+
return point >= 0xd800 && point <= 0xdfff;
|
|
254
|
+
}))
|
|
255
|
+
throw new Error('TOML values must be strings containing valid Unicode scalar values');
|
|
256
|
+
// JSON escapes align with TOML basic strings except DEL must also be escaped.
|
|
257
|
+
return JSON.stringify(value).replace(/\u007f/g, '\\u007f');
|
|
258
|
+
}
|
|
259
|
+
function tomlKey(value) {
|
|
260
|
+
return typeof value === 'string' && /^[A-Za-z0-9_-]+$/.test(value) ? value : tomlString(value);
|
|
261
|
+
}
|
|
249
262
|
function buildServerToml(server) {
|
|
250
263
|
const lines = [];
|
|
251
|
-
lines.push(`[mcp_servers.${server.name}]`);
|
|
264
|
+
lines.push(`[mcp_servers.${tomlKey(server.name)}]`);
|
|
252
265
|
if (server.type === 'stdio') {
|
|
253
|
-
lines.push(`command =
|
|
266
|
+
lines.push(`command = ${tomlString(server.command)}`);
|
|
254
267
|
if (server.args && server.args.length > 0) {
|
|
255
|
-
const argsStr = server.args.map(a =>
|
|
268
|
+
const argsStr = server.args.map(a => tomlString(a)).join(', ');
|
|
256
269
|
lines.push(`args = [${argsStr}]`);
|
|
257
270
|
}
|
|
258
271
|
}
|
|
259
272
|
else {
|
|
260
|
-
lines.push(`url =
|
|
273
|
+
lines.push(`url = ${tomlString(server.url)}`);
|
|
261
274
|
}
|
|
262
275
|
lines.push(`startup_timeout_sec = 10.0`);
|
|
263
276
|
lines.push(`tool_timeout_sec = 60.0`);
|
|
@@ -344,12 +357,21 @@ async function injectJson(registry, servers, configPath, provider, dryRun, resul
|
|
|
344
357
|
existing = JSON.parse(content);
|
|
345
358
|
}
|
|
346
359
|
catch (error) {
|
|
360
|
+
if (error instanceof SyntaxError) {
|
|
361
|
+
throw new Error(`Refusing to overwrite malformed MCP config ${configPath}: invalid JSON`);
|
|
362
|
+
}
|
|
347
363
|
if ((provider === 'antigravity' || provider === 'agy') && error?.code !== 'ENOENT') {
|
|
348
364
|
throw new Error(`Refusing to overwrite malformed MCP config ${configPath}: ${error.message}`);
|
|
349
365
|
}
|
|
366
|
+
if (error?.code !== 'ENOENT')
|
|
367
|
+
throw error;
|
|
350
368
|
}
|
|
351
369
|
// Determine the MCP servers key for this provider
|
|
352
370
|
const mcpKey = provider === 'opencode' ? 'mcp' : 'mcpServers';
|
|
371
|
+
const isObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
372
|
+
if (!isObject(existing) || (Object.hasOwn(existing, mcpKey) && !isObject(existing[mcpKey]))) {
|
|
373
|
+
throw new Error('MCP configuration must contain an object root and an object server map');
|
|
374
|
+
}
|
|
353
375
|
const existingServers = existing[mcpKey] || {};
|
|
354
376
|
// Build new server entries
|
|
355
377
|
const newServers = { ...existingServers };
|
|
@@ -382,26 +404,17 @@ async function injectToml(registry, servers, configPath, provider, dryRun, resul
|
|
|
382
404
|
try {
|
|
383
405
|
existing = await readFile(configPath, 'utf-8');
|
|
384
406
|
}
|
|
385
|
-
catch {
|
|
386
|
-
|
|
407
|
+
catch (error) {
|
|
408
|
+
if (error.code !== 'ENOENT')
|
|
409
|
+
throw error;
|
|
387
410
|
}
|
|
388
|
-
const sectionsToAdd = [];
|
|
389
411
|
for (const server of servers) {
|
|
390
|
-
const
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
const sectionRegex = new RegExp(`\\[mcp_servers\\.${escapeRegex(server.name)}\\][\\s\\S]*?(?=\\n\\[|$)`);
|
|
394
|
-
existing = existing.replace(sectionRegex, buildServerToml(server));
|
|
412
|
+
const edited = replaceServer(existing, server.name, buildServerToml(server));
|
|
413
|
+
existing = edited.text;
|
|
414
|
+
if (edited.alreadyPresent)
|
|
395
415
|
result.alreadyPresent.push(server.name);
|
|
396
|
-
}
|
|
397
|
-
else {
|
|
398
|
-
sectionsToAdd.push(buildServerToml(server));
|
|
399
|
-
}
|
|
400
416
|
result.serversInjected.push(server.name);
|
|
401
417
|
}
|
|
402
|
-
if (sectionsToAdd.length > 0) {
|
|
403
|
-
existing = existing.trimEnd() + '\n\n' + sectionsToAdd.join('\n\n') + '\n';
|
|
404
|
-
}
|
|
405
418
|
if (!dryRun) {
|
|
406
419
|
await mkdir(resolve(configPath, '..'), { recursive: true });
|
|
407
420
|
await writeFile(configPath, existing, 'utf-8');
|
|
@@ -411,9 +424,6 @@ async function injectToml(registry, servers, configPath, provider, dryRun, resul
|
|
|
411
424
|
}
|
|
412
425
|
return result;
|
|
413
426
|
}
|
|
414
|
-
function escapeRegex(str) {
|
|
415
|
-
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
416
|
-
}
|
|
417
427
|
/** All supported provider names for injection */
|
|
418
428
|
export const SUPPORTED_PROVIDERS = [
|
|
419
429
|
'antigravity',
|