@aiwg/cli 2026.9.9 → 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 (36) 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/cli/handlers/help.js +6 -5
  5. package/dist/src/cli/handlers/init.js +7 -2
  6. package/dist/src/cli/handlers/ralph.js +8 -8
  7. package/dist/src/cli/handlers/steward.js +9 -1
  8. package/dist/src/cli/handlers/use.js +4 -0
  9. package/dist/src/cli/handlers/version.js +13 -4
  10. package/dist/src/cli/services/deployment-verification.js +32 -4
  11. package/dist/src/cli/watch-service.js +43 -3
  12. package/dist/src/dataset/fortemi-capability.d.ts +70 -0
  13. package/dist/src/dataset/fortemi-capability.js +211 -0
  14. package/dist/src/dataset/fortemi-dataset-execution.d.ts +9 -0
  15. package/dist/src/dataset/fortemi-dataset-execution.js +49 -8
  16. package/dist/src/dataset/fortemi-run-receipt.d.ts +1 -0
  17. package/dist/src/dataset/fortemi-run-receipt.js +2 -19
  18. package/dist/src/dataset/fortemi-schema-path.d.ts +7 -0
  19. package/dist/src/dataset/fortemi-schema-path.js +24 -0
  20. package/dist/src/dataset/index.d.ts +1 -0
  21. package/dist/src/dataset/index.js +1 -0
  22. package/dist/src/extensions/project-local-doctor.js +15 -1
  23. package/dist/src/lint/cli.js +5 -1
  24. package/dist/src/lint/reporters.js +17 -0
  25. package/dist/src/lint/runner.js +160 -5
  26. package/dist/src/policy/authorization.js +1 -1
  27. package/dist/src/smiths/context-pipeline/workspace-context.js +102 -63
  28. package/package.json +1 -1
  29. package/schemas/dataset/fortemi-capability-validation/1.0.1/authority.json +61 -0
  30. package/schemas/dataset/fortemi-capability-validation/1.0.1/capability.schema.json +469 -0
  31. package/schemas/security/threat-assessment-report.v1.schema.json +2 -1
  32. package/tools/agents/deploy-agents.mjs +4 -0
  33. package/tools/agents/providers/base.mjs +160 -12
  34. package/tools/agents/providers/claude.mjs +61 -4
  35. package/tools/agents/providers/codex.mjs +9 -0
  36. 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,25 @@ 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.
32
52
  /**
33
53
  * Verification targets — things the inducting agent could have checked.
34
54
  *
@@ -92,6 +112,49 @@ export const DEFAULT_UNCERTAINTY_PATTERNS = [
92
112
  '\\bunconfirmed\\b',
93
113
  'rests on .{0,60} rather than an independent',
94
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
+ }
95
158
  export const DEFAULT_OBSTACLE_PATTERNS = [
96
159
  '\\bHTTP\\s?[45]\\d{2}\\b',
97
160
  '\\b(?:401|403|404|429|451|503)\\b',
@@ -122,6 +185,9 @@ export const DEFAULT_OBSTACLE_PATTERNS = [
122
185
  'status:\\s*(?:incomplete|blocked)',
123
186
  '\\b(?:queried|fetched|checked|probed|confirmed|resolved)\\s+(?:on\\s+)?\\d{4}-\\d{2}-\\d{2}',
124
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,
125
191
  ];
126
192
  /**
127
193
  * Build a target-wide artifact ID index once per lint run.
@@ -211,11 +277,19 @@ function runCheck(check, content, frontmatter, filePath, targetDir, allFiles, re
211
277
  const refPattern = check.referencePattern || DEFAULT_REFERENCE_PATTERN;
212
278
  const regex = new RegExp(refPattern, 'g');
213
279
  const lines = content.split('\n');
280
+ const absence = (check.absenceMarkers ?? DEFAULT_ABSENCE_MARKERS).map((p) => new RegExp(p, 'i'));
214
281
  for (let i = 0; i < lines.length; i++) {
215
282
  let match;
216
283
  while ((match = regex.exec(lines[i])) !== null) {
217
284
  const refId = match[0];
218
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;
219
293
  if (!referenceIndex.has(refId) && !resolvesFromBasePath(refId, targetDir, basePath)) {
220
294
  diagnostics.push({
221
295
  ruleId: '',
@@ -254,6 +328,15 @@ function runCheck(check, content, frontmatter, filePath, targetDir, allFiles, re
254
328
  const clause = splitClauses(lines[i]).find((c) => uncertainty.some((re) => re.test(c)) && targets.some((re) => re.test(c)));
255
329
  if (!clause)
256
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;
257
340
  // Look in the matching line and the following `within` lines: the
258
341
  // obstacle normally sits in the same sentence or the next one.
259
342
  const window = lines.slice(i, i + within + 1).join(' ');
@@ -269,7 +352,7 @@ function runCheck(check, content, frontmatter, filePath, targetDir, allFiles, re
269
352
  file: filePath,
270
353
  line: i + 1,
271
354
  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.',
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.',
273
356
  });
274
357
  }
275
358
  break;
@@ -361,16 +444,44 @@ function runCheck(check, content, frontmatter, filePath, targetDir, allFiles, re
361
444
  }
362
445
  return diagnostics;
363
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
+ }
364
473
  /**
365
474
  * Run a single rule against all matching files in the target
366
475
  */
367
476
  async function runRule(rule, targetDir, allFiles, referenceIndex) {
368
477
  const diagnostics = [];
478
+ RULE_APPLIED.delete(rule.id);
369
479
  // Filter files matching the rule's glob
370
480
  const ruleGlob = rule.appliesTo.glob;
371
- const matchingFiles = allFiles.filter(f => minimatch(f, ruleGlob));
481
+ const matchingFiles = allFiles.filter(f => ruleAppliesToFile(ruleGlob, f, targetDir));
372
482
  if (matchingFiles.length === 0)
373
483
  return diagnostics;
484
+ RULE_APPLIED.add(rule.id);
374
485
  // Handle id-unique check at the ruleset level
375
486
  const uniqueCheck = rule.checks.find(c => c.type === 'id-unique');
376
487
  if (uniqueCheck) {
@@ -416,10 +527,12 @@ async function runRule(rule, targetDir, allFiles, referenceIndex) {
416
527
  }
417
528
  return diagnostics;
418
529
  }
530
+ /** Rule ids that selected at least one file in the current run (#2555). */
531
+ const RULE_APPLIED = new Set();
419
532
  /**
420
533
  * Collect all files under a target directory
421
534
  */
422
- async function collectFiles(targetDir, recursive) {
535
+ async function collectFiles(targetDir, recursive, respectGitignore = true) {
423
536
  const pattern = recursive ? '**/*' : '*';
424
537
  try {
425
538
  const files = await glob(pattern, {
@@ -427,12 +540,44 @@ async function collectFiles(targetDir, recursive) {
427
540
  nodir: true,
428
541
  dot: false,
429
542
  });
430
- 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;
431
545
  }
432
546
  catch {
433
547
  return [];
434
548
  }
435
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
+ }
436
581
  /**
437
582
  * Auto-detect which rulesets apply to a target path
438
583
  *
@@ -464,12 +609,17 @@ function autoDetectRulesets(target, available) {
464
609
  */
465
610
  export async function runLint(targetDir, rulesets, options = {}) {
466
611
  const recursive = options.recursive ?? true;
467
- const allFiles = await collectFiles(targetDir, recursive);
612
+ const allFiles = await collectFiles(targetDir, recursive, options.respectGitignore ?? true);
468
613
  const referenceIndex = buildReferenceIndex(allFiles);
469
614
  const allDiagnostics = [];
615
+ const inapplicableRules = [];
616
+ let rulesSelected = 0;
470
617
  for (const ruleset of rulesets) {
471
618
  for (const rule of ruleset.rules) {
619
+ rulesSelected += 1;
472
620
  const diagnostics = await runRule(rule, targetDir, allFiles, referenceIndex);
621
+ if (!RULE_APPLIED.has(rule.id))
622
+ inapplicableRules.push(rule.id);
473
623
  allDiagnostics.push(...diagnostics);
474
624
  }
475
625
  }
@@ -494,6 +644,11 @@ export async function runLint(targetDir, rulesets, options = {}) {
494
644
  warnings,
495
645
  infos,
496
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,
497
652
  },
498
653
  timestamp: new Date().toISOString(),
499
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}` });
@@ -387,7 +387,8 @@ export function buildWorkspaceManagedBlock(projectPath, providerFiles = []) {
387
387
  '',
388
388
  '- Edit project-neutral notes only inside the protected Project Context section below.',
389
389
  '- Keep detailed policies, runbooks, hooks, and quickrefs in linked files.',
390
- '- Keep provider-only directives in `.aiwg/context/providers/`.',
390
+ '- `.aiwg/context/providers/` is reference only: no provider bootstrap auto-loads it.',
391
+ ' Put directives you want read into Project Context above.',
391
392
  '- Never store secrets, tokens, credentials, or machine-local sensitive values here.',
392
393
  '',
393
394
  '### Artifact Routing',
@@ -425,11 +426,16 @@ export async function ensureWorkspaceContext(projectPath, options = {}) {
425
426
  const workspacePath = path.join(projectPath, 'WORKSPACE.md');
426
427
  const warnings = [];
427
428
  const existing = await readOptional(workspacePath);
429
+ // `aiwg use` and `aiwg regenerate` rebuild the managed block without
430
+ // knowing about earlier migrations. Rediscover the migrated provider files
431
+ // so their links survive every regeneration instead of only the one that
432
+ // wrote them (#2558).
433
+ const providerFiles = options.providerFiles ?? await migratedProviderFiles(projectPath);
428
434
  if (existing === null) {
429
- await atomicWrite(workspacePath, buildWorkspaceDocument(projectPath, '', options.providerFiles));
435
+ await atomicWrite(workspacePath, buildWorkspaceDocument(projectPath, '', providerFiles));
430
436
  return { path: workspacePath, action: 'created', warnings };
431
437
  }
432
- const block = buildWorkspaceManagedBlock(projectPath, options.providerFiles);
438
+ const block = buildWorkspaceManagedBlock(projectPath, providerFiles);
433
439
  if (existing.includes(WORKSPACE_MANAGED_START) || existing.includes(WORKSPACE_MANAGED_END)) {
434
440
  let updated = replaceBlock(existing, WORKSPACE_MANAGED_START, WORKSPACE_MANAGED_END, block);
435
441
  // A managed project-extraction block explicitly points operators back to
@@ -451,7 +457,7 @@ export async function ensureWorkspaceContext(projectPath, options = {}) {
451
457
  }
452
458
  const backupPath = `${workspacePath}.bak.${new Date().toISOString().replace(/[:.]/g, '-')}`;
453
459
  await fs.writeFile(backupPath, existing, 'utf8');
454
- await atomicWrite(workspacePath, buildWorkspaceDocument(projectPath, existing, options.providerFiles));
460
+ await atomicWrite(workspacePath, buildWorkspaceDocument(projectPath, existing, providerFiles));
455
461
  return { path: workspacePath, action: 'updated', backupPath, warnings };
456
462
  }
457
463
  export function buildProviderBootstrapBlock(provider) {
@@ -695,29 +701,29 @@ export async function auditWorkspaceContext(projectPath) {
695
701
  .filter(([, values]) => new Set(values.map((value) => value.polarity)).size > 1)
696
702
  .map(([key, values]) => ({ key, sources: values.map(({ path: sourcePath, directive }) => ({ path: sourcePath, directive })).sort((a, b) => a.path.localeCompare(b.path)) }));
697
703
  const rootOperator = sources.filter((source) => source.scope === 'root' && source.operatorContent.trim());
698
- // Provider startup roots remain attributed provider context even when one
699
- // directive is duplicated elsewhere. Promoting an entire root file because
700
- // of one matching line can copy large provider/framework bodies into the
701
- // provider-neutral WORKSPACE.md operator region.
702
- const neutralSources = rootOperator.filter((source) => source.path === 'WORKSPACE.md').map((source) => source.path);
703
- const providerSources = rootOperator.filter((source) => source.path !== 'WORKSPACE.md' && !neutralSources.includes(source.path)).map((source) => source.path);
704
- const providerOutputs = providerSources.map((source) => providerContextOutput(projectPath, source));
704
+ // Provider startup files (CLAUDE.md, AGENTS.md, WARP.md, …) are bootstrap
705
+ // surfaces: AIWG rewrites them to a managed bootstrap that `@`-imports
706
+ // WORKSPACE.md. The operator content they carried therefore ports into the
707
+ // WORKSPACE.md operator block, which every provider bootstrap loads and
708
+ // which `aiwg use` and `aiwg regenerate` preserve byte-for-byte. Routing
709
+ // it to `.aiwg/context/providers/` instead left it linked but never
710
+ // loaded, and a later `aiwg use` dropped even the link (#2558).
711
+ const neutralSources = rootOperator.map((source) => source.path);
712
+ const providerSources = [];
713
+ const providerOutputs = [];
705
714
  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);
715
+ // Report volume and destination per source. A substantial provider-named
716
+ // body is still surfaced for review (#2537): it now lands where every
717
+ // provider reads it, so anything genuinely provider-only should be moved to
718
+ // `.aiwg/context/providers/` by the operator after migration.
719
+ const routing = rootOperator.map((source) => ({
720
+ source: source.path,
721
+ operatorBytes: Buffer.byteLength(source.operatorContent, 'utf8'),
722
+ destination: 'WORKSPACE.md',
723
+ scope: 'project-neutral',
724
+ provider: source.path === 'WORKSPACE.md' ? null : source.provider,
725
+ })).sort((a, b) => b.operatorBytes - a.operatorBytes);
726
+ const scopeReview = routing.filter((entry) => entry.source !== 'WORKSPACE.md' && entry.operatorBytes >= SCOPE_REVIEW_BYTES);
721
727
  return {
722
728
  version: 1,
723
729
  projectPath,
@@ -742,34 +748,48 @@ export async function auditWorkspaceContext(projectPath) {
742
748
  function projectOutputPath(projectPath, absPath) {
743
749
  return path.relative(projectPath, absPath).replace(/\\/g, '/');
744
750
  }
745
- function providerContextOutput(projectPath, sourcePath) {
746
- return projectOutputPath(projectPath, projectAiwgPath(projectPath, 'context', 'providers', sourcePath.replace(/[^A-Za-z0-9.-]+/g, '-').replace(/^-+/, '')));
747
- }
751
+ const LEGACY_PROJECT_CONTEXT_PLACEHOLDER = [
752
+ '## Project Context',
753
+ '',
754
+ 'Add provider-neutral project conventions and links here.',
755
+ ].join('\n');
756
+ /**
757
+ * Operator content for the WORKSPACE.md operator block after migration.
758
+ *
759
+ * Existing WORKSPACE.md operator content is kept verbatim; the operator body
760
+ * of each provider startup file is appended once, verbatim, under an
761
+ * attributed heading. Tables, procedures, and code blocks survive intact —
762
+ * the earlier line-flattened directive list lost exactly the runbook content
763
+ * operators keep in CLAUDE.md (#2558). Re-running the migration is a no-op:
764
+ * a body that already appears in the block is not appended again.
765
+ */
748
766
  function neutralMigrationContent(audit) {
749
- const selected = audit.sources.filter((source) => audit.plan.neutralSources.includes(source.path));
750
- const seen = new Set();
751
- const lines = ['## Project Context', ''];
752
- for (const source of selected) {
753
- const unique = directives(source.operatorContent).filter((directive) => !seen.has(directive));
754
- for (const directive of unique)
755
- seen.add(directive);
756
- if (unique.length === 0)
767
+ const workspace = audit.sources.find((source) => source.path === 'WORKSPACE.md');
768
+ let base = workspace?.operatorContent.trim() ?? '';
769
+ if (base === PROJECT_CONTEXT_PLACEHOLDER || base === LEGACY_PROJECT_CONTEXT_PLACEHOLDER)
770
+ base = '';
771
+ const sections = [];
772
+ let accumulated = base;
773
+ for (const source of audit.sources) {
774
+ if (source.path === 'WORKSPACE.md' || !audit.plan.neutralSources.includes(source.path))
775
+ continue;
776
+ const body = source.operatorContent.trim();
777
+ if (!body || accumulated.includes(body))
757
778
  continue;
758
- lines.push(`### From ${source.path}`, '', ...unique.map((directive) => `- ${directive}`), '');
779
+ sections.push([
780
+ `### Migrated from ${source.path}`,
781
+ '',
782
+ `Source attribution: migrated from \`${source.path}\`; checksum \`${source.checksum}\`.`,
783
+ '',
784
+ body,
785
+ ].join('\n'));
786
+ accumulated = `${accumulated}\n${body}`;
759
787
  }
760
- if (lines.length === 2)
761
- lines.push('Add project conventions, local hook/context pointers, and links to deeper project documents here.');
762
- return lines.join('\n').trim();
763
- }
764
- function providerMigrationContent(source) {
765
- return [
766
- `# Provider-specific context from ${source.path}`,
767
- '',
768
- `Source attribution: migrated from \`${source.path}\`; checksum \`${source.checksum}\`.`,
769
- '',
770
- source.operatorContent.trim() || '(No operator-authored content remained after managed blocks were removed.)',
771
- '',
772
- ].join('\n');
788
+ const parts = [base || '## Project Context', ...sections];
789
+ if (!base && sections.length === 0) {
790
+ parts.push('Add project conventions, local hook/context pointers, and links to deeper project documents here.');
791
+ }
792
+ return parts.join('\n\n').trim();
773
793
  }
774
794
  async function configuredProviders(projectPath) {
775
795
  try {
@@ -784,17 +804,8 @@ async function configuredProviders(projectPath) {
784
804
  }
785
805
  async function stageMigrationWrites(projectPath, audit, options = {}) {
786
806
  const writes = new Map();
787
- for (const source of audit.sources.filter((item) => audit.plan.providerSources.includes(item.path))) {
788
- writes.set(providerContextOutput(projectPath, source.path), providerMigrationContent(source));
789
- }
790
- const providerFiles = [...new Set([
791
- ...audit.sources.filter((source) => source.path.replace(/\\/g, '/').includes('/context/providers/')).map((source) => source.path),
792
- ...writes.keys(),
793
- ])].sort();
794
- const existingWorkspace = audit.sources.find((source) => source.path === 'WORKSPACE.md');
795
- let workspaceOperatorContent = existingWorkspace?.managed
796
- ? existingWorkspace.operatorContent
797
- : neutralMigrationContent(audit);
807
+ const providerFiles = [...new Set(audit.sources.filter((source) => source.path.replace(/\\/g, '/').includes('/context/providers/')).map((source) => source.path))].sort();
808
+ let workspaceOperatorContent = neutralMigrationContent(audit);
798
809
  if (options.extractProject) {
799
810
  const extracted = await extractExistingProjectContext(projectPath);
800
811
  workspaceOperatorContent = mergeProjectExtraction(workspaceOperatorContent, extracted.content);
@@ -1061,8 +1072,36 @@ export async function diagnoseWorkspaceContext(projectPath) {
1061
1072
  for (const conflict of audit.conflicts) {
1062
1073
  diagnostics.push({ severity: 'error', code: 'directive-conflict', message: `Conflicting duplicate directive group '${conflict.key}' appears in ${conflict.sources.map((source) => source.path).join(', ')}.` });
1063
1074
  }
1075
+ // One diagnostic per source pair, not one per duplicated line: a shared
1076
+ // pointer stub otherwise reports every line as a separate policy conflict
1077
+ // and degrades the workspace over content that agrees with itself (#2558).
1078
+ const duplicatesByPair = new Map();
1064
1079
  for (const overlap of audit.identical) {
1065
- diagnostics.push({ severity: 'warning', code: 'duplicate-directive', message: `Identical directive is duplicated across ${overlap.sources.join(', ')}.` });
1080
+ const key = overlap.sources.join(', ');
1081
+ duplicatesByPair.set(key, (duplicatesByPair.get(key) ?? 0) + overlap.directives.length);
1082
+ }
1083
+ const operatorBodies = new Map(audit.sources.map((source) => [source.path, source.operatorContent.trim()]));
1084
+ for (const [pair, count] of duplicatesByPair) {
1085
+ const paths = pair.split(', ');
1086
+ const bodies = paths.map((item) => operatorBodies.get(item) ?? '');
1087
+ const wholeFileDuplicate = bodies.every((body) => body.length > 0 && body === bodies[0]);
1088
+ diagnostics.push({
1089
+ severity: wholeFileDuplicate ? 'info' : 'warning',
1090
+ code: 'duplicate-directive',
1091
+ message: wholeFileDuplicate
1092
+ ? `${pair} carry identical operator content (${count} directive(s)); keep one copy or accept the shared stub.`
1093
+ : `${count} identical directive(s) duplicated across ${pair}.`,
1094
+ });
1095
+ }
1096
+ for (const source of audit.sources) {
1097
+ if (!source.path.replace(/\\/g, '/').includes('/context/providers/') || !source.operatorContent.trim())
1098
+ continue;
1099
+ diagnostics.push({
1100
+ severity: 'info',
1101
+ code: 'provider-context-not-loaded',
1102
+ message: `${source.path} is linked from WORKSPACE.md but no provider bootstrap auto-loads it; move its operator content into the WORKSPACE.md Project Context section (\`aiwg regenerate --existing-project\` ports startup files there) or keep it as reference only.`,
1103
+ path: source.path,
1104
+ });
1066
1105
  }
1067
1106
  for (const finding of audit.sensitiveFindings) {
1068
1107
  diagnostics.push({ severity: 'error', code: 'possible-secret', message: 'Possible credential value found in context; remove it.', path: finding.path });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aiwg/cli",
3
- "version": "2026.9.9",
3
+ "version": "2026.9.10",
4
4
  "description": "Lightweight AIWG CLI for signed, versioned web-backed resources.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -0,0 +1,61 @@
1
+ {
2
+ "contract": "fortemi.dataset-capability-validation/v1",
3
+ "revision": "1.0.1",
4
+ "wireContract": "fortemi.dataset-execution-capabilities/v1",
5
+ "wireSchemaVersion": "1.0.0",
6
+ "consumerIssue": 2242,
7
+ "authority": {
8
+ "repository": "Fortemi/fortemi-react",
9
+ "issue": "https://git.integrolabs.net/Fortemi/fortemi-react/issues/422",
10
+ "commit": "5625215762a8fd06db8fef4ccedd87f3c370ec31",
11
+ "mergedCommit": "35edcd9f8429fcd91a417021cbe4c427f900ed73",
12
+ "path": "packages/core/schemas/dataset-execution-capabilities/validation/1.0.1",
13
+ "manifestSha256": "20b59bb871c3afc9bb972896b2a2da718970c6fe7ef54b49000418f62a5701c0"
14
+ },
15
+ "files": [
16
+ {
17
+ "path": "capability.schema.json",
18
+ "authorityPath": "schema.json",
19
+ "sha256": "a4e9b427da669a02e83db00d39f48c2e106770d6cd2ec1b717938c57793b754c"
20
+ },
21
+ {
22
+ "path": "../../../../test/fixtures/dataset/fortemi-capability/negotiation-vectors.json",
23
+ "authorityPath": "negotiation-vectors.json",
24
+ "sha256": "2793990a842d5969f1f64c90170ab97ca4ffc4b93d1302a2f358fae54631a6dc"
25
+ },
26
+ {
27
+ "path": "../../../../test/fixtures/dataset/fortemi-capability/wire-vectors.json",
28
+ "authorityPath": "wire-vectors.json",
29
+ "sha256": "c52d32d0b1d0d312cf8b242f11fc116c47d104a74112cbd5d0019e6d39df9744"
30
+ }
31
+ ],
32
+ "consumer": {
33
+ "repository": "roctinam/aiwg",
34
+ "implementation": "src/dataset/fortemi-capability.ts",
35
+ "binding": "src/dataset/fortemi-dataset-execution.ts",
36
+ "test": "test/unit/dataset/fortemi-capability.test.ts",
37
+ "boundary": "Independent AIWG implementation of the wire rules. AIWG never imports the producer's validator; agreement is established by the shared authority vectors."
38
+ },
39
+ "observedDescriptor": {
40
+ "source": "Fortemi/fortemi mcp-server/lib/dataset-execution.js buildDatasetExecutionDescriptor",
41
+ "commit": "a701847db779a0a3adfa36a09dfa1055a10a0a0a",
42
+ "fixture": "test/fixtures/dataset/fortemi-capability/server-descriptor.json",
43
+ "sha256": "66ee315912429645e97c77a96e3f5110fe9db5e6909ff3c39e8b6e22135d8b95",
44
+ "runtimeVersion": "2026.9.10",
45
+ "qualification": "Descriptor rendered from pinned producer source; not a released-artifact or live-session receipt."
46
+ },
47
+ "qualification": {
48
+ "status": "source-consumer-adoption",
49
+ "covers": [
50
+ "advertised capability descriptor validation before preview or execute",
51
+ "independent negotiation bound to the advertised descriptor",
52
+ "receipt capabilityDecision bound to that independent negotiation"
53
+ ],
54
+ "remaining": [
55
+ "clean installed and released consumer qualification",
56
+ "live Fortemi Server session evidence under roctinam/aiwg#2194 authorization"
57
+ ],
58
+ "maturity": "alpha",
59
+ "suiteAudit": "NO-GO"
60
+ }
61
+ }