craftkit 0.3.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.
- craft_kit/SKILL.md +145 -0
- craft_kit/__init__.py +3 -0
- craft_kit/__main__.py +5 -0
- craft_kit/agent.py +165 -0
- craft_kit/banner.py +86 -0
- craft_kit/brief.py +101 -0
- craft_kit/browser.py +198 -0
- craft_kit/checks.py +219 -0
- craft_kit/cli.py +394 -0
- craft_kit/home.py +370 -0
- craft_kit/png.py +135 -0
- craft_kit/proc.py +161 -0
- craft_kit/study.py +170 -0
- craft_kit/tables.py +154 -0
- craft_kit/ui.py +284 -0
- craft_kit/workspace.py +320 -0
- craftkit-0.3.0.dist-info/METADATA +174 -0
- craftkit-0.3.0.dist-info/RECORD +23 -0
- craftkit-0.3.0.dist-info/WHEEL +5 -0
- craftkit-0.3.0.dist-info/entry_points.txt +5 -0
- craftkit-0.3.0.dist-info/licenses/LICENSE-APACHE +177 -0
- craftkit-0.3.0.dist-info/licenses/LICENSE-MIT +21 -0
- craftkit-0.3.0.dist-info/top_level.txt +1 -0
craft_kit/SKILL.md
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: craft
|
|
3
|
+
description: Recreate or build any software — a website, CLI, desktop app, game, library or service — by studying an original (or a plain request), building a new project, checking it against the original with measurable checks, and refining until it passes. Use when asked to "recreate", "clone", "rebuild", "make my own version of", or "build something like" an existing site, program or tool, or to build a new tool from a description with verification. Works with or without the `craft` CLI.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Craft: study → build → check → refine
|
|
7
|
+
|
|
8
|
+
A method for rebuilding any piece of software from evidence, then proving the result is close.
|
|
9
|
+
It works the same for a website, a command-line tool, a desktop app, a game or a library. Follow
|
|
10
|
+
the five stages in order; never skip Check.
|
|
11
|
+
|
|
12
|
+
If the `craft` command is installed (`craft --version`), it does the bookkeeping for you: use
|
|
13
|
+
`craft new`, `craft study`, `craft check --self` and `craft status` at the matching stages. Without
|
|
14
|
+
it, do each stage by hand as written below. The folder layout and contracts are the same either way.
|
|
15
|
+
|
|
16
|
+
## 0. Intake (one minute, before anything else)
|
|
17
|
+
|
|
18
|
+
Settle five things. Ask the person only for what you can't infer, in one message, with 2-4 choices
|
|
19
|
+
per question.
|
|
20
|
+
|
|
21
|
+
| Field | Values | Default |
|
|
22
|
+
|---------|--------|---------|
|
|
23
|
+
| Request | what they want, in their words | (required) |
|
|
24
|
+
| Target | a URL, a file or folder, a command on PATH, or none | none: build from the request |
|
|
25
|
+
| Kind | `website`, `cli`, `app`, `game`, `tool` (library/API/service) | from the request's words |
|
|
26
|
+
| Stack | whatever the request names | the simplest stack that fits; for a website, whatever the original visibly uses |
|
|
27
|
+
| Mode | **Clean room** or **Deep** | Clean room |
|
|
28
|
+
|
|
29
|
+
- **Clean room**: use only what a visitor or user sees: screenshots, visible text and measured
|
|
30
|
+
layout of a site; what a program prints when run (only with the person's OK to run it); pictures
|
|
31
|
+
the person provides. Never open, read, fetch or decompile the original's files, page source or
|
|
32
|
+
program.
|
|
33
|
+
- **Deep** (the person must say so, for private use): you may look inside: a site's HTML and
|
|
34
|
+
scripts, a folder's files, a program's strings and code. Rebuild what you learn in your own code;
|
|
35
|
+
never paste the original's code or assets into the build.
|
|
36
|
+
|
|
37
|
+
The request always wins over defaults: if it names a language, framework or engine, use exactly
|
|
38
|
+
that, and never switch away from it to get past a failing build.
|
|
39
|
+
|
|
40
|
+
## 1. Workspace
|
|
41
|
+
|
|
42
|
+
Create one project folder (`craft new` does this):
|
|
43
|
+
|
|
44
|
+
```text
|
|
45
|
+
<project>/
|
|
46
|
+
craft.json request, target, kind, stack, mode, round counters (versioned)
|
|
47
|
+
BRIEF.md the task as given to the builder
|
|
48
|
+
capture/ evidence from Study — README.md first. Read-only.
|
|
49
|
+
reference/ pictures/files from the person. Read-only.
|
|
50
|
+
app/ THE ONLY FOLDER THE BUILD CHANGES
|
|
51
|
+
checks/round-N/ each check: result.json, screenshots, logs
|
|
52
|
+
checks/turn-N/ each builder turn: message sent, log, reply
|
|
53
|
+
unknowns.md open questions about the original
|
|
54
|
+
questions.json questions for the person, with their answers
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Keeping evidence (`capture/`) apart from the build (`app/`) is what makes the checks honest.
|
|
58
|
+
|
|
59
|
+
## 2. Study — gather evidence into `capture/`
|
|
60
|
+
|
|
61
|
+
Overview first, details after. Write `capture/README.md` as the index of everything you found.
|
|
62
|
+
|
|
63
|
+
| Target | Clean room | Deep adds |
|
|
64
|
+
|--------|------------|-----------|
|
|
65
|
+
| Website | full-page screenshots at **1440, 768, 390 px** (`screenshot-<w>.png`); visible facts: title, headings, navigation, buttons, computed fonts/colours/sizes, section count, visible text | the served HTML (`page.html`), scripts, network requests |
|
|
66
|
+
| CLI program | with permission, run it with no args, `--help`, `--version`, `-h` (stdin closed, 10 s limit); record exit code + output in `runs.json` | its strings (`strings.txt`), file type |
|
|
67
|
+
| App / game | the person's pictures in `reference/`; a screenshot of its window if allowed | its files, manifests, config values, strings |
|
|
68
|
+
| Folder / repo | nothing inside is read | inventory by file type, manifests (package.json, Cargo.toml…), README |
|
|
69
|
+
| None | the request alone | — |
|
|
70
|
+
|
|
71
|
+
Record each question the evidence doesn't answer in `unknowns.md`:
|
|
72
|
+
|
|
73
|
+
```text
|
|
74
|
+
- [ ] Does the menu collapse on phones? — open it at 390 px — impact: high
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Tick it later with the answer and the evidence that settled it. Missing evidence is never proof:
|
|
78
|
+
mark what you estimate.
|
|
79
|
+
|
|
80
|
+
## 3. Build — only inside `app/`
|
|
81
|
+
|
|
82
|
+
1. **Plan**: 3-7 plain-language steps at the top of `app/PLAN.md`; tick them as you go.
|
|
83
|
+
2. **Build** in the chosen stack until the build command succeeds.
|
|
84
|
+
3. **Declare** how the build is built, tested and run in `app/craft-app.json` (commands are lists
|
|
85
|
+
run inside `app/`; leave out what doesn't apply):
|
|
86
|
+
|
|
87
|
+
```json
|
|
88
|
+
{"version": 1, "install": ["npm", "install"], "build": ["npm", "run", "build"],
|
|
89
|
+
"test": ["npm", "test"], "run": ["node", "dist/main.js"], "site": "dist"}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
4. **Meet the kind's contract** — this is what Check runs:
|
|
93
|
+
|
|
94
|
+
| Kind | Contract |
|
|
95
|
+
|------|----------|
|
|
96
|
+
| website | static files with `index.html` at the top of `site`; each page at its own path; no sideways scroll on a phone |
|
|
97
|
+
| cli | `run` prints the same as the original for each recorded input and exits with the same code |
|
|
98
|
+
| app | `run --screenshot <file.png>`: start, draw ~2 s, save the window as PNG, exit |
|
|
99
|
+
| game | `--screenshot` as above, plus `run --self-test <file.json>`: play the core loop with scripted input and write `{"version":1,"steps":[{"step":"move","passed":true,"detail":"…"},{"step":"act",…},{"step":"hud",…}]}` measured from the game's own state |
|
|
100
|
+
| tool | tests that prove each feature the request names |
|
|
101
|
+
|
|
102
|
+
Rules while building:
|
|
103
|
+
- Rebuild layout, style and behaviour from the evidence. No logos, photos, brand names or long
|
|
104
|
+
copied text unless the person owns them or has permission; use neutral placeholders.
|
|
105
|
+
- Ask the person only about choices that change the result (write `questions.json` with 2-4
|
|
106
|
+
choices, keep building what doesn't depend on it). Never ask about technical details.
|
|
107
|
+
- Build everything asked. If part of it won't fit, ask before dropping it; never drop it silently.
|
|
108
|
+
- Never start dev servers or watchers (`npm run dev`, `vite`, `cargo watch`): Check serves and runs
|
|
109
|
+
the build itself.
|
|
110
|
+
|
|
111
|
+
## 4. Check — measure, don't guess
|
|
112
|
+
|
|
113
|
+
Run the same checks every round (`craft check --self` while building; `craft check` records a
|
|
114
|
+
round). Stop at the first failing build: nothing else can be judged.
|
|
115
|
+
|
|
116
|
+
| Check | Applies to | Passes when |
|
|
117
|
+
|-------|-----------|-------------|
|
|
118
|
+
| build | all | install + build commands exit 0 |
|
|
119
|
+
| web | website | serve `site` on 127.0.0.1, screenshot at the same three widths, compare with `capture/` per width; similarity ≥ the threshold (default 90%) |
|
|
120
|
+
| runs | cli | every recorded input: same exit code, ≥ 80% of printed words in common |
|
|
121
|
+
| screenshot | app, game | a readable, non-blank PNG; compared with `reference/` pictures when present |
|
|
122
|
+
| selftest | game | every core-loop step (move, act, hud) passes |
|
|
123
|
+
| tests | all | the project's tests pass ("no tests" is noted, not a failure) |
|
|
124
|
+
|
|
125
|
+
A check that couldn't look (no browser, no recording) says so: it's "not judged", never "passed".
|
|
126
|
+
Save each round's result as `checks/round-N/result.json`.
|
|
127
|
+
|
|
128
|
+
## 5. Refine — then Ready
|
|
129
|
+
|
|
130
|
+
- Feed the failing checks back as the next task: biggest visible or functional differences first,
|
|
131
|
+
with the measured numbers. Rebuild, re-check.
|
|
132
|
+
- Stop when every check passes, **or** after **6 refinements per request** (the count survives
|
|
133
|
+
retries; only a new request resets it), **or** when two turns in a row change nothing in `app/`
|
|
134
|
+
(stuck: ask the person for detail instead of looping).
|
|
135
|
+
- Before finishing, re-read `PLAN.md` and `unknowns.md`; settle what one more look can settle.
|
|
136
|
+
|
|
137
|
+
**Ready** = a summary for the person in 3-5 plain sentences: what was built, what differs from
|
|
138
|
+
the original or is still unknown, and one idea for what to ask next. No code, no file paths. Then
|
|
139
|
+
how to open it (`craft open`) and how to ask for a change (`craft run "…"`).
|
|
140
|
+
|
|
141
|
+
## Honesty rules (always)
|
|
142
|
+
|
|
143
|
+
- Never claim something works that you didn't run. Report what you verified and what you didn't.
|
|
144
|
+
- Every external program you start gets a time limit and no interactive stdin.
|
|
145
|
+
- Keep secrets out of the project: no keys or tokens in `app/`, `capture/` or logs.
|
craft_kit/__init__.py
ADDED
craft_kit/__main__.py
ADDED
craft_kit/agent.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""One builder turn: hand a message to a coding-agent CLI on stdin, show what it does on one live
|
|
2
|
+
line, and return its final reply."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from typing import Optional
|
|
9
|
+
|
|
10
|
+
from . import proc, tables
|
|
11
|
+
from .ui import UI
|
|
12
|
+
from .workspace import CraftError, Project, write_atomic
|
|
13
|
+
|
|
14
|
+
TURN_TIMEOUT = float(os.environ.get("CRAFT_TURN_TIMEOUT", 3 * 3600))
|
|
15
|
+
IDLE_TIMEOUT = float(os.environ.get("CRAFT_IDLE_TIMEOUT", 20 * 60))
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def pick(name: Optional[str]) -> tuple[str, dict]:
|
|
19
|
+
table = tables.agents()
|
|
20
|
+
name = name or os.environ.get("CRAFT_AGENT")
|
|
21
|
+
if name:
|
|
22
|
+
if name == "none":
|
|
23
|
+
raise CraftError("No agent: the brief is in BRIEF.md.", "Paste BRIEF.md into any coding agent opened in this folder, then run `craft check`.")
|
|
24
|
+
if name not in table:
|
|
25
|
+
raise CraftError(f"Unknown agent “{name}”.", f"Known: {', '.join(table)}. Add one in {tables.config_dir() / 'agents.json'}.")
|
|
26
|
+
return name, table[name]
|
|
27
|
+
for n in tables.AGENT_ORDER:
|
|
28
|
+
if n in table and proc.which(table[n]["argv"][0]):
|
|
29
|
+
return n, table[n]
|
|
30
|
+
raise CraftError("No coding agent found (claude, codex or gemini).", "Install one, or use `craft brief` and paste it into the agent you use, then `craft check`.")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
TOOL_KINDS = {
|
|
34
|
+
"Read": "read", "NotebookRead": "read", "LS": "read",
|
|
35
|
+
"Edit": "edit", "MultiEdit": "edit", "NotebookEdit": "edit",
|
|
36
|
+
"Write": "write",
|
|
37
|
+
"Bash": "run", "BashOutput": "run", "KillShell": "run",
|
|
38
|
+
"Grep": "search", "Glob": "search",
|
|
39
|
+
"WebFetch": "web", "WebSearch": "web",
|
|
40
|
+
"TodoWrite": "plan", "Task": "agent", "Agent": "agent",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _Claude:
|
|
45
|
+
"""Reads Claude Code's stream-json: every action for the live feed, the final reply and session id."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, live, root: str = ""):
|
|
48
|
+
self.live = live
|
|
49
|
+
self.root = root.rstrip("/") + "/" if root else ""
|
|
50
|
+
self.reply = ""
|
|
51
|
+
self.session: Optional[str] = None
|
|
52
|
+
self.is_error = False
|
|
53
|
+
self._started = False
|
|
54
|
+
|
|
55
|
+
def __call__(self, line: str) -> None:
|
|
56
|
+
try:
|
|
57
|
+
ev = json.loads(line)
|
|
58
|
+
except ValueError:
|
|
59
|
+
return
|
|
60
|
+
if not isinstance(ev, dict):
|
|
61
|
+
return
|
|
62
|
+
if ev.get("session_id"):
|
|
63
|
+
self.session = ev["session_id"]
|
|
64
|
+
kind = ev.get("type")
|
|
65
|
+
if kind == "system" and ev.get("subtype") == "init" and not self._started:
|
|
66
|
+
self._started = True
|
|
67
|
+
model = ev.get("model")
|
|
68
|
+
self.live.event("agent", f"session started{f' · {model}' if isinstance(model, str) else ''}", count=False)
|
|
69
|
+
elif kind == "assistant":
|
|
70
|
+
for item in (ev.get("message") or {}).get("content") or []:
|
|
71
|
+
if not isinstance(item, dict):
|
|
72
|
+
continue
|
|
73
|
+
if item.get("type") == "tool_use":
|
|
74
|
+
name = str(item.get("name", ""))
|
|
75
|
+
self.live.event(_tool_kind(name), _describe_tool(name, item.get("input") or {}, self.root))
|
|
76
|
+
elif item.get("type") == "text" and isinstance(item.get("text"), str):
|
|
77
|
+
first = next((ln for ln in item["text"].strip().splitlines() if ln.strip()), "")
|
|
78
|
+
self.live.event("say", first)
|
|
79
|
+
elif kind == "user":
|
|
80
|
+
for item in (ev.get("message") or {}).get("content") or []:
|
|
81
|
+
if isinstance(item, dict) and item.get("type") == "tool_result" and item.get("is_error"):
|
|
82
|
+
self.live.event("fail", _first_line(item.get("content")) or "a tool call failed")
|
|
83
|
+
elif kind == "result":
|
|
84
|
+
self.reply = str(ev.get("result") or "")
|
|
85
|
+
self.is_error = bool(ev.get("is_error"))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _tool_kind(name: str) -> str:
|
|
89
|
+
return TOOL_KINDS.get(name, "tool")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _first_line(content) -> str:
|
|
93
|
+
if isinstance(content, list):
|
|
94
|
+
content = " ".join(c.get("text", "") for c in content if isinstance(c, dict) and isinstance(c.get("text"), str))
|
|
95
|
+
if not isinstance(content, str):
|
|
96
|
+
return ""
|
|
97
|
+
return next((ln.strip() for ln in content.splitlines() if ln.strip()), "")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _describe_tool(name: str, args: dict, root: str = "") -> str:
|
|
101
|
+
def rel(v: str) -> str:
|
|
102
|
+
return v[len(root):] if root and v.startswith(root) else v
|
|
103
|
+
|
|
104
|
+
if name == "TodoWrite" and isinstance(args.get("todos"), list):
|
|
105
|
+
todos = [t for t in args["todos"] if isinstance(t, dict)]
|
|
106
|
+
done = sum(1 for t in todos if t.get("status") == "completed")
|
|
107
|
+
active = [t.get("activeForm") or t.get("content") for t in todos if t.get("status") == "in_progress"]
|
|
108
|
+
now = active[0] if active and isinstance(active[0], str) else ("all done" if todos and done == len(todos) else "updating the plan")
|
|
109
|
+
return f"{done}/{len(todos)} · {now}" if todos else now
|
|
110
|
+
if name in ("Task", "Agent") and isinstance(args.get("description"), str):
|
|
111
|
+
return args["description"]
|
|
112
|
+
if name in ("Grep", "Glob") and isinstance(args.get("pattern"), str):
|
|
113
|
+
where = rel(args["path"]) if isinstance(args.get("path"), str) else ""
|
|
114
|
+
return args["pattern"] + (f" in {where}" if where else "")
|
|
115
|
+
if name == "Bash" and isinstance(args.get("command"), str):
|
|
116
|
+
return args["command"].strip().splitlines()[0] if args["command"].strip() else "(empty command)"
|
|
117
|
+
for key in ("file_path", "notebook_path", "path", "pattern", "url", "query", "command"):
|
|
118
|
+
if isinstance(args.get(key), str):
|
|
119
|
+
text = rel(args[key])
|
|
120
|
+
return text if name in TOOL_KINDS else f"{name.split('__')[-1]} {text}"
|
|
121
|
+
return name.split("__")[-1] or name
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def turn(p: Project, message: str, ui: UI, agent: Optional[str], label: str) -> str:
|
|
125
|
+
name, spec = pick(agent or p.state.get("agent"))
|
|
126
|
+
p.state["agent"] = name
|
|
127
|
+
p.state["turns"] = p.state.get("turns", 0) + 1
|
|
128
|
+
rd = p.root / "checks" / f"turn-{p.state['turns']}"
|
|
129
|
+
rd.mkdir(parents=True, exist_ok=True)
|
|
130
|
+
write_atomic(rd / "message.md", message)
|
|
131
|
+
p.log(f"turn ({name}): {label}")
|
|
132
|
+
for attempt in (1, 2):
|
|
133
|
+
argv = list(spec["argv"])
|
|
134
|
+
session = p.state.get("session")
|
|
135
|
+
if session and spec.get("resume"):
|
|
136
|
+
argv += [a.replace("{session}", session) for a in spec["resume"]]
|
|
137
|
+
stage = "refine" if label.startswith("refin") else "build"
|
|
138
|
+
with ui.live(f"{label} · {name}", stage) as live:
|
|
139
|
+
reader = _Claude(live, str(p.root)) if spec.get("stream") == "claude-json" else None
|
|
140
|
+
|
|
141
|
+
def on_line(line: str) -> None:
|
|
142
|
+
if reader is not None:
|
|
143
|
+
reader(line)
|
|
144
|
+
elif line.strip():
|
|
145
|
+
live.event("out", line.strip())
|
|
146
|
+
|
|
147
|
+
r = proc.run(argv, cwd=str(p.root), timeout=TURN_TIMEOUT, idle=IDLE_TIMEOUT, input_text=message, on_line=on_line)
|
|
148
|
+
ui.say(f" {ui.dim('└ ' + live.tally())}")
|
|
149
|
+
write_atomic(rd / "agent.log", r.output)
|
|
150
|
+
lost = spec.get("lost")
|
|
151
|
+
if attempt == 1 and session and lost and lost in r.output:
|
|
152
|
+
ui.note("The agent's saved conversation isn't on this computer; starting a fresh one.")
|
|
153
|
+
p.state["session"] = None
|
|
154
|
+
p.save()
|
|
155
|
+
continue # the next message points it at BRIEF.md anyway
|
|
156
|
+
break
|
|
157
|
+
if reader is not None and reader.session:
|
|
158
|
+
p.state["session"] = reader.session
|
|
159
|
+
p.save()
|
|
160
|
+
if not r.ok or (reader is not None and reader.is_error):
|
|
161
|
+
why = r.error or (f"exit {r.code}" if r.code else "it reported an error")
|
|
162
|
+
raise CraftError(f"{name} stopped: {why}.\n{r.tail(12)}", f"Full output: {rd / 'agent.log'}. Fix the cause, then `craft run` carries on.")
|
|
163
|
+
reply = reader.reply if reader is not None else r.tail(40)
|
|
164
|
+
write_atomic(rd / "reply.md", reply)
|
|
165
|
+
return reply
|
craft_kit/banner.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""The "CRAFT KIT" title: block letters leaning right (italic), in a gradient where blue dominates."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
|
|
7
|
+
GLYPHS = {
|
|
8
|
+
"C": [" █████", "██ ", "██ ", "██ ", " █████"],
|
|
9
|
+
"R": ["█████ ", "██ ██", "█████ ", "██ ██ ", "██ ██"],
|
|
10
|
+
"A": [" ████ ", "██ ██", "██████", "██ ██", "██ ██"],
|
|
11
|
+
"F": ["██████", "██ ", "█████ ", "██ ", "██ "],
|
|
12
|
+
"T": ["██████", " ██ ", " ██ ", " ██ ", " ██ "],
|
|
13
|
+
"K": ["██ ██", "██ ██ ", "████ ", "██ ██ ", "██ ██"],
|
|
14
|
+
"I": ["██", "██", "██", "██", "██"],
|
|
15
|
+
" ": [" "] * 5,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
# Colour stops (position 0..1, RGB): sky → azure → royal blue → indigo, a touch of violet and pink at the end.
|
|
19
|
+
STOPS = [
|
|
20
|
+
(0.00, (70, 210, 255)),
|
|
21
|
+
(0.22, (40, 150, 255)),
|
|
22
|
+
(0.50, (45, 95, 245)),
|
|
23
|
+
(0.72, (80, 70, 235)),
|
|
24
|
+
(0.88, (150, 85, 250)),
|
|
25
|
+
(1.00, (240, 95, 200)),
|
|
26
|
+
]
|
|
27
|
+
SLANT = 1 # columns each row moves left from the top: the letters lean like italics
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def truecolor() -> bool:
|
|
31
|
+
return os.environ.get("COLORTERM", "").lower() in ("truecolor", "24bit") or os.environ.get("TERM_PROGRAM") in ("iTerm.app", "vscode", "WezTerm", "ghostty")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def colour_at(t: float) -> tuple[int, int, int]:
|
|
35
|
+
t = min(1.0, max(0.0, t))
|
|
36
|
+
for (p0, c0), (p1, c1) in zip(STOPS, STOPS[1:]):
|
|
37
|
+
if t <= p1:
|
|
38
|
+
k = (t - p0) / (p1 - p0) if p1 > p0 else 0.0
|
|
39
|
+
return tuple(round(a + (b - a) * k) for a, b in zip(c0, c1)) # type: ignore[return-value]
|
|
40
|
+
return STOPS[-1][1]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def fg(rgb: tuple[int, int, int]) -> str:
|
|
44
|
+
if truecolor():
|
|
45
|
+
return "\033[38;2;{};{};{}m".format(*rgb)
|
|
46
|
+
r, g, b = (round(v / 255 * 5) for v in rgb) # nearest colour of the 256-colour cube
|
|
47
|
+
return f"\033[38;5;{16 + 36 * r + 6 * g + b}m"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def gradient(text: str, italic: bool = True, bold: bool = False) -> str:
|
|
51
|
+
"""Colour a line of text along the gradient."""
|
|
52
|
+
chars = list(text)
|
|
53
|
+
n = max(1, len(chars) - 1)
|
|
54
|
+
style = ("\033[3m" if italic else "") + ("\033[1m" if bold else "")
|
|
55
|
+
return "".join(f"{style}{fg(colour_at(i / n))}{ch}" if ch != " " else ch for i, ch in enumerate(chars)) + "\033[0m"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def big_lines(word: str = "CRAFT KIT") -> list[str]:
|
|
59
|
+
rows = [""] * 5
|
|
60
|
+
for ch in word.upper():
|
|
61
|
+
glyph = GLYPHS.get(ch, GLYPHS[" "])
|
|
62
|
+
for r in range(5):
|
|
63
|
+
rows[r] += glyph[r] + " "
|
|
64
|
+
return [" " * (SLANT * (4 - r)) + row.rstrip() for r, row in enumerate(rows)]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def render(width: int, fancy: bool, indent: int = 2) -> list[str]:
|
|
68
|
+
"""The title as lines ready to print: big letters when there's room, one styled line otherwise."""
|
|
69
|
+
lines = big_lines()
|
|
70
|
+
longest = max(len(line) for line in lines)
|
|
71
|
+
if not fancy:
|
|
72
|
+
return [" " * indent + "C R A F T K I T"]
|
|
73
|
+
if longest + indent > width:
|
|
74
|
+
return [" " * indent + gradient("C R A F T K I T", bold=True)]
|
|
75
|
+
out = []
|
|
76
|
+
for line in lines:
|
|
77
|
+
cells = []
|
|
78
|
+
r = len(out)
|
|
79
|
+
for x, ch in enumerate(line):
|
|
80
|
+
if ch == " ":
|
|
81
|
+
cells.append(" ")
|
|
82
|
+
else:
|
|
83
|
+
# A slightly diagonal gradient: each letter shades from top-left to bottom-right.
|
|
84
|
+
cells.append(f"\033[3m\033[1m{fg(colour_at((x + 2 * r) / (longest + 8)))}{ch}")
|
|
85
|
+
out.append(" " * indent + "".join(cells) + "\033[0m")
|
|
86
|
+
return out
|
craft_kit/brief.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""What the builder is told: the first brief, refinements, follow-ups, answers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from . import tables
|
|
6
|
+
from .workspace import Project
|
|
7
|
+
|
|
8
|
+
RULES = """Rules:
|
|
9
|
+
- The request comes first. When it names a language, framework, engine or platform, use exactly that; the stack below is only the default for what it leaves open. Never switch away from what was asked to get past a failing build: fix the build.
|
|
10
|
+
- Change files only inside app/. Everything else here is read-only evidence.
|
|
11
|
+
- Rebuild layout, styling and behaviour yourself from the evidence. Don't copy logos, photos, brand names or long passages of text unless the person says they own them or have permission; use neutral placeholders instead.
|
|
12
|
+
- Ask the person only about choices that really change the result, never about technical details you can decide (see "Asking" below).
|
|
13
|
+
- Build everything that was asked for. If part of it won't fit this version, ask before leaving it out, with the smaller version as one choice; never drop it silently.
|
|
14
|
+
- Never start dev servers, watchers or anything that doesn't exit (npm run dev, vite, cargo watch). The checks serve and run the build themselves.
|
|
15
|
+
- Never claim something works that you didn't run. Missing evidence isn't proof: mark what you estimated.
|
|
16
|
+
- Finish with 3-5 plain sentences for the person: what you built, what differs from the original or is still unknown, and one idea for what to ask next. No code and no file paths in that summary."""
|
|
17
|
+
|
|
18
|
+
CONTRACTS = {
|
|
19
|
+
"website": 'The site must build to static files: index.html at the top of a folder, each other page at its own path, working when served as plain files. Name that folder in app/craft-app.json as "site" (for example "dist"; "." for plain HTML in app/). It is photographed at 1440, 768 and 390 px wide and compared with capture/screenshot-<width>.png; it must never scroll sideways on a phone.',
|
|
20
|
+
"cli": 'Declare "run" in app/craft-app.json (for example ["python3", "main.py"] or ["./target/debug/tool"], relative to app/). The check runs it with each input in capture/runs.json and compares the exit code and the printed words with the original\'s.',
|
|
21
|
+
"app": 'Declare "run" in app/craft-app.json. Give the program a `--screenshot <file.png>` option: start normally, draw about two seconds of frames, save the window as a PNG to that file and exit. The check runs it that way; a missing or one-colour picture fails.',
|
|
22
|
+
"game": 'Declare "run" in app/craft-app.json. Give the game `--screenshot <file.png>` (start, draw about two seconds, save the window as PNG, exit) and `--self-test <file.json>`: start a game as a player would, drive it with scripted input for a few seconds, measure from the game\'s own state whether each part of the core loop works, write {"version": 1, "steps": [{"step": "move", "passed": true, "detail": "walked 3 cells"}, {"step": "act", "passed": true, "detail": "…"}, {"step": "hud", "passed": true, "detail": "score and lives drawn"}]} and exit. The game isn\'t ready until every step passes.',
|
|
23
|
+
"tool": "Write tests that prove the features the request names, and declare how to run them in app/craft-app.json.",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
ASKING = """Asking: when a decision is really the person's, write questions.json in the project folder (not in app/):
|
|
27
|
+
{"version": 1, "questions": [{"id": "q1", "question": "Keep the dark theme?", "choices": ["Yes, dark", "Light", "Both, with a switch"]}]}
|
|
28
|
+
with 2-4 short choices each, then end your turn with a one-line reason. Their answers arrive in the next message. Keep building whatever doesn't depend on the answer first."""
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def mode_text(p: Project) -> str:
|
|
32
|
+
if p.deep:
|
|
33
|
+
return ("Deep mode, for the person's private use: you may look inside the original (its files, its code, a site's HTML and scripts) "
|
|
34
|
+
"to learn how it works. Rebuild what you learn in your own code; never paste the original's code or assets into app/.")
|
|
35
|
+
return ("Clean room: work only from what a visitor or user can see: the screenshots, measurements and runs in capture/, the pictures in reference/ "
|
|
36
|
+
"and the person's words. Never open, read, fetch, run or decompile the original's files, folder, page source or program yourself.")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def target_text(p: Project) -> str:
|
|
40
|
+
t = p.state["target"]
|
|
41
|
+
return {"website": f"the website {t['value']}", "local": f"the program or folder at {t['value']}"}.get(t["type"], "nothing specific (build from the request)")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def first(p: Project) -> str:
|
|
45
|
+
kind = tables.KINDS.get(p.state["kind"], tables.KINDS["tool"])
|
|
46
|
+
stack = tables.stack_by_name(p.state["stack"])
|
|
47
|
+
self_check = "Run `craft check --self` in the project folder: it runs exactly the checks that judge your work and lists what differs. Fix, build and check again until it passes."
|
|
48
|
+
return f"""You are the builder in a craft project: rebuilding software from evidence for a person who may not code.
|
|
49
|
+
|
|
50
|
+
Request: "{p.state['request']}"
|
|
51
|
+
Target: {target_text(p)}
|
|
52
|
+
What to build: {kind['goal']}.
|
|
53
|
+
Project folder (your working directory): {p.root}
|
|
54
|
+
|
|
55
|
+
{mode_text(p)}
|
|
56
|
+
|
|
57
|
+
The folder:
|
|
58
|
+
- capture/ evidence gathered by Study. Start with capture/README.md, then open every screenshot and file it lists.
|
|
59
|
+
- reference/ pictures or files the person added. Look at each.
|
|
60
|
+
- app/ the only folder you change. The whole build lives here.
|
|
61
|
+
- checks/ results of earlier checks (round-N/result.json, screenshots, logs).
|
|
62
|
+
- unknowns.md questions the evidence doesn't answer yet; you keep it up to date.
|
|
63
|
+
|
|
64
|
+
How to build it, unless the request says otherwise: {stack['steps']}
|
|
65
|
+
|
|
66
|
+
Declare how your build is built, tested and run in app/craft-app.json, each command a list of program and arguments run in app/:
|
|
67
|
+
{{"version": 1, "install": ["npm", "install"], "build": ["npm", "run", "build"], "test": ["npm", "test"], "run": ["node", "dist/main.js"], "site": "dist"}}
|
|
68
|
+
(leave out what doesn't apply). {CONTRACTS.get(p.state['kind'], CONTRACTS['tool'])}
|
|
69
|
+
|
|
70
|
+
How to work:
|
|
71
|
+
1) Study the evidence, overview first. Where a gap matters to the result, look again (Deep) or ask (Clean room) instead of guessing. Add each open question to unknowns.md as `- [ ] question — what would settle it — impact: high|medium|low`; tick it with the answer and its evidence when settled, and until then say what you did instead (an estimate, a placeholder, a question).
|
|
72
|
+
2) Plan: write 3-7 plain-language steps at the top of app/PLAN.md and tick them as you go.
|
|
73
|
+
3) Build it in app/ and fix errors until the build succeeds.
|
|
74
|
+
4) Test it against the original. {self_check} If `craft` isn't available, run the build, tests and the contract above yourself.
|
|
75
|
+
5) Before you reply, go through PLAN.md and unknowns.md once more: settle what one more look can settle, and say what's left.
|
|
76
|
+
|
|
77
|
+
{RULES}
|
|
78
|
+
|
|
79
|
+
{ASKING}"""
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def refine(p: Project, check_text: str, answers: str = "") -> str:
|
|
83
|
+
return f"""{check_text}
|
|
84
|
+
|
|
85
|
+
Fix this in app/ (biggest visible or functional differences first), build again until it succeeds, re-run the check, and reply with 1-2 plain sentences on what you changed. The full task is in BRIEF.md if you need it again.{answers}"""
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def follow_up(p: Project, message: str, answers: str = "") -> str:
|
|
89
|
+
return f"""The person asks for a change to the build: "{message}"
|
|
90
|
+
|
|
91
|
+
Keep what works and change only what this needs, in app/. Build and check again until it passes, then reply with 2-3 plain sentences on what changed. The original task and rules are in BRIEF.md.{answers}"""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def resume(p: Project, answers: str = "") -> str:
|
|
95
|
+
return f"""Carry on with the build where you left off. The task and rules are in BRIEF.md; app/PLAN.md, unknowns.md and checks/ show where things stand.{answers}"""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def answers_text(answered: list[dict]) -> str:
|
|
99
|
+
if not answered:
|
|
100
|
+
return ""
|
|
101
|
+
return "\n\nThe person answered your questions:\n" + "\n".join(f"- {q['question']} → {q['answer']}" for q in answered)
|