@chinhae/atw-cli 0.3.6 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +188 -15
  2. package/dist/cli/index.d.ts.map +1 -1
  3. package/dist/cli/index.js +25 -1
  4. package/dist/cli/index.js.map +1 -1
  5. package/dist/commands/init.d.ts +3 -0
  6. package/dist/commands/init.d.ts.map +1 -1
  7. package/dist/commands/init.js +154 -17
  8. package/dist/commands/init.js.map +1 -1
  9. package/dist/commands/mode.d.ts +21 -0
  10. package/dist/commands/mode.d.ts.map +1 -0
  11. package/dist/commands/mode.js +401 -0
  12. package/dist/commands/mode.js.map +1 -0
  13. package/dist/commands/update.d.ts +7 -0
  14. package/dist/commands/update.d.ts.map +1 -1
  15. package/dist/commands/update.js +20 -13
  16. package/dist/commands/update.js.map +1 -1
  17. package/dist/commands/workflow.d.ts +17 -0
  18. package/dist/commands/workflow.d.ts.map +1 -1
  19. package/dist/commands/workflow.js +9 -3
  20. package/dist/commands/workflow.js.map +1 -1
  21. package/dist/configurators/claude.d.ts +3 -2
  22. package/dist/configurators/claude.d.ts.map +1 -1
  23. package/dist/configurators/claude.js +3 -3
  24. package/dist/configurators/claude.js.map +1 -1
  25. package/dist/configurators/codex.d.ts +4 -3
  26. package/dist/configurators/codex.d.ts.map +1 -1
  27. package/dist/configurators/codex.js +10 -10
  28. package/dist/configurators/codex.js.map +1 -1
  29. package/dist/configurators/index.d.ts +9 -3
  30. package/dist/configurators/index.d.ts.map +1 -1
  31. package/dist/configurators/index.js +95 -17
  32. package/dist/configurators/index.js.map +1 -1
  33. package/dist/configurators/shared.d.ts +13 -0
  34. package/dist/configurators/shared.d.ts.map +1 -1
  35. package/dist/configurators/shared.js +25 -0
  36. package/dist/configurators/shared.js.map +1 -1
  37. package/dist/configurators/workflow.d.ts +15 -1
  38. package/dist/configurators/workflow.d.ts.map +1 -1
  39. package/dist/configurators/workflow.js +23 -5
  40. package/dist/configurators/workflow.js.map +1 -1
  41. package/dist/configurators/zcode.d.ts +1 -1
  42. package/dist/configurators/zcode.d.ts.map +1 -1
  43. package/dist/configurators/zcode.js +2 -2
  44. package/dist/configurators/zcode.js.map +1 -1
  45. package/dist/constants/paths.d.ts +4 -0
  46. package/dist/constants/paths.d.ts.map +1 -1
  47. package/dist/constants/paths.js +4 -0
  48. package/dist/constants/paths.js.map +1 -1
  49. package/dist/migrations/manifests/0.4.0.json +9 -0
  50. package/dist/templates/atw/index.d.ts +10 -1
  51. package/dist/templates/atw/index.d.ts.map +1 -1
  52. package/dist/templates/atw/index.js +11 -1
  53. package/dist/templates/atw/index.js.map +1 -1
  54. package/dist/templates/atw/scripts/add_session.py +34 -13
  55. package/dist/templates/atw/scripts/common/active_task.py +2 -2
  56. package/dist/templates/atw/scripts/common/config.py +13 -0
  57. package/dist/templates/atw/scripts/common/developer.py +15 -4
  58. package/dist/templates/atw/scripts/common/noob_mode.py +35 -15
  59. package/dist/templates/atw/scripts/common/packages_context.py +16 -3
  60. package/dist/templates/atw/scripts/common/session_context.py +77 -19
  61. package/dist/templates/atw/scripts/common/task_store.py +78 -27
  62. package/dist/templates/atw/scripts/noob_mode.py +16 -4
  63. package/dist/templates/atw/scripts/task.py +99 -38
  64. package/dist/templates/atw/workflow-general.md +554 -0
  65. package/dist/templates/atw/workflow.md +1 -1
  66. package/dist/templates/codex/hooks/session-start.py +69 -18
  67. package/dist/templates/common/bundled-skills/atw-noob-mode/SKILL.md +6 -4
  68. package/dist/templates/common/commands/start.md +43 -0
  69. package/dist/templates/copilot/hooks/session-start.py +69 -18
  70. package/dist/templates/dsh/DSH.md +43 -0
  71. package/dist/templates/markdown/agents.md +11 -0
  72. package/dist/templates/markdown/index.d.ts +9 -0
  73. package/dist/templates/markdown/index.d.ts.map +1 -1
  74. package/dist/templates/markdown/index.js +14 -1
  75. package/dist/templates/markdown/index.js.map +1 -1
  76. package/dist/templates/markdown/spec/conventions.md.txt +13 -0
  77. package/dist/templates/opencode/lib/atw-context.js +19 -0
  78. package/dist/templates/opencode/lib/session-utils.js +79 -24
  79. package/dist/templates/opencode/plugins/inject-workflow-state.js +18 -8
  80. package/dist/templates/pi/extensions/atw/index.ts.txt +20 -9
  81. package/dist/templates/shared-hooks/inject-shell-session-context.py +3 -3
  82. package/dist/templates/shared-hooks/inject-workflow-state.py +21 -2
  83. package/dist/templates/shared-hooks/session-start.py +70 -22
  84. package/dist/templates/snow/SNOW.md +30 -0
  85. package/dist/templates/snow/hooks/write-atw-context.py +64 -0
  86. package/dist/utils/manifest-prune.d.ts +2 -1
  87. package/dist/utils/manifest-prune.d.ts.map +1 -1
  88. package/dist/utils/manifest-prune.js +10 -7
  89. package/dist/utils/manifest-prune.js.map +1 -1
  90. package/dist/utils/optional-skills.d.ts.map +1 -1
  91. package/dist/utils/optional-skills.js +2 -1
  92. package/dist/utils/optional-skills.js.map +1 -1
  93. package/dist/utils/project-mode.d.ts +30 -0
  94. package/dist/utils/project-mode.d.ts.map +1 -0
  95. package/dist/utils/project-mode.js +86 -0
  96. package/dist/utils/project-mode.js.map +1 -0
  97. package/dist/utils/workflow-resolver.d.ts +9 -5
  98. package/dist/utils/workflow-resolver.d.ts.map +1 -1
  99. package/dist/utils/workflow-resolver.js +25 -11
  100. package/dist/utils/workflow-resolver.js.map +1 -1
  101. package/package.json +3 -3
@@ -10,6 +10,7 @@ Provides:
10
10
  output_json - Print JSON
11
11
  output_text - Print text
12
12
  get_update_hint - Once-per-session "update available" line
13
+ conventions_context - A general project's `<conventions>` block
13
14
  """
14
15
 
15
16
  from __future__ import annotations
@@ -22,7 +23,7 @@ import sys
22
23
  from pathlib import Path
23
24
 
24
25
  from .active_task import resolve_context_key
25
- from .config import get_git_packages
26
+ from .config import get_git_packages, get_project_mode
26
27
  from .git import run_git
27
28
  from .noob_mode import noob_mode_reminder
28
29
  from .packages_context import get_packages_section
@@ -264,12 +265,18 @@ def _collect_package_git_info(
264
265
  return discovered
265
266
 
266
267
 
267
- def _append_root_git_context(lines: list[str], root_git_info: dict) -> None:
268
+ def _append_root_git_context(
269
+ lines: list[str], root_git_info: dict, package_git_info: list[dict]
270
+ ) -> None:
268
271
  """Append root Git status without misleading non-Git roots."""
269
272
  lines.append("## GIT STATUS")
270
273
  if not root_git_info["isRepo"]:
271
274
  lines.append("Root is not a Git repository.")
272
- lines.append("Run Git commands from the package repository paths listed below.")
275
+ # 普通文件夹下面没有列出任何仓库,这句指路只在确实列了仓库时才说。
276
+ if package_git_info:
277
+ lines.append(
278
+ "Run Git commands from the package repository paths listed below."
279
+ )
273
280
  else:
274
281
  lines.append(f"Branch: {root_git_info['branch']}")
275
282
  if root_git_info["isClean"]:
@@ -488,6 +495,54 @@ def get_update_hint(repo_root: Path, context_key: str | None = None) -> str | No
488
495
  )
489
496
 
490
497
 
498
+ # =============================================================================
499
+ # Conventions (general projects)
500
+ # =============================================================================
501
+
502
+ # The one flat place a `general` project keeps what its user asked for more
503
+ # than once. A `dev` project spreads coding guidelines over per-package,
504
+ # per-layer directories under spec/ and lists their indexes at session start;
505
+ # a general project has this single file, handed to the AI in full instead.
506
+ #
507
+ # It sits under spec/ on purpose: that directory is already the user's own
508
+ # content everywhere it matters — never overwritten by `atw update`, never
509
+ # hash-tracked, checked for uncommitted work before `atw uninstall`.
510
+ FILE_CONVENTIONS = "conventions.md"
511
+
512
+ # Project-relative, as the AI should read and write it.
513
+ CONVENTIONS_PATH = f"{DIR_WORKFLOW}/{DIR_SPEC}/{FILE_CONVENTIONS}"
514
+
515
+ _CONVENTIONS_HEADER = (
516
+ f"Source: {CONVENTIONS_PATH} — what this project's user asked for more than "
517
+ "once. Follow it in every reply."
518
+ )
519
+
520
+
521
+ def conventions_context(repo_root: Path) -> str:
522
+ """The `<conventions>` block for a `general` project, "" for a `dev` one.
523
+
524
+ The session-start hooks and `get_context.py` all take the block from here,
525
+ so every platform says the same thing; the OpenCode plugin has its own port
526
+ (`lib/session-utils.js`) — keep the two in step.
527
+
528
+ A general project always gets the block: with the file missing or empty it
529
+ still names the place, so the AI knows where the first entry goes.
530
+ """
531
+ if get_project_mode(repo_root) != "general":
532
+ return ""
533
+ body = ""
534
+ try:
535
+ body = (repo_root / CONVENTIONS_PATH).read_text(encoding="utf-8").strip()
536
+ except OSError:
537
+ pass
538
+ if not body:
539
+ return (
540
+ f"<conventions>\n{_CONVENTIONS_HEADER} Nothing is written there yet."
541
+ "\n</conventions>"
542
+ )
543
+ return f"<conventions>\n{_CONVENTIONS_HEADER}\n\n{body}\n</conventions>"
544
+
545
+
491
546
  # =============================================================================
492
547
  # JSON Output
493
548
  # =============================================================================
@@ -607,6 +662,13 @@ def get_context_text(repo_root: Path | None = None) -> str:
607
662
  lines.append(f"Name: {developer}")
608
663
  lines.append("")
609
664
 
665
+ # A general project's conventions reach platforms without a hook the same
666
+ # way the noob-mode reminder below does.
667
+ conventions = conventions_context(repo_root)
668
+ if conventions:
669
+ lines.append(conventions)
670
+ lines.append("")
671
+
610
672
  # Platforms without a hook learn about noob mode from this output alone.
611
673
  noob_mode = noob_mode_reminder(repo_root)
612
674
  if noob_mode:
@@ -614,16 +676,14 @@ def get_context_text(repo_root: Path | None = None) -> str:
614
676
  lines.append("")
615
677
 
616
678
  root_git_info = _collect_root_git_info(repo_root)
617
- _append_root_git_context(lines, root_git_info)
679
+ package_git_info = _collect_package_git_info(
680
+ repo_root,
681
+ discover_unconfigured=not root_git_info["isRepo"],
682
+ )
683
+ _append_root_git_context(lines, root_git_info, package_git_info)
618
684
 
619
685
  # Package git repos — independent sub-repositories
620
- _append_package_git_context(
621
- lines,
622
- _collect_package_git_info(
623
- repo_root,
624
- discover_unconfigured=not root_git_info["isRepo"],
625
- ),
626
- )
686
+ _append_package_git_context(lines, package_git_info)
627
687
 
628
688
  # Current task
629
689
  lines.append("## CURRENT TASK")
@@ -851,16 +911,14 @@ def get_context_text_record(repo_root: Path | None = None) -> str:
851
911
  lines.append("")
852
912
 
853
913
  root_git_info = _collect_root_git_info(repo_root)
854
- _append_root_git_context(lines, root_git_info)
914
+ package_git_info = _collect_package_git_info(
915
+ repo_root,
916
+ discover_unconfigured=not root_git_info["isRepo"],
917
+ )
918
+ _append_root_git_context(lines, root_git_info, package_git_info)
855
919
 
856
920
  # Package git repos — independent sub-repositories
857
- _append_package_git_context(
858
- lines,
859
- _collect_package_git_info(
860
- repo_root,
861
- discover_unconfigured=not root_git_info["isRepo"],
862
- ),
863
- )
921
+ _append_package_git_context(lines, package_git_info)
864
922
 
865
923
  # CURRENT TASK
866
924
  lines.append("## CURRENT TASK")
@@ -28,6 +28,7 @@ from pathlib import Path
28
28
  from .config import (
29
29
  get_codex_dispatch_mode,
30
30
  get_packages,
31
+ get_project_mode,
31
32
  get_session_auto_commit,
32
33
  is_monorepo,
33
34
  resolve_package,
@@ -263,10 +264,30 @@ def _parse_meta_pairs(pairs: list[str] | None) -> dict[str, str] | None:
263
264
  return meta
264
265
 
265
266
 
266
- def _default_prd_content(title: str, description: str | None = None) -> str:
267
+ def _default_prd_content(
268
+ title: str, description: str | None = None, general: bool = False
269
+ ) -> str:
267
270
  """Return the default PRD skeleton created with every task."""
268
- goal = (description or "").strip() or "TBD."
269
271
  heading = title.strip() or "Untitled task"
272
+ if general:
273
+ # The user of a general project opens this file themselves: the three
274
+ # parts its workflow has the AI write, and no word about tickets.
275
+ unwritten = "(Not written yet.)"
276
+ return f"""# {heading}
277
+
278
+ ## What will be done
279
+
280
+ {(description or "").strip() or unwritten}
281
+
282
+ ## What will not be done
283
+
284
+ {unwritten}
285
+
286
+ ## What counts as done
287
+
288
+ {unwritten}
289
+ """
290
+ goal = (description or "").strip() or "TBD."
270
291
  return f"""# {heading}
271
292
 
272
293
  ## Goal
@@ -471,7 +492,7 @@ def cmd_create(args: argparse.Namespace) -> int:
471
492
  # back to the checked-out branch when the default can't be resolved
472
493
  # (no remote configured, offline, etc.) — the pre-existing behavior.
473
494
  # --base-branch lets the caller override both when neither is correct.
474
- _, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root)
495
+ branch_rc, branch_out, _ = run_git(["branch", "--show-current"], cwd=repo_root)
475
496
  current_branch = branch_out.strip() or "main"
476
497
  explicit_base_branch: str | None = getattr(args, "base_branch", None)
477
498
  if explicit_base_branch:
@@ -482,15 +503,17 @@ def cmd_create(args: argparse.Namespace) -> int:
482
503
  base_branch = resolved_base_branch
483
504
  else:
484
505
  base_branch = current_branch
485
- print(
486
- colored(
487
- f"warning: could not resolve the repository's default branch "
488
- f"(no remote configured, offline, etc.); stamping base_branch as "
489
- f"the checked-out branch '{base_branch}'. Pass --base-branch to override.",
490
- Colors.YELLOW,
491
- ),
492
- file=sys.stderr,
493
- )
506
+ # 普通文件夹里没有仓库也没有检出的分支,这条提醒说的事并不存在。
507
+ if branch_rc == 0:
508
+ print(
509
+ colored(
510
+ f"warning: could not resolve the repository's default branch "
511
+ f"(no remote configured, offline, etc.); stamping base_branch as "
512
+ f"the checked-out branch '{base_branch}'. Pass --base-branch to override.",
513
+ Colors.YELLOW,
514
+ ),
515
+ file=sys.stderr,
516
+ )
494
517
 
495
518
  task_data = {
496
519
  "id": slug,
@@ -535,10 +558,12 @@ def cmd_create(args: argparse.Namespace) -> int:
535
558
  )
536
559
  return 1
537
560
 
561
+ general = get_project_mode(repo_root) == "general"
562
+
538
563
  prd_path = task_dir / "prd.md"
539
564
  if not prd_path.exists():
540
565
  prd_path.write_text(
541
- _default_prd_content(args.title, description),
566
+ _default_prd_content(args.title, description, general),
542
567
  encoding="utf-8",
543
568
  )
544
569
 
@@ -554,8 +579,11 @@ def cmd_create(args: argparse.Namespace) -> int:
554
579
  # check.jsonl), so a seeded-but-empty implement manifest would only be a
555
580
  # file nobody curates. The `start` gate only refuses manifests that exist
556
581
  # with zero curated entries, so an absent one is not gated.
582
+ #
583
+ # A general project installs no sub-agents, so nobody would curate or read
584
+ # the manifest there either: it is not seeded, and `start` needs no flag.
557
585
  created_jsonl = False
558
- if _has_subagent_platform(repo_root):
586
+ if not general and _has_subagent_platform(repo_root):
559
587
  jsonl_path = task_dir / "check.jsonl"
560
588
  if not jsonl_path.exists():
561
589
  jsonl_path.write_text("", encoding="utf-8")
@@ -654,28 +682,42 @@ def cmd_create(args: argparse.Namespace) -> int:
654
682
  print(colored(f"Created task: {dir_name}", Colors.GREEN), file=sys.stderr)
655
683
  print("", file=sys.stderr)
656
684
  print(colored("Next steps:", Colors.BLUE), file=sys.stderr)
657
- print(" - Fill prd.md with requirements and acceptance criteria", file=sys.stderr)
658
- print(" - Lightweight task: PRD-only is valid", file=sys.stderr)
659
- print(" - Complex task: slice it into issues/NN-slug.md tickets before task.py start", file=sys.stderr)
660
- if created_jsonl:
685
+ if general:
686
+ # A general project has no tickets, no sub-agents to curate context
687
+ # for and no `continue` command: its workflow has the AI do the rest.
688
+ print(" - Talk it through with the user until what they want is clear", file=sys.stderr)
661
689
  print(
662
- " - Curate check.jsonl (created empty) as the spec/research "
663
- "manifest before task.py start when sub-agents need context:",
690
+ " - Write prd.md: what will be done, what will not, what counts as done; "
691
+ "show it to the user and wait for them to confirm",
664
692
  file=sys.stderr,
665
693
  )
666
694
  print(
667
- ' one JSON object per line — {"file": "<path>", "reason": "<why>"}; '
668
- "spec/research docs only, no code paths",
695
+ " - Once confirmed: task.py start <task>, then do the work",
669
696
  file=sys.stderr,
670
697
  )
698
+ else:
699
+ print(" - Fill prd.md with requirements and acceptance criteria", file=sys.stderr)
700
+ print(" - Lightweight task: PRD-only is valid", file=sys.stderr)
701
+ print(" - Complex task: slice it into issues/NN-slug.md tickets before task.py start", file=sys.stderr)
702
+ if created_jsonl:
703
+ print(
704
+ " - Curate check.jsonl (created empty) as the spec/research "
705
+ "manifest before task.py start when sub-agents need context:",
706
+ file=sys.stderr,
707
+ )
708
+ print(
709
+ ' one JSON object per line — {"file": "<path>", "reason": "<why>"}; '
710
+ "spec/research docs only, no code paths",
711
+ file=sys.stderr,
712
+ )
713
+ print(
714
+ " list available specs: python3 .atw/scripts/get_context.py --mode packages",
715
+ file=sys.stderr,
716
+ )
671
717
  print(
672
- " list available specs: python3 .atw/scripts/get_context.py --mode packages",
718
+ " - Use the ATW `continue` command or phase context to decide the next step",
673
719
  file=sys.stderr,
674
720
  )
675
- print(
676
- " - Use the ATW `continue` command or phase context to decide the next step",
677
- file=sys.stderr,
678
- )
679
721
  print("", file=sys.stderr)
680
722
 
681
723
  # Output relative path for script chaining
@@ -1442,6 +1484,15 @@ def _auto_commit_archive(
1442
1484
  )
1443
1485
  return True
1444
1486
 
1487
+ # 普通文件夹没有可提交的地方:这不是故障,说一句就够了,不把 git 的报错原文打出来。
1488
+ rc, _, _ = run_git(["rev-parse", "--is-inside-work-tree"], cwd=repo_root)
1489
+ if rc != 0:
1490
+ print(
1491
+ "[OK] Not a git repository — task archived, skipping auto-commit.",
1492
+ file=sys.stderr,
1493
+ )
1494
+ return True
1495
+
1445
1496
  source_rel = f"{DIR_WORKFLOW}/{DIR_TASKS}/{task_name}"
1446
1497
  rc, tracked_out, _ = run_git(
1447
1498
  ["ls-files", "--", source_rel],
@@ -3,9 +3,10 @@
3
3
  Switch noob mode (plain-language replies) for the current developer.
4
4
 
5
5
  Usage:
6
- python3 noob_mode.py on Turn it on
7
- python3 noob_mode.py off Turn it off
8
- python3 noob_mode.py status Show whether it is on
6
+ python3 noob_mode.py on Turn it on, whatever the project default
7
+ python3 noob_mode.py off Turn it off, whatever the project default
8
+ python3 noob_mode.py status Show whether it is on, and whether that is
9
+ this developer's setting or the project default
9
10
 
10
11
  The switch is personal — see common/noob_mode.py.
11
12
  """
@@ -14,7 +15,8 @@ from __future__ import annotations
14
15
 
15
16
  import sys
16
17
 
17
- from common.noob_mode import is_noob_mode_on, set_noob_mode
18
+ from common.config import get_project_mode
19
+ from common.noob_mode import is_noob_mode_on, noob_mode_choice, set_noob_mode
18
20
  from common.paths import DIR_SCRIPTS, DIR_WORKFLOW, FILE_DEVELOPER, get_repo_root
19
21
 
20
22
 
@@ -35,6 +37,16 @@ def main() -> None:
35
37
  sys.exit(1)
36
38
 
37
39
  print(f"Noob mode: {'on' if is_noob_mode_on(repo_root) else 'off'}")
40
+ if action == "status":
41
+ # stdout stays the one-line answer; where it comes from goes to stderr.
42
+ if noob_mode_choice(repo_root) is None:
43
+ source = (
44
+ f"project default (mode: {get_project_mode(repo_root)}), "
45
+ "no personal setting"
46
+ )
47
+ else:
48
+ source = "your own setting"
49
+ print(f"Source: {source}", file=sys.stderr)
38
50
 
39
51
 
40
52
  if __name__ == "__main__":
@@ -9,6 +9,7 @@ Usage:
9
9
  python3 task.py validate <dir> # Validate jsonl files
10
10
  python3 task.py list-context <dir> # List jsonl entries
11
11
  python3 task.py start <dir> # Set active task, record current branch
12
+ python3 task.py resume <dir> # Set active task, leave its status as it is
12
13
  python3 task.py current [--source] [--json] # Show active task
13
14
  python3 task.py finish # Clear active task
14
15
  python3 task.py set-branch <dir> <branch> # Set git branch
@@ -48,6 +49,7 @@ from common.active_task import (
48
49
  resolve_context_key,
49
50
  set_active_task,
50
51
  )
52
+ from common.config import get_project_mode
51
53
  from common.git import current_branch_name
52
54
  from common.io import (
53
55
  describe_json_read_failure,
@@ -186,14 +188,13 @@ def _record_start_state(
186
188
  )
187
189
 
188
190
 
189
- def cmd_start(args: argparse.Namespace) -> int:
190
- """Set active task."""
191
- repo_root = get_repo_root()
192
- task_input = args.dir
193
-
191
+ def _resolve_session_task(task_input: str, repo_root: Path) -> tuple[Path, str] | None:
192
+ """Resolve a task ref to its directory and the repo-relative path stored
193
+ in the session pointer. Prints the reason and returns None when it cannot.
194
+ """
194
195
  if not task_input:
195
196
  print(colored("Error: task directory or name required", Colors.RED))
196
- return 1
197
+ return None
197
198
 
198
199
  # Resolve task directory (supports task name, relative path, or absolute path)
199
200
  full_path = resolve_task_dir(task_input, repo_root)
@@ -202,19 +203,70 @@ def cmd_start(args: argparse.Namespace) -> int:
202
203
  # resolve_task_dir already named the exact reason on stderr. A second,
203
204
  # generic line on stdout would split one diagnosis across two streams
204
205
  # and bury the specific message.
205
- return 1
206
+ return None
206
207
 
207
208
  if not full_path.is_dir():
208
209
  print(colored(f"Error: Task not found: {task_input}", Colors.RED))
209
210
  print("Hint: Use task name (e.g., 'my-task') or full path (e.g., '.atw/tasks/01-31-my-task')")
211
+ return None
212
+
213
+ # Convert to relative path for storage. repo_root is resolved because
214
+ # full_path already is (resolve_task_dir only returns paths inside the
215
+ # resolved root), so an unresolved repo_root would mismatch under a
216
+ # symlink (e.g. /tmp on macOS) and reject a perfectly normal task.
217
+ try:
218
+ task_dir = full_path.relative_to(repo_root.resolve()).as_posix()
219
+ except ValueError:
220
+ # resolve_task_dir already refused everything outside the repo, so
221
+ # this is unreachable in practice. Refuse rather than fall back to
222
+ # str(full_path) — that fallback (a lexical relative_to() paired with
223
+ # an absolute-path fallback) is exactly the pattern that let a `..`
224
+ # ref escape into storage before this fix.
225
+ print(colored(f"Error: Task not found: {task_input}", Colors.RED))
226
+ print("Hint: Use task name (e.g., 'my-task') or full path (e.g., '.atw/tasks/01-31-my-task')")
227
+ return None
228
+
229
+ return full_path, task_dir
230
+
231
+
232
+ def _print_no_session_identity(command: str) -> None:
233
+ # Degraded mode: no session identity available.
234
+ # Hook didn't inject ATW_CONTEXT_ID (common on Windows + Claude Code,
235
+ # --continue resume path, fork distribution, hooks disabled, etc.). Skip
236
+ # per-session pointer write; AI continues based on conversation context.
237
+ print(colored(
238
+ "ℹ Session identity not available; active-task pointer not persisted "
239
+ "this session (degraded mode). AI continues based on conversation context.",
240
+ Colors.YELLOW,
241
+ ))
242
+ print(colored(
243
+ "Hint: run inside an AI IDE/session that exposes session identity, "
244
+ f"or set ATW_CONTEXT_ID before running task.py {command}.",
245
+ Colors.YELLOW,
246
+ ))
247
+
248
+
249
+ def cmd_start(args: argparse.Namespace) -> int:
250
+ """Set active task."""
251
+ repo_root = get_repo_root()
252
+ task_input = args.dir
253
+
254
+ resolved = _resolve_session_task(task_input, repo_root)
255
+ if resolved is None:
210
256
  return 1
257
+ full_path, task_dir = resolved
211
258
 
212
259
  # Context-manifest gate: a seeded-but-uncurated implement/check
213
260
  # manifest means every sub-agent dispatched for this task runs with zero
214
261
  # spec context, and nothing downstream surfaces that to the main session.
215
262
  # An absent manifest is not gated — create seeds the files only on
216
263
  # sub-agent-capable platforms, so absence means no sub-agent reads them.
217
- if not getattr(args, "allow_empty_context", False):
264
+ # Nor is a general project: it installs no sub-agents, and a task brought
265
+ # over from dev may still carry an empty manifest.
266
+ if (
267
+ not getattr(args, "allow_empty_context", False)
268
+ and get_project_mode(repo_root) != "general"
269
+ ):
218
270
  empty_manifests = [
219
271
  name
220
272
  for name in ("implement.jsonl", "check.jsonl")
@@ -232,39 +284,10 @@ def cmd_start(args: argparse.Namespace) -> int:
232
284
  print(" Intentionally empty? Re-run start with --allow-empty-context")
233
285
  return 1
234
286
 
235
- # Convert to relative path for storage. repo_root is resolved because
236
- # full_path already is (resolve_task_dir only returns paths inside the
237
- # resolved root), so an unresolved repo_root would mismatch under a
238
- # symlink (e.g. /tmp on macOS) and reject a perfectly normal task.
239
- try:
240
- task_dir = full_path.relative_to(repo_root.resolve()).as_posix()
241
- except ValueError:
242
- # resolve_task_dir already refused everything outside the repo, so
243
- # this is unreachable in practice. Refuse rather than fall back to
244
- # str(full_path) — that fallback (a lexical relative_to() paired with
245
- # an absolute-path fallback) is exactly the pattern that let a `..`
246
- # ref escape into storage before this fix.
247
- print(colored(f"Error: Task not found: {task_input}", Colors.RED))
248
- print("Hint: Use task name (e.g., 'my-task') or full path (e.g., '.atw/tasks/01-31-my-task')")
249
- return 1
250
-
251
287
  task_json_path = full_path / FILE_TASK_JSON
252
288
 
253
289
  if not resolve_context_key():
254
- # Degraded mode: no session identity available.
255
- # Hook didn't inject ATW_CONTEXT_ID (common on Windows + Claude Code,
256
- # --continue resume path, fork distribution, hooks disabled, etc.). Skip
257
- # per-session pointer write; AI continues based on conversation context.
258
- print(colored(
259
- "ℹ Session identity not available; active-task pointer not persisted "
260
- "this session (degraded mode). AI continues based on conversation context.",
261
- Colors.YELLOW,
262
- ))
263
- print(colored(
264
- "Hint: run inside an AI IDE/session that exposes session identity, "
265
- "or set ATW_CONTEXT_ID before running task.py start.",
266
- Colors.YELLOW,
267
- ))
290
+ _print_no_session_identity("start")
268
291
 
269
292
  # Still flip task.json status: discover → implement so downstream phases proceed.
270
293
  if task_json_path.is_file():
@@ -290,6 +313,36 @@ def cmd_start(args: argparse.Namespace) -> int:
290
313
  return 1
291
314
 
292
315
 
316
+ def cmd_resume(args: argparse.Namespace) -> int:
317
+ """Set active task without touching its record.
318
+
319
+ `start` is also "begin the work": it moves a planning-stage task to
320
+ `implement`. Picking a task up in a later session must not do that — a
321
+ task still being discussed would read as started before it was agreed.
322
+ So this writes the session pointer and nothing else: no status, no branch,
323
+ no manifest gate, no `after_start` hook.
324
+ """
325
+ repo_root = get_repo_root()
326
+
327
+ resolved = _resolve_session_task(args.dir, repo_root)
328
+ if resolved is None:
329
+ return 1
330
+ _, task_dir = resolved
331
+
332
+ if not resolve_context_key():
333
+ _print_no_session_identity("resume")
334
+ return 0
335
+
336
+ active = set_active_task(task_dir, repo_root)
337
+ if not active:
338
+ print(colored("Error: Failed to set current task", Colors.RED))
339
+ return 1
340
+
341
+ print(colored(f"✓ Current task set to: {task_dir}", Colors.GREEN))
342
+ print(f"Source: {active.source}")
343
+ return 0
344
+
345
+
293
346
  def cmd_finish(args: argparse.Namespace) -> int:
294
347
  """Clear active task."""
295
348
  repo_root = get_repo_root()
@@ -556,6 +609,7 @@ Usage:
556
609
  python3 task.py validate <dir> Validate jsonl files
557
610
  python3 task.py list-context <dir> List jsonl entries
558
611
  python3 task.py start <dir> Set active task; records the checked-out branch when unset
612
+ python3 task.py resume <dir> Set active task; its status and record stay as they are
559
613
  python3 task.py current [--source] Show active task
560
614
  python3 task.py finish Clear active task
561
615
  python3 task.py set-branch <dir> <branch> Set git branch
@@ -713,6 +767,12 @@ def main() -> int:
713
767
  help="Start even when implement.jsonl / check.jsonl have no curated entries",
714
768
  )
715
769
 
770
+ # resume
771
+ p_resume = subparsers.add_parser(
772
+ "resume", help="Set active task, leave its status as it is"
773
+ )
774
+ p_resume.add_argument("dir", help="Task directory")
775
+
716
776
  # current
717
777
  p_current = subparsers.add_parser("current", help="Show active task")
718
778
  p_current.add_argument("--source", action="store_true",
@@ -807,6 +867,7 @@ def main() -> int:
807
867
  "validate": cmd_validate,
808
868
  "list-context": cmd_list_context,
809
869
  "start": cmd_start,
870
+ "resume": cmd_resume,
810
871
  "current": cmd_current,
811
872
  "finish": cmd_finish,
812
873
  "set-branch": cmd_set_branch,