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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cowork-flow",
3
- "version": "0.0.38",
3
+ "version": "0.0.39",
4
4
  "description": "CLI for installing and maintaining cowork-flow collaboration templates.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1 +1 @@
1
- 0.0.38
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
- segments = normalized.split("/")
57
- invalid_path = (
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
- repo_root = Path(repo_root).resolve()
73
- full_path = repo_root.joinpath(*segments).resolve(strict=False)
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
- str(data["file"]),
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
- if full_path.is_dir():
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 = _resolve_task_dir(task_input, repo_root)
319
- if not full_path.is_dir():
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
- failure = preflight(full_path)
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
- if result.title:
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
- task_dir = result.active_task_path or _display_task_path(
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
- worker_blocked_commands = {
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
- commands = {
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 1
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 1
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 1
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
- result = TaskCreationService(repo_root).create(
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 1
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("", file=sys.stderr)
130
- print(f"{DIR_WORKFLOW}/{DIR_TASKS}/{dir_name}")
131
-
132
- run_hooks(
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
- return 0
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
- ``validators`` is preserved as an empty list (legacy field kept so external
84
- tooling that inspects the rule dict does not break).
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
- changed_files = collect_changed_files(repo_root)
190
+ return collect_changed_files(repo_root)
176
191
  except Exception: # noqa: BLE001 — defensively isolate git errors
177
- return violations
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
- for changed_file in changed_files:
190
- file_path = repo_root / changed_file.path
191
- if not file_path.is_file():
192
- continue
193
- suffix = file_path.suffix.lower()
194
- if suffix not in _EXTENSIONS:
195
- continue
196
- try:
197
- content = file_path.read_text(encoding="utf-8")
198
- except (OSError, UnicodeDecodeError):
199
- continue
200
- lines = content.splitlines()
201
- for lineno, raw in enumerate(lines, start=1):
202
- # Low-signal heuristic: only flag if the line also has an
203
- # assignment / call so variable-name mentions do not trip it.
204
- if (secret_re.search(raw)
205
- and "process.env" not in raw
206
- and "os.environ" not in raw):
207
- violations.append(_adv(
208
- "MACHINE-HARDCODED-SECRET-001",
209
- "Possible hard-coded secret in source",
210
- changed_file.path, lineno,
211
- "Read the value from configuration or environment variables.",
212
- ))
213
- if suffix == ".py" and bare_print_re.search(raw):
214
- violations.append(_adv(
215
- "MACHINE-DEBUG-PRINT-001",
216
- "Debug print on changed line",
217
- changed_file.path, lineno,
218
- "Use a structured logger or remove the print.",
219
- ))
220
- if suffix in {".js", ".mjs", ".cjs", ".ts", ".tsx", ".jsx"} and console_re.search(raw):
221
- violations.append(_adv(
222
- "MACHINE-CONSOLE-LOG-001",
223
- "console.{log,info,debug} on changed line",
224
- changed_file.path, lineno,
225
- "Use the project logger or remove the call.",
226
- ))
227
- if (suffix == ".py"
228
- and silent_except_re.match(raw)
229
- and lineno < len(lines)
230
- and lines[lineno].strip() in {"pass", "...", ""}):
231
- violations.append(_adv(
232
- "MACHINE-SILENT-EXCEPT-001",
233
- "Empty exception handler silently swallows errors",
234
- changed_file.path, lineno,
235
- "Log or re-raise the exception, or narrow the except types.",
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
- explicit = os.environ.get("COWORK_FLOW_CONTEXT_ID")
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
- codex_session = os.environ.get("CODEX_SESSION_ID")
74
- if codex_session and codex_session.strip():
75
- return f"codex_{_sanitize(codex_session)}"
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
- input_explicit = _first_input_value(
82
- values,
83
- (
84
- "COWORK_FLOW_CONTEXT_ID",
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
- input_claude_session = _first_input_value(
105
- values,
106
- (
107
- "CLAUDE_SESSION_ID",
108
- "claude_session_id",
109
- "CLAUDE_CODE_SESSION_ID",
110
- "claude_code_session_id",
111
- ),
112
- )
113
- if input_claude_session:
114
- return f"claude_{_sanitize(input_claude_session)}"
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
- input_thread = _first_input_value(
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 input_thread:
137
- return f"codex_{_sanitize(input_thread)}"
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
- legacy_main_session = (
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 legacy_main_session)
331
+ (scope == SCOPE_MAIN or historical_unscoped_main_session)
370
332
  and not data.get(FIELD_RUNTIME_CONTEXT_ID)
371
333
  )
372
334
 
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cowork-flow",
3
- "version": "0.0.38",
3
+ "version": "0.0.39",
4
4
  "description": "AI 协作工作流引擎:任务生命周期、子代理调度、门禁与状态注入。将 cowork-flow 的完整工作流带入 ZCode。",
5
5
  "author": {
6
6
  "name": "fisklikewater"