@event4u/agent-config 9.34.0 → 9.36.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 +64 -77
- package/CONTRIBUTING.md +23 -14
- package/dist/agent-src/commands/analyze/reference-repo.md +135 -42
- package/dist/agent-src/commands/roadmap/next.md +37 -5
- package/dist/agent-src/contexts/augment-infrastructure.md +1 -1
- package/dist/agent-src/rules/design-review-after-ui-write.md +53 -6
- package/dist/agent-src/rules/improve-before-implement.md +1 -1
- package/dist/agent-src/rules/ui-audit-gate.md +35 -0
- package/dist/agent-src/scripts/roadmap_gates.ts +119 -4
- package/dist/agent-src/skills/design-system-capture/SKILL.md +2 -2
- package/dist/agent-src/skills/existing-ui-audit/SKILL.md +1 -1
- package/dist/agent-src/skills/fe-design/SKILL.md +84 -12
- package/dist/agent-src/skills/fe-design/evals/triggers.json +11 -3
- package/dist/agent-src/skills/prediction-pool-optimizer/SKILL.md +5 -5
- package/dist/agent-src/skills/react-shadcn-ui/SKILL.md +1 -1
- package/dist/agent-src/skills/skill-writing/SKILL.md +24 -0
- package/dist/agent-src/skills/subagent-orchestration/prompts/README.md +29 -0
- package/dist/agent-src/skills/testing-anti-patterns/SKILL.md +25 -0
- package/dist/agent-src/templates/AGENTS.md +1 -1
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/agent-src/templates/scripts/memory_lookup.ts +1 -2
- package/dist/agent-src/templates/scripts/memory_signal.ts +2 -4
- package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/decision_gate.ts +35 -5
- package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation.ts +252 -0
- package/dist/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation_store.ts +307 -0
- package/dist/cli/registry.js +2 -2
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-YPAWXXXR.js → chunk-55RI5UXK.js} +17 -0
- package/dist/cli-delegate/{chunk-UVKNIJH2.js → chunk-JP53LNVT.js} +12 -0
- package/dist/cli-delegate/{chunk-736JCWLM.js → chunk-M2DPGFZ4.js} +10 -0
- package/dist/cli-delegate/{chunk-MA4GFWDY.js → chunk-NR7HSN3P.js} +2 -2
- package/dist/cli-delegate/{chunk-REMEZSVP.js → chunk-SFGARMAB.js} +13 -3
- package/dist/cli-delegate/{chunk-DS6BLDMG.js → chunk-U7CMAIOC.js} +7 -2
- package/dist/cli-delegate/cmd_conformance.js +4 -4
- package/dist/cli-delegate/cmd_doctor.js +3 -3
- package/dist/cli-delegate/cmd_export.js +3 -3
- package/dist/cli-delegate/cmd_handoff.js +1 -1
- package/dist/cli-delegate/cmd_migrate.js +1 -1
- package/dist/cli-delegate/cmd_prune.js +3 -3
- package/dist/cli-delegate/cmd_refresh.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +8 -1
- package/dist/cli-delegate/cmd_settings_migrate.js +3 -3
- package/dist/cli-delegate/cmd_settings_set.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +3 -3
- package/dist/cli-delegate/cmd_uninstall.js +3 -3
- package/dist/cli-delegate/cmd_upgrade.js +1 -1
- package/dist/cli-delegate/cmd_validate.js +3 -3
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +14 -14
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +789 -401
- package/dist/install/install.mjs +21 -4
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +99 -7
- package/dist/router.json +1 -1
- package/dist/server/schemas/settings.js +6 -0
- package/dist/server/schemas/settings.js.map +1 -1
- package/docs/contracts/settings-classes.md +5 -2
- package/docs/guidelines/agent-infra/agent-interaction-and-decision-quality.md +46 -4
- package/package.json +2 -2
- package/src/agent-src/scripts/roadmap_gates.ts +119 -4
- package/src/agent-src/templates/AGENTS.md +1 -1
- package/src/agent-src/templates/scripts/memory_lookup.ts +1 -2
- package/src/agent-src/templates/scripts/memory_signal.ts +2 -4
- package/src/agent-src/templates/scripts/work_engine/hooks/builtin/decision_gate.ts +35 -5
- package/src/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation.ts +252 -0
- package/src/agent-src/templates/scripts/work_engine/hooks/builtin/staged_confirmation_store.ts +307 -0
- package/src/config/agent-settings.template.yml +43 -2
- package/src/config/gate-coverage.yml +12 -0
- package/src/config/quorum-attendance-budget.json +10 -2
- package/src/scripts/_cli/cmd_doctor.ts +32 -0
- package/src/scripts/_cli/cmd_migrate.ts +26 -3
- package/src/scripts/_cli/cmd_refresh.ts +29 -0
- package/src/scripts/_cli/cmd_session_recycle.ts +40 -2
- package/src/scripts/_dispatch.bash +6 -2
- package/src/scripts/_lib/hook_settings.ts +79 -0
- package/src/scripts/_lib/session_register.ts +67 -1
- package/src/scripts/_lib/ui_surface.ts +97 -0
- package/src/scripts/ai_council/config.ts +60 -0
- package/src/scripts/ai_council/events_log.ts +103 -4
- package/src/scripts/ai_council/quorum.ts +83 -6
- package/src/scripts/capture_skill_catalogue.ts +483 -0
- package/src/scripts/council_cli.ts +36 -1
- package/src/scripts/hook_manifest.yaml +18 -3
- package/src/scripts/hooks/concern_registry.ts +2 -0
- package/src/scripts/hooks/design_slop_hook.ts +1 -1
- package/src/scripts/hooks/turn_end_gate_hook.ts +172 -11
- package/src/scripts/hooks/ui_route_nudge_hook.ts +250 -0
- package/src/scripts/lint_framework_leakage.ts +195 -28
- package/src/scripts/lint_framework_leakage_allowlist.json +94 -86
- package/src/scripts/lint_roadmap_complexity.ts +60 -0
- package/src/scripts/lint_rule_skill_pack_reach.ts +263 -0
- package/src/scripts/memory_lookup.ts +1 -2
- package/src/scripts/report_consultation_rate.ts +358 -0
- package/src/scripts/report_skill_activation.ts +5 -1
- package/src/scripts/schemas/command.schema.json +4 -0
- package/src/scripts/schemas/skill.schema.json +4 -0
- package/src/scripts/session_register_hook.ts +238 -18
- package/src/scripts/sessions_cli.ts +377 -12
- package/src/server/schemas/settings.ts +12 -0
- /package/dist/agent-src/skills/design-system-capture/{reference → references}/design-system-json.md +0 -0
- /package/dist/agent-src/skills/prediction-pool-optimizer/{reference → references}/ev-fixtures.md +0 -0
- /package/dist/agent-src/skills/prediction-pool-optimizer/{reference → references}/odds-and-bonus.md +0 -0
- /package/dist/agent-src/skills/react-shadcn-ui/{reference → references}/registry.md +0 -0
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* lint_rule_skill_pack_reach — a rule and the skills it routes to must be able
|
|
4
|
+
* to arrive in the same install.
|
|
5
|
+
*
|
|
6
|
+
* The defect that motivated this is real and was undocumented in the direction
|
|
7
|
+
* it actually runs. `ui-audit-gate` and `design-review-after-ui-write` are
|
|
8
|
+
* `packs: [frontend-design]`; `existing-ui-audit`, `design-review` and
|
|
9
|
+
* `fe-design` are `packs: [engineering-base]`. The direction the rule prose
|
|
10
|
+
* described — frontend-design without engineering-base — cannot happen, because
|
|
11
|
+
* `frontend-design` declares `requires: [engineering-base]`. The reverse can: a
|
|
12
|
+
* plain `laravel` or `react` install carries the skills and **no rule that ever
|
|
13
|
+
* routes to them**, since both packs only *suggest* frontend-design.
|
|
14
|
+
*
|
|
15
|
+
* TWO CHECKS, AND THEY ARE NOT THE SAME QUESTION.
|
|
16
|
+
*
|
|
17
|
+
* unreachable-route (error) — a rule routes to a skill that a pack-legal
|
|
18
|
+
* install of that rule cannot receive. The consumer gets the obligation
|
|
19
|
+
* without the artefact that discharges it.
|
|
20
|
+
*
|
|
21
|
+
* unrouted-skill (advisory) — a skill that IS routed somewhere reaches a pack
|
|
22
|
+
* none of its routing rules reach. The consumer has the artefact and no path
|
|
23
|
+
* to it. This is the measured case above.
|
|
24
|
+
*
|
|
25
|
+
* Scope, stated because the obvious reading is wider than the scan: the
|
|
26
|
+
* subject set is skills some rule already routes to. A skill NO rule routes
|
|
27
|
+
* to anywhere is never examined, deliberately — most of the estate is in
|
|
28
|
+
* that set by design (a skill invoked by name needs no rule), so reporting
|
|
29
|
+
* it would bury the finding this check exists for. Advisory for the same
|
|
30
|
+
* reason: reachability-only-by-invocation is a legitimate design, so a
|
|
31
|
+
* finding here is a question, not a verdict.
|
|
32
|
+
*
|
|
33
|
+
* The install closure is `pack + requires*` — the transitive expansion the
|
|
34
|
+
* resolver performs. `suggests` is deliberately NOT followed: it is advisory,
|
|
35
|
+
* the wizard may offer it and the user may decline, so an invariant that
|
|
36
|
+
* counted it would pass on installs that do not exist.
|
|
37
|
+
*
|
|
38
|
+
* ADVISORY UNTIL ITS FINDING SET IS EMPTY, AND THE FIRST RUN SAYS WHY.
|
|
39
|
+
* Measured on introduction: 12 `unreachable-route` and 14 `unrouted-skill`
|
|
40
|
+
* findings across 116 rules. The UI pair is one instance of an estate-wide
|
|
41
|
+
* pattern — safety floors routing into packs their own pack does not pull, and
|
|
42
|
+
* skills delivered by a pack whose routing rule ships in a different one.
|
|
43
|
+
* Shipping this as a blocking gate on day one would have meant either 12
|
|
44
|
+
* unrelated fixes smuggled into a feature change or a suppression file created
|
|
45
|
+
* on day one, which is the ratchet-shaped failure this repo has recorded
|
|
46
|
+
* before. So `--strict` exists and the default does not use it: promotion to
|
|
47
|
+
* error is a one-flag change once the set is empty, and the count is printed
|
|
48
|
+
* on every run so "still 12" cannot quietly become "still 30".
|
|
49
|
+
*
|
|
50
|
+
* Exit codes: 0 advisory run (default) or clean · 1 `unreachable-route`
|
|
51
|
+
* findings under `--strict` · 2 usage or IO error. A scan that walked no rules
|
|
52
|
+
* exits 2 rather than green — a gate that scanned nothing must never read as a
|
|
53
|
+
* pass.
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
import * as fs from 'node:fs';
|
|
57
|
+
import * as path from 'node:path';
|
|
58
|
+
import { fileURLToPath } from 'node:url';
|
|
59
|
+
|
|
60
|
+
import { parse as parseYaml } from 'yaml';
|
|
61
|
+
|
|
62
|
+
const _HERE = fileURLToPath(import.meta.url);
|
|
63
|
+
const REPO = path.resolve(path.dirname(_HERE), '..', '..');
|
|
64
|
+
const RULES_DIR = path.join(REPO, 'src', 'rules');
|
|
65
|
+
const SKILLS_DIR = path.join(REPO, 'src', 'skills');
|
|
66
|
+
const PACKS_FILE = path.join(REPO, 'src', 'config', 'discovery', 'packs.yml');
|
|
67
|
+
|
|
68
|
+
export interface PackDef {
|
|
69
|
+
id: string;
|
|
70
|
+
requires: string[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface Finding {
|
|
74
|
+
kind: 'unreachable-route' | 'unrouted-skill';
|
|
75
|
+
subject: string;
|
|
76
|
+
detail: string;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Frontmatter block between the leading fences, or "" when absent. */
|
|
80
|
+
function frontmatterOf(content: string): string {
|
|
81
|
+
if (!content.startsWith('---')) return '';
|
|
82
|
+
const end = content.indexOf('\n---', 3);
|
|
83
|
+
return end === -1 ? '' : content.slice(3, end);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function frontmatterObject(file: string): Record<string, unknown> {
|
|
87
|
+
const raw = frontmatterOf(fs.readFileSync(file, 'utf-8'));
|
|
88
|
+
if (!raw.trim()) return {};
|
|
89
|
+
try {
|
|
90
|
+
const parsed = parseYaml(raw) as unknown;
|
|
91
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
92
|
+
? (parsed as Record<string, unknown>)
|
|
93
|
+
: {};
|
|
94
|
+
} catch {
|
|
95
|
+
return {};
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function stringList(value: unknown): string[] {
|
|
100
|
+
if (Array.isArray(value)) return value.filter((v): v is string => typeof v === 'string');
|
|
101
|
+
if (typeof value === 'string') return [value];
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Parse the pack registry into id → hard requirements. */
|
|
106
|
+
export function readPacks(file: string): Map<string, PackDef> {
|
|
107
|
+
const parsed = parseYaml(fs.readFileSync(file, 'utf-8')) as unknown;
|
|
108
|
+
const out = new Map<string, PackDef>();
|
|
109
|
+
if (!Array.isArray(parsed)) return out;
|
|
110
|
+
for (const entry of parsed) {
|
|
111
|
+
if (!entry || typeof entry !== 'object') continue;
|
|
112
|
+
const record = entry as Record<string, unknown>;
|
|
113
|
+
const id = record['id'];
|
|
114
|
+
if (typeof id !== 'string') continue;
|
|
115
|
+
out.set(id, { id, requires: stringList(record['requires']) });
|
|
116
|
+
}
|
|
117
|
+
return out;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** `pack + requires*` — what an install selecting `pack` actually receives. */
|
|
121
|
+
export function installClosure(pack: string, packs: Map<string, PackDef>): Set<string> {
|
|
122
|
+
const seen = new Set<string>();
|
|
123
|
+
const queue = [pack];
|
|
124
|
+
while (queue.length > 0) {
|
|
125
|
+
const current = queue.pop()!;
|
|
126
|
+
if (seen.has(current)) continue;
|
|
127
|
+
seen.add(current);
|
|
128
|
+
for (const required of packs.get(current)?.requires ?? []) queue.push(required);
|
|
129
|
+
}
|
|
130
|
+
return seen;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
interface RuleInfo {
|
|
134
|
+
name: string;
|
|
135
|
+
packs: string[];
|
|
136
|
+
routesToSkills: string[];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function readRules(dir: string): RuleInfo[] {
|
|
140
|
+
return fs
|
|
141
|
+
.readdirSync(dir)
|
|
142
|
+
.filter((f) => f.endsWith('.md'))
|
|
143
|
+
.map((f) => {
|
|
144
|
+
const fm = frontmatterObject(path.join(dir, f));
|
|
145
|
+
return {
|
|
146
|
+
name: f.replace(/\.md$/, ''),
|
|
147
|
+
packs: stringList(fm['packs']),
|
|
148
|
+
routesToSkills: stringList(fm['routes_to'])
|
|
149
|
+
.filter((r) => r.startsWith('skill:'))
|
|
150
|
+
.map((r) => r.slice('skill:'.length)),
|
|
151
|
+
};
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function readSkillPacks(dir: string): Map<string, string[]> {
|
|
156
|
+
const out = new Map<string, string[]>();
|
|
157
|
+
for (const name of fs.readdirSync(dir)) {
|
|
158
|
+
const file = path.join(dir, name, 'SKILL.md');
|
|
159
|
+
if (!fs.existsSync(file)) continue;
|
|
160
|
+
out.set(name, stringList(frontmatterObject(file)['packs']));
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export function analyze(
|
|
166
|
+
rules: RuleInfo[],
|
|
167
|
+
skillPacks: Map<string, string[]>,
|
|
168
|
+
packs: Map<string, PackDef>,
|
|
169
|
+
): Finding[] {
|
|
170
|
+
const findings: Finding[] = [];
|
|
171
|
+
|
|
172
|
+
// 1. unreachable-route — the obligation without the artefact.
|
|
173
|
+
for (const rule of rules) {
|
|
174
|
+
if (rule.packs.length === 0) continue; // unscoped rule ships everywhere
|
|
175
|
+
for (const skill of rule.routesToSkills) {
|
|
176
|
+
const target = skillPacks.get(skill);
|
|
177
|
+
if (target === undefined) continue; // a missing skill is check_references' job
|
|
178
|
+
if (target.length === 0) continue; // unscoped skill ships everywhere
|
|
179
|
+
const unreachableFrom = rule.packs.filter((p) => {
|
|
180
|
+
const closure = installClosure(p, packs);
|
|
181
|
+
return !target.some((tp) => closure.has(tp));
|
|
182
|
+
});
|
|
183
|
+
if (unreachableFrom.length === 0) continue;
|
|
184
|
+
findings.push({
|
|
185
|
+
kind: 'unreachable-route',
|
|
186
|
+
subject: `${rule.name} → skill:${skill}`,
|
|
187
|
+
detail: `rule pack(s) [${unreachableFrom.join(', ')}] do not reach skill pack(s) [${target.join(', ')}]`,
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// 2. unrouted-skill — the artefact without a path to it.
|
|
193
|
+
const routedSkills = new Set(rules.flatMap((r) => r.routesToSkills));
|
|
194
|
+
for (const skill of routedSkills) {
|
|
195
|
+
const target = skillPacks.get(skill);
|
|
196
|
+
if (target === undefined || target.length === 0) continue;
|
|
197
|
+
const routingRules = rules.filter((r) => r.routesToSkills.includes(skill));
|
|
198
|
+
for (const skillPack of target) {
|
|
199
|
+
const closure = installClosure(skillPack, packs);
|
|
200
|
+
const reached = routingRules.some(
|
|
201
|
+
(r) => r.packs.length === 0 || r.packs.some((p) => closure.has(p)),
|
|
202
|
+
);
|
|
203
|
+
if (reached) continue;
|
|
204
|
+
findings.push({
|
|
205
|
+
kind: 'unrouted-skill',
|
|
206
|
+
subject: `skill:${skill}`,
|
|
207
|
+
detail: `an install of pack [${skillPack}] receives this skill but none of its routing rule(s) [${routingRules.map((r) => r.name).join(', ')}]`,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return findings;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function main(): number {
|
|
216
|
+
const packs = readPacks(PACKS_FILE);
|
|
217
|
+
const rules = readRules(RULES_DIR);
|
|
218
|
+
const skillPacks = readSkillPacks(SKILLS_DIR);
|
|
219
|
+
|
|
220
|
+
if (rules.length === 0 || skillPacks.size === 0 || packs.size === 0) {
|
|
221
|
+
process.stderr.write(
|
|
222
|
+
`❌ lint_rule_skill_pack_reach scanned nothing (rules=${rules.length}, skills=${skillPacks.size}, packs=${packs.size}) — refusing to report a pass\n`,
|
|
223
|
+
);
|
|
224
|
+
return 2;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const findings = analyze(rules, skillPacks, packs);
|
|
228
|
+
const errors = findings.filter((f) => f.kind === 'unreachable-route');
|
|
229
|
+
const advisories = findings.filter((f) => f.kind === 'unrouted-skill');
|
|
230
|
+
|
|
231
|
+
// --quiet suppresses the per-finding lines but never the count line: this
|
|
232
|
+
// gate is expected to stay non-empty for a while, and a quiet run that
|
|
233
|
+
// printed nothing at all would read as clean.
|
|
234
|
+
const quiet = process.argv.includes('--quiet');
|
|
235
|
+
if (!quiet) {
|
|
236
|
+
for (const finding of errors) {
|
|
237
|
+
process.stdout.write(`❌ ${finding.subject} — ${finding.detail}\n`);
|
|
238
|
+
}
|
|
239
|
+
for (const finding of advisories) {
|
|
240
|
+
process.stdout.write(`⚠️ ${finding.subject} — ${finding.detail}\n`);
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const strict = process.argv.includes('--strict');
|
|
245
|
+
process.stdout.write(
|
|
246
|
+
`scanned: ${rules.length} rule(s), ${skillPacks.size} skill(s), ${packs.size} pack(s) — ${errors.length} unreachable-route, ${advisories.length} unrouted-skill\n`,
|
|
247
|
+
);
|
|
248
|
+
if (errors.length > 0 && !strict) {
|
|
249
|
+
process.stdout.write(
|
|
250
|
+
`advisory run: ${errors.length} unreachable-route finding(s) reported, exit 0. Promote with --strict once the set is empty.\n`,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
return strict && errors.length > 0 ? 1 : 0;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(_HERE)) {
|
|
257
|
+
try {
|
|
258
|
+
process.exit(main());
|
|
259
|
+
} catch (error) {
|
|
260
|
+
process.stderr.write(`❌ ${(error as Error).message}\n`);
|
|
261
|
+
process.exit(2);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
@@ -529,8 +529,7 @@ function* _iter_knowledge_entries(): Generator<[string, Record<string, unknown>]
|
|
|
529
529
|
/**
|
|
530
530
|
* Naive relevance score: max over keys of (glob-match | substring).
|
|
531
531
|
*
|
|
532
|
-
*
|
|
533
|
-
* The `present` path returns a real score from agent-memory.
|
|
532
|
+
* Retrieval is best-effort by design — this is the only scoring path.
|
|
534
533
|
*
|
|
535
534
|
* DOCUMENTED FALLBACK (road-to-reachable-code-memory Phase 6 / ADR-129):
|
|
536
535
|
* this is the primary, always-available scorer. It has a known correctness
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* report_consultation_rate — the consultation half of the pre-registered
|
|
4
|
+
* frontend-skill metrics, computed from transcripts.
|
|
5
|
+
*
|
|
6
|
+
* WHAT IT MEASURES, AND WHY ONLY HALF.
|
|
7
|
+
* `agents/settings/contexts/skill-catalogue-baseline.md` pre-registers two
|
|
8
|
+
* rates. Exactly one of them is mechanically computable, and the reason the
|
|
9
|
+
* other is not is a roadmap non-goal rather than an oversight:
|
|
10
|
+
*
|
|
11
|
+
* CONSULTATION RATE — share of UI-write turns in a session that happened
|
|
12
|
+
* AFTER the session opened a design surface. Every input is a tool call:
|
|
13
|
+
* the write, its path, the prior read. Computable, and computed here.
|
|
14
|
+
*
|
|
15
|
+
* DISCHARGE RATE — share of UI-write turns followed by a review verdict
|
|
16
|
+
* that is "render-scoped or explicitly static-scoped". A verdict is PROSE.
|
|
17
|
+
* Deciding whether a paragraph is a scoped verdict means matching prose,
|
|
18
|
+
* which the roadmap's non-goals forbid ("no prose-matching missed-activation
|
|
19
|
+
* detector", the FC-8 boundary the round-6 phase drew). So this script does
|
|
20
|
+
* NOT compute it, and does not approximate it under its name.
|
|
21
|
+
*
|
|
22
|
+
* What it offers instead is a labelled PROXY: how often a UI-write turn is
|
|
23
|
+
* followed, in the same session, by opening `design-review`. That is a
|
|
24
|
+
* consultation-shaped fact about the review skill, not a fact about a verdict —
|
|
25
|
+
* an agent can open the skill and still write "looks good". Reported under its
|
|
26
|
+
* own name so it can never be quoted as the discharge rate.
|
|
27
|
+
*
|
|
28
|
+
* ONE EVENT STREAM, NOT TWO. The UI-write and consultation predicates are
|
|
29
|
+
* imported from the `ui-route-nudge` concern rather than restated. A metric
|
|
30
|
+
* whose denominator drifted from the nudge's trigger would make any later A/B
|
|
31
|
+
* uninterpretable — the two would be measuring different populations while
|
|
32
|
+
* appearing to measure one.
|
|
33
|
+
*
|
|
34
|
+
* PRIVACY, stated at the precision the code actually holds. The text report is
|
|
35
|
+
* counts and rates only. The `--json` form additionally echoes the SCAN ROOT
|
|
36
|
+
* (`store`) — the directory the caller pointed at, which is a local path and is
|
|
37
|
+
* the one thing a reader needs to know what was measured. Nothing else crosses:
|
|
38
|
+
* no transcript filename, no session id, no path from inside a session, no
|
|
39
|
+
* prompt or file content. The measurement reads those and keeps none of them.
|
|
40
|
+
*
|
|
41
|
+
* Exit: 0 always, except a usage/IO error (1). Advisory — it gates nothing.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import * as fs from 'node:fs';
|
|
45
|
+
import * as os from 'node:os';
|
|
46
|
+
import * as path from 'node:path';
|
|
47
|
+
import { fileURLToPath } from 'node:url';
|
|
48
|
+
|
|
49
|
+
import {
|
|
50
|
+
isConsultation,
|
|
51
|
+
isUiWrite,
|
|
52
|
+
type ToolEvent,
|
|
53
|
+
} from './hooks/ui_route_nudge_hook.js';
|
|
54
|
+
|
|
55
|
+
const _HERE = fileURLToPath(import.meta.url);
|
|
56
|
+
|
|
57
|
+
/** Tool names that propose new file content on this host. */
|
|
58
|
+
const WRITE_TOOLS = new Set(['Write', 'Edit', 'NotebookEdit', 'MultiEdit']);
|
|
59
|
+
|
|
60
|
+
/** The skill whose opening the discharge PROXY counts. */
|
|
61
|
+
const REVIEW_SURFACE = 'design-review';
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* One transcript tool call, tagged with the assistant turn it belongs to.
|
|
65
|
+
*
|
|
66
|
+
* The turn index is what makes the published unit honest. The pre-registered
|
|
67
|
+
* metric is a share of UI *turns*; an earlier version counted `tool_use` parts
|
|
68
|
+
* and published the count as turns, which inflates any turn that writes two
|
|
69
|
+
* files and mixes a per-session numerator with a per-call denominator.
|
|
70
|
+
*/
|
|
71
|
+
export interface TimedEvent {
|
|
72
|
+
event: ToolEvent;
|
|
73
|
+
turn: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface SessionMeasurement {
|
|
77
|
+
/** Assistant turns containing at least one UI write. */
|
|
78
|
+
uiWriteTurns: number;
|
|
79
|
+
/** Those turns that followed a consultation in the same session. */
|
|
80
|
+
consulted: number;
|
|
81
|
+
/** Those turns followed later by opening the review skill. */
|
|
82
|
+
reviewOpenedAfter: number;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface RateReport {
|
|
86
|
+
store: string;
|
|
87
|
+
/** False when the store directory does not exist — never the same as empty. */
|
|
88
|
+
storeExists: boolean;
|
|
89
|
+
/** Transcripts read. Capped by --limit; the cap is reported when it bites. */
|
|
90
|
+
sessions: number;
|
|
91
|
+
/** True when --limit truncated the store rather than reading all of it. */
|
|
92
|
+
truncated: boolean;
|
|
93
|
+
sessionsWithUiWrite: number;
|
|
94
|
+
/** Assistant turns containing at least one UI write — the denominator. */
|
|
95
|
+
uiWriteTurns: number;
|
|
96
|
+
consulted: number;
|
|
97
|
+
reviewOpenedAfter: number;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Default transcript store for a project directory.
|
|
102
|
+
*
|
|
103
|
+
* The host flattens the path by replacing BOTH separators and dots — a worktree
|
|
104
|
+
* at `<repo>/.claude/worktrees/x` lands in `…-agent-config--claude-worktrees-x`,
|
|
105
|
+
* with two dashes where `/.` was. Replacing only `/` (the shape the older census
|
|
106
|
+
* helper carries) produces a directory that does not exist, and the caller then
|
|
107
|
+
* reads a clean zero out of a store it never found.
|
|
108
|
+
*/
|
|
109
|
+
export function defaultStore(cwd: string): string {
|
|
110
|
+
return path.join(os.homedir(), '.claude', 'projects', cwd.replace(/[/.]/g, '-'));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Map one transcript `tool_use` part onto the event shape the nudge decides
|
|
115
|
+
* over. Returns null for parts that carry no path — they cannot be either a
|
|
116
|
+
* UI write or a consultation.
|
|
117
|
+
*/
|
|
118
|
+
export function toolUseToEvent(part: Record<string, unknown>): ToolEvent | null {
|
|
119
|
+
const name = part['name'];
|
|
120
|
+
const input = part['input'];
|
|
121
|
+
if (typeof name !== 'string' || input === null || typeof input !== 'object') return null;
|
|
122
|
+
const ti = input as Record<string, unknown>;
|
|
123
|
+
const fileVal = ti['file_path'] ?? ti['path'] ?? ti['filePath'] ?? ti['notebook_path'];
|
|
124
|
+
if (typeof fileVal !== 'string' || fileVal === '') return null;
|
|
125
|
+
return { file: fileVal, isWrite: WRITE_TOOLS.has(name) };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Walk one session's tool events in order and measure it.
|
|
130
|
+
*
|
|
131
|
+
* Shares the nudge's PREDICATES (`isUiWrite`, `isConsultation`) but not its
|
|
132
|
+
* `decide`, deliberately: `decide` stops warning after `MAX_NUDGES`, which is
|
|
133
|
+
* correct for a reminder and wrong for a denominator — measuring through it
|
|
134
|
+
* would silently drop every UI write past the second one in a session.
|
|
135
|
+
*
|
|
136
|
+
* Ordering is load-bearing. A consultation counts only for writes that come
|
|
137
|
+
* after it; a session that opens `fe-design` at the end does not retroactively
|
|
138
|
+
* consult for the file it already wrote.
|
|
139
|
+
*/
|
|
140
|
+
export function measureSession(timed: readonly TimedEvent[]): SessionMeasurement {
|
|
141
|
+
const out: SessionMeasurement = { uiWriteTurns: 0, consulted: 0, reviewOpenedAfter: 0 };
|
|
142
|
+
let consulted = false;
|
|
143
|
+
/** turn -> was the session already consulted when that turn first wrote UI */
|
|
144
|
+
const writeTurns = new Map<number, boolean>();
|
|
145
|
+
let lastReviewOpenAt = -1;
|
|
146
|
+
const reviewOpenPositions: number[] = [];
|
|
147
|
+
const writePositions: Array<{ position: number; turn: number }> = [];
|
|
148
|
+
|
|
149
|
+
timed.forEach(({ event, turn }, position) => {
|
|
150
|
+
if (isUiWrite(event)) {
|
|
151
|
+
if (!writeTurns.has(turn)) {
|
|
152
|
+
writeTurns.set(turn, consulted);
|
|
153
|
+
writePositions.push({ position, turn });
|
|
154
|
+
}
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
if (isConsultation(event)) {
|
|
158
|
+
consulted = true;
|
|
159
|
+
if (event.file.replace(/\\/g, '/').toLowerCase().includes(`skills/${REVIEW_SURFACE}/`)) {
|
|
160
|
+
reviewOpenPositions.push(position);
|
|
161
|
+
lastReviewOpenAt = position;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
out.uiWriteTurns = writeTurns.size;
|
|
167
|
+
for (const wasConsulted of writeTurns.values()) if (wasConsulted) out.consulted += 1;
|
|
168
|
+
// A single max comparison, not a scan per write: the proxy asks only whether
|
|
169
|
+
// ANY review-open follows, so the last one decides every earlier write.
|
|
170
|
+
void reviewOpenPositions;
|
|
171
|
+
for (const { position } of writePositions) {
|
|
172
|
+
if (lastReviewOpenAt > position) out.reviewOpenedAfter += 1;
|
|
173
|
+
}
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Read every `tool_use` part of one transcript file, in order. */
|
|
178
|
+
export function readSessionEvents(file: string): TimedEvent[] {
|
|
179
|
+
const events: TimedEvent[] = [];
|
|
180
|
+
let turn = -1;
|
|
181
|
+
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
|
|
182
|
+
if (!line.trim()) continue;
|
|
183
|
+
let entry: Record<string, unknown>;
|
|
184
|
+
try {
|
|
185
|
+
entry = JSON.parse(line) as Record<string, unknown>;
|
|
186
|
+
} catch {
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (entry['type'] !== 'assistant') continue;
|
|
190
|
+
turn += 1;
|
|
191
|
+
const msg = entry['message'];
|
|
192
|
+
const content =
|
|
193
|
+
msg !== null && typeof msg === 'object'
|
|
194
|
+
? (msg as Record<string, unknown>)['content']
|
|
195
|
+
: null;
|
|
196
|
+
if (!Array.isArray(content)) continue;
|
|
197
|
+
for (const part of content) {
|
|
198
|
+
if (part === null || typeof part !== 'object') continue;
|
|
199
|
+
const p = part as Record<string, unknown>;
|
|
200
|
+
if (p['type'] !== 'tool_use') continue;
|
|
201
|
+
const event = toolUseToEvent(p);
|
|
202
|
+
if (event) events.push({ event, turn });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
return events;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function measureStore(store: string, limit: number): RateReport {
|
|
209
|
+
const report: RateReport = {
|
|
210
|
+
store,
|
|
211
|
+
storeExists: fs.existsSync(store),
|
|
212
|
+
sessions: 0,
|
|
213
|
+
truncated: false,
|
|
214
|
+
sessionsWithUiWrite: 0,
|
|
215
|
+
uiWriteTurns: 0,
|
|
216
|
+
consulted: 0,
|
|
217
|
+
reviewOpenedAfter: 0,
|
|
218
|
+
};
|
|
219
|
+
// A store that is not there and a store with nothing in it are different
|
|
220
|
+
// findings, and only one of them is about the agent. Collapsing them into
|
|
221
|
+
// one clean zero is the failure this repo has recorded four times.
|
|
222
|
+
if (!report.storeExists) return report;
|
|
223
|
+
|
|
224
|
+
// mtime is read ONCE per file, not inside the comparator: a comparator that
|
|
225
|
+
// stats is O(n log n) syscalls and throws ENOENT if a live store rotates a
|
|
226
|
+
// transcript mid-sort — a measurement must not crash on the store being used.
|
|
227
|
+
const all = fs
|
|
228
|
+
.readdirSync(store)
|
|
229
|
+
.filter((f) => f.endsWith('.jsonl'))
|
|
230
|
+
.map((f) => path.join(store, f))
|
|
231
|
+
.map((file) => {
|
|
232
|
+
let mtimeMs = 0;
|
|
233
|
+
try {
|
|
234
|
+
mtimeMs = fs.statSync(file).mtimeMs;
|
|
235
|
+
} catch {
|
|
236
|
+
/* vanished between readdir and stat — sort it last, skip it below */
|
|
237
|
+
}
|
|
238
|
+
return { file, mtimeMs };
|
|
239
|
+
})
|
|
240
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
241
|
+
|
|
242
|
+
const files = all.slice(0, limit).map((f) => f.file);
|
|
243
|
+
report.truncated = all.length > files.length;
|
|
244
|
+
report.sessions = files.length;
|
|
245
|
+
for (const file of files) {
|
|
246
|
+
let measured: SessionMeasurement;
|
|
247
|
+
try {
|
|
248
|
+
measured = measureSession(readSessionEvents(file));
|
|
249
|
+
} catch {
|
|
250
|
+
continue; // rotated or unreadable transcript — skip, never crash
|
|
251
|
+
}
|
|
252
|
+
if (measured.uiWriteTurns === 0) continue;
|
|
253
|
+
report.sessionsWithUiWrite += 1;
|
|
254
|
+
report.uiWriteTurns += measured.uiWriteTurns;
|
|
255
|
+
report.consulted += measured.consulted;
|
|
256
|
+
report.reviewOpenedAfter += measured.reviewOpenedAfter;
|
|
257
|
+
}
|
|
258
|
+
return report;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function pct(n: number, d: number): string {
|
|
262
|
+
return d === 0 ? 'n/a' : `${((100 * n) / d).toFixed(1)}%`;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function render(report: RateReport, threshold: number): string {
|
|
266
|
+
const lines: string[] = [];
|
|
267
|
+
lines.push('consultation rate — advisory, gates on nothing');
|
|
268
|
+
lines.push('');
|
|
269
|
+
if (!report.storeExists) {
|
|
270
|
+
lines.push(' ❌ transcript store not found — nothing was measured.');
|
|
271
|
+
lines.push(' This is not a rate of zero. Point --store at a real store;');
|
|
272
|
+
lines.push(' `ls ~/.claude/projects` lists them.');
|
|
273
|
+
return lines.join('\n');
|
|
274
|
+
}
|
|
275
|
+
lines.push(
|
|
276
|
+
` sessions scanned ${report.sessions}${report.truncated ? ' (TRUNCATED by --limit)' : ''}`,
|
|
277
|
+
);
|
|
278
|
+
lines.push(` sessions with a UI write ${report.sessionsWithUiWrite}`);
|
|
279
|
+
lines.push(` UI-write turns ${report.uiWriteTurns}`);
|
|
280
|
+
lines.push('');
|
|
281
|
+
lines.push(
|
|
282
|
+
` CONSULTATION RATE ${pct(report.consulted, report.uiWriteTurns)} (${report.consulted}/${report.uiWriteTurns})`,
|
|
283
|
+
);
|
|
284
|
+
lines.push(
|
|
285
|
+
` discharge PROXY (review opened) ${pct(report.reviewOpenedAfter, report.uiWriteTurns)} (${report.reviewOpenedAfter}/${report.uiWriteTurns})`,
|
|
286
|
+
);
|
|
287
|
+
lines.push('');
|
|
288
|
+
|
|
289
|
+
if (report.sessionsWithUiWrite < threshold) {
|
|
290
|
+
lines.push(
|
|
291
|
+
` ⚠ ${report.sessionsWithUiWrite} session(s) with a UI write — below the pre-registered floor of ${threshold}.`,
|
|
292
|
+
);
|
|
293
|
+
lines.push(' Report the rate as provisional; it is not yet a baseline.');
|
|
294
|
+
lines.push('');
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
lines.push(' The PROXY is not the discharge rate. It counts a UI-write turn followed by');
|
|
298
|
+
lines.push(' opening `design-review` in the same session. A verdict is prose, and matching');
|
|
299
|
+
lines.push(' prose is a roadmap non-goal, so the discharge rate stays unmeasured rather');
|
|
300
|
+
lines.push(' than approximated under its own name.');
|
|
301
|
+
return lines.join('\n');
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Value of `flag`, or `fallback` when it is absent.
|
|
306
|
+
*
|
|
307
|
+
* Throws when the flag is present with no value after it. The earlier version
|
|
308
|
+
* returned the fallback there, so `--store` as the last argv element silently
|
|
309
|
+
* measured the DEFAULT store — and since the text report never prints the scan
|
|
310
|
+
* root, the substitution was invisible in exactly the output a reader would
|
|
311
|
+
* publish. A usage error is the only honest answer to "you asked for a store
|
|
312
|
+
* and named none".
|
|
313
|
+
*/
|
|
314
|
+
function argValue(flag: string, fallback: string): string {
|
|
315
|
+
const index = process.argv.indexOf(flag);
|
|
316
|
+
if (index === -1) return fallback;
|
|
317
|
+
const value = process.argv[index + 1];
|
|
318
|
+
if (value === undefined || value.startsWith('--')) {
|
|
319
|
+
throw new Error(`${flag} requires a value`);
|
|
320
|
+
}
|
|
321
|
+
return value;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** Parse a positive-integer flag, or fail loudly. Never silently NaN. */
|
|
325
|
+
function intArg(flag: string, fallback: string): number {
|
|
326
|
+
const raw = argValue(flag, fallback);
|
|
327
|
+
const parsed = Number.parseInt(raw, 10);
|
|
328
|
+
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
329
|
+
throw new Error(`${flag} must be a positive integer (got ${JSON.stringify(raw)})`);
|
|
330
|
+
}
|
|
331
|
+
return parsed;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function main(): number {
|
|
335
|
+
const store = argValue('--store', defaultStore(process.cwd()));
|
|
336
|
+
const limit = intArg('--limit', '200');
|
|
337
|
+
// Validated for the same reason --limit is, and it was not: a NaN threshold
|
|
338
|
+
// makes `sessionsWithUiWrite < NaN` false, which SUPPRESSES the provisional
|
|
339
|
+
// warning — the run then reads as a settled baseline over any corpus.
|
|
340
|
+
const threshold = intArg('--min-sessions', '20');
|
|
341
|
+
|
|
342
|
+
const report = measureStore(store, limit);
|
|
343
|
+
if (process.argv.includes('--json')) {
|
|
344
|
+
process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
|
|
345
|
+
} else {
|
|
346
|
+
process.stdout.write(`${render(report, threshold)}\n`);
|
|
347
|
+
}
|
|
348
|
+
return 0;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(_HERE)) {
|
|
352
|
+
try {
|
|
353
|
+
process.exit(main());
|
|
354
|
+
} catch (error) {
|
|
355
|
+
process.stderr.write(`❌ ${(error as Error).message}\n`);
|
|
356
|
+
process.exit(1);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
@@ -114,7 +114,11 @@ export interface UsageReport {
|
|
|
114
114
|
|
|
115
115
|
/** Default store for the current working directory, mangled the way the host does. */
|
|
116
116
|
export function defaultStore(cwd: string): string {
|
|
117
|
-
|
|
117
|
+
// Dots are flattened too, not only separators: a worktree at
|
|
118
|
+
// `<repo>/.claude/worktrees/x` lands in `…-agent-config--claude-worktrees-x`.
|
|
119
|
+
// Replacing only `/` returns a directory that does not exist, and the caller
|
|
120
|
+
// then reads a clean zero out of a store it never found.
|
|
121
|
+
return path.join(os.homedir(), '.claude', 'projects', cwd.replace(/[/.]/g, '-'));
|
|
118
122
|
}
|
|
119
123
|
|
|
120
124
|
export function measureUsage(store: string, limit: number): UsageReport {
|
|
@@ -257,6 +257,10 @@
|
|
|
257
257
|
"type": "boolean",
|
|
258
258
|
"description": "AI OS Product UI allowlist (road-to-ai-os-product-ui.md Phase 1). When true, the command is exposed in the browser /tasks surface and may be spawned by POST /api/v1/task/run. Default false — commands that touch git, secrets, or run destructive migrations must stay false (terminal only). Per-command opt-in, never opt-out."
|
|
259
259
|
},
|
|
260
|
+
"requires_confirmation": {
|
|
261
|
+
"type": "boolean",
|
|
262
|
+
"description": "Optional, additive: the command's action STAGES rather than executes, so an irreversible step waits for an explicit human confirmation naming the exact object (non-destructive-by-default § Never act while asking). Twin of the same key in skill.schema.json's execution block; commands carry no execution block, so it sits top-level. A declaration only — it names the action class, never the object, which exists at stage time and not in frontmatter. Mechanism: stageAction/confirmOnce in work_engine/hooks/builtin/staged_confirmation.ts; enumerate with `agent-config gates --pending`. Nothing BINDS on it yet (blocker confirmation-degraded-host-semantics: 5 of 8 hosts have no pre_tool_use slot)."
|
|
263
|
+
},
|
|
260
264
|
"auto_detect": {
|
|
261
265
|
"type": "boolean",
|
|
262
266
|
"description": "6.1.0 orchestrator auto-detection opt-in (Step 1, non-interactive-contract). When true, the orchestrator auto-detects its sub-command per the confidence-tiered detection table instead of always showing the menu, honoring the non-interactive-contract (TTY/CI detection, --yes/--json, abort schemas). Per-orchestrator kill-switch: set false to always show the menu; overridable globally via commands.auto_detect in .agent-settings.yml and per-invocation via --no-auto-detect. Only orchestrators (type: orchestrator) carry it."
|
|
@@ -222,6 +222,10 @@
|
|
|
222
222
|
},
|
|
223
223
|
"description": "U3 (ecosystem harvest): optional deny-list layered UNDER allowed_tools — defense-in-depth; a tool matching here is refused even if a broad allow pattern would admit it. Supports the scoped-grant syntax (e.g. Bash(git push:*))."
|
|
224
224
|
},
|
|
225
|
+
"requires_confirmation": {
|
|
226
|
+
"type": "boolean",
|
|
227
|
+
"description": "Optional, additive: the skill's action STAGES rather than executes, so an irreversible step waits for an explicit human confirmation naming the exact object (non-destructive-by-default § Never act while asking). A declaration only — it names the action class, never the object, which exists at stage time and not in frontmatter. The mechanism is stageAction/confirmOnce in work_engine/hooks/builtin/staged_confirmation.ts; enumerate what is pending with `agent-config gates --pending`. Nothing BINDS on this flag yet: on 5 of 8 hosts there is no pre_tool_use slot, so a guard would degrade to prose while the flag reads like a control — that decision is deferred behind blocker confirmation-degraded-host-semantics."
|
|
228
|
+
},
|
|
225
229
|
"command": {
|
|
226
230
|
"type": "array",
|
|
227
231
|
"minItems": 1,
|