universal-dev-standards 6.12.0 → 6.13.0-beta.2

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.
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * UDS Hook: Turn Completion Integrity
3
+ * UDS Hook: Turn Completion Integrity — Claude Code adapter
4
4
  *
5
5
  * Runs at turn end. Blocks when the agent's final message states a first-person
6
6
  * commitment to a next action that the turn then ended without taking.
@@ -14,6 +14,13 @@
14
14
  * that can trap a session is worse than none, because the only recovery a human
15
15
  * has is to disable it, and they will disable it permanently.
16
16
  *
17
+ * This file is one of several per-harness adapters (see also
18
+ * check-turn-completion-codex.mjs, check-turn-completion-gemini.mjs). Judgement
19
+ * — packs, cooldown, the rolling window, the self-echo marker — lives in
20
+ * turn-completion/engine.mjs and is shared by all of them; this file's only job
21
+ * is reading Claude Code's stdin/transcript shape and writing Claude Code's
22
+ * output shape.
23
+ *
17
24
  * Usage: node check-turn-completion.mjs (reads stdin)
18
25
  * node check-turn-completion.mjs --self-test
19
26
  * node check-turn-completion.mjs --languages
@@ -21,60 +28,13 @@
21
28
  * @see docs/specs/SPEC-HOOKS-001-core-standard-hooks.md
22
29
  * @see core/turn-completion-integrity.md
23
30
  */
24
- import { readFileSync, mkdirSync, writeFileSync, existsSync } from 'node:fs';
25
- import { homedir } from 'node:os';
26
- import { join, dirname } from 'node:path';
27
- import { fileURLToPath } from 'node:url';
28
- import { detectCommitment, userAskedToStop } from './turn-completion/detect.mjs';
29
-
30
- export const VERSION = '1.1.0';
31
-
32
- const COOLDOWN_SEC = 120;
33
- // A cap counted per session with no reset is an off switch on a delay: it
34
- // disarms silently in exactly the long session where the rule matters most.
35
- // A rolling window bounds runaway loops just as well and recovers by itself.
36
- const MAX_BLOCKS_PER_WINDOW = 6;
37
- const WINDOW_SEC = 3600;
38
-
39
- const STATE_DIR = join(homedir(), '.uds', 'turn-completion');
40
- const HERE = dirname(fileURLToPath(import.meta.url));
41
-
42
- /** Locales this hook ships. The self-test fails if any of them will not load. */
43
- export const SHIPPED_LOCALES = ['en', 'zh-TW'];
44
-
45
- /**
46
- * Load every shipped locale pack.
47
- *
48
- * At runtime a pack that fails to load is skipped (R5: a broken pack must not
49
- * stop the turn from ending). 🔴 But that swallow is also how a shipping
50
- * mistake hides: with zero packs loaded the hook runs, exits 0, and can never
51
- * fire — the same shape as good behaviour. So `failed` is returned rather than
52
- * discarded, and the self-test treats a non-empty `failed` as a failure.
53
- * Caught while renaming the packs to .mjs: the loader still asked for .js.
54
- */
55
- export async function loadPacks() {
56
- const packs = [];
57
- const failed = [];
58
- for (const id of SHIPPED_LOCALES) {
59
- try {
60
- packs.push(await import(join(HERE, 'turn-completion', 'locales', `${id}.mjs`)));
61
- } catch (e) {
62
- failed.push({ id, why: String((e && e.message) || e) });
63
- }
64
- }
65
- return { packs, failed };
66
- }
31
+ import { readFileSync, existsSync } from 'node:fs';
32
+ import {
33
+ VERSION, SHIPPED_LOCALES, SELF_ECHO, decide, loadPacks, isSelfEcho,
34
+ runSelfTest, printLanguages,
35
+ } from './turn-completion/engine.mjs';
67
36
 
68
- /**
69
- * 🔴 This hook's own block message enters the transcript as a user turn. On the
70
- * next run it would therefore be read as "the human's last message", hiding the
71
- * real one ("pause, I'm going home") and silently voiding the R9 exemption.
72
- *
73
- * Same family as the detector matching the prose that documents it — except
74
- * here it reads its own output. The reason string carries this line so it can
75
- * recognise itself.
76
- */
77
- export const SELF_ECHO = 'UDS standard turn-completion-integrity (R1)';
37
+ export { VERSION, SHIPPED_LOCALES, SELF_ECHO, loadPacks };
78
38
 
79
39
  /** Plain text of a transcript message, or '' if it carries none. */
80
40
  function textOf(msg) {
@@ -104,33 +64,11 @@ export function lastMessages(transcriptPath) {
104
64
  const t = textOf(msg);
105
65
  if (!t) continue;
106
66
  if (msg.role === 'assistant') assistant = t;
107
- else if (msg.role === 'user' && !t.includes(SELF_ECHO)) user = t;
67
+ else if (msg.role === 'user' && !isSelfEcho(t)) user = t;
108
68
  }
109
69
  return { assistant, user };
110
70
  }
111
71
 
112
- function readState(path) {
113
- try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return {}; }
114
- }
115
-
116
- function reason(packId, sentence) {
117
- return [
118
- 'Your last message stated a next action, and then the turn ended without taking it.',
119
- '',
120
- `Detected by the ${packId} pack, in: "${sentence}"`,
121
- '',
122
- 'UDS standard turn-completion-integrity (R1): a stated next action is not optional.',
123
- '',
124
- 'Do one of these now:',
125
- ' (a) take the action you just said you would take;',
126
- ' (b) if it is actually blocked, say what blocks it and what you need, then do',
127
- ' the next item that is not blocked;',
128
- ' (c) if everything is done or blocked, list every remaining item WITH the',
129
- ' person or input it waits on. That itemized list is the only shape of',
130
- ' ending that R2 accepts — "the main parts are done" is not one.',
131
- ].join('\n');
132
- }
133
-
134
72
  async function main() {
135
73
  let raw = '';
136
74
  try {
@@ -147,87 +85,24 @@ async function main() {
147
85
  const tp = data.transcript_path;
148
86
  if (!tp || !existsSync(tp)) return; // cannot tell is not the same as should block
149
87
 
150
- const sid = String(data.session_id || 'unknown');
151
- const statePath = join(STATE_DIR, `${sid}.json`);
152
- const st = readState(statePath);
153
- const now = Date.now() / 1000;
154
-
155
- const stamps = (Array.isArray(st.stamps) ? st.stamps : [])
156
- .filter((t) => typeof t === 'number' && now - t < WINDOW_SEC);
157
- if (stamps.length >= MAX_BLOCKS_PER_WINDOW) return;
158
- if (now - (st.last || 0) < COOLDOWN_SEC) return;
159
-
160
88
  let msgs;
161
89
  try { msgs = lastMessages(tp); } catch { return; }
162
- if (!msgs.assistant.trim()) return;
163
90
 
164
- const { packs } = await loadPacks();
165
- if (packs.length === 0) return;
166
-
167
- // The human asked for the turn to end. That is a legitimate ending, and the
168
- // agent's own words cannot distinguish it from an abandoned commitment.
169
- if (userAskedToStop(msgs.user, packs)) return;
170
-
171
- const hit = detectCommitment(msgs.assistant, packs);
172
- if (!hit.fired) return;
173
-
174
- stamps.push(now);
175
- try {
176
- mkdirSync(STATE_DIR, { recursive: true });
177
- writeFileSync(statePath, JSON.stringify({ stamps, last: now }));
178
- } catch {
179
- /* state is an optimisation; failing to write it must not change the verdict */
180
- }
181
-
182
- process.stdout.write(JSON.stringify({
183
- decision: 'block',
184
- reason: reason(hit.packId, hit.sentence),
185
- }));
186
- }
187
-
188
- async function selfTest() {
189
- const { packs, failed } = await loadPacks();
190
- let ok = failed.length === 0 && packs.length === SHIPPED_LOCALES.length;
191
- console.log(`[turn-completion] v${VERSION} — packs: ${packs.map((p) => p.id).join(', ')}`
192
- + ` (${packs.length}/${SHIPPED_LOCALES.length} shipped locales)`);
193
- for (const f of failed) console.log(` x locale ${f.id} failed to load — ${f.why}`);
194
-
195
- for (const pack of packs) {
196
- for (const [want, label, text] of pack.corpus) {
197
- // Run against ALL packs, which is the shipped configuration. A pack that
198
- // is correct alone and wrong beside another is not correct.
199
- const got = detectCommitment(text, packs).fired;
200
- const good = got === want;
201
- ok &&= good;
202
- console.log(` ${good ? 'OK ' : 'x '} [${pack.id}] ${want ? 'must block' : 'must pass'} — ${label}`
203
- + (good ? '' : ` (got: ${got ? 'block' : 'pass'})`));
204
- }
205
- for (const [want, label, text] of pack.stopCorpus || []) {
206
- const got = userAskedToStop(text, packs);
207
- const good = got === want;
208
- ok &&= good;
209
- console.log(` ${good ? 'OK ' : 'x '} [${pack.id}] user ${want ? 'IS' : 'is NOT'} asking to stop — ${label}`
210
- + (good ? '' : ` (got: ${got ? 'exempt' : 'not exempt'})`));
211
- }
212
- }
213
- // The block message must contain the marker, or the hook cannot tell its own
214
- // output from the human's next instruction.
215
- const selfRecognised = reason('en', 'x').includes(SELF_ECHO);
216
- ok &&= selfRecognised;
217
- console.log(` ${selfRecognised ? 'OK ' : 'x '} block message carries the self-echo marker`);
91
+ const verdict = await decide({
92
+ sessionId: data.session_id,
93
+ assistantText: msgs.assistant,
94
+ userText: msgs.user,
95
+ });
96
+ if (!verdict.fire) return;
218
97
 
219
- console.log(`[turn-completion] self-test ${ok ? 'passed' : 'FAILED'}`);
220
- process.exit(ok ? 0 : 1);
98
+ process.stdout.write(JSON.stringify({ decision: 'block', reason: verdict.reason }));
221
99
  }
222
100
 
223
101
  const arg = process.argv[2];
224
102
  if (arg === '--self-test') {
225
- await selfTest();
103
+ process.exit((await runSelfTest('turn-completion')) ? 0 : 1);
226
104
  } else if (arg === '--languages') {
227
- const { packs } = await loadPacks();
228
- console.log(packs.map((p) => `${p.id} (${p.label})`).join('\n'));
229
- console.log('\nThis check reads prose. If you work in a language not listed above,'
230
- + '\nit is installed and running but cannot fire. See turn-completion-integrity R8.');
105
+ await printLanguages();
231
106
  } else {
232
107
  try { await main(); } catch { /* R5 */ }
233
108
  }
@@ -0,0 +1,206 @@
1
+ /**
2
+ * Shared decision engine for the turn-completion-integrity check.
3
+ *
4
+ * Every harness adapter (Claude Code, Codex, Gemini CLI, ...) reduces its own
5
+ * stdin contract down to { sessionId, assistantText, userText } and hands it
6
+ * to decide() here. This file owns judgement — pack loading, cooldown, the
7
+ * rolling-window cap, the self-echo marker — so that three (or more) copies
8
+ * of that logic cannot drift out of sync with each other. An adapter's only
9
+ * remaining job is reading its tool's input and writing its tool's output
10
+ * shape for a block; neither of those touches this file.
11
+ *
12
+ * @see core/turn-completion-integrity.md
13
+ */
14
+ import { readFileSync, mkdirSync, writeFileSync, existsSync } from 'node:fs';
15
+ import { homedir } from 'node:os';
16
+ import { join, dirname } from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+ import { detectCommitment, userAskedToStop } from './detect.mjs';
19
+
20
+ export const VERSION = '1.2.0';
21
+
22
+ const COOLDOWN_SEC = 120;
23
+ // A cap counted per session with no reset is an off switch on a delay: it
24
+ // disarms silently in exactly the long session where the rule matters most.
25
+ // A rolling window bounds runaway loops just as well and recovers by itself.
26
+ const MAX_BLOCKS_PER_WINDOW = 6;
27
+ const WINDOW_SEC = 3600;
28
+
29
+ // Overridable so tests (and anything else that must not touch the
30
+ // developer's real state) can point this at a throwaway directory instead of
31
+ // mocking fs. Read once per call, not once at import time, so a test can set
32
+ // it per-case without re-importing the module.
33
+ function stateDir() {
34
+ return process.env.UDS_TURN_COMPLETION_STATE_DIR || join(homedir(), '.uds', 'turn-completion');
35
+ }
36
+
37
+ const HERE = dirname(fileURLToPath(import.meta.url));
38
+
39
+ /** Locales this hook ships. The self-test fails if any of them will not load. */
40
+ export const SHIPPED_LOCALES = ['en', 'zh-TW'];
41
+
42
+ /**
43
+ * Load every shipped locale pack.
44
+ *
45
+ * At runtime a pack that fails to load is skipped (R5: a broken pack must not
46
+ * stop the turn from ending). 🔴 But that swallow is also how a shipping
47
+ * mistake hides: with zero packs loaded the hook runs, exits 0, and can never
48
+ * fire — the same shape as good behaviour. So `failed` is returned rather than
49
+ * discarded, and the self-test treats a non-empty `failed` as a failure.
50
+ */
51
+ export async function loadPacks() {
52
+ const packs = [];
53
+ const failed = [];
54
+ for (const id of SHIPPED_LOCALES) {
55
+ try {
56
+ packs.push(await import(join(HERE, 'locales', `${id}.mjs`)));
57
+ } catch (e) {
58
+ failed.push({ id, why: String((e && e.message) || e) });
59
+ }
60
+ }
61
+ return { packs, failed };
62
+ }
63
+
64
+ /**
65
+ * 🔴 This hook's own block message can re-enter a transcript as a human turn
66
+ * (measured on Claude Code — the block reason is written back as the next
67
+ * "user" message). On the next run it would therefore be read as "the human's
68
+ * last message", hiding the real one ("pause, I'm going home") and silently
69
+ * voiding the R9 exemption. Every adapter that reads a transcript for the
70
+ * human's last message MUST strip a message containing this marker before
71
+ * treating it as the human's words; stripSelfEcho() below does that.
72
+ */
73
+ export const SELF_ECHO = 'UDS standard turn-completion-integrity (R1)';
74
+
75
+ /** True if this text is (or contains) the hook's own prior block message. */
76
+ export function isSelfEcho(text) {
77
+ return typeof text === 'string' && text.includes(SELF_ECHO);
78
+ }
79
+
80
+ function readState(path) {
81
+ try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return {}; }
82
+ }
83
+
84
+ export function reason(packId, sentence) {
85
+ return [
86
+ 'Your last message stated a next action, and then the turn ended without taking it.',
87
+ '',
88
+ `Detected by the ${packId} pack, in: "${sentence}"`,
89
+ '',
90
+ 'UDS standard turn-completion-integrity (R1): a stated next action is not optional.',
91
+ '',
92
+ 'Do one of these now:',
93
+ ' (a) take the action you just said you would take;',
94
+ ' (b) if it is actually blocked, say what blocks it and what you need, then do',
95
+ ' the next item that is not blocked;',
96
+ ' (c) if everything is done or blocked, list every remaining item WITH the',
97
+ ' person or input it waits on. That itemized list is the only shape of',
98
+ ' ending that R2 accepts — "the main parts are done" is not one.',
99
+ ].join('\n');
100
+ }
101
+
102
+ /**
103
+ * Decide whether this turn should block, and own the per-session cooldown and
104
+ * rolling-window state shared by every adapter.
105
+ *
106
+ * @param {object} input
107
+ * @param {string} input.sessionId
108
+ * @param {string} input.assistantText - the agent's final message for this turn
109
+ * @param {string} input.userText - the human's most recent message. The
110
+ * caller is responsible for excluding this hook's own SELF_ECHO from it
111
+ * where that tool's transcript can carry it (see isSelfEcho/stripSelfEcho).
112
+ * @returns {Promise<{ fire: boolean, reason?: string, packId?: string, sentence?: string }>}
113
+ */
114
+ export async function decide({ sessionId, assistantText, userText }) {
115
+ if (!assistantText || !assistantText.trim()) return { fire: false };
116
+
117
+ const statePath = join(stateDir(), `${String(sessionId || 'unknown')}.json`);
118
+ const st = readState(statePath);
119
+ const now = Date.now() / 1000;
120
+
121
+ const stamps = (Array.isArray(st.stamps) ? st.stamps : [])
122
+ .filter((t) => typeof t === 'number' && now - t < WINDOW_SEC);
123
+ if (stamps.length >= MAX_BLOCKS_PER_WINDOW) return { fire: false };
124
+ if (now - (st.last || 0) < COOLDOWN_SEC) return { fire: false };
125
+
126
+ const { packs } = await loadPacks();
127
+ if (packs.length === 0) return { fire: false };
128
+
129
+ // The human asked for the turn to end. That is a legitimate ending, and the
130
+ // agent's own words cannot distinguish it from an abandoned commitment.
131
+ if (userAskedToStop(userText, packs)) return { fire: false };
132
+
133
+ const hit = detectCommitment(assistantText, packs);
134
+ if (!hit.fired) return { fire: false };
135
+
136
+ stamps.push(now);
137
+ try {
138
+ mkdirSync(stateDir(), { recursive: true });
139
+ writeFileSync(statePath, JSON.stringify({ stamps, last: now }));
140
+ } catch {
141
+ /* state is an optimisation; failing to write it must not change the verdict */
142
+ }
143
+
144
+ return { fire: true, reason: reason(hit.packId, hit.sentence), packId: hit.packId, sentence: hit.sentence };
145
+ }
146
+
147
+ /**
148
+ * Run every locale pack's corpus and print the result. Shared by every
149
+ * adapter's `--self-test` so the corpus can only be correct or wrong once,
150
+ * not once per tool.
151
+ *
152
+ * @param {string} label - printed in the header, e.g. "turn-completion-codex"
153
+ * @returns {Promise<boolean>} true if every corpus case and the self-echo
154
+ * marker check passed
155
+ */
156
+ export async function runSelfTest(label = 'turn-completion') {
157
+ const { packs, failed } = await loadPacks();
158
+ let ok = failed.length === 0 && packs.length === SHIPPED_LOCALES.length;
159
+ console.log(`[${label}] v${VERSION} — packs: ${packs.map((p) => p.id).join(', ')}`
160
+ + ` (${packs.length}/${SHIPPED_LOCALES.length} shipped locales)`);
161
+ for (const f of failed) console.log(` x locale ${f.id} failed to load — ${f.why}`);
162
+
163
+ for (const pack of packs) {
164
+ for (const [want, caseLabel, text] of pack.corpus) {
165
+ // Run against ALL packs, which is the shipped configuration. A pack that
166
+ // is correct alone and wrong beside another is not correct.
167
+ const got = detectCommitment(text, packs).fired;
168
+ const good = got === want;
169
+ ok &&= good;
170
+ console.log(` ${good ? 'OK ' : 'x '} [${pack.id}] ${want ? 'must block' : 'must pass'} — ${caseLabel}`
171
+ + (good ? '' : ` (got: ${got ? 'block' : 'pass'})`));
172
+ }
173
+ for (const [want, caseLabel, text] of pack.stopCorpus || []) {
174
+ const got = userAskedToStop(text, packs);
175
+ const good = got === want;
176
+ ok &&= good;
177
+ console.log(` ${good ? 'OK ' : 'x '} [${pack.id}] user ${want ? 'IS' : 'is NOT'} asking to stop — ${caseLabel}`
178
+ + (good ? '' : ` (got: ${got ? 'exempt' : 'not exempt'})`));
179
+ }
180
+ }
181
+ // The block message must contain the marker, or a transcript-reading adapter
182
+ // cannot tell its own output from the human's next instruction.
183
+ const selfRecognised = reason('en', 'x').includes(SELF_ECHO);
184
+ ok &&= selfRecognised;
185
+ console.log(` ${selfRecognised ? 'OK ' : 'x '} block message carries the self-echo marker`);
186
+
187
+ console.log(`[${label}] self-test ${ok ? 'passed' : 'FAILED'}`);
188
+ return ok;
189
+ }
190
+
191
+ /**
192
+ * Print the languages this hook ships, and the R8 disclosure. Shared by every
193
+ * adapter's `--languages` so the installer's probeLanguageLimits() gets the
194
+ * same answer regardless of which tool's script it runs.
195
+ */
196
+ export async function printLanguages() {
197
+ const { packs } = await loadPacks();
198
+ console.log(packs.map((p) => `${p.id} (${p.label})`).join('\n'));
199
+ console.log('\nThis check reads prose. If you work in a language not listed above,'
200
+ + '\nit is installed and running but cannot fire. See turn-completion-integrity R8.');
201
+ }
202
+
203
+ /** True if the given path exists — re-exported so adapters don't add their own fs import just for this. */
204
+ export function pathExists(p) {
205
+ return !!p && existsSync(p);
206
+ }
@@ -46,6 +46,16 @@ const REPORTING =
46
46
  const ASKING =
47
47
  /(\bshould I\b|\bdo you want\b|\bwould you like\b|\bwhich (one|file|option)\b|\blet me know\b|\btell me\b|\bgive me\b|\bpaste\b|\bwaiting on you\b|\byour call\b|\bup to you\b|\?)/i;
48
48
 
49
+ // "Once you pick, I'll ..." — the precondition is the human's decision, not a
50
+ // request for information, so ASKING above never caught it (measured: the
51
+ // zh-TW mirror of this shape fired as an unkept commitment). Grammar-based, not
52
+ // a verb list, to match this pack's own design note above: "once/after/as soon
53
+ // as you", up to 20 non-terminating characters, a comma, then "I".
54
+ // Known limit, left uncovered on purpose rather than widened past this shape:
55
+ // a version with no comma ("After you merged it I will …") still fires. See
56
+ // the corpus entry below.
57
+ const CONDITIONAL_ON_YOU = /\b(once|after|as soon as) you\b[^,.\n]{0,20},\s*I\b/i;
58
+
49
59
  /**
50
60
  * Expand contractions so the patterns below never have to fight an apostrophe.
51
61
  * Both the ASCII and typographic apostrophes appear in real transcripts.
@@ -96,7 +106,8 @@ export function isCommitment(sentence) {
96
106
  }
97
107
 
98
108
  export function isAsking(text) {
99
- return ASKING.test(normalize(text));
109
+ const n = normalize(text);
110
+ return ASKING.test(n) || CONDITIONAL_ON_YOU.test(n);
100
111
  }
101
112
 
102
113
  /**
@@ -104,9 +115,18 @@ export function isAsking(text) {
104
115
  * here disables the check for the rest of the session, which is worse than a
105
116
  * missed block. It must read as an instruction to stop, not a mention of
106
117
  * stopping.
118
+ *
119
+ * 🔴 2026-09-27: `\bI'?m (heading|going) (home|out)\b` was dead code — it can
120
+ * never match, because isStopRequest() always calls normalize() first, and
121
+ * normalize() rewrites "I'm" to "I am" before this pattern ever sees the
122
+ * text (`\bI'll\b` -> "I will" etc. have the same shape, but no other branch
123
+ * here depended on the contracted form surviving normalize()). Verified: a
124
+ * bare "I'm heading out." tested false pre-fix, true once the pattern
125
+ * accounts for the post-normalize "I am" form. Written to match either form
126
+ * defensively, in case normalize()'s rewrite list ever changes.
107
127
  */
108
128
  const STOP_REQUEST =
109
- /(\blet's (stop|pause|pick this up later)\b|\b(pause|stop) (here|for now|there)\b|\bhold (on|off)\b|\bthat's (enough|it) for (now|today)\b|\b(done|enough) for (now|today)\b|\bwrap (it |this )?up\b|\bcontinue (this )?later\b|\bpick (this|it) up (tomorrow|later)\b|\btake a break\b|\bI'?m (heading|going) (home|out)\b|\bgood ?night\b)/i;
129
+ /(\blet's (stop|pause|pick this up later)\b|\b(pause|stop) (here|for now|there)\b|\bhold (on|off)\b|\bthat's (enough|it) for (now|today)\b|\b(done|enough) for (now|today)\b|\bwrap (it |this )?up\b|\bcontinue (this )?later\b|\bpick (this|it) up (tomorrow|later)\b|\btake a break\b|\bI(?:'m| am) (heading|going) (home|out)\b|\bgood ?night\b)/i;
110
130
 
111
131
  export function isStopRequest(text) {
112
132
  return STOP_REQUEST.test(normalize(text));
@@ -142,6 +162,23 @@ export const corpus = [
142
162
  [false, 'legitimate stop: the next move is the human\'s', 'I will wait for your key before deploying.'],
143
163
  [false, 'conditional: asking in the same paragraph',
144
164
  'Tell me which file you meant and I will check it.'],
165
+ // 🔴 A third shape of conditional: the precondition is the human's decision,
166
+ // not a request for information or a report-back. Mirrors a false block
167
+ // measured in the zh-TW pack for the same grammar ("你選定後,我會…").
168
+ [false, 'conditional: once you pick, I will',
169
+ "Once you pick a model, I'll wire it into the config."],
170
+ [false, 'conditional: after you confirm, I will',
171
+ 'After you confirm the plan, I will kick off the deploy.'],
172
+ [false, 'conditional: once you decide, I will',
173
+ 'Once you decide, I will draft the ADR and file the tickets.'],
174
+ [true, 'subject is not you, still commits',
175
+ "After I fix this, I'll push it up."],
176
+ // Known limit, not fixed here: no comma between the precondition and "I"
177
+ // still fires. Widening past a comma risks swallowing real unkept
178
+ // commitments ("After you leave I will still ship it" has no comma either),
179
+ // so this stays a documented gap rather than a wider pattern.
180
+ [true, 'known limit: same shape, no comma — still fires',
181
+ 'After you merged it I will follow up.'],
145
182
  ];
146
183
 
147
184
  /**
@@ -156,4 +193,15 @@ export const stopCorpus = [
156
193
  [false, 'mentions stopping but is not one', 'Explain why the hook stops the turn.'],
157
194
  [false, 'asks for work', 'Stop using the hardcoded list and walk the registry instead.'],
158
195
  [false, 'ordinary instruction', 'Fix the detector and push it.'],
196
+ // 🔴 the "I'm heading (home|out)" branch above was dead code before this
197
+ // fix — normalize() rewrites "I'm" to "I am" before the pattern is tested,
198
+ // and the pattern required the contracted form. This case exercises it
199
+ // directly (no "let's pause"/"hold on" alongside it, unlike the two cases
200
+ // above that already passed for a different reason).
201
+ [true, "bare 'heading out', no other stop phrase nearby", "I'm heading out."],
202
+ [false, 'a leaving verb alone is an instruction to finish something BEFORE leaving, not a stop request', 'Before I head out, please finish the migration.'],
159
203
  ];
204
+ // Mutation check (verified by hand, then reverted — see commit/handback
205
+ // notes): reverting STOP_REQUEST's `I(?:'m| am)` back to the dead-code
206
+ // `I'?m`-only form turns the "bare 'heading out'" case above false, which
207
+ // fails `--self-test`.
@@ -44,7 +44,15 @@ const ASKING = new RegExp(
44
44
  // 「跟我說一聲,我會到 PC15 上確認」 — the precondition sat in a heading one paragraph up, so
45
45
  // the paragraph opens with the request. A report-back phrase, a comma, then 我 at once is
46
46
  // "you tell me, then I act". A report-back phrase alone does not qualify.
47
- '|(跟我說|告訴我|回報我|讓我知道)(一聲)?[,,]\\s*我)'
47
+ '|(跟我說|告訴我|回報我|讓我知道)(一聲)?[,,]\\s*我' +
48
+ // 「你選定後,我會…」— the precondition is the human's decision, not a request
49
+ // for information or a report-back, so neither branch above caught it. Measured:
50
+ // fired as an unkept commitment. Narrow on purpose: literal 你, then 1-6 chars
51
+ // that are not another 我/你 or punctuation (a short decision verb — 選定/選好/
52
+ // 決定/確認/回覆/點頭 — not a whole clause), then 後, then a comma and 我. 你 with
53
+ // no 後 right after (「你選定的那份我會接著處理」) and 後 with no 你 right before
54
+ // (「改好後我接著合併」, 「他確認後,我會…」) must both still fall through and block.
55
+ '|你[^,,。\\n我你]{1,6}(之)?後[,,]\\s*我)'
48
56
  );
49
57
 
50
58
  // First person + future marker + action verb, within one sentence.
@@ -85,15 +93,28 @@ export function isAsking(text) {
85
93
  return ASKING.test(text);
86
94
  }
87
95
 
96
+ // 🔴 2026-09-25 實測缺口:「我要出門了,等我回來再繼續」與「暫停,我要出門」
97
+ // 都沒有被下面任何一支既有樣式接住——前者當天真的誤擋了一次真實的 Claude
98
+ // Code 回合。R10 說叫停樣式放寬是危險方向(錯誤豁免會讓整個 session 的檢查
99
+ // 失效),所以這裡刻意寫窄:離開類詞必須跟「稍後再續」或裸的「暫停」同時
100
+ // 出現在同一小段裡,單獨出現的離開詞不算——「出門前把這三件做完」只有離開
101
+ // 詞、沒有稍後再續,仍必須判 false(見下方 stopCorpus 的反例)。
102
+ const LEAVING = '(出門|離開一下|先走)';
103
+ const RESUME_LATER = '(等我回來|回來再|明天再|晚點再|待會再|待会再)';
104
+ const LEAVE_THEN_LATER = `${LEAVING}[^。!?\\n]{0,12}${RESUME_LATER}|${RESUME_LATER}[^。!?\\n]{0,12}${LEAVING}`;
105
+ const LEAVE_WITH_PAUSE = `暫停[^。!?\\n]{0,12}${LEAVING}|${LEAVING}[^。!?\\n]{0,12}暫停`;
106
+
88
107
  /**
89
108
  * The human asking for the turn to end. Kept narrow on purpose: a false
90
109
  * positive disables the check for the rest of the session.
91
110
  */
92
- const STOP_REQUEST =
111
+ const STOP_REQUEST = new RegExp(
93
112
  // 🔴 `先停` 曾寫成裸的,而語料當場抓到「先**停用**那份硬編碼清單」——
94
113
  // 一句要求做事的指令被讀成叫我停。這個方向的誤判會把守衛整場關掉,
95
114
  // 所以 `停` 後面接得出動詞的字一律排除。
96
- /(先暫停|暫停一下|先停(?![用止掉住])|停一下|先不要(做|動)|不用繼續|今天(先)?到這|先這樣|收工|下班|我要回家|明天再(說|弄|做)|改天再|先擱著|睡了|晚安)/;
115
+ '(先暫停|暫停一下|先停(?![用止掉住])|停一下|先不要(做|動)|不用繼續|今天(先)?到這|先這樣|收工|下班|我要回家|明天再(說|弄|做)|改天再|先擱著|睡了|晚安' +
116
+ `|${LEAVE_THEN_LATER}|${LEAVE_WITH_PAUSE})`
117
+ );
97
118
 
98
119
  export function isStopRequest(text) {
99
120
  return STOP_REQUEST.test(text);
@@ -150,6 +171,26 @@ export const corpus = [
150
171
  '我先去確認有沒有新的發現。'],
151
172
  [true, '沒有要求回報的承諾(仍必須擋)',
152
173
  '設定檔已經改好了。我接著把驗證結果寫進規格,然後回報。'],
174
+ // 🔴 條件式承諾的第三種形狀:前提是使用者的決定,不是資訊或回報。
175
+ // 09-25 實測:「你選定後,我會…」被判成未兌現承諾(fired=true)。
176
+ [false, '條件式承諾:你選定後,我會…',
177
+ '你選定後,我會把這一輪的發想寫成正式決策紀錄,再分別派工給 UDS 和 EGR。'],
178
+ [false, '條件式承諾:你決定後',
179
+ '你決定後,我會接著把設定寫回 repo。'],
180
+ [false, '條件式承諾:你確認後',
181
+ '你確認後,我會把清單送出。'],
182
+ [false, '條件式承諾:你回覆後',
183
+ '你回覆後,我會接著處理。'],
184
+ [false, '條件式承諾:你選好後',
185
+ '你選好後,我會接著跑一次測試。'],
186
+ [false, '條件式承諾:你點頭後',
187
+ '你點頭後,我會接著把這份規格送出。'],
188
+ [true, '主詞不是你,仍必須擋——「後」在,但前面不是你',
189
+ '改好後我接著合併。'],
190
+ [true, '主詞不是你,仍必須擋——第三人稱的「後」',
191
+ '他確認後,我會把清單整理好。'],
192
+ [true, '你在句中,但後面沒有「後」,仍必須擋',
193
+ '你選定的那份我會接著處理。'],
153
194
  ];
154
195
 
155
196
  /**
@@ -163,4 +204,14 @@ export const stopCorpus = [
163
204
  [false, '提到停止但不是叫停', '解釋一下這支 hook 為什麼會擋下回合。'],
164
205
  [false, '要求做事而句中有停', '先停用那份硬編碼清單,改成走訪註冊表。'],
165
206
  [false, '一般指令', '把偵測器修好然後推上去。'],
207
+ // 🔴 2026-09-25 真實使用者原話——當時真的誤擋了一次回合,見上方 STOP_REQUEST 的註解。
208
+ [true, '離開+稍後再續(真實原話)', '我要出門了,等我回來再繼續'],
209
+ [true, '暫停+離開(真實原話)', '暫停,我要出門'],
210
+ // 窄性反例:單獨的離開詞、或單獨的「稍後」詞,都不算叫停——那是要求
211
+ // 「在離開前」把事情做完,不是要收掉這一輪。
212
+ [false, '離開詞單獨出現:這是要求離開前做完,不是叫停', '出門前把這三件做完'],
213
+ [false, '稍後詞單獨出現,沒有離開詞:一樣是要求先做完', '等我回來前先把測試跑完'],
166
214
  ];
215
+ // 突變驗證(手動跑過、已還原——見 commit/交接紀錄):把 STOP_REQUEST 的
216
+ // `LEAVE_THEN_LATER`/`LEAVE_WITH_PAUSE` 兩個新分支拿掉,上面兩筆「真實原話」
217
+ // 會變 false,`--self-test` 隨之變紅,證明這兩筆真的在測新加的規則。
@@ -1,8 +1,8 @@
1
1
  ---
2
2
  source: ../../CHANGELOG.md
3
- source_version: 6.12.0
4
- translation_version: 6.12.0
5
- last_synced: 2026-09-24
3
+ source_version: 6.13.0-beta.2
4
+ translation_version: 6.13.0-beta.2
5
+ last_synced: 2026-09-26
6
6
  status: current
7
7
  ---
8
8
 
@@ -17,6 +17,30 @@ status: current
17
17
 
18
18
  ## [Unreleased]
19
19
 
20
+ ## [6.13.0-beta.2] - 2026-09-26
21
+
22
+ > **测试版** — 以 `npm install -g universal-dev-standards@beta` 安装。要测什么、已知限制、如何退回正式版:见 [docs/PRE-RELEASE.md](../../docs/PRE-RELEASE.md)。
23
+
24
+ ### 修复
25
+
26
+ - **`uds uninstall` 从未移除 `installHooks()`/`installCodexHooks()`/`installGeminiHooks()` 写入的关卡——不只是 Codex 与 Gemini CLI(6.13.0-beta.1 记载的已知限制),Claude Code 自己的 `.claude/settings.json` 也有一模一样的缺口,而且从未被记录过。** `hooks` 这个 uninstall 分类原本只处理 `.husky/pre-commit` 与 `.git/hooks/pre-commit`;三个安装函数实际写入的配置文件完全没有任何 uninstaller 在管,导致每一个关卡在 `uds uninstall` 之后仍持续运行。新增的 `uninstallClaudeCodeHooks`/`uninstallCodexHooks`/`uninstallGeminiHooks`(`src/uninstallers/hook-uninstaller.js`)现在会精准移除 `.claude/settings.json`、`.codex/hooks.json`、`.gemini/settings.json` 里 UDS 安装的条目——判断依据是命令路径**加上**一份 UDS 目前确实有发布的脚本文件名清单,不是只看路径,这样用户自己放进 UDS 同一个 `scripts/hooks/` 目录下的 hook 就不会被误删。移除后变空的事件数组会一并从配置里移除;配置文件若因此变成完全空的对象(代表整份都是 UDS 写入的)就直接删除文件,否则保留文件并写回其余内容。JSON 格式损坏时报告错误并保持原样,不会覆写。已接入 `uds uninstall` 既有的 `hooks` 分类、`--dry-run` 预览,以及交互菜单里该分类的说明文字。
27
+ - **Codex 适配层的 R9 豁免(「用户叫停就放行」)在真实 Codex 安装上从未真正生效过——`scripts/hooks/check-turn-completion-codex.mjs` 的 `bestEffortLastUserMessage()` 试过的两种记录形状都读不到字段。** 已对真实 codex-cli 0.156.1 的 `~/.codex/sessions/**/*.jsonl` 对话记录坐实:一条用户消息记录长这样——`{"type":"response_item","payload":{"type":"message","role":"user","content":[{"type":"input_text","text":...}]}}`——消息位于 `payload` 之下,不在该条记录最外层、也不在 `message` 键下,所以该字段一直被读成不存在,R9 从未真正豁免过任何一轮 Codex 对话。现在优先读取 `payload.type === "message"`(其余记录类型若刚好用到原来那两种形状仍保留为后备),同时支持 `input_text` 内容项(与既有的 `text` 形状并存),且不把 `role: "developer"` 的记录当成用户消息。`core/turn-completion-integrity.md`「支持的执行环境」一节与 `docs/PRE-RELEASE.md` 已从「未对照真实安装验证过」更新为已坐实的真实形状。
28
+ - **R9 的 zh-TW 叫停检测漏掉了「要离开、稍后再继续」这一族——已实测:「我要出门了,等我回来再继续」在 2026-09-25 真的误挡了一次 Claude Code 的对话完成关卡。** 这句与「暂停,我要出门」都没有被既有的任何一个 `STOP_REQUEST` 模式接住。新增两个窄模式:离开类词(出门/离开一下/先走)必须跟「稍后再续」类词(等我回来/回来再/明天再/晚点再/待会再)或裸的「暂停」同时出现在同一小段里——单独的离开词(例如「出门前把这三件做完」,这是要求离开前做完,不是叫停)语料仍必须判 false。顺手查了 en 语料包有没有同样的缺口,发现 `\bI'?m (heading|going) (home|out)\b` 是永远打不中的死代码:`isStopRequest()` 一律先调用 `normalize()`,会把 "I'm" 改写成 "I am",只认缩写形式的模式因此永远匹配不到;已改为 `I(?:'m| am) (heading|going) (home|out)`,坐实裸的 "I'm heading out." 修复前为 false、修复后为 true。
29
+
30
+ ## [6.13.0-beta.1] - 2026-09-26
31
+
32
+ > **测试版** — 以 `npm install -g universal-dev-standards@beta` 安装。要测什么、已知限制、如何退回正式版:见 [docs/PRE-RELEASE.md](../../docs/PRE-RELEASE.md)。已知限制:`uds uninstall` 尚不会移除 Codex/Gemini CLI 设置里的关卡。
33
+
34
+ ### 新增
35
+
36
+ - **`turn-completion-integrity` 1.4.0 把 Stop 关卡推广到 Codex 与 Gemini CLI,与既有的 Claude Code 适配层并存。** 判断逻辑(语料包、冷却、滚动窗口、自我回音标记)抽到共用的 `turn-completion/engine.mjs`,三个工具脚本各自只转译自己工具的契约,不再各带一份判断逻辑。Codex(`.codex/hooks.json`,Stop 事件)直接从 stdin 读取 `last_assistant_message`,在 exit 0 时于 stdout 输出 `{"decision":"block","reason":...}` 来拦截——官方文档写明这个事件纯文本或空输出无效,这点与 Claude Code「沉默即放行」不同;用户的最后一条消息用 `transcript_path` 尽力读取,因为其确切格式未对照真实安装验证过,所以 R9(豁免用户主动喊停的回合)在 Codex 上是文档记载的已知落差,不是静默失效。Gemini CLI(`.gemini/settings.json`,`AfterAgent` 事件)的 stdin 直接给出 `prompt` 与 `prompt_response`,完全不需要解析对话记录,拦截方式是 `{"decision":"deny","reason":...}`——官方文档标记为优先于 exit code 2 的做法。`uds init --with-hooks` 现在也会调用 `installCodexHooks`/`installGeminiHooks`,门槛是采用者有没有选那个工具,没用到 Codex 或 Gemini CLI 的项目不会被写入任何东西。Cursor 已评估,明确标记为不支持(Cursor 的 stop hook 能不能真的拦下一个回合仍未确定)。见[支持的执行环境](../../core/turn-completion-integrity.md#supported-harnesses)与 [CLI-INIT-OPTIONS.md](../../docs/CLI-INIT-OPTIONS.md#claude-code-以外的强制执行-hooks)。
37
+
38
+ - **`developer-memory` 1.2.0:新增 `code-reference` 过期检查——记忆引用的文件路径或符号一旦搬走或不存在,浮现前就会被标记,不再被悄悄沿用。** 沿用 `knowledge-graph-memory` 1.0.0 已定义的双模式(§2),不另外发明第三种:降级模式(没有图引擎——AI 自己用 Glob/Grep/Read 确认引用还在,与既有的记忆验证原则同一套机制)与引擎模式(有图引擎时,例如 EngramGraph 的 `egr refs check`,报告每个引用的状态:`present`/`moved`(附新位置)/`missing`/`unresolvable`)。`unresolvable` 一律不得当成 `present` 或 `missing`——它代表检查器无法判断,不是引用没事或已消失。时机挂在既有的 `proactive-surfacing` 规则(§4.1),检查在记忆浮现**之前**进行,不是之后。第一批只涵盖文件路径与符号名称(函数/类);`file:line` 明确排除在外——行号会随任何不相关的编辑漂移,属于不同种类的过期(见 DEC-115 OQ-1,2027-01-31 前重新评估)。`core/developer-memory.md` §11 加入一段非规范性的 Claude Code `SessionStart` hook 范例;其他工具则改走各自 repo 的说明文件(CLAUDE.md/AGENTS.md/.cursorrules 等)。(DEC-115-L1)
39
+
40
+ ### 修复
41
+
42
+ - **`turn-completion-integrity` 的 zh-TW 与 en 检测器,会在前提是用户自己决定的条件式承诺上误拦。** 「你选定后,我会把这一轮的发想写成正式决策记录…」被判成未兑现的承诺——既有的豁免只涵盖「要求信息」与「要求回报」,没有涵盖「前提是用户的决定」这种条件句。zh-TW 新增一个窄模式:你 + 短决定动词(选定/选好/决定/确认/回覆/点头)+ 后 + 逗号 + 我;en 新增以语法为准(不是动词清单,延续本包既有设计)的 `(once|after|as soon as) you ..., I` 模式。两份语料都补了成对的反例(主语不是你/you,或只有一半的形状),证明收窄没有连带漏拦真的未兑现承诺;en 版本也记下一个刻意留下未解的已知限制(前提与 "I" 之间没有逗号时仍会误拦——放宽会漏拦真的未兑现承诺)。
43
+
20
44
  ## [6.12.0] - 2026-09-25
21
45
 
22
46
  ### 新增