@aiwg/cli 2026.9.7 → 2026.9.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/bin/aiwg.mjs +55 -10
  2. package/dist/src/artifacts/cli.js +20 -8
  3. package/dist/src/artifacts/corpus-views/taxonomies.js +1 -1
  4. package/dist/src/artifacts/index-builder.js +43 -1
  5. package/dist/src/artifacts/query-engine.js +7 -0
  6. package/dist/src/cli/handlers/help.js +6 -5
  7. package/dist/src/cli/handlers/init.js +7 -2
  8. package/dist/src/cli/handlers/installation.js +102 -2
  9. package/dist/src/cli/handlers/mc.js +87 -17
  10. package/dist/src/cli/handlers/ralph.js +8 -8
  11. package/dist/src/cli/handlers/refresh.js +67 -7
  12. package/dist/src/cli/handlers/repo-access.js +155 -4
  13. package/dist/src/cli/handlers/setup.js +5 -5
  14. package/dist/src/cli/handlers/steward.js +39 -2
  15. package/dist/src/cli/handlers/use.js +13 -4
  16. package/dist/src/cli/handlers/version.js +49 -14
  17. package/dist/src/cli/handlers/workspace-context.js +8 -0
  18. package/dist/src/cli/services/deployment-verification.js +71 -10
  19. package/dist/src/cli/watch-service.js +43 -3
  20. package/dist/src/config/aiwg-config.js +95 -3
  21. package/dist/src/config/cli.js +16 -1
  22. package/dist/src/config/gitignore.js +5 -0
  23. package/dist/src/dataset/fortemi-capability.d.ts +70 -0
  24. package/dist/src/dataset/fortemi-capability.js +211 -0
  25. package/dist/src/dataset/fortemi-dataset-execution.d.ts +9 -0
  26. package/dist/src/dataset/fortemi-dataset-execution.js +49 -8
  27. package/dist/src/dataset/fortemi-run-receipt.d.ts +1 -0
  28. package/dist/src/dataset/fortemi-run-receipt.js +2 -19
  29. package/dist/src/dataset/fortemi-schema-path.d.ts +7 -0
  30. package/dist/src/dataset/fortemi-schema-path.js +24 -0
  31. package/dist/src/dataset/index.d.ts +1 -0
  32. package/dist/src/dataset/index.js +1 -0
  33. package/dist/src/extensions/claude-hooks-installer.js +22 -6
  34. package/dist/src/extensions/project-local-doctor.js +15 -1
  35. package/dist/src/lint/cli.js +5 -1
  36. package/dist/src/lint/reporters.js +17 -0
  37. package/dist/src/lint/runner.js +297 -4
  38. package/dist/src/policy/authorization.js +1 -1
  39. package/dist/src/smiths/context-pipeline/workspace-context.js +138 -49
  40. package/package.json +1 -1
  41. package/schemas/dataset/fortemi-capability-validation/1.0.1/authority.json +61 -0
  42. package/schemas/dataset/fortemi-capability-validation/1.0.1/capability.schema.json +469 -0
  43. package/schemas/security/threat-assessment-report.v1.schema.json +2 -1
  44. package/tools/agents/deploy-agents.mjs +8 -0
  45. package/tools/agents/providers/base.mjs +261 -16
  46. package/tools/agents/providers/claude.mjs +61 -4
  47. package/tools/agents/providers/codex.mjs +9 -0
  48. package/tools/skills/deploy-skills-codex.mjs +121 -7
@@ -7,6 +7,7 @@
7
7
  * @issue #810
8
8
  */
9
9
  import fs from 'fs';
10
+ import { spawnSync } from 'child_process';
10
11
  import fsp from 'fs/promises';
11
12
  import path from 'path';
12
13
  import { glob } from 'glob';
@@ -29,6 +30,165 @@ function parseFrontmatter(content) {
29
30
  return result;
30
31
  }
31
32
  const DEFAULT_REFERENCE_PATTERN = '\\bREF-\\d{3,}\\b';
33
+ /**
34
+ * Phrases that mark an identifier as deliberately absent (#2555). A mention
35
+ * next to one of these documents a gap rather than pointing at a document, so
36
+ * it is not an unresolved reference.
37
+ */
38
+ export const DEFAULT_ABSENCE_MARKERS = [
39
+ '\\bunallocated\\b',
40
+ '\\bnot in corpus\\b',
41
+ '\\bskipped\\b',
42
+ '\\bretired\\b',
43
+ '\\bwithdrawn\\b',
44
+ '\\bdeliberately (?:absent|unallocated|skipped)\\b',
45
+ '\\bnever (?:allocated|assigned)\\b',
46
+ '\\bdeduplicat',
47
+ ];
48
+ // Deliberately NOT markers: "does not exist", "missing", "no longer exists".
49
+ // Those are how a broken reference is described, not how a corpus records an
50
+ // intentional gap, and treating them as markers would suppress the finding this
51
+ // check exists for.
52
+ /**
53
+ * Verification targets — things the inducting agent could have checked.
54
+ *
55
+ * Requiring one of these is what separates "the agent skipped a cheap check"
56
+ * from "the paper's own claim is unverified". Only the first is a provenance
57
+ * gap; the second is legitimate analysis, and #2523 explicitly does not ask
58
+ * agents to stop declaring limitations. Validated against a 2,544-reference
59
+ * corpus, where grammar-only matching made ~45% of hits paper-claim prose
60
+ * ("scaling behavior above 7B is unverified", "Not confirmed (34% vs 51%)").
61
+ */
62
+ /**
63
+ * Split a markdown line into clauses.
64
+ *
65
+ * Corpus prose keeps whole paragraphs, bullet bodies and changelog table rows on
66
+ * a single line, so "same line" is far too coarse a scope for relating an
67
+ * unperformed action to its target. Sentence and cell boundaries are the unit
68
+ * that actually corresponds to one statement.
69
+ */
70
+ function splitClauses(line) {
71
+ return line
72
+ .split(/(?<=[.;:!?])\s+|\s+\u2014\s+|\s+--\s+|\|/g)
73
+ .map((c) => c.trim())
74
+ .filter(Boolean);
75
+ }
76
+ export const DEFAULT_VERIFICATION_TARGETS = [
77
+ 'openreview',
78
+ '(?:acl )?anthology',
79
+ 'proceedings',
80
+ 'camera[- ]ready',
81
+ 'published version',
82
+ '\\bPMLR\\b',
83
+ '\\bDBLP\\b',
84
+ '\\bOpenAlex\\b',
85
+ 'semantic scholar',
86
+ '\\bpubpeer\\b',
87
+ '\\bunpaywall\\b',
88
+ 'retraction|correction notice|expression of concern',
89
+ 'citation (?:census|count)',
90
+ 'influential citation',
91
+ '\\bPDF\\b',
92
+ 'full[- ]text',
93
+ '\\be-?print\\b',
94
+ '(?:code|project|dataset|repository|repo)\\s+(?:page|url|link|release|availability)',
95
+ '\\bvenue\\b',
96
+ '\\bacceptance\\b',
97
+ 'source[_ ]type',
98
+ ];
99
+ /**
100
+ * Phrases that assert a check was not performed. Drawn from real induction
101
+ * output — each of these has appeared in a corpus reference doc (#2523).
102
+ * Only counted when a verification target appears on the same line.
103
+ */
104
+ export const DEFAULT_UNCERTAINTY_PATTERNS = [
105
+ '(?:was|were|is|are) not (?:retrieved|run|performed|attempted|fetched|queried|checked|acquired|probed|verified|confirmed)',
106
+ 'not (?:retrieved|run|performed|attempted|fetched|queried|checked|acquired|probed|verified|confirmed)\\b',
107
+ // Requires the action to be stated as unperformed. Without the trailing verb
108
+ // this matched bare "no search" in unrelated prose and scope statements like
109
+ // "no exhaustive census is claimed", neither of which is a skipped check.
110
+ 'no (?:\\w+[ -]){0,4}(?:quer(?:y|ies)|search|census|fetch|check|lookup|probe)(?:es|s)?\\s+(?:was |were )?(?:performed|run|attempted|made|conducted|executed)',
111
+ '\\b(?:is|remains) unverified\\b',
112
+ '\\bunconfirmed\\b',
113
+ 'rests on .{0,60} rather than an independent',
114
+ ];
115
+ /**
116
+ * A dated outcome closes an uncertainty (#2523's "records an outcome with a
117
+ * date"). The vocabulary is deliberately wider than a verb+date pair: the
118
+ * retraction convention recommended alongside this rule writes
119
+ * `~~<limitation>~~ **Done YYYY-MM-DD (<what closed it>).**`, which no
120
+ * verb+date pattern matches, so a correctly dated retraction was rejected for
121
+ * its shape (#2556).
122
+ */
123
+ export const DATED_OUTCOME_PATTERN = '(?:\\b(?:done|resolved|completed|closed|addressed|fixed|verified|retrieved|archived|queried|fetched|checked|probed|confirmed)\\b[^\\n]{0,60}?\\d{4}-\\d{2}-\\d{2}|\\d{4}-\\d{2}-\\d{2}[^\\n]{0,30}?\\b(?:done|resolved|completed|closed|addressed|fixed)\\b)';
124
+ /**
125
+ * The closure half of {@link DATED_OUTCOME_PATTERN}. Only explicit closure
126
+ * verbs count outside a struck span: a date sitting anywhere near an action
127
+ * verb ("...was not retrieved at induction. Induction ran 2026-09-01.") is not
128
+ * an outcome, and treating it as one would discharge the very statements this
129
+ * rule exists to catch.
130
+ */
131
+ export const CLOSURE_OUTCOME_PATTERN = '(?:\\b(?:done|resolved|completed|closed|addressed|fixed)\\b[^\\n]{0,60}?\\d{4}-\\d{2}-\\d{2}|\\d{4}-\\d{2}-\\d{2}[^\\n]{0,30}?\\b(?:done|resolved|completed|closed|addressed|fixed)\\b)';
132
+ /**
133
+ * Spans struck through with `~~…~~` are, by the markup's own meaning, no longer
134
+ * asserted. When a dated outcome follows the span on the same line, the pair is
135
+ * a completed retraction however long the struck text is — the length of the
136
+ * struck span was the hidden variable behind #2556.
137
+ */
138
+ function retractedWithDatedOutcome(line) {
139
+ const dated = new RegExp(DATED_OUTCOME_PATTERN, 'i');
140
+ const strikethrough = /~~([\s\S]*?)~~/g;
141
+ let match;
142
+ while ((match = strikethrough.exec(line)) !== null) {
143
+ if (dated.test(line.slice(match.index + match[0].length)))
144
+ return true;
145
+ }
146
+ return false;
147
+ }
148
+ /** Character offsets of every `~~…~~` span on a line. */
149
+ function strikethroughSpans(line) {
150
+ const spans = [];
151
+ const strikethrough = /~~([\s\S]*?)~~/g;
152
+ let match;
153
+ while ((match = strikethrough.exec(line)) !== null) {
154
+ spans.push({ start: match.index, end: match.index + match[0].length });
155
+ }
156
+ return spans;
157
+ }
158
+ export const DEFAULT_OBSTACLE_PATTERNS = [
159
+ '\\bHTTP\\s?[45]\\d{2}\\b',
160
+ '\\b(?:401|403|404|429|451|503)\\b',
161
+ 'paywall',
162
+ 'closed[- ]access',
163
+ 'requires? (?:a )?(?:credential|token|API key|subscription|login|account)',
164
+ '\\b(?:HF_TOKEN|API[_ ]KEY)\\b',
165
+ 'rate[- ]limit',
166
+ 'anti[- ]bot',
167
+ 'cloudflare',
168
+ 'captcha',
169
+ 'endpoint unknown',
170
+ 'no (?:known )?endpoint',
171
+ // Obstacle vocabulary observed in a real corpus: these are named obstacles,
172
+ // so the statement is already a real outcome rather than a silent skip.
173
+ 'proof[- ]of[- ]work',
174
+ 'challenge (?:artifact|page|response)',
175
+ 'returned challenge',
176
+ '\\bgated\\b',
177
+ 'did not render',
178
+ 'green OA',
179
+ // Explicit scope declarations: saying what is deliberately not claimed is an
180
+ // outcome, unlike omitting the check and not saying so.
181
+ 'is not (?:claimed|asserted)',
182
+ 'NOT (?:recorded|asserted) as',
183
+ 'evidence boundary',
184
+ 'completion_evidence',
185
+ 'status:\\s*(?:incomplete|blocked)',
186
+ '\\b(?:queried|fetched|checked|probed|confirmed|resolved)\\s+(?:on\\s+)?\\d{4}-\\d{2}-\\d{2}',
187
+ '\\bdeferred\\b.{0,40}\\b(?:because|since|due to)\\b',
188
+ // An explicit dated closure ("**Done 2026-09-12 (post-induction audit).**"),
189
+ // which the verb+date pattern above does not match (#2556).
190
+ CLOSURE_OUTCOME_PATTERN,
191
+ ];
32
192
  /**
33
193
  * Build a target-wide artifact ID index once per lint run.
34
194
  *
@@ -117,11 +277,19 @@ function runCheck(check, content, frontmatter, filePath, targetDir, allFiles, re
117
277
  const refPattern = check.referencePattern || DEFAULT_REFERENCE_PATTERN;
118
278
  const regex = new RegExp(refPattern, 'g');
119
279
  const lines = content.split('\n');
280
+ const absence = (check.absenceMarkers ?? DEFAULT_ABSENCE_MARKERS).map((p) => new RegExp(p, 'i'));
120
281
  for (let i = 0; i < lines.length; i++) {
121
282
  let match;
122
283
  while ((match = regex.exec(lines[i])) !== null) {
123
284
  const refId = match[0];
124
285
  const basePath = check.basePath || '.aiwg/research/findings';
286
+ // A corpus documents its deliberate gaps ("REF-2464 remains
287
+ // unallocated after deduplication"). Reading that sentence as a
288
+ // dangling reference left only two ways to silence an error: delete
289
+ // accurate documentation, or allocate a REF that is deliberately
290
+ // absent (#2555).
291
+ if (absence.some((re) => re.test(lines[i])))
292
+ continue;
125
293
  if (!referenceIndex.has(refId) && !resolvesFromBasePath(refId, targetDir, basePath)) {
126
294
  diagnostics.push({
127
295
  ruleId: '',
@@ -136,6 +304,59 @@ function runCheck(check, content, frontmatter, filePath, targetDir, allFiles, re
136
304
  }
137
305
  break;
138
306
  }
307
+ case 'unregistered-uncertainty': {
308
+ // An uncertainty written only into prose is invisible to the verification
309
+ // contract: it was never a declared check, so it never surfaces as
310
+ // `incomplete` and the "never report skipped verification as success" rule
311
+ // is never violated. Flag the bare form; accept it once a specific
312
+ // obstacle is named or an outcome is recorded (#2523).
313
+ const uncertainty = (check.uncertaintyPatterns ?? DEFAULT_UNCERTAINTY_PATTERNS)
314
+ .map((p) => new RegExp(p, 'i'));
315
+ const targets = (check.verificationTargets ?? DEFAULT_VERIFICATION_TARGETS)
316
+ .map((p) => new RegExp(p, 'i'));
317
+ const obstacle = (check.obstaclePatterns ?? DEFAULT_OBSTACLE_PATTERNS)
318
+ .map((p) => new RegExp(p, 'i'));
319
+ const within = check.obstacleWithinLines ?? 2;
320
+ const lines = content.split('\n');
321
+ for (let i = 0; i < lines.length; i++) {
322
+ // Both conditions must hold in the SAME CLAUSE: an unperformed action
323
+ // AND something the agent could have acted on. Without the target, the
324
+ // match is as likely to be the paper's own limitation, which must stay
325
+ // untouched. Without clause scoping, a long markdown line relates two
326
+ // unrelated clauses — real corpus prose puts whole paragraphs and
327
+ // changelog tables on one line, which produced most false positives.
328
+ const clause = splitClauses(lines[i]).find((c) => uncertainty.some((re) => re.test(c)) && targets.some((re) => re.test(c)));
329
+ if (!clause)
330
+ continue;
331
+ // A struck span followed by a dated outcome is a completed retraction,
332
+ // whatever its length. Clause scoping otherwise ends the window before
333
+ // the `**Done <date>**` that closes it, so the recommended convention
334
+ // failed for two-sentence struck text and passed for one (#2556).
335
+ const clauseAt = lines[i].indexOf(clause);
336
+ const struck = clauseAt >= 0 && strikethroughSpans(lines[i])
337
+ .some((span) => clauseAt >= span.start && clauseAt < span.end);
338
+ if (struck && retractedWithDatedOutcome(lines[i]))
339
+ continue;
340
+ // Look in the matching line and the following `within` lines: the
341
+ // obstacle normally sits in the same sentence or the next one.
342
+ const window = lines.slice(i, i + within + 1).join(' ');
343
+ if (obstacle.some((re) => re.test(window)))
344
+ continue;
345
+ diagnostics.push({
346
+ ruleId: '',
347
+ ruleName: '',
348
+ // Left unset so the rule's declared severity applies (see runRule).
349
+ // This is a prose heuristic, so the shipped rule declares `warn`; an
350
+ // operator can raise it to error once a corpus is clean.
351
+ severity: undefined,
352
+ file: filePath,
353
+ line: i + 1,
354
+ message: `Uncertainty stated without a named obstacle or recorded outcome: ${clause.trim().slice(0, 160)}`,
355
+ 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. If the check has since been done, retract the statement with the dated form — `~~<original limitation>~~ **Done YYYY-MM-DD (<what closed it>).** <evidence>` — which closes it whatever the length of the struck text.',
356
+ });
357
+ }
358
+ break;
359
+ }
139
360
  case 'pattern-match': {
140
361
  if (!check.pattern)
141
362
  break;
@@ -223,16 +444,44 @@ function runCheck(check, content, frontmatter, filePath, targetDir, allFiles, re
223
444
  }
224
445
  return diagnostics;
225
446
  }
447
+ /**
448
+ * Candidate paths a rule glob may be written against (#2555).
449
+ *
450
+ * `collectFiles` walks with `cwd: targetDir`, so files arrive target-relative
451
+ * (`REF-001.md`) while rule globs are written repo-relative
452
+ * (`documentation/references/**\/*.md`). Narrowing the target to the very
453
+ * directory a rule names therefore matched nothing, and the run reported PASS
454
+ * with every finding unreported. Matching against the target-relative path and
455
+ * each ancestor-prefixed form makes a glob resolve the same way wherever the
456
+ * walk is rooted.
457
+ */
458
+ export function ruleMatchCandidates(file, targetDir) {
459
+ const normalizedFile = file.replace(/\\/g, '/');
460
+ const candidates = [normalizedFile];
461
+ const segments = path.resolve(targetDir).replace(/\\/g, '/').split('/').filter(Boolean);
462
+ let prefix = '';
463
+ for (let i = segments.length - 1; i >= 0 && segments.length - i <= 12; i -= 1) {
464
+ prefix = prefix ? `${segments[i]}/${prefix}` : `${segments[i]}/`;
465
+ candidates.push(`${prefix}${normalizedFile}`);
466
+ }
467
+ return candidates;
468
+ }
469
+ /** True when a rule's glob selects this file, wherever the walk was rooted. */
470
+ export function ruleAppliesToFile(ruleGlob, file, targetDir) {
471
+ return ruleMatchCandidates(file, targetDir).some((candidate) => minimatch(candidate, ruleGlob));
472
+ }
226
473
  /**
227
474
  * Run a single rule against all matching files in the target
228
475
  */
229
476
  async function runRule(rule, targetDir, allFiles, referenceIndex) {
230
477
  const diagnostics = [];
478
+ RULE_APPLIED.delete(rule.id);
231
479
  // Filter files matching the rule's glob
232
480
  const ruleGlob = rule.appliesTo.glob;
233
- const matchingFiles = allFiles.filter(f => minimatch(f, ruleGlob));
481
+ const matchingFiles = allFiles.filter(f => ruleAppliesToFile(ruleGlob, f, targetDir));
234
482
  if (matchingFiles.length === 0)
235
483
  return diagnostics;
484
+ RULE_APPLIED.add(rule.id);
236
485
  // Handle id-unique check at the ruleset level
237
486
  const uniqueCheck = rule.checks.find(c => c.type === 'id-unique');
238
487
  if (uniqueCheck) {
@@ -278,10 +527,12 @@ async function runRule(rule, targetDir, allFiles, referenceIndex) {
278
527
  }
279
528
  return diagnostics;
280
529
  }
530
+ /** Rule ids that selected at least one file in the current run (#2555). */
531
+ const RULE_APPLIED = new Set();
281
532
  /**
282
533
  * Collect all files under a target directory
283
534
  */
284
- async function collectFiles(targetDir, recursive) {
535
+ async function collectFiles(targetDir, recursive, respectGitignore = true) {
285
536
  const pattern = recursive ? '**/*' : '*';
286
537
  try {
287
538
  const files = await glob(pattern, {
@@ -289,12 +540,44 @@ async function collectFiles(targetDir, recursive) {
289
540
  nodir: true,
290
541
  dot: false,
291
542
  });
292
- return files.filter(f => f.endsWith('.md') || f.endsWith('.yaml') || f.endsWith('.yml') || f.endsWith('.json'));
543
+ const linted = files.filter(f => f.endsWith('.md') || f.endsWith('.yaml') || f.endsWith('.yml') || f.endsWith('.json'));
544
+ return respectGitignore ? dropGitignored(targetDir, linted) : linted;
293
545
  }
294
546
  catch {
295
547
  return [];
296
548
  }
297
549
  }
550
+ /**
551
+ * Drop files git ignores (#2555).
552
+ *
553
+ * Generated trees are regenerated, not authored: linting `indices/` reported an
554
+ * error in text that documents a deliberate gap. `git check-ignore` is asked
555
+ * once for the whole set so the semantics are git's own (negations, nested
556
+ * `.gitignore`, `core.excludesFile`) rather than a re-implementation. Outside a
557
+ * repository, or without git, every file is kept.
558
+ */
559
+ function dropGitignored(targetDir, files) {
560
+ if (files.length === 0)
561
+ return files;
562
+ try {
563
+ const result = spawnSync('git', ['check-ignore', '--stdin'], {
564
+ cwd: targetDir,
565
+ input: `${files.join('\n')}\n`,
566
+ encoding: 'utf8',
567
+ });
568
+ // 0 = some paths ignored, 1 = none ignored, anything else = not a repo or
569
+ // git unavailable, in which case nothing is filtered.
570
+ if (result.error || (result.status !== 0 && result.status !== 1))
571
+ return files;
572
+ const ignored = new Set((result.stdout ?? '').split('\n').map((line) => line.trim()).filter(Boolean));
573
+ if (ignored.size === 0)
574
+ return files;
575
+ return files.filter((file) => !ignored.has(file));
576
+ }
577
+ catch {
578
+ return files;
579
+ }
580
+ }
298
581
  /**
299
582
  * Auto-detect which rulesets apply to a target path
300
583
  *
@@ -326,12 +609,17 @@ function autoDetectRulesets(target, available) {
326
609
  */
327
610
  export async function runLint(targetDir, rulesets, options = {}) {
328
611
  const recursive = options.recursive ?? true;
329
- const allFiles = await collectFiles(targetDir, recursive);
612
+ const allFiles = await collectFiles(targetDir, recursive, options.respectGitignore ?? true);
330
613
  const referenceIndex = buildReferenceIndex(allFiles);
331
614
  const allDiagnostics = [];
615
+ const inapplicableRules = [];
616
+ let rulesSelected = 0;
332
617
  for (const ruleset of rulesets) {
333
618
  for (const rule of ruleset.rules) {
619
+ rulesSelected += 1;
334
620
  const diagnostics = await runRule(rule, targetDir, allFiles, referenceIndex);
621
+ if (!RULE_APPLIED.has(rule.id))
622
+ inapplicableRules.push(rule.id);
335
623
  allDiagnostics.push(...diagnostics);
336
624
  }
337
625
  }
@@ -356,6 +644,11 @@ export async function runLint(targetDir, rulesets, options = {}) {
356
644
  warnings,
357
645
  infos,
358
646
  passed,
647
+ // A run where no rule selected a file must never read the same as a clean
648
+ // run (#2555). Reporters surface this; callers can gate on it.
649
+ rulesSelected,
650
+ rulesApplied: rulesSelected - inapplicableRules.length,
651
+ inapplicableRules,
359
652
  },
360
653
  timestamp: new Date().toISOString(),
361
654
  };
@@ -113,7 +113,7 @@ export function evaluateAuthorization(model, request) {
113
113
  }
114
114
  export function validateAuthorization(model) {
115
115
  if (!model)
116
- return [{ severity: 'warning', code: 'authorization-missing', message: 'No normalized authorization block; run steward permissions audit.' }];
116
+ return [{ severity: 'warning', code: 'authorization-missing', message: 'No normalized authorization block; default deny applies. Write one with `aiwg steward permissions migrate --apply`.' }];
117
117
  const out = [];
118
118
  if (model.version !== '1')
119
119
  out.push({ severity: 'error', code: 'version', message: `Unsupported authorization version: ${model.version}` });