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.
Files changed (101) hide show
  1. package/README.md +193 -0
  2. package/bin/cowork-flow.js +9 -0
  3. package/package.json +28 -0
  4. package/src/cli.js +61 -0
  5. package/src/commands/init.js +88 -0
  6. package/src/commands/sync.js +33 -0
  7. package/src/commands/update.js +60 -0
  8. package/src/lib/copy-template.js +189 -0
  9. package/src/lib/package-info.js +49 -0
  10. package/src/lib/paths.js +6 -0
  11. package/template/.agent/skills/break-loop/SKILL.md +131 -0
  12. package/template/.agent/skills/check-cross-layer/SKILL.md +158 -0
  13. package/template/.agent/skills/finish-work/SKILL.md +163 -0
  14. package/template/.agent/skills/record-session/SKILL.md +78 -0
  15. package/template/.agent/skills/start/SKILL.md +390 -0
  16. package/template/.agent/skills/update-spec/SKILL.md +335 -0
  17. package/template/.cowork-flow/.version +1 -0
  18. package/template/.cowork-flow/changes/archive/.gitkeep +1 -0
  19. package/template/.cowork-flow/config.yaml +49 -0
  20. package/template/.cowork-flow/plans/.gitkeep +1 -0
  21. package/template/.cowork-flow/scripts/add_session.py +441 -0
  22. package/template/.cowork-flow/scripts/change.py +314 -0
  23. package/template/.cowork-flow/scripts/common/__init__.py +84 -0
  24. package/template/.cowork-flow/scripts/common/config.py +128 -0
  25. package/template/.cowork-flow/scripts/common/developer.py +205 -0
  26. package/template/.cowork-flow/scripts/common/files.py +24 -0
  27. package/template/.cowork-flow/scripts/common/git_context.py +628 -0
  28. package/template/.cowork-flow/scripts/common/paths.py +350 -0
  29. package/template/.cowork-flow/scripts/common/task_utils.py +200 -0
  30. package/template/.cowork-flow/scripts/get_context.py +16 -0
  31. package/template/.cowork-flow/scripts/get_developer.py +26 -0
  32. package/template/.cowork-flow/scripts/init_developer.py +51 -0
  33. package/template/.cowork-flow/scripts/task.py +1378 -0
  34. package/template/.cowork-flow/spec/backend/database-guidelines.md +26 -0
  35. package/template/.cowork-flow/spec/backend/directory-structure.md +27 -0
  36. package/template/.cowork-flow/spec/backend/error-handling.md +20 -0
  37. package/template/.cowork-flow/spec/backend/index.md +36 -0
  38. package/template/.cowork-flow/spec/backend/logging-guidelines.md +20 -0
  39. package/template/.cowork-flow/spec/backend/quality-guidelines.md +24 -0
  40. package/template/.cowork-flow/spec/frontend/component-guidelines.md +19 -0
  41. package/template/.cowork-flow/spec/frontend/directory-structure.md +18 -0
  42. package/template/.cowork-flow/spec/frontend/hook-guidelines.md +17 -0
  43. package/template/.cowork-flow/spec/frontend/index.md +28 -0
  44. package/template/.cowork-flow/spec/frontend/quality-guidelines.md +23 -0
  45. package/template/.cowork-flow/spec/frontend/state-management.md +18 -0
  46. package/template/.cowork-flow/spec/frontend/type-safety.md +18 -0
  47. package/template/.cowork-flow/spec/guides/code-reuse-thinking-guide.md +26 -0
  48. package/template/.cowork-flow/spec/guides/cross-layer-thinking-guide.md +21 -0
  49. package/template/.cowork-flow/spec/guides/index.md +31 -0
  50. package/template/.cowork-flow/spec/guides/pre-implementation-checklist.md +15 -0
  51. package/template/.cowork-flow/tasks/.gitkeep +1 -0
  52. package/template/.cowork-flow/tasks/archive/.gitkeep +1 -0
  53. package/template/.cowork-flow/workflow.md +411 -0
  54. package/template/.cowork-flow/workspace/index.md +95 -0
  55. package/template/.superpowers/brainstorming/SKILL.md +164 -0
  56. package/template/.superpowers/brainstorming/scripts/frame-template.html +214 -0
  57. package/template/.superpowers/brainstorming/scripts/helper.js +88 -0
  58. package/template/.superpowers/brainstorming/scripts/server.cjs +354 -0
  59. package/template/.superpowers/brainstorming/scripts/start-server.sh +148 -0
  60. package/template/.superpowers/brainstorming/scripts/stop-server.sh +56 -0
  61. package/template/.superpowers/brainstorming/spec-document-reviewer-prompt.md +49 -0
  62. package/template/.superpowers/brainstorming/visual-companion.md +287 -0
  63. package/template/.superpowers/dispatching-parallel-agents/SKILL.md +182 -0
  64. package/template/.superpowers/executing-plans/SKILL.md +70 -0
  65. package/template/.superpowers/finishing-a-development-branch/SKILL.md +251 -0
  66. package/template/.superpowers/receiving-code-review/SKILL.md +213 -0
  67. package/template/.superpowers/requesting-code-review/SKILL.md +103 -0
  68. package/template/.superpowers/requesting-code-review/code-reviewer.md +168 -0
  69. package/template/.superpowers/subagent-driven-development/SKILL.md +279 -0
  70. package/template/.superpowers/subagent-driven-development/code-quality-reviewer-prompt.md +25 -0
  71. package/template/.superpowers/subagent-driven-development/implementer-prompt.md +113 -0
  72. package/template/.superpowers/subagent-driven-development/spec-reviewer-prompt.md +61 -0
  73. package/template/.superpowers/systematic-debugging/CREATION-LOG.md +119 -0
  74. package/template/.superpowers/systematic-debugging/SKILL.md +296 -0
  75. package/template/.superpowers/systematic-debugging/condition-based-waiting-example.ts +158 -0
  76. package/template/.superpowers/systematic-debugging/condition-based-waiting.md +115 -0
  77. package/template/.superpowers/systematic-debugging/defense-in-depth.md +122 -0
  78. package/template/.superpowers/systematic-debugging/find-polluter.sh +63 -0
  79. package/template/.superpowers/systematic-debugging/root-cause-tracing.md +169 -0
  80. package/template/.superpowers/systematic-debugging/test-academic.md +14 -0
  81. package/template/.superpowers/systematic-debugging/test-pressure-1.md +58 -0
  82. package/template/.superpowers/systematic-debugging/test-pressure-2.md +68 -0
  83. package/template/.superpowers/systematic-debugging/test-pressure-3.md +69 -0
  84. package/template/.superpowers/test-driven-development/SKILL.md +371 -0
  85. package/template/.superpowers/test-driven-development/testing-anti-patterns.md +299 -0
  86. package/template/.superpowers/using-git-worktrees/SKILL.md +215 -0
  87. package/template/.superpowers/using-superpowers/SKILL.md +117 -0
  88. package/template/.superpowers/using-superpowers/references/codex-tools.md +59 -0
  89. package/template/.superpowers/using-superpowers/references/copilot-tools.md +42 -0
  90. package/template/.superpowers/using-superpowers/references/gemini-tools.md +51 -0
  91. package/template/.superpowers/verification-before-completion/SKILL.md +139 -0
  92. package/template/.superpowers/writing-plans/SKILL.md +152 -0
  93. package/template/.superpowers/writing-plans/plan-document-reviewer-prompt.md +49 -0
  94. package/template/.superpowers/writing-skills/SKILL.md +655 -0
  95. package/template/.superpowers/writing-skills/anthropic-best-practices.md +1150 -0
  96. package/template/.superpowers/writing-skills/examples/CLAUDE_MD_TESTING.md +189 -0
  97. package/template/.superpowers/writing-skills/graphviz-conventions.dot +172 -0
  98. package/template/.superpowers/writing-skills/persuasion-principles.md +187 -0
  99. package/template/.superpowers/writing-skills/render-graphs.js +168 -0
  100. package/template/.superpowers/writing-skills/testing-skills-with-subagents.md +384 -0
  101. 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 []