@linimin/pi-letscook 0.1.58 → 0.1.59

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.
@@ -0,0 +1,28 @@
1
+ # Completion Control Plane
2
+
3
+ This repository uses the `completion` workflow for long-running coding tasks.
4
+
5
+ ## Canonical tracked contract files
6
+
7
+ - `.agent/README.md`
8
+ - `.agent/mission.md`
9
+ - `.agent/profile.json`
10
+ - `.agent/verify_completion_stop.sh`
11
+ - `.agent/verify_completion_control_plane.sh`
12
+
13
+ ## Ignored canonical execution state
14
+
15
+ - `.agent/state.json`
16
+ - `.agent/plan.json`
17
+ - `.agent/active-slice.json`
18
+ - `.agent/slice-history.jsonl`
19
+ - `.agent/stop-check-history.jsonl`
20
+ - `.agent/verification-evidence.json`
21
+ - `.agent/*.log`
22
+ - `.agent/tmp/`
23
+
24
+ `.agent/verification-evidence.json` is the durable canonical record of deterministic verification for the selected slice or current HEAD. Recovery, review, audit, and stop-check reminder surfaces consume it instead of temp-only artifacts or conversational summaries when it is populated.
25
+
26
+ The source of truth for long-running completion work is canonical `.agent/**` state plus current repo truth.
27
+
28
+ Project: pi-letscook
@@ -0,0 +1,8 @@
1
+ # Mission
2
+
3
+ Project: pi-letscook
4
+
5
+ Mission anchor:
6
+ Implement the primary-agent → /cook handoff pipeline refactor so /cook only starts implementation workflow from structurally startable handoffs, while preserving explicit handoff as the preferred startup-intake path.
7
+
8
+ 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.
@@ -0,0 +1,13 @@
1
+ {
2
+ "schema_version": 1,
3
+ "protocol_id": "completion",
4
+ "project_name": "pi-letscook",
5
+ "required_stop_judges": 3,
6
+ "priority_policy_id": "completion-default",
7
+ "task_type": "completion-workflow",
8
+ "evaluation_profile": "completion-rubric-v1",
9
+ "docs_surfaces": [
10
+ "README.md",
11
+ "CHANGELOG.md"
12
+ ]
13
+ }
@@ -0,0 +1,203 @@
1
+ #!/usr/bin/env bash
2
+ ':' //; exec node "$0" "$@"
3
+ const fs = require('node:fs');
4
+ const { spawnSync } = require('node:child_process');
5
+
6
+ const REQUIRED_TRACKED_CONTRACT_FILES = [
7
+ '.agent/README.md',
8
+ '.agent/mission.md',
9
+ '.agent/profile.json',
10
+ '.agent/verify_completion_stop.sh',
11
+ '.agent/verify_completion_control_plane.sh',
12
+ ];
13
+
14
+ function fail(message) {
15
+ console.error(message);
16
+ process.exit(1);
17
+ }
18
+
19
+ function readJson(file) {
20
+ try {
21
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
22
+ } catch (error) {
23
+ fail('Failed to read ' + file + ': ' + error.message);
24
+ }
25
+ }
26
+
27
+ function asString(value) {
28
+ return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
29
+ }
30
+
31
+ function asNumber(value) {
32
+ return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
33
+ }
34
+
35
+ function asStringArray(value) {
36
+ return Array.isArray(value)
37
+ ? value.filter((item) => typeof item === 'string' && item.trim().length > 0)
38
+ : [];
39
+ }
40
+
41
+ function sameStringArrays(left, right) {
42
+ return left.length === right.length && left.every((item, index) => item === right[index]);
43
+ }
44
+
45
+ function runGit(args, options = {}) {
46
+ const result = spawnSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] });
47
+ if (!options.allowFailure && result.status !== 0) {
48
+ const stderr = asString(result.stderr) ?? 'git command failed';
49
+ fail(`git ${args.join(' ')} failed: ${stderr}`);
50
+ }
51
+ return result;
52
+ }
53
+
54
+ function gitHeadSha() {
55
+ const result = runGit(['rev-parse', 'HEAD'], { allowFailure: true });
56
+ return result.status === 0 ? asString(result.stdout) : undefined;
57
+ }
58
+
59
+ function ensureTrackedContractFiles() {
60
+ for (const file of REQUIRED_TRACKED_CONTRACT_FILES) {
61
+ const result = runGit(['ls-files', '--error-unmatch', file], { allowFailure: true });
62
+ if (result.status !== 0) {
63
+ fail(`Required tracked completion contract file is missing from git index: ${file}`);
64
+ }
65
+ }
66
+ }
67
+
68
+ function ensureCommitExists(commitish, label) {
69
+ const result = runGit(['rev-parse', '--verify', `${commitish}^{commit}`], { allowFailure: true });
70
+ if (result.status !== 0) {
71
+ fail(`${label} must resolve to an existing commit: ${commitish}`);
72
+ }
73
+ }
74
+
75
+ function trackedDiffFiles(fromCommit, toCommit) {
76
+ const result = runGit(['diff', '--name-only', '--diff-filter=ACMR', `${fromCommit}..${toCommit}`]);
77
+ return result.stdout
78
+ .split(/\r?\n/)
79
+ .map((line) => line.trim())
80
+ .filter(Boolean);
81
+ }
82
+
83
+ const profile = readJson('.agent/profile.json');
84
+ const state = readJson('.agent/state.json');
85
+ const plan = readJson('.agent/plan.json');
86
+ const active = readJson('.agent/active-slice.json');
87
+ const evidence = readJson('.agent/verification-evidence.json');
88
+
89
+ ensureTrackedContractFiles();
90
+
91
+ for (const [file, record] of [
92
+ ['.agent/profile.json', profile],
93
+ ['.agent/state.json', state],
94
+ ['.agent/plan.json', plan],
95
+ ['.agent/active-slice.json', active],
96
+ ]) {
97
+ if (!asString(record.task_type)) fail(file + ' is missing task_type');
98
+ if (!asString(record.evaluation_profile)) fail(file + ' is missing evaluation_profile');
99
+ }
100
+
101
+ const taskType = asString(profile.task_type);
102
+ const evaluationProfile = asString(profile.evaluation_profile);
103
+ if (asString(state.task_type) !== taskType) fail('.agent/state.json task_type must match .agent/profile.json task_type');
104
+ if (asString(plan.task_type) !== taskType) fail('.agent/plan.json task_type must match .agent/profile.json task_type');
105
+ if (asString(active.task_type) !== taskType) fail('.agent/active-slice.json task_type must match .agent/profile.json task_type');
106
+ if (asString(state.evaluation_profile) !== evaluationProfile) fail('.agent/state.json evaluation_profile must match .agent/profile.json evaluation_profile');
107
+ if (asString(plan.evaluation_profile) !== evaluationProfile) fail('.agent/plan.json evaluation_profile must match .agent/profile.json evaluation_profile');
108
+ if (asString(active.evaluation_profile) !== evaluationProfile) fail('.agent/active-slice.json evaluation_profile must match .agent/profile.json evaluation_profile');
109
+
110
+ if (asString(evidence.artifact_type) !== 'completion-verification-evidence') {
111
+ fail('.agent/verification-evidence.json artifact_type must be completion-verification-evidence');
112
+ }
113
+
114
+ const exactStatuses = new Set(['selected', 'in_progress', 'committed', 'done']);
115
+ const activeStatus = asString(active.status);
116
+ const exactHandoff = exactStatuses.has(activeStatus || '');
117
+ const planSlices = Array.isArray(plan.candidate_slices) ? plan.candidate_slices : [];
118
+ const activeSliceId = asString(active.slice_id);
119
+ const planSlice = activeSliceId ? planSlices.find((slice) => asString(slice && slice.slice_id) === activeSliceId) : undefined;
120
+
121
+ if (exactHandoff && !planSlice) {
122
+ fail('slice_id must match a slice in .agent/plan.json when status carries an exact handoff');
123
+ }
124
+
125
+ if (exactHandoff) {
126
+ const requiredStringFields = ['goal', 'why_now', 'basis_commit'];
127
+ for (const field of requiredStringFields) {
128
+ if (!asString(active[field])) fail('.agent/active-slice.json is missing ' + field + ' when status carries an exact handoff');
129
+ }
130
+ const requiredArrayFields = ['contract_ids', 'acceptance_criteria', 'blocked_on', 'locked_notes', 'must_fix_findings', 'implementation_surfaces', 'verification_commands', 'remaining_contract_ids_before'];
131
+ for (const field of requiredArrayFields) {
132
+ if (!Array.isArray(active[field])) fail('.agent/active-slice.json is missing ' + field + ' when status carries an exact handoff');
133
+ }
134
+ const requiredNumberFields = ['priority', 'release_blocker_count_before', 'high_value_gap_count_before'];
135
+ for (const field of requiredNumberFields) {
136
+ if (asNumber(active[field]) === undefined) fail('.agent/active-slice.json is missing ' + field + ' when status carries an exact handoff');
137
+ }
138
+
139
+ const mismatchFields = [];
140
+ if (asString(planSlice.slice_id) !== activeSliceId) mismatchFields.push('slice_id');
141
+ if (asString(planSlice.goal) !== asString(active.goal)) mismatchFields.push('goal');
142
+ if (!sameStringArrays(asStringArray(planSlice.contract_ids), asStringArray(active.contract_ids))) mismatchFields.push('contract_ids');
143
+ if (!sameStringArrays(asStringArray(planSlice.acceptance_criteria), asStringArray(active.acceptance_criteria))) mismatchFields.push('acceptance_criteria');
144
+ if (!sameStringArrays(asStringArray(planSlice.blocked_on), asStringArray(active.blocked_on))) mismatchFields.push('blocked_on');
145
+ if (asNumber(planSlice.priority) !== asNumber(active.priority)) mismatchFields.push('priority');
146
+ if (asString(planSlice.why_now) !== asString(active.why_now)) mismatchFields.push('why_now');
147
+ const planMirrorFields = ['locked_notes', 'must_fix_findings', 'implementation_surfaces', 'verification_commands', 'basis_commit', 'remaining_contract_ids_before', 'release_blocker_count_before', 'high_value_gap_count_before'];
148
+ for (const field of planMirrorFields) {
149
+ const planValue = planSlice[field];
150
+ const activeValue = active[field];
151
+ if (Array.isArray(planValue) || Array.isArray(activeValue)) {
152
+ if (!sameStringArrays(asStringArray(planValue), asStringArray(activeValue))) mismatchFields.push(field);
153
+ continue;
154
+ }
155
+ if (typeof planValue === 'number' || typeof activeValue === 'number') {
156
+ if (asNumber(planValue) !== asNumber(activeValue)) mismatchFields.push(field);
157
+ continue;
158
+ }
159
+ if (asString(planValue) !== asString(activeValue)) mismatchFields.push(field);
160
+ }
161
+ if (mismatchFields.length > 0) {
162
+ fail('.agent/active-slice.json must match the selected .agent/plan.json slice across: ' + mismatchFields.join(', '));
163
+ }
164
+
165
+ if (asString(evidence.subject_type) !== 'selected_slice') {
166
+ fail('subject_type must be selected_slice when active slice exact handoff requires verification evidence');
167
+ }
168
+ if (asString(evidence.slice_id) !== activeSliceId) fail('.agent/verification-evidence.json slice_id must match .agent/active-slice.json slice_id');
169
+ if (asString(evidence.goal) !== asString(active.goal)) fail('.agent/verification-evidence.json goal must match .agent/active-slice.json goal');
170
+ if (!sameStringArrays(asStringArray(evidence.contract_ids), asStringArray(active.contract_ids))) fail('.agent/verification-evidence.json contract_ids must match .agent/active-slice.json contract_ids');
171
+ if (asString(evidence.basis_commit) !== asString(active.basis_commit)) fail('.agent/verification-evidence.json basis_commit must match .agent/active-slice.json basis_commit');
172
+ if (!sameStringArrays(asStringArray(evidence.verification_commands), asStringArray(active.verification_commands))) {
173
+ fail('.agent/verification-evidence.json verification_commands must match .agent/active-slice.json verification_commands');
174
+ }
175
+ if (!asString(evidence.recorded_at)) fail('.agent/verification-evidence.json recorded_at must be present for selected-slice evidence');
176
+ if (asString(evidence.outcome) === 'not_recorded') fail('.agent/verification-evidence.json outcome must not be not_recorded for selected-slice evidence');
177
+ const headSha = gitHeadSha();
178
+ if (headSha && asString(evidence.head_sha) !== headSha) {
179
+ fail('.agent/verification-evidence.json head_sha must match current HEAD');
180
+ }
181
+
182
+ const basisCommit = asString(active.basis_commit);
183
+ if (basisCommit && headSha) {
184
+ ensureCommitExists(basisCommit, '.agent/active-slice.json basis_commit');
185
+ const ancestorCheck = runGit(['merge-base', '--is-ancestor', basisCommit, headSha], { allowFailure: true });
186
+ if (ancestorCheck.status !== 0) {
187
+ fail(`.agent/active-slice.json basis_commit must be an ancestor of current HEAD: ${basisCommit} -> ${headSha}`);
188
+ }
189
+ const changedFiles = trackedDiffFiles(basisCommit, headSha);
190
+ const implementationSurfaces = new Set(asStringArray(active.implementation_surfaces));
191
+ const missingSurfaces = changedFiles.filter((file) => !implementationSurfaces.has(file));
192
+ if (missingSurfaces.length > 0) {
193
+ fail('.agent/active-slice.json implementation_surfaces must cover every tracked file changed from basis_commit to current HEAD; missing: ' + missingSurfaces.join(', '));
194
+ }
195
+ }
196
+ } else {
197
+ const subjectType = asString(evidence.subject_type);
198
+ if (subjectType === 'none') {
199
+ if (asString(evidence.outcome) && asString(evidence.outcome) !== 'not_recorded') {
200
+ fail('.agent/verification-evidence.json outcome must stay not_recorded when subject_type=none');
201
+ }
202
+ }
203
+ }
@@ -0,0 +1,20 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
5
+ cd "$ROOT"
6
+
7
+ echo "[completion-stop] verifying control plane and .agent/verification-evidence.json parity"
8
+ bash .agent/verify_completion_control_plane.sh
9
+
10
+ if [[ "${PI_COMPLETION_RUNNING_RELEASE_CHECK:-0}" == "1" ]]; then
11
+ echo "[completion-stop] release-check is already in progress; skipping nested npm run release-check >/dev/null recursion"
12
+ npm run evaluator-calibration-test >/dev/null
13
+ echo "completion stop verification passed"
14
+ exit 0
15
+ fi
16
+
17
+ echo "[completion-stop] delegating to npm run release-check >/dev/null for broad packaged verification, evaluator calibration, and contract coverage"
18
+ PI_COMPLETION_RUNNING_RELEASE_CHECK=1 npm run release-check >/dev/null
19
+
20
+ echo "completion stop verification passed"
package/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ ## 0.1.59
6
+
7
+ ### Changed
8
+
9
+ - relaxed the pre-`/cook` ordinary-chat boundary so the primary agent can keep discussing and refining requirements before explicit `/cook` instead of switching into a hard handoff-only refusal mode as soon as workflow-worthiness is detected
10
+ - kept `/cook` as the only explicit workflow boundary, while teaching the pre-`/cook` prompt surfaces to recommend `/cook` advisory-first and only emit implementation-ready capsules once the first bounded slice is concrete enough
11
+ - made fresh explicit `/cook` handoffs reusable from recent ordinary-chat discussion instead of requiring the immediately preceding turn, so Cancel can return users cleanly to ordinary discussion before they rerun `/cook`
12
+ - kept bare `/cook` startup and done-workflow next-round entry fail-closed on missing or non-startable explicit handoffs, while active workflows still resume from canonical `.agent/**` state unless a fresh explicit handoff proposes replacement
13
+ - updated public parity and shipped package contents so the tracked `.agent` contract files are included in package tarballs and packaged smoke/release verification can scaffold canonical state truthfully
14
+
5
15
  ## 0.1.58
6
16
 
7
17
  ### Changed
package/README.md CHANGED
@@ -30,10 +30,10 @@ Then run `/reload` in Pi.
30
30
  1. Install the package:
31
31
  `pi install npm:@linimin/pi-letscook`
32
32
  2. Run `/reload` in Pi.
33
- 3. In the main chat, describe the concrete repo change you want.
33
+ 3. In the main chat, describe the concrete repo change you want and let the primary agent help refine it until the first slice is ready for `/cook`.
34
34
  4. Run `/cook`.
35
35
  5. Review the startup brief and choose **Start** or **Cancel**.
36
- 6. Later, run `/cook` again to continue, refocus, or start the next round.
36
+ 6. Later, run `/cook` again to resume from canonical state or confirm an explicit replacement or next-round handoff.
37
37
 
38
38
  ```text
39
39
  /cook
@@ -43,20 +43,20 @@ Then run `/reload` in Pi.
43
43
 
44
44
  | If you want to... | Do this |
45
45
  |---|---|
46
- | Start a long-running task | Discuss the concrete repo change in the main chat, then run `/cook` |
46
+ | Start a long-running task | Discuss the concrete repo change in the main chat, wait for a fresh primary-agent handoff, then run `/cook` |
47
47
  | Continue the current workflow | Run `/cook` |
48
- | Refocus or start the next round | Discuss the new concrete repo change in the main chat, then run `/cook` |
48
+ | Refocus or start the next round | Discuss the new concrete repo change in the main chat, wait for a fresh primary-agent handoff, then run `/cook` |
49
49
 
50
50
  ## What `/cook` expects
51
51
 
52
- - preferably a fresh explicit primary-agent `/cook` handoff capsule from the immediately preceding ordinary-chat turn
52
+ - a fresh valid explicit primary-agent `/cook` handoff capsule from recent ordinary-chat discussion whenever `/cook` is starting a new workflow or the next round after a completed workflow
53
53
  - for that handoff capsule to start workflow immediately, it must already be implementation-startable: a bounded `first_slice_goal`, repo-change-oriented acceptance, `implementation_surfaces`, `verification_commands`, and `why_this_slice_first`
54
- - otherwise, when no fresh explicit handoff is blocking startup, recent main-chat discussion about concrete repo changes
55
- - enough detail to derive a startup brief with mission, scope, constraints or non-goals, acceptance, and notes or risks
54
+ - enough detail in the main chat for the primary agent to form that bounded handoff capsule before you run `/cook`
56
55
  - README/CHANGELOG updates still count as concrete repo changes
57
56
  - assistant-produced summaries and plan/spec/design-doc/proposal-only artifacts still do not count unless they include the explicit structured `/cook` handoff capsule
57
+ - recent main-chat discussion can still validate or supplement an accepted explicit handoff, but it no longer starts a new workflow by itself
58
58
 
59
- If no fresh valid handoff exists and recent discussion is missing, weak, ambiguous, assistant-produced, or only describes planning artifacts instead of concrete repo changes, `/cook` fails closed, leaves canonical `.agent/**` state unchanged, and tells you to clarify the mission in the main chat before rerunning `/cook`.
59
+ If no fresh valid handoff exists for new-workflow or next-round entry, `/cook` fails closed, leaves canonical `.agent/**` state unchanged, and tells you to get an explicit primary-agent handoff in the main chat before rerunning `/cook`.
60
60
 
61
61
  If a fresh explicit handoff exists but is still workflow-worthy rather than implementation-startable, `/cook` also fails closed instead of silently treating that capsule as planning support or canonical workflow state.
62
62
 
@@ -66,36 +66,40 @@ If you pass inline arguments to `/cook`, it also fails closed and tells you to m
66
66
 
67
67
  Only explicit `/cook` enters the workflow. Ordinary prompts stay in the main chat and go straight to the primary agent.
68
68
 
69
- If a task has clearly matured into completion-workflow scope, the primary agent should hand you off to `/cook` instead of starting long-running implementation directly in ordinary chat.
69
+ If a task has clearly matured into completion-workflow scope, the primary agent should recommend `/cook` instead of starting long-running implementation directly in ordinary chat.
70
70
 
71
- That handoff should include an explicit structured `/cook` capsule in the assistant reply so `/cook` can confirm the already-formed mission instead of re-deriving it from broad ambient context.
71
+ Before you explicitly run `/cook`, the conversation can still stay in ordinary chat: the primary agent may keep answering follow-up questions and refining requirements rather than switching into a hard handoff-only refusal mode.
72
72
 
73
- The preferred capsule is still advisory startup intake, not canonical workflow state, and it only counts as implementation-ready when it already names the first bounded slice, repo-change-oriented acceptance, implementation surfaces, and verification commands.
73
+ That handoff should include an explicit structured `/cook` capsule in the assistant reply once the first slice is implementation-ready, so `/cook` can confirm the already-formed mission instead of re-deriving it from broad ambient context.
74
+
75
+ The capsule is still advisory startup intake, not canonical workflow state, and new-workflow or next-round entry only proceeds when it already names the first bounded slice, repo-change-oriented acceptance, implementation surfaces, and verification commands.
74
76
 
75
77
  Important behavior:
76
78
  - `/cook` is the canonical workflow boundary and manual entry point
77
- - startup, refocus, and next-round routing stay confirm-first; nothing silently starts a workflow
79
+ - startup and next-round entry stay confirm-first and require a fresh valid explicit primary-agent handoff
80
+ - active workflows resume from canonical `.agent/**` state unless a fresh valid explicit handoff proposes a replacement
78
81
  - explicit slash commands other than `/cook` continue normally in the main chat
79
82
  - ordinary main-chat discussion may clarify or propose, but mature long-running implementation should be handed off to `/cook`
80
83
 
81
84
  ## Typical examples
82
85
 
83
- Start a new workflow from recent discussion:
86
+ Start a new workflow after a fresh primary-agent handoff:
84
87
 
85
88
  ```text
86
89
  I want to add login redirect handling and tests.
90
+ # let the primary agent hand you off to /cook
87
91
  /cook
88
92
  ```
89
93
 
90
94
  ## What happens when you run `/cook`
91
95
 
92
- `/cook` first looks for a fresh explicit primary-agent handoff capsule. If one is valid and implementation-startable, `/cook` builds the startup brief from that handoff and only uses recent discussion as validation or supplemental notes. `/cook` falls back to deriving a startup brief from recent discussion only when no fresh explicit handoff is blocking startup—for example, when there is no fresh capsule or only stale or invalidated capsules—before showing the existing approval-only Start/Cancel gate.
96
+ `/cook` first looks for a fresh explicit primary-agent handoff capsule from recent ordinary-chat discussion. New-workflow entry and done-workflow next-round entry start only when that capsule is fresh, valid, and implementation-startable; otherwise `/cook` fails closed instead of deriving startup from recent discussion. When a workflow is already active and no fresh valid explicit handoff is present, `/cook` resumes from canonical `.agent/**` state instead of deriving replacement startup from recent discussion.
93
97
 
94
98
  | Repo state | What you'll see |
95
99
  |---|---|
96
- | No workflow yet | If a fresh explicit handoff capsule exists and is implementation-startable, a startup brief built from that handoff. Otherwise, when no fresh explicit handoff is blocking startup, a startup brief built from recent main-chat discussion. You choose **Start** or **Cancel**. Weak, unreliable, stale, planning-only, or non-startable explicit-handoff intake fails closed. |
97
- | Active workflow exists | Usually a resume of the current workflow. If a fresh explicit handoff capsule or recent discussion clearly points to a different concrete repo change, `/cook` shows a chooser first and only rewrites canonical state after you confirm the new startup brief. Ambiguous intake stays conservative. |
98
- | Previous workflow is `done` | A fresh explicit handoff capsule can still start the next implementation round behind **Start** or **Cancel**. Without a fresh explicit handoff blocking startup, `/cook` can fall back to recent discussion. Discussion that only restates already-finished work still fails closed. |
100
+ | No workflow yet | If a fresh explicit handoff capsule exists and is implementation-startable, you get a startup brief built from that handoff and choose **Start** or **Cancel**. Otherwise `/cook` fails closed, leaves canonical state unchanged, and tells you to get a fresh explicit primary-agent handoff. Weak, unreliable, stale, planning-only, or non-startable explicit-handoff intake also fails closed. |
101
+ | Active workflow exists | Usually a resume of the current workflow from canonical `.agent/**` state. If a fresh explicit handoff capsule points to a different concrete repo change, `/cook` shows a chooser first and only rewrites canonical state after you confirm the replacement. Ambiguous intake stays conservative. |
102
+ | Previous workflow is `done` | A fresh explicit handoff capsule can still start the next implementation round behind **Start** or **Cancel**. Without one, `/cook` fails closed instead of deriving the next round from recent discussion. |
99
103
 
100
104
  ## Confirmation and fail-closed behavior
101
105
 
@@ -103,9 +107,9 @@ I want to add login redirect handling and tests.
103
107
 
104
108
  - startup, next-round, and refocus proposals are approval-only
105
109
  - actions are **Start** and **Cancel**
106
- - **Cancel** is side-effect free: discuss changes in the main chat and rerun `/cook`
110
+ - **Cancel** is side-effect free: canonical workflow state stays unchanged, so you can discuss changes in the main chat and rerun `/cook`
107
111
  - weak, ambiguous, stale, invalid, assistant-produced, or planning-only intake does not start a workflow
108
- - when recent discussion suggests a different workflow, `/cook` shows a chooser before any canonical state rewrite
112
+ - when a fresh explicit handoff suggests replacing an active workflow, `/cook` shows a chooser before any canonical state rewrite
109
113
 
110
114
  When you accept startup or refocus, `/cook` persists the chosen workflow state in canonical `.agent/**` files before the re-ground round begins.
111
115
 
@@ -60,16 +60,21 @@ type CookContextProposalResult = {
60
60
  blockedFailureMessage?: string;
61
61
  };
62
62
 
63
+ function buildCookExplicitHandoffRequiredMessage(deps: CompletionDriverDeps, prefix?: string): string {
64
+ const requirement =
65
+ "/cook failed closed because starting a new completion workflow now requires a fresh valid explicit primary-agent handoff. Ask the primary agent to emit a fresh ```cook_handoff``` capsule in the main chat, then rerun /cook.";
66
+ return prefix ? `${prefix} ${requirement}` : requirement;
67
+ }
68
+
63
69
  type ActiveWorkflowProposalAssessment = {
64
- action: "continue" | "refocus" | "unclear" | "blocked";
70
+ action: "continue" | "refocus" | "blocked";
65
71
  currentMissionAnchor: string;
66
72
  proposal?: ContextProposal;
67
73
  blockedFailureMessage?: string;
68
74
  reason:
69
75
  | "matching_mission"
70
- | "clear_refocus"
71
- | "missing_proposal"
72
- | "ambiguous_discussion"
76
+ | "missing_explicit_handoff"
77
+ | "fresh_explicit_handoff"
73
78
  | "fresh_explicit_handoff_not_startable";
74
79
  };
75
80
 
@@ -122,6 +127,7 @@ export type CompletionDriverDeps = {
122
127
  ) => string;
123
128
  completionResumePrompt: (taskType: string, evaluationProfile: string) => string;
124
129
  deriveCookContextProposal: (ctx: DriverContext, projectName: string) => Promise<CookContextProposalResult>;
130
+ deriveCookStartupProposal: (ctx: DriverContext, projectName: string) => Promise<CookContextProposalResult>;
125
131
  confirmContextProposal: (
126
132
  ctx: { hasUI: boolean; ui: any },
127
133
  proposal: ContextProposal,
@@ -137,7 +143,6 @@ export type CompletionDriverDeps = {
137
143
  maybeWriteActiveWorkflowRoutingSnapshot: (assessment: ActiveWorkflowProposalAssessment) => void;
138
144
  missionAnchorsLikelyEquivalent: (left: string, right: string) => boolean;
139
145
  missionAnchorsStrictlyEquivalent: (left: string, right: string) => boolean;
140
- shouldTreatBareActiveWorkflowProposalAsClearRefocus: (proposal: ContextProposal) => boolean;
141
146
  activateCompletionRoutingForRoot: (root: string | undefined) => void;
142
147
  maybeWriteTestSnapshot: (targetPath: string | undefined, content: string) => void;
143
148
  completionTestDriverPromptPath: () => string | undefined;
@@ -316,23 +321,23 @@ async function assessActiveWorkflowProposalRouting(
316
321
  ): Promise<ActiveWorkflowProposalAssessment> {
317
322
  const currentMission = currentMissionAnchor(snapshot);
318
323
  const projectName = path.basename(snapshot.files.root);
319
- const derived = await deps.deriveCookContextProposal(ctx, projectName);
320
- if (derived.blockedFailureMessage) {
324
+ const explicitHandoff = await deps.deriveCookStartupProposal(ctx, projectName);
325
+ if (explicitHandoff.blockedFailureMessage) {
321
326
  const assessment: ActiveWorkflowProposalAssessment = {
322
327
  action: "blocked",
323
328
  currentMissionAnchor: currentMission,
324
- blockedFailureMessage: derived.blockedFailureMessage,
329
+ blockedFailureMessage: explicitHandoff.blockedFailureMessage,
325
330
  reason: "fresh_explicit_handoff_not_startable",
326
331
  };
327
332
  deps.maybeWriteActiveWorkflowRoutingSnapshot(assessment);
328
333
  return assessment;
329
334
  }
330
- const proposal = derived.proposal;
335
+ const proposal = explicitHandoff.proposal;
331
336
  if (!proposal) {
332
337
  const assessment: ActiveWorkflowProposalAssessment = {
333
- action: "unclear",
338
+ action: "continue",
334
339
  currentMissionAnchor: currentMission,
335
- reason: "missing_proposal",
340
+ reason: "missing_explicit_handoff",
336
341
  };
337
342
  deps.maybeWriteActiveWorkflowRoutingSnapshot(assessment);
338
343
  return assessment;
@@ -347,21 +352,11 @@ async function assessActiveWorkflowProposalRouting(
347
352
  deps.maybeWriteActiveWorkflowRoutingSnapshot(assessment);
348
353
  return assessment;
349
354
  }
350
- if (deps.shouldTreatBareActiveWorkflowProposalAsClearRefocus(proposal)) {
351
- const assessment: ActiveWorkflowProposalAssessment = {
352
- action: "refocus",
353
- currentMissionAnchor: currentMission,
354
- proposal,
355
- reason: "clear_refocus",
356
- };
357
- deps.maybeWriteActiveWorkflowRoutingSnapshot(assessment);
358
- return assessment;
359
- }
360
355
  const assessment: ActiveWorkflowProposalAssessment = {
361
- action: "unclear",
356
+ action: "refocus",
362
357
  currentMissionAnchor: currentMission,
363
358
  proposal,
364
- reason: "ambiguous_discussion",
359
+ reason: "fresh_explicit_handoff",
365
360
  };
366
361
  deps.maybeWriteActiveWorkflowRoutingSnapshot(assessment);
367
362
  return assessment;
@@ -541,14 +536,14 @@ export async function runCookEntry(
541
536
  if (!snapshot) {
542
537
  const root = findRepoRoot(cwd) ?? cwd;
543
538
  const projectName = path.basename(root);
544
- const derived = await deps.deriveCookContextProposal(ctx, projectName);
539
+ const derived = await deps.deriveCookStartupProposal(ctx, projectName);
545
540
  if (derived.blockedFailureMessage) {
546
541
  deps.emitCommandText(ctx, derived.blockedFailureMessage, "info");
547
542
  return;
548
543
  }
549
544
  const proposal = derived.proposal;
550
545
  if (!proposal) {
551
- deps.emitCommandText(ctx, buildCookStructuredDiscussionFailureMessage(deps), "info");
546
+ deps.emitCommandText(ctx, buildCookExplicitHandoffRequiredMessage(deps), "info");
552
547
  return;
553
548
  }
554
549
  const decision = await deps.confirmContextProposal(ctx, proposal, {
@@ -586,14 +581,14 @@ export async function runCookEntry(
586
581
  if (!goal) {
587
582
  if (workflowDone) {
588
583
  const projectName = path.basename(snapshot.files.root);
589
- const derived = await deps.deriveCookContextProposal(ctx, projectName);
584
+ const derived = await deps.deriveCookStartupProposal(ctx, projectName);
590
585
  if (derived.blockedFailureMessage) {
591
586
  deps.emitCommandText(ctx, derived.blockedFailureMessage, "info");
592
587
  return;
593
588
  }
594
589
  const proposal = derived.proposal;
595
590
  if (!proposal) {
596
- deps.emitCommandText(ctx, buildCookStructuredDiscussionFailureMessage(deps, "The previous completion workflow is already done."), "info");
591
+ deps.emitCommandText(ctx, buildCookExplicitHandoffRequiredMessage(deps, "The previous completion workflow is already done."), "info");
597
592
  return;
598
593
  }
599
594
  const decision = await deps.confirmContextProposal(ctx, proposal, {
@@ -615,7 +610,7 @@ export async function runCookEntry(
615
610
  buildAdvisoryStartupBrief({ proposal, analysis: decision.analysis }),
616
611
  );
617
612
  snapshot = (await loadCompletionSnapshot(snapshot.files.root)) ?? snapshot;
618
- deps.emitCommandText(ctx, `Started a new completion workflow round from recent discussion: ${decision.missionAnchor}`, "info");
613
+ deps.emitCommandText(ctx, `Started a new completion workflow round from explicit primary-agent handoff: ${decision.missionAnchor}`, "info");
619
614
  } else {
620
615
  const assessment = await assessActiveWorkflowProposalRouting(ctx, snapshot, deps);
621
616
  if (assessment.action === "blocked") {
@@ -626,15 +621,21 @@ export async function runCookEntry(
626
621
  await resumeActiveWorkflowFromCanonicalState(pi, ctx, snapshot, deps);
627
622
  return;
628
623
  }
624
+ const explicitReplacement = assessment.reason === "fresh_explicit_handoff";
629
625
  const decision = await confirmExistingWorkflowProposal(ctx, snapshot, assessment.proposal, deps, {
630
- intro:
631
- assessment.action === "refocus"
632
- ? "Recent non-command discussion suggests a different workflow. Choose how /cook should proceed:"
633
- : "Recent discussion may point to a different implementation goal. Review the current mission and the latest inferred mission before deciding how /cook should proceed:",
634
- proposedMissionLabel: "Proposed mission from recent discussion",
635
- refocusChoiceLabel:
636
- "Start new workflow from recent discussion\n\nReview the proposed replacement in a final Start/Cancel confirmation before /cook rewrites canonical workflow state.",
637
- comparison: assessment.action === "refocus" ? "semantic" : "strict",
626
+ intro: explicitReplacement
627
+ ? "A fresh explicit primary-agent handoff proposes replacing the current workflow. Choose how /cook should proceed:"
628
+ : "A replacement workflow is ready. Choose how /cook should proceed:",
629
+ proposedMissionLabel: explicitReplacement
630
+ ? "Proposed mission from explicit primary-agent handoff"
631
+ : "Proposed mission",
632
+ refocusChoiceLabel: explicitReplacement
633
+ ? "Start new workflow from explicit primary-agent handoff\n\nReview the proposed replacement in a final Start/Cancel confirmation before /cook rewrites canonical workflow state."
634
+ : "Start new workflow\n\nReview the proposed replacement in a final Start/Cancel confirmation before /cook rewrites canonical workflow state.",
635
+ alternateChoiceLabel: explicitReplacement
636
+ ? "Start alternate workflow from explicit primary-agent handoff\n\nReview this alternate replacement in a final Start/Cancel confirmation before /cook rewrites canonical workflow state."
637
+ : undefined,
638
+ comparison: "strict",
638
639
  });
639
640
  if (!decision) {
640
641
  deps.emitCommandText(ctx, buildCookCancellationMessage("Cancelled existing workflow confirmation", deps), "info");
@@ -646,10 +647,9 @@ export async function runCookEntry(
646
647
  }
647
648
  const selectedProposal = decision.proposal;
648
649
  const proposalDecision = await deps.confirmContextProposal(ctx, selectedProposal, {
649
- title:
650
- assessment.action === "refocus"
651
- ? "Start the replacement workflow from this startup brief?"
652
- : "Start the latest inferred workflow from this startup brief?",
650
+ title: assessment.reason === "fresh_explicit_handoff"
651
+ ? "Start the replacement workflow from this explicit startup brief?"
652
+ : "Start the replacement workflow from this startup brief?",
653
653
  });
654
654
  if (!proposalDecision) {
655
655
  deps.emitCommandText(ctx, buildCookCancellationMessage("Cancelled replacement workflow proposal", deps), "info");
@@ -667,7 +667,13 @@ export async function runCookEntry(
667
667
  buildAdvisoryStartupBrief({ proposal: selectedProposal, analysis: proposalDecision.analysis }),
668
668
  );
669
669
  snapshot = (await loadCompletionSnapshot(snapshot.files.root)) ?? snapshot;
670
- deps.emitCommandText(ctx, `Refocused completion mission from recent discussion to: ${proposalDecision.missionAnchor}`, "info");
670
+ deps.emitCommandText(
671
+ ctx,
672
+ assessment.reason === "fresh_explicit_handoff"
673
+ ? `Refocused completion mission from explicit primary-agent handoff to: ${proposalDecision.missionAnchor}`
674
+ : `Refocused completion mission to: ${proposalDecision.missionAnchor}`,
675
+ "info",
676
+ );
671
677
  }
672
678
  }
673
679
  kickoffMissionAnchor = kickoffMissionAnchor ?? currentMissionAnchor(snapshot);