spine-cli 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.
spine/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """spine-cli: harness-agnostic process spine."""
2
+
3
+ __version__ = "0.1.0"
spine/artifacts.py ADDED
@@ -0,0 +1,257 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import subprocess
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+
8
+ from spine.model import EXEC_ROOT, SPEC_ROOT, dump_front, load_contract, parse_front
9
+
10
+
11
+ def _now() -> datetime:
12
+ return datetime.now(timezone.utc)
13
+
14
+
15
+ def identity() -> str:
16
+ env = os.environ.get("SPINE_USER") or os.environ.get("GIT_AUTHOR_NAME")
17
+ if env:
18
+ return env
19
+ try:
20
+ proc = subprocess.run(
21
+ ["git", "config", "--get", "user.name"],
22
+ capture_output=True,
23
+ text=True,
24
+ check=False,
25
+ )
26
+ name = (proc.stdout or "").strip()
27
+ if proc.returncode == 0 and name:
28
+ return name
29
+ except OSError:
30
+ pass
31
+ return os.environ.get("USER") or "unknown"
32
+
33
+
34
+ def _slug(title: str) -> str:
35
+ s = "".join(ch.lower() if ch.isalnum() else "-" for ch in title).strip("-")
36
+ while "--" in s:
37
+ s = s.replace("--", "-")
38
+ return s or "item"
39
+
40
+
41
+ def _next_number(folder: Path) -> int:
42
+ n = 0
43
+ if not folder.exists():
44
+ return 1
45
+ for p in folder.glob("*.md"):
46
+ head = p.name.split("-", 1)[0]
47
+ if head.isdigit():
48
+ n = max(n, int(head))
49
+ return n + 1
50
+
51
+
52
+ def new_ticket(root: Path, title: str, typ: str = "grilling") -> Path:
53
+ folder = root / SPEC_ROOT / "tickets"
54
+ folder.mkdir(parents=True, exist_ok=True)
55
+ num = _next_number(folder)
56
+ path = folder / f"{num:02d}-{_slug(title)}.md"
57
+ body = f"## Question\n\n{title}\n"
58
+ text = dump_front(
59
+ {"Type": typ, "Status": "open", "Blocked by": "", "Owner": "", "Claimed-at": ""},
60
+ body,
61
+ title=title,
62
+ )
63
+ path.write_text(text, encoding="utf-8")
64
+ return path
65
+
66
+
67
+ def new_work_item(root: Path, title: str, profile: str = "software") -> Path:
68
+ folder = root / SPEC_ROOT / "work-items"
69
+ folder.mkdir(parents=True, exist_ok=True)
70
+ num = _next_number(folder)
71
+ path = folder / f"{num:02d}-{_slug(title)}.md"
72
+ body = f"## Intent\n\n{title}\n"
73
+ text = dump_front(
74
+ {
75
+ "Type": "work-item",
76
+ "Profile": profile,
77
+ "Status": "ready",
78
+ "Owner": "",
79
+ "Claimed-at": "",
80
+ "Links": "",
81
+ "Deliverable": "",
82
+ },
83
+ body,
84
+ title=title,
85
+ )
86
+ path.write_text(text, encoding="utf-8")
87
+ return path
88
+
89
+
90
+ def resolve_artifact(root: Path, spec: str) -> Path:
91
+ p = Path(spec)
92
+ if p.is_absolute() and p.exists():
93
+ return p
94
+ cand = root / spec
95
+ if cand.exists():
96
+ return cand
97
+ for folder in ("work-items", "tickets"):
98
+ d = root / SPEC_ROOT / folder
99
+ if d.exists():
100
+ for f in d.glob("*.md"):
101
+ if spec in f.name or spec in f.stem:
102
+ return f
103
+ raise FileNotFoundError(spec)
104
+
105
+
106
+ def read_meta(path: Path) -> tuple[dict[str, str], str]:
107
+ return parse_front(path.read_text(encoding="utf-8"))
108
+
109
+
110
+ def write_meta(path: Path, meta: dict[str, str], body: str) -> None:
111
+ title = meta.get("Title") or path.stem
112
+ path.write_text(dump_front(meta, body, title=title), encoding="utf-8")
113
+
114
+
115
+ def _deliverable_exists(root: Path, meta: dict[str, str]) -> bool:
116
+ dest = (meta.get("Deliverable") or "").strip()
117
+ if not dest:
118
+ return False
119
+ p = Path(dest)
120
+ return (root / dest).exists() or p.exists()
121
+
122
+
123
+ def _has_proof(root: Path, kind: str, stem: str) -> bool:
124
+ folder = root / EXEC_ROOT / kind
125
+ if not folder.is_dir():
126
+ return False
127
+ return any(p.is_file() and (stem in p.name or p.stem == stem) for p in folder.iterdir())
128
+
129
+
130
+ def set_status(root: Path, spec: str, nxt: str) -> Path:
131
+ contract = load_contract(root)
132
+ path = resolve_artifact(root, spec)
133
+ meta, body = read_meta(path)
134
+ cur = meta.get("Status", "")
135
+ if nxt not in contract.statuses:
136
+ raise ValueError(f"unknown status {nxt}")
137
+ profile = (meta.get("Profile") or "software").lower()
138
+ software = profile == "software"
139
+ if cur == "doing" and nxt == "done" and not software:
140
+ if not _deliverable_exists(root, meta):
141
+ raise ValueError("non-software doing → done needs an existing Deliverable path")
142
+ elif not contract.allowed(cur, nxt):
143
+ raise ValueError(f"illegal transition {cur} → {nxt}")
144
+ if software and cur == "checking" and nxt == "reviewing":
145
+ if not _has_proof(root, "evals", path.stem):
146
+ raise ValueError("software checking → reviewing needs proof under .spine/evals/")
147
+ if nxt == "done" and software:
148
+ required = contract.software_required()
149
+ if cur not in {"reviewing"} and "reviewing" in required:
150
+ raise ValueError("software profile requires reviewing before done")
151
+ if not _has_proof(root, "reviews", path.stem):
152
+ raise ValueError("software reviewing → done needs proof under .spine/reviews/")
153
+ meta["Status"] = nxt
154
+ write_meta(path, meta, body)
155
+ return path
156
+
157
+
158
+ def claim(root: Path, spec: str) -> Path:
159
+ path = resolve_artifact(root, spec)
160
+ meta, body = read_meta(path)
161
+ owner = meta.get("Owner") or ""
162
+ claimed_at = meta.get("Claimed-at") or ""
163
+ if owner and claimed_at and not _stale(claimed_at, root=root):
164
+ raise ValueError(f"already claimed by {owner} at {claimed_at}")
165
+ meta["Owner"] = identity()
166
+ meta["Claimed-at"] = _now().isoformat()
167
+ if meta.get("Status") == "open":
168
+ meta["Status"] = "claimed"
169
+ write_meta(path, meta, body)
170
+ runtime = root / EXEC_ROOT / "claims" / f"{path.stem}.claim"
171
+ runtime.parent.mkdir(parents=True, exist_ok=True)
172
+ runtime.write_text(f"{meta['Owner']}\n{meta['Claimed-at']}\n", encoding="utf-8")
173
+ return path
174
+
175
+
176
+ def release(root: Path, spec: str) -> Path:
177
+ path = resolve_artifact(root, spec)
178
+ meta, body = read_meta(path)
179
+ meta["Owner"] = ""
180
+ meta["Claimed-at"] = ""
181
+ if meta.get("Status") == "claimed":
182
+ meta["Status"] = "open"
183
+ write_meta(path, meta, body)
184
+ runtime = root / EXEC_ROOT / "claims" / f"{path.stem}.claim"
185
+ if runtime.exists():
186
+ runtime.unlink()
187
+ return path
188
+
189
+
190
+ def _stale(iso: str, hours: int | None = None, root: Path | None = None) -> bool:
191
+ hours = hours or load_contract(root).stale_hours
192
+ try:
193
+ then = datetime.fromisoformat(iso)
194
+ except ValueError:
195
+ return True
196
+ if then.tzinfo is None:
197
+ then = then.replace(tzinfo=timezone.utc)
198
+ return (_now() - then).total_seconds() > hours * 3600
199
+
200
+
201
+ def is_stale(meta: dict[str, str], root: Path | None = None) -> bool:
202
+ at = meta.get("Claimed-at") or ""
203
+ if not at:
204
+ return False
205
+ return _stale(at, root=root)
206
+
207
+
208
+ def link(root: Path, a: str, b: str) -> None:
209
+ pa, pb = resolve_artifact(root, a), resolve_artifact(root, b)
210
+ for path, other in ((pa, pb), (pb, pa)):
211
+ meta, body = read_meta(path)
212
+ links = [x.strip() for x in (meta.get("Links") or "").split(",") if x.strip()]
213
+ rel = str(other.relative_to(root))
214
+ if rel not in links:
215
+ links.append(rel)
216
+ meta["Links"] = ", ".join(links)
217
+ write_meta(path, meta, body)
218
+
219
+
220
+ def _ticket_blocked(meta: dict[str, str]) -> bool:
221
+ return bool((meta.get("Blocked by") or "").strip())
222
+
223
+
224
+ def board(root: Path) -> str:
225
+ contract = load_contract(root)
226
+ lines = ["# spine status", ""]
227
+ wi = root / SPEC_ROOT / "work-items"
228
+ tk = root / SPEC_ROOT / "tickets"
229
+ if wi.exists():
230
+ lines.append("## Work items")
231
+ for p in sorted(wi.glob("*.md")):
232
+ meta, _ = read_meta(p)
233
+ software = (meta.get("Profile") or "software").lower() == "software"
234
+ gates = contract.next_gates(meta.get("Status", ""), software=software)
235
+ gate = ",".join(gates) if gates else "-"
236
+ lines.append(
237
+ f"- {p.name}: {meta.get('Status','?')} next={gate} owner={meta.get('Owner','') or '-'}"
238
+ )
239
+ lines.append("")
240
+ frontier: list[str] = []
241
+ if tk.exists():
242
+ lines.append("## Tickets")
243
+ for p in sorted(tk.glob("*.md")):
244
+ meta, _ = read_meta(p)
245
+ status = meta.get("Status", "?")
246
+ blocked = meta.get("Blocked by", "") or "-"
247
+ lines.append(f"- {p.name}: {status} blocked={blocked}")
248
+ if status == "open" and not _ticket_blocked(meta) and not (meta.get("Owner") or "").strip():
249
+ frontier.append(p.name)
250
+ lines.append("")
251
+ lines.append("## Frontier")
252
+ if frontier:
253
+ for name in frontier:
254
+ lines.append(f"- {name}")
255
+ else:
256
+ lines.append("- (empty)")
257
+ return "\n".join(lines) + "\n"
spine/cli.py ADDED
@@ -0,0 +1,108 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ from spine import __version__
8
+ from spine.artifacts import board, claim, link, new_ticket, new_work_item, release, set_status
9
+ from spine.doctor import doctor
10
+ from spine.evolve import evolve, install_wires, set_pack
11
+ from spine.initcmd import init_target
12
+
13
+
14
+ def _root() -> Path:
15
+ return Path.cwd()
16
+
17
+
18
+ def main(argv: list[str] | None = None) -> int:
19
+ parser = argparse.ArgumentParser(prog="spine", description="Harness-agnostic process spine")
20
+ parser.add_argument("--version", action="version", version=f"spine {__version__}")
21
+ sub = parser.add_subparsers(dest="cmd", required=True)
22
+
23
+ p_init = sub.add_parser("init", help="scaffold a target")
24
+ p_init.add_argument("--refresh", action="store_true")
25
+
26
+ sub.add_parser("status", help="board")
27
+
28
+ p_new = sub.add_parser("new", help="mint a work item and/or map ticket")
29
+ p_new.add_argument("kind", nargs="?", choices=["ticket", "work-item"])
30
+ p_new.add_argument("--title", required=True)
31
+ p_new.add_argument("--type", default="grilling")
32
+ p_new.add_argument("--profile", default="software")
33
+ p_new.add_argument("--ticket", action="store_true")
34
+ p_new.add_argument("--work-item", dest="work_item_flag", action="store_true")
35
+
36
+ p_claim = sub.add_parser("claim")
37
+ p_claim.add_argument("target")
38
+ p_rel = sub.add_parser("release")
39
+ p_rel.add_argument("target")
40
+
41
+ p_ss = sub.add_parser("set-status")
42
+ p_ss.add_argument("target")
43
+ p_ss.add_argument("status")
44
+
45
+ p_link = sub.add_parser("link")
46
+ p_link.add_argument("a")
47
+ p_link.add_argument("b")
48
+
49
+ p_doc = sub.add_parser("doctor")
50
+ p_doc.add_argument("--report-only", action="store_true")
51
+
52
+ p_wire = sub.add_parser("wire", help="install or record skills.sh craft packs")
53
+ p_wire.add_argument("--install", action="store_true", help="npx skills add default pack")
54
+ p_wire.add_argument("--pack", default="mattpocock/skills")
55
+
56
+ sub.add_parser("evolve", help="refresh binder from wheel; update skills.sh installs")
57
+
58
+ args = parser.parse_args(argv)
59
+ root = _root()
60
+
61
+ try:
62
+ if args.cmd == "init":
63
+ for line in init_target(root, refresh=args.refresh):
64
+ print(line)
65
+ elif args.cmd == "status":
66
+ print(board(root), end="")
67
+ elif args.cmd == "new":
68
+ want_ticket = args.kind == "ticket" or args.ticket
69
+ want_wi = args.kind == "work-item" or args.work_item_flag
70
+ if not want_ticket and not want_wi:
71
+ raise ValueError("new needs ticket and/or work-item")
72
+ if want_ticket:
73
+ print(new_ticket(root, args.title, args.type))
74
+ if want_wi:
75
+ print(new_work_item(root, args.title, args.profile))
76
+ elif args.cmd == "claim":
77
+ print(claim(root, args.target))
78
+ elif args.cmd == "release":
79
+ print(release(root, args.target))
80
+ elif args.cmd == "set-status":
81
+ print(set_status(root, args.target, args.status))
82
+ elif args.cmd == "link":
83
+ link(root, args.a, args.b)
84
+ print("linked")
85
+ elif args.cmd == "doctor":
86
+ for line in doctor(root, apply=not args.report_only):
87
+ print(line)
88
+ elif args.cmd == "wire":
89
+ set_pack(root, args.pack)
90
+ if args.install:
91
+ for line in install_wires(root, extra_pack=args.pack):
92
+ print(line)
93
+ else:
94
+ print(f"default_pack={args.pack} (pass --install to run npx skills add)")
95
+ elif args.cmd == "evolve":
96
+ for line in evolve(root):
97
+ print(line)
98
+ else:
99
+ parser.error("unknown command")
100
+ return 2
101
+ except (ValueError, FileNotFoundError) as exc:
102
+ print(f"error: {exc}", file=sys.stderr)
103
+ return 1
104
+ return 0
105
+
106
+
107
+ if __name__ == "__main__":
108
+ raise SystemExit(main())
@@ -0,0 +1,16 @@
1
+ ---
2
+ name: build
3
+ description: Implement a software work item in doing. Use when the item is claimed and status is doing.
4
+ ---
5
+
6
+ # build
7
+
8
+ ```
9
+ spine claim docs/spine/work-items/NN-slug.md
10
+ spine set-status docs/spine/work-items/NN-slug.md doing
11
+ ```
12
+
13
+ Agents own content. CLI owns metadata. Install craft: `npx skills add mattpocock/skills -s implement -s tdd -s codebase-design -y`.
14
+
15
+ ## Gate
16
+ doing (agent-drivable). Next: checking.
@@ -0,0 +1,16 @@
1
+ ---
2
+ name: doctor
3
+ description: Recover mechanical drift in a spine target. Use at session start and when files look wrong.
4
+ ---
5
+
6
+ # doctor
7
+
8
+ ```
9
+ spine doctor
10
+ spine evolve
11
+ ```
12
+
13
+ Doctor auto-fixes stale claims, CLI-owned links, and rollups. It reports unknown statuses, missing spec files, gitignore drift. Never rewrite content sections by hand to fake a fix.
14
+
15
+ ## Gate
16
+ Recovery loop. Not a harness hook.
@@ -0,0 +1,17 @@
1
+ ---
2
+ name: eval
3
+ description: Check a software work item against acceptance. Use when status is checking.
4
+ ---
5
+
6
+ # eval
7
+
8
+ Write proof under `.spine/evals/`. Then:
9
+
10
+ ```
11
+ spine set-status docs/spine/work-items/NN-slug.md reviewing
12
+ ```
13
+
14
+ Craft: `npx skills add mattpocock/skills -s qa -y`.
15
+
16
+ ## Gate
17
+ checking (agent-drivable). Software profile requires this before done.
@@ -0,0 +1,18 @@
1
+ ---
2
+ name: new
3
+ description: Capture a map ticket or work item via the spine CLI. Use when something new must be recorded in the target.
4
+ ---
5
+
6
+ # new
7
+
8
+ Mint artifacts with the CLI. Do not hand-edit status, owner, claim, links, or numbers.
9
+
10
+ ```
11
+ spine new ticket --title "..." --type grilling
12
+ spine new work-item --title "..." --profile software
13
+ ```
14
+
15
+ Craft skills for interviewing live on skills.sh. Default wire: `mattpocock/skills` `grill-me` / `wayfinder` (`npx skills add mattpocock/skills -s grill-me -s wayfinder -y`). See `docs/spine/wires.yaml`.
16
+
17
+ ## Gate
18
+ creating-work-item is HITL. Tickets start `open`. Work items start `ready`.
@@ -0,0 +1,18 @@
1
+ ---
2
+ name: next
3
+ description: Pick the next spine action from the map frontier and work-item board.
4
+ ---
5
+
6
+ # next
7
+
8
+ Run `spine doctor` then `spine status`. Take the first open unblocked unclaimed ticket, or the work item whose status is the next gate.
9
+
10
+ ```
11
+ spine doctor
12
+ spine status
13
+ ```
14
+
15
+ Craft router (optional): `npx skills add mattpocock/skills -s ask-matt -s implement -y`.
16
+
17
+ ## Gate
18
+ Respect HITL: do not `set-status` to `reviewing` verdicts or mint work items without a human if the contract marks them HITL.
@@ -0,0 +1,23 @@
1
+ ---
2
+ name: review
3
+ description: Review a software work item. Use when status is reviewing.
4
+ ---
5
+
6
+ # review
7
+
8
+ HITL verdict. Proof under `.spine/reviews/`. Pass:
9
+
10
+ ```
11
+ spine set-status docs/spine/work-items/NN-slug.md done
12
+ ```
13
+
14
+ Bounce:
15
+
16
+ ```
17
+ spine set-status docs/spine/work-items/NN-slug.md changes-requested
18
+ ```
19
+
20
+ Craft: `npx skills add mattpocock/skills -s code-review -y`.
21
+
22
+ ## Gate
23
+ reviewing (HITL). Software profile requires this before done.
@@ -0,0 +1,19 @@
1
+ ---
2
+ name: wayfind
3
+ description: Plan with a spine map and tickets. Use when the way to a destination is foggy or multi-session.
4
+ ---
5
+
6
+ # wayfind
7
+
8
+ Planning lives on the map, not on the work item. Load `docs/spine/maps/`, work frontier tickets under `docs/spine/tickets/`. Claim a ticket with the CLI before work.
9
+
10
+ ```
11
+ spine status
12
+ spine new ticket --title "..." --type research
13
+ spine claim tickets/NN-slug.md
14
+ ```
15
+
16
+ Do not vendor Wayfinder. Install craft from skills.sh: `npx skills add mattpocock/skills -s wayfinder -s grilling -s domain-modeling -y`.
17
+
18
+ ## Gate
19
+ No work-item status for planning. Create a work item at `ready` only when the way is clear.
@@ -0,0 +1,31 @@
1
+ version: 1
2
+ statuses:
3
+ - ready
4
+ - doing
5
+ - checking
6
+ - reviewing
7
+ - done
8
+ - changes-requested
9
+ transitions:
10
+ ready: [doing]
11
+ doing: [checking]
12
+ checking: [reviewing]
13
+ reviewing: [done, changes-requested]
14
+ changes-requested: [doing]
15
+ done: []
16
+ hitl:
17
+ - creating-work-item
18
+ - reviewing
19
+ agent_drivable:
20
+ - doing
21
+ - checking
22
+ profiles:
23
+ software:
24
+ required_before_done:
25
+ - checking
26
+ - reviewing
27
+ default:
28
+ allow_doing_to_done: true
29
+ claim:
30
+ stale_hours: 4
31
+ owner_env: SPINE_USER
spine/data/wires.yaml ADDED
@@ -0,0 +1,26 @@
1
+ # Binder concern → skills.sh craft skills. Not vendored; installed via `npx skills`.
2
+ source: skills.sh
3
+ default_pack: mattpocock/skills
4
+ concerns:
5
+ wayfind:
6
+ pack: mattpocock/skills
7
+ skills: [wayfinder, grilling, domain-modeling]
8
+ new:
9
+ pack: mattpocock/skills
10
+ skills: [grill-me, wayfinder]
11
+ next:
12
+ pack: mattpocock/skills
13
+ skills: [ask-matt, implement]
14
+ doctor:
15
+ pack: null
16
+ skills: []
17
+ cli: [spine doctor]
18
+ build:
19
+ pack: mattpocock/skills
20
+ skills: [implement, tdd, codebase-design]
21
+ eval:
22
+ pack: mattpocock/skills
23
+ skills: [qa]
24
+ review:
25
+ pack: mattpocock/skills
26
+ skills: [code-review]
spine/doctor.py ADDED
@@ -0,0 +1,72 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ from spine.artifacts import is_stale, read_meta, release, write_meta
6
+ from spine.model import EXEC_ROOT, SPEC_DIRS, SPEC_ROOT, load_contract, packaged_contract
7
+
8
+
9
+ def doctor(root: Path, *, apply: bool = True) -> list[str]:
10
+ msgs: list[str] = []
11
+ contract = load_contract(root)
12
+ packaged = packaged_contract()
13
+ if contract.version != packaged.version:
14
+ msgs.append(
15
+ f"REPORT contract version {contract.version} in target vs packaged {packaged.version}"
16
+ )
17
+
18
+ for name in SPEC_DIRS:
19
+ p = root / SPEC_ROOT / name
20
+ if not p.is_dir():
21
+ msgs.append(f"REPORT missing spec dir {p.relative_to(root)}")
22
+
23
+ for rel in (
24
+ SPEC_ROOT / "README.md",
25
+ SPEC_ROOT / "contract.yaml",
26
+ SPEC_ROOT / "wires.yaml",
27
+ SPEC_ROOT / "maps" / "map.md",
28
+ ):
29
+ if not (root / rel).exists():
30
+ msgs.append(f"REPORT missing spec file {rel}")
31
+
32
+ gi = root / ".gitignore"
33
+ if not gi.exists() or ".spine/" not in gi.read_text(encoding="utf-8"):
34
+ msgs.append("REPORT gitignore missing .spine/")
35
+
36
+ folders = []
37
+ for name in ("work-items", "tickets"):
38
+ d = root / SPEC_ROOT / name
39
+ if d.is_dir():
40
+ folders.append(d)
41
+
42
+ for folder in folders:
43
+ for path in folder.glob("*.md"):
44
+ meta, body = read_meta(path)
45
+ status = meta.get("Status", "")
46
+ if status and status not in contract.statuses and status not in {"open", "claimed", "resolved"}:
47
+ msgs.append(f"REPORT unknown status {status} in {path.name}")
48
+ if is_stale(meta, root=root):
49
+ msgs.append(f"STALE claim on {path.name}")
50
+ if apply:
51
+ release(root, str(path.relative_to(root)))
52
+ msgs.append(f"FIX released stale claim {path.name}")
53
+ links = [x.strip() for x in (meta.get("Links") or "").split(",") if x.strip()]
54
+ kept = []
55
+ changed = False
56
+ for rel in links:
57
+ if (root / rel).exists():
58
+ kept.append(rel)
59
+ else:
60
+ msgs.append(f"BROKEN link {path.name} → {rel}")
61
+ changed = True
62
+ if changed and apply:
63
+ meta["Links"] = ", ".join(kept)
64
+ write_meta(path, meta, body)
65
+ msgs.append(f"FIX links on {path.name}")
66
+
67
+ out = root / EXEC_ROOT / "doctor" / "last.txt"
68
+ out.parent.mkdir(parents=True, exist_ok=True)
69
+ out.write_text("\n".join(msgs) + ("\n" if msgs else "ok\n"), encoding="utf-8")
70
+ if not msgs:
71
+ msgs.append("ok")
72
+ return msgs
spine/evolve.py ADDED
@@ -0,0 +1,92 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ import subprocess
5
+ from pathlib import Path
6
+
7
+ import yaml
8
+
9
+ from spine.initcmd import init_target
10
+ from spine.resources import read_data
11
+
12
+
13
+ def _npx() -> str | None:
14
+ return shutil.which("npx")
15
+
16
+
17
+ def load_wires(root: Path) -> dict:
18
+ path = root / "docs/spine/wires.yaml"
19
+ text = path.read_text(encoding="utf-8") if path.exists() else read_data("wires.yaml")
20
+ return yaml.safe_load(text) or {}
21
+
22
+
23
+ def install_cmd(pack: str, skills: list[str], npx: str = "npx") -> list[str]:
24
+ cmd = [npx, "--yes", "skills", "add", pack, "-y"]
25
+ seen: set[str] = set()
26
+ for name in skills:
27
+ if name and name not in seen:
28
+ seen.add(name)
29
+ cmd.extend(["-s", name])
30
+ return cmd
31
+
32
+
33
+ def skills_for_pack(wires: dict, pack: str) -> list[str]:
34
+ names: list[str] = []
35
+ for spec in (wires.get("concerns") or {}).values():
36
+ if not isinstance(spec, dict):
37
+ continue
38
+ if spec.get("pack") != pack:
39
+ continue
40
+ names.extend(spec.get("skills") or [])
41
+ return names
42
+
43
+
44
+ def install_wires(root: Path, *, extra_pack: str | None = None) -> list[str]:
45
+ notes: list[str] = []
46
+ npx = _npx()
47
+ wires = load_wires(root)
48
+ pack = extra_pack or wires.get("default_pack") or "mattpocock/skills"
49
+ skills = skills_for_pack(wires, pack)
50
+ if not npx:
51
+ notes.append(
52
+ "npx not found; skip craft install. Binder skills are already copied. "
53
+ f"Run: {' '.join(install_cmd(pack, skills))}"
54
+ )
55
+ return notes
56
+ cmd = install_cmd(pack, skills, npx)
57
+ proc = subprocess.run(cmd, cwd=root, capture_output=True, text=True)
58
+ notes.append(f"$ {' '.join(cmd)} exit={proc.returncode}")
59
+ if proc.stdout:
60
+ notes.append(proc.stdout[-2000:])
61
+ if proc.returncode != 0 and proc.stderr:
62
+ notes.append(proc.stderr[-1000:])
63
+ return notes
64
+
65
+
66
+ def evolve(root: Path) -> list[str]:
67
+ notes = init_target(root, refresh=True)
68
+ npx = _npx()
69
+ if npx:
70
+ proc = subprocess.run([npx, "--yes", "skills", "update"], cwd=root, capture_output=True, text=True)
71
+ notes.append(f"skills update exit={proc.returncode}")
72
+ if proc.stdout:
73
+ notes.append(proc.stdout[-1500:])
74
+ else:
75
+ notes.append("npx not found; refreshed binder from wheel only")
76
+ return notes
77
+
78
+
79
+ def set_pack(root: Path, pack: str) -> None:
80
+ path = root / "docs/spine/wires.yaml"
81
+ text = path.read_text(encoding="utf-8") if path.exists() else read_data("wires.yaml")
82
+ lines = []
83
+ replaced = False
84
+ for line in text.splitlines():
85
+ if line.startswith("default_pack:"):
86
+ lines.append(f"default_pack: {pack}")
87
+ replaced = True
88
+ else:
89
+ lines.append(line)
90
+ if not replaced:
91
+ lines.insert(0, f"default_pack: {pack}")
92
+ path.write_text("\n".join(lines) + "\n", encoding="utf-8")
spine/initcmd.py ADDED
@@ -0,0 +1,111 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ from pathlib import Path
5
+
6
+ from spine.model import EXEC_DIRS, EXEC_ROOT, HARNESS_SKILL_ROOTS, SPEC_DIRS, SPEC_ROOT
7
+ from spine.resources import data_root, read_data
8
+
9
+ README = """# spine artifacts
10
+
11
+ Checked-in **spec tier** (this tree):
12
+
13
+ - `maps/` — planning maps (index only)
14
+ - `tickets/` — map tickets (`NN-slug.md`)
15
+ - `work-items/` — execution work items
16
+ - `decisions/` — durable decisions
17
+ - `wires.yaml` — binder concern → skills.sh craft skills
18
+
19
+ Gitignored **execution tier** lives in `.spine/` (`evals`, `reviews`, `handoffs`, `doctor`, `claims`).
20
+
21
+ Install craft skills with the open skills CLI (skills.sh), not by copying bodies into this repo:
22
+
23
+ ```
24
+ npx skills add mattpocock/skills -y
25
+ ```
26
+
27
+ Or `spine wire --install` to install the default wires.
28
+ """
29
+
30
+ GITIGNORE_BLOCK = """
31
+ # spine execution tier
32
+ .spine/
33
+ """
34
+
35
+ MAP = """# Map
36
+
37
+ ## Destination
38
+
39
+ ## Notes
40
+
41
+ ## Decisions so far
42
+
43
+ ## Not yet specified
44
+
45
+ ## Out of scope
46
+ """
47
+
48
+
49
+ def _copy_tree(src: Path, dest: Path) -> None:
50
+ dest.mkdir(parents=True, exist_ok=True)
51
+ for item in src.iterdir():
52
+ target = dest / item.name
53
+ if item.is_dir():
54
+ _copy_tree(item, target)
55
+ else:
56
+ shutil.copy2(item, target)
57
+
58
+
59
+ def init_target(root: Path, *, refresh: bool = False) -> list[str]:
60
+ notes: list[str] = []
61
+ for name in SPEC_DIRS:
62
+ p = root / SPEC_ROOT / name
63
+ p.mkdir(parents=True, exist_ok=True)
64
+ notes.append(f"ensure {p.relative_to(root)}")
65
+ for name in EXEC_DIRS:
66
+ p = root / EXEC_ROOT / name
67
+ p.mkdir(parents=True, exist_ok=True)
68
+ notes.append(f"ensure {p.relative_to(root)}")
69
+
70
+ readme = root / SPEC_ROOT / "README.md"
71
+ if refresh or not readme.exists():
72
+ readme.write_text(README, encoding="utf-8")
73
+ notes.append("wrote docs/spine/README.md")
74
+
75
+ wires = root / SPEC_ROOT / "wires.yaml"
76
+ if refresh or not wires.exists():
77
+ wires.write_text(read_data("wires.yaml"), encoding="utf-8")
78
+ notes.append("wrote docs/spine/wires.yaml")
79
+
80
+ contract = root / SPEC_ROOT / "contract.yaml"
81
+ if refresh or not contract.exists():
82
+ contract.write_text(read_data("contract.yaml"), encoding="utf-8")
83
+ notes.append("wrote docs/spine/contract.yaml")
84
+
85
+ mapp = root / SPEC_ROOT / "maps" / "map.md"
86
+ if not mapp.exists():
87
+ mapp.write_text(MAP, encoding="utf-8")
88
+ notes.append("wrote docs/spine/maps/map.md")
89
+
90
+ gi = root / ".gitignore"
91
+ existing = gi.read_text(encoding="utf-8") if gi.exists() else ""
92
+ if ".spine/" not in existing:
93
+ gi.write_text(
94
+ existing.rstrip() + "\n" + GITIGNORE_BLOCK if existing else GITIGNORE_BLOCK.lstrip("\n"),
95
+ encoding="utf-8",
96
+ )
97
+ notes.append("updated .gitignore")
98
+
99
+ binder_src = data_root() / "binder"
100
+ for harness in HARNESS_SKILL_ROOTS:
101
+ dest = root / harness
102
+ dest.mkdir(parents=True, exist_ok=True)
103
+ if refresh or not any(dest.glob("*/SKILL.md")):
104
+ _copy_tree(binder_src, dest)
105
+ notes.append(f"copied binder → {harness}")
106
+ else:
107
+ for skill_dir in binder_src.iterdir():
108
+ target = dest / skill_dir.name
109
+ _copy_tree(skill_dir, target)
110
+ notes.append(f"refreshed binder → {harness}")
111
+ return notes
spine/model.py ADDED
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ from dataclasses import dataclass
4
+ from pathlib import Path
5
+
6
+ import yaml
7
+
8
+ from spine.resources import read_data
9
+
10
+ SPEC_ROOT = Path("docs/spine")
11
+ EXEC_ROOT = Path(".spine")
12
+ SPEC_DIRS = ("maps", "tickets", "work-items", "decisions")
13
+ EXEC_DIRS = ("evals", "reviews", "handoffs", "doctor", "claims")
14
+ HARNESS_SKILL_ROOTS = (
15
+ Path(".agents/skills"),
16
+ Path(".claude/skills"),
17
+ Path(".cursor/skills"),
18
+ )
19
+ TICKET_STATUSES = {"open", "claimed", "resolved"}
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class Contract:
24
+ raw: dict
25
+ source: str = "packaged"
26
+
27
+ @property
28
+ def version(self) -> int:
29
+ return int(self.raw["version"])
30
+
31
+ @property
32
+ def statuses(self) -> list[str]:
33
+ return list(self.raw["statuses"])
34
+
35
+ @property
36
+ def transitions(self) -> dict[str, list[str]]:
37
+ return {k: list(v) for k, v in self.raw["transitions"].items()}
38
+
39
+ @property
40
+ def stale_hours(self) -> int:
41
+ return int(self.raw["claim"]["stale_hours"])
42
+
43
+ def allowed(self, current: str, nxt: str) -> bool:
44
+ return nxt in self.transitions.get(current, [])
45
+
46
+ def software_required(self) -> list[str]:
47
+ return list(self.raw["profiles"]["software"]["required_before_done"])
48
+
49
+ def next_gates(self, current: str, *, software: bool = True) -> list[str]:
50
+ nxt = list(self.transitions.get(current, []))
51
+ if current == "doing" and not software:
52
+ if "done" not in nxt:
53
+ nxt.append("done")
54
+ return nxt
55
+
56
+
57
+ def packaged_contract() -> Contract:
58
+ return Contract(yaml.safe_load(read_data("contract.yaml")), source="packaged")
59
+
60
+
61
+ def load_contract(root: Path | None = None) -> Contract:
62
+ if root is not None:
63
+ path = root / SPEC_ROOT / "contract.yaml"
64
+ if path.exists():
65
+ return Contract(yaml.safe_load(path.read_text(encoding="utf-8")), source=str(path))
66
+ return packaged_contract()
67
+
68
+
69
+ def parse_front(text: str) -> tuple[dict[str, str], str]:
70
+ """Parse simple `Key: value` header until blank line."""
71
+ lines = text.splitlines()
72
+ meta: dict[str, str] = {}
73
+ i = 0
74
+ if lines and lines[0].startswith("# "):
75
+ meta["Title"] = lines[0][2:].strip()
76
+ i = 1
77
+ if i < len(lines) and lines[i] == "":
78
+ i += 1
79
+ while i < len(lines) and lines[i] != "" and ":" in lines[i] and not lines[i].startswith("#"):
80
+ k, _, v = lines[i].partition(":")
81
+ meta[k.strip()] = v.strip()
82
+ i += 1
83
+ body = "\n".join(lines[i:]).lstrip("\n")
84
+ return meta, body
85
+
86
+
87
+ def dump_front(meta: dict[str, str], body: str, title: str | None = None) -> str:
88
+ title = title or meta.get("Title", "untitled")
89
+ keys = [k for k in meta if k != "Title"]
90
+ header = [f"# {title}", ""]
91
+ for k in keys:
92
+ header.append(f"{k}: {meta[k]}")
93
+ return "\n".join(header) + "\n\n" + body.lstrip("\n")
spine/resources.py ADDED
@@ -0,0 +1,14 @@
1
+ """Load packaged data files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from importlib.resources import files
6
+ from pathlib import Path
7
+
8
+
9
+ def data_root() -> Path:
10
+ return Path(str(files("spine") / "data"))
11
+
12
+
13
+ def read_data(*parts: str) -> str:
14
+ return (data_root().joinpath(*parts)).read_text(encoding="utf-8")
@@ -0,0 +1,161 @@
1
+ Metadata-Version: 2.5
2
+ Name: spine-cli
3
+ Version: 0.1.0
4
+ Summary: Harness-agnostic process spine CLI
5
+ Author: ASabale
6
+ License: MIT
7
+ License-File: LICENSE
8
+ Requires-Python: >=3.11
9
+ Requires-Dist: pyyaml>=6
10
+ Provides-Extra: dev
11
+ Requires-Dist: pytest>=8; extra == 'dev'
12
+ Description-Content-Type: text/markdown
13
+
14
+ # spine — v1 spec
15
+
16
+ Public, harness-agnostic process spine. Planning and execution survive sessions as files in a **target**. This spec plus [CONTEXT.md](CONTEXT.md) is enough to implement the toolkit. It does not implement it.
17
+
18
+ License: **MIT**. Product, GitHub slug, docs, and CLI command: **spine**. PyPI distribution: **spine-cli**. Intended GitHub home: https://github.com/ASabale/spine (not created by this spec). Do not publish as Generic Workflows.
19
+
20
+ ## Job
21
+
22
+ Durable process across sessions: a **map** plus **tickets** for planning; a **work item** for execution (status, owner, claim, links, next gate). Repo files in the target are the source of truth. No harness API is required for correctness. Optional adapters later; never required.
23
+
24
+ ## Stranger install
25
+
26
+ Python **3.11+**. Console script `spine` via `[project.scripts]`. Wheel ships the **contract** and binder `SKILL.md` as package data (`importlib.resources`). Init copies skills onto harness discovery roots; a wheel copy is invisible until then.
27
+
28
+ ```text
29
+ uvx --from spine-cli spine init
30
+ ```
31
+
32
+ or `uv tool install spine-cli` then `spine init`. Idempotent: missing artifacts only; refresh offered. Then `spine doctor` once. No plugin marketplace. No clone of the spine source repo. GitHub home is documentation, not an install dependency.
33
+
34
+ ## Two-tier paths
35
+
36
+ **Spec tier** (checked in):
37
+
38
+ ```text
39
+ docs/spine/README.md
40
+ docs/spine/maps/
41
+ docs/spine/tickets/
42
+ docs/spine/work-items/
43
+ docs/spine/decisions/
44
+ ```
45
+
46
+ **Execution tier** (gitignored; init writes `.gitignore` for `.spine/`):
47
+
48
+ ```text
49
+ .spine/evals/
50
+ .spine/reviews/
51
+ .spine/handoffs/
52
+ .spine/doctor/
53
+ .spine/claims/
54
+ ```
55
+
56
+ Do not use `docs/workflow/` or `.workflow/`. Folder names match the glossary. `docs/spine/README.md` names the tree for a first-time agent.
57
+
58
+ ## Planning (Wayfinder as files)
59
+
60
+ No GitHub Issues required. One map file per map. Sections: Destination, Notes, Decisions so far, Not yet specified (fog), Out of scope. The map is an **index**: gist plus link; it does not restate a ticket’s answer.
61
+
62
+ Child tickets: `NN-<slug>.md`, title is the name. Fields: `Type:` (research, prototype, grilling, task), `Status:` (open, claimed, resolved), `Blocked by: NN`. Answers under `## Answer`. Research write-ups are separate files, linked, not pasted. Frontier: open, unblocked, unclaimed tickets. Claim on tickets is a field the **CLI** owns.
63
+
64
+ Planning never lives on the work item. Chart the map, cut tickets, work the frontier. When the way is clear, HITL **creates** a work item at `ready`.
65
+
66
+ ## Work-item machine (contract)
67
+
68
+ Happy path: `ready → doing → checking → reviewing → done`.
69
+
70
+ Bounce: `reviewing → changes-requested → doing`.
71
+
72
+ Legal transitions:
73
+
74
+ - `ready → doing`
75
+ - `doing → checking`
76
+ - `checking → reviewing`
77
+ - `reviewing → done`
78
+ - `reviewing → changes-requested`
79
+ - `changes-requested → doing`
80
+
81
+ HITL: creating a work item; `reviewing`. Agent-drivable: `doing`, `checking`. No `grilling` / `prd` / `planning` status on a work item. Next session reads the current status as the next gate.
82
+
83
+ Skills and the CLI **reference** this contract; they never restate it.
84
+
85
+ ## Software profile
86
+
87
+ Same contract. Software work items must pass `checking` (eval against acceptance) and `reviewing` (human or reviewer-agent verdict) before `done`. Proof artifacts live in `.spine/{evals,reviews}/`. Non-software items may `doing → done` under human judgment with a named deliverable existing. No PRD/blueprint stages on the work item.
88
+
89
+ ## CLI
90
+
91
+ Humans and agents use the same CLI. It is the only writer of machine-driving metadata (status, owner, claim, links, numbers).
92
+
93
+ Verbs: `init`, `status`, `new`, `claim`, `release`, `set-status`, `link`, `doctor`.
94
+
95
+ - `init` — scaffold a target.
96
+ - `status` — board (work items + next gate).
97
+ - `new` — mint a work item and/or a map ticket (flags, not a second verb).
98
+ - `claim` / `release` — solo-first lock.
99
+ - `set-status` — only legal transitions.
100
+ - `link` — CLI-owned bidirectional links.
101
+ - `doctor` — mechanical drift.
102
+
103
+ No harness-specific subcommands. No plugin install.
104
+
105
+ ## Claim policy
106
+
107
+ `Owner` + `Claimed-at` owned by the CLI. Stale after **4 hours**. `spine claim` fails if a non-stale claim exists. `spine release` and stale doctor-fix clear it. No auth system. Username is `user.name` or `SPINE_USER`.
108
+
109
+ ## Doctor
110
+
111
+ CLI command, not a harness hook.
112
+
113
+ **Auto-fix:** stale claims past the policy window; broken bidirectional links the CLI owns; rollups the CLI owns.
114
+
115
+ **Report-only:** unknown statuses after a contract bump; missing spec-tier files; gitignore drift.
116
+
117
+ Never auto-edit content sections. Harness session-start is adapter fog, not required v1.
118
+
119
+ ## Binder
120
+
121
+ v1 concerns (thin `SKILL.md` each): `new` (capture), `wayfind`, `next` (frontier + work-item board), `doctor`, plus software pointers `build`, `eval`, `review`. Do not vendor grilling, TDD, or Wayfinder bodies. Drop graph, tour, and a `dev-*` palette.
122
+
123
+ Shape: YAML frontmatter + short body — when to use, CLI invocations by pointer, `## Gate` names that exist in the contract (not copied statuses). One skill text. Init writes copies onto harness roots (at least `.agents/skills` for Cursor/Codex/oh-my-pi; `.claude/skills` for Claude Code). No Devin `hooks.v1.json`.
124
+
125
+ ## Out of scope (v1)
126
+
127
+ - Harness plugins or hooks as required.
128
+ - Code graph (CRG / Graphify).
129
+ - Shipping a full craft-skill catalog.
130
+ - Importing a 16-status idea→done machine.
131
+ - Creating the GitHub repository as part of implementing this spec (home is named; create is a later act).
132
+
133
+ ## Deferred (not decided here)
134
+
135
+ - Contract versioning and artifact migration.
136
+ - Optional per-harness adapters.
137
+ - A docs-only type profile besides software.
138
+ - How already-wayfinding folders relate to a spine-inited target.
139
+ - Whether init scaffolds CONTEXT.md / ADR conventions.
140
+ - Work-item templates (PRD, blueprint).
141
+ - User-level vs project-level install besides `uvx` + init.
142
+
143
+ ## Auto-evolve, recover, and skill wires (v1 addendum)
144
+
145
+ Operator instruction 2026-09-18: the toolkit is auto-evolvable, self-recoverable, harness/agent/platform-agnostic. Craft skills are **not** vendored. Discover them on **skills.sh**. Install and update via the open skills CLI (`npx skills …`). Default recommended pack: **mattpocock/skills**. Users may wire any pack.
146
+
147
+ **Wires file** (spec tier): `docs/spine/wires.yaml` maps binder concerns to skills.sh slugs (`owner/repo` + skill name). Init writes a default mapping to Matt Pocock skills; `spine wire` edits it; `spine evolve` refreshes binder copies from the wheel and runs `npx skills update` when the CLI is available.
148
+
149
+ Default wires (concern → skills.sh skills, not vendored bodies):
150
+
151
+ - `wayfind` → `mattpocock/skills` `wayfinder`, `grilling`, `domain-modeling`
152
+ - `new` → `mattpocock/skills` `grill-me` (and map capture via `wayfinder`)
153
+ - `next` → `mattpocock/skills` `ask-matt`, `implement`
154
+ - `doctor` → binder `doctor` plus `spine doctor`
155
+ - `build` → `mattpocock/skills` `implement`, `tdd`, `codebase-design`
156
+ - `eval` → `mattpocock/skills` `qa`
157
+ - `review` → `mattpocock/skills` `code-review`
158
+
159
+ **Self-recover:** `spine doctor` is the recovery loop. `spine evolve` is the refresh loop. Init stays idempotent.
160
+
161
+ **Extra CLI verbs (addendum):** `wire`, `evolve`. Same CLI owns metadata; `npx skills` owns craft-skill files on harness roots.
@@ -0,0 +1,22 @@
1
+ spine/__init__.py,sha256=FiHwvf8cW_Pex07RxXH14uu26BBuV7-Z0_rOoiY7tN4,72
2
+ spine/artifacts.py,sha256=nhvP7DqE8RzAlRnwf4zKYsMhjtU14ViVHvLB6twaVQw,8615
3
+ spine/cli.py,sha256=BQIAq5IPq8GzEWgs_czHbE63rOfhLpAmgeQufK9dtZk,3968
4
+ spine/doctor.py,sha256=Cb0lTkWyvuLIvT90ltCYaFfgCdZLHyw70d7faUnapGE,2703
5
+ spine/evolve.py,sha256=twimtefe9rTOGSHHeTb3WHqTwTIWULgRtPD0YvCG4tc,2899
6
+ spine/initcmd.py,sha256=YgPzGw0sIBshKmbSxveRomR2AyOydrZdANgstXy5w_Q,3349
7
+ spine/model.py,sha256=IOPg-fIIHw10Jeci-xb_Z-VR4K8cf1DRv1vowhmP2zg,2857
8
+ spine/resources.py,sha256=ATXyoG15Spgdx4R3BaMEpr1dG_FDKffNkXs5nREZ1nw,312
9
+ spine/data/contract.yaml,sha256=Qrfa6y_HzoR4Fqh8AzUslX4GOydXIs5bc3JuULqM1Yw,506
10
+ spine/data/wires.yaml,sha256=uBEFlhQ2E_zQZol8MwdX1GuSmG907OGt5FYU17SXozQ,640
11
+ spine/data/binder/build/SKILL.md,sha256=0ea7XX8AXbKHDe17OiRv8_cb1xuZ3UPFad3MnZixYdg,425
12
+ spine/data/binder/doctor/SKILL.md,sha256=hUADmmwei1LNdOD_ynNvdXY0-kYtoB1v7lEEqFHuzH0,398
13
+ spine/data/binder/eval/SKILL.md,sha256=6WV1i4KZFX4XrbLWHp4p1zxYHCC74iiE7DdBqaX5Wl8,360
14
+ spine/data/binder/new/SKILL.md,sha256=DEv2mRQkCKwdM1RbjNvOc2HfVRyMwOHrLy2llnFtdf0,632
15
+ spine/data/binder/next/SKILL.md,sha256=cvqpIJnTc1cu6YkbzlTOL6uUM4pj5i4hKqFdFn_-TjI,507
16
+ spine/data/binder/review/SKILL.md,sha256=3UJQ7UmR339sbk6VNN5-oKUWshjwrn6L46ETlKlQb90,438
17
+ spine/data/binder/wayfind/SKILL.md,sha256=DDLh7f_gQFsKPhfhhGhZcshoPJXAso1QPdSZjN2tHqw,649
18
+ spine_cli-0.1.0.dist-info/METADATA,sha256=ZV6w3oDmFJXzfOQCkBZgSAort8KG3_UZj7dFKJ2_C_k,7621
19
+ spine_cli-0.1.0.dist-info/WHEEL,sha256=THafob7ofN-NsuMN7Mg4qZyHaQI7KkD-QlcQatYhXPo,87
20
+ spine_cli-0.1.0.dist-info/entry_points.txt,sha256=es1BIsv-j7vuYVLoXKCkqIjpjsp0eybIuGSjVpSJC8k,41
21
+ spine_cli-0.1.0.dist-info/licenses/LICENSE,sha256=7jKasL10pUuPMwky6QZ7MSba7qPk3KE-1eIg4Kp3S0s,1062
22
+ spine_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.3
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ spine = spine.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ASabale
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTY OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.