@event4u/agent-config 15.0.0 → 16.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.augment-plugin/marketplace.json +3 -3
- package/.augment-plugin/plugin.json +1 -1
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +142 -348
- package/dist/agent-src/commands/roadmap/create.md +3 -2
- package/dist/agent-src/contexts/communication/rules-auto/user-interaction-mechanics.md +50 -0
- package/dist/agent-src/rules/user-interaction.md +31 -30
- package/dist/agent-src/skills/error-handling-patterns/SKILL.md +9 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli-delegate/{chunk-HZ52BRGG.js → chunk-H32DGUJH.js} +14 -89
- package/dist/cli-delegate/chunk-NUHLI2RX.js +80 -0
- package/dist/cli-delegate/{chunk-TYSY33MD.js → chunk-QJWAUMJW.js} +12 -2
- package/dist/cli-delegate/{chunk-2KKXJW25.js → chunk-S3VFXMTM.js} +1 -1
- package/dist/cli-delegate/cmd_conformance.js +831 -272
- package/dist/cli-delegate/cmd_explain.js +4 -2
- package/dist/cli-delegate/cmd_handoff.js +2 -2
- package/dist/cli-delegate/cmd_packs_active.js +2 -1
- package/dist/cli-delegate/cmd_session_recycle.js +2 -2
- package/dist/cli-delegate/cmd_settings_set.js +13 -0
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +5 -5
- 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 +1274 -571
- package/dist/install/install.mjs +1 -0
- package/dist/mcp/registry-manifest.json +2 -2
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +1218 -632
- package/dist/scripts/_lib/installed_lock.js +1 -1
- package/dist/server/io/yamlIO.js +50 -0
- package/package.json +1 -1
- package/src/config/continuity-surface.json +7 -0
- package/src/config/gate-violation-baselines.json +3 -3
- package/src/config/host-injection-effect.json +1 -1
- package/src/config/preamble-payload-budget.json +12 -7
- package/src/config/preamble-payload-exceptions.json +28 -0
- package/src/scripts/_dispatch.bash +3 -1
- package/src/scripts/_lib/base_ref_payload.ts +315 -0
- package/src/scripts/_lib/dropped_decision.ts +142 -0
- package/src/scripts/_lib/installed_lock.ts +1 -1
- package/src/scripts/_lib/measured_payload_ceiling.ts +424 -0
- package/src/scripts/_lib/payload_catalogue_completeness.ts +211 -0
- package/src/scripts/_lib/release_findings_ingest.ts +324 -0
- package/src/scripts/_lib/release_tag_dispatch.ts +63 -0
- package/src/scripts/_lib/review_baseline.ts +254 -0
- package/src/scripts/_lib/review_skipped_record.ts +61 -1
- package/src/scripts/_lib/standing_bound_ratchet.ts +216 -103
- package/src/scripts/_lib/turn_end_refusals.ts +51 -4
- package/src/scripts/check_beta_review_markers.ts +35 -4
- package/src/scripts/check_finding_dispositions.ts +167 -16
- package/src/scripts/check_held_object_arrivals.ts +381 -0
- package/src/scripts/check_preamble_payload_budget.ts +399 -122
- package/src/scripts/check_release_published.ts +1 -1
- package/src/scripts/check_reply_consistency.ts +86 -17
- package/src/scripts/hook_manifest.json +1 -1
- package/src/scripts/hook_manifest.yaml +39 -3
- package/src/scripts/hooks/block_kernel_rule_writes.ts +12 -2
- package/src/scripts/hooks/concern_registry.ts +2 -0
- package/src/scripts/hooks/end_review_nudge_hook.ts +101 -21
- package/src/scripts/hooks/review_baseline_hook.ts +130 -0
- package/src/scripts/hooks/turn_end_gate_hook.ts +232 -14
- package/src/scripts/hooks_doctor.ts +4 -1
- package/src/scripts/install.ts +1 -1
- package/src/scripts/interruption_report.ts +8 -2
- package/src/scripts/janitor.ts +8 -1
- package/src/scripts/measure_turn_end_gate.ts +302 -5
- package/src/scripts/release.ts +52 -64
- package/src/scripts/release_drill.ts +193 -14
- package/src/scripts/release_env.ts +1 -1
- package/src/scripts/release_publication.ts +1 -1
- package/src/scripts/report_held_object_arrivals.ts +456 -0
- package/src/scripts/sync_agent_settings.ts +140 -18
- package/src/server/io/yamlIO.ts +48 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* review-baseline — `session_start` concern
|
|
4
|
+
* (road-to-a-question-that-survives-the-turn Phase 2).
|
|
5
|
+
*
|
|
6
|
+
* WHAT IT DOES, and it is the whole of it: on session start, record how many
|
|
7
|
+
* non-doc lines were ALREADY dirty, and what HEAD was. `end-review-nudge`
|
|
8
|
+
* subtracts that at `stop` so a session is charged for its own mutation instead
|
|
9
|
+
* of for whatever the working tree happened to be carrying.
|
|
10
|
+
*
|
|
11
|
+
* THE DEFECT IT CLOSES. `end_review_nudge_hook`'s own header names it: a dirty
|
|
12
|
+
* tree that predates the session is counted as the session's, and the reason it
|
|
13
|
+
* was not fixed was "no such baseline exists anywhere a `stop` concern can read
|
|
14
|
+
* it". That was true of a phase with a one-concern scope, and stale by the time
|
|
15
|
+
* it mattered — `session_start` is bound on seven hosts carrying fourteen
|
|
16
|
+
* concerns. The measured consequence of leaving it: on a branch deliberately
|
|
17
|
+
* accumulating work, the nudge read 1,771 lines of two crashed sessions'
|
|
18
|
+
* uncommitted work and fired every turn. An advisory that always fires is noise,
|
|
19
|
+
* and noise is worse than silence because it trains the reader to skip the one
|
|
20
|
+
* that mattered.
|
|
21
|
+
*
|
|
22
|
+
* MEASURED WITH THE READER'S OWN INSTRUMENT. `totalNonDocMutatedLinesWithMeasure`
|
|
23
|
+
* is imported from the nudge rather than reimplemented, so the two sides cannot
|
|
24
|
+
* drift on what a "non-doc mutated line" is. A second counting implementation
|
|
25
|
+
* would produce a baseline in one unit and a measurement in another, and the
|
|
26
|
+
* subtraction would be silently wrong rather than visibly broken.
|
|
27
|
+
*
|
|
28
|
+
* CAPTURE ONLY. It never emits context, never warns, and always returns 0. A
|
|
29
|
+
* `session_start` concern that could refuse would turn an unreadable git
|
|
30
|
+
* directory into a session that cannot start, which is a far worse failure than
|
|
31
|
+
* the noise it is fixing.
|
|
32
|
+
*
|
|
33
|
+
* NO PATHS, NO FINGERPRINTS. The record is two numbers and a timestamp. A
|
|
34
|
+
* path-level change set was the shape the source draft proposed; it is a bigger
|
|
35
|
+
* object with a bigger privacy surface, and nothing in the nudge consumes one.
|
|
36
|
+
* The file cannot hold a path, a diff or a file body by construction, which is
|
|
37
|
+
* the same PII-exclusion-by-construction the telemetry record keeps.
|
|
38
|
+
*/
|
|
39
|
+
import * as fs from 'node:fs';
|
|
40
|
+
import * as path from 'node:path';
|
|
41
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
42
|
+
|
|
43
|
+
import {
|
|
44
|
+
baselineStateFile,
|
|
45
|
+
currentHeadSha,
|
|
46
|
+
type ReviewBaseline,
|
|
47
|
+
} from '../_lib/review_baseline.js';
|
|
48
|
+
import { deriveSessionKey, totalNonDocMutatedLinesWithMeasure } from './end_review_nudge_hook.js';
|
|
49
|
+
import { unwrap, type JsonObject, type JsonValue } from './envelope.js';
|
|
50
|
+
import { readHookStdin } from './hook_stdin.js';
|
|
51
|
+
import { atomic_write_json } from './state_io.js';
|
|
52
|
+
|
|
53
|
+
const EXIT_OK = 0;
|
|
54
|
+
|
|
55
|
+
export function buildBaseline(workspaceRoot: string, now: Date = new Date()): ReviewBaseline {
|
|
56
|
+
const measured = totalNonDocMutatedLinesWithMeasure(workspaceRoot);
|
|
57
|
+
return {
|
|
58
|
+
head_sha: currentHeadSha(workspaceRoot),
|
|
59
|
+
baseline_lines: measured.lines,
|
|
60
|
+
// Carried, not discarded. Past `UNTRACKED_FILE_CAP` the count is a
|
|
61
|
+
// synthetic `THRESHOLD + 1 + tracked` chosen to be over the bar rather
|
|
62
|
+
// than to be true, and subtracting that from a later exact count is
|
|
63
|
+
// arithmetic over two different quantities. The consumer refuses the
|
|
64
|
+
// subtraction when the two measures disagree; it can only do that if the
|
|
65
|
+
// writer says which one it took.
|
|
66
|
+
measure: measured.measure,
|
|
67
|
+
written_at: now.toISOString(),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function main(): number {
|
|
72
|
+
let envelope: JsonObject;
|
|
73
|
+
let payload: JsonObject;
|
|
74
|
+
try {
|
|
75
|
+
[envelope, payload] = unwrap(readHookStdin(), 'claude');
|
|
76
|
+
} catch {
|
|
77
|
+
return EXIT_OK;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const event = String(envelope['event'] ?? '');
|
|
81
|
+
if (event !== '' && event !== 'session_start') return EXIT_OK;
|
|
82
|
+
|
|
83
|
+
const workspaceRoot =
|
|
84
|
+
String((envelope['workspace_root'] as JsonValue | undefined) ?? '').trim() || process.cwd();
|
|
85
|
+
|
|
86
|
+
// A baseline is written ONCE per session. A host that fires session_start
|
|
87
|
+
// twice — Cline maps both TaskStart and TaskResume onto it — must not
|
|
88
|
+
// re-baseline mid-session: the second write would fold everything the
|
|
89
|
+
// session had already changed into its own baseline and the nudge would go
|
|
90
|
+
// permanently quiet for that session. Over-writing is the silent failure
|
|
91
|
+
// direction, so it is the one guarded.
|
|
92
|
+
const sessionKey = deriveSessionKey(envelope, payload);
|
|
93
|
+
const file = baselineStateFile(workspaceRoot, sessionKey);
|
|
94
|
+
try {
|
|
95
|
+
if (fs.existsSync(file)) return EXIT_OK;
|
|
96
|
+
} catch {
|
|
97
|
+
return EXIT_OK;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
// `atomic_write_json` is already `AGENT_CONFIG_REPLAY`-aware, so a
|
|
102
|
+
// fixture-replay run leaves no baseline behind.
|
|
103
|
+
atomic_write_json(file, buildBaseline(workspaceRoot) as unknown as JsonObject);
|
|
104
|
+
} catch {
|
|
105
|
+
// An unwritable state dir degrades to today's behaviour — the nudge
|
|
106
|
+
// reads `absent` and does not subtract. Never a failed session start.
|
|
107
|
+
}
|
|
108
|
+
return EXIT_OK;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Bundle-safety: never auto-run when inlined into an esbuild bundle, where
|
|
112
|
+
// every module shares the bundle's `import.meta.url`.
|
|
113
|
+
declare const __AGENT_CONFIG_BUNDLE__: boolean | undefined;
|
|
114
|
+
function _isCliEntry(): boolean {
|
|
115
|
+
if (typeof __AGENT_CONFIG_BUNDLE__ !== 'undefined' && __AGENT_CONFIG_BUNDLE__) return false;
|
|
116
|
+
if (process.argv[1] === undefined) return false;
|
|
117
|
+
const argvUrl = pathToFileURL(path.resolve(process.argv[1])).href;
|
|
118
|
+
if (import.meta.url === argvUrl) return true;
|
|
119
|
+
try {
|
|
120
|
+
const here = fs.realpathSync(fileURLToPath(import.meta.url));
|
|
121
|
+
const argv = fs.realpathSync(path.resolve(process.argv[1]));
|
|
122
|
+
return here === argv;
|
|
123
|
+
} catch {
|
|
124
|
+
return false;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (_isCliEntry()) {
|
|
129
|
+
process.exit(main());
|
|
130
|
+
}
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* is deliberately not another reminder. It is a check at the point of
|
|
10
10
|
* delivery that can say no.
|
|
11
11
|
*
|
|
12
|
-
*
|
|
12
|
+
* FIVE detectors ride on one guard, because building the unsafe part twice
|
|
13
13
|
* is how a second detector becomes a second outage:
|
|
14
14
|
*
|
|
15
15
|
* A — promissory closing (FC-5, 20 measured occurrences)
|
|
@@ -22,11 +22,18 @@
|
|
|
22
22
|
* only, so tool activity is invisible to A and B.)
|
|
23
23
|
* D — completion claim (a claim of done carrying no fresh evidence; landed
|
|
24
24
|
* under conformance round 7 § Phase 1.)
|
|
25
|
+
* E — pending decision (an earlier reply in the SAME user turn put numbered
|
|
26
|
+
* options to the user and the closing reply carries
|
|
27
|
+
* none — the turn asked and then dropped the ask.
|
|
28
|
+
* Read off `assistantTurnTexts`, which is why that
|
|
29
|
+
* field exists: every other detector reads
|
|
30
|
+
* `lastAssistant` alone and therefore cannot see a
|
|
31
|
+
* question that was asked one reply earlier.)
|
|
25
32
|
*
|
|
26
|
-
* TWO of the
|
|
33
|
+
* TWO of the five are CONDITIONAL, and saying "unconditional" here would be the
|
|
27
34
|
* same stale-header defect this block corrects below. A and D are the
|
|
28
35
|
* completion-adjacent pair an open subagent dispatch excuses, so `main()` runs
|
|
29
|
-
* them only when `dispatchOpen` is false; B and
|
|
36
|
+
* them only when `dispatchOpen` is false; B, C and E run on every turn-end. That
|
|
30
37
|
* narrowing is deliberate (Phase 3 Step 2, narrowed again by R2 round 2) and is
|
|
31
38
|
* the third allow path, alongside the two re-entrancy layers — see the
|
|
32
39
|
* per-detector list in `main()`.
|
|
@@ -35,7 +42,7 @@
|
|
|
35
42
|
* below has carried four since round 7. `DETECTOR_IDS` in
|
|
36
43
|
* `_lib/turn_end_refusals.ts` is read off that union rather than off this
|
|
37
44
|
* comment for exactly that reason, and the count is corrected here rather than
|
|
38
|
-
* left as a stale header the next reader has to disbelieve.
|
|
45
|
+
* left as a stale header the next reader has to disbelieve. E made it five.
|
|
39
46
|
*
|
|
40
47
|
* ## Removal condition
|
|
41
48
|
*
|
|
@@ -157,6 +164,23 @@ import {
|
|
|
157
164
|
// agreement invisible when the producer's layout moved. A builder makes the move
|
|
158
165
|
// a type error.
|
|
159
166
|
import { statePathFor as ciStatePathFor } from '../before_complete_hook.js';
|
|
167
|
+
// The spec-backed options-block reading, imported rather than re-derived:
|
|
168
|
+
// `user-interaction` Iron Law 1's definition of an ask — a block PLUS its
|
|
169
|
+
// recommendation line — lives in exactly one place and detector E reads it from
|
|
170
|
+
// there.
|
|
171
|
+
//
|
|
172
|
+
// WHAT KEEPS IT FROM RUNNING ON IMPORT, stated precisely because the obvious
|
|
173
|
+
// answer is wrong: that module is `_isCliEntry`-guarded, but WITHOUT the
|
|
174
|
+
// `__AGENT_CONFIG_BUNDLE__` early-out this file carries. Inside
|
|
175
|
+
// `dist/hooks/dispatch.js` every module shares one `import.meta.url`, so the
|
|
176
|
+
// guard alone would not settle it; what does is the `.__direct__` argv rewrite
|
|
177
|
+
// in the `build:hooks` banner. Named here so a future change to that banner
|
|
178
|
+
// does not silently start running a lint's `main()` on every hook dispatch.
|
|
179
|
+
// Detector E, extracted to `_lib` where its prose costs no ratchet debt.
|
|
180
|
+
// Re-exported so every existing importer of this module is unchanged.
|
|
181
|
+
import { detectDroppedDecision } from '../_lib/dropped_decision.js';
|
|
182
|
+
|
|
183
|
+
export { detectDroppedDecision };
|
|
160
184
|
import { isSafeTranscriptPath } from './end_review_nudge_hook.js';
|
|
161
185
|
import { unwrap, type JsonObject, type JsonValue } from './envelope.js';
|
|
162
186
|
import { readHookStdin } from './hook_stdin.js';
|
|
@@ -190,7 +214,13 @@ const EXIT_ALLOW = 0;
|
|
|
190
214
|
export const TRANSCRIPT_READ_MAX_BYTES = 8 * 1024 * 1024; // 8 MB
|
|
191
215
|
|
|
192
216
|
/** Detector identity, used in the state marker and the refusal text. */
|
|
193
|
-
export type DetectorId =
|
|
217
|
+
export type DetectorId =
|
|
218
|
+
| 'promissory'
|
|
219
|
+
| 'language'
|
|
220
|
+
| 'verification'
|
|
221
|
+
| 'completion'
|
|
222
|
+
| 'pending-decision'
|
|
223
|
+
| 'untested';
|
|
194
224
|
|
|
195
225
|
export interface Finding {
|
|
196
226
|
detector: DetectorId;
|
|
@@ -723,6 +753,110 @@ export function detectUnverifiedEdit(toolCalls: readonly ToolCall[]): Finding |
|
|
|
723
753
|
};
|
|
724
754
|
}
|
|
725
755
|
|
|
756
|
+
// ---------------------------------------------------------------------------
|
|
757
|
+
// Detector F — a completion claim over production code no test accompanies
|
|
758
|
+
// ---------------------------------------------------------------------------
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* WHY THIS EXISTS, and why detector C did not already cover it.
|
|
762
|
+
*
|
|
763
|
+
* Detector C asks "did ANY verification command run after the last edit". It is
|
|
764
|
+
* satisfied by `eslint`. That is deliberate — see `isVerificationCommand`, which
|
|
765
|
+
* is narrow about what counts as a command and says nothing about what the
|
|
766
|
+
* command COVERS. So a turn can write four hundred lines of feature code, run
|
|
767
|
+
* the linter, claim done, and pass every guard in this file.
|
|
768
|
+
*
|
|
769
|
+
* Measured consequence, reported by the maintainer 2026-09-11 about a consumer
|
|
770
|
+
* project: a feature shipped whose detail view crashed on open, whose flyouts
|
|
771
|
+
* were broken, and for which the maintainer then had to hand-enumerate what
|
|
772
|
+
* should have been exercised — every view, every CRUD path, drag-and-drop,
|
|
773
|
+
* mobile, filters. Their words: *"Solche Fehler können nur auftreten, weil Du
|
|
774
|
+
* nicht TDD gearbeitet ... hast."* Nothing in the suite refused that turn,
|
|
775
|
+
* because nothing in the suite asks whether the change is TESTED — only whether
|
|
776
|
+
* something ran.
|
|
777
|
+
*
|
|
778
|
+
* THE SIGNAL, and why it is this one. "Is the change covered" is undecidable
|
|
779
|
+
* from a transcript: coverage lives in the consumer's tooling, which this hook
|
|
780
|
+
* cannot run. What IS decidable is the cheapest possible proxy, and it happens
|
|
781
|
+
* to be the exact shape of the reported failure — **production source changed,
|
|
782
|
+
* no test file touched, done claimed**. A proxy is worth shipping here precisely
|
|
783
|
+
* because the population it flags is small and the failure it catches is total:
|
|
784
|
+
* a feature with zero new test lines is not under-tested, it is untested.
|
|
785
|
+
*
|
|
786
|
+
* WHY IT IS GATED ON THE CLAIM rather than on the edit. Firing on every edit
|
|
787
|
+
* would refuse the first turn of every red-green-refactor cycle — the discipline
|
|
788
|
+
* it exists to encourage — and a guard that fires on the majority of turns is a
|
|
789
|
+
* guard that gets switched off. The obligation belongs to the CLAIM, which is
|
|
790
|
+
* also where `verify-before-complete` puts it and where detector D already sits.
|
|
791
|
+
*
|
|
792
|
+
* WHAT IT CANNOT DO. It cannot tell a good test from a token one: a turn that
|
|
793
|
+
* touches any test file clears it. That is a deliberate floor rather than an
|
|
794
|
+
* oversight — `testing-anti-patterns` owns assertion quality, and a guard that
|
|
795
|
+
* tried to judge it from a path would be judging something it cannot see. The
|
|
796
|
+
* honest claim is narrow: this refuses the case where the agent wrote NO test at
|
|
797
|
+
* all and said it was finished.
|
|
798
|
+
*/
|
|
799
|
+
|
|
800
|
+
/** Extensions that carry behaviour a test can be owed for. */
|
|
801
|
+
const _SOURCE_RE = /\.(?:ts|tsx|js|jsx|mjs|cjs|vue|svelte|php|py|rb|go|rs|java|kt|swift|cs|scala|ex|exs)$/i;
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Paths that ARE tests, across the conventions this suite's stacks use.
|
|
805
|
+
*
|
|
806
|
+
* Deliberately generous: a false NEGATIVE here (calling a test file a test) only
|
|
807
|
+
* silences the detector, while a false positive would refuse a turn that did
|
|
808
|
+
* write its tests — the direction that gets a guard disabled.
|
|
809
|
+
*/
|
|
810
|
+
const _TEST_PATH_RE =
|
|
811
|
+
/(?:^|\/)(?:tests?|specs?|__tests__|__specs__|e2e|cypress|playwright|features)\//i;
|
|
812
|
+
const _TEST_FILE_RE =
|
|
813
|
+
/(?:\.(?:test|spec)\.[a-z]+$)|(?:_test\.[a-z]+$)|(?:(?:^|\/)test_[^/]+$)|(?:Test\.php$)|(?:Spec\.php$)|(?:\.feature$)/i;
|
|
814
|
+
|
|
815
|
+
function _isTestPath(p: string): boolean {
|
|
816
|
+
return _TEST_PATH_RE.test(p) || _TEST_FILE_RE.test(p);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function _isProductionSource(p: string): boolean {
|
|
820
|
+
return _SOURCE_RE.test(p) && !_isTestPath(p);
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
/**
|
|
824
|
+
* Fire when the turn changed production code, touched no test, and said done.
|
|
825
|
+
*
|
|
826
|
+
* All three conditions are load-bearing. Drop the first and it fires on a docs
|
|
827
|
+
* turn; drop the second and it fires on a turn that did its job; drop the third
|
|
828
|
+
* and it fires mid-cycle on the red step of red-green-refactor.
|
|
829
|
+
*/
|
|
830
|
+
export function detectUntestedChange(reply: string, toolCalls: readonly ToolCall[]): Finding | null {
|
|
831
|
+
const edited = toolCalls
|
|
832
|
+
.filter((c) => _EDIT_TOOLS.has(c.name) && c.path !== undefined)
|
|
833
|
+
.map((c) => c.path as string);
|
|
834
|
+
const source = edited.filter(_isProductionSource);
|
|
835
|
+
if (source.length === 0) return null;
|
|
836
|
+
if (edited.some(_isTestPath)) return null;
|
|
837
|
+
|
|
838
|
+
// The claim gate, reusing detector D's pair rather than a second dialect of
|
|
839
|
+
// "done" — two lists of completion phrasings would drift, and the negation
|
|
840
|
+
// check is what keeps an honest "noch nicht fertig" from being refused.
|
|
841
|
+
const prose = visibleProse(reply);
|
|
842
|
+
const m = _COMPLETION_RE.exec(prose);
|
|
843
|
+
if (!m) return null;
|
|
844
|
+
if (_NEGATED_CLAIM_RE.test(_lineAround(prose, m.index))) return null;
|
|
845
|
+
|
|
846
|
+
const shown = source.slice(0, 3).join(', ');
|
|
847
|
+
return {
|
|
848
|
+
detector: 'untested',
|
|
849
|
+
evidence: shown + (source.length > 3 ? ` (+${String(source.length - 3)} more)` : ''),
|
|
850
|
+
reason:
|
|
851
|
+
'a completion claim over production code this turn changed, with NO test file ' +
|
|
852
|
+
'touched anywhere in the turn — running a linter is not evidence the change ' +
|
|
853
|
+
'works. Write the test that would have caught the failure, and enumerate the ' +
|
|
854
|
+
'states the change actually has (every view, every CRUD path, the empty and ' +
|
|
855
|
+
'error states) rather than the happy one. If a test genuinely does not belong ' +
|
|
856
|
+
'here, say which file carries the coverage instead',
|
|
857
|
+
};
|
|
858
|
+
}
|
|
859
|
+
|
|
726
860
|
// ---------------------------------------------------------------------------
|
|
727
861
|
// Re-entrancy — the guard, keyed on the turn, not on the reply
|
|
728
862
|
// ---------------------------------------------------------------------------
|
|
@@ -811,6 +945,7 @@ function markRefusedTurn(
|
|
|
811
945
|
sessionKey: string,
|
|
812
946
|
turnOrdinal: number,
|
|
813
947
|
detectors: readonly DetectorId[],
|
|
948
|
+
promptId: string,
|
|
814
949
|
): void {
|
|
815
950
|
if (is_replay_mode()) return;
|
|
816
951
|
const file = sessionStateFile(workspaceRoot, sessionKey);
|
|
@@ -835,6 +970,7 @@ function markRefusedTurn(
|
|
|
835
970
|
turnOrdinal,
|
|
836
971
|
at: new Date().toISOString(),
|
|
837
972
|
version,
|
|
973
|
+
promptId,
|
|
838
974
|
}) as unknown as Record<string, unknown>,
|
|
839
975
|
);
|
|
840
976
|
} catch {
|
|
@@ -868,6 +1004,20 @@ export interface ToolCall {
|
|
|
868
1004
|
|
|
869
1005
|
export interface TranscriptTail {
|
|
870
1006
|
lastAssistant: string;
|
|
1007
|
+
/**
|
|
1008
|
+
* Every assistant text of the CURRENT turn, in order — reset at each genuine
|
|
1009
|
+
* user prompt exactly like `toolCalls`, and for the same reason: a question
|
|
1010
|
+
* asked three turns ago was answered, a question asked earlier in THIS turn
|
|
1011
|
+
* was not. Detector E reads it; the other four read `lastAssistant`, which is
|
|
1012
|
+
* always the last element when this is non-empty.
|
|
1013
|
+
*
|
|
1014
|
+
* A tool-only assistant entry contributes NOTHING here. `_messageText`
|
|
1015
|
+
* returns null for it and the collector skips it, so the ordinary shape of a
|
|
1016
|
+
* working turn — prose, tool call, prose — produces two elements and not
|
|
1017
|
+
* three. That is the whole false-positive surface of detector E, and it is
|
|
1018
|
+
* closed here rather than in the detector.
|
|
1019
|
+
*/
|
|
1020
|
+
assistantTurnTexts: string[];
|
|
871
1021
|
/**
|
|
872
1022
|
* How many GENUINE user prompts the transcript carries — harness-injected
|
|
873
1023
|
* user-role entries excluded via `isSyntheticPrompt`. This is the turn's
|
|
@@ -899,7 +1049,12 @@ export function readTranscriptTail(
|
|
|
899
1049
|
transcriptPath: string,
|
|
900
1050
|
opts: { homeDir?: string; maxBytes?: number } = {},
|
|
901
1051
|
): TranscriptTail {
|
|
902
|
-
const empty: TranscriptTail = {
|
|
1052
|
+
const empty: TranscriptTail = {
|
|
1053
|
+
lastAssistant: '',
|
|
1054
|
+
turnOrdinal: 0,
|
|
1055
|
+
toolCalls: [],
|
|
1056
|
+
assistantTurnTexts: [],
|
|
1057
|
+
};
|
|
903
1058
|
if (!transcriptPath || !isSafeTranscriptPath(transcriptPath, opts)) return empty;
|
|
904
1059
|
let lines: string[];
|
|
905
1060
|
try {
|
|
@@ -919,6 +1074,7 @@ export function readTranscriptTail(
|
|
|
919
1074
|
let lastAssistant = '';
|
|
920
1075
|
let turnOrdinal = 0;
|
|
921
1076
|
let toolCalls: ToolCall[] = [];
|
|
1077
|
+
let assistantTurnTexts: string[] = [];
|
|
922
1078
|
for (const rawLine of lines) {
|
|
923
1079
|
const line = rawLine.trim();
|
|
924
1080
|
if (!line) continue;
|
|
@@ -944,12 +1100,13 @@ export function readTranscriptTail(
|
|
|
944
1100
|
// only a tool_use block has no text at all, so `continue`-ing on a null
|
|
945
1101
|
// text would drop exactly the entries this detector exists to see.
|
|
946
1102
|
if (role === 'assistant') {
|
|
947
|
-
toolCalls.push(...
|
|
1103
|
+
toolCalls.push(...extractToolCalls(content));
|
|
948
1104
|
}
|
|
949
1105
|
const text = _messageText(content);
|
|
950
1106
|
if (text === null) continue;
|
|
951
1107
|
if (role === 'assistant') {
|
|
952
1108
|
lastAssistant = text;
|
|
1109
|
+
assistantTurnTexts.push(text);
|
|
953
1110
|
} else if (!isSyntheticPrompt(text)) {
|
|
954
1111
|
turnOrdinal += 1;
|
|
955
1112
|
// A genuine user prompt starts a new turn, so the previous turn's
|
|
@@ -957,17 +1114,34 @@ export function readTranscriptTail(
|
|
|
957
1114
|
// run three turns ago would vouch for an edit made now — the
|
|
958
1115
|
// "fresh" in edit-without-FRESH-verification is this line.
|
|
959
1116
|
toolCalls = [];
|
|
1117
|
+
// Same boundary, same reason, for detector E: an options block the
|
|
1118
|
+
// user has since replied to is answered, not dropped. Resetting on
|
|
1119
|
+
// the identical line is what makes "in the SAME user turn" true of
|
|
1120
|
+
// the array rather than merely intended.
|
|
1121
|
+
assistantTurnTexts = [];
|
|
960
1122
|
}
|
|
961
1123
|
}
|
|
962
|
-
return {
|
|
1124
|
+
return {
|
|
1125
|
+
lastAssistant: lastAssistant.trim(),
|
|
1126
|
+
turnOrdinal,
|
|
1127
|
+
toolCalls,
|
|
1128
|
+
assistantTurnTexts,
|
|
1129
|
+
};
|
|
963
1130
|
}
|
|
964
1131
|
|
|
965
1132
|
/**
|
|
966
1133
|
* Extract this entry's tool calls, keeping only name, shell command and target
|
|
967
1134
|
* path. A tool input can hold a whole file body; nothing but those three fields
|
|
968
1135
|
* is carried forward.
|
|
1136
|
+
*
|
|
1137
|
+
* Exported because `measure_turn_end_gate` scores detectors C and E over a real
|
|
1138
|
+
* transcript corpus and therefore has to rebuild the same `ToolCall[]` the gate
|
|
1139
|
+
* sees. A second extractor there would be a second dialect of "what the turn
|
|
1140
|
+
* did", and the measurement would then be of that dialect rather than of the
|
|
1141
|
+
* shipped gate — the exact population-parity defect that script's own header
|
|
1142
|
+
* documents twice.
|
|
969
1143
|
*/
|
|
970
|
-
function
|
|
1144
|
+
export function extractToolCalls(content: unknown): ToolCall[] {
|
|
971
1145
|
if (!Array.isArray(content)) return [];
|
|
972
1146
|
const out: ToolCall[] = [];
|
|
973
1147
|
for (const blk of content) {
|
|
@@ -1074,11 +1248,30 @@ export function main(): number {
|
|
|
1074
1248
|
// in production while its comment claimed it was enforced — the same
|
|
1075
1249
|
// fixed-the-definition-not-the-caller shape as two other findings in that
|
|
1076
1250
|
// round, which is why this line exists rather than a default parameter.
|
|
1077
|
-
const { lastAssistant, turnOrdinal, toolCalls } = readTranscriptTail(
|
|
1078
|
-
|
|
1079
|
-
|
|
1251
|
+
const { lastAssistant, turnOrdinal, toolCalls, assistantTurnTexts } = readTranscriptTail(
|
|
1252
|
+
transcriptPath,
|
|
1253
|
+
{ maxBytes: TRANSCRIPT_READ_MAX_BYTES },
|
|
1254
|
+
);
|
|
1080
1255
|
if (!lastAssistant) return EXIT_ALLOW;
|
|
1081
1256
|
|
|
1257
|
+
// The closing reply comes from the field the host hands us when it supplies
|
|
1258
|
+
// one, not only from a file the host writes asynchronously. The transcript
|
|
1259
|
+
// may lag the in-memory conversation, and a tail that is one assistant entry
|
|
1260
|
+
// short is silence for `detectDroppedDecision`, whose early return needs two
|
|
1261
|
+
// texts — i.e. the detector goes quiet in exactly the shape it exists for.
|
|
1262
|
+
// `suggestion_capture_hook.ts` reads the same field for the same reason.
|
|
1263
|
+
// Appended, never substituted: `lastAssistant` and the four other detectors
|
|
1264
|
+
// are untouched, and a host that supplies no such field is unchanged.
|
|
1265
|
+
const payloadClosing = str(
|
|
1266
|
+
(payload['last_assistant_message'] ?? payload['lastAssistantMessage']) as
|
|
1267
|
+
| JsonValue
|
|
1268
|
+
| undefined,
|
|
1269
|
+
);
|
|
1270
|
+
const closingFromPayload = payloadClosing !== '' && !assistantTurnTexts.includes(payloadClosing);
|
|
1271
|
+
const turnTexts = closingFromPayload
|
|
1272
|
+
? [...assistantTurnTexts, payloadClosing]
|
|
1273
|
+
: assistantTurnTexts;
|
|
1274
|
+
|
|
1082
1275
|
// Layer 2 — keyed on the turn's ORDINAL, never on the prompt's text.
|
|
1083
1276
|
// A host that sends no `session_id` shares one bucket AND one small-integer
|
|
1084
1277
|
// ordinal namespace, so an unrelated session whose ordinal matches a stored
|
|
@@ -1092,6 +1285,13 @@ export function main(): number {
|
|
|
1092
1285
|
// producer keys that on the raw `session_id`. Passing `sessionKey` here would
|
|
1093
1286
|
// read a path nothing writes — the same shape as the STATE_FILE break this
|
|
1094
1287
|
// parameter exists to close.
|
|
1288
|
+
// The host's own id for the prompt being processed. Read here and recorded
|
|
1289
|
+
// beside the ordinal in the refusal marker — never used as the guard key.
|
|
1290
|
+
// The ordinal's drift is what both re-entrancy layers exist for, so a later
|
|
1291
|
+
// reading needs the pair to tell a drifted ordinal from a second prompt.
|
|
1292
|
+
const promptId = str(
|
|
1293
|
+
(payload['prompt_id'] ?? payload['promptId']) as JsonValue | undefined,
|
|
1294
|
+
);
|
|
1095
1295
|
const rawSessionId = str(envelope['session_id'] as JsonValue | undefined) || '';
|
|
1096
1296
|
const sessionKey = deriveSessionKey(rawSessionId || 'unknown-session');
|
|
1097
1297
|
if (alreadyRefusedTurn(workspaceRoot, sessionKey, turnOrdinal)) return EXIT_ALLOW;
|
|
@@ -1129,6 +1329,22 @@ export function main(): number {
|
|
|
1129
1329
|
dispatchOpen
|
|
1130
1330
|
? null
|
|
1131
1331
|
: detectCompletionClaim(lastAssistant, readCiSettled(workspaceRoot, rawSessionId)),
|
|
1332
|
+
// Detector E is UNCONDITIONAL, and that is the opposite choice from A
|
|
1333
|
+
// and D one line above rather than an oversight. An open dispatch
|
|
1334
|
+
// excuses a promissory closing because the work is genuinely still
|
|
1335
|
+
// running; it excuses nothing about a question the user was already
|
|
1336
|
+
// asked. Worse: a dispatch — or the `end-review-nudge` stop concern
|
|
1337
|
+
// that produced the measured failure — IS the continuation that drops
|
|
1338
|
+
// the decision, so narrowing E the same way would silence it in exactly
|
|
1339
|
+
// the case it exists for.
|
|
1340
|
+
detectDroppedDecision(turnTexts, closingFromPayload),
|
|
1341
|
+
// Detector F takes the OTHER side of that same question, and the two
|
|
1342
|
+
// sitting adjacent is the clearest place to say why. E fires on a
|
|
1343
|
+
// question already asked, which an open dispatch cannot excuse. F fires
|
|
1344
|
+
// on a completion CLAIM, and a turn waiting on a subagent has not
|
|
1345
|
+
// finished — so its closing is not the claim F is about. Same slot,
|
|
1346
|
+
// opposite trigger, opposite treatment of `dispatchOpen`.
|
|
1347
|
+
dispatchOpen ? null : detectUntestedChange(lastAssistant, toolCalls),
|
|
1132
1348
|
]) {
|
|
1133
1349
|
if (f) findings.push(f);
|
|
1134
1350
|
}
|
|
@@ -1139,6 +1355,7 @@ export function main(): number {
|
|
|
1139
1355
|
sessionKey,
|
|
1140
1356
|
turnOrdinal,
|
|
1141
1357
|
findings.map((f) => f.detector),
|
|
1358
|
+
promptId,
|
|
1142
1359
|
);
|
|
1143
1360
|
|
|
1144
1361
|
const lines = findings.map(
|
|
@@ -1147,8 +1364,9 @@ export function main(): number {
|
|
|
1147
1364
|
process.stderr.write(
|
|
1148
1365
|
`turn-end-gate: REFUSED — this turn is not finished.\n${lines.join('\n')}\n` +
|
|
1149
1366
|
' Do the promised work now, correct the language, run the ' +
|
|
1150
|
-
'verification the edit needs,
|
|
1151
|
-
'it — then end
|
|
1367
|
+
'verification the edit needs, read the CI verdict before claiming ' +
|
|
1368
|
+
'it, or re-present the options block this turn dropped — then end ' +
|
|
1369
|
+
'the turn.\n' +
|
|
1152
1370
|
' This turn will not be refused a second time.\n',
|
|
1153
1371
|
);
|
|
1154
1372
|
return EXIT_BLOCK;
|
|
@@ -40,6 +40,7 @@ import * as hooks_status from "./hooks_status.js";
|
|
|
40
40
|
import {
|
|
41
41
|
DETECTOR_IDS,
|
|
42
42
|
collectRefusalStats,
|
|
43
|
+
emptyCounts,
|
|
43
44
|
readInstallBoundary,
|
|
44
45
|
type InstallBoundary,
|
|
45
46
|
type RefusalStats,
|
|
@@ -268,7 +269,9 @@ export function collect(
|
|
|
268
269
|
turn_end_refusals = {
|
|
269
270
|
sessionsWithRefusals: 0,
|
|
270
271
|
total: 0,
|
|
271
|
-
|
|
272
|
+
// Derived, never a literal: a fifth detector made the old four-key object
|
|
273
|
+
// a type error here and a silent `NaN` in the aggregator it mirrors.
|
|
274
|
+
byDetector: emptyCounts(),
|
|
272
275
|
byPeriod: [],
|
|
273
276
|
legacyRecords: 0,
|
|
274
277
|
unversionedRecords: 0,
|
package/src/scripts/install.ts
CHANGED
|
@@ -1920,7 +1920,7 @@ const _CLAUDE_SKILL_BUNDLE: ReadonlyArray<readonly [string, string]> = [
|
|
|
1920
1920
|
['dist/agent-src/personas', 'personas'],
|
|
1921
1921
|
];
|
|
1922
1922
|
|
|
1923
|
-
const GLOBAL_DEPLOY_SOURCES: Record<string, ReadonlyArray<readonly [string, string]>> = {
|
|
1923
|
+
export const GLOBAL_DEPLOY_SOURCES: Record<string, ReadonlyArray<readonly [string, string]>> = {
|
|
1924
1924
|
'claude-code': _CLAUDE_SKILL_BUNDLE,
|
|
1925
1925
|
augment: [
|
|
1926
1926
|
['dist/agent-src/rules', 'rules'],
|
|
@@ -495,8 +495,14 @@ export function buildReport(root: string, windowRequested: number): Report {
|
|
|
495
495
|
const notes: string[] = [];
|
|
496
496
|
if (ledger.length === 0) {
|
|
497
497
|
notes.push(
|
|
498
|
-
'interruptions.jsonl is empty or absent — the contact axis has no observations
|
|
499
|
-
'The Phase 0 concern records on `stop`; a baseline needs
|
|
498
|
+
'interruptions.jsonl is empty or absent — the contact axis has no observations ' +
|
|
499
|
+
'IN THIS CHECKOUT. The Phase 0 concern records on `stop`; a baseline needs ' +
|
|
500
|
+
'sessions to have run since it landed. Before reading this as the instrument ' +
|
|
501
|
+
'having no data: `agents/runtime/` is gitignored and therefore per-checkout, ' +
|
|
502
|
+
'so a fresh worktree always reports zero here however full the ledger is ' +
|
|
503
|
+
'elsewhere. Re-run with `--root <path-to-a-checkout-with-runtime-state>` ' +
|
|
504
|
+
'before recording an absence — a reading taken in an empty worktree was once ' +
|
|
505
|
+
'parked as a measured null while the ledger already held three weeks of records.',
|
|
500
506
|
);
|
|
501
507
|
}
|
|
502
508
|
if (bySession.size === 0) {
|
package/src/scripts/janitor.ts
CHANGED
|
@@ -62,7 +62,7 @@ const TTL_CONFIG: ReadonlyArray<{
|
|
|
62
62
|
},
|
|
63
63
|
// ── Session-scoped runtime state (Step 4.1) ────────────────────────────
|
|
64
64
|
//
|
|
65
|
-
// These
|
|
65
|
+
// These five directories accrete ONE entry per session behind a
|
|
66
66
|
// once-per-session latch, and nothing in the tree removed them: writers
|
|
67
67
|
// and readers existed, no reaper did. They are named INDIVIDUALLY rather
|
|
68
68
|
// than sweeping `agents/runtime/state` as a whole, and that is the
|
|
@@ -101,6 +101,13 @@ const TTL_CONFIG: ReadonlyArray<{
|
|
|
101
101
|
owner: 'maintainer',
|
|
102
102
|
reviewBy: '2026-11-10',
|
|
103
103
|
},
|
|
104
|
+
{
|
|
105
|
+
dir: 'agents/runtime/state/review-baseline',
|
|
106
|
+
ttlDays: 7,
|
|
107
|
+
description: 'Per-session start-of-session mutation baseline (one file per session)',
|
|
108
|
+
owner: 'maintainer',
|
|
109
|
+
reviewBy: '2026-11-10',
|
|
110
|
+
},
|
|
104
111
|
];
|
|
105
112
|
|
|
106
113
|
// Directories that must NEVER be auto-swept
|