@event4u/agent-config 9.29.0 → 9.30.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 +108 -7
- package/README.md +1 -0
- package/dist/agent-src/commands/analyze/inbox.md +22 -1
- package/dist/agent-src/commands/explain-run.md +31 -1
- package/dist/agent-src/commands/team/delegate.md +1 -0
- package/dist/agent-src/contexts/execution/orchestration-telemetry.md +4 -1
- package/dist/agent-src/contexts/execution/roadmap-process-loop.md +17 -0
- package/dist/agent-src/contexts/execution/subagent-response-contract.md +11 -1
- package/dist/agent-src/rules/preservation-guard.md +9 -0
- package/dist/agent-src/scripts/roadmap_gates.ts +312 -0
- package/dist/agent-src/scripts/update_roadmap_progress.ts +72 -4
- package/dist/agent-src/skills/subagent-orchestration/SKILL.md +10 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli/registry.js +2 -0
- package/dist/cli/registry.js.map +1 -1
- package/dist/cli-delegate/{chunk-OJM3MBJM.js → chunk-BTJNRUX3.js} +21 -34
- package/dist/cli-delegate/{chunk-MMXA5GY7.js → chunk-DDUNNFYX.js} +14 -0
- package/dist/cli-delegate/{chunk-NZ5N2XQL.js → chunk-EQY4FLJB.js} +1 -1
- package/dist/cli-delegate/{chunk-RCIA67XN.js → chunk-IYTGIG2Y.js} +44 -1
- package/dist/cli-delegate/chunk-MMJL4TIX.js +18 -0
- package/dist/cli-delegate/chunk-O3CO5BFB.js +161 -0
- package/dist/cli-delegate/{chunk-REKCMYOU.js → chunk-PFBV6XRT.js} +36 -20
- package/dist/cli-delegate/chunk-RVGBH4XK.js +142 -0
- package/dist/cli-delegate/cmd_conformance.js +2798 -95
- package/dist/cli-delegate/cmd_doctor.js +2 -1
- package/dist/cli-delegate/cmd_export.js +2 -2
- package/dist/cli-delegate/cmd_handoff.js +86 -6
- package/dist/cli-delegate/cmd_prune.js +2 -2
- package/dist/cli-delegate/cmd_route_audit.js +2 -1
- package/dist/cli-delegate/cmd_session_recycle.js +177 -0
- package/dist/cli-delegate/cmd_settings_get.js +1 -1
- package/dist/cli-delegate/cmd_settings_migrate.js +2 -2
- package/dist/cli-delegate/cmd_settings_set.js +2 -2
- package/dist/cli-delegate/cmd_sync.js +2 -2
- package/dist/cli-delegate/cmd_uninstall.js +2 -2
- package/dist/cli-delegate/cmd_validate.js +2 -2
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +7 -7
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.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 +1904 -575
- package/dist/install/install.mjs +14 -0
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +3760 -726
- package/dist/server/schemas/settings.js +6 -0
- package/dist/server/schemas/settings.js.map +1 -1
- package/dist/ui/assets/index-B5qGYOJo.js +26 -0
- package/dist/ui/assets/index-B5qGYOJo.js.map +1 -0
- package/dist/ui/index.html +1 -1
- package/docs/contracts/settings-classes.md +21 -5
- package/package.json +1 -1
- package/src/agent-src/scripts/roadmap_gates.ts +312 -0
- package/src/agent-src/scripts/update_roadmap_progress.ts +72 -4
- package/src/config/agent-settings.template.yml +36 -0
- package/src/config/dispatch-economy-metrics.json +61 -0
- package/src/config/evaluator-budgets.json +4 -4
- package/src/config/hook-token-budget.json +91 -0
- package/src/config/recycle-threshold-budget.json +12 -0
- package/src/config/rule-enforcement-baseline.json +86 -0
- package/src/scripts/_cli/cmd_conformance.ts +17 -2
- package/src/scripts/_cli/cmd_session_recycle.ts +226 -0
- package/src/scripts/_dispatch.bash +23 -0
- package/src/scripts/_lib/judgment_ladder.ts +254 -2
- package/src/scripts/_lib/orchestration_record.ts +41 -10
- package/src/scripts/_lib/recycle_envelope_paths.ts +35 -0
- package/src/scripts/_lib/self_repair.ts +194 -0
- package/src/scripts/_lib/session_eol.ts +230 -0
- package/src/scripts/_lib/session_role.ts +42 -0
- package/src/scripts/_lib/subagent_capsule.ts +159 -0
- package/src/scripts/_lib/subagent_response.ts +75 -0
- package/src/scripts/ai_council/clients.ts +50 -1
- package/src/scripts/ask_transport.ts +221 -0
- package/src/scripts/bench_hook_injection.ts +234 -0
- package/src/scripts/check_adr_frontmatter.ts +84 -40
- package/src/scripts/check_branch_freshness.ts +1 -0
- package/src/scripts/check_corpus_staleness.ts +18 -4
- package/src/scripts/check_detector_corpus.ts +17 -0
- package/src/scripts/check_gitignore_freshness.ts +42 -8
- package/src/scripts/check_hook_bundle_freshness.ts +20 -0
- package/src/scripts/check_kernel_prefix_stability.ts +1 -0
- package/src/scripts/check_standing_rule_delivery.ts +73 -32
- package/src/scripts/check_static_layer_stability.ts +154 -0
- package/src/scripts/check_suppression_hygiene.ts +48 -0
- package/src/scripts/dispatch_economy_report.ts +448 -0
- package/src/scripts/dispatch_r2_reviewer.ts +15 -2
- package/src/scripts/explain_run.ts +206 -4
- package/src/scripts/generate_settings_reference.ts +1 -0
- package/src/scripts/handoff_context_hook.ts +112 -4
- package/src/scripts/hook_manifest.yaml +117 -10
- package/src/scripts/hooks/concern_registry.ts +8 -0
- package/src/scripts/hooks/delegation_nudge_hook.ts +40 -3
- package/src/scripts/hooks/dispatch_hook.ts +39 -2
- package/src/scripts/hooks/edit_shape_hook.ts +303 -0
- package/src/scripts/hooks/orchestration_record_hook.ts +19 -1
- package/src/scripts/hooks/reread_guard_hook.ts +314 -0
- package/src/scripts/hooks/rtk_wrap_hook.ts +133 -14
- package/src/scripts/hooks/session_eol_hook.ts +268 -0
- package/src/scripts/hooks/turn_end_gate_hook.ts +507 -0
- package/src/scripts/install-hooks.sh +1 -1
- package/src/scripts/language_mirror_hook.ts +89 -0
- package/src/scripts/lint_decision_impact.ts +14 -1
- package/src/scripts/lint_hook_manifest.ts +82 -0
- package/src/scripts/lint_readme_serial_comma.ts +1 -0
- package/src/scripts/lint_rule_enforcement_declaration.ts +347 -0
- package/src/scripts/measure_turn_end_gate.ts +194 -0
- package/src/scripts/orchestration_record.ts +4 -1
- package/src/scripts/rebuild_hook_bundle.ts +1 -0
- package/src/scripts/report_conformance_funnel.ts +284 -0
- package/src/scripts/report_imperative_density.ts +1 -0
- package/src/scripts/routing_doctor.ts +97 -0
- package/src/scripts/self_repair_cli.ts +51 -3
- package/src/scripts/session_eol_report.ts +261 -0
- package/src/server/schemas/settings.ts +14 -0
- package/src/templates/compact-instructions.md +40 -0
- package/dist/ui/assets/index-JDAF00bC.js +0 -26
- package/dist/ui/assets/index-JDAF00bC.js.map +0 -1
|
@@ -37,14 +37,18 @@ import {
|
|
|
37
37
|
} from './auto_dispatch.js';
|
|
38
38
|
|
|
39
39
|
/** The five dispatch rungs, or `null` for the silent ∅ (in-session / ask). */
|
|
40
|
-
export type LadderRung = 0 | 1 | 2 | 3 | 4 | null;
|
|
40
|
+
export type LadderRung = 0 | 0.5 | 1 | 2 | 3 | 4 | null;
|
|
41
41
|
|
|
42
42
|
export type LadderVerdict =
|
|
43
43
|
| 'script' // rung 0 — deterministic transform/primitive, no spawn at all
|
|
44
44
|
| 'subagent' // rung 1/2 — one, or several downshifted, subagent dispatch(es)
|
|
45
45
|
| 'team' // rung 3 — communicating slices, dispatched via the host teams primitive
|
|
46
46
|
| 'council' // rung 4 — contested judgment
|
|
47
|
-
|
|
47
|
+
// 'ask' carries TWO shapes, split by rung (road-to-token-economy-dispatch
|
|
48
|
+
// Phase 4): rung 0.5 = a bounded question routed to ONE completion via
|
|
49
|
+
// `ask_transport` (no session, no tools, hard-capped — never the user);
|
|
50
|
+
// rung null = ambiguity, a VERDICT to the user, never a speculative spawn.
|
|
51
|
+
| 'ask'
|
|
48
52
|
| 'in-session'; // ∅ — halted, no host primitive, trivial, approval-required, or recursive guard
|
|
49
53
|
|
|
50
54
|
export interface LadderResult {
|
|
@@ -279,6 +283,62 @@ export function detectContestedJudgment(text: string): { matched: boolean; reaso
|
|
|
279
283
|
* `point-of-action-carrier` blocker already names as its pre-registered
|
|
280
284
|
* null ("no discriminator" is publishable and does not block this roadmap).
|
|
281
285
|
*/
|
|
286
|
+
/**
|
|
287
|
+
* Rung-0.5 signal — a single bounded question (token-economy-dispatch 4.2).
|
|
288
|
+
*
|
|
289
|
+
* Deliberately conservative, regex-only (the F3-lite discipline): the cost
|
|
290
|
+
* of a false NEGATIVE is one avoidable spawn; the cost of a false POSITIVE
|
|
291
|
+
* is an ask that returns INSUFFICIENT_CONTEXT and gets escalated — which the
|
|
292
|
+
* registered `ask_economy` kill criterion counts against the rung. So every
|
|
293
|
+
* ambiguity falls through (no match), and four hard exclusions apply:
|
|
294
|
+
*
|
|
295
|
+
* 1. Multi-line prompts — a bounded question is one sentence.
|
|
296
|
+
* 2. Length > 240 chars — beyond that it is a briefing, not a question.
|
|
297
|
+
* 3. Imperative work verbs (fix/implement/write/…) — those are tasks.
|
|
298
|
+
* 4. No interrogative shape — must lead with an interrogative (EN/DE) and
|
|
299
|
+
* end with `?`, OR be an explicit classify/categorize instruction
|
|
300
|
+
* (the one imperative the roadmap names as ask-shaped).
|
|
301
|
+
*/
|
|
302
|
+
const _QUESTION_IMPERATIVE_RE =
|
|
303
|
+
/\b(fix|implement|refactor|build|create|write|add|update|delete|remove|rename|migrate|install|deploy|commit|push|merge|run|execute|generate|erstelle|baue|implementiere|schreibe|behebe)\b/i;
|
|
304
|
+
const _QUESTION_LEAD_RE =
|
|
305
|
+
/^(is|are|does|do|did|can|could|should|would|will|which|what|why|how|when|where|who|whose|ist|sind|hat|haben|kann|welche[rs]?|was|warum|wie|wann|wo|wer)\b/i;
|
|
306
|
+
const _CLASSIFY_LEAD_RE = /^(classify|categori[sz]e|klassifiziere)\b/i;
|
|
307
|
+
const _QUESTION_MAX_LEN = 240;
|
|
308
|
+
|
|
309
|
+
/** A question that names a file/repo object or a concrete path needs TOOLS —
|
|
310
|
+
* a blind completion cannot read it, so it is never ask-shaped. */
|
|
311
|
+
const _NEEDS_TOOLS_RE =
|
|
312
|
+
/\b(this|the|that|my|our)\s+(?:[\w-]+\s+){0,2}?(file|files|codebase|repo|repository|transcript|log|logs|diff|document|branch|pr|route|routes|schema|module|class|function|endpoint|test|tests)\b|[\w.-]+\/[\w.-]+|\b[\w-]+\.(ts|js|py|php|md|json|yaml|yml|sh|go|rs)\b/i;
|
|
313
|
+
|
|
314
|
+
export function detectBoundedQuestion(taskText: string): { matched: boolean; reason: string } {
|
|
315
|
+
const text = taskText.trim();
|
|
316
|
+
if (!text || text.length > _QUESTION_MAX_LEN || text.includes('\n')) {
|
|
317
|
+
return { matched: false, reason: 'not-bounded' };
|
|
318
|
+
}
|
|
319
|
+
if (_NEEDS_TOOLS_RE.test(text)) {
|
|
320
|
+
return { matched: false, reason: 'references-tool-requiring-objects' };
|
|
321
|
+
}
|
|
322
|
+
// Classify-lead first: its PAYLOAD may quote work verbs ("classify this
|
|
323
|
+
// error: ETIMEDOUT on push") without being a work instruction itself.
|
|
324
|
+
if (_CLASSIFY_LEAD_RE.test(text)) {
|
|
325
|
+
return {
|
|
326
|
+
matched: true,
|
|
327
|
+
reason: 'bounded-question (classification) — rung-0.5 single completion via ask_transport, no session',
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
if (_QUESTION_IMPERATIVE_RE.test(text)) {
|
|
331
|
+
return { matched: false, reason: 'imperative-work-verb' };
|
|
332
|
+
}
|
|
333
|
+
if (_QUESTION_LEAD_RE.test(text) && text.endsWith('?')) {
|
|
334
|
+
return {
|
|
335
|
+
matched: true,
|
|
336
|
+
reason: 'bounded-question — rung-0.5 single completion via ask_transport, no session',
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
return { matched: false, reason: 'no-interrogative-shape' };
|
|
340
|
+
}
|
|
341
|
+
|
|
282
342
|
export function classifyLadder(inp: LadderInputs): LadderResult {
|
|
283
343
|
if (inp.interactiveApprovalRequired === true) {
|
|
284
344
|
return {
|
|
@@ -362,6 +422,18 @@ export function classifyLadder(inp: LadderInputs): LadderResult {
|
|
|
362
422
|
// regex and dispatched a subagent anyway, silently bypassing the floor
|
|
363
423
|
// `classifyTask` had just enforced one line above.
|
|
364
424
|
if (inp.signals.size_estimate <= SIZE_FLOOR) {
|
|
425
|
+
// Rung 0.5 (token-economy-dispatch 4.2) — a SELF-CONTAINED bounded
|
|
426
|
+
// question below the floor is the one below-spawn shape with its own
|
|
427
|
+
// routing verdict: one completion via ask_transport (no session, no
|
|
428
|
+
// tools, hard-capped). This is an ORCHESTRATOR-facing routing datum —
|
|
429
|
+
// the delegation-nudge carrier deliberately stays silent on it (a
|
|
430
|
+
// user's question is answered in-session at zero marginal cost; see
|
|
431
|
+
// classifyPrompt). Questions referencing files/paths are excluded by
|
|
432
|
+
// the detector: a completion without tools cannot read them.
|
|
433
|
+
const question = detectBoundedQuestion(inp.taskText);
|
|
434
|
+
if (question.matched) {
|
|
435
|
+
return { rung: 0.5, verdict: 'ask', reason: question.reason };
|
|
436
|
+
}
|
|
365
437
|
return {
|
|
366
438
|
rung: null,
|
|
367
439
|
verdict: classification.action === 'ask' ? 'ask' : 'in-session',
|
|
@@ -380,3 +452,183 @@ export function classifyLadder(inp: LadderInputs): LadderResult {
|
|
|
380
452
|
reason: classification.reason,
|
|
381
453
|
};
|
|
382
454
|
}
|
|
455
|
+
|
|
456
|
+
// ── Explanation trail (additive — Phase 4.1, road-to-feedback-9-29) ──────
|
|
457
|
+
//
|
|
458
|
+
// `classifyLadder` short-circuits on the first rung that resolves, so its
|
|
459
|
+
// minimal `{rung, verdict, reason}` return cannot answer "why NOT team /
|
|
460
|
+
// why NOT council / why no spawn". This layer answers exactly that without
|
|
461
|
+
// touching the minimal shape current callers depend on: the result is taken
|
|
462
|
+
// from `classifyLadder` itself (never re-derived, so the two can never
|
|
463
|
+
// disagree on the headline), and the per-rung trail is reconstructed from
|
|
464
|
+
// the SAME exported pure detectors and gate fields the resolver reads, in
|
|
465
|
+
// the same fixed priority order.
|
|
466
|
+
|
|
467
|
+
export type RungStatus = 'taken' | 'rejected' | 'not-reached';
|
|
468
|
+
|
|
469
|
+
export interface RungEvaluation {
|
|
470
|
+
rung: 0 | 0.5 | 1 | 2 | 3 | 4;
|
|
471
|
+
/** The verdict this rung resolves to when taken. */
|
|
472
|
+
resolves_to: 'script' | 'ask' | 'subagent' | 'team' | 'council';
|
|
473
|
+
status: RungStatus;
|
|
474
|
+
reason: string;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
export interface LadderExplanation {
|
|
478
|
+
/** The authoritative resolution — exactly `classifyLadder(inp)`. */
|
|
479
|
+
result: LadderResult;
|
|
480
|
+
/**
|
|
481
|
+
* Per-rung trail in the resolver's fixed priority order: 0, 4, 3, 2, 0.5, 1.
|
|
482
|
+
*
|
|
483
|
+
* 0.5 sits where the resolver checks it — inside the size-floor branch,
|
|
484
|
+
* after rung 2's verdict and before rung 1 — not where its number would
|
|
485
|
+
* sort. The order IS the contract, so the trail follows the walk.
|
|
486
|
+
*/
|
|
487
|
+
trail: RungEvaluation[];
|
|
488
|
+
/** Present only when `result.rung` is null (∅) — the why-no-spawn reason. */
|
|
489
|
+
no_spawn_reason?: string;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Explain one ladder resolution: the rung taken, every rung genuinely
|
|
494
|
+
* evaluated-and-rejected (with the detector's own reason), and rungs the
|
|
495
|
+
* short-circuit never reached (marked `not-reached`, never given a
|
|
496
|
+
* fabricated rejection reason — a lower rung's detector might well have
|
|
497
|
+
* matched had it been consulted).
|
|
498
|
+
*/
|
|
499
|
+
export function explainLadder(inp: LadderInputs): LadderExplanation {
|
|
500
|
+
const result = classifyLadder(inp);
|
|
501
|
+
const trail: RungEvaluation[] = [];
|
|
502
|
+
const approval = inp.interactiveApprovalRequired === true;
|
|
503
|
+
|
|
504
|
+
// Rung 0 — evaluated first, exempt from the guards below.
|
|
505
|
+
if (result.rung === 0) {
|
|
506
|
+
trail.push({ rung: 0, resolves_to: 'script', status: 'taken', reason: result.reason });
|
|
507
|
+
} else if (approval) {
|
|
508
|
+
trail.push({
|
|
509
|
+
rung: 0,
|
|
510
|
+
resolves_to: 'script',
|
|
511
|
+
status: 'not-reached',
|
|
512
|
+
reason: 'interactive-approval-required resolves before every rung',
|
|
513
|
+
});
|
|
514
|
+
} else {
|
|
515
|
+
const lookup = classifyLookup(inp.taskText);
|
|
516
|
+
const mechanical = detectMechanicalTransform(inp.taskText);
|
|
517
|
+
trail.push({ rung: 0, resolves_to: 'script', status: 'rejected', reason: `${lookup.reason}; ${mechanical.reason}` });
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// A gate that resolved ∅ before rung 4 blocks rungs 4-1 wholesale; a
|
|
521
|
+
// resolution at rung 0 means they were never consulted either.
|
|
522
|
+
const gate: string | null = approval
|
|
523
|
+
? 'interactive-approval-required — caller-stated, resolves ∅'
|
|
524
|
+
: result.rung === 0
|
|
525
|
+
? 'resolved at rung 0'
|
|
526
|
+
: inp.insideSubagentSession === true
|
|
527
|
+
? 'recursive-dispatch guard — running inside a subagent/teammate session'
|
|
528
|
+
: inp.activation.halted
|
|
529
|
+
? 'emergency.orchestration_halt is set'
|
|
530
|
+
: !inp.activation.subagent_spawn
|
|
531
|
+
? 'host has no subagent_spawn primitive'
|
|
532
|
+
: null;
|
|
533
|
+
|
|
534
|
+
const notReached = (why: string): string => `not evaluated — ${why}`;
|
|
535
|
+
|
|
536
|
+
// Rung 4 — council.
|
|
537
|
+
if (gate !== null) {
|
|
538
|
+
trail.push({ rung: 4, resolves_to: 'council', status: 'not-reached', reason: notReached(gate) });
|
|
539
|
+
} else if (result.rung === 4) {
|
|
540
|
+
trail.push({ rung: 4, resolves_to: 'council', status: 'taken', reason: result.reason });
|
|
541
|
+
} else {
|
|
542
|
+
trail.push({ rung: 4, resolves_to: 'council', status: 'rejected', reason: detectContestedJudgment(inp.taskText).reason });
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// Rung 3 — team.
|
|
546
|
+
if (gate !== null) {
|
|
547
|
+
trail.push({ rung: 3, resolves_to: 'team', status: 'not-reached', reason: notReached(gate) });
|
|
548
|
+
} else if (result.rung === 4) {
|
|
549
|
+
trail.push({ rung: 3, resolves_to: 'team', status: 'not-reached', reason: notReached('resolved at rung 4') });
|
|
550
|
+
} else if (result.rung === 3) {
|
|
551
|
+
trail.push({ rung: 3, resolves_to: 'team', status: 'taken', reason: result.reason });
|
|
552
|
+
} else {
|
|
553
|
+
const communication = detectCommunicationNeed(inp.taskText);
|
|
554
|
+
const reason =
|
|
555
|
+
communication.matched && result.degraded_from === 3
|
|
556
|
+
? `${communication.reason} — but host reports agent_teams: false, degraded (see resolved rung)`
|
|
557
|
+
: communication.reason;
|
|
558
|
+
trail.push({ rung: 3, resolves_to: 'team', status: 'rejected', reason });
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Rungs 2 and 1 — classifyTask's verdict, then the single-slice check.
|
|
562
|
+
const resolvedAbove = result.rung === 4 || result.rung === 3;
|
|
563
|
+
if (gate !== null || resolvedAbove) {
|
|
564
|
+
const why = gate ?? `resolved at rung ${result.rung}`;
|
|
565
|
+
trail.push({ rung: 2, resolves_to: 'subagent', status: 'not-reached', reason: notReached(why) });
|
|
566
|
+
trail.push({ rung: 0.5, resolves_to: 'ask', status: 'not-reached', reason: notReached(why) });
|
|
567
|
+
trail.push({ rung: 1, resolves_to: 'subagent', status: 'not-reached', reason: notReached(why) });
|
|
568
|
+
} else {
|
|
569
|
+
const classification = classifyTask(inp.signals, inp.activation);
|
|
570
|
+
if (result.rung === 2) {
|
|
571
|
+
trail.push({ rung: 2, resolves_to: 'subagent', status: 'taken', reason: result.reason });
|
|
572
|
+
trail.push({ rung: 0.5, resolves_to: 'ask', status: 'not-reached', reason: notReached('resolved at rung 2') });
|
|
573
|
+
trail.push({ rung: 1, resolves_to: 'subagent', status: 'not-reached', reason: notReached('resolved at rung 2') });
|
|
574
|
+
} else {
|
|
575
|
+
trail.push({ rung: 2, resolves_to: 'subagent', status: 'rejected', reason: classification.reason });
|
|
576
|
+
if (result.rung === 1) {
|
|
577
|
+
trail.push({
|
|
578
|
+
rung: 0.5,
|
|
579
|
+
resolves_to: 'ask',
|
|
580
|
+
status: 'not-reached',
|
|
581
|
+
reason: notReached(`task above the size floor (${inp.signals.size_estimate} > ${SIZE_FLOOR}) — rung 0.5 is reachable only below it`),
|
|
582
|
+
});
|
|
583
|
+
trail.push({ rung: 1, resolves_to: 'subagent', status: 'taken', reason: result.reason });
|
|
584
|
+
} else if (result.degraded_from === 3) {
|
|
585
|
+
// The communication branch returns from INSIDE rung 3's handling
|
|
586
|
+
// (its `classifyTask` fallback is the rung-2 attempt), so it exits
|
|
587
|
+
// before `detectBoundedReadHeavySlice` is ever called. Reporting
|
|
588
|
+
// rung 1 as `rejected` here quoted that detector's reason — and
|
|
589
|
+
// when the detector MATCHES, that reason describes a match, so
|
|
590
|
+
// the trail read "rejected: one lite-tier subagent instead of
|
|
591
|
+
// session-context burn". A maintainer asking why nothing spawned
|
|
592
|
+
// was told rung 1 declined, hiding the missing agent_teams
|
|
593
|
+
// capability that actually ended the walk.
|
|
594
|
+
const skipped = notReached('the rung-3 communication branch resolved before it was consulted');
|
|
595
|
+
trail.push({ rung: 0.5, resolves_to: 'ask', status: 'not-reached', reason: skipped });
|
|
596
|
+
trail.push({ rung: 1, resolves_to: 'subagent', status: 'not-reached', reason: skipped });
|
|
597
|
+
} else if (inp.signals.size_estimate <= SIZE_FLOOR) {
|
|
598
|
+
// Rung 0.5 lives INSIDE the resolver's floor branch, between rung
|
|
599
|
+
// 2 and rung 1, so it is reachable only here. Emitting it in that
|
|
600
|
+
// position keeps the trail's order the resolver's order; omitting
|
|
601
|
+
// it (as this trail did before the rung landed) would report a
|
|
602
|
+
// walk that skipped a rung the resolver actually consulted.
|
|
603
|
+
if (result.rung === 0.5) {
|
|
604
|
+
trail.push({ rung: 0.5, resolves_to: 'ask', status: 'taken', reason: result.reason });
|
|
605
|
+
} else {
|
|
606
|
+
trail.push({
|
|
607
|
+
rung: 0.5,
|
|
608
|
+
resolves_to: 'ask',
|
|
609
|
+
status: 'rejected',
|
|
610
|
+
reason: detectBoundedQuestion(inp.taskText).reason,
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
trail.push({
|
|
614
|
+
rung: 1,
|
|
615
|
+
resolves_to: 'subagent',
|
|
616
|
+
status: 'rejected',
|
|
617
|
+
reason: `task below size floor (${inp.signals.size_estimate} <= ${SIZE_FLOOR}) — rung 1 never bypasses the floor`,
|
|
618
|
+
});
|
|
619
|
+
} else {
|
|
620
|
+
// Above the floor the resolver never enters the branch rung 0.5
|
|
621
|
+
// lives in, so it was not consulted — not declined.
|
|
622
|
+
trail.push({
|
|
623
|
+
rung: 0.5,
|
|
624
|
+
resolves_to: 'ask',
|
|
625
|
+
status: 'not-reached',
|
|
626
|
+
reason: notReached(`task above the size floor (${inp.signals.size_estimate} > ${SIZE_FLOOR}) — rung 0.5 is reachable only below it`),
|
|
627
|
+
});
|
|
628
|
+
trail.push({ rung: 1, resolves_to: 'subagent', status: 'rejected', reason: detectBoundedReadHeavySlice(inp.taskText).reason });
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
return result.rung === null ? { result, trail, no_spawn_reason: result.reason } : { result, trail };
|
|
634
|
+
}
|
|
@@ -19,8 +19,10 @@
|
|
|
19
19
|
import type { LookupClass } from './auto_dispatch.js';
|
|
20
20
|
|
|
21
21
|
export type DispatchOutcome = 'DONE' | 'DONE_WITH_CONCERNS' | 'NEEDS_CONTEXT' | 'BLOCKED' | 'killed';
|
|
22
|
-
/** Which route the
|
|
23
|
-
|
|
22
|
+
/** Which route the rung took: deterministic primitive (lean-init L0),
|
|
23
|
+
* single-completion ask (token-economy-dispatch rung 0.5), or a full
|
|
24
|
+
* subagent spawn. */
|
|
25
|
+
export type RouteTaken = 'primitive' | 'subagent' | 'ask';
|
|
24
26
|
export type VerifyMode = 'deterministic' | 'judge' | 'none';
|
|
25
27
|
export type Provenance = 'measured' | 'estimated';
|
|
26
28
|
export type TierChosen = 'lite' | 'medium' | 'high';
|
|
@@ -98,6 +100,23 @@ export interface RecordInput {
|
|
|
98
100
|
/** Rules the worker actually applied/cited (count of the envelope's
|
|
99
101
|
* `rules_applied`-equivalent on the worker side). Counts only. */
|
|
100
102
|
rules_used?: number | null | undefined;
|
|
103
|
+
// ── dispatch-economy additive fields (road-to-token-economy-dispatch
|
|
104
|
+
// Phase 1.1). `init_tokens` above (lean-init) is the spawn-payload
|
|
105
|
+
// half; `work_tokens` is the delta from first worker turn to envelope
|
|
106
|
+
// close. Both are counts; the pair's provenance rides
|
|
107
|
+
// `floor_provenance` exactly like `token_delta_provenance` tags
|
|
108
|
+
// `token_delta`. ──
|
|
109
|
+
/** Tokens the worker consumed AFTER init (delta to envelope close). */
|
|
110
|
+
work_tokens?: number | null | undefined;
|
|
111
|
+
/** Chars the dispatch RETURNED into the orchestrator context (serialized
|
|
112
|
+
* tool-result length on sync completions) — the Phase 6.3 detector for
|
|
113
|
+
* "isolation win refunded through the return channel". A count, never
|
|
114
|
+
* content. */
|
|
115
|
+
return_channel_chars?: number | null | undefined;
|
|
116
|
+
/** Provenance of the init/work pair: 'measured' (transcript ledger via
|
|
117
|
+
* cc_transcript) or 'estimated'. Defaults to 'estimated' when either
|
|
118
|
+
* field is present without a tag. */
|
|
119
|
+
floor_provenance?: Provenance | null | undefined;
|
|
101
120
|
// ── capsule shadow-measurement fields (road-to-worker-generation-recycling
|
|
102
121
|
// Phase 1.2). SHADOW ONLY: the worker still runs to stop-loss, nothing
|
|
103
122
|
// reads a capsule. All counts / enums — a capsule's CONTENT never
|
|
@@ -138,7 +157,7 @@ const TIER_SOURCES: readonly TierSource[] = ['static', 'inferred', 'inherit'];
|
|
|
138
157
|
const BANDS: readonly Band[] = ['low', 'medium', 'high'];
|
|
139
158
|
const PHASES: readonly LinePhase[] = ['refine', 'memory', 'analyze', 'plan', 'implement', 'test', 'verify', 'report'];
|
|
140
159
|
const LOOKUP_CLASSES: readonly LookupClass[] = ['definition', 'references', 'string-existence', 'report-run'];
|
|
141
|
-
const ROUTES_TAKEN: readonly RouteTaken[] = ['primitive', 'subagent'];
|
|
160
|
+
const ROUTES_TAKEN: readonly RouteTaken[] = ['primitive', 'subagent', 'ask'];
|
|
142
161
|
const TRIGGER_ARMS: readonly TriggerArm[] = ['watermark', 'saturation', 'tie'];
|
|
143
162
|
/** Hex-only payload hash — a hash can never smuggle content (privacy by construction). */
|
|
144
163
|
const PAYLOAD_HASH_RE = /^[a-f0-9]{8,64}$/i;
|
|
@@ -164,13 +183,15 @@ function isInt(n: unknown): n is number {
|
|
|
164
183
|
export function buildOrchestrationLine(input: RecordInput): BuiltLine {
|
|
165
184
|
const errors: string[] = [];
|
|
166
185
|
|
|
167
|
-
// A lookup-class primitive route
|
|
168
|
-
// (
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
186
|
+
// A lookup-class primitive route (lean-init L0) and a rung-0.5 ask route
|
|
187
|
+
// (token-economy-dispatch Phase 4) are the TWO recordable zero-spawn
|
|
188
|
+
// events: the routing decision itself is the datum the cost-reduction
|
|
189
|
+
// claim reads. Everything else with spawn_count 0 stays unrecordable
|
|
190
|
+
// in-session work.
|
|
191
|
+
const zeroSpawnRoute =
|
|
192
|
+
(input.route_taken === 'primitive' || input.route_taken === 'ask') && input.spawn_count === 0;
|
|
193
|
+
if (!zeroSpawnRoute && (!isInt(input.spawn_count) || input.spawn_count < 1)) {
|
|
194
|
+
errors.push('spawn_count must be an integer ≥ 1 (0 = handled in-session, not a dispatch — do not record; exception: route_taken=primitive|ask routes record with spawn_count 0)');
|
|
174
195
|
}
|
|
175
196
|
if (!isInt(input.token_delta)) errors.push('token_delta must be an integer (negative = net saved vs the in-session baseline)');
|
|
176
197
|
if (input.task_size_estimate !== undefined && (!isInt(input.task_size_estimate) || input.task_size_estimate < 0)) {
|
|
@@ -239,9 +260,14 @@ export function buildOrchestrationLine(input: RecordInput): BuiltLine {
|
|
|
239
260
|
for (const [key, v] of [
|
|
240
261
|
['rules_carried', input.rules_carried],
|
|
241
262
|
['rules_used', input.rules_used],
|
|
263
|
+
['work_tokens', input.work_tokens],
|
|
264
|
+
['return_channel_chars', input.return_channel_chars],
|
|
242
265
|
] as const) {
|
|
243
266
|
if (v != null && (!isInt(v) || v < 0)) errors.push(`${key} must be a non-negative integer count`);
|
|
244
267
|
}
|
|
268
|
+
if (input.floor_provenance != null && !PROVENANCES.includes(input.floor_provenance)) {
|
|
269
|
+
errors.push(`floor_provenance must be one of ${PROVENANCES.join(' | ')} (or omitted)`);
|
|
270
|
+
}
|
|
245
271
|
if (
|
|
246
272
|
input.rules_carried != null &&
|
|
247
273
|
input.rules_used != null &&
|
|
@@ -298,6 +324,11 @@ export function buildOrchestrationLine(input: RecordInput): BuiltLine {
|
|
|
298
324
|
origin: input.origin ?? null,
|
|
299
325
|
rules_carried: input.rules_carried ?? null,
|
|
300
326
|
rules_used: input.rules_used ?? null,
|
|
327
|
+
// dispatch-economy additive fields — readers ignore unknowns per audit-log-v1
|
|
328
|
+
work_tokens: input.work_tokens ?? null,
|
|
329
|
+
return_channel_chars: input.return_channel_chars ?? null,
|
|
330
|
+
floor_provenance:
|
|
331
|
+
input.floor_provenance ?? (input.init_tokens != null || input.work_tokens != null ? 'estimated' : null),
|
|
301
332
|
// capsule shadow-measurement — readers ignore unknowns per audit-log-v1
|
|
302
333
|
capsule_emitted: input.capsule_emitted ?? null,
|
|
303
334
|
capsule_entries: input.capsule_entries ?? null,
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recycle-envelope filesystem contract (road-to-token-economy-recycling
|
|
3
|
+
* Phase 2.2/2.3) — the ONE place the producer (`session:recycle`) and the
|
|
4
|
+
* consumer (`handoff_context_hook`) agree on where the envelope lives and
|
|
5
|
+
* when it is stale. A second copy of any of these values is the drift seam
|
|
6
|
+
* Risk 6 warns about.
|
|
7
|
+
*/
|
|
8
|
+
import * as path from 'node:path';
|
|
9
|
+
|
|
10
|
+
/** Where `session:recycle` writes the pending envelope (gitignored runtime state). */
|
|
11
|
+
export const RECYCLE_ENVELOPE_REL = path.join('agents', 'runtime', 'state', 'recycle-envelope.json');
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Where the consumer MOVES the envelope on consumption — moved, not copied,
|
|
15
|
+
* so a consumed or rejected envelope can never leak into a later session,
|
|
16
|
+
* while the last one stays inspectable for debugging.
|
|
17
|
+
*/
|
|
18
|
+
export const RECYCLE_CONSUMED_REL = path.join(
|
|
19
|
+
'agents',
|
|
20
|
+
'runtime',
|
|
21
|
+
'state',
|
|
22
|
+
'recycle-envelope.consumed.json',
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
/** Staleness guard — mirrors the handoff file's 48 h discipline. */
|
|
26
|
+
export const RECYCLE_MAX_AGE_HOURS = 48;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Producer-side size cap on the serialized envelope. The schema's per-field
|
|
30
|
+
* caps make a transcript-shaped envelope invalid; this cap keeps even a
|
|
31
|
+
* legal envelope inside the `handoff-context` injection budget
|
|
32
|
+
* (`src/config/hook-token-budget.json`: 8192 B for the concern) with room
|
|
33
|
+
* for the wrapper block and a co-pending handoff.
|
|
34
|
+
*/
|
|
35
|
+
export const RECYCLE_ENVELOPE_MAX_BYTES = 6144;
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
import { createHash } from 'node:crypto';
|
|
29
29
|
|
|
30
30
|
import { redact_low_impact_entry } from '../ai_council/redact_low_impact_entry.js';
|
|
31
|
+
import { is_kernel_rule } from './kernel_rules.js';
|
|
31
32
|
|
|
32
33
|
/** Where a record came from. */
|
|
33
34
|
export type DefectSource = 'user-reported' | 'self-detected';
|
|
@@ -128,6 +129,199 @@ export function egressBlockedReason(record: DefectRecord, repoRoot?: string | nu
|
|
|
128
129
|
return `privacy floor refused the evidence (${kinds}) — record stays local`;
|
|
129
130
|
}
|
|
130
131
|
|
|
132
|
+
// ── may-not-modify deny-list ───────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Surfaces self-repair may never target with a PATCH. Detection and reporting
|
|
136
|
+
* stay untouched — for a record targeting one of these, egress degrades to
|
|
137
|
+
* report-only: an issue (or the local record) is still allowed, the
|
|
138
|
+
* pull-request path refuses with the named reason.
|
|
139
|
+
*
|
|
140
|
+
* The point is structural: the loop that fixes the agent's own defects must
|
|
141
|
+
* not be a channel through which the agent loosens the floors it is bound by.
|
|
142
|
+
* Same posture as `block_kernel_rule_writes` (tool-call-time) and the settings
|
|
143
|
+
* class-C fence (write-time), applied to the one outward vehicle this loop
|
|
144
|
+
* owns. Nothing auto-applies a patch today; this keeps that true for the
|
|
145
|
+
* denied surfaces even when the PR path is otherwise available.
|
|
146
|
+
*/
|
|
147
|
+
export type DeniedSurface =
|
|
148
|
+
| 'kernel-rule'
|
|
149
|
+
| 'safety-floor-rule'
|
|
150
|
+
| 'settings-class-c'
|
|
151
|
+
| 'ci-enforcement'
|
|
152
|
+
| 'self-repair-policy';
|
|
153
|
+
|
|
154
|
+
export interface PatchDenial {
|
|
155
|
+
surface: DeniedSurface;
|
|
156
|
+
/** The token in the record that matched. */
|
|
157
|
+
match: string;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Characters that decorate a token but never appear inside a real path, rule
|
|
162
|
+
* id, or settings key: markdown emphasis / strikethrough, autolink angle
|
|
163
|
+
* brackets, table pipes. They are removed ANYWHERE in the token, not just at
|
|
164
|
+
* its edges, so `src/rules/**scope-control**.md` reduces like `**…**` does.
|
|
165
|
+
*/
|
|
166
|
+
const DECORATION_RE = /[*~<>|]/g;
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Edge punctuation a sentence leaves on a token — sentence-final `.`, a `:`
|
|
170
|
+
* label separator, list/reference brackets, and underscore emphasis (`_` is
|
|
171
|
+
* stripped at the EDGES only: it is load-bearing inside `self_repair.ts`,
|
|
172
|
+
* `hook_manifest.yaml`, and every snake_case settings key).
|
|
173
|
+
*/
|
|
174
|
+
const EDGE_TRIM_RE = /^[.:[\]_]+|[.:[\]_]+$/g;
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Word-ish / path-ish tokens of a record's text, punctuation-stripped.
|
|
178
|
+
*
|
|
179
|
+
* `suggested_surface` is free-text the agent writes, and this repo's prose is
|
|
180
|
+
* saturated with `**bold**` file paths — so a tokenizer that only splits on
|
|
181
|
+
* whitespace-and-quotes lets ordinary markdown emphasis smuggle a denied
|
|
182
|
+
* surface past the scan (`**src/rules/scope-control.md**` read as ALLOWED
|
|
183
|
+
* while the same path bare read as DENIED). Both the raw token and its
|
|
184
|
+
* un-decorated form are emitted, and the edge trim runs AFTER the decoration
|
|
185
|
+
* strip so an unwrapped `**.github/workflows/ci.yml**` cannot keep a leading
|
|
186
|
+
* dot in front of the anchored `github/workflows/` match.
|
|
187
|
+
*/
|
|
188
|
+
function denyTokens(text: string): string[] {
|
|
189
|
+
const out: string[] = [];
|
|
190
|
+
for (const raw of text.split(/[\s,;()!?"'`]+/)) {
|
|
191
|
+
if (raw.length === 0) {
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
for (const candidate of new Set([raw, raw.replace(DECORATION_RE, '')])) {
|
|
195
|
+
const token = candidate.replace(EDGE_TRIM_RE, '');
|
|
196
|
+
if (token.length > 0) {
|
|
197
|
+
out.push(token);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return out;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Basename stem (no `.md`) of a path-or-id token. */
|
|
205
|
+
function stemOf(token: string): string {
|
|
206
|
+
const base = token.replace(/\\/g, '/').split('/').pop() ?? token;
|
|
207
|
+
return base.replace(/\.md$/, '');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Dotted settings-key shape — `personal.autonomy`, `tokens.rich_skills`, … */
|
|
211
|
+
const DOTTED_KEY_RE = /^[a-z0-9_]+(\.[a-z0-9_]+)+$/;
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* The surfaces through which enforcement is actually armed or disarmed. A
|
|
215
|
+
* workflow file is only ONE of them, and in this repo it is not even the usual
|
|
216
|
+
* one — a new gate is registered in the taskfiles the workflow calls, and a
|
|
217
|
+
* tool-call-time guard is armed in the hook manifest. Each entry, and why it
|
|
218
|
+
* arms enforcement:
|
|
219
|
+
*
|
|
220
|
+
* - `.github/workflows/` — the CI job definitions; deleting a step removes
|
|
221
|
+
* the gate from every PR. (The leading dot is stripped by `denyTokens`, so
|
|
222
|
+
* the match is on the segment pair, not the dotfile spelling.)
|
|
223
|
+
* - `Taskfile.yml` / `taskfiles/*.yml` — the task graph the workflows invoke
|
|
224
|
+
* (`task ci`, `task ci-fast`). A gate that is not listed here does not run,
|
|
225
|
+
* however green the workflow looks.
|
|
226
|
+
* - `src/scripts/hook_manifest.yaml` — the concern↔slot bindings. Unbinding
|
|
227
|
+
* a concern disarms a PreToolUse guard without touching the guard's code.
|
|
228
|
+
* - `src/scripts/hooks/` — the guard implementations themselves
|
|
229
|
+
* (`block_no_verify`, `block_kernel_rule_writes`, `block_config_weakening`).
|
|
230
|
+
* - `src/scripts/check_*.ts` / `src/scripts/lint_*.ts` — the gate scripts. A
|
|
231
|
+
* loosened threshold or a narrowed scan root disarms the gate in place,
|
|
232
|
+
* which is exactly the "gates that scan nothing exit green" failure.
|
|
233
|
+
*
|
|
234
|
+
* Deliberately NOT the whole of `src/scripts/`: that would deny nearly every
|
|
235
|
+
* legitimate repair this loop exists to file.
|
|
236
|
+
*/
|
|
237
|
+
function isCiEnforcementPath(token: string): boolean {
|
|
238
|
+
const t = token.replace(/\\/g, '/');
|
|
239
|
+
if (/(^|\/)github\/workflows\//.test(t)) {
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
if (/(^|\/)Taskfile(\.[a-z0-9_-]+)?\.ya?ml$/i.test(t)) {
|
|
243
|
+
return true;
|
|
244
|
+
}
|
|
245
|
+
if (/(^|\/)taskfiles\//.test(t)) {
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
248
|
+
if (/(^|\/)hook_manifest\.ya?ml$/.test(t)) {
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
if (/(^|\/)scripts\/hooks\//.test(t)) {
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
return /(^|\/)scripts\/(check|lint)_[a-z0-9_]+\.ts$/.test(t);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Is this record's fix aimed at a denied surface? Scans the two fields that
|
|
259
|
+
* name what the fix should change (`suggested_surface`) and what was observed
|
|
260
|
+
* (`evidence`). A match errs toward denial on purpose — the cost of a false
|
|
261
|
+
* positive is a report instead of a patch, still egress; the cost of a false
|
|
262
|
+
* negative is a patch channel into a safety floor.
|
|
263
|
+
*
|
|
264
|
+
* The kernel set resolves from `kernel_rules.ts` — the same canonical source
|
|
265
|
+
* `block_kernel_rule_writes` uses — never a copy that can drift. Settings
|
|
266
|
+
* class-C keys are matched against `classCKeys` when the caller can supply
|
|
267
|
+
* them (parsed from `docs/contracts/settings-classes.md`); the contract file
|
|
268
|
+
* itself is denied by path regardless, so an unreadable contract narrows the
|
|
269
|
+
* dynamic check without opening the fence's own definition to patching.
|
|
270
|
+
*/
|
|
271
|
+
export function patchDeniedSurface(
|
|
272
|
+
record: DefectFinding,
|
|
273
|
+
opts?: { classCKeys?: ReadonlySet<string> | null },
|
|
274
|
+
): PatchDenial | null {
|
|
275
|
+
const classCKeys = opts?.classCKeys ?? null;
|
|
276
|
+
for (const token of denyTokens(`${record.suggested_surface}\n${record.evidence}`)) {
|
|
277
|
+
// Checked before `ci-enforcement` so a self-repair file that also lives
|
|
278
|
+
// under an enforcement path (`src/scripts/hooks/self_repair_hook.ts`)
|
|
279
|
+
// reports the more specific surface. Either way it is denied.
|
|
280
|
+
if (/self[-_]repair/i.test(token)) {
|
|
281
|
+
return { surface: 'self-repair-policy', match: token };
|
|
282
|
+
}
|
|
283
|
+
if (isCiEnforcementPath(token)) {
|
|
284
|
+
return { surface: 'ci-enforcement', match: token };
|
|
285
|
+
}
|
|
286
|
+
if (token.includes('settings-classes.md')) {
|
|
287
|
+
return { surface: 'settings-class-c', match: token };
|
|
288
|
+
}
|
|
289
|
+
if (is_kernel_rule(token)) {
|
|
290
|
+
return { surface: 'kernel-rule', match: token };
|
|
291
|
+
}
|
|
292
|
+
if (stemOf(token).endsWith('-safety-floor')) {
|
|
293
|
+
return { surface: 'safety-floor-rule', match: token };
|
|
294
|
+
}
|
|
295
|
+
if (classCKeys !== null && DOTTED_KEY_RE.test(token)) {
|
|
296
|
+
// Exact key first, then every classified ancestor down to the root
|
|
297
|
+
// segment — a class-C key whose value is a MAP has children that
|
|
298
|
+
// never appear under the key's own name.
|
|
299
|
+
const parts = token.split('.');
|
|
300
|
+
for (let end = parts.length; end > 0; end--) {
|
|
301
|
+
if (classCKeys.has(parts.slice(0, end).join('.'))) {
|
|
302
|
+
return { surface: 'settings-class-c', match: token };
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return null;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** The named refusal the patch/PR path reports. Null = patch path allowed. */
|
|
311
|
+
export function patchDeniedReason(
|
|
312
|
+
record: DefectFinding,
|
|
313
|
+
opts?: { classCKeys?: ReadonlySet<string> | null },
|
|
314
|
+
): string | null {
|
|
315
|
+
const denial = patchDeniedSurface(record, opts);
|
|
316
|
+
if (denial === null) {
|
|
317
|
+
return null;
|
|
318
|
+
}
|
|
319
|
+
return (
|
|
320
|
+
`self-repair may not patch a ${denial.surface} surface (matched: ${denial.match}) — ` +
|
|
321
|
+
'the pull-request path is refused; the record egresses report-only (issue / local record)'
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
131
325
|
// ── detectors ──────────────────────────────────────────────────────
|
|
132
326
|
|
|
133
327
|
// An agent-directed complaint, not a complaint about the code. Deliberately
|