@event4u/agent-config 9.36.0 → 10.1.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 +117 -2
- package/agents/templates/.ai-council.yml.example +31 -51
- package/dist/agent-src/commands/analyze/conformance.md +15 -4
- package/dist/agent-src/rules/session-canary.md +28 -0
- package/dist/agent-src/scripts/archive_completed_roadmaps.ts +17 -0
- package/dist/agent-src/skills/adr-create/SKILL.md +5 -0
- package/dist/agent-src/skills/check-refs/SKILL.md +5 -0
- package/dist/agent-src/skills/lint-skills/SKILL.md +5 -0
- package/dist/agent-src/skills/md-language-check/SKILL.md +5 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli-delegate/{chunk-SFGARMAB.js → chunk-2H5SE7DU.js} +24 -4
- package/dist/cli-delegate/{chunk-U7CMAIOC.js → chunk-3JUTT26I.js} +1 -1
- package/dist/cli-delegate/{chunk-JP53LNVT.js → chunk-HJSKTMIF.js} +6 -14
- package/dist/cli-delegate/{chunk-QEEB4LHO.js → chunk-LRXJK6GN.js} +1 -1
- package/dist/cli-delegate/{chunk-NR7HSN3P.js → chunk-NX5AXSLD.js} +3 -3
- package/dist/cli-delegate/{chunk-AG47LAGQ.js → chunk-TVGXH7VA.js} +1 -1
- package/dist/cli-delegate/{chunk-55RI5UXK.js → chunk-XT2YTJCZ.js} +22 -18
- package/dist/cli-delegate/{chunk-JEJABGKB.js → chunk-XTC5ARB6.js} +16 -12
- package/dist/cli-delegate/cmd_analyze_session.js +1 -1
- package/dist/cli-delegate/cmd_brand_status.js +1 -1
- package/dist/cli-delegate/cmd_conformance.js +1037 -196
- package/dist/cli-delegate/cmd_doctor.js +3 -3
- package/dist/cli-delegate/cmd_explain.js +2 -2
- package/dist/cli-delegate/cmd_export.js +4 -4
- package/dist/cli-delegate/cmd_handoff.js +3 -3
- package/dist/cli-delegate/cmd_mcp_available.js +1 -1
- package/dist/cli-delegate/cmd_migrate.js +2 -2
- package/dist/cli-delegate/cmd_packs_active.js +2 -2
- package/dist/cli-delegate/cmd_prune.js +4 -4
- package/dist/cli-delegate/cmd_route_audit.js +2 -2
- package/dist/cli-delegate/cmd_route_explain.js +1 -1
- package/dist/cli-delegate/cmd_session_recycle.js +1 -1
- package/dist/cli-delegate/cmd_settings_get.js +1 -1
- package/dist/cli-delegate/cmd_settings_migrate.js +4 -4
- package/dist/cli-delegate/cmd_settings_set.js +1 -1
- package/dist/cli-delegate/cmd_sync.js +4 -4
- package/dist/cli-delegate/cmd_uninstall.js +4 -4
- package/dist/cli-delegate/cmd_update.js +1 -1
- package/dist/cli-delegate/cmd_upgrade.js +1 -1
- package/dist/cli-delegate/cmd_validate.js +4 -4
- package/dist/cli-delegate/cmd_versions.js +1 -1
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +8 -8
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +2 -2
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +2 -2
- package/dist/hooks/dispatch.js +125 -89
- package/dist/install/install.mjs +22 -26
- package/dist/mcp/registry-manifest.json +1 -1
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +1579 -616
- package/dist/scripts/_lib/agent_settings.js +39 -25
- package/dist/scripts/_lib/agent_settings.js.map +1 -1
- package/dist/scripts/condense.js +15 -2
- package/dist/scripts/condense.js.map +1 -1
- package/dist/server/routes/wizard.js +23 -3
- package/dist/server/routes/wizard.js.map +1 -1
- package/dist/server/schemas/settings.js +6 -6
- package/dist/server/schemas/settings.js.map +1 -1
- package/docs/contracts/settings-classes.md +14 -9
- package/package.json +1 -1
- package/src/agent-src/scripts/archive_completed_roadmaps.ts +17 -0
- package/src/config/agent-settings.template.yml +6 -36
- package/src/config/ci-local-parity.yml +44 -0
- package/src/scripts/_lib/agent_settings.ts +39 -25
- package/src/scripts/_lib/environment_detector.ts +52 -1
- package/src/scripts/_lib/untrusted_content.test.ts +175 -0
- package/src/scripts/_lib/untrusted_content.ts +184 -0
- package/src/scripts/ai_council/config.ts +62 -24
- package/src/scripts/before_complete_hook.ts +39 -0
- package/src/scripts/check_bridge_derivation.ts +89 -3
- package/src/scripts/check_ci_local_parity.ts +74 -0
- package/src/scripts/check_references.ts +15 -1
- package/src/scripts/condense.ts +16 -2
- package/src/scripts/conformance_scan.ts +200 -9
- package/src/scripts/council_cli.ts +95 -3
- package/src/scripts/hook_manifest.yaml +12 -6
- package/src/scripts/hooks/block_no_verify.ts +82 -9
- package/src/scripts/hooks/turn_end_gate_hook.ts +138 -105
- package/src/scripts/probe_promissory_closing.ts +180 -0
- package/src/scripts/probe_session_canary.ts +180 -0
- package/src/scripts/schemas/skill.schema.json +42 -0
- package/src/scripts/skill_linter.ts +57 -2
- package/src/server/schemas/settings.ts +6 -14
|
@@ -58,19 +58,41 @@
|
|
|
58
58
|
* matching ordinal reads as already-refused, so on such a host layer 2
|
|
59
59
|
* degrades to "may under-refuse" and layer 1 is the real guard.
|
|
60
60
|
*
|
|
61
|
-
* ##
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
61
|
+
* ## Always armed — the switch was removed, and why
|
|
62
|
+
*
|
|
63
|
+
* This gate shipped behind `hooks.turn_end_gate.enabled`, default OFF, on the
|
|
64
|
+
* council's round-6 reading: "the mechanism exists and soaks before it binds."
|
|
65
|
+
* The maintainer removed the switch on 2026-08-12, and the decisive argument
|
|
66
|
+
* was that the soak the switch was protecting could never happen — a concern
|
|
67
|
+
* that is off does not run, so "merged in its own PR with its own soak period"
|
|
68
|
+
* (the second condition of `blocker: stop-refusal-decision`) was unsatisfiable
|
|
69
|
+
* for as long as the condition's own mechanism stayed disabled. A default-off
|
|
70
|
+
* safety gate is not a soaking gate; it is an absent one.
|
|
71
|
+
*
|
|
72
|
+
* What replaces the switch is not "always refuse". It is the detectors' own
|
|
73
|
+
* trigger conditions, which is where the judgement belonged in the first
|
|
74
|
+
* place: A fires only on a closing paragraph that promises work, B only when a
|
|
75
|
+
* language pin exists AND the reply misses it, C only when the turn edited a
|
|
76
|
+
* file and then ran nothing. Silence is the default on every ordinary turn.
|
|
77
|
+
* `delegation-nudge` is the sibling precedent — no structural signal, no
|
|
78
|
+
* output at all.
|
|
79
|
+
*
|
|
80
|
+
* The cost of removing the switch, stated once because it is real: a
|
|
81
|
+
* false-positive detector can no longer be turned off by configuration, only
|
|
82
|
+
* by a revert. That is bounded rather than open-ended — the two re-entrancy
|
|
83
|
+
* layers below cap a misfire at ONE extra turn, never a wedge — and it is the
|
|
84
|
+
* reason a new detector ships only with a measured false-positive corpus.
|
|
85
|
+
*
|
|
86
|
+
* MEASURE IT ON YOUR OWN CORPUS, and the instruction moved here on purpose:
|
|
87
|
+
* it used to live in the settings description that this change deleted, and
|
|
88
|
+
* `src/config/pack-size-budget.json` cites that description as the reason
|
|
89
|
+
* `measure_turn_end_gate.ts` is shipped rather than excluded from the pack. So
|
|
90
|
+
* the pointer is restated rather than dropped —
|
|
91
|
+
* `./scripts-run src/scripts/measure_turn_end_gate --store <transcript dir>`
|
|
92
|
+
* re-derives each detector's fire rate against your own transcripts. No rate is
|
|
93
|
+
* quoted in this file: the round-5 number moved twice while the instrument
|
|
94
|
+
* itself was being corrected, and a fixed figure in a shipped comment would be
|
|
95
|
+
* a moving target presented as a constant.
|
|
74
96
|
*
|
|
75
97
|
* CONTRACT: dispatcher-internal exit is 1 (EXIT_BLOCK) on a fire, 0
|
|
76
98
|
* otherwise. `fail_closed: false` — deliberately. A crash in a turn-end
|
|
@@ -94,13 +116,15 @@ import * as fs from 'node:fs';
|
|
|
94
116
|
import * as path from 'node:path';
|
|
95
117
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
96
118
|
|
|
97
|
-
import { load_agent_settings } from '../_lib/agent_settings.js';
|
|
98
119
|
import {
|
|
99
120
|
classify,
|
|
100
121
|
isSyntheticPrompt,
|
|
101
122
|
STATE_FILE as LANGUAGE_STATE_FILE,
|
|
102
123
|
type Verdict,
|
|
103
124
|
} from '../language_mirror_hook.js';
|
|
125
|
+
// Round 7 § Phase 1 — the CI-settle producer. Imported for its STATE_FILE only,
|
|
126
|
+
// so the consumer cannot read a path the producer does not write.
|
|
127
|
+
import { STATE_FILE as CI_STATE_FILE } from '../before_complete_hook.js';
|
|
104
128
|
import { isSafeTranscriptPath } from './end_review_nudge_hook.js';
|
|
105
129
|
import { unwrap, type JsonObject, type JsonValue } from './envelope.js';
|
|
106
130
|
import { readHookStdin } from './hook_stdin.js';
|
|
@@ -121,7 +145,7 @@ const EXIT_ALLOW = 0;
|
|
|
121
145
|
export const TRANSCRIPT_READ_MAX_BYTES = 8 * 1024 * 1024; // 8 MB
|
|
122
146
|
|
|
123
147
|
/** Detector identity, used in the state marker and the refusal text. */
|
|
124
|
-
export type DetectorId = 'promissory' | 'language' | 'verification';
|
|
148
|
+
export type DetectorId = 'promissory' | 'language' | 'verification' | 'completion';
|
|
125
149
|
|
|
126
150
|
export interface Finding {
|
|
127
151
|
detector: DetectorId;
|
|
@@ -323,7 +347,84 @@ export interface LanguagePin {
|
|
|
323
347
|
language: Verdict;
|
|
324
348
|
}
|
|
325
349
|
|
|
326
|
-
/**
|
|
350
|
+
/**
|
|
351
|
+
* Round 7 § Phase 1 — where the CI-settle fact comes from, and why not from here.
|
|
352
|
+
*
|
|
353
|
+
* The obvious implementation reads the unsettled state out of the transcript this
|
|
354
|
+
* hook already parses. It cannot: `_toolCalls` keeps only name, command and target
|
|
355
|
+
* path, so a tool RESULT — where the pending count lives — is never read; and
|
|
356
|
+
* `toolCalls` is reset at every genuine user prompt by design, while the measured
|
|
357
|
+
* failure is a completion claim in a LATER turn than the poll. Building it that way
|
|
358
|
+
* meant reversing an invariant this file argues for two functions down.
|
|
359
|
+
*
|
|
360
|
+
* So the producer is `before_complete_hook`, which already sees tool output on
|
|
361
|
+
* `post_tool_use` and already owns the `pendingCount` predicate. It writes
|
|
362
|
+
* `ci_last` into `agents/state/verify-before-complete.json` and this reads it.
|
|
363
|
+
*
|
|
364
|
+
* NO network call, deliberately. Asking `gh pr checks` here would put a network
|
|
365
|
+
* round-trip on every turn-end; `road-to-hook-latency-repair` exists because that
|
|
366
|
+
* cost is real. The rejected alternative is named so the next author does not
|
|
367
|
+
* re-derive it.
|
|
368
|
+
*
|
|
369
|
+
* ASYMMETRY that makes the cross-turn read legitimate where the reader refuses it
|
|
370
|
+
* for verification: a stale POSITIVE ("I verified") wrongly vouches for work it
|
|
371
|
+
* never saw. A stale NEGATIVE ("CI was not settled") only ever refuses more often.
|
|
372
|
+
* Same freshness invariant, opposite failure direction.
|
|
373
|
+
*/
|
|
374
|
+
export function readCiSettled(workspaceRoot: string): { seen: boolean; settled: boolean } {
|
|
375
|
+
try {
|
|
376
|
+
const raw = fs.readFileSync(path.join(workspaceRoot, CI_STATE_FILE), 'utf-8');
|
|
377
|
+
const decoded: unknown = JSON.parse(raw);
|
|
378
|
+
if (typeof decoded === 'object' && decoded !== null && !Array.isArray(decoded)) {
|
|
379
|
+
const ci = (decoded as Record<string, unknown>)['ci_last'];
|
|
380
|
+
if (typeof ci === 'object' && ci !== null && !Array.isArray(ci)) {
|
|
381
|
+
return { seen: true, settled: (ci as Record<string, unknown>)['settled'] === true };
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
} catch {
|
|
385
|
+
// absent, unreadable, or malformed — all mean "no CI was observed", and a
|
|
386
|
+
// session that never polled CI must never be refused for it.
|
|
387
|
+
}
|
|
388
|
+
return { seen: false, settled: false };
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* A completion claim in the delivered reply. Deliberately narrow: the German and
|
|
393
|
+
* English closings the corpus actually produced, anchored to a line so a mid-reply
|
|
394
|
+
* "fertig" inside a sentence about something else does not fire.
|
|
395
|
+
*
|
|
396
|
+
* Measured shapes it must catch (round 7, § Phase 1): "Fertig, Matze." ·
|
|
397
|
+
* "Damit ist alles erledigt." · "Aufgabe erledigt." · "der komplette Auftrag ist
|
|
398
|
+
* durch".
|
|
399
|
+
*/
|
|
400
|
+
const _COMPLETION_RE =
|
|
401
|
+
/(^|\n)\s*(?:\*\*)?(?:fertig\b|damit ist alles erledigt|aufgabe erledigt|alles erledigt\b|komplett(?:er)? (?:auftrag|abgearbeitet)|der komplette auftrag ist durch|done[.!]|all done\b|task complete)/i;
|
|
402
|
+
|
|
403
|
+
export function detectCompletionClaim(
|
|
404
|
+
reply: string,
|
|
405
|
+
ci: { seen: boolean; settled: boolean },
|
|
406
|
+
): Finding | null {
|
|
407
|
+
// A session that never read CI has nothing to be premature about.
|
|
408
|
+
if (!ci.seen || ci.settled) return null;
|
|
409
|
+
const prose = visibleProse(reply);
|
|
410
|
+
const m = _COMPLETION_RE.exec(prose);
|
|
411
|
+
if (!m) return null;
|
|
412
|
+
const at = m.index;
|
|
413
|
+
return {
|
|
414
|
+
detector: 'completion',
|
|
415
|
+
evidence: prose.slice(at, at + 120).trim(),
|
|
416
|
+
reason:
|
|
417
|
+
'a completion claim while the last CI read was not settled — ' +
|
|
418
|
+
'read the verdict, then claim it (verify-before-complete)',
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Read the pin the language-mirror hook wrote. Absent ⇒ no obligation.
|
|
424
|
+
*
|
|
425
|
+
* (R2 finding 8: this line was left 73 lines above, where inserting `readCiSettled`
|
|
426
|
+
* had stranded it — so it documented the wrong function and this one had none.)
|
|
427
|
+
*/
|
|
327
428
|
export function readLanguagePin(workspaceRoot: string): Verdict {
|
|
328
429
|
try {
|
|
329
430
|
const raw = fs.readFileSync(path.join(workspaceRoot, LANGUAGE_STATE_FILE), 'utf-8');
|
|
@@ -688,78 +789,6 @@ function _messageText(content: unknown): string | null {
|
|
|
688
789
|
return parts.length > 0 ? parts.join('\n') : null;
|
|
689
790
|
}
|
|
690
791
|
|
|
691
|
-
// ---------------------------------------------------------------------------
|
|
692
|
-
// Settings — master OFF, detectors ON within it
|
|
693
|
-
// ---------------------------------------------------------------------------
|
|
694
|
-
|
|
695
|
-
export interface GateSettings {
|
|
696
|
-
enabled: boolean;
|
|
697
|
-
promissory: boolean;
|
|
698
|
-
language: boolean;
|
|
699
|
-
verification: boolean;
|
|
700
|
-
}
|
|
701
|
-
|
|
702
|
-
/**
|
|
703
|
-
* YAML spellings a human writes for "on". The `yaml` package parses to the 1.2
|
|
704
|
-
* core schema, where `yes` is the STRING "yes" rather than a boolean — so a
|
|
705
|
-
* maintainer who writes `enabled: yes` means it, and a reader that silently
|
|
706
|
-
* treats it as off is the R2 finding-6b defect wearing a parser instead of a
|
|
707
|
-
* regex. Anything not in this set is off; there is no truthiness guessing.
|
|
708
|
-
*/
|
|
709
|
-
function _isOn(v: unknown): boolean | null {
|
|
710
|
-
if (typeof v === 'boolean') return v;
|
|
711
|
-
if (typeof v !== 'string') return null;
|
|
712
|
-
const s = v.trim().toLowerCase();
|
|
713
|
-
if (s === 'true' || s === 'yes' || s === 'on') return true;
|
|
714
|
-
if (s === 'false' || s === 'no' || s === 'off') return false;
|
|
715
|
-
return null;
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
/**
|
|
719
|
-
* Read the three switches through the real settings cascade.
|
|
720
|
-
*
|
|
721
|
-
* The previous version hand-walked `<workspaceRoot>/.agent-settings.yml` for
|
|
722
|
-
* speed, and R2 finding 6 found three defects in that trade: it ignored the
|
|
723
|
-
* user-global layer entirely (so a maintainer who opts in globally got no gate
|
|
724
|
-
* AND no diagnostic), its end-of-line-anchored regex read
|
|
725
|
-
* `enabled: true # soak opt-in` as FALSE, and it accepted a `turn_end_gate:`
|
|
726
|
-
* block under ANY parent as ON because a text walker has no notion of a parent.
|
|
727
|
-
*
|
|
728
|
-
* The stated reason for skipping the cascade does not survive contact with the
|
|
729
|
-
* sibling concerns: `delegation-nudge` calls `load_agent_settings` on
|
|
730
|
-
* `user_prompt_submit`, i.e. every turn. Correctness on a gate that can refuse
|
|
731
|
-
* a turn-end outranks one settings read per turn-end.
|
|
732
|
-
*/
|
|
733
|
-
export function readGateSettings(workspaceRoot: string): GateSettings {
|
|
734
|
-
const off: GateSettings = {
|
|
735
|
-
enabled: false,
|
|
736
|
-
promissory: true,
|
|
737
|
-
language: true,
|
|
738
|
-
verification: true,
|
|
739
|
-
};
|
|
740
|
-
let settings: Record<string, unknown>;
|
|
741
|
-
try {
|
|
742
|
-
settings = load_agent_settings({ cwd: workspaceRoot }) as Record<string, unknown>;
|
|
743
|
-
} catch {
|
|
744
|
-
return off;
|
|
745
|
-
}
|
|
746
|
-
const hooks = settings['hooks'];
|
|
747
|
-
if (typeof hooks !== 'object' || hooks === null || Array.isArray(hooks)) return off;
|
|
748
|
-
const block = (hooks as Record<string, unknown>)['turn_end_gate'];
|
|
749
|
-
if (typeof block !== 'object' || block === null || Array.isArray(block)) return off;
|
|
750
|
-
const gate = block as Record<string, unknown>;
|
|
751
|
-
const flag = (name: string, dflt: boolean): boolean => {
|
|
752
|
-
const v = _isOn(gate[name]);
|
|
753
|
-
return v === null ? dflt : v;
|
|
754
|
-
};
|
|
755
|
-
return {
|
|
756
|
-
enabled: flag('enabled', false),
|
|
757
|
-
promissory: flag('promissory', true),
|
|
758
|
-
language: flag('language', true),
|
|
759
|
-
verification: flag('verification', true),
|
|
760
|
-
};
|
|
761
|
-
}
|
|
762
|
-
|
|
763
792
|
// ---------------------------------------------------------------------------
|
|
764
793
|
// main
|
|
765
794
|
// ---------------------------------------------------------------------------
|
|
@@ -782,9 +811,6 @@ export function main(): number {
|
|
|
782
811
|
|
|
783
812
|
const workspaceRoot = str(envelope['workspace_root'] as JsonValue | undefined) || process.cwd();
|
|
784
813
|
|
|
785
|
-
const settings = readGateSettings(workspaceRoot);
|
|
786
|
-
if (!settings.enabled) return EXIT_ALLOW;
|
|
787
|
-
|
|
788
814
|
const transcriptPath = str(
|
|
789
815
|
(payload['transcript_path'] ?? payload['transcriptPath']) as JsonValue | undefined,
|
|
790
816
|
);
|
|
@@ -818,17 +844,24 @@ export function main(): number {
|
|
|
818
844
|
);
|
|
819
845
|
if (alreadyRefusedTurn(workspaceRoot, sessionKey, turnOrdinal)) return EXIT_ALLOW;
|
|
820
846
|
|
|
847
|
+
// Every detector runs on every turn-end. The gating is INSIDE each one —
|
|
848
|
+
// no promise, no pin mismatch, no unverified edit ⇒ no finding ⇒ the turn
|
|
849
|
+
// ends. That is the whole of "fires when it is warranted"; there is no
|
|
850
|
+
// second, configurable notion of warranted layered on top of it.
|
|
821
851
|
const findings: Finding[] = [];
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
852
|
+
for (const f of [
|
|
853
|
+
detectPromissory(lastAssistant),
|
|
854
|
+
detectLanguage(lastAssistant, readLanguagePin(workspaceRoot)),
|
|
855
|
+
detectUnverifiedEdit(toolCalls),
|
|
856
|
+
// Round 7 § Phase 1 — detector D, in the same unconditional list as the
|
|
857
|
+
// other three. It shipped with its own settings flag one commit earlier
|
|
858
|
+
// and that flag is gone: `hooks.turn_end_gate.*` was deleted on
|
|
859
|
+
// 2026-08-12 because a default-off safety gate is an absent one. Its
|
|
860
|
+
// gating is where the comment above says gating belongs — inside the
|
|
861
|
+
// detector: no CI observed, or a settled read, or no completion claim
|
|
862
|
+
// ⇒ no finding.
|
|
863
|
+
detectCompletionClaim(lastAssistant, readCiSettled(workspaceRoot)),
|
|
864
|
+
]) {
|
|
832
865
|
if (f) findings.push(f);
|
|
833
866
|
}
|
|
834
867
|
if (findings.length === 0) return EXIT_ALLOW;
|
|
@@ -840,10 +873,10 @@ export function main(): number {
|
|
|
840
873
|
);
|
|
841
874
|
process.stderr.write(
|
|
842
875
|
`turn-end-gate: REFUSED — this turn is not finished.\n${lines.join('\n')}\n` +
|
|
843
|
-
' Do the promised work now, correct the language,
|
|
844
|
-
'verification the edit needs,
|
|
845
|
-
'
|
|
846
|
-
'
|
|
876
|
+
' Do the promised work now, correct the language, run the ' +
|
|
877
|
+
'verification the edit needs, or read the CI verdict before claiming ' +
|
|
878
|
+
'it — then end the turn.\n' +
|
|
879
|
+
' This turn will not be refused a second time.\n',
|
|
847
880
|
);
|
|
848
881
|
return EXIT_BLOCK;
|
|
849
882
|
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Round 7 § 6.3 — the promissory-closing rate, as a script instead of an ad-hoc probe.
|
|
4
|
+
*
|
|
5
|
+
* `verify-before-complete` § Turn-completion forbids ending a turn on a promise of
|
|
6
|
+
* unexecuted work ("I'll…", "als nächstes mache ich…"). Round 7 measured the rate
|
|
7
|
+
* to decide whether to build a mechanism, and the answer was: don't — a blocking
|
|
8
|
+
* `detectPromissory` ALREADY ships in `turn-end-gate`.
|
|
9
|
+
*
|
|
10
|
+
* That inverts the reading of a low number, and the inversion is the reason this
|
|
11
|
+
* script exists. Round 7's first draft declined the class as "below any threshold
|
|
12
|
+
* worth a gate"; with a gate already in place, 0.6 % is plausibly the mechanism
|
|
13
|
+
* WORKING, not the class being rare. A rate over a gated class measures residual
|
|
14
|
+
* leakage. Read it that way or not at all.
|
|
15
|
+
*
|
|
16
|
+
* WHAT IS COUNTED: only a HAND-BACK turn — an assistant turn immediately followed
|
|
17
|
+
* by a genuine user turn, i.e. where the agent actually stopped. A promise in a
|
|
18
|
+
* mid-turn message is not a violation; the agent kept going. A turn that ASKS is
|
|
19
|
+
* also excluded: a question is a legitimate stop condition.
|
|
20
|
+
*
|
|
21
|
+
* HONEST BOUND: the predicate is deliberately narrow (first-person + future,
|
|
22
|
+
* anchored in the closing lines), so the figure is a FLOOR. A looser predicate
|
|
23
|
+
* over the same corpus read 1.2 % against this one's 0.6 %; both are printed so a
|
|
24
|
+
* reader can see the width of the bracket rather than trust one edge of it.
|
|
25
|
+
*
|
|
26
|
+
* Usage:
|
|
27
|
+
* ./scripts-run src/scripts/probe_promissory_closing [--limit N] [--store PATH]
|
|
28
|
+
*
|
|
29
|
+
* Exit codes: 0 always — a measurement, not a gate.
|
|
30
|
+
*/
|
|
31
|
+
import fs from "node:fs";
|
|
32
|
+
import path from "node:path";
|
|
33
|
+
|
|
34
|
+
import { defaultStore, HARNESS_TEXT, isInjectedBody } from "./conformance_scan.js";
|
|
35
|
+
import { isSyntheticPrompt } from "./_lib/prompt_shape.js";
|
|
36
|
+
|
|
37
|
+
/** First person + intent, at the start of a closing sentence. Narrow on purpose. */
|
|
38
|
+
export const PROMISE_NARROW: readonly RegExp[] = [
|
|
39
|
+
/\bich (mache|baue|schreibe|starte|prüfe|räume|setze|lege|fixe|committe|pushe|nehme|ziehe|hole|führe|erstelle|implementiere)\b[^.?!]{0,80}\b(jetzt|als nächstes|gleich|danach|anschließend|im nächsten schritt)\b/i,
|
|
40
|
+
/\b(als nächstes|im nächsten schritt|danach) (mache|baue|schreibe|starte|prüfe|nehme|committe|pushe|erstelle|implementiere) ich\b/i,
|
|
41
|
+
/\bi'?(ll| will) (now |next |then )?(start|build|write|check|run|fix|commit|push|create|implement|add|wire|measure)\b/i,
|
|
42
|
+
/\bnext,? i'?(ll| will)\b/i,
|
|
43
|
+
/\bi'?m going to (start|build|write|check|run|fix|commit|push|create|implement)\b/i,
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
/** The bracket's other edge — any future-intent marker at all. */
|
|
47
|
+
export const PROMISE_LOOSE =
|
|
48
|
+
/\b(ich werde|ich mache|ich baue|ich starte|ich prüfe|als nächstes|next up|i'll |i will |i'm going to|nun mache ich|jetzt mache ich)/i;
|
|
49
|
+
|
|
50
|
+
/** A reply that asks is a legitimate stop condition, never a promissory closing. */
|
|
51
|
+
const ASKS = /(\?\s*$)|(^\s*\d\.\s)/m;
|
|
52
|
+
|
|
53
|
+
interface Turn {
|
|
54
|
+
role: "user" | "assistant";
|
|
55
|
+
text: string;
|
|
56
|
+
at: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function turns(lines: string[]): Turn[] {
|
|
60
|
+
const out: Turn[] = [];
|
|
61
|
+
for (const line of lines) {
|
|
62
|
+
if (!line.trim()) continue;
|
|
63
|
+
let e: Record<string, unknown>;
|
|
64
|
+
try {
|
|
65
|
+
e = JSON.parse(line) as Record<string, unknown>;
|
|
66
|
+
} catch {
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (e["isSidechain"] === true) continue;
|
|
70
|
+
if (e["type"] === "user" && e["toolUseResult"] !== undefined) continue;
|
|
71
|
+
if (e["isCompactSummary"] === true) continue;
|
|
72
|
+
const msg = e["message"] as { role?: string; content?: unknown } | undefined;
|
|
73
|
+
const role = msg?.role;
|
|
74
|
+
if (role !== "user" && role !== "assistant") continue;
|
|
75
|
+
const c = msg?.content;
|
|
76
|
+
const text =
|
|
77
|
+
typeof c === "string"
|
|
78
|
+
? c
|
|
79
|
+
: Array.isArray(c)
|
|
80
|
+
? c
|
|
81
|
+
.filter((b: { type?: string }) => b?.type === "text")
|
|
82
|
+
.map((b: { text?: string }) => b.text ?? "")
|
|
83
|
+
.join("\n")
|
|
84
|
+
: "";
|
|
85
|
+
if (!text.trim()) continue;
|
|
86
|
+
if (role === "assistant" && HARNESS_TEXT.test(text.trim())) continue;
|
|
87
|
+
// A skill / slash-command body arrives in the user role and is not a chat
|
|
88
|
+
// message, so it must not close a hand-back.
|
|
89
|
+
if (role === "user" && (isSyntheticPrompt(text) || isInjectedBody(text))) continue;
|
|
90
|
+
out.push({ role, text, at: String(e["timestamp"] ?? "") });
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface PromissoryResult {
|
|
96
|
+
/** Hand-back turns that could carry a promissory closing — asking ones excluded. */
|
|
97
|
+
handbacks: number;
|
|
98
|
+
/** Asking hand-backs, excluded from the population. R2 finding 6. */
|
|
99
|
+
asked: number;
|
|
100
|
+
narrow: { session: string; at: string; span: string }[];
|
|
101
|
+
loose: number;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function measure(store: string, limit: number): PromissoryResult {
|
|
105
|
+
const result: PromissoryResult = { handbacks: 0, asked: 0, narrow: [], loose: 0 };
|
|
106
|
+
const files = fs
|
|
107
|
+
.readdirSync(store)
|
|
108
|
+
.filter((f) => f.endsWith(".jsonl"))
|
|
109
|
+
.map((f) => ({ f, m: fs.statSync(path.join(store, f)).mtimeMs }))
|
|
110
|
+
.sort((a, b) => b.m - a.m)
|
|
111
|
+
.slice(0, limit);
|
|
112
|
+
|
|
113
|
+
for (const { f } of files) {
|
|
114
|
+
const session = f.replace(/\.jsonl$/, "").slice(0, 8);
|
|
115
|
+
const ts = turns(fs.readFileSync(path.join(store, f), "utf8").split("\n"));
|
|
116
|
+
for (let i = 0; i < ts.length; i += 1) {
|
|
117
|
+
const t = ts[i] as Turn;
|
|
118
|
+
if (t.role !== "assistant") continue;
|
|
119
|
+
const next = ts[i + 1];
|
|
120
|
+
// A hand-back: the next turn is the user's, or the session ends here.
|
|
121
|
+
if (next !== undefined && next.role !== "user") continue;
|
|
122
|
+
const tail = t.text.trimEnd().split("\n").slice(-4).join("\n");
|
|
123
|
+
// R2 finding 6: `handbacks` was incremented BEFORE this guard, so every
|
|
124
|
+
// asking hand-back sat in the denominator while being excluded from both
|
|
125
|
+
// numerators — understating both rates by the ask share. An asking closing
|
|
126
|
+
// is a legitimate stop condition and cannot be a promissory closing, so it
|
|
127
|
+
// is not in the population at all. Counted separately, because a reader who
|
|
128
|
+
// cannot see the ask share cannot judge the denominator.
|
|
129
|
+
if (ASKS.test(tail)) {
|
|
130
|
+
result.asked += 1;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
result.handbacks += 1;
|
|
134
|
+
if (PROMISE_LOOSE.test(tail)) result.loose += 1;
|
|
135
|
+
const hit = PROMISE_NARROW.find((re) => re.test(tail));
|
|
136
|
+
if (hit) {
|
|
137
|
+
result.narrow.push({ session, at: t.at, span: (tail.match(hit)?.[0] ?? "").slice(0, 110) });
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return result;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function main(argv?: string[]): number {
|
|
145
|
+
const args = argv ?? process.argv.slice(2);
|
|
146
|
+
const arg = (flag: string, dflt: string): string => {
|
|
147
|
+
const i = args.indexOf(flag);
|
|
148
|
+
return i >= 0 && args[i + 1] !== undefined ? (args[i + 1] as string) : dflt;
|
|
149
|
+
};
|
|
150
|
+
const store = arg("--store", defaultStore(process.cwd()));
|
|
151
|
+
const limit = Number(arg("--limit", "30"));
|
|
152
|
+
|
|
153
|
+
if (!fs.existsSync(store)) {
|
|
154
|
+
process.stdout.write(`probe:promissory-closing · store not found: ${store}\n`);
|
|
155
|
+
return 0;
|
|
156
|
+
}
|
|
157
|
+
const r = measure(store, limit);
|
|
158
|
+
process.stdout.write(
|
|
159
|
+
`probe:promissory-closing · ${r.handbacks} eligible hand-back turn(s) ` +
|
|
160
|
+
`(+${r.asked} that ASK, excluded — a question is a legitimate stop condition) in ${store}\n`,
|
|
161
|
+
);
|
|
162
|
+
for (const h of r.narrow) {
|
|
163
|
+
process.stdout.write(` ${h.session} ${h.at.slice(0, 16)} ${JSON.stringify(h.span)}\n`);
|
|
164
|
+
}
|
|
165
|
+
const pct = (n: number): string => (r.handbacks === 0 ? "—" : `${((100 * n) / r.handbacks).toFixed(1)}%`);
|
|
166
|
+
process.stdout.write(
|
|
167
|
+
` narrow predicate: ${r.narrow.length} (${pct(r.narrow.length)}) · ` +
|
|
168
|
+
`loose predicate: ${r.loose} (${pct(r.loose)})\n`,
|
|
169
|
+
);
|
|
170
|
+
process.stdout.write(
|
|
171
|
+
" The class is ALREADY GATED — `turn-end-gate` ships a blocking `detectPromissory`.\n" +
|
|
172
|
+
" So a low figure here is residual leakage past a working mechanism, not evidence\n" +
|
|
173
|
+
" the class is rare. The narrow figure is a floor; the loose one is the other edge.\n",
|
|
174
|
+
);
|
|
175
|
+
return 0;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (process.argv[1] !== undefined && import.meta.url.endsWith(path.basename(process.argv[1]))) {
|
|
179
|
+
process.exit(main());
|
|
180
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* Round 7 § 6.3 — the opening-canary rate, as a script instead of an ad-hoc probe.
|
|
4
|
+
*
|
|
5
|
+
* `session-canary` obliges the agent to address the user by name in the first
|
|
6
|
+
* reply of a session AND of each new task within it. The rule's own text records
|
|
7
|
+
* two audits reading it as broadly missed (~13/15, then 24/29 task starts) and
|
|
8
|
+
* concludes the reminder carrier does not move it.
|
|
9
|
+
*
|
|
10
|
+
* This measures the half that is DECIDABLE from a transcript: the per-SESSION
|
|
11
|
+
* instance. The per-TASK instance is not measurable at all — no task boundary is
|
|
12
|
+
* recorded anywhere in a transcript — which is why the mechanism the rule
|
|
13
|
+
* pre-registers ("a check at delivery that rejects a task-start reply carrying no
|
|
14
|
+
* greeting") cannot be built as specified. Round 7 states that as an honest
|
|
15
|
+
* downgrade rather than an unbuilt promise.
|
|
16
|
+
*
|
|
17
|
+
* Two instrument defects this probe was born with, fixed and recorded:
|
|
18
|
+
* 1. Its first version omitted `You've hit your` from the harness predicate, so
|
|
19
|
+
* a spend-limit banner counted as a missed greeting: 24/28 instead of 25/28.
|
|
20
|
+
* The predicate is now the same one `conformance_scan` uses.
|
|
21
|
+
* 2. It reads the first assistant turn carrying PROSE, not the first assistant
|
|
22
|
+
* entry: a session opening with 30 tool calls has no prose to greet in.
|
|
23
|
+
*
|
|
24
|
+
* Usage:
|
|
25
|
+
* ./scripts-run src/scripts/probe_session_canary [--limit N] [--store PATH] [--name NAME]
|
|
26
|
+
*
|
|
27
|
+
* Exit codes: 0 always — this is a measurement, not a gate. A rate is not a
|
|
28
|
+
* verdict, and the round-7 reading (96 % post-carrier) is not a number anyone
|
|
29
|
+
* should gate on before it has a second window to compare against.
|
|
30
|
+
*/
|
|
31
|
+
import fs from "node:fs";
|
|
32
|
+
import path from "node:path";
|
|
33
|
+
|
|
34
|
+
import { load_agent_settings } from "./_lib/agent_settings.js";
|
|
35
|
+
import { defaultStore, HARNESS_TEXT } from "./conformance_scan.js";
|
|
36
|
+
|
|
37
|
+
/** The first assistant turn that carries prose, or null. */
|
|
38
|
+
function firstProse(lines: string[]): { text: string; at: string } | null {
|
|
39
|
+
for (const line of lines) {
|
|
40
|
+
if (!line.trim()) continue;
|
|
41
|
+
let e: Record<string, unknown>;
|
|
42
|
+
try {
|
|
43
|
+
e = JSON.parse(line) as Record<string, unknown>;
|
|
44
|
+
} catch {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (e["isSidechain"] === true) continue;
|
|
48
|
+
const msg = e["message"] as { role?: string; content?: unknown } | undefined;
|
|
49
|
+
if (msg?.role !== "assistant") continue;
|
|
50
|
+
const c = msg.content;
|
|
51
|
+
if (!Array.isArray(c)) continue;
|
|
52
|
+
const text = c
|
|
53
|
+
.filter((b: { type?: string }) => b?.type === "text")
|
|
54
|
+
.map((b: { text?: string }) => b.text ?? "")
|
|
55
|
+
.join("\n")
|
|
56
|
+
.trim();
|
|
57
|
+
if (!text) continue;
|
|
58
|
+
// A harness banner occupies an assistant turn without being the assistant
|
|
59
|
+
// writing. Same predicate as the scan's, imported rather than re-typed.
|
|
60
|
+
if (HARNESS_TEXT.test(text)) continue;
|
|
61
|
+
return { text, at: String(e["timestamp"] ?? "") };
|
|
62
|
+
}
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The name the obligation actually uses, read through the same three layers the
|
|
68
|
+
* rule names: project `personal.canary_name` → user-global `personal.canary_name`
|
|
69
|
+
* → user-global `identity.name`. Empty string when no layer carries one, which is
|
|
70
|
+
* exactly when the rule is inert.
|
|
71
|
+
*
|
|
72
|
+
* Deliberately tolerant of every read failure: a probe that throws because a
|
|
73
|
+
* settings file is malformed measures the file, not the behaviour.
|
|
74
|
+
*/
|
|
75
|
+
export function resolveCanaryName(): string {
|
|
76
|
+
try {
|
|
77
|
+
const s = load_agent_settings({ cwd: process.cwd() }) as Record<string, unknown>;
|
|
78
|
+
const personal = s["personal"];
|
|
79
|
+
if (typeof personal === "object" && personal !== null && !Array.isArray(personal)) {
|
|
80
|
+
const n = (personal as Record<string, unknown>)["canary_name"];
|
|
81
|
+
if (typeof n === "string" && n.trim() !== "") return n.trim();
|
|
82
|
+
}
|
|
83
|
+
const identity = s["identity"];
|
|
84
|
+
if (typeof identity === "object" && identity !== null && !Array.isArray(identity)) {
|
|
85
|
+
const n = (identity as Record<string, unknown>)["name"];
|
|
86
|
+
if (typeof n === "string" && n.trim() !== "") return n.trim();
|
|
87
|
+
}
|
|
88
|
+
} catch {
|
|
89
|
+
// no settings, unreadable settings, malformed settings — all mean "no name".
|
|
90
|
+
}
|
|
91
|
+
return "";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export interface CanaryRow {
|
|
95
|
+
session: string;
|
|
96
|
+
at: string;
|
|
97
|
+
greeted: boolean;
|
|
98
|
+
opener: string;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function measure(store: string, limit: number, name: string): CanaryRow[] {
|
|
102
|
+
const re = new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`, "i");
|
|
103
|
+
return fs
|
|
104
|
+
.readdirSync(store)
|
|
105
|
+
.filter((f) => f.endsWith(".jsonl"))
|
|
106
|
+
.map((f) => ({ f, m: fs.statSync(path.join(store, f)).mtimeMs }))
|
|
107
|
+
.sort((a, b) => b.m - a.m)
|
|
108
|
+
.slice(0, limit)
|
|
109
|
+
.flatMap(({ f }) => {
|
|
110
|
+
const lines = fs.readFileSync(path.join(store, f), "utf8").split("\n");
|
|
111
|
+
const first = firstProse(lines);
|
|
112
|
+
if (first === null) return [];
|
|
113
|
+
// The name has to be in the OPENING, not anywhere in a long first reply —
|
|
114
|
+
// a greeting is a greeting only where the reader looks for it.
|
|
115
|
+
const head = first.text.split("\n").slice(0, 3).join(" ");
|
|
116
|
+
return [
|
|
117
|
+
{
|
|
118
|
+
session: f.replace(/\.jsonl$/, "").slice(0, 8),
|
|
119
|
+
at: first.at,
|
|
120
|
+
greeted: re.test(head),
|
|
121
|
+
opener: first.text.split("\n")[0]?.slice(0, 70).replace(/\s+/g, " ") ?? "",
|
|
122
|
+
},
|
|
123
|
+
];
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function main(argv?: string[]): number {
|
|
128
|
+
const args = argv ?? process.argv.slice(2);
|
|
129
|
+
const arg = (flag: string, dflt: string): string => {
|
|
130
|
+
const i = args.indexOf(flag);
|
|
131
|
+
return i >= 0 && args[i + 1] !== undefined ? (args[i + 1] as string) : dflt;
|
|
132
|
+
};
|
|
133
|
+
const store = arg("--store", defaultStore(process.cwd()));
|
|
134
|
+
const limit = Number(arg("--limit", "30"));
|
|
135
|
+
// R2 finding 7: the default used to be a maintainer's own nickname, hardcoded
|
|
136
|
+
// in `src/` — which breaks the portability Iron Law and made the figure
|
|
137
|
+
// irreproducible for anyone else (a name that never appears greps to a silent
|
|
138
|
+
// 0 %). The rule this probe measures resolves the name through the settings
|
|
139
|
+
// cascade, so the probe resolves it the same way and REFUSES rather than
|
|
140
|
+
// guessing when no layer carries one.
|
|
141
|
+
const name = arg("--name", resolveCanaryName());
|
|
142
|
+
|
|
143
|
+
if (name === "") {
|
|
144
|
+
process.stdout.write(
|
|
145
|
+
"probe:session-canary · no canary name configured — nothing to measure.\n" +
|
|
146
|
+
" The obligation itself is inert without one (`session-canary`: no name on any\n" +
|
|
147
|
+
" layer ⇒ the rule does not fire), so a rate here would be a rate over an\n" +
|
|
148
|
+
" obligation that does not exist. Pass --name <name> to measure anyway.\n",
|
|
149
|
+
);
|
|
150
|
+
return 0;
|
|
151
|
+
}
|
|
152
|
+
if (!fs.existsSync(store)) {
|
|
153
|
+
process.stdout.write(`probe:session-canary · store not found: ${store}\n`);
|
|
154
|
+
return 0;
|
|
155
|
+
}
|
|
156
|
+
const rows = measure(store, limit, name);
|
|
157
|
+
if (rows.length === 0) {
|
|
158
|
+
process.stdout.write(`probe:session-canary · 0 session(s) with assistant prose in ${store}\n`);
|
|
159
|
+
return 0;
|
|
160
|
+
}
|
|
161
|
+
const hit = rows.filter((r) => r.greeted).length;
|
|
162
|
+
process.stdout.write(`probe:session-canary · ${rows.length} session(s) · name=${name}\n`);
|
|
163
|
+
for (const r of rows.filter((r) => !r.greeted).sort((a, b) => a.at.localeCompare(b.at))) {
|
|
164
|
+
process.stdout.write(` MISS ${r.session} ${r.at.slice(0, 16)} "${r.opener}"\n`);
|
|
165
|
+
}
|
|
166
|
+
process.stdout.write(
|
|
167
|
+
` opening canary present in ${hit}/${rows.length} sessions ` +
|
|
168
|
+
`(${((100 * hit) / rows.length).toFixed(1)}%)\n`,
|
|
169
|
+
);
|
|
170
|
+
process.stdout.write(
|
|
171
|
+
" Per-SESSION only. The per-TASK instance the rule also obliges is NOT decidable\n" +
|
|
172
|
+
" from a transcript — no task boundary is recorded — so this figure is a floor on\n" +
|
|
173
|
+
" compliance, never a verdict on the obligation as a whole.\n",
|
|
174
|
+
);
|
|
175
|
+
return 0;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (process.argv[1] !== undefined && import.meta.url.endsWith(path.basename(process.argv[1]))) {
|
|
179
|
+
process.exit(main());
|
|
180
|
+
}
|