hermes-task-framework 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. hermes_task_framework-1.0.0.dist-info/METADATA +7 -0
  2. hermes_task_framework-1.0.0.dist-info/RECORD +56 -0
  3. hermes_task_framework-1.0.0.dist-info/WHEEL +5 -0
  4. hermes_task_framework-1.0.0.dist-info/licenses/LICENSE +1 -0
  5. hermes_task_framework-1.0.0.dist-info/top_level.txt +1 -0
  6. task-framework/__init__.py +2 -0
  7. task-framework/skills/task-external-repos-pattern/SKILL.md +165 -0
  8. task-framework/skills/task-framework/CHANGELOG.md +45 -0
  9. task-framework/skills/task-framework/SKILL.md +1365 -0
  10. task-framework/skills/task-framework/docs/TASKS.md +3 -0
  11. task-framework/skills/task-framework/docs/architecture.dot +65 -0
  12. task-framework/skills/task-framework/docs/architecture.svg +188 -0
  13. task-framework/skills/task-framework/docs/product-requirements.md +65 -0
  14. task-framework/skills/task-framework/references/auto-runner.md +32 -0
  15. task-framework/skills/task-framework/references/composites/code-review-session.md +30 -0
  16. task-framework/skills/task-framework/references/composites/research.md +45 -0
  17. task-framework/skills/task-framework/references/composites/software-dev.md +41 -0
  18. task-framework/skills/task-framework/references/document-analysis-workflow.md +95 -0
  19. task-framework/skills/task-framework/references/file-safety-lesson.md +38 -0
  20. task-framework/skills/task-framework/references/generating-output-documents.md +166 -0
  21. task-framework/skills/task-framework/references/operations/code-write.md +30 -0
  22. task-framework/skills/task-framework/references/operations/document-write.md +185 -0
  23. task-framework/skills/task-framework/references/operations/info-search.md +27 -0
  24. task-framework/skills/task-framework/references/operations/web-research.md +57 -0
  25. task-framework/skills/task-framework/references/policy-time-metadata.md +137 -0
  26. task-framework/skills/task-framework/references/research-workflow.md +220 -0
  27. task-framework/skills/task-framework/references/task-format-validation.md +25 -0
  28. task-framework/skills/task-framework/references/task-hash-naming.md +40 -0
  29. task-framework/skills/task-framework/references/task-lifecycle-example.md +122 -0
  30. task-framework/skills/task-framework/references/task-overview-discovery.md +31 -0
  31. task-framework/skills/task-framework/references/task-types/analysis.md +60 -0
  32. task-framework/skills/task-framework/references/task-types/external-audit.md +56 -0
  33. task-framework/skills/task-framework/references/task-types/video-production-pipeline.md +77 -0
  34. task-framework/skills/task-framework/scripts/__init__.py +0 -0
  35. task-framework/skills/task-framework/scripts/__pycache__/manage_task.cpython-312.pyc +0 -0
  36. task-framework/skills/task-framework/scripts/__pycache__/task_ref.cpython-312.pyc +0 -0
  37. task-framework/skills/task-framework/scripts/__pycache__/update-index.cpython-311.pyc +0 -0
  38. task-framework/skills/task-framework/scripts/__pycache__/update-index.cpython-312.pyc +0 -0
  39. task-framework/skills/task-framework/scripts/convert_md_to_pdf.py +236 -0
  40. task-framework/skills/task-framework/scripts/manage_task.py +442 -0
  41. task-framework/skills/task-framework/scripts/task-runner.sh +42 -0
  42. task-framework/skills/task-framework/scripts/task_ref.py +119 -0
  43. task-framework/skills/task-framework/scripts/update-index.py +293 -0
  44. task-framework/skills/task-framework/templates/TASK.md +114 -0
  45. task-framework/skills/task-framework/templates/TASK_MEMORY.md +27 -0
  46. task-framework/skills/task-framework/templates/run.py +187 -0
  47. task-framework/skills/task-lifecycle-edge-cases/SKILL.md +84 -0
  48. task-framework/skills/task-lifecycle-edge-cases/references/task-5d5a1a-recovery-example.md +67 -0
  49. task-framework/skills/task-lifecycle-portability/SKILL.md +128 -0
  50. task-framework/skills/task-lifecycle-portability/references/design-session-20260611.md +32 -0
  51. task-framework/skills/task-lifecycle-portability/references/output-model-design.md +55 -0
  52. task-framework/skills/task-lifecycle-portability/references/pipeline-output-transition.md +41 -0
  53. task-framework/skills/task-lifecycle-portability/references/task-recovery-5d5a1a-example.md +27 -0
  54. task-framework/skills/task-lifecycle-portability/references/task-recovery-procedure.md +63 -0
  55. task-framework/skills/task-timestamp-convention/SKILL.md +72 -0
  56. task-framework/skills/task-tracker/SKILL.md +80 -0
@@ -0,0 +1,236 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Convert a large Chinese markdown file to PDF without pandoc.
4
+
5
+ Pandoc fails on markdown files with --- horizontal rules (YAML parse exception)
6
+ and times out on files >500KB of Chinese text. This script uses a custom
7
+ markdown-to-HTML renderer + weasyprint as a drop-in replacement.
8
+
9
+ Usage:
10
+ uv run scripts/convert_md_to_pdf.py input.md output.pdf
11
+ uv run scripts/convert_md_to_pdf.py input.md output.pdf --title "文档标题"
12
+ """
13
+
14
+ import re
15
+ import subprocess
16
+ import sys
17
+ from pathlib import Path
18
+
19
+
20
+ def md_to_html(md_text: str) -> str:
21
+ """Convert markdown to HTML with Chinese-friendly rendering."""
22
+ lines = md_text.split('\n')
23
+ html_parts = []
24
+ in_code_block = False
25
+ in_table = False
26
+ table_rows = []
27
+ in_list = False
28
+ list_type = None
29
+ list_items = []
30
+
31
+ def flush_table():
32
+ nonlocal in_table, table_rows
33
+ if not table_rows:
34
+ return
35
+ html_parts.append('<table>\n')
36
+ for i, row in enumerate(table_rows):
37
+ tag = 'th' if i == 0 else 'td'
38
+ html_parts.append('<tr>' + ''.join(f'<{tag}>{c}</{tag}>' for c in row) + '</tr>\n')
39
+ html_parts.append('</table>\n')
40
+ table_rows = []
41
+ in_table = False
42
+
43
+ def flush_list():
44
+ nonlocal in_list, list_type, list_items
45
+ if not list_items:
46
+ return
47
+ html_parts.append(f'<{list_type}>\n')
48
+ for item in list_items:
49
+ html_parts.append(f'<li>{item}</li>\n')
50
+ html_parts.append(f'</{list_type}>\n')
51
+ list_items = []
52
+ in_list = False
53
+ list_type = None
54
+
55
+ def fmt(text):
56
+ text = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', text)
57
+ text = re.sub(r'\*(.+?)\*', r'<em>\1</em>', text)
58
+ text = re.sub(r'`(.+?)`', r'<code>\1</code>', text)
59
+ return text
60
+
61
+ i = 0
62
+ while i < len(lines):
63
+ line = lines[i]
64
+
65
+ if line.strip().startswith('```'):
66
+ flush_table()
67
+ flush_list()
68
+ if in_code_block:
69
+ in_code_block = False
70
+ html_parts.append('</code></pre>\n')
71
+ else:
72
+ in_code_block = True
73
+ html_parts.append(f'<pre><code class="language-{line.strip()[3:]}">\n')
74
+ i += 1
75
+ continue
76
+
77
+ if in_code_block:
78
+ html_parts.append(line.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;') + '\n')
79
+ i += 1
80
+ continue
81
+
82
+ if line.strip() == '---' and not in_table:
83
+ flush_table()
84
+ flush_list()
85
+ html_parts.append('<hr>\n')
86
+ i += 1
87
+ continue
88
+
89
+ h_match = re.match(r'^(#{1,3})\s+(.+)$', line)
90
+ if h_match:
91
+ flush_table()
92
+ flush_list()
93
+ level = len(h_match.group(1))
94
+ text = h_match.group(2)
95
+ html_parts.append(f'<h{level}>{text}</h{level}>\n')
96
+ i += 1
97
+ continue
98
+
99
+ if line.startswith('> '):
100
+ flush_table()
101
+ flush_list()
102
+ html_parts.append(f'<blockquote><p>{line[2:]}</p></blockquote>\n')
103
+ i += 1
104
+ continue
105
+
106
+ if '|' in line and line.strip().startswith('|'):
107
+ flush_list()
108
+ in_table = True
109
+ if re.match(r'^\|[\s\-:]+\|', line):
110
+ i += 1
111
+ continue
112
+ cells = [fmt(c.strip()) for c in line.strip().split('|')[1:-1]]
113
+ table_rows.append(cells)
114
+ i += 1
115
+ continue
116
+ else:
117
+ if in_table:
118
+ flush_table()
119
+
120
+ ul_match = re.match(r'^[\s]*[-*]\s+(.+)$', line)
121
+ if ul_match:
122
+ flush_table()
123
+ if list_type != 'ul':
124
+ flush_list()
125
+ in_list, list_type = True, 'ul'
126
+ list_items.append(fmt(ul_match.group(1)))
127
+ i += 1
128
+ continue
129
+
130
+ ol_match = re.match(r'^[\s]*(\d+)\.\s+(.+)$', line)
131
+ if ol_match:
132
+ flush_table()
133
+ if list_type != 'ol':
134
+ flush_list()
135
+ in_list, list_type = True, 'ol'
136
+ list_items.append(fmt(ol_match.group(2)))
137
+ i += 1
138
+ continue
139
+
140
+ if line.strip() == '' and in_list:
141
+ i += 1
142
+ continue
143
+
144
+ if in_list and line.strip() and not re.match(r'^[\s*\-*\d]', line) and not line.startswith('|'):
145
+ flush_list()
146
+
147
+ if line.strip():
148
+ flush_list()
149
+ flush_table()
150
+ html_parts.append(f'<p>{fmt(line)}</p>\n')
151
+ i += 1
152
+
153
+ flush_table()
154
+ flush_list()
155
+ if in_code_block:
156
+ html_parts.append('</code></pre>\n')
157
+
158
+ return ''.join(html_parts)
159
+
160
+
161
+ def build_html(md_content: str, title: str = "Document") -> str:
162
+ """Wrap markdown HTML in a full document with print CSS."""
163
+ body = md_to_html(md_content)
164
+ return f'''<!DOCTYPE html>
165
+ <html lang="zh-CN">
166
+ <head>
167
+ <meta charset="utf-8">
168
+ <title>{title}</title>
169
+ <style>
170
+ @page {{ size: A4; margin: 1.8cm; }}
171
+ body {{ font-family: 'WenQuanYi Zen Hei', 'Noto Sans CJK SC', sans-serif;
172
+ font-size: 10pt; line-height: 1.7; color: #1a1a1a; }}
173
+ h1 {{ font-size: 20pt; color: #1a3a5c; border-bottom: 2px solid #1a3a5c; padding-bottom: 6px; }}
174
+ h2 {{ font-size: 15pt; color: #2a5a8c; margin-top: 28px; border-bottom: 1px solid #ccc; padding-bottom: 4px; page-break-after: avoid; }}
175
+ h3 {{ font-size: 12pt; color: #3a7abc; margin-top: 18px; page-break-after: avoid; }}
176
+ table {{ border-collapse: collapse; width: 100%; margin: 10px 0; font-size: 9pt; page-break-inside: avoid; }}
177
+ th, td {{ border: 1px solid #bbb; padding: 4px 8px; text-align: left; }}
178
+ th {{ background-color: #e8f0f8; }}
179
+ code {{ background: #f4f4f4; padding: 1px 4px; border-radius: 2px; font-size: 9pt; }}
180
+ pre {{ background: #f8f8f8; border: 1px solid #ddd; padding: 10px; font-size: 9pt; overflow-x: auto; }}
181
+ blockquote {{ border-left: 4px solid #1a3a5c; margin: 10px 0; padding: 5px 14px; background: #f8faff; }}
182
+ hr {{ border: none; border-top: 2px solid #1a3a5c; margin: 24px 0; }}
183
+ ul, ol {{ margin: 6px 0; padding-left: 24px; }}
184
+ p {{ margin: 6px 0; }}
185
+ </style>
186
+ </head>
187
+ <body>
188
+ {body}
189
+ </body>
190
+ </html>'''
191
+
192
+
193
+ def main():
194
+ if len(sys.argv) < 3:
195
+ print(f"Usage: {sys.argv[0]} <input.md> <output.pdf> [--title \"...\"]", file=sys.stderr)
196
+ sys.exit(1)
197
+
198
+ md_path = Path(sys.argv[1])
199
+ pdf_path = Path(sys.argv[2])
200
+ title = "Document"
201
+
202
+ if '--title' in sys.argv:
203
+ idx = sys.argv.index('--title')
204
+ if idx + 1 < len(sys.argv):
205
+ title = sys.argv[idx + 1]
206
+
207
+ if not md_path.exists():
208
+ print(f"Error: input file not found: {md_path}", file=sys.stderr)
209
+ sys.exit(1)
210
+
211
+ print(f"Reading: {md_path}")
212
+ content = md_path.read_text(encoding='utf-8')
213
+
214
+ print(f"Converting markdown to HTML ({len(content):,} chars)...")
215
+ html = build_html(content, title)
216
+
217
+ html_path = pdf_path.with_suffix('.html')
218
+ html_path.write_text(html, encoding='utf-8')
219
+
220
+ print(f"Converting HTML to PDF with weasyprint...")
221
+ result = subprocess.run(
222
+ ["weasyprint", str(html_path), str(pdf_path)],
223
+ capture_output=True, text=True, timeout=300
224
+ )
225
+
226
+ html_path.unlink()
227
+
228
+ if result.returncode != 0:
229
+ print(f"weasyprint failed: {result.stderr[:500]}", file=sys.stderr)
230
+ sys.exit(1)
231
+
232
+ print(f"PDF written: {pdf_path} ({pdf_path.stat().st_size:,} bytes)")
233
+
234
+
235
+ if __name__ == '__main__':
236
+ main()
@@ -0,0 +1,442 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ task-framework 任务生命周期管理工具
4
+
5
+ 管理 ~/studio/hermes/tasks/ 下的任务文件(默认,可通过 config.yaml 配置)。
6
+ 不再使用 ~/.hermes/personal/tasks/ 镜像目录——所有文件直接存放在任务目录下。
7
+
8
+ 配置优先级(同 token-consumption-tracker 模式):
9
+ 1. HERMES_TASKS_DIR 环境变量
10
+ 2. 当前 profile config.yaml: tasks.data_dir
11
+ 3. 全局 ~/.hermes/config.yaml: tasks.data_dir
12
+ 4. Fallback: ~/studio/hermes/tasks
13
+
14
+ Commands:
15
+ init 为任务创建目录 + TASK.md + TASK_MEMORY.md + .hermes-task.json
16
+ export 将任务打包为可移植 tar.gz
17
+ import 从 tar.gz 恢复任务
18
+ rebuild 按 hash 查找最近 tar.gz 并导入
19
+ reindex 重建 index.md
20
+ list 列出所有任务状态
21
+ migrate 一次性迁移:将 ~/.hermes/personal/tasks/ 下的文件迁移到统一目录
22
+ ensure-all 全量注册所有现有任务
23
+ """
24
+ import os, sys, glob, json, shutil, re, tarfile, tempfile, hashlib, random, argparse
25
+ from datetime import datetime
26
+ from pathlib import Path
27
+
28
+
29
+ # ── config resolution (mirrors token-consumption-tracker pattern) ──
30
+
31
+ def _read_config_yaml(config_path):
32
+ if not config_path or not os.path.exists(str(config_path)):
33
+ return {}
34
+ try:
35
+ import yaml
36
+ with open(config_path) as fh:
37
+ return yaml.safe_load(fh) or {}
38
+ except Exception:
39
+ return {}
40
+
41
+
42
+ def _resolve_tasks_root() -> str:
43
+ """Priority chain: env → profile config → global config → fallback."""
44
+ # 1. Env var
45
+ env = os.environ.get("HERMES_TASKS_DIR", "").strip()
46
+ if env:
47
+ return os.path.expanduser(env)
48
+
49
+ # 2. Per-profile config
50
+ hermes_home = os.environ.get("HERMES_HOME", "").strip()
51
+ if hermes_home:
52
+ cfg = _read_config_yaml(Path(hermes_home) / "config.yaml")
53
+ tasks_cfg = cfg.get("tasks", {})
54
+ if isinstance(tasks_cfg, dict):
55
+ val = tasks_cfg.get("data_dir")
56
+ if val and isinstance(val, str):
57
+ return os.path.expanduser(val)
58
+
59
+ # 3. Global config
60
+ global_cfg_path = Path(os.path.expanduser("~/.hermes/config.yaml"))
61
+ if hermes_home:
62
+ profile_cfg_path = (Path(hermes_home) / "config.yaml").resolve()
63
+ else:
64
+ profile_cfg_path = None
65
+ if profile_cfg_path is None or global_cfg_path.resolve() != profile_cfg_path.resolve():
66
+ cfg = _read_config_yaml(global_cfg_path)
67
+ tasks_cfg = cfg.get("tasks", {})
68
+ if isinstance(tasks_cfg, dict):
69
+ val = tasks_cfg.get("data_dir")
70
+ if val and isinstance(val, str):
71
+ return os.path.expanduser(val)
72
+
73
+ # 4. Fallback
74
+ return os.path.expanduser("~/studio/hermes/tasks")
75
+
76
+
77
+ TASKS_ROOT = _resolve_tasks_root()
78
+
79
+
80
+ def hash6():
81
+ return hashlib.md5(str(random.random()).encode()).hexdigest()[:6]
82
+
83
+
84
+ def _task_hash_from_dir(task_dir):
85
+ name = os.path.basename(task_dir)
86
+ m = re.search(r'-([a-z0-9]{6})$', name)
87
+ return m.group(1) if m else None
88
+
89
+
90
+ def _find_task_dir_by_hash(h):
91
+ if not os.path.isdir(TASKS_ROOT):
92
+ return None
93
+ for d in sorted(glob.glob(os.path.join(TASKS_ROOT, "2*")), reverse=True):
94
+ if os.path.isdir(d) and _task_hash_from_dir(d) == h:
95
+ return d
96
+ return None
97
+
98
+
99
+ def _find_all_task_dirs():
100
+ if not os.path.isdir(TASKS_ROOT):
101
+ return []
102
+ dirs = []
103
+ for d in sorted(glob.glob(os.path.join(TASKS_ROOT, "2*")), reverse=True):
104
+ if os.path.isdir(d) and (
105
+ os.path.exists(os.path.join(d, ".hermes-task.json")) or
106
+ os.path.exists(os.path.join(d, "TASK.md"))
107
+ ):
108
+ dirs.append(d)
109
+ return dirs
110
+
111
+
112
+ def _suggest_dir_name(h, task_dir=None):
113
+ meta = {}
114
+ meta_path = None
115
+ if task_dir and os.path.isdir(task_dir):
116
+ meta_path = os.path.join(task_dir, ".hermes-task.json")
117
+ if meta_path and os.path.exists(meta_path):
118
+ try:
119
+ with open(meta_path) as f:
120
+ meta = json.load(f)
121
+ except Exception:
122
+ pass
123
+
124
+ ts = meta.get('created_at', '')
125
+ if not ts:
126
+ ts = datetime.now().strftime('%Y%m%d-%H%M%S')
127
+ else:
128
+ try:
129
+ dt = datetime.fromisoformat(ts)
130
+ ts = dt.strftime('%Y%m%d-%H%M%S')
131
+ except Exception:
132
+ ts = str(ts).replace(' ', '-').replace(':', '')[:15]
133
+
134
+ name = meta.get('name', '')
135
+ if not name:
136
+ if task_dir:
137
+ title = None
138
+ task_md = os.path.join(task_dir, "TASK.md")
139
+ if os.path.exists(task_md):
140
+ m = re.search(r'^# Task:\s*(.+)', open(task_md).read(), re.MULTILINE)
141
+ if m:
142
+ title = m.group(1).strip()
143
+ if title:
144
+ name = re.sub(r'[^a-z0-9-]', '', title.lower().replace(' ', '-'))
145
+ if not name:
146
+ name = f'task-{h}'
147
+ name = re.sub(rf'-{re.escape(h)}$', '', name)
148
+ return f"{ts}.{name}-{h}"
149
+
150
+
151
+ def _ensure_task_files(task_dir, h):
152
+ """Create real files directly in the task directory (no symlinks, no mirror)."""
153
+ os.makedirs(task_dir, exist_ok=True)
154
+
155
+ task_f = os.path.join(task_dir, "TASK.md")
156
+ mem_f = os.path.join(task_dir, "TASK_MEMORY.md")
157
+ meta_f = os.path.join(task_dir, ".hermes-task.json")
158
+
159
+ if not os.path.exists(task_f):
160
+ with open(task_f, 'w') as f:
161
+ f.write("# Task: --\n\n## Status\n\nactive\n\n## Goal\n\n\n## Checklist\n\n")
162
+ print(f" Created: {task_f}")
163
+
164
+ if not os.path.exists(mem_f):
165
+ with open(mem_f, 'w') as f:
166
+ f.write(f"# TASK_MEMORY.md -- {h}\n\n")
167
+ print(f" Created: {mem_f}")
168
+
169
+ if not os.path.exists(meta_f):
170
+ with open(meta_f, 'w') as f:
171
+ json.dump({
172
+ "hash": h, "name": "",
173
+ "created_at": datetime.now().isoformat(),
174
+ "outputs": {}, "dependencies": []
175
+ }, f, indent=2, ensure_ascii=False)
176
+ print(f" Created: {meta_f}")
177
+
178
+ for d in ['input', 'output']:
179
+ dp = os.path.join(task_dir, d)
180
+ os.makedirs(dp, exist_ok=True)
181
+
182
+ return task_f, mem_f, meta_f
183
+
184
+
185
+ # ── commands ──────────────────────────────────────────────────────
186
+
187
+ def cmd_init(hash_or_dir):
188
+ if re.match(r'^[a-z0-9]{6}$', hash_or_dir):
189
+ h = hash_or_dir
190
+ task_dir = _find_task_dir_by_hash(h)
191
+ if not task_dir:
192
+ task_dir = os.path.join(TASKS_ROOT, _suggest_dir_name(h))
193
+ else:
194
+ task_dir = os.path.abspath(hash_or_dir)
195
+ h = _task_hash_from_dir(task_dir)
196
+ if not h:
197
+ print(f"Cannot determine hash from '{hash_or_dir}'")
198
+ return False
199
+ print(f" hash={h} task={task_dir}")
200
+ _ensure_task_files(task_dir, h)
201
+ return True
202
+
203
+
204
+ def cmd_export(hash_or_dir):
205
+ if re.match(r'^[a-z0-9]{6}$', hash_or_dir):
206
+ h = hash_or_dir
207
+ task_dir = _find_task_dir_by_hash(h)
208
+ else:
209
+ task_dir = os.path.abspath(hash_or_dir)
210
+ h = _task_hash_from_dir(task_dir)
211
+ if not h:
212
+ print(f"Cannot determine hash from '{hash_or_dir}'")
213
+ return False
214
+ if not task_dir or not os.path.isdir(task_dir):
215
+ print(f"Task directory not found for hash {h}")
216
+ return False
217
+
218
+ dir_name = os.path.basename(task_dir)
219
+ with tempfile.TemporaryDirectory(prefix=f"task-export-{h}-") as tmp:
220
+ staging = os.path.join(tmp, dir_name)
221
+ _copytree_deref(task_dir, staging, ignore=['.git'])
222
+ archive_name = f"{dir_name}.tar.gz"
223
+ archive_path = os.path.join(os.path.dirname(task_dir), archive_name)
224
+ with tarfile.open(archive_path, 'w:gz') as tar:
225
+ tar.add(staging, arcname=os.path.basename(staging))
226
+ print(f"Exported: {archive_path}")
227
+ return True
228
+
229
+
230
+ def _copytree_deref(src, dst, ignore=None):
231
+ os.makedirs(dst, exist_ok=True)
232
+ for item in os.listdir(src):
233
+ if ignore and item in ignore:
234
+ continue
235
+ s = os.path.join(src, item)
236
+ d = os.path.join(dst, item)
237
+ if os.path.isdir(s):
238
+ _copytree_deref(s, d, ignore)
239
+ else:
240
+ shutil.copy2(s, d)
241
+
242
+
243
+ def cmd_import(archive_path):
244
+ archive_path = os.path.abspath(archive_path)
245
+ if not os.path.exists(archive_path):
246
+ print(f"Archive not found: {archive_path}")
247
+ return False
248
+ with tempfile.TemporaryDirectory(prefix="task-import-") as tmp:
249
+ with tarfile.open(archive_path, 'r:gz') as tar:
250
+ tar.extractall(path=tmp)
251
+ extracted = os.listdir(tmp)
252
+ print(f"Extracted: {extracted}")
253
+
254
+ task_dir_name = None
255
+ for item in extracted:
256
+ item_path = os.path.join(tmp, item)
257
+ if os.path.isdir(item_path):
258
+ contents = os.listdir(item_path)
259
+ if any(f in contents for f in ['TASK.md', '.hermes-task.json']):
260
+ task_dir_name = item
261
+ break
262
+
263
+ if not task_dir_name:
264
+ print("No recognizable task directory found in archive")
265
+ return False
266
+
267
+ task_dir_path = os.path.join(tmp, task_dir_name)
268
+ h = _task_hash_from_dir(task_dir_path)
269
+ if not h:
270
+ # Try from meta.json
271
+ meta_path = os.path.join(task_dir_path, ".hermes-task.json")
272
+ if os.path.exists(meta_path):
273
+ try:
274
+ meta = json.load(open(meta_path))
275
+ h = meta.get('hash', '')
276
+ except Exception:
277
+ pass
278
+ if not h:
279
+ print(f"Cannot determine hash from '{task_dir_name}'")
280
+ return False
281
+
282
+ dest_name = task_dir_name if _task_hash_from_dir(task_dir_name) == h else _suggest_dir_name(h, task_dir_path)
283
+ task_dest = os.path.join(TASKS_ROOT, dest_name)
284
+ if os.path.exists(task_dest):
285
+ alt = f"{dest_name}-imported-{hash6()}"
286
+ task_dest = os.path.join(TASKS_ROOT, alt)
287
+ print(f"Using alternative: {task_dest}")
288
+
289
+ os.makedirs(task_dest, exist_ok=True)
290
+ for item in os.listdir(task_dir_path):
291
+ s = os.path.join(task_dir_path, item)
292
+ d = os.path.join(task_dest, item)
293
+ if os.path.isdir(s):
294
+ if not os.path.exists(d):
295
+ shutil.copytree(s, d)
296
+ else:
297
+ shutil.copy2(s, d)
298
+
299
+ print(f"\nImported: {task_dest}")
300
+ return True
301
+
302
+
303
+ def cmd_rebuild(hash_or_archive):
304
+ if tarfile.is_tarfile(hash_or_archive):
305
+ return cmd_import(hash_or_archive)
306
+ h = hash_or_archive
307
+ if not re.match(r'^[a-z0-9]{6}$', h):
308
+ print(f"Invalid hash: {h}")
309
+ return False
310
+ pattern = os.path.join(TASKS_ROOT, f"*{h}*.tar.gz")
311
+ matches = sorted(glob.glob(pattern), key=os.path.getmtime, reverse=True)
312
+ if not matches:
313
+ print(f"No tar.gz found for hash {h} in {TASKS_ROOT}")
314
+ return False
315
+ archive = matches[0]
316
+ print(f"Found: {archive}")
317
+ return cmd_import(archive)
318
+
319
+
320
+ def cmd_reindex():
321
+ index_path = os.path.join(TASKS_ROOT, "index.md")
322
+ task_dirs = _find_all_task_dirs()
323
+ lines = [
324
+ "# Task Index",
325
+ "",
326
+ "| Timestamp | Name | Hash | Status | TASK.md | MEMORY.md | Meta |",
327
+ "|-----------|------|------|--------|---------|-----------|------|",
328
+ ]
329
+ for d in task_dirs:
330
+ name = os.path.basename(d)
331
+ parts = name.split('.', 1)
332
+ ts = parts[0] if len(parts) > 1 else name
333
+ task_name = parts[1] if len(parts) > 1 else name
334
+ h = _task_hash_from_dir(d)
335
+ status = "?"
336
+ task_md = os.path.join(d, "TASK.md")
337
+ if os.path.exists(task_md):
338
+ m = re.search(r'## Status\s*\n\s*(\w+)', open(task_md).read())
339
+ if m:
340
+ status = m.group(1)
341
+ task_ok = "Y" if os.path.exists(os.path.join(d, "TASK.md")) else "N"
342
+ mem_ok = "Y" if os.path.exists(os.path.join(d, "TASK_MEMORY.md")) else "N"
343
+ meta_ok = "Y" if os.path.exists(os.path.join(d, ".hermes-task.json")) else "N"
344
+ lines.append(f"| {ts} | {task_name} | {h or '?'} | {status} | {task_ok} | {mem_ok} | {meta_ok} |")
345
+
346
+ with open(index_path, 'w') as f:
347
+ f.write('\n'.join(lines) + '\n')
348
+ print(f"Reindexed {len(task_dirs)} tasks -> {index_path}")
349
+ return True
350
+
351
+
352
+ def cmd_list():
353
+ index_path = os.path.join(TASKS_ROOT, "index.md")
354
+ if os.path.exists(index_path):
355
+ print(open(index_path).read())
356
+ else:
357
+ print("index.md not found. Run `reindex` first.")
358
+ return True
359
+
360
+
361
+ def cmd_migrate():
362
+ """One-shot migration: copy files from ~/.hermes/personal/tasks/<hash>/ to task dir."""
363
+ old_root = os.path.expanduser("~/.hermes/personal/tasks")
364
+ if not os.path.isdir(old_root):
365
+ print("No old personal/tasks directory to migrate from.")
366
+ return True
367
+
368
+ migrated = 0
369
+ for hd_name in os.listdir(old_root):
370
+ hd = os.path.join(old_root, hd_name)
371
+ if not os.path.isdir(hd) or not re.match(r'^[a-z0-9]{6}$', hd_name):
372
+ continue
373
+ h = hd_name
374
+ task_dir = _find_task_dir_by_hash(h)
375
+ if not task_dir:
376
+ # Create new task dir
377
+ task_dir = os.path.join(TASKS_ROOT, _suggest_dir_name(h))
378
+ os.makedirs(task_dir, exist_ok=True)
379
+
380
+ # Copy files from old mirror to task dir, if task dir doesn't already have them
381
+ for old_name, new_name in [
382
+ ("task.md", "TASK.md"),
383
+ ("memory.md", "TASK_MEMORY.md"),
384
+ ("meta.json", ".hermes-task.json"),
385
+ ]:
386
+ src = os.path.join(hd, old_name)
387
+ dst = os.path.join(task_dir, new_name)
388
+ if os.path.exists(src):
389
+ # Symlinks pass os.path.exists — check explicitly so we don't skip
390
+ if os.path.islink(dst):
391
+ os.remove(dst)
392
+ if not os.path.exists(dst):
393
+ shutil.copy2(src, dst)
394
+ print(f" {old_name} -> {new_name}")
395
+
396
+ migrated += 1
397
+
398
+ cmd_reindex()
399
+ print(f"\nMigrated {migrated} tasks from {old_root}")
400
+ return True
401
+
402
+
403
+ def cmd_ensure_all():
404
+ task_dirs = _find_all_task_dirs()
405
+ for d in task_dirs:
406
+ h = _task_hash_from_dir(d)
407
+ if h:
408
+ cmd_init(h)
409
+ cmd_reindex()
410
+ print(f"\nAll {len(task_dirs)} tasks registered.")
411
+
412
+
413
+ def main():
414
+ parser = argparse.ArgumentParser(description='Task lifecycle management')
415
+ parser.add_argument('action', choices=[
416
+ 'init', 'export', 'import', 'rebuild',
417
+ 'reindex', 'list', 'migrate', 'ensure-all'
418
+ ])
419
+ parser.add_argument('arg', nargs='?', default='')
420
+ args = parser.parse_args()
421
+
422
+ actions = {
423
+ 'init': lambda: cmd_init(args.arg),
424
+ 'export': lambda: cmd_export(args.arg),
425
+ 'import': lambda: cmd_import(args.arg) if args.arg else False,
426
+ 'rebuild': lambda: cmd_rebuild(args.arg) if args.arg else False,
427
+ 'reindex': cmd_reindex,
428
+ 'list': cmd_list,
429
+ 'migrate': cmd_migrate,
430
+ 'ensure-all': cmd_ensure_all,
431
+ }
432
+ fn = actions.get(args.action)
433
+ if fn:
434
+ ok = fn()
435
+ else:
436
+ ok = False
437
+
438
+ sys.exit(0 if ok else 1)
439
+
440
+
441
+ if __name__ == '__main__':
442
+ main()