handoffpack 0.1.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.
- handoffpack/__init__.py +3 -0
- handoffpack/adapter_instructions.py +492 -0
- handoffpack/codec.py +249 -0
- handoffpack/guards.py +148 -0
- handoffpack/legend.json +21 -0
- handoffpack/license.py +194 -0
- handoffpack/server.py +692 -0
- handoffpack-0.1.0.dist-info/METADATA +145 -0
- handoffpack-0.1.0.dist-info/RECORD +12 -0
- handoffpack-0.1.0.dist-info/WHEEL +5 -0
- handoffpack-0.1.0.dist-info/entry_points.txt +3 -0
- handoffpack-0.1.0.dist-info/top_level.txt +1 -0
handoffpack/__init__.py
ADDED
|
@@ -0,0 +1,492 @@
|
|
|
1
|
+
"""Instruction-file adapter: recurring-instruction files -> HP1 instruction cards.
|
|
2
|
+
|
|
3
|
+
Converts CLAUDE.md / SKILL.md / AGENTS.md style recurring-instruction
|
|
4
|
+
files into HP1 cards, one card per section, fully deterministically:
|
|
5
|
+
|
|
6
|
+
(a) deterministic pass — markdown section splitter + per-line rule
|
|
7
|
+
classification into DO / LIM / BAN slots where unambiguous;
|
|
8
|
+
(b) ambiguous or nuanced content is marked UNCONVERTED with a reason
|
|
9
|
+
(nuance guard from handoffpack.guards is applied at section level,
|
|
10
|
+
conditional-clause detection at line level). Content is NEVER
|
|
11
|
+
silently dropped: every non-blank source line ends up either in
|
|
12
|
+
a card slot or inside a verbatim UNCONVERTED block.
|
|
13
|
+
(c) output = a .hp1 card file + a JSON sidecar report (token
|
|
14
|
+
before/after via the chars/4 estimator, converted/unconverted
|
|
15
|
+
section counts, BAN-rule violations found);
|
|
16
|
+
(d) --check mode — validate an existing .hp1 against source drift
|
|
17
|
+
(sha256 over newline-normalized source text) and re-parse every
|
|
18
|
+
embedded HP1 card through the codec.
|
|
19
|
+
|
|
20
|
+
Reuses handoffpack primitives throughout (codec.assemble_card / parse_card /
|
|
21
|
+
estimate_tokens, guards.validate_ban_field / assess_nuance /
|
|
22
|
+
check_breakeven). No network, no LLM, no new card format.
|
|
23
|
+
|
|
24
|
+
Known deterministic limitations (documented, not hidden):
|
|
25
|
+
- only ATX markdown headings ('#'..'######') split sections; setext
|
|
26
|
+
headings are treated as plain content lines;
|
|
27
|
+
- imperative-verb detection uses a fixed allowlist; anything outside
|
|
28
|
+
it is honestly UNCONVERTED rather than guessed.
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import argparse
|
|
33
|
+
import hashlib
|
|
34
|
+
import json
|
|
35
|
+
import re
|
|
36
|
+
import sys
|
|
37
|
+
from dataclasses import dataclass, field
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
from typing import Any
|
|
40
|
+
|
|
41
|
+
from . import codec, guards
|
|
42
|
+
from . import license as hplicense
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"split_sections",
|
|
46
|
+
"classify_line",
|
|
47
|
+
"convert_text",
|
|
48
|
+
"render_hp1",
|
|
49
|
+
"build_report",
|
|
50
|
+
"convert_file",
|
|
51
|
+
"check_file",
|
|
52
|
+
"main",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
HPINSTR_HEADER = "#HPINSTR 1.0"
|
|
56
|
+
|
|
57
|
+
# ---------------------------------------------------------------------------
|
|
58
|
+
# line-level patterns
|
|
59
|
+
# ---------------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
_HEADING = re.compile(r"^(#{1,6})\s+(.*\S)\s*$")
|
|
62
|
+
_SEPARATOR = re.compile(r"^\s*[-=_*─-╿]{3,}\s*$")
|
|
63
|
+
_BULLET = re.compile(r"^\s*(?:[-*+]\s+|\d+[.)]\s*)(.*)$")
|
|
64
|
+
_CODE_FENCE = re.compile(r"^\s*(```|~~~)")
|
|
65
|
+
|
|
66
|
+
# BAN lead-ins: plain prohibitions. The lead-in itself is stripped so the
|
|
67
|
+
# BAN slot holds the forbidden action only ("Never use emojis" -> "use emojis").
|
|
68
|
+
_BAN_LEAD = re.compile(
|
|
69
|
+
r"^(?:never|do\s+not|don'?t|no|avoid|forbid(?:den)?[:.]?|"
|
|
70
|
+
r"금지[:.]?|절대)\s+(.+)$",
|
|
71
|
+
re.IGNORECASE,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
# LIM lead-ins: standing constraints. Kept verbatim as the LIM value.
|
|
75
|
+
_LIM_LEAD = re.compile(
|
|
76
|
+
r"^(?:always|must|only|limit|max(?:imum)?|min(?:imum)?|keep|use|prefer|"
|
|
77
|
+
r"stick\s+to|ensure|require[sd]?|restrict)\b",
|
|
78
|
+
re.IGNORECASE,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# DO: imperative dev verbs (fixed allowlist — outside it, honest UNCONVERTED).
|
|
82
|
+
_DO_VERBS = (
|
|
83
|
+
"run|read|write|check|test|report|commit|push|pull|review|update|create|"
|
|
84
|
+
"add|remove|delete|install|build|compile|format|lint|document|verify|"
|
|
85
|
+
"follow|start|stop|open|close|list|ask|search|grep|fix|refactor|rename|"
|
|
86
|
+
"move|copy|save|log|measure|validate|parse|generate|return|include|"
|
|
87
|
+
"prepend|append|state|split"
|
|
88
|
+
)
|
|
89
|
+
_DO_LEAD = re.compile(rf"^(?:{_DO_VERBS})\b", re.IGNORECASE)
|
|
90
|
+
|
|
91
|
+
# Conditional-clause markers: reuse the guard module's nuance markers so
|
|
92
|
+
# line-level and section-level detection cannot diverge.
|
|
93
|
+
_CONDITIONAL = guards._NUANCE_MARKERS
|
|
94
|
+
|
|
95
|
+
_HP1_PREFIX = codec.load_legend().get("header", "HP1") + codec.load_legend().get("separator", "|")
|
|
96
|
+
|
|
97
|
+
_UNCONV_BEGIN = "#--UNCONVERTED"
|
|
98
|
+
_UNCONV_END = "#--END"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# ---------------------------------------------------------------------------
|
|
102
|
+
# data model
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
@dataclass
|
|
106
|
+
class Section:
|
|
107
|
+
index: int
|
|
108
|
+
title: str
|
|
109
|
+
heading_line: str | None # None for preamble
|
|
110
|
+
lines: list[str] = field(default_factory=list) # body lines, verbatim
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass
|
|
114
|
+
class SectionResult:
|
|
115
|
+
index: int
|
|
116
|
+
title: str
|
|
117
|
+
status: str # "converted" | "partial" | "unconverted" | "empty"
|
|
118
|
+
card: str | None
|
|
119
|
+
slotted: list[tuple[str, str, str]] = field(default_factory=list) # (line, slot, value)
|
|
120
|
+
unconverted: list[tuple[str, str]] = field(default_factory=list) # (line, reason)
|
|
121
|
+
section_reason: str | None = None
|
|
122
|
+
ban_violations: list[str] = field(default_factory=list)
|
|
123
|
+
raw_lines: list[str] = field(default_factory=list) # heading + body, verbatim
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
@dataclass
|
|
127
|
+
class Conversion:
|
|
128
|
+
source_name: str
|
|
129
|
+
source_sha256: str
|
|
130
|
+
source_text: str
|
|
131
|
+
sections: list[SectionResult] = field(default_factory=list)
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def ban_violations(self) -> list[str]:
|
|
135
|
+
out: list[str] = []
|
|
136
|
+
for sec in self.sections:
|
|
137
|
+
out.extend(sec.ban_violations)
|
|
138
|
+
return out
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
# (a) section splitter
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
|
|
145
|
+
def split_sections(text: str) -> list[Section]:
|
|
146
|
+
"""Split instruction text into sections on ATX markdown headings.
|
|
147
|
+
|
|
148
|
+
Content before the first heading becomes a "(preamble)" section.
|
|
149
|
+
Heading lines are kept (heading_line) so nothing is lost when a
|
|
150
|
+
section must be preserved verbatim.
|
|
151
|
+
"""
|
|
152
|
+
sections: list[Section] = []
|
|
153
|
+
current = Section(index=0, title="(preamble)", heading_line=None)
|
|
154
|
+
for line in text.splitlines():
|
|
155
|
+
m = _HEADING.match(line)
|
|
156
|
+
if m:
|
|
157
|
+
if current.lines or current.heading_line is not None:
|
|
158
|
+
sections.append(current)
|
|
159
|
+
current = Section(
|
|
160
|
+
index=len(sections),
|
|
161
|
+
title=m.group(2).strip("# ").strip(),
|
|
162
|
+
heading_line=line,
|
|
163
|
+
)
|
|
164
|
+
else:
|
|
165
|
+
current.lines.append(line)
|
|
166
|
+
sections.append(current)
|
|
167
|
+
# drop a fully empty preamble (no content lines at all)
|
|
168
|
+
if sections and sections[0].heading_line is None and not any(
|
|
169
|
+
ln.strip() for ln in sections[0].lines
|
|
170
|
+
):
|
|
171
|
+
sections = sections[1:]
|
|
172
|
+
for i, sec in enumerate(sections):
|
|
173
|
+
sec.index = i
|
|
174
|
+
return sections
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
# ---------------------------------------------------------------------------
|
|
178
|
+
# (a)+(b) per-line rule classification
|
|
179
|
+
# ---------------------------------------------------------------------------
|
|
180
|
+
|
|
181
|
+
def classify_line(line: str) -> tuple[str | None, str, str | None]:
|
|
182
|
+
"""Classify one content line.
|
|
183
|
+
|
|
184
|
+
Returns (slot, value, reason):
|
|
185
|
+
slot in {"DO","LIM","BAN"} with the slot value, reason None; or
|
|
186
|
+
slot None with value=original line and a reason string explaining
|
|
187
|
+
why the line stays UNCONVERTED. Only unambiguous patterns convert.
|
|
188
|
+
"""
|
|
189
|
+
stripped = line.strip()
|
|
190
|
+
m = _BULLET.match(stripped)
|
|
191
|
+
numbered = bool(re.match(r"^\s*\d+[.)]", stripped))
|
|
192
|
+
body = (m.group(1) if m else stripped).strip()
|
|
193
|
+
if not body:
|
|
194
|
+
return None, line, "blank"
|
|
195
|
+
|
|
196
|
+
# 1. BAN lead-in — must survive the BAN-field guard, else it is a
|
|
197
|
+
# BAN-rule violation and stays UNCONVERTED (never silently dropped).
|
|
198
|
+
ban_m = _BAN_LEAD.match(body)
|
|
199
|
+
if ban_m:
|
|
200
|
+
value = ban_m.group(1).strip().rstrip(".")
|
|
201
|
+
verdict = guards.validate_ban_field(value)
|
|
202
|
+
if verdict["ok"]:
|
|
203
|
+
return "BAN", value, None
|
|
204
|
+
return None, line, f"ban-rule violation: {verdict['reason']}"
|
|
205
|
+
|
|
206
|
+
# 2. conditional clause anywhere -> nuanced, do not compress.
|
|
207
|
+
cond = _CONDITIONAL.search(body)
|
|
208
|
+
if cond:
|
|
209
|
+
return None, line, f"conditional clause ({cond.group(0).strip()!r}) — nuance preserved verbatim"
|
|
210
|
+
|
|
211
|
+
# 3. standing constraint.
|
|
212
|
+
if _LIM_LEAD.match(body):
|
|
213
|
+
return "LIM", body.rstrip("."), None
|
|
214
|
+
|
|
215
|
+
# 4. imperative step (numbered lines get the benefit of list position).
|
|
216
|
+
if _DO_LEAD.match(body) or (numbered and _DO_LEAD.match(body.split(":")[0])):
|
|
217
|
+
return "DO", body.rstrip("."), None
|
|
218
|
+
|
|
219
|
+
return None, line, "unclassifiable: no unambiguous DO/LIM/BAN pattern"
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _convert_section(sec: Section) -> SectionResult:
|
|
223
|
+
raw_lines = ([sec.heading_line] if sec.heading_line is not None else []) + sec.lines
|
|
224
|
+
result = SectionResult(index=sec.index, title=sec.title, status="empty", card=None,
|
|
225
|
+
raw_lines=raw_lines)
|
|
226
|
+
|
|
227
|
+
content = [ln for ln in sec.lines if ln.strip()]
|
|
228
|
+
if not content:
|
|
229
|
+
# heading with no body: TASK-only card, nothing droppable.
|
|
230
|
+
result.card = codec.assemble_card({"TASK": sec.title, "sec": str(sec.index)})
|
|
231
|
+
return result
|
|
232
|
+
|
|
233
|
+
# (b) section-level nuance guard: conditional-dense prose is kept whole.
|
|
234
|
+
nu = guards.assess_nuance("\n".join(content))
|
|
235
|
+
if nu["verdict"] == "advise-skip":
|
|
236
|
+
result.status = "unconverted"
|
|
237
|
+
result.section_reason = nu["reason"]
|
|
238
|
+
result.unconverted = [(ln, "section-level nuance guard") for ln in content]
|
|
239
|
+
return result
|
|
240
|
+
|
|
241
|
+
slots: dict[str, list[str]] = {"DO": [], "LIM": [], "BAN": []}
|
|
242
|
+
in_code = False
|
|
243
|
+
for ln in content:
|
|
244
|
+
if _CODE_FENCE.match(ln):
|
|
245
|
+
in_code = not in_code
|
|
246
|
+
result.unconverted.append((ln, "code fence — preserved verbatim"))
|
|
247
|
+
continue
|
|
248
|
+
if in_code:
|
|
249
|
+
result.unconverted.append((ln, "code block — preserved verbatim"))
|
|
250
|
+
continue
|
|
251
|
+
if _SEPARATOR.match(ln):
|
|
252
|
+
result.unconverted.append((ln, "decorative separator"))
|
|
253
|
+
continue
|
|
254
|
+
slot, value, reason = classify_line(ln)
|
|
255
|
+
if slot is None:
|
|
256
|
+
result.unconverted.append((ln, reason or "unclassifiable"))
|
|
257
|
+
if reason and reason.startswith("ban-rule violation"):
|
|
258
|
+
result.ban_violations.append(ln.strip())
|
|
259
|
+
else:
|
|
260
|
+
slots[slot].append(value)
|
|
261
|
+
result.slotted.append((ln, slot, value))
|
|
262
|
+
|
|
263
|
+
if result.slotted:
|
|
264
|
+
card_slots: dict[str, Any] = {"TASK": sec.title, "sec": str(sec.index)}
|
|
265
|
+
for name in ("DO", "LIM", "BAN"):
|
|
266
|
+
if slots[name]:
|
|
267
|
+
card_slots[name] = slots[name] # codec joins lists with ';'
|
|
268
|
+
result.card = codec.assemble_card(card_slots)
|
|
269
|
+
substantive_left = [
|
|
270
|
+
(ln, r) for ln, r in result.unconverted
|
|
271
|
+
if not r.startswith("decorative")
|
|
272
|
+
]
|
|
273
|
+
result.status = "converted" if not substantive_left else "partial"
|
|
274
|
+
else:
|
|
275
|
+
result.status = "unconverted"
|
|
276
|
+
result.section_reason = "no unambiguous rules found — kept verbatim"
|
|
277
|
+
# ensure the heading is preserved with the body (raw_lines has it)
|
|
278
|
+
|
|
279
|
+
return result
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def convert_text(text: str, source_name: str = "<memory>") -> Conversion:
|
|
283
|
+
normalized = text.replace("\r\n", "\n").replace("\r", "\n")
|
|
284
|
+
conv = Conversion(
|
|
285
|
+
source_name=source_name,
|
|
286
|
+
source_sha256=hashlib.sha256(normalized.encode("utf-8")).hexdigest(),
|
|
287
|
+
source_text=normalized,
|
|
288
|
+
)
|
|
289
|
+
for sec in split_sections(normalized):
|
|
290
|
+
conv.sections.append(_convert_section(sec))
|
|
291
|
+
return conv
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# ---------------------------------------------------------------------------
|
|
295
|
+
# (c) rendering + sidecar report
|
|
296
|
+
# ---------------------------------------------------------------------------
|
|
297
|
+
|
|
298
|
+
def render_hp1(conv: Conversion) -> str:
|
|
299
|
+
lines: list[str] = [
|
|
300
|
+
HPINSTR_HEADER,
|
|
301
|
+
f"#source: {conv.source_name}",
|
|
302
|
+
f"#source_sha256: {conv.source_sha256}",
|
|
303
|
+
]
|
|
304
|
+
for sec in conv.sections:
|
|
305
|
+
if sec.status == "unconverted":
|
|
306
|
+
reason = (sec.section_reason or "unclassifiable").replace("\n", " ")
|
|
307
|
+
lines.append(f"{_UNCONV_BEGIN} sec={sec.index} reason={reason}")
|
|
308
|
+
lines.extend(sec.raw_lines)
|
|
309
|
+
lines.append(_UNCONV_END)
|
|
310
|
+
continue
|
|
311
|
+
if sec.card:
|
|
312
|
+
lines.append(sec.card)
|
|
313
|
+
leftovers = [
|
|
314
|
+
(ln, r) for ln, r in sec.unconverted
|
|
315
|
+
if not r.startswith("decorative")
|
|
316
|
+
]
|
|
317
|
+
if leftovers:
|
|
318
|
+
lines.append(f"{_UNCONV_BEGIN} sec={sec.index} reason=partial: unclassified lines preserved")
|
|
319
|
+
for ln, r in leftovers:
|
|
320
|
+
lines.append(ln)
|
|
321
|
+
lines.append(_UNCONV_END)
|
|
322
|
+
return "\n".join(lines) + "\n"
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def build_report(conv: Conversion, hp1_text: str) -> dict[str, Any]:
|
|
326
|
+
statuses = [s.status for s in conv.sections]
|
|
327
|
+
content_lines = sum(len([ln for ln in s.raw_lines if ln.strip()]) for s in conv.sections)
|
|
328
|
+
slotted_lines = sum(len(s.slotted) for s in conv.sections)
|
|
329
|
+
unconverted_lines = sum(len(s.unconverted) for s in conv.sections)
|
|
330
|
+
before = codec.estimate_tokens(conv.source_text)
|
|
331
|
+
after = codec.estimate_tokens(hp1_text)
|
|
332
|
+
be = guards.check_breakeven(conv.source_text)
|
|
333
|
+
return {
|
|
334
|
+
"format": "hpinstr-report/1.0",
|
|
335
|
+
"source": conv.source_name,
|
|
336
|
+
"source_sha256": conv.source_sha256,
|
|
337
|
+
"sections": {
|
|
338
|
+
"total": len(conv.sections),
|
|
339
|
+
"converted": statuses.count("converted"),
|
|
340
|
+
"partial": statuses.count("partial"),
|
|
341
|
+
"unconverted": statuses.count("unconverted"),
|
|
342
|
+
"empty": statuses.count("empty"),
|
|
343
|
+
},
|
|
344
|
+
"lines": {
|
|
345
|
+
"content": content_lines,
|
|
346
|
+
"slotted": slotted_lines,
|
|
347
|
+
"unconverted": unconverted_lines,
|
|
348
|
+
},
|
|
349
|
+
"line_conversion_rate": round(slotted_lines / content_lines, 3) if content_lines else 0.0,
|
|
350
|
+
"tokens": {
|
|
351
|
+
"estimator": "chars/4 (codec.estimate_tokens)",
|
|
352
|
+
"before": before,
|
|
353
|
+
"after": after,
|
|
354
|
+
"reduction_pct": round((1 - after / before) * 100, 1) if before else 0.0,
|
|
355
|
+
# cards only, excluding verbatim UNCONVERTED blocks — shows the
|
|
356
|
+
# ceiling a sampling-assisted pass could approach.
|
|
357
|
+
"cards_only": sum(
|
|
358
|
+
codec.estimate_tokens(s.card) for s in conv.sections if s.card
|
|
359
|
+
),
|
|
360
|
+
},
|
|
361
|
+
"below_breakeven": bool(be["passthrough"]),
|
|
362
|
+
"ban_violations": conv.ban_violations,
|
|
363
|
+
"unconverted_sections": [
|
|
364
|
+
{"sec": s.index, "title": s.title, "reason": s.section_reason}
|
|
365
|
+
for s in conv.sections if s.status == "unconverted"
|
|
366
|
+
],
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def convert_file(src: str | Path, out: str | Path | None = None,
|
|
371
|
+
report_path: str | Path | None = None) -> dict[str, Any]:
|
|
372
|
+
src = Path(src)
|
|
373
|
+
text = src.read_text(encoding="utf-8")
|
|
374
|
+
conv = convert_text(text, source_name=src.name)
|
|
375
|
+
hp1_text = render_hp1(conv)
|
|
376
|
+
out = Path(out) if out else src.with_suffix(".hp1")
|
|
377
|
+
report_path = Path(report_path) if report_path else Path(str(out) + ".report.json")
|
|
378
|
+
out.write_text(hp1_text, encoding="utf-8")
|
|
379
|
+
report = build_report(conv, hp1_text)
|
|
380
|
+
report["output"] = str(out)
|
|
381
|
+
report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
382
|
+
return report
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
# ---------------------------------------------------------------------------
|
|
386
|
+
# (d) --check mode: hash-based source-drift validation
|
|
387
|
+
# ---------------------------------------------------------------------------
|
|
388
|
+
|
|
389
|
+
def check_file(hp1_path: str | Path, source_path: str | Path) -> dict[str, Any]:
|
|
390
|
+
"""Validate an existing .hp1 file against its source.
|
|
391
|
+
|
|
392
|
+
Checks: header magic, recorded sha256 vs the source's current
|
|
393
|
+
(newline-normalized) sha256, and that every embedded HP1 card still
|
|
394
|
+
parses under the current legend. Returns {"ok", "drift", ...}.
|
|
395
|
+
"""
|
|
396
|
+
hp1_text = Path(hp1_path).read_text(encoding="utf-8")
|
|
397
|
+
lines = hp1_text.splitlines()
|
|
398
|
+
issues: list[str] = []
|
|
399
|
+
|
|
400
|
+
if not lines or lines[0].strip() != HPINSTR_HEADER:
|
|
401
|
+
return {"ok": False, "drift": False,
|
|
402
|
+
"issues": [f"not an HPINSTR file: missing {HPINSTR_HEADER!r} header"]}
|
|
403
|
+
|
|
404
|
+
recorded = None
|
|
405
|
+
for ln in lines[:5]:
|
|
406
|
+
if ln.startswith("#source_sha256:"):
|
|
407
|
+
recorded = ln.split(":", 1)[1].strip()
|
|
408
|
+
break
|
|
409
|
+
if recorded is None:
|
|
410
|
+
issues.append("no #source_sha256 line in header")
|
|
411
|
+
|
|
412
|
+
src_text = Path(source_path).read_text(encoding="utf-8")
|
|
413
|
+
normalized = src_text.replace("\r\n", "\n").replace("\r", "\n")
|
|
414
|
+
current = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
|
415
|
+
drift = recorded is not None and recorded != current
|
|
416
|
+
if drift:
|
|
417
|
+
issues.append(
|
|
418
|
+
f"source drift: recorded sha256 {recorded[:12]}... != current {current[:12]}... — re-run convert"
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
cards_checked = 0
|
|
422
|
+
in_unconv = False
|
|
423
|
+
for ln in lines:
|
|
424
|
+
if ln.startswith(_UNCONV_BEGIN):
|
|
425
|
+
in_unconv = True
|
|
426
|
+
continue
|
|
427
|
+
if ln.strip() == _UNCONV_END:
|
|
428
|
+
in_unconv = False
|
|
429
|
+
continue
|
|
430
|
+
if not in_unconv and ln.startswith(_HP1_PREFIX):
|
|
431
|
+
cards_checked += 1
|
|
432
|
+
try:
|
|
433
|
+
codec.parse_card(ln)
|
|
434
|
+
except codec.CodecError as exc:
|
|
435
|
+
issues.append(f"card {cards_checked} invalid: {exc}")
|
|
436
|
+
|
|
437
|
+
return {
|
|
438
|
+
"ok": not issues,
|
|
439
|
+
"drift": drift,
|
|
440
|
+
"recorded_sha256": recorded,
|
|
441
|
+
"current_sha256": current,
|
|
442
|
+
"cards_checked": cards_checked,
|
|
443
|
+
"issues": issues,
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
# ---------------------------------------------------------------------------
|
|
448
|
+
# CLI: hp-instructions
|
|
449
|
+
# ---------------------------------------------------------------------------
|
|
450
|
+
|
|
451
|
+
def _print_json(obj: Any) -> None:
|
|
452
|
+
"""Print JSON; fall back to ASCII escapes on narrow consoles (cp949 etc.)."""
|
|
453
|
+
try:
|
|
454
|
+
print(json.dumps(obj, ensure_ascii=False, indent=2))
|
|
455
|
+
except UnicodeEncodeError:
|
|
456
|
+
print(json.dumps(obj, ensure_ascii=True, indent=2))
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def main(argv: list[str] | None = None) -> int:
|
|
460
|
+
parser = argparse.ArgumentParser(
|
|
461
|
+
prog="hp-instructions",
|
|
462
|
+
description="Convert recurring-instruction files (CLAUDE.md style) into HP1 instruction cards.",
|
|
463
|
+
)
|
|
464
|
+
parser.add_argument("source", help="instruction file to convert (or source file in --check mode)")
|
|
465
|
+
parser.add_argument("-o", "--out", help="output .hp1 path (default: <source>.hp1)")
|
|
466
|
+
parser.add_argument("--report", help="sidecar report path (default: <out>.report.json)")
|
|
467
|
+
parser.add_argument("--check", metavar="HP1_FILE",
|
|
468
|
+
help="validate this existing .hp1 against SOURCE for drift instead of converting")
|
|
469
|
+
parser.add_argument("--license", metavar="KEY", default=None,
|
|
470
|
+
help="license key (default discovery: $HP_LICENSE, then ~/.handoffpack/license)")
|
|
471
|
+
args = parser.parse_args(argv)
|
|
472
|
+
|
|
473
|
+
if args.check:
|
|
474
|
+
# --check (drift scan) is FREE — no license required.
|
|
475
|
+
result = check_file(args.check, args.source)
|
|
476
|
+
_print_json(result)
|
|
477
|
+
return 0 if result["ok"] else (2 if result.get("drift") else 1)
|
|
478
|
+
|
|
479
|
+
# Full conversion run is a paid feature: 'plus' tier, verified offline.
|
|
480
|
+
ok, msg, _payload = hplicense.require_tier("plus", args.license)
|
|
481
|
+
if not ok:
|
|
482
|
+
_print_json({"error": "license_required", "detail": msg,
|
|
483
|
+
"hint": "hp-instructions --check remains free; full conversion needs a 'plus' key"})
|
|
484
|
+
return 3
|
|
485
|
+
|
|
486
|
+
report = convert_file(args.source, args.out, args.report)
|
|
487
|
+
_print_json(report)
|
|
488
|
+
return 0
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
if __name__ == "__main__":
|
|
492
|
+
sys.exit(main())
|