@event4u/agent-config 9.27.0 → 9.28.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/AGENTS.md +12 -11
- package/CHANGELOG.md +99 -0
- package/README.md +1 -1
- package/dist/agent-src/commands/analyze/conformance.md +24 -0
- package/dist/agent-src/contexts/execution/orchestration-telemetry.md +10 -3
- package/dist/agent-src/rules/artifact-engagement-recording.md +4 -1
- package/dist/agent-src/rules/autonomous-execution.md +1 -1
- package/dist/agent-src/rules/brand-source-of-truth.md +33 -6
- package/dist/agent-src/rules/context-hygiene.md +3 -1
- package/dist/agent-src/rules/council-availability.md +93 -0
- package/dist/agent-src/rules/cross-source-consistency.md +3 -0
- package/dist/agent-src/rules/delegation-policy.md +29 -5
- package/dist/agent-src/rules/design-fidelity.md +3 -0
- package/dist/agent-src/rules/evaluator-independence.md +2 -1
- package/dist/agent-src/rules/external-code-graph-interop.md +2 -1
- package/dist/agent-src/rules/finance-safety-floor.md +1 -1
- package/dist/agent-src/rules/git-history-discipline.md +1 -1
- package/dist/agent-src/rules/history-discipline.md +3 -1
- package/dist/agent-src/rules/legal-safety-floor.md +1 -1
- package/dist/agent-src/rules/no-pr-progress-comments.md +3 -1
- package/dist/agent-src/rules/onboarding-gate.md +3 -1
- package/dist/agent-src/rules/roadmap-ci-steps-policy.md +4 -1
- package/dist/agent-src/rules/role-mode-adherence.md +1 -1
- package/dist/agent-src/rules/scale-discipline.md +3 -1
- package/dist/agent-src/rules/self-repair-loop.md +103 -0
- package/dist/agent-src/rules/session-canary.md +2 -1
- package/dist/agent-src/rules/strategy-safety-floor.md +1 -1
- package/dist/agent-src/rules/token-budget-discipline.md +3 -1
- package/dist/agent-src/rules/tool-safety.md +5 -1
- package/dist/agent-src/skills/adversarial-review/SKILL.md +1 -1
- package/dist/agent-src/skills/analysis-autonomous-mode/SKILL.md +1 -1
- package/dist/agent-src/skills/performance-analysis/SKILL.md +1 -1
- package/dist/agent-src/skills/persona-improvement/SKILL.md +1 -1
- package/dist/agent-src/skills/project-analyzer/SKILL.md +1 -1
- package/dist/agent-src/skills/security-audit/SKILL.md +1 -1
- package/dist/agent-src/skills/sequential-thinking/SKILL.md +1 -1
- package/dist/agent-src/skills/skill-improvement-pipeline/SKILL.md +1 -1
- package/dist/agent-src/skills/universal-project-analysis/SKILL.md +1 -1
- package/dist/agent-src/templates/AGENTS.md +6 -10
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli/registry.js +7 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/chunk-22GCIJ2O.js +270 -0
- package/dist/cli-delegate/{chunk-BCPIOAGV.js → chunk-2BE5I2MN.js} +8 -8
- package/dist/cli-delegate/{chunk-AIIUU73N.js → chunk-7OMQCH4V.js} +1 -1
- package/dist/cli-delegate/{chunk-HJ6DAJHR.js → chunk-GB2FSUEE.js} +57 -7
- package/dist/cli-delegate/{chunk-WR6GTRHJ.js → chunk-JXARCGVN.js} +3 -3
- package/dist/cli-delegate/{chunk-3KJIMFTB.js → chunk-PQSAQKHW.js} +466 -304
- package/dist/cli-delegate/chunk-REKCMYOU.js +2991 -0
- package/dist/cli-delegate/cmd_analyze_session.js +1 -1
- package/dist/cli-delegate/cmd_brand_status.js +134 -0
- package/dist/cli-delegate/cmd_conformance.js +7 -7
- package/dist/cli-delegate/cmd_converge.js +3 -3
- package/dist/cli-delegate/cmd_doctor.js +4 -4
- package/dist/cli-delegate/cmd_explain.js +80 -330
- package/dist/cli-delegate/cmd_export.js +5 -5
- package/dist/cli-delegate/cmd_fleet.js +3 -3
- package/dist/cli-delegate/cmd_handoff.js +2 -2
- package/dist/cli-delegate/cmd_mcp_available.js +433 -0
- package/dist/cli-delegate/cmd_migrate.js +3 -3
- package/dist/cli-delegate/cmd_packs_active.js +153 -0
- package/dist/cli-delegate/cmd_preflight.js +1 -1
- package/dist/cli-delegate/cmd_prune.js +5 -5
- package/dist/cli-delegate/cmd_route_audit.js +8 -8
- package/dist/cli-delegate/cmd_route_explain.js +4 -4
- package/dist/cli-delegate/cmd_settings_get.js +272 -0
- package/dist/cli-delegate/cmd_settings_migrate.js +5 -5
- package/dist/cli-delegate/cmd_settings_set.js +7 -2967
- package/dist/cli-delegate/cmd_sync.js +5 -5
- package/dist/cli-delegate/cmd_uninstall.js +5 -5
- package/dist/cli-delegate/cmd_update.js +4 -4
- package/dist/cli-delegate/cmd_upgrade.js +4 -4
- package/dist/cli-delegate/cmd_validate.js +5 -5
- package/dist/cli-delegate/cmd_versions.js +4 -4
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +100 -40
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +3 -3
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +7 -5
- package/dist/discovery/trust-report.md +11 -11
- package/dist/discovery/workspaces.json +6 -4
- package/dist/hooks/dispatch.js +9004 -6132
- package/dist/install/install.mjs +697 -507
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +127 -20
- package/dist/router.json +1 -1
- package/dist/scripts/_lib/agent_settings.js +80 -15
- package/dist/scripts/_lib/agent_settings.js.map +1 -1
- package/docs/contracts/settings-classes.md +29 -8
- package/docs/guidelines/agent-infra/memory-access.md +14 -3
- package/package.json +1 -1
- package/src/agent-src/templates/AGENTS.md +6 -10
- package/src/config/budgets.yml +29 -0
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/gate-coverage.yml +26 -0
- package/src/scripts/_cli/cmd_brand_status.ts +174 -0
- package/src/scripts/_cli/cmd_mcp_available.ts +266 -0
- package/src/scripts/_cli/cmd_packs_active.ts +225 -0
- package/src/scripts/_cli/cmd_settings_get.ts +324 -0
- package/src/scripts/_dispatch.bash +57 -0
- package/src/scripts/_lib/agent_settings.ts +82 -16
- package/src/scripts/_lib/carrier_divergence.ts +135 -0
- package/src/scripts/_lib/host_capability.ts +84 -1
- package/src/scripts/_lib/prompt_shape.ts +88 -0
- package/src/scripts/_lib/review_skipped_record.ts +102 -0
- package/src/scripts/_lib/rule_layer_overlap.ts +254 -0
- package/src/scripts/_lib/self_repair.ts +342 -0
- package/src/scripts/_lib/self_repair_store.ts +100 -0
- package/src/scripts/_lib/transcript_entry.ts +80 -0
- package/src/scripts/check_branch_freshness.ts +255 -0
- package/src/scripts/check_council_config_location.ts +125 -1
- package/src/scripts/check_standing_rule_delivery.ts +314 -0
- package/src/scripts/config/profiles.ts +13 -3
- package/src/scripts/conformance_scan.ts +272 -24
- package/src/scripts/council_availability_hook.ts +188 -0
- package/src/scripts/council_cli.ts +126 -7
- package/src/scripts/hook_manifest.yaml +107 -26
- package/src/scripts/hooks/block_no_verify.ts +39 -2
- package/src/scripts/hooks/block_unauthorized_git.ts +156 -7
- package/src/scripts/hooks/concern_registry.ts +10 -0
- package/src/scripts/hooks/delegation_nudge_hook.ts +450 -0
- package/src/scripts/hooks/end_review_nudge_hook.ts +726 -0
- package/src/scripts/hooks/orchestration_record_hook.ts +342 -0
- package/src/scripts/install.ts +151 -0
- package/src/scripts/language_mirror_hook.ts +30 -37
- package/src/scripts/measure_scope_dedup.ts +19 -13
- package/src/scripts/onboarding_gate_hook.ts +40 -1
- package/src/scripts/orchestration_record.ts +4 -1
- package/src/scripts/preamble_byte_census.ts +9 -20
- package/src/scripts/report_carrier_divergence.ts +364 -0
- package/src/scripts/report_skill_activation.ts +257 -0
- package/src/scripts/report_skill_obligation_violations.ts +579 -0
- package/src/scripts/routing_doctor.ts +77 -6
- package/src/scripts/self_repair_cli.ts +240 -0
- package/src/scripts/self_repair_hook.ts +241 -0
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* report_skill_obligation_violations.ts — SK-2, *loaded-but-violated*: a skill
|
|
4
|
+
* body was in context and an obligation stated in it was violated in a later
|
|
5
|
+
* assistant turn of the same session.
|
|
6
|
+
*
|
|
7
|
+
* Executes round-6 Phase 3.2. Advisory: **it gates on nothing**, and it must not
|
|
8
|
+
* acquire a threshold — same reason as `report_skill_activation`, whose census
|
|
9
|
+
* this consumes rather than re-deriving.
|
|
10
|
+
*
|
|
11
|
+
* THE HEADLINE IS THE COVERAGE, NOT THE VIOLATION COUNT
|
|
12
|
+
* ----------------------------------------------------
|
|
13
|
+
* Phase 3.1 scoped SK-2 to the 30 skills carrying a deterministic
|
|
14
|
+
* `MUST`/`NEVER`/`ALWAYS`, so the coverage would be "legible rather than
|
|
15
|
+
* implied". Reading their bodies makes it legible in a way the phase did not
|
|
16
|
+
* anticipate. Across those 30 skills there are **110 obligation lines**, and of
|
|
17
|
+
* those:
|
|
18
|
+
*
|
|
19
|
+
* 4 name a concrete artefact — a repo path or a command literal
|
|
20
|
+
* 1 of those 4 names the PRESCRIBED alternative, not the forbidden thing
|
|
21
|
+
* 106 are deterministic in WORDING and require judgement to test
|
|
22
|
+
*
|
|
23
|
+
* "NEVER return `clean` out of politeness", "NEVER penalise an artifact for
|
|
24
|
+
* being short", "NEVER invent threat actors with unrealistic capabilities" — the
|
|
25
|
+
* verb is absolute and the violation is a reading. So the census's own number
|
|
26
|
+
* repeats the 8 → 30 correction one level down: `DETERMINISTIC_RE` matches the
|
|
27
|
+
* *sentence*, and being deterministic in wording is not the same property as
|
|
28
|
+
* being observable. This script measures the observable subset and publishes the
|
|
29
|
+
* ratio, because a detector over 3 obligations that reported only its own hit
|
|
30
|
+
* count would read as coverage of the 30.
|
|
31
|
+
*
|
|
32
|
+
* THE POLARITY TRAP, WHICH IS WHY THE ARTEFACT SPLIT IS NOT A GREP
|
|
33
|
+
* ---------------------------------------------------------------
|
|
34
|
+
* `using-git-worktrees` says *"NEVER `rm -rf` a worktree — use `git worktree
|
|
35
|
+
* remove`"*. A naive artefact extraction lifts `git worktree remove` and would
|
|
36
|
+
* flag the prescribed FIX as the violation. So an artefact found after a pivot
|
|
37
|
+
* (`—`, `use`, `instead`, `→`) is classified `prescribed` and excluded, by name.
|
|
38
|
+
* The forbidden half of that same obligation (`rm -rf` against a worktree path)
|
|
39
|
+
* stays unmechanised on purpose: `rm -rf` is legitimate against anything that is
|
|
40
|
+
* not a worktree, so a literal match would manufacture false positives — which
|
|
41
|
+
* is the failure 3.3 exists to catch and this script prefers not to create.
|
|
42
|
+
*
|
|
43
|
+
* WHAT COUNTS AS A VIOLATION
|
|
44
|
+
* --------------------------
|
|
45
|
+
* path artefact an EDIT/WRITE tool call naming that path after the skill
|
|
46
|
+
* loaded. A read never counts: the obligations say
|
|
47
|
+
* "hand-edit", and flagging a read would make the detector
|
|
48
|
+
* fire on the act of checking.
|
|
49
|
+
* command artefact a shell tool call whose command string contains the literal.
|
|
50
|
+
*
|
|
51
|
+
* Both require the skill to have been loaded EARLIER in the same session — that
|
|
52
|
+
* is the "loaded-but" half, and it is what separates SK-2 from a repo-wide grep.
|
|
53
|
+
*
|
|
54
|
+
* Exit: 0 always, except a usage/IO error (1). Deliberate.
|
|
55
|
+
*/
|
|
56
|
+
import * as fs from 'node:fs';
|
|
57
|
+
import * as os from 'node:os';
|
|
58
|
+
import * as path from 'node:path';
|
|
59
|
+
import process from 'node:process';
|
|
60
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
61
|
+
|
|
62
|
+
import { entryText, isSidechain, toolUses as _toolUses } from './_lib/transcript_entry.js';
|
|
63
|
+
import { censusSkills, DETERMINISTIC_RE, SKILLS_ROOT } from './report_skill_activation.js';
|
|
64
|
+
|
|
65
|
+
const _HERE = fileURLToPath(import.meta.url);
|
|
66
|
+
const REPO_ROOT = path.resolve(path.dirname(_HERE), '..', '..');
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Every obligation line — DERIVED from the census's own pattern rather than
|
|
70
|
+
* restated, so "what counts as a deterministic obligation" has one definition.
|
|
71
|
+
* The census tests whether a skill has one (single match, `m`); this needs the
|
|
72
|
+
* whole line, every time (`.*`, `g`). A second literal here is how the 8 → 30
|
|
73
|
+
* spread happened in the first place: two defensible regexes, two numbers.
|
|
74
|
+
*/
|
|
75
|
+
const OBLIGATION_LINE_RE = new RegExp(`${DETERMINISTIC_RE.source}.*`, 'gm');
|
|
76
|
+
|
|
77
|
+
/** A backticked repo path — needs a separator AND an extension to count. */
|
|
78
|
+
const PATH_RE = /`([A-Za-z0-9_./-]*\/[A-Za-z0-9_.-]+\.[a-z]{2,6})`/g;
|
|
79
|
+
/** A backticked command, gated on a known tool word in command position. */
|
|
80
|
+
const CMD_RE = /`([a-z][a-z0-9-]*(?: [a-z][a-z0-9:._-]*){1,3})`/g;
|
|
81
|
+
const CMD_HEADS = new Set([
|
|
82
|
+
'cargo',
|
|
83
|
+
'npm',
|
|
84
|
+
'pnpm',
|
|
85
|
+
'yarn',
|
|
86
|
+
'git',
|
|
87
|
+
'php',
|
|
88
|
+
'composer',
|
|
89
|
+
'task',
|
|
90
|
+
'make',
|
|
91
|
+
'npx',
|
|
92
|
+
'gh',
|
|
93
|
+
'python3',
|
|
94
|
+
'docker',
|
|
95
|
+
]);
|
|
96
|
+
// `vendor` was here and is unreachable: CMD_RE's token class excludes `/`, so
|
|
97
|
+
// `vendor/bin/phpunit` can never match. A head that cannot fire reads as coverage.
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Where a line stops stating the prohibition and starts stating the remedy.
|
|
101
|
+
* Everything after the first pivot is prescriptive, so an artefact there is the
|
|
102
|
+
* fix, not the offence.
|
|
103
|
+
*
|
|
104
|
+
* DELIBERATELY NOT a bare `use` / `instead`. A first draft matched those anywhere
|
|
105
|
+
* in the line, which inverts the very polarity the pivot exists to get right:
|
|
106
|
+
* *"NEVER use `X`"* puts the pivot BEFORE the artefact, so the forbidden thing
|
|
107
|
+
* would be classified `prescribed` and silently dropped from the mechanisable
|
|
108
|
+
* set — biasing this report's own headline ratio downward. A pivot is a
|
|
109
|
+
* punctuation break (dash, arrow, semicolon) or a remedy phrase that follows one,
|
|
110
|
+
* never a verb that can appear inside the prohibition itself.
|
|
111
|
+
*/
|
|
112
|
+
const PIVOT_RE = /\s—\s|\s–\s|→|;\s|\s—use\b|\buse instead\b/;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* `forbidden` — a NEVER line's own artefact: its appearance IS the violation.
|
|
116
|
+
* `prescribed` — the remedy a NEVER line points at instead.
|
|
117
|
+
* `required` — a MUST / ALWAYS line's artefact. NOT testable here, and the
|
|
118
|
+
* distinction is load-bearing rather than tidy: for a positive obligation the
|
|
119
|
+
* violation is the artefact's **absence**, and a transcript cannot show that
|
|
120
|
+
* something never happened without also knowing it was due. Deciding polarity
|
|
121
|
+
* from position alone put `ALWAYS run \`task ci\`` into `forbidden`, so the
|
|
122
|
+
* detector would have flagged *compliance* as a violation. Verified against a
|
|
123
|
+
* fixture before the fix: `forbidden: ["task ci"]`.
|
|
124
|
+
*/
|
|
125
|
+
export type Polarity = 'forbidden' | 'prescribed' | 'required';
|
|
126
|
+
|
|
127
|
+
/** The absolute a line opens with — the verb that decides what its artefact means. */
|
|
128
|
+
export type ObligationVerb = 'NEVER' | 'MUST' | 'ALWAYS';
|
|
129
|
+
|
|
130
|
+
export interface Obligation {
|
|
131
|
+
skill: string;
|
|
132
|
+
line: string;
|
|
133
|
+
verb: ObligationVerb;
|
|
134
|
+
artefact: string;
|
|
135
|
+
kind: 'path' | 'command';
|
|
136
|
+
polarity: Polarity;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface ObligationCensus {
|
|
140
|
+
/** The 30 (or however many the census names today). */
|
|
141
|
+
skills: string[];
|
|
142
|
+
totalLines: number;
|
|
143
|
+
/** Artefact-bearing, either polarity. One entry per ARTEFACT. */
|
|
144
|
+
withArtefact: Obligation[];
|
|
145
|
+
/**
|
|
146
|
+
* Obligation LINES carrying at least one artefact. Kept separate from
|
|
147
|
+
* `withArtefact.length` because a line can name two, and dividing an
|
|
148
|
+
* artefact count by a line count and calling it a share of lines overstates
|
|
149
|
+
* the coverage this report exists to be honest about.
|
|
150
|
+
*/
|
|
151
|
+
linesWithArtefact: number;
|
|
152
|
+
/** The mechanisable subset — what this detector can actually test. */
|
|
153
|
+
forbidden: Obligation[];
|
|
154
|
+
prescribed: Obligation[];
|
|
155
|
+
/** MUST / ALWAYS artefacts: required, and their violation is unobservable here. */
|
|
156
|
+
required: Obligation[];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The absolute the line opens with. Defaults to NEVER only if none matched. */
|
|
160
|
+
function _verb(line: string): ObligationVerb {
|
|
161
|
+
const m = /\b(NEVER|MUST|ALWAYS)\b/.exec(line);
|
|
162
|
+
return (m?.[1] as ObligationVerb | undefined) ?? 'NEVER';
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function _artefacts(line: string): Array<{ artefact: string; kind: 'path' | 'command' }> {
|
|
166
|
+
const out: Array<{ artefact: string; kind: 'path' | 'command' }> = [];
|
|
167
|
+
for (const m of line.matchAll(PATH_RE)) {
|
|
168
|
+
out.push({ artefact: m[1] as string, kind: 'path' });
|
|
169
|
+
}
|
|
170
|
+
for (const m of line.matchAll(CMD_RE)) {
|
|
171
|
+
const cmd = m[1] as string;
|
|
172
|
+
if (CMD_HEADS.has(cmd.split(' ')[0] as string)) {
|
|
173
|
+
out.push({ artefact: cmd, kind: 'command' });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Derive the obligations FROM the skill bodies — never a hand-written list.
|
|
181
|
+
*
|
|
182
|
+
* A literal list here would go stale the first time a skill's wording changed,
|
|
183
|
+
* and the detector would then police an obligation the skill no longer states.
|
|
184
|
+
* The census supplies the skill set for the same reason: one definition of "the
|
|
185
|
+
* 30".
|
|
186
|
+
*/
|
|
187
|
+
export function extractObligations(repoRoot: string): ObligationCensus {
|
|
188
|
+
const skillsRoot = path.join(repoRoot, SKILLS_ROOT);
|
|
189
|
+
const census = censusSkills(skillsRoot);
|
|
190
|
+
const out: ObligationCensus = {
|
|
191
|
+
skills: census.withDeterministicObligation,
|
|
192
|
+
totalLines: 0,
|
|
193
|
+
withArtefact: [],
|
|
194
|
+
linesWithArtefact: 0,
|
|
195
|
+
forbidden: [],
|
|
196
|
+
prescribed: [],
|
|
197
|
+
required: [],
|
|
198
|
+
};
|
|
199
|
+
for (const skill of census.withDeterministicObligation) {
|
|
200
|
+
let text: string;
|
|
201
|
+
try {
|
|
202
|
+
text = fs.readFileSync(path.join(skillsRoot, skill, 'SKILL.md'), 'utf-8');
|
|
203
|
+
} catch {
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
const fmEnd = text.startsWith('---') ? text.indexOf('\n---', 3) : -1;
|
|
207
|
+
const body = fmEnd === -1 ? text : text.slice(fmEnd + 4);
|
|
208
|
+
for (const raw of body.match(OBLIGATION_LINE_RE) ?? []) {
|
|
209
|
+
const line = raw.trim();
|
|
210
|
+
out.totalLines += 1;
|
|
211
|
+
const verb = _verb(line);
|
|
212
|
+
const pivot = PIVOT_RE.exec(line);
|
|
213
|
+
const head = pivot === null ? line : line.slice(0, pivot.index);
|
|
214
|
+
const found = _artefacts(line);
|
|
215
|
+
if (found.length > 0) {
|
|
216
|
+
out.linesWithArtefact += 1;
|
|
217
|
+
}
|
|
218
|
+
for (const a of found) {
|
|
219
|
+
// The VERB decides what the artefact means; position only splits a
|
|
220
|
+
// prohibition from its remedy. Reading position alone classified
|
|
221
|
+
// `ALWAYS run \`task ci\`` as forbidden — the detector would have
|
|
222
|
+
// reported compliance as a violation.
|
|
223
|
+
const polarity: Polarity =
|
|
224
|
+
verb === 'NEVER'
|
|
225
|
+
? head.includes(`\`${a.artefact}\``)
|
|
226
|
+
? 'forbidden'
|
|
227
|
+
: 'prescribed'
|
|
228
|
+
: 'required';
|
|
229
|
+
const ob: Obligation = { skill, line, verb, artefact: a.artefact, kind: a.kind, polarity };
|
|
230
|
+
out.withArtefact.push(ob);
|
|
231
|
+
if (polarity === 'forbidden') out.forbidden.push(ob);
|
|
232
|
+
else if (polarity === 'prescribed') out.prescribed.push(ob);
|
|
233
|
+
else out.required.push(ob);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
return out;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// ── transcript side ────────────────────────────────────────────────────────
|
|
241
|
+
|
|
242
|
+
type Entry = Record<string, unknown>;
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Parse a session once. Both passes below need the same entries, and parsing the
|
|
246
|
+
* file three times for one report is the kind of waste that also invites the two
|
|
247
|
+
* passes to disagree about what they read.
|
|
248
|
+
*/
|
|
249
|
+
function _parseSession(lines: readonly string[]): Array<Entry | null> {
|
|
250
|
+
return lines.map((line) => {
|
|
251
|
+
if (!line.trim()) return null;
|
|
252
|
+
try {
|
|
253
|
+
return JSON.parse(line) as Entry;
|
|
254
|
+
} catch {
|
|
255
|
+
return null;
|
|
256
|
+
}
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Tools that WRITE. A read must never satisfy a "never hand-edit" obligation. */
|
|
261
|
+
const WRITE_TOOLS = new Set([
|
|
262
|
+
'Edit',
|
|
263
|
+
'Write',
|
|
264
|
+
'NotebookEdit',
|
|
265
|
+
'MultiEdit',
|
|
266
|
+
'str-replace-editor',
|
|
267
|
+
'str_replace_editor',
|
|
268
|
+
'save-file',
|
|
269
|
+
'save_file',
|
|
270
|
+
]);
|
|
271
|
+
const SHELL_TOOLS = new Set(['Bash', 'BashTool', 'launch-process', 'launch_process']);
|
|
272
|
+
|
|
273
|
+
/** `…/skills/<name>` in an injected body, which is how a skill reaches context. */
|
|
274
|
+
const SKILL_BODY_RE = /Base directory for this skill:\s*\S*?\/skills\/([A-Za-z0-9_-]+)/;
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Which skills were in context, and from which entry index onward.
|
|
278
|
+
*
|
|
279
|
+
* Two carriers, both real in this store: an injected body in the user role, and
|
|
280
|
+
* a `Skill` tool call. A slash-command form (`roadmap:process-full`) is
|
|
281
|
+
* normalised to its skill stem so both spellings resolve to one key.
|
|
282
|
+
*/
|
|
283
|
+
export function loadedSkills(lines: readonly string[]): Map<string, number> {
|
|
284
|
+
return loadedSkillsFrom(_parseSession(lines));
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** As `loadedSkills`, over already-parsed entries. */
|
|
288
|
+
export function loadedSkillsFrom(entries: ReadonlyArray<Entry | null>): Map<string, number> {
|
|
289
|
+
const first = new Map<string, number>();
|
|
290
|
+
const note = (name: string, at: number): void => {
|
|
291
|
+
const key = name.replace(/:/g, '-');
|
|
292
|
+
if (!first.has(key)) first.set(key, at);
|
|
293
|
+
};
|
|
294
|
+
entries.forEach((entry, i) => {
|
|
295
|
+
// A sidechain turn did not happen in the main thread. Counting one lets a
|
|
296
|
+
// skill loaded inside a subagent be paired with an edit made outside it,
|
|
297
|
+
// which breaks the ordering premise that separates SK-2 from a grep.
|
|
298
|
+
if (entry === null || isSidechain(entry)) return;
|
|
299
|
+
if (entry['type'] === 'user') {
|
|
300
|
+
// `entryText`, not a string-only read: measured in one 30-session
|
|
301
|
+
// store, ALL 41 injected skill bodies arrive as content blocks and
|
|
302
|
+
// none as a bare string, so a string-only reader detects zero of
|
|
303
|
+
// them — and a detector with an empty loaded-set returns no findings,
|
|
304
|
+
// which is indistinguishable from compliance.
|
|
305
|
+
const m = SKILL_BODY_RE.exec(entryText(entry));
|
|
306
|
+
if (m !== null) note(m[1] as string, i);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (entry['type'] !== 'assistant') return;
|
|
310
|
+
for (const t of _toolUses(entry)) {
|
|
311
|
+
if (t.name !== 'Skill') continue;
|
|
312
|
+
const s = t.input['skill'];
|
|
313
|
+
if (typeof s === 'string' && s !== '') note(s, i);
|
|
314
|
+
}
|
|
315
|
+
});
|
|
316
|
+
return first;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export interface Flag {
|
|
320
|
+
session: string;
|
|
321
|
+
skill: string;
|
|
322
|
+
artefact: string;
|
|
323
|
+
kind: 'path' | 'command';
|
|
324
|
+
/** Entry index of the violating turn, and of the turn that loaded the skill. */
|
|
325
|
+
at: number;
|
|
326
|
+
loadedAt: number;
|
|
327
|
+
tool: string;
|
|
328
|
+
/** The matched literal in context — what makes the flag hand-checkable. */
|
|
329
|
+
evidence: string;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Which input fields name the TARGET of the act, per kind.
|
|
334
|
+
*
|
|
335
|
+
* Concatenating every string value would match a forbidden path that appears in
|
|
336
|
+
* `new_string` / `old_string` / `content` — i.e. flag an edit to some other file
|
|
337
|
+
* whose text merely MENTIONS `docs/THIRD-PARTY-NOTICES.md` as hand-editing it.
|
|
338
|
+
* The obligation is about the target, so only target-bearing fields are read; the
|
|
339
|
+
* same reasoning excludes a `description` that quotes a forbidden command.
|
|
340
|
+
*/
|
|
341
|
+
const PATH_FIELDS = ['file_path', 'path', 'filePath', 'notebook_path', 'target_file'] as const;
|
|
342
|
+
const COMMAND_FIELDS = ['command', 'cmd'] as const;
|
|
343
|
+
|
|
344
|
+
function _targetText(input: Record<string, unknown>, kind: 'path' | 'command'): string {
|
|
345
|
+
const fields = kind === 'path' ? PATH_FIELDS : COMMAND_FIELDS;
|
|
346
|
+
return fields
|
|
347
|
+
.map((f) => input[f])
|
|
348
|
+
.filter((v): v is string => typeof v === 'string')
|
|
349
|
+
.join('\n');
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export function scanSessionForViolations(
|
|
353
|
+
session: string,
|
|
354
|
+
lines: readonly string[],
|
|
355
|
+
forbidden: readonly Obligation[],
|
|
356
|
+
parsed?: ReadonlyArray<Entry | null>,
|
|
357
|
+
): Flag[] {
|
|
358
|
+
// `parsed` lets `scanStore` reuse the single pass it already made — without it
|
|
359
|
+
// the file is parsed twice per session, which contradicts `_parseSession`'s
|
|
360
|
+
// own reason for existing.
|
|
361
|
+
const entries = parsed ?? _parseSession(lines);
|
|
362
|
+
const loaded = loadedSkillsFrom(entries);
|
|
363
|
+
if (loaded.size === 0) return [];
|
|
364
|
+
const relevant = forbidden.filter((o) => loaded.has(o.skill));
|
|
365
|
+
if (relevant.length === 0) return [];
|
|
366
|
+
|
|
367
|
+
const flags: Flag[] = [];
|
|
368
|
+
entries.forEach((entry, i) => {
|
|
369
|
+
if (entry === null || isSidechain(entry)) return;
|
|
370
|
+
if (entry['type'] !== 'assistant') return;
|
|
371
|
+
for (const t of _toolUses(entry)) {
|
|
372
|
+
const isWrite = WRITE_TOOLS.has(t.name);
|
|
373
|
+
const isShell = SHELL_TOOLS.has(t.name);
|
|
374
|
+
if (!isWrite && !isShell) continue;
|
|
375
|
+
for (const o of relevant) {
|
|
376
|
+
const loadedAt = loaded.get(o.skill) as number;
|
|
377
|
+
// "in a LATER assistant turn" — the load must precede the act,
|
|
378
|
+
// or a skill invoked to clean up after a violation would be read
|
|
379
|
+
// as having caused it.
|
|
380
|
+
if (i <= loadedAt) continue;
|
|
381
|
+
if (o.kind === 'path' && !isWrite) continue;
|
|
382
|
+
if (o.kind === 'command' && !isShell) continue;
|
|
383
|
+
const text = _targetText(t.input, o.kind);
|
|
384
|
+
if (!text.includes(o.artefact)) continue;
|
|
385
|
+
const at = text.indexOf(o.artefact);
|
|
386
|
+
flags.push({
|
|
387
|
+
session,
|
|
388
|
+
skill: o.skill,
|
|
389
|
+
artefact: o.artefact,
|
|
390
|
+
kind: o.kind,
|
|
391
|
+
at: i,
|
|
392
|
+
loadedAt,
|
|
393
|
+
tool: t.name,
|
|
394
|
+
evidence: text.slice(Math.max(0, at - 40), at + o.artefact.length + 40).replace(/\n/g, ' '),
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
});
|
|
399
|
+
return flags;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
export interface Sk2Report {
|
|
403
|
+
store: string;
|
|
404
|
+
sessions: number;
|
|
405
|
+
sessionsWithASkill: number;
|
|
406
|
+
census: ObligationCensus;
|
|
407
|
+
flags: Flag[];
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export function defaultStore(cwd: string): string {
|
|
411
|
+
return path.join(os.homedir(), '.claude', 'projects', cwd.replace(/[/.]/g, '-'));
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export function scanStore(repoRoot: string, store: string, limit: number): Sk2Report {
|
|
415
|
+
const census = extractObligations(repoRoot);
|
|
416
|
+
const report: Sk2Report = {
|
|
417
|
+
store,
|
|
418
|
+
sessions: 0,
|
|
419
|
+
sessionsWithASkill: 0,
|
|
420
|
+
census,
|
|
421
|
+
flags: [],
|
|
422
|
+
};
|
|
423
|
+
let files: string[];
|
|
424
|
+
try {
|
|
425
|
+
files = fs
|
|
426
|
+
.readdirSync(store)
|
|
427
|
+
.filter((f) => f.endsWith('.jsonl'))
|
|
428
|
+
// mtime read ONCE per file, not twice per comparison — a comparator
|
|
429
|
+
// that stats is O(n log n) syscalls for an O(n) fact.
|
|
430
|
+
.map((f) => ({ p: path.join(store, f), m: fs.statSync(path.join(store, f)).mtimeMs }))
|
|
431
|
+
.sort((a, b) => b.m - a.m)
|
|
432
|
+
.slice(0, limit)
|
|
433
|
+
.map((x) => x.p);
|
|
434
|
+
} catch {
|
|
435
|
+
return report;
|
|
436
|
+
}
|
|
437
|
+
report.sessions = files.length;
|
|
438
|
+
for (const file of files) {
|
|
439
|
+
const lines = fs.readFileSync(file, 'utf-8').split('\n');
|
|
440
|
+
const id = path.basename(file).replace(/\.jsonl$/, '').slice(0, 8);
|
|
441
|
+
const entries = _parseSession(lines);
|
|
442
|
+
if (loadedSkillsFrom(entries).size > 0) report.sessionsWithASkill += 1;
|
|
443
|
+
report.flags.push(...scanSessionForViolations(id, lines, census.forbidden, entries));
|
|
444
|
+
}
|
|
445
|
+
return report;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function _pct(n: number, d: number): string {
|
|
449
|
+
return d === 0 ? 'n/a' : `${((100 * n) / d).toFixed(1)}%`;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
export function render(r: Sk2Report): string {
|
|
453
|
+
const c = r.census;
|
|
454
|
+
const lines: string[] = [];
|
|
455
|
+
lines.push('SK-2 loaded-but-violated — advisory, gates on nothing');
|
|
456
|
+
lines.push('');
|
|
457
|
+
lines.push(' COVERAGE (the headline — read this before any violation count)');
|
|
458
|
+
lines.push(` skills with a deterministic obligation ${c.skills.length}`);
|
|
459
|
+
lines.push(` obligation lines in them ${c.totalLines}`);
|
|
460
|
+
lines.push(
|
|
461
|
+
` …LINES naming a concrete artefact ${c.linesWithArtefact} (${_pct(c.linesWithArtefact, c.totalLines)})`,
|
|
462
|
+
);
|
|
463
|
+
lines.push(` …artefacts named across those lines ${c.withArtefact.length}`);
|
|
464
|
+
lines.push(` …of those, the FORBIDDEN artefact ${c.forbidden.length}`);
|
|
465
|
+
lines.push(` …the PRESCRIBED alternative (excluded) ${c.prescribed.length}`);
|
|
466
|
+
lines.push(` …REQUIRED by a MUST/ALWAYS (excluded) ${c.required.length}`);
|
|
467
|
+
lines.push(
|
|
468
|
+
` testable without judgement ${c.forbidden.length} artefact(s) over ${c.totalLines} obligation line(s)`,
|
|
469
|
+
);
|
|
470
|
+
lines.push('');
|
|
471
|
+
lines.push(' The other lines are deterministic in wording and need a reading to test');
|
|
472
|
+
lines.push(' ("NEVER return `clean` out of politeness"). That is the FC-8 class this suite');
|
|
473
|
+
lines.push(' excludes, so it is reported as uncovered rather than approximated.');
|
|
474
|
+
lines.push('');
|
|
475
|
+
if (c.forbidden.length > 0) {
|
|
476
|
+
lines.push(' Mechanised obligations:');
|
|
477
|
+
for (const o of c.forbidden) {
|
|
478
|
+
lines.push(` [${o.skill}] ${o.kind}: ${o.artefact}`);
|
|
479
|
+
}
|
|
480
|
+
lines.push('');
|
|
481
|
+
}
|
|
482
|
+
if (c.prescribed.length > 0) {
|
|
483
|
+
lines.push(' Excluded as prescriptive (the artefact is the remedy, not the offence):');
|
|
484
|
+
for (const o of c.prescribed) {
|
|
485
|
+
lines.push(` [${o.skill}] ${o.artefact}`);
|
|
486
|
+
}
|
|
487
|
+
lines.push('');
|
|
488
|
+
}
|
|
489
|
+
if (c.required.length > 0) {
|
|
490
|
+
lines.push(' Excluded as REQUIRED — a MUST/ALWAYS line, so its violation is the');
|
|
491
|
+
lines.push(' artefact being ABSENT, and a transcript cannot show that something never');
|
|
492
|
+
lines.push(' happened without also knowing it was due:');
|
|
493
|
+
for (const o of c.required) {
|
|
494
|
+
lines.push(` [${o.skill}] ${o.verb} ${o.artefact}`);
|
|
495
|
+
}
|
|
496
|
+
lines.push('');
|
|
497
|
+
}
|
|
498
|
+
lines.push(
|
|
499
|
+
` Scanned ${r.sessions} session(s); ${r.sessionsWithASkill} had a skill in context.`,
|
|
500
|
+
);
|
|
501
|
+
lines.push(` Flags: ${r.flags.length}`);
|
|
502
|
+
for (const f of r.flags) {
|
|
503
|
+
lines.push(` ${f.session} [${f.skill}] ${f.artefact} via ${f.tool} (entry ${f.at}, loaded ${f.loadedAt})`);
|
|
504
|
+
lines.push(` …${f.evidence}…`);
|
|
505
|
+
}
|
|
506
|
+
lines.push('');
|
|
507
|
+
lines.push(' PRECISION: every flag above is hand-read before any number from this script');
|
|
508
|
+
lines.push(' is cited (Phase 3.3). A detector that cannot state its false-positive rate');
|
|
509
|
+
lines.push(' ships as detection-only, and this one says so.');
|
|
510
|
+
return lines.join('\n');
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
export function main(argv?: readonly string[]): number {
|
|
514
|
+
const args = argv ?? process.argv.slice(2);
|
|
515
|
+
let limit = 30;
|
|
516
|
+
let json = false;
|
|
517
|
+
const stores: string[] = [];
|
|
518
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
519
|
+
const a = args[i];
|
|
520
|
+
// A value must not itself be a flag: `--store --json` otherwise reads
|
|
521
|
+
// `--json` as a store path and reports 0 sessions, silently. The sibling
|
|
522
|
+
// report closes the same hole in this branch.
|
|
523
|
+
const value = (at: number): string | null => {
|
|
524
|
+
const v = args[at + 1];
|
|
525
|
+
return v === undefined || v.startsWith('-') ? null : v;
|
|
526
|
+
};
|
|
527
|
+
if (a === '--limit') {
|
|
528
|
+
const v = value(i);
|
|
529
|
+
if (v === null) {
|
|
530
|
+
process.stderr.write('report_skill_obligation_violations: --limit needs a number\n');
|
|
531
|
+
return 1;
|
|
532
|
+
}
|
|
533
|
+
limit = Number.parseInt(v, 10);
|
|
534
|
+
i += 1;
|
|
535
|
+
} else if (a === '--store') {
|
|
536
|
+
const v = value(i);
|
|
537
|
+
if (v === null) {
|
|
538
|
+
process.stderr.write('report_skill_obligation_violations: --store needs a path\n');
|
|
539
|
+
return 1;
|
|
540
|
+
}
|
|
541
|
+
stores.push(v);
|
|
542
|
+
i += 1;
|
|
543
|
+
} else if (a === '--json') {
|
|
544
|
+
json = true;
|
|
545
|
+
} else if (a === '--help' || a === '-h') {
|
|
546
|
+
process.stdout.write(
|
|
547
|
+
'usage: report_skill_obligation_violations [--limit N] [--store PATH]... [--json]\n',
|
|
548
|
+
);
|
|
549
|
+
return 0;
|
|
550
|
+
} else if (a !== undefined && a.startsWith('--') && a !== '--quiet') {
|
|
551
|
+
process.stderr.write(`report_skill_obligation_violations: unknown flag ${a}\n`);
|
|
552
|
+
return 1;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
if (!Number.isFinite(limit) || limit <= 0) {
|
|
556
|
+
process.stderr.write('report_skill_obligation_violations: --limit must be a positive integer\n');
|
|
557
|
+
return 1;
|
|
558
|
+
}
|
|
559
|
+
if (stores.length === 0) stores.push(defaultStore(REPO_ROOT));
|
|
560
|
+
for (const store of stores) {
|
|
561
|
+
const r = scanStore(REPO_ROOT, store, limit);
|
|
562
|
+
process.stdout.write(json ? `${JSON.stringify(r, null, 2)}\n` : `${render(r)}\n`);
|
|
563
|
+
}
|
|
564
|
+
return 0;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function _isCliEntry(): boolean {
|
|
568
|
+
if (process.argv[1] === undefined) return false;
|
|
569
|
+
if (import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) return true;
|
|
570
|
+
try {
|
|
571
|
+
return fs.realpathSync(_HERE) === fs.realpathSync(path.resolve(process.argv[1]));
|
|
572
|
+
} catch {
|
|
573
|
+
return false;
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
if (_isCliEntry()) {
|
|
578
|
+
process.exit(main());
|
|
579
|
+
}
|