@event4u/agent-config 14.14.0 → 14.14.1
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/.augment-plugin/marketplace.json +3 -3
- package/.augment-plugin/plugin.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +33 -0
- package/README.md +1 -1
- package/dist/agent-src/commands/pr/create.md +16 -1
- package/dist/agent-src/contexts/execution/terminal-states.md +25 -4
- package/dist/agent-src/rules/missing-skill-recovery.md +2 -2
- package/dist/agent-src/rules/token-budget-discipline.md +2 -2
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli/registry.js +1 -0
- package/dist/cli-delegate/{chunk-ILMUPLGW.js → chunk-7RXIKF62.js} +159 -86
- package/dist/cli-delegate/cmd_conformance.js +1 -1
- package/dist/cli-delegate/cmd_doctor.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +5 -5
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +528 -371
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +185 -98
- package/package.json +1 -1
- package/src/config/assurance-capability-registry.json +1 -1
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-coverage.yml +62 -2
- package/src/config/gate-reachability-exemptions.json +1 -0
- package/src/config/gate-violation-baselines.json +4 -3
- package/src/config/web-launch-readiness.json +4 -2
- package/src/scripts/_cli/_doctor_emit.ts +71 -0
- package/src/scripts/_cli/_doctor_strict.ts +163 -0
- package/src/scripts/_cli/cmd_doctor.ts +16 -37
- package/src/scripts/_dispatch.bash +11 -0
- package/src/scripts/_lib/branch_convergence.ts +186 -0
- package/src/scripts/_lib/context_observation.ts +132 -0
- package/src/scripts/_lib/continuation_ladder.ts +188 -0
- package/src/scripts/_lib/experiment_binding.ts +95 -0
- package/src/scripts/_lib/ignored_blocker.ts +8 -1
- package/src/scripts/_lib/outcome_envelope.ts +6 -1
- package/src/scripts/_lib/outcome_vocabularies.ts +135 -5
- package/src/scripts/_lib/release_highlights.ts +12 -3
- package/src/scripts/_lib/repeated_failure.ts +19 -2
- package/src/scripts/_lib/runtime_journal.ts +11 -4
- package/src/scripts/check_agent_artifact_location.ts +337 -0
- package/src/scripts/check_artefact_count_messaging.ts +74 -8
- package/src/scripts/check_enforcement_coverage.ts +40 -1
- package/src/scripts/check_release_highlights.ts +17 -1
- package/src/scripts/check_web_launch_readiness.ts +310 -20
- package/src/scripts/evolution_lab.ts +53 -1
- package/src/scripts/generate_capability_matrix.ts +16 -9
- package/src/scripts/grade_target_readiness.ts +221 -2
- package/src/scripts/hook_effect_doctor.ts +47 -4
- package/src/scripts/hooks/run_continuation_hook.ts +78 -88
- package/src/scripts/hooks/session_eol_hook.ts +12 -1
- package/src/scripts/install-hooks.sh +18 -0
- package/src/scripts/lint_code_comments.ts +106 -1
- package/src/scripts/release_drill.ts +57 -1
- package/src/scripts/release_publication.ts +58 -0
- package/src/scripts/roadmap_context.ts +15 -0
- package/src/scripts/skill_scout.ts +633 -0
- package/src/scripts/sync_pr_branch.ts +521 -42
|
@@ -0,0 +1,633 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Governed skill scout — evaluate an external skill candidate against what this
|
|
4
|
+
* package already covers, and end in a written verdict either way.
|
|
5
|
+
*
|
|
6
|
+
* WHY THE REJECTION IS THE PRODUCT. A scouting pipeline whose success path is
|
|
7
|
+
* better developed than its rejection path will produce optimistic verdicts,
|
|
8
|
+
* because that is the path it knows how to walk. The maintainer's own framing
|
|
9
|
+
* put the constraint before the feature: the contribution offer appears only
|
|
10
|
+
* when every gate has actually passed, and otherwise the run ends with
|
|
11
|
+
* "keine Contribution empfohlen" and a reason. So {@link Verdict} carries the
|
|
12
|
+
* SAME fields in both directions and both are rendered by the same function —
|
|
13
|
+
* there is no shape a passing run can reach that a failing run cannot.
|
|
14
|
+
*
|
|
15
|
+
* IMPORT INTELLIGENCE, NOT FILES. What is evaluated is the marginal capability
|
|
16
|
+
* delta against this package's own skill index, never the candidate in
|
|
17
|
+
* isolation and never the candidate's self-description. A skill that is
|
|
18
|
+
* excellent and already covered is a reject, and the reason names the covering
|
|
19
|
+
* artefact.
|
|
20
|
+
*
|
|
21
|
+
* TWO RECORDED DECISIONS BOUND THIS FILE. Both were resolved by AI council on
|
|
22
|
+
* 2026-09-03 (members: anthropic, openai), unanimously:
|
|
23
|
+
*
|
|
24
|
+
* - `scout-egress-authority` = (a): NO network fetch, ever. Candidates arrive
|
|
25
|
+
* by human copy into the quarantine root. This keeps the lethal-trifecta
|
|
26
|
+
* legs separated by construction rather than by a runtime gate. No network
|
|
27
|
+
* primitive, no URL handling, and no transport import appears below — the
|
|
28
|
+
* test for this asserts on the literal source text, so this comment must
|
|
29
|
+
* not name the tokens it forbids either.
|
|
30
|
+
* - `scout-invocation-surface` = (a): the scout runs only inside this
|
|
31
|
+
* package. A maintainer running it here is already in the repository that
|
|
32
|
+
* would receive the change, so the "shall I open a PR" question never
|
|
33
|
+
* arises. The terminal state is a recommendation, and the scout never
|
|
34
|
+
* performs an outward action of any kind.
|
|
35
|
+
*
|
|
36
|
+
* NO NEW CLI VERB (ADR-041) and NO NEW SKILL. This is a script plus a Taskfile
|
|
37
|
+
* target. Adding a `skill-scout` meta-skill would cost a catalogue entry
|
|
38
|
+
* against a preamble budget with zero headroom, which is why the originating
|
|
39
|
+
* roadmap defers it until two real pipeline runs exist.
|
|
40
|
+
*
|
|
41
|
+
* Exit codes: 0 = the run produced a verdict (either verdict), 1 = the run
|
|
42
|
+
* could not produce one (candidate missing, or intake refused it).
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import * as fs from 'node:fs';
|
|
46
|
+
import * as path from 'node:path';
|
|
47
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
48
|
+
|
|
49
|
+
import { collect, _keyword_vector, _cosine } from './audit_skill_overlap.js';
|
|
50
|
+
import { reportScanned, DeadScopeError } from './_lib/scan_scope.js';
|
|
51
|
+
|
|
52
|
+
const _HERE = fileURLToPath(import.meta.url);
|
|
53
|
+
const _DEFAULT_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
|
|
54
|
+
|
|
55
|
+
/** Quarantine root, relative to the repository root. Gitignored in whole. */
|
|
56
|
+
export const QUARANTINE_REL = 'agents/runtime/skill-scout/candidates';
|
|
57
|
+
|
|
58
|
+
/** Text-only allow-list. A candidate carrying anything else is refused. */
|
|
59
|
+
const ALLOWED_EXT: ReadonlySet<string> = new Set([
|
|
60
|
+
'.md',
|
|
61
|
+
'.txt',
|
|
62
|
+
'.json',
|
|
63
|
+
'.yml',
|
|
64
|
+
'.yaml',
|
|
65
|
+
'.toml',
|
|
66
|
+
]);
|
|
67
|
+
|
|
68
|
+
/** 512 KiB. A skill that does not fit is not a skill. */
|
|
69
|
+
const MAX_FILE_BYTES = 512 * 1024;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Cosine above which the candidate is considered already covered.
|
|
73
|
+
*
|
|
74
|
+
* NOT a measured optimum, and said so rather than implied.
|
|
75
|
+
* `audit_skill_overlap` reports pairs from 0.30 upward as worth a human read;
|
|
76
|
+
* 0.45 sits above that so a coverage rejection is a claim about substantial
|
|
77
|
+
* overlap rather than about shared vocabulary.
|
|
78
|
+
*/
|
|
79
|
+
export const COVERAGE_THRESHOLD = 0.45;
|
|
80
|
+
|
|
81
|
+
/** The four gates the contribution recommendation is conditioned on. */
|
|
82
|
+
export type GateName = 'novelty' | 'security_licence' | 'benefit' | 'challenge_loops';
|
|
83
|
+
|
|
84
|
+
export const GATE_NAMES: readonly GateName[] = [
|
|
85
|
+
'novelty',
|
|
86
|
+
'security_licence',
|
|
87
|
+
'benefit',
|
|
88
|
+
'challenge_loops',
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
export interface GateResult {
|
|
92
|
+
readonly gate: GateName;
|
|
93
|
+
readonly passed: boolean;
|
|
94
|
+
/** Why it passed or failed. Never empty, in either direction. */
|
|
95
|
+
readonly reason: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface Delta {
|
|
99
|
+
/** Highest cosine against any skill in this package. */
|
|
100
|
+
readonly max_similarity: number;
|
|
101
|
+
/** The skill that produced it — the covering artefact when it covers. */
|
|
102
|
+
readonly nearest: string | null;
|
|
103
|
+
readonly nearest_path: string | null;
|
|
104
|
+
/** How many of this package's skills the candidate was compared against. */
|
|
105
|
+
readonly compared_against: number;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export interface IntakeResult {
|
|
109
|
+
readonly accepted: boolean;
|
|
110
|
+
readonly refusals: readonly string[];
|
|
111
|
+
readonly files_seen: number;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* One run's complete output.
|
|
116
|
+
*
|
|
117
|
+
* The field set is identical for both outcomes — this is the shape 3.2 of the
|
|
118
|
+
* originating roadmap requires, and the test asserts it structurally rather
|
|
119
|
+
* than by reading prose.
|
|
120
|
+
*/
|
|
121
|
+
export interface Verdict {
|
|
122
|
+
readonly candidate: string;
|
|
123
|
+
readonly recommended: boolean;
|
|
124
|
+
/** Free of any upstream name — `source-confidentiality`. */
|
|
125
|
+
readonly reason: string;
|
|
126
|
+
readonly delta: Delta;
|
|
127
|
+
readonly gates: readonly GateResult[];
|
|
128
|
+
readonly lints_run: readonly string[];
|
|
129
|
+
readonly lints_unavailable: readonly { readonly lint: string; readonly why: string }[];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The lint fleet, split by whether it can actually be pointed at a candidate.
|
|
134
|
+
*
|
|
135
|
+
* Established by 1.2 of the originating roadmap: no new linters were written,
|
|
136
|
+
* and the split below is MEASURED, not inferred from the presence of a
|
|
137
|
+
* `--root` flag. That distinction is the finding. Four lints under
|
|
138
|
+
* `src/scripts/` accept `--root`; running each of them against a real
|
|
139
|
+
* quarantined candidate on 2026-09-03 showed that only ONE of the four reaches
|
|
140
|
+
* a candidate at all. The other three take `--root` to mean the repository
|
|
141
|
+
* root (a test affordance) or scan a sub-path a text-only candidate does not
|
|
142
|
+
* have. A fleet list built from `grep --root` would have been wrong in three
|
|
143
|
+
* places and would have reported lints that silently scanned nothing.
|
|
144
|
+
*
|
|
145
|
+
* The `unavailable` half is not a gap to close later. It is the recorded answer
|
|
146
|
+
* to "which of the fleet can be pointed at a candidate", with the reason per
|
|
147
|
+
* entry, which is exactly what 1.2 asked for.
|
|
148
|
+
*/
|
|
149
|
+
export const LINT_FLEET_ROOTED: readonly string[] = ['lint_skill_descriptions'];
|
|
150
|
+
|
|
151
|
+
export const LINT_FLEET_UNROOTED: readonly { lint: string; why: string }[] = [
|
|
152
|
+
{
|
|
153
|
+
lint: 'lint_skill_link_reach',
|
|
154
|
+
why: "its `--root` is a repository root for its own self-test; the skill scan stays pinned to dist/agent-src/skills and reported a dead scope against a candidate root",
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
lint: 'lint_skill_scripts_readonly',
|
|
158
|
+
why: 'scans `<skill>/scripts/**` only; a text-only candidate has no scripts directory, so the run is a dead scope rather than a pass',
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
lint: 'check_skill_admissions',
|
|
162
|
+
why: 'its corpus is the admissions ledger, not the skill tree; `--root` relocates the repository for its tests and the ledger is absent under a candidate root',
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
lint: 'skill_linter',
|
|
166
|
+
why: 'scans the projected tree, which a quarantined candidate is absent from by design',
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
lint: 'lint_token_budget_discipline',
|
|
170
|
+
why: 'reads the committed budget corpus; a candidate has no budget row until it is adopted',
|
|
171
|
+
},
|
|
172
|
+
];
|
|
173
|
+
|
|
174
|
+
function _walk(dir: string): string[] {
|
|
175
|
+
const out: string[] = [];
|
|
176
|
+
const stack = [dir];
|
|
177
|
+
while (stack.length > 0) {
|
|
178
|
+
const cur = stack.pop() as string;
|
|
179
|
+
let entries: fs.Dirent[];
|
|
180
|
+
try {
|
|
181
|
+
entries = fs.readdirSync(cur, { withFileTypes: true });
|
|
182
|
+
} catch {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
for (const e of entries) {
|
|
186
|
+
const full = path.join(cur, e.name);
|
|
187
|
+
if (e.isSymbolicLink()) {
|
|
188
|
+
out.push(full);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
if (e.isDirectory()) {
|
|
192
|
+
stack.push(full);
|
|
193
|
+
} else {
|
|
194
|
+
out.push(full);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
return out.sort();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Confine a candidate name to ONE segment directly under the quarantine root.
|
|
203
|
+
*
|
|
204
|
+
* WHY THIS IS A REFUSAL AND NOT A NORMALISATION. Every guarantee the quarantine
|
|
205
|
+
* contract makes is a property of a directory *under the quarantine root* — the
|
|
206
|
+
* human copy step is the trust checkpoint, and a path that leaves the root was
|
|
207
|
+
* never copied there. Rewriting a traversing name into a confined one would
|
|
208
|
+
* accept the operator's typo as an instruction; refusing states that the name
|
|
209
|
+
* did not identify a candidate at all.
|
|
210
|
+
*
|
|
211
|
+
* Without this the name flowed straight into `path.join(qroot, name)`, so
|
|
212
|
+
* `--candidate ../../../../.github/workflows` walked 33 files that no human
|
|
213
|
+
* staged and rendered `adoption recommended` over them — and the scan-scope
|
|
214
|
+
* line reported its root as `…/candidates/../../../../.github/workflows`,
|
|
215
|
+
* asserting a scope it was not scanning. Both halves are closed here, before
|
|
216
|
+
* `reportScanned` runs, so the assertion can never record the traversed root.
|
|
217
|
+
*
|
|
218
|
+
* Returns the refusal reason, or `null` when the name is confined.
|
|
219
|
+
*/
|
|
220
|
+
export function candidateNameRefusal(name: string): string | null {
|
|
221
|
+
if (name.trim() === '') {
|
|
222
|
+
return 'candidate name is empty';
|
|
223
|
+
}
|
|
224
|
+
if (name.includes('\0')) {
|
|
225
|
+
return 'candidate name contains a NUL byte';
|
|
226
|
+
}
|
|
227
|
+
if (name === '.' || name === '..') {
|
|
228
|
+
return `candidate name ${name} is a directory reference, not a candidate`;
|
|
229
|
+
}
|
|
230
|
+
// Both separators, on every platform: a backslash is a legal POSIX filename
|
|
231
|
+
// character, so checking only `path.sep` would let `a\..\b` through on
|
|
232
|
+
// POSIX and mean something else entirely once the same name reaches Windows.
|
|
233
|
+
if (name.includes('/') || name.includes('\\')) {
|
|
234
|
+
return `candidate name ${name} is not a single path segment — candidates live directly under ${QUARANTINE_REL}/`;
|
|
235
|
+
}
|
|
236
|
+
if (name !== path.basename(name)) {
|
|
237
|
+
return `candidate name ${name} is not a single path segment`;
|
|
238
|
+
}
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Resolve a candidate name against the quarantine root, or refuse it.
|
|
244
|
+
*
|
|
245
|
+
* The containment assertion is deliberately kept after the name check rather
|
|
246
|
+
* than instead of it: the name check states the rule, and this states that the
|
|
247
|
+
* resolved path obeys it whatever `path` does on the host platform.
|
|
248
|
+
*/
|
|
249
|
+
export function resolveCandidateDir(
|
|
250
|
+
quarantineRoot: string,
|
|
251
|
+
name: string,
|
|
252
|
+
): { readonly dir: string; readonly refusal: null } | { readonly dir: null; readonly refusal: string } {
|
|
253
|
+
const named = candidateNameRefusal(name);
|
|
254
|
+
if (named !== null) {
|
|
255
|
+
return { dir: null, refusal: named };
|
|
256
|
+
}
|
|
257
|
+
const root = path.resolve(quarantineRoot);
|
|
258
|
+
const dir = path.resolve(root, name);
|
|
259
|
+
if (dir !== path.join(root, name) || !dir.startsWith(root + path.sep)) {
|
|
260
|
+
return { dir: null, refusal: `candidate ${name} resolves outside ${QUARANTINE_REL}/` };
|
|
261
|
+
}
|
|
262
|
+
return { dir, refusal: null };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Refuse a candidate that is not inert.
|
|
267
|
+
*
|
|
268
|
+
* Every check is a refusal, never a repair. A candidate that fails is reported
|
|
269
|
+
* and stops — there is no sanitising pass, because a pass that fixes a
|
|
270
|
+
* candidate is a pass that can be fooled into fixing it wrongly.
|
|
271
|
+
*/
|
|
272
|
+
export function intake(candidateDir: string): IntakeResult {
|
|
273
|
+
const refusals: string[] = [];
|
|
274
|
+
if (!fs.existsSync(candidateDir) || !fs.statSync(candidateDir).isDirectory()) {
|
|
275
|
+
return { accepted: false, refusals: ['candidate directory does not exist'], files_seen: 0 };
|
|
276
|
+
}
|
|
277
|
+
const files = _walk(candidateDir);
|
|
278
|
+
for (const f of files) {
|
|
279
|
+
const rel = path.relative(candidateDir, f).split(path.sep).join('/');
|
|
280
|
+
const st = fs.lstatSync(f);
|
|
281
|
+
if (st.isSymbolicLink()) {
|
|
282
|
+
refusals.push(`${rel}: symlink — quarantine holds inert regular files only`);
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
const ext = path.extname(f).toLowerCase();
|
|
286
|
+
if (!ALLOWED_EXT.has(ext)) {
|
|
287
|
+
refusals.push(`${rel}: extension ${ext || '(none)'} is outside the text allow-list`);
|
|
288
|
+
}
|
|
289
|
+
if ((st.mode & 0o111) !== 0) {
|
|
290
|
+
refusals.push(`${rel}: executable bit set — a candidate is never executed`);
|
|
291
|
+
}
|
|
292
|
+
if (st.size > MAX_FILE_BYTES) {
|
|
293
|
+
refusals.push(`${rel}: ${st.size} bytes exceeds the ${MAX_FILE_BYTES}-byte cap`);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
if (files.length === 0) {
|
|
297
|
+
refusals.push('candidate directory is empty');
|
|
298
|
+
}
|
|
299
|
+
return { accepted: refusals.length === 0, refusals, files_seen: files.length };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Concatenate a candidate's text into one vector source.
|
|
304
|
+
*
|
|
305
|
+
* THE INERTNESS CHECKS ARE RE-RUN HERE, and the reason is a window rather than
|
|
306
|
+
* a doubt about `intake`. `intake` walks and `lstat`s, then this walks and
|
|
307
|
+
* reads — two separate passes over a mutable directory. A regular `x.md`
|
|
308
|
+
* swapped for a symlink between them passed a clean intake and would then be
|
|
309
|
+
* followed out of the quarantine at read time, because the extension filter is
|
|
310
|
+
* the only thing this pass used to look at.
|
|
311
|
+
*
|
|
312
|
+
* This NARROWS that window; it does not close it. Closing it needs an
|
|
313
|
+
* fd-based open plus `fstat` on the same descriptor, which `readFileSync` does
|
|
314
|
+
* not give us. What remains is a regular file swapped for a different regular
|
|
315
|
+
* file inside the read pass — by an actor who already has write access to the
|
|
316
|
+
* local, gitignored quarantine root, and whose reward is a cosine number, since
|
|
317
|
+
* no candidate text reaches the verdict output. The residual is recorded in the
|
|
318
|
+
* originating roadmap rather than implied to be gone.
|
|
319
|
+
*/
|
|
320
|
+
export function candidateText(candidateDir: string): string {
|
|
321
|
+
return _walk(candidateDir)
|
|
322
|
+
.filter((f) => ALLOWED_EXT.has(path.extname(f).toLowerCase()))
|
|
323
|
+
.map((f) => {
|
|
324
|
+
try {
|
|
325
|
+
const st = fs.lstatSync(f);
|
|
326
|
+
// Not a regular file (symlink, fifo, device) → it is not
|
|
327
|
+
// candidate text, whatever it was when intake saw it.
|
|
328
|
+
if (!st.isFile()) return '';
|
|
329
|
+
if (st.size > MAX_FILE_BYTES) return '';
|
|
330
|
+
return fs.readFileSync(f, 'utf-8');
|
|
331
|
+
} catch {
|
|
332
|
+
return '';
|
|
333
|
+
}
|
|
334
|
+
})
|
|
335
|
+
.join('\n');
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* The capability differential — computed from THIS package's index.
|
|
340
|
+
*
|
|
341
|
+
* The candidate contributes a keyword vector and nothing else. Its own claim
|
|
342
|
+
* about novelty is never read, which is the structural half of
|
|
343
|
+
* `untrusted-input-defense`: after vectorisation there is nobody to address.
|
|
344
|
+
*/
|
|
345
|
+
export function computeDelta(candidateDir: string, skillRoot?: string): Delta {
|
|
346
|
+
const mine = skillRoot === undefined ? collect() : collect(skillRoot);
|
|
347
|
+
const vec = _keyword_vector(candidateText(candidateDir));
|
|
348
|
+
let best = 0;
|
|
349
|
+
let nearest: string | null = null;
|
|
350
|
+
let nearestPath: string | null = null;
|
|
351
|
+
for (const s of mine) {
|
|
352
|
+
const sim = _cosine(vec, s.vector);
|
|
353
|
+
if (sim > best) {
|
|
354
|
+
best = sim;
|
|
355
|
+
nearest = s.name;
|
|
356
|
+
nearestPath = s.relpath;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return {
|
|
360
|
+
max_similarity: Math.round(best * 10000) / 10000,
|
|
361
|
+
nearest,
|
|
362
|
+
nearest_path: nearestPath,
|
|
363
|
+
compared_against: mine.length,
|
|
364
|
+
};
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
export interface GateInputs {
|
|
368
|
+
/** Licence identifier the operator recorded for the candidate. */
|
|
369
|
+
readonly licence: string | null;
|
|
370
|
+
/** A measured benefit, in the operator's own units. Absent = gate fails. */
|
|
371
|
+
readonly benefit: string | null;
|
|
372
|
+
/** One entry per completed challenge loop. */
|
|
373
|
+
readonly challenges: readonly { readonly round: number; readonly critical_open: boolean }[];
|
|
374
|
+
readonly intake: IntakeResult;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Evaluate all four gates. Every gate is always evaluated and always reported —
|
|
379
|
+
* there is no short-circuit, because a run that stops at the first failure
|
|
380
|
+
* cannot tell the operator which of the four they still have to earn.
|
|
381
|
+
*/
|
|
382
|
+
export function evaluateGates(delta: Delta, inputs: GateInputs): GateResult[] {
|
|
383
|
+
const results: GateResult[] = [];
|
|
384
|
+
|
|
385
|
+
const covered = delta.max_similarity >= COVERAGE_THRESHOLD;
|
|
386
|
+
results.push({
|
|
387
|
+
gate: 'novelty',
|
|
388
|
+
passed: !covered,
|
|
389
|
+
reason: covered
|
|
390
|
+
? `already covered by \`${delta.nearest ?? 'an existing skill'}\`` +
|
|
391
|
+
` (${delta.nearest_path ?? 'path unresolved'}) at similarity ` +
|
|
392
|
+
`${delta.max_similarity} against ${delta.compared_against} skills` +
|
|
393
|
+
` — a well-written skill this package already covers is a reject`
|
|
394
|
+
: `nearest existing skill \`${delta.nearest ?? 'none'}\` scores ` +
|
|
395
|
+
`${delta.max_similarity}, below the ${COVERAGE_THRESHOLD} coverage threshold`,
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
const licenceOk = inputs.licence !== null && inputs.licence.trim() !== '';
|
|
399
|
+
const intakeOk = inputs.intake.accepted;
|
|
400
|
+
results.push({
|
|
401
|
+
gate: 'security_licence',
|
|
402
|
+
passed: licenceOk && intakeOk,
|
|
403
|
+
reason: !intakeOk
|
|
404
|
+
? `intake refused the candidate: ${inputs.intake.refusals.join('; ')}`
|
|
405
|
+
: licenceOk
|
|
406
|
+
? `intake clean over ${inputs.intake.files_seen} inert files; licence recorded as ${inputs.licence}`
|
|
407
|
+
: 'no licence recorded — an unknown licence is never treated as permissive',
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
const benefitOk = inputs.benefit !== null && inputs.benefit.trim() !== '';
|
|
411
|
+
results.push({
|
|
412
|
+
gate: 'benefit',
|
|
413
|
+
passed: benefitOk,
|
|
414
|
+
reason: benefitOk
|
|
415
|
+
? `measured benefit recorded: ${inputs.benefit}`
|
|
416
|
+
: 'no measured benefit recorded — a plausible benefit is not a measured one',
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
const loops = inputs.challenges.length;
|
|
420
|
+
const unresolved = inputs.challenges.filter((c) => c.critical_open).length;
|
|
421
|
+
const loopsOk = loops >= 3 && unresolved === 0;
|
|
422
|
+
results.push({
|
|
423
|
+
gate: 'challenge_loops',
|
|
424
|
+
passed: loopsOk,
|
|
425
|
+
reason: loopsOk
|
|
426
|
+
? `three challenge loops recorded with no unresolved critical objection`
|
|
427
|
+
: loops < 3
|
|
428
|
+
? `${loops} of 3 challenge loops recorded`
|
|
429
|
+
: `${unresolved} unresolved critical objection(s) across ${loops} loops`,
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
return results;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/** Build the verdict. Both outcomes take this path; there is no second one. */
|
|
436
|
+
export function buildVerdict(
|
|
437
|
+
candidate: string,
|
|
438
|
+
delta: Delta,
|
|
439
|
+
gates: readonly GateResult[],
|
|
440
|
+
intakeResult: IntakeResult,
|
|
441
|
+
): Verdict {
|
|
442
|
+
const failed = gates.filter((g) => !g.passed);
|
|
443
|
+
const recommended = failed.length === 0 && intakeResult.accepted;
|
|
444
|
+
return {
|
|
445
|
+
candidate,
|
|
446
|
+
recommended,
|
|
447
|
+
reason: recommended
|
|
448
|
+
? 'all four gates passed — adoption into this package is recommended'
|
|
449
|
+
: `keine Contribution empfohlen — ${failed.map((g) => `${g.gate}: ${g.reason}`).join('; ')}`,
|
|
450
|
+
delta,
|
|
451
|
+
gates,
|
|
452
|
+
lints_run: LINT_FLEET_ROOTED,
|
|
453
|
+
lints_unavailable: LINT_FLEET_UNROOTED,
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** Render a verdict. One function, both outcomes — 3.2's requirement, literally. */
|
|
458
|
+
export function render(v: Verdict): string {
|
|
459
|
+
const lines: string[] = [];
|
|
460
|
+
lines.push(`skill-scout · ${v.candidate}`);
|
|
461
|
+
lines.push(` verdict ${v.recommended ? 'adoption recommended' : 'keine Contribution empfohlen'}`);
|
|
462
|
+
lines.push(` reason ${v.reason}`);
|
|
463
|
+
lines.push(
|
|
464
|
+
` delta max ${v.delta.max_similarity} vs \`${v.delta.nearest ?? 'none'}\`` +
|
|
465
|
+
` over ${v.delta.compared_against} skills`,
|
|
466
|
+
);
|
|
467
|
+
for (const g of v.gates) {
|
|
468
|
+
lines.push(` gate ${g.gate.padEnd(17)} ${g.passed ? 'pass' : 'FAIL'} — ${g.reason}`);
|
|
469
|
+
}
|
|
470
|
+
lines.push(` lints run ${v.lints_run.join(', ')}`);
|
|
471
|
+
for (const u of v.lints_unavailable) {
|
|
472
|
+
lines.push(` lint unavailable ${u.lint} — ${u.why}`);
|
|
473
|
+
}
|
|
474
|
+
lines.push(
|
|
475
|
+
' note this scout never posts anything; opening a PR stays a human action',
|
|
476
|
+
);
|
|
477
|
+
return lines.join('\n');
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
interface Args {
|
|
481
|
+
candidate: string | null;
|
|
482
|
+
list: boolean;
|
|
483
|
+
json: boolean;
|
|
484
|
+
licence: string | null;
|
|
485
|
+
benefit: string | null;
|
|
486
|
+
challenges: number;
|
|
487
|
+
criticalOpen: number;
|
|
488
|
+
root: string;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export function parseArgs(argv: readonly string[]): Args {
|
|
492
|
+
const args: Args = {
|
|
493
|
+
candidate: null,
|
|
494
|
+
list: false,
|
|
495
|
+
json: false,
|
|
496
|
+
licence: null,
|
|
497
|
+
benefit: null,
|
|
498
|
+
challenges: 0,
|
|
499
|
+
criticalOpen: 0,
|
|
500
|
+
root: _DEFAULT_ROOT,
|
|
501
|
+
};
|
|
502
|
+
for (let i = 0; i < argv.length; i++) {
|
|
503
|
+
const a = argv[i] as string;
|
|
504
|
+
if (a === '--list') args.list = true;
|
|
505
|
+
else if (a === '--json') args.json = true;
|
|
506
|
+
else if (a === '--candidate') args.candidate = argv[++i] ?? null;
|
|
507
|
+
else if (a === '--licence' || a === '--license') args.licence = argv[++i] ?? null;
|
|
508
|
+
else if (a === '--benefit') args.benefit = argv[++i] ?? null;
|
|
509
|
+
else if (a === '--challenges') args.challenges = Number(argv[++i] ?? 0);
|
|
510
|
+
else if (a === '--critical-open') args.criticalOpen = Number(argv[++i] ?? 0);
|
|
511
|
+
else if (a === '--root') args.root = path.resolve(argv[++i] ?? '.');
|
|
512
|
+
else if (a.startsWith('--')) throw new Error(`unrecognized argument: ${a}`);
|
|
513
|
+
}
|
|
514
|
+
return args;
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
export function main(argv: readonly string[] | null = null): number {
|
|
518
|
+
const args = parseArgs(argv ?? process.argv.slice(2));
|
|
519
|
+
const qroot = path.join(args.root, QUARANTINE_REL);
|
|
520
|
+
|
|
521
|
+
if (args.list) {
|
|
522
|
+
if (!fs.existsSync(qroot)) {
|
|
523
|
+
process.stdout.write(`skill-scout · no quarantine root at ${QUARANTINE_REL}\n`);
|
|
524
|
+
return 0;
|
|
525
|
+
}
|
|
526
|
+
const names = fs
|
|
527
|
+
.readdirSync(qroot, { withFileTypes: true })
|
|
528
|
+
.filter((e) => e.isDirectory())
|
|
529
|
+
.map((e) => e.name)
|
|
530
|
+
.sort();
|
|
531
|
+
process.stdout.write(
|
|
532
|
+
names.length === 0
|
|
533
|
+
? `skill-scout · quarantine empty (${QUARANTINE_REL})\n`
|
|
534
|
+
: `skill-scout · candidates: ${names.join(', ')}\n`,
|
|
535
|
+
);
|
|
536
|
+
return 0;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
if (args.candidate === null) {
|
|
540
|
+
process.stderr.write(
|
|
541
|
+
'skill-scout · usage: --candidate <name> [--licence X] [--benefit "..."] ' +
|
|
542
|
+
'[--challenges N] [--critical-open N] [--json] | --list\n' +
|
|
543
|
+
`skill-scout · candidates are placed BY HAND under ${QUARANTINE_REL}/<name>/;\n` +
|
|
544
|
+
'skill-scout · this scout performs no network fetch (scout-egress-authority = a).\n',
|
|
545
|
+
);
|
|
546
|
+
return 1;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const resolved = resolveCandidateDir(qroot, args.candidate);
|
|
550
|
+
if (resolved.refusal !== null) {
|
|
551
|
+
// Refused BEFORE the scan-scope assertion below, so that assertion can
|
|
552
|
+
// never report a root the run had no business reading.
|
|
553
|
+
process.stderr.write(
|
|
554
|
+
`skill-scout · ${args.candidate} · intake refused:\n - ${resolved.refusal}\n`,
|
|
555
|
+
);
|
|
556
|
+
return 1;
|
|
557
|
+
}
|
|
558
|
+
const dir = resolved.dir;
|
|
559
|
+
const intakeResult = intake(dir);
|
|
560
|
+
|
|
561
|
+
// SCAN-SCOPE ASSERTION, and it is not a formality here.
|
|
562
|
+
//
|
|
563
|
+
// `skill_scout` matches the `skill_` prefix in `_lib/gate_population.ts`,
|
|
564
|
+
// so the hardening ratchet counts it in the gate population. That filter is
|
|
565
|
+
// deliberately over-inclusive — "a reporting script that asserts its scan
|
|
566
|
+
// scope loses nothing, while a validator missing from the population is
|
|
567
|
+
// invisible exposure" — and the right response is to assert, not to widen
|
|
568
|
+
// the exclusion.
|
|
569
|
+
//
|
|
570
|
+
// It also happens to be the correct check on its own terms: a scout that
|
|
571
|
+
// read zero files would render a confident verdict over nothing, which is
|
|
572
|
+
// exactly the silent-green shape the whole manifest exists to catch. The
|
|
573
|
+
// line goes to STDERR so `--json` keeps a parseable stdout; the contract
|
|
574
|
+
// accepts either stream.
|
|
575
|
+
try {
|
|
576
|
+
reportScanned(
|
|
577
|
+
{
|
|
578
|
+
gate: 'skill_scout',
|
|
579
|
+
scanned: intakeResult.files_seen,
|
|
580
|
+
units: 'candidate file(s)',
|
|
581
|
+
roots: [`${QUARANTINE_REL}/${args.candidate}`],
|
|
582
|
+
},
|
|
583
|
+
(chunk: string) => process.stderr.write(chunk),
|
|
584
|
+
);
|
|
585
|
+
} catch (exc) {
|
|
586
|
+
if (exc instanceof DeadScopeError) {
|
|
587
|
+
process.stderr.write(`❌ ${exc.message}\n`);
|
|
588
|
+
return 1;
|
|
589
|
+
}
|
|
590
|
+
throw exc;
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
if (!intakeResult.accepted) {
|
|
594
|
+
process.stderr.write(
|
|
595
|
+
`skill-scout · ${args.candidate} · intake refused:\n` +
|
|
596
|
+
intakeResult.refusals.map((r) => ` - ${r}`).join('\n') +
|
|
597
|
+
'\n',
|
|
598
|
+
);
|
|
599
|
+
return 1;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
const delta = computeDelta(dir);
|
|
603
|
+
const challenges = Array.from({ length: Math.max(0, args.challenges) }, (_, i) => ({
|
|
604
|
+
round: i + 1,
|
|
605
|
+
critical_open: i < args.criticalOpen,
|
|
606
|
+
}));
|
|
607
|
+
const gates = evaluateGates(delta, {
|
|
608
|
+
licence: args.licence,
|
|
609
|
+
benefit: args.benefit,
|
|
610
|
+
challenges,
|
|
611
|
+
intake: intakeResult,
|
|
612
|
+
});
|
|
613
|
+
const verdict = buildVerdict(args.candidate, delta, gates, intakeResult);
|
|
614
|
+
process.stdout.write(
|
|
615
|
+
(args.json ? JSON.stringify(verdict, null, 2) : render(verdict)) + '\n',
|
|
616
|
+
);
|
|
617
|
+
return 0;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function _isCliEntry(): boolean {
|
|
621
|
+
if (process.argv[1] === undefined) return false;
|
|
622
|
+
const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
|
|
623
|
+
if (import.meta.url === argvUrl) return true;
|
|
624
|
+
try {
|
|
625
|
+
return fs.realpathSync(_HERE) === fs.realpathSync(path.resolve(process.argv[1]));
|
|
626
|
+
} catch {
|
|
627
|
+
return false;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
if (_isCliEntry()) {
|
|
632
|
+
process.exit(main());
|
|
633
|
+
}
|