cowork-flow 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +193 -0
- package/bin/cowork-flow.js +9 -0
- package/package.json +28 -0
- package/src/cli.js +61 -0
- package/src/commands/init.js +88 -0
- package/src/commands/sync.js +33 -0
- package/src/commands/update.js +60 -0
- package/src/lib/copy-template.js +189 -0
- package/src/lib/package-info.js +49 -0
- package/src/lib/paths.js +6 -0
- package/template/.agent/skills/break-loop/SKILL.md +131 -0
- package/template/.agent/skills/check-cross-layer/SKILL.md +158 -0
- package/template/.agent/skills/finish-work/SKILL.md +163 -0
- package/template/.agent/skills/record-session/SKILL.md +78 -0
- package/template/.agent/skills/start/SKILL.md +390 -0
- package/template/.agent/skills/update-spec/SKILL.md +335 -0
- package/template/.cowork-flow/.version +1 -0
- package/template/.cowork-flow/changes/archive/.gitkeep +1 -0
- package/template/.cowork-flow/config.yaml +49 -0
- package/template/.cowork-flow/plans/.gitkeep +1 -0
- package/template/.cowork-flow/scripts/add_session.py +441 -0
- package/template/.cowork-flow/scripts/change.py +314 -0
- package/template/.cowork-flow/scripts/common/__init__.py +84 -0
- package/template/.cowork-flow/scripts/common/config.py +128 -0
- package/template/.cowork-flow/scripts/common/developer.py +205 -0
- package/template/.cowork-flow/scripts/common/files.py +24 -0
- package/template/.cowork-flow/scripts/common/git_context.py +628 -0
- package/template/.cowork-flow/scripts/common/paths.py +350 -0
- package/template/.cowork-flow/scripts/common/task_utils.py +200 -0
- package/template/.cowork-flow/scripts/get_context.py +16 -0
- package/template/.cowork-flow/scripts/get_developer.py +26 -0
- package/template/.cowork-flow/scripts/init_developer.py +51 -0
- package/template/.cowork-flow/scripts/task.py +1378 -0
- package/template/.cowork-flow/spec/backend/database-guidelines.md +26 -0
- package/template/.cowork-flow/spec/backend/directory-structure.md +27 -0
- package/template/.cowork-flow/spec/backend/error-handling.md +20 -0
- package/template/.cowork-flow/spec/backend/index.md +36 -0
- package/template/.cowork-flow/spec/backend/logging-guidelines.md +20 -0
- package/template/.cowork-flow/spec/backend/quality-guidelines.md +24 -0
- package/template/.cowork-flow/spec/frontend/component-guidelines.md +19 -0
- package/template/.cowork-flow/spec/frontend/directory-structure.md +18 -0
- package/template/.cowork-flow/spec/frontend/hook-guidelines.md +17 -0
- package/template/.cowork-flow/spec/frontend/index.md +28 -0
- package/template/.cowork-flow/spec/frontend/quality-guidelines.md +23 -0
- package/template/.cowork-flow/spec/frontend/state-management.md +18 -0
- package/template/.cowork-flow/spec/frontend/type-safety.md +18 -0
- package/template/.cowork-flow/spec/guides/code-reuse-thinking-guide.md +26 -0
- package/template/.cowork-flow/spec/guides/cross-layer-thinking-guide.md +21 -0
- package/template/.cowork-flow/spec/guides/index.md +31 -0
- package/template/.cowork-flow/spec/guides/pre-implementation-checklist.md +15 -0
- package/template/.cowork-flow/tasks/.gitkeep +1 -0
- package/template/.cowork-flow/tasks/archive/.gitkeep +1 -0
- package/template/.cowork-flow/workflow.md +411 -0
- package/template/.cowork-flow/workspace/index.md +95 -0
- package/template/.superpowers/brainstorming/SKILL.md +164 -0
- package/template/.superpowers/brainstorming/scripts/frame-template.html +214 -0
- package/template/.superpowers/brainstorming/scripts/helper.js +88 -0
- package/template/.superpowers/brainstorming/scripts/server.cjs +354 -0
- package/template/.superpowers/brainstorming/scripts/start-server.sh +148 -0
- package/template/.superpowers/brainstorming/scripts/stop-server.sh +56 -0
- package/template/.superpowers/brainstorming/spec-document-reviewer-prompt.md +49 -0
- package/template/.superpowers/brainstorming/visual-companion.md +287 -0
- package/template/.superpowers/dispatching-parallel-agents/SKILL.md +182 -0
- package/template/.superpowers/executing-plans/SKILL.md +70 -0
- package/template/.superpowers/finishing-a-development-branch/SKILL.md +251 -0
- package/template/.superpowers/receiving-code-review/SKILL.md +213 -0
- package/template/.superpowers/requesting-code-review/SKILL.md +103 -0
- package/template/.superpowers/requesting-code-review/code-reviewer.md +168 -0
- package/template/.superpowers/subagent-driven-development/SKILL.md +279 -0
- package/template/.superpowers/subagent-driven-development/code-quality-reviewer-prompt.md +25 -0
- package/template/.superpowers/subagent-driven-development/implementer-prompt.md +113 -0
- package/template/.superpowers/subagent-driven-development/spec-reviewer-prompt.md +61 -0
- package/template/.superpowers/systematic-debugging/CREATION-LOG.md +119 -0
- package/template/.superpowers/systematic-debugging/SKILL.md +296 -0
- package/template/.superpowers/systematic-debugging/condition-based-waiting-example.ts +158 -0
- package/template/.superpowers/systematic-debugging/condition-based-waiting.md +115 -0
- package/template/.superpowers/systematic-debugging/defense-in-depth.md +122 -0
- package/template/.superpowers/systematic-debugging/find-polluter.sh +63 -0
- package/template/.superpowers/systematic-debugging/root-cause-tracing.md +169 -0
- package/template/.superpowers/systematic-debugging/test-academic.md +14 -0
- package/template/.superpowers/systematic-debugging/test-pressure-1.md +58 -0
- package/template/.superpowers/systematic-debugging/test-pressure-2.md +68 -0
- package/template/.superpowers/systematic-debugging/test-pressure-3.md +69 -0
- package/template/.superpowers/test-driven-development/SKILL.md +371 -0
- package/template/.superpowers/test-driven-development/testing-anti-patterns.md +299 -0
- package/template/.superpowers/using-git-worktrees/SKILL.md +215 -0
- package/template/.superpowers/using-superpowers/SKILL.md +117 -0
- package/template/.superpowers/using-superpowers/references/codex-tools.md +59 -0
- package/template/.superpowers/using-superpowers/references/copilot-tools.md +42 -0
- package/template/.superpowers/using-superpowers/references/gemini-tools.md +51 -0
- package/template/.superpowers/verification-before-completion/SKILL.md +139 -0
- package/template/.superpowers/writing-plans/SKILL.md +152 -0
- package/template/.superpowers/writing-plans/plan-document-reviewer-prompt.md +49 -0
- package/template/.superpowers/writing-skills/SKILL.md +655 -0
- package/template/.superpowers/writing-skills/anthropic-best-practices.md +1150 -0
- package/template/.superpowers/writing-skills/examples/CLAUDE_MD_TESTING.md +189 -0
- package/template/.superpowers/writing-skills/graphviz-conventions.dot +172 -0
- package/template/.superpowers/writing-skills/persuasion-principles.md +187 -0
- package/template/.superpowers/writing-skills/render-graphs.js +168 -0
- package/template/.superpowers/writing-skills/testing-skills-with-subagents.md +384 -0
- package/template/AGENTS.md +116 -0
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""Cowork Flow change 管理脚本。"""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import argparse
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import sys
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from common.paths import DIR_ARCHIVE, DIR_CHANGES, DIR_WORKFLOW, get_repo_root
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
SLUG_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
|
18
|
+
VALID_LEVELS = {"L1", "L2"}
|
|
19
|
+
VALID_STATUSES = {"draft", "active", "archived"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _now_iso() -> str:
|
|
23
|
+
return datetime.now().astimezone().isoformat(timespec="seconds")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _changes_dir(repo_root: Path) -> Path:
|
|
27
|
+
return repo_root / DIR_WORKFLOW / DIR_CHANGES
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _change_dir(repo_root: Path, slug: str) -> Path:
|
|
31
|
+
return _changes_dir(repo_root) / slug
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _archive_dir(repo_root: Path) -> Path:
|
|
35
|
+
return _changes_dir(repo_root) / DIR_ARCHIVE
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _validate_slug(slug: str) -> bool:
|
|
39
|
+
if SLUG_PATTERN.fullmatch(slug):
|
|
40
|
+
return True
|
|
41
|
+
print(
|
|
42
|
+
"Error: slug must match lowercase hyphenated pattern "
|
|
43
|
+
"^[a-z0-9]+(?:-[a-z0-9]+)*$",
|
|
44
|
+
file=sys.stderr,
|
|
45
|
+
)
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _metadata_path(change_dir: Path) -> Path:
|
|
50
|
+
return change_dir / "change.yaml"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _format_scalar(value: object) -> str:
|
|
54
|
+
if value is None:
|
|
55
|
+
return "null"
|
|
56
|
+
if isinstance(value, bool):
|
|
57
|
+
return "true" if value else "false"
|
|
58
|
+
return str(value)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _write_metadata(change_dir: Path, metadata: dict[str, object]) -> None:
|
|
62
|
+
lines = [f"{key}: {_format_scalar(value)}\n" for key, value in metadata.items()]
|
|
63
|
+
_metadata_path(change_dir).write_text("".join(lines), encoding="utf-8")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _parse_scalar(value: str) -> object:
|
|
67
|
+
if value == "null":
|
|
68
|
+
return None
|
|
69
|
+
if value == "true":
|
|
70
|
+
return True
|
|
71
|
+
if value == "false":
|
|
72
|
+
return False
|
|
73
|
+
return value
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _read_metadata(change_dir: Path) -> dict[str, object]:
|
|
77
|
+
path = _metadata_path(change_dir)
|
|
78
|
+
data: dict[str, object] = {}
|
|
79
|
+
if not path.is_file():
|
|
80
|
+
return data
|
|
81
|
+
|
|
82
|
+
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
|
83
|
+
line = raw_line.strip()
|
|
84
|
+
if not line or line.startswith("#") or ":" not in line:
|
|
85
|
+
continue
|
|
86
|
+
key, value = line.split(":", 1)
|
|
87
|
+
data[key.strip()] = _parse_scalar(value.strip())
|
|
88
|
+
return data
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _has_text(path: Path) -> bool:
|
|
92
|
+
return path.is_file() and bool(path.read_text(encoding="utf-8").strip())
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _spec_files(change_dir: Path) -> list[Path]:
|
|
96
|
+
specs_dir = change_dir / "specs"
|
|
97
|
+
if not specs_dir.is_dir():
|
|
98
|
+
return []
|
|
99
|
+
return sorted(path for path in specs_dir.rglob("spec.md") if path.is_file())
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _resolve_link(repo_root: Path, base_dir: str, value: object) -> Path | None:
|
|
103
|
+
if value is None:
|
|
104
|
+
return None
|
|
105
|
+
if not isinstance(value, str) or not value.strip():
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
raw_path = Path(value)
|
|
109
|
+
if raw_path.is_absolute():
|
|
110
|
+
return raw_path
|
|
111
|
+
|
|
112
|
+
direct = repo_root / raw_path
|
|
113
|
+
if direct.exists():
|
|
114
|
+
return direct
|
|
115
|
+
return repo_root / DIR_WORKFLOW / base_dir / raw_path
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _validate_link(repo_root: Path, metadata: dict[str, object], field: str, base_dir: str) -> str | None:
|
|
119
|
+
target = _resolve_link(repo_root, base_dir, metadata.get(field))
|
|
120
|
+
if target is not None and not target.exists():
|
|
121
|
+
return f"{field} points to missing path: {target}"
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def validate_change(repo_root: Path, slug: str, quiet: bool = False) -> bool:
|
|
126
|
+
if not _validate_slug(slug):
|
|
127
|
+
return False
|
|
128
|
+
|
|
129
|
+
change_dir = _change_dir(repo_root, slug)
|
|
130
|
+
errors: list[str] = []
|
|
131
|
+
|
|
132
|
+
if not change_dir.is_dir():
|
|
133
|
+
errors.append(f"change not found: {slug}")
|
|
134
|
+
else:
|
|
135
|
+
metadata = _read_metadata(change_dir)
|
|
136
|
+
if not metadata:
|
|
137
|
+
errors.append("missing change.yaml")
|
|
138
|
+
|
|
139
|
+
if metadata.get("slug") != slug:
|
|
140
|
+
errors.append("change.yaml slug does not match directory name")
|
|
141
|
+
|
|
142
|
+
status = metadata.get("status")
|
|
143
|
+
if status not in VALID_STATUSES:
|
|
144
|
+
errors.append("change.yaml status must be draft, active, or archived")
|
|
145
|
+
|
|
146
|
+
level = metadata.get("level")
|
|
147
|
+
if level not in VALID_LEVELS:
|
|
148
|
+
errors.append("change.yaml level must be L1 or L2")
|
|
149
|
+
|
|
150
|
+
if not _has_text(change_dir / "proposal.md"):
|
|
151
|
+
errors.append("proposal.md is missing or empty")
|
|
152
|
+
|
|
153
|
+
documentation_only = metadata.get("documentation_only") is True
|
|
154
|
+
if not documentation_only:
|
|
155
|
+
specs = _spec_files(change_dir)
|
|
156
|
+
if not specs or not any(_has_text(spec) for spec in specs):
|
|
157
|
+
errors.append("specs must include at least one non-empty behavior spec")
|
|
158
|
+
|
|
159
|
+
if level == "L2" and not _has_text(change_dir / "design.md"):
|
|
160
|
+
errors.append("design.md is required for L2 changes")
|
|
161
|
+
|
|
162
|
+
plan_error = _validate_link(repo_root, metadata, "plan", "plans")
|
|
163
|
+
if plan_error:
|
|
164
|
+
errors.append(plan_error)
|
|
165
|
+
|
|
166
|
+
task_error = _validate_link(repo_root, metadata, "task", "tasks")
|
|
167
|
+
if task_error:
|
|
168
|
+
errors.append(task_error)
|
|
169
|
+
|
|
170
|
+
if errors:
|
|
171
|
+
for error in errors:
|
|
172
|
+
print(f"Error: {error}", file=sys.stderr)
|
|
173
|
+
return False
|
|
174
|
+
|
|
175
|
+
if not quiet:
|
|
176
|
+
print(f"{slug} valid")
|
|
177
|
+
return True
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def create_change(args: argparse.Namespace) -> int:
|
|
181
|
+
repo_root = get_repo_root()
|
|
182
|
+
slug = args.slug
|
|
183
|
+
if not _validate_slug(slug):
|
|
184
|
+
return 2
|
|
185
|
+
|
|
186
|
+
change_dir = _change_dir(repo_root, slug)
|
|
187
|
+
if change_dir.exists():
|
|
188
|
+
print(f"Error: change already exists: {slug}", file=sys.stderr)
|
|
189
|
+
return 1
|
|
190
|
+
|
|
191
|
+
change_dir.mkdir(parents=True)
|
|
192
|
+
(change_dir / "specs").mkdir()
|
|
193
|
+
(change_dir / "proposal.md").write_text(
|
|
194
|
+
f"# {slug}\n\nDescribe the proposed behavior change.\n",
|
|
195
|
+
encoding="utf-8",
|
|
196
|
+
)
|
|
197
|
+
(change_dir / "design.md").write_text("", encoding="utf-8")
|
|
198
|
+
(change_dir / "specs" / ".gitkeep").write_text("", encoding="utf-8")
|
|
199
|
+
_write_metadata(
|
|
200
|
+
change_dir,
|
|
201
|
+
{
|
|
202
|
+
"slug": slug,
|
|
203
|
+
"status": "draft",
|
|
204
|
+
"level": args.level,
|
|
205
|
+
"created_at": _now_iso(),
|
|
206
|
+
"documentation_only": False,
|
|
207
|
+
"plan": None,
|
|
208
|
+
"task": None,
|
|
209
|
+
},
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
print(f"created {slug}")
|
|
213
|
+
return 0
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def validate_command(args: argparse.Namespace) -> int:
|
|
217
|
+
repo_root = get_repo_root()
|
|
218
|
+
return 0 if validate_change(repo_root, args.slug) else 1
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def archive_change(args: argparse.Namespace) -> int:
|
|
222
|
+
repo_root = get_repo_root()
|
|
223
|
+
slug = args.slug
|
|
224
|
+
if not validate_change(repo_root, slug, quiet=True):
|
|
225
|
+
return 1
|
|
226
|
+
|
|
227
|
+
source = _change_dir(repo_root, slug)
|
|
228
|
+
month = datetime.now().astimezone().strftime("%Y-%m")
|
|
229
|
+
destination = _archive_dir(repo_root) / month / slug
|
|
230
|
+
if destination.exists():
|
|
231
|
+
print(f"Error: archive destination already exists: {destination}", file=sys.stderr)
|
|
232
|
+
return 1
|
|
233
|
+
|
|
234
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
235
|
+
shutil.move(str(source), str(destination))
|
|
236
|
+
|
|
237
|
+
metadata = _read_metadata(destination)
|
|
238
|
+
metadata["status"] = "archived"
|
|
239
|
+
metadata["archived_at"] = _now_iso()
|
|
240
|
+
_write_metadata(destination, metadata)
|
|
241
|
+
|
|
242
|
+
print(f"archived {slug}")
|
|
243
|
+
return 0
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _change_rows(repo_root: Path) -> list[tuple[str, dict[str, object]]]:
|
|
247
|
+
rows: list[tuple[str, dict[str, object]]] = []
|
|
248
|
+
changes_dir = _changes_dir(repo_root)
|
|
249
|
+
|
|
250
|
+
if changes_dir.is_dir():
|
|
251
|
+
for path in sorted(changes_dir.iterdir()):
|
|
252
|
+
if not path.is_dir() or path.name == DIR_ARCHIVE:
|
|
253
|
+
continue
|
|
254
|
+
rows.append((path.name, _read_metadata(path)))
|
|
255
|
+
|
|
256
|
+
archive_dir = _archive_dir(repo_root)
|
|
257
|
+
if archive_dir.is_dir():
|
|
258
|
+
for metadata_path in sorted(archive_dir.glob("*/*/change.yaml")):
|
|
259
|
+
change_dir = metadata_path.parent
|
|
260
|
+
rows.append((change_dir.name, _read_metadata(change_dir)))
|
|
261
|
+
|
|
262
|
+
return rows
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def list_changes(args: argparse.Namespace) -> int:
|
|
266
|
+
repo_root = get_repo_root()
|
|
267
|
+
rows = _change_rows(repo_root)
|
|
268
|
+
if not rows:
|
|
269
|
+
print("No changes")
|
|
270
|
+
return 0
|
|
271
|
+
|
|
272
|
+
for slug, metadata in rows:
|
|
273
|
+
status = metadata.get("status", "unknown")
|
|
274
|
+
level = metadata.get("level", "unknown")
|
|
275
|
+
plan = metadata.get("plan")
|
|
276
|
+
task = metadata.get("task")
|
|
277
|
+
print(
|
|
278
|
+
f"{slug}\tstatus={status}\tlevel={level}\t"
|
|
279
|
+
f"plan={_format_scalar(plan)}\ttask={_format_scalar(task)}"
|
|
280
|
+
)
|
|
281
|
+
return 0
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
285
|
+
parser = argparse.ArgumentParser(description="Manage Cowork Flow changes")
|
|
286
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
287
|
+
|
|
288
|
+
create_parser = subparsers.add_parser("create")
|
|
289
|
+
create_parser.add_argument("slug")
|
|
290
|
+
create_parser.add_argument("--level", choices=sorted(VALID_LEVELS), default="L1")
|
|
291
|
+
create_parser.set_defaults(func=create_change)
|
|
292
|
+
|
|
293
|
+
validate_parser = subparsers.add_parser("validate")
|
|
294
|
+
validate_parser.add_argument("slug")
|
|
295
|
+
validate_parser.set_defaults(func=validate_command)
|
|
296
|
+
|
|
297
|
+
archive_parser = subparsers.add_parser("archive")
|
|
298
|
+
archive_parser.add_argument("slug")
|
|
299
|
+
archive_parser.set_defaults(func=archive_change)
|
|
300
|
+
|
|
301
|
+
list_parser = subparsers.add_parser("list")
|
|
302
|
+
list_parser.set_defaults(func=list_changes)
|
|
303
|
+
|
|
304
|
+
return parser
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def main(argv: list[str] | None = None) -> int:
|
|
308
|
+
parser = build_parser()
|
|
309
|
+
args = parser.parse_args(argv)
|
|
310
|
+
return args.func(args)
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
if __name__ == "__main__":
|
|
314
|
+
sys.exit(main())
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Common utilities for cowork-flow workflow scripts.
|
|
3
|
+
|
|
4
|
+
This module provides shared functionality used by other cowork-flow scripts.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import io
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
# =============================================================================
|
|
11
|
+
# Windows Encoding Fix (MUST be at top, before any other output)
|
|
12
|
+
# =============================================================================
|
|
13
|
+
# On Windows, stdout defaults to the system code page (often GBK/CP936).
|
|
14
|
+
# This causes UnicodeEncodeError when printing non-ASCII characters.
|
|
15
|
+
#
|
|
16
|
+
# Any script that imports from common will automatically get this fix.
|
|
17
|
+
# =============================================================================
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _configure_stream(stream: object) -> object:
|
|
21
|
+
"""Configure a stream for UTF-8 encoding on Windows."""
|
|
22
|
+
# Try reconfigure() first (Python 3.7+, more reliable)
|
|
23
|
+
if hasattr(stream, "reconfigure"):
|
|
24
|
+
stream.reconfigure(encoding="utf-8", errors="replace") # type: ignore[union-attr]
|
|
25
|
+
return stream
|
|
26
|
+
# Fallback: detach and rewrap with TextIOWrapper
|
|
27
|
+
elif hasattr(stream, "detach"):
|
|
28
|
+
return io.TextIOWrapper(
|
|
29
|
+
stream.detach(), # type: ignore[union-attr]
|
|
30
|
+
encoding="utf-8",
|
|
31
|
+
errors="replace",
|
|
32
|
+
)
|
|
33
|
+
return stream
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
if sys.platform == "win32":
|
|
37
|
+
sys.stdout = _configure_stream(sys.stdout) # type: ignore[assignment]
|
|
38
|
+
sys.stderr = _configure_stream(sys.stderr) # type: ignore[assignment]
|
|
39
|
+
sys.stdin = _configure_stream(sys.stdin) # type: ignore[assignment]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def configure_encoding() -> None:
|
|
43
|
+
"""
|
|
44
|
+
Configure stdout/stderr/stdin for UTF-8 encoding on Windows.
|
|
45
|
+
|
|
46
|
+
This is automatically called when importing from common,
|
|
47
|
+
but can be called manually for scripts that don't import common.
|
|
48
|
+
|
|
49
|
+
Safe to call multiple times.
|
|
50
|
+
"""
|
|
51
|
+
global sys
|
|
52
|
+
if sys.platform == "win32":
|
|
53
|
+
sys.stdout = _configure_stream(sys.stdout) # type: ignore[assignment]
|
|
54
|
+
sys.stderr = _configure_stream(sys.stderr) # type: ignore[assignment]
|
|
55
|
+
sys.stdin = _configure_stream(sys.stdin) # type: ignore[assignment]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
from .paths import (
|
|
59
|
+
DIR_WORKFLOW,
|
|
60
|
+
DIR_AGENT,
|
|
61
|
+
DIR_CHANGES,
|
|
62
|
+
DIR_WORKSPACE,
|
|
63
|
+
DIR_TASKS,
|
|
64
|
+
DIR_ARCHIVE,
|
|
65
|
+
DIR_SPEC,
|
|
66
|
+
DIR_SCRIPTS,
|
|
67
|
+
FILE_DEVELOPER,
|
|
68
|
+
FILE_CURRENT_TASK,
|
|
69
|
+
FILE_TASK_JSON,
|
|
70
|
+
FILE_JOURNAL_PREFIX,
|
|
71
|
+
get_repo_root,
|
|
72
|
+
get_developer,
|
|
73
|
+
check_developer,
|
|
74
|
+
get_tasks_dir,
|
|
75
|
+
get_workspace_dir,
|
|
76
|
+
get_active_journal_file,
|
|
77
|
+
count_lines,
|
|
78
|
+
get_current_task,
|
|
79
|
+
get_current_task_abs,
|
|
80
|
+
set_current_task,
|
|
81
|
+
clear_current_task,
|
|
82
|
+
has_current_task,
|
|
83
|
+
generate_task_date_prefix,
|
|
84
|
+
)
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
cowork-flow configuration reader.
|
|
4
|
+
|
|
5
|
+
Reads settings from .cowork-flow/config.yaml with sensible defaults.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .paths import DIR_WORKFLOW, get_repo_root
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Defaults
|
|
16
|
+
DEFAULT_SESSION_COMMIT_MESSAGE = "chore: record journal"
|
|
17
|
+
DEFAULT_MAX_JOURNAL_LINES = 2000
|
|
18
|
+
|
|
19
|
+
CONFIG_FILE = "config.yaml"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _unquote(value: str) -> str:
|
|
23
|
+
if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
|
|
24
|
+
return value[1:-1]
|
|
25
|
+
return value
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _parse_simple_yaml(content: str) -> dict:
|
|
29
|
+
result: dict = {}
|
|
30
|
+
current_section: str | None = None
|
|
31
|
+
current_list_key: str | None = None
|
|
32
|
+
|
|
33
|
+
for raw_line in content.splitlines():
|
|
34
|
+
stripped = raw_line.strip()
|
|
35
|
+
if not stripped or stripped.startswith("#"):
|
|
36
|
+
continue
|
|
37
|
+
|
|
38
|
+
indent = len(raw_line) - len(raw_line.lstrip())
|
|
39
|
+
|
|
40
|
+
if indent == 0 and ":" in stripped:
|
|
41
|
+
key, _, value = stripped.partition(":")
|
|
42
|
+
key = key.strip()
|
|
43
|
+
value = _unquote(value.strip())
|
|
44
|
+
current_section = None
|
|
45
|
+
current_list_key = None
|
|
46
|
+
|
|
47
|
+
if value:
|
|
48
|
+
result[key] = value
|
|
49
|
+
else:
|
|
50
|
+
result[key] = {}
|
|
51
|
+
current_section = key
|
|
52
|
+
continue
|
|
53
|
+
|
|
54
|
+
if current_section and indent >= 2:
|
|
55
|
+
section = result.setdefault(current_section, {})
|
|
56
|
+
if not isinstance(section, dict):
|
|
57
|
+
continue
|
|
58
|
+
|
|
59
|
+
if stripped.startswith("- ") and current_list_key:
|
|
60
|
+
current_list = section.setdefault(current_list_key, [])
|
|
61
|
+
if isinstance(current_list, list):
|
|
62
|
+
current_list.append(_unquote(stripped[2:].strip()))
|
|
63
|
+
continue
|
|
64
|
+
|
|
65
|
+
if ":" in stripped:
|
|
66
|
+
key, _, value = stripped.partition(":")
|
|
67
|
+
key = key.strip()
|
|
68
|
+
value = _unquote(value.strip())
|
|
69
|
+
if value:
|
|
70
|
+
section[key] = value
|
|
71
|
+
current_list_key = None
|
|
72
|
+
else:
|
|
73
|
+
section[key] = []
|
|
74
|
+
current_list_key = key
|
|
75
|
+
|
|
76
|
+
return result
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _get_config_path(repo_root: Path | None = None) -> Path:
|
|
80
|
+
"""Get path to config.yaml."""
|
|
81
|
+
root = repo_root or get_repo_root()
|
|
82
|
+
return root / DIR_WORKFLOW / CONFIG_FILE
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _load_config(repo_root: Path | None = None) -> dict:
|
|
86
|
+
"""Load and parse config.yaml. Returns empty dict on any error."""
|
|
87
|
+
config_file = _get_config_path(repo_root)
|
|
88
|
+
try:
|
|
89
|
+
content = config_file.read_text(encoding="utf-8")
|
|
90
|
+
return _parse_simple_yaml(content)
|
|
91
|
+
except (OSError, IOError):
|
|
92
|
+
return {}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def get_session_commit_message(repo_root: Path | None = None) -> str:
|
|
96
|
+
"""Get the commit message for auto-committing session records."""
|
|
97
|
+
config = _load_config(repo_root)
|
|
98
|
+
return config.get("session_commit_message", DEFAULT_SESSION_COMMIT_MESSAGE)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def get_max_journal_lines(repo_root: Path | None = None) -> int:
|
|
102
|
+
"""Get the maximum lines per journal file."""
|
|
103
|
+
config = _load_config(repo_root)
|
|
104
|
+
value = config.get("max_journal_lines", DEFAULT_MAX_JOURNAL_LINES)
|
|
105
|
+
try:
|
|
106
|
+
return int(value)
|
|
107
|
+
except (ValueError, TypeError):
|
|
108
|
+
return DEFAULT_MAX_JOURNAL_LINES
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def get_hooks(event: str, repo_root: Path | None = None) -> list[str]:
|
|
112
|
+
"""Get hook commands for a lifecycle event.
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
event: Event name (e.g. "after_create", "after_archive").
|
|
116
|
+
repo_root: Repository root path.
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
List of shell commands to execute, empty if none configured.
|
|
120
|
+
"""
|
|
121
|
+
config = _load_config(repo_root)
|
|
122
|
+
hooks = config.get("hooks")
|
|
123
|
+
if not isinstance(hooks, dict):
|
|
124
|
+
return []
|
|
125
|
+
commands = hooks.get(event)
|
|
126
|
+
if isinstance(commands, list):
|
|
127
|
+
return [str(c) for c in commands]
|
|
128
|
+
return []
|