mindslice-forge 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.
- mindslice_forge/__init__.py +12 -0
- mindslice_forge/__main__.py +5 -0
- mindslice_forge/checkpoint.py +106 -0
- mindslice_forge/cli.py +85 -0
- mindslice_forge/engine.py +91 -0
- mindslice_forge/harness.py +81 -0
- mindslice_forge/lookup.py +91 -0
- mindslice_forge/pipeline.py +419 -0
- mindslice_forge/profile.py +83 -0
- mindslice_forge/prompts/card-format.md +76 -0
- mindslice_forge/prompts/extract.md +100 -0
- mindslice_forge/prompts/finalize.md +69 -0
- mindslice_forge/prompts/plan.md +145 -0
- mindslice_forge/prompts/probe.md +15 -0
- mindslice_forge/prompts/repair.md +27 -0
- mindslice_forge/split.py +137 -0
- mindslice_forge/state.py +44 -0
- mindslice_forge-0.1.0.dist-info/METADATA +275 -0
- mindslice_forge-0.1.0.dist-info/RECORD +23 -0
- mindslice_forge-0.1.0.dist-info/WHEEL +5 -0
- mindslice_forge-0.1.0.dist-info/entry_points.txt +2 -0
- mindslice_forge-0.1.0.dist-info/licenses/LICENSE +202 -0
- mindslice_forge-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
"""The forge pipeline: profile -> plan -> split -> extract (chunk by chunk) -> finalize.
|
|
2
|
+
|
|
3
|
+
Every stage has the same shape: an agent session does the work, a check decides whether it is
|
|
4
|
+
sound, and repair sessions fix what the check reports. A finished stage is committed in the
|
|
5
|
+
workspace and recorded in ``state.json``, so running the same command again continues from there.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import secrets
|
|
13
|
+
import shlex
|
|
14
|
+
import shutil
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Callable
|
|
20
|
+
|
|
21
|
+
from . import ForgeError, checkpoint, engine, harness, lookup, profile, split
|
|
22
|
+
from . import state as progress
|
|
23
|
+
|
|
24
|
+
PROMPTS = Path(__file__).parent / "prompts"
|
|
25
|
+
NO_PROMPT = "(none given: build a faithful, general-purpose knowledge base of the sources)"
|
|
26
|
+
SPLIT_NEEDED = "The sources are too large to read in one session, so you must also write the splitter."
|
|
27
|
+
SPLIT_NOT_NEEDED = "The sources fit in one session and will be read as a single chunk: do NOT write a splitter."
|
|
28
|
+
MAX_ISSUES_SHOWN = 50
|
|
29
|
+
PROBE_TIMEOUT = 300 # seconds: the harness check is a task of a few tool calls
|
|
30
|
+
|
|
31
|
+
Check = Callable[[], list[dict]]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Job:
|
|
36
|
+
sources: list[Path]
|
|
37
|
+
dicts: list[Path]
|
|
38
|
+
prompt: str
|
|
39
|
+
out: Path
|
|
40
|
+
harness: list[str] # command template, see harness.template()
|
|
41
|
+
sample: int | None = None
|
|
42
|
+
chunk_size: int = 100_000
|
|
43
|
+
timeout: int = 3600
|
|
44
|
+
max_repairs: int = 2
|
|
45
|
+
from_stage: str | None = None
|
|
46
|
+
dry_run: bool = False
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def work(self) -> Path:
|
|
50
|
+
return lookup.forge_dir(self.out)
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def chunks(self) -> Path:
|
|
54
|
+
return self.work / "chunks"
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def state_file(self) -> Path:
|
|
58
|
+
return self.work / "state.json"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def log(message: str) -> None:
|
|
62
|
+
print(message, flush=True)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def run(job: Job) -> None:
|
|
66
|
+
sources, not_text = profile.scan(job.sources)
|
|
67
|
+
if not sources:
|
|
68
|
+
raise ForgeError("no text files found in the sources")
|
|
69
|
+
inventory = profile.render(sources, not_text)
|
|
70
|
+
needs_split = sum(s.chars for s in sources) > job.chunk_size
|
|
71
|
+
if job.dry_run:
|
|
72
|
+
log(inventory)
|
|
73
|
+
log(f"Reference frame:\n{bullets(job.dicts)}\n")
|
|
74
|
+
log(f"Workspace: {job.out}\nForge directory: {job.work}")
|
|
75
|
+
log(f"Splitting: {'needed' if needs_split else 'not needed'} at {job.chunk_size} characters per chunk")
|
|
76
|
+
log(f"An agent session must reach:\n{reach(job)}")
|
|
77
|
+
log(f"Harness: {shlex.join(job.harness) if job.harness else '(no --harness-cmd given)'}")
|
|
78
|
+
return
|
|
79
|
+
|
|
80
|
+
engine.version()
|
|
81
|
+
state = start(job, sources)
|
|
82
|
+
(job.work / "profile.md").write_text(inventory, encoding="utf-8")
|
|
83
|
+
if not state["plan"]:
|
|
84
|
+
plan(job, state, inventory, needs_split)
|
|
85
|
+
if not state["split"]:
|
|
86
|
+
split_sources(job, state, sources, needs_split)
|
|
87
|
+
|
|
88
|
+
chunks = split.load(job.chunks)
|
|
89
|
+
selected = chunks[:job.sample] if job.sample else chunks
|
|
90
|
+
for index, chunk in enumerate(selected):
|
|
91
|
+
if chunk["id"] not in state["chunks_done"]:
|
|
92
|
+
previous = selected[index - 1]["id"] if index else None
|
|
93
|
+
extract(job, state, chunk, f"{index + 1}/{len(chunks)}", previous)
|
|
94
|
+
if not state["finalized"]:
|
|
95
|
+
finalize(job, state, len(chunks))
|
|
96
|
+
|
|
97
|
+
report = engine.validate(job.out)
|
|
98
|
+
if report["errors"]:
|
|
99
|
+
raise ForgeError("the finished workspace does not validate:\n" + format_issues(report["errors"]))
|
|
100
|
+
log(f"\nWorkspace ready: {job.out}")
|
|
101
|
+
log(f" {report['stats']['cards']} cards, {len(state['chunks_done'])} of {len(chunks)} chunks processed, "
|
|
102
|
+
f"{len(report['warnings'])} validation warning(s)")
|
|
103
|
+
log(f" Report: {job.work / 'report.md'}")
|
|
104
|
+
log(f" Per-chunk changes: git -C {job.out} log --stat")
|
|
105
|
+
log(f" Next: mindslice -w {job.out} doctor")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ---------------------------------------------------------------------------
|
|
109
|
+
# Starting and resuming
|
|
110
|
+
# ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def start(job: Job, sources: list[profile.Source]) -> dict:
|
|
114
|
+
"""Load the state of an earlier run, or create the workspace for a new one."""
|
|
115
|
+
files = [s.path for s in sources] + profile.collect(job.dicts)
|
|
116
|
+
fingerprint = progress.fingerprint(files, job.prompt, job.chunk_size)
|
|
117
|
+
state = progress.load(job.state_file)
|
|
118
|
+
if state is None:
|
|
119
|
+
if job.from_stage:
|
|
120
|
+
raise ForgeError(f"--from needs an earlier run, and {job.work} has none")
|
|
121
|
+
if job.out.exists() and any(job.out.iterdir()):
|
|
122
|
+
raise ForgeError(f"{job.out} already exists and was not created by the forge; choose another --output")
|
|
123
|
+
job.work.mkdir(parents=True, exist_ok=True)
|
|
124
|
+
engine.init(job.out)
|
|
125
|
+
checkpoint.init(job.out)
|
|
126
|
+
checkpoint.commit(job.out, "empty workspace", tag="forge-init")
|
|
127
|
+
state = progress.new(fingerprint)
|
|
128
|
+
elif job.from_stage:
|
|
129
|
+
rewind(job, state)
|
|
130
|
+
state["fingerprint"] = fingerprint
|
|
131
|
+
elif state["fingerprint"] != fingerprint:
|
|
132
|
+
raise ForgeError("the sources, dict, prompt or chunk size changed since this output was started.\n"
|
|
133
|
+
"Redo the affected stages with --from plan|split|extract, or choose another --output.")
|
|
134
|
+
else:
|
|
135
|
+
checkpoint.rollback(job.out) # drop whatever an interrupted session left behind
|
|
136
|
+
progress.save(job.state_file, state)
|
|
137
|
+
return state
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def rewind(job: Job, state: dict) -> None:
|
|
141
|
+
"""--from STAGE: return the workspace and the forge directory to how they were before that stage."""
|
|
142
|
+
stage = job.from_stage if state["plan"] else "plan" # nothing exists yet after an unfinished plan
|
|
143
|
+
progress.rewind(state, stage)
|
|
144
|
+
if stage == "finalize":
|
|
145
|
+
checkpoint.rollback(job.out)
|
|
146
|
+
return
|
|
147
|
+
checkpoint.rollback(job.out, "forge-init" if stage == "plan" else "forge-plan")
|
|
148
|
+
stale = {"plan": ("plan.md", "split.py", "chunks", "ledger", "handoff", "lookups.jsonl"),
|
|
149
|
+
"split": ("chunks", "ledger", "handoff", "lookups.jsonl"),
|
|
150
|
+
"extract": ("ledger", "handoff", "lookups.jsonl")}[stage]
|
|
151
|
+
for name in stale:
|
|
152
|
+
path = job.work / name
|
|
153
|
+
shutil.rmtree(path) if path.is_dir() else path.unlink(missing_ok=True)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# ---------------------------------------------------------------------------
|
|
157
|
+
# Stages
|
|
158
|
+
# ---------------------------------------------------------------------------
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def plan(job: Job, state: dict, inventory: str, needs_split: bool) -> None:
|
|
162
|
+
log("[plan] the agent studies the sources and writes the build plan")
|
|
163
|
+
prompt = render("plan", profile=inventory, dicts=bullets(job.dicts), user_prompt=job.prompt or NO_PROMPT,
|
|
164
|
+
plan_file=job.work / "plan.md", slices_dir=job.out / "slices",
|
|
165
|
+
split_note=SPLIT_NEEDED if needs_split else SPLIT_NOT_NEEDED,
|
|
166
|
+
split_file=job.work / "split.py", chunk_size=job.chunk_size)
|
|
167
|
+
run_step(job, "plan", prompt, lambda: check_plan(job, needs_split))
|
|
168
|
+
aliases = engine.mount_slices(job.out)
|
|
169
|
+
checkpoint.commit(job.out, "plan", tag="forge-plan")
|
|
170
|
+
state["plan"] = True
|
|
171
|
+
progress.save(job.state_file, state)
|
|
172
|
+
log(f"[plan] done; slices: {', '.join(aliases)}")
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def check_plan(job: Job, needs_split: bool) -> list[dict]:
|
|
176
|
+
issues = []
|
|
177
|
+
if not has_text(job.work / "plan.md"):
|
|
178
|
+
issues.append(problem("plan.md was not written", job.work / "plan.md"))
|
|
179
|
+
if needs_split and not has_text(job.work / "split.py"):
|
|
180
|
+
issues.append(problem("split.py was not written", job.work / "split.py"))
|
|
181
|
+
slices = sorted((job.out / "slices").glob("*.slice"))
|
|
182
|
+
if not slices:
|
|
183
|
+
issues.append(problem("no <name>.slice directory was created", job.out / "slices"))
|
|
184
|
+
for directory in slices:
|
|
185
|
+
if not has_text(directory / "slice.toml"):
|
|
186
|
+
issues.append(problem("slice.toml is missing", directory / "slice.toml"))
|
|
187
|
+
issues += engine.issues(engine.validate(job.out, directory))
|
|
188
|
+
return issues
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def split_sources(job: Job, state: dict, sources: list[profile.Source], needs_split: bool) -> None:
|
|
192
|
+
if needs_split:
|
|
193
|
+
log("[split] running the agent-written splitter")
|
|
194
|
+
settle(job, "split", lambda: check_split(job, sources), task="plan")
|
|
195
|
+
else:
|
|
196
|
+
split.single_chunk(job.chunks, sources)
|
|
197
|
+
chunks = split.load(job.chunks)
|
|
198
|
+
state["split"] = True
|
|
199
|
+
progress.save(job.state_file, state)
|
|
200
|
+
log(f"[split] {len(chunks)} chunk(s): {chunks[0].get('title', '')} ... {chunks[-1].get('title', '')}")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def check_split(job: Job, sources: list[profile.Source]) -> list[dict]:
|
|
204
|
+
script = job.work / "split.py"
|
|
205
|
+
failure = split.run_splitter(script, job.chunks, sources, job.chunk_size)
|
|
206
|
+
errors = [failure] if failure else split.verify(job.chunks, sources, job.chunk_size)
|
|
207
|
+
return [problem(error, script) for error in errors]
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def extract(job: Job, state: dict, chunk: dict, position: str, previous: str | None) -> None:
|
|
211
|
+
step, title = f"extract-{chunk['id']}", chunk.get("title", "")
|
|
212
|
+
log(f"[{step}] chunk {position}: {title}")
|
|
213
|
+
handoff = job.work / "handoff"
|
|
214
|
+
prompt = render("extract", plan_file=job.work / "plan.md", user_prompt=job.prompt or NO_PROMPT,
|
|
215
|
+
dicts=bullets(job.dicts), chunk_file=job.chunks / chunk["file"], chunk_title=title,
|
|
216
|
+
chunk_parts=parts_list(chunk), lookup=lookup_command(job),
|
|
217
|
+
position=position, out=job.out, slices_dir=job.out / "slices",
|
|
218
|
+
previous_handoff=handoff / f"{previous}.md" if previous else "(none: this is the first chunk)",
|
|
219
|
+
handoff_file=handoff / f"{chunk['id']}.md", ledger_file=job.work / "ledger" / f"{chunk['id']}.md")
|
|
220
|
+
run_step(job, step, prompt, lambda: check_chunk(job, chunk), check_lookups=True)
|
|
221
|
+
checkpoint.commit(job.out, f"chunk {chunk['id']}: {title}")
|
|
222
|
+
state["chunks_done"].append(chunk["id"])
|
|
223
|
+
state["finalized"] = False
|
|
224
|
+
progress.save(job.state_file, state)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def check_chunk(job: Job, chunk: dict) -> list[dict]:
|
|
228
|
+
notes = [job.work / "ledger" / f"{chunk['id']}.md", job.work / "handoff" / f"{chunk['id']}.md"]
|
|
229
|
+
issues = [problem(f"{path.parent.name}/{path.name} was not written", path) for path in notes if not has_text(path)]
|
|
230
|
+
return issues + engine.issues(engine.validate(job.out), checkpoint.changed(job.out))
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def not_looked_up(job: Job, queries: list[str]) -> list[dict]:
|
|
234
|
+
"""Every new card in this step needs a successful lookup naming it."""
|
|
235
|
+
slices = (job.out / "slices").resolve()
|
|
236
|
+
overviews = {directory / "overview.md" for directory in slices.glob("*.slice")}
|
|
237
|
+
new_cards = sorted(path for path in checkpoint.created(job.out)
|
|
238
|
+
if slices in path.parents and path.suffix == ".md" and path not in overviews)
|
|
239
|
+
return [{"severity": "error", "code": "not_looked_up", "file": str(card),
|
|
240
|
+
"message": "this card was created without looking up its title or an alias. Look it up now, with the "
|
|
241
|
+
f"command {lookup_command(job)}; if the workspace already has a card for the same thing, "
|
|
242
|
+
"merge the two into the one that existed first"}
|
|
243
|
+
for card in new_cards if not lookup.covered(engine.card_names(card), queries)]
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def finalize(job: Job, state: dict, total: int) -> None:
|
|
247
|
+
log("[finalize] overviews, remaining problems, retrieval checks and the report")
|
|
248
|
+
done = len(state["chunks_done"])
|
|
249
|
+
coverage = f"{done} of {total} chunks" + ("" if done == total else " (a partial, sample build)")
|
|
250
|
+
prompt = render("finalize", plan_file=job.work / "plan.md", user_prompt=job.prompt or NO_PROMPT,
|
|
251
|
+
out=job.out, lookup=lookup_command(job), slices_dir=job.out / "slices", ledger_dir=job.work / "ledger",
|
|
252
|
+
report_file=job.work / "report.md", coverage=coverage,
|
|
253
|
+
profile_file=job.work / "profile.md", manifest_file=job.chunks / "manifest.json",
|
|
254
|
+
issues=format_issues(engine.issues(engine.validate(job.out))) or "(none)")
|
|
255
|
+
run_step(job, "finalize", prompt, lambda: check_final(job), check_lookups=True)
|
|
256
|
+
checkpoint.commit(job.out, "finalize")
|
|
257
|
+
state["finalized"] = True
|
|
258
|
+
progress.save(job.state_file, state)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def check_final(job: Job) -> list[dict]:
|
|
262
|
+
required = [d / "overview.md" for d in sorted((job.out / "slices").glob("*.slice"))] + [job.work / "report.md"]
|
|
263
|
+
issues = [problem(f"{path.name} was not written", path) for path in required if not has_text(path)]
|
|
264
|
+
return issues + engine.issues(engine.validate(job.out))
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# ---------------------------------------------------------------------------
|
|
268
|
+
# Sessions, checks and repairs
|
|
269
|
+
# ---------------------------------------------------------------------------
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def run_step(job: Job, step: str, prompt: str, check: Check, *, check_lookups: bool = False) -> None:
|
|
273
|
+
"""Run the step's agent session, retrying from a clean workspace if it fails, then settle its result."""
|
|
274
|
+
for delay in (*harness.BACKOFF, None):
|
|
275
|
+
earlier = len(lookup.queries(job.out)) if check_lookups else 0
|
|
276
|
+
if session(job, step, prompt):
|
|
277
|
+
break
|
|
278
|
+
checkpoint.rollback(job.out)
|
|
279
|
+
if delay is None:
|
|
280
|
+
raise ForgeError(f"{step}: the agent session failed {len(harness.BACKOFF) + 1} times; "
|
|
281
|
+
f"see {job.work / 'logs' / (step + '.log')}")
|
|
282
|
+
log(f"[{step}] the session failed; retrying in {delay}s")
|
|
283
|
+
time.sleep(delay)
|
|
284
|
+
|
|
285
|
+
def checked() -> list[dict]:
|
|
286
|
+
issues = check()
|
|
287
|
+
if check_lookups:
|
|
288
|
+
issues += not_looked_up(job, lookup.queries(job.out)[earlier:])
|
|
289
|
+
return issues
|
|
290
|
+
|
|
291
|
+
settle(job, step, checked, task=step)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def settle(job: Job, step: str, check: Check, task: str) -> None:
|
|
295
|
+
"""Run repair sessions until ``check`` is satisfied. Errors left after the last repair are fatal;
|
|
296
|
+
warnings are not, and end up in the final report. ``task`` names the prompt the work was done under."""
|
|
297
|
+
for attempt in range(job.max_repairs + 1):
|
|
298
|
+
issues = check()
|
|
299
|
+
if not issues:
|
|
300
|
+
return
|
|
301
|
+
if attempt < job.max_repairs:
|
|
302
|
+
log(f"[{step}] {len(issues)} problem(s) found; repair session {attempt + 1}")
|
|
303
|
+
session(job, f"{step}.repair{attempt + 1}",
|
|
304
|
+
render("repair", task_file=job.work / "prompts" / f"{task}.md", issues=format_issues(issues)))
|
|
305
|
+
errors = [issue for issue in issues if issue["severity"] == "error"]
|
|
306
|
+
if errors:
|
|
307
|
+
raise ForgeError(f"{step}: problems remain after {job.max_repairs} repair session(s):\n{format_issues(errors)}\n"
|
|
308
|
+
f"The failed attempt is left in place for inspection; "
|
|
309
|
+
f"running the same command again discards it and retries this step.")
|
|
310
|
+
log(f"[{step}] {len(issues)} warning(s) left for the final report")
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def session(job: Job, name: str, prompt: str) -> bool:
|
|
314
|
+
"""One fresh agent session. The rendered prompt and the session log are kept in the forge directory."""
|
|
315
|
+
check_harness(job)
|
|
316
|
+
prompt_file = job.work / "prompts" / f"{name}.md"
|
|
317
|
+
prompt_file.parent.mkdir(parents=True, exist_ok=True)
|
|
318
|
+
prompt_file.write_text(prompt, encoding="utf-8")
|
|
319
|
+
cmd = harness.command(job.harness, prompt_file, job.work)
|
|
320
|
+
checkpoint.begin(job.out)
|
|
321
|
+
return harness.run(cmd, job.work, job.work / "logs" / f"{name}.log", job.timeout)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def check_harness(job: Job) -> None:
|
|
325
|
+
"""Before the first session with a harness command, prove with a short one that it works.
|
|
326
|
+
|
|
327
|
+
The forge does not know the harness, so a command that cannot do the work must fail here, in
|
|
328
|
+
seconds and with the reason, not after a long step and its retries. The command that passed
|
|
329
|
+
is recorded in the forge directory, and is not checked again."""
|
|
330
|
+
record = job.work / "harness.json"
|
|
331
|
+
if record.exists() and json.loads(record.read_text(encoding="utf-8")) == job.harness:
|
|
332
|
+
return
|
|
333
|
+
log("[harness] checking the harness command with one short session")
|
|
334
|
+
engine_version, source = engine.version(), profile.scan(job.sources)[0][0].path
|
|
335
|
+
token, answer_file = secrets.token_hex(4), job.out / "forge-probe.txt"
|
|
336
|
+
prompt_file, log_file = job.work / "prompts" / "probe.md", job.work / "logs" / "probe.log"
|
|
337
|
+
prompt_file.parent.mkdir(parents=True, exist_ok=True)
|
|
338
|
+
prompt_file.write_text(render("probe", source=source, probe_file=answer_file, token=token), encoding="utf-8")
|
|
339
|
+
cmd = harness.command(job.harness, prompt_file, job.work)
|
|
340
|
+
timeout = min(job.timeout, PROBE_TIMEOUT)
|
|
341
|
+
checkpoint.begin(job.out)
|
|
342
|
+
try: # no step is unfinished when a run reaches its first session, so the rollback only removes the answer
|
|
343
|
+
finished = harness.run(cmd, job.work, log_file, timeout)
|
|
344
|
+
answer = answer_file.read_text(encoding="utf-8", errors="replace") if answer_file.is_file() else None
|
|
345
|
+
finally:
|
|
346
|
+
checkpoint.rollback(job.out)
|
|
347
|
+
|
|
348
|
+
problems = []
|
|
349
|
+
if not finished:
|
|
350
|
+
problems.append(f"the command did not end with exit status 0 within {timeout}s")
|
|
351
|
+
if answer is None:
|
|
352
|
+
problems.append(f"the session did not write {answer_file}: the agent must receive its task, use its tools "
|
|
353
|
+
"without asking for permission, and be able to write in the workspace")
|
|
354
|
+
else:
|
|
355
|
+
if token not in answer:
|
|
356
|
+
problems.append(f"the session wrote {answer_file} without the code from its prompt")
|
|
357
|
+
if engine_version not in answer:
|
|
358
|
+
problems.append(f"`mindslice --version` did not print `{engine_version}` in the session: "
|
|
359
|
+
"the agent's shell needs the same `mindslice` on its PATH")
|
|
360
|
+
if str(source.stat().st_size) not in answer.replace(",", ""):
|
|
361
|
+
problems.append(f"the session did not find the size of {source}: the agent must be able to read the sources")
|
|
362
|
+
if problems:
|
|
363
|
+
tail = log_file.read_text(encoding="utf-8", errors="replace").splitlines()[-12:]
|
|
364
|
+
raise ForgeError("the harness command cannot run this build:\n" + "\n".join(f"- {p}" for p in problems)
|
|
365
|
+
+ f"\nCommand: {shlex.join(cmd)}\nThe end of the session log, {log_file}:\n"
|
|
366
|
+
+ "\n".join(f" {line[:300]}" for line in tail)
|
|
367
|
+
+ "\n`mindslice-forge --help` says what the command must do.")
|
|
368
|
+
record.write_text(json.dumps(job.harness, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
369
|
+
log("[harness] the command works")
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
# ---------------------------------------------------------------------------
|
|
373
|
+
# Small helpers
|
|
374
|
+
# ---------------------------------------------------------------------------
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def render(name: str, **values: object) -> str:
|
|
378
|
+
"""Fill the ``{{placeholders}}`` of a prompt template; ``{{card_format}}`` is always available."""
|
|
379
|
+
values.setdefault("card_format", (PROMPTS / "card-format.md").read_text(encoding="utf-8"))
|
|
380
|
+
template = (PROMPTS / f"{name}.md").read_text(encoding="utf-8")
|
|
381
|
+
return re.sub(r"\{\{(\w+)\}\}", lambda match: str(values[match.group(1)]), template)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def problem(message: str, file: Path) -> dict:
|
|
385
|
+
"""A forge-detected error, in the shape of an engine validation issue."""
|
|
386
|
+
return {"severity": "error", "code": "forge", "message": message, "file": str(file)}
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def format_issues(issues: list[dict]) -> str:
|
|
390
|
+
lines = [f"- {i['severity']} [{i['code']}] {i.get('file') or i.get('address', '')}: {i['message']}"
|
|
391
|
+
for i in issues[:MAX_ISSUES_SHOWN]]
|
|
392
|
+
if len(issues) > MAX_ISSUES_SHOWN:
|
|
393
|
+
lines.append(f"- ... and {len(issues) - MAX_ISSUES_SHOWN} more of the same kinds")
|
|
394
|
+
return "\n".join(lines)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def lookup_command(job: Job) -> str:
|
|
398
|
+
"""The command line an agent uses to search the workspace, with the search recorded."""
|
|
399
|
+
return shlex.join([sys.executable, lookup.__file__, str(job.out)])
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def parts_list(chunk: dict) -> str:
|
|
403
|
+
"""Which source lines a chunk holds: a chunk can span several files, and a file several chunks."""
|
|
404
|
+
return "\n".join(f"- `{p['source']}`, lines {p['start_line']}-{p['end_line']}" for p in chunk["parts"]) or "(none)"
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def reach(job: Job) -> str:
|
|
408
|
+
"""The directories a session of the harness command works in, for whoever composes that command."""
|
|
409
|
+
read = dict.fromkeys(p if p.is_dir() else p.parent for p in [*job.sources, *job.dicts])
|
|
410
|
+
return "\n".join([f"- `{job.work}` (its working directory; read and write)", f"- `{job.out}` (read and write)",
|
|
411
|
+
*(f"- `{directory}` (read)" for directory in read)])
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def bullets(paths: list[Path]) -> str:
|
|
415
|
+
return "\n".join(f"- `{p}`" for p in paths) or "(none)"
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def has_text(path: Path) -> bool:
|
|
419
|
+
return path.is_file() and path.stat().st_size > 0
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Source inventory: which text files there are, how they are encoded and how large they are.
|
|
2
|
+
|
|
3
|
+
Nothing here looks at the *structure* of a document; finding chapters and sections is the agent's job.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import codecs
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from . import ForgeError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Source:
|
|
17
|
+
path: Path
|
|
18
|
+
encoding: str
|
|
19
|
+
lines: int
|
|
20
|
+
chars: int
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def collect(paths: list[Path]) -> list[Path]:
|
|
24
|
+
"""The files named by ``paths``: a file is itself, a directory is every non-hidden file below it."""
|
|
25
|
+
files: list[Path] = []
|
|
26
|
+
for path in paths:
|
|
27
|
+
if path.is_file():
|
|
28
|
+
files.append(path)
|
|
29
|
+
elif path.is_dir():
|
|
30
|
+
files += sorted(p for p in path.rglob("*")
|
|
31
|
+
if p.is_file() and not any(part.startswith(".") for part in p.relative_to(path).parts))
|
|
32
|
+
else:
|
|
33
|
+
raise ForgeError(f"no such file or directory: {path}")
|
|
34
|
+
return list(dict.fromkeys(files))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def detect_encoding(data: bytes) -> str | None:
|
|
38
|
+
"""The encoding that decodes ``data``, or None when it is not text."""
|
|
39
|
+
if data.startswith((codecs.BOM_UTF16_LE, codecs.BOM_UTF16_BE)):
|
|
40
|
+
return "utf-16"
|
|
41
|
+
if b"\x00" in data[:8192]:
|
|
42
|
+
return None
|
|
43
|
+
for encoding in ("utf-8-sig", "gb18030"):
|
|
44
|
+
try:
|
|
45
|
+
data.decode(encoding)
|
|
46
|
+
return encoding
|
|
47
|
+
except UnicodeDecodeError:
|
|
48
|
+
continue
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def read_lines(path: Path, encoding: str) -> list[str]:
|
|
53
|
+
"""The file's lines without their terminators. Line N of a source is ``read_lines(...)[N - 1]``;
|
|
54
|
+
this is what Python's ``open(path, encoding=encoding)`` iterates over."""
|
|
55
|
+
text = path.read_bytes().decode(encoding).replace("\r\n", "\n").replace("\r", "\n")
|
|
56
|
+
lines = text.split("\n")
|
|
57
|
+
if lines and lines[-1] == "":
|
|
58
|
+
lines.pop()
|
|
59
|
+
return lines
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def scan(paths: list[Path]) -> tuple[list[Source], list[Path]]:
|
|
63
|
+
"""Profile every file under ``paths``: (text sources, files skipped because they are not text)."""
|
|
64
|
+
sources, skipped = [], []
|
|
65
|
+
for path in collect(paths):
|
|
66
|
+
encoding = detect_encoding(path.read_bytes())
|
|
67
|
+
if encoding is None:
|
|
68
|
+
skipped.append(path)
|
|
69
|
+
continue
|
|
70
|
+
lines = read_lines(path, encoding)
|
|
71
|
+
sources.append(Source(path, encoding, len(lines), sum(map(len, lines))))
|
|
72
|
+
return sources, skipped
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def render(sources: list[Source], skipped: list[Path]) -> str:
|
|
76
|
+
"""The inventory as Markdown, for the agent and for the user."""
|
|
77
|
+
out = ["| source | encoding | lines | characters |", "| --- | --- | ---: | ---: |"]
|
|
78
|
+
out += [f"| `{s.path}` | {s.encoding} | {s.lines} | {s.chars} |" for s in sources]
|
|
79
|
+
out.append(f"\nTotal: {len(sources)} file(s), {sum(s.chars for s in sources)} characters.")
|
|
80
|
+
if skipped:
|
|
81
|
+
out.append("\nNot text, left out of the build:")
|
|
82
|
+
out += [f"- `{p}`" for p in skipped]
|
|
83
|
+
return "\n".join(out) + "\n"
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Card format and retrieval vocabulary
|
|
2
|
+
|
|
3
|
+
```markdown
|
|
4
|
+
+++
|
|
5
|
+
title = "Python Backend Error Handling Conventions"
|
|
6
|
+
aliases = ["error-handling", "exception-policy"]
|
|
7
|
+
tags = ["python", "backend", "reliability"]
|
|
8
|
+
extends = [] # @alias/path or slice-relative path
|
|
9
|
+
supersedes = [] # @alias/path or slice-relative path
|
|
10
|
+
+++
|
|
11
|
+
|
|
12
|
+
All backend services raise typed AppError subclasses and translate them to RFC 7807 only at the HTTP boundary.
|
|
13
|
+
|
|
14
|
+
- Define domain exceptions in `core/errors.py` inheriting from `AppError`.
|
|
15
|
+
- Map `AppError` subclasses to status codes only in the exception middleware (see [[architecture/http-middleware]]).
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Rules:
|
|
19
|
+
|
|
20
|
+
1. **One focused sub-topic per card**: answer one independently useful question. Around 5-15
|
|
21
|
+
statements is a guideline, not a quota; a short fact needs no padding. `validate()` warns past
|
|
22
|
+
50 lines. Split by meaning while preserving prerequisites, exceptions and ordered procedures;
|
|
23
|
+
use numbered steps, tables or code blocks when those represent the knowledge more faithfully.
|
|
24
|
+
2. **Summary paragraph** directly under the closing `+++`: 1-2 sentences, <= 240 chars.
|
|
25
|
+
It is what `search()` shows, so make it a high-signal abstract, not a heading.
|
|
26
|
+
3. **Bullets are atomic, not stripped**: one fact/rule per `- ` line, self-contained, greppable,
|
|
27
|
+
diffable. Keep the concrete values, names, dates, conditions and the source's own wording:
|
|
28
|
+
search matches only words the card contains.
|
|
29
|
+
4. **Frontmatter is flat TOML**: only strings and lists of strings. `title` is weighted highest
|
|
30
|
+
in search; `aliases` are synonyms (also usable as `[[alias]]` link targets, see below); `tags`
|
|
31
|
+
are facets.
|
|
32
|
+
5. **Paths are addresses**: `architecture/async-patterns.md` is `architecture/async-patterns`.
|
|
33
|
+
Use lowercase-kebab-case, group by topic folders, keep paths stable (links depend on them).
|
|
34
|
+
**Name a card by the durable topic or slot it fills** (`team/lead`, `subscriptions/streaming`),
|
|
35
|
+
never by the value it holds today (`team/lead-sarah-chen`, `subscriptions/xelvoria`). Ids and
|
|
36
|
+
`[[links]]` outlive values: a link to a value-named card keeps stating the old value after it
|
|
37
|
+
changes. When maintaining an existing overlay requires a separate replacement id, use a stable
|
|
38
|
+
qualifier such as the change date (`team/lead-2026-11`), never the new value. Link to the slot
|
|
39
|
+
(`[[team/lead]]`): `read()` follows `supersedes` from it to the current card.
|
|
40
|
+
6. **Links**: `[[path]]` or `[[path|label]]`. Without an `@alias/` prefix a link resolves *only
|
|
41
|
+
inside the same slice* - never into other mounts. Use `[[@persona/tone]]` to reach across slices
|
|
42
|
+
and declare that slice in `slice.toml` `dependencies`.
|
|
43
|
+
7. Never put secrets, tokens, machine-specific absolute paths or session noise in cards.
|
|
44
|
+
8. **`load = "always"`** (optional, after `title`) marks a card every session reads at start, with
|
|
45
|
+
no query: an agent persona, the user's reply language, standing instructions. Use it sparingly
|
|
46
|
+
(it costs context in every session) and keep such cards short. Everything else is the default,
|
|
47
|
+
`load = "as-needed"`: found by search. Changes that touch an always-loaded card (editing,
|
|
48
|
+
deleting, extending or superseding it, or marking a card always) are staged for the user's
|
|
49
|
+
approval by default when made through `write`; the user approves with `mindslice apply`.
|
|
50
|
+
Direct authoring of a new slice does not pass through that review buffer; do not infer standing
|
|
51
|
+
instructions from source documents just because they contain imperative wording.
|
|
52
|
+
|
|
53
|
+
## Aliases: the words someone will search with
|
|
54
|
+
|
|
55
|
+
`search()` is lexical: a card is found only by words it contains (stemmed, case-folded; Chinese,
|
|
56
|
+
Japanese and other unspaced scripts match by character pairs). Someone who has never read the card
|
|
57
|
+
searches with *their* words, not yours. `aliases` are indexed just below the title, so they are the
|
|
58
|
+
place to put the other words for the same thing. Aim for 3-8 useful short aliases per card,
|
|
59
|
+
without padding when fewer suffice:
|
|
60
|
+
|
|
61
|
+
- **Synonyms and near-synonyms** of the title's key terms (`exception policy` for error handling).
|
|
62
|
+
- **The asker's phrasing**: the symptom, task or goal that sends someone to this card, as they
|
|
63
|
+
would say it (`500 errors`, `what to return when a request fails`), including colloquial wording.
|
|
64
|
+
- **Abbreviations and expansions** both ways, and spelling variants (`RFC 7807`, `problem details`).
|
|
65
|
+
- **Former names** of renamed things, and names from tools people know (`error middleware`).
|
|
66
|
+
- **The team's other working language**, if it has one: the terms a colleague would use for this
|
|
67
|
+
topic in that language (`错误处理` on an English card, `error handling` on a Chinese one).
|
|
68
|
+
|
|
69
|
+
Avoid:
|
|
70
|
+
|
|
71
|
+
- Words already in the title or summary: they are indexed already.
|
|
72
|
+
- Generic words that fit most cards (`process`, `policy`, `guide`, `team`, `规范`, `流程`): they make
|
|
73
|
+
unrelated cards match.
|
|
74
|
+
- An alias another card in the same slice already uses (`validate()` warns), or one that equals
|
|
75
|
+
another card's path: aliases are also link and `read()` targets.
|
|
76
|
+
|