@event4u/agent-config 13.0.0 → 14.1.0
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/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +195 -315
- package/README.md +1 -1
- package/dist/agent-src/commands/evals/roadmap.json +8 -0
- package/dist/agent-src/commands/fix/ci.md +39 -1
- package/dist/agent-src/commands/pr/create.md +65 -4
- package/dist/agent-src/commands/roadmap/next.md +28 -5
- package/dist/agent-src/commands/work.md +16 -0
- package/dist/agent-src/contexts/contracts/command-suggestion-flow.md +47 -0
- package/dist/agent-src/contexts/execution/contract-decision-sheet.md +94 -0
- package/dist/agent-src/contexts/execution/plan-confidence-gate.md +30 -0
- package/dist/agent-src/contexts/execution/roadmap-execution-contract.md +21 -8
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +55 -8
- package/dist/agent-src/contexts/execution/subagent-response-contract.md +42 -0
- package/dist/agent-src/contexts/execution/subagent-spawn-contract.md +29 -0
- package/dist/agent-src/rules/command-suggestion-policy.md +33 -0
- package/dist/agent-src/rules/design-review-after-ui-write.md +25 -8
- package/dist/agent-src/rules/ui-audit-gate.md +25 -8
- package/dist/agent-src/scripts/gate_execute.ts +395 -0
- package/dist/agent-src/scripts/resume_probe.ts +422 -0
- package/dist/agent-src/scripts/roadmap_gates.ts +138 -4
- package/dist/agent-src/scripts/update_roadmap_progress.ts +53 -2
- package/dist/agent-src/skills/ai-council/SKILL.md +61 -0
- package/dist/agent-src/skills/experiment-loop/SKILL.md +116 -0
- package/dist/agent-src/skills/experiment-loop/evals/output-schema.yml +27 -0
- package/dist/agent-src/skills/experiment-loop/evals/triggers.json +17 -0
- package/dist/agent-src/skills/experiment-loop/references/pivot-ladder.md +54 -0
- package/dist/agent-src/skills/experiment-loop/references/protocol.md +61 -0
- package/dist/agent-src/skills/experiment-loop/references/register-format.md +66 -0
- package/dist/agent-src/skills/roadmap-management/SKILL.md +2 -2
- package/dist/agent-src/skills/roadmap-writing/SKILL.md +1 -1
- package/dist/agent-src/skills/subagent-orchestration/schemas/team-review-status.json +8 -3
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/roadmaps.md +43 -3
- package/dist/cli/registry.js +1 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-N7FEST6D.js → chunk-7PZ4FSZB.js} +351 -174
- package/dist/cli-delegate/{chunk-3B72LSZL.js → chunk-F3YUF7SA.js} +1 -1
- package/dist/cli-delegate/cmd_conformance.js +336 -112
- package/dist/cli-delegate/cmd_doctor.js +2 -2
- package/dist/cli-delegate/cmd_handoff.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +40 -18
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +3 -3
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +6 -5
- package/dist/discovery/trust-report.md +2 -2
- package/dist/discovery/workspaces.json +5 -4
- package/dist/hooks/dispatch.js +2128 -1326
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +1764 -899
- package/dist/router.json +1 -1
- package/dist/scripts/condense.js +1 -1
- package/dist/scripts/condense.js.map +1 -1
- package/dist/ui/assets/{index-B0aMJnuz.js → index-C1pBRm1f.js} +2 -2
- package/dist/ui/assets/index-C1pBRm1f.js.map +1 -0
- package/dist/ui/index.html +1 -1
- package/package.json +1 -1
- package/src/agent-src/scripts/gate_execute.ts +395 -0
- package/src/agent-src/scripts/resume_probe.ts +422 -0
- package/src/agent-src/scripts/roadmap_gates.ts +138 -4
- package/src/agent-src/scripts/update_roadmap_progress.ts +53 -2
- package/src/config/ci-local-parity.yml +13 -0
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-coverage.yml +111 -5
- package/src/config/gate-violation-baselines.json +5 -5
- package/src/config/rule-activation-census.json +41 -0
- package/src/config/rule-stub-ceilings.json +52 -0
- package/src/scripts/_dispatch.bash +4 -0
- package/src/scripts/_lib/evaluator_contract.ts +159 -0
- package/src/scripts/_lib/gate_ledger.ts +5 -1
- package/src/scripts/_lib/gh_transient.ts +80 -0
- package/src/scripts/_lib/release_material.ts +16 -5
- package/src/scripts/_lib/review_independence.ts +111 -0
- package/src/scripts/_lib/session_register.ts +14 -0
- package/src/scripts/_lib/skill_catalogue.ts +20 -0
- package/src/scripts/_lib/skill_catalogue_series.ts +411 -0
- package/src/scripts/_lib/turn_end_refusals.ts +482 -0
- package/src/scripts/ai_council/_py_json.ts +96 -0
- package/src/scripts/ai_council/cli_call_budget.ts +357 -0
- package/src/scripts/ai_council/clients.ts +80 -143
- package/src/scripts/ai_council/config.ts +25 -11
- package/src/scripts/ai_council/probe_store.ts +196 -0
- package/src/scripts/ai_council/qualification.ts +425 -0
- package/src/scripts/ai_council/qualification_wiring.ts +243 -0
- package/src/scripts/ai_council/quorum_wiring.ts +211 -0
- package/src/scripts/ai_team/team_dispatch.ts +40 -6
- package/src/scripts/build_archive_index.ts +62 -3
- package/src/scripts/capture_skill_catalogue.ts +264 -4
- package/src/scripts/check_evaluator_schema.ts +249 -0
- package/src/scripts/check_gate_coverage.ts +93 -0
- package/src/scripts/check_pr_ci_current.ts +492 -0
- package/src/scripts/check_review_schema.ts +195 -0
- package/src/scripts/check_rule_activation_census.ts +451 -0
- package/src/scripts/check_rule_stub_ceiling.ts +1128 -0
- package/src/scripts/condense.ts +1 -1
- package/src/scripts/council_cli.ts +141 -191
- package/src/scripts/dispatch_r2_reviewer.ts +8 -1
- package/src/scripts/git_authorization_hook.ts +186 -1
- package/src/scripts/hook_manifest.yaml +72 -5
- package/src/scripts/hooks/block_unauthorized_git.ts +62 -6
- package/src/scripts/hooks/concern_registry.ts +4 -0
- package/src/scripts/hooks/dispatch_hook.ts +43 -1
- package/src/scripts/hooks/interruption_ledger_hook.ts +256 -0
- package/src/scripts/hooks/ship_diff_volume_hook.ts +180 -0
- package/src/scripts/hooks/turn_end_gate_hook.ts +119 -26
- package/src/scripts/hooks_doctor.ts +112 -0
- package/src/scripts/interruption_report.ts +443 -0
- package/src/scripts/lint_evidence_artifacts.ts +359 -0
- package/src/scripts/lint_roadmap_blockers.ts +65 -0
- package/src/scripts/lint_roadmap_complexity.ts +7 -1
- package/src/scripts/release.ts +38 -53
- package/src/scripts/rule_activation_census.ts +292 -0
- package/src/scripts/schemas/evaluator-output.schema.json +60 -0
- package/src/scripts/schemas/review-findings.schema.json +31 -0
- package/src/scripts/self_review_gate.ts +6 -0
- package/src/scripts/session_register_hook.ts +30 -1
- package/src/scripts/sync_pr_branch.ts +271 -0
- package/src/scripts/test_council_qualification.ts +213 -0
- package/dist/ui/assets/index-B0aMJnuz.js.map +0 -1
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Bring the branch up to its PR base before a push, so the PR never goes stale.
|
|
4
|
+
*
|
|
5
|
+
* `check_branch_freshness` already DETECTS "behind the base" and refuses the
|
|
6
|
+
* push. What did not exist was the other half: doing something about it. So the
|
|
7
|
+
* documented sequence — freshness → merge the base in → regenerate → verify →
|
|
8
|
+
* push (`/create-pr` § 1b-ii) — was entirely model-carried, and a step carried
|
|
9
|
+
* only by prose is a step that gets skipped under time pressure. Measured on
|
|
10
|
+
* PR #1391: the base moved three times during one run, the push was rejected
|
|
11
|
+
* twice for it, and the PR reached `CONFLICTING` before anyone noticed.
|
|
12
|
+
*
|
|
13
|
+
* Deliberately NOT wired into the pre-push hook. This MUTATES the working tree —
|
|
14
|
+
* a merge commit, possibly regenerated files — and a hook that rewrites the tree
|
|
15
|
+
* mid-push turns one rejected push into an unreviewed commit. Detection belongs
|
|
16
|
+
* in the hook (and is already there); resolution belongs to a step the agent runs
|
|
17
|
+
* with the result in front of it.
|
|
18
|
+
*
|
|
19
|
+
* A conflict is NOT auto-resolved and never will be by this script. It stops,
|
|
20
|
+
* names the conflicted paths, and says which of them are generated (regenerate,
|
|
21
|
+
* do not hand-merge) versus authored (a human decision). Auto-resolving a
|
|
22
|
+
* content conflict is how a parallel session's work disappears.
|
|
23
|
+
*
|
|
24
|
+
* Exit codes: 0 = already current, or merged cleanly · 1 = conflict, or the base
|
|
25
|
+
* could not be resolved · 2 = internal error. `scanned:` on every path.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import * as path from 'node:path';
|
|
29
|
+
import { spawnSync } from 'node:child_process';
|
|
30
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
31
|
+
|
|
32
|
+
import { reportScanned } from './_lib/scan_scope.js';
|
|
33
|
+
|
|
34
|
+
const NETWORK_TIMEOUT_MS = 8_000;
|
|
35
|
+
|
|
36
|
+
/** Paths that are GENERATED — a conflict here is regenerated, never hand-merged. */
|
|
37
|
+
const GENERATED = [
|
|
38
|
+
'agents/roadmaps-progress.md',
|
|
39
|
+
'agents/index.md',
|
|
40
|
+
'docs/catalog.md',
|
|
41
|
+
'dist/agent-src/',
|
|
42
|
+
'.augment/',
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
function sh(cmd: string, args: readonly string[], cwd: string): { ok: boolean; out: string; err: string } {
|
|
46
|
+
const r = spawnSync(cmd, [...args], {
|
|
47
|
+
cwd,
|
|
48
|
+
encoding: 'utf-8',
|
|
49
|
+
timeout: NETWORK_TIMEOUT_MS,
|
|
50
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
51
|
+
});
|
|
52
|
+
return { ok: r.status === 0, out: r.stdout ?? '', err: (r.stderr ?? '').trim() };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** True when `rel` is a generated artefact rather than an authored one. */
|
|
56
|
+
export function isGenerated(rel: string): boolean {
|
|
57
|
+
const norm = rel.replace(/\\/g, '/');
|
|
58
|
+
return GENERATED.some((g) => (g.endsWith('/') ? norm.startsWith(g) : norm === g));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface Plan {
|
|
62
|
+
/** 0 = nothing to do or merged · 1 = needs a human · 2 = internal. */
|
|
63
|
+
exit: 0 | 1 | 2;
|
|
64
|
+
message: string;
|
|
65
|
+
/** Conflicted paths, split so the caller knows which to regenerate. */
|
|
66
|
+
generated: string[];
|
|
67
|
+
authored: string[];
|
|
68
|
+
scanned: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Classify a conflicted file list.
|
|
73
|
+
*
|
|
74
|
+
* Pure, so the split is testable without producing a real merge conflict — and
|
|
75
|
+
* the split is the useful part: a generated conflict has one correct resolution
|
|
76
|
+
* (regenerate) and an authored one has none that a script may choose.
|
|
77
|
+
*/
|
|
78
|
+
export function classifyConflicts(files: readonly string[]): Pick<Plan, 'generated' | 'authored'> {
|
|
79
|
+
const generated: string[] = [];
|
|
80
|
+
const authored: string[] = [];
|
|
81
|
+
for (const f of files) {
|
|
82
|
+
if (f.trim() === '') continue;
|
|
83
|
+
(isGenerated(f.trim()) ? generated : authored).push(f.trim());
|
|
84
|
+
}
|
|
85
|
+
return { generated, authored };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Resolve the base this branch will actually merge into. */
|
|
89
|
+
export function resolveBase(repo: string, override: string | null): { base: string | null; how: string } {
|
|
90
|
+
if (override !== null && override.trim() !== '') {
|
|
91
|
+
return { base: override.trim(), how: 'given by --base' };
|
|
92
|
+
}
|
|
93
|
+
const branch = sh('git', ['rev-parse', '--abbrev-ref', 'HEAD'], repo).out.trim();
|
|
94
|
+
if (branch !== '' && branch !== 'HEAD') {
|
|
95
|
+
// The forge knows the REAL base, which matters for a stacked or
|
|
96
|
+
// release-line PR: measuring against the repo default would compare
|
|
97
|
+
// against a branch this PR never merges into.
|
|
98
|
+
const pr = sh('gh', ['pr', 'list', '--head', branch, '--state', 'open', '--json', 'baseRefName', '--limit', '1'], repo);
|
|
99
|
+
if (pr.ok) {
|
|
100
|
+
try {
|
|
101
|
+
const rows = JSON.parse(pr.out || '[]') as Array<{ baseRefName?: string }>;
|
|
102
|
+
const b = rows[0]?.baseRefName;
|
|
103
|
+
if (typeof b === 'string' && b !== '') {
|
|
104
|
+
return { base: `origin/${b}`, how: `the open PR base (${b})` };
|
|
105
|
+
}
|
|
106
|
+
} catch {
|
|
107
|
+
/* fall through to the default-branch probe */
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const head = sh('git', ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'], repo);
|
|
112
|
+
if (head.ok && head.out.trim() !== '') {
|
|
113
|
+
return { base: head.out.trim(), how: 'the repo default branch' };
|
|
114
|
+
}
|
|
115
|
+
return { base: null, how: 'unresolvable — no open PR and no origin/HEAD' };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function sync(repo: string, baseOverride: string | null, dryRun: boolean): Plan {
|
|
119
|
+
const { base, how } = resolveBase(repo, baseOverride);
|
|
120
|
+
if (base === null) {
|
|
121
|
+
return { exit: 1, message: `cannot resolve a base to update against — ${how}.`, generated: [], authored: [], scanned: 0 };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const fetched = sh('git', ['fetch', 'origin', '--prune'], repo);
|
|
125
|
+
if (!fetched.ok) {
|
|
126
|
+
// Unreachable remote is not "already current" — saying so is the whole
|
|
127
|
+
// point, since a silent pass here reproduces the staleness this closes.
|
|
128
|
+
return {
|
|
129
|
+
exit: 0,
|
|
130
|
+
message: `unverified — could not fetch origin (${fetched.err.split('\n')[0] ?? '?'}). Base freshness NOT checked.`,
|
|
131
|
+
generated: [],
|
|
132
|
+
authored: [],
|
|
133
|
+
scanned: 0,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const behind = sh('git', ['rev-list', '--count', `HEAD..${base}`], repo).out.trim();
|
|
138
|
+
if (behind === '' || behind === '0') {
|
|
139
|
+
return { exit: 0, message: `already current with ${base} (${how}).`, generated: [], authored: [], scanned: 1 };
|
|
140
|
+
}
|
|
141
|
+
if (dryRun) {
|
|
142
|
+
return {
|
|
143
|
+
exit: 0,
|
|
144
|
+
message: `${behind} commit(s) behind ${base} (${how}) — would merge it in. Dry run, nothing changed.`,
|
|
145
|
+
generated: [],
|
|
146
|
+
authored: [],
|
|
147
|
+
scanned: 1,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const merge = sh('git', ['merge', base, '--no-edit'], repo);
|
|
152
|
+
if (merge.ok) {
|
|
153
|
+
return {
|
|
154
|
+
exit: 0,
|
|
155
|
+
message:
|
|
156
|
+
`merged ${base} in (${how}, was ${behind} behind). REGENERATE derived files now — a clean ` +
|
|
157
|
+
'auto-merge of a generated file is still wrong.',
|
|
158
|
+
generated: [],
|
|
159
|
+
authored: [],
|
|
160
|
+
scanned: 1,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const conflicted = sh('git', ['diff', '--name-only', '--diff-filter=U'], repo).out.split('\n');
|
|
165
|
+
const split = classifyConflicts(conflicted);
|
|
166
|
+
return {
|
|
167
|
+
exit: 1,
|
|
168
|
+
message:
|
|
169
|
+
`merge of ${base} (${how}) hit ${String(split.generated.length + split.authored.length)} conflict(s). ` +
|
|
170
|
+
'NOT auto-resolved: a content conflict is where a parallel session\'s work disappears.',
|
|
171
|
+
...split,
|
|
172
|
+
scanned: 1,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function main(argv?: readonly string[]): number {
|
|
177
|
+
const args = argv ?? process.argv.slice(2);
|
|
178
|
+
let repo = process.cwd();
|
|
179
|
+
let base: string | null = null;
|
|
180
|
+
let dryRun = false;
|
|
181
|
+
let quiet = false;
|
|
182
|
+
for (let i = 0; i < args.length; i++) {
|
|
183
|
+
const a = args[i] as string;
|
|
184
|
+
const val = (): string | null => {
|
|
185
|
+
const v = args[++i];
|
|
186
|
+
return v === undefined || v.startsWith('--') ? null : v;
|
|
187
|
+
};
|
|
188
|
+
if (a === '--repo') {
|
|
189
|
+
const v = val();
|
|
190
|
+
if (v === null) {
|
|
191
|
+
process.stderr.write('❌ sync_pr_branch: --repo requires a value\n');
|
|
192
|
+
reportScanned({ gate: 'sync_pr_branch', scanned: 0, units: 'base ref(s)', roots: ['origin'], allowEmpty: 'argument error' });
|
|
193
|
+
return 1;
|
|
194
|
+
}
|
|
195
|
+
repo = v;
|
|
196
|
+
} else if (a === '--base') {
|
|
197
|
+
const v = val();
|
|
198
|
+
if (v === null) {
|
|
199
|
+
process.stderr.write('❌ sync_pr_branch: --base requires a value\n');
|
|
200
|
+
reportScanned({ gate: 'sync_pr_branch', scanned: 0, units: 'base ref(s)', roots: ['origin'], allowEmpty: 'argument error' });
|
|
201
|
+
return 1;
|
|
202
|
+
}
|
|
203
|
+
base = v;
|
|
204
|
+
} else if (a === '--dry-run') {
|
|
205
|
+
dryRun = true;
|
|
206
|
+
} else if (a === '--quiet') {
|
|
207
|
+
quiet = true;
|
|
208
|
+
} else if (a === '-h' || a === '--help') {
|
|
209
|
+
process.stdout.write(
|
|
210
|
+
'usage: sync_pr_branch [--repo PATH] [--base REF] [--dry-run] [--quiet]\n' +
|
|
211
|
+
' Merges the PR base into the current branch so the PR does not go stale.\n' +
|
|
212
|
+
' Resolves the base from the open PR when there is one. A conflict is\n' +
|
|
213
|
+
' reported and never auto-resolved; generated and authored conflicts are\n' +
|
|
214
|
+
' listed separately because only the first has one correct resolution.\n',
|
|
215
|
+
);
|
|
216
|
+
reportScanned({ gate: 'sync_pr_branch', scanned: 0, units: 'base ref(s)', roots: ['origin'], allowEmpty: 'help output' });
|
|
217
|
+
return 0;
|
|
218
|
+
} else {
|
|
219
|
+
process.stderr.write(`❌ sync_pr_branch: unknown argument \`${a}\`\n`);
|
|
220
|
+
reportScanned({ gate: 'sync_pr_branch', scanned: 0, units: 'base ref(s)', roots: ['origin'], allowEmpty: 'argument error' });
|
|
221
|
+
return 1;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
let plan: Plan;
|
|
226
|
+
try {
|
|
227
|
+
plan = sync(repo, base, dryRun);
|
|
228
|
+
} catch (exc) {
|
|
229
|
+
reportScanned({ gate: 'sync_pr_branch', scanned: 0, units: 'base ref(s)', roots: ['origin'], allowEmpty: 'internal error' });
|
|
230
|
+
process.stderr.write(`❌ sync_pr_branch: internal error: ${exc instanceof Error ? exc.message : String(exc)}\n`);
|
|
231
|
+
return 2;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if (plan.exit === 1) {
|
|
235
|
+
process.stdout.write(`❌ ${plan.message}\n`);
|
|
236
|
+
if (plan.generated.length > 0) {
|
|
237
|
+
process.stdout.write(
|
|
238
|
+
`\n GENERATED (${String(plan.generated.length)}) — resolve by REGENERATING, never by mixing hunks:\n`,
|
|
239
|
+
);
|
|
240
|
+
for (const f of plan.generated) process.stdout.write(` · ${f}\n`);
|
|
241
|
+
process.stdout.write(' → git checkout --ours <file> && task sync && task generate-tools\n');
|
|
242
|
+
}
|
|
243
|
+
if (plan.authored.length > 0) {
|
|
244
|
+
process.stdout.write(`\n AUTHORED (${String(plan.authored.length)}) — a human decision, read both sides:\n`);
|
|
245
|
+
for (const f of plan.authored) process.stdout.write(` · ${f}\n`);
|
|
246
|
+
}
|
|
247
|
+
} else if (plan.message.startsWith('unverified')) {
|
|
248
|
+
// Loud even under --quiet: unverified reported silently is
|
|
249
|
+
// indistinguishable from verified, which is the defect being closed.
|
|
250
|
+
process.stdout.write(`⚠️ sync_pr_branch: ${plan.message}\n`);
|
|
251
|
+
} else if (!quiet || plan.scanned > 0) {
|
|
252
|
+
process.stdout.write(`✅ ${plan.message}\n`);
|
|
253
|
+
}
|
|
254
|
+
reportScanned({
|
|
255
|
+
gate: 'sync_pr_branch',
|
|
256
|
+
scanned: plan.scanned,
|
|
257
|
+
units: 'base ref(s)',
|
|
258
|
+
roots: ['origin'],
|
|
259
|
+
...(plan.scanned === 0 ? { allowEmpty: 'base unresolvable or remote unreachable — stated above' } : {}),
|
|
260
|
+
});
|
|
261
|
+
return plan.exit;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const _HERE = fileURLToPath(import.meta.url);
|
|
265
|
+
function _isCliEntry(): boolean {
|
|
266
|
+
if (process.argv[1] === undefined) return false;
|
|
267
|
+
return import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href || process.argv[1] === _HERE;
|
|
268
|
+
}
|
|
269
|
+
if (_isCliEntry()) {
|
|
270
|
+
process.exit(main());
|
|
271
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Executable probe for the provider-qualification exit criterion
|
|
3
|
+
* (road-to-release-review-p0 Phase 3).
|
|
4
|
+
*
|
|
5
|
+
* The roadmap states the criterion as behaviour, not as coverage: *"a
|
|
6
|
+
* deliberately broken seat yields unavailable rather than configured, and a
|
|
7
|
+
* council run against it reports short instead of printing a quorum."* This
|
|
8
|
+
* script asserts exactly that, against the real `build_members` path, with a
|
|
9
|
+
* synthetic roster and an injected environment report — no config file, no
|
|
10
|
+
* network, no provider call, no spend.
|
|
11
|
+
*
|
|
12
|
+
* It exists in addition to the vitest suites rather than instead of them. The
|
|
13
|
+
* unit tests can be green while the wiring is gone: `build_members` evaluates
|
|
14
|
+
* qualification only when a `probe_store` is supplied, so a future refactor
|
|
15
|
+
* that drops the argument at the three production call sites would leave every
|
|
16
|
+
* test passing and the repair silently inert. This probe re-asserts the
|
|
17
|
+
* end-to-end shape the roadmap names, and is the `verify:` annotation its
|
|
18
|
+
* Phase 3 steps point at.
|
|
19
|
+
*
|
|
20
|
+
* Exit 0 = the criterion holds. Exit 1 = it does not, with the failing
|
|
21
|
+
* assertion named. `--quiet` suppresses the per-assertion lines and keeps the
|
|
22
|
+
* verdict, matching the house convention for a script CI may call.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import fs from 'node:fs';
|
|
26
|
+
import path from 'node:path';
|
|
27
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
28
|
+
|
|
29
|
+
import type { EnvironmentReport } from './_lib/environment_detector.js';
|
|
30
|
+
import type { ProbeStore } from './ai_council/probe_store.js';
|
|
31
|
+
import type { QuorumResult } from './ai_council/quorum.js';
|
|
32
|
+
import { isCountableForQuorum, qualifyMember, type MemberQualification } from './ai_council/qualification.js';
|
|
33
|
+
import { build_members } from './council_cli.js';
|
|
34
|
+
|
|
35
|
+
const QUIET = process.argv.includes('--quiet');
|
|
36
|
+
|
|
37
|
+
const KEY_VAR = 'COUNCIL_QUALIFICATION_PROBE_KEY';
|
|
38
|
+
const TODAY = new Date().toISOString().slice(0, 10);
|
|
39
|
+
|
|
40
|
+
function _out(line: string): void {
|
|
41
|
+
if (!QUIET) {
|
|
42
|
+
process.stdout.write(`${line}\n`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const failures: string[] = [];
|
|
47
|
+
|
|
48
|
+
function check(name: string, condition: boolean, detail: string): void {
|
|
49
|
+
if (condition) {
|
|
50
|
+
_out(` ok ${name}`);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
failures.push(`${name} — ${detail}`);
|
|
54
|
+
_out(` FAIL ${name} — ${detail}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** No hosts, no auth records, no keys — every rung is decided by the roster. */
|
|
58
|
+
function emptyReport(): EnvironmentReport {
|
|
59
|
+
return { hosts: [], auth: [], keys: [] };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function rosterSettings(): Record<string, unknown> {
|
|
63
|
+
return {
|
|
64
|
+
ai_council: {
|
|
65
|
+
enabled: true,
|
|
66
|
+
mode: 'auto',
|
|
67
|
+
members: {
|
|
68
|
+
// Healthy: resolves through the key rung and has been observed.
|
|
69
|
+
anthropic: { enabled: true, model: 'model-a', api_key_ref: `env:${KEY_VAR}` },
|
|
70
|
+
// Broken: constructs identically, and its last exchange was
|
|
71
|
+
// rejected by the transport. This is the recorded codex shape —
|
|
72
|
+
// a seat that looks configured from every angle a config read
|
|
73
|
+
// can see.
|
|
74
|
+
openai: { enabled: true, model: 'model-b', api_key_ref: `env:${KEY_VAR}` },
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function storeWith(members: ProbeStore['members']): ProbeStore {
|
|
81
|
+
return { schema: 1, members };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function main(): number {
|
|
85
|
+
_out('council qualification — exit-criterion probe');
|
|
86
|
+
|
|
87
|
+
// The roster is only constructible while the key var is set; restore
|
|
88
|
+
// whatever was there so the probe leaves no environment residue.
|
|
89
|
+
const prior = process.env[KEY_VAR];
|
|
90
|
+
process.env[KEY_VAR] = 'sk-probe-key';
|
|
91
|
+
try {
|
|
92
|
+
// ── Criterion 1: a deliberately broken seat yields `unavailable`. ──
|
|
93
|
+
const broken = qualifyMember({
|
|
94
|
+
name: 'broken',
|
|
95
|
+
transport: { available: true, transport: 'api', reason: null, absentReason: null },
|
|
96
|
+
modelId: 'model-b',
|
|
97
|
+
lastProbe: { at: TODAY, outcome: 'model_unservable' },
|
|
98
|
+
});
|
|
99
|
+
check(
|
|
100
|
+
'a seat whose model the transport rejected reports unavailable',
|
|
101
|
+
broken.verdict === 'unavailable',
|
|
102
|
+
`got ${broken.verdict}`,
|
|
103
|
+
);
|
|
104
|
+
check(
|
|
105
|
+
'and it is not countable toward a quorum',
|
|
106
|
+
!isCountableForQuorum(broken.verdict),
|
|
107
|
+
'a seat nothing will answer from was counted present',
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
// ── Criterion 1b: "configured" alone never reads as available. ──
|
|
111
|
+
const neverObserved = qualifyMember({
|
|
112
|
+
name: 'never-observed',
|
|
113
|
+
transport: { available: true, transport: 'api', reason: null, absentReason: null },
|
|
114
|
+
modelId: 'model-a',
|
|
115
|
+
lastProbe: null,
|
|
116
|
+
});
|
|
117
|
+
check(
|
|
118
|
+
'a configured, plausible, never-observed seat reports unknown',
|
|
119
|
+
neverObserved.verdict === 'unknown',
|
|
120
|
+
`got ${neverObserved.verdict} — configuration must never read as demonstrated`,
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
// ── Criterion 2: the run reports SHORT rather than printing a quorum. ──
|
|
124
|
+
const quorum_out: { result: QuorumResult | null } = { result: null };
|
|
125
|
+
const qualification_out: MemberQualification[] = [];
|
|
126
|
+
build_members(rosterSettings(), {
|
|
127
|
+
environment_report: emptyReport(),
|
|
128
|
+
quorum_out,
|
|
129
|
+
qualification_out,
|
|
130
|
+
probe_store: storeWith({
|
|
131
|
+
anthropic: { at: TODAY, outcome: 'ok' },
|
|
132
|
+
openai: { at: TODAY, outcome: 'model_unservable' },
|
|
133
|
+
}),
|
|
134
|
+
});
|
|
135
|
+
const q = quorum_out.result;
|
|
136
|
+
check('the pre-run quorum was evaluated', q !== null, 'quorum_out.result stayed null');
|
|
137
|
+
if (q !== null) {
|
|
138
|
+
check(
|
|
139
|
+
'the broken seat is withheld from present',
|
|
140
|
+
q.present === 1,
|
|
141
|
+
`present=${String(q.present)}, expected 1 of a 2-seat roster`,
|
|
142
|
+
);
|
|
143
|
+
// The direction check. Shrinking `n` would lower ceil(n/2) and make
|
|
144
|
+
// a short pass EASIER to conclude — the same over-claim in the
|
|
145
|
+
// opposite arithmetic.
|
|
146
|
+
check(
|
|
147
|
+
'the roster is NOT shrunk — total stays 2',
|
|
148
|
+
q.total === 2,
|
|
149
|
+
`total=${String(q.total)}; dropping the seat from n would lower the threshold`,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
check(
|
|
153
|
+
'qualification reached the caller',
|
|
154
|
+
qualification_out.length === 2,
|
|
155
|
+
`qualification_out has ${String(qualification_out.length)} entries — the wiring is inert`,
|
|
156
|
+
);
|
|
157
|
+
const wiredBroken = qualification_out.find((x) => x.name === 'openai');
|
|
158
|
+
check(
|
|
159
|
+
'the broken seat is reported unavailable end-to-end',
|
|
160
|
+
wiredBroken?.verdict === 'unavailable',
|
|
161
|
+
`got ${String(wiredBroken?.verdict)}`,
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
// ── Criterion 3: the store is consulted, not assumed. ──
|
|
165
|
+
const ungated: { result: QuorumResult | null } = { result: null };
|
|
166
|
+
build_members(rosterSettings(), { environment_report: emptyReport(), quorum_out: ungated });
|
|
167
|
+
check(
|
|
168
|
+
'omitting the store leaves presence at constructibility only',
|
|
169
|
+
ungated.result?.present === 2,
|
|
170
|
+
`present=${String(ungated.result?.present)} — the default must stay deterministic`,
|
|
171
|
+
);
|
|
172
|
+
} finally {
|
|
173
|
+
if (prior === undefined) {
|
|
174
|
+
delete process.env[KEY_VAR];
|
|
175
|
+
} else {
|
|
176
|
+
process.env[KEY_VAR] = prior;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
if (failures.length > 0) {
|
|
181
|
+
process.stdout.write(
|
|
182
|
+
`council qualification: ❌ ${String(failures.length)} assertion(s) failed\n`,
|
|
183
|
+
);
|
|
184
|
+
for (const f of failures) {
|
|
185
|
+
process.stdout.write(` - ${f}\n`);
|
|
186
|
+
}
|
|
187
|
+
return 1;
|
|
188
|
+
}
|
|
189
|
+
process.stdout.write('council qualification: ✅ exit criterion holds\n');
|
|
190
|
+
return 0;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const _HERE = fileURLToPath(import.meta.url);
|
|
194
|
+
function _isCliEntry(): boolean {
|
|
195
|
+
if (process.argv[1] === undefined) {
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
|
|
199
|
+
if (import.meta.url === argvUrl) {
|
|
200
|
+
return true;
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
const here = fs.realpathSync(fileURLToPath(import.meta.url));
|
|
204
|
+
const argv = fs.realpathSync(path.resolve(process.argv[1]));
|
|
205
|
+
return here === argv;
|
|
206
|
+
} catch {
|
|
207
|
+
return false;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (_isCliEntry() || process.argv[1] === _HERE) {
|
|
212
|
+
process.exit(main());
|
|
213
|
+
}
|