Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,8 @@ behind it, and any flags — and writes the proposal to `.tracegrad/`. Nothing
touches your prompt until `tracegrad apply`. `apply --revert` restores the
snapshot taken before the write.

Harness loop (Claude Code / Pi): [`skills/tracegrad-harness/`](skills/tracegrad-harness/).

Two more commands exist for staged use: `tracegrad attribute` runs the paid
attribution pass alone and caches it, and `tracegrad propose` then produces the
proposal for the cost of a single synthesis call. `tracegrad trends` compares
Expand Down
35 changes: 35 additions & 0 deletions skills/tracegrad-harness/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Tracegrad harness skill pack

Harness loop for Claude Code / Pi. Core stays lean. Sidecars live beside the user repo, never under `src/tracegrad/`. No Kitaru in this pack.

`tracegrad run` does not write the prompt. Harness apply is only `tracegrad apply --accept <HUMAN_OR_POLICY_INDICES>` after a human or policy names those indices — procedure in [review-edits](review-edits/SKILL.md). Never invent accepts.

CLI flags: `tracegrad <cmd> --help`. Manifest, `.tracegradrc`, and project state: repo README.

## Loop

1. [import-traces](import-traces/SKILL.md) — adapt-in a user-named export → JSONL
2. [propose-edits](propose-edits/SKILL.md) — estimate, then run (or attribute + propose); cards on disk
3. [review-edits](review-edits/SKILL.md) — show cards; `--accept` after human- or policy-named indices
4. [export-prompt](export-prompt/SKILL.md) — adapt-out after apply wrote the template
5. [next-batch](next-batch/SKILL.md) — conductor over 1–4, then `status` / `trends`

## Reach

| Situation | Skill |
| --- | --- |
| import traces, adapt-in, JSONL from a store | [import-traces](import-traces/SKILL.md) |
| estimate, propose, run, attribute | [propose-edits](propose-edits/SKILL.md) |
| cards, review, accept, apply, policy | [review-edits](review-edits/SKILL.md) |
| export / adapt-out the applied prompt | [export-prompt](export-prompt/SKILL.md) |
| next batch, close the loop | [next-batch](next-batch/SKILL.md) |

JSONL ingest rules: [import-traces/jsonl-contract.md](import-traces/jsonl-contract.md).

## Sidecars and policy

Copy [examples/](examples/) beside the user repo:

- `sidecar-adapt-in.py` — `FIELD_MAP` = Tracegrad field → foreign path
- `sidecar-adapt-out.py` — applied template → user path
- `policy.commented.toml` — agent-side apply gate (unattended apply off by default; `accept` is a TOML integer array). Core does not load this file.
38 changes: 38 additions & 0 deletions skills/tracegrad-harness/examples/policy.commented.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Tracegrad harness apply policy — EXAMPLE
#
# Agent-side gate for review-edits (next-batch follows that skill).
# NOT loaded by Tracegrad core. Core still only writes the prompt via
# `tracegrad apply --accept <HUMAN_OR_POLICY_INDICES>`.
#
# Copy to the project (suggested name: tracegrad-apply-policy.toml) and
# edit. Unattended apply stays OFF until a human sets unattended_apply
# and names accept indices. Missing file, false, or ambiguous → stop
# and ask. A human may still name indices for attended --accept.
# Never invent --accept values. There is no allow_all.

# Fully unattended apply is OFF by default. Leave this false (or omit it).
unattended_apply = false

# Card indices for `tracegrad apply --accept`. Empty / omitted / commented
# means do not apply. The agent must not guess, rank, or "take the safe ones".
# Example after a human named cards 0 and 2:
# accept = [0, 2]
accept = []

# DELETE edits: refuse unless a human explicitly sets this true.
allow_delete = false

# Instruction ids the agent must not apply, even if they survived core gates.
# Complements `.tracegradrc` neverDelete (which core already enforces).
# Example: neverDelete = ["prompt/identity"]
neverDelete = []

# If the proposal's tokens_after would exceed this, stop and ask.
# Uncomment and set a positive integer to enable.
# token_ceiling = 4000

# Optional: pin a run. If omitted, the skill uses the latest proposal.
# run_id = "run-0001"

# Do not set a flag that means "accept every card". There is no allow_all
# here on purpose. `tracegrad apply --all` is not a policy default.
136 changes: 136 additions & 0 deletions skills/tracegrad-harness/examples/sidecar-adapt-in.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""Sidecar adapt-in: map a foreign trace export to Tracegrad JSONL.

Copy this file next to the user repo. Do not move it into src/tracegrad/.
The user pipeline stays unchanged; this adapter is the only place that
learns the foreign field names. This stub is not a vendor integration.

One JSON object per line; ingest forbids extra keys. Required: trace_id,
input, output, judge.score in [0, 1], judge.rationale, prompt_hash.
Optional meta.model. Ingest drops duplicate trace_ids, rationales under
24 usable characters, and non-dominant prompt_hash.

FIELD_MAP (or --map-json) is Tracegrad field → foreign dotted path.
"""

from __future__ import annotations

import argparse
import json
import sys
from pathlib import Path
from typing import Any, Mapping

# Tracegrad field → foreign dotted path. Edit per project.
FIELD_MAP: dict[str, str] = {
"trace_id": "id",
"input": "prompt",
"output": "response",
"judge.score": "score",
"judge.rationale": "rationale",
"prompt_hash": "prompt_hash",
"meta.model": "model",
}


def _get(record: Mapping[str, Any], path: str) -> Any:
current: Any = record
for part in path.split("."):
if not isinstance(current, Mapping) or part not in current:
return None
current = current[part]
return current


def adapt_record(record: Mapping[str, Any], field_map: Mapping[str, str]) -> dict[str, Any]:
"""Map one foreign object to a Tracegrad trace dict. Drop incomplete rows."""

def mapped(key: str) -> Any:
source = field_map.get(key)
if not source:
return None
return _get(record, source)

trace_id = mapped("trace_id")
rationale = mapped("judge.rationale")
prompt_hash = mapped("prompt_hash")
score = mapped("judge.score")
if trace_id is None or rationale is None or prompt_hash is None or score is None:
return {}
try:
score_f = float(score)
except (TypeError, ValueError):
return {}
if not 0.0 <= score_f <= 1.0:
return {}

out: dict[str, Any] = {
"trace_id": str(trace_id),
"input": "" if mapped("input") is None else str(mapped("input")),
"output": "" if mapped("output") is None else str(mapped("output")),
"judge": {"score": score_f, "rationale": str(rationale)},
"prompt_hash": str(prompt_hash),
}
model = mapped("meta.model")
if model:
out["meta"] = {"model": str(model)}
return out


def iter_records(path: Path) -> list[Mapping[str, Any]]:
text = path.read_text(encoding="utf-8")
stripped = text.lstrip()
if stripped.startswith("["):
payload = json.loads(text)
if not isinstance(payload, list):
raise SystemExit(f"{path}: expected a JSON array")
return [row for row in payload if isinstance(row, Mapping)]
rows: list[Mapping[str, Any]] = []
for line_number, line in enumerate(text.splitlines(), start=1):
if not line.strip():
continue
row = json.loads(line)
if not isinstance(row, Mapping):
raise SystemExit(f"{path}:{line_number}: expected a JSON object")
rows.append(row)
return rows


def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--source", required=True, help="foreign JSON or JSONL export")
parser.add_argument("--out", required=True, help="Tracegrad JSONL destination")
parser.add_argument(
"--map-json",
default=None,
help="optional JSON object overriding FIELD_MAP",
)
args = parser.parse_args(argv)

field_map = dict(FIELD_MAP)
if args.map_json:
override = json.loads(Path(args.map_json).read_text(encoding="utf-8"))
if not isinstance(override, dict):
print("tracegrad sidecar: --map-json must be an object", file=sys.stderr)
return 1
field_map.update({str(k): str(v) for k, v in override.items()})

written = 0
skipped = 0
destination = Path(args.out)
destination.parent.mkdir(parents=True, exist_ok=True)
with destination.open("w", encoding="utf-8") as handle:
for record in iter_records(Path(args.source)):
adapted = adapt_record(record, field_map)
if not adapted:
skipped += 1
continue
handle.write(json.dumps(adapted, ensure_ascii=False) + "\n")
written += 1

print(f"wrote {written} traces to {destination} ({skipped} skipped)", file=sys.stderr)
return 0 if written else 1


if __name__ == "__main__":
raise SystemExit(main())
53 changes: 53 additions & 0 deletions skills/tracegrad-harness/examples/sidecar-adapt-out.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
#!/usr/bin/env python3
"""Sidecar adapt-out: copy the applied Tracegrad template to the user path.

Run only after `tracegrad apply` has written the manifest template.
Copy this file next to the user repo. Do not move it into src/tracegrad/.
This adapter does not apply edits and does not invent a destination.

If --from and --to are the same path, this is a no-op. That is the usual
case when the app already loads the manifest `template_file`.
"""

from __future__ import annotations

import argparse
import shutil
import sys
from pathlib import Path


def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--from",
dest="source",
required=True,
help="applied template (manifest template_file, resolved)",
)
parser.add_argument(
"--to",
dest="destination",
required=True,
help="path the user pipeline actually loads",
)
args = parser.parse_args(argv)

source = Path(args.source)
destination = Path(args.destination)
if not source.is_file():
print(f"tracegrad sidecar: applied template not found: {source}", file=sys.stderr)
print("apply first; this adapter does not write the prompt itself", file=sys.stderr)
return 1
if source.resolve() == destination.resolve():
print(f"adapt-out no-op: {source} is already the user path", file=sys.stderr)
return 0

destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(source, destination)
print(f"copied {source} -> {destination}", file=sys.stderr)
return 0


if __name__ == "__main__":
raise SystemExit(main())
36 changes: 36 additions & 0 deletions skills/tracegrad-harness/export-prompt/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
name: export-prompt
description: "Adapt-out the applied template to a user-named path. Invoke for export after apply. Not apply."
---

# Export prompt

Sidecar adapt-out: copy the template `tracegrad apply` already wrote to the path the user's app loads. Apply is [`../review-edits/SKILL.md`](../review-edits/SKILL.md). Export is not a Tracegrad subcommand.

Skip (successful no-op) when the manifest `template_file` **is** the user path. Do not copy a proposed, unapplied template.

## Steps

1. **Confirm apply for this run.** Look for a new prompt hash on apply stdout, a new line in `.tracegrad/ledgers/applied.jsonl`, and a snapshot under `.tracegrad/snapshots/`.

Done: at least one of those exists. If none, stop and send the user to `review-edits`.

2. **Source.** Resolve manifest `template_file` against the same `--base-directory` used at apply (`tracegrad apply --help`). The source is the path apply printed (`applied … to <template>`).

Done: that file exists. If it is missing or was edited after apply, stop — do not copy an out-of-band file.

3. **Destination.** Use only a path the user named (config, flag, or existing sidecar default). Do not guess a production path.

Done: destination path is explicit.

4. **Adapt-out.** Copy [`../examples/sidecar-adapt-out.py`](../examples/sidecar-adapt-out.py) beside the user repo if needed, then:

```sh
python sidecar-adapt-out.py \
--from path/from/manifest/prompt.md \
--to /path/the/user/app/loads/prompt.md
```

Overwrite only that destination. Same resolved `--from` and `--to` is a no-op (exit 0). Do not vendor a prompt store into `src/tracegrad/`.

Done: sidecar exit 0, and either the destination bytes match the source or the adapter printed the same-path no-op. Report source, destination, and that core was not modified.
36 changes: 36 additions & 0 deletions skills/tracegrad-harness/import-traces/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
---
name: import-traces
description: "Adapt-in a user-named store export to Tracegrad JSONL. Invoke for import traces."
---

# Import traces

JSONL shape and ingest drops: [jsonl-contract.md](jsonl-contract.md). Adapter lives beside the user repo, not under `src/tracegrad/`. This skill does not run analysis or apply.

## Steps

1. **Project state.** Run `tracegrad init` if `.tracegrad/` is missing.

Done: `.tracegrad/` exists (init exit 0, or the directory was already there).

2. **Source.** Use only the export path, directory, or command the user named. Do not scrape an unnamed production API.

Done: that source path is recorded.

3. **Sidecar.** Copy [`../examples/sidecar-adapt-in.py`](../examples/sidecar-adapt-in.py) beside the user repo (or edit their existing adapter). Fill `FIELD_MAP` (Tracegrad field → foreign dotted path): `trace_id` → vendor id path, `input`/`output` → prompt/response paths, `judge.score`/`judge.rationale` → score/rationale paths, `prompt_hash` → prompt-version path. Then:

```sh
python sidecar-adapt-in.py --source /path/to/user-export --out batch.jsonl
```

Hand-write JSONL only when the batch is tiny **and** the user asked. Unknown store format → stop and ask; do not add a connector under `src/tracegrad/`.

Done: sidecar exit 0, `batch.jsonl` exists, stderr reports `wrote N traces` with N > 0.

4. **Sanity.** Check line count, unique `trace_id`, rationale usable-length, and `prompt_hash` values (see [jsonl-contract.md](jsonl-contract.md)). Deduplicate in the adapter. Fix the map rather than pad fake rationales. Several hashes → warn and split batches if the user wants every version.

Done: a short note lists N, the source, and the `prompt_hash` values seen. Empty export → stop here; do not call `tracegrad run`.

5. **Handoff.** Give the JSONL path to `propose-edits` or `next-batch`.

Done: JSONL path is in the reply. This skill ran no `tracegrad` command except `init`.
28 changes: 28 additions & 0 deletions skills/tracegrad-harness/import-traces/jsonl-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# Tracegrad JSONL contract

Ingest reads **one JSON object per line**. Extra keys are rejected (`extra: forbid`). A manifest is a separate JSON file for `tracegrad run --manifest`, not a JSONL row.

## Object shape

```json
{
"trace_id": "t-001",
"input": "user or task text",
"output": "model response",
"judge": {"score": 0.4, "rationale": "why this score, in words"},
"prompt_hash": "sha256:… of the prompt version that produced this trace"
}
```

| Field | Rule |
| --- | --- |
| `trace_id` | Non-empty string. Unique within the file. Later duplicates drop as `duplicate-trace-id`. |
| `input` / `output` | Strings (may be empty). `output` is what violations quote. |
| `judge.score` | Number in `[0.0, 1.0]`. |
| `judge.rationale` | Non-empty string. Ingest drops rationales with fewer than **24** usable characters (`rationale-below-quality-floor`). Usable = stripped length ≥ 24 **and** at least one letter. A score without a real rationale is not a batch. |
| `prompt_hash` | Non-empty string identifying the prompt version. Mixed hashes: only the dominant partition is kept (`prompt-hash-partition`). |
| `meta` | Optional. Only `meta.model` is allowed. Mixed models are reported, not dropped. |

Do not emit `null` for required strings. Do not put the judge score in a different shape (`label`, `pass`, nested vendor blobs) — map those in the sidecar.

`FIELD_MAP` in the adapt-in sidecar is **Tracegrad field → foreign dotted path** (e.g. `trace_id` → vendor id path, `input`/`output` → prompt/response paths).
Loading
Loading