@aiwg/cli 2026.9.9 → 2026.9.11
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/bin/aiwg.mjs +55 -10
- package/dist/src/artifacts/cli.js +20 -8
- package/dist/src/artifacts/corpus-views/taxonomies.js +1 -1
- package/dist/src/cli/handlers/help.js +6 -5
- package/dist/src/cli/handlers/init.js +7 -2
- package/dist/src/cli/handlers/ralph.js +8 -8
- package/dist/src/cli/handlers/steward.js +9 -1
- package/dist/src/cli/handlers/use.js +4 -0
- package/dist/src/cli/handlers/version.js +13 -4
- package/dist/src/cli/services/deployment-verification.js +32 -4
- package/dist/src/cli/watch-service.js +43 -3
- package/dist/src/dataset/fortemi-capability.d.ts +70 -0
- package/dist/src/dataset/fortemi-capability.js +211 -0
- package/dist/src/dataset/fortemi-dataset-execution.d.ts +9 -0
- package/dist/src/dataset/fortemi-dataset-execution.js +49 -8
- package/dist/src/dataset/fortemi-run-receipt.d.ts +1 -0
- package/dist/src/dataset/fortemi-run-receipt.js +2 -19
- package/dist/src/dataset/fortemi-schema-path.d.ts +7 -0
- package/dist/src/dataset/fortemi-schema-path.js +24 -0
- package/dist/src/dataset/index.d.ts +1 -0
- package/dist/src/dataset/index.js +1 -0
- package/dist/src/extensions/project-local-doctor.js +15 -1
- package/dist/src/lint/cli.js +5 -1
- package/dist/src/lint/reporters.js +17 -0
- package/dist/src/lint/runner.js +160 -5
- package/dist/src/policy/authorization.js +1 -1
- package/dist/src/smiths/context-pipeline/workspace-context.js +102 -63
- package/package.json +1 -1
- package/schemas/dataset/fortemi-capability-validation/1.0.1/authority.json +61 -0
- package/schemas/dataset/fortemi-capability-validation/1.0.1/capability.schema.json +469 -0
- package/schemas/security/threat-assessment-report.v1.schema.json +2 -1
- package/tools/agents/deploy-agents.mjs +4 -0
- package/tools/agents/providers/base.mjs +160 -12
- package/tools/agents/providers/claude.mjs +61 -4
- package/tools/agents/providers/codex.mjs +9 -0
- package/tools/skills/deploy-skills-codex.mjs +121 -7
package/dist/src/lint/runner.js
CHANGED
|
@@ -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 =>
|
|
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
|
-
|
|
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;
|
|
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
|
-
'-
|
|
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, '',
|
|
435
|
+
await atomicWrite(workspacePath, buildWorkspaceDocument(projectPath, '', providerFiles));
|
|
430
436
|
return { path: workspacePath, action: 'created', warnings };
|
|
431
437
|
}
|
|
432
|
-
const block = buildWorkspaceManagedBlock(projectPath,
|
|
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,
|
|
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
|
|
699
|
-
//
|
|
700
|
-
//
|
|
701
|
-
//
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
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
|
-
//
|
|
707
|
-
//
|
|
708
|
-
//
|
|
709
|
-
//
|
|
710
|
-
const routing = rootOperator.map((source) => {
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
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
|
-
|
|
746
|
-
|
|
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
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
}
|
|
764
|
-
|
|
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
|
-
|
|
788
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
+
}
|