cowork-flow 0.0.38 → 0.0.39
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.
- package/package.json +1 -1
- package/template/.cowork-flow/.version +1 -1
- package/template/.cowork-flow/scripts/application/task_context.py +134 -25
- package/template/.cowork-flow/scripts/commands/task.py +171 -110
- package/template/.cowork-flow/scripts/commands/task_context_commands.py +18 -1
- package/template/.cowork-flow/scripts/commands/task_create_command.py +51 -24
- package/template/.cowork-flow/scripts/commands/task_parser.py +22 -0
- package/template/.cowork-flow/scripts/common/gates/validate_coding_standards.py +104 -62
- package/template/.cowork-flow/scripts/common/task/active_task.py +36 -74
- package/template/.zcode/.zcode-plugin/plugin.json +1 -1
package/package.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
0.0.
|
|
1
|
+
0.0.39
|
|
@@ -21,6 +21,11 @@ from common.core.paths import (
|
|
|
21
21
|
|
|
22
22
|
CONTEXT_JSONL_FILES = ("implement.jsonl", "check.jsonl", "debug.jsonl")
|
|
23
23
|
CONTEXT_ENTRY_TYPES = frozenset(("file", "directory", "planned-file"))
|
|
24
|
+
PLANNED_FILE_HINT = (
|
|
25
|
+
"If this is a planned new file, run "
|
|
26
|
+
"./.cowork-flow/run task add-planned-file <dir> <jsonl> <path> "
|
|
27
|
+
'"reason" or add "type": "planned-file" to the JSONL entry.'
|
|
28
|
+
)
|
|
24
29
|
|
|
25
30
|
|
|
26
31
|
class TaskContextError(RuntimeError):
|
|
@@ -40,6 +45,15 @@ def normalize_context_path(
|
|
|
40
45
|
) -> tuple[str, Path]:
|
|
41
46
|
"""Return a canonical repo-relative context path and its absolute target."""
|
|
42
47
|
candidate = Path(repo_root) / str(path)
|
|
48
|
+
_validate_context_entry_type(candidate, entry_type)
|
|
49
|
+
normalized = _normalized_context_path(path, entry_type)
|
|
50
|
+
segments = normalized.split("/")
|
|
51
|
+
_validate_context_path(candidate, normalized, segments, path, entry_type)
|
|
52
|
+
repo_root, full_path = _resolved_context_target(repo_root, segments)
|
|
53
|
+
return _typed_context_path(normalized, full_path, repo_root, entry_type)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _validate_context_entry_type(candidate: Path, entry_type: str) -> None:
|
|
43
57
|
if entry_type not in CONTEXT_ENTRY_TYPES:
|
|
44
58
|
raise TaskContextError(
|
|
45
59
|
"TASK-CONTEXT-TYPE-001",
|
|
@@ -47,14 +61,38 @@ def normalize_context_path(
|
|
|
47
61
|
f"unsupported context entry type: {entry_type}",
|
|
48
62
|
)
|
|
49
63
|
|
|
64
|
+
|
|
65
|
+
def _normalized_context_path(path: str, entry_type: str) -> str:
|
|
50
66
|
normalized = str(path).replace("\\", "/")
|
|
51
67
|
while normalized.startswith("./"):
|
|
52
68
|
normalized = normalized[2:]
|
|
53
69
|
if entry_type == "directory" and normalized.endswith("/"):
|
|
54
70
|
normalized = normalized[:-1]
|
|
71
|
+
return normalized
|
|
55
72
|
|
|
56
|
-
|
|
57
|
-
|
|
73
|
+
|
|
74
|
+
def _validate_context_path(
|
|
75
|
+
candidate: Path,
|
|
76
|
+
normalized: str,
|
|
77
|
+
segments: list[str],
|
|
78
|
+
path: str,
|
|
79
|
+
entry_type: str,
|
|
80
|
+
) -> None:
|
|
81
|
+
if not _is_valid_context_path(normalized, segments, path, entry_type):
|
|
82
|
+
raise TaskContextError(
|
|
83
|
+
"TASK-CONTEXT-PATH-002",
|
|
84
|
+
candidate,
|
|
85
|
+
"context path must be a canonical repository-relative path",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _is_valid_context_path(
|
|
90
|
+
normalized: str,
|
|
91
|
+
segments: list[str],
|
|
92
|
+
path: str,
|
|
93
|
+
entry_type: str,
|
|
94
|
+
) -> bool:
|
|
95
|
+
return not (
|
|
58
96
|
not normalized
|
|
59
97
|
or normalized.startswith("/")
|
|
60
98
|
or re.match(r"^[A-Za-z]:", normalized) is not None
|
|
@@ -62,15 +100,22 @@ def normalize_context_path(
|
|
|
62
100
|
or any(character in normalized for character in "*?[]")
|
|
63
101
|
or (entry_type == "planned-file" and str(path).endswith(("/", "\\")))
|
|
64
102
|
)
|
|
65
|
-
if invalid_path:
|
|
66
|
-
raise TaskContextError(
|
|
67
|
-
"TASK-CONTEXT-PATH-002",
|
|
68
|
-
candidate,
|
|
69
|
-
"context path must be a canonical repository-relative path",
|
|
70
|
-
)
|
|
71
103
|
|
|
72
|
-
|
|
73
|
-
|
|
104
|
+
|
|
105
|
+
def _resolved_context_target(
|
|
106
|
+
repo_root: Path,
|
|
107
|
+
segments: list[str],
|
|
108
|
+
) -> tuple[Path, Path]:
|
|
109
|
+
resolved_root = Path(repo_root).resolve()
|
|
110
|
+
return resolved_root, resolved_root.joinpath(*segments).resolve(strict=False)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _typed_context_path(
|
|
114
|
+
normalized: str,
|
|
115
|
+
full_path: Path,
|
|
116
|
+
repo_root: Path,
|
|
117
|
+
entry_type: str,
|
|
118
|
+
) -> tuple[str, Path]:
|
|
74
119
|
try:
|
|
75
120
|
full_path.relative_to(repo_root)
|
|
76
121
|
except ValueError as error:
|
|
@@ -150,31 +195,43 @@ def validate_context_entry(
|
|
|
150
195
|
)
|
|
151
196
|
|
|
152
197
|
entry_type = data.get("type", "file")
|
|
198
|
+
normalized = str(data["file"])
|
|
153
199
|
try:
|
|
154
200
|
normalized_path, full_path = normalize_context_path(
|
|
155
201
|
repo_root,
|
|
156
|
-
|
|
202
|
+
normalized,
|
|
157
203
|
entry_type,
|
|
158
204
|
)
|
|
159
205
|
except TaskContextError as error:
|
|
160
|
-
code = (
|
|
161
|
-
"invalid_entry_type"
|
|
162
|
-
if error.code == "TASK-CONTEXT-TYPE-001"
|
|
163
|
-
else "invalid_path"
|
|
164
|
-
)
|
|
206
|
+
code = _context_error_issue_code(error)
|
|
165
207
|
return _context_issue(context_file, line, code, error.detail)
|
|
166
208
|
|
|
167
209
|
if is_skill_path(normalized_path):
|
|
168
210
|
return None
|
|
211
|
+
return _validate_context_entry_target(
|
|
212
|
+
context_file,
|
|
213
|
+
line,
|
|
214
|
+
entry_type,
|
|
215
|
+
normalized_path,
|
|
216
|
+
full_path,
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _context_error_issue_code(error: TaskContextError) -> str:
|
|
221
|
+
if error.code == "TASK-CONTEXT-TYPE-001":
|
|
222
|
+
return "invalid_entry_type"
|
|
223
|
+
return "invalid_path"
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _validate_context_entry_target(
|
|
227
|
+
context_file: str,
|
|
228
|
+
line: int,
|
|
229
|
+
entry_type: str,
|
|
230
|
+
normalized_path: str,
|
|
231
|
+
full_path: Path,
|
|
232
|
+
) -> ContextValidationIssue | None:
|
|
169
233
|
if entry_type == "planned-file":
|
|
170
|
-
|
|
171
|
-
return _context_issue(
|
|
172
|
-
context_file,
|
|
173
|
-
line,
|
|
174
|
-
"invalid_path",
|
|
175
|
-
f"Planned file is a directory: {normalized_path}",
|
|
176
|
-
)
|
|
177
|
-
return None
|
|
234
|
+
return _planned_file_issue(context_file, line, normalized_path, full_path)
|
|
178
235
|
if entry_type == "directory" and not full_path.is_dir():
|
|
179
236
|
return _context_issue(
|
|
180
237
|
context_file,
|
|
@@ -187,11 +244,27 @@ def validate_context_entry(
|
|
|
187
244
|
context_file,
|
|
188
245
|
line,
|
|
189
246
|
"file_not_found",
|
|
190
|
-
f"File not found: {normalized_path}",
|
|
247
|
+
f"File not found: {normalized_path}. {PLANNED_FILE_HINT}",
|
|
191
248
|
)
|
|
192
249
|
return None
|
|
193
250
|
|
|
194
251
|
|
|
252
|
+
def _planned_file_issue(
|
|
253
|
+
context_file: str,
|
|
254
|
+
line: int,
|
|
255
|
+
normalized_path: str,
|
|
256
|
+
full_path: Path,
|
|
257
|
+
) -> ContextValidationIssue | None:
|
|
258
|
+
if not full_path.is_dir():
|
|
259
|
+
return None
|
|
260
|
+
return _context_issue(
|
|
261
|
+
context_file,
|
|
262
|
+
line,
|
|
263
|
+
"invalid_path",
|
|
264
|
+
f"Planned file is a directory: {normalized_path}",
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
195
268
|
@dataclass(frozen=True)
|
|
196
269
|
class ContextInitializationResult:
|
|
197
270
|
created: tuple[str, ...]
|
|
@@ -463,6 +536,7 @@ class TaskContextService:
|
|
|
463
536
|
created.append(file_name)
|
|
464
537
|
entry_counts[file_name] = len(entries)
|
|
465
538
|
|
|
539
|
+
self.ensure_task_artifact_placeholders(task_dir)
|
|
466
540
|
return ContextInitializationResult(
|
|
467
541
|
created=tuple(created),
|
|
468
542
|
skipped=tuple(skipped),
|
|
@@ -607,6 +681,33 @@ class TaskContextService:
|
|
|
607
681
|
)
|
|
608
682
|
return tuple(summaries)
|
|
609
683
|
|
|
684
|
+
def ensure_task_artifact_placeholders(self, task_dir: Path) -> tuple[str, ...]:
|
|
685
|
+
"""Create empty placeholders for planned task-local context files."""
|
|
686
|
+
task_dir = Path(task_dir)
|
|
687
|
+
created: list[str] = []
|
|
688
|
+
for entry in self.entries(task_dir, "implement"):
|
|
689
|
+
if entry.get("type") == "planned-file":
|
|
690
|
+
continue
|
|
691
|
+
file_path = str(entry.get("file", "")).strip()
|
|
692
|
+
if not file_path:
|
|
693
|
+
continue
|
|
694
|
+
try:
|
|
695
|
+
normalized, full_path = normalize_context_path(
|
|
696
|
+
self.repo_root,
|
|
697
|
+
file_path,
|
|
698
|
+
"planned-file",
|
|
699
|
+
)
|
|
700
|
+
except TaskContextError:
|
|
701
|
+
continue
|
|
702
|
+
if not _is_task_local_artifact(task_dir, full_path):
|
|
703
|
+
continue
|
|
704
|
+
if full_path.exists():
|
|
705
|
+
continue
|
|
706
|
+
full_path.parent.mkdir(parents=True, exist_ok=True)
|
|
707
|
+
full_path.write_text("", encoding="utf-8")
|
|
708
|
+
created.append(normalized)
|
|
709
|
+
return tuple(created)
|
|
710
|
+
|
|
610
711
|
def _implement_entries(self, dev_type: str) -> list[dict]:
|
|
611
712
|
entries = get_implement_base()
|
|
612
713
|
entries.extend(
|
|
@@ -654,3 +755,11 @@ class TaskContextService:
|
|
|
654
755
|
if context_name.endswith(".jsonl"):
|
|
655
756
|
return context_name
|
|
656
757
|
return f"{context_name}.jsonl"
|
|
758
|
+
|
|
759
|
+
|
|
760
|
+
def _is_task_local_artifact(task_dir: Path, full_path: Path) -> bool:
|
|
761
|
+
try:
|
|
762
|
+
full_path.resolve(strict=False).relative_to(task_dir.resolve(strict=False))
|
|
763
|
+
except ValueError:
|
|
764
|
+
return False
|
|
765
|
+
return not full_path.exists() or full_path.is_file()
|
|
@@ -16,6 +16,7 @@ else:
|
|
|
16
16
|
import _bootstrap # noqa: F401
|
|
17
17
|
|
|
18
18
|
from application.task_context import (
|
|
19
|
+
PLANNED_FILE_HINT,
|
|
19
20
|
TaskContextService,
|
|
20
21
|
detect_installed_platforms as _detect_installed_platforms,
|
|
21
22
|
discover_spec_files as _discover_spec_files,
|
|
@@ -42,6 +43,7 @@ from application.batch_execution import (
|
|
|
42
43
|
)
|
|
43
44
|
from commands.task_archive_commands import cmd_archive
|
|
44
45
|
from commands.task_context_commands import (
|
|
46
|
+
cmd_add_planned_file,
|
|
45
47
|
cmd_add_context,
|
|
46
48
|
cmd_init_context,
|
|
47
49
|
cmd_list_context,
|
|
@@ -205,6 +207,15 @@ def _task_context_validation_issues(
|
|
|
205
207
|
)
|
|
206
208
|
|
|
207
209
|
|
|
210
|
+
def _refresh_task_artifact_placeholders(
|
|
211
|
+
task_dir: Path,
|
|
212
|
+
repo_root: Path,
|
|
213
|
+
) -> list[str]:
|
|
214
|
+
service = TaskContextService(repo_root)
|
|
215
|
+
service.ensure_task_artifact_placeholders(task_dir)
|
|
216
|
+
return _task_context_validation_issues(task_dir, repo_root)
|
|
217
|
+
|
|
218
|
+
|
|
208
219
|
def _optional_readiness_blockers(
|
|
209
220
|
repo_root: Path,
|
|
210
221
|
task_dir: Path,
|
|
@@ -243,6 +254,8 @@ def _start_preflight(
|
|
|
243
254
|
task_dir,
|
|
244
255
|
repo_root,
|
|
245
256
|
)
|
|
257
|
+
if validation_issues:
|
|
258
|
+
validation_issues = _refresh_task_artifact_placeholders(task_dir, repo_root)
|
|
246
259
|
if validation_issues:
|
|
247
260
|
return LifecyclePreflightFailure(
|
|
248
261
|
code="TASK-CONTEXT-001",
|
|
@@ -250,7 +263,7 @@ def _start_preflight(
|
|
|
250
263
|
blockers=tuple(validation_issues),
|
|
251
264
|
hint=(
|
|
252
265
|
"run ./.cowork-flow/run task validate <dir> and fix the "
|
|
253
|
-
"reported issues"
|
|
266
|
+
f"reported issues. {PLANNED_FILE_HINT}"
|
|
254
267
|
),
|
|
255
268
|
)
|
|
256
269
|
|
|
@@ -302,6 +315,102 @@ def _report_lifecycle_repository_error(
|
|
|
302
315
|
return 1
|
|
303
316
|
|
|
304
317
|
|
|
318
|
+
def _resolve_start_task(
|
|
319
|
+
task_input: str,
|
|
320
|
+
repo_root: Path,
|
|
321
|
+
) -> Path | None:
|
|
322
|
+
full_path = _resolve_task_dir(task_input, repo_root)
|
|
323
|
+
if full_path.is_dir():
|
|
324
|
+
return full_path
|
|
325
|
+
print(
|
|
326
|
+
colored(
|
|
327
|
+
f"Error: Task not found: {task_input}",
|
|
328
|
+
Colors.RED,
|
|
329
|
+
)
|
|
330
|
+
)
|
|
331
|
+
print(
|
|
332
|
+
"Hint: Use task name (e.g., 'my-task') or full path "
|
|
333
|
+
f"(e.g., '{DIR_WORKFLOW}/tasks/01-31-my-task')"
|
|
334
|
+
)
|
|
335
|
+
return None
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _run_auto_start(
|
|
339
|
+
args: argparse.Namespace,
|
|
340
|
+
full_path: Path,
|
|
341
|
+
preflight,
|
|
342
|
+
) -> int:
|
|
343
|
+
failure = preflight(full_path)
|
|
344
|
+
if failure is not None:
|
|
345
|
+
return _report_lifecycle_preflight(failure)
|
|
346
|
+
from common.task.batch_mode import run_batch_entry
|
|
347
|
+
|
|
348
|
+
return run_batch_entry(get_repo_root(), full_path, args)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _report_start_failure(
|
|
352
|
+
result: LifecycleResult,
|
|
353
|
+
service: TaskLifecycleService,
|
|
354
|
+
) -> int:
|
|
355
|
+
if result.title:
|
|
356
|
+
return _report_lifecycle_preflight(result)
|
|
357
|
+
if result.code == "LIFECYCLE-TRANSITION-001":
|
|
358
|
+
_print_transition_blockers(list(result.blockers))
|
|
359
|
+
return 1
|
|
360
|
+
if result.code == "LIFECYCLE-GATE-001":
|
|
361
|
+
return _report_gate_block(
|
|
362
|
+
"Spec enforcement blocked task start",
|
|
363
|
+
result.gate_result,
|
|
364
|
+
service.gate_runner,
|
|
365
|
+
)
|
|
366
|
+
if result.code == "LIFECYCLE-CONTEXT-001":
|
|
367
|
+
return _report_missing_session_context()
|
|
368
|
+
if result.repository_error is not None:
|
|
369
|
+
return _report_lifecycle_repository_error(result)
|
|
370
|
+
print(
|
|
371
|
+
colored("Error: Task lifecycle failed", Colors.RED),
|
|
372
|
+
file=sys.stderr,
|
|
373
|
+
)
|
|
374
|
+
return 1
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def _report_missing_session_context() -> int:
|
|
378
|
+
print(
|
|
379
|
+
colored(
|
|
380
|
+
"Error: Missing session context. Set "
|
|
381
|
+
"COWORK_FLOW_CONTEXT_ID or run inside a "
|
|
382
|
+
"supported host session.",
|
|
383
|
+
Colors.RED,
|
|
384
|
+
),
|
|
385
|
+
file=sys.stderr,
|
|
386
|
+
)
|
|
387
|
+
return 1
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _report_start_success(
|
|
391
|
+
result: LifecycleResult,
|
|
392
|
+
repo_root: Path,
|
|
393
|
+
full_path: Path,
|
|
394
|
+
) -> None:
|
|
395
|
+
task_dir = result.active_task_path or _display_task_path(
|
|
396
|
+
repo_root,
|
|
397
|
+
full_path,
|
|
398
|
+
)
|
|
399
|
+
print(
|
|
400
|
+
colored(
|
|
401
|
+
f"[OK] Active session task set to: {task_dir}",
|
|
402
|
+
Colors.GREEN,
|
|
403
|
+
)
|
|
404
|
+
)
|
|
405
|
+
print()
|
|
406
|
+
print(
|
|
407
|
+
colored(
|
|
408
|
+
"Fixed agents will load context from this task's jsonl files.",
|
|
409
|
+
Colors.BLUE,
|
|
410
|
+
)
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
|
|
305
414
|
def cmd_start(args: argparse.Namespace) -> int:
|
|
306
415
|
"""Set the active task for this session."""
|
|
307
416
|
repo_root = get_repo_root()
|
|
@@ -315,18 +424,8 @@ def cmd_start(args: argparse.Namespace) -> int:
|
|
|
315
424
|
)
|
|
316
425
|
return 1
|
|
317
426
|
|
|
318
|
-
full_path =
|
|
319
|
-
if
|
|
320
|
-
print(
|
|
321
|
-
colored(
|
|
322
|
-
f"Error: Task not found: {task_input}",
|
|
323
|
-
Colors.RED,
|
|
324
|
-
)
|
|
325
|
-
)
|
|
326
|
-
print(
|
|
327
|
-
"Hint: Use task name (e.g., 'my-task') or full path "
|
|
328
|
-
f"(e.g., '{DIR_WORKFLOW}/tasks/01-31-my-task')"
|
|
329
|
-
)
|
|
427
|
+
full_path = _resolve_start_task(task_input, repo_root)
|
|
428
|
+
if full_path is None:
|
|
330
429
|
return 1
|
|
331
430
|
|
|
332
431
|
def preflight(
|
|
@@ -335,63 +434,14 @@ def cmd_start(args: argparse.Namespace) -> int:
|
|
|
335
434
|
return _start_preflight(task_dir, repo_root)
|
|
336
435
|
|
|
337
436
|
if getattr(args, "auto", False):
|
|
338
|
-
|
|
339
|
-
if failure is not None:
|
|
340
|
-
return _report_lifecycle_preflight(failure)
|
|
341
|
-
from common.task.batch_mode import run_batch_entry
|
|
342
|
-
|
|
343
|
-
return run_batch_entry(repo_root, full_path, args)
|
|
437
|
+
return _run_auto_start(args, full_path, preflight)
|
|
344
438
|
|
|
345
439
|
service = TaskLifecycleService(repo_root)
|
|
346
440
|
result = service.start(full_path, preflight=preflight)
|
|
347
441
|
if not result.ok:
|
|
348
|
-
|
|
349
|
-
return _report_lifecycle_preflight(result)
|
|
350
|
-
if result.code == "LIFECYCLE-TRANSITION-001":
|
|
351
|
-
_print_transition_blockers(list(result.blockers))
|
|
352
|
-
return 1
|
|
353
|
-
if result.code == "LIFECYCLE-GATE-001":
|
|
354
|
-
return _report_gate_block(
|
|
355
|
-
"Spec enforcement blocked task start",
|
|
356
|
-
result.gate_result,
|
|
357
|
-
service.gate_runner,
|
|
358
|
-
)
|
|
359
|
-
if result.code == "LIFECYCLE-CONTEXT-001":
|
|
360
|
-
print(
|
|
361
|
-
colored(
|
|
362
|
-
"Error: Missing session context. Set "
|
|
363
|
-
"COWORK_FLOW_CONTEXT_ID or run inside a "
|
|
364
|
-
"supported host session.",
|
|
365
|
-
Colors.RED,
|
|
366
|
-
),
|
|
367
|
-
file=sys.stderr,
|
|
368
|
-
)
|
|
369
|
-
return 1
|
|
370
|
-
if result.repository_error is not None:
|
|
371
|
-
return _report_lifecycle_repository_error(result)
|
|
372
|
-
print(
|
|
373
|
-
colored("Error: Task lifecycle failed", Colors.RED),
|
|
374
|
-
file=sys.stderr,
|
|
375
|
-
)
|
|
376
|
-
return 1
|
|
442
|
+
return _report_start_failure(result, service)
|
|
377
443
|
|
|
378
|
-
|
|
379
|
-
repo_root,
|
|
380
|
-
full_path,
|
|
381
|
-
)
|
|
382
|
-
print(
|
|
383
|
-
colored(
|
|
384
|
-
f"[OK] Active session task set to: {task_dir}",
|
|
385
|
-
Colors.GREEN,
|
|
386
|
-
)
|
|
387
|
-
)
|
|
388
|
-
print()
|
|
389
|
-
print(
|
|
390
|
-
colored(
|
|
391
|
-
"Fixed agents will load context from this task's jsonl files.",
|
|
392
|
-
Colors.BLUE,
|
|
393
|
-
)
|
|
394
|
-
)
|
|
444
|
+
_report_start_success(result, repo_root, full_path)
|
|
395
445
|
_run_hooks("after_start", full_path / FILE_TASK_JSON, repo_root)
|
|
396
446
|
return 0
|
|
397
447
|
|
|
@@ -592,6 +642,60 @@ def cmd_current(args: argparse.Namespace) -> int:
|
|
|
592
642
|
return 0
|
|
593
643
|
|
|
594
644
|
|
|
645
|
+
WORKER_BLOCKED_COMMANDS = frozenset((
|
|
646
|
+
"create",
|
|
647
|
+
"init-context",
|
|
648
|
+
"add-context",
|
|
649
|
+
"add-planned-file",
|
|
650
|
+
"start",
|
|
651
|
+
"batch-resume",
|
|
652
|
+
"batch-record-result",
|
|
653
|
+
"review",
|
|
654
|
+
"complete",
|
|
655
|
+
"finish",
|
|
656
|
+
"archive",
|
|
657
|
+
"add-subtask",
|
|
658
|
+
"remove-subtask",
|
|
659
|
+
))
|
|
660
|
+
|
|
661
|
+
COMMANDS = {
|
|
662
|
+
"create": cmd_create,
|
|
663
|
+
"init-context": cmd_init_context,
|
|
664
|
+
"add-context": cmd_add_context,
|
|
665
|
+
"add-planned-file": cmd_add_planned_file,
|
|
666
|
+
"validate": cmd_validate,
|
|
667
|
+
"list-context": cmd_list_context,
|
|
668
|
+
"start": cmd_start,
|
|
669
|
+
"batch-resume": cmd_batch_resume,
|
|
670
|
+
"batch-record-result": cmd_batch_record_result,
|
|
671
|
+
"current": cmd_current,
|
|
672
|
+
"review": cmd_review,
|
|
673
|
+
"complete": cmd_complete,
|
|
674
|
+
"next": cmd_next,
|
|
675
|
+
"finish": cmd_finish,
|
|
676
|
+
"archive": cmd_archive,
|
|
677
|
+
"add-subtask": cmd_add_subtask,
|
|
678
|
+
"remove-subtask": cmd_remove_subtask,
|
|
679
|
+
"list": cmd_list,
|
|
680
|
+
"list-archive": cmd_list_archive,
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
def _worker_command_blocked(execution_context, command: str) -> bool:
|
|
685
|
+
if not execution_context.is_worker or command not in WORKER_BLOCKED_COMMANDS:
|
|
686
|
+
return False
|
|
687
|
+
print(
|
|
688
|
+
worker_command_block_message(
|
|
689
|
+
execution_context,
|
|
690
|
+
f"task {command}",
|
|
691
|
+
"Workers must not activate, archive, or mutate "
|
|
692
|
+
"cowork-flow task state.",
|
|
693
|
+
),
|
|
694
|
+
file=sys.stderr,
|
|
695
|
+
)
|
|
696
|
+
return True
|
|
697
|
+
|
|
698
|
+
|
|
595
699
|
def main() -> int:
|
|
596
700
|
"""CLI entry point."""
|
|
597
701
|
args = build_parser().parse_args()
|
|
@@ -600,53 +704,10 @@ def main() -> int:
|
|
|
600
704
|
show_usage()
|
|
601
705
|
return 1
|
|
602
706
|
|
|
603
|
-
|
|
604
|
-
"create",
|
|
605
|
-
"init-context",
|
|
606
|
-
"add-context",
|
|
607
|
-
"start",
|
|
608
|
-
"batch-resume",
|
|
609
|
-
"batch-record-result",
|
|
610
|
-
"review",
|
|
611
|
-
"complete",
|
|
612
|
-
"finish",
|
|
613
|
-
"archive",
|
|
614
|
-
"add-subtask",
|
|
615
|
-
"remove-subtask",
|
|
616
|
-
}
|
|
617
|
-
if execution_context.is_worker and args.command in worker_blocked_commands:
|
|
618
|
-
print(
|
|
619
|
-
worker_command_block_message(
|
|
620
|
-
execution_context,
|
|
621
|
-
f"task {args.command}",
|
|
622
|
-
"Workers must not activate, archive, or mutate "
|
|
623
|
-
"cowork-flow task state.",
|
|
624
|
-
),
|
|
625
|
-
file=sys.stderr,
|
|
626
|
-
)
|
|
707
|
+
if _worker_command_blocked(execution_context, args.command):
|
|
627
708
|
return 2
|
|
628
709
|
|
|
629
|
-
|
|
630
|
-
"create": cmd_create,
|
|
631
|
-
"init-context": cmd_init_context,
|
|
632
|
-
"add-context": cmd_add_context,
|
|
633
|
-
"validate": cmd_validate,
|
|
634
|
-
"list-context": cmd_list_context,
|
|
635
|
-
"start": cmd_start,
|
|
636
|
-
"batch-resume": cmd_batch_resume,
|
|
637
|
-
"batch-record-result": cmd_batch_record_result,
|
|
638
|
-
"current": cmd_current,
|
|
639
|
-
"review": cmd_review,
|
|
640
|
-
"complete": cmd_complete,
|
|
641
|
-
"next": cmd_next,
|
|
642
|
-
"finish": cmd_finish,
|
|
643
|
-
"archive": cmd_archive,
|
|
644
|
-
"add-subtask": cmd_add_subtask,
|
|
645
|
-
"remove-subtask": cmd_remove_subtask,
|
|
646
|
-
"list": cmd_list,
|
|
647
|
-
"list-archive": cmd_list_archive,
|
|
648
|
-
}
|
|
649
|
-
command = commands.get(args.command)
|
|
710
|
+
command = COMMANDS.get(args.command)
|
|
650
711
|
if command is None:
|
|
651
712
|
show_usage()
|
|
652
713
|
return 1
|
|
@@ -8,6 +8,7 @@ from pathlib import Path
|
|
|
8
8
|
|
|
9
9
|
from application.task_context import (
|
|
10
10
|
CONTEXT_JSONL_FILES,
|
|
11
|
+
PLANNED_FILE_HINT,
|
|
11
12
|
TaskContextError,
|
|
12
13
|
TaskContextService,
|
|
13
14
|
)
|
|
@@ -57,6 +58,15 @@ def cmd_init_context(args: argparse.Namespace) -> int:
|
|
|
57
58
|
)
|
|
58
59
|
return 1
|
|
59
60
|
|
|
61
|
+
_report_init_context_result(target_dir, dev_type, result)
|
|
62
|
+
return 0
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _report_init_context_result(
|
|
66
|
+
target_dir: Path,
|
|
67
|
+
dev_type: str,
|
|
68
|
+
result,
|
|
69
|
+
) -> None:
|
|
60
70
|
print(
|
|
61
71
|
colored(
|
|
62
72
|
"=== Initializing Agent Context Files ===",
|
|
@@ -79,7 +89,6 @@ def cmd_init_context(args: argparse.Namespace) -> int:
|
|
|
79
89
|
f" {colored('[OK]', Colors.GREEN)} "
|
|
80
90
|
f"{result.entry_counts[file_name]} entries"
|
|
81
91
|
)
|
|
82
|
-
return 0
|
|
83
92
|
|
|
84
93
|
|
|
85
94
|
def cmd_add_context(args: argparse.Namespace) -> int:
|
|
@@ -133,6 +142,12 @@ def cmd_add_context(args: argparse.Namespace) -> int:
|
|
|
133
142
|
return 0
|
|
134
143
|
|
|
135
144
|
|
|
145
|
+
def cmd_add_planned_file(args: argparse.Namespace) -> int:
|
|
146
|
+
"""Add a planned-file entry to a JSONL context file."""
|
|
147
|
+
args.entry_type = "planned-file"
|
|
148
|
+
return cmd_add_context(args)
|
|
149
|
+
|
|
150
|
+
|
|
136
151
|
def cmd_validate(args: argparse.Namespace) -> int:
|
|
137
152
|
"""Validate JSONL context files."""
|
|
138
153
|
repo_root = get_repo_root()
|
|
@@ -158,6 +173,8 @@ def cmd_validate(args: argparse.Namespace) -> int:
|
|
|
158
173
|
print(
|
|
159
174
|
f" {colored(f'{jsonl_name}:{issue.line}: {issue.message}', Colors.RED)}"
|
|
160
175
|
)
|
|
176
|
+
if issue.code == "file_not_found":
|
|
177
|
+
print(f" Hint: {PLANNED_FILE_HINT}")
|
|
161
178
|
error_count = len(result.issues)
|
|
162
179
|
total_errors += error_count
|
|
163
180
|
if error_count == 0:
|
|
@@ -25,9 +25,30 @@ from common.core.paths import (
|
|
|
25
25
|
def cmd_create(args: argparse.Namespace) -> int:
|
|
26
26
|
"""Create a new task."""
|
|
27
27
|
repo_root = get_repo_root()
|
|
28
|
+
request = _build_create_request(args, repo_root)
|
|
29
|
+
if request is None:
|
|
30
|
+
return 1
|
|
31
|
+
|
|
32
|
+
result = _create_task(repo_root, request)
|
|
33
|
+
if result is None:
|
|
34
|
+
return 1
|
|
35
|
+
|
|
36
|
+
_print_create_result(result)
|
|
37
|
+
run_hooks(
|
|
38
|
+
"after_create",
|
|
39
|
+
result.task_dir / FILE_TASK_JSON,
|
|
40
|
+
repo_root,
|
|
41
|
+
)
|
|
42
|
+
return 0
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _build_create_request(
|
|
46
|
+
args: argparse.Namespace,
|
|
47
|
+
repo_root,
|
|
48
|
+
) -> TaskCreationRequest | None:
|
|
28
49
|
if not args.title:
|
|
29
50
|
print(colored("Error: title is required", Colors.RED), file=sys.stderr)
|
|
30
|
-
return
|
|
51
|
+
return None
|
|
31
52
|
|
|
32
53
|
assignee = args.assignee or get_developer(repo_root)
|
|
33
54
|
if not assignee:
|
|
@@ -39,7 +60,7 @@ def cmd_create(args: argparse.Namespace) -> int:
|
|
|
39
60
|
),
|
|
40
61
|
file=sys.stderr,
|
|
41
62
|
)
|
|
42
|
-
return
|
|
63
|
+
return None
|
|
43
64
|
|
|
44
65
|
creator = get_developer(repo_root) or assignee
|
|
45
66
|
slug = args.slug or slugify(args.title)
|
|
@@ -51,21 +72,23 @@ def cmd_create(args: argparse.Namespace) -> int:
|
|
|
51
72
|
),
|
|
52
73
|
file=sys.stderr,
|
|
53
74
|
)
|
|
54
|
-
return
|
|
75
|
+
return None
|
|
76
|
+
|
|
77
|
+
return TaskCreationRequest(
|
|
78
|
+
title=args.title,
|
|
79
|
+
slug=slug,
|
|
80
|
+
assignee=assignee,
|
|
81
|
+
priority=args.priority,
|
|
82
|
+
description=args.description,
|
|
83
|
+
creator=creator,
|
|
84
|
+
parent=args.parent,
|
|
85
|
+
from_plan=getattr(args, "from_plan", None),
|
|
86
|
+
)
|
|
87
|
+
|
|
55
88
|
|
|
89
|
+
def _create_task(repo_root, request: TaskCreationRequest):
|
|
56
90
|
try:
|
|
57
|
-
|
|
58
|
-
TaskCreationRequest(
|
|
59
|
-
title=args.title,
|
|
60
|
-
slug=slug,
|
|
61
|
-
assignee=assignee,
|
|
62
|
-
priority=args.priority,
|
|
63
|
-
description=args.description,
|
|
64
|
-
creator=creator,
|
|
65
|
-
parent=args.parent,
|
|
66
|
-
from_plan=getattr(args, "from_plan", None),
|
|
67
|
-
)
|
|
68
|
-
)
|
|
91
|
+
return TaskCreationService(repo_root).create(request)
|
|
69
92
|
except TaskCreationError as error:
|
|
70
93
|
print(
|
|
71
94
|
colored(
|
|
@@ -74,8 +97,10 @@ def cmd_create(args: argparse.Namespace) -> int:
|
|
|
74
97
|
),
|
|
75
98
|
file=sys.stderr,
|
|
76
99
|
)
|
|
77
|
-
return
|
|
100
|
+
return None
|
|
101
|
+
|
|
78
102
|
|
|
103
|
+
def _print_create_result(result) -> None:
|
|
79
104
|
dir_name = result.task_dir.name
|
|
80
105
|
if result.directory_existed:
|
|
81
106
|
print(
|
|
@@ -112,6 +137,11 @@ def cmd_create(args: argparse.Namespace) -> int:
|
|
|
112
137
|
colored(f"Created task: {dir_name}", Colors.GREEN),
|
|
113
138
|
file=sys.stderr,
|
|
114
139
|
)
|
|
140
|
+
_print_next_steps()
|
|
141
|
+
print(f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _print_next_steps() -> None:
|
|
115
145
|
print("", file=sys.stderr)
|
|
116
146
|
print(colored("Next steps:", Colors.BLUE), file=sys.stderr)
|
|
117
147
|
print(
|
|
@@ -126,12 +156,9 @@ def cmd_create(args: argparse.Namespace) -> int:
|
|
|
126
156
|
" 3. Run: ./.cowork-flow/run task start <dir>",
|
|
127
157
|
file=sys.stderr,
|
|
128
158
|
)
|
|
129
|
-
print(
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
"after_create",
|
|
134
|
-
result.task_dir / FILE_TASK_JSON,
|
|
135
|
-
repo_root,
|
|
159
|
+
print(
|
|
160
|
+
" Planned new files: ./.cowork-flow/run task add-planned-file "
|
|
161
|
+
"<dir> implement <path> \"reason\"",
|
|
162
|
+
file=sys.stderr,
|
|
136
163
|
)
|
|
137
|
-
|
|
164
|
+
print("", file=sys.stderr)
|
|
@@ -42,6 +42,8 @@ Usage:
|
|
|
42
42
|
./.cowork-flow/run task init-context <dir> <dev_type> Initialize jsonl files
|
|
43
43
|
./.cowork-flow/run task add-context <dir> <jsonl> <path> [reason] [--type TYPE]
|
|
44
44
|
Add entry to jsonl
|
|
45
|
+
./.cowork-flow/run task add-planned-file <dir> <jsonl> <path> [reason]
|
|
46
|
+
Add planned file entry
|
|
45
47
|
./.cowork-flow/run task validate <dir> Validate jsonl files
|
|
46
48
|
./.cowork-flow/run task list-context <dir> List jsonl entries
|
|
47
49
|
./.cowork-flow/run task start <dir> Set active session task
|
|
@@ -69,6 +71,7 @@ Examples:
|
|
|
69
71
|
./.cowork-flow/run task next
|
|
70
72
|
./.cowork-flow/run task add-context <dir> implement .cowork-flow/spec/backend/auth.md "Auth guidelines"
|
|
71
73
|
./.cowork-flow/run task add-context <dir> implement src/new.py "Planned source" --type planned-file
|
|
74
|
+
./.cowork-flow/run task add-planned-file <dir> implement src/new.py "Planned source"
|
|
72
75
|
./.cowork-flow/run task start .cowork-flow/tasks/01-21-add-login
|
|
73
76
|
./.cowork-flow/run task review
|
|
74
77
|
./.cowork-flow/run task complete
|
|
@@ -119,6 +122,24 @@ def _add_add_context_command(subparsers: argparse._SubParsersAction) -> None:
|
|
|
119
122
|
)
|
|
120
123
|
|
|
121
124
|
|
|
125
|
+
def _add_add_planned_file_command(subparsers: argparse._SubParsersAction) -> None:
|
|
126
|
+
add_planned_file = subparsers.add_parser(
|
|
127
|
+
"add-planned-file",
|
|
128
|
+
help="Add planned file context entry",
|
|
129
|
+
)
|
|
130
|
+
add_planned_file.add_argument("dir", help="Task directory")
|
|
131
|
+
add_planned_file.add_argument(
|
|
132
|
+
"file",
|
|
133
|
+
help="JSONL file (implement|check|debug)",
|
|
134
|
+
)
|
|
135
|
+
add_planned_file.add_argument("path", help="Planned file path to add")
|
|
136
|
+
add_planned_file.add_argument(
|
|
137
|
+
"reason",
|
|
138
|
+
nargs="?",
|
|
139
|
+
help="Reason for adding",
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
122
143
|
def _add_context_validation_commands(subparsers: argparse._SubParsersAction) -> None:
|
|
123
144
|
validate = subparsers.add_parser(
|
|
124
145
|
"validate",
|
|
@@ -136,6 +157,7 @@ def _add_context_validation_commands(subparsers: argparse._SubParsersAction) ->
|
|
|
136
157
|
def _add_context_commands(subparsers: argparse._SubParsersAction) -> None:
|
|
137
158
|
_add_init_context_command(subparsers)
|
|
138
159
|
_add_add_context_command(subparsers)
|
|
160
|
+
_add_add_planned_file_command(subparsers)
|
|
139
161
|
_add_context_validation_commands(subparsers)
|
|
140
162
|
|
|
141
163
|
|
|
@@ -39,6 +39,16 @@ _EXTENSIONS = {
|
|
|
39
39
|
|
|
40
40
|
_BACKEND_SUFFIXES = re.compile(r"\.(py|java|go|rs|rb|cs|kt|php)$")
|
|
41
41
|
_FRONTEND_SUFFIXES = re.compile(r"\.(tsx|jsx|vue|svelte|ts|js|mjs|cjs|css|scss)$")
|
|
42
|
+
_SECRET_RE = re.compile(
|
|
43
|
+
r"""(?ix)
|
|
44
|
+
(?:password|passwd|secret|api[_\s]?key|token|access[_\s]?key)
|
|
45
|
+
\s*[:=]\s*["'][^"'}{)($]{3,}["']
|
|
46
|
+
"""
|
|
47
|
+
)
|
|
48
|
+
_SILENT_EXCEPT_RE = re.compile(r"^\s*except\b\s*.*:\s*(#.*)?$")
|
|
49
|
+
_BARE_PRINT_RE = re.compile(r"(?<!\w)print\s*\(")
|
|
50
|
+
_CONSOLE_RE = re.compile(r"\bconsole\.(log|info|debug)\s*\(")
|
|
51
|
+
_JS_SUFFIXES = {".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx"}
|
|
42
52
|
|
|
43
53
|
|
|
44
54
|
def _is_backend_file(rel_path: str) -> bool:
|
|
@@ -80,8 +90,8 @@ def load_spec_rules(
|
|
|
80
90
|
"""Return every natural-language rule declared under ``spec/<category>/``.
|
|
81
91
|
|
|
82
92
|
Re-reads markdown on every call, so user edits take effect without restart.
|
|
83
|
-
|
|
84
|
-
|
|
93
|
+
The returned rule shape is the current runtime checklist contract:
|
|
94
|
+
category, source, line, and text.
|
|
85
95
|
"""
|
|
86
96
|
out: list[dict] = []
|
|
87
97
|
repo_root = Path(repo_root)
|
|
@@ -102,7 +112,6 @@ def load_spec_rules(
|
|
|
102
112
|
"source": f".cowork-flow/spec/{category}/{md.name}",
|
|
103
113
|
"line": lineno,
|
|
104
114
|
"text": rule,
|
|
105
|
-
"validators": [],
|
|
106
115
|
})
|
|
107
116
|
return out
|
|
108
117
|
|
|
@@ -171,72 +180,105 @@ def collect_machine_checks(
|
|
|
171
180
|
whether to render them in a gate log, inject them into context, or ignore.
|
|
172
181
|
"""
|
|
173
182
|
violations: list[dict] = []
|
|
183
|
+
for changed_file in _changed_machine_check_files(repo_root):
|
|
184
|
+
violations.extend(_machine_check_violations_for_file(repo_root, changed_file))
|
|
185
|
+
return violations
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _changed_machine_check_files(repo_root: Path) -> Sequence[object]:
|
|
174
189
|
try:
|
|
175
|
-
|
|
190
|
+
return collect_changed_files(repo_root)
|
|
176
191
|
except Exception: # noqa: BLE001 — defensively isolate git errors
|
|
177
|
-
return
|
|
192
|
+
return ()
|
|
178
193
|
|
|
179
|
-
secret_re = re.compile(
|
|
180
|
-
r"""(?ix)
|
|
181
|
-
(?:password|passwd|secret|api[_\s]?key|token|access[_\s]?key)
|
|
182
|
-
\s*[:=]\s*["'][^"'}{)($]{3,}["']
|
|
183
|
-
"""
|
|
184
|
-
)
|
|
185
|
-
silent_except_re = re.compile(r"^\s*except\b\s*.*:\s*(#.*)?$")
|
|
186
|
-
bare_print_re = re.compile(r"(?<!\w)print\s*\(")
|
|
187
|
-
console_re = re.compile(r"\bconsole\.(log|info|debug)\s*\(")
|
|
188
194
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
195
|
+
def _machine_check_violations_for_file(repo_root: Path, changed_file: object) -> list[dict]:
|
|
196
|
+
file_path = repo_root / changed_file.path
|
|
197
|
+
if not file_path.is_file():
|
|
198
|
+
return []
|
|
199
|
+
suffix = file_path.suffix.lower()
|
|
200
|
+
if suffix not in _EXTENSIONS:
|
|
201
|
+
return []
|
|
202
|
+
try:
|
|
203
|
+
content = file_path.read_text(encoding="utf-8")
|
|
204
|
+
except (OSError, UnicodeDecodeError):
|
|
205
|
+
return []
|
|
206
|
+
return _machine_check_violations_for_lines(changed_file.path, suffix, content.splitlines())
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _machine_check_violations_for_lines(
|
|
210
|
+
rel_path: str,
|
|
211
|
+
suffix: str,
|
|
212
|
+
lines: list[str],
|
|
213
|
+
) -> list[dict]:
|
|
214
|
+
violations: list[dict] = []
|
|
215
|
+
for lineno, raw in enumerate(lines, start=1):
|
|
216
|
+
violations.extend(_machine_check_violations_for_line(rel_path, suffix, lines, lineno, raw))
|
|
217
|
+
return violations
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _machine_check_violations_for_line(
|
|
221
|
+
rel_path: str,
|
|
222
|
+
suffix: str,
|
|
223
|
+
lines: list[str],
|
|
224
|
+
lineno: int,
|
|
225
|
+
raw: str,
|
|
226
|
+
) -> list[dict]:
|
|
227
|
+
violations: list[dict] = []
|
|
228
|
+
if _looks_like_hardcoded_secret(raw):
|
|
229
|
+
violations.append(_adv(
|
|
230
|
+
"MACHINE-HARDCODED-SECRET-001",
|
|
231
|
+
"Possible hard-coded secret in source",
|
|
232
|
+
rel_path, lineno,
|
|
233
|
+
"Read the value from configuration or environment variables.",
|
|
234
|
+
))
|
|
235
|
+
if suffix == ".py" and _BARE_PRINT_RE.search(raw):
|
|
236
|
+
violations.append(_adv(
|
|
237
|
+
"MACHINE-DEBUG-PRINT-001",
|
|
238
|
+
"Debug print on changed line",
|
|
239
|
+
rel_path, lineno,
|
|
240
|
+
"Use a structured logger or remove the print.",
|
|
241
|
+
))
|
|
242
|
+
if suffix in _JS_SUFFIXES and _CONSOLE_RE.search(raw):
|
|
243
|
+
violations.append(_adv(
|
|
244
|
+
"MACHINE-CONSOLE-LOG-001",
|
|
245
|
+
"console.{log,info,debug} on changed line",
|
|
246
|
+
rel_path, lineno,
|
|
247
|
+
"Use the project logger or remove the call.",
|
|
248
|
+
))
|
|
249
|
+
if _is_silent_python_except(suffix, lines, lineno, raw):
|
|
250
|
+
violations.append(_adv(
|
|
251
|
+
"MACHINE-SILENT-EXCEPT-001",
|
|
252
|
+
"Empty exception handler silently swallows errors",
|
|
253
|
+
rel_path, lineno,
|
|
254
|
+
"Log or re-raise the exception, or narrow the except types.",
|
|
255
|
+
))
|
|
237
256
|
return violations
|
|
238
257
|
|
|
239
258
|
|
|
259
|
+
def _looks_like_hardcoded_secret(raw: str) -> bool:
|
|
260
|
+
# Low-signal heuristic: only flag if the line also has an assignment / call.
|
|
261
|
+
return (
|
|
262
|
+
_SECRET_RE.search(raw) is not None
|
|
263
|
+
and "process.env" not in raw
|
|
264
|
+
and "os.environ" not in raw
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _is_silent_python_except(
|
|
269
|
+
suffix: str,
|
|
270
|
+
lines: list[str],
|
|
271
|
+
lineno: int,
|
|
272
|
+
raw: str,
|
|
273
|
+
) -> bool:
|
|
274
|
+
return (
|
|
275
|
+
suffix == ".py"
|
|
276
|
+
and _SILENT_EXCEPT_RE.match(raw) is not None
|
|
277
|
+
and lineno < len(lines)
|
|
278
|
+
and lines[lineno].strip() in {"pass", "...", ""}
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
|
|
240
282
|
def _adv(
|
|
241
283
|
rule_id: str,
|
|
242
284
|
message: str,
|
|
@@ -54,88 +54,50 @@ def _first_prompt_value(values: Mapping[str, object] | None) -> str | None:
|
|
|
54
54
|
|
|
55
55
|
|
|
56
56
|
def resolve_context_key(values: Mapping[str, object] | None = None) -> str | None:
|
|
57
|
-
|
|
58
|
-
if explicit and explicit.strip():
|
|
59
|
-
return _sanitize(explicit)
|
|
60
|
-
|
|
61
|
-
opencode_session = os.environ.get("OPENCODE_SESSION_ID")
|
|
62
|
-
if opencode_session and opencode_session.strip():
|
|
63
|
-
return f"opencode_{_sanitize(opencode_session)}"
|
|
64
|
-
|
|
65
|
-
claude_session = os.environ.get("CLAUDE_SESSION_ID")
|
|
66
|
-
if claude_session and claude_session.strip():
|
|
67
|
-
return f"claude_{_sanitize(claude_session)}"
|
|
57
|
+
return _resolve_env_context_key() or _resolve_input_context_key(values)
|
|
68
58
|
|
|
69
|
-
claude_code_session = os.environ.get("CLAUDE_CODE_SESSION_ID")
|
|
70
|
-
if claude_code_session and claude_code_session.strip():
|
|
71
|
-
return f"claude_{_sanitize(claude_code_session)}"
|
|
72
59
|
|
|
73
|
-
|
|
74
|
-
if
|
|
75
|
-
return f"
|
|
60
|
+
def _prefixed_context_key(prefix: str, raw: str | None) -> str | None:
|
|
61
|
+
if raw and raw.strip():
|
|
62
|
+
return f"{prefix}_{_sanitize(raw)}"
|
|
63
|
+
return None
|
|
76
64
|
|
|
77
|
-
codex_thread = os.environ.get("CODEX_THREAD_ID")
|
|
78
|
-
if codex_thread and codex_thread.strip():
|
|
79
|
-
return f"codex_{_sanitize(codex_thread)}"
|
|
80
65
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
"cowork_flow_context_id",
|
|
86
|
-
"context_id",
|
|
87
|
-
),
|
|
88
|
-
)
|
|
89
|
-
if input_explicit:
|
|
90
|
-
return _sanitize(input_explicit)
|
|
91
|
-
|
|
92
|
-
input_opencode_session = _first_input_value(
|
|
93
|
-
values,
|
|
94
|
-
(
|
|
95
|
-
"OPENCODE_SESSION_ID",
|
|
96
|
-
"opencode_session_id",
|
|
97
|
-
"sessionID",
|
|
98
|
-
"sessionId",
|
|
99
|
-
),
|
|
100
|
-
)
|
|
101
|
-
if input_opencode_session:
|
|
102
|
-
return f"opencode_{_sanitize(input_opencode_session)}"
|
|
66
|
+
def _resolve_env_context_key() -> str | None:
|
|
67
|
+
explicit = os.environ.get("COWORK_FLOW_CONTEXT_ID")
|
|
68
|
+
if explicit and explicit.strip():
|
|
69
|
+
return _sanitize(explicit)
|
|
103
70
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
(
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
)
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
71
|
+
for prefix, env_name in (
|
|
72
|
+
("opencode", "OPENCODE_SESSION_ID"),
|
|
73
|
+
("claude", "CLAUDE_SESSION_ID"),
|
|
74
|
+
("claude", "CLAUDE_CODE_SESSION_ID"),
|
|
75
|
+
("codex", "CODEX_SESSION_ID"),
|
|
76
|
+
("codex", "CODEX_THREAD_ID"),
|
|
77
|
+
):
|
|
78
|
+
context_key = _prefixed_context_key(prefix, os.environ.get(env_name))
|
|
79
|
+
if context_key:
|
|
80
|
+
return context_key
|
|
81
|
+
return None
|
|
115
82
|
|
|
116
|
-
input_session = _first_input_value(
|
|
117
|
-
values,
|
|
118
|
-
(
|
|
119
|
-
"CODEX_SESSION_ID",
|
|
120
|
-
"codex_session_id",
|
|
121
|
-
"session_id",
|
|
122
|
-
),
|
|
123
|
-
)
|
|
124
|
-
if input_session:
|
|
125
|
-
return f"codex_{_sanitize(input_session)}"
|
|
126
83
|
|
|
127
|
-
|
|
84
|
+
def _resolve_input_context_key(values: Mapping[str, object] | None) -> str | None:
|
|
85
|
+
explicit = _first_input_value(
|
|
128
86
|
values,
|
|
129
|
-
(
|
|
130
|
-
"CODEX_THREAD_ID",
|
|
131
|
-
"codex_thread_id",
|
|
132
|
-
"thread_id",
|
|
133
|
-
"conversation_id",
|
|
134
|
-
),
|
|
87
|
+
("COWORK_FLOW_CONTEXT_ID", "cowork_flow_context_id", "context_id"),
|
|
135
88
|
)
|
|
136
|
-
if
|
|
137
|
-
return
|
|
89
|
+
if explicit:
|
|
90
|
+
return _sanitize(explicit)
|
|
138
91
|
|
|
92
|
+
for prefix, names in (
|
|
93
|
+
("opencode", ("OPENCODE_SESSION_ID", "opencode_session_id", "sessionID", "sessionId")),
|
|
94
|
+
("claude", ("CLAUDE_SESSION_ID", "claude_session_id", "CLAUDE_CODE_SESSION_ID", "claude_code_session_id")),
|
|
95
|
+
("codex", ("CODEX_SESSION_ID", "codex_session_id", "session_id")),
|
|
96
|
+
("codex", ("CODEX_THREAD_ID", "codex_thread_id", "thread_id", "conversation_id")),
|
|
97
|
+
):
|
|
98
|
+
context_key = _prefixed_context_key(prefix, _first_input_value(values, names))
|
|
99
|
+
if context_key:
|
|
100
|
+
return context_key
|
|
139
101
|
return None
|
|
140
102
|
|
|
141
103
|
|
|
@@ -361,12 +323,12 @@ def is_main_session(repo_root: Path, values: Mapping[str, object] | None = None)
|
|
|
361
323
|
return False
|
|
362
324
|
data = _read_json(_session_path(repo_root, context_key))
|
|
363
325
|
scope = data.get(FIELD_SCOPE)
|
|
364
|
-
|
|
326
|
+
historical_unscoped_main_session = (
|
|
365
327
|
scope is None
|
|
366
328
|
and not context_key.startswith("subagent_")
|
|
367
329
|
)
|
|
368
330
|
return (
|
|
369
|
-
(scope == SCOPE_MAIN or
|
|
331
|
+
(scope == SCOPE_MAIN or historical_unscoped_main_session)
|
|
370
332
|
and not data.get(FIELD_RUNTIME_CONTEXT_ID)
|
|
371
333
|
)
|
|
372
334
|
|