riskrails 4.2.0 → 4.3.0

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.
Files changed (42) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.codex-plugin/plugin.json +1 -1
  4. package/README.md +25 -15
  5. package/agents/ai-reviewer.md.tmpl +14 -1
  6. package/ai/agents/implementer.md +6 -0
  7. package/ai/agents/reviewer.md +8 -1
  8. package/bin/ansi.js +12 -0
  9. package/bin/menu.js +71 -12
  10. package/bin/riskrails.js +240 -14
  11. package/bin/uninstall.js +115 -0
  12. package/bin/wizard.js +72 -0
  13. package/hooks/context-guard.py +11 -1
  14. package/hooks/dialog-gate.py +3 -2
  15. package/hooks/guard/decision.py +4 -3
  16. package/hooks/guard/dialog.py +12 -2
  17. package/hooks/guard/ere.py +29 -10
  18. package/hooks/guard/git.py +2 -1
  19. package/hooks/guard/paths_base.py +7 -0
  20. package/hooks/guard/paths_bash.py +3 -3
  21. package/hooks/runtime-gate.py +87 -0
  22. package/hooks/update_notice.py +23 -8
  23. package/package.json +1 -1
  24. package/skills/ai-init/templates/.ai/policies/coding.md +9 -0
  25. package/skills/ai-task/rr_state/dialog.py +32 -7
  26. package/skills/ai-task/rr_state/dialog_cmd.py +38 -5
  27. package/skills/ai-task/rr_state/gate.py +10 -4
  28. package/skills/ai-task/rr_state/labels.py +33 -4
  29. package/skills/ai-task/rr_state/questions_cmd.py +5 -4
  30. package/skills/ai-task/rr_state/report.py +73 -10
  31. package/skills/ai-task/rr_state/report_cmd.py +2 -0
  32. package/skills/ai-task/rr_state/review.py +18 -14
  33. package/skills/ai-task/rr_state/start.py +12 -9
  34. package/skills/ai-task/rr_state/steps.py +16 -7
  35. package/skills/ai-task/rr_state/verify.py +17 -12
  36. package/skills/project-update/history/blobs/dac90781793b8a1c8e5f3e0307665b6394d922140c3831c5508d019f79e1ed0a +41 -0
  37. package/skills/project-update/history/index.json +6 -3
  38. package/skills/rr-build/SKILL.md +5 -2
  39. package/skills/rr-ship/SKILL.md +2 -2
  40. package/skills/rr-spec/SKILL.md +6 -0
  41. package/skills/rr-status/SKILL.md +2 -0
  42. package/skills/rr-status/status.py +5 -3
@@ -1,14 +1,16 @@
1
1
  """report: the text the state is rendered to — the two close facts, the status lines, the schema line, task.md and its writer.
2
2
 
3
- core: md_cell
4
- shell: two_facts, status_lines, schema_line, task_md_lines, write_task_md
3
+ core: md_cell, waiting_line
4
+ shell: two_facts, pending_questions, waiting_decisions, waiting, status_lines, schema_line, task_md_lines, write_task_md
5
5
  """
6
6
  import contextlib
7
+ import json
7
8
  import os
8
9
  import re
9
10
  import sys
10
11
 
11
12
  from rr_state import fs, journal, locks, questions, task
13
+ from rr_state import ledger as rr_ledger
12
14
  from rr_state import policy as rr_policy # status_lines names a local `policy`, as state.py does
13
15
 
14
16
  # weight.py and sensors.py sit beside state.py; the entry puts that directory on sys.path.
@@ -73,20 +75,81 @@ def two_facts(root, state, tree):
73
75
  b = (True, "%d changed files, all in the task's files" % len(diff))
74
76
  return {"a": a, "b": b, "diff": diff or [], "outside": outside, "documents": documents}
75
77
 
78
+ def pending_questions(root, state):
79
+ """The ids the status shows as pending: the cache, and for schema 7 `## Questions`
80
+ as the file has it (the cache lags a hand-filled [Answer]:, as guard_pending reads it)
81
+ plus an open gate's id."""
82
+ pending = ((state or {}).get("questions") or {}).get("pending") or []
83
+ if task.is_v7(state):
84
+ with contextlib.suppress(OSError, ValueError, KeyError):
85
+ _, asked = questions.parse_questions(fs.questions_path(root, state, None))
86
+ gate_id = task.open_gate(state)
87
+ pending = [q["id"] for q in asked if (q["kind"] != "G" and questions.is_pending(q)) or q["id"] == gate_id]
88
+ return pending
89
+
90
+
91
+ def waiting_decisions(root):
92
+ """The open decisions that still wait — dialog.prune_decisions' rule without its
93
+ writes: not settled, and not every command pick of the latest dialog call used.
94
+ An unreadable decision.json waits for nobody here; the decision commands say why."""
95
+ try:
96
+ with open(fs.decision_path(root), encoding="utf-8") as fh:
97
+ opened = json.load(fh)["open"]
98
+ except (OSError, ValueError, KeyError, TypeError, RecursionError):
99
+ return []
100
+ ledger = rr_ledger.read_ledger(root)
101
+ settled = {e.get("decision") for e in ledger if e["type"] == "settle"}
102
+ used = {e.get("id") for e in ledger if e["type"] == "use"}
103
+ rounds = rr_ledger.latest_rounds(ledger)
104
+ out = []
105
+ for decision in opened:
106
+ commands = {o.get("n") for o in decision.get("options") or [] if o.get("kind") == "command"}
107
+ picks = [e for e in rounds.get(decision.get("id"), []) if e.get("option") in commands]
108
+ if decision.get("id") not in settled and not (picks and all(p.get("id") in used for p in picks)):
109
+ out.append(decision)
110
+ return out
111
+
112
+
113
+ def waiting(root, state, pending):
114
+ """R12: who the task waits on and for what — decisions, then the gate, then questions."""
115
+ decisions = waiting_decisions(root)
116
+ gate = task.open_gate(state) if task.is_v7(state) else None
117
+ asked = [q for q in pending if q != gate]
118
+ on = "you" if decisions or gate or asked else "agent" if (state or {}).get("task_id") else "nobody"
119
+ return {"on": on, "decisions": decisions, "gate": gate, "questions": asked}
120
+
121
+
122
+ def waiting_line(root, state, wait):
123
+ """Line 1 of the status, or None with no task in flight and nothing open. When
124
+ several wait, one line joins them in this order: decisions, the gate, questions."""
125
+ if wait["on"] == "nobody":
126
+ return None
127
+ if wait["on"] == "agent":
128
+ return "waiting on the agent: %s — %s" % (state.get("stage") or state.get("current_stage") or "?",
129
+ fs.one_line(state.get("next_action"), 140) or "?")
130
+ parts = []
131
+ if wait["decisions"]:
132
+ parts.append("%s — the dialog ('state.py decision show' sends it again)" % ", ".join(
133
+ "%s %s" % (d.get("id"), fs.one_line(d.get("header"), 40)) for d in wait["decisions"][:4]))
134
+ if wait["gate"]:
135
+ parts.append("gate %s — approve in the dialog ('state.py close' offers it) or in your own terminal"
136
+ % wait["gate"])
137
+ if wait["questions"]:
138
+ parts.append("%s — answer in the dialog or the chat, or fill [Answer]: in %s" % (
139
+ ", ".join(map(str, wait["questions"][:4])),
140
+ os.path.relpath(fs.questions_path(root, state, None), root)))
141
+ return "waiting on you: " + "; ".join(parts)
142
+
143
+
76
144
  def status_lines(root, state):
77
145
  """≤ 20 lines; read-only (the facts are measured, nothing is written)."""
78
- lines = ["root: %s" % root]
146
+ pending = pending_questions(root, state)
147
+ head = waiting_line(root, state, waiting(root, state, pending))
148
+ lines = ([head] if head else []) + ["root: %s" % root]
79
149
  if not state or not state.get("task_id"):
80
150
  return lines + ["task: none — no task in flight", schema_line(root)]
81
151
  events, _ = journal.read_journal(root, state["task_id"])
82
152
  last = events[-1] if events else {}
83
- pending = (state.get("questions") or {}).get("pending") or []
84
- if task.is_v7(state):
85
- # The cache lags a hand-filled [Answer]:; the file is the truth, as guard_pending reads it.
86
- with contextlib.suppress(OSError, ValueError, KeyError):
87
- _, asked = questions.parse_questions(fs.questions_path(root, state, None))
88
- gate_id = task.open_gate(state)
89
- pending = [q["id"] for q in asked if (q["kind"] != "G" and questions.is_pending(q)) or q["id"] == gate_id]
90
153
  lines.append("task: %s — %s" % (state["task_id"], fs.one_line(state.get("goal"), 140)))
91
154
  if not task.is_v7(state):
92
155
  lines += ["tier: %s · workflow %s · stage %s" % (state.get("risk_tier") or "?", state.get("workflow"),
@@ -225,6 +225,8 @@ def cmd_status(args, root, rt=rr_runtime.Runtime()):
225
225
  "operations")}
226
226
  out["facts"] = {"a": list(facts["a"]), "b": list(facts["b"]), "tree": tree}
227
227
  out["questions_pending"] = len((state.get("questions") or {}).get("pending") or [])
228
+ wait = report.waiting(root, state, report.pending_questions(root, state))
229
+ out["waiting"] = dict(wait, decisions=[d.get("id") for d in wait["decisions"]])
228
230
  print(json.dumps(out, indent=2, ensure_ascii=False))
229
231
  return
230
232
  print("\n".join(report.status_lines(root, state)))
@@ -84,20 +84,24 @@ def cmd_review(args, root, rt=rr_runtime.Runtime()):
84
84
  if review["passes"] >= rounds:
85
85
  if not args.by:
86
86
  report = ["--report", os.path.abspath(args.report)] if args.report and os.path.isfile(args.report) else []
87
- dialog.offer_decision(root, state, "human_set", "Review",
88
- "%s\nREVIEW_BUDGET: %d review passes; weight.json allows %d%s. The next verdict is yours."
89
- % (dialog.task_facts(root, state), review["passes"], rounds,
90
- " (%s)" % budget_name if rr_policy.batch_on(state) else ""),
91
- [{"kind": "choice", "effect": "keep", "label": "Keep it open — no verdict now"}] +
92
- [{"kind": "command",
93
- "label": rr_labels.state_label(root, ["review", "--verdict", verdict, "--tier", args.tier] +
94
- report + ["--by", rr_labels.human_name(root)])}
95
- for verdict in ("pass", "pass_with_findings", "blockers_open")], "review", rt=rt)
96
- fs.die("REVIEW_BUDGET — %d review passes; weight.json allows %d (%s). The next "
97
- "verdict is the human's, in their own terminal:\n python3 %s --root %s review --verdict "
98
- "<verdict> --tier %s --by \"<name>\"" % (review["passes"], rounds, budget_name,
99
- shlex.quote(fs.ENTRY),
100
- shlex.quote(root), args.tier), 6)
87
+ decision = dialog.offer_decision(
88
+ root, state, "human_set", "Review",
89
+ "%s\n%d review passes so far; this task allows %d%s. The next verdict is yours."
90
+ % (dialog.task_facts(root, state), review["passes"], rounds,
91
+ " (%s)" % budget_name if rr_policy.batch_on(state) else ""),
92
+ [{"kind": "choice", "effect": "keep", "label": "Keep it open — no verdict now"}] +
93
+ [{"kind": "command",
94
+ "label": rr_labels.state_label(root, ["review", "--verdict", verdict, "--tier", args.tier] +
95
+ report + ["--by", rr_labels.human_name(root)])}
96
+ for verdict in ("pass", "pass_with_findings", "blockers_open")], "review", rt=rt)
97
+ cmd = "python3 %s --root %s review --verdict <verdict> --tier %s --by \"<name>\"" % (
98
+ shlex.quote(fs.ENTRY), shlex.quote(root), args.tier)
99
+ asked = dialog.human_refusal("the next review verdict", cmd, "the review budget is spent: %d passes, %d allowed"
100
+ % (review["passes"], rounds), decision and decision["id"])
101
+ fs.die(("REVIEW_BUDGET — %d review passes; weight.json allows %d (%s).\n%s"
102
+ % (review["passes"], rounds, budget_name, asked)) if asked else
103
+ "REVIEW_BUDGET — %d review passes; weight.json allows %d (%s). The next verdict is the human's, "
104
+ "in their own terminal:\n %s" % (review["passes"], rounds, budget_name, cmd), 6)
101
105
  via, why = dialog.human_route(root, allow_dialog=True, rt=rt)
102
106
  if via is None:
103
107
  fs.die("APPROVAL_REFUSED — a verdict past the review budget is the human's, from their own "
@@ -159,15 +159,18 @@ def cmd_start(args, root, rt=rr_runtime.Runtime()):
159
159
  if rr_policy.MODES.index(args.mode) < rr_policy.MODES.index(mode):
160
160
  via, why = dialog.human_route(root, allow_dialog=True, rt=rt)
161
161
  if via is None:
162
- dialog.offer_decision(root, None, "human_set", "Mode",
163
- "%s — W %s, the weight says %s.\nLower the mode from %s to %s (by %s)?"
164
- % (fs.one_line(args.goal, 300), result.w, mode, mode, args.mode, args.by),
165
- [{"kind": "choice", "effect": "keep", "label": "Keep mode %s" % mode},
166
- {"kind": "command", "label": labels.state_label(root, sys.argv[1:])}], "start --mode", rt=rt)
167
- fs.die("APPROVAL_REFUSED — lowering the mode from %s to %s is a human's decision. Run it in "
168
- "your own terminal:\n python3 %s --root %s start … --mode %s --by %s\nRefused because %s."
169
- % (mode, args.mode, shlex.quote(fs.ENTRY), shlex.quote(root),
170
- args.mode, shlex.quote(args.by), why), 5)
162
+ decision = dialog.offer_decision(
163
+ root, None, "human_set", "Mode",
164
+ "%s\nThe task's weight says mode %s. Lower it to %s (by %s)? A lower mode means lighter checks "
165
+ "and a lighter review." % (fs.one_line(args.goal, 300), mode, args.mode, args.by),
166
+ [{"kind": "choice", "effect": "keep", "label": "Keep mode %s" % mode},
167
+ {"kind": "command", "label": labels.state_label(root, sys.argv[1:])}], "start --mode", rt=rt)
168
+ cmd = "python3 %s --root %s start … --mode %s --by %s" % (
169
+ shlex.quote(fs.ENTRY), shlex.quote(root), args.mode, shlex.quote(args.by))
170
+ what = "lowering the mode from %s to %s" % (mode, args.mode)
171
+ fs.die(dialog.human_refusal(what, cmd, why, decision and decision["id"])
172
+ or "APPROVAL_REFUSED — %s is a human's decision. Run it in your own terminal:\n %s\n"
173
+ "Refused because %s." % (what, cmd, why), 5)
171
174
  override = {"from": mode, "to": args.mode, "by": args.by, "why": args.why, "ts": fs.now()}
172
175
  mode = args.mode
173
176
  if args.slug is not None:
@@ -6,8 +6,9 @@ shell: cmd_step_done_v7, cmd_steps, cmd_batch
6
6
  # (uniform cli dispatch: every cmd_* takes rt last, whether it uses it or not)
7
7
  import json
8
8
  import shlex
9
+ import sys
9
10
 
10
- from rr_state import dialog, fs, handoff, journal, report, task
11
+ from rr_state import dialog, fs, handoff, journal, labels, report, task
11
12
  from rr_state import policy as rr_policy # cmd_batch binds a local `policy`
12
13
  from rr_state import runtime as rr_runtime
13
14
 
@@ -103,11 +104,18 @@ def cmd_batch(args, root, rt=rr_runtime.Runtime()):
103
104
  return
104
105
  via = None
105
106
  if want:
106
- via, why = dialog.human_route(root, rt=rt)
107
+ via, why = dialog.human_route(root, allow_dialog=True, rt=rt)
107
108
  if via is None:
108
- fs.die("APPROVAL_REFUSED — turning the batch loop on during a task lowers the rigor, so it is the "
109
- "human's. In their own terminal:\n python3 %s --root %s batch on --by \"<name>\"\nRefused "
110
- "because %s." % (shlex.quote(fs.ENTRY), shlex.quote(root), why), 5)
109
+ decision = dialog.offer_decision(
110
+ root, state, "human_set", "Batch",
111
+ "%s\nTurn the batch loop on for this task? It lowers the rigor for the rest of the task: the batch "
112
+ "budgets replace the standard ones." % dialog.task_facts(root, state),
113
+ [{"kind": "choice", "effect": "keep", "label": "Keep the standard loop"},
114
+ {"kind": "command", "label": labels.state_label(root, sys.argv[1:])}], "batch on", rt=rt)
115
+ cmd = "python3 %s --root %s batch on --by \"<name>\"" % (shlex.quote(fs.ENTRY), shlex.quote(root))
116
+ fs.die(dialog.human_refusal("turning the batch loop on during a task", cmd, why, decision and decision["id"])
117
+ or "APPROVAL_REFUSED — turning the batch loop on during a task lowers the rigor, so it is the "
118
+ "human's. In their own terminal:\n %s\nRefused because %s." % (cmd, why), 5)
111
119
  elif not (args.why or "").strip():
112
120
  fs.die("batch off needs --why \"<why the standard loop from here>\"")
113
121
  policy = rr_policy.weight_policy(root)
@@ -118,8 +126,9 @@ def cmd_batch(args, root, rt=rr_runtime.Runtime()):
118
126
  # At the gate (stage ship) the next action is the human's and stays so.
119
127
  state["next_action"] = rr_policy.review_next_action(state)
120
128
  journal.emit(root, state, "batch_set", "%s by %s%s" % (args.action, args.by, " via %s" % via if via else ""),
121
- {"on": want, "by": args.by, "why": args.why or None, "via": via,
122
- "review_passes": state["review"]["passes"], "suite_runs": state["verify"]["suite_runs"]},
129
+ dict({"on": want, "by": args.by, "why": args.why or None, "via": via,
130
+ "review_passes": state["review"]["passes"], "suite_runs": state["verify"]["suite_runs"]},
131
+ **dialog.pick_data(dialog.DIALOG_PICK[0])),
123
132
  actor="human" if via == "terminal" else "agent", rt=rt)
124
133
  handoff.set_resume_point(state, rt=rt)
125
134
  handoff.write_handoff(root, state, "batch", rt=rt)
@@ -171,18 +171,23 @@ def cmd_verify(args, root, rt=rr_runtime.Runtime()):
171
171
  max_runs, budget_name = rr_policy.budget(policy, state, "max_suite_runs")
172
172
  if scope in ("suite", "all") and state["verify"]["suite_runs"] >= max_runs:
173
173
  if not args.by:
174
- dialog.offer_decision(root, state, "human_set", "Budget",
175
- "%s\nSUITE_BUDGET: %d suite runs; weight.json allows %d%s. Run it once more?"
176
- % (dialog.task_facts(root, state), state["verify"]["suite_runs"], max_runs,
177
- " (%s)" % budget_name if rr_policy.batch_on(state) else ""),
178
- [{"kind": "choice", "effect": "keep", "label": "Keep the budget — stop here"},
179
- {"kind": "command",
180
- "label": labels.state_label(root, sys.argv[1:] + ["--by", labels.human_name(root)])}], "verify", rt=rt)
181
- fs.die("SUITE_BUDGET — %d suite runs; weight.json allows %d (%s). Running it "
182
- "again is not the next move: the human decides. In their own terminal:\n python3 %s "
183
- "--root %s verify --scope %s --by \"<name>\""
184
- % (state["verify"]["suite_runs"], max_runs, budget_name, shlex.quote(fs.ENTRY),
185
- shlex.quote(root), scope), 6)
174
+ runs = state["verify"]["suite_runs"]
175
+ decision = dialog.offer_decision(
176
+ root, state, "human_set", "Budget",
177
+ "%s\nThe test suite has run %d times; this task allows %d%s. Run it once more?"
178
+ % (dialog.task_facts(root, state), runs, max_runs,
179
+ " (%s)" % budget_name if rr_policy.batch_on(state) else ""),
180
+ [{"kind": "choice", "effect": "keep", "label": "Keep the budget — stop here"},
181
+ {"kind": "command",
182
+ "label": labels.state_label(root, sys.argv[1:] + ["--by", labels.human_name(root)])}], "verify", rt=rt)
183
+ cmd = "python3 %s --root %s verify --scope %s --by \"<name>\"" % (
184
+ shlex.quote(fs.ENTRY), shlex.quote(root), scope)
185
+ asked = dialog.human_refusal("a run past the suite budget", cmd, "the budget is spent: %d runs, %d allowed"
186
+ % (runs, max_runs), decision and decision["id"])
187
+ fs.die(("SUITE_BUDGET — %d suite runs; weight.json allows %d (%s).\n%s" % (runs, max_runs, budget_name, asked))
188
+ if asked else
189
+ "SUITE_BUDGET — %d suite runs; weight.json allows %d (%s). Running it again is not the next "
190
+ "move: the human decides. In their own terminal:\n %s" % (runs, max_runs, budget_name, cmd), 6)
186
191
  via, why = dialog.human_route(root, allow_dialog=True, rt=rt)
187
192
  if via is None:
188
193
  fs.die("APPROVAL_REFUSED — a run past the suite budget is the human's, from their own terminal."
@@ -0,0 +1,41 @@
1
+ # Coding policy
2
+
3
+ <!-- /ai-init fills the project-specific half of this file from the repository.
4
+ The rules above the line are general and apply everywhere. -->
5
+
6
+ ## General
7
+
8
+ - Follow the conventions already in the file you are editing, even when they are
9
+ not the conventions you would choose. Consistency inside a file beats
10
+ correctness in the abstract.
11
+ - One logical change per step. If you find yourself writing "and also", stop.
12
+ - No new abstraction until there are three call sites that need it.
13
+ - Extend through the framework's own extension points before writing a new
14
+ mechanism: decorators, event subscribers, form extensions, template overrides,
15
+ compiler passes, configuration.
16
+ - Never modify `vendor/` or `node_modules/`. A needed change to a third-party
17
+ package becomes a decorator, an event listener, or a patch that is applied on
18
+ install and reviewable in git.
19
+ - Dead code is deleted only in a task that is about deleting it, with evidence
20
+ that it is dead.
21
+ - A function does one thing and its name says which. When the name needs
22
+ "and", split it.
23
+ - Inputs come in as parameters and the result goes out as the return value;
24
+ files, the network and the clock stay at the edge, in the caller.
25
+ - An error message says what failed, what was expected, what was found, and
26
+ the way out.
27
+
28
+ In existing code the file's conventions win; the last three lines hold for the
29
+ functions you add.
30
+
31
+ ## Comments
32
+
33
+ Write the comment that explains *why*, and only when the reason is not visible in
34
+ the code. Do not narrate what the next line does. Do not leave commented-out
35
+ code; git remembers it.
36
+
37
+ ## Project specifics
38
+
39
+ <!-- KNOWN FACT entries only: the linters that actually run, the static-analysis
40
+ level actually configured, the naming and layering the code actually uses.
41
+ Cite the config file for each. -->
@@ -40,7 +40,8 @@
40
40
  "ai-init/.ai/agents/implementer.md": [
41
41
  "7e5c824bb71fe5353ec8a67827145b8fbc22ecd902ade1ad3005fafeed4ffb71",
42
42
  "94da08ac6945d5614cc7918d450aab94f3aa8a929a1c8f9fa9a0a9ac51176287",
43
- "1fe2f3d3bfe2d1b671aa759b89ac1d88ae73eefed2208a485c9e972fa7b079e6"
43
+ "1fe2f3d3bfe2d1b671aa759b89ac1d88ae73eefed2208a485c9e972fa7b079e6",
44
+ "71364f3e392fe460b5b32cfec8de047a0aae8f88f144d110f90a329163677d42"
44
45
  ],
45
46
  "ai-init/.ai/agents/manager.md": [
46
47
  "0aeb04b04262683cac6513545683486a73dc473b447bba43b5aaab18403957a6",
@@ -67,7 +68,8 @@
67
68
  "e71abd1823fbbe6e635b141f04a301c3d24c2cdd56c7a5c2b25c7ea1898be54f",
68
69
  "be34ab013c39ace1e1ec902b9bcfe9653ba6aaeddb9df63fde2d2aa17bfb2263",
69
70
  "6956a4c991262b5733645f29d7067c091c1126721623b07275b4f276560ec28e",
70
- "707e67834ec3295705c4e391b794d8600a3fbc3c02dda16db50035f84e6b47c9"
71
+ "707e67834ec3295705c4e391b794d8600a3fbc3c02dda16db50035f84e6b47c9",
72
+ "499e51e1a393f776aa425aeb2be575f0a58eb483d83bc9f1c7da94ff38c3fb0e"
71
73
  ],
72
74
  "ai-init/.ai/agents/risk.md": [
73
75
  "d7146a591898984115124c83174a2c93cc080ad594f132f3d89c8b41b006c805",
@@ -83,7 +85,8 @@
83
85
  "7f713c6ecd00bc2379deda511d8bda786341dbe5f5187f3b899302d904fed6bc"
84
86
  ],
85
87
  "ai-init/.ai/policies/coding.md": [
86
- "3dd55bf5f9d9471c68af3353166da11593a11e639f7557397ce46787291fa629"
88
+ "3dd55bf5f9d9471c68af3353166da11593a11e639f7557397ce46787291fa629",
89
+ "dac90781793b8a1c8e5f3e0307665b6394d922140c3831c5508d019f79e1ed0a"
87
90
  ],
88
91
  "ai-init/.ai/policies/context-management.md": [
89
92
  "230df2220e9c73c563c3c926d26f5c6dbe249df26a56fa43b7b321cdee2d5783",
@@ -157,10 +157,13 @@ The batch is `[{"question": "…", "options": [{"key": "A", "text": "…"}, …]
157
157
  `"A: text"` becomes `{"key": "A", "text": "text"}`.
158
158
 
159
159
  Option keys are single letters, A first, at least two; `--recommend` marks the
160
- one you would pick. Then **stop** and show the questions: while one is pending,
160
+ one you would pick. If `ask` printed `DIALOG_PENDING`, send that dialog. With no
161
+ decision pending (a free question-tool call is denied while one is), put the
162
+ question through the question tool and record the pick with
163
+ `$STATE answer Q1=B --via picker`. Otherwise **stop** and show the questions: while one is pending,
161
164
  `step-done` and `close` exit 4. The human answers in the conversation — record
162
165
  it with `$STATE answer Q1=B Q2=X:"free text"` or `--prose "1B 2A"` — or fills the
163
- `[Answer]:` line in `.ai/reports/<task-id>/task.md` §Questions, which is then the
166
+ `[Answer]:` line in `.ai/reports/<task-id>/task.md` §Questions; that file stays the
164
167
  record. Never edit that file, and never answer on the human's behalf.
165
168
 
166
169
  ## 6. Hand over by mode
@@ -51,8 +51,8 @@ approve in your own terminal, then run close again:
51
51
  python3 /home/…/skills/ai-task/state.py --root /path/to/project approve --by "<name>"
52
52
  ```
53
53
 
54
- Show the human the summary (§3), the open findings, the rollback, and that
55
- command **verbatim** — paths absolute and quoted, because they paste it into
54
+ Show the human the summary (§3), the review's `READ FIRST` lines, the open
55
+ findings, the rollback, and that command **verbatim** — paths absolute and quoted, because they paste it into
56
56
  their own shell, where `$AI_HOME` is not set. Then **stop**.
57
57
 
58
58
  - **You approve only through the dialog.** With `.ai/policies/dialog.json`
@@ -39,6 +39,12 @@ $STATE ask --topic <slug> "<one line>" --option "A: <text>" --option "B: <text>"
39
39
  $STATE answer --topic <slug> Q1=B # or --prose "1B", or Q1=X:"free text"
40
40
  ```
41
41
 
42
+ If `ask` printed `DIALOG_PENDING`, send that dialog. With no decision pending (a
43
+ free question-tool call is denied while one is), put the question through the
44
+ question tool and relay the pick with `$STATE answer --topic <slug> Q1=B --via
45
+ picker`; the questions file stays the record, and the human may still answer in
46
+ the chat or fill `[Answer]:`.
47
+
42
48
  If `superpowers:brainstorming` is available, let it drive the conversation; the
43
49
  questions and answers still go through `ask` and `answer`. Otherwise:
44
50
 
@@ -16,6 +16,8 @@ done
16
16
  python3 -I "$AI_HOME/skills/rr-status/status.py" --root "$PWD"
17
17
  ```
18
18
 
19
+ With a task in flight or a decision open, the first line says who it waits on (`waiting on you` or `waiting on the agent`).
20
+
19
21
  After the output, at most one line, and only when a line asks for it:
20
22
 
21
23
  - `root:` is an ancestor of this repository, or `$HOME`: that `.ai/` arms the
@@ -174,9 +174,11 @@ def main(argv=None):
174
174
  return 1
175
175
  lines, task_id = None, None
176
176
  state = read_json(state_file(root))
177
- if state.get("schema") == 7:
178
- # A schema-7 task is state.py's to describe; this file's own read stays for
179
- # an older task, and for an install whose state.py cannot answer.
177
+ decisions = read_json(os.path.join(root, ".ai", "state", "decision.json")).get("open")
178
+ if state.get("schema") == 7 or (not state.get("task_id") and decisions):
179
+ # A schema-7 task, or an open decision with no task, is state.py's to describe
180
+ # (its first line says who waits); this file's own read stays for an older
181
+ # task, and for an install whose state.py cannot answer.
180
182
  done = subprocess.run([sys.executable, "-I", os.path.join(SKILLS, "ai-task", "state.py"),
181
183
  "--root", root, "status"], capture_output=True, text=True, check=False)
182
184
  if done.returncode == 0 and done.stdout.strip():