taskchef 7.27.2 → 7.28.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskchef",
3
- "version": "7.27.2",
3
+ "version": "7.28.1",
4
4
  "description": "Dispatch work from a data-only workspace to visible Codex project tasks.",
5
5
  "author": {
6
6
  "name": "Favo Yang",
package/README.md CHANGED
@@ -162,6 +162,16 @@ Use $taskchef-executor to execute and report this delegated TaskChef assignment.
162
162
  <!-- taskchef_id=c0f010ff-84f2-4838-a69d-0ff1f5d721d7 -->
163
163
  ```
164
164
 
165
+ ### Model roles
166
+
167
+ TaskChef can apply personal Planner, Implementer, and Reviewer model preferences
168
+ from `~/.codex/agents/`. Planning-only tasks use Planner, while coding tasks and
169
+ single tasks that plan and then execute use Implementer throughout. To apply
170
+ both models, finish a Planner task that saves its plan, then create a separate
171
+ Implementer task using that plan. Reviewer preferences apply when a workflow
172
+ explicitly starts a review subagent. Explicit per-task model choices take
173
+ precedence, and changes affect only tasks created afterward.
174
+
165
175
  ## Work with and report executors
166
176
 
167
177
  Open an executor as an ordinary Codex task. Each executor reports `working`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskchef",
3
- "version": "7.27.2",
3
+ "version": "7.28.1",
4
4
  "description": "A non-blocking interactive dispatcher for visible Codex tasks.",
5
5
  "license": "MIT",
6
6
  "author": "Favo Yang",
@@ -37,6 +37,7 @@
37
37
  "docs/images/reported-work-duration-mobile.jpg",
38
38
  "index.js",
39
39
  "mcp",
40
+ "scripts/roles",
40
41
  "scripts/benchmark-dispatch-prepare.js",
41
42
  "scripts/e2e-benchmark.js",
42
43
  "src",
@@ -0,0 +1,293 @@
1
+ #!/usr/bin/env python3
2
+ """Read native Codex agent preferences without applying their instruction layers.
3
+
4
+ Requires Python 3.11+. Standalone, standard library only. TaskChef packages an
5
+ identical copy; neither installation imports the other at runtime.
6
+ """
7
+ import argparse
8
+ import json
9
+ import os
10
+ from pathlib import Path
11
+ import re
12
+ import sys
13
+ import tomllib
14
+
15
+ ROLES = ('planner', 'implementer', 'reviewer')
16
+ EFFORTS = ('none', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra')
17
+
18
+
19
+ def read_layer(directory):
20
+ found = {}
21
+ problems = []
22
+ try:
23
+ with os.scandir(directory) as entries:
24
+ files = sorted(Path(entry.path) for entry in entries if entry.name.endswith('.toml'))
25
+ for file in files:
26
+ try:
27
+ if file.stat().st_size > 65536:
28
+ raise ValueError('profile exceeds 64 KiB')
29
+ with file.open('rb') as stream:
30
+ data = tomllib.load(stream)
31
+ name = data.get('name')
32
+ if name not in ROLES:
33
+ if file.stem in ROLES:
34
+ raise ValueError('name must match the role filename')
35
+ continue
36
+ errors = []
37
+ for field in ('description', 'developer_instructions'):
38
+ if not isinstance(data.get(field), str) or not data[field].strip():
39
+ errors.append(f'{field} must be a nonempty string')
40
+ model = data.get('model')
41
+ effort = data.get('model_reasoning_effort')
42
+ entry = {'source': str(file), 'model': model, 'effort': effort, 'problems': errors}
43
+ if name in found:
44
+ entry['problems'].append('duplicate role name in the same directory')
45
+ found[name] = entry
46
+ except (OSError, ValueError) as error:
47
+ # Do not expose TOML lines, which can contain unrelated private data.
48
+ message = f'{file}: unreadable or malformed agent TOML ({type(error).__name__})'
49
+ problems.append(message)
50
+ if file.stem in ROLES:
51
+ found[file.stem] = {'source': str(file), 'model': None, 'effort': None, 'problems': [message]}
52
+ except FileNotFoundError:
53
+ return found, problems
54
+ except OSError:
55
+ message = f'{directory}: cannot read agent directory'
56
+ problems.append(message)
57
+ found = {role: {'source': str(directory), 'model': None, 'effort': None, 'problems': [message]} for role in ROLES}
58
+ return found, problems
59
+
60
+
61
+ def resolve(codex_home=None, explicit_model=None, explicit_effort=None):
62
+ home = Path(codex_home or os.environ.get('CODEX_HOME') or Path.home() / '.codex').expanduser().resolve()
63
+ personal, problems = read_layer(home / 'agents')
64
+ catalog = None
65
+ catalog_source = str(home / 'models_cache.json')
66
+ try:
67
+ cache = json.loads(Path(catalog_source).read_text())
68
+ if not isinstance(cache, dict) or not isinstance(cache.get('models'), list):
69
+ raise ValueError('invalid catalog')
70
+ for model in cache['models']:
71
+ if not isinstance(model, dict) or not isinstance(model.get('slug'), str):
72
+ raise ValueError('invalid model entry')
73
+ levels = model.get('supported_reasoning_levels')
74
+ if not isinstance(levels, list) or any(not isinstance(level, dict) or not isinstance(level.get('effort'), str) for level in levels):
75
+ raise ValueError('invalid effort catalog')
76
+ catalog = {model['slug']: model for model in cache['models']}
77
+ except FileNotFoundError:
78
+ pass
79
+ except (OSError, ValueError, KeyError, TypeError):
80
+ problems.append('Local Codex model catalog is unreadable or malformed; validate availability with the current native tool.')
81
+ results = []
82
+ for role in ROLES:
83
+ entry = personal.get(role)
84
+ source = entry['source'] if entry else None
85
+ model = entry['model'] if entry else None
86
+ effort = entry['effort'] if entry else None
87
+ errors = list(entry['problems']) if entry else []
88
+ if explicit_model is not None:
89
+ model, effort = explicit_model, explicit_effort
90
+ effective_source = 'explicit user model choice'
91
+ errors = []
92
+ else:
93
+ effective_source = source
94
+ if explicit_effort is not None:
95
+ effort = explicit_effort
96
+ if model is not None and (not isinstance(model, str) or not model.strip() or len(model) > 256):
97
+ errors.append('model must be a nonempty string of at most 256 characters')
98
+ if effort is not None and effort not in EFFORTS:
99
+ errors.append('reasoning effort is unsupported')
100
+ status = 'invalid' if errors else 'configured' if (model is not None or effort is not None) else 'missing'
101
+ availability = 'not checked; native tool must validate before dispatch'
102
+ if model and not errors and catalog is not None:
103
+ if model not in catalog:
104
+ errors.append('model absent from local Codex catalog; confirm against the current native tool before dispatch')
105
+ status = 'unavailable'
106
+ else:
107
+ supported = [v.get('effort') for v in catalog[model].get('supported_reasoning_levels', []) if isinstance(v, dict)]
108
+ if effort and effort not in supported:
109
+ errors.append('effort absent from local model catalog; confirm against the current native tool')
110
+ status = 'unavailable'
111
+ availability = 'present in cached catalog; current native tool is authoritative'
112
+ overrides = {}
113
+ if status == 'configured':
114
+ if model is not None:
115
+ overrides['model'] = model
116
+ if effort is not None:
117
+ overrides['thinking'] = effort
118
+ results.append({'role': role, 'source': source, 'effectiveSource': effective_source,
119
+ 'model': model, 'effort': effort, 'status': status,
120
+ 'availability': availability, 'problems': errors,
121
+ 'taskOverrides': overrides,
122
+ 'subagentOverrides': {('reasoning_effort' if k == 'thinking' else k): v for k, v in overrides.items()},
123
+ 'fallback': 'inherit parent settings' if role == 'reviewer' else 'native new-task default (not guaranteed dispatcher inheritance)'})
124
+ model_options = [] if catalog is None else [
125
+ {'value': slug, 'label': entry.get('display_name') or slug,
126
+ 'efforts': [level['effort'] for level in entry['supported_reasoning_levels']]}
127
+ for slug, entry in catalog.items() if entry.get('visibility') != 'hide'
128
+ ]
129
+ return {'roles': results, 'problems': problems, 'catalogSource': catalog_source if catalog is not None else None,
130
+ 'modelOptions': model_options,
131
+ 'precedence': 'explicit user model (and its explicit effort) > personal role > native defaults; explicit effort alone overrides role effort',
132
+ 'scope': 'Model and effort only; agent instructions, tools, permissions, and other TOML keys are not applied by this adapter.'}
133
+
134
+
135
+ def setup(role, model, effort, codex_home=None):
136
+ home = Path(codex_home or os.environ.get('CODEX_HOME') or Path.home() / '.codex').expanduser().resolve()
137
+ existing, problems = read_layer(home / 'agents')
138
+ if role in existing or problems:
139
+ return {'created': False, 'role': role, 'problems': problems or ['existing role preserved'], 'source': existing.get(role, {}).get('source')}
140
+ if not model.strip() or len(model) > 256 or effort not in EFFORTS:
141
+ raise ValueError('invalid model or effort')
142
+ directory = home / 'agents'
143
+ directory.mkdir(parents=True, exist_ok=True)
144
+ target = directory / f'{role}.toml'
145
+ data = {'name': role, 'description': f'{role.capitalize()} model preferences',
146
+ 'developer_instructions': f'Perform the assigned {role} work within the user\'s requested scope.',
147
+ 'model': model, 'model_reasoning_effort': effort}
148
+ try:
149
+ with target.open('x') as stream:
150
+ stream.write(''.join(f'{key} = {json.dumps(value)}\n' for key, value in data.items()))
151
+ except FileExistsError:
152
+ return {'created': False, 'role': role, 'source': str(target), 'problems': ['existing file preserved']}
153
+ return {'created': True, 'role': role, 'source': str(target), 'model': model, 'effort': effort}
154
+
155
+
156
+ def multiline_state(line, state=None):
157
+ """Track TOML multiline strings without interpreting their contents as keys."""
158
+ index = 0
159
+ while index < len(line):
160
+ if state:
161
+ closing = line.find(state, index)
162
+ while closing >= 0 and state == '"""':
163
+ escapes = 0
164
+ cursor = closing - 1
165
+ while cursor >= 0 and line[cursor] == '\\':
166
+ escapes += 1
167
+ cursor -= 1
168
+ if escapes % 2 == 0:
169
+ break
170
+ closing = line.find(state, closing + 3)
171
+ if closing < 0:
172
+ return state
173
+ state = None
174
+ index = closing + 3
175
+ continue
176
+ if line[index] == '#':
177
+ return None
178
+ if line.startswith('"""', index) or line.startswith("'''", index):
179
+ state = line[index:index + 3]
180
+ index += 3
181
+ continue
182
+ if line[index] in ('"', "'"):
183
+ quote = line[index]
184
+ index += 1
185
+ while index < len(line):
186
+ if line[index] == quote and (quote == "'" or index == 0 or line[index - 1] != '\\'):
187
+ index += 1
188
+ break
189
+ if quote == '"' and line[index] == '\\':
190
+ index += 2
191
+ else:
192
+ index += 1
193
+ continue
194
+ index += 1
195
+ return state
196
+
197
+
198
+ def replace_top_level_preferences(content, model, effort):
199
+ replacements = {
200
+ 'model': json.dumps(model),
201
+ 'model_reasoning_effort': json.dumps(effort),
202
+ }
203
+ assignment = re.compile(
204
+ r'^(?P<prefix>\s*(?P<key>model|model_reasoning_effort|["\']model["\']|["\']model_reasoning_effort["\'])\s*=\s*).*$'
205
+ )
206
+ lines = content.splitlines(keepends=True)
207
+ state = None
208
+ boundary = len(lines)
209
+ found = set()
210
+ for index, line in enumerate(lines):
211
+ if state is None and re.match(r'^\s*\[', line):
212
+ boundary = index
213
+ break
214
+ if state is None:
215
+ body = line.rstrip('\r\n')
216
+ ending = line[len(body):]
217
+ match = assignment.match(body)
218
+ if match:
219
+ key = match.group('key').strip('"\'')
220
+ lines[index] = f"{match.group('prefix')}{replacements[key]}{ending}"
221
+ found.add(key)
222
+ state = multiline_state(line, state)
223
+ missing = [f'{key} = {value}\n' for key, value in replacements.items() if key not in found]
224
+ if missing:
225
+ prefix = '' if boundary == 0 or lines[boundary - 1].endswith(('\n', '\r')) else '\n'
226
+ lines[boundary:boundary] = [prefix + ''.join(missing)]
227
+ candidate = ''.join(lines)
228
+ expected = tomllib.loads(content)
229
+ expected['model'] = model
230
+ expected['model_reasoning_effort'] = effort
231
+ if tomllib.loads(candidate) != expected:
232
+ raise ValueError('agent TOML could not be updated without changing unrelated settings')
233
+ return candidate
234
+
235
+
236
+ def update(role, model, effort, codex_home=None):
237
+ home = Path(codex_home or os.environ.get('CODEX_HOME') or Path.home() / '.codex').expanduser().resolve()
238
+ resolved = resolve(codex_home=str(home))
239
+ options = {entry['value']: entry for entry in resolved['modelOptions']}
240
+ if model not in options or effort not in options[model]['efforts']:
241
+ raise ValueError('model or reasoning effort is unavailable')
242
+ existing, layer_problems = read_layer(home / 'agents')
243
+ role_entry = existing.get(role)
244
+ if layer_problems or (role_entry and role_entry['problems']):
245
+ raise ValueError('existing role configuration must be fixed before editing')
246
+ if role_entry is None:
247
+ return setup(role, model, effort, str(home))
248
+ target = Path(role_entry['source'])
249
+ agents = (home / 'agents').resolve()
250
+ if target.is_symlink() or target.resolve().parent != agents:
251
+ raise ValueError('role source is outside the personal agent directory')
252
+ content = replace_top_level_preferences(target.read_text(), model, effort)
253
+ temporary = target.with_name(f'.{target.name}.{os.getpid()}.tmp')
254
+ try:
255
+ temporary.write_text(content)
256
+ os.chmod(temporary, target.stat().st_mode & 0o777)
257
+ os.replace(temporary, target)
258
+ finally:
259
+ try:
260
+ temporary.unlink()
261
+ except FileNotFoundError:
262
+ pass
263
+ return {'updated': True, 'role': role, 'source': str(target), 'model': model, 'effort': effort}
264
+
265
+
266
+ def main():
267
+ parser = argparse.ArgumentParser(description=__doc__)
268
+ parser.add_argument('--codex-home')
269
+ parser.add_argument('--role', choices=ROLES)
270
+ parser.add_argument('--model')
271
+ parser.add_argument('--effort')
272
+ parser.add_argument('--setup', action='store_true')
273
+ parser.add_argument('--update', action='store_true')
274
+ args = parser.parse_args()
275
+ if args.setup and args.update:
276
+ parser.error('--setup and --update are mutually exclusive')
277
+ if args.setup:
278
+ if not (args.role and args.model and args.effort):
279
+ parser.error('--setup requires --role, --model, and --effort')
280
+ result = setup(args.role, args.model, args.effort, args.codex_home)
281
+ elif args.update:
282
+ if not (args.role and args.model and args.effort):
283
+ parser.error('--update requires --role, --model, and --effort')
284
+ result = update(args.role, args.model, args.effort, args.codex_home)
285
+ else:
286
+ result = resolve(args.codex_home, args.model, args.effort)
287
+ if args.role:
288
+ result['roles'] = [r for r in result['roles'] if r['role'] == args.role]
289
+ print(json.dumps(result, indent=2))
290
+
291
+
292
+ if __name__ == '__main__':
293
+ main()
@@ -56,3 +56,29 @@ For adding, importing, removing, listing, or reindexing projects, read
56
56
  [project index](references/project-index.md). Preserve exact canonical local
57
57
  Codex project identity and existing curated entries; opening a folder is not
58
58
  proof of registration. Completion requires verifying the intended index entry.
59
+
60
+ ## Optional model roles
61
+
62
+ Offer planner, implementer, and reviewer profiles during setup. Model and effort
63
+ preferences belong in user configuration, never defaults hard-coded in skills.
64
+ If the user provides values, configure them without asking again. Otherwise
65
+ explain the optional profiles and retain native defaults until values are chosen.
66
+
67
+ Run `python3 <plugin-root>/scripts/roles/resolve_roles.py` to preview personal
68
+ roles. Python 3.11+ is required for this optional helper. Create each requested
69
+ missing profile with:
70
+
71
+ ```sh
72
+ python3 <plugin-root>/scripts/roles/resolve_roles.py --setup --role <role> --model <requested-model> --effort <requested-effort>
73
+ ```
74
+
75
+ This authorized role setup may create `$CODEX_HOME/agents/<role>.toml` (default
76
+ `~/.codex/agents/`) outside the dispatcher workspace. Existing definitions,
77
+ including custom filenames, are preserved. Report conflicts and diagnostics;
78
+ never overwrite a custom role. Re-run the preview and check model/effort against
79
+ the available native interface. Missing roles are optional: TaskChef omits
80
+ new-task overrides; reviewers inherit parent settings. Native new-task defaults
81
+ are not guaranteed dispatcher inheritance. Read the delegate's
82
+ [role resolution](../taskchef-delegate/references/model-roles.md) for precedence
83
+ and adapter limits. Open the dashboard's `/#settings` preview to show the
84
+ configured sources, requested effective settings, fallback, and problems.
@@ -49,6 +49,13 @@ stop and report that the TaskChef plugin must be reloaded or installed.
49
49
  3. Route against configured project `name`, `description`, and canonical
50
50
  `githubRepos`; use `path` only as checkout identity. Require exactly one
51
51
  match and an exact native-project path. Ask instead of guessing.
52
+ Select `planner` for a requested planning assignment and `implementer` for
53
+ coding work. Use the personal `modelRoles` from preparation;
54
+ resolve explicit user choices and follow [model roles](references/model-roles.md).
55
+ Small coding changes need no separate planning task. For a planning/coding
56
+ split, the saved plan is the handoff to a separately requested implementation
57
+ assignment; do not switch models during a turn or coordinate phases automatically.
58
+ Resolve configuration problems before recording the task.
52
59
  4. Build each executor instruction in this exact shape:
53
60
 
54
61
  - Begin with the actual assignment on the first line and keep its complete
@@ -0,0 +1,52 @@
1
+ # Dispatch model roles
2
+
3
+ Preparation includes personal model previews. Role preferences use native Codex
4
+ agent TOML under `$CODEX_HOME/agents/` (default `~/.codex/agents/`); they are
5
+ optional. The resolver requires Python 3.11+.
6
+
7
+ Select `planner` for a planning assignment or `implementer` for coding. Pass
8
+ its `taskOverrides` as native `model` and `thinking` arguments only after checking
9
+ those values against the current create-task tool's supported model/effort
10
+ choices. A missing role means omit both arguments and use the native new-task
11
+ default. This is not guaranteed to inherit the dispatcher's model.
12
+
13
+ Explicit user model choices take precedence over the personal role and native
14
+ defaults. An explicit model without explicit effort omits effort and uses the native
15
+ interface's omission behavior; do not mix in a different role's effort or claim
16
+ a specific effective effort without evidence. Explicit effort
17
+ alone replaces the resolved role effort. To resolve explicit choices, or recheck a preview after configuration
18
+ changes, run:
19
+
20
+ ```sh
21
+ python3 <plugin-root>/scripts/roles/resolve_roles.py --role implementer --model <user-model> --effort <user-effort>
22
+ ```
23
+
24
+ Omit flags the user did not specify. Resolve the plugin root from this skill's
25
+ real path, two parents above the skill directory. Do not substitute dispatcher
26
+ paths for the target project or infer model names from skill prose.
27
+
28
+ Report malformed profiles, duplicate names, unavailable models/efforts, and
29
+ resolver failures. Do not quietly fall back and claim preferences were honored.
30
+ The local model catalog is advisory: explain if a current native tool disproves
31
+ its stale warning. Otherwise resolve the problem or obtain a valid explicit
32
+ choice before task creation. Native creation failures retain normal TaskChef
33
+ failure reporting. A preparation without role fields supports historical
34
+ installations: check role files before deciding that no preferences exist.
35
+
36
+ Only model and effort are adapted. The native agent file's instructions, tools,
37
+ permissions, and other settings are not applied by create-task arguments. A
38
+ role name in a title is not a native role selector. If the native interface adds
39
+ a role selector, use it only with verified effective settings.
40
+
41
+ For a plan-to-implementation handoff, a planner assignment can save
42
+ `plans/addition.md` describing a pure addition function and the acceptance
43
+ example `2 + 3 = 5`. A later implementer assignment reads that exact plan,
44
+ implements the function, runs the example, and uses fresh reviewer subagents.
45
+ The dispatcher returns after each creation; it does not wait for or launch
46
+ subsequent phases automatically. A small addition change can instead go
47
+ straight to the implementer.
48
+
49
+ A single assignment that asks to plan and then execute is an implementation
50
+ assignment. Use the implementer model for that whole task; do not switch models
51
+ mid-turn. To apply both configured roles, dispatch a planner task first, wait
52
+ for its saved plan, then dispatch a separate implementer task using that plan.