@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.
Files changed (89) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +117 -2
  3. package/agents/templates/.ai-council.yml.example +31 -51
  4. package/dist/agent-src/commands/analyze/conformance.md +15 -4
  5. package/dist/agent-src/rules/session-canary.md +28 -0
  6. package/dist/agent-src/scripts/archive_completed_roadmaps.ts +17 -0
  7. package/dist/agent-src/skills/adr-create/SKILL.md +5 -0
  8. package/dist/agent-src/skills/check-refs/SKILL.md +5 -0
  9. package/dist/agent-src/skills/lint-skills/SKILL.md +5 -0
  10. package/dist/agent-src/skills/md-language-check/SKILL.md +5 -0
  11. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
  12. package/dist/cli-delegate/{chunk-SFGARMAB.js → chunk-2H5SE7DU.js} +24 -4
  13. package/dist/cli-delegate/{chunk-U7CMAIOC.js → chunk-3JUTT26I.js} +1 -1
  14. package/dist/cli-delegate/{chunk-JP53LNVT.js → chunk-HJSKTMIF.js} +6 -14
  15. package/dist/cli-delegate/{chunk-QEEB4LHO.js → chunk-LRXJK6GN.js} +1 -1
  16. package/dist/cli-delegate/{chunk-NR7HSN3P.js → chunk-NX5AXSLD.js} +3 -3
  17. package/dist/cli-delegate/{chunk-AG47LAGQ.js → chunk-TVGXH7VA.js} +1 -1
  18. package/dist/cli-delegate/{chunk-55RI5UXK.js → chunk-XT2YTJCZ.js} +22 -18
  19. package/dist/cli-delegate/{chunk-JEJABGKB.js → chunk-XTC5ARB6.js} +16 -12
  20. package/dist/cli-delegate/cmd_analyze_session.js +1 -1
  21. package/dist/cli-delegate/cmd_brand_status.js +1 -1
  22. package/dist/cli-delegate/cmd_conformance.js +1037 -196
  23. package/dist/cli-delegate/cmd_doctor.js +3 -3
  24. package/dist/cli-delegate/cmd_explain.js +2 -2
  25. package/dist/cli-delegate/cmd_export.js +4 -4
  26. package/dist/cli-delegate/cmd_handoff.js +3 -3
  27. package/dist/cli-delegate/cmd_mcp_available.js +1 -1
  28. package/dist/cli-delegate/cmd_migrate.js +2 -2
  29. package/dist/cli-delegate/cmd_packs_active.js +2 -2
  30. package/dist/cli-delegate/cmd_prune.js +4 -4
  31. package/dist/cli-delegate/cmd_route_audit.js +2 -2
  32. package/dist/cli-delegate/cmd_route_explain.js +1 -1
  33. package/dist/cli-delegate/cmd_session_recycle.js +1 -1
  34. package/dist/cli-delegate/cmd_settings_get.js +1 -1
  35. package/dist/cli-delegate/cmd_settings_migrate.js +4 -4
  36. package/dist/cli-delegate/cmd_settings_set.js +1 -1
  37. package/dist/cli-delegate/cmd_sync.js +4 -4
  38. package/dist/cli-delegate/cmd_uninstall.js +4 -4
  39. package/dist/cli-delegate/cmd_update.js +1 -1
  40. package/dist/cli-delegate/cmd_upgrade.js +1 -1
  41. package/dist/cli-delegate/cmd_validate.js +4 -4
  42. package/dist/cli-delegate/cmd_versions.js +1 -1
  43. package/dist/discovery/deprecation-report.md +1 -1
  44. package/dist/discovery/discovery-manifest.json +8 -8
  45. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  46. package/dist/discovery/discovery-manifest.summary.md +1 -1
  47. package/dist/discovery/dormancy-report.md +1 -1
  48. package/dist/discovery/orphan-report.md +1 -1
  49. package/dist/discovery/packs.json +2 -2
  50. package/dist/discovery/trust-report.md +1 -1
  51. package/dist/discovery/workspaces.json +2 -2
  52. package/dist/hooks/dispatch.js +125 -89
  53. package/dist/install/install.mjs +22 -26
  54. package/dist/mcp/registry-manifest.json +1 -1
  55. package/dist/mcp/server.json +2 -2
  56. package/dist/mcp/server.mjs +1579 -616
  57. package/dist/scripts/_lib/agent_settings.js +39 -25
  58. package/dist/scripts/_lib/agent_settings.js.map +1 -1
  59. package/dist/scripts/condense.js +15 -2
  60. package/dist/scripts/condense.js.map +1 -1
  61. package/dist/server/routes/wizard.js +23 -3
  62. package/dist/server/routes/wizard.js.map +1 -1
  63. package/dist/server/schemas/settings.js +6 -6
  64. package/dist/server/schemas/settings.js.map +1 -1
  65. package/docs/contracts/settings-classes.md +14 -9
  66. package/package.json +1 -1
  67. package/src/agent-src/scripts/archive_completed_roadmaps.ts +17 -0
  68. package/src/config/agent-settings.template.yml +6 -36
  69. package/src/config/ci-local-parity.yml +44 -0
  70. package/src/scripts/_lib/agent_settings.ts +39 -25
  71. package/src/scripts/_lib/environment_detector.ts +52 -1
  72. package/src/scripts/_lib/untrusted_content.test.ts +175 -0
  73. package/src/scripts/_lib/untrusted_content.ts +184 -0
  74. package/src/scripts/ai_council/config.ts +62 -24
  75. package/src/scripts/before_complete_hook.ts +39 -0
  76. package/src/scripts/check_bridge_derivation.ts +89 -3
  77. package/src/scripts/check_ci_local_parity.ts +74 -0
  78. package/src/scripts/check_references.ts +15 -1
  79. package/src/scripts/condense.ts +16 -2
  80. package/src/scripts/conformance_scan.ts +200 -9
  81. package/src/scripts/council_cli.ts +95 -3
  82. package/src/scripts/hook_manifest.yaml +12 -6
  83. package/src/scripts/hooks/block_no_verify.ts +82 -9
  84. package/src/scripts/hooks/turn_end_gate_hook.ts +138 -105
  85. package/src/scripts/probe_promissory_closing.ts +180 -0
  86. package/src/scripts/probe_session_canary.ts +180 -0
  87. package/src/scripts/schemas/skill.schema.json +42 -0
  88. package/src/scripts/skill_linter.ts +57 -2
  89. 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
- * ## Default OFF — and why, given the roadmap says otherwise
62
- *
63
- * `road-to-conformance-round5.md` § 3.6 asks for a kill-switch per
64
- * detector, "default on". The council recorded in round 6 (2026-08-08)
65
- * asked for the mechanism to be opt-in, "defaulting to off, so the
66
- * mechanism exists and soaks before it binds" — two prior hook-severity
67
- * mistakes plus a turn-end blast radius made default-on uninsurable in
68
- * their reading. Both are satisfied literally: the MASTER switch
69
- * (`hooks.turn_end_gate.enabled`) is default OFF, and WITHIN it every
70
- * detector is default ON. A maintainer who opts in gets all of them without
71
- * further configuration; until then this concern is a no-op that costs one
72
- * settings-cascade read per turn-end. Detector C landed under the same rule —
73
- * the master switch was NOT flipped when it shipped.
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
- /** Read the pin the language-mirror hook wrote. Absent ⇒ no obligation. */
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
- if (settings.promissory) {
823
- const f = detectPromissory(lastAssistant);
824
- if (f) findings.push(f);
825
- }
826
- if (settings.language) {
827
- const f = detectLanguage(lastAssistant, readLanguagePin(workspaceRoot));
828
- if (f) findings.push(f);
829
- }
830
- if (settings.verification) {
831
- const f = detectUnverifiedEdit(toolCalls);
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, or run the ' +
844
- 'verification the edit needs, then end the turn.\n' +
845
- ' This turn will not be refused a second time.\n' +
846
- ' Disable: hooks.turn_end_gate.enabled: false in .agent-settings.yml\n',
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
+ }