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
50 changes: 50 additions & 0 deletions .agents/prompts/planning-reviser.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# Planning Revision Contract

You are the project planner. An independent planning review assessed your
architecture, roadmap, and ADRs. `revise-planning.sh` runs you in two phases
in the planning worktree.

Read `AGENTS.md`, `.agents/prompts/project-planner.md`, the approved
`docs/PROJECT_REQUIREMENTS.md`, `docs/PROJECT_DESCRIPTION.md` when present,
the current planning documents, and the review supplied by the caller.

The approved requirements are authoritative. You may not change them, and you
may not resolve a finding by silently departing from them.

## Phase 1: decide

You run read-only. Decide exactly once for every finding of the review, using
its `id` as `finding_id`:

- `ADOPT`: the finding is valid and you will change the architecture, roadmap,
or ADRs to resolve it.
- `REJECT`: the finding is wrong or the current planning is the better choice.
Explain why in terms of the requirements.
- `DEFER`: the finding is valid, but belongs to a later roadmap feature or a
just-in-time feature plan, not to the project planning.
- `ESCALATE`: resolving the finding needs a human product decision or a change
to the approved requirements.

Critical and major findings may only be adopted or escalated. Every decision
needs a rationale.

Return JSON that matches the schema supplied by the caller
(`.agents/schemas/revision.schema.json`). Do not write files and do not add
text around it.

## Phase 2: revise

You run with write access, after the human approved your decisions. Resolve
exactly the adopted findings listed by the caller:

- change only `docs/architecture.md`, `docs/roadmap.md`, and direct Markdown
ADRs under `docs/decisions/`;
- keep roadmap feature IDs stable; add new IDs instead of renumbering;
- keep the documents consistent with each other and with the requirements;
- do not address rejected, deferred, or escalated findings;
- do not modify the requirements, the project description, review or revision
artifacts, or any other file;
- do not commit, push, open or merge a PR, create Issues, or deploy.

Finish when every adopted finding is resolved. A new planning review round
confirms the result.
23 changes: 23 additions & 0 deletions .agents/schemas/revision.schema.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"type": "object",
"additionalProperties": false,
"required": ["decisions"],
"properties": {
"decisions": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": ["finding_id", "decision", "rationale"],
"properties": {
"finding_id": { "type": "string" },
"decision": {
"type": "string",
"enum": ["ADOPT", "REJECT", "DEFER", "ESCALATE"]
},
"rationale": { "type": "string" }
}
}
}
}
}
37 changes: 35 additions & 2 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,8 +182,41 @@ Each round is stored as `.agents/reviews/planning-<name>-review-NN.json` with
a generated report, in the same format as feature reviews but without an Issue.
The review covers only the planning documents, so it becomes stale when one of
them is added, changed, or deleted, and stays current otherwise
(`./scripts/check-review.sh`). A planning review is not triaged: revise the
planning documents for the findings you accept and run the review again.
(`./scripts/check-review.sh`). A planning review is not triaged; it is
revised.

### Planning revision

Let the original project planner handle the findings of a current planning
review:

```bash
./scripts/revise-planning.sh --review .agents/reviews/planning-project-bootstrap-review-01.json
```

It uses role `project-planner` in two phases:

1. **Decide.** Read-only, the planner returns one decision per finding with a
rationale: `ADOPT`, `REJECT`, `DEFER` (belongs to a later feature), or
`ESCALATE` (needs a human product decision or a change to the approved
requirements). Critical and major findings may only be adopted or
escalated. The decisions are validated like review results, shown to you,
and recorded only after your approval, as
`.agents/reviews/<review>-revision.json` with a generated report.
2. **Revise.** A write session resolves exactly the adopted findings. It may
change only `docs/architecture.md`, `docs/roadmap.md`, and direct Markdown
ADRs. Any other change, including to the requirements, the description, or
a review artifact, and any commit fails the run and keeps the worktree for
inspection.

Escalated findings are listed with the next step: change the requirements
through Project Grill and approve them again, or decide that the finding does
not apply. When the write session fails, the approved decisions stay recorded;
while the review is still current, running the script again applies them
without deciding again.

After a revision the review is stale by design. Run `review-planning.sh` for
the next round, and repeat until the review passes.

Open and merge a planning PR before creating Issues for actionable roadmap
features. The script never implements features, creates Issues, commits,
Expand Down
100 changes: 100 additions & 0 deletions scripts/lib/review-data.sh
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,36 @@ _review_data_rules='
else empty end)
end)
end;

# Input: {decisions}. Revision decisions of the project planner about the
# findings of a planning review.
def revision_decision_rules($severity):
if type != "object" or (.decisions | type) != "array" then
"the result must be an object with a decisions array"
else
unexpected(["decisions"]; "the result"),
([.decisions[] | objects | .finding_id] as $decided
| ($severity | keys[]) as $id
| ($decided | map(select(. == $id)) | length) as $count
| if $count == 0 then "finding \($id) has no revision decision"
elif $count > 1 then "finding \($id) has more than one revision decision"
else empty end),
(.decisions | to_entries[] | (.key + 1) as $n | .value as $d |
if ($d | type) != "object" then
"decision \($n) is not an object"
elif ($d.finding_id | type) != "string" or ($severity | has($d.finding_id) | not) then
"decision \($n) refers to an unknown finding: \($d.finding_id | tostring)"
else
($d | unexpected(["decision", "finding_id", "rationale"]; "decision \($n)")),
(if ($d.decision | IN("ADOPT", "REJECT", "DEFER", "ESCALATE")) then empty
else "finding \($d.finding_id) has an invalid decision" end),
(if ($d.rationale | nonempty) then empty
else "finding \($d.finding_id) has no rationale" end),
(if ($severity[$d.finding_id] | IN("critical", "major")) and ($d.decision | IN("REJECT", "DEFER")) then
"\($severity[$d.finding_id]) finding \($d.finding_id) must be adopted or escalated"
else empty end)
end)
end;
'

# review_result_errors <result-file>: rules for a reviewer's result.
Expand Down Expand Up @@ -320,6 +350,76 @@ triage_artifact_errors() {
' "$1"
}

# revision_decision_errors <decisions-file> <review-json>: rules for the
# project planner's decisions about the findings of a planning review.
revision_decision_errors() {
review_data_is_json "$1" || {
echo "the decisions are not exactly one JSON value"
return 0
}

jq -r --slurpfile review "$2" "$_review_data_rules"'
revision_decision_rules($review[0].findings | map({key: .id, value: .severity}) | from_entries)
' "$1"
}

# revision_artifact_errors <revision-json> <review-json>: rules for a stored
# revision, checked against its planning review.
revision_artifact_errors() {
review_data_is_json "$1" || {
echo "the revision is not exactly one JSON value"
return 0
}

jq -r --slurpfile review "$2" "$_review_data_rules"'
if type != "object" or .schema != "revision/v1" then
"the file is not a revision/v1 artifact"
else
unexpected(["approved_at", "branch", "decisions", "planner", "review_round", "reviewed_tree",
"schema", "source_review"]; "the revision"),
(if (.approved_at | type) == "string"
and (.approved_at | test("^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$")) then empty
else "the revision has no valid UTC approval timestamp" end),
(if .branch == $review[0].branch and .review_round == $review[0].round
and .reviewed_tree == $review[0].reviewed_tree then empty
else "the revision does not describe its planning review" end),
(if (.planner | type) == "object" and (.planner.agent | nonempty) and (.planner.model | nonempty) then empty
else "the revision must name its planner agent and model" end),
($review[0].findings | map({key: .id, value: {severity, title}}) | from_entries) as $source
| (if (.decisions | type) == "array" then
(.decisions[] | objects |
unexpected(["decision", "finding_id", "rationale", "severity", "title"]; "the decision for \(.finding_id | tostring)"),
(if $source[.finding_id] == null or {severity, title} == $source[.finding_id] then empty
else "the decision for \(.finding_id) does not match the severity and title in the review" end))
else empty end),
({decisions: (if (.decisions | type) == "array"
then (.decisions | map(if type == "object" then {finding_id, decision, rationale} else . end))
else .decisions end)}
| revision_decision_rules($review[0].findings | map({key: .id, value: .severity}) | from_entries))
end
' "$1"
}

# revision_render_markdown <revision-json> <source-name>
revision_render_markdown() {
jq -r --arg source "$2" '
def group($decision; $heading):
"## \($heading)\n\n" +
([.decisions[] | select(.decision == $decision)] as $items
| if ($items | length) == 0 then "None.\n"
else ($items | map("### \(.finding_id). \(.title)\n\n- Severity: \(.severity)\n- Rationale: \(.rationale)\n") | join("\n")) end);
"<!-- Generated from \($source). Do not edit; this file is never read by the scripts. -->\n\n" +
"# Planning Revision — \(.branch), review round \(.review_round)\n\n" +
"Source review: `\(.source_review)`\n\n" +
"Planner: \(.planner.agent) (\(.planner.model))\n\n" +
"Approved at: \(.approved_at)\n\n" +
group("ADOPT"; "Adopted") + "\n" +
group("REJECT"; "Rejected") + "\n" +
group("DEFER"; "Deferred") + "\n" +
group("ESCALATE"; "Escalated to the human")
' "$1"
}

# triage_render_markdown <triage-json> <source-name>
triage_render_markdown() {
jq -r --arg source "$2" '
Expand Down
14 changes: 8 additions & 6 deletions scripts/lib/review-run.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@
# review-feature.sh and review-planning.sh.
# Source this file after agent.sh, review-data.sh, and fingerprint.sh.

# review_run_reviewer <agent> <model> <root> <prompt> <context-file> <schema-file> <result-file> <scratch-dir>
# Runs the reviewer read-only. An invalid result is retried once, with the
# reasons for the rejection. Exits the calling script when the reviewer fails,
# changes the working tree or the review artifacts, creates a commit, or
# returns an invalid result twice.
# review_run_reviewer <agent> <model> <root> <prompt> <context-file> <schema-file> <result-file> <scratch-dir> [validator]
# Runs a read-only agent that returns structured output. The validator prints
# the rule violations of a result file and defaults to review_result_errors.
# An invalid result is retried once, with the reasons for the rejection. Exits
# the calling script when the agent fails, changes the working tree or the
# review artifacts, creates a commit, or returns an invalid result twice.
review_run_reviewer() {
local agent="$1"
local model="$2"
Expand All @@ -18,6 +19,7 @@ review_run_reviewer() {
local schema_file="$6"
local result_file="$7"
local scratch="$8/review-run"
local validator="${9:-review_result_errors}"
local head_before
local tree_before
local artifacts_before
Expand Down Expand Up @@ -53,7 +55,7 @@ review_run_reviewer() {
exit 1
fi

result_errors="$(review_result_errors "$result_file")"
result_errors="$("$validator" "$result_file")"
[[ -n "$result_errors" ]] || return 0

echo "The reviewer returned an invalid result (attempt $attempt of 2):" >&2
Expand Down
79 changes: 79 additions & 0 deletions scripts/lib/scope.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
#!/usr/bin/env bash

# Filesystem scope enforcement for write-capable agent sessions.
# Source this file; do not execute it.
#
# A snapshot records every path in a worktree, including untracked and ignored
# files but not .git, with its type, mode, and content hash. Paths that the
# session may change are left out by a caller-supplied function. Comparing a
# snapshot taken before and after the session shows every out-of-scope change.

scope_file_mode() {
if [[ "$(uname -s)" == "Darwin" ]]; then
stat -f '%Lp' "$1"
else
stat -c '%a' "$1"
fi
}

# Allowed paths of the project planner: the architecture, the roadmap, and
# direct Markdown ADRs.
scope_planner_allowed() {
case "$1" in
docs/architecture.md | docs/roadmap.md)
return 0
;;
docs/decisions/*.md)
[[ "${1#docs/decisions/}" != */* ]]
return
;;
*)
return 1
;;
esac
}

# scope_snapshot <worktree> <allowed-function> <target-file>
# Paths are read NUL-delimited and written escaped, so names with tabs or
# newlines cannot spoof an allowed path.
scope_snapshot() {
local worktree="$1"
local allowed="$2"
local target="$3"

(
cd "$worktree"
find . -mindepth 1 ! -path './.git' -print0 |
while IFS= read -r -d '' relative; do
path="${relative#./}"
if "$allowed" "$path"; then
continue
fi

if [[ -L "$path" ]]; then
signature="symlink:$(scope_file_mode "$path"):$(readlink "$path")"
elif [[ -f "$path" ]]; then
signature="regular:$(scope_file_mode "$path"):$(git hash-object -- "$path")"
elif [[ -d "$path" ]]; then
signature="directory:$(scope_file_mode "$path")"
else
signature="other:$(scope_file_mode "$path")"
fi

path_key="$(printf '%s' "$path" | git hash-object --stdin)"
printf '%s\t%q\t%s\n' "$path_key" "$path" "$signature"
done
) | LC_ALL=C sort >"$target"
}

# scope_unchanged <baseline-file> <current-file>
# Succeeds when both snapshots are equal; otherwise prints the escaped
# differences to standard error.
scope_unchanged() {
if cmp -s "$1" "$2"; then
return 0
fi
echo "Detected out-of-scope filesystem changes (escaped paths shown):" >&2
diff -u "$1" "$2" >&2 || true
return 1
}
8 changes: 5 additions & 3 deletions scripts/review-planning.sh
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,8 @@ review_store "$result_file" "$out" "$metadata"
echo " $review_relative.json (source of truth)"
echo " $review_relative.md (generated report)"
echo
echo "Next: revise the planning documents in this worktree for the findings you"
echo "accept, then run ./scripts/review-planning.sh again. When the review passes,"
echo "commit the planning and open the planning PR as described in docs/development.md."
if [[ "$(jq '.findings | length' "$out.json")" -gt 0 ]]; then
echo "Next: ./scripts/revise-planning.sh --review $review_relative.json"
else
echo "Next: commit the planning and open the planning PR as described in docs/development.md."
fi
Loading
Loading