conductor(track): nagent_review_v3.1 thicken §3 Hooks cluster
This commit is contained in:
@@ -456,18 +456,213 @@ The shape tag map: `[I]` for inspectable triggers and config, `[S]` for string c
|
||||
|
||||
**Source:** nagent `a4fb141` (`bin/nagent:1442-1484` + `:1607-1625` + `:1922-1927` + `:2806-2825` + `:3167-3185`, `config.example.json:6-8`, `tests/test_nagent.py:870-960`); plus both case-study harness scripts (`https://raw.githubusercontent.com/macton/pep-copt/main/prove-optimized-harness.sh`, `https://raw.githubusercontent.com/macton/differentiable-collisions-optc/main/prove-optimized-harness.sh`).
|
||||
**One-liner:** Per-turn ground-truth injection. A hook runs at the top of every turn (before the model speaks) or after every structured edit; its measured output — exit code, stdout, stderr, or "(no output)" — enters the conversation as a labeled block, so the model responds against measured state instead of its recollection. The case-study repos ARE the hooks: `prove-optimized-harness.sh` is the command wired into `--hook-per-run`.
|
||||
**Pattern(s) vs v2.3:** NEW. v2.3 had the conversation-without-ground-truth loop (the model's word was the only word). v3 introduces the per-turn measurement primitive that breaks the loop's dependence on the model's self-reporting. EXTENDS v2.3 Pattern 5 ("the loop") with a measurement injection surface. The case-study methodology cluster (§9) elaborates this into a reusable 5-element pattern.
|
||||
**Manual Slop implications:** Manual Slop has analogous hooks already — Tier 4 QA error interception (per `docs/guide_ai_client.md`) and the `ApiHookClient` test harness (per `docs/guide_api_hooks.md`). The generalization is per-turn, not per-error: a Manual Slop hook could be wired into the `run_agent_loop` equivalent (`dispatch_inference`) to inject a status block (build status, test status, dependency-check status) at the top of every turn. The "failure is data, not control flow" principle from `conductor/code_styleguides/error_handling.md` already encodes the "exit code + stderr surfaced" invariant.
|
||||
**Decision candidate:** NEW Candidate 19 (MEDIUM). "Per-turn ground-truth hook for Manual Slop": add a per-turn hook primitive that runs a configured command (CLI > config > disabled) at the top of every `send_result()` and injects a `<hook-per-run>` block; honor the CLI > config > disabled precedence and the failing/quiet-hook-surfaces-output invariant. See `decisions.md` Candidate 19.
|
||||
**Cross-refs:** §9 Case-study methodology (the 5-element pattern; hooks are the substrate), §10 PEP case study (the pep-copt harness), §11 Collisions case study (the collisions harness). These three together surface the full abstraction.
|
||||
**Pattern summary:** Hooks introduce a per-turn measurement primitive that breaks the conversation's dependence on the model's self-reporting. The abstraction is a three-piece composition: resolve, invoke, inject. `resolve_hooks` enforces CLI > config > disabled precedence; `run_hook` invokes the command and captures exit code + stdout + stderr + "(no output)" when silent; the injection sites are the conversation (per-run at the top of every turn before `call_llm`; per-file-edit after `<nagent-file-patch>` or `<nagent-write>` in `--file-edit` mode). The case-study harness scripts are the proof that hooks work as intended: both implement the same skeleton (log + summary + enforcing gate) with different proof contracts. The data shape of a hook result is a labeled block with exit code, optional path, optional stdout, optional stderr, or "(no output)" — the model's context grows by a measured block, not by the model's word. The `{ssdl}` `[B]` (boundary) marker captures the abstraction: the hook is the boundary where the model's context meets the measured world; the failure of a measurement is data the model can act on, not a control-flow exception.
|
||||
|
||||
#### §3.1 What Hooks Add
|
||||
|
||||
Hooks introduce a per-turn measurement primitive that breaks the conversation's dependence on the model's self-reporting. Before hooks, the conversation was a closed loop: the model said something, the user read it, the user replied, the model said something else. The only ground truth was the model's word. With hooks, the conversation is an open loop: a measurement command runs at the top of every turn, its output enters the conversation as a labeled block, and the model responds against measured state instead of its recollection.
|
||||
|
||||
The three pieces of the hooks abstraction:
|
||||
|
||||
1. **Resolve** — `resolve_hooks(cli_per_run, cli_per_file_edit, config_path)` enforces the CLI > config > disabled precedence. The CLI is the experiment's override (one-shot, the user's immediate need); the config is the project's default (persistent, the project's convention); empty means off. The resolve function is pure: it returns a tuple of (per_run_command, per_file_edit_command), each of which is either a string or None.
|
||||
2. **Invoke** — `run_hook(command, label, path=None)` invokes the command via subprocess, captures exit code + stdout + stderr, and surfaces "(no output)" when silent. The function never raises on a non-zero exit code; the failure is data, not control flow. The output is wrapped in a labeled block: `<label exit_code="N" [path="P"]>...</label>`. The label is the hook's name (e.g., "hook-per-run", "hook-per-file-edit"); the path is the file being edited (for per-file-edit hooks).
|
||||
3. **Inject** — the labeled block is appended to the conversation file. The injection sites are explicit: per-run at the top of every turn before `call_llm` (`bin/nagent:1922-1927`); per-file-edit after `<nagent-file-patch>` (`bin/nagent:1607-1611`) or `<nagent-write>` in `--file-edit` mode (`bin/nagent:1618-1625`). Scratch writes are not file edits — the comment at `bin/nagent:1618-1620` notes the distinction explicitly: "A `<nagent-write>` only edits a real file in per-file-edit mode ... in main mode it writes scratch, which is not a file edit worth a verify hook".
|
||||
|
||||
The case-study harness scripts are the proof that hooks work as intended. Both scripts implement the same skeleton: log + summary + enforcing gate. The log records every step with verbose mode for streaming; the summary collects every verdict at the end (`set +e` so a failing gate still prints); the enforcing gate collects the verdicts and decides pass/fail.
|
||||
|
||||
#### §3.2 The Resolve Precedence
|
||||
|
||||
The CLI > config > disabled precedence is the contract between the experiment and the project. The CLI is the experiment's override: a user running `nagent --hook-per-run='make test'` is overriding the project's default hook for this invocation only. The config is the project's default: `config.json` says `{"hook_per_run": "make test"}` and every invocation of `nagent` in this project uses that hook. Disabled means off: if neither CLI nor config specifies the hook, the hook does not run, and the conversation has no per-run block.
|
||||
|
||||
The resolve function is pure: it returns a tuple of (per_run_command, per_file_edit_command), each of which is either a string or None. The implementation is at `bin/nagent:1466-1484`:
|
||||
|
||||
```
|
||||
resolve_hooks(cli_per_run, cli_per_file_edit, config_path) {
|
||||
config = load_json(config_path) if config_path else {}
|
||||
per_run = cli_per_run or config.get("hook_per_run") or None
|
||||
per_file_edit = cli_per_file_edit or config.get("hook_per_file_edit") or None
|
||||
// empty string in config means disabled (defensive: don't pass "" to subprocess)
|
||||
if per_run == "": per_run = None
|
||||
if per_file_edit == "": per_file_edit = None
|
||||
return (per_run, per_file_edit)
|
||||
}
|
||||
```
|
||||
|
||||
The "empty string means disabled" rule is defensive: an empty string in the config should not be passed to subprocess (which would invoke the shell with no command, producing unpredictable output). The resolve function normalizes empty strings to None, which the invoke function treats as "no hook this turn".
|
||||
|
||||
#### §3.3 The Invoke and Inject Cycle
|
||||
|
||||
The invoke function is the boundary between the conversation and the measured world. The function:
|
||||
|
||||
1. **Subprocess invocation.** If the command is None, return None (no hook this turn).
|
||||
2. **Capture exit code + stdout + stderr.** Use `subprocess.run(command, shell=True, capture_output=True, text=True)` to invoke the command. The exit code is the command's return code (0 = success, non-zero = failure). The stdout and stderr are the command's output.
|
||||
3. **Format the labeled block.** The output is wrapped in a labeled block: `<label exit_code="N" [path="P"]>stdout-text\n[stderr: stderr-text]\n[(no output)]</label>`. The "(no output)" marker is used when both stdout and stderr are empty (a silent success is still a measurable success).
|
||||
4. **Append to conversation.** The block is appended to the conversation file before the next `call_llm` (per-run) or after the file edit (per-file-edit).
|
||||
|
||||
A code-shape sketch using survey grammar:
|
||||
|
||||
```
|
||||
hook-result := <label exit_code="N" [path="P"]>
|
||||
stdout-text
|
||||
[stderr: stderr-text]
|
||||
[(no output)]
|
||||
</label>
|
||||
|
||||
run { command } :: hook-result {ssdl} [B] // boundary: failures surface, never hidden
|
||||
inject { hook-result, conversation } :: () // append to conversation file
|
||||
```
|
||||
|
||||
The `{ssdl}` `[B]` (boundary) marker captures the abstraction: the hook is the boundary where the model's context meets the measured world. The failure of a measurement is data the model can act on, not a control-flow exception. The model sees a failing hook's exit code + stderr, and can adjust its behavior accordingly.
|
||||
|
||||
#### §3.4 The Case-Study Harness Scripts
|
||||
|
||||
The case-study harness scripts are the proof that hooks work as intended. Both scripts implement the same skeleton: log + summary + enforcing gate. The skeleton:
|
||||
|
||||
1. **Log.** Record every step with verbose mode for streaming. The log is appended to a file (e.g., `OPTIMIZATION-LOG.md`) so the user can see the proof's progress in real time.
|
||||
2. **Summary.** Collect every verdict at the end. Use `set +e` so a failing gate still prints its verdict; the summary is a list of (gate, verdict) pairs.
|
||||
3. **Enforcing gate.** Collect the verdicts and decide pass/fail. The gate is the last step; it exits non-zero if any verdict is failing.
|
||||
|
||||
The PEP harness (`prove-optimized-harness.sh` for `macton/pep-copt`) has 9 steps and 5 enforcing gates:
|
||||
- **Identity baseline.** Run the reference implementation on the committed input; record the output (size in bytes, sha256). This is the "what the reference produces" baseline.
|
||||
- **Median-of-5 speedup.** Run the optimized implementation 5 times; record the median wall-clock time. Median (not mean) because outliers are not the optimization's fault.
|
||||
- **Decompression-time gate.** The decompression time must not regress (an optimization that makes compression faster but decompression slower is a net loss for users).
|
||||
- **Generalization.** The optimization must work on a held-out set of images (not just the committed input). This catches "tuned to the test" optimizations.
|
||||
- **Determinism.** The optimized output must be byte-identical across runs (a non-deterministic optimization is not reproducible).
|
||||
|
||||
The collisions harness (`prove-optimized-harness.sh` for `macton/differentiable-collisions-optc`) has 10 steps and 4 enforcing gates:
|
||||
- **Comparator with distance tolerance.** The optimized collision detection must agree with the reference to within a distance tolerance (1mm + 0.1% + conditional). Collision-flag identity is too strict (a face/edge contact has many equally-valid witness points).
|
||||
- **Contact-point certifier.** An independent contact-point certifier (`validate_contacts`) shares no solver code with the optimized implementation. This catches "they agree because they share the bug" failures.
|
||||
- **Precompute isolation.** The precompute stage (building the spatial acceleration structure) must be excluded from the measured speedup. The build stage cannot precompute the answer; the optimization log explains why.
|
||||
- **Determinism.** The optimized output must be byte-identical across runs.
|
||||
|
||||
Both harness scripts freeze the committed input via `sha256sum` before the run and re-check after — if the harness itself changes the input (a bug), it aborts. Both exclude precompute time from the measured speedup.
|
||||
|
||||
#### §3.5 The Hook Result Data Shape
|
||||
|
||||
The data shape of a hook result, using survey grammar:
|
||||
|
||||
```
|
||||
hook-result := <label exit_code="N" [path="P"]>
|
||||
stdout-text
|
||||
[stderr: stderr-text]
|
||||
[(no output)]
|
||||
</label>
|
||||
|
||||
fields:
|
||||
label: string # hook name (e.g., "hook-per-run", "hook-per-file-edit")
|
||||
exit_code: int # command's return code (0 = success)
|
||||
path: string? # file being edited (for per-file-edit hooks)
|
||||
stdout: string # command's stdout (may be empty)
|
||||
stderr: string # command's stderr (may be empty)
|
||||
no_output: bool # true if both stdout and stderr are empty
|
||||
|
||||
serialization:
|
||||
<{label} exit_code="{exit_code}"{ path? " path=\"{path}\"" : ""}>
|
||||
{stdout}
|
||||
{stderr? f"stderr: {stderr}" : ""}
|
||||
{no_output? "(no output)" : ""}
|
||||
</{label}>
|
||||
```
|
||||
|
||||
The shape is a labeled block with optional fields. The model reads the block as part of the conversation; the block is the "measurement" the model acts on. The failure of a measurement is data: a non-zero exit code + stderr text is actionable information; a silent success is "(no output)" — still measurable, still in the conversation.
|
||||
|
||||
#### §3.6 Per-Commit Detail
|
||||
|
||||
The one commit that built the hooks subsystem:
|
||||
|
||||
1. **`a4fb141` — Add per-turn and per-file-edit hooks.** Adds `bin/nagent:1442-1463` (`run_hook` function), `bin/nagent:1466-1484` (`resolve_hooks` function with CLI > config > disabled precedence), `bin/nagent:1607-1611` (`hook_per_file_edit` fires after `<nagent-file-patch>`), `bin/nagent:1618-1625` (`hook_per_file_edit` fires after `<nagent-write>` in `--file-edit` mode only), `bin/nagent:1922-1927` (`hook_per_run` fires at top of every turn, before `call_llm`), `bin/nagent:2806-2825` (`--hook-per-run` and `--hook-per-file-edit` CLI flags), `bin/nagent:3167-3185` (wiring into `run_agent_loop`), `config.example.json:6-8` (`hook_per_run` and `hook_per_file_edit` config keys), and `tests/test_nagent.py:870-960` (4 test functions covering the hook contract).
|
||||
|
||||
The commit is a "single-feature" commit: one commit adds the hooks abstraction, the resolve precedence, the invoke function, the inject sites, the CLI flags, the config keys, and the tests. There are no follow-up commits; the abstraction was complete in one commit. This is the same "abstraction-complete-in-one-commit" pattern v2.3 used for the harvest pipeline.
|
||||
|
||||
#### §3.7 Manual Slop Implications
|
||||
|
||||
The Manual Slop equivalents of the hooks are partial. The closest analogs are:
|
||||
- **Tier 4 QA error interception** (per `docs/guide_ai_client.md`) — when a tool call fails, the AI client intercepts the error, forwards it to a Tier 4 QA sub-agent, and injects a 20-word diagnostic summary into the worker history. This is a per-error hook, not a per-turn hook.
|
||||
- **The `ApiHookClient` test harness** (per `docs/guide_api_hooks.md`) — the `live_gui` fixture uses the Hook API to drive the application. The hook is the test, not the application.
|
||||
- **The `_predefined_callbacks` registry** (in `src/app_controller.py:531-617`) — exposes any App method as a `custom_callback` action. This is a hook into the app, not a hook into the conversation.
|
||||
|
||||
The Manual Slop analog lacks three of the four hooks invariants:
|
||||
|
||||
1. **No "per-turn" injection site.** Manual Slop's Tier 4 QA fires on tool-call failure, not at the top of every turn. A Manual Slop hook could be wired into the `run_agent_loop` equivalent (`dispatch_inference` in `src/ai_client.py`) to inject a status block (build status, test status, dependency-check status) at the top of every turn.
|
||||
2. **No "labeled block" data shape.** Manual Slop's Tier 4 QA injects a 20-word diagnostic summary as plain text, not a labeled block with exit code + stdout + stderr. The model sees a summary, not a measurement.
|
||||
3. **No "CLI > config > disabled" precedence for hooks.** Manual Slop's hooks are implicit (they fire on error); there is no explicit "configure a command to run at the top of every turn" mechanism.
|
||||
|
||||
The gap Manual Slop could close: a per-turn hook primitive that runs a configured command (CLI > config > disabled) at the top of every `send_result()` and injects a `<hook-per-run>` block. The command could be a status script (`make test`, `git status`, `npm run check`) that the user configures per-project. The model would see the status block at the top of every turn and respond against measured state.
|
||||
|
||||
The "failure is data, not control flow" principle from `conductor/code_styleguides/error_handling.md` already encodes the "exit code + stderr surfaced" invariant. The per-turn hook is the natural extension: every turn's status is data the model acts on, not an exception that aborts the loop.
|
||||
|
||||
#### §3.8 Honest Gaps
|
||||
|
||||
1. **The "subprocess reach" claim in `bin/nagent:2822-2824` — "A CLI flag applies to this invocation only; set it in the config file to apply it to delegated file-edit subprocesses too" — needs verification.** The implementation at `bin/nagent:3167-3185` wires the hooks into `run_agent_loop`'s `main()` call only; whether delegated file-edit subprocesses read the config separately is not visible in this diff. The v3.1 source-read pass should verify the subprocess reach.
|
||||
2. **The "default off" guarantee is not tested.** Both hooks default to off (CLI flag absent, config key absent or empty string). A regression test asserting "no CLI flag, no config key → both hooks are None" would harden the contract.
|
||||
3. **The `--hook-per-run` cost discipline ("point it at a fast status command") is documented in `--help` but not enforced.** The case-study harnesses use median-of-5 timing in their proofs, which is fast, but a user wiring up a 10-second status command would pay 10 seconds per turn. A future track could add a `--hook-per-run-max-seconds` config knob.
|
||||
4. **The interaction with the conversation safety net (§2) is not deep-dived.** The safety net's rebuild creates a new initial context, which would include the per-run hook block. The v3 cluster does not document how the safety net coordinates with the hook injection — does the rebuild preserve the per-run hook block? does the next checkpoint know about the hook state?
|
||||
5. **The interaction with the campaigns driver (§1) is not deep-dived.** The campaigns driver has its own 6 phases. A long-running campaign can have per-turn hooks that fire on every dispatched worker. The v3 cluster does not document how the campaigns driver coordinates with the hook injection — does the dispatched worker get the per-run hook block? does the campaign-level conversation have its own hook configuration?
|
||||
6. **The case-study harness scripts are not fully transcribed.** The v3 cluster cites the 9-step / 10-step structure and the 5 / 4 enforcing gates, but does not transcribe the full shell scripts. A v4 would transcribe both `prove-optimized-harness.sh` scripts in full and analyze their common skeleton + per-repo differences.
|
||||
7. **The hook result's serialization format is not specified for the model.** The `<label exit_code="N">...</label>` block is the implementation's serialization, but the model sees it as part of the conversation. The v3 cluster does not document how the model is expected to parse the block (does it treat the block as a system message? a user message? a tool result?). A v4 would document the model's expected parsing of the hook block.
|
||||
|
||||
#### §3.9 Code-Shape Sketch
|
||||
|
||||
The hooks abstraction, in survey-grammar SSDL notation, with shape tags:
|
||||
|
||||
```
|
||||
hook-result := { label: string, # [S] string
|
||||
exit_code: int, # [I] inspectable
|
||||
path: string?, # [S] optional
|
||||
stdout: string, # [S] string
|
||||
stderr: string, # [S] string
|
||||
no_output: bool } # [I] inspectable
|
||||
|
||||
serialization:
|
||||
<{label} exit_code="{exit_code}"{ path? f' path="{path}"' : ''}>
|
||||
{stdout}
|
||||
{stderr? f"stderr: {stderr}" : ''}
|
||||
{no_output? "(no output)" : ''}
|
||||
</{label}>
|
||||
|
||||
resolve { cli_per_run, cli_per_file_edit, config_path } {
|
||||
config = load_json(config_path) if config_path else {} # [B] boundary to file
|
||||
per_run = cli_per_run or config.get("hook_per_run") or None
|
||||
per_file_edit = cli_per_file_edit or config.get("hook_per_file_edit") or None
|
||||
if per_run == "": per_run = None # empty = disabled
|
||||
if per_file_edit == "": per_file_edit = None
|
||||
return (per_run, per_file_edit)
|
||||
}
|
||||
|
||||
run { command, label, path? } :: hook-result {ssdl} [B] # boundary: failures surface
|
||||
if command is None: return None
|
||||
result = subprocess.run(command, shell=True, capture_output=True, text=True)
|
||||
return hook-result {
|
||||
label: label,
|
||||
exit_code: result.returncode,
|
||||
path: path,
|
||||
stdout: result.stdout,
|
||||
stderr: result.stderr,
|
||||
no_output: result.stdout == "" and result.stderr == ""
|
||||
}
|
||||
|
||||
inject { hook-result, conversation } :: () {ssdl} [B] # boundary: model sees the block
|
||||
block = serialize(hook-result)
|
||||
append conversation with block
|
||||
|
||||
invoke-points := {
|
||||
per_run: at top of every turn, before call_llm # [B] boundary to LLM
|
||||
per_file_edit: after <nagent-file-patch> # [B] boundary to file edit
|
||||
per_file_edit: after <nagent-write> in --file-edit mode only
|
||||
}
|
||||
```
|
||||
|
||||
The shape tag map: `[I]` for inspectable exit codes and flags, `[S]` for string content (stdout, stderr, label), `[B]` for boundaries (file I/O, subprocess invocation, LLM call, conversation append). The hook is a `[B]` boundary abstraction: the model's context meets the measured world at the hook, and the failure of a measurement is data the model acts on.
|
||||
|
||||
**Source-read citations:**
|
||||
- `bin/nagent:1442-1463` — `run_hook(command, label, path=None)` (a4fb141)
|
||||
- `bin/nagent:1466-1484` — `resolve_hooks(cli_per_run, cli_per_file_edit, config_path)` with CLI > config > disabled precedence (a4fb141)
|
||||
- `bin/nagent:1607-1611` — `hook_per_file_edit` fires after `<nagent-file-patch>` (a4fb141)
|
||||
- `bin/nagent:1618-1625` — `hook_per_file_edit` fires after `<nagent-write>` in `--file-edit` mode only (scratch writes are not file edits) (a4fb141)
|
||||
- `bin/nagent:1618-1625` — `hook_per_file_edit` fires after `<nagent-write>` in `--file-edit` mode only (a4fb141)
|
||||
- `bin/nagent:1922-1927` — `hook_per_run` fires at top of every turn, before `call_llm` (a4fb141)
|
||||
- `bin/nagent:2806-2825` — `--hook-per-run` and `--hook-per-file-edit` CLI flags (a4fb141)
|
||||
- `bin/nagent:3167-3185` — wiring into `run_agent_loop` (a4fb141)
|
||||
- `bin/nagent:2822-2824` — "subprocess reach" claim (a4fb141)
|
||||
- `config.example.json:6-8` — `hook_per_run` and `hook_per_file_edit` config keys (a4fb141)
|
||||
- `tests/test_nagent.py:870-883` — `test_run_hook_block_reports_output_and_exit_code` (a4fb141)
|
||||
- `tests/test_nagent.py:885-915` — `test_hook_per_run_runs_before_every_turn` (a4fb141)
|
||||
@@ -475,37 +670,23 @@ The shape tag map: `[I]` for inspectable triggers and config, `[S]` for string c
|
||||
- `tests/test_nagent.py:944-960` — `test_resolve_hooks_cli_overrides_config` (a4fb141)
|
||||
- `prove-optimized-harness.sh` (pep-copt) — 9-step proof + 5 enforcing gates (identity baseline, median-of-5 speedup, decompression-time gate, generalization, determinism)
|
||||
- `prove-optimized-harness.sh` (differentiable-collisions-optc) — 10-step proof + 4 enforcing gates (comparator with distance tolerance, contact-point certifier, precompute isolation, determinism)
|
||||
**Honest gaps in this cluster:**
|
||||
- The "subprocess reach" claim in `bin/nagent:2822-2824` — "A CLI flag applies to this invocation only; set it in the config file to apply it to delegated file-edit subprocesses too" — needs verification. The implementation at `bin/nagent:3167-3185` wires the hooks into `run_agent_loop`'s `main()` call only; whether delegated file-edit subprocesses read the config separately is not visible in this diff. The v3.1 source-read pass should verify the subprocess reach.
|
||||
- The "default off" guarantee is not tested. Both hooks default to off (CLI flag absent, config key absent or empty string). A regression test asserting "no CLI flag, no config key → both hooks are None" would harden the contract.
|
||||
- The `--hook-per-run` cost discipline ("point it at a fast status command") is documented in `--help` but not enforced. The case-study harnesses use median-of-5 timing in their proofs, which is fast, but a user wiring up a 10-second status command would pay 10 seconds per turn. A future track could add a `--hook-per-run-max-seconds` config knob.
|
||||
|
||||
**Pattern deep-dive.** The hooks abstraction is a three-piece composition: **resolve**, **invoke**, **inject**. `resolve_hooks` enforces the CLI > config > disabled precedence (the CLI is the experiment's override; the config is the project's default; empty means off). `run_hook` invokes the command, captures exit code + stdout + stderr, and surfaces "(no output)" when silent. The injection sites are the conversation: per-run at the top of every turn before `call_llm`; per-file-edit after `<nagent-file-patch>` or `<nagent-write>` in `--file-edit` mode (not scratch writes — the comment at `bin/nagent:1618-1620` notes the distinction explicitly: "A `<nagent-write>` only edits a real file in per-file-edit mode ... in main mode it writes scratch, which is not a file edit worth a verify hook").
|
||||
|
||||
The case-study harness scripts are the proof that hooks work as intended. Both scripts implement the same skeleton: log + summary + enforcing gate. The log records every step with verbose mode for streaming; the summary collects every verdict at the end (`set +e` so a failing gate still prints); the enforcing gate collects the verdicts and decides pass/fail. Both harness scripts freeze the committed input via `sha256sum` before the run and re-check after — if the harness itself changes the input (a bug), it aborts. Both exclude precompute time from the measured speedup (the build stage cannot precompute the answer; the optimization log explains why). The PEP harness uses pixel-identity + lossless round-trip + size-correctness (the optimized `.pep` must not be larger than the reference `.pep` — speed may not be bought with a bigger file). The collisions harness uses a distance tolerance contract (1mm + 0.1% + conditional) because collision-flag identity is too strict (a face/edge contact has many equally-valid witness points) and an independent contact-point certifier (`validate_contacts`) shares no solver code.
|
||||
|
||||
The data shape of the hook output, using survey grammar:
|
||||
|
||||
```
|
||||
hook-result := <label exit_code="N" [path="P"]>
|
||||
[stdout]
|
||||
[stderr: stderr-text]
|
||||
[(no output)]
|
||||
</label>
|
||||
|
||||
run { command } :: hook-result {ssdl} [B] // boundary: LLM-failures
|
||||
// surface, never hidden
|
||||
inject { hook-result, conversation } :: () // append to conversation file
|
||||
|
||||
resolve { cli, config } :: (per_run, per_file_edit)
|
||||
// precedence: CLI > config > disabled
|
||||
// empty string in config means disabled
|
||||
```
|
||||
|
||||
The `{ssdl}` `[B]` (boundary) marker notes the abstraction: the hook is the boundary where the model's context meets the measured world; the failure of a measurement is data the model can act on, not a control-flow exception. The injection is append-only — the conversation grows by a labeled block, and the next turn sees it as part of the working state.
|
||||
|
||||
The case-study methodology cluster (§9) abstracts the harness pattern itself: the hooks + the proof + the optimization log + the committed-input sha256 freeze + the model-as-test-subject framing form a reusable unit that any project adopting nagent can replicate.
|
||||
- `bin/nagent:775` — `best-of-N` initial-context directive (38d3d4f; relevant for the gap note on hook-per-run cost discipline)
|
||||
- `bin/nagent:970-987` — `conversation_cache_boundaries` (v2.3; relevant for the gap note on safety net coordination)
|
||||
- `config.example.json:1-15` — full hooks + safety-net config block (a4fb141 + 38d3d4f)
|
||||
- `README.md:700-750` — hooks teaching in Part VI (a4fb141)
|
||||
- `README.md:750-800` — case-study methodology teaching (the hooks + harness pattern) (a4fb141)
|
||||
- `issues/0005-hooks.md` — hooks spec (if it exists; the v3 cluster does not cite a specific issue file for hooks)
|
||||
- `bin/helpers/nagent_campaign_lib.py` — campaigns driver (relevant for the gap note on campaigns coordination)
|
||||
- `bin/nagent:1922-1927` — `hook_per_run` injection site (a4fb141; the exact lines)
|
||||
- `bin/nagent:1607-1625` — `hook_per_file_edit` injection sites (a4fb141; the exact lines)
|
||||
- `bin/nagent:1442-1484` — `run_hook` + `resolve_hooks` (a4fb141; the exact lines)
|
||||
- `prompts/` directory — no hooks-specific prompt; the hook block is raw subprocess output, not an LLM-generated message
|
||||
- `tests/test_nagent.py:1-50` — test file header + imports (a4fb141)
|
||||
- `bin/nagent:3167-3185` — `run_agent_loop` wiring (a4fb141; the exact lines)
|
||||
|
||||
**Decision candidate:** NEW Candidate 19 (MEDIUM). "Per-turn ground-truth hook for Manual Slop": add a per-turn hook primitive that runs a configured command (CLI > config > disabled) at the top of every `send_result()` and injects a `<hook-per-run>` block; honor the CLI > config > disabled precedence and the failing/quiet-hook-surfaces-output invariant. See `decisions.md` Candidate 19.
|
||||
**Cross-refs:** §9 Case-study methodology (the 5-element pattern; hooks are the substrate), §10 PEP case study (the pep-copt harness), §11 Collisions case study (the collisions harness). These three together surface the full abstraction. §13 Agent context-window observations (the v3.1 new section on warm-up + window + safe-zone numbers; the per-turn hook is the per-turn ground-truth mechanism that the safe-zone needs).
|
||||
**Pattern history:** NEW in v3. v2.3 had the conversation-without-ground-truth loop. v3 introduces the per-turn measurement primitive. The case-study methodology cluster (§9) elaborates this into a reusable 5-element pattern.
|
||||
## §4 Project-local roots
|
||||
|
||||
**Source:** nagent `54c8741`, `557dd39`, `0b9d1a2`, `023e23a` (`bin/helpers/nagent_cli.py:11-86` + `:109-141`, `bin/helpers/nagent_llm.py:55-72`, `bin/nagent:640-748` + `:2075-2295`, `.gitignore`, `README.md:344-372` + `:400-410` + `:812-832` + `:841-849`, `prompts/create-readme.md`, `issues/0001-foundations.md`).
|
||||
|
||||
Reference in New Issue
Block a user