@event4u/agent-config 9.32.0 → 9.34.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 +85 -72
- package/dist/agent-src/commands/agent-handoff.md +15 -3
- package/dist/agent-src/commands/review/routing.md +3 -4
- package/dist/agent-src/contexts/communication/rules-auto/reply-close-mechanics.md +65 -0
- package/dist/agent-src/contexts/execution/orchestration-telemetry.md +3 -0
- package/dist/agent-src/rules/delegation-policy.md +6 -0
- package/dist/agent-src/scripts/archive_completed_roadmaps.ts +35 -1
- package/dist/agent-src/scripts/roadmap_gates.ts +107 -3
- package/dist/agent-src/skills/corpus-grounding/SKILL.md +21 -12
- package/dist/agent-src/skills/design-tokens/SKILL.md +15 -0
- package/dist/agent-src/skills/docker/SKILL.md +11 -0
- package/dist/agent-src/skills/laravel/SKILL.md +11 -0
- package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +24 -0
- package/dist/agent-src/skills/review-routing/SKILL.md +23 -6
- package/dist/agent-src/skills/rule-writing/SKILL.md +23 -0
- package/dist/agent-src/skills/skill-writing/SKILL.md +80 -2
- package/dist/agent-src/skills/systematic-debugging/SKILL.md +5 -3
- package/dist/agent-src/skills/tailwind-engineer/SKILL.md +24 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/scripts/ownership-map.example.yml +1 -1
- package/dist/cli/registry.js +4 -4
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/chunk-HTIU3W56.js +66 -0
- package/dist/cli-delegate/{chunk-2WELHO3P.js → chunk-I5EAFRII.js} +35 -0
- package/dist/cli-delegate/{chunk-ITIQSGOQ.js → chunk-MA4GFWDY.js} +7 -4
- package/dist/cli-delegate/{chunk-PFBV6XRT.js → chunk-Q26HT4QT.js} +1 -63
- package/dist/cli-delegate/{chunk-542DZR4P.js → chunk-REMEZSVP.js} +1660 -146
- package/dist/cli-delegate/{chunk-3EWYC3YK.js → chunk-UEZ7D35A.js} +4 -3
- package/dist/cli-delegate/{chunk-LVRYBKTI.js → chunk-YPAWXXXR.js} +158 -0
- package/dist/cli-delegate/cmd_conformance.js +256 -226
- package/dist/cli-delegate/cmd_doctor.js +3 -2
- package/dist/cli-delegate/cmd_export.js +1 -1
- package/dist/cli-delegate/cmd_handoff.js +21 -28
- package/dist/cli-delegate/cmd_memory_get.js +1 -1
- package/dist/cli-delegate/cmd_prune.js +1 -1
- package/dist/cli-delegate/cmd_route_audit.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +20 -5
- package/dist/cli-delegate/cmd_settings_get.js +4 -2
- package/dist/cli-delegate/cmd_settings_migrate.js +1 -1
- package/dist/cli-delegate/cmd_settings_set.js +5 -3
- 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 +17 -17
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +2 -2
- package/dist/discovery/dormancy-report.md +17 -0
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +3 -3
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +3 -3
- package/dist/hooks/dispatch.js +114 -7
- package/dist/install/install.mjs +7 -4
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +3755 -1168
- package/docs/guidelines/agent-infra/failure-signatures.md +47 -12
- package/package.json +2 -2
- package/src/agent-src/scripts/archive_completed_roadmaps.ts +35 -1
- package/src/agent-src/scripts/roadmap_gates.ts +107 -3
- package/src/agent-src/templates/scripts/ownership-map.example.yml +1 -1
- package/src/config/cost-parity-budget.json +167 -0
- package/src/config/gate-coverage.yml +44 -5
- package/src/config/quorum-attendance-budget.json +39 -0
- package/src/scripts/_cli/cmd_doctor.ts +69 -1
- package/src/scripts/_cli/cmd_session_recycle.ts +25 -4
- package/src/scripts/_dispatch.bash +5 -3
- package/src/scripts/_lib/env_kill_switch.ts +37 -0
- package/src/scripts/_lib/orchestration_record.ts +45 -0
- package/src/scripts/_lib/security_lint.ts +18 -4
- package/src/scripts/_lib/self_repair.ts +60 -0
- package/src/scripts/_lib/self_repair_store.ts +65 -5
- package/src/scripts/_lib/subagent_capsule.ts +78 -1
- package/src/scripts/ai_council/clients.ts +31 -0
- package/src/scripts/ai_council/events_log.ts +130 -8
- package/src/scripts/ai_council/orchestrator.ts +50 -3
- package/src/scripts/ai_council/pricing.ts +48 -1
- package/src/scripts/ai_council/prompts.ts +165 -3
- package/src/scripts/ai_council/quorum.ts +30 -0
- package/src/scripts/ai_council/session.ts +11 -0
- package/src/scripts/ai_team/review_gate.ts +2 -2
- package/src/scripts/ask_transport.ts +20 -1
- package/src/scripts/build_discovery_manifest.ts +165 -0
- package/src/scripts/cache_realization_report.ts +38 -0
- package/src/scripts/check_claims.ts +51 -2
- package/src/scripts/check_release_highlights.ts +7 -1
- package/src/scripts/ci_time_ratio.ts +3 -1
- package/src/scripts/conformance_scan.ts +105 -0
- package/src/scripts/cost/track.mjs +39 -0
- package/src/scripts/cost_summary.ts +104 -1
- package/src/scripts/council_cli.ts +131 -13
- package/src/scripts/discovery_graph.ts +201 -27
- package/src/scripts/dispatch_economy_report.ts +5 -2
- package/src/scripts/dispatch_r2_reviewer.ts +102 -1
- package/src/scripts/install.ts +20 -6
- package/src/scripts/lint_budget_ownership.ts +81 -0
- package/src/scripts/lint_handoffs.ts +92 -2
- package/src/scripts/lint_hedge_words.ts +317 -0
- package/src/scripts/lint_mcp_config_security.ts +1 -1
- package/src/scripts/lint_skill_frontmatter_safety.ts +56 -2
- package/src/scripts/lint_workflow_paths.ts +384 -0
- package/src/scripts/preamble_byte_census.ts +9 -1
- package/src/scripts/report_carrier_divergence.ts +10 -0
- package/src/scripts/report_conformance_funnel.ts +40 -0
- package/src/scripts/schemas/subagent.schema.json +3 -2
- package/src/scripts/self_repair_cli.ts +26 -1
- package/src/scripts/skill_linter.ts +58 -2
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* lint_workflow_paths — a workflow `paths:` filter that matches nothing runs
|
|
4
|
+
* nothing, silently, forever.
|
|
5
|
+
*
|
|
6
|
+
* ## The defect class
|
|
7
|
+
*
|
|
8
|
+
* A GitHub `on.<event>.paths` filter is a promise: "run this job when these
|
|
9
|
+
* files change". When the named tree is renamed, retired, or gitignored, the
|
|
10
|
+
* promise stops being kept and nothing says so — the workflow does not error,
|
|
11
|
+
* it simply never triggers. The job still appears in the Actions list, still
|
|
12
|
+
* shows green from its last real run, and a reader auditing the pipeline reads
|
|
13
|
+
* an intent the pipeline no longer has.
|
|
14
|
+
*
|
|
15
|
+
* That is strictly worse than a missing filter. A missing filter over-triggers
|
|
16
|
+
* and wastes minutes; a dead one under-triggers and hides regressions, and it
|
|
17
|
+
* is invisible to every other gate in this suite: `actionlint` validates the
|
|
18
|
+
* schema (a string is a valid string), and no reference checker resolves a
|
|
19
|
+
* glob against the tree.
|
|
20
|
+
*
|
|
21
|
+
* ## Measured, not assumed
|
|
22
|
+
*
|
|
23
|
+
* The census that motivated this gate found **20** dead entries across 6
|
|
24
|
+
* workflow files in four classes: a source tree retired by ADR-051, a
|
|
25
|
+
* **gitignored** projection tree (matched by no diff, ever), two paths that
|
|
26
|
+
* moved during the Python-to-TS migration, and two bare repo-root paths whose
|
|
27
|
+
* real files live under `src/`. All 20 were classified before this gate was
|
|
28
|
+
* written, and all 20 were repaired in the same change — so the gate ships
|
|
29
|
+
* **strict** over a corpus verified empty, rather than advisory over a corpus
|
|
30
|
+
* nobody looked at. A gate that reports zero findings on a corpus it has never
|
|
31
|
+
* been proven able to fail on is blind, not clean; `--self-test` is what
|
|
32
|
+
* separates those two.
|
|
33
|
+
*
|
|
34
|
+
* ## What counts as a match
|
|
35
|
+
*
|
|
36
|
+
* A glob matches when at least one **tracked** file matches it. Tracked, not
|
|
37
|
+
* on-disk, is the correct universe on purpose: a `paths:` filter is evaluated
|
|
38
|
+
* against a diff, and only tracked files appear in diffs. This is exactly why
|
|
39
|
+
* `.augment/**` is dead here despite the directory existing locally — it is
|
|
40
|
+
* gitignored, so no change to it can ever reach a pull request.
|
|
41
|
+
*
|
|
42
|
+
* Directory-shaped entries (`taskfiles/**`, or a bare `src/scripts`) match by
|
|
43
|
+
* prefix, mirroring GitHub's own semantics.
|
|
44
|
+
*
|
|
45
|
+
* ## The escape, and why it is inline
|
|
46
|
+
*
|
|
47
|
+
* A filter naming a path that does not exist *yet* — a tree an in-flight PR
|
|
48
|
+
* introduces — declares itself on the entry:
|
|
49
|
+
*
|
|
50
|
+
* - "future/tree/ **" # workflow-path-allow: lands in PR #1234, filter pre-staged
|
|
51
|
+
*
|
|
52
|
+
* The reason is required and must be more than one word. There is deliberately
|
|
53
|
+
* no allowlist JSON: a side-channel file is the shape that grows past twenty
|
|
54
|
+
* entries and quietly becomes the budget bypass `autonomous-execution` names.
|
|
55
|
+
*/
|
|
56
|
+
import * as fs from 'node:fs';
|
|
57
|
+
import { execFileSync } from 'node:child_process';
|
|
58
|
+
import * as os from 'node:os';
|
|
59
|
+
import * as path from 'node:path';
|
|
60
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
61
|
+
|
|
62
|
+
import { GateLedger } from './_lib/gate_ledger.js';
|
|
63
|
+
import { runGateCli, runSelfTest } from './_lib/gate_self_test.js';
|
|
64
|
+
import { reportScanned } from './_lib/scan_scope.js';
|
|
65
|
+
|
|
66
|
+
const SCRIPTS_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
67
|
+
const REAL_REPO_ROOT = path.resolve(SCRIPTS_DIR, '..', '..');
|
|
68
|
+
|
|
69
|
+
const ALLOW_RE = /#\s*workflow-path-allow:\s*(.+?)\s*$/;
|
|
70
|
+
/** A reason must carry real content — two words minimum, eight characters. */
|
|
71
|
+
const MIN_REASON_WORDS = 2;
|
|
72
|
+
const MIN_REASON_CHARS = 8;
|
|
73
|
+
|
|
74
|
+
export interface PathFinding {
|
|
75
|
+
readonly file: string;
|
|
76
|
+
readonly line: number;
|
|
77
|
+
readonly glob: string;
|
|
78
|
+
readonly why: 'no-match' | 'thin-reason';
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Translate the subset of glob syntax GitHub path filters use into a RegExp.
|
|
83
|
+
*
|
|
84
|
+
* `**` crosses directory separators, `*` and `?` do not. Everything else is a
|
|
85
|
+
* literal — no brace expansion, no character classes, because GitHub does not
|
|
86
|
+
* document them for this field and guessing at unsupported syntax would make
|
|
87
|
+
* the gate disagree with the thing it is auditing.
|
|
88
|
+
*/
|
|
89
|
+
export function globToRegExp(glob: string): RegExp {
|
|
90
|
+
let out = '';
|
|
91
|
+
for (let i = 0; i < glob.length; i += 1) {
|
|
92
|
+
const c = glob[i] as string;
|
|
93
|
+
if (c === '*') {
|
|
94
|
+
if (glob[i + 1] === '*') {
|
|
95
|
+
out += '.*';
|
|
96
|
+
i += 1;
|
|
97
|
+
if (glob[i + 1] === '/') {
|
|
98
|
+
i += 1;
|
|
99
|
+
}
|
|
100
|
+
} else {
|
|
101
|
+
out += '[^/]*';
|
|
102
|
+
}
|
|
103
|
+
} else if (c === '?') {
|
|
104
|
+
out += '[^/]';
|
|
105
|
+
} else {
|
|
106
|
+
out += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return new RegExp(`^${out}$`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Does `glob` match at least one path in `tracked`? */
|
|
113
|
+
export function globMatchesAny(glob: string, tracked: readonly string[]): boolean {
|
|
114
|
+
const re = globToRegExp(glob);
|
|
115
|
+
const prefix = `${glob.replace(/\/?\*\*$/, '').replace(/\/$/, '')}/`;
|
|
116
|
+
return tracked.some((t) => re.test(t) || t === glob || t.startsWith(prefix));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Extract every `paths:` / `paths-ignore:` list entry from one workflow body.
|
|
121
|
+
*
|
|
122
|
+
* A hand-rolled scan rather than a YAML parse, deliberately: the gate must
|
|
123
|
+
* report the LINE an entry sits on so a maintainer can jump to it, and a parsed
|
|
124
|
+
* document has thrown that away. The block ends at the first non-comment,
|
|
125
|
+
* non-list line — which is how YAML block sequences end anyway.
|
|
126
|
+
*/
|
|
127
|
+
export function extractPathEntries(
|
|
128
|
+
body: string,
|
|
129
|
+
): { line: number; glob: string; allow: string | null }[] {
|
|
130
|
+
const out: { line: number; glob: string; allow: string | null }[] = [];
|
|
131
|
+
let inBlock = false;
|
|
132
|
+
const lines = body.split('\n');
|
|
133
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
134
|
+
const line = lines[i] as string;
|
|
135
|
+
if (/^\s*paths(-ignore)?:\s*$/.test(line)) {
|
|
136
|
+
inBlock = true;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (!inBlock) {
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
if (line.trim() === '' || line.trim().startsWith('#')) {
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
const m = line.match(/^\s*-\s*(?:'([^']*)'|"([^"]*)"|([^#\s]+))\s*(#.*)?$/);
|
|
146
|
+
if (m === null) {
|
|
147
|
+
inBlock = false;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
const glob = (m[1] ?? m[2] ?? m[3] ?? '').trim();
|
|
151
|
+
if (glob === '') {
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
const allowMatch = (m[4] ?? '').match(ALLOW_RE);
|
|
155
|
+
out.push({ line: i + 1, glob, allow: allowMatch ? (allowMatch[1] as string) : null });
|
|
156
|
+
}
|
|
157
|
+
return out;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function _trackedFiles(repoRoot: string): string[] {
|
|
161
|
+
try {
|
|
162
|
+
return execFileSync('git', ['ls-files'], {
|
|
163
|
+
cwd: repoRoot,
|
|
164
|
+
encoding: 'utf-8',
|
|
165
|
+
maxBuffer: 256 * 1024 * 1024,
|
|
166
|
+
})
|
|
167
|
+
.split('\n')
|
|
168
|
+
.filter((l) => l !== '');
|
|
169
|
+
} catch {
|
|
170
|
+
return [];
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function _reasonIsThin(reason: string): boolean {
|
|
175
|
+
return (
|
|
176
|
+
reason.trim().length < MIN_REASON_CHARS ||
|
|
177
|
+
reason.trim().split(/\s+/).filter(Boolean).length < MIN_REASON_WORDS
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
export interface ScanOptions {
|
|
182
|
+
readonly repoRoot: string;
|
|
183
|
+
readonly workflowDir: string;
|
|
184
|
+
readonly tracked: readonly string[];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function scanWorkflowDir(
|
|
188
|
+
opts: ScanOptions,
|
|
189
|
+
ledger: GateLedger,
|
|
190
|
+
): PathFinding[] {
|
|
191
|
+
const findings: PathFinding[] = [];
|
|
192
|
+
let files: string[] = [];
|
|
193
|
+
try {
|
|
194
|
+
files = fs
|
|
195
|
+
.readdirSync(opts.workflowDir)
|
|
196
|
+
.filter((f) => /\.ya?ml$/.test(f))
|
|
197
|
+
.sort();
|
|
198
|
+
} catch {
|
|
199
|
+
files = [];
|
|
200
|
+
}
|
|
201
|
+
ledger.plan(files);
|
|
202
|
+
|
|
203
|
+
for (const f of files) {
|
|
204
|
+
let body: string;
|
|
205
|
+
try {
|
|
206
|
+
body = fs.readFileSync(path.join(opts.workflowDir, f), 'utf-8');
|
|
207
|
+
} catch {
|
|
208
|
+
ledger.fail(f, 'unreadable');
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
const before = findings.length;
|
|
212
|
+
for (const entry of extractPathEntries(body)) {
|
|
213
|
+
if (entry.allow !== null) {
|
|
214
|
+
if (_reasonIsThin(entry.allow)) {
|
|
215
|
+
findings.push({ file: f, line: entry.line, glob: entry.glob, why: 'thin-reason' });
|
|
216
|
+
}
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
if (!globMatchesAny(entry.glob, opts.tracked)) {
|
|
220
|
+
findings.push({ file: f, line: entry.line, glob: entry.glob, why: 'no-match' });
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (findings.length > before) {
|
|
224
|
+
ledger.fail(f, `${String(findings.length - before)} dead path filter(s)`);
|
|
225
|
+
} else {
|
|
226
|
+
ledger.complete(f);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return findings;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
interface Args {
|
|
233
|
+
quiet: boolean;
|
|
234
|
+
selfTest: boolean;
|
|
235
|
+
root: string | null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function _parseArgs(argv: readonly string[]): Args {
|
|
239
|
+
const args: Args = { quiet: false, selfTest: false, root: null };
|
|
240
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
241
|
+
const a = argv[i];
|
|
242
|
+
if (a === '--quiet') {
|
|
243
|
+
args.quiet = true;
|
|
244
|
+
} else if (a === '--self-test') {
|
|
245
|
+
args.selfTest = true;
|
|
246
|
+
} else if (a === '--root') {
|
|
247
|
+
args.root = argv[i + 1] ?? null;
|
|
248
|
+
i += 1;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return args;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function _selfTest(): number {
|
|
255
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'lwp-selftest-'));
|
|
256
|
+
const mkRepo = (name: string, workflow: string): string => {
|
|
257
|
+
const repo = path.join(tmp, name);
|
|
258
|
+
fs.mkdirSync(path.join(repo, '.github', 'workflows'), { recursive: true });
|
|
259
|
+
fs.mkdirSync(path.join(repo, 'src'), { recursive: true });
|
|
260
|
+
fs.writeFileSync(path.join(repo, 'src', 'real.ts'), '// real\n');
|
|
261
|
+
execFileSync('git', ['init', '-q'], { cwd: repo });
|
|
262
|
+
execFileSync('git', ['add', '-A'], { cwd: repo });
|
|
263
|
+
fs.writeFileSync(path.join(repo, '.github', 'workflows', 'w.yml'), workflow, 'utf-8');
|
|
264
|
+
execFileSync('git', ['add', '-A'], { cwd: repo });
|
|
265
|
+
return repo;
|
|
266
|
+
};
|
|
267
|
+
const run = (repo: string): number =>
|
|
268
|
+
runGateCli(
|
|
269
|
+
REAL_REPO_ROOT,
|
|
270
|
+
'src/scripts/lint_workflow_paths.ts',
|
|
271
|
+
['--root', repo, '--quiet'],
|
|
272
|
+
REAL_REPO_ROOT,
|
|
273
|
+
);
|
|
274
|
+
|
|
275
|
+
const HEAD = 'on:\n pull_request:\n paths:\n';
|
|
276
|
+
try {
|
|
277
|
+
const dead = mkRepo('dead', `${HEAD} - "retired/tree/**"\n`);
|
|
278
|
+
const live = mkRepo('live', `${HEAD} - "src/**"\n`);
|
|
279
|
+
const allowed = mkRepo(
|
|
280
|
+
'allowed',
|
|
281
|
+
`${HEAD} - "future/tree/**" # workflow-path-allow: lands in the follow-up PR\n`,
|
|
282
|
+
);
|
|
283
|
+
const thin = mkRepo('thin', `${HEAD} - "future/tree/**" # workflow-path-allow: soon\n`);
|
|
284
|
+
const bareDir = mkRepo('baredir', `${HEAD} - "src"\n`);
|
|
285
|
+
return runSelfTest({
|
|
286
|
+
gate: 'lint_workflow_paths',
|
|
287
|
+
minCases: 4,
|
|
288
|
+
minRejectCases: 2,
|
|
289
|
+
cases: [
|
|
290
|
+
{
|
|
291
|
+
name: 'a filter naming a retired tree is rejected',
|
|
292
|
+
expect: 'reject',
|
|
293
|
+
run: () => run(dead),
|
|
294
|
+
},
|
|
295
|
+
{ name: 'a filter that matches tracked files passes', expect: 'accept', run: () => run(live) },
|
|
296
|
+
{
|
|
297
|
+
name: 'a bare directory entry matches by prefix, as GitHub does',
|
|
298
|
+
expect: 'accept',
|
|
299
|
+
run: () => run(bareDir),
|
|
300
|
+
},
|
|
301
|
+
{
|
|
302
|
+
name: 'a declared pre-staged filter with a real reason passes',
|
|
303
|
+
expect: 'accept',
|
|
304
|
+
run: () => run(allowed),
|
|
305
|
+
},
|
|
306
|
+
{
|
|
307
|
+
name: 'a one-word reason is rejected — an unclassifiable exception is boilerplate',
|
|
308
|
+
expect: 'reject',
|
|
309
|
+
run: () => run(thin),
|
|
310
|
+
},
|
|
311
|
+
],
|
|
312
|
+
});
|
|
313
|
+
} finally {
|
|
314
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export function main(argv: readonly string[] = process.argv.slice(2)): number {
|
|
319
|
+
const args = _parseArgs(argv);
|
|
320
|
+
if (args.selfTest) {
|
|
321
|
+
return _selfTest();
|
|
322
|
+
}
|
|
323
|
+
const repoRoot = args.root !== null ? path.resolve(args.root) : REAL_REPO_ROOT;
|
|
324
|
+
const workflowDir = path.join(repoRoot, '.github', 'workflows');
|
|
325
|
+
const tracked = _trackedFiles(repoRoot);
|
|
326
|
+
|
|
327
|
+
const ledger = new GateLedger('lint_workflow_paths');
|
|
328
|
+
const findings = scanWorkflowDir({ repoRoot, workflowDir, tracked }, ledger);
|
|
329
|
+
const tally = ledger.finalize();
|
|
330
|
+
|
|
331
|
+
if (!args.quiet && findings.length > 0) {
|
|
332
|
+
process.stdout.write('\nlint_workflow_paths — path filters that can never fire:\n\n');
|
|
333
|
+
for (const f of findings) {
|
|
334
|
+
const why =
|
|
335
|
+
f.why === 'no-match'
|
|
336
|
+
? 'matches no tracked file'
|
|
337
|
+
: 'workflow-path-allow reason is too thin to classify';
|
|
338
|
+
process.stdout.write(
|
|
339
|
+
` ${f.file}:${String(f.line)} ${f.glob}\n ${why}\n`,
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
process.stdout.write(
|
|
343
|
+
'\n Fix: delete the entry if the tree is retired, or repoint it if the path moved.\n' +
|
|
344
|
+
' A filter that matches nothing does not over-trigger — it never triggers.\n',
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
if (!args.quiet) {
|
|
348
|
+
process.stdout.write(
|
|
349
|
+
`\nlint_workflow_paths: ${String(findings.length)} dead filter(s) across ` +
|
|
350
|
+
`${String(new Set(findings.map((f) => f.file)).size)} workflow file(s)\n`,
|
|
351
|
+
);
|
|
352
|
+
ledger.report();
|
|
353
|
+
}
|
|
354
|
+
reportScanned({
|
|
355
|
+
gate: 'lint_workflow_paths',
|
|
356
|
+
scanned: tally.completed + tally.failed,
|
|
357
|
+
units: 'workflow file(s)',
|
|
358
|
+
roots: ['.github/workflows'],
|
|
359
|
+
});
|
|
360
|
+
return findings.length > 0 ? 1 : 0;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const _HERE = fileURLToPath(import.meta.url);
|
|
364
|
+
function _isCliEntry(): boolean {
|
|
365
|
+
if (process.argv[1] === undefined) {
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
368
|
+
const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
|
|
369
|
+
if (import.meta.url === argvUrl) {
|
|
370
|
+
return true;
|
|
371
|
+
}
|
|
372
|
+
try {
|
|
373
|
+
return (
|
|
374
|
+
fs.realpathSync(fileURLToPath(import.meta.url)) ===
|
|
375
|
+
fs.realpathSync(path.resolve(process.argv[1]))
|
|
376
|
+
);
|
|
377
|
+
} catch {
|
|
378
|
+
return false;
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (_isCliEntry() || process.argv[1] === _HERE) {
|
|
383
|
+
process.exit(main());
|
|
384
|
+
}
|
|
@@ -557,7 +557,15 @@ export function main(argv: string[] = process.argv.slice(2)): number {
|
|
|
557
557
|
return 0;
|
|
558
558
|
}
|
|
559
559
|
|
|
560
|
-
|
|
560
|
+
// `__AGENT_CONFIG_BUNDLE__` is defined true by every esbuild target. Inside a
|
|
561
|
+
// bundle `import.meta.url` is the OUTPUT file's URL for every bundled module,
|
|
562
|
+
// so the guard below would match whenever the bundle is invoked directly — and
|
|
563
|
+
// importing this module from a bundled CLI entry (`cmd_doctor` bundles under
|
|
564
|
+
// `build:cli-delegate`) would run the census and exit before that entry's own
|
|
565
|
+
// main. Same shape as the guard in `dispatch_economy_report.ts`.
|
|
566
|
+
declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
|
|
567
|
+
const _isBundled = typeof __AGENT_CONFIG_BUNDLE__ !== 'undefined' && __AGENT_CONFIG_BUNDLE__;
|
|
568
|
+
if (!_isBundled && process.argv[1] !== undefined) {
|
|
561
569
|
const invokedUrl = pathToFileURL(path.resolve(process.argv[1])).href;
|
|
562
570
|
if (import.meta.url === invokedUrl) process.exit(main());
|
|
563
571
|
}
|
|
@@ -35,6 +35,16 @@
|
|
|
35
35
|
* ignored, which is the failure `check-rule-invariants` already paid for once.
|
|
36
36
|
* The point is that the condition becomes VISIBLE, not that it becomes illegal.
|
|
37
37
|
*
|
|
38
|
+
* BEFORE QUOTING A COUNT FROM THIS REPORT
|
|
39
|
+
* ---------------------------------------
|
|
40
|
+
* Read `agents/settings/contexts/carrier-divergence-109-vs-24.md` first. The
|
|
41
|
+
* figure "109 divergent pairs, binding undefined" reached five independent
|
|
42
|
+
* release reviews as the tree's largest technical debt; all 109 carry
|
|
43
|
+
* byte-identical prose, and the real actionable set is the 24-pair `paths:`
|
|
44
|
+
* subset below, in the over-delivery direction. That correction had no stable
|
|
45
|
+
* surface, which is why every fresh reader re-derived the wrong number from
|
|
46
|
+
* this report's own output.
|
|
47
|
+
*
|
|
38
48
|
* THE FOUR CLASSES, AND WHICH ONE A READER MUST ACT ON
|
|
39
49
|
* ---------------------------------------------------
|
|
40
50
|
* body-diff the two carriers deliver different PROSE. This is the class
|
|
@@ -11,6 +11,11 @@
|
|
|
11
11
|
* conformance sources, so the three questions that were answered in three
|
|
12
12
|
* places read as the funnel they are:
|
|
13
13
|
*
|
|
14
|
+
* OPPORTUNITY in how many scanned sessions a skill was in context at all —
|
|
15
|
+
* derived from `report_skill_obligation_violations.scanStore`'s
|
|
16
|
+
* own `sessionsWithASkill`, so it adds no classifier. It is the
|
|
17
|
+
* denominator ACTIVATION was missing: `skills.total` is supply,
|
|
18
|
+
* not opportunity.
|
|
14
19
|
* DELIVERY what rule text the carriers put in front of the model, and
|
|
15
20
|
* whether the two carriers agree —
|
|
16
21
|
* `conformance_scan.measureDelivered` (carrier census) +
|
|
@@ -176,6 +181,41 @@ export function render(r: FunnelReport): string {
|
|
|
176
181
|
}
|
|
177
182
|
lines.push('');
|
|
178
183
|
|
|
184
|
+
// ── OPPORTUNITY ────────────────────────────────────────────────────────
|
|
185
|
+
// The stage the funnel was missing. ACTIVATION below reports against
|
|
186
|
+
// `skills.total`, which is a SUPPLY number: how many skills exist. That
|
|
187
|
+
// denominator can only fall as the catalogue grows, and it says nothing
|
|
188
|
+
// about whether any skill had a chance to fire. Opportunity is the
|
|
189
|
+
// denominator that belongs between delivery and activation — sessions in
|
|
190
|
+
// which a skill was actually in context.
|
|
191
|
+
//
|
|
192
|
+
// DERIVED, never re-measured: `sessionsWithASkill` is the SK-2 scan's own
|
|
193
|
+
// field, so this stage adds no classifier and cannot disagree with the
|
|
194
|
+
// COMPLIANCE stage that already prints it.
|
|
195
|
+
lines.push('OPPORTUNITY — in how many sessions could a skill fire at all?');
|
|
196
|
+
if (!r.storePresent) {
|
|
197
|
+
lines.push(' NO DATA: no transcript store — opportunity is not measurable here.');
|
|
198
|
+
lines.push(' An absent store is an answer, not a zero.');
|
|
199
|
+
} else {
|
|
200
|
+
lines.push(` sessions scanned ${r.sk2.sessions}`);
|
|
201
|
+
lines.push(
|
|
202
|
+
` with a skill in context ${r.sk2.sessionsWithASkill} ` +
|
|
203
|
+
`(${_pct(r.sk2.sessionsWithASkill, r.sk2.sessions)})`,
|
|
204
|
+
);
|
|
205
|
+
lines.push(
|
|
206
|
+
' Skill invocations per such session ' +
|
|
207
|
+
(r.sk2.sessionsWithASkill === 0
|
|
208
|
+
? 'n/a'
|
|
209
|
+
: (r.usage.invocations / r.sk2.sessionsWithASkill).toFixed(2)),
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
lines.push(" Honest scope: \"in context\" is the SK-2 scan's reading — a skill was");
|
|
213
|
+
lines.push(' LOADED, not that the task needed it. So this bounds activation from');
|
|
214
|
+
lines.push(' above; it never claims a missed invocation was a defect. Report-only and');
|
|
215
|
+
lines.push(' deliberately without a threshold: a joined funnel with a denominator');
|
|
216
|
+
lines.push(' invites one, and the activation red-baseline null is terminal.');
|
|
217
|
+
lines.push('');
|
|
218
|
+
|
|
179
219
|
// ── ACTIVATION ─────────────────────────────────────────────────────────
|
|
180
220
|
lines.push('ACTIVATION — are the delivered skills invoked?');
|
|
181
221
|
lines.push(` skills shipped ${r.skills.total}`);
|
|
@@ -49,9 +49,10 @@
|
|
|
49
49
|
"uniqueItems": true,
|
|
50
50
|
"items": {
|
|
51
51
|
"type": "string",
|
|
52
|
-
"
|
|
52
|
+
"pattern": "^(Read|Write|Edit|Grep|Glob|Bash|WebFetch|WebSearch|NotebookEdit)(\\([^()]+\\))?$"
|
|
53
53
|
},
|
|
54
|
-
"description": "
|
|
54
|
+
"description": "A tool grant: one of the Claude Code tool names, optionally scoped — `Bash(npm test:*)` grants one command family where a bare `Bash` grants a shell. The base name stays closed, so a typo still fails CI rather than producing broken `.claude/agents/` YAML in the user's IDE; only the optional `(...)` suffix is new. Prefer the narrowest grant the task actually needs, per tool-safety Least Agency.",
|
|
55
|
+
"$comment": "Was a bare enum, which made the scoped-grant syntax `tool-safety` prescribes literally inexpressible here — a schema forbidding what the rule asks for. skill.schema.json's allowed_tools permits that syntax by validating nothing at all; this pattern permits it while keeping the base-name guarantee that made the enum worth having."
|
|
55
56
|
},
|
|
56
57
|
"trust": {
|
|
57
58
|
"type": "object",
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
type EgressChoice,
|
|
38
38
|
type EgressRoute,
|
|
39
39
|
egressBlockedReason,
|
|
40
|
+
NEW_RECORDS_PER_SOURCE_PER_WINDOW,
|
|
40
41
|
patchDeniedReason,
|
|
41
42
|
type PushVia,
|
|
42
43
|
renderReport,
|
|
@@ -48,6 +49,7 @@ import {
|
|
|
48
49
|
listRecords,
|
|
49
50
|
markReleased,
|
|
50
51
|
openRecords,
|
|
52
|
+
readOverflow,
|
|
51
53
|
readRecord,
|
|
52
54
|
} from './_lib/self_repair_store.js';
|
|
53
55
|
|
|
@@ -400,11 +402,33 @@ export function executeRelease(
|
|
|
400
402
|
return { published: null, attempts };
|
|
401
403
|
}
|
|
402
404
|
|
|
405
|
+
/**
|
|
406
|
+
* The creation cap's drop tally, printed whenever it is non-zero. This is the
|
|
407
|
+
* half that keeps the cap honest: a bounded store is only an improvement if
|
|
408
|
+
* "records were refused" is readable, otherwise the bound is indistinguishable
|
|
409
|
+
* from defects that never happened.
|
|
410
|
+
*/
|
|
411
|
+
function overflowLines(root: string): string {
|
|
412
|
+
const counts = readOverflow(root);
|
|
413
|
+
const rows = Object.entries(counts).filter(([, v]) => (v?.dropped ?? 0) > 0);
|
|
414
|
+
if (rows.length === 0) {
|
|
415
|
+
return '';
|
|
416
|
+
}
|
|
417
|
+
let out = '\n';
|
|
418
|
+
for (const [source, v] of rows) {
|
|
419
|
+
out +=
|
|
420
|
+
` ! creation cap refused ${v?.dropped ?? 0} new ${source} record(s) ` +
|
|
421
|
+
`(cap ${NEW_RECORDS_PER_SOURCE_PER_WINDOW} per source per 24h, last ${v?.last_seen ?? '?'})\n`;
|
|
422
|
+
}
|
|
423
|
+
return out;
|
|
424
|
+
}
|
|
425
|
+
|
|
403
426
|
function statusCmd(root: string): number {
|
|
404
427
|
const open = openRecords(root);
|
|
405
428
|
const all = listRecords(root);
|
|
406
429
|
if (all.length === 0) {
|
|
407
|
-
|
|
430
|
+
const overflow = overflowLines(root);
|
|
431
|
+
process.stdout.write(`self-repair: no records.\n${overflow}`);
|
|
408
432
|
return EXIT_OK;
|
|
409
433
|
}
|
|
410
434
|
process.stdout.write(`self-repair: ${open.length} open of ${all.length} record(s)\n\n`);
|
|
@@ -415,6 +439,7 @@ function statusCmd(root: string): number {
|
|
|
415
439
|
` ${r.evidence}\n`,
|
|
416
440
|
);
|
|
417
441
|
}
|
|
442
|
+
process.stdout.write(overflowLines(root));
|
|
418
443
|
process.stdout.write('\nRelease one: agent-config self-repair:release <fingerprint>\n');
|
|
419
444
|
return EXIT_OK;
|
|
420
445
|
}
|
|
@@ -106,6 +106,44 @@ const SECTION_ALIASES: Record<string, Set<string>> = {
|
|
|
106
106
|
|
|
107
107
|
const RECOMMENDED_SKILL_SECTIONS: string[] = [];
|
|
108
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Sections `skill-writing` labels required for a NAMED SUBSET of skills, paired
|
|
111
|
+
* with the predicate that decides the subset.
|
|
112
|
+
*
|
|
113
|
+
* The predicate must be decidable from the filesystem, never from prose. That is
|
|
114
|
+
* what keeps the false-positive class empty by construction: a skill either ships
|
|
115
|
+
* a directory or it does not, so there is nothing for a heuristic to get wrong,
|
|
116
|
+
* and the check can ship as `error` on day one instead of as an open-ended
|
|
117
|
+
* advisory (the stage-vs-hedge argument in `check_suppression_hygiene.ts`).
|
|
118
|
+
*
|
|
119
|
+
* `skill-writing` carries two further section patterns — `Rationalizations to
|
|
120
|
+
* reject` and `Non-negotiable deliverable`. Neither is listed here, and neither
|
|
121
|
+
* is labelled required any more. Their populations ("security-stop-routed",
|
|
122
|
+
* "adjacent-technology cluster") are judgement calls that no property of the tree
|
|
123
|
+
* declares, so a predicate for them would be exactly the heuristic this table
|
|
124
|
+
* refuses; compliance stood at 0 of 289 skills while the label said required.
|
|
125
|
+
* Council 2026-08-11 (2/2, anthropic + openai) resolved the mismatch by
|
|
126
|
+
* reclassifying both to `recommended pattern` rather than by adding a declared
|
|
127
|
+
* frontmatter key — an authored opt-in cannot see the skill that should have
|
|
128
|
+
* opted in and did not, so it would buy a gate without buying the guarantee.
|
|
129
|
+
*/
|
|
130
|
+
const CONDITIONAL_SKILL_SECTIONS: { section: string; when: (skillDir: string) => boolean; reason: string }[] = [
|
|
131
|
+
{
|
|
132
|
+
section: 'Security constraints',
|
|
133
|
+
// `isDirectory()`, not `existsSync` — a FILE named `scripts` would satisfy
|
|
134
|
+
// an existence test while shipping no script at all, and the predicate is
|
|
135
|
+
// only false-positive-free because it means what it says.
|
|
136
|
+
when: (skillDir) => {
|
|
137
|
+
try {
|
|
138
|
+
return fs.statSync(path.join(skillDir, 'scripts')).isDirectory();
|
|
139
|
+
} catch {
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
reason: 'the skill ships a scripts/ directory',
|
|
144
|
+
},
|
|
145
|
+
];
|
|
146
|
+
|
|
109
147
|
const RULE_BAD_SIGNS = ['## Procedure', '## Output format', '## Gotchas'];
|
|
110
148
|
|
|
111
149
|
const FRUGALITY_WRITER_SKILLS = new Set([
|
|
@@ -1346,7 +1384,7 @@ function lint_execution_metadata(execution: ExecutionBlock): Issue[] {
|
|
|
1346
1384
|
return issues;
|
|
1347
1385
|
}
|
|
1348
1386
|
|
|
1349
|
-
export function lint_skill(p: string, text: string): LintResult {
|
|
1387
|
+
export function lint_skill(p: string, text: string, absPath: string | null = null): LintResult {
|
|
1350
1388
|
const issues: Issue[] = [];
|
|
1351
1389
|
const suggestions: string[] = [];
|
|
1352
1390
|
|
|
@@ -1359,6 +1397,24 @@ export function lint_skill(p: string, text: string): LintResult {
|
|
|
1359
1397
|
}
|
|
1360
1398
|
}
|
|
1361
1399
|
|
|
1400
|
+
// `p` may be a repo-relative DISPLAY path, so it is not resolvable on its own;
|
|
1401
|
+
// `absPath` is the path actually read and `lint_file` always supplies it. A
|
|
1402
|
+
// caller that supplies neither gets no conditional check rather than a check
|
|
1403
|
+
// resolved against whatever the process cwd happens to be — a gate whose
|
|
1404
|
+
// verdict depends on the caller's directory is worse than an absent one.
|
|
1405
|
+
const skillDir = absPath !== null ? path.dirname(absPath) : path.isAbsolute(p) ? path.dirname(p) : null;
|
|
1406
|
+
for (const { section, when, reason } of CONDITIONAL_SKILL_SECTIONS) {
|
|
1407
|
+
if (skillDir !== null && when(skillDir) && !sectionMatches(section, sections)) {
|
|
1408
|
+
issues.push(
|
|
1409
|
+
new Issue(
|
|
1410
|
+
'error',
|
|
1411
|
+
'missing_conditional_section',
|
|
1412
|
+
`Missing required section: ${section} (${reason})`,
|
|
1413
|
+
),
|
|
1414
|
+
);
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1362
1418
|
for (const section of RECOMMENDED_SKILL_SECTIONS) {
|
|
1363
1419
|
if (!sectionMatches(section, sections)) {
|
|
1364
1420
|
issues.push(
|
|
@@ -3533,7 +3589,7 @@ export function lint_file(p: string, repoRoot: string | null = null): LintResult
|
|
|
3533
3589
|
|
|
3534
3590
|
let result: LintResult;
|
|
3535
3591
|
if (artifactType === 'skill') {
|
|
3536
|
-
result = lint_skill(displayPath, text);
|
|
3592
|
+
result = lint_skill(displayPath, text, p);
|
|
3537
3593
|
} else if (artifactType === 'rule') {
|
|
3538
3594
|
result = lint_rule(displayPath, text);
|
|
3539
3595
|
} else if (artifactType === 'command') {
|