@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.
Files changed (66) hide show
  1. package/.augment-plugin/marketplace.json +3 -3
  2. package/.augment-plugin/plugin.json +1 -1
  3. package/.claude-plugin/marketplace.json +1 -1
  4. package/CHANGELOG.md +33 -0
  5. package/README.md +1 -1
  6. package/dist/agent-src/commands/pr/create.md +16 -1
  7. package/dist/agent-src/contexts/execution/terminal-states.md +25 -4
  8. package/dist/agent-src/rules/missing-skill-recovery.md +2 -2
  9. package/dist/agent-src/rules/token-budget-discipline.md +2 -2
  10. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  11. package/dist/cli/registry.js +1 -0
  12. package/dist/cli-delegate/{chunk-ILMUPLGW.js → chunk-7RXIKF62.js} +159 -86
  13. package/dist/cli-delegate/cmd_conformance.js +1 -1
  14. package/dist/cli-delegate/cmd_doctor.js +1 -1
  15. package/dist/discovery/deprecation-report.md +1 -1
  16. package/dist/discovery/discovery-manifest.json +5 -5
  17. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  18. package/dist/discovery/discovery-manifest.summary.md +1 -1
  19. package/dist/discovery/dormancy-report.md +1 -1
  20. package/dist/discovery/orphan-report.md +1 -1
  21. package/dist/discovery/packs.json +2 -2
  22. package/dist/discovery/trust-report.md +1 -1
  23. package/dist/discovery/workspaces.json +2 -2
  24. package/dist/hooks/dispatch.js +528 -371
  25. package/dist/mcp/registry-manifest.json +2 -2
  26. package/dist/mcp/server.json +2 -2
  27. package/dist/mcp/server.mjs +185 -98
  28. package/package.json +1 -1
  29. package/src/config/assurance-capability-registry.json +1 -1
  30. package/src/config/evaluator-budgets.json +4 -4
  31. package/src/config/gate-coverage.yml +62 -2
  32. package/src/config/gate-reachability-exemptions.json +1 -0
  33. package/src/config/gate-violation-baselines.json +4 -3
  34. package/src/config/web-launch-readiness.json +4 -2
  35. package/src/scripts/_cli/_doctor_emit.ts +71 -0
  36. package/src/scripts/_cli/_doctor_strict.ts +163 -0
  37. package/src/scripts/_cli/cmd_doctor.ts +16 -37
  38. package/src/scripts/_dispatch.bash +11 -0
  39. package/src/scripts/_lib/branch_convergence.ts +186 -0
  40. package/src/scripts/_lib/context_observation.ts +132 -0
  41. package/src/scripts/_lib/continuation_ladder.ts +188 -0
  42. package/src/scripts/_lib/experiment_binding.ts +95 -0
  43. package/src/scripts/_lib/ignored_blocker.ts +8 -1
  44. package/src/scripts/_lib/outcome_envelope.ts +6 -1
  45. package/src/scripts/_lib/outcome_vocabularies.ts +135 -5
  46. package/src/scripts/_lib/release_highlights.ts +12 -3
  47. package/src/scripts/_lib/repeated_failure.ts +19 -2
  48. package/src/scripts/_lib/runtime_journal.ts +11 -4
  49. package/src/scripts/check_agent_artifact_location.ts +337 -0
  50. package/src/scripts/check_artefact_count_messaging.ts +74 -8
  51. package/src/scripts/check_enforcement_coverage.ts +40 -1
  52. package/src/scripts/check_release_highlights.ts +17 -1
  53. package/src/scripts/check_web_launch_readiness.ts +310 -20
  54. package/src/scripts/evolution_lab.ts +53 -1
  55. package/src/scripts/generate_capability_matrix.ts +16 -9
  56. package/src/scripts/grade_target_readiness.ts +221 -2
  57. package/src/scripts/hook_effect_doctor.ts +47 -4
  58. package/src/scripts/hooks/run_continuation_hook.ts +78 -88
  59. package/src/scripts/hooks/session_eol_hook.ts +12 -1
  60. package/src/scripts/install-hooks.sh +18 -0
  61. package/src/scripts/lint_code_comments.ts +106 -1
  62. package/src/scripts/release_drill.ts +57 -1
  63. package/src/scripts/release_publication.ts +58 -0
  64. package/src/scripts/roadmap_context.ts +15 -0
  65. package/src/scripts/skill_scout.ts +633 -0
  66. 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
+ }