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.
@@ -0,0 +1,3 @@
1
+ """handoffpack — deterministic structured-card codec + guards + MCP translator server."""
2
+
3
+ __version__ = "0.1.0"
@@ -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())