@event4u/agent-config 10.1.0 → 10.2.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 (115) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/CHANGELOG.md +109 -0
  3. package/README.md +1 -1
  4. package/dist/agent-src/commands/roadmap/next.md +38 -0
  5. package/dist/agent-src/contexts/communication/rules-auto/token-efficiency-mechanics.md +22 -1
  6. package/dist/agent-src/contexts/contracts/frugality-charter.md +1 -1
  7. package/dist/agent-src/rules/context-hygiene.md +39 -0
  8. package/dist/agent-src/rules/token-budget-discipline.md +3 -2
  9. package/dist/agent-src/rules/token-efficiency.md +22 -0
  10. package/dist/agent-src/skills/ai-council/SKILL.md +28 -6
  11. package/dist/agent-src/skills/condense-memory/SKILL.md +1 -1
  12. package/dist/agent-src/skills/roadmap-writing/SKILL.md +1 -1
  13. package/dist/agent-src/skills/token-optimizer/SKILL.md +5 -1
  14. package/dist/agent-src/templates/agent-settings.md +12 -28
  15. package/dist/agent-src/templates/agents/agent-project-settings.example.yml +2 -2
  16. package/dist/agent-src/templates/scripts/work_engine/_lib/agent_settings.ts +0 -1
  17. package/dist/cli/registry.js +1 -1
  18. package/dist/cli/registry.js.map +1 -1
  19. package/dist/cli-delegate/{chunk-3JUTT26I.js → chunk-6ENSXSBW.js} +1 -1
  20. package/dist/cli-delegate/{chunk-TVGXH7VA.js → chunk-6LOPHJLD.js} +1 -1
  21. package/dist/cli-delegate/{chunk-XT2YTJCZ.js → chunk-AYKQGIBG.js} +4 -0
  22. package/dist/cli-delegate/{chunk-HJSKTMIF.js → chunk-BV3WR67D.js} +3 -19
  23. package/dist/cli-delegate/{chunk-HTIU3W56.js → chunk-BWMXLD4B.js} +6 -2
  24. package/dist/cli-delegate/{chunk-2H5SE7DU.js → chunk-E4RUZKXB.js} +165 -38
  25. package/dist/cli-delegate/{chunk-XTC5ARB6.js → chunk-IR3IVRVC.js} +7 -2
  26. package/dist/cli-delegate/{chunk-LRXJK6GN.js → chunk-JMQZ7MM5.js} +1 -1
  27. package/dist/cli-delegate/{chunk-NX5AXSLD.js → chunk-X3VOARZD.js} +3 -3
  28. package/dist/cli-delegate/cmd_analyze_session.js +1 -1
  29. package/dist/cli-delegate/cmd_brand_status.js +1 -1
  30. package/dist/cli-delegate/cmd_conformance.js +881 -204
  31. package/dist/cli-delegate/cmd_doctor.js +3 -3
  32. package/dist/cli-delegate/cmd_explain.js +2 -2
  33. package/dist/cli-delegate/cmd_export.js +4 -4
  34. package/dist/cli-delegate/cmd_handoff.js +6 -5
  35. package/dist/cli-delegate/cmd_mcp_available.js +1 -1
  36. package/dist/cli-delegate/cmd_migrate.js +2 -2
  37. package/dist/cli-delegate/cmd_packs_active.js +2 -2
  38. package/dist/cli-delegate/cmd_prune.js +4 -4
  39. package/dist/cli-delegate/cmd_route_audit.js +2 -2
  40. package/dist/cli-delegate/cmd_route_explain.js +1 -1
  41. package/dist/cli-delegate/cmd_session_recycle.js +44 -9
  42. package/dist/cli-delegate/cmd_settings_get.js +2 -2
  43. package/dist/cli-delegate/cmd_settings_migrate.js +4 -4
  44. package/dist/cli-delegate/cmd_settings_set.js +2 -2
  45. package/dist/cli-delegate/cmd_sync.js +4 -4
  46. package/dist/cli-delegate/cmd_uninstall.js +4 -4
  47. package/dist/cli-delegate/cmd_update.js +1 -1
  48. package/dist/cli-delegate/cmd_upgrade.js +1 -1
  49. package/dist/cli-delegate/cmd_validate.js +4 -4
  50. package/dist/cli-delegate/cmd_versions.js +1 -1
  51. package/dist/discovery/deprecation-report.md +1 -1
  52. package/dist/discovery/discovery-manifest.json +11 -11
  53. package/dist/discovery/discovery-manifest.json.sha256 +1 -1
  54. package/dist/discovery/discovery-manifest.summary.md +1 -1
  55. package/dist/discovery/dormancy-report.md +1 -1
  56. package/dist/discovery/orphan-report.md +1 -1
  57. package/dist/discovery/packs.json +2 -2
  58. package/dist/discovery/trust-report.md +1 -1
  59. package/dist/discovery/workspaces.json +2 -2
  60. package/dist/hooks/dispatch.js +88 -20
  61. package/dist/install/install.mjs +9 -21
  62. package/dist/mcp/registry-manifest.json +1 -1
  63. package/dist/mcp/server.json +2 -2
  64. package/dist/mcp/server.mjs +1336 -489
  65. package/dist/scripts/_lib/agent_settings.js +25 -2
  66. package/dist/scripts/_lib/agent_settings.js.map +1 -1
  67. package/dist/scripts/_lib/compile_time_toggles.js +4 -3
  68. package/dist/scripts/_lib/compile_time_toggles.js.map +1 -1
  69. package/dist/server/io/yamlIO.js +0 -2
  70. package/dist/server/io/yamlIO.js.map +1 -1
  71. package/dist/server/schemas/settings.js +3 -9
  72. package/dist/server/schemas/settings.js.map +1 -1
  73. package/dist/shared/settingsClasses.js +64 -2
  74. package/dist/shared/settingsClasses.js.map +1 -1
  75. package/dist/ui/assets/{index-B5qGYOJo.js → index-5fXOHoVh.js} +2 -2
  76. package/dist/ui/assets/index-5fXOHoVh.js.map +1 -0
  77. package/dist/ui/index.html +1 -1
  78. package/docs/contracts/settings-classes.md +307 -145
  79. package/docs/guidelines/agent-infra/layered-settings.md +1 -2
  80. package/docs/guidelines/agent-infra/symptom-driven-harvest-loop.md +104 -0
  81. package/package.json +1 -1
  82. package/src/agent-src/templates/scripts/work_engine/_lib/agent_settings.ts +0 -1
  83. package/src/config/agent-settings.template.yml +12 -30
  84. package/src/config/gate-violation-baselines.json +9 -3
  85. package/src/config/profiles/balanced.ini +0 -2
  86. package/src/config/profiles/full.ini +1 -3
  87. package/src/config/profiles/minimal.ini +0 -2
  88. package/src/scripts/_cli/cmd_session_recycle.ts +66 -9
  89. package/src/scripts/_cli/handoff_sessions.ts +3 -2
  90. package/src/scripts/_lib/agent_settings.ts +25 -2
  91. package/src/scripts/_lib/bench_telegraph.ts +1 -1
  92. package/src/scripts/_lib/cc_transcript.ts +25 -0
  93. package/src/scripts/_lib/compile_time_toggles.ts +4 -3
  94. package/src/scripts/ai_council/clients.ts +228 -33
  95. package/src/scripts/cache_realization_report.ts +3 -2
  96. package/src/scripts/check_rule_projection_integrity.ts +3 -1
  97. package/src/scripts/conformance_scan.ts +282 -9
  98. package/src/scripts/cost/track.mjs +10 -4
  99. package/src/scripts/council_cli.ts +33 -3
  100. package/src/scripts/external_sources_denylist.json +3 -1
  101. package/src/scripts/hook_manifest.yaml +17 -6
  102. package/src/scripts/hooks/block_unauthorized_git.ts +54 -8
  103. package/src/scripts/hooks/delegation_nudge_hook.ts +33 -8
  104. package/src/scripts/hooks/evidence_independence.ts +61 -9
  105. package/src/scripts/hooks/session_eol_hook.ts +123 -3
  106. package/src/scripts/hooks/turn_end_gate_hook.ts +31 -0
  107. package/src/scripts/lint_settings_classes.ts +273 -11
  108. package/src/scripts/lint_symptom_intake.ts +241 -0
  109. package/src/scripts/report_consultation_rate.ts +2 -1
  110. package/src/scripts/report_skill_activation.ts +6 -4
  111. package/src/scripts/report_skill_obligation_violations.ts +2 -1
  112. package/src/server/io/yamlIO.ts +0 -2
  113. package/src/server/schemas/settings.ts +3 -19
  114. package/src/shared/settingsClasses.ts +93 -2
  115. package/dist/ui/assets/index-B5qGYOJo.js.map +0 -1
@@ -128,10 +128,35 @@ function _isObject(v: unknown): v is JsonObject {
128
128
  const FILE_TOKEN_RE =
129
129
  /`?\b[\w][\w.\-]{0,80}\.(ts|tsx|js|jsx|mjs|cjs|py|php|rb|go|rs|java|kt|swift|md|mdx|json|ya?ml|css|scss|less|html|vue|svelte|sql|sh|bash|c|cpp|h|hpp|cs|toml|xml)\b`?/gi;
130
130
 
131
- export function detectEnumeratedFiles(text: string): number {
131
+ /**
132
+ * The unique file tokens themselves, lower-cased and stripped of backticks.
133
+ *
134
+ * `detectEnumeratedFiles` needs only the size, but `conformance_scan`'s
135
+ * `task-completeness` check needs the IDENTITIES: it asks which of the files a
136
+ * prompt enumerated were actually touched, which a count cannot answer. Exposing
137
+ * them here rather than re-matching `FILE_TOKEN_RE` in the scanner keeps one
138
+ * regex in the tree — a second copy is the "second artefact to keep in sync"
139
+ * that would let the classifier and the measurement disagree silently, which is
140
+ * the property `conformance_scan`'s own header requires of every classifier it
141
+ * imports.
142
+ */
143
+ /**
144
+ * Minimum unique file tokens before the FILE shape counts as a delegable
145
+ * signal (F7, review). Named rather than inlined because `conformance_scan`'s
146
+ * `task-completeness` check gates on the same floor: a prompt whose file
147
+ * enumeration is below it is not treated as naming a deliverable SET by either
148
+ * reader, and two copies of the number would let them disagree about which
149
+ * prompts are in scope.
150
+ */
151
+ export const FILE_SIGNAL_FLOOR = 3;
152
+
153
+ export function enumeratedFileTokens(text: string): string[] {
132
154
  const matches = text.match(FILE_TOKEN_RE) ?? [];
133
- const unique = new Set(matches.map((m) => m.replace(/`/g, "").toLowerCase()));
134
- return unique.size;
155
+ return [...new Set(matches.map((m) => m.replace(/`/g, "").toLowerCase()))];
156
+ }
157
+
158
+ export function detectEnumeratedFiles(text: string): number {
159
+ return enumeratedFileTokens(text).length;
135
160
  }
136
161
 
137
162
  /**
@@ -246,12 +271,12 @@ export function extractTaskSignals(text: string): ExtractedSignals {
246
271
  const conjunction = detectMultiDeliverableConjunction(text);
247
272
  const forEach = detectForEachShape(text);
248
273
 
249
- // F7 (review): a bare file-token count below 3 is common and NOT itself
250
- // evidence of independent, parallelizable work — "rename a.ts to b.ts"
251
- // names 2 files but is one mechanical edit, not two delegable slices. The
252
- // explicit "N files/Dateien" phrase (`detectExplicitSliceCount`) is a
274
+ // F7 (review): a bare file-token count below FILE_SIGNAL_FLOOR is common and
275
+ // NOT itself evidence of independent, parallelizable work — "rename a.ts to
276
+ // b.ts" names 2 files but is one mechanical edit, not two delegable slices.
277
+ // The explicit "N files/Dateien" phrase (`detectExplicitSliceCount`) is a
253
278
  // stronger, author-stated signal and keeps its existing floor of N>=2.
254
- const fileSignal = fileCount >= 3 ? fileCount : 0;
279
+ const fileSignal = fileCount >= FILE_SIGNAL_FLOOR ? fileCount : 0;
255
280
 
256
281
  let slices = Math.max(fileSignal, explicit, conjunction);
257
282
  if (slices === 0 && forEach) {
@@ -135,9 +135,27 @@ export function isEvaluationPrompt(prompt: string): boolean {
135
135
  * ON, so the second-dispatch block requires a self-reference. The pre-loaded-
136
136
  * verdict block does NOT: steering an evaluator is wrong whatever it is
137
137
  * pointed at.
138
+ *
139
+ * That fix was still too wide, and the cross-project session audit (2026-08-12)
140
+ * measured the cost: `road-to-release-truth/fc1ff181` turn 3 fanned out 16
141
+ * IMPLEMENTATION workers, and 15 were classified self-scoped on one phrase —
142
+ * `this branch`. In context it read "two conversions already landed on this
143
+ * branch in exactly the style you should match", i.e. it named WHERE the work
144
+ * happens. A worktree dispatch prompt carries that phrase by construction
145
+ * ("Work in <worktree> (branch …)"), so on a hook-bound host the guard ate 15
146
+ * of 16 workers — the exact behaviour `evaluator-independence` § "When it does
147
+ * NOT fire" promises is out of scope ("…or IMPLEMENT is not evaluation and is
148
+ * not gated").
149
+ *
150
+ * So the self-reference must name a SUBJECT a verdict can be shopped for — a
151
+ * diff, a change, a patch — never the LOCATION the work happens in. `this
152
+ * branch` is dropped for that reason; `my branch` stays, because the possessive
153
+ * makes it a claim about the agent's own work rather than an address. `this pr`
154
+ * stays for the same reason `this diff` does: a PR is a thing reviewers render
155
+ * verdicts on, whereas a branch is where you stand while working.
138
156
  */
139
157
  const SELF_SCOPE_RE =
140
- /\b(my (work|change|diff|implementation|code|fix|patch|branch|pr)|this (diff|change|branch|pr|delta|implementation|patch)|the delta|the change i|what i (wrote|built|changed|implemented)|i just (wrote|built|changed|implemented))\b/i;
158
+ /\b(my (work|change|diff|implementation|code|fix|patch|branch|pr)|this (diff|change|pr|delta|implementation|patch)|the delta|the change i|what i (wrote|built|changed|implemented)|i just (wrote|built|changed|implemented))\b/i;
141
159
 
142
160
  export function isSelfScoped(prompt: string): boolean {
143
161
  return SELF_SCOPE_RE.test(prompt);
@@ -251,16 +269,50 @@ export function decide(
251
269
  return { exit: EXIT_ALLOW, stdout: "", stderr: "", evaluations: priorEvaluations };
252
270
  }
253
271
 
272
+ // WARN, not block — a deliberate severity decision, not an oversight.
273
+ //
274
+ // This branch decides from PROSE ALONE: `isEvaluationPrompt` and `isSelfScoped`
275
+ // both infer intent from a natural-language prompt, and no structured fact
276
+ // corroborates them. Under the tier rule in `docs/contracts/hook-architecture-v1.md`
277
+ // (§ What a concern may block on) that is Tier 3, and Tier 3 may only warn.
278
+ //
279
+ // It is written from measurement, not taste. A 16-way fan-out of IMPLEMENTATION
280
+ // subagents lost 15 workers to this branch, because `isEvaluationPrompt` fired
281
+ // on the unavoidable words review/audit/check and `isSelfScoped` on the phrase
282
+ // `this branch` — an address every worktree dispatch prompt carries. Narrowing
283
+ // the phrase list fixed that instance; the council convened on the design
284
+ // (anthropic + openai, 2026-08-12, quorum 2/2) held that no finite pattern can
285
+ // bound the false-positive set, and that a prompt — unlike a shell command —
286
+ // has no grammar to anchor an "invoked vs named" discriminator to.
287
+ //
288
+ // What is NOT downgraded: the pre-loaded-verdict block above. That one matches
289
+ // a literal steering formulation ("NO-FINDINGS is expected and welcome"), which
290
+ // IS the violation rather than evidence of one, so it keeps blocking.
291
+ //
292
+ // The route back to blocking is structural, not another regex: a `role` /
293
+ // `evidence_scope` field the dispatcher sets at the call site, where the caller
294
+ // knows by construction whether it is commissioning an evaluation. Then this
295
+ // branch reads a field instead of guessing, and becomes Tier 1.
296
+ // exit 0 + `decision: "warn"` on stdout is how this dispatcher carries an
297
+ // advisory — NOT exit 2. The internal ladder's `2 = warn` is read as BLOCK by
298
+ // Claude Code's native PreToolUse contract, which is the defect that made an
299
+ // advisory guard a hard deny once already; the same shape here would have kept
300
+ // the fan-out blocked while claiming to warn.
254
301
  if (priorEvaluations >= 1) {
255
302
  return {
256
- exit: EXIT_BLOCK,
257
- stdout: "",
258
- stderr:
259
- `Blocked: second evaluation dispatch of your own work in this turn (verdict ` +
260
- `shopping). One evaluation has already run; commissioning another with a ` +
261
- `different prompt or scope selects the answer instead of measuring it. ` +
262
- `Report what the first pass returned, then re-plan.\n`,
263
- evaluations: priorEvaluations,
303
+ exit: EXIT_ALLOW,
304
+ stdout: `${JSON.stringify({
305
+ decision: "warn",
306
+ reason:
307
+ `Second evaluation dispatch of your own work in this turn. If both passes ` +
308
+ `judge the SAME subject, that is verdict shopping — commissioning another ` +
309
+ `with a different prompt or scope selects the answer instead of measuring ` +
310
+ `it. Report what the first pass returned, then re-plan. If this is an ` +
311
+ `implementation fan-out rather than a second review, carry on: this check ` +
312
+ `reads prose and cannot tell the two apart with certainty.`,
313
+ })}\n`,
314
+ stderr: "",
315
+ evaluations: priorEvaluations + 1,
264
316
  };
265
317
  }
266
318
 
@@ -17,6 +17,16 @@
17
17
  * SILENCE, never a block (fail-open); hooks cannot inject `/clear`, so the
18
18
  * recycle action itself stays advisory-carried by design (roadmap 5.1).
19
19
  *
20
+ * Counter-check (second warn path, also once per session): when the advisory
21
+ * fired on an earlier Stop and no envelope written since then exists, inject
22
+ * one further line saying so — the advisory recommends an action whose next
23
+ * step destroys the session, so recommending it without ever checking the
24
+ * result is how a silent write failure becomes total context loss. Stamped by
25
+ * `missing_envelope_warned_at`, gated on the same threshold, so the emergency
26
+ * off-switch silences BOTH paths. Two independent emitters, at most one line
27
+ * each per session — the budget the Stop slot carries from this hook is two,
28
+ * not one.
29
+ *
20
30
  * Phase 4.2 (read surface): every Stop also overwrites
21
31
  * `agents/runtime/state/context-fill.json` with the machine-readable fill
22
32
  * level + threshold state — display substrate for an external statusline;
@@ -35,6 +45,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
35
45
 
36
46
  import recycleThresholdConfig from '../../config/recycle-threshold-budget.json';
37
47
 
48
+ import { RECYCLE_ENVELOPE_REL } from '../_lib/recycle_envelope_paths.js';
38
49
  import {
39
50
  emptyCounters,
40
51
  eolSessionKey,
@@ -67,6 +78,13 @@ export interface SessionEolState {
67
78
  counters: EolCounters;
68
79
  /** ISO stamp when the recycle advisory fired for this session (F2), or null. */
69
80
  advisory_fired_at: string | null;
81
+ /**
82
+ * ISO stamp when the follow-up "advised, but no envelope exists" line
83
+ * fired, or null. Separate from `advisory_fired_at` so the counter-check
84
+ * is once-per-session in its own right: one reminder is a safety net, one
85
+ * per Stop for the rest of the session is a nag the reader learns to skip.
86
+ */
87
+ missing_envelope_warned_at?: string | null;
70
88
  updated_at: string;
71
89
  }
72
90
 
@@ -105,6 +123,10 @@ export function readState(file: string): SessionEolState {
105
123
  counters: parsed.counters,
106
124
  advisory_fired_at:
107
125
  typeof parsed.advisory_fired_at === 'string' ? parsed.advisory_fired_at : null,
126
+ missing_envelope_warned_at:
127
+ typeof parsed.missing_envelope_warned_at === 'string'
128
+ ? parsed.missing_envelope_warned_at
129
+ : null,
108
130
  updated_at: typeof parsed.updated_at === 'string' ? parsed.updated_at : '',
109
131
  };
110
132
  }
@@ -115,10 +137,70 @@ export function readState(file: string): SessionEolState {
115
137
  schema_version: 1,
116
138
  counters: emptyCounters(),
117
139
  advisory_fired_at: null,
140
+ missing_envelope_warned_at: null,
118
141
  updated_at: '',
119
142
  };
120
143
  }
121
144
 
145
+ /**
146
+ * Is there a recycle envelope under this workspace, written since `since`?
147
+ *
148
+ * Two properties, both load-bearing:
149
+ *
150
+ * **Only ENOENT counts as missing.** `fs.existsSync` swallows every error into
151
+ * `false`, so an unreadable directory (EACCES on a mounted or root-owned tree)
152
+ * would read as "no envelope" and produce the manufactured "your envelope is
153
+ * gone" warning this check exists to avoid. `statSync` + an errno test is the
154
+ * only shape that can tell the two apart.
155
+ *
156
+ * **Freshness, not mere existence.** The consumer moves the envelope aside at
157
+ * session_start, so a file still sitting here belongs to a session that never
158
+ * cleared. Counting it would silence the warning in the case it exists to
159
+ * catch, and worse: `/clear` would then resume the successor from another
160
+ * session's state — a wrong resume instead of an empty one. An envelope whose
161
+ * `written_at` predates the advisory is not this session's.
162
+ */
163
+ export function envelopeExists(workspaceRoot: string, since: string | null = null): boolean {
164
+ const target = path.join(workspaceRoot, RECYCLE_ENVELOPE_REL);
165
+ let raw: string;
166
+ try {
167
+ raw = fs.readFileSync(target, 'utf-8');
168
+ } catch (exc) {
169
+ // ENOENT is the real "no envelope"; anything else is a failed CHECK,
170
+ // and a failed check must never assert absence.
171
+ return (exc as NodeJS.ErrnoException)?.code !== 'ENOENT';
172
+ }
173
+ if (since === null) return true;
174
+ try {
175
+ const written = (JSON.parse(raw) as { written_at?: unknown }).written_at;
176
+ // Unparseable or undated: present, and not provably stale. Treat it as
177
+ // this session's — the alternative warns about a file that is there.
178
+ if (typeof written !== 'string') return true;
179
+ return Date.parse(written) >= Date.parse(since);
180
+ } catch {
181
+ return true;
182
+ }
183
+ }
184
+
185
+ /**
186
+ * The follow-up line: the advisory already fired, and no envelope arrived.
187
+ *
188
+ * This is the half of the reported minimum fix that lives outside the command
189
+ * — the hook that recommends the call making the same counter-check before the
190
+ * operator acts on it. Whatever stopped the write (a wrong root, a refusal
191
+ * scrolled past, a forgotten step), the state visible here is identical, and
192
+ * the next action the advisory recommended is the one that destroys the
193
+ * session.
194
+ */
195
+ export function buildMissingEnvelopeLine(workspaceRoot: string): string {
196
+ return (
197
+ `recycle advised earlier, but no envelope exists at ` +
198
+ `${path.join(workspaceRoot, RECYCLE_ENVELOPE_REL)} — /clear now starts the successor ` +
199
+ `from nothing. Re-run \`agent-config session:recycle\` and check that it prints the ` +
200
+ `absolute path it wrote before clearing.`
201
+ );
202
+ }
203
+
122
204
  /**
123
205
  * Resolve the recycle threshold (Phase 3.1): the per-process override wins
124
206
  * (tests; `0` = advisory lane off), else the committed constant from
@@ -136,13 +218,22 @@ export function readThresholdTokens(): number | null {
136
218
  return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : null;
137
219
  }
138
220
 
139
- /** The exactly-one advisory line injected past threshold (Phase 3.2). */
221
+ /**
222
+ * The exactly-one advisory line injected past threshold (Phase 3.2).
223
+ *
224
+ * The `/clear` half is conditional on purpose. An earlier wording read
225
+ * `run X … then /clear`, which is an instruction to destroy the session with
226
+ * no check in between — and a reader who followed it while the command was
227
+ * silently writing nothing lost everything. The proof to wait for is the
228
+ * absolute path the command prints; naming it costs one clause.
229
+ */
140
230
  export function buildAdvisoryLine(tokens: number, threshold: number): string {
141
231
  return (
142
232
  `context past recycle threshold (${tokens.toLocaleString('en-US')} of ` +
143
233
  `${threshold.toLocaleString('en-US')} tokens): run \`agent-config session:recycle\` to ` +
144
- `write the recycle envelope, then /clear — the successor session resumes from the ` +
145
- `envelope at session_start (road-to-token-economy-recycling)`
234
+ `write the recycle envelope. It prints the absolute path it wrote — /clear only after ` +
235
+ `you have seen that line. The successor session then resumes from the envelope at ` +
236
+ `session_start (road-to-token-economy-recycling)`
146
237
  );
147
238
  }
148
239
 
@@ -213,10 +304,29 @@ export function main(): number {
213
304
  tokens >= threshold &&
214
305
  state.advisory_fired_at === null;
215
306
 
307
+ // The counter-check: the advisory fired on an EARLIER Stop (so `state`,
308
+ // the pre-update snapshot, already carries the stamp) and no envelope has
309
+ // appeared since. It can never collide with `shouldAdvise` — that branch
310
+ // requires `advisory_fired_at === null` — so at most one line is emitted
311
+ // per Stop, and this one at most once per session.
312
+ //
313
+ // `threshold !== null` gates it for the same reason the advisory is gated:
314
+ // `AGENT_RECYCLE_THRESHOLD_TOKENS=0` is documented as the emergency switch
315
+ // for the whole advisory lane, and a lane that keeps one more warn after
316
+ // being switched off is not off.
317
+ const shouldWarnMissing =
318
+ threshold !== null &&
319
+ state.advisory_fired_at !== null &&
320
+ (state.missing_envelope_warned_at ?? null) === null &&
321
+ !envelopeExists(workspaceRoot, state.advisory_fired_at);
322
+
216
323
  const nextState: SessionEolState = {
217
324
  schema_version: 1,
218
325
  counters,
219
326
  advisory_fired_at: shouldAdvise ? now : state.advisory_fired_at,
327
+ missing_envelope_warned_at: shouldWarnMissing
328
+ ? now
329
+ : (state.missing_envelope_warned_at ?? null),
220
330
  updated_at: now,
221
331
  };
222
332
  try {
@@ -237,6 +347,16 @@ export function main(): number {
237
347
  );
238
348
  return EXIT_WARN;
239
349
  }
350
+ if (shouldWarnMissing) {
351
+ process.stdout.write(
352
+ `${JSON.stringify({
353
+ decision: 'warn',
354
+ reason: 'session-eol: recycle advised, no envelope written',
355
+ additional_context: buildMissingEnvelopeLine(workspaceRoot),
356
+ })}\n`,
357
+ );
358
+ return EXIT_WARN;
359
+ }
240
360
  return 0;
241
361
  }
242
362
 
@@ -400,6 +400,34 @@ export function readCiSettled(workspaceRoot: string): { seen: boolean; settled:
400
400
  const _COMPLETION_RE =
401
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
402
 
403
+ /**
404
+ * The line the claim was found on says the opposite of a claim.
405
+ *
406
+ * A line-anchored keyword cannot tell "Fertig." from "Fertig ist der Fix noch
407
+ * nicht." — both open with the same token, and the second is exactly the honest
408
+ * status report this gate exists to ENCOURAGE. Measured 2026-08-12: three of ten
409
+ * realistic closings were refused, and all three were "not done yet" lines.
410
+ *
411
+ * This is a negation check on the SAME line, not another attempt to enumerate
412
+ * completion phrasings. The council that reviewed this guard (anthropic +
413
+ * openai, 2026-08-12) stated the bound plainly: a finite pattern cannot cover an
414
+ * infinite false-positive set, so extending the keyword list again would repeat
415
+ * the move that has already failed three times on the sibling git guard. What
416
+ * makes a block defensible here is not the pattern — it is that the pattern only
417
+ * fires as a TRIGGER to consult structured CI state (`ci.settled`), which is the
418
+ * council's Tier-2 shape. This check removes the cases where the prose itself
419
+ * already contradicts the trigger.
420
+ */
421
+ const _NEGATED_CLAIM_RE =
422
+ /\b(noch nicht|nicht fertig|nicht durch|wäre verfrüht|ist verfrüht|not yet|not done|isn'?t done|nein)\b/i;
423
+
424
+ /** The line `at` falls on — the scope a negation has to appear in to count. */
425
+ function _lineAround(prose: string, at: number): string {
426
+ const start = prose.lastIndexOf('\n', at) + 1;
427
+ const end = prose.indexOf('\n', at);
428
+ return prose.slice(start, end === -1 ? prose.length : end);
429
+ }
430
+
403
431
  export function detectCompletionClaim(
404
432
  reply: string,
405
433
  ci: { seen: boolean; settled: boolean },
@@ -410,6 +438,9 @@ export function detectCompletionClaim(
410
438
  const m = _COMPLETION_RE.exec(prose);
411
439
  if (!m) return null;
412
440
  const at = m.index;
441
+ // The trigger fired, but the line it fired on negates it — a status report,
442
+ // not a claim. Refusing these punishes exactly the honesty the gate wants.
443
+ if (_NEGATED_CLAIM_RE.test(_lineAround(prose, at))) return null;
413
444
  return {
414
445
  detector: 'completion',
415
446
  evidence: prose.slice(at, at + 120).trim(),