-
Notifications
You must be signed in to change notification settings - Fork 0
feat: docket construct, a ledger bootstrapped from written history #15
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
27e6711
44d4fc6
e75fe7c
4075024
e2d074d
ecd2cad
6999978
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,192 @@ | ||
| """The construct subcommand: stage proposals, review them, accept them. | ||
|
|
||
| Three steps, never one command. Extraction proposes, a human reads, acceptance | ||
| writes. Collapsing them would put records in the ledger nobody approved, and | ||
| approval is the whole thing the ledger records. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import argparse | ||
| import re | ||
| import subprocess | ||
| import sys | ||
| import textwrap | ||
| from pathlib import Path | ||
|
|
||
| from docket import env | ||
|
|
||
| # docket.construct is imported inside the function bodies below, never here. A | ||
| # SessionStart hook builds this parser on every session, and it must not pay for | ||
| # a subpackage only this command uses. | ||
|
|
||
| MISSING_SDK = ( | ||
| "docket construct needs the openai SDK:\n" | ||
| " uv pip install openai (or: python3 -m pip install --user openai)\n" | ||
| "It is the only command that does. Every other command, and the " | ||
| "SessionStart hook, run without it." | ||
| ) | ||
|
|
||
| _WIDTH = 88 | ||
| _TEXT_MAX = 400 | ||
|
|
||
| # Every string in a proposal came from a model. Control characters would reach | ||
| # the terminal verbatim, and an escape sequence can repaint a reviewer's screen. | ||
| _CONTROL = re.compile(r"[\x00-\x08\x0b-\x1f\x7f-\x9f]") | ||
|
|
||
|
|
||
| def _safe(text: str, limit: int = _TEXT_MAX) -> str: | ||
| """Model text fit to print: no control characters, bounded length.""" | ||
| clean = _CONTROL.sub("", str(text)) | ||
| return clean if len(clean) <= limit else clean[:limit] + "..." | ||
|
|
||
|
|
||
| def _docket_dir() -> Path: | ||
| """The directory holding both the staging file and the ledger. | ||
|
|
||
| A project's own .docket wins. Construct's main case is a project whose | ||
| ledger does not exist yet, and there ledger_path() answers with the global | ||
| store, which is nowhere the user would look for their own proposals. Pairing | ||
| the two matters: proposals in one place and the records they became in | ||
| another is worse than either choice alone. | ||
| """ | ||
| local = env.project_root() / ".docket" | ||
| if local.is_dir(): | ||
| return local | ||
| return env.ledger_path().parent | ||
|
|
||
|
|
||
| def _staged_path() -> Path: | ||
| return _docket_dir() / "proposed.jsonl" | ||
|
|
||
|
|
||
| def _ledger_path() -> Path: | ||
| return _docket_dir() / env.LEDGER.name | ||
|
|
||
|
|
||
| def _live_paths() -> set[str]: | ||
| """Every tracked path in the repository, for resolving a record's scope.""" | ||
| try: | ||
| done = subprocess.run(["git", "ls-files", "-z"], | ||
| capture_output=True, timeout=10) | ||
| except (OSError, subprocess.SubprocessError): | ||
| return set() | ||
| if done.returncode != 0: | ||
| return set() | ||
| text = done.stdout.decode("utf-8", errors="surrogateescape") | ||
| return {path for path in text.split("\0") if path} | ||
|
|
||
|
|
||
| def review(staged: Path, live: set[str]) -> int: | ||
| """Print staged proposals grouped by source, strongest first. | ||
|
|
||
| Each one carries its anchor, which is verbatim source text, so a reader can | ||
| open the document and check the record against the line it came from. | ||
| """ | ||
| from docket.construct import stage | ||
|
|
||
| try: | ||
| proposals = stage.read(staged) | ||
| except stage.StageError as exc: | ||
| print(f"docket: {exc}", file=sys.stderr) | ||
| return 1 | ||
| groups = stage.review_groups(proposals, live) | ||
| if not groups: | ||
| print("docket: nothing staged for review") | ||
| return 0 | ||
|
|
||
| resolved, scoped = stage.resolution_rate( | ||
| [p for p in proposals if p.get("state") == "staged"], live) | ||
| print(f"# scope resolves: {resolved}/{scoped}") | ||
| print(f"# staged: {sum(len(items) for _, items in groups)}\n") | ||
|
|
||
| for name, items in groups: | ||
| print(f"## {_safe(name)}") | ||
| for item in items: | ||
| mark = "" if stage.resolves(item, live) else " [unresolved scope]" | ||
| print(f" {item['kind']} ({item.get('confidence', 'low')}){mark}") | ||
| for line in textwrap.wrap(_safe(item["text"]), width=_WIDTH): | ||
| print(f" {line}") | ||
| if item.get("choice"): | ||
| for line in textwrap.wrap(f"choice: {_safe(item['choice'])}", | ||
| width=_WIDTH): | ||
| print(f" {line}") | ||
| if item.get("scope"): | ||
| print(f" scope: {_safe(', '.join(item['scope']))}") | ||
| print(f" anchor: {_safe(item['anchor'])}") | ||
| print(f" key: {item['key'][:12]}") | ||
| print() | ||
| return 0 | ||
|
|
||
|
|
||
| def accept_staged(staged: Path, ledger: Path, source: str | None = None) -> int: | ||
| """Append every accepted proposal through the ordinary ledger writer.""" | ||
| from docket.construct import accept | ||
|
|
||
| written, skipped = accept.run(staged, ledger, source=source) | ||
| if not written and not skipped: | ||
| print("docket: nothing accepted yet; mark proposals accepted first") | ||
| return 0 | ||
| print(f"docket: wrote {written} record{'' if written == 1 else 's'}") | ||
| if skipped: | ||
| print(f"docket: skipped {skipped}; a record whose support was not " | ||
| "accepted would lose its grounds") | ||
| return 0 | ||
|
|
||
|
|
||
| def cmd_construct(args: argparse.Namespace) -> int: | ||
| staged = _staged_path() | ||
|
|
||
| if args.review and args.accept: | ||
| print("docket: --review and --accept are separate steps", file=sys.stderr) | ||
| return 2 | ||
| if args.paths and (args.review or args.accept): | ||
| print("docket: extraction and review are separate steps; run construct " | ||
| "with paths first, then --review", file=sys.stderr) | ||
| return 2 | ||
|
|
||
| if args.review: | ||
| return review(staged, _live_paths()) | ||
| if args.accept: | ||
| return accept_staged(staged, _ledger_path(), source=args.source) | ||
| if not args.paths: | ||
| print("docket: name the documents to read, or pass --review or --accept", | ||
| file=sys.stderr) | ||
| return 2 | ||
|
|
||
| return _extract(args, staged) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win Reject
Validate this combination before dispatching to 🤖 Prompt for AI Agents |
||
|
|
||
|
|
||
| def _extract(args: argparse.Namespace, staged: Path) -> int: | ||
| """Run both passes and stage the result. | ||
|
|
||
| The SDK import lives here, inside the one command body that needs it. | ||
| """ | ||
| from docket.construct import run, stage | ||
|
|
||
| # A dry run reads nothing and calls nothing, so it must not demand the | ||
| # dependency. It is the one command someone runs to see what would happen. | ||
| if not args.dry_run: | ||
| try: | ||
| import openai # noqa: F401 | ||
| except ImportError: | ||
| print(MISSING_SDK, file=sys.stderr) | ||
| return 1 | ||
|
|
||
| try: | ||
| proposals, report = run.two_pass(args.paths, jobs=args.jobs, | ||
| dry_run=args.dry_run) | ||
| except run.RunError as exc: | ||
| print(f"docket: {exc}", file=sys.stderr) | ||
| return 1 | ||
|
|
||
| for line in report: | ||
| print(line) | ||
| if args.dry_run: | ||
| return 0 | ||
|
|
||
| merged = stage.merge(stage.read(staged), proposals) | ||
| stage.write(staged, merged) | ||
|
Comment on lines
+183
to
+189
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win A partial extraction failure still feeds an incomplete proposal set into 🤖 Prompt for AI Agents |
||
| print(f"docket: staged {len(merged)} proposals in {staged}") | ||
| print("docket: read them with docket construct --review") | ||
| return 0 | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| """Bootstrap a ledger from a project's written history. | ||
|
|
||
| Construct never writes to the ledger. It stages proposals, a human accepts | ||
| them, and that acceptance is the approval the ledger records. | ||
| """ |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,109 @@ | ||
| """Accepted proposals becoming ledger records. | ||
|
|
||
| The only place construct touches the ledger, and it writes through the ordinary | ||
| writer so numbering and locking stay honest. Acceptance is the approval the | ||
| ledger exists to record; extraction never is. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from pathlib import Path | ||
|
|
||
| from docket.construct import stage | ||
| from docket.ledger import append, make_record | ||
|
|
||
| AUTHOR = "docket-construct" | ||
|
|
||
|
|
||
| def _order(proposals: list[dict]) -> list[dict]: | ||
| """Supported records first, so a key resolves to an id by the time it is | ||
| needed. Support is acyclic by then, so a stable pass suffices.""" | ||
| by_key = {p["key"]: p for p in proposals} | ||
| done: list[dict] = [] | ||
| seen: set[str] = set() | ||
|
|
||
| def visit(item: dict) -> None: | ||
| if item["key"] in seen: | ||
| return | ||
| seen.add(item["key"]) | ||
| for group in item.get("supports") or []: | ||
| for key in group: | ||
| if key in by_key: | ||
| visit(by_key[key]) | ||
| for key in item.get("supersedes") or []: | ||
| if key in by_key: | ||
| visit(by_key[key]) | ||
| done.append(item) | ||
|
|
||
| for item in proposals: | ||
| visit(item) | ||
| return done | ||
|
|
||
|
|
||
| def _rationale(item: dict) -> str: | ||
| """The record's own rationale, with the document it was read from. | ||
|
|
||
| A later reader has to be able to tell a constructed record from one a human | ||
| wrote at the time. | ||
| """ | ||
| source = item["source"]["path"] | ||
| body = (item.get("rationale") or "").strip() | ||
| note = f"Constructed from {source}." | ||
| return f"{body} {note}".strip() | ||
|
|
||
|
|
||
| def run(staged: Path, ledger: Path, source: str | None = None) -> tuple[int, int]: | ||
| """Write every accepted proposal, and report written and skipped counts. | ||
|
|
||
| Incremental and resumable: each proposal carries its own state, so | ||
| accepting one document's records leaves the rest staged. A 520-record | ||
| review spans several sittings, and a run that must finish in one pass gets | ||
| rubber-stamped instead of read. | ||
| """ | ||
| proposals = stage.read(staged) | ||
| wanted = [p for p in proposals | ||
| if p.get("state") == "accepted" | ||
| and (source is None or p["source"]["path"] == source)] | ||
|
|
||
| ids: dict[str, str] = {} | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift Persist the proposal-key-to-ledger-ID mapping.
A failure after Store the construct key with the ledger record or another durable mapping. Rebuild Also applies to: 99-105 🤖 Prompt for AI Agents |
||
| written = skipped = 0 | ||
|
|
||
| for item in _order(wanted): | ||
| supports = [] | ||
| dropped = False | ||
| for group in item.get("supports") or []: | ||
| resolved = [ids[key] for key in group if key in ids] | ||
| if len(resolved) != len(group): | ||
| # A record supporting one nobody accepted would enter the | ||
| # ledger claiming grounds that are not there. | ||
| dropped = True | ||
| break | ||
| if resolved: | ||
| supports.append(resolved) | ||
| if dropped: | ||
| skipped += 1 | ||
| continue | ||
|
|
||
| supersedes = [ids[key] for key in item.get("supersedes") or [] if key in ids] | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win Do not silently remove unresolved supersession targets. If an accepted proposal supersedes a proposal that was not accepted, this list comprehension writes the new record without the reviewed supersession relation. The ledger then states different semantics from the staged proposal. Require every supersession key to resolve. If any key is unresolved, skip the record and retain its accepted state, as the support path already does. 🤖 Prompt for AI Agents |
||
|
|
||
| record = make_record( | ||
| item["kind"], | ||
| item["text"], | ||
| choice=item.get("choice") or None, | ||
| scope=list(item.get("scope") or []), | ||
| rationale=_rationale(item), | ||
| supports=supports or None, | ||
| supersedes=supersedes or None, | ||
| author=AUTHOR, | ||
| ) | ||
| stored = append(ledger, record) | ||
| ids[item["key"]] = stored["id"] | ||
| item["state"] = "written" | ||
| written += 1 | ||
| # After each append, never once at the end. There is no transaction | ||
| # across N appends, so a failure on append k would otherwise commit k-1 | ||
| # records while the stage still calls them accepted. The user would see | ||
| # the error, rerun, and append a second copy of every one. | ||
| stage.write(staged, proposals) | ||
|
|
||
| return written, skipped | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Return repository-relative paths from Git.
When the command runs from a repository subdirectory,
git ls-filescan return paths relative to that subdirectory. Construct scopes use repository-relative paths, so valid scopes can appear unresolved.Add
--full-nameor run Git withcwd=env.project_root().Proposed fix
📝 Committable suggestion
🧰 Tools
🪛 ast-grep (0.45.3)
[error] 44-45: Command coming from incoming request
Context: subprocess.run(["git", "ls-files", "-z"],
capture_output=True, timeout=10)
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').
(subprocess-from-request)
🪛 Ruff (0.16.4)
[warning] 45-45:
subprocess.runwithout explicitcheckargumentAdd explicit
check=False(PLW1510)
[error] 45-45: Starting a process with a partial executable path
(S607)
🤖 Prompt for AI Agents