@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.
@@ -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 = validateThreatAssessmentConfig(parsed.security?.threatAssessment);
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 = validateThreatAssessmentConfig(config.security?.threatAssessment);
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
  }
@@ -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
- * (any hook entry tagged `_aiwg_managed: true`). Accepts both the
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 pre-existing settings.json to ${backup}`);
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
- if (h._aiwg_id !== hookId)
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
- if (h.command !== command || h.type !== 'command' || h._aiwg_managed !== true) {
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
- result.warnings.push(`Refreshed stale ${event} → ${hookId} command path`);
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
  }
@@ -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
- return [...content.matchAll(/\[[^\]]+\]\((\.\/?[^)#]+)(?:#[^)]+)?\)/g)].map((match) => match[1]);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiwg/cli",
3
- "version": "2026.9.7",
3
+ "version": "2026.9.9",
4
4
  "description": "Lightweight AIWG CLI for signed, versioned web-backed resources.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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 command');
2947
- console.warn(' files overlap with newly deployed skills. Remove AIWG command files manually');
2948
- console.warn(` to fix: rm ${rel}/<command>.md`);
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