cowork-flow 1.4.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/CHANGELOG.md +81 -1
  2. package/README.md +85 -9
  3. package/package.json +2 -2
  4. package/presets/dsh/plugins/workflow-state.js +152 -0
  5. package/presets/kimi-code/hooks/cowork-flow-inject.mjs +113 -0
  6. package/src/cli.js +6 -0
  7. package/src/commands/install-dsh-preset.js +36 -2
  8. package/src/commands/install-kimi-hook.js +343 -0
  9. package/src/lib/host-assets.js +1 -10
  10. package/template/.codex/hooks.json +12 -0
  11. package/template/.cowork-flow/.version +1 -1
  12. package/template/.cowork-flow/adapters/kimi-code/adapter.yaml +31 -0
  13. package/template/.cowork-flow/scripts/adapters/cli/spec_check.py +1 -1
  14. package/template/.cowork-flow/scripts/adapters/cli/state.py +1 -1
  15. package/template/.cowork-flow/scripts/adapters/cli/subagent.py +38 -23
  16. package/template/.cowork-flow/scripts/adapters/cli/task.py +0 -3
  17. package/template/.cowork-flow/scripts/adapters/cli/task_lifecycle_commands.py +5 -2
  18. package/template/.cowork-flow/scripts/adapters/host/claude_code_policy.py +3 -14
  19. package/template/.cowork-flow/scripts/adapters/host/host_manifest.py +1 -2
  20. package/template/.cowork-flow/scripts/adapters/host/inject.py +14 -8
  21. package/template/.cowork-flow/scripts/adapters/host/kimi_code_policy.py +21 -0
  22. package/template/.cowork-flow/scripts/adapters/host/workflow_state_hook.py +61 -20
  23. package/template/.cowork-flow/scripts/adapters/host/zcode_policy.py +0 -17
  24. package/template/.cowork-flow/scripts/adapters/mcp/state_server.py +1 -1
  25. package/template/.cowork-flow/scripts/adapters/review/test_intent.py +0 -31
  26. package/template/.cowork-flow/scripts/infra/paths.py +0 -8
  27. package/template/.cowork-flow/scripts/run.py +1 -0
  28. package/template/.cowork-flow/scripts/runtime/host_identity.py +260 -0
  29. package/template/.cowork-flow/scripts/runtime/session_state.py +48 -53
  30. package/template/.cowork-flow/scripts/services/context_discovery.py +0 -35
  31. package/template/.cowork-flow/scripts/services/fact_view.py +1 -2
  32. package/template/.cowork-flow/scripts/services/lifecycle_checks.py +0 -20
  33. package/template/.cowork-flow/scripts/services/spec_check.py +1 -1
  34. package/template/.cowork-flow/scripts/services/task_context.py +0 -2
  35. package/template/.cowork-flow/spec/contracts/context-injection.md +91 -5
  36. package/template/.cowork-flow/spec/contracts/fact-layer-access.md +2 -1
  37. package/template/.cowork-flow/spec/contracts/spec-checks.md +16 -5
  38. package/template/.cowork-flow/spec/runtime/host-assets.json +49 -0
  39. package/template/.cowork-flow/spec/runtime/index.md +1 -1
  40. package/template/.cowork-flow/spec/schemas/host-assets.schema.json +1 -8
  41. package/template/.cowork-flow/spec/schemas/index.md +1 -0
  42. package/template/.kimi-code/agents/cowork-check.md +29 -0
  43. package/template/.kimi-code/agents/cowork-implement.md +30 -0
  44. package/template/.kimi-code/agents/cowork-research.md +23 -0
  45. package/template/.opencode/plugins/cowork-flow.js +86 -0
  46. package/template/.zcode/.zcode-plugin/plugin.json +1 -1
  47. package/template/AGENTS.md +1 -0
  48. package/template/skills/runtime-health/scripts/doctor.py +186 -1
@@ -1,10 +1,12 @@
1
- import { cp, mkdir, rm, access } from 'node:fs/promises';
1
+ import { cp, mkdir, rm, access, readFile, writeFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { homedir } from 'node:os';
4
4
 
5
5
  import { packageRoot, templateRoot } from '../lib/paths.js';
6
+ import { readPackageInfo } from '../lib/package-info.js';
6
7
 
7
8
  const PRESET_ID = 'cowork-flow';
9
+ const MARKER_FILE = '.cowork-flow-preset.json';
8
10
  const PRESET_SRC = join(packageRoot, 'presets', 'dsh');
9
11
  const SKILLS_SRC = join(templateRoot, 'skills');
10
12
 
@@ -33,6 +35,17 @@ function getDshPresetRoot() {
33
35
  }
34
36
 
35
37
 
38
+ async function readInstalledVersion(destDir) {
39
+ try {
40
+ const raw = await readFile(join(destDir, MARKER_FILE), 'utf8');
41
+ const parsed = JSON.parse(raw);
42
+ return typeof parsed.version === 'string' ? parsed.version : null;
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+
36
49
  export async function runInstallDshPreset(args = []) {
37
50
  const { dryRun, force } = parseArgs(args);
38
51
 
@@ -53,9 +66,25 @@ export async function runInstallDshPreset(args = []) {
53
66
  return;
54
67
  }
55
68
 
69
+ const { version } = await readPackageInfo();
70
+
56
71
  if (!force && (await pathExists(destDir))) {
72
+ const installedVersion = await readInstalledVersion(destDir);
57
73
  console.log(`cowork-flow DSH preset already installed at ${destDir}`);
58
- console.log('Use --force to overwrite.');
74
+ if (installedVersion === null) {
75
+ console.log(
76
+ `Installed version unknown (no ${MARKER_FILE}); current version is ${version}.`
77
+ );
78
+ console.log('Refresh it with: cowork-flow install-dsh-preset --force');
79
+ } else if (installedVersion !== version) {
80
+ console.log(
81
+ `Installed version ${installedVersion} differs from current version ${version}.`
82
+ );
83
+ console.log('The preset does not update with sync or npm; refresh it with:');
84
+ console.log(' cowork-flow install-dsh-preset --force');
85
+ } else {
86
+ console.log('Use --force to overwrite.');
87
+ }
59
88
  return;
60
89
  }
61
90
 
@@ -66,6 +95,11 @@ export async function runInstallDshPreset(args = []) {
66
95
 
67
96
  await cp(PRESET_SRC, destDir, { recursive: true });
68
97
  await cp(SKILLS_SRC, skillsDest, { recursive: true, force: true });
98
+ await writeFile(
99
+ join(destDir, MARKER_FILE),
100
+ `${JSON.stringify({ version, installedAt: new Date().toISOString() }, null, 2)}\n`,
101
+ 'utf8'
102
+ );
69
103
 
70
104
  console.log(`✓ cowork-flow DSH preset installed to ${destDir}`);
71
105
  console.log(' Start a new DSH session and pick the "Cowork Flow" preset.');
@@ -0,0 +1,343 @@
1
+ import { access, copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
2
+ import { dirname, join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+
5
+ import { packageRoot } from '../lib/paths.js';
6
+ import { readPackageInfo } from '../lib/package-info.js';
7
+
8
+ const SHIM_NAME = 'cowork-flow-inject.mjs';
9
+ const SHIM_SRC = join(packageRoot, 'presets', 'kimi-code', 'hooks', SHIM_NAME);
10
+ const MARKER_FILE = '.cowork-flow-kimi-hook.json';
11
+ const HOOK_EVENT = 'UserPromptSubmit';
12
+ // Kimi Code's own hook default; kept explicit so an edit is visible here.
13
+ const HOOK_TIMEOUT = 30;
14
+ const MANAGED_START = '# cowork-flow: kimi hook start. Managed by "cowork-flow install-kimi-hook"; edits inside this block are replaced.';
15
+ const MANAGED_END = '# cowork-flow: kimi hook end.';
16
+
17
+
18
+ function parseArgs(args) {
19
+ return {
20
+ dryRun: args.includes('--dry-run'),
21
+ force: args.includes('--force'),
22
+ uninstall: args.includes('--uninstall')
23
+ };
24
+ }
25
+
26
+
27
+ function getKimiHome() {
28
+ return process.env.KIMI_CODE_HOME || join(homedir(), '.kimi-code');
29
+ }
30
+
31
+
32
+ async function pathExists(target) {
33
+ try {
34
+ await access(target);
35
+ return true;
36
+ } catch {
37
+ return false;
38
+ }
39
+ }
40
+
41
+
42
+ async function readIfExists(file) {
43
+ try {
44
+ return await readFile(file, 'utf8');
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+
51
+ /**
52
+ * Kimi Code's user-level config.toml, holding the `[[hooks]]` rows.
53
+ */
54
+ function configFile(home) {
55
+ return join(home, 'config.toml');
56
+ }
57
+
58
+
59
+ /**
60
+ * Transport shim installed beside the config, spawned as
61
+ * `node <home>/hooks/cowork-flow-inject.mjs` from every session.
62
+ */
63
+ function shimDest(home) {
64
+ return join(home, 'hooks', SHIM_NAME);
65
+ }
66
+
67
+
68
+ /**
69
+ * Version marker for the installed hook, written next to the shim. The
70
+ * installed files live outside any project, so nothing else records which
71
+ * release put them there — doctor reads this to tell an up-to-date install
72
+ * from one that predates the project runtime.
73
+ */
74
+ function markerDest(home) {
75
+ return join(home, 'hooks', MARKER_FILE);
76
+ }
77
+
78
+
79
+ /**
80
+ * One `[[hooks]]` row that pipes every UserPromptSubmit payload into the
81
+ * shim. Kimi Code allows exactly four hook fields — event, matcher, command,
82
+ * timeout — and rejects the whole config when a row carries anything else,
83
+ * so the row is deliberately minimal. `matcher` is omitted on purpose:
84
+ * omitted means "match every prompt". Paths are quoted because a Kimi Code
85
+ * home may contain spaces (Windows user profiles).
86
+ */
87
+ function hookBlock(home) {
88
+ const command = 'node "' + shimDest(home) + '"';
89
+ return [
90
+ MANAGED_START,
91
+ '[[hooks]]',
92
+ 'event = ' + JSON.stringify(HOOK_EVENT),
93
+ 'command = ' + JSON.stringify(command),
94
+ 'timeout = ' + HOOK_TIMEOUT,
95
+ MANAGED_END
96
+ ].join('\n');
97
+ }
98
+
99
+
100
+ /**
101
+ * End of the `[[hooks]]` row starting at `start`: the next table header, the
102
+ * next row of the same array, or the end of file. Trailing blank lines stay
103
+ * outside the range so removal leaves them for the surrounding layout.
104
+ */
105
+ function hookRowEnd(lines, start) {
106
+ let end = start + 1;
107
+ while (end < lines.length) {
108
+ const trimmed = lines[end].trim();
109
+ if (trimmed === '[[hooks]]' || (trimmed.startsWith('[') && trimmed.endsWith(']'))) {
110
+ break;
111
+ }
112
+ end += 1;
113
+ }
114
+ while (end > start + 1 && lines[end - 1].trim() === '') {
115
+ end -= 1;
116
+ }
117
+ return end;
118
+ }
119
+
120
+
121
+ /**
122
+ * Locate the managed block inside the config text.
123
+ *
124
+ * The config is edited as text on purpose: a TOML round-trip would rewrite
125
+ * every pretty-printed entry of the user's own config (providers, models,
126
+ * permissions), and the file must stay byte-stable outside the block. The
127
+ * marker comments take precedence so the block is removed even when the row
128
+ * no longer resembles ours; without markers, the only ownership signal left
129
+ * is a `[[hooks]]` row whose command names this shim. Returns null when
130
+ * neither exists.
131
+ */
132
+ function findManagedBlock(text, home) {
133
+ const lines = text.split('\n');
134
+ const marked = lines.findIndex((line) => line.trim() === MANAGED_START);
135
+ if (marked !== -1) {
136
+ let end = marked + 1;
137
+ while (end < lines.length && lines[end].trim() !== MANAGED_END) {
138
+ end += 1;
139
+ }
140
+ return { start: marked, end: Math.min(end + 1, lines.length) };
141
+ }
142
+ const owns = shimDest(home).replace(/\\/g, '/');
143
+ for (let index = 0; index < lines.length; index += 1) {
144
+ if (lines[index].trim() !== '[[hooks]]') {
145
+ continue;
146
+ }
147
+ const end = hookRowEnd(lines, index);
148
+ const row = lines.slice(index, end).join('\n');
149
+ if (row.includes(MANAGED_START) || row.replace(/\\/g, '/').includes(owns)) {
150
+ return { start: index, end };
151
+ }
152
+ }
153
+ return null;
154
+ }
155
+
156
+
157
+ function withBlock(text, home) {
158
+ const block = hookBlock(home).split('\n');
159
+ const range = findManagedBlock(text, home);
160
+ if (range !== null) {
161
+ const lines = text.split('\n');
162
+ if (lines.slice(range.start, range.end).join('\n') === block.join('\n')) {
163
+ return { text, changed: false, existed: true };
164
+ }
165
+ return {
166
+ text: lines.slice(0, range.start).concat(block).concat(lines.slice(range.end)).join('\n'),
167
+ changed: true,
168
+ existed: true
169
+ };
170
+ }
171
+ const base = text.replace(/\s+$/, '');
172
+ const appended = base === '' ? block.join('\n') : base + '\n\n' + block.join('\n');
173
+ return { text: appended + '\n', changed: true, existed: false };
174
+ }
175
+
176
+
177
+ function withoutBlock(text, home) {
178
+ const range = findManagedBlock(text, home);
179
+ if (range === null) {
180
+ return { text, changed: false };
181
+ }
182
+ const lines = text.split('\n');
183
+ const kept = lines.slice(0, range.start).concat(lines.slice(range.end));
184
+ const body = kept.join('\n').replace(/^\n+/, '').replace(/\s+$/, '');
185
+ if (body === '') {
186
+ // Nothing of the user's own config remains: an empty file left behind
187
+ // would be a config that only ever held this hook.
188
+ return { text: '', changed: true };
189
+ }
190
+ return { text: body + '\n', changed: true };
191
+ }
192
+
193
+
194
+ /**
195
+ * Compact line preview for --dry-run: every line that differs plus its
196
+ * neighbours, so the operator sees where the block lands without a full-file
197
+ * dump.
198
+ */
199
+ function previewDiff(before, after) {
200
+ // A trailing newline is a line terminator, not an extra empty line; without
201
+ // this the preview would always report a phantom last-line change.
202
+ const toLines = (text) => {
203
+ if (text === '') {
204
+ return [];
205
+ }
206
+ return (text.endsWith('\n') ? text.slice(0, -1) : text).split('\n');
207
+ };
208
+ const left = toLines(before);
209
+ const right = toLines(after);
210
+ const total = Math.max(left.length, right.length);
211
+ const shown = new Set();
212
+ for (let index = 0; index < total; index += 1) {
213
+ if (left[index] !== right[index]) {
214
+ for (let offset = -2; offset <= 2; offset += 1) {
215
+ if (index + offset >= 0 && index + offset < total) {
216
+ shown.add(index + offset);
217
+ }
218
+ }
219
+ }
220
+ }
221
+ const lines = [];
222
+ let previous = -1;
223
+ for (const index of [...shown].sort((a, b) => a - b)) {
224
+ if (previous !== -1 && index > previous + 1) {
225
+ lines.push(' ...');
226
+ }
227
+ const marker = left[index] === right[index] ? ' ' : '+ ';
228
+ lines.push(' ' + (index + 1) + ' ' + marker + (right[index] ?? ''));
229
+ previous = index;
230
+ }
231
+ return lines;
232
+ }
233
+
234
+
235
+ /**
236
+ * Install (or uninstall) the user-level Kimi Code context-injection hook.
237
+ *
238
+ * Kimi Code reads hooks only from $KIMI_CODE_HOME/config.toml, so the shim is
239
+ * installed under the same home and every session — in every project —
240
+ * invokes it; the shim itself exits silently outside cowork-flow projects.
241
+ * Only the Kimi Code home is written: no project file is touched.
242
+ */
243
+ export async function runInstallKimiHook(args = []) {
244
+ const { dryRun, uninstall } = parseArgs(args);
245
+
246
+ if (!(await pathExists(SHIM_SRC))) {
247
+ throw new Error('Kimi Code hook shim missing at ' + SHIM_SRC + '. Reinstall cowork-flow.');
248
+ }
249
+
250
+ const home = getKimiHome();
251
+ const config = configFile(home);
252
+ const shim = shimDest(home);
253
+ const marker = markerDest(home);
254
+
255
+ if (dryRun) {
256
+ const before = await readIfExists(config);
257
+ if (uninstall) {
258
+ const result = before === null ? { changed: false } : withoutBlock(before, home);
259
+ console.log('[dry-run] Would uninstall Kimi Code context hook:');
260
+ console.log(
261
+ ' Config: ' + config
262
+ + (before === null
263
+ ? ' (absent; nothing to remove)'
264
+ : (result.changed ? ' (managed block found)' : ' (no managed block)'))
265
+ );
266
+ console.log(' Remove shim: ' + shim);
267
+ console.log(' Remove marker: ' + marker);
268
+ return;
269
+ }
270
+ console.log('[dry-run] Would install Kimi Code context hook:');
271
+ console.log(' Shim: ' + SHIM_SRC + ' -> ' + shim);
272
+ console.log(' Marker: ' + marker);
273
+ if (before === null) {
274
+ console.log(' Create config: ' + config);
275
+ for (const line of previewDiff('', hookBlock(home))) {
276
+ console.log(line);
277
+ }
278
+ return;
279
+ }
280
+ const result = withBlock(before, home);
281
+ if (!result.changed) {
282
+ console.log(' Config: ' + config + ' (managed block already up to date)');
283
+ return;
284
+ }
285
+ console.log(' Config: ' + config + (result.existed ? ' (update managed block)' : ' (append managed block)'));
286
+ for (const line of previewDiff(before, result.text)) {
287
+ console.log(line);
288
+ }
289
+ return;
290
+ }
291
+
292
+ if (uninstall) {
293
+ const before = await readIfExists(config);
294
+ if (before === null) {
295
+ console.log('No Kimi Code config at ' + config + '; nothing to uninstall.');
296
+ } else {
297
+ const result = withoutBlock(before, home);
298
+ if (!result.changed) {
299
+ console.log('No managed hook block found in ' + config + '.');
300
+ } else if (result.text === '') {
301
+ await rm(config, { force: true });
302
+ console.log('✓ Kimi Code context hook removed; ' + config + ' held nothing else and was deleted.');
303
+ } else {
304
+ await writeFile(config, result.text, 'utf8');
305
+ console.log('✓ Kimi Code context hook removed from ' + config);
306
+ }
307
+ }
308
+ const hadShim = await pathExists(shim);
309
+ await rm(shim, { force: true });
310
+ if (hadShim) {
311
+ console.log('✓ Removed hook shim ' + shim);
312
+ }
313
+ const hadMarker = await pathExists(marker);
314
+ await rm(marker, { force: true });
315
+ if (hadMarker) {
316
+ console.log('✓ Removed version marker ' + marker);
317
+ }
318
+ return;
319
+ }
320
+
321
+ const { version } = await readPackageInfo();
322
+ await mkdir(dirname(shim), { recursive: true });
323
+ await copyFile(SHIM_SRC, shim);
324
+ await writeFile(
325
+ marker,
326
+ `${JSON.stringify({ version, installedAt: new Date().toISOString() }, null, 2)}\n`,
327
+ 'utf8'
328
+ );
329
+ const before = (await readIfExists(config)) ?? '';
330
+ const result = withBlock(before, home);
331
+ if (result.changed) {
332
+ await writeFile(config, result.text, 'utf8');
333
+ }
334
+
335
+ console.log('✓ cowork-flow Kimi Code hook shim installed to ' + shim);
336
+ console.log(' Recorded version ' + version + ' in ' + marker);
337
+ console.log(
338
+ ' Registered UserPromptSubmit hook in ' + config
339
+ + (result.changed ? (result.existed ? ' (block updated)' : ' (block appended)') : ' (already up to date)')
340
+ );
341
+ console.log(' Start a new Kimi Code session (or reload the config) for the hook to load.');
342
+ console.log(' Note: injection happens once per submitted prompt; other Kimi Code events cannot inject.');
343
+ }
@@ -19,12 +19,6 @@ const REQUIRED_HOST_NEUTRAL_CAPABILITIES = [
19
19
  'file_write',
20
20
  'party_board_action'
21
21
  ];
22
- const REQUIRED_CAPABILITY_MATRIX_HOSTS = [
23
- 'codex',
24
- 'claude-code',
25
- 'opencode',
26
- 'zcode'
27
- ];
28
22
  const CAPABILITY_STATUS_VALUES = [
29
23
  'native',
30
24
  'shim',
@@ -406,10 +400,7 @@ function validateCapabilityMatrix(matrix, allowed, platformIds) {
406
400
  if (!matrix.hosts || typeof matrix.hosts !== 'object' || Array.isArray(matrix.hosts)) {
407
401
  throw new Error('Host Asset Manifest capabilityMatrix.hosts must be an object');
408
402
  }
409
- const requiredHosts = new Set([
410
- ...REQUIRED_CAPABILITY_MATRIX_HOSTS,
411
- ...platformIds
412
- ]);
403
+ const requiredHosts = new Set([...platformIds]);
413
404
  for (const hostId of [...requiredHosts].sort()) {
414
405
  if (!matrix.hosts[hostId]) {
415
406
  throw new Error(`Host Asset Manifest capability matrix missing host: ${hostId}`);
@@ -10,6 +10,18 @@
10
10
  }
11
11
  ]
12
12
  }
13
+ ],
14
+ "PostToolUse": [
15
+ {
16
+ "matcher": "apply_patch|Write|Edit",
17
+ "hooks": [
18
+ {
19
+ "type": "command",
20
+ "command": ".cowork-flow/run python .codex/hooks/inject-workflow-state.py",
21
+ "timeout": 5
22
+ }
23
+ ]
24
+ }
13
25
  ]
14
26
  }
15
27
  }
@@ -1 +1 @@
1
- 1.4.0
1
+ 1.6.0
@@ -0,0 +1,31 @@
1
+ schemaVersion: 1
2
+ host: kimi-code
3
+ displayName: Kimi Code
4
+ capabilities:
5
+ dispatchSubagent: native
6
+ freshChildContext: native
7
+ sendFollowup: native
8
+ waitChild: native
9
+ listChildren: native
10
+ cancelChild: native
11
+ stateInjection: shim
12
+ backgroundChild: native
13
+ runtimeContextDispatch: native
14
+ runtimeContextBinding: shim
15
+ runtimeContextCleanup: native
16
+ editScopeWarning: unsupported
17
+ dispatch:
18
+ primitive: subagent
19
+ agentsPath: .kimi-code/agents
20
+ skillsPath: .agents/skills
21
+ runtimeContext:
22
+ transport: prompt
23
+ promptKey: cowork_runtime_context_id
24
+ envKey: COWORK_FLOW_RUNTIME_CONTEXT_ID
25
+ metadataKey: cowork_runtime_context_id
26
+ contracts:
27
+ dispatch: RUNTIME_CONTEXT_DISPATCH_V2
28
+ leafExecutor: true
29
+ fallback:
30
+ whenRequiredCapabilityMissing: inline_or_manual
31
+ whenRuntimeContextMissing: fail_closed
@@ -33,7 +33,7 @@ def main() -> int:
33
33
  "--phase",
34
34
  choices=("edit", "lifecycle"),
35
35
  default="lifecycle",
36
- help="edit: only declarations matching the given files (3s clamp); "
36
+ help="edit: only declarations matching the given files (2.5s clamp); "
37
37
  "lifecycle: every declaration honoring its files scope.",
38
38
  )
39
39
  parser.add_argument(
@@ -4,7 +4,7 @@
4
4
 
5
5
  Assembles one machine-readable fact view per task (task.json, decision-anchor
6
6
  essentials, plan binding, bound sessions, trusted snapshot). See
7
- services/fact_view.py and docs/direction.md stage 1.
7
+ services/fact_view.py and the fact-layer stage 1 design.
8
8
  """
9
9
 
10
10
  from __future__ import annotations
@@ -16,6 +16,13 @@ if __package__:
16
16
  from . import _bootstrap as _bootstrap # noqa: F401
17
17
  else:
18
18
  import _bootstrap # noqa: F401
19
+ from runtime.host_identity import (
20
+ HostIdentity,
21
+ declared_host,
22
+ detect_host,
23
+ host_ids,
24
+ identity_for,
25
+ )
19
26
  from runtime.session_state import (
20
27
  resolve_context_key,
21
28
  runtime_context_path,
@@ -66,27 +73,34 @@ def _next_id(base_dir: Path, title: str) -> str:
66
73
  return candidate
67
74
 
68
75
 
69
- def _detect_host() -> str:
70
- env = os.environ
71
- if env.get("CLAUDE_CODE_SESSION_ID") or env.get("CLAUDE_SESSION_ID"):
72
- return "claude-code"
73
- if env.get("CODEX_SESSION_ID") or env.get("CODEX_THREAD_ID"):
74
- return "codex"
75
- if env.get("ZCODE_SESSION_ID"):
76
- return "zcode"
77
- if env.get("OPENCODE_SESSION_ID"):
78
- return "opencode"
79
- return "codex"
80
-
81
-
82
- def _detect_adapter(host: str) -> str:
83
- adapters = {
84
- "claude-code": "claude-code.hooks",
85
- "codex": "codex.spawn_agent",
86
- "opencode": "opencode.task",
87
- "zcode": "zcode.plugin",
88
- }
89
- return adapters.get(host, "codex.spawn_agent")
76
+ def _detect_host() -> str | None:
77
+ """The host whose per-session env var is present, or None.
78
+
79
+ None is the honest answer when this process carries no host evidence:
80
+ defaulting to another host would file this session's runtime context
81
+ under that host's identity.
82
+ """
83
+ return detect_host(os.environ)
84
+
85
+
86
+ def _resolve_host(host_id: str | None) -> HostIdentity:
87
+ """The host for this dispatch, or ValueError when it cannot be established.
88
+
89
+ Guessing another host would file this session's runtime context under that
90
+ host's identity, so a missing or unregistered host is refused instead.
91
+ """
92
+ resolved = host_id or declared_host(os.environ) or _detect_host()
93
+ if resolved is None:
94
+ raise ValueError(
95
+ "host is required when no session host is detectable; "
96
+ f"pass --host with one of: {', '.join(host_ids())}"
97
+ )
98
+ identity = identity_for(resolved)
99
+ if identity is None:
100
+ raise ValueError(
101
+ f"unknown host: {resolved}; known hosts: {', '.join(host_ids())}"
102
+ )
103
+ return identity
90
104
 
91
105
 
92
106
  def _host_context_prefix(host: str) -> str:
@@ -128,13 +142,14 @@ def cmd_init(args: argparse.Namespace) -> int:
128
142
  base_dir = subagent_contexts_dir(repo_root)
129
143
  runtime_context_id = _next_id(base_dir, args.title)
130
144
  task_dir = getattr(args, "execution_task_dir", None)
131
- host = args.host or _detect_host()
132
- adapter = args.adapter or _detect_adapter(host)
133
145
  try:
146
+ identity = _resolve_host(args.host)
147
+ adapter = args.adapter or identity.adapter
134
148
  agent_type, dispatch_kind = _resolve_agent_type(args.role, getattr(args, "agent_type", None))
135
149
  except ValueError as error:
136
150
  print(f"Error: {error}", file=sys.stderr)
137
151
  return 1
152
+ host = identity.id
138
153
  if dispatch_kind == "formal" and not task_dir:
139
154
  print("Error: fixed agent dispatch requires --execution-task-dir", file=sys.stderr)
140
155
  return 1
@@ -19,13 +19,10 @@ from services.context_discovery import (
19
19
  get_implement_spec,
20
20
  )
21
21
  from services.task_context import (
22
- detect_installed_platforms as _detect_installed_platforms,
23
22
  discover_spec_files as _discover_spec_files,
24
23
  get_check_context,
25
24
  get_debug_context,
26
- is_skill_path as _is_skill_path,
27
25
  iter_jsonl_lines as _iter_jsonl_lines,
28
- skill_path as _skill_path,
29
26
  write_jsonl as _write_jsonl,
30
27
  )
31
28
  from adapters.cli.task_archive_commands import cmd_archive
@@ -82,7 +82,9 @@ def _resolve_status_task_dir(
82
82
  print(
83
83
  colored(
84
84
  "Error: Session binding is process-fallback (shared); "
85
- "pass a task dir explicitly.",
85
+ "set COWORK_FLOW_CONTEXT_ID to the session value shown "
86
+ "in the workflow-state header, or pass a task dir "
87
+ "explicitly.",
86
88
  Colors.RED,
87
89
  ),
88
90
  file=sys.stderr,
@@ -368,7 +370,8 @@ def cmd_finish(args: argparse.Namespace) -> int:
368
370
  print(
369
371
  colored(
370
372
  "Error: Refusing to clear a session binding resolved from "
371
- "process-fallback identity; archive the task to clear it.",
373
+ "process-fallback identity; archive the task to clear it, or "
374
+ "set COWORK_FLOW_CONTEXT_ID to your own session identity.",
372
375
  Colors.RED,
373
376
  ),
374
377
  file=sys.stderr,
@@ -3,9 +3,9 @@
3
3
  workflow hook.
4
4
 
5
5
  Rendering stays in workflow_state_hook.py; this module owns only the
6
- claude-code transport specifics — the session alias ported from the claude
7
- wrapper, the runtime preamble block, and the PostToolUse spec-advisory path
8
- (stderr + exit 2, shared with codex via the neutral helper).
6
+ claude-code transport specifics — the runtime preamble block and the
7
+ PostToolUse spec-advisory path (stderr + exit 2, shared with codex via the
8
+ neutral helper).
9
9
  """
10
10
 
11
11
  from __future__ import annotations
@@ -24,16 +24,6 @@ CLAUDE_PREAMBLE = (
24
24
  )
25
25
 
26
26
 
27
- def claude_session_alias(data: dict[str, Any]) -> dict[str, Any]:
28
- # Ported from the claude-code wrapper: bare session_id must resolve as a
29
- # claude session, not fall through to the codex session_id rule.
30
- if "claude_session_id" not in data:
31
- session_id = data.get("session_id")
32
- if isinstance(session_id, str) and session_id.strip():
33
- data["claude_session_id"] = session_id
34
- return data
35
-
36
-
37
27
  def preamble(root: Path) -> tuple[str, ...]:
38
28
  return CLAUDE_PREAMBLE
39
29
 
@@ -46,7 +36,6 @@ def post_tool_use(root: Path, hook_input: dict[str, Any]) -> tuple[str, int]:
46
36
 
47
37
  POLICY = HostPolicy(
48
38
  host="claude-code",
49
- session_alias=claude_session_alias,
50
39
  preamble=preamble,
51
40
  post_tool_use=post_tool_use,
52
41
  )