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.
- hermes_task_framework-1.0.0.dist-info/METADATA +7 -0
- hermes_task_framework-1.0.0.dist-info/RECORD +56 -0
- hermes_task_framework-1.0.0.dist-info/WHEEL +5 -0
- hermes_task_framework-1.0.0.dist-info/licenses/LICENSE +1 -0
- hermes_task_framework-1.0.0.dist-info/top_level.txt +1 -0
- task-framework/__init__.py +2 -0
- task-framework/skills/task-external-repos-pattern/SKILL.md +165 -0
- task-framework/skills/task-framework/CHANGELOG.md +45 -0
- task-framework/skills/task-framework/SKILL.md +1365 -0
- task-framework/skills/task-framework/docs/TASKS.md +3 -0
- task-framework/skills/task-framework/docs/architecture.dot +65 -0
- task-framework/skills/task-framework/docs/architecture.svg +188 -0
- task-framework/skills/task-framework/docs/product-requirements.md +65 -0
- task-framework/skills/task-framework/references/auto-runner.md +32 -0
- task-framework/skills/task-framework/references/composites/code-review-session.md +30 -0
- task-framework/skills/task-framework/references/composites/research.md +45 -0
- task-framework/skills/task-framework/references/composites/software-dev.md +41 -0
- task-framework/skills/task-framework/references/document-analysis-workflow.md +95 -0
- task-framework/skills/task-framework/references/file-safety-lesson.md +38 -0
- task-framework/skills/task-framework/references/generating-output-documents.md +166 -0
- task-framework/skills/task-framework/references/operations/code-write.md +30 -0
- task-framework/skills/task-framework/references/operations/document-write.md +185 -0
- task-framework/skills/task-framework/references/operations/info-search.md +27 -0
- task-framework/skills/task-framework/references/operations/web-research.md +57 -0
- task-framework/skills/task-framework/references/policy-time-metadata.md +137 -0
- task-framework/skills/task-framework/references/research-workflow.md +220 -0
- task-framework/skills/task-framework/references/task-format-validation.md +25 -0
- task-framework/skills/task-framework/references/task-hash-naming.md +40 -0
- task-framework/skills/task-framework/references/task-lifecycle-example.md +122 -0
- task-framework/skills/task-framework/references/task-overview-discovery.md +31 -0
- task-framework/skills/task-framework/references/task-types/analysis.md +60 -0
- task-framework/skills/task-framework/references/task-types/external-audit.md +56 -0
- task-framework/skills/task-framework/references/task-types/video-production-pipeline.md +77 -0
- task-framework/skills/task-framework/scripts/__init__.py +0 -0
- task-framework/skills/task-framework/scripts/__pycache__/manage_task.cpython-312.pyc +0 -0
- task-framework/skills/task-framework/scripts/__pycache__/task_ref.cpython-312.pyc +0 -0
- task-framework/skills/task-framework/scripts/__pycache__/update-index.cpython-311.pyc +0 -0
- task-framework/skills/task-framework/scripts/__pycache__/update-index.cpython-312.pyc +0 -0
- task-framework/skills/task-framework/scripts/convert_md_to_pdf.py +236 -0
- task-framework/skills/task-framework/scripts/manage_task.py +442 -0
- task-framework/skills/task-framework/scripts/task-runner.sh +42 -0
- task-framework/skills/task-framework/scripts/task_ref.py +119 -0
- task-framework/skills/task-framework/scripts/update-index.py +293 -0
- task-framework/skills/task-framework/templates/TASK.md +114 -0
- task-framework/skills/task-framework/templates/TASK_MEMORY.md +27 -0
- task-framework/skills/task-framework/templates/run.py +187 -0
- task-framework/skills/task-lifecycle-edge-cases/SKILL.md +84 -0
- task-framework/skills/task-lifecycle-edge-cases/references/task-5d5a1a-recovery-example.md +67 -0
- task-framework/skills/task-lifecycle-portability/SKILL.md +128 -0
- task-framework/skills/task-lifecycle-portability/references/design-session-20260611.md +32 -0
- task-framework/skills/task-lifecycle-portability/references/output-model-design.md +55 -0
- task-framework/skills/task-lifecycle-portability/references/pipeline-output-transition.md +41 -0
- task-framework/skills/task-lifecycle-portability/references/task-recovery-5d5a1a-example.md +27 -0
- task-framework/skills/task-lifecycle-portability/references/task-recovery-procedure.md +63 -0
- task-framework/skills/task-timestamp-convention/SKILL.md +72 -0
- 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('&', '&').replace('<', '<').replace('>', '>') + '\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()
|