@event4u/agent-config 16.4.0 → 16.5.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/.augment-plugin/marketplace.json +3 -3
- package/.augment-plugin/plugin.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +40 -191
- package/README.md +1 -1
- package/dist/agent-src/commands/design.md +1 -1
- package/dist/agent-src/guidelines/design-antipatterns.md +1 -1
- package/dist/agent-src/rules/token-budget-discipline.md +2 -2
- package/dist/agent-src/skills/accessibility-auditor/data/aria-patterns.csv +2 -0
- package/dist/agent-src/skills/accessibility-auditor/data/manifest.json +3 -3
- package/dist/agent-src/skills/canvas-design/SKILL.md +4 -2
- package/dist/agent-src/skills/design-intelligence/references/integration-mapping.md +13 -10
- package/dist/agent-src/skills/fe-design/references/design-patterns.md +49 -2
- package/dist/agent-src/skills/frontend-render-security/SKILL.md +9 -0
- package/dist/agent-src/skills/iconography/SKILL.md +4 -2
- package/dist/agent-src/skills/image-provider-routing/SKILL.md +16 -5
- package/dist/agent-src/skills/logo-generation/SKILL.md +12 -8
- package/dist/agent-src/skills/svg-authoring/SKILL.md +318 -0
- package/dist/agent-src/skills/wireframe/SKILL.md +5 -2
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli/commands/uiRender.js +201 -4
- package/dist/cli/commands/uiRenderAsset.js +1308 -0
- package/dist/cli/commands/uiRenderAssetCompare.js +752 -0
- package/dist/cli/commands/uiRenderAssetGrid.js +265 -0
- package/dist/cli/commands/uiRenderAssetGrounds.js +323 -0
- package/dist/cli/commands/uiRenderAssetIsolation.js +85 -0
- package/dist/cli/commands/uiRenderAssetTypes.js +145 -0
- package/dist/cli/main.js +47 -1
- package/dist/cli/registry.js +1 -1
- package/dist/cli-delegate/{chunk-MSUL5NYP.js → chunk-4DBG5K7M.js} +1 -1
- package/dist/cli-delegate/{chunk-AED2VQGM.js → chunk-TMWYUOK4.js} +10 -1
- package/dist/cli-delegate/cmd_conformance.js +395 -214
- package/dist/cli-delegate/cmd_handoff.js +2 -2
- package/dist/cli-delegate/cmd_session_recycle.js +2 -2
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +47 -22
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +6 -6
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +19 -15
- package/dist/discovery/trust-report.md +2 -2
- package/dist/discovery/workspaces.json +13 -9
- package/dist/hooks/dispatch.js +1073 -808
- package/dist/hooks/dispatch.sha256 +1 -1
- package/dist/hooks/svg_rules.chunk.cjs +1626 -0
- package/dist/hooks/svg_rules.chunk.cjs.sha256 +1 -0
- package/dist/mcp/registry-manifest.json +3 -3
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +931 -735
- package/dist/scripts/_lib/scan_scope.js +105 -0
- package/dist/scripts/_lib/svg_dom.js +284 -0
- package/dist/scripts/_lib/svg_geometry.js +477 -0
- package/dist/scripts/_lib/svg_motion.js +179 -0
- package/dist/scripts/_lib/svg_rules.js +1337 -0
- package/dist/scripts/_lib/svg_urls.js +67 -0
- package/dist/scripts/lint_svg_asset.js +324 -0
- package/docs/guidelines/design-antipatterns.md +1 -1
- package/package.json +4 -3
- package/src/config/gate-violation-baselines.json +3 -3
- package/src/config/routing-coverage-seed.json +9 -2
- package/src/scripts/_lib/svg_dom.ts +318 -0
- package/src/scripts/_lib/svg_geometry.ts +480 -0
- package/src/scripts/_lib/svg_motion.ts +208 -0
- package/src/scripts/_lib/svg_rules.ts +1453 -0
- package/src/scripts/_lib/svg_urls.ts +72 -0
- package/src/scripts/_lib/verification_command.ts +59 -0
- package/src/scripts/_lib/verify_clause.ts +27 -17
- package/src/scripts/hooks/block_speaking_inbox_dir.ts +16 -1
- package/src/scripts/hooks/design_pass_hook.ts +157 -3
- package/src/scripts/hooks/svg_obligation.ts +497 -0
- package/src/scripts/hooks/svg_rules_loader.ts +133 -0
- package/src/scripts/hooks/turn_end_gate_hook.ts +5 -3
- package/src/scripts/lint_consolidation_lineage.ts +6 -3
- package/src/scripts/lint_design_quality.ts +60 -12
- package/src/scripts/lint_motion_authority_drift.ts +27 -0
- package/src/scripts/lint_svg_asset.ts +365 -0
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The open-SVG-obligation reading — step 2.1(b) of `road-to-svg-review-followups`.
|
|
3
|
+
*
|
|
4
|
+
* THE HOLE THIS CLOSES, which a review found and a reading confirmed. A turn
|
|
5
|
+
* that edits an `.svg` and then runs `ui:render --profile asset` looks, to the
|
|
6
|
+
* turn-end gate, like a turn that verified its work. It is not: on a machine
|
|
7
|
+
* with no Playwright that command rendered nothing, and until 2026-10-11 it
|
|
8
|
+
* also exited 0. Teaching `isVerificationCommand` the command would have made
|
|
9
|
+
* the hole worse rather than better — the transcript path never sees an exit
|
|
10
|
+
* code (`turn_end_gate_hook.ts:764` matches a command and stops), so a bare
|
|
11
|
+
* call would have discharged the obligation on both paths.
|
|
12
|
+
*
|
|
13
|
+
* So the detector reads the RENDER, never the call. An `.svg` the turn edited
|
|
14
|
+
* is discharged by exactly one of two records:
|
|
15
|
+
*
|
|
16
|
+
* 1. an asset manifest with `verification: "verified"` whose `asset.digest`
|
|
17
|
+
* equals the file's CURRENT digest; or
|
|
18
|
+
* 2. an asset manifest with `reason: "renderer-absent"` whose `static` block
|
|
19
|
+
* carries `errors: 0` on that same digest — reported as `static-only`.
|
|
20
|
+
*
|
|
21
|
+
* Path 2 is why a consumer without Playwright is not refused forever on a gate
|
|
22
|
+
* declared `blocking`. A `render-failed` manifest discharges NOTHING: a
|
|
23
|
+
* renderer that ran and failed is a real failure, not a capability fact.
|
|
24
|
+
*
|
|
25
|
+
* FOUR PRECISIONS, each from a third review and each closing a way the gate
|
|
26
|
+
* would otherwise refuse honest work or accept dishonest work:
|
|
27
|
+
*
|
|
28
|
+
* (ii) A planted fixture opens no obligation. `tests/design-artifacts/
|
|
29
|
+
* fixtures/**` and `tests/design-artifacts/scenarios/**` carry errors
|
|
30
|
+
* ON PURPOSE and can never reach `verified`, so without this every turn
|
|
31
|
+
* that touches one would be refused on a blocking gate. By PATH, not by
|
|
32
|
+
* an inline ignore marker — a marker would hide the planted error from
|
|
33
|
+
* the very test that has to find it.
|
|
34
|
+
* (iii) A manifest counts only where THIS turn ran a render NAMING THAT
|
|
35
|
+
* FILE after the edit. Otherwise an agent could write a manifest by
|
|
36
|
+
* hand, digest included, and discharge its own obligation.
|
|
37
|
+
*
|
|
38
|
+
* A turn that writes into the render directory by any other route —
|
|
39
|
+
* an edit tool, or a shell command naming the directory — voids every
|
|
40
|
+
* manifest for that turn, so a hand-written manifest plus a render that
|
|
41
|
+
* names the file (and failed) no longer discharges (16.5.0 release
|
|
42
|
+
* review).
|
|
43
|
+
*
|
|
44
|
+
* THE RESIDUAL, stated rather than implied. This is a check against an
|
|
45
|
+
* omission, not a tamper-proof attestation: a process that can run a
|
|
46
|
+
* shell can write a file by a route no transcript reading recognises.
|
|
47
|
+
* What is enforced is that a render naming the file ran and that the
|
|
48
|
+
* turn did not visibly write the render directory; what is not
|
|
49
|
+
* enforced is that the manifest on disk is the one that render produced. A manifest left by an
|
|
50
|
+
* EARLIER turn whose digest still matches therefore discharges — and
|
|
51
|
+
* that is defensible, because a matching digest means the bytes have
|
|
52
|
+
* not moved since it was taken, which is the whole claim a digest
|
|
53
|
+
* makes. It is recorded here because the first version of this
|
|
54
|
+
* paragraph promised more than the code did (finding 2 of this
|
|
55
|
+
* change's own completion review), and a gate whose documented
|
|
56
|
+
* assurance outruns its implementation is quoted as written.
|
|
57
|
+
* (iv) A deleted `.svg` opens no obligation — it has no current digest, so
|
|
58
|
+
* there is nothing a render could be bound to.
|
|
59
|
+
*
|
|
60
|
+
* Its own module rather than another 200 lines inside the gate: the gate ships
|
|
61
|
+
* in a byte-capped bundle AND sits two lines under the source-size ratchet, so
|
|
62
|
+
* a separate module is both the unit whose cost can be measured and the only
|
|
63
|
+
* place this could have gone.
|
|
64
|
+
*
|
|
65
|
+
* It is also why `openSvgObligation` returns a finding-shaped object rather
|
|
66
|
+
* than a reason the gate wraps: the gate's entry for it is ONE line, and the
|
|
67
|
+
* reasoning that would have been a comment there is the paragraph above.
|
|
68
|
+
*
|
|
69
|
+
* THE DETECTOR RUNS UNCONDITIONALLY, including while a subagent dispatch is
|
|
70
|
+
* open — nothing about a dispatch makes an unrendered graphic rendered.
|
|
71
|
+
*/
|
|
72
|
+
import * as crypto from 'node:crypto';
|
|
73
|
+
import * as fs from 'node:fs';
|
|
74
|
+
import * as path from 'node:path';
|
|
75
|
+
|
|
76
|
+
import { isAssetRenderCommand } from '../_lib/verification_command.js';
|
|
77
|
+
import type { ToolCall } from '../_lib/turn_end_transcript.js';
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Re-exported, not re-derived. Finding 3 of this change's SECOND completion
|
|
81
|
+
* review moved the predicate into `_lib/verification_command.ts`, because
|
|
82
|
+
* detector C needs it too and a `_lib` module may not import from `hooks/`.
|
|
83
|
+
* Two copies of a command-shape regex is how the two detectors come to
|
|
84
|
+
* disagree about what a render is.
|
|
85
|
+
*/
|
|
86
|
+
export { isAssetRenderCommand };
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Tools that WRITE. One copy, here rather than imported from the gate: the
|
|
90
|
+
* gate imports this module, so the dependency runs one way.
|
|
91
|
+
*/
|
|
92
|
+
const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit']);
|
|
93
|
+
|
|
94
|
+
/** Where `ui:render` writes. One copy of the path, shared with the CLI by value. */
|
|
95
|
+
export const RENDER_REL = path.join('agents', 'runtime', 'state', 'render');
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Paths whose `.svg` files are planted on purpose. Precision (ii).
|
|
99
|
+
*
|
|
100
|
+
* Compared against a forward-slash form of the path so the list reads the same
|
|
101
|
+
* on every platform and a Windows-shaped tool call is not silently exempt.
|
|
102
|
+
*/
|
|
103
|
+
export const PLANTED_PREFIXES: readonly string[] = [
|
|
104
|
+
'tests/design-artifacts/fixtures/',
|
|
105
|
+
'tests/design-artifacts/scenarios/',
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
export type DischargeKind = 'verified' | 'static-only';
|
|
109
|
+
|
|
110
|
+
export interface SvgObligation {
|
|
111
|
+
/** Repo-relative, forward slashes — the evidence span a refusal quotes. */
|
|
112
|
+
file: string;
|
|
113
|
+
/** Why it is still open, in the words the refusal uses. */
|
|
114
|
+
reason: string;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface SvgDischarge {
|
|
118
|
+
file: string;
|
|
119
|
+
kind: DischargeKind;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export interface SvgObligationReading {
|
|
123
|
+
open: SvgObligation[];
|
|
124
|
+
discharged: SvgDischarge[];
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The shape this reads out of a manifest. Deliberately partial. */
|
|
128
|
+
interface AssetManifestish {
|
|
129
|
+
profile?: unknown;
|
|
130
|
+
verification?: unknown;
|
|
131
|
+
reason?: unknown;
|
|
132
|
+
asset?: { digest?: unknown; findings?: unknown } | undefined;
|
|
133
|
+
static?: { errors?: unknown; digest?: unknown } | undefined;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Does a manifest's asset reading carry an error-severity finding? */
|
|
137
|
+
function hasErrorFinding(m: AssetManifestish): boolean {
|
|
138
|
+
const f = m.asset?.findings;
|
|
139
|
+
return Array.isArray(f) && f.some((x) => (x as { severity?: unknown })?.severity === 'error');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Repo-relative, forward slashes — whatever the host handed us.
|
|
144
|
+
*
|
|
145
|
+
* Finding 1 of this change's completion review, and it was a live gate risk
|
|
146
|
+
* rather than a tidiness point: this used to normalise separators only, so an
|
|
147
|
+
* ABSOLUTE tool-call path never started with `tests/design-artifacts/…` and the
|
|
148
|
+
* planted-fixture exemption silently missed. A blocking gate would then have
|
|
149
|
+
* refused every turn that edits one of the ~25 deliberately-broken fixtures
|
|
150
|
+
* this very change adds — and no test caught it, because every case passed a
|
|
151
|
+
* relative path. The function four lines down already treated an absolute path
|
|
152
|
+
* as expected input, which is the contradiction inside one file that made the
|
|
153
|
+
* finding decidable.
|
|
154
|
+
*/
|
|
155
|
+
const rel = (root: string, p: string): string => {
|
|
156
|
+
const slashed = p.split(path.sep).join('/');
|
|
157
|
+
// Normalised, so `fixtures/../../../public/a.svg` is `public/a.svg` and not
|
|
158
|
+
// a planted fixture by prefix (16.5.0 re-cut review).
|
|
159
|
+
if (!path.isAbsolute(p)) return path.posix.normalize(slashed).replace(/^\.\//, '');
|
|
160
|
+
const inside = path.relative(root, p).split(path.sep).join('/');
|
|
161
|
+
// `path.relative` escaping the root with `..` means the path is NOT in this
|
|
162
|
+
// workspace; keep it as given rather than inventing a relation to it.
|
|
163
|
+
return inside === '' || inside.startsWith('../') ? slashed : inside;
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
export function isPlantedFixture(relPath: string, root = ''): boolean {
|
|
167
|
+
const norm = rel(root, relPath);
|
|
168
|
+
return PLANTED_PREFIXES.some((prefix) => norm.startsWith(prefix));
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Every asset manifest currently under the render directory. */
|
|
172
|
+
export function readAssetManifests(root: string): AssetManifestish[] {
|
|
173
|
+
const dir = path.join(root, RENDER_REL);
|
|
174
|
+
let entries: string[];
|
|
175
|
+
try {
|
|
176
|
+
entries = fs.readdirSync(dir);
|
|
177
|
+
} catch {
|
|
178
|
+
return [];
|
|
179
|
+
}
|
|
180
|
+
const out: AssetManifestish[] = [];
|
|
181
|
+
for (const name of entries) {
|
|
182
|
+
let raw: string;
|
|
183
|
+
try {
|
|
184
|
+
raw = fs.readFileSync(path.join(dir, name, 'manifest.json'), 'utf8');
|
|
185
|
+
} catch {
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
try {
|
|
189
|
+
const parsed = JSON.parse(raw) as AssetManifestish;
|
|
190
|
+
if (parsed.profile === 'asset') out.push(parsed);
|
|
191
|
+
} catch {
|
|
192
|
+
// An unreadable manifest discharges nothing, which is the same
|
|
193
|
+
// verdict as an absent one and the safe direction for a gate.
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return out;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* How a digest is discharged by the manifests on disk, or `null`.
|
|
201
|
+
*
|
|
202
|
+
* `verified` wins over `static-only` when both exist: the stronger record is
|
|
203
|
+
* the one the report should name.
|
|
204
|
+
*/
|
|
205
|
+
export function dischargeFor(manifests: readonly AssetManifestish[], digest: string): DischargeKind | null {
|
|
206
|
+
let staticOnly = false;
|
|
207
|
+
for (const m of manifests) {
|
|
208
|
+
// AN ERROR FLOOR ON BOTH PATHS. Finding 2 of the fifth completion
|
|
209
|
+
// review: `verification: 'verified'` is written unconditionally by the
|
|
210
|
+
// render — it means a digest-bound render HAPPENED, which is what the
|
|
211
|
+
// skill's status table says and what it should keep meaning — and this
|
|
212
|
+
// branch discharged on it with no look at what the render FOUND. So a
|
|
213
|
+
// file whose own reading carries an error findings discharged a
|
|
214
|
+
// blocking gate through the strong path, while the weaker
|
|
215
|
+
// `renderer-absent` path below refused at a single error. The rigour
|
|
216
|
+
// was inverted: the path that saw more of the file demanded less of it.
|
|
217
|
+
if (m.verification === 'verified' && m.asset?.digest === digest && !hasErrorFinding(m)) {
|
|
218
|
+
return 'verified';
|
|
219
|
+
}
|
|
220
|
+
if (m.reason === 'renderer-absent' && m.static?.digest === digest && m.static.errors === 0) {
|
|
221
|
+
staticOnly = true;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
return staticOnly ? 'static-only' : null;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export interface SvgObligationInputs {
|
|
228
|
+
/**
|
|
229
|
+
* One entry per file the turn EDITED, in order, `.svg` and otherwise, each
|
|
230
|
+
* carrying the `ui:render --profile asset` command lines that ran after
|
|
231
|
+
* THAT file's last edit, verbatim.
|
|
232
|
+
*
|
|
233
|
+
* Precision (iii), and both halves of it were wrong once.
|
|
234
|
+
*
|
|
235
|
+
* The first round made it per file by NAME: a single boolean used to mean
|
|
236
|
+
* that any asset render anywhere unlocked every manifest on disk, so a
|
|
237
|
+
* hand-written manifest plus a decoy render on an unrelated file
|
|
238
|
+
* discharged the obligation. A command must NAME the file it discharges.
|
|
239
|
+
*
|
|
240
|
+
* The second round made it per file by TIME, which is finding 2 of that
|
|
241
|
+
* round. The render window was taken from the turn's LAST `.svg` edit
|
|
242
|
+
* globally while the obligation is per file, so the ordinary interleaved
|
|
243
|
+
* turn — edit a, render a, edit b, render b — hid render-a behind edit-b
|
|
244
|
+
* and refused a turn in which both files were rendered correctly after
|
|
245
|
+
* their own edits. Confirmed by running it before it was changed.
|
|
246
|
+
*
|
|
247
|
+
* Both windows are the same shape: a render counts for a file only if it
|
|
248
|
+
* names that file AND follows that file's own last edit.
|
|
249
|
+
*/
|
|
250
|
+
edits: ReadonlyArray<{ path: string; rendered: readonly string[] }>;
|
|
251
|
+
/**
|
|
252
|
+
* True when this turn wrote into the render directory by some route other
|
|
253
|
+
* than an asset render — an edit tool, or a shell command naming the
|
|
254
|
+
* directory. The manifests on disk are then not evidence of a render, so
|
|
255
|
+
* none of them discharges anything (16.5.0 release review).
|
|
256
|
+
*/
|
|
257
|
+
renderDirTampered?: boolean;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* The reading. Pure apart from reading the files it is asked about, so the
|
|
262
|
+
* eight cases the roadmap names can be exercised without a hook run.
|
|
263
|
+
*/
|
|
264
|
+
export function readSvgObligations(root: string, inputs: SvgObligationInputs): SvgObligationReading {
|
|
265
|
+
const open: SvgObligation[] = [];
|
|
266
|
+
const discharged: SvgDischarge[] = [];
|
|
267
|
+
const seen = new Set<string>();
|
|
268
|
+
let manifests: AssetManifestish[] | null = null;
|
|
269
|
+
|
|
270
|
+
for (const { path: raw, rendered } of inputs.edits) {
|
|
271
|
+
if (!/\.svg$/i.test(raw)) continue;
|
|
272
|
+
// Repo-relative FIRST, so `seen`, the planted exemption and the
|
|
273
|
+
// reported `file` all speak the same dialect — the doc on
|
|
274
|
+
// `SvgObligation.file` promises repo-relative and used to be false for
|
|
275
|
+
// an absolute tool call.
|
|
276
|
+
const relPath = rel(root, raw);
|
|
277
|
+
if (seen.has(relPath)) continue;
|
|
278
|
+
seen.add(relPath);
|
|
279
|
+
// (ii) planted fixtures carry errors on purpose.
|
|
280
|
+
if (isPlantedFixture(relPath, root)) continue;
|
|
281
|
+
|
|
282
|
+
// (iv) a deleted file has no current digest, so nothing could be bound
|
|
283
|
+
// to it and no render could ever discharge it.
|
|
284
|
+
const abs = path.isAbsolute(raw) ? raw : path.join(root, relPath);
|
|
285
|
+
// The BYTES, matching the render and the static witness. Finding 2 of
|
|
286
|
+
// the sixth completion review: this hashed the UTF-8 decoded string
|
|
287
|
+
// while the render hashed the raw buffer, so a file the decode is not
|
|
288
|
+
// byte-exact over could never have its obligation discharged.
|
|
289
|
+
let bytes: Buffer;
|
|
290
|
+
try {
|
|
291
|
+
bytes = fs.readFileSync(abs);
|
|
292
|
+
} catch {
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
const digest = crypto.createHash('sha256').update(bytes).digest('hex');
|
|
296
|
+
|
|
297
|
+
// (iii) The manifests are only consulted once a render was observed.
|
|
298
|
+
// Reading them first and then discarding the verdict would make the
|
|
299
|
+
// ordering look like a performance detail rather than the rule.
|
|
300
|
+
if (!renderNames(rendered, relPath)) {
|
|
301
|
+
open.push({
|
|
302
|
+
file: relPath,
|
|
303
|
+
reason:
|
|
304
|
+
'no `ui:render --profile asset` naming this file ran after THIS file\'s last edit, so no ' +
|
|
305
|
+
'manifest on ' +
|
|
306
|
+
'disk can be attributed to it — a manifest written by any other route, and a render of ' +
|
|
307
|
+
'some other file, both discharge nothing',
|
|
308
|
+
});
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
if (inputs.renderDirTampered) {
|
|
312
|
+
open.push({
|
|
313
|
+
file: relPath,
|
|
314
|
+
reason:
|
|
315
|
+
'this turn wrote into the render directory by a route other than `ui:render`, so no manifest ' +
|
|
316
|
+
'there is evidence of a render — re-run the render in a turn that does not touch it',
|
|
317
|
+
});
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
manifests ??= readAssetManifests(root);
|
|
321
|
+
const kind = dischargeFor(manifests, digest);
|
|
322
|
+
if (kind === null) {
|
|
323
|
+
open.push({
|
|
324
|
+
file: relPath,
|
|
325
|
+
reason:
|
|
326
|
+
'no asset manifest matches this file\'s current digest — a render that is `verified`, or a ' +
|
|
327
|
+
'`renderer-absent` one whose static witness reports 0 errors on the same digest, discharges it',
|
|
328
|
+
});
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
discharged.push({ file: relPath, kind });
|
|
332
|
+
}
|
|
333
|
+
return { open, discharged };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Does one of the observed renders NAME this file?
|
|
338
|
+
*
|
|
339
|
+
* The render is a precondition for reading a manifest, never a discharge on
|
|
340
|
+
* its own — `isVerificationCommand` still does not match it, so the command's
|
|
341
|
+
* text cannot satisfy the obligation on the transcript path. Detector C reads
|
|
342
|
+
* `isVerificationAttempt` instead, which is a different question with a
|
|
343
|
+
* different right answer; both live in `_lib/verification_command.ts`, where
|
|
344
|
+
* the split is argued.
|
|
345
|
+
*/
|
|
346
|
+
export function renderNames(commands: readonly string[], relPath: string): boolean {
|
|
347
|
+
const base = relPath.split('/').pop() ?? relPath;
|
|
348
|
+
return commands.some((c) => {
|
|
349
|
+
const slashed = c.split(path.sep).join('/');
|
|
350
|
+
// The path as written, or its basename — a caller may `cd` first, and a
|
|
351
|
+
// basename collision inside one turn is a narrower hole than the
|
|
352
|
+
// any-render-unlocks-everything it replaces.
|
|
353
|
+
//
|
|
354
|
+
// BOTH tests are anchored. Finding 3 of the fourth completion review:
|
|
355
|
+
// the path branch was a bare `includes`, so a render of
|
|
356
|
+
// `public/icon.svg` counted as naming `n.svg` and `on.svg` — any file
|
|
357
|
+
// whose path is a substring of another file's path was discharged by a
|
|
358
|
+
// render it had nothing to do with, which is the
|
|
359
|
+
// any-render-unlocks-everything hole this function replaced, reopened
|
|
360
|
+
// one character at a time. The boundary admits `/` before, so a path
|
|
361
|
+
// written `./public/icon.svg` still names `public/icon.svg`.
|
|
362
|
+
const bounded = (needle: string): boolean =>
|
|
363
|
+
new RegExp(`(^|[\\s/'"])${escapeRe(needle)}(\\s|$|['"])`).test(slashed);
|
|
364
|
+
return bounded(relPath) || bounded(base);
|
|
365
|
+
});
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* A render call standing alone — no second command chained to it and no
|
|
370
|
+
* redirect — so its text cannot also carry a write. A leading `cd <dir> &&`,
|
|
371
|
+
* piping its output to a read-only filter, and folding stderr into stdout are
|
|
372
|
+
* allowed.
|
|
373
|
+
*/
|
|
374
|
+
export function isPureRender(command: string): boolean {
|
|
375
|
+
if (!isAssetRenderCommand(command)) return false;
|
|
376
|
+
const [head, ...pipes] = command.replace(/^\s*cd\s+[^;&|<>`$\n]+&&\s*/, '').replace(/\s\d?>&\d\b/g, ' ').replace(/\s2>\s*\/dev\/null\b/g, ' ').split('|');
|
|
377
|
+
if (/[;&<>`\n]|\$\(/.test(head ?? '')) return false;
|
|
378
|
+
return pipes.every((p) => /^\s*(tail|head|grep|jq|cat|less|wc)\b[^;&<>`|]*$/.test(p) && !/\$\(/.test(p));
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* Does a shell command (other than a pure render) write somewhere a render
|
|
383
|
+
* manifest could live? Read by shape: a write operator, together with the
|
|
384
|
+
* render directory, a `render/` path or a `manifest.json` — so `cd` into the
|
|
385
|
+
* state directory and a relative write is caught, and reading a manifest
|
|
386
|
+
* back is not.
|
|
387
|
+
*/
|
|
388
|
+
export function writesRenderDir(command: string): boolean {
|
|
389
|
+
if (isPureRender(command)) return false;
|
|
390
|
+
const slashed = command.split(path.sep).join('/');
|
|
391
|
+
const names = /(^|[\s/'"])(render\/|manifest\.json\b)/.test(slashed) || slashed.includes('state/render');
|
|
392
|
+
// A LISTED interpreter in a command that names the directory counts as a
|
|
393
|
+
// write — inline flag, long option, `eval` or a program on stdin — because
|
|
394
|
+
// what it does with the path is not readable from the command line. A
|
|
395
|
+
// read-only script is caught too, on purpose: the cost is one re-render.
|
|
396
|
+
// An unlisted or indirected interpreter is not seen; see the header residual.
|
|
397
|
+
const writes = />(?!&)|\b(tee|sponge)\b|\b(cp|mv|ln|install|rsync|dd)\s|\bsed\s+-i|\bwriteFile|\b(python[\d.]*|perl|ruby|node|deno|bun|php|osascript)\b/.test(
|
|
398
|
+
slashed.replace(/\s\d?>&\d\b/g, ' ').replace(/\s2>\s*\/dev\/null\b/g, ' '),
|
|
399
|
+
);
|
|
400
|
+
return names && writes;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Escape a path component for use inside a RegExp. */
|
|
404
|
+
function escapeRe(s: string): string {
|
|
405
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* An `.svg` this turn edited that no render describes — step 2.1(b) of
|
|
412
|
+
* `road-to-svg-review-followups`.
|
|
413
|
+
*
|
|
414
|
+
* REPORTED UNDER `verification`, not under a new detector id, and that is a
|
|
415
|
+
* choice rather than a shortcut: this IS a verification finding — a file
|
|
416
|
+
* changed and nothing that can fail was run over it — and a seventh id would
|
|
417
|
+
* ripple through `DETECTOR_IDS`, the per-detector counters and the demotion
|
|
418
|
+
* contract for a distinction the operator never makes.
|
|
419
|
+
*
|
|
420
|
+
* IT RUNS EVEN WHEN `detectUnverifiedEdit` IS SATISFIED. That is the point:
|
|
421
|
+
* review claim C2 is about a turn that edits an `.svg` and then runs something
|
|
422
|
+
* — a test suite, a linter, a bare `ui:render` — which looks like verification
|
|
423
|
+
* and did not look at the graphic. An unrelated check discharges nothing here.
|
|
424
|
+
*
|
|
425
|
+
* The companion half is that `isVerificationCommand` learns NEITHER `ui:render`
|
|
426
|
+
* NOR `xmllint`. Teaching it either would discharge the obligation on the
|
|
427
|
+
* transcript path from the command text alone, which is the hole rather than
|
|
428
|
+
* the fix. `svg_obligation.ts` carries the two records that do discharge it.
|
|
429
|
+
*/
|
|
430
|
+
export function openSvgObligation(
|
|
431
|
+
workspaceRoot: string,
|
|
432
|
+
toolCalls: readonly ToolCall[],
|
|
433
|
+
): { detector: 'verification'; evidence: string; reason: string; mode: 'transcript' } | null {
|
|
434
|
+
// Precision (iii). The render has to come AFTER the edit it describes, on
|
|
435
|
+
// both evidence paths — `toolCalls` is assembled for both, so one rule
|
|
436
|
+
// covers them rather than two that can disagree.
|
|
437
|
+
//
|
|
438
|
+
// The window is per FILE, which finding 2 of the second review round is
|
|
439
|
+
// about: taking it from the turn's last `.svg` edit globally hid an
|
|
440
|
+
// earlier file's render behind a later file's edit, so the ordinary
|
|
441
|
+
// interleaved turn was refused although every file had been rendered after
|
|
442
|
+
// its own edit. The last edit of each path is the one that counts, because
|
|
443
|
+
// an earlier render describes bytes a later edit replaced.
|
|
444
|
+
const lastEditOf = new Map<string, number>();
|
|
445
|
+
for (let i = 0; i < toolCalls.length; i += 1) {
|
|
446
|
+
const c = toolCalls[i]!;
|
|
447
|
+
if (!EDIT_TOOLS.has(c.name) || c.path === undefined) continue;
|
|
448
|
+
lastEditOf.set(c.path, i);
|
|
449
|
+
}
|
|
450
|
+
if (lastEditOf.size === 0) return null;
|
|
451
|
+
|
|
452
|
+
const edits = [...lastEditOf].map(([p, at]) => {
|
|
453
|
+
const rendered: string[] = [];
|
|
454
|
+
for (let i = at + 1; i < toolCalls.length; i += 1) {
|
|
455
|
+
const c = toolCalls[i]!;
|
|
456
|
+
if (c.name === 'Bash' && c.command !== undefined && isPureRender(c.command)) {
|
|
457
|
+
rendered.push(c.command);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return { path: p, rendered };
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
const renderDir = RENDER_REL.split(path.sep).join('/');
|
|
464
|
+
const renderDirTampered = toolCalls.some((c) => {
|
|
465
|
+
if (EDIT_TOOLS.has(c.name) && c.path !== undefined) {
|
|
466
|
+
return rel(workspaceRoot, c.path).startsWith(`${renderDir}/`);
|
|
467
|
+
}
|
|
468
|
+
return c.name === 'Bash' && c.command !== undefined && writesRenderDir(c.command);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
let reading: SvgObligationReading;
|
|
472
|
+
try {
|
|
473
|
+
reading = readSvgObligations(workspaceRoot, { edits, renderDirTampered });
|
|
474
|
+
} catch {
|
|
475
|
+
// A gate that crashed on its own instrument is a new failure mode on a
|
|
476
|
+
// path meant to be inert. An unreadable render directory is no finding.
|
|
477
|
+
return null;
|
|
478
|
+
}
|
|
479
|
+
const first = reading.open[0];
|
|
480
|
+
if (first === undefined) return null;
|
|
481
|
+
|
|
482
|
+
const more = reading.open.length > 1 ? ` (and ${String(reading.open.length - 1)} more)` : '';
|
|
483
|
+
return {
|
|
484
|
+
// Reported under `verification` rather than a seventh detector id: it
|
|
485
|
+
// IS a verification finding — a file changed and nothing that can fail
|
|
486
|
+
// ran over it — and a new id would ripple through DETECTOR_IDS, the
|
|
487
|
+
// per-detector counters and the demotion contract for a distinction the
|
|
488
|
+
// operator never makes.
|
|
489
|
+
detector: 'verification',
|
|
490
|
+
mode: 'transcript',
|
|
491
|
+
evidence: first.file,
|
|
492
|
+
reason:
|
|
493
|
+
`this turn edited an .svg and no render describes it${more} — ${first.reason}. ` +
|
|
494
|
+
'A bare `ui:render --profile asset` that rendered nothing, and a test or lint run over other ' +
|
|
495
|
+
'files, both discharge nothing: the gate reads the render, never the call.',
|
|
496
|
+
};
|
|
497
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The conditional chunk — step 0.1 of `road-to-svg-rules-in-the-dispatch-path`.
|
|
3
|
+
*
|
|
4
|
+
* WHY A CHUNK AND NOT AN IMPORT. `src/scripts/_lib/svg_rules.ts` costs 30,924
|
|
5
|
+
* bytes when it is statically imported into `dist/hooks/dispatch.js`, the one
|
|
6
|
+
* bundle every hook event loads on every dispatch. An AI council of two
|
|
7
|
+
* providers refused a ceiling raise for it 2/2 on 2026-10-11 and both seats
|
|
8
|
+
* asked for this to be tried first: emit the rule set beside the bundle and
|
|
9
|
+
* load it only when the written path ends in `.svg`.
|
|
10
|
+
*
|
|
11
|
+
* WHY `require` AND NOT `await import`. `ConcernMain` is
|
|
12
|
+
* `(argv?) => number | undefined | void` — synchronous. A concern that returned
|
|
13
|
+
* a Promise would have its exit code read as an object, so the chunk has to be
|
|
14
|
+
* loadable without an `await`. It is therefore emitted as CJS and pulled with
|
|
15
|
+
* `createRequire`, which is synchronous. This is a constraint of the dispatch
|
|
16
|
+
* contract, not a style choice; making it `async` means making every concern
|
|
17
|
+
* async, which is a different change than this one.
|
|
18
|
+
*
|
|
19
|
+
* WHY THE SPECIFIER IS COMPUTED AT RUNTIME. A literal `import()` or `require()`
|
|
20
|
+
* argument is analyzable, and esbuild would inline the module — which is
|
|
21
|
+
* exactly the 30,924 bytes this avoids. Both branches build their specifier
|
|
22
|
+
* from `import.meta.url`, so neither is a static edge in the bundle's graph.
|
|
23
|
+
*
|
|
24
|
+
* THE TWO BRANCHES.
|
|
25
|
+
*
|
|
26
|
+
* bundled — `import.meta.url` is `dist/hooks/dispatch.js` (esbuild keeps
|
|
27
|
+
* `import.meta.url` in ESM output and the banner resolves the
|
|
28
|
+
* real path through a symlink), so the chunk is its sibling
|
|
29
|
+
* `dist/hooks/svg_rules.chunk.cjs`.
|
|
30
|
+
* unbundled — running from source under `tsx`, where `require` of a `.ts`
|
|
31
|
+
* file resolves through tsx's CJS hook. Measured working
|
|
32
|
+
* 2026-10-11; there is no chunk on this path and none is built.
|
|
33
|
+
*
|
|
34
|
+
* FAILURE IS NULL, NEVER A THROW. A missing chunk in a consumer install, a
|
|
35
|
+
* chunk packed to another path, or a module that throws on load all return
|
|
36
|
+
* `null` and the caller reports the rules as unavailable. The dispatch bundle
|
|
37
|
+
* handles every hook event in one process: a throw that escaped here would
|
|
38
|
+
* break events that never touched an `.svg`, which is the containment
|
|
39
|
+
* `design_pass_svg.test.ts` pins.
|
|
40
|
+
*/
|
|
41
|
+
import { createRequire } from 'node:module';
|
|
42
|
+
import * as path from 'node:path';
|
|
43
|
+
import { fileURLToPath } from 'node:url';
|
|
44
|
+
|
|
45
|
+
import type { Finding as SvgFinding, ScanOptions } from '../_lib/svg_rules.js';
|
|
46
|
+
import { verifyBundle } from './bundle_integrity.js';
|
|
47
|
+
|
|
48
|
+
/** The signature `scanSvg` exports. Type-only, so no bytes reach the bundle. */
|
|
49
|
+
export type SvgScan = (text: string, file: string, opts?: ScanOptions) => SvgFinding[];
|
|
50
|
+
|
|
51
|
+
/** The emitted sibling's name. `build:hook-chunks` writes exactly this. */
|
|
52
|
+
export const CHUNK_BASENAME = 'svg_rules.chunk.cjs';
|
|
53
|
+
|
|
54
|
+
declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
|
|
55
|
+
|
|
56
|
+
/** True when this code is running from `dist/hooks/dispatch.js`. */
|
|
57
|
+
export function isBundled(): boolean {
|
|
58
|
+
return typeof __AGENT_CONFIG_BUNDLE__ !== 'undefined' && __AGENT_CONFIG_BUNDLE__;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Where to load the rules from, given this module's own URL.
|
|
63
|
+
*
|
|
64
|
+
* Exported so a test can pin both branches without a bundle: the path algebra
|
|
65
|
+
* is the half that breaks silently in a consumer install, and a probe that can
|
|
66
|
+
* only run against a built bundle would not be run.
|
|
67
|
+
*/
|
|
68
|
+
export function rulesSpecifier(selfUrl: string, bundled: boolean): string {
|
|
69
|
+
const selfPath = fileURLToPath(selfUrl);
|
|
70
|
+
const dir = path.dirname(selfPath);
|
|
71
|
+
if (bundled) return path.join(dir, CHUNK_BASENAME);
|
|
72
|
+
// From source: `.../src/scripts/hooks/` → `.../src/scripts/_lib/`. The
|
|
73
|
+
// extension follows this file's own, so a compiled-to-JS tree resolves too.
|
|
74
|
+
const ext = selfPath.endsWith('.ts') ? '.ts' : '.js';
|
|
75
|
+
return path.join(dir, '..', '_lib', `svg_rules${ext}`);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const cache = new Map<string, SvgScan | null>();
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The rule set, or `null` when it could not be loaded.
|
|
82
|
+
*
|
|
83
|
+
* Cached including the failure: a consumer whose install is missing the chunk
|
|
84
|
+
* would otherwise pay a failed module resolution on every `.svg` write.
|
|
85
|
+
*
|
|
86
|
+
* Keyed BY `selfUrl`. Finding 13 of the third completion review: a single
|
|
87
|
+
* slot meant the first call's verdict answered every later call whatever url
|
|
88
|
+
* it passed, so the parameter was accepted and ignored — the shape this
|
|
89
|
+
* change's own review found three times under other names. It is one caller
|
|
90
|
+
* and one url on the hook path, so nothing observable changed; a cache whose
|
|
91
|
+
* key is not its input is wrong regardless of who currently calls it, and
|
|
92
|
+
* that is cheaper to fix than to remember.
|
|
93
|
+
*/
|
|
94
|
+
export function loadSvgScan(selfUrl: string = import.meta.url): SvgScan | null {
|
|
95
|
+
const hit = cache.get(selfUrl);
|
|
96
|
+
if (hit !== undefined || cache.has(selfUrl)) return hit ?? null;
|
|
97
|
+
const scan = loadSvgScanUncached(selfUrl);
|
|
98
|
+
cache.set(selfUrl, scan);
|
|
99
|
+
return scan;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The uncached load. Separate so a test can exercise a failure and a success.
|
|
104
|
+
*
|
|
105
|
+
* `bundled` is an explicit override rather than always `isBundled()`: under a
|
|
106
|
+
* test runner the define is absent, so the bundled branch — the one that
|
|
107
|
+
* breaks silently in a consumer install — would otherwise be unreachable from
|
|
108
|
+
* any test that does not first build a bundle, and a probe that expensive does
|
|
109
|
+
* not get run. The hook path never passes it.
|
|
110
|
+
*/
|
|
111
|
+
export function loadSvgScanUncached(
|
|
112
|
+
selfUrl: string = import.meta.url,
|
|
113
|
+
bundled: boolean = isBundled(),
|
|
114
|
+
): SvgScan | null {
|
|
115
|
+
try {
|
|
116
|
+
const specifier = rulesSpecifier(selfUrl, bundled);
|
|
117
|
+
// The chunk runs inside the dispatch process, so it gets the bundle's
|
|
118
|
+
// own integrity check: a mismatch with its sidecar is not executed.
|
|
119
|
+
// No sidecar is `unverifiable` and allowed, as for `dispatch.js`.
|
|
120
|
+
if (bundled && verifyBundle(specifier).state === 'mismatch') return null;
|
|
121
|
+
const req = createRequire(selfUrl);
|
|
122
|
+
const mod = req(specifier) as Record<string, unknown>;
|
|
123
|
+
const scan = mod['scanSvg'];
|
|
124
|
+
return typeof scan === 'function' ? (scan as SvgScan) : null;
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Test seam: forget every cached verdict. Never called by the hook path. */
|
|
131
|
+
export function _resetSvgScanCache(): void {
|
|
132
|
+
cache.clear();
|
|
133
|
+
}
|
|
@@ -197,7 +197,8 @@ import {
|
|
|
197
197
|
graphUntestedVerdict,
|
|
198
198
|
} from '../_lib/graph_feeder_record.js';
|
|
199
199
|
import { type GraphState, graphState } from '../code_graph/detect.js';
|
|
200
|
-
import { isVerificationCommand } from '../_lib/verification_command.js';
|
|
200
|
+
import { isVerificationAttempt, isVerificationCommand } from '../_lib/verification_command.js';
|
|
201
|
+
import { openSvgObligation } from './svg_obligation.js';
|
|
201
202
|
// Round 8 — the record path detector C prefers over its own regex. The regex
|
|
202
203
|
// reads a command's TEXT and `echo test` matches it; these read what the run
|
|
203
204
|
// exited with and what it printed.
|
|
@@ -761,7 +762,7 @@ export function detectUnverifiedEdit(
|
|
|
761
762
|
|
|
762
763
|
for (let i = lastEdit + 1; i < toolCalls.length; i += 1) {
|
|
763
764
|
const c = toolCalls[i]!;
|
|
764
|
-
if (c.name === 'Bash' && c.command !== undefined &&
|
|
765
|
+
if (c.name === 'Bash' && c.command !== undefined && isVerificationAttempt(c.command)) {
|
|
765
766
|
return null;
|
|
766
767
|
}
|
|
767
768
|
}
|
|
@@ -772,7 +773,7 @@ export function detectUnverifiedEdit(
|
|
|
772
773
|
evidence: edited,
|
|
773
774
|
reason:
|
|
774
775
|
'this turn changed a file and then ran no verification command — ' +
|
|
775
|
-
'no test, type-check, lint or
|
|
776
|
+
'no test, type-check, lint, build or asset render follows the last edit ' +
|
|
776
777
|
'(verify-before-complete: a claim without a fresh run is unverified)',
|
|
777
778
|
mode: 'transcript',
|
|
778
779
|
};
|
|
@@ -1281,6 +1282,7 @@ function runDetectors(inputs: DetectorInputs): Finding[] {
|
|
|
1281
1282
|
// on a completion CLAIM, and a turn waiting on a subagent has not
|
|
1282
1283
|
// finished — so its closing is not the claim F is about. Same slot,
|
|
1283
1284
|
// opposite trigger, opposite treatment of `dispatchOpen`.
|
|
1285
|
+
openSvgObligation(workspaceRoot, toolCalls),
|
|
1284
1286
|
dispatchOpen ? null : detectUntestedChange(lastAssistant, toolCalls, runState),
|
|
1285
1287
|
]) {
|
|
1286
1288
|
if (f) findings.push(f);
|