@event4u/agent-config 9.12.0 → 9.14.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 +110 -238
- package/README.md +1 -1
- package/dist/agent-src/commands/optimize/deep.md +147 -0
- package/dist/agent-src/commands/optimize.md +12 -6
- package/dist/agent-src/rules/active-remediation.md +0 -2
- package/dist/agent-src/rules/architecture.md +0 -1
- package/dist/agent-src/rules/artifact-drafting-protocol.md +0 -4
- package/dist/agent-src/rules/autonomous-execution.md +1 -2
- package/dist/agent-src/rules/broken-access-control.md +0 -2
- package/dist/agent-src/rules/code-comment-discipline.md +0 -3
- package/dist/agent-src/rules/code-provenance.md +0 -3
- package/dist/agent-src/rules/communication-through-line.md +2 -3
- package/dist/agent-src/rules/content-quoting-floor.md +0 -2
- package/dist/agent-src/rules/context-hygiene.md +1 -3
- package/dist/agent-src/rules/decision-revisit-gate.md +0 -2
- package/dist/agent-src/rules/design-fidelity.md +29 -0
- package/dist/agent-src/rules/domain-adoption-policy.md +0 -3
- package/dist/agent-src/rules/downstream-changes.md +10 -1
- package/dist/agent-src/rules/external-reference-deep-dive.md +0 -7
- package/dist/agent-src/rules/fast-path-marker-visibility.md +0 -1
- package/dist/agent-src/rules/git-history-discipline.md +0 -7
- package/dist/agent-src/rules/guidelines.md +0 -2
- package/dist/agent-src/rules/improve-before-implement.md +4 -2
- package/dist/agent-src/rules/invite-challenge.md +0 -4
- package/dist/agent-src/rules/linked-projects-onboarding-gate.md +0 -2
- package/dist/agent-src/rules/markdown-safe-codeblocks.md +0 -1
- package/dist/agent-src/rules/minimal-safe-diff.md +2 -2
- package/dist/agent-src/rules/missing-tool-handling.md +0 -1
- package/dist/agent-src/rules/no-attribution-footers.md +0 -3
- package/dist/agent-src/rules/no-decorative-emojis-in-git-surfaces.md +8 -5
- package/dist/agent-src/rules/no-pr-progress-comments.md +1 -3
- package/dist/agent-src/rules/no-roadmap-references.md +0 -2
- package/dist/agent-src/rules/output-discipline.md +0 -1
- package/dist/agent-src/rules/prefer-enums-over-literals.md +0 -2
- package/dist/agent-src/rules/preservation-guard.md +0 -3
- package/dist/agent-src/rules/role-mode-adherence.md +0 -1
- package/dist/agent-src/rules/secret-vcs-guard.md +0 -2
- package/dist/agent-src/rules/senior-engineering-discipline.md +0 -2
- package/dist/agent-src/rules/session-canary.md +0 -2
- package/dist/agent-src/rules/size-enforcement.md +0 -5
- package/dist/agent-src/rules/source-confidentiality.md +0 -3
- package/dist/agent-src/rules/source-discovery-gate.md +0 -2
- package/dist/agent-src/rules/think-before-action.md +3 -3
- package/dist/agent-src/rules/token-budget-discipline.md +0 -1
- package/dist/agent-src/rules/token-efficiency.md +1 -2
- package/dist/agent-src/rules/tool-safety.md +0 -1
- package/dist/agent-src/rules/user-interaction.md +0 -3
- package/dist/agent-src/rules/user-interrupt-priority.md +0 -1
- package/dist/agent-src/skills/agent-docs-writing/SKILL.md +8 -0
- package/dist/agent-src/skills/analysis-skill-router/SKILL.md +55 -86
- package/dist/agent-src/skills/character-consistency/SKILL.md +7 -4
- package/dist/agent-src/skills/decision-record/SKILL.md +8 -0
- package/dist/agent-src/skills/design-review/SKILL.md +11 -0
- package/dist/agent-src/skills/design-system-capture/reference/design-system-json.md +8 -0
- package/dist/agent-src/skills/existing-ui-audit/SKILL.md +34 -3
- package/dist/agent-src/skills/fe-design/SKILL.md +10 -0
- package/dist/agent-src/skills/motion-choreographer/SKILL.md +7 -4
- package/dist/agent-src/skills/overbuild-review-lens/SKILL.md +201 -0
- package/dist/agent-src/skills/performance/SKILL.md +14 -0
- package/dist/agent-src/skills/pixar-storyteller/SKILL.md +8 -5
- package/dist/agent-src/skills/scene-expander/SKILL.md +9 -6
- package/dist/agent-src/skills/supply-chain-intake/SKILL.md +10 -0
- package/dist/agent-src/skills/universal-project-analysis/SKILL.md +14 -37
- package/dist/agent-src/skills/video-director/SKILL.md +9 -6
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +154 -13
- package/dist/agent-src/templates/scripts/work_engine/directives/ui/design.ts +118 -0
- package/dist/agent-src/templates/scripts/work_engine/directives/ui/polish.ts +40 -0
- package/dist/agent-src/templates/scripts/work_engine/state.ts +55 -0
- package/dist/cli-delegate/{chunk-QFKQLQ7X.js → chunk-GCVFLBP2.js} +399 -365
- package/dist/cli-delegate/cmd_export.js +1 -1
- package/dist/cli-delegate/cmd_prune.js +1 -1
- package/dist/cli-delegate/cmd_settings_migrate.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +1 -1
- package/dist/cli-delegate/cmd_uninstall.js +1 -1
- package/dist/cli-delegate/cmd_validate.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +123 -73
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +4 -4
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +10 -8
- package/dist/discovery/trust-report.md +3 -3
- package/dist/discovery/workspaces.json +8 -6
- package/dist/hooks/dispatch.js +174 -13
- package/dist/install/install.mjs +648 -614
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +783 -507
- package/dist/router.json +1 -1
- package/dist/scripts/condense.js +2 -2
- package/docs/MIGRATION.md +1 -1
- package/docs/guidelines/agent-infra/agent-interaction-and-decision-quality.md +71 -0
- package/docs/guidelines/agent-infra/minimal-safe-diff-mechanics.md +24 -1
- package/docs/guidelines/code-clarity.md +9 -2
- package/docs/guidelines/design-fidelity-mechanics.md +44 -0
- package/llms.txt +63 -27
- package/package.json +4 -4
- package/src/agent-src/templates/scripts/work_engine/directives/ui/apply.ts +154 -13
- package/src/agent-src/templates/scripts/work_engine/directives/ui/design.ts +118 -0
- package/src/agent-src/templates/scripts/work_engine/directives/ui/polish.ts +40 -0
- package/src/agent-src/templates/scripts/work_engine/state.ts +55 -0
- package/src/config/agent-settings.template.yml +8 -0
- package/src/scripts/_lib/counted_probe.ts +142 -0
- package/src/scripts/_lib/exec_evidence.ts +5 -0
- package/src/scripts/_lib/overbuild_lens_contract.ts +196 -0
- package/src/scripts/_lib/release_scope.ts +207 -0
- package/src/scripts/_lib/scoped_projection.ts +212 -0
- package/src/scripts/ai_council/stance_tally.ts +37 -2
- package/src/scripts/audit_auto_rules.ts +9 -9
- package/src/scripts/audit_likelihood.ts +3 -3
- package/src/scripts/audit_overlap.ts +2 -2
- package/src/scripts/audit_skill_overlap.ts +233 -95
- package/src/scripts/audit_skill_overlap_allowlist.json +4 -0
- package/src/scripts/build_proof.ts +15 -1
- package/src/scripts/check_artefact_count_messaging.ts +128 -15
- package/src/scripts/check_dependency_floors.ts +16 -6
- package/src/scripts/check_gate_coverage.ts +6 -6
- package/src/scripts/check_kernel_rule_bundle.ts +5 -4
- package/src/scripts/check_token_quality_golden.ts +0 -18
- package/src/scripts/code_graph/loader.ts +35 -6
- package/src/scripts/compile_router.ts +3 -8
- package/src/scripts/condense.ts +2 -2
- package/src/scripts/count_scoped_projection.ts +104 -0
- package/src/scripts/explain_run.ts +0 -1
- package/src/scripts/generate_pack_manifests.ts +3 -20
- package/src/scripts/hooks/block_kernel_rule_writes.ts +145 -10
- package/src/scripts/hooks/block_no_verify.ts +101 -0
- package/src/scripts/install.ts +13 -90
- package/src/scripts/lint_agent_security.ts +5 -4
- package/src/scripts/lint_framework_leakage_allowlist.json +2 -2
- package/src/scripts/lint_regression.ts +92 -1
- package/src/scripts/lint_skill_descriptions.ts +93 -5
- package/src/scripts/project_thin_rules.ts +2 -2
- package/src/scripts/release.ts +107 -3
- package/src/scripts/resolve_lint_scope.ts +53 -0
- package/src/scripts/router_telemetry.ts +0 -5
- package/src/scripts/runtime_dispatcher.ts +40 -0
- package/src/scripts/schemas/pack.schema.json +1 -5
- package/src/scripts/schemas/rule.schema.json +1 -2
- package/src/scripts/self_review_gate.ts +15 -73
- package/src/scripts/skill_linter.ts +160 -11
- package/src/scripts/sweep_dead_scan_roots.ts +550 -0
- package/src/scripts/trigger_coverage.ts +11 -37
- package/src/scripts/update_counts.ts +90 -11
|
@@ -33,6 +33,34 @@ const _SHELL_SEPARATORS: ReadonlySet<string> = new Set(['&&', '||', ';', '|']);
|
|
|
33
33
|
const _NO_VERIFY_FLAGS: ReadonlySet<string> = new Set(['--no-verify']);
|
|
34
34
|
const _NO_VERIFY_SHORT: ReadonlySet<string> = new Set(['-n']);
|
|
35
35
|
const _HOOKS_PATH_RE = /^core\.hooksPath\s*=/i;
|
|
36
|
+
/** Bare key form, as `git config core.hooksPath <value>` spells it. */
|
|
37
|
+
const _HOOKS_PATH_KEY_RE = /^core\.hooksPath$/i;
|
|
38
|
+
/**
|
|
39
|
+
* `git config` flags that make the invocation a READ or a RESTORE rather than
|
|
40
|
+
* a write. `--unset` removes the override — the opposite of disabling hooks —
|
|
41
|
+
* so it stays allowed, or the gate would block its own remediation.
|
|
42
|
+
*/
|
|
43
|
+
/** Global `git` options that consume the following token as their value. */
|
|
44
|
+
const _GIT_GLOBAL_OPTS_WITH_VALUE: ReadonlySet<string> = new Set([
|
|
45
|
+
'-C',
|
|
46
|
+
'-c',
|
|
47
|
+
'--config',
|
|
48
|
+
'--git-dir',
|
|
49
|
+
'--work-tree',
|
|
50
|
+
'--namespace',
|
|
51
|
+
'--exec-path',
|
|
52
|
+
'--super-prefix',
|
|
53
|
+
]);
|
|
54
|
+
const _CONFIG_READ_OR_RESTORE: ReadonlySet<string> = new Set([
|
|
55
|
+
'--get',
|
|
56
|
+
'--get-all',
|
|
57
|
+
'--get-regexp',
|
|
58
|
+
'--get-urlmatch',
|
|
59
|
+
'--list',
|
|
60
|
+
'-l',
|
|
61
|
+
'--unset',
|
|
62
|
+
'--unset-all',
|
|
63
|
+
]);
|
|
36
64
|
|
|
37
65
|
type JsonValue = string | number | boolean | null | JsonValue[] | { [k: string]: JsonValue };
|
|
38
66
|
type JsonObject = { [k: string]: JsonValue };
|
|
@@ -178,7 +206,80 @@ function _git_base(tokens: string[]): string[] | null {
|
|
|
178
206
|
return null;
|
|
179
207
|
}
|
|
180
208
|
|
|
209
|
+
/**
|
|
210
|
+
* `git config … core.hooksPath <value>` — the subcommand form of the hooksPath
|
|
211
|
+
* override, distinct from the `-c` / `--config` global-option form handled in
|
|
212
|
+
* `_is_blocked`.
|
|
213
|
+
*
|
|
214
|
+
* Closed by road-to-governance-invariants Phase 1 after the S0.2 spike measured
|
|
215
|
+
* it: `git-history-discipline` already claimed `core.hooksPath` overrides were
|
|
216
|
+
* "deterministically blocked", and only the inline `-c` form was. That made
|
|
217
|
+
* `git config core.hooksPath /dev/null` → `git commit` a two-step sequence
|
|
218
|
+
* whose every step this gate allowed and whose composition is exactly the
|
|
219
|
+
* outcome the gate exists to prevent.
|
|
220
|
+
*
|
|
221
|
+
* Deliberately narrow, per the 2026-08-02 council cut (option ii — widen only
|
|
222
|
+
* the exact shapes with no plausible legitimate use):
|
|
223
|
+
* - a READ (`--get`, `--list`, or the key with no value) is allowed;
|
|
224
|
+
* - `--unset` is allowed — it restores hooks, it does not disable them;
|
|
225
|
+
* - non-git ways of disabling a hook (`mv`/`chmod`/`rm` on `.git/hooks/*`)
|
|
226
|
+
* are NOT matched here. Recognising them would turn this guard into a
|
|
227
|
+
* shell sandbox and those verbs have ordinary legitimate uses. That gap
|
|
228
|
+
* is published rather than papered over — see the S0.2 spike.
|
|
229
|
+
*/
|
|
230
|
+
function _is_config_hookspath_write(git_tokens: string[]): [boolean, string] {
|
|
231
|
+
const rest = git_tokens.slice(1);
|
|
232
|
+
// The subcommand is the first token that is neither a global option nor
|
|
233
|
+
// the VALUE of one — `git -C /repo config …` must still resolve to
|
|
234
|
+
// `config`, not to `/repo`.
|
|
235
|
+
let subIdx = -1;
|
|
236
|
+
for (let k = 0; k < rest.length; k += 1) {
|
|
237
|
+
const tok = rest[k] as string;
|
|
238
|
+
if (!tok.startsWith('-')) {
|
|
239
|
+
subIdx = k;
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
if (_GIT_GLOBAL_OPTS_WITH_VALUE.has(tok)) {
|
|
243
|
+
k += 1; // consume its value
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (subIdx === -1 || rest[subIdx] !== 'config') {
|
|
247
|
+
return [false, ''];
|
|
248
|
+
}
|
|
249
|
+
const args = rest.slice(subIdx + 1);
|
|
250
|
+
if (args.some((t) => _CONFIG_READ_OR_RESTORE.has(t))) {
|
|
251
|
+
return [false, ''];
|
|
252
|
+
}
|
|
253
|
+
for (let k = 0; k < args.length; k += 1) {
|
|
254
|
+
const tok = args[k] as string;
|
|
255
|
+
// `core.hooksPath=<value>` — one token, always a write.
|
|
256
|
+
if (_HOOKS_PATH_RE.test(tok)) {
|
|
257
|
+
return [
|
|
258
|
+
true,
|
|
259
|
+
`'git config ${tok}' disables git hooks via hooksPath (git-history-discipline)`,
|
|
260
|
+
];
|
|
261
|
+
}
|
|
262
|
+
if (!_HOOKS_PATH_KEY_RE.test(tok)) {
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
// Bare key: a write only when a value follows.
|
|
266
|
+
const next = args.slice(k + 1).find((t) => !t.startsWith('-'));
|
|
267
|
+
if (next !== undefined) {
|
|
268
|
+
return [
|
|
269
|
+
true,
|
|
270
|
+
`'git config ${tok} ${next}' disables git hooks via hooksPath (git-history-discipline)`,
|
|
271
|
+
];
|
|
272
|
+
}
|
|
273
|
+
return [false, ''];
|
|
274
|
+
}
|
|
275
|
+
return [false, ''];
|
|
276
|
+
}
|
|
277
|
+
|
|
181
278
|
function _is_blocked(git_tokens: string[]): [boolean, string] {
|
|
279
|
+
const [cfgBlocked, cfgReason] = _is_config_hookspath_write(git_tokens);
|
|
280
|
+
if (cfgBlocked) {
|
|
281
|
+
return [cfgBlocked, cfgReason];
|
|
282
|
+
}
|
|
182
283
|
let i = 1; // skip 'git'
|
|
183
284
|
while (i < git_tokens.length) {
|
|
184
285
|
const tok = git_tokens[i] as string;
|
package/src/scripts/install.ts
CHANGED
|
@@ -74,6 +74,7 @@ import type * as YamlModule from 'yaml';
|
|
|
74
74
|
import { build_merge_entries } from './_lib/json_pointers.js';
|
|
75
75
|
import { is_claude_builtin_name } from './_lib/claude_builtin_names.js';
|
|
76
76
|
import * as installed_lock from './_lib/installed_lock.js';
|
|
77
|
+
import * as scoped_projection from './_lib/scoped_projection.js';
|
|
77
78
|
import * as surface_tiers from './_lib/surface_tiers.js';
|
|
78
79
|
import * as global_deploy_inventory from './_lib/global_deploy_inventory.js';
|
|
79
80
|
import * as installed_tools from './_lib/installed_tools.js';
|
|
@@ -3344,89 +3345,16 @@ function _prune_lab_modules(
|
|
|
3344
3345
|
// `requires` graph). Mirrors the core-only lab prune above — same
|
|
3345
3346
|
// `_prune_modules_by` mechanics, a different predicate.
|
|
3346
3347
|
|
|
3347
|
-
|
|
3348
|
-
|
|
3349
|
-
|
|
3350
|
-
|
|
3351
|
-
|
|
3352
|
-
|
|
3353
|
-
requires: string[];
|
|
3354
|
-
}
|
|
3355
|
-
|
|
3356
|
-
/**
|
|
3357
|
-
* Parse `src/config/discovery/packs.yml` into `{id, workspaces, requires}`
|
|
3358
|
-
* records. `requires` falls back to the legacy `requires_hint` field name
|
|
3359
|
-
* when `requires` is absent.
|
|
3360
|
-
*
|
|
3361
|
-
* Deliberately THROWS on a missing file or a parse result that is not a
|
|
3362
|
-
* list — an empty active-pack set would be the LEAST safe fallback here
|
|
3363
|
-
* (it would prune every tagged artefact, the opposite of the fail-safe
|
|
3364
|
-
* contract). The caller wraps this in a try/catch that restores the full
|
|
3365
|
-
* tree on any error instead.
|
|
3366
|
-
*/
|
|
3367
|
-
function _load_packs_registry(package_root: string): PackRecord[] {
|
|
3368
|
-
const vocab_path = path.join(package_root, 'src', 'config', 'discovery', 'packs.yml');
|
|
3369
|
-
const data = yamlSafeLoad(readText(vocab_path));
|
|
3370
|
-
if (!Array.isArray(data)) {
|
|
3371
|
-
throw new Error(`packs.yml did not parse to a list: ${vocab_path}`);
|
|
3372
|
-
}
|
|
3373
|
-
const out: PackRecord[] = [];
|
|
3374
|
-
for (const entry of data) {
|
|
3375
|
-
if (!_isPlainObject(entry)) continue;
|
|
3376
|
-
const id = entry['id'];
|
|
3377
|
-
if (typeof id !== 'string' || id === '') continue;
|
|
3378
|
-
const workspaces_raw = entry['workspaces'];
|
|
3379
|
-
const workspaces = Array.isArray(workspaces_raw)
|
|
3380
|
-
? workspaces_raw.filter((w): w is string => typeof w === 'string')
|
|
3381
|
-
: [];
|
|
3382
|
-
const requires_raw = entry['requires'] ?? entry['requires_hint'];
|
|
3383
|
-
const requires = Array.isArray(requires_raw)
|
|
3384
|
-
? requires_raw.filter((r): r is string => typeof r === 'string')
|
|
3385
|
-
: [];
|
|
3386
|
-
out.push({ id, workspaces, requires });
|
|
3387
|
-
}
|
|
3388
|
-
return out;
|
|
3389
|
-
}
|
|
3390
|
-
|
|
3391
|
-
/**
|
|
3392
|
-
* Active pack id set for `scoped` mode: every pack whose `workspaces`
|
|
3393
|
-
* intersects `SCOPED_ACTIVE_WORKSPACES`, unioned with `runtime_active_packs`,
|
|
3394
|
-
* expanded over the `requires` graph (transitive closure).
|
|
3348
|
+
/*
|
|
3349
|
+
* The scoped-projection predicate lives in `_lib/scoped_projection.ts` so the
|
|
3350
|
+
* installer and `count_scoped_projection.ts` — which produces the number the
|
|
3351
|
+
* claims ledger publishes — run the SAME code rather than the same rule
|
|
3352
|
+
* written twice. Re-exported under the existing private names to keep the
|
|
3353
|
+
* prune tests' import surface unchanged.
|
|
3395
3354
|
*/
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
): Set<string> {
|
|
3400
|
-
const by_id = new Map<string, PackRecord>();
|
|
3401
|
-
for (const p of packs) by_id.set(p.id, p);
|
|
3402
|
-
|
|
3403
|
-
const active = new Set<string>();
|
|
3404
|
-
for (const p of packs) {
|
|
3405
|
-
if (p.workspaces.some((w) => SCOPED_ACTIVE_WORKSPACES.has(w))) {
|
|
3406
|
-
active.add(p.id);
|
|
3407
|
-
}
|
|
3408
|
-
}
|
|
3409
|
-
for (const id of runtime_active_packs) {
|
|
3410
|
-
active.add(id);
|
|
3411
|
-
}
|
|
3412
|
-
|
|
3413
|
-
let frontier = [...active];
|
|
3414
|
-
while (frontier.length > 0) {
|
|
3415
|
-
const next: string[] = [];
|
|
3416
|
-
for (const id of frontier) {
|
|
3417
|
-
const rec = by_id.get(id);
|
|
3418
|
-
if (rec === undefined) continue;
|
|
3419
|
-
for (const dep of rec.requires) {
|
|
3420
|
-
if (!active.has(dep)) {
|
|
3421
|
-
active.add(dep);
|
|
3422
|
-
next.push(dep);
|
|
3423
|
-
}
|
|
3424
|
-
}
|
|
3425
|
-
}
|
|
3426
|
-
frontier = next;
|
|
3427
|
-
}
|
|
3428
|
-
return active;
|
|
3429
|
-
}
|
|
3355
|
+
type PackRecord = scoped_projection.PackRecord;
|
|
3356
|
+
const _load_packs_registry = scoped_projection.load_packs_registry;
|
|
3357
|
+
const _compute_active_pack_ids = scoped_projection.compute_active_pack_ids;
|
|
3430
3358
|
|
|
3431
3359
|
/**
|
|
3432
3360
|
* Read the GLOBAL settings document that governs a global deploy, if one
|
|
@@ -3574,14 +3502,9 @@ function _prune_scoped_modules(
|
|
|
3574
3502
|
deploy_results: Record<string, DeployResult>,
|
|
3575
3503
|
active_ids: ReadonlySet<string>,
|
|
3576
3504
|
): [number, Record<string, DeployResult>] {
|
|
3577
|
-
return _prune_modules_by(deploy_results, (p) =>
|
|
3578
|
-
|
|
3579
|
-
|
|
3580
|
-
for (const id of packs) {
|
|
3581
|
-
if (active_ids.has(id)) return false;
|
|
3582
|
-
}
|
|
3583
|
-
return true;
|
|
3584
|
-
});
|
|
3505
|
+
return _prune_modules_by(deploy_results, (p) =>
|
|
3506
|
+
scoped_projection.is_pruned_under_scoped(p, active_ids),
|
|
3507
|
+
);
|
|
3585
3508
|
}
|
|
3586
3509
|
|
|
3587
3510
|
function install_global(
|
|
@@ -23,10 +23,10 @@
|
|
|
23
23
|
* Usage:
|
|
24
24
|
* ./scripts-run src/scripts/lint_agent_security [--sarif artifacts/agent-security.sarif]
|
|
25
25
|
*/
|
|
26
|
-
import { spawnSync } from "node:child_process";
|
|
27
26
|
import fs from "node:fs";
|
|
28
27
|
import path from "node:path";
|
|
29
28
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
29
|
+
import { runCountedProbe } from "./_lib/counted_probe.js";
|
|
30
30
|
|
|
31
31
|
const _HERE = path.dirname(fileURLToPath(import.meta.url));
|
|
32
32
|
|
|
@@ -54,9 +54,10 @@ function _run(script: string): [number, Finding[]] {
|
|
|
54
54
|
// (capture_output=True, text=True equivalent). Mirrors the retired Python implementation's
|
|
55
55
|
// `subprocess.run([sys.executable, HERE/<child>.py, "--json"])`, now that the
|
|
56
56
|
// children are TypeScript and the `.py` originals are deleted.
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
57
|
+
// Bounded read: a truncated `--json` payload would fail to parse and be
|
|
58
|
+
// swallowed as zero findings — a security aggregate reporting clean because
|
|
59
|
+
// it lost the answer. `runCountedProbe` throws on overflow instead.
|
|
60
|
+
const proc = runCountedProbe(tsxBin, [path.join(_HERE, script), "--json"]);
|
|
60
61
|
let findings: Finding[];
|
|
61
62
|
try {
|
|
62
63
|
const parsed: unknown = JSON.parse((proc.stdout ?? "") || "[]");
|
|
@@ -93,8 +93,8 @@
|
|
|
93
93
|
{
|
|
94
94
|
"file": "src/skills/supply-chain-intake/SKILL.md",
|
|
95
95
|
"lines": [
|
|
96
|
-
|
|
97
|
-
|
|
96
|
+
80,
|
|
97
|
+
82
|
|
98
98
|
],
|
|
99
99
|
"reason": "Per-ecosystem check commands in fenced blocks; the pip/composer siblings sit farther apart than the ±10-line window — genuine multi-ecosystem documentation."
|
|
100
100
|
},
|
|
@@ -64,6 +64,65 @@ const STATUS_ORDER: Record<string, number> = { pass: 0, pass_with_warnings: 1, f
|
|
|
64
64
|
* baseline worktree-add failure (→ exit 2 in main). */
|
|
65
65
|
class CalledProcessError extends Error {}
|
|
66
66
|
|
|
67
|
+
/** The baseline ran but produced nothing usable (→ exit 2 in main).
|
|
68
|
+
*
|
|
69
|
+
* Distinct from the two DOCUMENTED empty-baseline degradations (no linter file
|
|
70
|
+
* in the ref; a pre-migration `.py` baseline with no python3). Those are
|
|
71
|
+
* deliberate. This one is a broken run, and returning an empty baseline for it
|
|
72
|
+
* silently reclassifies every existing finding as a "new file" — the gate then
|
|
73
|
+
* fires on an unmodified tree and can never report a real regression, because a
|
|
74
|
+
* regression needs the file present in both maps. */
|
|
75
|
+
class BaselineCollectionError extends Error {}
|
|
76
|
+
|
|
77
|
+
/** Give the detached baseline worktree a resolvable module context.
|
|
78
|
+
*
|
|
79
|
+
* `git worktree add` checks out tracked files only, so the temp tree has no
|
|
80
|
+
* `node_modules` and the linter dies on its first bare import. Symlinking the
|
|
81
|
+
* repo's own install is enough — the baseline runs the ref's linter source
|
|
82
|
+
* against the ref's artefacts; only the dependency resolution is borrowed. */
|
|
83
|
+
function _link_node_modules(tmpdir: string, repo_root: string): void {
|
|
84
|
+
const target = path.join(repo_root, 'node_modules');
|
|
85
|
+
const link = path.join(tmpdir, 'node_modules');
|
|
86
|
+
if (fs.existsSync(link) || !fs.existsSync(target)) {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
fs.symlinkSync(target, link, 'dir');
|
|
91
|
+
} catch {
|
|
92
|
+
// Non-fatal here: a failed link surfaces as a failed baseline run
|
|
93
|
+
// below, which raises with the real cause rather than degrading.
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Re-root the baseline's file keys onto the repo-relative paths the working-tree
|
|
98
|
+
* run emits.
|
|
99
|
+
*
|
|
100
|
+
* The baseline runs with `--repo-root <tmpdir>` and reports absolute paths under
|
|
101
|
+
* that temp worktree; the working-tree run reports paths relative to the repo.
|
|
102
|
+
* Left as-is the two maps share no key, so every comparison is vacuous — the
|
|
103
|
+
* disjoint guard would reject the run outright once the baseline is non-empty. */
|
|
104
|
+
function _relativise(data: LinterJson, tmpdir: string): LinterJson {
|
|
105
|
+
const roots = new Set<string>([tmpdir]);
|
|
106
|
+
try {
|
|
107
|
+
roots.add(fs.realpathSync(tmpdir));
|
|
108
|
+
} catch {
|
|
109
|
+
/* the temp dir is about to be removed; the literal prefix still applies */
|
|
110
|
+
}
|
|
111
|
+
const strip = (f: string): string => {
|
|
112
|
+
for (const r of roots) {
|
|
113
|
+
const prefix = r.endsWith(path.sep) ? r : r + path.sep;
|
|
114
|
+
if (f.startsWith(prefix)) {
|
|
115
|
+
return f.slice(prefix.length);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return f;
|
|
119
|
+
};
|
|
120
|
+
return {
|
|
121
|
+
...data,
|
|
122
|
+
results: (data.results ?? []).map((e) => ({ ...e, file: strip(e.file) })),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
67
126
|
/** Run the linter and return parsed JSON. If ref is null, run on working tree.
|
|
68
127
|
*
|
|
69
128
|
* Mirrors `run_linter_json`: for a ref, add a detached temp worktree, run the
|
|
@@ -91,6 +150,7 @@ function run_linter_json(ref: string | null, repo_root: string): LinterJson {
|
|
|
91
150
|
);
|
|
92
151
|
}
|
|
93
152
|
try {
|
|
153
|
+
_link_node_modules(tmpdir, repo_root);
|
|
94
154
|
// The baseline ref may be pre-migration (`.py`, run via python3) or
|
|
95
155
|
// post-migration (`.ts`, run via tsx). Probe both extensions in both
|
|
96
156
|
// locations and spawn by extension. A historical `.py` ref genuinely
|
|
@@ -139,7 +199,17 @@ function run_linter_json(ref: string | null, repo_root: string): LinterJson {
|
|
|
139
199
|
encoding: 'utf8',
|
|
140
200
|
});
|
|
141
201
|
const out = result.stdout ?? '';
|
|
142
|
-
|
|
202
|
+
if (!out.trim()) {
|
|
203
|
+
const why =
|
|
204
|
+
result.status === 0
|
|
205
|
+
? 'it exited 0 but printed nothing'
|
|
206
|
+
: `it exited ${result.status ?? 'null'}`;
|
|
207
|
+
throw new BaselineCollectionError(
|
|
208
|
+
`the baseline linter for '${ref}' produced no output — ${why}.\n` +
|
|
209
|
+
`stderr: ${(result.stderr ?? '').trim().split('\n').slice(-6).join('\n')}`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
return _relativise(JSON.parse(out) as LinterJson, tmpdir);
|
|
143
213
|
} finally {
|
|
144
214
|
spawnSync('git', ['-C', repo_root, 'worktree', 'remove', '--force', tmpdir], {
|
|
145
215
|
encoding: 'utf8',
|
|
@@ -433,6 +503,10 @@ function main(): number {
|
|
|
433
503
|
);
|
|
434
504
|
return 2;
|
|
435
505
|
}
|
|
506
|
+
if (e instanceof BaselineCollectionError) {
|
|
507
|
+
process.stderr.write(`Error: ${e.message}\n`);
|
|
508
|
+
return 2;
|
|
509
|
+
}
|
|
436
510
|
throw e;
|
|
437
511
|
}
|
|
438
512
|
|
|
@@ -454,6 +528,22 @@ function main(): number {
|
|
|
454
528
|
return 2;
|
|
455
529
|
}
|
|
456
530
|
|
|
531
|
+
// The same guard's blind spot: an EMPTY baseline against a populated
|
|
532
|
+
// current tree is not comparable either, and it is the worse failure —
|
|
533
|
+
// every existing finding is then reported as a "new file", so the gate
|
|
534
|
+
// fires on an unmodified tree and no real regression can ever surface.
|
|
535
|
+
// The documented empty-baseline degradations (no linter in the ref; a
|
|
536
|
+
// pre-migration `.py` baseline with no python3) print their own warning
|
|
537
|
+
// above and land here on purpose.
|
|
538
|
+
if (!baseKeys.length && currKeys.length) {
|
|
539
|
+
process.stderr.write(
|
|
540
|
+
`Error: the baseline '${args.baseline}' produced no lint results while the ` +
|
|
541
|
+
`current tree produced ${currKeys.length} — runs are not comparable; ` +
|
|
542
|
+
'refusing to report. Every finding would be misreported as new.\n',
|
|
543
|
+
);
|
|
544
|
+
return 2;
|
|
545
|
+
}
|
|
546
|
+
|
|
457
547
|
const delta = compare(baseline_map, current_map);
|
|
458
548
|
|
|
459
549
|
if (args.format === 'json') {
|
|
@@ -528,4 +618,5 @@ export {
|
|
|
528
618
|
format_text,
|
|
529
619
|
format_markdown,
|
|
530
620
|
main,
|
|
621
|
+
_relativise,
|
|
531
622
|
};
|
|
@@ -18,11 +18,32 @@
|
|
|
18
18
|
* (em-dash / colon / comma list), no quoted example,
|
|
19
19
|
* no imperative-verb lead. A bare topic restatement.
|
|
20
20
|
*
|
|
21
|
-
* (d)
|
|
21
|
+
* (a)–(d) are signal-ABSENCE checks over every skill: an imperative house-style
|
|
22
22
|
* description ("Writes Laravel PHP — Eloquent, Artisan …") carries routing
|
|
23
23
|
* signal and passes. The farm-generator's name-echo frontmatter is the
|
|
24
24
|
* must-fail specimen.
|
|
25
25
|
*
|
|
26
|
+
* Two further checks are POSITIVE — they demand something be present — and are
|
|
27
|
+
* therefore scoped to CLUSTERED skills only (road-to-overlap-truth-and-skill-cut
|
|
28
|
+
* Phase 5):
|
|
29
|
+
*
|
|
30
|
+
* (e) clustered-no-sibling-routing — the canonical overlap tool pairs this
|
|
31
|
+
* skill with a same-pack sibling at >= the canonical
|
|
32
|
+
* threshold, and the description never names that
|
|
33
|
+
* sibling. Two skills the router cannot tell apart,
|
|
34
|
+
* neither of which says which one loses.
|
|
35
|
+
* (f) clustered-no-quoted-phrase — same cluster condition, and the description
|
|
36
|
+
* carries no quoted literal user phrase. In a cluster,
|
|
37
|
+
* the user's own words are the only tie-break the
|
|
38
|
+
* router gets.
|
|
39
|
+
*
|
|
40
|
+
* The cluster condition is what keeps these honest. A quoted-phrase mandate
|
|
41
|
+
* across all ~287 skills is noise, not routing — it would fire on skills with no
|
|
42
|
+
* neighbour to be confused with. Today the cluster set is EMPTY (the corpus has
|
|
43
|
+
* no same-pack pair at or above threshold), so (e) and (f) are dormant by
|
|
44
|
+
* construction and wake up exactly when a new skill lands close to an existing
|
|
45
|
+
* one. That is the point.
|
|
46
|
+
*
|
|
26
47
|
* Deterministic, static-config only. Allowlist mirrors lint_skill_originality:
|
|
27
48
|
* `lint_skill_descriptions_allowlist.json`, capped at 20 (autonomous-execution
|
|
28
49
|
* allowlist-growth antipattern — over-cap means the linter is wrong).
|
|
@@ -39,6 +60,7 @@ import * as path from 'node:path';
|
|
|
39
60
|
import process from 'node:process';
|
|
40
61
|
import { fileURLToPath } from 'node:url';
|
|
41
62
|
|
|
63
|
+
import { OVERLAP_THRESHOLD, _cosine, collect } from './audit_skill_overlap.js';
|
|
42
64
|
import { parse_frontmatter } from './skill_overlap.js';
|
|
43
65
|
|
|
44
66
|
const _HERE = fileURLToPath(import.meta.url);
|
|
@@ -57,10 +79,54 @@ const IMPERATIVE_LEAD_RE =
|
|
|
57
79
|
|
|
58
80
|
interface Violation {
|
|
59
81
|
slug: string;
|
|
60
|
-
code:
|
|
82
|
+
code:
|
|
83
|
+
| 'desc-equals-name'
|
|
84
|
+
| 'duplicated-trigger'
|
|
85
|
+
| 'triggers-are-name'
|
|
86
|
+
| 'no-routing-signal'
|
|
87
|
+
| 'clustered-no-sibling-routing'
|
|
88
|
+
| 'clustered-no-quoted-phrase';
|
|
61
89
|
detail: string;
|
|
62
90
|
}
|
|
63
91
|
|
|
92
|
+
/** A quoted literal phrase — `'expand this scene'` / `"film-grade scene"`. */
|
|
93
|
+
const QUOTED_PHRASE_RE = /['"`][^'"`]{3,}['"`]/;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Same-pack neighbours at or above the canonical overlap threshold, keyed by
|
|
97
|
+
* directory slug.
|
|
98
|
+
*
|
|
99
|
+
* Uses the canonical instrument, not a second implementation — one metric, one
|
|
100
|
+
* threshold, one answer. Cross-pack pairs are excluded for the same reason a
|
|
101
|
+
* cross-pack merge is out of scope: they do not compete for the same install.
|
|
102
|
+
*/
|
|
103
|
+
function computeClusters(root: string, threshold: number = OVERLAP_THRESHOLD): Map<string, string[]> {
|
|
104
|
+
const clusters = new Map<string, string[]>();
|
|
105
|
+
const skills = collect(root).map((s) => ({
|
|
106
|
+
slug: path.basename(path.dirname(s.relpath)),
|
|
107
|
+
packs: s.packs,
|
|
108
|
+
vector: s.vector,
|
|
109
|
+
}));
|
|
110
|
+
for (let i = 0; i < skills.length; i += 1) {
|
|
111
|
+
for (let j = i + 1; j < skills.length; j += 1) {
|
|
112
|
+
const a = skills[i]!;
|
|
113
|
+
const b = skills[j]!;
|
|
114
|
+
let sharesPack = false;
|
|
115
|
+
for (const p of a.packs) {
|
|
116
|
+
if (b.packs.has(p)) {
|
|
117
|
+
sharesPack = true;
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (!sharesPack) continue;
|
|
122
|
+
if (_cosine(a.vector, b.vector) < threshold) continue;
|
|
123
|
+
clusters.set(a.slug, [...(clusters.get(a.slug) ?? []), b.slug]);
|
|
124
|
+
clusters.set(b.slug, [...(clusters.get(b.slug) ?? []), a.slug]);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return clusters;
|
|
128
|
+
}
|
|
129
|
+
|
|
64
130
|
/** A description carries routing signal if it names a condition, leads with an
|
|
65
131
|
* imperative verb, enumerates specifics (em-dash / colon / ≥2 comma segments),
|
|
66
132
|
* or embeds an example query (a quoted phrase). Absence of all four is the defect. */
|
|
@@ -89,7 +155,7 @@ function triggerPhrases(desc: string): string[] {
|
|
|
89
155
|
.filter((p) => p.length >= 3);
|
|
90
156
|
}
|
|
91
157
|
|
|
92
|
-
function analyseSkill(slug: string, fm: Record<string, string
|
|
158
|
+
function analyseSkill(slug: string, fm: Record<string, string>, siblings: readonly string[] = []): Violation[] {
|
|
93
159
|
const out: Violation[] = [];
|
|
94
160
|
const name = fm['name'] ?? slug;
|
|
95
161
|
const desc = (fm['description'] ?? '').trim();
|
|
@@ -137,6 +203,25 @@ function analyseSkill(slug: string, fm: Record<string, string>): Violation[] {
|
|
|
137
203
|
});
|
|
138
204
|
}
|
|
139
205
|
|
|
206
|
+
// (e) + (f) fire ONLY for skills the canonical tool put in a cluster.
|
|
207
|
+
if (siblings.length > 0) {
|
|
208
|
+
const unrouted = siblings.filter((s) => !desc.includes(s));
|
|
209
|
+
if (unrouted.length > 0) {
|
|
210
|
+
out.push({
|
|
211
|
+
slug,
|
|
212
|
+
code: 'clustered-no-sibling-routing',
|
|
213
|
+
detail: `overlaps ${unrouted.map((s) => `\`${s}\``).join(', ')} at or above the canonical threshold, but the description never names ${unrouted.length === 1 ? 'it' : 'them'} — add a sibling-routing clause so the router knows which one loses`,
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
if (!QUOTED_PHRASE_RE.test(desc)) {
|
|
217
|
+
out.push({
|
|
218
|
+
slug,
|
|
219
|
+
code: 'clustered-no-quoted-phrase',
|
|
220
|
+
detail: `clustered with ${siblings.map((s) => `\`${s}\``).join(', ')} but carries no quoted literal user phrase — in a cluster the user's own words are the tie-break`,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
140
225
|
return out;
|
|
141
226
|
}
|
|
142
227
|
|
|
@@ -192,13 +277,14 @@ function parseArgs(argv: string[]): Args {
|
|
|
192
277
|
function main(argv: string[]): number {
|
|
193
278
|
const args = parseArgs(argv);
|
|
194
279
|
const allow = loadAllowlist();
|
|
280
|
+
const clusters = computeClusters(args.root);
|
|
195
281
|
const violations: Violation[] = [];
|
|
196
282
|
let scanned = 0;
|
|
197
283
|
for (const md of skillMds(args.root)) {
|
|
198
284
|
scanned += 1;
|
|
199
285
|
const slug = path.basename(path.dirname(md));
|
|
200
286
|
const [fm] = parse_frontmatter(fs.readFileSync(md, 'utf-8'));
|
|
201
|
-
for (const v of analyseSkill(slug, fm)) {
|
|
287
|
+
for (const v of analyseSkill(slug, fm, clusters.get(slug) ?? [])) {
|
|
202
288
|
if (!allow.has(`${v.slug}::${v.code}`)) violations.push(v);
|
|
203
289
|
}
|
|
204
290
|
}
|
|
@@ -216,7 +302,9 @@ function main(argv: string[]): number {
|
|
|
216
302
|
return 1;
|
|
217
303
|
}
|
|
218
304
|
if (!args.quiet) {
|
|
219
|
-
process.stdout.write(
|
|
305
|
+
process.stdout.write(
|
|
306
|
+
`✅ lint_skill_descriptions: ${scanned} skill(s) scanned (${clusters.size} clustered), no description defects.\n`,
|
|
307
|
+
);
|
|
220
308
|
}
|
|
221
309
|
return 0;
|
|
222
310
|
}
|
|
@@ -155,8 +155,8 @@ const _TRIGGER_HINT_LIMIT = 6;
|
|
|
155
155
|
/** A short, comma-joined sample of the rule's trigger keywords/phrases. */
|
|
156
156
|
function _trigger_hint(fm: string): string {
|
|
157
157
|
const hits: string[] = [];
|
|
158
|
-
// re.finditer(r'^\s*-\s*(?:keyword|phrase
|
|
159
|
-
const re = /^\s*-\s*(?:keyword|phrase
|
|
158
|
+
// re.finditer(r'^\s*-\s*(?:keyword|phrase):\s*"?(.+?)"?\s*$', fm, re.MULTILINE)
|
|
159
|
+
const re = /^\s*-\s*(?:keyword|phrase):\s*"?(.+?)"?\s*$/gm;
|
|
160
160
|
let m: RegExpExecArray | null;
|
|
161
161
|
while ((m = re.exec(fm)) !== null) {
|
|
162
162
|
hits.push((m[1] as string).trim());
|