@linimin/pi-letscook 0.1.51 → 0.1.52

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.
@@ -8,6 +8,8 @@ pi() {
8
8
  TMPDIR="$(mktemp -d)"
9
9
  trap 'rm -rf "$TMPDIR"' EXIT
10
10
 
11
+ export PI_COMPLETION_TEST_TRIGGER_MODE=router
12
+
11
13
  write_session() {
12
14
  local session_path="$1"
13
15
  local cwd="$2"
@@ -47,6 +49,175 @@ with session_path.open('w', encoding='utf-8') as fh:
47
49
  PY
48
50
  }
49
51
 
52
+ write_mixed_session() {
53
+ local session_path="$1"
54
+ local cwd="$2"
55
+ local assistant_text="$3"
56
+ local user_text="$4"
57
+ python3 - "$session_path" "$cwd" "$assistant_text" "$user_text" <<'PY'
58
+ import json
59
+ import sys
60
+ from pathlib import Path
61
+
62
+ session_path = Path(sys.argv[1])
63
+ cwd = sys.argv[2]
64
+ assistant_text = sys.argv[3]
65
+ user_text = sys.argv[4]
66
+ session_path.parent.mkdir(parents=True, exist_ok=True)
67
+ entries = [
68
+ {
69
+ "type": "session",
70
+ "version": 3,
71
+ "id": "11111111-1111-4111-8111-111111111111",
72
+ "timestamp": "2026-01-01T00:00:00.000Z",
73
+ "cwd": cwd,
74
+ },
75
+ {
76
+ "type": "message",
77
+ "id": "a1b2c3d4",
78
+ "parentId": None,
79
+ "timestamp": "2026-01-01T00:00:01.000Z",
80
+ "message": {
81
+ "role": "assistant",
82
+ "content": assistant_text,
83
+ "timestamp": 1767225601000,
84
+ },
85
+ },
86
+ {
87
+ "type": "message",
88
+ "id": "b2c3d4e5",
89
+ "parentId": "a1b2c3d4",
90
+ "timestamp": "2026-01-01T00:00:02.000Z",
91
+ "message": {
92
+ "role": "user",
93
+ "content": user_text,
94
+ "timestamp": 1767225602000,
95
+ },
96
+ },
97
+ ]
98
+ with session_path.open('w', encoding='utf-8') as fh:
99
+ for entry in entries:
100
+ fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
101
+ PY
102
+ }
103
+
104
+ write_completion_state() {
105
+ local root="$1"
106
+ local mission="$2"
107
+ local continuation_policy="$3"
108
+ local project_done="$4"
109
+ local current_phase="$5"
110
+ local next_role="$6"
111
+ local next_action="$7"
112
+ python3 - "$root" "$mission" "$continuation_policy" "$project_done" "$current_phase" "$next_role" "$next_action" <<'PY'
113
+ import json
114
+ import sys
115
+ from pathlib import Path
116
+
117
+ root = Path(sys.argv[1])
118
+ mission = sys.argv[2]
119
+ continuation_policy = sys.argv[3]
120
+ project_done = sys.argv[4].lower() == 'true'
121
+ current_phase = sys.argv[5]
122
+ next_role = None if sys.argv[6] == 'null' else sys.argv[6]
123
+ next_action = None if sys.argv[7] == 'null' else sys.argv[7]
124
+ agent = root / '.agent'
125
+ agent.mkdir(parents=True, exist_ok=True)
126
+ (agent / 'mission.md').write_text(
127
+ '# Mission\n\n'
128
+ f'Project: {root.name}\n\n'
129
+ 'Mission anchor:\n'
130
+ f'{mission}\n\n'
131
+ "This file is a tracked human-readable statement of the repo's completion mission. Re-grounders may refine this file when repo truth becomes clearer, but it must stay truthful to shipped behavior and the active completion objective.\n",
132
+ encoding='utf-8',
133
+ )
134
+ profile = {
135
+ 'schema_version': 1,
136
+ 'protocol_id': 'completion',
137
+ 'project_name': root.name,
138
+ 'required_stop_judges': 3,
139
+ 'priority_policy_id': 'completion-default',
140
+ 'task_type': 'completion-workflow',
141
+ 'evaluation_profile': 'completion-rubric-v1',
142
+ 'docs_surfaces': ['README.md', 'CHANGELOG.md'],
143
+ }
144
+ state = {
145
+ 'schema_version': 1,
146
+ 'mission_anchor': mission,
147
+ 'current_phase': current_phase,
148
+ 'continuation_policy': continuation_policy,
149
+ 'continuation_reason': 'test fixture',
150
+ 'project_done': project_done,
151
+ 'task_type': 'completion-workflow',
152
+ 'evaluation_profile': 'completion-rubric-v1',
153
+ 'requires_reground': continuation_policy == 'done',
154
+ 'slices_since_last_reground': 0,
155
+ 'remaining_release_blockers': 0,
156
+ 'remaining_high_value_gaps': 0,
157
+ 'unsatisfied_contract_ids': [],
158
+ 'release_blocker_ids': [],
159
+ 'next_mandatory_action': next_action,
160
+ 'next_mandatory_role': next_role,
161
+ 'remaining_stop_judges': 3,
162
+ 'last_reground_at': '2026-01-01T00:00:00Z',
163
+ 'last_auditor_verdict': None,
164
+ 'contract_status': 'partial' if continuation_policy != 'done' else 'done',
165
+ 'latest_completed_slice': None,
166
+ 'latest_verified_slice': None,
167
+ }
168
+ plan = {
169
+ 'schema_version': 1,
170
+ 'mission_anchor': mission,
171
+ 'task_type': 'completion-workflow',
172
+ 'evaluation_profile': 'completion-rubric-v1',
173
+ 'last_reground_at': '2026-01-01T00:00:00Z',
174
+ 'plan_basis': 'test-fixture',
175
+ 'candidate_slices': [],
176
+ }
177
+ active = {
178
+ 'schema_version': 1,
179
+ 'mission_anchor': mission,
180
+ 'task_type': 'completion-workflow',
181
+ 'evaluation_profile': 'completion-rubric-v1',
182
+ 'status': 'idle',
183
+ 'slice_id': None,
184
+ 'goal': None,
185
+ 'contract_ids': [],
186
+ 'acceptance_criteria': [],
187
+ 'priority': None,
188
+ 'why_now': None,
189
+ 'blocked_on': [],
190
+ 'locked_notes': [],
191
+ 'must_fix_findings': [],
192
+ 'implementation_surfaces': [],
193
+ 'verification_commands': [],
194
+ 'basis_commit': None,
195
+ 'remaining_contract_ids_before': [],
196
+ 'release_blocker_count_before': None,
197
+ 'high_value_gap_count_before': None,
198
+ }
199
+ verification = {
200
+ 'schema_version': 1,
201
+ 'artifact_type': 'completion-verification-evidence',
202
+ 'subject_type': 'fixture',
203
+ 'slice_id': None,
204
+ 'goal': None,
205
+ 'contract_ids': [],
206
+ 'basis_commit': None,
207
+ 'head_sha': None,
208
+ 'verification_commands': [],
209
+ 'outcome': 'not_recorded',
210
+ 'recorded_at': None,
211
+ 'summary': 'test fixture',
212
+ }
213
+ (agent / 'profile.json').write_text(json.dumps(profile, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
214
+ (agent / 'state.json').write_text(json.dumps(state, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
215
+ (agent / 'plan.json').write_text(json.dumps(plan, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
216
+ (agent / 'active-slice.json').write_text(json.dumps(active, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
217
+ (agent / 'verification-evidence.json').write_text(json.dumps(verification, ensure_ascii=False, indent=2) + '\n', encoding='utf-8')
218
+ PY
219
+ }
220
+
50
221
  write_fallback_extension() {
51
222
  local target="$1"
52
223
  cat >"$target" <<'TS'
@@ -95,22 +266,39 @@ SENDER_EXTENSION="$TMPDIR/extension-sender.ts"
95
266
  write_fallback_extension "$FALLBACK_EXTENSION"
96
267
  write_extension_sender_extension "$SENDER_EXTENSION"
97
268
 
98
- DISCUSSION=$'Mission: Route natural-language handoff into the shared /cook entry.\nScope:\n- Add an input hook before the primary agent starts implementation work.\n- Keep /cook as the canonical workflow boundary.\nConstraints:\n- Do not transform natural-language input into /cook.\nAcceptance:\n- Route execution handoff text into the shared /cook entry behind approval-only confirmation.'
99
- MISSION='Route natural-language handoff into the shared /cook entry.'
100
- ROUTE_CLASSIFIER_OUTPUT='{"intent":"route_to_cook","confidence":0.95,"reason":"The latest input is an execution handoff that should transfer control into /cook.","focusHint":"shared /cook entry handoff","evidence":["current input is a start-execution phrase","recent discussion already defines a concrete workflow mission"],"riskFlags":[]}'
101
- NORMAL_CLASSIFIER_OUTPUT='{"intent":"normal_prompt","confidence":0.82,"reason":"The latest input is still asking the main agent to explain instead of handing control to /cook.","evidence":["the user is still asking for explanation in the main chat"],"riskFlags":["possible-normal-agent-request"]}'
269
+ STARTUP_DISCUSSION=$'Mission: Route natural-language handoff into the shared /cook entry.\nScope:\n- Add an input hook before the primary agent starts implementation work.\n- Keep /cook as the canonical workflow boundary.\nConstraints:\n- Do not transform natural-language input into /cook.\nAcceptance:\n- Route execution handoff text into the shared /cook entry behind approval-only confirmation.'
270
+ STARTUP_MISSION='Route natural-language handoff into the shared /cook entry.'
271
+ ACTIVE_MISSION='Keep the startup-only natural-language /cook handoff working.'
272
+ REFOCUS_DISCUSSION=$'Mission: Expand commandless routing to bias-aware resume and refocus offers.\nScope:\n- Distinguish startup, resume, refocus, and next-round workflow offers before the primary agent runs.\n- Keep confirmed handoffs on the shared /cook entry.\nConstraints:\n- Do not duplicate /cook workflow logic.\nAcceptance:\n- Resume and refocus handoffs reach the shared /cook entry with bias metadata.'
273
+ REFOCUS_MISSION='Expand commandless routing to bias-aware resume and refocus offers.'
274
+ NEXT_ROUND_DISCUSSION=$'Mission: Start the next completion workflow round for docs parity cleanup.\nScope:\n- Refresh docs and tests around commandless workflow entry.\n- Keep the existing workflow history done.\nConstraints:\n- Do not reopen the finished workflow mission.\nAcceptance:\n- The next workflow round uses a new mission anchor without reopening the finished one.'
275
+ NEXT_ROUND_MISSION='Start the next completion workflow round for docs parity cleanup.'
276
+ STARTUP_ROUTER_TEXT='把 login redirect 補完整,順便加測試'
277
+ NORMAL_ROUTER_TEXT='你覺得 login redirect 應該怎麼拆比較好?'
278
+ RESUME_ROUTER_TEXT='接著把剩下的測試補完'
279
+ REFOCUS_ROUTER_TEXT='先不要做 redirect 了,這輪改修 session timeout'
280
+ NEXT_ROUND_ROUTER_TEXT='這輪改做 docs parity cleanup'
281
+ UNCLEAR_ROUTER_TEXT='先做這個吧'
282
+
283
+ STARTUP_CLASSIFIER_OUTPUT='{"decision":"offer_workflow","confidence":0.95,"workflow_bias":"startup","reason":"The latest input is a startup handoff from recent discussion into the canonical workflow.","focusHint":"shared /cook entry handoff","evidence":["current input is a start-execution phrase","recent discussion already defines a concrete workflow mission"],"riskFlags":[]}'
284
+ RESUME_CLASSIFIER_OUTPUT='{"decision":"offer_workflow","confidence":0.94,"workflow_bias":"resume","reason":"The latest input is continuing the active workflow.","focusHint":"resume current workflow","evidence":["canonical state already exists","the latest input is a continue-style handoff"],"riskFlags":[]}'
285
+ REFOCUS_CLASSIFIER_OUTPUT='{"decision":"offer_workflow","confidence":0.91,"workflow_bias":"refocus","reason":"The latest input is starting a different workflow direction from recent discussion.","focusHint":"bias-aware resume and refocus offers","evidence":["recent discussion changes the mission","the latest input confirms starting the new direction"],"riskFlags":["active-workflow-refocus-risk"]}'
286
+ NEXT_ROUND_CLASSIFIER_OUTPUT='{"decision":"offer_workflow","confidence":0.92,"workflow_bias":"next_round","reason":"The previous workflow is done and the latest input starts a new implementation round.","focusHint":"next workflow round docs parity","evidence":["canonical workflow is already done","recent discussion defines a new task"],"riskFlags":[]}'
287
+ NORMAL_CLASSIFIER_OUTPUT='{"decision":"normal_prompt","confidence":0.82,"workflow_bias":"unknown","reason":"The latest input is still asking the main agent to explain instead of handing control to /cook.","evidence":["the user is still asking for explanation in the main chat"],"riskFlags":["possible-normal-agent-request"]}'
288
+ UNCLEAR_CLASSIFIER_OUTPUT='{"decision":"unclear","confidence":0.41,"workflow_bias":"unknown","reason":"The latest input looks workflow-related but the safer path is to clarify whether this should resume or refocus the workflow.","focusHint":"bias-aware resume and refocus offers","evidence":["the latest input is a short start-intent acknowledgement","recent discussion suggests a different workflow candidate"],"riskFlags":["ambiguous-approval","multiple_candidate_missions"]}'
102
289
 
103
- # Assist-mode accepted routing should enter the shared /cook flow before the primary agent sees the handoff.
290
+ # Router-mode startup routing should enter the shared /cook flow with natural-language metadata.
104
291
  ROUTE_ROOT="$TMPDIR/route-repo"
105
292
  ROUTE_SESSION="$TMPDIR/route-session.jsonl"
106
293
  ROUTE_PROMPT="$TMPDIR/route-driver-prompt.txt"
107
294
  ROUTE_ROUTING="$TMPDIR/route-routing.json"
108
295
  ROUTE_CLASSIFIER="$TMPDIR/route-classifier.json"
296
+ ROUTE_CONFIRMATION="$TMPDIR/route-confirmation.json"
109
297
  ROUTE_FALLBACK="$TMPDIR/route-fallback.json"
110
298
  mkdir -p "$ROUTE_ROOT"
111
299
  cd "$ROUTE_ROOT"
112
300
  git init -q
113
- write_session "$ROUTE_SESSION" "$ROUTE_ROOT" "$DISCUSSION"
301
+ write_session "$ROUTE_SESSION" "$ROUTE_ROOT" "$STARTUP_DISCUSSION"
114
302
 
115
303
  PI_COOK_TRIGGER_FALLBACK_PATH="$ROUTE_FALLBACK" \
116
304
  PI_COOK_TRIGGER_FALLBACK_SOURCE=interactive \
@@ -118,14 +306,15 @@ PI_COMPLETION_CONTEXT_PROPOSAL_ACTION=accept \
118
306
  PI_COMPLETION_DISABLE_CONTEXT_PROPOSAL_ANALYST=1 \
119
307
  PI_COMPLETION_SKIP_DRIVER_KICKOFF=1 \
120
308
  PI_COMPLETION_TEST_DRIVER_PROMPT_PATH="$ROUTE_PROMPT" \
121
- PI_COMPLETION_TEST_TRIGGER_CLASSIFIER_OUTPUT="$ROUTE_CLASSIFIER_OUTPUT" \
309
+ PI_COMPLETION_TEST_TRIGGER_CLASSIFIER_OUTPUT="$STARTUP_CLASSIFIER_OUTPUT" \
122
310
  PI_COMPLETION_TEST_TRIGGER_CLASSIFIER_SNAPSHOT_PATH="$ROUTE_CLASSIFIER" \
123
311
  PI_COMPLETION_TEST_TRIGGER_CONFIRM_ACTION=start \
312
+ PI_COMPLETION_TEST_TRIGGER_CONFIRMATION_PATH="$ROUTE_CONFIRMATION" \
124
313
  PI_COMPLETION_TEST_TRIGGER_ROUTING_PATH="$ROUTE_ROUTING" \
125
- pi --session "$ROUTE_SESSION" -e "$PKG_ROOT" -e "$FALLBACK_EXTENSION" -p "開始做" \
314
+ pi --session "$ROUTE_SESSION" -e "$PKG_ROOT" -e "$FALLBACK_EXTENSION" -p "$STARTUP_ROUTER_TEXT" \
126
315
  >"$TMPDIR/pi-cook-trigger-route.out" 2>"$TMPDIR/pi-cook-trigger-route.err"
127
316
 
128
- python3 - "$ROUTE_PROMPT" "$ROUTE_ROUTING" "$ROUTE_CLASSIFIER" "$ROUTE_FALLBACK" "$MISSION" "$TMPDIR/pi-cook-trigger-route.out" "$TMPDIR/pi-cook-trigger-route.err" <<'PY'
317
+ python3 - "$ROUTE_PROMPT" "$ROUTE_ROUTING" "$ROUTE_CLASSIFIER" "$ROUTE_CONFIRMATION" "$ROUTE_FALLBACK" "$STARTUP_MISSION" "$STARTUP_ROUTER_TEXT" "$TMPDIR/pi-cook-trigger-route.out" "$TMPDIR/pi-cook-trigger-route.err" <<'PY'
129
318
  import json
130
319
  import sys
131
320
  from pathlib import Path
@@ -133,31 +322,92 @@ from pathlib import Path
133
322
  prompt = Path(sys.argv[1]).read_text()
134
323
  routing = json.loads(Path(sys.argv[2]).read_text())
135
324
  classifier = json.loads(Path(sys.argv[3]).read_text())
136
- fallback = Path(sys.argv[4])
137
- mission = sys.argv[5]
138
- output = Path(sys.argv[6]).read_text() + Path(sys.argv[7]).read_text()
325
+ confirmation = json.loads(Path(sys.argv[4]).read_text())
326
+ fallback = Path(sys.argv[5])
327
+ mission = sys.argv[6]
328
+ trigger_text = sys.argv[7]
329
+ output = Path(sys.argv[8]).read_text() + Path(sys.argv[9]).read_text()
139
330
  profile = json.loads(Path('.agent/profile.json').read_text())
140
331
  state = json.loads(Path('.agent/state.json').read_text())
141
332
  plan = json.loads(Path('.agent/plan.json').read_text())
142
333
  active = json.loads(Path('.agent/active-slice.json').read_text())
143
334
 
144
- assert routing['action'] == 'routed_to_cook', 'accepted handoff should route into the shared /cook entry'
145
- assert routing['reason'] == 'accepted_takeover', 'accepted handoff should record the takeover reason'
146
- assert routing['classificationIntent'] == 'route_to_cook', 'accepted handoff should snapshot the route_to_cook classifier result'
147
- assert routing['focusHint'] == 'shared /cook entry handoff', 'accepted handoff should preserve the classifier focus hint'
148
- assert classifier['result']['status'] == 'classified', 'accepted handoff should snapshot a classified trigger result'
149
- assert classifier['result']['classification']['intent'] == 'route_to_cook', 'accepted handoff classifier snapshot should preserve route_to_cook intent'
150
- assert not fallback.exists(), 'accepted handoff should keep the original interactive input away from later fallback handlers'
151
- assert 'Start or continue the completion workflow for this repo.' in prompt, 'accepted handoff should queue the shared completion driver prompt'
152
- assert 'Canonical routing profile:' in prompt, 'accepted handoff driver prompt should keep the canonical routing metadata'
153
- assert state['mission_anchor'] == mission, 'accepted handoff should bootstrap canonical mission state through the shared /cook entry'
154
- assert plan['mission_anchor'] == mission, 'accepted handoff should bootstrap plan.json through the shared /cook entry'
155
- assert active['mission_anchor'] == mission, 'accepted handoff should bootstrap active-slice.json through the shared /cook entry'
156
- assert profile['task_type'] == 'completion-workflow', 'accepted handoff should keep the canonical task_type'
157
- assert 'Routing natural-language handoff into /cook.' in output, 'accepted handoff should notify that /cook took over'
335
+ assert routing['action'] == 'routed_to_cook', 'accepted startup handoff should route into the shared /cook entry'
336
+ assert routing['reason'] == 'accepted_takeover', 'accepted startup handoff should record the takeover reason'
337
+ assert routing['classificationDecision'] == 'offer_workflow', 'accepted startup handoff should snapshot the offer_workflow classifier result'
338
+ assert routing['workflowBias'] == 'startup', 'accepted startup handoff should preserve the startup routing bias'
339
+ assert routing['confirmationAction'] == 'start_workflow', 'accepted startup handoff should record the confirmed workflow action'
340
+ assert confirmation['title'] == 'Start a completion workflow from the recent discussion?', 'startup handoff should show the startup-specific workflow offer'
341
+ assert confirmation['actions'][0]['label'] == 'Start workflow', 'startup handoff should show the startup-specific primary action label'
342
+ assert classifier['result']['status'] == 'classified', 'accepted startup handoff should snapshot a classified trigger result'
343
+ assert classifier['result']['classification']['decision'] == 'offer_workflow', 'startup classifier snapshot should preserve offer_workflow'
344
+ assert classifier['result']['classification']['workflowBias'] == 'startup', 'startup classifier snapshot should preserve the startup bias'
345
+ assert not fallback.exists(), 'accepted startup handoff should keep the original interactive input away from later fallback handlers'
346
+ assert 'Start or continue the completion workflow for this repo.' in prompt, 'accepted startup handoff should queue the shared completion driver prompt'
347
+ assert 'Natural-language handoff metadata:' in prompt, 'accepted startup handoff should pass structured handoff metadata into the shared driver prompt'
348
+ assert '- preferred_routing_bias: startup' in prompt, 'accepted startup handoff should preserve the startup routing bias in the shared driver prompt'
349
+ assert f'- trigger_text: {trigger_text}' in prompt, 'accepted startup handoff should preserve the trigger text in the shared driver prompt'
350
+ assert state['mission_anchor'] == mission, 'accepted startup handoff should bootstrap canonical mission state through the shared /cook entry'
351
+ assert plan['mission_anchor'] == mission, 'accepted startup handoff should bootstrap plan.json through the shared /cook entry'
352
+ assert active['mission_anchor'] == mission, 'accepted startup handoff should bootstrap active-slice.json through the shared /cook entry'
353
+ assert profile['task_type'] == 'completion-workflow', 'accepted startup handoff should keep the canonical task_type'
354
+ assert 'Routing natural-language handoff into /cook.' in output, 'accepted startup handoff should notify that /cook took over'
355
+ PY
356
+
357
+ # Send as normal chat should replay the original start-intent message exactly once through the main chat path.
358
+ REPLAY_ROOT="$TMPDIR/replay-repo"
359
+ REPLAY_SESSION="$TMPDIR/replay-session.jsonl"
360
+ REPLAY_PROMPT="$TMPDIR/replay-driver-prompt.txt"
361
+ REPLAY_ROUTING="$TMPDIR/replay-routing.json"
362
+ REPLAY_CLASSIFIER="$TMPDIR/replay-classifier.json"
363
+ REPLAY_CONFIRMATION="$TMPDIR/replay-confirmation.json"
364
+ REPLAY_FALLBACK="$TMPDIR/replay-fallback.json"
365
+ mkdir -p "$REPLAY_ROOT"
366
+ cd "$REPLAY_ROOT"
367
+ git init -q
368
+ write_session "$REPLAY_SESSION" "$REPLAY_ROOT" "$STARTUP_DISCUSSION"
369
+
370
+ PI_COOK_TRIGGER_FALLBACK_PATH="$REPLAY_FALLBACK" \
371
+ PI_COOK_TRIGGER_FALLBACK_SOURCE=any \
372
+ PI_COMPLETION_TEST_DRIVER_PROMPT_PATH="$REPLAY_PROMPT" \
373
+ PI_COMPLETION_TEST_TRIGGER_CLASSIFIER_OUTPUT="$STARTUP_CLASSIFIER_OUTPUT" \
374
+ PI_COMPLETION_TEST_TRIGGER_CLASSIFIER_SNAPSHOT_PATH="$REPLAY_CLASSIFIER" \
375
+ PI_COMPLETION_TEST_TRIGGER_CONFIRM_ACTION=send_as_normal_chat \
376
+ PI_COMPLETION_TEST_TRIGGER_CONFIRMATION_PATH="$REPLAY_CONFIRMATION" \
377
+ PI_COMPLETION_TEST_TRIGGER_ROUTING_PATH="$REPLAY_ROUTING" \
378
+ pi --session "$REPLAY_SESSION" -e "$PKG_ROOT" -e "$FALLBACK_EXTENSION" -p "$STARTUP_ROUTER_TEXT" \
379
+ >"$TMPDIR/pi-cook-trigger-replay.out" 2>"$TMPDIR/pi-cook-trigger-replay.err"
380
+
381
+ python3 - "$REPLAY_ROUTING" "$REPLAY_CLASSIFIER" "$REPLAY_CONFIRMATION" "$REPLAY_FALLBACK" "$REPLAY_PROMPT" "$STARTUP_ROUTER_TEXT" "$TMPDIR/pi-cook-trigger-replay.out" "$TMPDIR/pi-cook-trigger-replay.err" <<'PY'
382
+ import json
383
+ import sys
384
+ from pathlib import Path
385
+
386
+ routing = json.loads(Path(sys.argv[1]).read_text())
387
+ classifier = json.loads(Path(sys.argv[2]).read_text())
388
+ confirmation = json.loads(Path(sys.argv[3]).read_text())
389
+ fallback = json.loads(Path(sys.argv[4]).read_text())
390
+ driver_prompt = Path(sys.argv[5])
391
+ trigger_text = sys.argv[6]
392
+ output = Path(sys.argv[7]).read_text() + Path(sys.argv[8]).read_text()
393
+
394
+ assert routing['action'] == 'handled', 'send as normal chat should intercept the original workflow offer turn'
395
+ assert routing['reason'] == 'user_sent_as_normal_chat', 'send as normal chat should record the explicit replay decision'
396
+ assert routing['classificationDecision'] == 'offer_workflow', 'send as normal chat should still snapshot the offer_workflow classifier result'
397
+ assert routing['workflowBias'] == 'startup', 'send as normal chat should preserve the original workflow bias'
398
+ assert routing['confirmationAction'] == 'send_as_normal_chat', 'send as normal chat should record the replay confirmation action'
399
+ assert routing['replayedToPrimaryAgent'] is True, 'send as normal chat should record that the original message was replayed to the primary agent'
400
+ assert routing['replayBypassMarkerApplied'] is True, 'send as normal chat should record that the replay used the router-bypass marker'
401
+ assert confirmation['actions'][1]['label'] == 'Send as normal chat', 'workflow offers should expose send as normal chat instead of keep chatting'
402
+ assert classifier['result']['classification']['workflowBias'] == 'startup', 'send as normal chat should preserve the startup bias in the classifier snapshot'
403
+ assert fallback['source'] == 'extension', 'send as normal chat should replay through an extension-originated bypass turn'
404
+ assert fallback['text'] == trigger_text, 'send as normal chat should replay the original prompt text exactly once'
405
+ assert not driver_prompt.exists(), 'send as normal chat should not queue a /cook driver prompt'
406
+ assert not Path('.agent').exists(), 'send as normal chat should not bootstrap canonical workflow state'
407
+ assert 'bypassed router interception' in output, 'send as normal chat should tell the user that the replay bypassed router interception'
158
408
  PY
159
409
 
160
- # Candidate natural-language prompts classified as normal prompts should continue to the main agent path.
410
+ # Router-mode normal prompts should still continue to the main agent path after per-turn classification.
161
411
  NORMAL_ROOT="$TMPDIR/normal-repo"
162
412
  NORMAL_SESSION="$TMPDIR/normal-session.jsonl"
163
413
  NORMAL_ROUTING="$TMPDIR/normal-routing.json"
@@ -167,7 +417,7 @@ NORMAL_PROMPT="$TMPDIR/normal-driver-prompt.txt"
167
417
  mkdir -p "$NORMAL_ROOT"
168
418
  cd "$NORMAL_ROOT"
169
419
  git init -q
170
- write_session "$NORMAL_SESSION" "$NORMAL_ROOT" "$DISCUSSION"
420
+ write_session "$NORMAL_SESSION" "$NORMAL_ROOT" "$STARTUP_DISCUSSION"
171
421
 
172
422
  PI_COOK_TRIGGER_FALLBACK_PATH="$NORMAL_FALLBACK" \
173
423
  PI_COOK_TRIGGER_FALLBACK_SOURCE=interactive \
@@ -175,10 +425,10 @@ PI_COMPLETION_TEST_DRIVER_PROMPT_PATH="$NORMAL_PROMPT" \
175
425
  PI_COMPLETION_TEST_TRIGGER_CLASSIFIER_OUTPUT="$NORMAL_CLASSIFIER_OUTPUT" \
176
426
  PI_COMPLETION_TEST_TRIGGER_CLASSIFIER_SNAPSHOT_PATH="$NORMAL_CLASSIFIER" \
177
427
  PI_COMPLETION_TEST_TRIGGER_ROUTING_PATH="$NORMAL_ROUTING" \
178
- pi --session "$NORMAL_SESSION" -e "$PKG_ROOT" -e "$FALLBACK_EXTENSION" -p "start by explaining the current repo state" \
428
+ pi --session "$NORMAL_SESSION" -e "$PKG_ROOT" -e "$FALLBACK_EXTENSION" -p "$NORMAL_ROUTER_TEXT" \
179
429
  >"$TMPDIR/pi-cook-trigger-normal.out" 2>"$TMPDIR/pi-cook-trigger-normal.err"
180
430
 
181
- python3 - "$NORMAL_ROUTING" "$NORMAL_CLASSIFIER" "$NORMAL_FALLBACK" "$NORMAL_PROMPT" <<'PY'
431
+ python3 - "$NORMAL_ROUTING" "$NORMAL_CLASSIFIER" "$NORMAL_FALLBACK" "$NORMAL_PROMPT" "$NORMAL_ROUTER_TEXT" <<'PY'
182
432
  import json
183
433
  import sys
184
434
  from pathlib import Path
@@ -187,18 +437,468 @@ routing = json.loads(Path(sys.argv[1]).read_text())
187
437
  classifier = json.loads(Path(sys.argv[2]).read_text())
188
438
  fallback = json.loads(Path(sys.argv[3]).read_text())
189
439
  driver_prompt = Path(sys.argv[4])
440
+ normal_text = sys.argv[5]
190
441
 
191
442
  assert routing['action'] == 'continue', 'normal prompts should pass through to the main agent path'
192
443
  assert routing['reason'] == 'classifier_normal_prompt', 'normal prompts should record the classifier_normal_prompt routing reason'
193
- assert routing['classificationIntent'] == 'normal_prompt', 'normal prompts should snapshot the normal_prompt classifier intent'
444
+ assert routing['classificationDecision'] == 'normal_prompt', 'normal prompts should snapshot the normal_prompt classifier decision'
445
+ assert routing['workflowBias'] == 'unknown', 'normal prompts should preserve the unknown workflow bias'
194
446
  assert classifier['result']['status'] == 'classified', 'normal prompt pass-through should snapshot a classified trigger result'
195
- assert classifier['result']['classification']['intent'] == 'normal_prompt', 'normal prompt pass-through should preserve the normal_prompt intent'
447
+ assert classifier['result']['classification']['decision'] == 'normal_prompt', 'normal prompt pass-through should preserve the normal_prompt decision'
196
448
  assert fallback['source'] == 'interactive', 'normal prompt pass-through should reach a later interactive fallback handler'
197
- assert fallback['text'] == 'start by explaining the current repo state', 'normal prompt pass-through should preserve the original prompt text'
449
+ assert fallback['text'] == normal_text, 'normal prompt pass-through should preserve the original prompt text'
198
450
  assert not Path('.agent').exists(), 'normal prompt pass-through should not bootstrap canonical workflow state'
199
451
  assert not driver_prompt.exists(), 'normal prompt pass-through should not queue a /cook driver prompt'
200
452
  PY
201
453
 
454
+ # Resume offers should keep the active workflow on the shared canonical resume path.
455
+ RESUME_ROOT="$TMPDIR/resume-repo"
456
+ RESUME_SESSION="$TMPDIR/resume-session.jsonl"
457
+ RESUME_PROMPT="$TMPDIR/resume-driver-prompt.txt"
458
+ RESUME_ROUTING="$TMPDIR/resume-routing.json"
459
+ RESUME_CLASSIFIER="$TMPDIR/resume-classifier.json"
460
+ RESUME_CONFIRMATION="$TMPDIR/resume-confirmation.json"
461
+ RESUME_FALLBACK="$TMPDIR/resume-fallback.json"
462
+ mkdir -p "$RESUME_ROOT"
463
+ cd "$RESUME_ROOT"
464
+ git init -q
465
+ write_completion_state "$RESUME_ROOT" "$ACTIVE_MISSION" continue false implement completion-implementer "Implement the active workflow slice"
466
+ write_session "$RESUME_SESSION" "$RESUME_ROOT" "$STARTUP_DISCUSSION"
467
+
468
+ PI_COOK_TRIGGER_FALLBACK_PATH="$RESUME_FALLBACK" \
469
+ PI_COOK_TRIGGER_FALLBACK_SOURCE=interactive \
470
+ PI_COMPLETION_SKIP_DRIVER_KICKOFF=1 \
471
+ PI_COMPLETION_TEST_DRIVER_PROMPT_PATH="$RESUME_PROMPT" \
472
+ PI_COMPLETION_TEST_TRIGGER_CLASSIFIER_OUTPUT="$RESUME_CLASSIFIER_OUTPUT" \
473
+ PI_COMPLETION_TEST_TRIGGER_CLASSIFIER_SNAPSHOT_PATH="$RESUME_CLASSIFIER" \
474
+ PI_COMPLETION_TEST_TRIGGER_CONFIRM_ACTION=start \
475
+ PI_COMPLETION_TEST_TRIGGER_CONFIRMATION_PATH="$RESUME_CONFIRMATION" \
476
+ PI_COMPLETION_TEST_TRIGGER_ROUTING_PATH="$RESUME_ROUTING" \
477
+ pi --session "$RESUME_SESSION" -e "$PKG_ROOT" -e "$FALLBACK_EXTENSION" -p "$RESUME_ROUTER_TEXT" \
478
+ >"$TMPDIR/pi-cook-trigger-resume.out" 2>"$TMPDIR/pi-cook-trigger-resume.err"
479
+
480
+ python3 - "$RESUME_PROMPT" "$RESUME_ROUTING" "$RESUME_CLASSIFIER" "$RESUME_CONFIRMATION" "$RESUME_FALLBACK" "$ACTIVE_MISSION" "$RESUME_ROUTER_TEXT" <<'PY'
481
+ import json
482
+ import sys
483
+ from pathlib import Path
484
+
485
+ prompt = Path(sys.argv[1]).read_text()
486
+ routing = json.loads(Path(sys.argv[2]).read_text())
487
+ classifier = json.loads(Path(sys.argv[3]).read_text())
488
+ confirmation = json.loads(Path(sys.argv[4]).read_text())
489
+ fallback = Path(sys.argv[5])
490
+ mission = sys.argv[6]
491
+ trigger_text = sys.argv[7]
492
+ state = json.loads(Path('.agent/state.json').read_text())
493
+
494
+ assert routing['action'] == 'routed_to_cook', 'resume handoff should route into the shared /cook entry'
495
+ assert routing['workflowBias'] == 'resume', 'resume handoff should preserve the resume routing bias'
496
+ assert routing['confirmationAction'] == 'start_workflow', 'resume handoff should record the workflow confirmation action'
497
+ assert confirmation['title'] == 'Resume the current completion workflow?', 'resume handoff should show the resume-specific workflow offer'
498
+ assert confirmation['actions'][0]['label'] == 'Resume workflow', 'resume handoff should show the resume-specific primary action label'
499
+ assert classifier['result']['classification']['workflowBias'] == 'resume', 'resume classifier snapshot should preserve the resume bias'
500
+ assert not fallback.exists(), 'resume handoff should keep the original interactive input away from later fallback handlers'
501
+ assert 'Resume the completion workflow from canonical state.' in prompt, 'resume handoff should queue the shared canonical resume prompt'
502
+ assert 'Natural-language handoff metadata:' in prompt, 'resume handoff should pass structured handoff metadata into the resume prompt'
503
+ assert '- preferred_routing_bias: resume' in prompt, 'resume handoff should preserve the resume bias in the resume prompt'
504
+ assert f'- trigger_text: {trigger_text}' in prompt, 'resume handoff should preserve the trigger text in the resume prompt'
505
+ assert state['mission_anchor'] == mission, 'resume handoff should preserve the active mission anchor'
506
+ PY
507
+
508
+ # Refocus offers should keep the chooser semantics inside the shared /cook entry before rewriting canonical state.
509
+ REFOCUS_ROOT="$TMPDIR/refocus-repo"
510
+ REFOCUS_SESSION="$TMPDIR/refocus-session.jsonl"
511
+ REFOCUS_PROMPT="$TMPDIR/refocus-driver-prompt.txt"
512
+ REFOCUS_ROUTING="$TMPDIR/refocus-routing.json"
513
+ REFOCUS_CLASSIFIER="$TMPDIR/refocus-classifier.json"
514
+ REFOCUS_CONFIRMATION="$TMPDIR/refocus-confirmation.json"
515
+ REFOCUS_CHOOSER="$TMPDIR/refocus-chooser.json"
516
+ mkdir -p "$REFOCUS_ROOT"
517
+ cd "$REFOCUS_ROOT"
518
+ git init -q
519
+ write_completion_state "$REFOCUS_ROOT" "$ACTIVE_MISSION" continue false implement completion-implementer "Implement the active workflow slice"
520
+ write_session "$REFOCUS_SESSION" "$REFOCUS_ROOT" "$REFOCUS_DISCUSSION"
521
+
522
+ PI_COMPLETION_CONTEXT_PROPOSAL_ACTION=accept \
523
+ PI_COMPLETION_DISABLE_CONTEXT_PROPOSAL_ANALYST=1 \
524
+ PI_COMPLETION_EXISTING_WORKFLOW_ACTION=refocus \
525
+ PI_COMPLETION_SKIP_DRIVER_KICKOFF=1 \
526
+ PI_COMPLETION_TEST_DRIVER_PROMPT_PATH="$REFOCUS_PROMPT" \
527
+ PI_COMPLETION_TEST_EXISTING_WORKFLOW_CHOOSER_PATH="$REFOCUS_CHOOSER" \
528
+ PI_COMPLETION_TEST_TRIGGER_CLASSIFIER_OUTPUT="$REFOCUS_CLASSIFIER_OUTPUT" \
529
+ PI_COMPLETION_TEST_TRIGGER_CLASSIFIER_SNAPSHOT_PATH="$REFOCUS_CLASSIFIER" \
530
+ PI_COMPLETION_TEST_TRIGGER_CONFIRM_ACTION=start \
531
+ PI_COMPLETION_TEST_TRIGGER_CONFIRMATION_PATH="$REFOCUS_CONFIRMATION" \
532
+ PI_COMPLETION_TEST_TRIGGER_ROUTING_PATH="$REFOCUS_ROUTING" \
533
+ pi --session "$REFOCUS_SESSION" -e "$PKG_ROOT" -p "$REFOCUS_ROUTER_TEXT" \
534
+ >"$TMPDIR/pi-cook-trigger-refocus.out" 2>"$TMPDIR/pi-cook-trigger-refocus.err"
535
+
536
+ python3 - "$REFOCUS_PROMPT" "$REFOCUS_ROUTING" "$REFOCUS_CLASSIFIER" "$REFOCUS_CONFIRMATION" "$REFOCUS_CHOOSER" "$REFOCUS_MISSION" "$REFOCUS_ROUTER_TEXT" <<'PY'
537
+ import json
538
+ import sys
539
+ from pathlib import Path
540
+
541
+ prompt = Path(sys.argv[1]).read_text()
542
+ routing = json.loads(Path(sys.argv[2]).read_text())
543
+ classifier = json.loads(Path(sys.argv[3]).read_text())
544
+ confirmation = json.loads(Path(sys.argv[4]).read_text())
545
+ chooser = json.loads(Path(sys.argv[5]).read_text())
546
+ mission = sys.argv[6]
547
+ trigger_text = sys.argv[7]
548
+ state = json.loads(Path('.agent/state.json').read_text())
549
+
550
+ assert routing['action'] == 'routed_to_cook', 'refocus handoff should route into the shared /cook entry'
551
+ assert routing['workflowBias'] == 'refocus', 'refocus handoff should preserve the refocus routing bias'
552
+ assert confirmation['title'] == 'Refocus the completion workflow from the recent discussion?', 'refocus handoff should show the refocus-specific workflow offer'
553
+ assert confirmation['actions'][0]['label'] == 'Refocus workflow', 'refocus handoff should show the refocus-specific primary action label'
554
+ assert classifier['result']['classification']['workflowBias'] == 'refocus', 'refocus classifier snapshot should preserve the refocus bias'
555
+ assert chooser['candidateMissions'][0] == mission, 'refocus chooser snapshot should preserve the replacement mission'
556
+ assert 'Start or continue the completion workflow for this repo.' in prompt, 'refocus handoff should queue the shared completion driver prompt'
557
+ assert 'Natural-language handoff metadata:' in prompt, 'refocus handoff should pass structured handoff metadata into the shared driver prompt'
558
+ assert '- preferred_routing_bias: refocus' in prompt, 'refocus handoff should preserve the refocus bias in the shared driver prompt'
559
+ assert f'- trigger_text: {trigger_text}' in prompt, 'refocus handoff should preserve the trigger text in the shared driver prompt'
560
+ assert state['mission_anchor'] == mission, 'refocus handoff should rewrite canonical mission state only through the shared /cook entry'
561
+ PY
562
+
563
+ # Next-round offers should start a new workflow round from recent discussion after a completed workflow.
564
+ NEXT_ROOT="$TMPDIR/next-round-repo"
565
+ NEXT_SESSION="$TMPDIR/next-round-session.jsonl"
566
+ NEXT_PROMPT="$TMPDIR/next-round-driver-prompt.txt"
567
+ NEXT_ROUTING="$TMPDIR/next-round-routing.json"
568
+ NEXT_CLASSIFIER="$TMPDIR/next-round-classifier.json"
569
+ NEXT_CONFIRMATION="$TMPDIR/next-round-confirmation.json"
570
+ mkdir -p "$NEXT_ROOT"
571
+ cd "$NEXT_ROOT"
572
+ git init -q
573
+ write_completion_state "$NEXT_ROOT" "$ACTIVE_MISSION" done true done null null
574
+ write_session "$NEXT_SESSION" "$NEXT_ROOT" "$NEXT_ROUND_DISCUSSION"
575
+
576
+ PI_COMPLETION_CONTEXT_PROPOSAL_ACTION=accept \
577
+ PI_COMPLETION_DISABLE_CONTEXT_PROPOSAL_ANALYST=1 \
578
+ PI_COMPLETION_SKIP_DRIVER_KICKOFF=1 \
579
+ PI_COMPLETION_TEST_DRIVER_PROMPT_PATH="$NEXT_PROMPT" \
580
+ PI_COMPLETION_TEST_TRIGGER_CLASSIFIER_OUTPUT="$NEXT_ROUND_CLASSIFIER_OUTPUT" \
581
+ PI_COMPLETION_TEST_TRIGGER_CLASSIFIER_SNAPSHOT_PATH="$NEXT_CLASSIFIER" \
582
+ PI_COMPLETION_TEST_TRIGGER_CONFIRM_ACTION=start \
583
+ PI_COMPLETION_TEST_TRIGGER_CONFIRMATION_PATH="$NEXT_CONFIRMATION" \
584
+ PI_COMPLETION_TEST_TRIGGER_ROUTING_PATH="$NEXT_ROUTING" \
585
+ pi --session "$NEXT_SESSION" -e "$PKG_ROOT" -p "$NEXT_ROUND_ROUTER_TEXT" \
586
+ >"$TMPDIR/pi-cook-trigger-next-round.out" 2>"$TMPDIR/pi-cook-trigger-next-round.err"
587
+
588
+ python3 - "$NEXT_PROMPT" "$NEXT_ROUTING" "$NEXT_CLASSIFIER" "$NEXT_CONFIRMATION" "$NEXT_ROUND_MISSION" "$NEXT_ROUND_ROUTER_TEXT" <<'PY'
589
+ import json
590
+ import sys
591
+ from pathlib import Path
592
+
593
+ prompt = Path(sys.argv[1]).read_text()
594
+ routing = json.loads(Path(sys.argv[2]).read_text())
595
+ classifier = json.loads(Path(sys.argv[3]).read_text())
596
+ confirmation = json.loads(Path(sys.argv[4]).read_text())
597
+ mission = sys.argv[5]
598
+ trigger_text = sys.argv[6]
599
+ state = json.loads(Path('.agent/state.json').read_text())
600
+
601
+ assert routing['action'] == 'routed_to_cook', 'next-round handoff should route into the shared /cook entry'
602
+ assert routing['workflowBias'] == 'next_round', 'next-round handoff should preserve the next_round routing bias'
603
+ assert confirmation['title'] == 'Start the next completion workflow round from the recent discussion?', 'next-round handoff should show the next-round-specific workflow offer'
604
+ assert confirmation['actions'][0]['label'] == 'Start next round', 'next-round handoff should show the next-round-specific primary action label'
605
+ assert classifier['result']['classification']['workflowBias'] == 'next_round', 'next-round classifier snapshot should preserve the next_round bias'
606
+ assert 'Natural-language handoff metadata:' in prompt, 'next-round handoff should pass structured handoff metadata into the shared driver prompt'
607
+ assert '- preferred_routing_bias: next_round' in prompt, 'next-round handoff should preserve the next_round bias in the shared driver prompt'
608
+ assert f'- trigger_text: {trigger_text}' in prompt, 'next-round handoff should preserve the trigger text in the shared driver prompt'
609
+ assert state['mission_anchor'] == mission, 'next-round handoff should start a new mission anchor through the shared /cook entry'
610
+ PY
611
+
612
+ # Unclear low-confidence commandless inputs should clarify instead of silently falling through.
613
+ UNCLEAR_ROOT="$TMPDIR/unclear-repo"
614
+ UNCLEAR_SESSION="$TMPDIR/unclear-session.jsonl"
615
+ UNCLEAR_PROMPT="$TMPDIR/unclear-driver-prompt.txt"
616
+ UNCLEAR_ROUTING="$TMPDIR/unclear-routing.json"
617
+ UNCLEAR_CLASSIFIER="$TMPDIR/unclear-classifier.json"
618
+ UNCLEAR_CLARIFICATION="$TMPDIR/unclear-clarification.json"
619
+ mkdir -p "$UNCLEAR_ROOT"
620
+ cd "$UNCLEAR_ROOT"
621
+ git init -q
622
+ write_completion_state "$UNCLEAR_ROOT" "$ACTIVE_MISSION" continue false implement completion-implementer "Implement the active workflow slice"
623
+ write_session "$UNCLEAR_SESSION" "$UNCLEAR_ROOT" "$REFOCUS_DISCUSSION"
624
+
625
+ PI_COMPLETION_CONTEXT_PROPOSAL_ACTION=accept \
626
+ PI_COMPLETION_DISABLE_CONTEXT_PROPOSAL_ANALYST=1 \
627
+ PI_COMPLETION_EXISTING_WORKFLOW_ACTION=refocus \
628
+ PI_COMPLETION_SKIP_DRIVER_KICKOFF=1 \
629
+ PI_COMPLETION_TEST_DRIVER_PROMPT_PATH="$UNCLEAR_PROMPT" \
630
+ PI_COMPLETION_TEST_EXISTING_WORKFLOW_CHOOSER_PATH="$REFOCUS_CHOOSER" \
631
+ PI_COMPLETION_TEST_TRIGGER_CLASSIFIER_OUTPUT="$UNCLEAR_CLASSIFIER_OUTPUT" \
632
+ PI_COMPLETION_TEST_TRIGGER_CLASSIFIER_SNAPSHOT_PATH="$UNCLEAR_CLASSIFIER" \
633
+ PI_COMPLETION_TEST_TRIGGER_CLARIFICATION_ACTION=refocus \
634
+ PI_COMPLETION_TEST_TRIGGER_CLARIFICATION_PATH="$UNCLEAR_CLARIFICATION" \
635
+ PI_COMPLETION_TEST_TRIGGER_ROUTING_PATH="$UNCLEAR_ROUTING" \
636
+ pi --session "$UNCLEAR_SESSION" -e "$PKG_ROOT" -p "$UNCLEAR_ROUTER_TEXT" \
637
+ >"$TMPDIR/pi-cook-trigger-unclear.out" 2>"$TMPDIR/pi-cook-trigger-unclear.err"
638
+
639
+ python3 - "$UNCLEAR_PROMPT" "$UNCLEAR_ROUTING" "$UNCLEAR_CLASSIFIER" "$UNCLEAR_CLARIFICATION" "$REFOCUS_MISSION" <<'PY'
640
+ import json
641
+ import sys
642
+ from pathlib import Path
643
+
644
+ prompt = Path(sys.argv[1]).read_text()
645
+ routing = json.loads(Path(sys.argv[2]).read_text())
646
+ classifier = json.loads(Path(sys.argv[3]).read_text())
647
+ clarification = json.loads(Path(sys.argv[4]).read_text())
648
+ mission = sys.argv[5]
649
+ state = json.loads(Path('.agent/state.json').read_text())
650
+
651
+ assert routing['action'] == 'routed_to_cook', 'unclear commandless routing should resolve through clarification instead of silently continuing'
652
+ assert routing['reason'] == 'clarification_resolved', 'unclear commandless routing should record clarification_resolved'
653
+ assert routing['classificationDecision'] == 'unclear', 'unclear routing should preserve the unclear classifier decision'
654
+ assert routing['clarificationAction'] == 'route_refocus', 'unclear routing should record the selected clarification action'
655
+ assert routing['clarificationSelectedBias'] == 'refocus', 'unclear routing should preserve the clarification-selected routing bias'
656
+ assert routing['clarificationGoal'] == mission, 'unclear routing should preserve the clarified mission goal'
657
+ assert classifier['result']['classification']['decision'] == 'unclear', 'unclear classifier snapshot should preserve the unclear decision'
658
+ assert clarification['title'] == 'Clarify how the completion workflow should proceed', 'unclear routing should show the clarification chooser'
659
+ assert clarification['actions'][0]['id'] == 'route_resume', 'unclear active workflow clarification should offer resume first'
660
+ assert clarification['actions'][1]['id'] == 'route_refocus', 'unclear active workflow clarification should offer refocus'
661
+ assert clarification['actions'][2]['id'] == 'send_as_normal_chat', 'unclear clarification should expose send as normal chat before cancel'
662
+ assert 'Natural-language handoff metadata:' in prompt, 'clarified commandless routing should still pass structured handoff metadata into the shared driver prompt'
663
+ assert '- clarification_selected_bias: refocus' in prompt, 'clarified commandless routing should carry clarification bias into the shared driver prompt'
664
+ assert f'- clarification_goal: {mission}' in prompt, 'clarified commandless routing should carry the clarified mission goal into the shared driver prompt'
665
+ assert state['mission_anchor'] == mission, 'clarified refocus routing should still rewrite canonical state only through the shared /cook entry'
666
+ PY
667
+
668
+ # Clarification send as normal chat should replay the original message exactly once without rewriting canonical workflow state.
669
+ UNCLEAR_REPLAY_ROOT="$TMPDIR/unclear-replay-repo"
670
+ UNCLEAR_REPLAY_SESSION="$TMPDIR/unclear-replay-session.jsonl"
671
+ UNCLEAR_REPLAY_PROMPT="$TMPDIR/unclear-replay-driver-prompt.txt"
672
+ UNCLEAR_REPLAY_ROUTING="$TMPDIR/unclear-replay-routing.json"
673
+ UNCLEAR_REPLAY_CLARIFICATION="$TMPDIR/unclear-replay-clarification.json"
674
+ UNCLEAR_REPLAY_FALLBACK="$TMPDIR/unclear-replay-fallback.json"
675
+ mkdir -p "$UNCLEAR_REPLAY_ROOT"
676
+ cd "$UNCLEAR_REPLAY_ROOT"
677
+ git init -q
678
+ write_completion_state "$UNCLEAR_REPLAY_ROOT" "$ACTIVE_MISSION" continue false implement completion-implementer "Implement the active workflow slice"
679
+ write_session "$UNCLEAR_REPLAY_SESSION" "$UNCLEAR_REPLAY_ROOT" "$REFOCUS_DISCUSSION"
680
+
681
+ PI_COOK_TRIGGER_FALLBACK_PATH="$UNCLEAR_REPLAY_FALLBACK" \
682
+ PI_COOK_TRIGGER_FALLBACK_SOURCE=any \
683
+ PI_COMPLETION_DISABLE_CONTEXT_PROPOSAL_ANALYST=1 \
684
+ PI_COMPLETION_SKIP_DRIVER_KICKOFF=1 \
685
+ PI_COMPLETION_TEST_DRIVER_PROMPT_PATH="$UNCLEAR_REPLAY_PROMPT" \
686
+ PI_COMPLETION_TEST_TRIGGER_CLASSIFIER_OUTPUT="$UNCLEAR_CLASSIFIER_OUTPUT" \
687
+ PI_COMPLETION_TEST_TRIGGER_CLARIFICATION_ACTION=send_as_normal_chat \
688
+ PI_COMPLETION_TEST_TRIGGER_CLARIFICATION_PATH="$UNCLEAR_REPLAY_CLARIFICATION" \
689
+ PI_COMPLETION_TEST_TRIGGER_ROUTING_PATH="$UNCLEAR_REPLAY_ROUTING" \
690
+ pi --session "$UNCLEAR_REPLAY_SESSION" -e "$PKG_ROOT" -e "$FALLBACK_EXTENSION" -p "$UNCLEAR_ROUTER_TEXT" \
691
+ >"$TMPDIR/pi-cook-trigger-unclear-replay.out" 2>"$TMPDIR/pi-cook-trigger-unclear-replay.err"
692
+
693
+ python3 - "$UNCLEAR_REPLAY_ROUTING" "$UNCLEAR_REPLAY_CLARIFICATION" "$UNCLEAR_REPLAY_FALLBACK" "$UNCLEAR_REPLAY_PROMPT" "$UNCLEAR_ROUTER_TEXT" "$ACTIVE_MISSION" "$TMPDIR/pi-cook-trigger-unclear-replay.out" "$TMPDIR/pi-cook-trigger-unclear-replay.err" <<'PY'
694
+ import json
695
+ import sys
696
+ from pathlib import Path
697
+
698
+ routing = json.loads(Path(sys.argv[1]).read_text())
699
+ clarification = json.loads(Path(sys.argv[2]).read_text())
700
+ fallback = json.loads(Path(sys.argv[3]).read_text())
701
+ driver_prompt = Path(sys.argv[4])
702
+ trigger_text = sys.argv[5]
703
+ mission = sys.argv[6]
704
+ output = Path(sys.argv[7]).read_text() + Path(sys.argv[8]).read_text()
705
+ state = json.loads(Path('.agent/state.json').read_text())
706
+
707
+ assert routing['action'] == 'handled', 'clarification send as normal chat should keep the original intercepted turn handled'
708
+ assert routing['reason'] == 'user_sent_as_normal_chat_after_clarification', 'clarification send as normal chat should record the explicit replay reason'
709
+ assert routing['clarificationAction'] == 'send_as_normal_chat', 'clarification send as normal chat should record the replay clarification action'
710
+ assert routing['replayedToPrimaryAgent'] is True, 'clarification send as normal chat should record that the original message was replayed'
711
+ assert routing['replayBypassMarkerApplied'] is True, 'clarification send as normal chat should record the router-bypass replay marker'
712
+ assert clarification['actions'][2]['label'] == 'Send as normal chat', 'clarification UI should expose send as normal chat instead of keep chatting'
713
+ assert fallback['source'] == 'extension', 'clarification send as normal chat should replay through an extension-originated bypass turn'
714
+ assert fallback['text'] == trigger_text, 'clarification send as normal chat should replay the original prompt text exactly once'
715
+ assert not driver_prompt.exists(), 'clarification send as normal chat should not queue a /cook driver prompt'
716
+ assert state['mission_anchor'] == mission, 'clarification send as normal chat should keep canonical workflow state unchanged'
717
+ assert 'bypassed router interception' in output, 'clarification send as normal chat should tell the user that the replay bypassed router interception'
718
+ PY
719
+
720
+ # Clarification cancel should fail closed without replaying the original message or rewriting canonical state.
721
+ UNCLEAR_CANCEL_ROOT="$TMPDIR/unclear-cancel-repo"
722
+ UNCLEAR_CANCEL_SESSION="$TMPDIR/unclear-cancel-session.jsonl"
723
+ UNCLEAR_CANCEL_PROMPT="$TMPDIR/unclear-cancel-driver-prompt.txt"
724
+ UNCLEAR_CANCEL_ROUTING="$TMPDIR/unclear-cancel-routing.json"
725
+ UNCLEAR_CANCEL_CLARIFICATION="$TMPDIR/unclear-cancel-clarification.json"
726
+ mkdir -p "$UNCLEAR_CANCEL_ROOT"
727
+ cd "$UNCLEAR_CANCEL_ROOT"
728
+ git init -q
729
+ write_completion_state "$UNCLEAR_CANCEL_ROOT" "$ACTIVE_MISSION" continue false implement completion-implementer "Implement the active workflow slice"
730
+ write_session "$UNCLEAR_CANCEL_SESSION" "$UNCLEAR_CANCEL_ROOT" "$REFOCUS_DISCUSSION"
731
+
732
+ PI_COMPLETION_DISABLE_CONTEXT_PROPOSAL_ANALYST=1 \
733
+ PI_COMPLETION_SKIP_DRIVER_KICKOFF=1 \
734
+ PI_COMPLETION_TEST_DRIVER_PROMPT_PATH="$UNCLEAR_CANCEL_PROMPT" \
735
+ PI_COMPLETION_TEST_TRIGGER_CLASSIFIER_OUTPUT="$UNCLEAR_CLASSIFIER_OUTPUT" \
736
+ PI_COMPLETION_TEST_TRIGGER_CLARIFICATION_ACTION=cancel \
737
+ PI_COMPLETION_TEST_TRIGGER_CLARIFICATION_PATH="$UNCLEAR_CANCEL_CLARIFICATION" \
738
+ PI_COMPLETION_TEST_TRIGGER_ROUTING_PATH="$UNCLEAR_CANCEL_ROUTING" \
739
+ pi --session "$UNCLEAR_CANCEL_SESSION" -e "$PKG_ROOT" -p "$UNCLEAR_ROUTER_TEXT" \
740
+ >"$TMPDIR/pi-cook-trigger-unclear-cancel.out" 2>"$TMPDIR/pi-cook-trigger-unclear-cancel.err"
741
+
742
+ python3 - "$UNCLEAR_CANCEL_ROUTING" "$UNCLEAR_CANCEL_PROMPT" "$TMPDIR/pi-cook-trigger-unclear-cancel.out" "$TMPDIR/pi-cook-trigger-unclear-cancel.err" "$ACTIVE_MISSION" <<'PY'
743
+ import json
744
+ import sys
745
+ from pathlib import Path
746
+
747
+ routing = json.loads(Path(sys.argv[1]).read_text())
748
+ driver_prompt = Path(sys.argv[2])
749
+ output = Path(sys.argv[3]).read_text() + Path(sys.argv[4]).read_text()
750
+ mission = sys.argv[5]
751
+ state = json.loads(Path('.agent/state.json').read_text())
752
+
753
+ assert routing['action'] == 'handled', 'clarification cancel should fail closed instead of continuing to the main agent'
754
+ assert routing['reason'] == 'user_cancelled_clarification', 'clarification cancel should record the user_cancelled_clarification reason'
755
+ assert routing['clarificationAction'] == 'cancel', 'clarification cancel should record the cancel action'
756
+ assert not driver_prompt.exists(), 'clarification cancel should not queue a /cook driver prompt'
757
+ assert 'rerun /cook explicitly' in output, 'clarification cancel should direct the user back to explicit /cook when needed'
758
+ assert state['mission_anchor'] == mission, 'clarification cancel should keep canonical state unchanged'
759
+ PY
760
+
761
+ # Explicit adoption of a recent assistant plan should carry adopted context into the shared /cook entry.
762
+ ADOPTED_PLAN_ROOT="$TMPDIR/adopted-plan-repo"
763
+ ADOPTED_PLAN_SESSION="$TMPDIR/adopted-plan-session.jsonl"
764
+ ADOPTED_PLAN_PROMPT="$TMPDIR/adopted-plan-driver-prompt.txt"
765
+ ADOPTED_PLAN_ROUTING="$TMPDIR/adopted-plan-routing.json"
766
+ mkdir -p "$ADOPTED_PLAN_ROOT"
767
+ cd "$ADOPTED_PLAN_ROOT"
768
+ git init -q
769
+ write_mixed_session "$ADOPTED_PLAN_SESSION" "$ADOPTED_PLAN_ROOT" "$STARTUP_DISCUSSION" "$STARTUP_DISCUSSION"
770
+
771
+ PI_COMPLETION_CONTEXT_PROPOSAL_ACTION=accept \
772
+ PI_COMPLETION_DISABLE_CONTEXT_PROPOSAL_ANALYST=1 \
773
+ PI_COMPLETION_SKIP_DRIVER_KICKOFF=1 \
774
+ PI_COMPLETION_TEST_DRIVER_PROMPT_PATH="$ADOPTED_PLAN_PROMPT" \
775
+ PI_COMPLETION_TEST_TRIGGER_CLASSIFIER_OUTPUT="$STARTUP_CLASSIFIER_OUTPUT" \
776
+ PI_COMPLETION_TEST_TRIGGER_CONFIRM_ACTION=start \
777
+ PI_COMPLETION_TEST_TRIGGER_ROUTING_PATH="$ADOPTED_PLAN_ROUTING" \
778
+ pi --session "$ADOPTED_PLAN_SESSION" -e "$PKG_ROOT" -p "就照剛剛那份方案做" \
779
+ >"$TMPDIR/pi-cook-trigger-adopted-plan.out" 2>"$TMPDIR/pi-cook-trigger-adopted-plan.err"
780
+
781
+ python3 - "$ADOPTED_PLAN_PROMPT" "$ADOPTED_PLAN_ROUTING" <<'PY'
782
+ import json
783
+ import sys
784
+ from pathlib import Path
785
+
786
+ prompt = Path(sys.argv[1]).read_text()
787
+ routing = json.loads(Path(sys.argv[2]).read_text())
788
+
789
+ assert routing['adoptedArtifactKind'] == 'recent_plan', 'explicit adoption of the recent assistant plan should surface as a recent_plan artifact'
790
+ assert routing['adoptedArtifactBasis'] == 'explicit_user_adoption', 'adopted recent plans should preserve the explicit_user_adoption basis'
791
+ assert '- adopted_artifact_kind: recent_plan' in prompt, 'adopted recent plans should be forwarded into the shared /cook entry metadata'
792
+ assert '- adopted_artifact_basis: explicit_user_adoption' in prompt, 'adopted recent plans should preserve their trust-boundary basis in the shared driver prompt'
793
+ assert '- adopted_artifact_title: latest discussed assistant plan' in prompt, 'adopted recent plans should include the adopted artifact title in the shared driver prompt'
794
+ assert '- adopted_artifact_preview:' in prompt, 'adopted recent plans should include preview context in the shared driver prompt'
795
+ PY
796
+
797
+ # Explicit adoption of a repo markdown artifact should carry the path into the shared /cook entry.
798
+ ADOPTED_MD_ROOT="$TMPDIR/adopted-md-repo"
799
+ ADOPTED_MD_SESSION="$TMPDIR/adopted-md-session.jsonl"
800
+ ADOPTED_MD_PROMPT="$TMPDIR/adopted-md-driver-prompt.txt"
801
+ ADOPTED_MD_ROUTING="$TMPDIR/adopted-md-routing.json"
802
+ mkdir -p "$ADOPTED_MD_ROOT/docs"
803
+ cd "$ADOPTED_MD_ROOT"
804
+ git init -q
805
+ cat > docs/plan.md <<'EOF'
806
+ # Plan
807
+
808
+ Mission: Route natural-language handoff into the shared /cook entry.
809
+ EOF
810
+ write_session "$ADOPTED_MD_SESSION" "$ADOPTED_MD_ROOT" "$STARTUP_DISCUSSION"
811
+
812
+ PI_COMPLETION_CONTEXT_PROPOSAL_ACTION=accept \
813
+ PI_COMPLETION_DISABLE_CONTEXT_PROPOSAL_ANALYST=1 \
814
+ PI_COMPLETION_SKIP_DRIVER_KICKOFF=1 \
815
+ PI_COMPLETION_TEST_DRIVER_PROMPT_PATH="$ADOPTED_MD_PROMPT" \
816
+ PI_COMPLETION_TEST_TRIGGER_CLASSIFIER_OUTPUT="$STARTUP_CLASSIFIER_OUTPUT" \
817
+ PI_COMPLETION_TEST_TRIGGER_CONFIRM_ACTION=start \
818
+ PI_COMPLETION_TEST_TRIGGER_ROUTING_PATH="$ADOPTED_MD_ROUTING" \
819
+ pi --session "$ADOPTED_MD_SESSION" -e "$PKG_ROOT" -p "照 docs/plan.md 開始" \
820
+ >"$TMPDIR/pi-cook-trigger-adopted-md.out" 2>"$TMPDIR/pi-cook-trigger-adopted-md.err"
821
+
822
+ python3 - "$ADOPTED_MD_PROMPT" "$ADOPTED_MD_ROUTING" <<'PY'
823
+ import json
824
+ import sys
825
+ from pathlib import Path
826
+
827
+ prompt = Path(sys.argv[1]).read_text()
828
+ routing = json.loads(Path(sys.argv[2]).read_text())
829
+
830
+ assert routing['adoptedArtifactKind'] == 'repo_markdown', 'explicit adoption of a repo markdown artifact should surface as repo_markdown'
831
+ assert routing['adoptedArtifactPath'] == 'docs/plan.md', 'repo markdown adoption should preserve the adopted path'
832
+ assert '- adopted_artifact_kind: repo_markdown' in prompt, 'repo markdown adoption should be forwarded into the shared /cook entry metadata'
833
+ assert '- adopted_artifact_path: docs/plan.md' in prompt, 'repo markdown adoption should preserve the adopted path in the shared driver prompt'
834
+ PY
835
+
836
+ # An unresolved explicit repo markdown path must fail closed instead of falling back to recent-plan metadata.
837
+ MISSING_MD_ROOT="$TMPDIR/missing-md-repo"
838
+ MISSING_MD_SESSION="$TMPDIR/missing-md-session.jsonl"
839
+ MISSING_MD_PROMPT="$TMPDIR/missing-md-driver-prompt.txt"
840
+ MISSING_MD_ROUTING="$TMPDIR/missing-md-routing.json"
841
+ mkdir -p "$MISSING_MD_ROOT"
842
+ cd "$MISSING_MD_ROOT"
843
+ git init -q
844
+ write_mixed_session "$MISSING_MD_SESSION" "$MISSING_MD_ROOT" "$STARTUP_DISCUSSION" "$STARTUP_DISCUSSION"
845
+
846
+ PI_COMPLETION_CONTEXT_PROPOSAL_ACTION=accept \
847
+ PI_COMPLETION_DISABLE_CONTEXT_PROPOSAL_ANALYST=1 \
848
+ PI_COMPLETION_SKIP_DRIVER_KICKOFF=1 \
849
+ PI_COMPLETION_TEST_DRIVER_PROMPT_PATH="$MISSING_MD_PROMPT" \
850
+ PI_COMPLETION_TEST_TRIGGER_CLASSIFIER_OUTPUT="$STARTUP_CLASSIFIER_OUTPUT" \
851
+ PI_COMPLETION_TEST_TRIGGER_CONFIRM_ACTION=start \
852
+ PI_COMPLETION_TEST_TRIGGER_ROUTING_PATH="$MISSING_MD_ROUTING" \
853
+ pi --session "$MISSING_MD_SESSION" -e "$PKG_ROOT" -p "use docs/missing.md" \
854
+ >"$TMPDIR/pi-cook-trigger-missing-md.out" 2>"$TMPDIR/pi-cook-trigger-missing-md.err"
855
+
856
+ python3 - "$MISSING_MD_PROMPT" "$MISSING_MD_ROUTING" <<'PY'
857
+ import json
858
+ import sys
859
+ from pathlib import Path
860
+
861
+ prompt = Path(sys.argv[1]).read_text()
862
+ routing = json.loads(Path(sys.argv[2]).read_text())
863
+
864
+ assert routing['adoptedArtifactKind'] is None, 'unresolved explicit repo markdown paths must not fall back to recent_plan adoption metadata'
865
+ assert routing['adoptedArtifactBasis'] is None, 'unresolved explicit repo markdown paths must not preserve adopted-artifact trust metadata'
866
+ assert '- adopted_artifact_kind:' not in prompt, 'unresolved explicit repo markdown paths must not be elevated into the shared /cook handoff metadata'
867
+ assert '- adopted_artifact_basis:' not in prompt, 'unresolved explicit repo markdown paths must not preserve adopted-artifact basis metadata in the shared driver prompt'
868
+ PY
869
+
870
+ # Unadopted assistant plans should remain background only and should not be elevated into handoff context.
871
+ UNADOPTED_PLAN_ROOT="$TMPDIR/unadopted-plan-repo"
872
+ UNADOPTED_PLAN_SESSION="$TMPDIR/unadopted-plan-session.jsonl"
873
+ UNADOPTED_PLAN_PROMPT="$TMPDIR/unadopted-plan-driver-prompt.txt"
874
+ UNADOPTED_PLAN_ROUTING="$TMPDIR/unadopted-plan-routing.json"
875
+ mkdir -p "$UNADOPTED_PLAN_ROOT"
876
+ cd "$UNADOPTED_PLAN_ROOT"
877
+ git init -q
878
+ write_mixed_session "$UNADOPTED_PLAN_SESSION" "$UNADOPTED_PLAN_ROOT" "$STARTUP_DISCUSSION" "$STARTUP_DISCUSSION"
879
+
880
+ PI_COMPLETION_CONTEXT_PROPOSAL_ACTION=accept \
881
+ PI_COMPLETION_DISABLE_CONTEXT_PROPOSAL_ANALYST=1 \
882
+ PI_COMPLETION_SKIP_DRIVER_KICKOFF=1 \
883
+ PI_COMPLETION_TEST_DRIVER_PROMPT_PATH="$UNADOPTED_PLAN_PROMPT" \
884
+ PI_COMPLETION_TEST_TRIGGER_CLASSIFIER_OUTPUT="$STARTUP_CLASSIFIER_OUTPUT" \
885
+ PI_COMPLETION_TEST_TRIGGER_CONFIRM_ACTION=start \
886
+ PI_COMPLETION_TEST_TRIGGER_ROUTING_PATH="$UNADOPTED_PLAN_ROUTING" \
887
+ pi --session "$UNADOPTED_PLAN_SESSION" -e "$PKG_ROOT" -p "開始做" \
888
+ >"$TMPDIR/pi-cook-trigger-unadopted-plan.out" 2>"$TMPDIR/pi-cook-trigger-unadopted-plan.err"
889
+
890
+ python3 - "$UNADOPTED_PLAN_PROMPT" "$UNADOPTED_PLAN_ROUTING" <<'PY'
891
+ import json
892
+ import sys
893
+ from pathlib import Path
894
+
895
+ prompt = Path(sys.argv[1]).read_text()
896
+ routing = json.loads(Path(sys.argv[2]).read_text())
897
+
898
+ assert routing['adoptedArtifactKind'] is None, 'unadopted assistant plans must stay background-only in routing snapshots'
899
+ assert '- adopted_artifact_kind:' not in prompt, 'unadopted assistant plans must not be elevated into the shared /cook handoff metadata'
900
+ PY
901
+
202
902
  # Extension-originated turns should bypass natural-language routing and continue unchanged.
203
903
  EXT_ROOT="$TMPDIR/extension-source-repo"
204
904
  EXT_ROUTING="$TMPDIR/extension-source-routing.json"
@@ -212,6 +912,7 @@ git init -q
212
912
  PI_COOK_TRIGGER_EXTENSION_SOURCE_TEXT="開始做" \
213
913
  PI_COOK_TRIGGER_FALLBACK_PATH="$EXT_FALLBACK" \
214
914
  PI_COOK_TRIGGER_FALLBACK_SOURCE=extension \
915
+ PI_COMPLETION_TEST_TRIGGER_MODE=assist \
215
916
  PI_COMPLETION_TEST_DRIVER_PROMPT_PATH="$EXT_PROMPT" \
216
917
  PI_COMPLETION_TEST_TRIGGER_CLASSIFIER_SNAPSHOT_PATH="$EXT_CLASSIFIER" \
217
918
  PI_COMPLETION_TEST_TRIGGER_ROUTING_PATH="$EXT_ROUTING" \
@@ -245,7 +946,7 @@ COOK_PROMPT="$TMPDIR/explicit-cook-driver-prompt.txt"
245
946
  mkdir -p "$COOK_ROOT"
246
947
  cd "$COOK_ROOT"
247
948
  git init -q
248
- write_session "$COOK_SESSION" "$COOK_ROOT" "$DISCUSSION"
949
+ write_session "$COOK_SESSION" "$COOK_ROOT" "$STARTUP_DISCUSSION"
249
950
 
250
951
  PI_COMPLETION_CONTEXT_PROPOSAL_ACTION=accept \
251
952
  PI_COMPLETION_DISABLE_CONTEXT_PROPOSAL_ANALYST=1 \
@@ -255,7 +956,7 @@ PI_COMPLETION_TEST_TRIGGER_ROUTING_PATH="$COOK_ROUTING" \
255
956
  pi --session "$COOK_SESSION" -e "$PKG_ROOT" -p "/cook" \
256
957
  >"$TMPDIR/pi-cook-trigger-explicit-cook.out" 2>"$TMPDIR/pi-cook-trigger-explicit-cook.err"
257
958
 
258
- python3 - "$COOK_PROMPT" "$COOK_ROUTING" "$MISSION" <<'PY'
959
+ python3 - "$COOK_PROMPT" "$COOK_ROUTING" "$STARTUP_MISSION" <<'PY'
259
960
  import json
260
961
  import sys
261
962
  from pathlib import Path
@@ -270,45 +971,152 @@ assert not routing.exists(), 'explicit /cook should bypass the natural-language
270
971
  assert state['mission_anchor'] == mission, 'explicit /cook should keep the existing startup behavior'
271
972
  PY
272
973
 
273
- # Classifier timeout/failure should conservatively stop the original input from reaching the main agent.
974
+ # Classifier timeout should surface recovery UI and allow explicit send-as-normal-chat replay.
274
975
  TIMEOUT_ROOT="$TMPDIR/timeout-repo"
275
976
  TIMEOUT_SESSION="$TMPDIR/timeout-session.jsonl"
276
977
  TIMEOUT_ROUTING="$TMPDIR/timeout-routing.json"
277
978
  TIMEOUT_CLASSIFIER="$TMPDIR/timeout-classifier.json"
278
979
  TIMEOUT_FALLBACK="$TMPDIR/timeout-fallback.json"
279
980
  TIMEOUT_PROMPT="$TMPDIR/timeout-driver-prompt.txt"
981
+ TIMEOUT_RECOVERY="$TMPDIR/timeout-recovery.json"
280
982
  mkdir -p "$TIMEOUT_ROOT"
281
983
  cd "$TIMEOUT_ROOT"
282
984
  git init -q
283
- write_session "$TIMEOUT_SESSION" "$TIMEOUT_ROOT" "$DISCUSSION"
985
+ write_session "$TIMEOUT_SESSION" "$TIMEOUT_ROOT" "$STARTUP_DISCUSSION"
284
986
 
285
987
  PI_COOK_TRIGGER_FALLBACK_PATH="$TIMEOUT_FALLBACK" \
286
- PI_COOK_TRIGGER_FALLBACK_SOURCE=interactive \
988
+ PI_COOK_TRIGGER_FALLBACK_SOURCE=any \
287
989
  PI_COMPLETION_TEST_DRIVER_PROMPT_PATH="$TIMEOUT_PROMPT" \
288
990
  PI_COMPLETION_TEST_TRIGGER_CLASSIFIER_FAILURE=timeout \
289
991
  PI_COMPLETION_TEST_TRIGGER_CLASSIFIER_SNAPSHOT_PATH="$TIMEOUT_CLASSIFIER" \
992
+ PI_COMPLETION_TEST_TRIGGER_RECOVERY_ACTION=send_as_normal_chat \
993
+ PI_COMPLETION_TEST_TRIGGER_RECOVERY_PATH="$TIMEOUT_RECOVERY" \
290
994
  PI_COMPLETION_TEST_TRIGGER_ROUTING_PATH="$TIMEOUT_ROUTING" \
291
- pi --session "$TIMEOUT_SESSION" -e "$PKG_ROOT" -e "$FALLBACK_EXTENSION" -p "開始做" \
995
+ pi --session "$TIMEOUT_SESSION" -e "$PKG_ROOT" -e "$FALLBACK_EXTENSION" -p "$STARTUP_ROUTER_TEXT" \
292
996
  >"$TMPDIR/pi-cook-trigger-timeout.out" 2>"$TMPDIR/pi-cook-trigger-timeout.err"
293
997
 
294
- python3 - "$TIMEOUT_ROUTING" "$TIMEOUT_CLASSIFIER" "$TIMEOUT_FALLBACK" "$TIMEOUT_PROMPT" "$TMPDIR/pi-cook-trigger-timeout.out" "$TMPDIR/pi-cook-trigger-timeout.err" <<'PY'
998
+ python3 - "$TIMEOUT_ROUTING" "$TIMEOUT_CLASSIFIER" "$TIMEOUT_FALLBACK" "$TIMEOUT_PROMPT" "$TIMEOUT_RECOVERY" "$STARTUP_ROUTER_TEXT" "$TMPDIR/pi-cook-trigger-timeout.out" "$TMPDIR/pi-cook-trigger-timeout.err" <<'PY'
295
999
  import json
296
1000
  import sys
297
1001
  from pathlib import Path
298
1002
 
299
1003
  routing = json.loads(Path(sys.argv[1]).read_text())
300
1004
  classifier = json.loads(Path(sys.argv[2]).read_text())
301
- fallback = Path(sys.argv[3])
1005
+ fallback = json.loads(Path(sys.argv[3]).read_text())
302
1006
  driver_prompt = Path(sys.argv[4])
303
- output = Path(sys.argv[5]).read_text() + Path(sys.argv[6]).read_text()
1007
+ recovery = json.loads(Path(sys.argv[5]).read_text())
1008
+ trigger_text = sys.argv[6]
1009
+ output = Path(sys.argv[7]).read_text() + Path(sys.argv[8]).read_text()
304
1010
 
305
- assert routing['action'] == 'handled', 'classifier timeout should conservatively handle the original input'
306
- assert routing['reason'] == 'classifier_timeout', 'classifier timeout should record the conservative timeout reason'
1011
+ assert routing['action'] == 'handled', 'classifier timeout recovery should keep the original intercepted turn handled'
1012
+ assert routing['reason'] == 'classifier_timeout_send_as_normal_chat', 'classifier timeout recovery should record the explicit replay outcome'
1013
+ assert routing['recoveryAction'] == 'send_as_normal_chat', 'classifier timeout recovery should record the chosen recovery action'
1014
+ assert routing['replayedToPrimaryAgent'] is True, 'classifier timeout recovery should record that the original message was replayed'
1015
+ assert routing['replayBypassMarkerApplied'] is True, 'classifier timeout recovery should record the router-bypass replay marker'
307
1016
  assert classifier['result']['status'] == 'timeout', 'classifier timeout should snapshot the timeout result'
308
- assert not fallback.exists(), 'classifier timeout should keep the original interactive input away from later fallback handlers'
309
- assert not driver_prompt.exists(), 'classifier timeout should not queue a /cook driver prompt'
310
- assert not Path('.agent').exists(), 'classifier timeout should not bootstrap canonical workflow state'
311
- assert 'run /cook explicitly' in output, 'classifier timeout should guide the user toward explicit /cook handoff'
1017
+ assert recovery['actions'][0]['id'] == 'retry_routing', 'classifier timeout recovery should offer retry routing first'
1018
+ assert recovery['actions'][1]['id'] == 'send_as_normal_chat', 'classifier timeout recovery should offer send as normal chat'
1019
+ assert fallback['source'] == 'extension', 'classifier timeout send as normal chat should replay through an extension-originated bypass turn'
1020
+ assert fallback['text'] == trigger_text, 'classifier timeout send as normal chat should replay the original prompt text exactly once'
1021
+ assert not driver_prompt.exists(), 'classifier timeout send as normal chat should not queue a /cook driver prompt'
1022
+ assert not Path('.agent').exists(), 'classifier timeout send as normal chat should not bootstrap canonical workflow state'
1023
+ assert 'bypassed router interception' in output, 'classifier timeout recovery should tell the user that the replay bypassed router interception'
1024
+ PY
1025
+
1026
+ # Invalid classifier output should surface recovery UI and stay fail-closed on cancel.
1027
+ INVALID_ROOT="$TMPDIR/invalid-output-repo"
1028
+ INVALID_SESSION="$TMPDIR/invalid-output-session.jsonl"
1029
+ INVALID_ROUTING="$TMPDIR/invalid-output-routing.json"
1030
+ INVALID_CLASSIFIER="$TMPDIR/invalid-output-classifier.json"
1031
+ INVALID_FALLBACK="$TMPDIR/invalid-output-fallback.json"
1032
+ INVALID_PROMPT="$TMPDIR/invalid-output-driver-prompt.txt"
1033
+ INVALID_RECOVERY="$TMPDIR/invalid-output-recovery.json"
1034
+ mkdir -p "$INVALID_ROOT"
1035
+ cd "$INVALID_ROOT"
1036
+ git init -q
1037
+ write_session "$INVALID_SESSION" "$INVALID_ROOT" "$STARTUP_DISCUSSION"
1038
+
1039
+ PI_COOK_TRIGGER_FALLBACK_PATH="$INVALID_FALLBACK" \
1040
+ PI_COOK_TRIGGER_FALLBACK_SOURCE=any \
1041
+ PI_COMPLETION_TEST_DRIVER_PROMPT_PATH="$INVALID_PROMPT" \
1042
+ PI_COMPLETION_TEST_TRIGGER_CLASSIFIER_FAILURE=invalid_output \
1043
+ PI_COMPLETION_TEST_TRIGGER_CLASSIFIER_SNAPSHOT_PATH="$INVALID_CLASSIFIER" \
1044
+ PI_COMPLETION_TEST_TRIGGER_RECOVERY_ACTION=cancel \
1045
+ PI_COMPLETION_TEST_TRIGGER_RECOVERY_PATH="$INVALID_RECOVERY" \
1046
+ PI_COMPLETION_TEST_TRIGGER_ROUTING_PATH="$INVALID_ROUTING" \
1047
+ pi --session "$INVALID_SESSION" -e "$PKG_ROOT" -e "$FALLBACK_EXTENSION" -p "$STARTUP_ROUTER_TEXT" \
1048
+ >"$TMPDIR/pi-cook-trigger-invalid.out" 2>"$TMPDIR/pi-cook-trigger-invalid.err"
1049
+
1050
+ python3 - "$INVALID_ROUTING" "$INVALID_CLASSIFIER" "$INVALID_FALLBACK" "$INVALID_PROMPT" "$INVALID_RECOVERY" "$TMPDIR/pi-cook-trigger-invalid.out" "$TMPDIR/pi-cook-trigger-invalid.err" <<'PY'
1051
+ import json
1052
+ import sys
1053
+ from pathlib import Path
1054
+
1055
+ routing = json.loads(Path(sys.argv[1]).read_text())
1056
+ classifier = json.loads(Path(sys.argv[2]).read_text())
1057
+ fallback = Path(sys.argv[3])
1058
+ driver_prompt = Path(sys.argv[4])
1059
+ recovery = json.loads(Path(sys.argv[5]).read_text())
1060
+ output = Path(sys.argv[6]).read_text() + Path(sys.argv[7]).read_text()
1061
+
1062
+ assert routing['action'] == 'handled', 'invalid classifier output should stay fail-closed instead of continuing to the main agent'
1063
+ assert routing['reason'] == 'classifier_invalid_output_cancelled', 'invalid classifier output cancel should record the cancel outcome'
1064
+ assert routing['recoveryAction'] == 'cancel', 'invalid classifier output should record the cancel recovery action'
1065
+ assert routing['replayedToPrimaryAgent'] is False, 'invalid classifier output cancel should not replay the original message'
1066
+ assert classifier['result']['status'] == 'invalid_output', 'invalid classifier output should snapshot the invalid_output result'
1067
+ assert recovery['title'] == 'Router recovery needed before this prompt can continue', 'invalid classifier output should show the recovery chooser'
1068
+ assert not fallback.exists(), 'invalid classifier output cancel should keep the original input away from later fallback handlers'
1069
+ assert not driver_prompt.exists(), 'invalid classifier output cancel should not queue a /cook driver prompt'
1070
+ assert not Path('.agent').exists(), 'invalid classifier output cancel should not bootstrap canonical workflow state'
1071
+ assert 'rerun /cook explicitly' in output, 'invalid classifier output cancel should direct the user back to explicit /cook when needed'
1072
+ PY
1073
+
1074
+ # Classifier subprocess errors should also surface recovery UI and stay fail-closed on cancel.
1075
+ ERROR_ROOT="$TMPDIR/error-repo"
1076
+ ERROR_SESSION="$TMPDIR/error-session.jsonl"
1077
+ ERROR_ROUTING="$TMPDIR/error-routing.json"
1078
+ ERROR_CLASSIFIER="$TMPDIR/error-classifier.json"
1079
+ ERROR_FALLBACK="$TMPDIR/error-fallback.json"
1080
+ ERROR_PROMPT="$TMPDIR/error-driver-prompt.txt"
1081
+ ERROR_RECOVERY="$TMPDIR/error-recovery.json"
1082
+ mkdir -p "$ERROR_ROOT"
1083
+ cd "$ERROR_ROOT"
1084
+ git init -q
1085
+ write_session "$ERROR_SESSION" "$ERROR_ROOT" "$STARTUP_DISCUSSION"
1086
+
1087
+ PI_COOK_TRIGGER_FALLBACK_PATH="$ERROR_FALLBACK" \
1088
+ PI_COOK_TRIGGER_FALLBACK_SOURCE=any \
1089
+ PI_COMPLETION_TEST_DRIVER_PROMPT_PATH="$ERROR_PROMPT" \
1090
+ PI_COMPLETION_TEST_TRIGGER_CLASSIFIER_FAILURE=error \
1091
+ PI_COMPLETION_TEST_TRIGGER_CLASSIFIER_SNAPSHOT_PATH="$ERROR_CLASSIFIER" \
1092
+ PI_COMPLETION_TEST_TRIGGER_RECOVERY_ACTION=cancel \
1093
+ PI_COMPLETION_TEST_TRIGGER_RECOVERY_PATH="$ERROR_RECOVERY" \
1094
+ PI_COMPLETION_TEST_TRIGGER_ROUTING_PATH="$ERROR_ROUTING" \
1095
+ pi --session "$ERROR_SESSION" -e "$PKG_ROOT" -e "$FALLBACK_EXTENSION" -p "$STARTUP_ROUTER_TEXT" \
1096
+ >"$TMPDIR/pi-cook-trigger-error.out" 2>"$TMPDIR/pi-cook-trigger-error.err"
1097
+
1098
+ python3 - "$ERROR_ROUTING" "$ERROR_CLASSIFIER" "$ERROR_FALLBACK" "$ERROR_PROMPT" "$ERROR_RECOVERY" "$TMPDIR/pi-cook-trigger-error.out" "$TMPDIR/pi-cook-trigger-error.err" <<'PY'
1099
+ import json
1100
+ import sys
1101
+ from pathlib import Path
1102
+
1103
+ routing = json.loads(Path(sys.argv[1]).read_text())
1104
+ classifier = json.loads(Path(sys.argv[2]).read_text())
1105
+ fallback = Path(sys.argv[3])
1106
+ driver_prompt = Path(sys.argv[4])
1107
+ recovery = json.loads(Path(sys.argv[5]).read_text())
1108
+ output = Path(sys.argv[6]).read_text() + Path(sys.argv[7]).read_text()
1109
+
1110
+ assert routing['action'] == 'handled', 'classifier errors should stay fail-closed instead of continuing to the main agent'
1111
+ assert routing['reason'] == 'classifier_error_cancelled', 'classifier errors should record the cancel outcome'
1112
+ assert routing['recoveryAction'] == 'cancel', 'classifier errors should record the cancel recovery action'
1113
+ assert routing['replayedToPrimaryAgent'] is False, 'classifier error cancel should not replay the original message'
1114
+ assert classifier['result']['status'] == 'error', 'classifier errors should snapshot the error result'
1115
+ assert recovery['actions'][2]['id'] == 'cancel', 'classifier errors should expose cancel in the recovery chooser'
1116
+ assert not fallback.exists(), 'classifier error cancel should keep the original input away from later fallback handlers'
1117
+ assert not driver_prompt.exists(), 'classifier error cancel should not queue a /cook driver prompt'
1118
+ assert not Path('.agent').exists(), 'classifier error cancel should not bootstrap canonical workflow state'
1119
+ assert 'rerun /cook explicitly' in output, 'classifier error cancel should direct the user back to explicit /cook when needed'
312
1120
  PY
313
1121
 
314
1122
  echo "cook trigger routing test passed: $TMPDIR"