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.
- workstream/ARCHITECTURE.md +89 -0
- workstream/__init__.py +8 -0
- workstream/cli.py +136 -0
- workstream/commands/__init__.py +0 -0
- workstream/commands/backfill.py +139 -0
- workstream/commands/block.py +93 -0
- workstream/commands/checkin.py +51 -0
- workstream/commands/cron.py +119 -0
- workstream/commands/focus_cmd.py +273 -0
- workstream/commands/idea.py +172 -0
- workstream/commands/index.py +89 -0
- workstream/commands/init.py +567 -0
- workstream/commands/inspect_cmd.py +354 -0
- workstream/commands/list_cmd.py +99 -0
- workstream/commands/nest.py +108 -0
- workstream/commands/new.py +95 -0
- workstream/commands/next_cmd.py +333 -0
- workstream/commands/report.py +190 -0
- workstream/commands/resume.py +145 -0
- workstream/commands/review.py +227 -0
- workstream/commands/serve.py +23 -0
- workstream/commands/setup.py +178 -0
- workstream/commands/show.py +123 -0
- workstream/commands/snooze.py +117 -0
- workstream/commands/stale.py +116 -0
- workstream/commands/sweep.py +1753 -0
- workstream/commands/tree.py +105 -0
- workstream/commands/update_status.py +117 -0
- workstream/config.py +322 -0
- workstream/extensions/__init__.py +0 -0
- workstream/extensions/workstream.ts +633 -0
- workstream/focus_artifact.py +157 -0
- workstream/git.py +194 -0
- workstream/harness.py +49 -0
- workstream/llm.py +78 -0
- workstream/markdown.py +501 -0
- workstream/models.py +274 -0
- workstream/plan_index.py +88 -0
- workstream/provisioning.py +196 -0
- workstream/repo_discovery.py +158 -0
- workstream/review_artifact.py +96 -0
- workstream/scripts/migrate_statuses.py +120 -0
- workstream/skills/__init__.py +0 -0
- workstream/skills/workstream_context/SKILL.md +75 -0
- workstream/skills/workstream_context/__init__.py +0 -0
- workstream/skills/workstream_focus/SKILL.md +141 -0
- workstream/skills/workstream_init/SKILL.md +86 -0
- workstream/skills/workstream_review/SKILL.md +224 -0
- workstream/skills/workstream_sweep/SKILL.md +178 -0
- workstream/sweep_state.py +93 -0
- workstream/templates/dashboard.html +382 -0
- workstream/templates/detail.html +360 -0
- workstream/templates/plan.html +210 -0
- workstream/test/__init__.py +0 -0
- workstream/test/conftest.py +221 -0
- workstream/test/fixtures/sample_sprint_note.md +10 -0
- workstream/test/fixtures/sample_workstream.md +41 -0
- workstream/test/test_backfill.py +180 -0
- workstream/test/test_batch_writeback.py +81 -0
- workstream/test/test_commands.py +938 -0
- workstream/test/test_config.py +54 -0
- workstream/test/test_focus_artifact.py +211 -0
- workstream/test/test_git.py +88 -0
- workstream/test/test_heuristics.py +136 -0
- workstream/test/test_hierarchy.py +231 -0
- workstream/test/test_init.py +452 -0
- workstream/test/test_inspect.py +143 -0
- workstream/test/test_llm.py +78 -0
- workstream/test/test_markdown.py +626 -0
- workstream/test/test_models.py +506 -0
- workstream/test/test_next.py +206 -0
- workstream/test/test_plan_index.py +83 -0
- workstream/test/test_provisioning.py +270 -0
- workstream/test/test_repo_discovery.py +181 -0
- workstream/test/test_resume.py +71 -0
- workstream/test/test_sweep.py +1196 -0
- workstream/test/test_sweep_state.py +86 -0
- workstream/test/test_thoughts.py +516 -0
- workstream/test/test_web.py +606 -0
- workstream/thoughts.py +505 -0
- workstream/web.py +444 -0
- workstream_cli-0.0.1.dist-info/LICENSE +21 -0
- workstream_cli-0.0.1.dist-info/METADATA +93 -0
- workstream_cli-0.0.1.dist-info/RECORD +86 -0
- workstream_cli-0.0.1.dist-info/WHEEL +4 -0
- 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
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
|