workstream-cli 0.0.1__py3-none-any.whl

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 (86) hide show
  1. workstream/ARCHITECTURE.md +89 -0
  2. workstream/__init__.py +8 -0
  3. workstream/cli.py +136 -0
  4. workstream/commands/__init__.py +0 -0
  5. workstream/commands/backfill.py +139 -0
  6. workstream/commands/block.py +93 -0
  7. workstream/commands/checkin.py +51 -0
  8. workstream/commands/cron.py +119 -0
  9. workstream/commands/focus_cmd.py +273 -0
  10. workstream/commands/idea.py +172 -0
  11. workstream/commands/index.py +89 -0
  12. workstream/commands/init.py +567 -0
  13. workstream/commands/inspect_cmd.py +354 -0
  14. workstream/commands/list_cmd.py +99 -0
  15. workstream/commands/nest.py +108 -0
  16. workstream/commands/new.py +95 -0
  17. workstream/commands/next_cmd.py +333 -0
  18. workstream/commands/report.py +190 -0
  19. workstream/commands/resume.py +145 -0
  20. workstream/commands/review.py +227 -0
  21. workstream/commands/serve.py +23 -0
  22. workstream/commands/setup.py +178 -0
  23. workstream/commands/show.py +123 -0
  24. workstream/commands/snooze.py +117 -0
  25. workstream/commands/stale.py +116 -0
  26. workstream/commands/sweep.py +1753 -0
  27. workstream/commands/tree.py +105 -0
  28. workstream/commands/update_status.py +117 -0
  29. workstream/config.py +322 -0
  30. workstream/extensions/__init__.py +0 -0
  31. workstream/extensions/workstream.ts +633 -0
  32. workstream/focus_artifact.py +157 -0
  33. workstream/git.py +194 -0
  34. workstream/harness.py +49 -0
  35. workstream/llm.py +78 -0
  36. workstream/markdown.py +501 -0
  37. workstream/models.py +274 -0
  38. workstream/plan_index.py +88 -0
  39. workstream/provisioning.py +196 -0
  40. workstream/repo_discovery.py +158 -0
  41. workstream/review_artifact.py +96 -0
  42. workstream/scripts/migrate_statuses.py +120 -0
  43. workstream/skills/__init__.py +0 -0
  44. workstream/skills/workstream_context/SKILL.md +75 -0
  45. workstream/skills/workstream_context/__init__.py +0 -0
  46. workstream/skills/workstream_focus/SKILL.md +141 -0
  47. workstream/skills/workstream_init/SKILL.md +86 -0
  48. workstream/skills/workstream_review/SKILL.md +224 -0
  49. workstream/skills/workstream_sweep/SKILL.md +178 -0
  50. workstream/sweep_state.py +93 -0
  51. workstream/templates/dashboard.html +382 -0
  52. workstream/templates/detail.html +360 -0
  53. workstream/templates/plan.html +210 -0
  54. workstream/test/__init__.py +0 -0
  55. workstream/test/conftest.py +221 -0
  56. workstream/test/fixtures/sample_sprint_note.md +10 -0
  57. workstream/test/fixtures/sample_workstream.md +41 -0
  58. workstream/test/test_backfill.py +180 -0
  59. workstream/test/test_batch_writeback.py +81 -0
  60. workstream/test/test_commands.py +938 -0
  61. workstream/test/test_config.py +54 -0
  62. workstream/test/test_focus_artifact.py +211 -0
  63. workstream/test/test_git.py +88 -0
  64. workstream/test/test_heuristics.py +136 -0
  65. workstream/test/test_hierarchy.py +231 -0
  66. workstream/test/test_init.py +452 -0
  67. workstream/test/test_inspect.py +143 -0
  68. workstream/test/test_llm.py +78 -0
  69. workstream/test/test_markdown.py +626 -0
  70. workstream/test/test_models.py +506 -0
  71. workstream/test/test_next.py +206 -0
  72. workstream/test/test_plan_index.py +83 -0
  73. workstream/test/test_provisioning.py +270 -0
  74. workstream/test/test_repo_discovery.py +181 -0
  75. workstream/test/test_resume.py +71 -0
  76. workstream/test/test_sweep.py +1196 -0
  77. workstream/test/test_sweep_state.py +86 -0
  78. workstream/test/test_thoughts.py +516 -0
  79. workstream/test/test_web.py +606 -0
  80. workstream/thoughts.py +505 -0
  81. workstream/web.py +444 -0
  82. workstream_cli-0.0.1.dist-info/LICENSE +21 -0
  83. workstream_cli-0.0.1.dist-info/METADATA +93 -0
  84. workstream_cli-0.0.1.dist-info/RECORD +86 -0
  85. workstream_cli-0.0.1.dist-info/WHEEL +4 -0
  86. workstream_cli-0.0.1.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,89 @@
1
+ # ws Architecture
2
+
3
+ ws is a workstream manager — it tracks trains of thought spanning projects and time.
4
+ Data lives as markdown files in `_Workstreams/`, each with YAML frontmatter and a markdown body.
5
+
6
+ ## Execution Modes
7
+
8
+ ws operates in four distinct modes depending on who initiates the call and how results are consumed.
9
+
10
+ | Mode | Who drives | LLM involved | ws lifetime | Examples |
11
+ |------------|------------------|--------------|--------------------|----------------------------------------------------|
12
+ | **direct** | User via CLI | No | Runs and exits | `ws list`, `ws stale`, `ws next`, `ws report`, `ws show` |
13
+ | **handoff**| User via CLI | Yes (omp) | Execs into omp | `ws review`, `ws sweep --review-plans`, `ws init` bootstrap |
14
+ | **batch** | User via CLI | Yes (loop) | Stays alive | `ws sweep --discover`, `ws sweep --review-plans --batch`, summary backfill |
15
+ | **tool** | Agent in omp | Already running | Runs and exits | Agent runs `ws stale`, `ws show`, etc. as tool calls |
16
+
17
+ ### Direct
18
+
19
+ Deterministic CLI commands. Pure Python, no LLM. Read workstream files, compute, print.
20
+
21
+ ### Handoff
22
+
23
+ ws gathers context, writes a manifest to a temp file, then **replaces itself** via `os.execv` into an omp/claude harness process. The harness receives the manifest as `--append-system-prompt` and a skill directive. After exec, ws is gone — the harness owns the session.
24
+
25
+ ### Batch
26
+
27
+ ws orchestrates repeated LLM calls in a loop. It stays alive as the driver: sends prompts via `llm.py`, parses structured responses, writes results back to workstream files. Used for bulk operations where interactive review is unnecessary.
28
+
29
+ ### Tool
30
+
31
+ The user is already inside an omp session. The agent calls ws CLI commands as tools. ws is a passive data source — it reads, computes, prints, and exits. No handoff occurs.
32
+
33
+ ## Recursion Guard
34
+
35
+ When a handoff command is invoked from **within** an existing omp session (tool mode), ws must not exec into a second harness. Detection: check for `$OMPCODE=1` or `$CLAUDECODE=1` environment variables. When detected, print the context manifest to stdout instead of calling `os.execv`. See `commands/sweep.py` for the handoff path.
36
+
37
+ ## Data Model
38
+
39
+ All types live in `models.py`. No I/O — pure dataclasses and helpers.
40
+
41
+ | Type | Description |
42
+ |-----------------|----------------------------------------------------------|
43
+ | `Workstream` | Top-level entity: id, title, status, size, tags, repos, timestamps, thread, plans, branches, next_actions, log, thoughts, ideas, decisions, summary |
44
+ | `ThreadEntry` | Timestamped narrative entry in a workstream's thread |
45
+ | `ThoughtEntry` | Captured thought with timestamp and content |
46
+ | `IdeaEntry` | Idea with timestamp and content |
47
+ | `PlanRef` | Reference to a plan file: path, id, optional workstream |
48
+ | `BranchRef` | Reference to a git branch: name, repo, optional dates |
49
+ | `LogEntry` | Timestamped structured log entry (action + detail) |
50
+
51
+ Serialization to/from markdown+YAML frontmatter is handled by `markdown.py`.
52
+
53
+ ## Key Modules
54
+
55
+ | Module | Responsibility |
56
+ |---------------------|-----------------------------------------------------------------------|
57
+ | `cli.py` | Entry point, face `Command` tree, middleware for config loading |
58
+ | `config.py` | Config discovery (`$WS_DIR`, walk-up, global pointer), LLM detection |
59
+ | `models.py` | Dataclasses for all entities. Pure data + helpers, no I/O |
60
+ | `markdown.py` | YAML frontmatter round-trip, section-level read/write |
61
+ | `llm.py` | LLM shell-out abstraction — detect and invoke CLI-based LLM agents |
62
+ | `git.py` | Thin subprocess wrappers for branch and commit queries |
63
+ | `repo_discovery.py` | Scan directories for git repos, build activity index |
64
+ | `sweep_state.py` | Persistent state for sweep deduplication across runs |
65
+ | `thoughts.py` | Thought/snooze computation helpers |
66
+ | `web.py` | Live web dashboard |
67
+
68
+ ### Commands (`commands/`)
69
+
70
+ | Command | Mode | What it does |
71
+ |---------------|----------|-----------------------------------------------------------|
72
+ | `init` | handoff | First-time setup, install harness skills, optional bootstrap |
73
+ | `list_cmd` | direct | Tabular listing of workstreams |
74
+ | `show` | direct | Display a single workstream by id prefix |
75
+ | `stale` | direct | Find workstreams that need attention |
76
+ | `next_cmd` | direct | Prioritized active workstreams by staleness |
77
+ | `report` | direct | Activity report across workstreams |
78
+ | `sweep` | all four | Scan repos, discover/review plans, batch or interactive |
79
+ | `backfill` | batch | Add IDs and infer associations for plan files |
80
+ | `checkin` | direct | Record a check-in on a workstream |
81
+ | `new` | direct | Create a new workstream |
82
+ | `idea` | direct | Idea capture, listing, promotion |
83
+ | `snooze` | direct | Snooze/wake workstreams |
84
+ | `nest` | direct | Workstream hierarchy (nest/unnest) |
85
+ | `tree` | direct | ASCII tree of workstream hierarchy |
86
+ | `index` | direct | Generate index.md for the workstreams directory |
87
+ | `serve` | direct | Live web dashboard server |
88
+ | `setup` | direct | Validate installation health, optionally fix |
89
+ | `cron` | direct | Set up periodic sweep in user crontab |
workstream/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ from importlib.metadata import version, PackageNotFoundError
2
+
3
+ try:
4
+ __version__ = version('workstream')
5
+ except PackageNotFoundError:
6
+ __version__ = '0.1.0-dev'
7
+
8
+ __all__ = ['__version__']
workstream/cli.py ADDED
@@ -0,0 +1,136 @@
1
+ """ws CLI entry point and shared helpers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ from face import Command, CommandLineError, UsageError, face_middleware
9
+
10
+ from workstream.config import Config, apply_timezone, load_config
11
+ from workstream.markdown import load_workstream
12
+ from workstream.models import Workstream, slugify
13
+
14
+
15
+ # Files that live in the workstream directory but aren't workstream files.
16
+ _SKIP_FILES = frozenset(('index.md', 'inbox.md', 'dashboard.html'))
17
+
18
+
19
+ def load_all_workstreams(ws_dir: Path) -> list[Workstream]:
20
+ """Load every workstream from *ws_dir*, skipping non-workstream files."""
21
+ result: list[Workstream] = []
22
+ for f in sorted(ws_dir.glob('*.md')):
23
+ if f.name in _SKIP_FILES:
24
+ continue
25
+ try:
26
+ result.append(load_workstream(f))
27
+ except Exception:
28
+ continue
29
+ return result
30
+
31
+
32
+ def resolve_workstream(config: Config, id_prefix: str) -> Workstream:
33
+ """Find the unique workstream matching *id_prefix*.
34
+
35
+ Matches against full ID, hex suffix, any prefix/suffix of the ID,
36
+ or title slug prefix. Raises ``UsageError`` on zero or multiple matches.
37
+ """
38
+ ws_dir = config.workstreams_path
39
+ matches: list[Workstream] = []
40
+ for ws in load_all_workstreams(ws_dir):
41
+ # Match: full ID prefix, full ID suffix, hex-suffix prefix, or slug prefix
42
+ hex_suffix = ws.id.split('-', 3)[-1] # e.g. 'a1b2c3d4e5'
43
+ slug = slugify(ws.title)
44
+ if (ws.id.startswith(id_prefix)
45
+ or ws.id.endswith(id_prefix)
46
+ or hex_suffix.startswith(id_prefix)
47
+ or slug.startswith(id_prefix)):
48
+ matches.append(ws)
49
+
50
+ if len(matches) == 0:
51
+ raise UsageError(f'No workstream matching "{id_prefix}"')
52
+ if len(matches) > 1:
53
+ names = ', '.join(f'{w.id} ({w.title})' for w in matches)
54
+ raise UsageError(f'Multiple matches for "{id_prefix}": {names}')
55
+ return matches[0]
56
+
57
+
58
+ # ---------------------------------------------------------------------------
59
+ # Middleware: resolve config via --dir flag / env var / auto-discovery
60
+ # ---------------------------------------------------------------------------
61
+
62
+ @face_middleware(provides=['config'], optional=True)
63
+ def mw_load_config(next_, dir):
64
+ """Resolve workstreams dir from --dir flag, $WS_DIR, or auto-discovery."""
65
+ if dir:
66
+ ws_dir = Path(dir).expanduser().resolve()
67
+ # Accept both notes root (parent dir) and direct _Workstreams path
68
+ candidate = ws_dir / '_Workstreams'
69
+ if candidate.is_dir():
70
+ ws_dir = candidate
71
+ elif not ws_dir.is_dir():
72
+ # CommandLineError from middleware doesn't get printed by face's
73
+ # run loop; print explicitly so the user sees the message.
74
+ print(f'error: --dir: directory does not exist: {ws_dir}', file=sys.stderr)
75
+ raise SystemExit(1)
76
+ config = load_config(ws_dir_override=ws_dir)
77
+ else:
78
+ config = load_config()
79
+ # Guard: if we resolved a workstreams dir but it has no config.yaml,
80
+ # the pointer is stale or WS_DIR is pointing at the wrong directory.
81
+ if config.workstreams_dir:
82
+ ws_path = Path(config.workstreams_dir)
83
+ if ws_path.is_dir() and not (ws_path / 'config.yaml').exists():
84
+ raise UsageError(
85
+ f'Directory {str(ws_path)!r} has no workstream config (config.yaml).\n'
86
+ f"Is $WS_DIR set correctly? Run 'ws init' to initialize."
87
+ )
88
+ apply_timezone(config)
89
+ return next_(config=config)
90
+
91
+
92
+ def build_command() -> Command:
93
+ """Construct the top-level ``ws`` command with all subcommands."""
94
+ cmd = Command(None, name='ws', doc='Workstream manager \u2014 track trains of thought across projects.')
95
+ cmd.add('--dir', parse_as=str, missing=None,
96
+ doc='Workstreams directory (or parent); overrides $WS_DIR and auto-discovery')
97
+ cmd.add(mw_load_config)
98
+
99
+ from workstream.commands import init, new, list_cmd, show, report, index, next_cmd, stale, sweep, serve, backfill, cron, nest, tree, snooze, idea, checkin, setup, inspect_cmd, review, block, focus_cmd, resume, update_status
100
+
101
+ cmd.add(init.get_command())
102
+ cmd.add(new.get_command())
103
+ cmd.add(list_cmd.get_command())
104
+ cmd.add(show.get_command())
105
+ cmd.add(report.get_command())
106
+ cmd.add(index.get_command())
107
+ cmd.add(next_cmd.get_command())
108
+ cmd.add(stale.get_command())
109
+ cmd.add(sweep.get_command())
110
+ cmd.add(serve.get_command())
111
+ cmd.add(backfill.get_command())
112
+ cmd.add(cron.get_command())
113
+ cmd.add(nest.get_nest_command())
114
+ cmd.add(nest.get_unnest_command())
115
+ cmd.add(tree.get_command())
116
+ cmd.add(snooze.get_command())
117
+ cmd.add(snooze.get_wake_command())
118
+ cmd.add(idea.get_idea_command())
119
+ cmd.add(idea.get_ideas_command())
120
+ cmd.add(idea.get_promote_command())
121
+ cmd.add(checkin.get_command())
122
+ cmd.add(inspect_cmd.get_command())
123
+ cmd.add(setup.get_command())
124
+ cmd.add(review.get_command())
125
+ cmd.add(block.get_command())
126
+ cmd.add(block.get_unblock_command())
127
+ cmd.add(focus_cmd.get_command())
128
+ cmd.add(resume.get_command())
129
+ cmd.add(update_status.get_command())
130
+ return cmd
131
+
132
+
133
+ def main() -> None:
134
+ """CLI entry point."""
135
+ cmd = build_command()
136
+ cmd.run()
File without changes
@@ -0,0 +1,139 @@
1
+ """ws backfill — add IDs and infer workstream associations for .plans/ files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from face import Command
8
+
9
+ from workstream.config import Config
10
+ from workstream.cli import load_all_workstreams
11
+ from workstream.markdown import parse_frontmatter, write_frontmatter
12
+ from workstream.models import ACTIVE_STATUSES, generate_id, slugify
13
+
14
+
15
+ def _load_active_workstreams(config):
16
+ """Load active workstreams from the configured directory."""
17
+ return [ws for ws in load_all_workstreams(config.workstreams_path)
18
+ if ws.status in ACTIVE_STATUSES]
19
+
20
+
21
+ def _slug_overlap(plan_slug: str, ws_slug: str) -> bool:
22
+ """Check if plan slug shares meaningful tokens with workstream slug."""
23
+ plan_parts = set(plan_slug.split('-'))
24
+ ws_parts = set(ws_slug.split('-'))
25
+ # Remove trivially short tokens (single chars, empty)
26
+ plan_parts = {p for p in plan_parts if len(p) > 1}
27
+ ws_parts = {p for p in ws_parts if len(p) > 1}
28
+ if not plan_parts or not ws_parts:
29
+ return False
30
+ return bool(plan_parts & ws_parts)
31
+
32
+
33
+ def _infer_workstream(repo_name: str, plan_slug: str, workstreams):
34
+ """Try to infer the workstream for a plan.
35
+
36
+ Returns the workstream ID if exactly one active workstream matches
37
+ by repo AND has title/slug overlap. Returns None otherwise.
38
+ """
39
+ candidates = []
40
+ for ws in workstreams:
41
+ if repo_name not in ws.repos:
42
+ continue
43
+ ws_slug = slugify(ws.title)
44
+ if _slug_overlap(plan_slug, ws_slug):
45
+ candidates.append(ws)
46
+ if len(candidates) == 1:
47
+ return candidates[0].id
48
+ return None
49
+
50
+
51
+ def _backfill_handler(config: Config, dry_run: bool = False) -> None:
52
+ workstreams = _load_active_workstreams(config)
53
+
54
+ total_plans = 0
55
+ ids_added = 0
56
+ ws_associated = 0
57
+
58
+ for repo_cfg in config.repos:
59
+ repo_path = Path(repo_cfg.path).expanduser()
60
+ plans_dir = config.get_plans_dir(repo_cfg.name, repo_path)
61
+ if not plans_dir.is_dir():
62
+ continue
63
+
64
+ for plan_file in sorted(plans_dir.glob('*.md')):
65
+ total_plans += 1
66
+ try:
67
+ text = plan_file.read_text(encoding='utf-8')
68
+ meta, body = parse_frontmatter(text)
69
+ except Exception as exc:
70
+ print(f' warning: skipping {repo_cfg.name}/{plan_file.name}: {exc}')
71
+ continue
72
+
73
+ changed = False
74
+ plan_id = meta.get('id', '')
75
+ title = meta.get('title', plan_file.stem)
76
+ plan_slug = slugify(title)
77
+
78
+ # Step b/c: ensure plan has an ID and filename matches
79
+ if not plan_id:
80
+ plan_id = generate_id()
81
+ meta['id'] = plan_id
82
+ changed = True
83
+ ids_added += 1
84
+ if dry_run:
85
+ print(f' would add id {plan_id} to {repo_cfg.name}/{plan_file.name}')
86
+
87
+ # Determine canonical filename
88
+ canonical_name = f'{plan_id}-{plan_slug}.md'
89
+ needs_rename = plan_file.name != canonical_name
90
+
91
+ # Step d: infer workstream if empty
92
+ ws_field = meta.get('workstream', '')
93
+ if not ws_field:
94
+ inferred = _infer_workstream(repo_cfg.name, plan_slug, workstreams)
95
+ if inferred:
96
+ meta['workstream'] = inferred
97
+ changed = True
98
+ ws_associated += 1
99
+ if dry_run:
100
+ print(f' would set workstream={inferred} on {repo_cfg.name}/{plan_file.name}')
101
+
102
+ if not changed and not needs_rename:
103
+ continue
104
+
105
+ if dry_run:
106
+ if needs_rename:
107
+ print(f' would rename {repo_cfg.name}/{plan_file.name} -> {canonical_name}')
108
+ continue
109
+
110
+ # Write updated frontmatter
111
+ new_text = write_frontmatter(meta, body)
112
+
113
+ if needs_rename:
114
+ new_path = plans_dir / canonical_name
115
+ if new_path.exists() and new_path != plan_file:
116
+ print(f' warning: cannot rename {plan_file.name} -> {canonical_name}: target exists')
117
+ # Still write updated metadata to the original file
118
+ plan_file.write_text(new_text, encoding='utf-8')
119
+ else:
120
+ plan_file.write_text(new_text, encoding='utf-8')
121
+ if new_path != plan_file:
122
+ plan_file.rename(new_path)
123
+ print(f' renamed {repo_cfg.name}/{plan_file.name} -> {canonical_name}')
124
+ else:
125
+ plan_file.write_text(new_text, encoding='utf-8')
126
+
127
+ label = 'Would process' if dry_run else 'Processed'
128
+ print(f'Backfill {"(dry run) " if dry_run else ""}complete: '
129
+ f'{total_plans} plans {label.lower()}, '
130
+ f'{ids_added} IDs added, '
131
+ f'{ws_associated} workstream associations made.')
132
+
133
+
134
+ def get_command() -> Command:
135
+ cmd = Command(_backfill_handler, name='backfill',
136
+ doc='Add IDs and infer workstream associations for .plans/ files.')
137
+ cmd.add('--dry-run', parse_as=True,
138
+ doc='Show what would be done without modifying files.')
139
+ return cmd
@@ -0,0 +1,93 @@
1
+ """ws block / ws unblock — block and unblock workstreams."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+
7
+ from face import Command, CommandLineError
8
+
9
+ from workstream.cli import resolve_workstream
10
+ from workstream.config import Config
11
+ from workstream.markdown import (
12
+ load_workstream,
13
+ parse_frontmatter,
14
+ write_frontmatter,
15
+ append_log_entry,
16
+ )
17
+ from workstream.models import LogEntry
18
+
19
+
20
+ def _block_handler(posargs_: tuple, config: Config) -> None:
21
+ if not posargs_:
22
+ raise CommandLineError('Usage: ws block <id-prefix> [reason...]')
23
+
24
+ id_prefix = posargs_[0]
25
+ reason = ' '.join(posargs_[1:]) if len(posargs_) > 1 else ''
26
+
27
+ ws = resolve_workstream(config, id_prefix)
28
+
29
+ # Re-load fresh from disk to avoid stale state.
30
+ ws = load_workstream(ws.source_path)
31
+ today = datetime.now().strftime('%Y-%m-%d')
32
+
33
+ ws.status = 'blocked'
34
+ ws.blocked_notes = reason
35
+ ws.updated = today
36
+
37
+ # Read raw text, update frontmatter, append log entry, write back.
38
+ text = ws.source_path.read_text(encoding='utf-8')
39
+ meta, body = parse_frontmatter(text)
40
+ meta.update(ws.frontmatter_dict())
41
+ detail = reason if reason else ''
42
+ body = append_log_entry(body, LogEntry(date=today, event='blocked', detail=detail))
43
+ out = write_frontmatter(meta, body)
44
+ ws.source_path.write_text(out.rstrip('\n') + '\n', encoding='utf-8')
45
+
46
+ if reason:
47
+ print(f"Blocked '{ws.title}': {reason}")
48
+ else:
49
+ print(f"Blocked '{ws.title}'")
50
+
51
+
52
+ def _unblock_handler(posargs_: tuple, config: Config) -> None:
53
+ if len(posargs_) != 1:
54
+ raise CommandLineError('Usage: ws unblock <id-prefix>')
55
+
56
+ id_prefix = posargs_[0]
57
+ ws = resolve_workstream(config, id_prefix)
58
+
59
+ # Re-load fresh from disk.
60
+ ws = load_workstream(ws.source_path)
61
+ today = datetime.now().strftime('%Y-%m-%d')
62
+
63
+ ws.status = 'active'
64
+ ws.blocked_notes = ''
65
+ ws.updated = today
66
+
67
+ # Read raw text, update frontmatter, append log entry, write back.
68
+ text = ws.source_path.read_text(encoding='utf-8')
69
+ meta, body = parse_frontmatter(text)
70
+ meta.update(ws.frontmatter_dict())
71
+ # Explicitly remove cleared blocked_notes (frontmatter_dict omits empty values)
72
+ meta.pop('blocked_notes', None)
73
+ body = append_log_entry(body, LogEntry(date=today, event='unblocked'))
74
+ out = write_frontmatter(meta, body)
75
+ ws.source_path.write_text(out.rstrip('\n') + '\n', encoding='utf-8')
76
+
77
+ print(f"Unblocked '{ws.title}' ({ws.id})")
78
+
79
+
80
+ def get_command() -> Command:
81
+ return Command(
82
+ _block_handler, name='block',
83
+ doc='Block a workstream (stuck on external dependency).',
84
+ posargs=True,
85
+ )
86
+
87
+
88
+ def get_unblock_command() -> Command:
89
+ return Command(
90
+ _unblock_handler, name='unblock',
91
+ doc='Unblock a workstream.',
92
+ posargs=True,
93
+ )
@@ -0,0 +1,51 @@
1
+ """ws checkin — record a check-in on a workstream."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+
7
+ from face import Command, CommandLineError
8
+
9
+ from workstream.cli import resolve_workstream
10
+ from workstream.config import Config
11
+ from workstream.markdown import (
12
+ load_workstream, parse_frontmatter, write_frontmatter, append_log_entry,
13
+ )
14
+ from workstream.models import LogEntry
15
+
16
+
17
+ def _checkin_handler(posargs_: tuple, config: Config) -> None:
18
+ if not posargs_:
19
+ raise CommandLineError('Usage: ws checkin <id-prefix> [note...]')
20
+
21
+ id_prefix = posargs_[0]
22
+ note = ' '.join(posargs_[1:]) if len(posargs_) > 1 else ''
23
+
24
+ ws = resolve_workstream(config, id_prefix)
25
+ # Re-load fresh from disk
26
+ ws = load_workstream(ws.source_path)
27
+
28
+ now_dt = datetime.now().isoformat(timespec='seconds')
29
+ today_date = datetime.now().strftime('%Y-%m-%d')
30
+
31
+ # Read raw text for body manipulation
32
+ text = ws.source_path.read_text(encoding='utf-8')
33
+ meta, body = parse_frontmatter(text)
34
+ meta['updated'] = now_dt
35
+ meta['last_activity'] = now_dt
36
+
37
+ entry = LogEntry(date=today_date, event='checked-in', detail=note)
38
+ body = append_log_entry(body, entry)
39
+
40
+ ws.source_path.write_text(
41
+ write_frontmatter(meta, body), encoding='utf-8'
42
+ )
43
+ if note:
44
+ print(f"Checked in on '{ws.title}': {note}")
45
+ else:
46
+ print(f"Checked in on '{ws.title}'")
47
+
48
+
49
+ def get_command() -> Command:
50
+ return Command(_checkin_handler, name='checkin',
51
+ doc='Record a check-in with optional note.', posargs=True)
@@ -0,0 +1,119 @@
1
+ """ws cron — set up periodic sweep in user crontab."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import shutil
6
+ import subprocess
7
+ import os
8
+ from pathlib import Path
9
+
10
+ from face import Command
11
+
12
+
13
+ _CRON_COMMENT = '# workstream: periodic sweep'
14
+
15
+
16
+ def _find_ws_path() -> str:
17
+ """Find the ``ws`` binary on PATH."""
18
+ path = shutil.which('ws')
19
+ if path:
20
+ return path
21
+ # Fallback: invoke via python module
22
+ return 'python3 -m workstream.cli'
23
+
24
+
25
+ def _sweep_log_path() -> str:
26
+ """Return XDG-compliant path for the sweep log file."""
27
+ state_dir = os.environ.get('XDG_STATE_HOME', '')
28
+ if not state_dir:
29
+ state_dir = str(Path.home() / '.local' / 'state')
30
+ return str(Path(state_dir) / 'workstream' / 'sweep.log')
31
+
32
+
33
+ def _build_cron_line(interval: str, thoughts: bool) -> str:
34
+ """Build the crontab line for the given interval."""
35
+ ws_path = _find_ws_path()
36
+ thoughts_flag = ' --thoughts' if thoughts else ''
37
+
38
+ # Cron schedules
39
+ schedules = {
40
+ 'hourly': '0 * * * *',
41
+ 'daily': '0 9 * * *',
42
+ 'weekly': '0 9 * * 1',
43
+ }
44
+ schedule = schedules.get(interval, schedules['daily'])
45
+
46
+ log_path = _sweep_log_path()
47
+ log_dir = str(Path(log_path).parent)
48
+ return f'{schedule} mkdir -p {log_dir} && {ws_path} sweep{thoughts_flag} >> {log_path} 2>&1'
49
+
50
+
51
+ def _get_current_crontab() -> str:
52
+ """Read current user crontab, returning '' if empty."""
53
+ result = subprocess.run(
54
+ ['crontab', '-l'], capture_output=True, text=True, timeout=10
55
+ )
56
+ if result.returncode != 0:
57
+ return ''
58
+ return result.stdout
59
+
60
+
61
+ def _cron_handler(interval: str = 'daily', thoughts: bool = False,
62
+ install: bool = False, remove: bool = False) -> None:
63
+ cron_line = _build_cron_line(interval, thoughts)
64
+
65
+ if remove:
66
+ current = _get_current_crontab()
67
+ if _CRON_COMMENT not in current:
68
+ print('No workstream cron entry found.')
69
+ return
70
+ # Remove our lines
71
+ lines = current.splitlines()
72
+ new_lines = [l for l in lines if _CRON_COMMENT not in l and 'ws sweep' not in l]
73
+ new_crontab = '\n'.join(new_lines) + '\n'
74
+ result = subprocess.run(
75
+ ['crontab', '-'], input=new_crontab, capture_output=True, text=True, timeout=10
76
+ )
77
+ if result.returncode == 0:
78
+ print('Removed workstream cron entry.')
79
+ else:
80
+ print(f'Failed to update crontab: {result.stderr}')
81
+ return
82
+
83
+ if install:
84
+ current = _get_current_crontab()
85
+ # Remove any existing workstream entry first
86
+ lines = current.splitlines()
87
+ lines = [l for l in lines if _CRON_COMMENT not in l and 'ws sweep' not in l]
88
+
89
+ lines.append(_CRON_COMMENT)
90
+ lines.append(cron_line)
91
+ new_crontab = '\n'.join(lines) + '\n'
92
+
93
+ result = subprocess.run(
94
+ ['crontab', '-'], input=new_crontab, capture_output=True, text=True, timeout=10
95
+ )
96
+ if result.returncode == 0:
97
+ print(f'Installed cron entry ({interval}):')
98
+ print(f' {cron_line}')
99
+ else:
100
+ print(f'Failed to install crontab: {result.stderr}')
101
+ return
102
+
103
+ # Default: just print what would be added
104
+ print(f'Cron entry ({interval}):')
105
+ print(f' {cron_line}')
106
+ print()
107
+ print('To install, run: ws cron --install')
108
+ print('To install with thoughts sweep: ws cron --install --thoughts')
109
+ print('To remove: ws cron --remove')
110
+
111
+
112
+ def get_command() -> Command:
113
+ cmd = Command(_cron_handler, name='cron', doc='Set up periodic sweep via crontab.')
114
+ cmd.add('--interval', parse_as=str, missing='daily',
115
+ doc='Sweep frequency: hourly, daily, weekly (default: daily)')
116
+ cmd.add('--thoughts', parse_as=True, doc='Include --thoughts flag in sweep')
117
+ cmd.add('--install', parse_as=True, doc='Install the cron entry')
118
+ cmd.add('--remove', parse_as=True, doc='Remove the workstream cron entry')
119
+ return cmd