@aiwg/cli 2026.9.7 → 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/installation.js +102 -2
- package/dist/src/cli/handlers/mc.js +87 -17
- package/dist/src/cli/handlers/refresh.js +67 -7
- package/dist/src/cli/handlers/repo-access.js +155 -4
- 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 +9 -4
- 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 +39 -6
- 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/extensions/claude-hooks-installer.js +22 -6
- package/dist/src/lint/runner.js +138 -0
- package/dist/src/smiths/context-pipeline/workspace-context.js +51 -1
- package/package.json +1 -1
- package/tools/agents/deploy-agents.mjs +4 -0
- package/tools/agents/providers/base.mjs +101 -4
|
@@ -35,6 +35,92 @@ export const WORKSPACE_REPO_ACTIONS = [
|
|
|
35
35
|
'service-action',
|
|
36
36
|
'destructive',
|
|
37
37
|
];
|
|
38
|
+
export const PROJECT_CLASSIFICATIONS = ['private', 'sanitized', 'public'];
|
|
39
|
+
/** Normalize the string-or-object `project` field to the object form. */
|
|
40
|
+
/**
|
|
41
|
+
* Validate the `project` block. Accepts the bare-string form unconditionally so
|
|
42
|
+
* existing configs keep loading (#2535).
|
|
43
|
+
*/
|
|
44
|
+
export function validateProjectConfig(project) {
|
|
45
|
+
const errors = [];
|
|
46
|
+
if (project === undefined || typeof project === 'string')
|
|
47
|
+
return errors;
|
|
48
|
+
if (typeof project !== 'object' || project === null || Array.isArray(project)) {
|
|
49
|
+
errors.push('project: must be a string (name) or an object');
|
|
50
|
+
return errors;
|
|
51
|
+
}
|
|
52
|
+
const value = project;
|
|
53
|
+
if (value.classification !== undefined
|
|
54
|
+
&& !PROJECT_CLASSIFICATIONS.includes(value.classification)) {
|
|
55
|
+
errors.push(`project.classification: must be one of ${PROJECT_CLASSIFICATIONS.join(' | ')}`);
|
|
56
|
+
}
|
|
57
|
+
for (const key of ['name', 'description']) {
|
|
58
|
+
if (value[key] !== undefined && typeof value[key] !== 'string') {
|
|
59
|
+
errors.push(`project.${key}: must be a string`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (value.pii !== undefined && typeof value.pii !== 'boolean') {
|
|
63
|
+
errors.push('project.pii: must be a boolean');
|
|
64
|
+
}
|
|
65
|
+
if (value.handling !== undefined) {
|
|
66
|
+
if (typeof value.handling !== 'object' || value.handling === null || Array.isArray(value.handling)) {
|
|
67
|
+
errors.push('project.handling: must be an object');
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
const handling = value.handling;
|
|
71
|
+
for (const key of ['excerptable', 'publishable', 'mirror']) {
|
|
72
|
+
if (handling[key] !== undefined && typeof handling[key] !== 'boolean') {
|
|
73
|
+
errors.push(`project.handling.${key}: must be a boolean`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return errors;
|
|
79
|
+
}
|
|
80
|
+
export function resolveProject(project) {
|
|
81
|
+
if (project === undefined)
|
|
82
|
+
return undefined;
|
|
83
|
+
if (typeof project === 'string')
|
|
84
|
+
return { name: project };
|
|
85
|
+
return project;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Handling defaults derived from the classification when not stated explicitly.
|
|
89
|
+
* A `private` repo is closed by default; anything else stays permissive, so
|
|
90
|
+
* declaring a classification never silently tightens an existing project.
|
|
91
|
+
*/
|
|
92
|
+
export function resolveProjectHandling(project) {
|
|
93
|
+
const closed = project?.classification === 'private';
|
|
94
|
+
return {
|
|
95
|
+
excerptable: project?.handling?.excerptable ?? !closed,
|
|
96
|
+
publishable: project?.handling?.publishable ?? !closed,
|
|
97
|
+
mirror: project?.handling?.mirror ?? !closed,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/** Deprecated spellings mapped to their current value. */
|
|
101
|
+
export const FORCE_PUSH_POLICY_ALIASES = {
|
|
102
|
+
'main-only-blocked': 'own-branch-only',
|
|
103
|
+
};
|
|
104
|
+
/**
|
|
105
|
+
* The rename also narrowed the permission: `main-only-blocked` allowed force-push on
|
|
106
|
+
* *any* feature branch, `own-branch-only` restricts it to the agent's own. Callers
|
|
107
|
+
* surface this rather than migrating silently, because accepting the alias quietly
|
|
108
|
+
* would change what an agent is permitted to do.
|
|
109
|
+
*/
|
|
110
|
+
export const FORCE_PUSH_POLICY_ALIAS_NOTE = "'main-only-blocked' is a deprecated alias for 'own-branch-only'. The permission also narrowed: "
|
|
111
|
+
+ 'the old value allowed force-push on any feature branch, the new one only on the agent\'s own branch.';
|
|
112
|
+
/**
|
|
113
|
+
* Normalize a force-push policy, mapping deprecated spellings forward. Returns the
|
|
114
|
+
* canonical value and the alias it came from, if any.
|
|
115
|
+
*/
|
|
116
|
+
export function normalizeForcePushPolicy(value) {
|
|
117
|
+
if (value === undefined)
|
|
118
|
+
return { policy: undefined };
|
|
119
|
+
const alias = FORCE_PUSH_POLICY_ALIASES[value];
|
|
120
|
+
if (alias)
|
|
121
|
+
return { policy: alias, deprecatedFrom: value };
|
|
122
|
+
return { policy: value };
|
|
123
|
+
}
|
|
38
124
|
const DEFAULT_BRANCH_NAMING = {
|
|
39
125
|
prefix_by_type: {
|
|
40
126
|
feat: 'feat/{issue}-{slug}',
|
|
@@ -70,7 +156,7 @@ export function resolveDelivery(delivery) {
|
|
|
70
156
|
committer: delivery?.committer,
|
|
71
157
|
signing: delivery?.signing,
|
|
72
158
|
release_signing: delivery?.release_signing,
|
|
73
|
-
force_push_policy: delivery?.force_push_policy ?? 'never',
|
|
159
|
+
force_push_policy: normalizeForcePushPolicy(delivery?.force_push_policy).policy ?? 'never',
|
|
74
160
|
auto_close_issues: delivery?.auto_close_issues ?? true,
|
|
75
161
|
issue_comment_on_cycle: delivery?.issue_comment_on_cycle ?? true,
|
|
76
162
|
};
|
|
@@ -770,7 +856,10 @@ export async function readAiwgConfig(projectDir) {
|
|
|
770
856
|
if (authorizationErrors.length > 0) {
|
|
771
857
|
throw new Error(`Invalid .aiwg/aiwg.config:\n${authorizationErrors.map(item => item.message).join('\n')}`);
|
|
772
858
|
}
|
|
773
|
-
const threatAssessmentErrors =
|
|
859
|
+
const threatAssessmentErrors = [
|
|
860
|
+
...validateThreatAssessmentConfig(parsed.security?.threatAssessment),
|
|
861
|
+
...validateProjectConfig(parsed.project),
|
|
862
|
+
];
|
|
774
863
|
if (threatAssessmentErrors.length > 0) {
|
|
775
864
|
throw new Error(`Invalid .aiwg/aiwg.config:\n${threatAssessmentErrors.join('\n')}`);
|
|
776
865
|
}
|
|
@@ -788,7 +877,10 @@ export async function readAiwgConfig(projectDir) {
|
|
|
788
877
|
* sync so split-root health remains deterministic.
|
|
789
878
|
*/
|
|
790
879
|
export async function writeAiwgConfig(projectDir, config) {
|
|
791
|
-
const threatAssessmentErrors =
|
|
880
|
+
const threatAssessmentErrors = [
|
|
881
|
+
...validateThreatAssessmentConfig(config.security?.threatAssessment),
|
|
882
|
+
...validateProjectConfig(config.project),
|
|
883
|
+
];
|
|
792
884
|
if (threatAssessmentErrors.length > 0) {
|
|
793
885
|
throw new Error(`Invalid .aiwg/aiwg.config:\n${threatAssessmentErrors.join('\n')}`);
|
|
794
886
|
}
|
package/dist/src/config/cli.js
CHANGED
|
@@ -142,6 +142,14 @@ async function handleSet(config, args) {
|
|
|
142
142
|
// Set validates enum membership for known fields (delivery.mode,
|
|
143
143
|
// delivery.merge_style, delivery.force_push_policy, remotes.issue_provider)
|
|
144
144
|
// before writing.
|
|
145
|
+
/** Deprecated values accepted on `set` and normalized to their current spelling. */
|
|
146
|
+
const ENUM_ALIASES = {
|
|
147
|
+
'delivery.force_push_policy': { 'main-only-blocked': 'own-branch-only' },
|
|
148
|
+
};
|
|
149
|
+
/** Extra context printed alongside an alias normalization, where semantics also changed. */
|
|
150
|
+
const ENUM_ALIAS_NOTES = {
|
|
151
|
+
'delivery.force_push_policy': "The permission also narrowed: the old value allowed force-push on any feature branch, the new one only on the agent's own branch.",
|
|
152
|
+
};
|
|
145
153
|
const ENUM_RULES = {
|
|
146
154
|
'delivery.mode': ['direct', 'feature-branch', 'pr-required'],
|
|
147
155
|
'delivery.merge_style': ['rebase-merge', 'squash', 'merge', 'fast-forward-only'],
|
|
@@ -210,8 +218,15 @@ async function projectConfigGet(key, args) {
|
|
|
210
218
|
async function projectConfigSet(key, raw, args) {
|
|
211
219
|
const { readAiwgConfig, writeAiwgConfig, getProjectDir, emptyConfig, validateExternalLinks, } = await import('./aiwg-config.js');
|
|
212
220
|
const projectDir = getProjectDir(undefined, args);
|
|
213
|
-
// Validate enum fields before writing
|
|
221
|
+
// Validate enum fields before writing. Deprecated spellings normalize forward with a
|
|
222
|
+
// notice rather than failing, so a config written before a rename stays settable (#2532).
|
|
214
223
|
const allowed = ENUM_RULES[key];
|
|
224
|
+
const aliased = ENUM_ALIASES[key]?.[raw];
|
|
225
|
+
if (aliased) {
|
|
226
|
+
process.stderr.write(`Note: '${raw}' is a deprecated alias for '${aliased}'; writing '${aliased}'.\n`
|
|
227
|
+
+ (ENUM_ALIAS_NOTES[key] ? ` ${ENUM_ALIAS_NOTES[key]}\n` : ''));
|
|
228
|
+
raw = aliased;
|
|
229
|
+
}
|
|
215
230
|
if (allowed && !allowed.includes(raw)) {
|
|
216
231
|
throw new AiwgError({
|
|
217
232
|
code: 'ERR_INVALID_VALUE',
|
|
@@ -24,6 +24,9 @@ const GITIGNORE_PROBE_BASENAME = '.aiwg-ignore-probe';
|
|
|
24
24
|
*/
|
|
25
25
|
export const AIWG_RUNTIME_PATTERNS = [
|
|
26
26
|
'.aiwg/working/',
|
|
27
|
+
// Transactional preimages written before AIWG rewrites operator files.
|
|
28
|
+
// Recoverable local evidence, not project content (#2542).
|
|
29
|
+
'.aiwg/backups/',
|
|
27
30
|
// .aiwg/.index/ is the artifact graph index (JSON nodes/edges + checksum
|
|
28
31
|
// manifest). It is a pure build artifact, fully regenerable from corpus
|
|
29
32
|
// content via `aiwg index build --all`, so it is ignored rather than committed.
|
|
@@ -56,6 +59,8 @@ export const PROVIDER_CONVENTIONAL_PATTERNS = [
|
|
|
56
59
|
*/
|
|
57
60
|
export const CLAUDE_SESSION_PATTERNS = [
|
|
58
61
|
'.claude/settings.local.json',
|
|
62
|
+
// Timestamped backups AIWG writes before merging hook entries (#2542).
|
|
63
|
+
'.claude/settings.json.bak.*',
|
|
59
64
|
];
|
|
60
65
|
/** All recommended patterns combined */
|
|
61
66
|
export const ALL_RECOMMENDED_PATTERNS = [
|
|
@@ -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
|
}
|
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;
|
|
@@ -33,6 +33,12 @@ const LEGACY_ROOT_FILES = [
|
|
|
33
33
|
'.github/copilot-instructions.md',
|
|
34
34
|
'AIWG.md',
|
|
35
35
|
];
|
|
36
|
+
/**
|
|
37
|
+
* Operator-content volume above which a provider-named source is surfaced for a scope
|
|
38
|
+
* decision instead of being routed on filename alone (#2537). A genuine provider adapter
|
|
39
|
+
* is a few hundred bytes; a migrated project contract is tens of KB.
|
|
40
|
+
*/
|
|
41
|
+
const SCOPE_REVIEW_BYTES = 4096;
|
|
36
42
|
const GENERATED_BLOCKS = [
|
|
37
43
|
[PROVIDER_BOOTSTRAP_START, PROVIDER_BOOTSTRAP_END],
|
|
38
44
|
['<!-- AIWG:context-hook:start -->', '<!-- AIWG:context-hook:end -->'],
|
|
@@ -697,6 +703,21 @@ export async function auditWorkspaceContext(projectPath) {
|
|
|
697
703
|
const providerSources = rootOperator.filter((source) => source.path !== 'WORKSPACE.md' && !neutralSources.includes(source.path)).map((source) => source.path);
|
|
698
704
|
const providerOutputs = providerSources.map((source) => providerContextOutput(projectPath, source));
|
|
699
705
|
const workspaceExists = sources.some((source) => source.path === 'WORKSPACE.md');
|
|
706
|
+
// Filename is the default scope signal, but a project that used CLAUDE.md as its
|
|
707
|
+
// main context file before WORKSPACE.md existed has project-neutral methodology in
|
|
708
|
+
// a provider-named file. Report volume and destination per source, and surface the
|
|
709
|
+
// substantial ones as a decision rather than routing them silently (#2537).
|
|
710
|
+
const routing = rootOperator.map((source) => {
|
|
711
|
+
const neutral = neutralSources.includes(source.path);
|
|
712
|
+
return {
|
|
713
|
+
source: source.path,
|
|
714
|
+
operatorBytes: Buffer.byteLength(source.operatorContent, 'utf8'),
|
|
715
|
+
destination: neutral ? 'WORKSPACE.md' : providerContextOutput(projectPath, source.path),
|
|
716
|
+
scope: neutral ? 'project-neutral' : `${source.provider ?? 'provider'}-only`,
|
|
717
|
+
provider: neutral ? null : source.provider,
|
|
718
|
+
};
|
|
719
|
+
}).sort((a, b) => b.operatorBytes - a.operatorBytes);
|
|
720
|
+
const scopeReview = routing.filter((entry) => entry.scope !== 'project-neutral' && entry.operatorBytes >= SCOPE_REVIEW_BYTES);
|
|
700
721
|
return {
|
|
701
722
|
version: 1,
|
|
702
723
|
projectPath,
|
|
@@ -708,6 +729,8 @@ export async function auditWorkspaceContext(projectPath) {
|
|
|
708
729
|
conflicts,
|
|
709
730
|
sensitiveFindings,
|
|
710
731
|
plan: {
|
|
732
|
+
routing,
|
|
733
|
+
scopeReview,
|
|
711
734
|
neutralSources,
|
|
712
735
|
providerSources,
|
|
713
736
|
nestedSources: sources.filter((source) => source.scope === 'nested').map((source) => source.path),
|
|
@@ -945,8 +968,35 @@ export async function rollbackWorkspaceContext(projectPath, requestedId) {
|
|
|
945
968
|
await atomicWrite(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
|
|
946
969
|
return { id, restored: manifest.files.map((file) => file.path) };
|
|
947
970
|
}
|
|
971
|
+
/**
|
|
972
|
+
* Blank out fenced code blocks and inline code spans, preserving offsets and line
|
|
973
|
+
* structure, so link extraction sees only prose. Illustrative paths inside an
|
|
974
|
+
* example block are documentation, not context links the graph should resolve (#2536).
|
|
975
|
+
*/
|
|
976
|
+
function maskCodeRegions(content) {
|
|
977
|
+
const lines = content.split('\n');
|
|
978
|
+
let fence = null;
|
|
979
|
+
const masked = lines.map((line) => {
|
|
980
|
+
const openOrClose = /^\s{0,3}(`{3,}|~{3,})(.*)$/.exec(line);
|
|
981
|
+
if (fence) {
|
|
982
|
+
// A closing fence uses the same character, is at least as long, and carries no info string.
|
|
983
|
+
if (openOrClose && openOrClose[1][0] === fence.marker && openOrClose[1].length >= fence.length && openOrClose[2].trim() === '') {
|
|
984
|
+
fence = null;
|
|
985
|
+
}
|
|
986
|
+
return ' '.repeat(line.length);
|
|
987
|
+
}
|
|
988
|
+
if (openOrClose) {
|
|
989
|
+
fence = { marker: openOrClose[1][0], length: openOrClose[1].length };
|
|
990
|
+
return ' '.repeat(line.length);
|
|
991
|
+
}
|
|
992
|
+
// Inline code spans: a run of N backticks closes on the next run of exactly N.
|
|
993
|
+
return line.replace(/(`+)(?:[^`]|(?!\1)`)*?\1/g, (span) => ' '.repeat(span.length));
|
|
994
|
+
});
|
|
995
|
+
return masked.join('\n');
|
|
996
|
+
}
|
|
948
997
|
function markdownLinks(content) {
|
|
949
|
-
|
|
998
|
+
const prose = maskCodeRegions(content);
|
|
999
|
+
return [...prose.matchAll(/\[[^\]]+\]\((\.\/?[^)#]+)(?:#[^)]+)?\)/g)].map((match) => match[1]);
|
|
950
1000
|
}
|
|
951
1001
|
export async function workspaceLinkedFiles(projectPath) {
|
|
952
1002
|
const content = await readOptional(path.join(projectPath, 'WORKSPACE.md'));
|
package/package.json
CHANGED
|
@@ -505,6 +505,7 @@ function parseArgs() {
|
|
|
505
505
|
asPlugin: false, // Generate .factory-plugin/ bundle (Factory provider only)
|
|
506
506
|
deployBehaviors: false, // Deploy behaviors in addition to agents
|
|
507
507
|
skipCommandsMigration: false, // Skip commands → skills migration (warns about duplicates)
|
|
508
|
+
warnOnSkippedCommandsMigration: true, // Emit the duplicate warning when the migration is skipped
|
|
508
509
|
// Managed-marker provenance (#2502). Deployers that are not shipping the
|
|
509
510
|
// bundled framework corpus (project-local bundles, in particular) must
|
|
510
511
|
// override these so `aiwg refresh` does not mistake their artifacts for
|
|
@@ -544,6 +545,8 @@ function parseArgs() {
|
|
|
544
545
|
else if (a === '--quiet' || a === '-q') cfg.quiet = true;
|
|
545
546
|
else if (a === '--as-plugin') cfg.asPlugin = true;
|
|
546
547
|
else if (a === '--skip-commands-migration') cfg.skipCommandsMigration = true;
|
|
548
|
+
// Structural opt-out: skip the migration without claiming the operator declined it (#2541).
|
|
549
|
+
else if (a === '--no-commands-warning') cfg.warnOnSkippedCommandsMigration = false;
|
|
547
550
|
else if (a === '--copy-all' || a === '--copy-standard-skills') cfg.copyStandardSkills = true;
|
|
548
551
|
else if (a === '--deploy-source' && args[i + 1]) cfg.deploySource = String(args[++i]);
|
|
549
552
|
else if (a === '--deploy-version' && args[i + 1]) cfg.deployVersion = String(args[++i]);
|
|
@@ -1010,6 +1013,7 @@ export async function main() {
|
|
|
1010
1013
|
asPlugin: cfg.asPlugin,
|
|
1011
1014
|
deployBehaviors: cfg.kernelOnly ? false : cfg.deployBehaviors,
|
|
1012
1015
|
skipCommandsMigration: cfg.skipCommandsMigration,
|
|
1016
|
+
warnOnSkip: cfg.warnOnSkippedCommandsMigration !== false,
|
|
1013
1017
|
// #1217 / #1219: --copy-all flag forces legacy per-project mirror
|
|
1014
1018
|
// for the standard tier. Default is no-copy + index-driven discovery.
|
|
1015
1019
|
// Replaces the legacy AIWG_COPY_STANDARD_SKILLS env var (removed rc.30).
|
|
@@ -476,6 +476,41 @@ export function injectPlatformInContent(content, targetPlatform) {
|
|
|
476
476
|
* copies destined for such providers must drop the field entirely. An
|
|
477
477
|
* absent field is the documented "compatible with all platforms" default.
|
|
478
478
|
*/
|
|
479
|
+
/**
|
|
480
|
+
* Remove the `triggers:` field from a deployed rule's frontmatter.
|
|
481
|
+
*
|
|
482
|
+
* Rules declare trigger phrases so `aiwg discover` can reach them by the
|
|
483
|
+
* question an agent asks rather than by their policy name (#2544). That is
|
|
484
|
+
* index-time metadata: no provider matches a *rule* by trigger, and the agent
|
|
485
|
+
* reading the deployed rule gains nothing from the list. Shipping it spends
|
|
486
|
+
* startup context on noise, which is exactly the budget #2540 is defending —
|
|
487
|
+
* ~2KB across the 16 rules covered today, and ~16KB if every rule adopts.
|
|
488
|
+
*
|
|
489
|
+
* Skills are untouched: several providers do match skills by trigger.
|
|
490
|
+
*/
|
|
491
|
+
export function stripTriggersFromContent(content) {
|
|
492
|
+
const fmMatch = content.match(/^(---\r?\n)([\s\S]*?)(\r?\n---(?:\r?\n|$))([\s\S]*)$/);
|
|
493
|
+
if (!fmMatch) return content;
|
|
494
|
+
|
|
495
|
+
const [, open, fm, close, body] = fmMatch;
|
|
496
|
+
// Block list form:
|
|
497
|
+
// triggers:
|
|
498
|
+
// - "am I allowed to do this"
|
|
499
|
+
let updated = fm.replace(
|
|
500
|
+
/^triggers:[ \t]*\r?\n(?:[ \t]+-[ \t]+\S[^\r\n]*(?:\r?\n|$))*/m,
|
|
501
|
+
'',
|
|
502
|
+
);
|
|
503
|
+
// Inline form: triggers: ["a", "b"]
|
|
504
|
+
if (updated === fm) updated = fm.replace(/^triggers:[^\r\n]*(?:\r?\n|$)/m, '');
|
|
505
|
+
|
|
506
|
+
if (updated === fm) return content;
|
|
507
|
+
// An otherwise-empty frontmatter block is dropped rather than left as `---\n---`.
|
|
508
|
+
if (updated.trim().length === 0) return body.replace(/^\r?\n/, '');
|
|
509
|
+
// Removing a block mid-frontmatter can leave a trailing blank line before the
|
|
510
|
+
// closing fence; the deployed file should not carry it.
|
|
511
|
+
return open + updated.replace(/\s+$/, '') + close + body;
|
|
512
|
+
}
|
|
513
|
+
|
|
479
514
|
export function stripPlatformsFromContent(content) {
|
|
480
515
|
const fmMatch = content.match(/^(---\r?\n)([\s\S]*?)(\r?\n---(?:\r?\n|$))([\s\S]*)$/);
|
|
481
516
|
if (!fmMatch) return content;
|
|
@@ -707,6 +742,13 @@ export function deployFiles(files, destDir, opts, transformFn) {
|
|
|
707
742
|
const srcContent = fs.readFileSync(f, 'utf8');
|
|
708
743
|
let transformedContent = transformFn ? transformFn(f, srcContent, opts) : srcContent;
|
|
709
744
|
|
|
745
|
+
// Rule triggers are index metadata, not something the reading agent needs.
|
|
746
|
+
// Keyed on the source path so every provider's rule deploy gets it without
|
|
747
|
+
// eight call sites opting in, and so skill triggers are never touched (#2544).
|
|
748
|
+
if (/(?:^|[\\/])rules[\\/][^\\/]+$/.test(f)) {
|
|
749
|
+
transformedContent = stripTriggersFromContent(transformedContent);
|
|
750
|
+
}
|
|
751
|
+
|
|
710
752
|
// Inject target platform into agent .md files that use platforms: [all]
|
|
711
753
|
if (injectPlatform && provider && /platforms:\s*\[all\]/.test(transformedContent)) {
|
|
712
754
|
const platformName = PROVIDER_TO_PLATFORM[provider] || provider;
|
|
@@ -2932,8 +2974,47 @@ export function cleanupOldRuleFiles(rulesDir, opts = {}) {
|
|
|
2932
2974
|
* @param {boolean} opts.skipCommandsMigration - User opted out; warn about duplicates instead
|
|
2933
2975
|
* @returns {boolean} true if any AIWG command file was removed (or would be in dry-run)
|
|
2934
2976
|
*/
|
|
2977
|
+
/**
|
|
2978
|
+
* Commands directories already warned about this process. The stale-command
|
|
2979
|
+
* condition belongs to the directory, not to each deployed framework/addon, so
|
|
2980
|
+
* `aiwg use all` must not repeat it once per unit (#2541).
|
|
2981
|
+
*/
|
|
2982
|
+
const warnedCommandsDirs = new Set();
|
|
2983
|
+
|
|
2984
|
+
/**
|
|
2985
|
+
* AIWG-managed command filenames in a directory — sidecar entries or files
|
|
2986
|
+
* carrying the managed marker. Operator-authored commands and current
|
|
2987
|
+
* skill-command wrappers are excluded.
|
|
2988
|
+
*/
|
|
2989
|
+
function listManagedCommandFiles(commandsDir) {
|
|
2990
|
+
let entries;
|
|
2991
|
+
try {
|
|
2992
|
+
entries = fs.readdirSync(commandsDir, { withFileTypes: true });
|
|
2993
|
+
} catch {
|
|
2994
|
+
return [];
|
|
2995
|
+
}
|
|
2996
|
+
const sidecar = readSidecarManifest(commandsDir) || { managed: {} };
|
|
2997
|
+
const managed = sidecar.managed || {};
|
|
2998
|
+
const names = [];
|
|
2999
|
+
for (const entry of entries) {
|
|
3000
|
+
if (!entry.isFile()) continue;
|
|
3001
|
+
if (!entry.name.toLowerCase().endsWith('.md')) continue;
|
|
3002
|
+
if (managed[entry.name]?.kind === 'skill-command') continue;
|
|
3003
|
+
let owned = Object.prototype.hasOwnProperty.call(managed, entry.name);
|
|
3004
|
+
if (!owned) {
|
|
3005
|
+
try {
|
|
3006
|
+
owned = MANAGED_MARKER_RE.test(fs.readFileSync(path.join(commandsDir, entry.name), 'utf8'));
|
|
3007
|
+
} catch {
|
|
3008
|
+
owned = false;
|
|
3009
|
+
}
|
|
3010
|
+
}
|
|
3011
|
+
if (owned) names.push(entry.name);
|
|
3012
|
+
}
|
|
3013
|
+
return names;
|
|
3014
|
+
}
|
|
3015
|
+
|
|
2935
3016
|
export function migrateCommandsDirectory(commandsDir, opts = {}) {
|
|
2936
|
-
const { dryRun = false, skipCommandsMigration = false, verbose = false } = opts;
|
|
3017
|
+
const { dryRun = false, skipCommandsMigration = false, verbose = false, warnOnSkip = true } = opts;
|
|
2937
3018
|
|
|
2938
3019
|
if (!fs.existsSync(commandsDir)) return false;
|
|
2939
3020
|
|
|
@@ -2941,11 +3022,27 @@ export function migrateCommandsDirectory(commandsDir, opts = {}) {
|
|
|
2941
3022
|
if (entries.length === 0) return false;
|
|
2942
3023
|
|
|
2943
3024
|
if (skipCommandsMigration) {
|
|
3025
|
+
// Structural opt-outs (project-local addon bundles) skip the migration because
|
|
3026
|
+
// it does not apply to them, not because the operator declined it. Warning
|
|
3027
|
+
// there is noise, and it fired once per bundle (#2541).
|
|
3028
|
+
if (!warnOnSkip) return false;
|
|
3029
|
+
|
|
2944
3030
|
const rel = path.relative(process.cwd(), commandsDir);
|
|
3031
|
+
// The condition is a property of the directory, not of each deployed unit;
|
|
3032
|
+
// emit it once per run no matter how many units pass through.
|
|
3033
|
+
if (warnedCommandsDirs.has(commandsDir)) return false;
|
|
3034
|
+
warnedCommandsDirs.add(commandsDir);
|
|
3035
|
+
|
|
3036
|
+
const stale = listManagedCommandFiles(commandsDir);
|
|
3037
|
+
if (stale.length === 0) return false;
|
|
3038
|
+
|
|
2945
3039
|
console.warn(`\nWarning: commands migration skipped for ${rel}`);
|
|
2946
|
-
console.warn(' Duplicate entries may appear in the command palette because old
|
|
2947
|
-
console.warn(' files overlap with newly deployed skills
|
|
2948
|
-
console.warn(`
|
|
3040
|
+
console.warn(' Duplicate entries may appear in the command palette because these old');
|
|
3041
|
+
console.warn(' AIWG command files overlap with newly deployed skills:');
|
|
3042
|
+
for (const name of stale) console.warn(` ${path.join(rel, name)}`);
|
|
3043
|
+
console.warn(' Resolve automatically by re-running without --skip-commands-migration,');
|
|
3044
|
+
console.warn(' or remove them directly:');
|
|
3045
|
+
console.warn(` rm ${stale.map((name) => path.join(rel, name)).join(' ')}`);
|
|
2949
3046
|
return false;
|
|
2950
3047
|
}
|
|
2951
3048
|
|