workstream-cli 0.0.1__tar.gz

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 (85) hide show
  1. workstream_cli-0.0.1/LICENSE +21 -0
  2. workstream_cli-0.0.1/PKG-INFO +93 -0
  3. workstream_cli-0.0.1/README.md +61 -0
  4. workstream_cli-0.0.1/pyproject.toml +63 -0
  5. workstream_cli-0.0.1/src/workstream/ARCHITECTURE.md +89 -0
  6. workstream_cli-0.0.1/src/workstream/__init__.py +8 -0
  7. workstream_cli-0.0.1/src/workstream/cli.py +136 -0
  8. workstream_cli-0.0.1/src/workstream/commands/__init__.py +0 -0
  9. workstream_cli-0.0.1/src/workstream/commands/backfill.py +139 -0
  10. workstream_cli-0.0.1/src/workstream/commands/block.py +93 -0
  11. workstream_cli-0.0.1/src/workstream/commands/checkin.py +51 -0
  12. workstream_cli-0.0.1/src/workstream/commands/cron.py +119 -0
  13. workstream_cli-0.0.1/src/workstream/commands/focus_cmd.py +273 -0
  14. workstream_cli-0.0.1/src/workstream/commands/idea.py +172 -0
  15. workstream_cli-0.0.1/src/workstream/commands/index.py +89 -0
  16. workstream_cli-0.0.1/src/workstream/commands/init.py +567 -0
  17. workstream_cli-0.0.1/src/workstream/commands/inspect_cmd.py +354 -0
  18. workstream_cli-0.0.1/src/workstream/commands/list_cmd.py +99 -0
  19. workstream_cli-0.0.1/src/workstream/commands/nest.py +108 -0
  20. workstream_cli-0.0.1/src/workstream/commands/new.py +95 -0
  21. workstream_cli-0.0.1/src/workstream/commands/next_cmd.py +333 -0
  22. workstream_cli-0.0.1/src/workstream/commands/report.py +190 -0
  23. workstream_cli-0.0.1/src/workstream/commands/resume.py +145 -0
  24. workstream_cli-0.0.1/src/workstream/commands/review.py +227 -0
  25. workstream_cli-0.0.1/src/workstream/commands/serve.py +23 -0
  26. workstream_cli-0.0.1/src/workstream/commands/setup.py +178 -0
  27. workstream_cli-0.0.1/src/workstream/commands/show.py +123 -0
  28. workstream_cli-0.0.1/src/workstream/commands/snooze.py +117 -0
  29. workstream_cli-0.0.1/src/workstream/commands/stale.py +116 -0
  30. workstream_cli-0.0.1/src/workstream/commands/sweep.py +1753 -0
  31. workstream_cli-0.0.1/src/workstream/commands/tree.py +105 -0
  32. workstream_cli-0.0.1/src/workstream/commands/update_status.py +117 -0
  33. workstream_cli-0.0.1/src/workstream/config.py +322 -0
  34. workstream_cli-0.0.1/src/workstream/extensions/__init__.py +0 -0
  35. workstream_cli-0.0.1/src/workstream/extensions/workstream.ts +633 -0
  36. workstream_cli-0.0.1/src/workstream/focus_artifact.py +157 -0
  37. workstream_cli-0.0.1/src/workstream/git.py +194 -0
  38. workstream_cli-0.0.1/src/workstream/harness.py +49 -0
  39. workstream_cli-0.0.1/src/workstream/llm.py +78 -0
  40. workstream_cli-0.0.1/src/workstream/markdown.py +501 -0
  41. workstream_cli-0.0.1/src/workstream/models.py +274 -0
  42. workstream_cli-0.0.1/src/workstream/plan_index.py +88 -0
  43. workstream_cli-0.0.1/src/workstream/provisioning.py +196 -0
  44. workstream_cli-0.0.1/src/workstream/repo_discovery.py +158 -0
  45. workstream_cli-0.0.1/src/workstream/review_artifact.py +96 -0
  46. workstream_cli-0.0.1/src/workstream/scripts/migrate_statuses.py +120 -0
  47. workstream_cli-0.0.1/src/workstream/skills/__init__.py +0 -0
  48. workstream_cli-0.0.1/src/workstream/skills/workstream_context/SKILL.md +75 -0
  49. workstream_cli-0.0.1/src/workstream/skills/workstream_context/__init__.py +0 -0
  50. workstream_cli-0.0.1/src/workstream/skills/workstream_focus/SKILL.md +141 -0
  51. workstream_cli-0.0.1/src/workstream/skills/workstream_init/SKILL.md +86 -0
  52. workstream_cli-0.0.1/src/workstream/skills/workstream_review/SKILL.md +224 -0
  53. workstream_cli-0.0.1/src/workstream/skills/workstream_sweep/SKILL.md +178 -0
  54. workstream_cli-0.0.1/src/workstream/sweep_state.py +93 -0
  55. workstream_cli-0.0.1/src/workstream/templates/dashboard.html +382 -0
  56. workstream_cli-0.0.1/src/workstream/templates/detail.html +360 -0
  57. workstream_cli-0.0.1/src/workstream/templates/plan.html +210 -0
  58. workstream_cli-0.0.1/src/workstream/test/__init__.py +0 -0
  59. workstream_cli-0.0.1/src/workstream/test/conftest.py +221 -0
  60. workstream_cli-0.0.1/src/workstream/test/fixtures/sample_sprint_note.md +10 -0
  61. workstream_cli-0.0.1/src/workstream/test/fixtures/sample_workstream.md +41 -0
  62. workstream_cli-0.0.1/src/workstream/test/test_backfill.py +180 -0
  63. workstream_cli-0.0.1/src/workstream/test/test_batch_writeback.py +81 -0
  64. workstream_cli-0.0.1/src/workstream/test/test_commands.py +938 -0
  65. workstream_cli-0.0.1/src/workstream/test/test_config.py +54 -0
  66. workstream_cli-0.0.1/src/workstream/test/test_focus_artifact.py +211 -0
  67. workstream_cli-0.0.1/src/workstream/test/test_git.py +88 -0
  68. workstream_cli-0.0.1/src/workstream/test/test_heuristics.py +136 -0
  69. workstream_cli-0.0.1/src/workstream/test/test_hierarchy.py +231 -0
  70. workstream_cli-0.0.1/src/workstream/test/test_init.py +452 -0
  71. workstream_cli-0.0.1/src/workstream/test/test_inspect.py +143 -0
  72. workstream_cli-0.0.1/src/workstream/test/test_llm.py +78 -0
  73. workstream_cli-0.0.1/src/workstream/test/test_markdown.py +626 -0
  74. workstream_cli-0.0.1/src/workstream/test/test_models.py +506 -0
  75. workstream_cli-0.0.1/src/workstream/test/test_next.py +206 -0
  76. workstream_cli-0.0.1/src/workstream/test/test_plan_index.py +83 -0
  77. workstream_cli-0.0.1/src/workstream/test/test_provisioning.py +270 -0
  78. workstream_cli-0.0.1/src/workstream/test/test_repo_discovery.py +181 -0
  79. workstream_cli-0.0.1/src/workstream/test/test_resume.py +71 -0
  80. workstream_cli-0.0.1/src/workstream/test/test_sweep.py +1196 -0
  81. workstream_cli-0.0.1/src/workstream/test/test_sweep_state.py +86 -0
  82. workstream_cli-0.0.1/src/workstream/test/test_thoughts.py +516 -0
  83. workstream_cli-0.0.1/src/workstream/test/test_web.py +606 -0
  84. workstream_cli-0.0.1/src/workstream/thoughts.py +505 -0
  85. workstream_cli-0.0.1/src/workstream/web.py +444 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mahmoud Hashemi
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,93 @@
1
+ Metadata-Version: 2.3
2
+ Name: workstream-cli
3
+ Version: 0.0.1
4
+ Summary: Attention management for makers. Track trains of thought across projects.
5
+ License: MIT
6
+ Keywords: workstream,productivity,cli,attention-management,task-management
7
+ Author: Mahmoud Hashemi
8
+ Author-email: mahmoud@hatnote.com
9
+ Requires-Python: >=3.10,<4.0
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3 :: Only
21
+ Classifier: Topic :: Office/Business :: Scheduling
22
+ Classifier: Topic :: Software Development
23
+ Classifier: Topic :: Utilities
24
+ Requires-Dist: ashes (>=24.0.0)
25
+ Requires-Dist: clastic (>=24.0.0)
26
+ Requires-Dist: face (>=20.0.0)
27
+ Requires-Dist: pyyaml (>=6.0,<7.0)
28
+ Project-URL: Homepage, https://github.com/mahmoud/workstream
29
+ Project-URL: Repository, https://github.com/mahmoud/workstream
30
+ Description-Content-Type: text/markdown
31
+
32
+ # workstream
33
+
34
+ Attention management for makers. Track trains of thought across projects.
35
+
36
+ `ws` is a CLI tool for managing workstreams -- named trains of thought that
37
+ span code repos, non-code activities, and time. Obsidian-native markdown files
38
+ with YAML frontmatter, designed to be legible to both humans and LLMs.
39
+
40
+ ## Install
41
+
42
+ ```bash
43
+ pipx install workstream-cli # recommended
44
+ # or
45
+ pip install workstream-cli
46
+ ```
47
+
48
+ For development:
49
+ ```bash
50
+ pip install -e .
51
+ ```
52
+
53
+ ## Quick Start
54
+
55
+ ```bash
56
+ ws init # first-time setup
57
+ ws new "API Redesign" # create a workstream
58
+ ws list # see all workstreams
59
+ ws next --tag code --size day # what should I work on?
60
+ ws stale # what's gone cold?
61
+ ws sweep # scan repos for plan/branch updates
62
+ ws report --since 7d # summarize recent work
63
+ ```
64
+
65
+ ## Development
66
+
67
+ ### Running Tests
68
+
69
+ ```bash
70
+ pytest # quick, no coverage
71
+ pytest --cov=workstream # with coverage report
72
+ tox # full matrix + combined coverage report
73
+ tox -e py311 # single Python version
74
+ tox -e py311 -- -k test_models # single env, filtered tests
75
+ ```
76
+
77
+ ### Coverage Report
78
+
79
+ After running `tox` (which ends with `coverage-report`):
80
+ - Terminal summary prints automatically
81
+ - HTML report: open `htmlcov/index.html`
82
+
83
+ For a quick local coverage check without tox:
84
+
85
+ ```bash
86
+ pytest --cov=workstream --cov-report=term-missing --cov-report=html
87
+ open htmlcov/index.html
88
+ ```
89
+
90
+ ## License
91
+
92
+ MIT
93
+
@@ -0,0 +1,61 @@
1
+ # workstream
2
+
3
+ Attention management for makers. Track trains of thought across projects.
4
+
5
+ `ws` is a CLI tool for managing workstreams -- named trains of thought that
6
+ span code repos, non-code activities, and time. Obsidian-native markdown files
7
+ with YAML frontmatter, designed to be legible to both humans and LLMs.
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ pipx install workstream-cli # recommended
13
+ # or
14
+ pip install workstream-cli
15
+ ```
16
+
17
+ For development:
18
+ ```bash
19
+ pip install -e .
20
+ ```
21
+
22
+ ## Quick Start
23
+
24
+ ```bash
25
+ ws init # first-time setup
26
+ ws new "API Redesign" # create a workstream
27
+ ws list # see all workstreams
28
+ ws next --tag code --size day # what should I work on?
29
+ ws stale # what's gone cold?
30
+ ws sweep # scan repos for plan/branch updates
31
+ ws report --since 7d # summarize recent work
32
+ ```
33
+
34
+ ## Development
35
+
36
+ ### Running Tests
37
+
38
+ ```bash
39
+ pytest # quick, no coverage
40
+ pytest --cov=workstream # with coverage report
41
+ tox # full matrix + combined coverage report
42
+ tox -e py311 # single Python version
43
+ tox -e py311 -- -k test_models # single env, filtered tests
44
+ ```
45
+
46
+ ### Coverage Report
47
+
48
+ After running `tox` (which ends with `coverage-report`):
49
+ - Terminal summary prints automatically
50
+ - HTML report: open `htmlcov/index.html`
51
+
52
+ For a quick local coverage check without tox:
53
+
54
+ ```bash
55
+ pytest --cov=workstream --cov-report=term-missing --cov-report=html
56
+ open htmlcov/index.html
57
+ ```
58
+
59
+ ## License
60
+
61
+ MIT
@@ -0,0 +1,63 @@
1
+ [tool.poetry]
2
+ name = "workstream-cli"
3
+ version = "0.0.1"
4
+ readme = "README.md"
5
+ description = "Attention management for makers. Track trains of thought across projects."
6
+ authors = ["Mahmoud Hashemi <mahmoud@hatnote.com>"]
7
+ license = "MIT"
8
+ packages = [{include = "workstream", from = "src"}]
9
+ classifiers = [
10
+ "Development Status :: 3 - Alpha",
11
+ "Environment :: Console",
12
+ "Intended Audience :: Developers",
13
+ "License :: OSI Approved :: MIT License",
14
+ "Operating System :: OS Independent",
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.10",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Programming Language :: Python :: 3 :: Only",
20
+ "Topic :: Office/Business :: Scheduling",
21
+ "Topic :: Software Development",
22
+ "Topic :: Utilities",
23
+ ]
24
+ keywords = ["workstream", "productivity", "cli", "attention-management", "task-management"]
25
+
26
+ [tool.poetry.scripts]
27
+ ws = "workstream.cli:main"
28
+
29
+ [tool.poetry.dependencies]
30
+ python = "^3.10"
31
+ face = ">=20.0.0"
32
+ pyyaml = "^6.0"
33
+ ashes = ">=24.0.0"
34
+ clastic = ">=24.0.0"
35
+
36
+ [tool.poetry.group.dev.dependencies]
37
+ pytest = "^7.4"
38
+ pytest-cov = ">=4.1"
39
+ coverage = {version = ">=7.0", extras = ["toml"]}
40
+ tox = ">=4.0"
41
+
42
+ [tool.poetry.urls]
43
+ Homepage = "https://github.com/mahmoud/workstream"
44
+ Repository = "https://github.com/mahmoud/workstream"
45
+
46
+ [tool.pytest.ini_options]
47
+ testpaths = ["src/workstream/test"]
48
+
49
+ [build-system]
50
+ requires = ["poetry-core"]
51
+ build-backend = "poetry.core.masonry.api"
52
+
53
+ [tool.coverage.run]
54
+ source = ["workstream"]
55
+ branch = true
56
+ omit = ["src/workstream/test/*"]
57
+
58
+ [tool.coverage.report]
59
+ show_missing = true
60
+ skip_empty = true
61
+
62
+ [tool.coverage.html]
63
+ directory = "htmlcov"
@@ -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 |
@@ -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__']
@@ -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()
@@ -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