riskrails 3.0.1 → 3.2.1

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 (99) 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 +29 -15
  5. package/ai/policies/model-routing.md +7 -7
  6. package/ai/policies/production.md +3 -3
  7. package/ai/policies/release.md +2 -2
  8. package/ai/policies/review-economy.md +27 -30
  9. package/bin/riskrails.js +114 -23
  10. package/hooks/ai-path-guard-defaults.json +3 -2
  11. package/hooks/context-guard.py +15 -12
  12. package/hooks/dialog-gate.py +1 -1
  13. package/hooks/guard/common.py +2 -0
  14. package/hooks/guard/dialog.py +107 -27
  15. package/hooks/guard/git.py +12 -3
  16. package/hooks/guard/paths.py +150 -37
  17. package/hooks/project-scaffold.py +3 -6
  18. package/hooks/runtime-gate.py +134 -33
  19. package/hooks/update_notice.py +22 -16
  20. package/instructions/routing.md +1 -1
  21. package/instructions/stub.md +3 -3
  22. package/package.json +1 -1
  23. package/skills/AGENTS.md +1 -1
  24. package/skills/CLAUDE.md +1 -1
  25. package/skills/ai-init/templates/.ai/AGENTS.md +1 -1
  26. package/skills/ai-init/templates/.ai/VERSION +1 -1
  27. package/skills/ai-init/templates/.ai/policies/security.md +7 -1
  28. package/skills/ai-init/templates/.ai/policies/testing.md +19 -6
  29. package/skills/ai-init/templates/.ai/state/README.md +4 -4
  30. package/skills/ai-init/templates/AGENTS.block.md +2 -2
  31. package/skills/ai-init/templates/CLAUDE.block.md +2 -2
  32. package/skills/ai-init/templates/GEMINI.block.md +2 -2
  33. package/skills/ai-init/templates/gitignore.snippet +1 -0
  34. package/skills/ai-init/templates/junie-guidelines.block.md +2 -2
  35. package/skills/ai-task/rr_state/__init__.py +1 -0
  36. package/skills/ai-task/rr_state/cli.py +210 -0
  37. package/skills/ai-task/rr_state/close.py +171 -0
  38. package/skills/ai-task/rr_state/dialog.py +541 -0
  39. package/skills/ai-task/rr_state/dialog_cmd.py +164 -0
  40. package/skills/ai-task/rr_state/fs.py +220 -0
  41. package/skills/ai-task/rr_state/gate.py +125 -0
  42. package/skills/ai-task/rr_state/gate_cmd.py +128 -0
  43. package/skills/ai-task/rr_state/handoff.py +226 -0
  44. package/skills/ai-task/rr_state/journal.py +126 -0
  45. package/skills/ai-task/rr_state/labels.py +99 -0
  46. package/skills/ai-task/rr_state/ledger.py +145 -0
  47. package/skills/ai-task/rr_state/locks.py +84 -0
  48. package/skills/ai-task/rr_state/memory_cmd.py +310 -0
  49. package/skills/ai-task/rr_state/policy.py +378 -0
  50. package/skills/ai-task/rr_state/profile.py +373 -0
  51. package/skills/ai-task/rr_state/questions.py +336 -0
  52. package/skills/ai-task/rr_state/questions_cmd.py +321 -0
  53. package/skills/ai-task/rr_state/report.py +271 -0
  54. package/skills/ai-task/rr_state/report_cmd.py +230 -0
  55. package/skills/ai-task/rr_state/review.py +165 -0
  56. package/skills/ai-task/rr_state/runtime.py +80 -0
  57. package/skills/ai-task/rr_state/start.py +257 -0
  58. package/skills/ai-task/rr_state/steps.py +131 -0
  59. package/skills/ai-task/rr_state/task.py +396 -0
  60. package/skills/ai-task/rr_state/verify.py +231 -0
  61. package/skills/ai-task/sensors.py +37 -4
  62. package/skills/ai-task/state.py +24 -5138
  63. package/skills/project-init/templates/constitution.md +1 -1
  64. package/skills/project-init/templates/sdlc-README.md +20 -14
  65. package/skills/project-update/adopt.py +38 -24
  66. package/skills/project-update/history/blobs/00289f98434fed230eebe768dfb3425f83d737866a3aebc97aa95167c4fa0c6e +131 -0
  67. package/skills/project-update/history/blobs/079a91f976ad69b5cf49ccb9a28849576e4735ddd9485fc26599e528a69fd2f4 +30 -0
  68. package/skills/project-update/history/blobs/07ff7fa6f93d471ff533f553b4e523e4105ccde1f3dab70132c5237728f68422 +13 -0
  69. package/skills/project-update/history/blobs/0ef4a360c76da290173dea0f8073949cb0f8113710a38df95acae5d96bdd1286 +123 -0
  70. package/skills/project-update/history/blobs/18c7a2753ce9cb6f6d3d1e9d5d1125d75950b879d56b7325225b200869631b29 +15 -0
  71. package/skills/project-update/history/blobs/25d4f2a86deb5e2574bb3210b67bb24fcc4afb19f93a7b65a057daa874a9d18e +1 -0
  72. package/skills/project-update/history/blobs/48d9950f16e31d55475f566a80dc95f64b35f1c804d2f51729e313a4f29bac8d +13 -0
  73. package/skills/project-update/history/blobs/5a93f78b4ed4d416c171139b2ce610a117f9c1dd46fbb56ca86729cb4c53d10c +6 -0
  74. package/skills/project-update/history/blobs/6a3be0e624e5ad91e44d08857b7183ee53b7903e1e8d56ff196dbc9dde271e4a +75 -0
  75. package/skills/project-update/history/blobs/7170553410bba904ffb7edb4b231e3b3579b85542d84de717299980938f82c9d +13 -0
  76. package/skills/project-update/history/blobs/774b1e3a04ef0d49d33c44a2cfecd92eb6f6b738a12b6a681103ba7fc13e7b86 +67 -0
  77. package/skills/project-update/history/blobs/7f063e79195a5c85c37b7260dd20c184d3b9bea85e37202e0bbca73ad84bba49 +13 -0
  78. package/skills/project-update/history/blobs/a51517e13e57420d2d88a6bcfb4055d762d4a0d10a8f88450929c5c4572213a9 +67 -0
  79. package/skills/project-update/history/blobs/c8573ca1f810ce6c4ac49456a4c1725b93e0d3b74c54ce95d46d8747777895f0 +13 -0
  80. package/skills/project-update/history/blobs/d6e5b34d475e7187d2a8cb2aac7e7f1a427142baf6461bda387d70ceb4d8a8c1 +30 -0
  81. package/skills/project-update/history/blobs/e120ff4e32b2e950fc94ada4e787d57dc9c790da37223e5a202d74f7e3aadf80 +13 -0
  82. package/skills/project-update/history/index.json +38 -16
  83. package/skills/project-update/migrations/0003_context_diet.py +1 -1
  84. package/skills/project-update/migrations/0007_weight_model.py +130 -23
  85. package/skills/project-update/migrations/0011_retire_risk_tiers.py +37 -0
  86. package/skills/project-update/migrations/README.md +16 -1
  87. package/skills/project-update/migrations/__init__.py +6 -1
  88. package/skills/project-update/render_instructions.py +1 -1
  89. package/skills/project-update/update.py +569 -106
  90. package/skills/rr-build-all/SKILL.md +2 -2
  91. package/skills/rr-plan/SKILL.md +3 -1
  92. package/skills/rr-review/SKILL.md +13 -8
  93. package/skills/rr-setup/SKILL.md +24 -4
  94. package/skills/rr-setup/setup.py +7 -1
  95. package/skills/rr-spec/SKILL.md +4 -2
  96. package/skills/rr-status/status.py +36 -9
  97. package/skills/usage-report/usage-report.py +4 -2
  98. package/skills/ai-init/templates/.ai/policies/risk-tiers.json +0 -628
  99. package/skills/ai-init/templates/.ai/policies/risk-tiers.md +0 -180
@@ -146,8 +146,8 @@ EOF
146
146
  - **pass** → §6.
147
147
  - **pass_with_findings** → the in-scope HIGHs, grouped by file, one fixer per group in
148
148
  parallel; `$STATE verify --scope all --after-fix "H1,H2: …"`; `$STATE review --closes "H1,H2"`
149
- → §6. Each MEDIUM and LOW is one `$STATE memory risk add`, not a fix; only a BLOCKER, a HIGH
150
- or a regression buys an `--after-fix` run.
149
+ → §6. MEDIUM and LOW: `memory risk add` as `/rr-review` §4 says, for every verdict; only a
150
+ BLOCKER, a HIGH or a regression buys an `--after-fix` run.
151
151
  - **blockers_open** → every in-scope BLOCKER and HIGH, grouped by file, one fixer per group in
152
152
  parallel; `$STATE verify --scope all --after-fix "B1,H1: …"` (red: §4's batch); then
153
153
  **§5 again, a full pass** — a fix batch across many files is a new change, not a scoped one.
@@ -24,7 +24,9 @@ first: a task already in flight is finished or abandoned before another starts.
24
24
 
25
25
  When `$ARGUMENTS` is a spec (default: the newest `docs/sdlc/specs/*.md`):
26
26
 
27
- 1. `docs/sdlc/plans/TEMPLATE.md` exists, else `/rr-setup`. If the spec's
27
+ 1. `docs/sdlc/constitution.md` exists, else `/rr-setup`. The template is
28
+ `docs/sdlc/plans/TEMPLATE.md` when the project overrides it, else
29
+ `$AI_HOME/skills/project-init/templates/plan.md`. If the spec's
28
30
  **Flagged concerns** holds unresolved items, list them and have the human
29
31
  resolve or explicitly accept each one before planning.
30
32
  2. If `superpowers:writing-plans` is available, use it for the step
@@ -60,7 +60,10 @@ guarded, about 80 in full, then FINDINGS and VERDICT.
60
60
 
61
61
  Before you delegate, probe the change yourself for a minute: the few inputs its
62
62
  own threat model makes interesting, through the real code, and hand the
63
- reviewer what you saw — it starts where you stopped.
63
+ reviewer what you saw — it starts where you stopped. When the change touches a
64
+ guard regex's running time, run the timed probe yourself (`/usr/bin/grep -E`,
65
+ or Python `timeit` on inputs of at most 4096 chars) and hand over the
66
+ measurement: the bounded reviewer may not run timing loops.
64
67
 
65
68
  ## 3. Record the verdict
66
69
 
@@ -81,16 +84,18 @@ task's record (`task.md`), so there is no separate report to write.
81
84
 
82
85
  ## 4. After the verdict
83
86
 
84
- - **pass:** `/rr-ship`; the record and the close are one call,
85
- `$STATE review --verdict pass … <<'EOF' && $STATE close`. `review` exits 0 for every
86
- verdict, so the chain is for `pass` only.
87
+ Continue from the verdict §3 recorded; do not record it again. Whatever the
88
+ verdict, each MEDIUM and LOW is one `$STATE memory risk add`
89
+ (`.ai/project/known-risks.md` amended into the task if missing), not a fix.
90
+
91
+ - **pass:** `/rr-ship`, i.e. `$STATE close` directly: the verdict is already
92
+ recorded. (To record and close in one call, §3's `review --verdict pass … <<'EOF' && $STATE close`;
93
+ `review` exits 0 for every verdict, so that chain is for `pass` only.)
87
94
  - **pass_with_findings:** fix every in-scope HIGH in one batch, then
88
95
  `$STATE verify --scope all --after-fix "H1,H2: <one line>"` and
89
96
  `$STATE review --closes "H1,H2"`. The findings close on that green run, on a
90
- tree the fix changed — no second review. Each MEDIUM and LOW is one
91
- `$STATE memory risk add` (`.ai/project/known-risks.md` amended into the task
92
- if missing), not a fix; only a BLOCKER, a HIGH or a regression buys an
93
- `--after-fix` run.
97
+ tree the fix changed — no second review. MEDIUM and LOW: `memory risk add`
98
+ as above; only a BLOCKER, a HIGH or a regression buys an `--after-fix` run.
94
99
  - **blockers_open:** fix every BLOCKER and HIGH together, `verify --scope all
95
100
  --after-fix "…"`, then **one scoped re-review** (`/rr-review B1,H1`): brief a fresh `ai-reviewer`
96
101
  with the named findings and the fix's diff only — do they close, and what did
@@ -133,12 +133,25 @@ it cannot.
133
133
  each in one plain sentence and ask, unless `$ARGUMENTS` holds `--apply`.
134
134
  4. `$UPDATE --apply`. Exit 3 stopped on a changed, non-regular or unwritable
135
135
  file and names it; nothing after it was written — run the dry run again.
136
- **A `delete?` is the human's.** Never pass `--confirm-delete`, not even with
136
+ **A `delete?` is the human's.** Never pass `--confirm-delete` or `--confirm-cleanup`, not even with
137
137
  `--apply`. Give them, with `$AI_HOME` and `$PWD` substituted, for their own
138
138
  terminal (the `!` prefix is refused):
139
139
  `python3 "<home>/skills/project-update/update.py" "<project>" --apply --confirm-delete "<your name>"`.
140
140
  The schema stays put until it is settled — that command, or deleting the
141
- file by hand; there is no third way. For 0006 (shared files) the order is:
141
+ file by hand; there is no third way. Two kinds are housekeeping, not
142
+ migrations: `[sdlc]` — an unedited `docs/sdlc/*/TEMPLATE.md`, which the plugin
143
+ now supplies (an edited one stays as the project's override); `[cleanup]` —
144
+ the `*.log` files of closed tasks, and the report and log files of an
145
+ abandoned task (its `state.json` stays) or of one unclosed for more than seven
146
+ days (never the task in flight; and none when `.ai/state/state.json` is unreadable).
147
+ `--confirm-delete` does not settle `[cleanup]` rows, and the rows differ by kind:
148
+ files under `.ai/state/logs/` are git-ignored and git cannot restore them; files
149
+ under `.ai/reports/` are usually tracked, and the row says "tracked: git can restore
150
+ a committed copy" or, when git holds no committed copy, "uncommitted" — then
151
+ commit `.ai/` first (the hint says so), and an `original/` copy is kept as for any
152
+ other deletion. They have their own command, the human's too, never passed by the agent:
153
+ `python3 "<home>/skills/project-update/update.py" "<project>" --apply --confirm-cleanup "<your name>"`.
154
+ Show both groups as printed. For 0006 (shared files) the order is:
142
155
  `install.sh` for every runtime used, commit `.ai/`, read the `delete?` list,
143
156
  then the command with no task in flight. Rollback is git:
144
157
  `git restore --source=<commit before> --staged --worktree -- .ai`, before
@@ -157,7 +170,12 @@ it cannot.
157
170
  `that rule is not rendered`: name the `.ai/rules/` file and what it lacks
158
171
  (`paths:` and a body). `overrides the plugin's shared copy`: offer to diff
159
172
  each and delete those that add nothing. `foreign structure detected`: offer
160
- **--adopt**.
173
+ **--adopt**. `tracked by git but ignored`: show the line as printed and give
174
+ the human the command it prints, exactly as printed, plus a commit, for their
175
+ own terminal (never rebuilt from the path; only the xargs form untracks every
176
+ such file under `.ai/state`, a printed `rm --cached -- <path>` one file; a
177
+ second machine runs the same before its next pull, else a modify/delete
178
+ conflict); never run it yourself.
161
179
  7. `$UPDATE --check` and `git diff --stat`; say what changed, what you merged
162
180
  and what is the human's. Suggest `git add -A .ai docs/sdlc .gitignore` plus
163
181
  the runtime directories and instruction files.
@@ -177,7 +195,9 @@ overwrite. A foreign tool's structure, below, can be present as well.
177
195
 
178
196
  Every command is `$UPDATE --adopt …`, with `--mode coexist` on each when the team
179
197
  still uses the other tool (router rows only, nothing moves). Exit 5
180
- `ADOPT_REFUSED`: show the first line and stop. Exit 4 `ADOPT_INCOMPLETE`:
198
+ `ADOPT_REFUSED`: show the first line and stop. Exit 2: the routing table or a
199
+ migration could not be read (reason on stderr, prefixed `project-update:`),
200
+ nothing written. Exit 4 `ADOPT_INCOMPLETE`:
181
201
  `unmapped` files and a `split?` are yours to settle, then dry-run again; a
182
202
  `conflict` or a check `FAIL` left after that is the human's — never edit a
183
203
  destination or a source to make it pass.
@@ -182,7 +182,11 @@ def comment_purpose(lines):
182
182
 
183
183
 
184
184
  def purpose_of(root, rel):
185
- text = read_text(os.path.join(root, rel))
185
+ full = os.path.join(root, rel)
186
+ real, base = os.path.realpath(full), os.path.realpath(root)
187
+ if os.path.commonpath([real, base]) != base:
188
+ return None # a link out of the project: listed UNKNOWN, never read into toolbox.md
189
+ text = read_text(full)
186
190
  if text is None:
187
191
  return None
188
192
  lines = text.splitlines()
@@ -225,6 +229,8 @@ def inventory(root, table):
225
229
  for rel in paths:
226
230
  if rel in seen or not pattern.match(rel):
227
231
  continue
232
+ if "/hooks/" in rel and os.path.basename(rel).startswith("."):
233
+ continue
228
234
  seen.add(rel)
229
235
  if row["kind"] == "make":
230
236
  for n, target, purpose in make_targets(root, rel):
@@ -19,7 +19,9 @@ STATE="python3 $AI_HOME/skills/ai-task/state.py"
19
19
 
20
20
  ## 1. Prerequisites
21
21
 
22
- `docs/sdlc/specs/TEMPLATE.md` exists, else run `/rr-setup` first. Derive
22
+ `docs/sdlc/constitution.md` exists, else run `/rr-setup` first. The template is
23
+ `docs/sdlc/specs/TEMPLATE.md` when the project overrides it, else
24
+ `$AI_HOME/skills/project-init/templates/spec.md`. Derive
23
25
  `<slug>` from the topic (kebab-case, at most five words). If
24
26
  `docs/sdlc/specs/<slug>.md` exists, offer to refine it instead of starting over.
25
27
  When `$ARGUMENTS` is a path to an intent file, read it fully — it is small by
@@ -75,7 +77,7 @@ decision — moves this to the top tier.
75
77
 
76
78
  ## 5. Write
77
79
 
78
- Copy `docs/sdlc/specs/TEMPLATE.md` to `docs/sdlc/specs/<slug>.md`, replace
80
+ Copy the template (§1) to `docs/sdlc/specs/<slug>.md`, creating the folder, replace
79
81
  `{{TITLE}}` and `{{SLUG}}`. Replace the template's `Intent:` line with:
80
82
 
81
83
  ```markdown
@@ -5,7 +5,7 @@
5
5
 
6
6
  Read-only and model-free. A schema-7 task is described by `state.py status`;
7
7
  for any other task, and whenever that call fails, it reads the files directly —
8
- .ai/state/state.json (or the legacy current.json), the task's events.jsonl, .ai/policies/risk-tiers.json
8
+ .ai/state/state.json (or the legacy current.json), the task's events.jsonl, .ai/policies/weight.json (else the shipped table)
9
9
  and .ai/VERSION — so it answers in a project whose state.py is older, newer or
10
10
  broken. The root is found the way the guards find it (find_ai_root in
11
11
  hooks/lib/ai-hook-common.sh): the nearest ancestor holding .ai/, not stopping at
@@ -81,7 +81,7 @@ def schema_line(root):
81
81
  numbers = [int(name[:4]) for name in names if re.match(r"\d{4}_", name)]
82
82
  plugin = str(max(numbers)) if numbers else "?"
83
83
  behind = project.isdigit() and plugin.isdigit() and int(project) < int(plugin)
84
- return "schema: project %s · plugin %s%s" % (project, plugin, " — behind: run /project-update" if behind else "")
84
+ return "schema: project %s · plugin %s%s" % (project, plugin, " — behind: run /rr-setup --update" if behind else "")
85
85
 
86
86
 
87
87
  def one_line(value, limit=160):
@@ -97,13 +97,40 @@ def short(items, limit=4):
97
97
  return ", ".join(items[:limit]) + more if items else "none"
98
98
 
99
99
 
100
+ def budgets(root, state):
101
+ """(max_suite_runs, review_rounds_max) as state.py's budget() enforces them:
102
+ weight.json's batch section while the batch loop is on, else tests.max_suite_runs
103
+ and review_rounds_max. A project without weight.json runs the shipped table."""
104
+ policy = read_json(os.path.join(root, ".ai", "policies", "weight.json")) \
105
+ or read_json(os.path.join(SKILLS, "ai-init", "templates", ".ai", "policies", "weight.json"))
106
+
107
+ def whole(value, default):
108
+ return value if isinstance(value, int) and not isinstance(value, bool) and value >= 1 else default
109
+
110
+ if (state.get("batch") or {}).get("on"):
111
+ section = policy.get("batch") if isinstance(policy.get("batch"), dict) else {}
112
+ return whole(section.get("max_suite_runs"), 8), whole(section.get("review_rounds_max"), 3)
113
+ tests = policy.get("tests") if isinstance(policy.get("tests"), dict) else {}
114
+ return tests.get("max_suite_runs", 2), policy.get("review_rounds_max", 2)
115
+
116
+
117
+ def counter(state, section, key, legacy):
118
+ """A schema-7 counter (verify.suite_runs, review.passes), else the legacy one
119
+ (tests.suite_runs, reviews); 0 when neither is a number."""
120
+ new = state.get(section)
121
+ if isinstance(new, dict) and isinstance(new.get(key), int):
122
+ return new[key]
123
+ old = state.get(legacy)
124
+ old = old.get(key) if isinstance(old, dict) else old
125
+ return old if isinstance(old, int) else 0
126
+
127
+
100
128
  def report(root):
101
129
  lines = ["root: %s" % root]
102
130
  state = read_json(state_file(root))
103
131
  if not state.get("task_id"):
104
132
  return lines + ["task: none — no task in flight", schema_line(root)], None
105
- policy = read_json(os.path.join(root, ".ai", "policies", "risk-tiers.json"))
106
- tests_cfg = ((policy.get("sensors") or {}).get("tests") or {})
133
+ max_runs, max_rounds = budgets(root, state)
107
134
  plan = state.get("approved_plan") or {}
108
135
  steps = plan.get("steps") or []
109
136
  current = plan.get("current_step_id")
@@ -122,11 +149,11 @@ def report(root):
122
149
  else "step: none current (%d of %d done)" % (done, len(steps))),
123
150
  "verify: tests %s · suite runs %s/%s · e2e %s"
124
151
  % (state.get("test_status") or "?",
125
- (state.get("tests") if isinstance(state.get("tests"), dict) else {}).get("suite_runs", 0),
126
- tests_cfg.get("max_suite_runs", 5), state.get("e2e_status") or "?"),
152
+ counter(state, "verify", "suite_runs", "tests"),
153
+ max_runs, state.get("e2e_status") or "?"),
127
154
  "review: %s (%s of %s) · security %s"
128
- % (state.get("review_status") or "?", state.get("reviews") or 0,
129
- policy.get("review_rounds_max", 2), state.get("security_status") or "?"),
155
+ % (state.get("review_status") or "?", counter(state, "review", "passes", "reviews"),
156
+ max_rounds, state.get("security_status") or "?"),
130
157
  "approval: %s" % ("granted by %s" % approval.get("granted_by") if approval.get("granted")
131
158
  else "requested, waiting for the human" if approval.get("requested_at")
132
159
  else "not requested"),
@@ -146,7 +173,7 @@ def main(argv=None):
146
173
  args = parser.parse_args(argv)
147
174
  root = find_ai_root(args.root)
148
175
  if not root:
149
- print("root: none — no .ai/ in %s or above it; /ai-init sets one up" % os.path.abspath(args.root))
176
+ print("root: none — no .ai/ in %s or above it; run /rr-setup first" % os.path.abspath(args.root))
150
177
  return 1
151
178
  lines, task_id = None, None
152
179
  state = read_json(state_file(root))
@@ -690,7 +690,8 @@ def task_report(args) -> int:
690
690
  return 1
691
691
  start = min(stamps)
692
692
  closed = [parse_ts(e.get("ts")) for e in events if e.get("event") == "task_closed"]
693
- end = max(c for c in closed if c) if any(closed) else dt.datetime.now(dt.timezone.utc)
693
+ unclosed = not any(closed)
694
+ end = max(stamps) if unclosed else max(c for c in closed if c) # unclosed: the last event, not now
694
695
  tier, mode = "untiered", None
695
696
  involved = []
696
697
  for e in events:
@@ -729,7 +730,8 @@ def task_report(args) -> int:
729
730
 
730
731
  label = f"mode {mode} (tier {tier})" if mode else f"tier {tier}"
731
732
  print(f"task {args.task} {label} window {start.isoformat(timespec='seconds')}"
732
- f"..{end.isoformat(timespec='seconds')}")
733
+ f"..{end.isoformat(timespec='seconds')}"
734
+ + (" (unclosed: window ends at the last event)" if unclosed else ""))
733
735
  print(" (by time window: any other session in this project inside it is counted too)")
734
736
  for rt in involved or sorted(used):
735
737
  inp, cache, out = used.get(rt, [0, 0, 0])