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 +3 -0
- spine/artifacts.py +257 -0
- spine/cli.py +108 -0
- spine/data/binder/build/SKILL.md +16 -0
- spine/data/binder/doctor/SKILL.md +16 -0
- spine/data/binder/eval/SKILL.md +17 -0
- spine/data/binder/new/SKILL.md +18 -0
- spine/data/binder/next/SKILL.md +18 -0
- spine/data/binder/review/SKILL.md +23 -0
- spine/data/binder/wayfind/SKILL.md +19 -0
- spine/data/contract.yaml +31 -0
- spine/data/wires.yaml +26 -0
- spine/doctor.py +72 -0
- spine/evolve.py +92 -0
- spine/initcmd.py +111 -0
- spine/model.py +93 -0
- spine/resources.py +14 -0
- spine_cli-0.1.0.dist-info/METADATA +161 -0
- spine_cli-0.1.0.dist-info/RECORD +22 -0
- spine_cli-0.1.0.dist-info/WHEEL +4 -0
- spine_cli-0.1.0.dist-info/entry_points.txt +2 -0
- spine_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
spine/__init__.py
ADDED
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.
|
spine/data/contract.yaml
ADDED
|
@@ -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,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.
|